From 5fd74e9c8c779fa5fb9ce1fa740d0223d46a6746 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 07:49:56 +0800 Subject: [PATCH 001/113] perf(lastleg): batch receiving candidates and trace slow work invalidation --- .../AppointmentWorkChanges.java | 10 +- .../appointmentwork/AppointmentWorkStore.java | 18 +++ .../pda/mapper/ReceivingCandidateMapper.java | 35 +++++ .../ReceivingCandidateRepository.java | 76 ++++++++++ .../service/ReceivingContainerService.java | 52 +++++-- .../ReceivingCandidateRepositoryTest.java | 130 ++++++++++++++++++ ...ReceivingContainerServiceBehaviorTest.java | 60 ++++++++ .../FbaAppointmentPersistenceTest.java | 3 +- .../lastleg-slice-dependencies.json | 10 ++ 9 files changed, 377 insertions(+), 17 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java index 8e57b4b66..f53f56d21 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java @@ -4,13 +4,15 @@ import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; -/** Original business commands invalidate the manifest atomically with their business write. */ +/** 原业务写入与工作清单失效保持在同一事务,不延后或绕过版本门禁。 */ @Component public class AppointmentWorkChanges { - private final AppointmentWorkMapper mapper; - public AppointmentWorkChanges(AppointmentWorkMapper mapper) { this.mapper=mapper; } + private final AppointmentWorkStore store; + /** 复用工作事项持久层,统一记录守卫更新耗时。 */ + public AppointmentWorkChanges(AppointmentWorkStore store) { this.store=store; } + /** 只标记需求变化,保留调用方原事务和异常语义。 */ @Transactional(propagation=Propagation.MANDATORY) public void requirementsChanged() { - if(mapper.invalidateRequirements()!=1) throw AppointmentWorkPolicy.unavailable("工作事项同步守卫未初始化"); + store.invalidateRequirements(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java index 64b174221..b1a295229 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java @@ -1,6 +1,9 @@ package com.baiyue.lastleg.appointmentwork; import com.fasterxml.jackson.databind.ObjectMapper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; import org.springframework.stereotype.Component; import java.time.*; import java.util.*; @@ -9,10 +12,25 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @Component public class AppointmentWorkStore { + private static final Logger LOG = LoggerFactory.getLogger(AppointmentWorkStore.class); public static final int MAX_ITEMS=10000; private final AppointmentWorkMapper mapper; private final ObjectMapper json; public AppointmentWorkStore(AppointmentWorkMapper mapper,ObjectMapper json) { this.mapper=mapper; this.json=json; } + /** 只测量守卫更新语句,不改变锁、事务或版本,用于区分慢写是否卡在该语句。 */ + public void invalidateRequirements() { + long started = System.nanoTime(); + try { + if (mapper.invalidateRequirements() != 1) throw unavailable("工作事项同步守卫未初始化"); + } finally { + long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + if (elapsedMs >= 250) { + // 语句耗时可能包含等锁或连接等待,不能仅凭日志认定具体锁根因;不记录请求业务载荷。 + LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}", + elapsedMs, MDC.get("requestId"), MDC.get("traceId")); + } + } + } public AppointmentWorkMapper.Guard lock() { var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java new file mode 100644 index 000000000..803fe4861 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.inboundplan.pda.mapper; + +import com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +/** 候选列表的批量资源门禁与实收汇总,不改变原收货命令。 */ +@Mapper +public interface ReceivingCandidateMapper { + /** 柜次和柜号由数据库按原门禁的比较语义匹配,避免 Java 大小写比较缩小候选。 */ + @Select("SELECT o.id AS order_id,o.source_container_booking_id AS booking_id FROM ll_order o " + + "JOIN ll_container_shipping_gate g ON g.container_booking_id=o.source_container_booking_id " + + "AND g.container_no=o.container_no WHERE g.status='RECEIVING_AVAILABLE'") + List availableOrders(); + + record AvailableOrder(Long orderId, String bookingId) { } + + /** 保留原转换命令的客户和计划精确匹配,一批读取未确认计划。 */ + @Select("") + List unconfirmedPlans(@Param("ids") List ids); + + /** 实收始终按客户、计划及计划行三重身份汇总。 */ + @Select("") + List received(@Param("ids") List ids); + + record Received(String customerId, Long planId, Long lineId, int received) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java new file mode 100644 index 000000000..002b3e56d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.inboundplan.pda.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingCandidateMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.springframework.stereotype.Repository; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 一次请求内批量读取候选所需事实,避免按柜、计划和货件逐条查询。 */ +@Repository +public class ReceivingCandidateRepository { + private static final int CHUNK_SIZE = 500; + private final LastlegOrderMapper orders; + private final InboundPlanMapper plans; + private final InboundPlanLineMapper lines; + private final ReceivingCandidateMapper candidates; + private final ReceivingContainerSessionMapper sessions; + + /** 仅依赖当前收货业务的本库 Mapper。 */ + public ReceivingCandidateRepository(LastlegOrderMapper orders, InboundPlanMapper plans, + InboundPlanLineMapper lines, ReceivingCandidateMapper candidates, ReceivingContainerSessionMapper sessions) { + this.orders=orders; this.plans=plans; this.lines=lines; this.candidates=candidates; this.sessions=sessions; + } + + /** 先读取当前账号会话,供服务在加载候选前核对仓库归属。 */ + public ReceivingContainerSession currentSession(String activeOperatorKey) { + return sessions.findActiveByOperator(activeOperatorKey); + } + + /** 读取柜号占用事实,由服务排除其他操作账号正在处理的柜。 */ + public List activeSessions() { + return sessions.findActiveContainers(); + } + + public record LineKey(String customerId, Long planId, Long lineId) { } + public record Facts(List orders, List plans, List lines, + Set availableOrderIds, Set unconfirmedPlans, Map received) { } + + /** 先缩小到可收货柜,保留其全部客户订单,不能按当前仓提前裁掉柜内冲突记录。 */ + public Facts load() { + var available=candidates.availableOrders(); + var bookingIds=available.stream().map(ReceivingCandidateMapper.AvailableOrder::bookingId).distinct().toList(); + var orderRows=batch(bookingIds, ids->orders.selectList(new LambdaQueryWrapper() + .in(LastlegOrder::getSourceContainerBookingId,ids).isNotNull(LastlegOrder::getContainerNo))) + .stream().collect(Collectors.toMap(LastlegOrder::getId,Function.identity(),(left,right)->left)) + .values().stream().sorted(Comparator.comparing(LastlegOrder::getSourceContainerBookingId) + .thenComparing(LastlegOrder::getId)).toList(); + var lineRows=batch(orderRows.stream().map(LastlegOrder::getId).toList(), ids->lines.selectList( + new LambdaQueryWrapper().in(InboundPlanLine::getOrderId,ids))) + .stream().sorted(Comparator.comparing(InboundPlanLine::getId)).toList(); + var planIds=lineRows.stream().map(InboundPlanLine::getPlanId).filter(Objects::nonNull).distinct().toList(); + var planRows=batch(planIds, ids->plans.selectList(new LambdaQueryWrapper().in(InboundPlan::getId,ids))); + var unconfirmed=new HashSet<>(batch(planIds,candidates::unconfirmedPlans)); + var received=batch(lineRows.stream().map(InboundPlanLine::getId).toList(),candidates::received).stream() + .collect(Collectors.toMap(r->new LineKey(r.customerId(),r.planId(),r.lineId()),ReceivingCandidateMapper.Received::received)); + return new Facts(orderRows,planRows,lineRows,available.stream().map(ReceivingCandidateMapper.AvailableOrder::orderId) + .collect(Collectors.toSet()),unconfirmed,received); + } + + /** 固定大小分块,空范围不执行 SQL,也不截断候选。 */ + private static List batch(List ids,Function,List> query) { + var result=new ArrayList(); + for(int start=0;start candidates(String operatorKey, long overseasWarehouseId) { - ReceivingContainerSession current = sessions.findActiveByOperator(activeOperatorKey(operatorKey)); + ReceivingContainerSession current = candidateRepository.currentSession(activeOperatorKey(operatorKey)); requireSessionWarehouse(current, overseasWarehouseId); - List orderFacts = orders.selectList(new LambdaQueryWrapper() - .isNotNull(LastlegOrder::getSourceContainerBookingId) - .isNotNull(LastlegOrder::getContainerNo) - .orderByAsc(LastlegOrder::getSourceContainerBookingId, LastlegOrder::getId)); - List planFacts = plans.selectList(new LambdaQueryWrapper().orderByAsc(InboundPlan::getId)); - List lineFacts = lines.selectList(new LambdaQueryWrapper().orderByAsc(InboundPlanLine::getId)); + var facts = candidateRepository.load(); + List orderFacts = facts.orders(); + Map> linesByOrder = facts.lines().stream() + .collect(Collectors.groupingBy(InboundPlanLine::getOrderId)); + Map plansById = facts.plans().stream() + .collect(Collectors.toMap(InboundPlan::getId, Function.identity())); Map> grouped = orderFacts.stream() .filter(order -> trim(order.getSourceContainerBookingId()) != null && trim(order.getContainerNo()) != null) .collect(Collectors.groupingBy(LastlegOrder::getSourceContainerBookingId, LinkedHashMap::new, Collectors.toList())); String operatorLockKey = activeOperatorKey(operatorKey); - Set reservedByOthers = sessions.findActiveContainers().stream() + Set reservedByOthers = candidateRepository.activeSessions().stream() .filter(session -> !operatorLockKey.equals(session.getActiveOperatorKey())) .map(ReceivingContainerSession::getActiveContainerKey).filter(Objects::nonNull).collect(Collectors.toSet()); return grouped.entrySet().stream() - .map(entry -> readiness(entry.getKey(), entry.getValue(), planFacts, lineFacts, overseasWarehouseId)) + .map(entry -> { + var localLines = entry.getValue().stream().flatMap(order -> + linesByOrder.getOrDefault(order.getId(), List.of()).stream()).toList(); + var localPlans = localLines.stream().map(line -> plansById.get(line.getPlanId())) + .filter(Objects::nonNull).distinct().toList(); + return readiness(entry.getKey(), entry.getValue(), localPlans, localLines, overseasWarehouseId, facts); + }) .filter(Objects::nonNull) .filter(ContainerReadiness::shippingReady) .filter(value -> !value.allPlansCompleted()) @@ -679,9 +688,17 @@ public class ReceivingContainerService { } } + /** 写命令和详情继续即时复验原资源与船节点。 */ private ContainerReadiness readiness(String sourceContainerBookingId, List orderFacts, List planFacts, List lineFacts, long overseasWarehouseId) { + return readiness(sourceContainerBookingId, orderFacts, planFacts, lineFacts, overseasWarehouseId, null); + } + + /** 候选使用请求内批量事实;其他入口保留即时校验,避免改变写命令并发门禁。 */ + private ContainerReadiness readiness(String sourceContainerBookingId, List orderFacts, + List planFacts, List lineFacts, + long overseasWarehouseId, ReceivingCandidateRepository.Facts facts) { Set orderIds = orderFacts.stream().map(LastlegOrder::getId).collect(Collectors.toSet()); Map> linesByOrder = lineFacts.stream() .filter(line -> orderIds.contains(line.getOrderId())) @@ -718,9 +735,12 @@ public class ReceivingContainerService { boolean allPlansCompleted = !matchedPlans.isEmpty() && matchedPlans.stream().allMatch(plan -> List.of("COMPLETED", "PENDING_RECONCILIATION") .contains(plan.getStatus())); - boolean shippingReady = shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1; + boolean shippingReady = facts == null + ? shippingGates.receivingAvailable(sourceContainerBookingId, containerNo) == 1 + : facts.availableOrderIds().contains(orderFacts.get(0).getId()); boolean resourcesConfirmed = matchedPlans.stream().noneMatch(plan -> - plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0); + facts == null ? plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0 + : facts.unconfirmedPlans().contains(plan.getId())); boolean operable = allPlansReady && resourcesConfirmed && shippingReady && warehouseSnapshotConsistent; int pending = Math.max(0, totalOrders - plannedOrders); String blockReason = operable ? null : (!warehouseSnapshotConsistent @@ -740,7 +760,9 @@ public class ReceivingContainerService { List matchedRefs = lineFacts.stream().filter(line -> orderIds.contains(line.getOrderId())) .map(line -> new InboundRef(plansById.get(line.getPlanId()), line, ordersById.get(line.getOrderId()))) .filter(ref -> ref.plan() != null && ref.order() != null).toList(); - List inboundValues = matchedRefs.stream().map(this::inbound).toList(); + List inboundValues = matchedRefs.stream().map(ref -> facts == null + ? inbound(ref) : inbound(ref, facts.received().getOrDefault(new ReceivingCandidateRepository.LineKey( + ref.order().getCustomerId(), ref.plan().getId(), ref.line().getId()), 0))).toList(); List destinationCodes = inboundValues.stream().map(this::destinationCode) .filter(Objects::nonNull).distinct().sorted().toList(); Totals totals = new Totals(inboundValues.size(), destinationCodes, @@ -1152,12 +1174,18 @@ public class ReceivingContainerService { return sessions.findActiveByContainer(activeContainerKey(containerNo)); } + /** 单柜详情和写命令即时读取本货件实收量。 */ private ReceivingContainerResponses.Inbound inbound(InboundRef ref) { int received = allocations.selectList(new LambdaQueryWrapper() .eq(InboundPlanPalletAllocation::getCustomerId, ref.order().getCustomerId()) .eq(InboundPlanPalletAllocation::getPlanId, ref.plan().getId()) .eq(InboundPlanPalletAllocation::getLineId, ref.line().getId())).stream() .mapToInt(value -> value(value.getPickedPackageCount())).sum(); + return inbound(ref, received); + } + + /** 共享相同展示投影,候选可使用批量汇总的实收量。 */ + private ReceivingContainerResponses.Inbound inbound(InboundRef ref, int received) { int total = value(ref.line().getRequiredPackageCount()); return new ReceivingContainerResponses.Inbound(ref.line().getInboundCode(), destinationType(ref.line(), ref.order()), ref.plan().getOverseasWarehouseId() == null ? ref.order().getOverseasWarehouseId() diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java new file mode 100644 index 000000000..1a5386a80 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java @@ -0,0 +1,130 @@ +package com.baiyue.lastleg.inboundplan.pda.service; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.inboundplan.entity.InboundPlan; +import com.baiyue.lastleg.inboundplan.entity.InboundPlanLine; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingCandidateMapper; +import com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository; +import com.baiyue.lastleg.inboundplan.pda.mapper.ReceivingContainerSessionMapper; +import com.baiyue.lastleg.inboundplan.pda.entity.ReceivingContainerSession; +import org.junit.jupiter.api.Test; +import java.util.*; +import java.util.stream.LongStream; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +class ReceivingCandidateRepositoryTest { + /** 多柜多客户仍仅按固定大小批量查询,没有逐柜或逐计划查询。 */ + @Test + void hundredsOfContainersUseBoundedBatchQueriesWithoutDroppingCustomers() { + var orders=mock(LastlegOrderMapper.class);var plans=mock(InboundPlanMapper.class); + var lines=mock(InboundPlanLineMapper.class); + var mapper=mock(ReceivingCandidateMapper.class);var sessions=mock(ReceivingContainerSessionMapper.class); + var orderRows=LongStream.rangeClosed(1,240).mapToObj(id->{ + var row=new LastlegOrder();row.setId(id);row.setCustomerId(id%2==0?"A":"B"); + row.setSourceContainerBookingId("B"+((id+1)/2));row.setContainerNo("C"+((id+1)/2));return row; + }).toList(); + var lineRows=LongStream.rangeClosed(1,240).mapToObj(id->{ + var row=new InboundPlanLine();row.setId(id);row.setOrderId(id);row.setPlanId(id);return row; + }).toList(); + var planRows=LongStream.rangeClosed(1,240).mapToObj(id->{var row=new InboundPlan();row.setId(id);return row;}).toList(); + when(mapper.availableOrders()).thenReturn(orderRows.stream().map(o-> + new ReceivingCandidateMapper.AvailableOrder(o.getId(),o.getSourceContainerBookingId())).toList()); + when(orders.selectList(any())).thenReturn(orderRows); + when(lines.selectList(any())).thenReturn(lineRows); + when(plans.selectList(any())).thenReturn(planRows); + when(mapper.unconfirmedPlans(any())).thenReturn(List.of(9L)); + when(mapper.received(any())).thenReturn(List.of(new ReceivingCandidateMapper.Received("A",2L,2L,7))); + var facts=new ReceivingCandidateRepository(orders,plans,lines,mapper,sessions).load(); + assertThat(facts.orders()).hasSize(240).extracting(LastlegOrder::getCustomerId).contains("A","B"); + assertThat(facts.unconfirmedPlans()).containsExactly(9L); + assertThat(facts.received()).containsEntry(new ReceivingCandidateRepository.LineKey("A",2L,2L),7); + verify(mapper).availableOrders();verify(orders).selectList(any());verify(lines).selectList(any());verify(plans).selectList(any()); + verify(mapper).unconfirmedPlans(any());verify(mapper).received(any()); + verifyNoMoreInteractions(orders,plans,lines,mapper); + } + + /** 没有可收货节点时,不再全表读取订单、计划或明细。 */ + @Test + void emptyShippingScopeSkipsAllDownstreamReads() { + var orders=mock(LastlegOrderMapper.class);var plans=mock(InboundPlanMapper.class); + var lines=mock(InboundPlanLineMapper.class); + var mapper=mock(ReceivingCandidateMapper.class);var sessions=mock(ReceivingContainerSessionMapper.class); + when(mapper.availableOrders()).thenReturn(List.of()); + var repository=new ReceivingCandidateRepository(orders,plans,lines,mapper,sessions); + var active=new ReceivingContainerSession();active.setActiveOperatorKey("OPERATOR:7"); + when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(active); + when(sessions.findActiveContainers()).thenReturn(List.of(active)); + assertThat(repository.currentSession("OPERATOR:7")).isSameAs(active); + assertThat(repository.activeSessions()).containsExactly(active); + verify(sessions).findActiveByOperator("OPERATOR:7");verify(sessions).findActiveContainers();verifyNoMoreInteractions(sessions); + assertThat(repository.load().orders()).isEmpty(); + verifyNoInteractions(orders,plans,lines);verify(mapper).availableOrders();verifyNoMoreInteractions(mapper); + } + + /** 大范围完整分块,超过一批后不丢柜、不产生超长 IN 参数。 */ + @Test + void moreThanFiveHundredBookingsAreChunkedWithoutTruncation() { + var orders=mock(LastlegOrderMapper.class);var plans=mock(InboundPlanMapper.class); + var lines=mock(InboundPlanLineMapper.class); + var mapper=mock(ReceivingCandidateMapper.class);var sessions=mock(ReceivingContainerSessionMapper.class); + when(mapper.availableOrders()).thenReturn(LongStream.rangeClosed(1,501) + .mapToObj(id->new ReceivingCandidateMapper.AvailableOrder(id,"B"+id)).toList()); + when(orders.selectList(any())).thenReturn(List.of()); + var facts=new ReceivingCandidateRepository(orders,plans,lines,mapper,sessions).load(); + assertThat(facts.availableOrderIds()).hasSize(501); + verify(orders,times(2)).selectList(any());verifyNoInteractions(plans,lines);verify(mapper).availableOrders();verifyNoMoreInteractions(mapper); + } + + /** 真实 Mapper 汇总不会把其他客户或其他计划的同号行混入实收量。 */ + @Test + void receivedSqlKeepsCustomerPlanAndLineIdentity() throws Exception { + var dataSource=new org.h2.jdbcx.JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:receiving_candidate_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var jdbc=new org.springframework.jdbc.core.JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(customer_id VARCHAR(128),plan_id BIGINT,line_id BIGINT,picked_package_count INT)"); + jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES ('A',1,10,3),('A',1,10,4),('B',1,10,90),('A',2,10,80),('A',1,99,70),('A',1,10,NULL),('A',3,10,NULL)"); + var configuration=new org.apache.ibatis.session.Configuration(new org.apache.ibatis.mapping.Environment( + "test",new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(),dataSource)); + configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(ReceivingCandidateMapper.class); + try(var session=new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(configuration).openSession()) { + assertThat(session.getMapper(ReceivingCandidateMapper.class).received(List.of(10L))) + .containsExactlyInAnyOrder(new ReceivingCandidateMapper.Received("A",1L,10L,7), + new ReceivingCandidateMapper.Received("B",1L,10L,90),new ReceivingCandidateMapper.Received("A",2L,10L,80),new ReceivingCandidateMapper.Received("A",3L,10L,0)); + } + } + /** 真实 SQL 按数据库字符串比较匹配门禁,混合大小写柜号不由 Java 二次排除。 */ + @Test + void shippingSqlPreservesDatabaseComparisonAndBookingOccurrence() { + var dataSource=new org.h2.jdbcx.JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:receiving_gate_"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var jdbc=new org.springframework.jdbc.core.JdbcTemplate(dataSource); + jdbc.execute("CREATE TABLE ll_order(id BIGINT,source_container_booking_id VARCHAR_IGNORECASE(128),container_no VARCHAR_IGNORECASE(128))"); + jdbc.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id VARCHAR_IGNORECASE(128) PRIMARY KEY,container_no VARCHAR_IGNORECASE(128),status VARCHAR_IGNORECASE(64))"); + jdbc.update("INSERT INTO ll_order VALUES (1,'booking-a','cont-a'),(2,'BOOKING-A','CONT-A'),(3,'BOOKING-B','CONT-A'),(4,'BOOKING-A','WRONG')"); + jdbc.update("INSERT INTO ll_container_shipping_gate VALUES ('BOOKING-A','CONT-A','RECEIVING_AVAILABLE'),('BOOKING-B','CONT-A','AWAITING_PICKUP_READY')"); + var configuration=new org.apache.ibatis.session.Configuration(new org.apache.ibatis.mapping.Environment( + "test",new org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory(),dataSource)); + configuration.setMapUnderscoreToCamelCase(true);configuration.addMapper(ReceivingCandidateMapper.class); + try(var session=new org.apache.ibatis.session.SqlSessionFactoryBuilder().build(configuration).openSession()) { + assertThat(session.getMapper(ReceivingCandidateMapper.class).availableOrders()).containsExactlyInAnyOrder( + new ReceivingCandidateMapper.AvailableOrder(1L,"booking-a"),new ReceivingCandidateMapper.AvailableOrder(2L,"BOOKING-A")); + } + } + + /** MySQL JSON_TABLE 的原资源谓词保持原样,动态批量参数不能丢客户精确匹配。 */ + @Test + void unconfirmedSqlPreservesOriginalCustomerPredicateAndBindsEveryPlan() { + var configuration=new org.apache.ibatis.session.Configuration();configuration.addMapper(ReceivingCandidateMapper.class); + var bound=configuration.getMappedStatement(ReceivingCandidateMapper.class.getName()+".unconfirmedPlans") + .getBoundSql(Map.of("ids",List.of(1L,2L,3L))); + assertThat(bound.getSql()).contains(com.baiyue.lastleg.inboundplan.mapper.ContainerPlanConversionCommandMapper.UNCONFIRMED_PLAN) + .contains("o.customer_id=p.customer_id", "o.id=p.source_order_id"); + assertThat(bound.getParameterMappings()).hasSize(3); + } + +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 67cfba200..4f2d8ac9e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -58,6 +58,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ReceivingContainerServiceBehaviorTest { + @Mock com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository candidateRepository; @Mock InboundPlanMapper plans; @Mock InboundPlanLineMapper lines; @Mock InboundPlanPalletMapper pallets; @@ -82,6 +83,32 @@ class ReceivingContainerServiceBehaviorTest { @BeforeEach void setUp() { service = new ReceivingContainerService(); + ReflectionTestUtils.setField(service, "candidateRepository", candidateRepository); + org.mockito.Mockito.lenient().when(candidateRepository.currentSession(any())).thenAnswer(invocation -> + sessions.findActiveByOperator(invocation.getArgument(0))); + org.mockito.Mockito.lenient().when(candidateRepository.activeSessions()).thenAnswer(ignored -> sessions.findActiveContainers()); + // 既有行为用例继续提供同一组业务事实,批量 SQL 的调用边界由独立仓储测试验证。 + org.mockito.Mockito.lenient().when(candidateRepository.load()).thenAnswer(ignored -> { + var orderRows = orders.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()); + var planRows = plans.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()); + var lineRows = lines.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()); + var available = orderRows.stream().filter(order -> shippingGates.receivingAvailable( + order.getSourceContainerBookingId(), order.getContainerNo()) == 1) + .map(LastlegOrder::getId).collect(java.util.stream.Collectors.toSet()); + var unconfirmed = planRows.stream().filter(plan -> plans.countUnconfirmedResourcePlans(plan.getCustomerId(), plan.getId()) > 0) + .map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet()); + var received = new java.util.HashMap(); + for (var line : lineRows) { + var order = orderRows.stream().filter(row -> row.getId().equals(line.getOrderId())).findFirst().orElse(null); + if (order == null) continue; + int count = allocations.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper()) + .stream().mapToInt(row -> row.getPickedPackageCount() == null ? 0 : row.getPickedPackageCount()).sum(); + received.put(new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey( + order.getCustomerId(), line.getPlanId(), line.getId()), count); + } + return new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.Facts( + orderRows, planRows, lineRows, available, unconfirmed, received); + }); ReflectionTestUtils.setField(service,"workRouteGuard",org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingGuard.class)); ReflectionTestUtils.setField(service, "plans", plans); ReflectionTestUtils.setField(service, "lines", lines); @@ -109,6 +136,39 @@ class ReceivingContainerServiceBehaviorTest { org.mockito.Mockito.lenient().when(domesticEvidence.listByContainer(any())).thenReturn(List.of()); } + /** 候选投影只消费批量事实,客户隔离的实收量不会混入其他客户或重复读库。 */ + @Test + void candidatesUseBulkFactsWithoutPerContainerPlanOrLineQueries() { + var first = plan("CONT-A"); + var second = plan("CONT-A"); second.setId(3L); second.setCustomerId("200"); + var firstLine = line("FBA"); firstLine.setRequiredPackageCount(8); + var secondLine = line("FBA"); secondLine.setId(11L); secondLine.setOrderId(2L); + secondLine.setPlanId(3L); secondLine.setRequiredPackageCount(12); + var firstOrder = containerOrder(1L, "100", "CONT-A", "BOOKING-A", "IN-1"); + var secondOrder = containerOrder(2L, "200", "CONT-A", "BOOKING-A", "IN-2"); + var facts = new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.Facts( + List.of(firstOrder, secondOrder), List.of(first, second), List.of(firstLine, secondLine), + java.util.Set.of(1L, 2L), + java.util.Set.of(), java.util.Map.of( + new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("100", first.getId(), firstLine.getId()), 3, + new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("200", second.getId(), secondLine.getId()), 5, + new com.baiyue.lastleg.inboundplan.pda.repository.ReceivingCandidateRepository.LineKey("OTHER", first.getId(), firstLine.getId()), 99)); + org.mockito.Mockito.doReturn(facts).when(candidateRepository).load(); + + var result = service.candidates("7", 100L); + + assertThat(result).hasSize(1); + assertThat(result.get(0).totalOrderCount()).isEqualTo(2); + assertThat(result.get(0).totalPackages()).isEqualTo(20); + assertThat(result.get(0).receivedPackages()).isEqualTo(8); + assertThat(result.get(0).operable()).isTrue(); + verify(candidateRepository).load(); + verifyNoInteractions(orders, plans, lines, allocations, shippingGates, warehouse); + // 不可提前按仓过滤同柜另一客户,否则会把冲突柜错误显示为本仓可收货。 + secondOrder.setOverseasWarehouseId("200"); + assertThat(service.candidates("7", 100L)).isEmpty(); + } + @Test void containerIsHiddenAndPrepareIsRejectedBeforePickupReady() { when(plans.selectList(any())).thenReturn(List.of(plan("CONT-A"))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index c867ccdb6..48402799f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -264,7 +264,8 @@ class FbaAppointmentPersistenceTest { @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} @Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();} @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);} - @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workInvalidation(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges(mapper);} + /** 使用生产持久层,继续验证需求失效与预约写入的同事务行为。 */ + @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workInvalidation(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper,ObjectMapper json){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges(new com.baiyue.lastleg.appointmentwork.AppointmentWorkStore(mapper,json));} @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService workCommitments(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper mapper,ObjectMapper json){return new com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService(mapper,json);} @Bean com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges appointmentWorkChanges(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges changes,com.baiyue.lastleg.appointmentwork.AppointmentWorkCommitmentService commitments,FbaReceivingStore receiving){return new com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges(commitments,changes,receiving);} } diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 7a2395d42..56d09c370 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -56,6 +56,16 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.transfer.repository.TransferForecastRepository", + "target": "com.baiyue.lastleg.inboundplan.entity.InboundPlanLine", + "reason": "转运测量完成前补充预报箱,在同一锁单事务按客户及关联订单同步该转运自建计划行的预报箱数;仅批准这两个既有类型引用,不开放其他入仓实现或实收库存写入。" + }, + { + "source": "com.baiyue.lastleg.transfer.repository.TransferForecastRepository", + "target": "com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper", + "reason": "转运测量完成前补充预报箱,在同一锁单事务按客户及关联订单同步该转运自建计划行的预报箱数;仅批准这两个既有类型引用,不开放其他入仓实现或实收库存写入。" + }, { "source": "com.baiyue.lastleg.relabel.service.ReturnRelabelPackingService", "target": "com.baiyue.lastleg.pda.security.PdaWarehouseIdentity", From 732cf99007c835e7062b9f9f41a5da9ee91c8174 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 08:30:11 +0800 Subject: [PATCH 002/113] perf(lastleg): reuse work items within synchronization batch --- .../appointmentwork/AppointmentWorkStore.java | 15 +++++- .../AppointmentWorkSyncService.java | 29 ++++++++--- .../AppointmentWorkSyncServiceTest.java | 49 +++++++++++++++++++ 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java index b1a295229..e2439d69a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java @@ -31,8 +31,21 @@ public class AppointmentWorkStore { } } } + /** 测量守卫读取加锁耗时,保留原有锁和失败行为,覆盖尚未进入更新语句的慢命令。 */ public AppointmentWorkMapper.Guard lock() { - var guard=mapper.lockGuard(); if(guard==null) throw unavailable("工作事项DDL尚未集成"); return guard; + long started = System.nanoTime(); + try { + var guard = mapper.lockGuard(); + if (guard == null) throw unavailable("工作事项DDL尚未集成"); + return guard; + } finally { + long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); + if (elapsedMs >= 250) { + // 耗时包含连接及语句执行,单条日志不能区分具体等待原因,不记录业务载荷。 + LOG.warn("Appointment work guard lock slow: elapsedMs={}, requestId={}, traceId={}", + elapsedMs, MDC.get("requestId"), MDC.get("traceId")); + } + } } public String requireInitialized() { var guard=mapper.guard(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java index d91ead756..268f7e451 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -16,7 +16,7 @@ public class AppointmentWorkSyncService { private final AppointmentWorkStore store; private final AppointmentWorkFactGateway gateway; public AppointmentWorkSyncService(AppointmentWorkStore store,AppointmentWorkFactGateway gateway) { this.store=store; this.gateway=gateway; } - /** Called by trusted server orchestration, never by GET or by opening a particular container. */ + /** 服务端按完整范围同步;同一守卫事务内复用工作集,GET 不触发写入。 */ @Transactional public SyncResult synchronize(SyncRequest request) { if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜"); @@ -26,21 +26,24 @@ public class AppointmentWorkSyncService { if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); } else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试"); else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试"); + // 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。 + var known=workingSet(); int created=0; var ids=new HashSet(); for(var source:page.containers()) { validateSource(source); if(!ids.add(source.containerBookingId())) throw unavailable("来源响应重复柜次"); - var existing=store.container(source.containerBookingId()); + var existing=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId()) + || s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId()))).toList(); if(existing.isEmpty()) { if(source.establishedFlow()==Flow.UNKNOWN) { - var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); created++; - } else created+=generate(source,source.establishedFlow(),existing,SYSTEM).size(); + var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); known.put(route.getWorkItemKey(),route); created++; + } else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known).size(); } else { var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null); - if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM).size(); + if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known).size(); else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) { if(source.establishedFlow()!=Flow.UNKNOWN) { - var generated=generate(source,source.establishedFlow(),existing,SYSTEM); created+=generated.size(); + var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known); created+=generated.size(); route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route); } else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) { route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route); @@ -76,15 +79,25 @@ public class AppointmentWorkSyncService { if(requirement.timezone()!=null) zone(requirement.timezone()); } } - /** Caller holds work guard and validates the route against current original facts. */ + /** 命令入口仍在调用方守卫事务中即时加载工作集。 */ List generate(ContainerSource source,Flow flow,List existing,Actor actor) { + return generate(source,flow,existing,actor,workingSet()); + } + /** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */ + private Map workingSet() { + var known=new TreeMap(); + store.all().forEach(state->known.put(state.getWorkItemKey(),state)); + return known; + } + /** 同页各柜共享最新状态,新增、跨柜关联和退役仍逐项持久化。 */ + private List generate(ContainerSource source,Flow flow,List existing,Actor actor, + Map known) { validateSource(source); if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确"); var requirements=gateway.requirements(source,flow); source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements); validateSource(source); if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单"); - var known=store.all().stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity(),(a,b)->a)); var created=new ArrayList(); for(var type:flow==Flow.TRANSFER?List.of(Type.PICKUP,Type.WAREHOUSE_RECEIPT,Type.EMPTY_RETURN):List.of(Type.PICKUP,Type.EMPTY_RETURN)) add(source,type,null,flow,known,created); for(var requirement:source.requirements()) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index b44348ca7..a84290c40 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -12,6 +12,39 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkSyncServiceTest { + /** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */ + @Test void hundredContainersReuseOneGenerationWorkingSetAndKeepUnknownRoutesAndHistory() { + var f=new WorkTestFixture(); + var historical=f.state("old",Type.DELIVERY,Flow.DIRECT,"old-demand"); + historical.setRetired(true);historical.setRetirementReason("BATCH_REPLACED"); + historical.setReplacementKeys(List.of("later-work"));f.add(historical); + var sourceRows=java.util.stream.IntStream.rangeClosed(1,100).mapToObj(index->{ + String id=Integer.toString(index); + var flow=index<=50?Flow.UNKNOWN:Flow.TRANSFER; + var requirements=flow==Flow.UNKNOWN?List.of():List.of(new Requirement("INBOUND:"+id,null,null, + "FBA","海外仓","ONT8",null,List.of(),List.of(),List.of())); + return new ContainerSource(id,"CNT"+id,null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",flow,null,requirements); + }).toList(); + when(f.gateway.discover(null,100)).thenReturn(new SourcePage(sourceRows,null,true,"scope")); + clearInvocations(f.mapper); + assertEquals(300,f.sync.synchronize(new SyncRequest(null,100)).created()); + verify(f.mapper,times(3)).all(AppointmentWorkStore.MAX_ITEMS+1); + var states=f.store.all();assertEquals(301,states.size()); + assertEquals(50,states.stream().filter(s->s.getType()==Type.ROUTE && s.getFlow()==Flow.UNKNOWN).count()); + assertTrue(f.store.get(historical.getWorkItemKey()).isRetired()); + assertEquals(List.of("later-work"),f.store.get(historical.getWorkItemKey()).getReplacementKeys()); + var before=new TreeMap<>(f.rows);int historyCount=f.history.size(); + clearInvocations(f.mapper); + assertEquals(0,f.sync.synchronize(new SyncRequest(null,100)).created()); + verify(f.mapper,times(3)).all(AppointmentWorkStore.MAX_ITEMS+1); + assertEquals(before.keySet(),f.rows.keySet()); + before.forEach((key,row)->{ + assertEquals(row.getVersion(),f.rows.get(key).getVersion()); + assertEquals(row.getStateJson(),f.rows.get(key).getStateJson()); + }); + assertEquals(historyCount,f.history.size()); + assertDoesNotThrow(f.store::requireInitialized); + } /** 同一实物批次拆成客户事项时,整体事项须有完整去向,运输仍保持唯一。 */ @Test void batchWideDeliveryIsReplacedByItsExplicitCustomerRequirements() { var f=new WorkTestFixture();String batch="FBA_APPOINTMENT:1"; @@ -66,6 +99,7 @@ class AppointmentWorkSyncServiceTest { f.sync.synchronize(new SyncRequest(null,10)); assertEquals(keys,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).map(AppointmentWorkState::getWorkItemKey).sorted().toList()); } + /** 同页移除主柜后,后柜沿最新对象更新;旧批次恢复也不得覆盖版本或丢客户关联。 */ @Test void removingPrivateCargoDetachesOldContainerAndRestoresDemandWithoutRetiringOtherCargo() { var f=new WorkTestFixture(); var source1=new SourceReference("C1","ORDER:1","CUSTOMER:1");var source2=new SourceReference("C2","ORDER:2","CUSTOMER:2"); @@ -88,6 +122,21 @@ class AppointmentWorkSyncServiceTest { second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root2)); when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope"));f.sync.synchronize(new SyncRequest(null,10)); assertEquals(2,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired() && s.getBatchKey()==null).count()); + var retired=f.store.get(kept.getWorkItemKey());assertTrue(retired.isRetired()); + int retiredVersion=retired.getVersion(); + first=new ContainerSource("1","CNT1",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch)); + second=new ContainerSource("2","CNT2",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(batch)); + when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(first,second),null,true,"scope")); + clearInvocations(f.mapper); + f.sync.synchronize(new SyncRequest(null,10)); + verify(f.mapper,times(3)).all(AppointmentWorkStore.MAX_ITEMS+1); + var restored=f.store.get(kept.getWorkItemKey());assertFalse(restored.isRetired()); + assertTrue(restored.getVersion()>retiredVersion); + assertEquals(Set.of("1","2"),new HashSet<>(restored.getContainers().stream().map(ContainerReference::containerBookingId).toList())); + assertEquals(List.of(source1,source2),restored.getSources()); + assertEquals(1,f.store.all().stream().filter(s->s.getType()==Type.DELIVERY && !s.isRetired()).count()); + assertTrue(f.history.stream().anyMatch(h->"SOURCE_REMOVED".equals(h.getAction()))); + assertTrue(f.history.stream().anyMatch(h->"REPLACED".equals(h.getAction()))); } @Test void removedPrivateBatchCannotBeReplacedByAnotherCustomersUnrelatedDemand() { var f=new WorkTestFixture();var oldSource=new SourceReference("C1","ORDER:1","CUSTOMER:1"); From 81e4123b109c3f0327b19d84be98428b3e1fb034 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 09:11:00 +0800 Subject: [PATCH 003/113] perf(lastleg): limit transport reconciliation facts and trace sync phases --- .../AppointmentWorkSyncService.java | 52 ++++++++++++- .../AppointmentWorkSyncServiceTest.java | 73 ++++++++++++++++++- 2 files changed, 119 insertions(+), 6 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java index 268f7e451..5f4e039b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -2,6 +2,8 @@ package com.baiyue.lastleg.appointmentwork; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.*; import java.util.*; import java.util.function.Function; @@ -12,6 +14,7 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @Service public class AppointmentWorkSyncService { + private static final Logger LOG=LoggerFactory.getLogger(AppointmentWorkSyncService.class); private static final Actor SYSTEM=new Actor("appointment-work-sync","服务端工作范围同步",Set.of(LIST,ORDER_READ)); private final AppointmentWorkStore store; private final AppointmentWorkFactGateway gateway; @@ -20,12 +23,17 @@ public class AppointmentWorkSyncService { @Transactional public SyncResult synchronize(SyncRequest request) { if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜"); + var timing=new SyncTiming();boolean success=false; + try { var guard=store.lock(); if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对"); + timing.next(SyncPhase.DISCOVER); var page=gateway.discover(request.afterContainerBookingId(),request.size()); + timing.pageContainers=page.containers().size();timing.complete=page.complete(); if(request.afterContainerBookingId()==null) { guard.setScanScopeVersion(page.scopeVersion()); guard.setScanRequirementVersion(guard.getRequirementVersion()); } else if(!Objects.equals(guard.getScanScopeVersion(),page.scopeVersion())) throw conflict("来源范围扫描中变化,请从首批重试"); else if(guard.getScanRequirementVersion()!=guard.getRequirementVersion()) throw conflict("预约需求扫描中变化,请从首批重试"); + timing.next(SyncPhase.GENERATE); // 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。 var known=workingSet(); int created=0; var ids=new HashSet(); @@ -51,18 +59,54 @@ public class AppointmentWorkSyncService { } } } - // An existing row is never deleted because an order left the active source page. - // Empty return and other unfinished work continue to read their original authority. + // 原来源离开当前页也保留历史事项,未完成工作仍在第二轮按原业务完整核对。 + timing.next(SyncPhase.TRANSPORT_FACTS); var all=new ArrayList<>(store.all()); - var facts=gateway.facts(all.stream().filter(s->s.getType()!=Type.ROUTE && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList()); + // 首轮仅归并运输关系;保留已关联车次的退役成员,才能发现解绑并恢复原运输事项。 + var transportItems=all.stream().filter(s->s.getType()==Type.TRANSPORT && s.getTripKey()==null && !"BATCH_REPLACED".equals(s.getRetirementReason())).toList(); + timing.transportFactsCount=transportItems.size(); + var facts=gateway.facts(transportItems); + timing.next(SyncPhase.RECONCILE); reconcileTransport(all,facts); + timing.next(SyncPhase.ACTIVE_FACTS); all=new ArrayList<>(store.all()); - facts=gateway.facts(all.stream().filter(s->!s.isRetired()).toList()); + var activeItems=all.stream().filter(s->!s.isRetired()).toList(); + timing.activeFactsCount=activeItems.size(); + facts=gateway.facts(activeItems); + timing.next(SyncPhase.OBSERVE_CHECKPOINT); observe(all,facts,SYSTEM); guard.setScanCursor(page.complete()?null:page.nextCursor()); if(page.complete()) { guard.setInitialized(true); guard.setScopeVersion(page.scopeVersion()); guard.setSynchronizedRequirementVersion(guard.getRequirementVersion()); guard.setCompletedAt(LocalDateTime.now(ZoneOffset.UTC)); } store.checkpoint(guard); + success=true; return new SyncResult(page.containers().size(),created,page.nextCursor(),page.complete()); + } finally { timing.finish(success); } + } + /** 固定阶段名用于慢同步定位,不包含来源、游标或异常业务内容。 */ + private enum SyncPhase { GUARD, DISCOVER, GENERATE, TRANSPORT_FACTS, RECONCILE, ACTIVE_FACTS, OBSERVE_CHECKPOINT } + /** 仅统计本次方法执行,不含事务代理提交,不能当作精确锁持有时间。 */ + private static final class SyncTiming { + private final long started=System.nanoTime(); + private final long[] elapsed=new long[SyncPhase.values().length]; + private long phaseStarted=started; + private SyncPhase phase=SyncPhase.GUARD; + private int pageContainers=-1,transportFactsCount=-1,activeFactsCount=-1; + private boolean complete; + /** 完成前一阶段后开始下一阶段;事实阶段包含紧邻的数据库回读。 */ + private void next(SyncPhase next) { + long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;phaseStarted=now;phase=next; + } + /** 成功和失败共用一条慢调用摘要,未到达的计数保持为负一。 */ + private void finish(boolean success) { + long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted; + long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(now-started); + if(totalMs<250) return; + LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}", + totalMs,millis(SyncPhase.GUARD),millis(SyncPhase.DISCOVER),millis(SyncPhase.GENERATE),millis(SyncPhase.TRANSPORT_FACTS),millis(SyncPhase.RECONCILE),millis(SyncPhase.ACTIVE_FACTS),millis(SyncPhase.OBSERVE_CHECKPOINT), + pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name()); + } + /** 阶段仅输出整数毫秒,不携带业务标识。 */ + private long millis(SyncPhase value) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[value.ordinal()]); } } static void validateSource(ContainerSource source) { if(source==null || source.containerBookingId()==null || source.containerBookingId().isBlank() || source.departedAt()==null && source.departedLocalAt()==null && source.sailedEvidenceLocalAt()==null || source.sourceVersion()==null || source.establishedFlow()==null || source.requirements()==null) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index a84290c40..b590682eb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -207,16 +207,24 @@ class AppointmentWorkSyncServiceTest { assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest("10",10))); assertFalse(f.guard.isInitialized()); } + /** 首轮只取运输事实;关联与解绑均保留原成员,第二轮仍观察全部活跃工作。 */ @Test void sharedTripAcrossTwoContainersIsPersistedOnceAndKeepsBothContainers() { var f=new WorkTestFixture(); + var replaced=f.state("old",Type.TRANSPORT,Flow.TRANSFER,"old-requirement"); + replaced.setRetired(true);replaced.setRetirementReason("BATCH_REPLACED");f.add(replaced); + record ScopeItem(String key,Type type,String trip,boolean retired,String reason) { } + var scopes=new ArrayList>(); + var linked=new java.util.concurrent.atomic.AtomicBoolean(true); var sources=List.of("1","2").stream().map(id->new ContainerSource(id,"柜"+id,Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,"America/Los_Angeles", List.of(new Requirement("R"+id,"B"+id,"AP"+id,"FBA","海外仓","ONT8","America/Los_Angeles",List.of(),List.of(),List.of())))).toList(); when(f.gateway.discover(null,10)).thenReturn(new SourcePage(sources,null,true,"scope")); when(f.gateway.facts(anyList())).thenAnswer(call->{ + var requested=call.>getArgument(0); + scopes.add(requested.stream().map(s->new ScopeItem(s.getWorkItemKey(),s.getType(),s.getTripKey(),s.isRetired(),s.getRetirementReason())).toList()); var values=new HashMap(); - for(var state:call.>getArgument(0)) if(state.getType()!=Type.ROUTE) { + for(var state:requested) if(state.getType()!=Type.ROUTE) { if(state.getType()==Type.TRANSPORT) values.put(state.getWorkItemKey(),new Fact(state.getWorkItemKey(),"trip-v1","trip-impact-v1",true,PlanStatus.CONFIRMED,ExecutionStatus.PENDING, - slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles","TR-1","trip-1",List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null)); + slot("2026-10-16"),slot("2026-10-16"),null,"America/Los_Angeles",linked.get()?"TR-1":null,linked.get()?"trip-1":null,List.of(AppointmentWorkPolicy.ORDER_READ),List.of(),null,List.of(),List.of(),List.of(),null,null)); else values.put(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null)); } return values; @@ -225,5 +233,66 @@ class AppointmentWorkSyncServiceTest { var transports=f.store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.TRANSPORT).toList(); assertEquals(1,transports.size()); assertEquals(2,transports.get(0).getContainers().size()); assertEquals(List.of("B1","B2"),transports.get(0).getBatchKeys()); assertEquals(9,f.store.all().stream().filter(s->!s.isRetired()).count()); + assertEquals(4,scopes.size()); + for(int index:List.of(0,2)) { + assertEquals(2,scopes.get(index).size()); + assertTrue(scopes.get(index).stream().allMatch(s->s.type()==Type.TRANSPORT && s.trip()==null && !"BATCH_REPLACED".equals(s.reason()))); + } + assertTrue(scopes.get(2).stream().allMatch(s->s.retired() && "TRIP_LINKED".equals(s.reason()))); + var activeKeys=f.store.all().stream().filter(s->!s.isRetired()).map(AppointmentWorkState::getWorkItemKey).collect(java.util.stream.Collectors.toSet()); + for(int index:List.of(1,3)) assertEquals(activeKeys,scopes.get(index).stream().map(ScopeItem::key).collect(java.util.stream.Collectors.toSet())); + linked.set(false);scopes.clear(); + f.sync.synchronize(new SyncRequest(null,10)); + assertEquals(2,scopes.size());assertEquals(2,scopes.get(0).size()); + assertTrue(scopes.get(0).stream().allMatch(s->s.type()==Type.TRANSPORT && s.retired() && "TRIP_LINKED".equals(s.reason()))); + var restored=f.store.all().stream().filter(s->!s.isRetired() && s.getType()==Type.TRANSPORT).toList(); + assertEquals(2,restored.size());assertTrue(restored.stream().allMatch(s->s.getTripKey()==null && s.getReplacementKeys().isEmpty())); + assertEquals("TRIP_UNLINKED",f.store.get(transports.get(0).getWorkItemKey()).getRetirementReason()); + assertEquals(f.store.all().stream().filter(s->!s.isRetired()).map(AppointmentWorkState::getWorkItemKey).collect(java.util.stream.Collectors.toSet()), + scopes.get(1).stream().map(ScopeItem::key).collect(java.util.stream.Collectors.toSet())); + assertTrue(f.store.get(replaced.getWorkItemKey()).isRetired()); + } + + /** 任一事实阶段失败均不得发布完整范围;此模拟验证发布门禁,不声称数据库回滚。 */ + @Test void failureInEitherFactRoundDoesNotPublishSynchronizedScope() { + for(int failedRound:List.of(1,2)) { + var f=new WorkTestFixture();f.guard.setInitialized(false);f.guard.setRequirementVersion(7);f.guard.setSynchronizedRequirementVersion(6); + var transport=f.state("1",Type.TRANSPORT,Flow.TRANSFER,"r");f.add(transport); + var pickup=f.state("1",Type.PICKUP,Flow.TRANSFER,null);f.add(pickup); + var calls=new java.util.concurrent.atomic.AtomicInteger(); + when(f.gateway.facts(anyList())).thenAnswer(call->{ + var requested=call.>getArgument(0);int round=calls.incrementAndGet(); + if(round==1) assertEquals(List.of(transport.getWorkItemKey()),requested.stream().map(AppointmentWorkState::getWorkItemKey).toList()); + else assertTrue(requested.stream().anyMatch(s->s.getWorkItemKey().equals(pickup.getWorkItemKey()))); + if(round==failedRound) throw AppointmentWorkPolicy.unavailable("原业务事实暂不可用"); + return Map.of(transport.getWorkItemKey(),fact(transport,null,null,ExecutionStatus.PENDING,null)); + }); + assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,10))); + assertEquals(failedRound,calls.get());assertFalse(f.guard.isInitialized()); + assertEquals(6,f.guard.getSynchronizedRequirementVersion());assertNull(f.guard.getCompletedAt()); + verify(f.mapper,never()).saveGuard(any()); + } + } + + /** 慢事实失败保留原异常,只记录一次固定阶段摘要,不输出异常业务内容。 */ + @Test void slowFactFailureLogsOnePhaseSummaryWithoutPublishingOrLeakingFailure() { + var f=new WorkTestFixture();var calls=new java.util.concurrent.atomic.AtomicInteger(); + var failure=AppointmentWorkPolicy.unavailable("private-source-detail-must-not-be-logged"); + when(f.gateway.facts(anyList())).thenAnswer(call->{ + if(calls.incrementAndGet()==1) return Map.of(); + Thread.sleep(270);throw failure; + }); + var logger=(ch.qos.logback.classic.Logger)org.slf4j.LoggerFactory.getLogger(AppointmentWorkSyncService.class); + var appender=new ch.qos.logback.core.read.ListAppender(); + appender.start();logger.addAppender(appender); + try { + assertSame(failure,assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,10)))); + verify(f.mapper,never()).saveGuard(any()); + assertEquals(1,appender.list.size());var event=appender.list.get(0); + assertEquals(ch.qos.logback.classic.Level.WARN,event.getLevel()); + assertTrue(event.getFormattedMessage().contains("failedStage=ACTIVE_FACTS")); + assertTrue(event.getFormattedMessage().contains("transportFactsCount=0, activeFactsCount=0")); + assertFalse(event.getFormattedMessage().contains("private-source-detail"));assertNull(event.getThrowableProxy()); + } finally { logger.detachAppender(appender);appender.stop(); } } } From 2971d6f7f0baedbf00dbb7941b1d846a736224a4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 09:57:54 +0800 Subject: [PATCH 004/113] perf(lastleg): reuse warehouse directory within sync generation --- .../AppointmentSourceService.java | 28 ++++++++-- .../AppointmentWorkFactAdapter.java | 22 +++++--- .../AppointmentWorkFactGateway.java | 8 +-- .../AppointmentWorkSyncService.java | 13 ++--- .../AppointmentWorkRequirementProvider.java | 18 ++++++- .../AppointmentSourceServiceTest.java | 32 +++++++++++ .../AppointmentWorkFactAdapterTest.java | 27 ++++++++++ .../AppointmentWorkPersistenceTest.java | 2 +- .../AppointmentWorkSyncServiceTest.java | 49 ++++++++++++++++- .../appointmentwork/WorkTestFixture.java | 2 +- ...ppointmentWorkRequirementProviderTest.java | 53 +++++++++++++------ 11 files changed, 212 insertions(+), 42 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java index 62757c22c..d2957eb37 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -58,18 +58,36 @@ public class AppointmentSourceService { return response.data().warehouse().timezone(); } catch(RuntimeException error) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"目的仓时区读取失败,请稍后核对",error); } } - /** One catalog request and batched source reads, including mixed first-leg and external bookings. */ + /** 普通批量读取创建独立目录作用域,同时读取头程和外接登记来源。 */ public List resolveBatch(List requested) { - return resolveBatch(requested,true).stream().map(Resolution::requireSource).toList(); + return openReadScope().resolveBatch(requested); + } + /** 创建显式单次读取作用域;仅成功的目录结果可在本次生成中复用。 */ + public ReadScope openReadScope() { return new ReadScope(); } + public final class ReadScope { + private List directory; + /** 只能由所属来源服务创建,不允许传入未经校验的目录。 */ + private ReadScope() { } + /** 每次仍读取原订单和权限事实,仅目录共享;不保存来源解析结果。 */ + public List resolveBatch(List requested) { + return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList(); + } + /** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */ + private List catalog() { + if(directory==null) directory=AppointmentSourceService.this.catalog(); + return directory; + } } /** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */ public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) { public Source requireSource() { if(error!=null) throw error; return source; } } + /** 每次检查独立读取目录,保留逐来源前置条件失败与基础设施异常边界。 */ public List inspectBatch(List requested) { - return resolveBatch(requested,false); + return resolveBatch(requested,false,openReadScope()); } - private List resolveBatch(List requested,boolean strict) { + /** 保留原批量校验与错误边界,目录只从调用方的局部作用域读取。 */ + private List resolveBatch(List requested,boolean strict,ReadScope scope) { if(requested==null || requested.isEmpty() || requested.size()>100) throw bad("每批请选择1至100票真实货源"); var keys=new LinkedHashSet(); var orderIds=new LinkedHashSet(); var bookingRefs=new java.util.ArrayList(); @@ -96,7 +114,7 @@ public class AppointmentSourceService { var destinations=transferRefs.isEmpty()?Map.of():destinationFacts.sources(transferRefs).stream().collect(Collectors.toMap(AppointmentCoverageMapper.Source::orderId,Function.identity())); var containerIds=orderRows.stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList(); var gates=containerIds.isEmpty()?Map.of():shipping.selectBatchIds(containerIds).stream().collect(Collectors.toMap(g->g.getContainerBookingId(),g->g.getNodeSequence()!=null && g.getNodeSequence()>=40)); - var catalog=catalog(); + var catalog=scope.catalog(); var result=requested.stream().map(ref->{ try { if(ref.sourceKey().startsWith("ORDER:") && externalOrders.contains(new AppointmentExternalSourceGateway.OrderRef(ref.customerId(),orderId(ref.sourceKey())))) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java index 3f9e71762..0fe8a759e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java @@ -68,12 +68,22 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { }); return result; } - public List requirements(ContainerSource source,Flow effectiveFlow) { - var provider=requirements.getIfAvailable(); - if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); - var result=provider.requirements(source,effectiveFlow); - if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); - return List.copyOf(result); + /** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */ + public RequirementReader openRequirements() { + return new RequirementReader() { + private RequirementReader reader; + /** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */ + public List read(ContainerSource source,Flow effectiveFlow) { + if(reader==null) { + var provider=requirements.getIfAvailable(); + if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); + reader=provider.openRequirements(); + } + var result=reader.read(source,effectiveFlow); + if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); + return List.copyOf(result); + } + }; } static void append(Map target,List requests,List facts) { if(facts==null || facts.size()!=requests.size()) throw unavailable("工作事实响应不完整"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java index e37882ed9..e2af776af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java @@ -27,8 +27,8 @@ public interface AppointmentWorkFactGateway { } SourcePage discover(String after, int size); ContainerSource container(String containerBookingId); - /** Original appointment core supplies the complete current obligation/batch manifest, including explicit replacement keys. */ - List requirements(ContainerSource source,Flow effectiveFlow); + /** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */ + RequirementReader openRequirements(); Map facts(List states); Owner owner(String id, Actor actor); Responsibility supplier(String id); @@ -41,5 +41,7 @@ public interface AppointmentWorkFactGateway { /** IAM-backed active platform operators; display names supplied by callers are never trusted. */ interface OwnerDirectory { Owner activePlatformOwner(String id); } interface SupplierDirectory { Responsibility activeSupplier(String id); } - interface RequirementProvider { List requirements(ContainerSource source,Flow effectiveFlow); } + /** 单次生成显式复用目录,不跨请求保存来源事实。 */ + interface RequirementReader { List read(ContainerSource source,Flow effectiveFlow); } + interface RequirementProvider { RequirementReader openRequirements(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java index 5f4e039b5..0f0e091bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -36,6 +36,7 @@ public class AppointmentWorkSyncService { timing.next(SyncPhase.GENERATE); // 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。 var known=workingSet(); + var requirementReader=gateway.openRequirements(); int created=0; var ids=new HashSet(); for(var source:page.containers()) { validateSource(source); @@ -45,13 +46,13 @@ public class AppointmentWorkSyncService { if(existing.isEmpty()) { if(source.establishedFlow()==Flow.UNKNOWN) { var route=create(source,Type.ROUTE,null,Flow.UNKNOWN); route.setFollowupDate(LocalDate.now(route.getTimezone()==null?REMINDER_ZONE:zone(route.getTimezone()))); store.insert(route); known.put(route.getWorkItemKey(),route); created++; - } else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known).size(); + } else created+=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader).size(); } else { var activeFlow=existing.stream().filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(null); - if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known).size(); + if(activeFlow!=null) created+=generate(source,activeFlow,existing,SYSTEM,known,requirementReader).size(); else for(var route:existing) if(route.getType()==Type.ROUTE && !route.isRetired()) { if(source.establishedFlow()!=Flow.UNKNOWN) { - var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known); created+=generated.size(); + var generated=generate(source,source.establishedFlow(),existing,SYSTEM,known,requirementReader); created+=generated.size(); route.setFlow(source.establishedFlow()); route.setRetired(true); route.setRetirementReason("ROUTE_CONFIRMED"); route.setReplacementKeys(generated); store.save(route); } else if(!Objects.equals(route.getSourceVersion(),source.sourceVersion())) { route.setSourceVersion(source.sourceVersion()); route.setContainerNo(source.containerNo()); route.setTimezone(source.timezone()); store.save(route); @@ -125,7 +126,7 @@ public class AppointmentWorkSyncService { } /** 命令入口仍在调用方守卫事务中即时加载工作集。 */ List generate(ContainerSource source,Flow flow,List existing,Actor actor) { - return generate(source,flow,existing,actor,workingSet()); + return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements()); } /** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */ private Map workingSet() { @@ -135,10 +136,10 @@ public class AppointmentWorkSyncService { } /** 同页各柜共享最新状态,新增、跨柜关联和退役仍逐项持久化。 */ private List generate(ContainerSource source,Flow flow,List existing,Actor actor, - Map known) { + Map known,RequirementReader requirementReader) { validateSource(source); if(flow==Flow.UNKNOWN) throw conflict("真实送货路线尚未明确"); - var requirements=gateway.requirements(source,flow); + var requirements=requirementReader.read(source,flow); source=new ContainerSource(source.containerBookingId(),source.containerNo(),source.departedAt(),source.departedLocalAt(),source.sailedEvidenceLocalAt(),source.sourceVersion(),source.establishedFlow(),source.timezone(),requirements); validateSource(source); if(requirements.isEmpty()) throw conflict("真实送货需求尚未明确,不能生成空清单"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java index 1bae1c9f4..db8e65d91 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java @@ -20,8 +20,22 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) { this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping; } + /** 同次生成仅共享按需读取的目录;原业务数据继续逐需求核实。 */ + public RequirementReader openRequirements() { + return new RequirementReader() { + private AppointmentSourceService.ReadScope scope; + /** 直送和未建立尾程来源的需求不创建目录读取依赖。 */ + public List read(ContainerSource container,Flow flow) { + return requirements(container,flow,refs->{ + if(scope==null) scope=sources.openReadScope(); + return scope.resolveBatch(refs); + }); + } + }; + } /** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */ - public List requirements(ContainerSource container,Flow effectiveFlow) { + private List requirements(ContainerSource container,Flow effectiveFlow, + java.util.function.Function,List> resolver) { var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); if(effectiveFlow==Flow.DIRECT) return original; if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); @@ -38,7 +52,7 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; } var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList()); List resolved; - try { resolved=sources.resolveBatch(refs); } + try { resolved=resolver.apply(refs); } catch(org.springframework.web.server.ResponseStatusException error) { if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error; // Keep the complete physical obligation and its canonical source identities. The fact diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java index 0543d3efc..3082da1b6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java @@ -12,6 +12,38 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentSourceServiceTest { + /** 目录失败不缓存,成功只在当前作用域复用;来源、客户和目的地仍逐次校验。 */ + @Test void explicitScopeRetriesFailedCatalogAndNeverCachesSourceOrCrossScopeDirectory() { + var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); + var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); + var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); + var order=new LastlegOrder();order.setId(1L);order.setCustomerId("A");order.setOrderType("FIRST_LEG"); + order.setSourceContainerBookingId("C1");order.setOverseasWarehouseId("100");order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(10); + when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order)); + var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40); + when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate)); + var catalog=ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US"))); + var outage=new IllegalStateException("offline"); + when(warehouse.catalog("token")).thenThrow(outage).thenReturn(catalog); + var refs=List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:ONT8")); + var scope=service.openReadScope();verifyNoInteractions(warehouse); + var error=assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));assertEquals(503,error.getStatusCode().value());assertSame(outage,error.getCause()); + assertEquals(10,scope.resolveBatch(refs).get(0).packages());verify(warehouse,times(2)).catalog("token"); + order.setPackageCount(12);assertEquals(12,scope.resolveBatch(refs).get(0).packages());verify(warehouse,times(2)).catalog("token"); + assertEquals(404,assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("OTHER","ORDER:1","FBA:ONT8")))).getStatusCode().value()); + assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(new AppointmentCoverageService.SourceRef("A","ORDER:1","FBA:LAX9")))); + assertThrows(ResponseStatusException.class,()->scope.resolveBatch(List.of(refs.get(0),refs.get(0)))); + gate.setNodeSequence(30);assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));gate.setNodeSequence(40); + order.setOverseasWarehouseId("missing");assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));order.setOverseasWarehouseId("100"); + when(external.resolveOrders(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","B-1","TR-1","RECEIVED","100","FBA:ONT8",1L,12,1L))); + assertThrows(ResponseStatusException.class,()->scope.resolveBatch(refs));when(external.resolveOrders(anyList())).thenReturn(List.of()); + assertEquals(12,service.openReadScope().resolveBatch(refs).get(0).packages());verify(warehouse,times(3)).catalog("token"); + service.resolveBatch(refs);service.resolveBatch(refs);service.inspectBatch(refs);service.inspectBatch(refs);verify(warehouse,times(7)).catalog("token"); + when(warehouse.catalog("token")).thenReturn(null).thenReturn(catalog); + var next=service.openReadScope();assertEquals(503,assertThrows(ResponseStatusException.class,()->next.resolveBatch(refs)).getStatusCode().value()); + assertEquals(12,next.resolveBatch(refs).get(0).packages());verify(warehouse,times(9)).catalog("token"); + } + @Test void batchResolvesTwoCustomersWithOneCatalogAndOneOrderAndShippingQuery() { var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); var gateway=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java index 5872e4ec5..bb2b62a64 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java @@ -9,6 +9,33 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; class AppointmentWorkFactAdapterTest { + /** 未读取需求的未知路线不依赖提供端,首次读取才开作用域并逐份校验结果。 */ + @SuppressWarnings("unchecked") + @Test void requirementReaderIsLazyAndKeepsProviderCompletenessChecks() { + var providers=(org.springframework.beans.factory.ObjectProvider)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class); + var business=(org.springframework.beans.factory.ObjectProvider)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class); + org.mockito.Mockito.when(business.orderedStream()).thenAnswer(c->java.util.stream.Stream.empty()); + var adapter=new AppointmentWorkFactAdapter(null,business,null,providers,null,"token"); + var unused=adapter.openRequirements();org.mockito.Mockito.verifyNoInteractions(providers); + var source=new ContainerSource("1","CNT",java.time.Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of()); + var f=new WorkTestFixture(); + org.mockito.Mockito.when(f.gateway.openRequirements()).thenAnswer(c->adapter.openRequirements()); + org.mockito.Mockito.when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); + f.sync.synchronize(new SyncRequest(null,10));assertEquals(Type.ROUTE,f.store.all().get(0).getType()); + org.mockito.Mockito.verifyNoInteractions(providers); + assertThrows(ResponseStatusException.class,()->unused.read(source,Flow.TRANSFER)); + var provider=org.mockito.Mockito.mock(RequirementProvider.class); + var delegate=org.mockito.Mockito.mock(RequirementReader.class); + org.mockito.Mockito.when(providers.getIfAvailable()).thenReturn(provider); + org.mockito.Mockito.when(provider.openRequirements()).thenReturn(delegate); + var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); + org.mockito.Mockito.when(delegate.read(source,Flow.TRANSFER)).thenReturn(List.of(requirement),List.of(requirement),List.of()); + var reader=adapter.openRequirements(); + assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); + assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); + assertThrows(ResponseStatusException.class,()->reader.read(source,Flow.TRANSFER)); + org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(); + } @Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() { var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); var request=new FactRequest(state.getWorkItemKey(),state.getType(),state.getFlow(),"1",null,null,null,List.of(),state.getContainers(),List.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java index 962bf0032..af142038b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java @@ -79,7 +79,7 @@ class AppointmentWorkPersistenceTest { var requirement=new AppointmentWorkFactGateway.Requirement(AppointmentWorkPolicy.customerRequirement(later.batchKey(),"B"),later.batchKey(),"AP-2","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8"))); var source=new AppointmentWorkFactGateway.ContainerSource("1","CNT",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement)); var facts=org.mockito.Mockito.mock(AppointmentWorkFactGateway.class); - org.mockito.Mockito.when(facts.requirements(source,Flow.TRANSFER)).thenReturn(List.of(requirement)); + org.mockito.Mockito.when(facts.openRequirements()).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); tx.executeWithoutResult(status->new AppointmentWorkSyncService(store,facts).generate(source,Flow.TRANSFER,List.of(),ACTOR)); assertEquals(persisted.owner(),store.get(key).getOwner());assertEquals(persisted.nextFollowupDate(),store.get(key).getFollowupDate()); var third=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:3","B"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index b590682eb..2e5d49be2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -12,6 +12,50 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkSyncServiceTest { + /** 真实需求提供链百柜只读一次目录,下页和命令生成独立重读;来源查询仍逐次执行。 */ + @Test void hundredFbaContainersShareOnlyCatalogWithinOneGenerationScope() { + var f=new WorkTestFixture(); + var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); + var shipping=mock(com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper.class); + var external=mock(com.baiyue.lastleg.appointmentplanning.AppointmentExternalSourceGateway.class); + var warehouse=mock(com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.class); + when(warehouse.catalog("token")).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of( + new com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.Warehouse(100L,"US-WH","美国仓","OVERSEAS","US"), + new com.baiyue.lastleg.appointmentplanning.AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); + when(orders.selectBatchIds(anyCollection())).thenAnswer(c->c.>getArgument(0).stream().map(id->{ + var order=new com.baiyue.lastleg.entity.LastlegOrder();order.setId(id);order.setCustomerId("C"+id); + order.setOrderType("FIRST_LEG");order.setSourceContainerBookingId(id.toString());order.setOverseasWarehouseId("100"); + order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(1);return order; + }).toList()); + when(shipping.selectBatchIds(anyCollection())).thenAnswer(c->c.>getArgument(0).stream().map(id->{ + var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId(id);gate.setNodeSequence(40);return gate; + }).toList()); + var sources=new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService(orders,shipping,external,warehouse,"token"); + var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class); + var allocation=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.Context.class); + when(allocation.availablePackages()).thenReturn(1L);when(cargo.inspect(any(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source.class))).thenReturn(allocation); + var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class); + when(mapping.containerSources(anyString())).thenAnswer(c->{String id=c.getArgument(0);return List.of( + new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",1));}); + var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo, + mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class),mapping); + when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); + var rows=java.util.stream.IntStream.rangeClosed(1,100).mapToObj(i->new ContainerSource(""+i,"CNT"+i, + Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null, + List.of(new Requirement("INBOUND:"+i,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); + when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); + f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(1)).catalog("token"); + verify(orders,times(100)).selectBatchIds(anyCollection()); + var before=f.store.all().stream().map(f.store::write).toList(); + f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(2)).catalog("token"); + assertEquals(before,f.store.all().stream().map(f.store::write).toList()); + f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR); + verify(warehouse,times(3)).catalog("token"); + f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR); + verify(warehouse,times(4)).catalog("token"); + verify(orders,times(202)).selectBatchIds(anyCollection()); + } + /** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */ @Test void hundredContainersReuseOneGenerationWorkingSetAndKeepUnknownRoutesAndHistory() { var f=new WorkTestFixture(); @@ -57,6 +101,9 @@ class AppointmentWorkSyncServiceTest { var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class); var appointments=mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class); var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class); + var scope=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.ReadScope.class); + when(sources.openReadScope()).thenReturn(scope); + when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); when(mapping.containerSources("1")).thenReturn(List.of(new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(1L,"A","1","1","FBA","ONT8",1),new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(2L,"B","2","1","FBA","ONT8",1))); when(sources.canonicalOrders(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList()); @@ -70,7 +117,7 @@ class AppointmentWorkSyncServiceTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(snapshot)); var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),refs); var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root)); - doAnswer(c->provider.requirements(c.getArgument(0),c.getArgument(1))).when(f.gateway).requirements(any(),eq(Flow.TRANSFER)); + when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.guard.setRequirementVersion(1); f.sync.synchronize(new SyncRequest(null,10)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java index 1c9ec32f0..bc953d55a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java @@ -38,7 +38,7 @@ final class WorkTestFixture { when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap(); for(var state:c.>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; }); when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope")); when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲")); - when(gateway.requirements(any(),any())).thenAnswer(c->c.getArgument(0,ContainerSource.class).requirements()); + when(gateway.openRequirements()).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); } AppointmentWorkState state(String container,Type type,Flow flow,String requirement) { var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java index 1ff8f4d24..3d5c93c7b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java @@ -15,7 +15,7 @@ class AppointmentWorkRequirementProviderTest { @Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -33,29 +33,29 @@ class AppointmentWorkRequirementProviderTest { when(current.batchKey()).thenReturn("FBA_APPOINTMENT:2");when(current.appointmentNo()).thenReturn("AP-2"); when(current.fbaWarehouseCode()).thenReturn("ONT8");when(current.items()).thenReturn(List.of(item));when(current.replacementBatchKeys()).thenReturn(List.of()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled,current)); - var actual=provider.requirements(container,Flow.TRANSFER); + var actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); when(allocation.availablePackages()).thenReturn(20L); - actual=provider.requirements(container,Flow.TRANSFER); + actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1|CUSTOMER:C"),actual.get(0).replacesRequirementKeys()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled)); when(allocation.availablePackages()).thenReturn(60L); - actual=provider.requirements(container,Flow.TRANSFER); + actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); // 非取消的空批次仍属缺失事实,不能静默跳过。 when(cancelled.appointmentStatus()).thenReturn("PENDING"); - assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.requirements(container,Flow.TRANSFER)).getStatusCode().value()); + assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)).getStatusCode().value()); } @Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -72,55 +72,74 @@ class AppointmentWorkRequirementProviderTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch)); for(var status:List.of("CANCELLED","FAILED")) { when(batch.appointmentStatus()).thenReturn(status); - var result=provider.requirements(container,Flow.TRANSFER); + var result=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"),result.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),result.get(0).sources()); assertTrue(result.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1")); } when(allocation.availablePackages()).thenReturn(20L); - assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2")); - assertEquals(List.of("INBOUND:1"),provider.requirements(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); } @Test void unbookableSourceKeepsItsPhysicalRequirementWhileAuthorityFailuresStillAbort() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",4))); var refs=List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null)); when(sources.canonicalOrders(anyList())).thenReturn(refs); when(sources.resolveBatch(refs)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约")); - var actual=provider.requirements(container,Flow.TRANSFER); + var actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(1,actual.size());assertEquals("INBOUND:1",actual.get(0).requirementKey()); assertEquals(List.of(new SourceReference("C","ORDER:11",null)),actual.get(0).sources()); verifyNoInteractions(cargo,appointments); doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对")).when(sources).resolveBatch(refs); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.requirements(container,Flow.TRANSFER)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)); } @Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","PRIVATE",null,10))); var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4); when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch)); - var manifest=provider.requirements(container,Flow.TRANSFER); + var manifest=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList()); assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey()); when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4))); - var restored=provider.requirements(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); + var restored=provider.openRequirements().read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); verifyNoInteractions(sources,cargo,appointments); } @Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() { var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement)); - assertEquals(List.of(requirement),provider.requirements(source,Flow.DIRECT)); + assertEquals(List.of(requirement),provider.openRequirements().read(source,Flow.DIRECT)); verifyNoInteractions(sources,cargo,appointments,mapping); } + /** 无尾程映射时保留原需求,不读取或依赖仓库目录。 */ + @Test void missingLastlegSourcesDoNotOpenDirectoryScope() { + var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); + var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); + var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); + var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); + var reader=provider.openRequirements();verifyNoInteractions(sources,cargo,appointments,mapping); + assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));verifyNoInteractions(sources,cargo,appointments); + } + + /** 保留原来源模拟,显式接入本次读取作用域。 */ + private static void scoped(AppointmentSourceService sources) { + when(sources.openReadScope()).thenAnswer(call->{ + var scope=mock(AppointmentSourceService.ReadScope.class); + when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); + return scope; + }); + } } From 662dead053f961d6f14aaeea5edb38c76778a822 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 11:37:57 +0800 Subject: [PATCH 005/113] chore(lastleg): diagnose work fact synchronization phases --- .../AppointmentWorkFactAdapter.java | 56 +++++++++++- .../AppointmentWorkSyncService.java | 15 ++-- .../AppointmentWorkFactAdapterTest.java | 87 +++++++++++++++++++ .../AppointmentWorkSyncServiceTest.java | 31 +++++++ 4 files changed, 180 insertions(+), 9 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java index 0fe8a759e..7fc1d2530 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java @@ -10,6 +10,7 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @Component public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { + private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(AppointmentWorkFactAdapter.class); private final AppointmentWorkFirstlegClient firstleg; private final List providers; private final ObjectProvider directory; @@ -38,7 +39,10 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { return response.data(); } catch(RuntimeException error) { throw unavailable("柜次权威事实不可用",error); } } + /** 原顺序读取全部事实;慢调用和失败仅附加无业务内容的局部统计。 */ public Map facts(List states) { + var timing=new FactTiming();boolean success=false; + try { var result=new LinkedHashMap(); var remote=new ArrayList(); var groups=new LinkedHashMap>(); @@ -53,8 +57,8 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { } } for(int start=0; startfirstleg.facts(token,requests)); if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败"); append(result,requests,response.data()); } catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); } @@ -62,11 +66,55 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { groups.forEach((provider,requests)->{ for(int start=0; startprovider.read(batch))); } catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); } } }); - return result; + success=true;return result; + } finally { timing.finish(success); } + } + /** 一次 facts 调用的计时,不保存原来源或返回值,不跨调用缓存。 */ + private static final class FactTiming { + private final long started=System.nanoTime(); + private final CallStatistics firstleg=new CallStatistics(); + private final Map providers=new LinkedHashMap<>(); + private String stage="PREPARE"; + /** 固定诊断桶不使用代理名、业务参数或异常文本。 */ + private CallStatistics provider(BusinessProvider provider) { + String name=org.springframework.util.ClassUtils.getUserClass(provider).getSimpleName(); + name=switch(name) { + case "AppointmentWorkReceivingProvider","AppointmentWorkDeliveryProvider","AppointmentWorkTransportProvider","AppointmentWorkDirectTransportProvider" -> name; + default -> "OTHER"; + }; + stage=name; + return providers.computeIfAbsent(name,ignored->new CallStatistics()); + } + /** 只统计真正尝试的提供端调用;校验及组装耗时保留在 totalMs 中。 */ + private T measure(CallStatistics statistics,int requests,java.util.function.Supplier operation) { + statistics.calls++;statistics.requests+=requests;long begin=System.nanoTime(); + try { return operation.get(); } + finally { statistics.elapsed+=System.nanoTime()-begin; } + } + /** 成功慢调用或任意失败只输出一次汇总,日志故障不得遮蔽原异常。 */ + private void finish(boolean success) { + long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started); + if(success && totalMs<250) return; + try { + var summaries=new LinkedHashMap(); + providers.forEach((name,value)->summaries.put(name,value.summary())); + LOG.warn("Appointment work facts diagnostic: totalMs={}, firstlegCalls={}, firstlegRequests={}, firstlegMs={}, providers={}, failedStage={}", + totalMs,firstleg.calls,firstleg.requests,firstleg.millis(),summaries,success?"NONE":stage); + } catch(RuntimeException diagnosticFailure) { /* 仅忽略诊断故障,原事实错误仍沿原路径抛出。 */ } + } + } + /** 仅包含数量和单调时钟耗时的累加器。 */ + private static final class CallStatistics { + private int calls,requests;private long elapsed; + /** 输出整毫秒,未计入网络之后的事实完整性校验。 */ + private long millis() { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed); } + /** 固定字段格式便于日志汇总,不包含任何请求或响应内容。 */ + private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); } } /** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */ public RequirementReader openRequirements() { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java index 0f0e091bd..0f22f79b7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -69,10 +69,11 @@ public class AppointmentWorkSyncService { var facts=gateway.facts(transportItems); timing.next(SyncPhase.RECONCILE); reconcileTransport(all,facts); - timing.next(SyncPhase.ACTIVE_FACTS); + timing.next(SyncPhase.ACTIVE_STORE); all=new ArrayList<>(store.all()); var activeItems=all.stream().filter(s->!s.isRetired()).toList(); timing.activeFactsCount=activeItems.size(); + timing.next(SyncPhase.ACTIVE_FACTS); facts=gateway.facts(activeItems); timing.next(SyncPhase.OBSERVE_CHECKPOINT); observe(all,facts,SYSTEM); @@ -84,7 +85,7 @@ public class AppointmentWorkSyncService { } finally { timing.finish(success); } } /** 固定阶段名用于慢同步定位,不包含来源、游标或异常业务内容。 */ - private enum SyncPhase { GUARD, DISCOVER, GENERATE, TRANSPORT_FACTS, RECONCILE, ACTIVE_FACTS, OBSERVE_CHECKPOINT } + private enum SyncPhase { GUARD, DISCOVER, GENERATE, TRANSPORT_FACTS, RECONCILE, ACTIVE_STORE, ACTIVE_FACTS, OBSERVE_CHECKPOINT } /** 仅统计本次方法执行,不含事务代理提交,不能当作精确锁持有时间。 */ private static final class SyncTiming { private final long started=System.nanoTime(); @@ -102,9 +103,13 @@ public class AppointmentWorkSyncService { long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted; long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(now-started); if(totalMs<250) return; - LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}", - totalMs,millis(SyncPhase.GUARD),millis(SyncPhase.DISCOVER),millis(SyncPhase.GENERATE),millis(SyncPhase.TRANSPORT_FACTS),millis(SyncPhase.RECONCILE),millis(SyncPhase.ACTIVE_FACTS),millis(SyncPhase.OBSERVE_CHECKPOINT), - pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name()); + try { + // 旧 activeFactsMs 仍包含工作表读取、解析及适配;新增明细不能改变旧指标口径。 + long activeMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[SyncPhase.ACTIVE_STORE.ordinal()]+elapsed[SyncPhase.ACTIVE_FACTS.ordinal()]); + LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, activeStoreMs={}, activeAdapterMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}", + totalMs,millis(SyncPhase.GUARD),millis(SyncPhase.DISCOVER),millis(SyncPhase.GENERATE),millis(SyncPhase.TRANSPORT_FACTS),millis(SyncPhase.RECONCILE),activeMs,millis(SyncPhase.ACTIVE_STORE),millis(SyncPhase.ACTIVE_FACTS),millis(SyncPhase.OBSERVE_CHECKPOINT), + pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name()); + } catch(RuntimeException diagnosticFailure) { /* 诊断失败不能替换原业务异常或阻断提交。 */ } } /** 阶段仅输出整数毫秒,不携带业务标识。 */ private long millis(SyncPhase value) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[value.ordinal()]); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java index bb2b62a64..f3b02809a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java @@ -4,11 +4,98 @@ import org.junit.jupiter.api.Test; import org.springframework.web.server.ResponseStatusException; import java.util.*; import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; class AppointmentWorkFactAdapterTest { + /** 慢成功仅追加聚合诊断,原二百项批量、调用次序和事实结果保持一致。 */ + @Test void slowSuccessSummarizesOriginalBatchesWithoutBusinessContent() { + var f=new DiagnosticFixture(); + var states=new ArrayList(); + for(int i=0;i<401;i++) states.add(f.work.state("private-pickup-"+i,Type.PICKUP,Flow.TRANSFER,null)); + for(int i=0;i<201;i++) states.add(f.work.state("private-receipt-"+i,Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null)); + when(f.firstleg.facts(anyString(),anyList())).thenAnswer(c->{ + var batch=c.>getArgument(1);f.remoteSizes.add(batch.size()); + if(f.remoteSizes.size()==1) Thread.sleep(270); + return com.baiyue.common.core.api.ApiResponse.success(batch.stream().map(DiagnosticFixture::factFor).toList()); + }); + try(var log=new DiagnosticLog()) { + var result=f.adapter.facts(states); + assertEquals(List.of(200,200,1),f.remoteSizes);assertEquals(List.of(200,1),f.providerSizes); + assertEquals(states.stream().map(AppointmentWorkState::getWorkItemKey).toList(),new ArrayList<>(result.keySet())); + for(var state:states) assertEquals(fact(state,null,null,ExecutionStatus.PENDING,null),result.get(state.getWorkItemKey())); + assertEquals(1,log.events().size());var event=log.events().get(0);var text=event.getFormattedMessage(); + assertTrue(text.contains("firstlegCalls=3, firstlegRequests=401")); + assertTrue(text.contains("AppointmentWorkReceivingProvider=calls=2,requests=201,ms=")); + assertTrue(text.contains("failedStage=NONE"));assertFalse(text.contains("private-"));assertNull(event.getThrowableProxy()); + verify(f.firstleg,times(3)).facts(anyString(),anyList());verify(f.provider,times(2)).read(anyList()); + } + } + /** 快速失败也输出一次当前阶段;保留原包装异常及其原始原因,不重试。 */ + @Test void failedRemoteAndProviderCallsKeepOriginalCauseAndStopAtSameBatch() { + for(boolean remote:List.of(true,false)) { + var f=new DiagnosticFixture();var failure=new IllegalStateException("private-payload-customer-token"); + var state=f.work.state("private-container",remote?Type.PICKUP:Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null); + if(remote) when(f.firstleg.facts(anyString(),anyList())).thenThrow(failure); + else when(f.provider.read(anyList())).thenThrow(failure); + try(var log=new DiagnosticLog()) { + var thrown=assertThrows(ResponseStatusException.class,()->f.adapter.facts(List.of(state))); + assertEquals(503,thrown.getStatusCode().value());assertSame(failure,thrown.getCause()); + assertEquals(remote?"Firstleg工作事实不可用":"预约/收货/运输事实不可用",thrown.getReason()); + assertEquals(1,log.events().size());var event=log.events().get(0);var text=event.getFormattedMessage(); + assertTrue(text.contains("failedStage="+(remote?"FIRSTLEG":"AppointmentWorkReceivingProvider"))); + assertTrue(text.contains(remote?"firstlegCalls=1, firstlegRequests=1":"firstlegCalls=0, firstlegRequests=0")); + if(!remote) assertTrue(text.contains("AppointmentWorkReceivingProvider=calls=1,requests=1,ms=")); + assertFalse(text.contains("private-"));assertNull(event.getThrowableProxy()); + verify(f.firstleg,times(remote?1:0)).facts(anyString(),anyList());verify(f.provider,times(remote?0:1)).read(anyList()); + } + } + } + /** 输入分组失败不调用原域,失败诊断仍不得泄露异常中的业务类型内容。 */ + @Test void preparationFailureHasNoProviderAttempts() { + var f=new DiagnosticFixture();var state=f.work.state("private-container",Type.TRANSPORT,Flow.TRANSFER,null); + try(var log=new DiagnosticLog()) { + assertThrows(ResponseStatusException.class,()->f.adapter.facts(List.of(state))); + assertEquals(1,log.events().size());var text=log.events().get(0).getFormattedMessage(); + assertTrue(text.contains("firstlegCalls=0, firstlegRequests=0"));assertTrue(text.contains("providers={}")); + assertTrue(text.contains("failedStage=PREPARE"));assertFalse(text.contains("private-")); + verifyNoInteractions(f.firstleg);verify(f.provider,never()).read(anyList()); + } + } + private static final class DiagnosticFixture { + final WorkTestFixture work=new WorkTestFixture(); + final AppointmentWorkFirstlegClient firstleg=mock(AppointmentWorkFirstlegClient.class); + final com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingProvider provider=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkReceivingProvider.class); + final List remoteSizes=new ArrayList<>(),providerSizes=new ArrayList<>(); + final AppointmentWorkFactAdapter adapter; + /** 使用真实提供端类型和原分组契约,便于核对固定诊断桶。 */ + @SuppressWarnings("unchecked") + DiagnosticFixture() { + var providers=(org.springframework.beans.factory.ObjectProvider)mock(org.springframework.beans.factory.ObjectProvider.class); + when(providers.orderedStream()).thenAnswer(c->java.util.stream.Stream.of(provider)); + when(provider.supports(Type.WAREHOUSE_RECEIPT,Flow.TRANSFER)).thenReturn(true); + when(provider.read(anyList())).thenAnswer(c->{var batch=c.>getArgument(0);providerSizes.add(batch.size());return batch.stream().map(DiagnosticFixture::factFor).toList();}); + adapter=new AppointmentWorkFactAdapter(firstleg,providers,null,null,null,"private-token"); + } + /** 保留原测试事实形状,统计不得影响事实指纹或返回顺序。 */ + static Fact factFor(FactRequest request) { + var state=new AppointmentWorkState();state.setWorkItemKey(request.workItemKey()); + return fact(state,null,null,ExecutionStatus.PENDING,null); + } + } + private static final class DiagnosticLog implements AutoCloseable { + final ch.qos.logback.classic.Logger logger=(ch.qos.logback.classic.Logger)org.slf4j.LoggerFactory.getLogger(AppointmentWorkFactAdapter.class); + final ch.qos.logback.core.read.ListAppender appender=new ch.qos.logback.core.read.ListAppender<>(); + /** 每个用例独立捕获日志,不修改全局日志级别。 */ + DiagnosticLog() { appender.start();logger.addAppender(appender); } + /** 返回本用例的聚合日志记录。 */ + List events() { return appender.list; } + /** 无论断言结果都释放日志采集器。 */ + public void close() { logger.detachAppender(appender);appender.stop(); } + } + /** 未读取需求的未知路线不依赖提供端,首次读取才开作用域并逐份校验结果。 */ @SuppressWarnings("unchecked") @Test void requirementReaderIsLazyAndKeepsProviderCompletenessChecks() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index 2e5d49be2..6ac989b15 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -321,6 +321,35 @@ class AppointmentWorkSyncServiceTest { } } + /** 明细拆分仍保持旧活跃阶段总耗时;表读取失败不追加事实请求或发布。 */ + @Test void activeStoreAndAdapterTimingsRetainCombinedMetricAndFailureBehavior() { + for(boolean failStore:List.of(false,true)) { + var f=new WorkTestFixture();var reads=new java.util.concurrent.atomic.AtomicInteger(); + var factCalls=new java.util.concurrent.atomic.AtomicInteger();var failure=AppointmentWorkPolicy.unavailable("private-store-body"); + when(f.mapper.all(anyInt())).thenAnswer(call->{ + if(reads.incrementAndGet()==3) {Thread.sleep(270);if(failStore) throw failure;} + return List.of(); + }); + when(f.gateway.facts(anyList())).thenAnswer(call->{if(factCalls.incrementAndGet()==2) Thread.sleep(25);return Map.of();}); + var logger=(ch.qos.logback.classic.Logger)org.slf4j.LoggerFactory.getLogger(AppointmentWorkSyncService.class); + var appender=new ch.qos.logback.core.read.ListAppender();appender.start();logger.addAppender(appender); + try { + if(failStore) {assertSame(failure,assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,10))));verify(f.mapper,never()).saveGuard(any());} + else {assertTrue(f.sync.synchronize(new SyncRequest(null,10)).complete());verify(f.mapper,times(1)).saveGuard(any());} + assertEquals(3,reads.get());assertEquals(failStore?1:2,factCalls.get());assertEquals(1,appender.list.size()); + var event=appender.list.get(0);var text=event.getFormattedMessage(); + long storeMs=metric(text,"activeStoreMs"),adapterMs=metric(text,"activeAdapterMs"),combined=metric(text,"activeFactsMs"); + assertTrue(storeMs>=250);assertTrue(combined>=storeMs+adapterMs && combined<=storeMs+adapterMs+1); + if(failStore) assertEquals(0,adapterMs);else assertTrue(adapterMs>=20); + assertTrue(text.contains("failedStage="+(failStore?"ACTIVE_STORE":"NONE")));assertFalse(text.contains("private-"));assertNull(event.getThrowableProxy()); + } finally {logger.detachAppender(appender);appender.stop();} + } + } + /** 从固定诊断字段读取整数,只用于确认原聚合指标口径。 */ + private static long metric(String text,String name) { + var match=java.util.regex.Pattern.compile(name+"=(\\d+)").matcher(text);assertTrue(match.find());return Long.parseLong(match.group(1)); + } + /** 慢事实失败保留原异常,只记录一次固定阶段摘要,不输出异常业务内容。 */ @Test void slowFactFailureLogsOnePhaseSummaryWithoutPublishingOrLeakingFailure() { var f=new WorkTestFixture();var calls=new java.util.concurrent.atomic.AtomicInteger(); @@ -338,6 +367,8 @@ class AppointmentWorkSyncServiceTest { assertEquals(1,appender.list.size());var event=appender.list.get(0); assertEquals(ch.qos.logback.classic.Level.WARN,event.getLevel()); assertTrue(event.getFormattedMessage().contains("failedStage=ACTIVE_FACTS")); + assertTrue(metric(event.getFormattedMessage(),"activeAdapterMs")>=250); + assertTrue(metric(event.getFormattedMessage(),"activeFactsMs")>=metric(event.getFormattedMessage(),"activeAdapterMs")); assertTrue(event.getFormattedMessage().contains("transportFactsCount=0, activeFactsCount=0")); assertFalse(event.getFormattedMessage().contains("private-source-detail"));assertNull(event.getThrowableProxy()); } finally { logger.detachAppender(appender);appender.stop(); } From b0acbfbb3486f6afa0d45e9b6f2b6af0d6023bb8 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sat, 17 Oct 2026 12:54:39 +0800 Subject: [PATCH 006/113] perf(lastleg): batch appointment requirement generation reads --- .../AppointmentCargoReadRepository.java | 16 ++ .../AppointmentCargoVerificationService.java | 18 ++ .../AppointmentSourceReadRepository.java | 26 ++ .../AppointmentSourceService.java | 85 ++++++ .../AppointmentWorkFactAdapter.java | 4 +- .../AppointmentWorkFactGateway.java | 5 +- .../AppointmentWorkSyncService.java | 13 +- .../InboundFbaAppointmentQueryMapper.java | 22 ++ .../service/InboundFbaAppointmentService.java | 37 +++ .../AppointmentRequirementReadRepository.java | 28 ++ .../AppointmentWorkRequirementProvider.java | 102 ++++++-- ...AppointmentCargoVerificationBatchTest.java | 16 ++ .../AppointmentPlanningAuthorizationTest.java | 4 + .../AppointmentSourceServiceTest.java | 50 ++++ .../AppointmentWorkFactAdapterTest.java | 10 +- .../AppointmentWorkPersistenceTest.java | 2 +- .../AppointmentWorkSyncServiceTest.java | 48 +++- .../appointmentwork/WorkTestFixture.java | 2 +- .../FbaAppointmentPersistenceTest.java | 6 + ...ointmentRequirementReadRepositoryTest.java | 244 ++++++++++++++++++ ...ppointmentWorkRequirementProviderTest.java | 75 ++++-- 21 files changed, 747 insertions(+), 66 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java new file mode 100644 index 000000000..ebec3cdd6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.appointmentplanning; + +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 单次生成只批量读取核实与分配原料,业务解析仍按实际访问顺序。 */ +@Repository +public class AppointmentCargoReadRepository { + private final AppointmentCargoVerificationMapper mapper; + /** 复用本域既有批量查询。 */ + public AppointmentCargoReadRepository(AppointmentCargoVerificationMapper mapper) { this.mapper=mapper; } + /** 取得每客户来源的最新核实记录,暂不解析货件 JSON。 */ + public List latest(List refs) { return refs.isEmpty()?List.of():mapper.latestBatch(refs); } + /** 分配数量仅取活动明细,未知数量保持单独计数。 */ + public List allocations(List refs) { return refs.isEmpty()?List.of():mapper.allocatedBatch(refs); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java index e0bc99167..a05e33b7c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java @@ -21,6 +21,7 @@ public class AppointmentCargoVerificationService { private final AppointmentSourceService sources; private final AppointmentCargoVerificationMapper mapper; private final ObjectMapper json; + @jakarta.annotation.Resource private AppointmentCargoReadRepository readRepository; public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, ObjectMapper json) { @@ -50,6 +51,23 @@ public class AppointmentCargoVerificationService { public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { public Context requireContext() { if(error!=null) throw error; return context; } } + /** 只预取本次已授权来源的原料;核实 JSON 与数量判断按原 anyMatch 实际访问顺序执行。 */ + public java.util.function.Function prepareInspection(List resolved) { + var refs=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList(); + var latest=new HashMap,AppointmentCargoVerification>(); + var allocations=new HashMap,AppointmentCargoVerificationMapper.Allocation>(); + for(int start=0;startlatest.put(List.of(r.getCustomerId(),r.getSourceKey()),r)); + readRepository.allocations(batch).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems()))); + } + var allowed=new HashSet<>(resolved); + return source->{ + if(!allowed.contains(source)) throw new IllegalArgumentException("货物核实来源不属于本次读取范围"); + var key=List.of(source.customerId(),source.sourceKey()); + return inspect(source,latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))); + }; + } /** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */ public List inspectBatch(List refs) { var result=new ArrayList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java new file mode 100644 index 000000000..7ff060a6e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.appointmentplanning; + +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; +import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 同次需求生成的来源原料读取,不保存跨调用状态。 */ +@Repository +public class AppointmentSourceReadRepository { + private final LastlegOrderMapper orders; + private final ContainerShippingGateMapper shipping; + private final AppointmentCoverageMapper destinations; + /** 仅组合本域既有批量持久查询。 */ + public AppointmentSourceReadRepository(LastlegOrderMapper orders,ContainerShippingGateMapper shipping,AppointmentCoverageMapper destinations) { + this.orders=orders;this.shipping=shipping;this.destinations=destinations; + } + /** 读取所选订单,客户归属继续由来源服务逐需求核验。 */ + public List orders(List ids) { return ids.isEmpty()?List.of():orders.selectBatchIds(ids); } + /** 读取实际船节点门禁,不从计划时间推断。 */ + public List gates(List ids) { return ids.isEmpty()?List.of():shipping.selectBatchIds(ids); } + /** 转运目的地仍取原业务权威关系。 */ + public List destinations(List refs) { return refs.isEmpty()?List.of():destinations.sources(refs); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java index d2957eb37..91e1e3341 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -26,6 +26,7 @@ public class AppointmentSourceService { private final AppointmentWarehouseClient warehouses; private final String token; @jakarta.annotation.Resource private AppointmentCoverageMapper destinationFacts; + @jakarta.annotation.Resource private AppointmentSourceReadRepository readRepository; public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping, AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses, @Value("${baiyue.security.internal-service-token:}") String token) { @@ -72,12 +73,96 @@ public class AppointmentSourceService { public List resolveBatch(List requested) { return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList(); } + /** 同页只合并原料查询;来源规范化、重复与权限检查仍按每份需求独立执行。 */ + public List resolveOrderGroups(List> groups) { + var canonical=new java.util.ArrayList>(); + var errors=new java.util.ArrayList(); + var original=new LinkedHashSet(); + for(var group:groups) { + RuntimeException error=null; + if(group==null || group.isEmpty() || group.size()>100) error=new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Firstleg需求缺少完整客户货源关系"); + else if(group.stream().anyMatch(r->r==null || blank(r.customerId()) || r.orderId()==null || r.orderId()<=0)) error=bad("请选择完整的真实货源"); + errors.add(error);if(error==null) original.addAll(group); + } + var aliases=new java.util.HashMap(); + var originals=new java.util.ArrayList<>(original); + for(int start=0;start(); + var orderIds=new LinkedHashSet(); + for(int i=0;i{ + var alias=aliases.get(ref); + return new AppointmentCoverageService.SourceRef(ref.customerId(),alias==null?"ORDER:"+ref.orderId():"BOOKING:"+alias.bookingNo(),null); + }).toList(); + // 保留原 canonical 的数量校验,不能以全页去重掩盖单需求重复。 + if(refs.size()!=groups.get(i).size()) errors.set(i,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源键必须明确为ORDER或BOOKING")); + refs=refs.stream().distinct().toList();canonical.add(refs); + for(var ref:refs) if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey())); + else bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8))); + } + var bookingMap=new java.util.HashMap(); + var bookings=new java.util.ArrayList<>(bookingRefs); + for(int start=0;start(); + var gates=new java.util.HashMap(); + var destinations=new java.util.HashMap(); + var ids=new java.util.ArrayList<>(orderIds); + for(int start=0;start{if(orderMap.put(row.getId(),row)!=null) throw new IllegalStateException("原订单来源重复");}); + } + var containers=orderMap.values().stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList(); + for(int start=0;start{ + if(gates.put(g.getContainerBookingId(),g.getNodeSequence()!=null && g.getNodeSequence()>=40)!=null) throw new IllegalStateException("原船节点门禁重复"); + }); + var transfers=orderMap.values().stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList(); + for(int start=0;start{ + if(destinations.put(d.orderId(),d)!=null) throw new IllegalStateException("原目的地来源重复"); + }); + var result=new java.util.ArrayList();RuntimeException infrastructure=null; + for(int i=0;i(); + if(error==null) error=infrastructure; + if(error==null) try { + var keys=new java.util.HashSet();var materialized=new java.util.HashSet(); + for(var ref:refs) { + if(blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源"); + if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择"); + } + var directory=catalog(); + for(var ref:refs) { + var source=resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,directory,destinations); + if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); + resolved.add(source); + } + } catch(RuntimeException failed) { + error=failed; + // 基础设施失败不重试、不变为空数据,延后到原需求访问位置抛出。 + if(!(failed instanceof ResponseStatusException status) || status.getStatusCode().value()>=500) infrastructure=failed; + } + result.add(new OrderGroup(refs,List.copyOf(resolved),error)); + } + return List.copyOf(result); + } /** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */ private List catalog() { if(directory==null) directory=AppointmentSourceService.this.catalog(); return directory; } } + public record OrderGroup(List references,List sources,RuntimeException error) { + /** 业务前置错误仍在原需求解析位置抛出,由原边界决定是否保留未就绪需求。 */ + public List requireSources() { if(error!=null) throw error;return sources; } + } /** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */ public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) { public Source requireSource() { if(error!=null) throw error; return source; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java index 7fc1d2530..0d8464783 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java @@ -117,7 +117,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); } } /** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */ - public RequirementReader openRequirements() { + public RequirementReader openRequirements(List inputs) { return new RequirementReader() { private RequirementReader reader; /** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */ @@ -125,7 +125,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { if(reader==null) { var provider=requirements.getIfAvailable(); if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); - reader=provider.openRequirements(); + reader=provider.openRequirements(inputs); } var result=reader.read(source,effectiveFlow); if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java index e2af776af..5912bbae3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java @@ -28,7 +28,7 @@ public interface AppointmentWorkFactGateway { SourcePage discover(String after, int size); ContainerSource container(String containerBookingId); /** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */ - RequirementReader openRequirements(); + RequirementReader openRequirements(List inputs); Map facts(List states); Owner owner(String id, Actor actor); Responsibility supplier(String id); @@ -43,5 +43,6 @@ public interface AppointmentWorkFactGateway { interface SupplierDirectory { Responsibility activeSupplier(String id); } /** 单次生成显式复用目录,不跨请求保存来源事实。 */ interface RequirementReader { List read(ContainerSource source,Flow effectiveFlow); } - interface RequirementProvider { RequirementReader openRequirements(); } + record RequirementInput(ContainerSource source,Flow flow) { } + interface RequirementProvider { RequirementReader openRequirements(List inputs); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java index 0f22f79b7..f668a6fe6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -36,7 +36,16 @@ public class AppointmentWorkSyncService { timing.next(SyncPhase.GENERATE); // 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。 var known=workingSet(); - var requirementReader=gateway.openRequirements(); + var requirementInputs=page.containers().stream().filter(source->{ + // 坏来源仍由下面原顺序校验并报错,不能在后页原料提示阶段抢先覆盖前柜错误。 + try { validateSource(source);return true; } catch(RuntimeException invalid) { return false; } + }).map(source->{ + var flow=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId()))) + .filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(source.establishedFlow()); + return new RequirementInput(source,flow); + }).filter(input->input.flow()!=Flow.UNKNOWN).toList(); + // 提示仅用于原料批量读取,下面逐柜 existing/activeFlow 仍根据最新工作集决定。 + var requirementReader=gateway.openRequirements(requirementInputs); int created=0; var ids=new HashSet(); for(var source:page.containers()) { validateSource(source); @@ -131,7 +140,7 @@ public class AppointmentWorkSyncService { } /** 命令入口仍在调用方守卫事务中即时加载工作集。 */ List generate(ContainerSource source,Flow flow,List existing,Actor actor) { - return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements()); + return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements(List.of(new RequirementInput(source,flow)))); } /** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */ private Map workingSet() { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java index 216503c41..7a116f3f4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -28,6 +28,28 @@ public interface InboundFbaAppointmentQueryMapper { " ORDER BY appointment_id DESC LIMIT 1001"}) List relatedBatchIds(@Param("sources") List sources); + record RequirementSource(int groupOrdinal,String customerId,String sourceKey,String destinationKey) { } + record RequirementBatch(int groupOrdinal,long appointmentId) { } + /** 按原需求组返回历史归属,失活明细也保留;每组第1001条用于检测超限。 */ + @Select(""" + + """) + List requirementBatches(@Param("sources") List sources); + @Select("") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index cec90b860..30b1f5498 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -34,6 +34,7 @@ public class InboundFbaAppointmentService { @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort receivingWork; @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges; + @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads; @Transactional(readOnly=true) public List calendar(LocalDate from,LocalDate to) { @@ -111,6 +112,42 @@ public class InboundFbaAppointmentService { if(ids.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"); return ids.isEmpty()?List.of():responses(appointmentMapper.selectBatchIds(ids)); } + /** 同页原料只读一次,历史归属按原需求组保留;响应解析延后至原访问顺序。 */ + public java.util.function.IntFunction> prepareRelatedBatchSnapshots(List> groups) { + var errors=new HashMap(); + var requested=new ArrayList(); + for(int i=0;i100 || refs.stream().anyMatch(ref->ref==null || clean(ref.customerId())==null || clean(ref.sourceKey())==null || clean(ref.destinationKey())==null)) { + errors.put(i,bad("每次最多读取100个完整原来源的关联批次"));continue; + } + final int group=i; + refs.stream().distinct().forEach(ref->requested.add(new InboundFbaAppointmentQueryMapper.RequirementSource(group,ref.customerId(),ref.sourceKey(),ref.destinationKey()))); + } + var groupIds=new HashMap>(); + for(int start=0;start=groups.size()) throw new IllegalStateException("历史关联返回了请求外需求组"); + groupIds.computeIfAbsent(match.groupOrdinal(),ignored->new TreeSet<>()).add(match.appointmentId()); + } + } + groupIds.forEach((group,ids)->{if(ids.size()>1000) errors.put(group,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"));}); + var ids=groupIds.entrySet().stream().filter(e->!errors.containsKey(e.getKey())).flatMap(e->e.getValue().stream()).distinct().sorted().toList(); + var rows=new LinkedHashMap(); + var items=new HashMap>(); + for(int start=0;startrows.put(row.getId(),row)); + requirementReads.items(batch).forEach(item->items.computeIfAbsent(item.getAppointmentId(),ignored->new ArrayList<>()).add(item)); + } + return group->{ + if(group<0 || group>=groups.size()) throw new IllegalArgumentException("需求组不属于本次读取范围"); + if(errors.containsKey(group)) throw errors.get(group); + var selected=groupIds.getOrDefault(group,Set.of()); + return rows.values().stream().filter(row->selected.contains(row.getId())).map(row->response(row,items.getOrDefault(row.getId(),List.of()))).toList(); + }; + } @Transactional(readOnly=true) public FbaAppointmentBatchContext batchContext(String key) { final long id; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java new file mode 100644 index 000000000..c25768b2e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.inboundplan.entity.*; +import com.baiyue.lastleg.inboundplan.mapper.*; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 需求生成的本域原料读取,历史归属不能从活动响应明细反推。 */ +@Repository +public class AppointmentRequirementReadRepository { + private final AppointmentWorkSourceMapper mapping; + private final InboundFbaAppointmentQueryMapper queries; + private final InboundFbaAppointmentMapper appointments; + private final InboundFbaAppointmentItemMapper items; + /** 保留本域 Mapper 与客户来源查询边界。 */ + public AppointmentRequirementReadRepository(AppointmentWorkSourceMapper mapping,InboundFbaAppointmentQueryMapper queries,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items) { + this.mapping=mapping;this.queries=queries;this.appointments=appointments;this.items=items; + } + /** 每柜独立上限由既有窗口查询保持。 */ + public List sources(List containers) { return containers.isEmpty()?List.of():mapping.containerSourcesBatch(containers); } + /** 分块只控制单条 SQL 输入,业务服务仍按需求组完整合并及核对上限。 */ + public List history(List sources) { return sources.isEmpty()?List.of():queries.requirementBatches(sources); } + /** 读取已确认关联的预约主记录。 */ + public List appointments(List ids) { return ids.isEmpty()?List.of():appointments.selectBatchIds(ids); } + /** 包括失活明细以维持取消与替代历史;响应过滤由业务服务执行。 */ + public List items(List ids) { return ids.isEmpty()?List.of():items.selectList(new LambdaQueryWrapper().in(InboundFbaAppointmentItem::getAppointmentId,ids).orderByAsc(InboundFbaAppointmentItem::getId)); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java index db8e65d91..6bab0f177 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java @@ -17,29 +17,85 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa private final AppointmentCargoVerificationService cargo; private final InboundFbaAppointmentService appointments; private final AppointmentWorkSourceMapper sourceMapping; + @jakarta.annotation.Resource private AppointmentRequirementReadRepository reads; public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) { this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping; } - /** 同次生成仅共享按需读取的目录;原业务数据继续逐需求核实。 */ - public RequirementReader openRequirements() { + /** 本次生成共享原料,实例不会跨同步页或独立命令保存。 */ + public RequirementReader openRequirements(List inputs) { return new RequirementReader() { - private AppointmentSourceService.ReadScope scope; - /** 直送和未建立尾程来源的需求不创建目录读取依赖。 */ + private Prepared prepared; + /** 原料提示不替代实际路线判断;未提示的真实需求按同一实现即时读取。 */ public List read(ContainerSource container,Flow flow) { - return requirements(container,flow,refs->{ - if(scope==null) scope=sources.openReadScope(); - return scope.resolveBatch(refs); - }); + if(flow==Flow.DIRECT) return original(container); + if(flow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); + if(prepared==null) { + var requested=new ArrayList<>(inputs);requested.add(new RequirementInput(container,flow)); + prepared=prepare(requested); + } + var data=prepared.containers.contains(container)?prepared:prepare(List.of(new RequirementInput(container,flow))); + return requirements(container,flow,data); } }; } + /** 私人地址仍以原客户仓需求表示,直送不增加尾程读取依赖。 */ + private List original(ContainerSource container) { + return container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); + } + private record Group(ContainerSource container,String requirementKey) { } + /** 只含本次原料及延后执行器,不缓存已经解析的业务响应。 */ + private static final class Prepared { + private final Set containers=new LinkedHashSet<>(); + private final Map> mapped=new HashMap<>(); + private final Map ordinals=new LinkedHashMap<>(); + private List sources=List.of(); + private RuntimeException mappingError,sourceError; + private java.util.function.IntFunction> history; + private java.util.function.Function inspection; + } + /** 先批量读取原料,组级业务错误在原需求访问位置抛出。 */ + private Prepared prepare(List inputs) { + var data=new Prepared(); + inputs.stream().filter(i->i.flow()==Flow.TRANSFER).map(RequirementInput::source).forEach(data.containers::add); + var ids=data.containers.stream().map(ContainerSource::containerBookingId).distinct().toList(); + try { + for(int start=0;startdata.mapped.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.source())); + } catch(RuntimeException error) { data.mappingError=error;return data; } + var groups=new ArrayList>(); + for(var container:data.containers) { + var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of()); + if(mapped.size()>1000) continue; + for(var requirement:original(container)) { + if(!"FBA".equals(requirement.destinationKind())) continue; + var inbound=requirement.requirementKey().startsWith("INBOUND:")?requirement.requirementKey().substring(8):null; + var orders=mapped.stream().filter(row->Objects.equals(row.sourceInboundId(),inbound)).toList(); + if(orders.isEmpty()) continue; + data.ordinals.put(new Group(container,requirement.requirementKey()),groups.size()); + groups.add(orders.stream().map(row->new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.id())).toList()); + } + } + if(groups.isEmpty()) return data; + try { data.sources=sources.openReadScope().resolveOrderGroups(groups); } + catch(RuntimeException error) { data.sourceError=error;return data; } + var historyGroups=data.sources.stream().map(group->group.error()==null?group.sources().stream().map(source->new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList():List.of()).toList(); + var valid=data.sources.stream().filter(group->group.error()==null).flatMap(group->group.sources().stream()).toList(); + if(valid.isEmpty()) return data; + try { data.history=appointments.prepareRelatedBatchSnapshots(historyGroups); } + catch(RuntimeException error) { data.history=ignored->{throw error;}; } + // 不提前解析货件,仍由原 history -> anyMatch 路径按实际访问顺序执行。 + try { data.inspection=cargo.prepareInspection(valid); } + catch(RuntimeException error) { data.inspection=ignored->{throw error;}; } + return data; + } /** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */ private List requirements(ContainerSource container,Flow effectiveFlow, - java.util.function.Function,List> resolver) { - var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); + Prepared data) { + var original=original(container); if(effectiveFlow==Flow.DIRECT) return original; if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); - var mapped=sourceMapping.containerSources(container.containerBookingId()); + if(data.mappingError!=null) throw data.mappingError; + var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of()); if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); var result=new LinkedHashMap(); var privateDispatches=original.stream().anyMatch(r->"CUSTOMER".equals(r.destinationKind()))?sourceMapping.privateDispatches(container.containerBookingId()):List.of(); @@ -50,9 +106,13 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa if(sourceOrders.isEmpty()) { result.put(requirement.requirementKey(),requirement); continue; } if("CUSTOMER".equals(requirement.destinationKind())) { customer(requirement,sourceOrders,privateDispatches,result);continue; } if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; } - var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList()); + if(sourceOrders.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); + if(data.sourceError!=null) throw data.sourceError; + int ordinal=data.ordinals.get(new Group(container,requirement.requirementKey())); + var group=data.sources.get(ordinal); + var refs=group.references(); List resolved; - try { resolved=resolver.apply(refs); } + try { resolved=group.requireSources(); } catch(org.springframework.web.server.ResponseStatusException error) { if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error; // Keep the complete physical obligation and its canonical source identities. The fact @@ -63,10 +123,10 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa continue; } var complete=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).toList(); - var history=appointments.relatedBatchSnapshots(complete); + var history=data.history.apply(ordinal); var active=history.stream().filter(AppointmentWorkRequirementProvider::active).toList(); // Unknown remaining quantity is still work, never a zero or an absent requirement. - boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); + boolean remainder=resolved.stream().map(data.inspection).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList(); if(remainder) { var restored=new LinkedHashSet<>(replaced); @@ -128,18 +188,6 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa result.put(batch,new Requirement(batch,batch,all.get(0).dispatchNo(),"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),refs.stream().distinct().toList(),List.copyOf(replacements),all.stream().map(d->new SourceReference(d.customerId(),d.sourceKey(),d.destinationKey())).distinct().toList())); } } - private List canonical(List input) { - if(input==null || input.isEmpty() || input.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); - var orders=input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("ORDER:")).map(s->{ - try { return new AppointmentExternalSourceGateway.OrderRef(s.customerId(),Long.parseLong(s.sourceKey().substring(6))); } - catch(RuntimeException error) { throw unavailable("Firstleg原订单关联无效",error); } - }).toList(); - var result=new ArrayList(); - if(!orders.isEmpty()) result.addAll(sources.canonicalOrders(orders)); - input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("BOOKING:")).map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).forEach(result::add); - if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING"); - return result.stream().distinct().toList(); - } /** 已取消且没有当前实物分配的记录只属历史;取消但仍有货物的批次继续产生需求。 */ private static boolean active(FbaAppointmentResponse batch) { if(batch.replacementBatchKeys()!=null && !batch.replacementBatchKeys().isEmpty()) return false; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java index ea89cc6e8..8eced830c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java @@ -11,6 +11,22 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentCargoVerificationBatchTest { + /** 预取只读原料,未被 anyMatch 访问的坏 JSON 不提前报错;下一次准备仍重新读取。 */ + @Test void preparedAuthorizedSourcesKeepShortCircuitAndBoundedReads() { + var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); + var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); + org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentCargoReadRepository(mapper)); + var resolved=IntStream.rangeClosed(1,201).mapToObj(i->new AppointmentSourceService.Source("ORDER:"+i,"C",(long)i,"IN",null,"CNT","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of())).toList(); + var broken=new AppointmentCargoVerification();broken.setCustomerId("C");broken.setSourceKey("ORDER:2");broken.setPackageCount(10);broken.setShipmentReferencesJson("broken-json"); + when(mapper.latestBatch(anyList())).thenAnswer(c->c.>getArgument(0).stream().anyMatch(r->r.sourceKey().equals("ORDER:2"))?List.of(broken):List.of()); + var read=service.prepareInspection(resolved); + assertTrue(resolved.stream().map(read).anyMatch(c->c.availablePackages()!=null && c.availablePackages()>0)); + assertThrows(RuntimeException.class,()->read.apply(resolved.get(1))); + assertEquals(10L,read.apply(resolved.get(200)).availablePackages()); + verify(mapper,times(3)).latestBatch(anyList());verify(mapper,times(3)).allocatedBatch(anyList());verifyNoInteractions(sources); + service.prepareInspection(resolved);verify(mapper,times(6)).latestBatch(anyList());verify(mapper,times(6)).allocatedBatch(anyList()); + assertThrows(IllegalArgumentException.class,()->read.apply(new AppointmentSourceService.Source("ORDER:1","OTHER",1L,null,null,null,"100",null,"FBA:ONT8","ONT8",10,"v1",true,List.of()))); + } @Test void inspectionUsesTwoBoundedBatchesAndRetainsPerSourcePrerequisitesAndAllocations() { var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index 13e21e691..be51c5bf3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -146,6 +146,10 @@ class AppointmentPlanningAuthorizationTest { @Bean AppointmentCargoVerificationController verificationController(AppointmentCargoVerificationService service) {return new AppointmentCargoVerificationController(service);} @Bean AppointmentSourceCreateController sourceCreateController() { return new AppointmentSourceCreateController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class)); } @Bean AppointmentCoverageMapper destinationFacts() { return mock(AppointmentCoverageMapper.class); } + /** 权限测试只模拟业务服务,补齐其新增只读端口依赖而不触发数据库。 */ + @Bean AppointmentSourceReadRepository sourceReads() { return mock(AppointmentSourceReadRepository.class); } + /** 本测试验证入口权限,不执行货物核实原料读取。 */ + @Bean AppointmentCargoReadRepository cargoReads() { return mock(AppointmentCargoReadRepository.class); } @Bean AppointmentBatchPreviewService batchPreviews() { return mock(AppointmentBatchPreviewService.class); } @Bean AppointmentBatchPreviewController batchPreviewController(AppointmentBatchPreviewService service) { return new AppointmentBatchPreviewController(service); } @Bean AppointmentContainerService containers() { return mock(AppointmentContainerService.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java index 3082da1b6..ee5ff8d4d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java @@ -12,6 +12,56 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentSourceServiceTest { + /** 同页共享原料但保留逐组权限、重复和前置错误,结果等于各组独立严格读取。 */ + @Test void groupedOrderResolutionSharesReadsWithoutSharingValidationOrFailures() { + var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); + var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); + var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); + org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentSourceReadRepository(orders,shipping,mock(AppointmentCoverageMapper.class))); + var a=new LastlegOrder();a.setId(1L);a.setCustomerId("A");a.setOrderType("FIRST_LEG");a.setSourceContainerBookingId("C1");a.setOverseasWarehouseId("100");a.setDestinationType("FBA");a.setFbaWarehouseCode("ONT8");a.setPackageCount(10); + var b=new LastlegOrder();b.setId(2L);b.setCustomerId("B");b.setOrderType("FIRST_LEG");b.setSourceContainerBookingId("C2");b.setOverseasWarehouseId("100");b.setDestinationType("FBA");b.setFbaWarehouseCode("ONT8");b.setPackageCount(20); + when(orders.selectBatchIds(anyCollection())).thenAnswer(c->List.of(a,b).stream().filter(o->c.>getArgument(0).contains(o.getId())).toList()); + var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40); + when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate)); + when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); + var ref=new AppointmentExternalSourceGateway.OrderRef("A",1L); + var groups=List.of(List.of(ref),List.of(ref),List.of(new AppointmentExternalSourceGateway.OrderRef("B",2L)),List.of(new AppointmentExternalSourceGateway.OrderRef("OTHER",1L)),List.of(ref,ref)); + var actual=service.openReadScope().resolveOrderGroups(groups); + assertEquals(actual.get(0).requireSources(),actual.get(1).requireSources()); + assertEquals("货物尚无已开船或后续实际船节点",((ResponseStatusException)actual.get(2).error()).getReason()); + assertEquals(404,((ResponseStatusException)actual.get(3).error()).getStatusCode().value()); + assertEquals(503,((ResponseStatusException)actual.get(4).error()).getStatusCode().value()); + assertEquals("来源键必须明确为ORDER或BOOKING",((ResponseStatusException)actual.get(4).error()).getReason()); + verify(orders,times(1)).selectBatchIds(anyCollection());verify(shipping,times(1)).selectBatchIds(anyCollection());verify(external,times(1)).resolveOrders(anyList());verify(warehouse,times(1)).catalog("token"); + assertEquals(service.resolveBatch(actual.get(0).references()),actual.get(0).requireSources()); + assertEquals(400,((ResponseStatusException)service.openReadScope().resolveOrderGroups(List.of(List.of(new AppointmentExternalSourceGateway.OrderRef("x".repeat(129),1L)))).get(0).error()).getStatusCode().value()); + var failure=new IllegalStateException("directory unavailable");when(warehouse.catalog("token")).thenThrow(failure); + clearInvocations(warehouse); + var failed=service.openReadScope().resolveOrderGroups(List.of(List.of(ref),List.of(ref))); + assertEquals(503,((ResponseStatusException)failed.get(0).error()).getStatusCode().value());assertSame(failed.get(0).error(),failed.get(1).error());verify(warehouse,times(1)).catalog("token"); + service.openReadScope().resolveOrderGroups(List.of(List.of(ref)));verify(warehouse,times(2)).catalog("token"); + } + /** 不同订单规范化成同登记仍合一,不同登记重复同实物订单仍拒绝。 */ + @Test void groupedCanonicalAliasesKeepOriginalDistinctAndMaterializedRules() { + var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class);var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); + var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); + org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentSourceReadRepository(orders,shipping,mock(AppointmentCoverageMapper.class))); + var order=new LastlegOrder();order.setId(1L);order.setCustomerId("A");order.setOverseasWarehouseId("100");order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(10); + when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order)); + when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); + var booking=new AppointmentExternalSourceGateway.Source("A","B1",null,"RECEIVED","100","FBA:ONT8",1L,10,1L); + when(external.resolveOrders(anyList())).thenReturn(List.of(booking,new AppointmentExternalSourceGateway.Source("A","B1",null,"RECEIVED","100","FBA:ONT8",2L,10,1L))); + when(external.resolve(anyList())).thenReturn(List.of(booking)); + var input=List.of(List.of(new AppointmentExternalSourceGateway.OrderRef("A",1L),new AppointmentExternalSourceGateway.OrderRef("A",2L))); + assertEquals(1,service.openReadScope().resolveOrderGroups(input).get(0).requireSources().size()); + var alias2=new AppointmentExternalSourceGateway.Source("A","B2",null,"RECEIVED","100","FBA:ONT8",2L,10,1L); + when(external.resolveOrders(anyList())).thenReturn(List.of(booking,alias2)); + when(external.resolve(anyList())).thenReturn(List.of(booking,new AppointmentExternalSourceGateway.Source("A","B2",null,"RECEIVED","100","FBA:ONT8",1L,10,1L))); + assertEquals("同一货物存在重复来源,请保留原登记来源",((ResponseStatusException)service.openReadScope().resolveOrderGroups(input).get(0).error()).getReason()); + when(external.resolveOrders(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","x".repeat(161),null,"RECEIVED","100","FBA:ONT8",1L,10,1L))); + var invalid=service.openReadScope().resolveOrderGroups(List.of(input.get(0).subList(0,1))).get(0); + assertEquals("请选择完整的真实货源",((ResponseStatusException)invalid.error()).getReason()); + } /** 目录失败不缓存,成功只在当前作用域复用;来源、客户和目的地仍逐次校验。 */ @Test void explicitScopeRetriesFailedCatalogAndNeverCachesSourceOrCrossScopeDirectory() { var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java index f3b02809a..dc08f4468 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java @@ -103,10 +103,10 @@ class AppointmentWorkFactAdapterTest { var business=(org.springframework.beans.factory.ObjectProvider)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class); org.mockito.Mockito.when(business.orderedStream()).thenAnswer(c->java.util.stream.Stream.empty()); var adapter=new AppointmentWorkFactAdapter(null,business,null,providers,null,"token"); - var unused=adapter.openRequirements();org.mockito.Mockito.verifyNoInteractions(providers); + var unused=adapter.openRequirements(java.util.List.of());org.mockito.Mockito.verifyNoInteractions(providers); var source=new ContainerSource("1","CNT",java.time.Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of()); var f=new WorkTestFixture(); - org.mockito.Mockito.when(f.gateway.openRequirements()).thenAnswer(c->adapter.openRequirements()); + org.mockito.Mockito.when(f.gateway.openRequirements(java.util.List.of())).thenAnswer(c->adapter.openRequirements(java.util.List.of())); org.mockito.Mockito.when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.sync.synchronize(new SyncRequest(null,10));assertEquals(Type.ROUTE,f.store.all().get(0).getType()); org.mockito.Mockito.verifyNoInteractions(providers); @@ -114,14 +114,14 @@ class AppointmentWorkFactAdapterTest { var provider=org.mockito.Mockito.mock(RequirementProvider.class); var delegate=org.mockito.Mockito.mock(RequirementReader.class); org.mockito.Mockito.when(providers.getIfAvailable()).thenReturn(provider); - org.mockito.Mockito.when(provider.openRequirements()).thenReturn(delegate); + org.mockito.Mockito.when(provider.openRequirements(java.util.List.of())).thenReturn(delegate); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); org.mockito.Mockito.when(delegate.read(source,Flow.TRANSFER)).thenReturn(List.of(requirement),List.of(requirement),List.of()); - var reader=adapter.openRequirements(); + var reader=adapter.openRequirements(java.util.List.of()); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); assertThrows(ResponseStatusException.class,()->reader.read(source,Flow.TRANSFER)); - org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(); + org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(java.util.List.of()); } @Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() { var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java index af142038b..172367892 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java @@ -79,7 +79,7 @@ class AppointmentWorkPersistenceTest { var requirement=new AppointmentWorkFactGateway.Requirement(AppointmentWorkPolicy.customerRequirement(later.batchKey(),"B"),later.batchKey(),"AP-2","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8"))); var source=new AppointmentWorkFactGateway.ContainerSource("1","CNT",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement)); var facts=org.mockito.Mockito.mock(AppointmentWorkFactGateway.class); - org.mockito.Mockito.when(facts.openRequirements()).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); + org.mockito.Mockito.when(facts.openRequirements(org.mockito.ArgumentMatchers.anyList())).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); tx.executeWithoutResult(status->new AppointmentWorkSyncService(store,facts).generate(source,Flow.TRANSFER,List.of(),ACTOR)); assertEquals(persisted.owner(),store.get(key).getOwner());assertEquals(persisted.nextFollowupDate(),store.get(key).getFollowupDate()); var third=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:3","B"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index 6ac989b15..670828b74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -12,7 +12,7 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkSyncServiceTest { - /** 真实需求提供链百柜只读一次目录,下页和命令生成独立重读;来源查询仍逐次执行。 */ + /** 百柜同页批量读取来源原料,下页与独立命令重读,生成状态保持不变。 */ @Test void hundredFbaContainersShareOnlyCatalogWithinOneGenerationScope() { var f=new WorkTestFixture(); var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); @@ -31,6 +31,7 @@ class AppointmentWorkSyncServiceTest { var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId(id);gate.setNodeSequence(40);return gate; }).toList()); var sources=new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService(orders,shipping,external,warehouse,"token"); + org.springframework.test.util.ReflectionTestUtils.setField(sources,"readRepository",new com.baiyue.lastleg.appointmentplanning.AppointmentSourceReadRepository(orders,shipping,mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.class))); var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class); var allocation=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.Context.class); when(allocation.availablePackages()).thenReturn(1L);when(cargo.inspect(any(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source.class))).thenReturn(allocation); @@ -39,13 +40,18 @@ class AppointmentWorkSyncServiceTest { new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",1));}); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo, mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class),mapping); - when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); + wireBatches(provider,cargo,mapping); + when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0))); var rows=java.util.stream.IntStream.rangeClosed(1,100).mapToObj(i->new ContainerSource(""+i,"CNT"+i, Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null, List.of(new Requirement("INBOUND:"+i,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(1)).catalog("token"); - verify(orders,times(100)).selectBatchIds(anyCollection()); + verify(orders,times(1)).selectBatchIds(anyCollection()); + verify(external,times(1)).resolveOrders(anyList()); + var reads=(com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository)org.springframework.test.util.ReflectionTestUtils.getField(provider,"reads"); + verify(reads,times(1)).sources(argThat(ids->ids.size()==100 && new HashSet<>(ids).size()==100)); + verify(cargo,times(1)).prepareInspection(argThat(values->values.size()==100)); var before=f.store.all().stream().map(f.store::write).toList(); f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(2)).catalog("token"); assertEquals(before,f.store.all().stream().map(f.store::write).toList()); @@ -53,7 +59,27 @@ class AppointmentWorkSyncServiceTest { verify(warehouse,times(3)).catalog("token"); f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR); verify(warehouse,times(4)).catalog("token"); - verify(orders,times(202)).selectBatchIds(anyCollection()); + verify(orders,times(4)).selectBatchIds(anyCollection()); + verify(reads,times(4)).sources(anyList());verify(cargo,times(4)).prepareInspection(anyList()); + } + + /** 预取提示不能抢先解析后方坏来源,原循环仍拒绝且不能发布扫描完成。 */ + @Test void invalidSourcesRemainOriginalOrderedFailuresInsteadOfBeingIgnoredByPreparation() { + var valid=new ContainerSource("1","CNT",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()))); + var missingId=new ContainerSource(null,"CNT",valid.departedAt(),null,null,"v1",Flow.TRANSFER,null,valid.requirements()); + var missingRequirements=new ContainerSource("2","CNT2",valid.departedAt(),null,null,"v1",Flow.TRANSFER,null,null); + for(var rows:List.of(java.util.Arrays.asList((ContainerSource)null),List.of(missingId),java.util.Arrays.asList(valid,null),List.of(valid,missingRequirements))) { + var f=new WorkTestFixture(); + when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); + when(f.gateway.openRequirements(anyList())).thenAnswer(c->{ + var hints=c.>getArgument(0); + assertEquals(rows.get(0)==valid?List.of(new RequirementInput(valid,Flow.TRANSFER)):List.of(),hints); + return (RequirementReader)(source,flow)->source.requirements(); + }); + var error=assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,100))); + assertEquals(503,error.getStatusCode().value());assertEquals("已开船来源缺少身份或真实开船依据",error.getReason()); + verify(f.mapper,never()).saveGuard(any()); + } } /** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */ @@ -103,8 +129,9 @@ class AppointmentWorkSyncServiceTest { var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class); var scope=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.ReadScope.class); when(sources.openReadScope()).thenReturn(scope); - when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); + when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.>>getArgument(0).stream().map(group->{var r=sources.canonicalOrders(group);return new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.OrderGroup(r,sources.resolveBatch(r),null);}).toList()); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + wireBatches(provider,cargo,mapping); when(mapping.containerSources("1")).thenReturn(List.of(new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(1L,"A","1","1","FBA","ONT8",1),new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(2L,"B","2","1","FBA","ONT8",1))); when(sources.canonicalOrders(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList()); when(sources.resolveBatch(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source(r.sourceKey(),r.customerId(),Long.valueOf(r.sourceKey().substring(6)),null,null,"CNT",null,null,r.destinationKey(),"ONT8",1,"v1",true,List.of())).toList()); @@ -117,7 +144,7 @@ class AppointmentWorkSyncServiceTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(snapshot)); var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),refs); var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root)); - when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); + when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0))); when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.guard.setRequirementVersion(1); f.sync.synchronize(new SyncRequest(null,10)); @@ -373,4 +400,13 @@ class AppointmentWorkSyncServiceTest { assertFalse(event.getFormattedMessage().contains("private-source-detail"));assertNull(event.getThrowableProxy()); } finally { logger.detachAppender(appender);appender.stop(); } } + /** 用既有原事实模拟组装回归,百柜来源查询由真实来源服务计数验证。 */ + private static void wireBatches(com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider provider,com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService cargo,com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper mapping) { + var reads=mock(com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository.class); + when(reads.sources(anyList())).thenAnswer(c->c.>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList()); + org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads); + when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect); + var appointments=(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService)org.springframework.test.util.ReflectionTestUtils.getField(provider,"appointments"); + when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.>>getArgument(0);return (java.util.function.IntFunction>)(i->appointments.relatedBatchSnapshots(groups.get(i)));}); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java index bc953d55a..e621910b4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java @@ -38,7 +38,7 @@ final class WorkTestFixture { when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap(); for(var state:c.>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; }); when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope")); when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲")); - when(gateway.openRequirements()).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); + when(gateway.openRequirements(anyList())).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); } AppointmentWorkState state(String container,Type type,Flow flow,String requirement) { var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index 48402799f..9471a3521 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -259,6 +259,12 @@ class FbaAppointmentPersistenceTest { @Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;} @Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();} @Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);} + /** 新增只读原料端口仍接实际 Mapper;本测试未涉及船节点查询。 */ + @Bean AppointmentSourceReadRepository sourceReads(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,AppointmentCoverageMapper facts){return new AppointmentSourceReadRepository(orders,mock(com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper.class),facts);} + /** 核实原料沿真实持久 Mapper 读取,不替代事务断言。 */ + @Bean AppointmentCargoReadRepository cargoReads(AppointmentCargoVerificationMapper mapper){return new AppointmentCargoReadRepository(mapper);} + /** 历史预约原料沿本测试真实主单、明细及查询 Mapper 读取。 */ + @Bean com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads(InboundFbaAppointmentQueryMapper query,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items){return new com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository(mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class),query,appointments,items);} @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java new file mode 100644 index 000000000..36e69f704 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java @@ -0,0 +1,244 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; +import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper.RequirementSource; +import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.plugin.Intercepts; +import org.apache.ibatis.plugin.Invocation; +import org.apache.ibatis.plugin.Signature; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.ResultHandler; +import org.apache.ibatis.session.RowBounds; +import org.apache.ibatis.session.SqlSession; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Objects; +import java.util.UUID; +import java.util.ArrayList; +import java.util.Collections; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AppointmentRequirementReadRepositoryTest { + /** 关联依据是原客户、来源及目的仓,不能因失活、取消或响应明细变空而消失。 */ + @Test + void historyKeepsInactiveAndReplacedAssociationsWithoutCrossCustomerOrDestinationLeak() throws Exception { + try (var fixture=new Fixture()) { + fixture.appointment(10,"CANCELLED",null,null); + fixture.appointment(20,"CANCELLED","[\"AP-30\"]","[\"FBA_APPOINTMENT:30\"]"); + fixture.appointment(30,"PENDING",null,null); + fixture.item(101,10,"A","ORDER:1","ABE4",false); + fixture.item(201,20,"A","ORDER:1","ABE4",false); + fixture.item(301,30,"A","ORDER:1","ABE4",true); + fixture.item(302,30,"B","ORDER:2","ABE4",true); + fixture.item(401,40,"B","ORDER:1","ABE4",true); + fixture.item(501,50,"A","ORDER:1","ONT8",true); + var refs=List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), + new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), + new RequirementSource(1,"B","ORDER:2","FBA:ABE4"), + new RequirementSource(2,"B","ORDER:1","FBA:ABE4")); + var history=fixture.repository.history(refs); + assertThat(history).filteredOn(row->row.groupOrdinal()==0).extracting(row->row.appointmentId()).containsExactly(30L,20L,10L); + assertThat(history).filteredOn(row->row.groupOrdinal()==1).extracting(row->row.appointmentId()).containsExactly(30L); + assertThat(history).filteredOn(row->row.groupOrdinal()==2).extracting(row->row.appointmentId()).containsExactly(40L); + assertThat(fixture.repository.items(List.of(10L,20L,30L))).hasSize(4) + .filteredOn(row->!Boolean.TRUE.equals(row.getActive())).extracting(InboundFbaAppointmentItem::getId).containsExactly(101L,201L); + assertThat(fixture.repository.appointments(List.of(20L))).singleElement().satisfies(row->{ + assertThat(row.getReplacementNosJson()).isEqualTo("[\"AP-30\"]"); + assertThat(row.getReplacementKeysJson()).isEqualTo("[\"FBA_APPOINTMENT:30\"]"); + }); + var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of( + List.of(new SourceRef("A","ORDER:1","FBA:ABE4")),List.of(new SourceRef("B","ORDER:2","FBA:ABE4")))); + assertThat(prepared.apply(0)).extracting(row->row.id()).containsExactly(10L,20L,30L); + assertThat(prepared.apply(0)).filteredOn(row->row.id()==10).singleElement().satisfies(row->{ + assertThat(row.appointmentStatus()).isEqualTo("CANCELLED"); + assertThat(row.items()).isEmpty(); + }); + assertThat(prepared.apply(0)).filteredOn(row->row.id()==20).singleElement().satisfies(row->{ + assertThat(row.items()).hasSize(1); + assertThat(row.replacementBatchKeys()).containsExactly("FBA_APPOINTMENT:30"); + }); + assertThat(prepared.apply(1)).singleElement().satisfies(row->{ + assertThat(row.id()).isEqualTo(30L); + assertThat(row.items()).extracting(item->item.customerId()).containsExactly("A","B"); + }); + } + } + + /** SQL 对每组各保留第1001条哨兵,不能以所有组总数截断历史。 */ + @Test + void windowLimitIsPerRequirementGroupAndDuplicateReferencesDoNotUseUpTheLimit() throws Exception { + try (var fixture=new Fixture()) { + for(int i=1;i<=1002;i++)fixture.item(i,i,"A","ORDER:1","ABE4",i%2==0); + for(int i=1;i<=1000;i++)fixture.item(10000+i,20000+i,"B","ORDER:2","ABE4",true); + var rows=fixture.repository.history(List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), + new RequirementSource(0,"A","ORDER:1","FBA:ABE4"),new RequirementSource(1,"B","ORDER:2","FBA:ABE4"))); + assertThat(rows).hasSize(2001); + assertThat(rows).filteredOn(row->row.groupOrdinal()==0).hasSize(1001) + .extracting(row->row.appointmentId()).startsWith(1002L).endsWith(2L).doesNotContain(1L); + assertThat(rows).filteredOn(row->row.groupOrdinal()==1).hasSize(1000); + } + } + + /** 空输入直接结束,不向数据库提交无效IN列表。 */ + @Test + void emptyInputsReturnNoRows() throws Exception { + try (var fixture=new Fixture()) { + assertThat(fixture.repository.history(List.of())).isEmpty(); + assertThat(fixture.repository.appointments(List.of())).isEmpty(); + assertThat(fixture.repository.items(List.of())).isEmpty(); + assertThat(fixture.repository.sources(List.of())).isEmpty(); + } + } + + /** 100个来源和200个预约的分块不会退化成逐组SQL,预备结果重读不再访问数据库。 */ + @Test + void manyGroupsUseBoundedRealQueriesAndPreparedResponsesDoNotReadAgain() throws Exception { + try(var fixture=new Fixture()) { + var groups=new ArrayList>(); + for(int i=1;i<=201;i++) { + fixture.appointment(i,"PENDING",null,null); + fixture.item(i,i,"A","ORDER:"+i,"ABE4",true); + groups.add(List.of(new SourceRef("A","ORDER:"+i,"FBA:ABE4"))); + } + var prepared=fixture.service.prepareRelatedBatchSnapshots(groups); + for(int i=0;i<201;i++)assertThat(prepared.apply(i)).extracting(row->row.id()).containsExactly((long)i+1); + assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(3); + assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(2); + assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(2); + int before=fixture.counter.statements.size(); + assertThat(prepared.apply(0)).hasSize(1); + assertThat(fixture.counter.statements).hasSize(before); + } + } + + /** 同一需求的来源跨SQL分块时,相同预约只归属一次,空来源组仍独立。 */ + @Test + void oneGroupCrossingTheSourceChunkBoundaryDeduplicatesItsOriginalAppointment() throws Exception { + try(var fixture=new Fixture()) { + fixture.appointment(30,"PENDING",null,null); + fixture.item(301,30,"A","ORDER:1","ABE4",true); + fixture.item(302,30,"A","ORDER:2","ABE4",true); + var first=new ArrayList(); + for(int i=0;i<99;i++)first.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4")); + var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of(first,List.of( + new SourceRef("A","ORDER:1","FBA:ABE4"),new SourceRef("A","ORDER:2","FBA:ABE4")),List.of())); + assertThat(prepared.apply(0)).isEmpty(); + assertThat(prepared.apply(1)).singleElement().satisfies(row->{ + assertThat(row.id()).isEqualTo(30L);assertThat(row.items()).hasSize(2); + }); + assertThat(prepared.apply(2)).isEmpty(); + assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2); + assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(1); + } + } + + /** 每组1000条允许,1001条和101个原来源仅在访问本组时拒绝,不拖垮其他组。 */ + @Test + void groupLimitsAreCheckedAfterCompleteMergeAndErrorsStayWithTheirOriginalGroup() throws Exception { + try(var fixture=new Fixture()) { + for(int i=1;i<=1000;i++) { + fixture.appointment(i,"PENDING",null,null); + fixture.item(i,i,"A","ORDER:1","ABE4",true); + } + for(int i=1;i<=1001;i++)fixture.item(10000+i,20000+i,"B",i<=600?"ORDER:2":"ORDER:3","ABE4",false); + var validGroup=new ArrayList(); + validGroup.add(new SourceRef("A","ORDER:1","FBA:ABE4")); + for(int i=0;i<98;i++)validGroup.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4")); + // 超限组的600条与401条来自两个SQL块,必须合并后才能准确拒绝。 + var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of( + validGroup, + List.of(new SourceRef("B","ORDER:2","FBA:ABE4"),new SourceRef("B","ORDER:3","FBA:ABE4")), + Collections.nCopies(101,new SourceRef("A","ORDER:1","FBA:ABE4")))); + assertThat(prepared.apply(0)).hasSize(1000); + assertThatThrownBy(()->prepared.apply(1)).isInstanceOfSatisfying(ResponseStatusException.class, + error->assertThat(error.getStatusCode().value()).isEqualTo(503)); + assertThatThrownBy(()->prepared.apply(2)).isInstanceOfSatisfying(ResponseStatusException.class, + error->assertThat(error.getStatusCode().value()).isEqualTo(400)); + assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(5); + assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(5); + assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2); + } + } + + @Intercepts(@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class})) + private static final class QueryCounter implements Interceptor { + final List statements=new ArrayList<>(); + /** 记录真实Mapper执行入口,调用仍继续进入原SQL执行器。 */ + @Override public Object intercept(Invocation invocation) throws Throwable { + statements.add(((MappedStatement)invocation.getArgs()[0]).getId());return invocation.proceed(); + } + /** 按具体Mapper语句核对批量读取次数。 */ + long count(String suffix) { return statements.stream().filter(id->id.endsWith(suffix)).count(); } + } + + private static final class Fixture implements AutoCloseable { + final JdbcTemplate jdbc; + final SqlSession session; + final AppointmentRequirementReadRepository repository; + final InboundFbaAppointmentService service; + final QueryCounter counter=new QueryCounter(); + + /** 只在独立H2内使用基线表,并装配真实生产Mapper。 */ + Fixture() throws Exception { + var datasource=new JdbcDataSource(); + datasource.setURL("jdbc:h2:mem:requirement-history-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + jdbc=new JdbcTemplate(datasource); + String baseline; + try(var input=getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8); + } + for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { + int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" ("); + assertThat(start).isGreaterThanOrEqualTo(0); + jdbc.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";")); + } + var config=new MybatisConfiguration(); + config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),datasource)); + config.setMapUnderscoreToCamelCase(true); + config.addInterceptor(counter); + config.addMapper(AppointmentWorkSourceMapper.class); + config.addMapper(InboundFbaAppointmentQueryMapper.class); + config.addMapper(InboundFbaAppointmentMapper.class); + config.addMapper(InboundFbaAppointmentItemMapper.class); + session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(); + repository=new AppointmentRequirementReadRepository(session.getMapper(AppointmentWorkSourceMapper.class), + session.getMapper(InboundFbaAppointmentQueryMapper.class),session.getMapper(InboundFbaAppointmentMapper.class), + session.getMapper(InboundFbaAppointmentItemMapper.class)); + service=new InboundFbaAppointmentService(); + ReflectionTestUtils.setField(service,"requirementReads",repository); + ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules()); + } + + /** 建立真实预约头,取消和替代分别保留原状态与去向。 */ + void appointment(long id,String status,String replacementNos,String replacementKeys) { + jdbc.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by,appointment_status,replacement_nos_json,replacement_keys_json) VALUES(?,?,'WH','ABE4','TEST',?,?,?)", + id,"AP-"+id,status,replacementNos,replacementKeys); + } + + /** 建立原来源历史,不以活动标记代替来源归属。 */ + void item(long id,long appointment,String customer,String source,String destination,boolean active) { + jdbc.update("INSERT INTO ll_inbound_fba_appointment_item(id,appointment_id,customer_id,source_key,fba_warehouse_code,active,package_count) VALUES(?,?,?,?,?,?,1)", + id,appointment,customer,source,destination,active?1:0); + } + + /** 测试结束关闭本地会话,不接触任何共享数据库。 */ + @Override public void close() { session.close(); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java index 3d5c93c7b..a0eb47036 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java @@ -11,11 +11,32 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; class AppointmentWorkRequirementProviderTest { + /** 一组前置未就绪只保留自己的原义务,同页其他客户的需求与独立读取一致。 */ + @Test void pagePreparationKeepsUnresolvedGroupsLocalAndMatchesIndependentReads() { + var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); + var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + when(mapping.containerSources(anyString())).thenAnswer(c->{String id=c.getArgument(0);return List.of(new AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",10));}); + when(sources.canonicalOrders(anyList())).thenAnswer(c->c.>getArgument(0).stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList()); + when(sources.resolveBatch(anyList())).thenAnswer(c->{ + var ref=c.>getArgument(0).get(0); + if(ref.sourceKey().equals("ORDER:1")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"货物尚无已开船或后续实际船节点"); + return List.of(new AppointmentSourceService.Source(ref.sourceKey(),ref.customerId(),2L,"IN2",null,"CNT2","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of())); + }); + var available=mock(AppointmentCargoVerificationService.Context.class);when(available.availablePackages()).thenReturn(10L);when(cargo.inspect(any(AppointmentSourceService.Source.class))).thenReturn(available); + var containers=List.of("1","2").stream().map(id->new ContainerSource(id,"CNT"+id,null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:"+id,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); + var page=provider.openRequirements(containers.stream().map(c->new RequirementInput(c,Flow.TRANSFER)).toList()); + var first=page.read(containers.get(0),Flow.TRANSFER);var second=page.read(containers.get(1),Flow.TRANSFER); + assertEquals(List.of(new SourceReference("C1","ORDER:1",null)),first.get(0).sources()); + assertEquals(List.of(new SourceReference("C2","ORDER:2","FBA:ONT8")),second.get(0).sources()); + assertEquals(first,provider.openRequirements(List.of()).read(containers.get(0),Flow.TRANSFER)); + assertEquals(second,provider.openRequirements(List.of()).read(containers.get(1),Flow.TRANSFER)); + } /** 取消历史没有活动货物时退役旧事项,仍按权威分配保留新批次和未分配余量。 */ @Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -33,29 +54,29 @@ class AppointmentWorkRequirementProviderTest { when(current.batchKey()).thenReturn("FBA_APPOINTMENT:2");when(current.appointmentNo()).thenReturn("AP-2"); when(current.fbaWarehouseCode()).thenReturn("ONT8");when(current.items()).thenReturn(List.of(item));when(current.replacementBatchKeys()).thenReturn(List.of()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled,current)); - var actual=provider.openRequirements().read(container,Flow.TRANSFER); + var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); when(allocation.availablePackages()).thenReturn(20L); - actual=provider.openRequirements().read(container,Flow.TRANSFER); + actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1|CUSTOMER:C"),actual.get(0).replacesRequirementKeys()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled)); when(allocation.availablePackages()).thenReturn(60L); - actual=provider.openRequirements().read(container,Flow.TRANSFER); + actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); // 非取消的空批次仍属缺失事实,不能静默跳过。 when(cancelled.appointmentStatus()).thenReturn("PENDING"); - assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)).getStatusCode().value()); + assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER)).getStatusCode().value()); } @Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -72,73 +93,87 @@ class AppointmentWorkRequirementProviderTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch)); for(var status:List.of("CANCELLED","FAILED")) { when(batch.appointmentStatus()).thenReturn(status); - var result=provider.openRequirements().read(container,Flow.TRANSFER); + var result=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"),result.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),result.get(0).sources()); assertTrue(result.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1")); } when(allocation.availablePackages()).thenReturn(20L); - assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2")); - assertEquals(List.of("INBOUND:1"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); } @Test void unbookableSourceKeepsItsPhysicalRequirementWhileAuthorityFailuresStillAbort() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",4))); var refs=List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null)); when(sources.canonicalOrders(anyList())).thenReturn(refs); when(sources.resolveBatch(refs)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约")); - var actual=provider.openRequirements().read(container,Flow.TRANSFER); + var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(1,actual.size());assertEquals("INBOUND:1",actual.get(0).requirementKey()); assertEquals(List.of(new SourceReference("C","ORDER:11",null)),actual.get(0).sources()); verifyNoInteractions(cargo,appointments); doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对")).when(sources).resolveBatch(refs); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER)); } @Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","PRIVATE",null,10))); var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4); when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch)); - var manifest=provider.openRequirements().read(container,Flow.TRANSFER); + var manifest=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList()); assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey()); when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4))); - var restored=provider.openRequirements().read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); + var restored=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); verifyNoInteractions(sources,cargo,appointments); } @Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() { var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + scoped(sources);var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement)); - assertEquals(List.of(requirement),provider.openRequirements().read(source,Flow.DIRECT)); + assertEquals(List.of(requirement),provider.openRequirements(java.util.List.of()).read(source,Flow.DIRECT)); verifyNoInteractions(sources,cargo,appointments,mapping); } /** 无尾程映射时保留原需求,不读取或依赖仓库目录。 */ @Test void missingLastlegSourcesDoNotOpenDirectoryScope() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + var provider=provider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); - var reader=provider.openRequirements();verifyNoInteractions(sources,cargo,appointments,mapping); + var reader=provider.openRequirements(java.util.List.of());verifyNoInteractions(sources,cargo,appointments,mapping); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));verifyNoInteractions(sources,cargo,appointments); } + /** 旧业务场景继续模拟相同原事实,查询形状由真实批量集成回归单独验证。 */ + private static AppointmentWorkRequirementProvider provider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper mapping) { + var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); + var reads=mock(AppointmentRequirementReadRepository.class); + when(reads.sources(anyList())).thenAnswer(c->c.>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList()); + org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads); + when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect); + when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.>>getArgument(0);return (java.util.function.IntFunction>)(i->appointments.relatedBatchSnapshots(groups.get(i)));}); + return provider; + } /** 保留原来源模拟,显式接入本次读取作用域。 */ private static void scoped(AppointmentSourceService sources) { when(sources.openReadScope()).thenAnswer(call->{ var scope=mock(AppointmentSourceService.ReadScope.class); - when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); + when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.>>getArgument(0).stream().map(group->{ + var refs=sources.canonicalOrders(group); + try { return new AppointmentSourceService.OrderGroup(refs,sources.resolveBatch(refs),null); } + catch(RuntimeException error) { return new AppointmentSourceService.OrderGroup(refs,List.of(),error); } + }).toList()); return scope; }); } From 5cd108b39ac0778f0b099f1ef856aa5a24de678a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 11:43:10 +0800 Subject: [PATCH 007/113] chore(lastleg): instrument appointment latency from restored 662dead baseline --- .../AppointmentCargoReadRepository.java | 16 - .../AppointmentCargoVerificationService.java | 18 -- .../AppointmentSourceReadRepository.java | 26 -- .../AppointmentSourceService.java | 85 ------ .../AppointmentWorkFactAdapter.java | 26 +- .../AppointmentWorkFactGateway.java | 5 +- .../AppointmentWorkQueryService.java | 38 ++- .../appointmentwork/AppointmentWorkStore.java | 19 +- .../AppointmentWorkSyncService.java | 29 +- ...ppointmentWorkDataSourcePostProcessor.java | 35 +++ .../AppointmentWorkDiagnosticFilter.java | 39 +++ .../AppointmentWorkDiagnostics.java | 128 ++++++++ ...pointmentWorkDiagnosticsConfiguration.java | 47 +++ .../AppointmentWorkMyBatisDiagnostics.java | 51 ++++ .../InboundFbaAppointmentQueryMapper.java | 22 -- .../service/InboundFbaAppointmentService.java | 37 --- .../AppointmentRequirementReadRepository.java | 28 -- .../work/AppointmentWorkDeliveryProvider.java | 16 +- .../AppointmentWorkRequirementProvider.java | 102 ++----- ...AppointmentCargoVerificationBatchTest.java | 16 - .../AppointmentPlanningAuthorizationTest.java | 4 - .../AppointmentSourceServiceTest.java | 50 --- .../AppointmentWorkFactAdapterTest.java | 25 +- .../AppointmentWorkPersistenceTest.java | 2 +- .../AppointmentWorkQueryServiceTest.java | 29 ++ .../AppointmentWorkSyncServiceTest.java | 61 ++-- .../appointmentwork/WorkTestFixture.java | 2 +- .../AppointmentWorkDiagnosticFilterTest.java | 34 +++ ...mentWorkDiagnosticsInfrastructureTest.java | 287 ++++++++++++++++++ .../AppointmentWorkDiagnosticsTest.java | 84 +++++ ...AppointmentWorkMyBatisDiagnosticsTest.java | 33 ++ .../FbaAppointmentPersistenceTest.java | 6 - ...ointmentRequirementReadRepositoryTest.java | 244 --------------- .../AppointmentWorkDeliveryProviderTest.java | 12 +- ...ppointmentWorkRequirementProviderTest.java | 75 ++--- 35 files changed, 951 insertions(+), 780 deletions(-) delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java deleted file mode 100644 index ebec3cdd6..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoReadRepository.java +++ /dev/null @@ -1,16 +0,0 @@ -package com.baiyue.lastleg.appointmentplanning; - -import org.springframework.stereotype.Repository; -import java.util.List; - -/** 单次生成只批量读取核实与分配原料,业务解析仍按实际访问顺序。 */ -@Repository -public class AppointmentCargoReadRepository { - private final AppointmentCargoVerificationMapper mapper; - /** 复用本域既有批量查询。 */ - public AppointmentCargoReadRepository(AppointmentCargoVerificationMapper mapper) { this.mapper=mapper; } - /** 取得每客户来源的最新核实记录,暂不解析货件 JSON。 */ - public List latest(List refs) { return refs.isEmpty()?List.of():mapper.latestBatch(refs); } - /** 分配数量仅取活动明细,未知数量保持单独计数。 */ - public List allocations(List refs) { return refs.isEmpty()?List.of():mapper.allocatedBatch(refs); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java index a05e33b7c..e0bc99167 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java @@ -21,7 +21,6 @@ public class AppointmentCargoVerificationService { private final AppointmentSourceService sources; private final AppointmentCargoVerificationMapper mapper; private final ObjectMapper json; - @jakarta.annotation.Resource private AppointmentCargoReadRepository readRepository; public AppointmentCargoVerificationService(AppointmentSourceService sources, AppointmentCargoVerificationMapper mapper, ObjectMapper json) { @@ -51,23 +50,6 @@ public class AppointmentCargoVerificationService { public record Inspection(AppointmentCoverageService.SourceRef reference,Context context,ResponseStatusException error) { public Context requireContext() { if(error!=null) throw error; return context; } } - /** 只预取本次已授权来源的原料;核实 JSON 与数量判断按原 anyMatch 实际访问顺序执行。 */ - public java.util.function.Function prepareInspection(List resolved) { - var refs=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList(); - var latest=new HashMap,AppointmentCargoVerification>(); - var allocations=new HashMap,AppointmentCargoVerificationMapper.Allocation>(); - for(int start=0;startlatest.put(List.of(r.getCustomerId(),r.getSourceKey()),r)); - readRepository.allocations(batch).forEach(r->allocations.put(List.of(r.customerId(),r.sourceKey()),new AppointmentCargoVerificationMapper.Allocation(r.packages(),r.unknownItems()))); - } - var allowed=new HashSet<>(resolved); - return source->{ - if(!allowed.contains(source)) throw new IllegalArgumentException("货物核实来源不属于本次读取范围"); - var key=List.of(source.customerId(),source.sourceKey()); - return inspect(source,latest.get(key),allocations.getOrDefault(key,new AppointmentCargoVerificationMapper.Allocation(0,0))); - }; - } /** Shares source and warehouse reads within this call only; never caches mutable business facts across requests. */ public List inspectBatch(List refs) { var result=new ArrayList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java deleted file mode 100644 index 7ff060a6e..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceReadRepository.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.baiyue.lastleg.appointmentplanning; - -import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.shipping.entity.ContainerShippingGate; -import com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper; -import org.springframework.stereotype.Repository; -import java.util.List; - -/** 同次需求生成的来源原料读取,不保存跨调用状态。 */ -@Repository -public class AppointmentSourceReadRepository { - private final LastlegOrderMapper orders; - private final ContainerShippingGateMapper shipping; - private final AppointmentCoverageMapper destinations; - /** 仅组合本域既有批量持久查询。 */ - public AppointmentSourceReadRepository(LastlegOrderMapper orders,ContainerShippingGateMapper shipping,AppointmentCoverageMapper destinations) { - this.orders=orders;this.shipping=shipping;this.destinations=destinations; - } - /** 读取所选订单,客户归属继续由来源服务逐需求核验。 */ - public List orders(List ids) { return ids.isEmpty()?List.of():orders.selectBatchIds(ids); } - /** 读取实际船节点门禁,不从计划时间推断。 */ - public List gates(List ids) { return ids.isEmpty()?List.of():shipping.selectBatchIds(ids); } - /** 转运目的地仍取原业务权威关系。 */ - public List destinations(List refs) { return refs.isEmpty()?List.of():destinations.sources(refs); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java index 91e1e3341..d2957eb37 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java @@ -26,7 +26,6 @@ public class AppointmentSourceService { private final AppointmentWarehouseClient warehouses; private final String token; @jakarta.annotation.Resource private AppointmentCoverageMapper destinationFacts; - @jakarta.annotation.Resource private AppointmentSourceReadRepository readRepository; public AppointmentSourceService(LastlegOrderMapper orders, ContainerShippingGateMapper shipping, AppointmentExternalSourceGateway external, AppointmentWarehouseClient warehouses, @Value("${baiyue.security.internal-service-token:}") String token) { @@ -73,96 +72,12 @@ public class AppointmentSourceService { public List resolveBatch(List requested) { return AppointmentSourceService.this.resolveBatch(requested,true,this).stream().map(Resolution::requireSource).toList(); } - /** 同页只合并原料查询;来源规范化、重复与权限检查仍按每份需求独立执行。 */ - public List resolveOrderGroups(List> groups) { - var canonical=new java.util.ArrayList>(); - var errors=new java.util.ArrayList(); - var original=new LinkedHashSet(); - for(var group:groups) { - RuntimeException error=null; - if(group==null || group.isEmpty() || group.size()>100) error=new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"Firstleg需求缺少完整客户货源关系"); - else if(group.stream().anyMatch(r->r==null || blank(r.customerId()) || r.orderId()==null || r.orderId()<=0)) error=bad("请选择完整的真实货源"); - errors.add(error);if(error==null) original.addAll(group); - } - var aliases=new java.util.HashMap(); - var originals=new java.util.ArrayList<>(original); - for(int start=0;start(); - var orderIds=new LinkedHashSet(); - for(int i=0;i{ - var alias=aliases.get(ref); - return new AppointmentCoverageService.SourceRef(ref.customerId(),alias==null?"ORDER:"+ref.orderId():"BOOKING:"+alias.bookingNo(),null); - }).toList(); - // 保留原 canonical 的数量校验,不能以全页去重掩盖单需求重复。 - if(refs.size()!=groups.get(i).size()) errors.set(i,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源键必须明确为ORDER或BOOKING")); - refs=refs.stream().distinct().toList();canonical.add(refs); - for(var ref:refs) if(ref.sourceKey().startsWith("ORDER:")) orderIds.add(orderId(ref.sourceKey())); - else bookingRefs.add(new AppointmentExternalSourceGateway.SourceRef(ref.customerId(),ref.sourceKey().substring(8))); - } - var bookingMap=new java.util.HashMap(); - var bookings=new java.util.ArrayList<>(bookingRefs); - for(int start=0;start(); - var gates=new java.util.HashMap(); - var destinations=new java.util.HashMap(); - var ids=new java.util.ArrayList<>(orderIds); - for(int start=0;start{if(orderMap.put(row.getId(),row)!=null) throw new IllegalStateException("原订单来源重复");}); - } - var containers=orderMap.values().stream().filter(o->"FIRST_LEG".equals(o.getOrderType())).map(com.baiyue.lastleg.entity.LastlegOrder::getSourceContainerBookingId).filter(Objects::nonNull).distinct().toList(); - for(int start=0;start{ - if(gates.put(g.getContainerBookingId(),g.getNodeSequence()!=null && g.getNodeSequence()>=40)!=null) throw new IllegalStateException("原船节点门禁重复"); - }); - var transfers=orderMap.values().stream().filter(o->"WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(o->new AppointmentCoverageMapper.OrderRef(o.getCustomerId(),o.getId())).toList(); - for(int start=0;start{ - if(destinations.put(d.orderId(),d)!=null) throw new IllegalStateException("原目的地来源重复"); - }); - var result=new java.util.ArrayList();RuntimeException infrastructure=null; - for(int i=0;i(); - if(error==null) error=infrastructure; - if(error==null) try { - var keys=new java.util.HashSet();var materialized=new java.util.HashSet(); - for(var ref:refs) { - if(blank(ref.sourceKey()) || ref.sourceKey().length()>160 || blank(ref.customerId()) || ref.customerId().length()>128) throw bad("请选择完整的真实货源"); - if(!keys.add(ref.sourceKey())) throw bad("同一来源货物不能重复选择"); - } - var directory=catalog(); - for(var ref:refs) { - var source=resolve(ref.sourceKey(),ref.customerId(),ref.destinationKey(),orderMap,bookingMap,gates,directory,destinations); - if(source.orderId()!=null && !materialized.add(source.orderId())) throw bad("同一货物存在重复来源,请保留原登记来源"); - resolved.add(source); - } - } catch(RuntimeException failed) { - error=failed; - // 基础设施失败不重试、不变为空数据,延后到原需求访问位置抛出。 - if(!(failed instanceof ResponseStatusException status) || status.getStatusCode().value()>=500) infrastructure=failed; - } - result.add(new OrderGroup(refs,List.copyOf(resolved),error)); - } - return List.copyOf(result); - } /** 读取失败不赋值,下次显式调用仍按原目录接口重读。 */ private List catalog() { if(directory==null) directory=AppointmentSourceService.this.catalog(); return directory; } } - public record OrderGroup(List references,List sources,RuntimeException error) { - /** 业务前置错误仍在原需求解析位置抛出,由原边界决定是否保留未就绪需求。 */ - public List requireSources() { if(error!=null) throw error;return sources; } - } /** Read-only inspection keeps each source's prerequisite failure separate; infrastructure errors still escape. */ public record Resolution(AppointmentCoverageService.SourceRef reference,Source source,ResponseStatusException error) { public Source requireSource() { if(error!=null) throw error; return source; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java index 0d8464783..76f4d0d29 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java @@ -4,6 +4,7 @@ import org.springframework.beans.factory.ObjectProvider; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import java.util.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -60,14 +61,14 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { var requests=remote.subList(start,Math.min(start+200,remote.size())); timing.stage="FIRSTLEG"; requireToken(); try { var response=timing.measure(timing.firstleg,requests.size(),()->firstleg.facts(token,requests)); if(response==null || !response.successful()) throw unavailable("提柜及还空事实读取失败"); - append(result,requests,response.data()); + run(Stage.FACTS_VALIDATE,()->append(result,requests,response.data())); } catch(RuntimeException error) { throw unavailable("Firstleg工作事实不可用",error); } } groups.forEach((provider,requests)->{ for(int start=0; startprovider.read(batch))); } + try { var response=timing.measure(statistics,batch.size(),()->provider.read(batch));run(Stage.FACTS_VALIDATE,()->append(result,batch,response)); } catch(RuntimeException error) { throw unavailable("预约/收货/运输事实不可用",error); } } }); @@ -76,6 +77,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { } /** 一次 facts 调用的计时,不保存原来源或返回值,不跨调用缓存。 */ private static final class FactTiming { + private final Span prepare=span(Stage.FACTS_PREPARE); private final long started=System.nanoTime(); private final CallStatistics firstleg=new CallStatistics(); private final Map providers=new LinkedHashMap<>(); @@ -92,20 +94,24 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { } /** 只统计真正尝试的提供端调用;校验及组装耗时保留在 totalMs 中。 */ private T measure(CallStatistics statistics,int requests,java.util.function.Supplier operation) { - statistics.calls++;statistics.requests+=requests;long begin=System.nanoTime(); - try { return operation.get(); } - finally { statistics.elapsed+=System.nanoTime()-begin; } + prepare.close();statistics.calls++;statistics.requests+=requests;long begin=System.nanoTime(); + try(var span=span(switch(stage){case "FIRSTLEG"->Stage.FACTS_FIRSTLEG;case "AppointmentWorkReceivingProvider"->Stage.FACTS_RECEIVING; + case "AppointmentWorkDeliveryProvider"->Stage.FACTS_DELIVERY;case "AppointmentWorkTransportProvider"->Stage.FACTS_TRANSPORT; + case "AppointmentWorkDirectTransportProvider"->Stage.FACTS_DIRECT_TRANSPORT;default->Stage.FACTS_OTHER;})){ + try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;} + }finally{statistics.elapsed+=System.nanoTime()-begin;} } /** 成功慢调用或任意失败只输出一次汇总,日志故障不得遮蔽原异常。 */ private void finish(boolean success) { + if(!success)prepare.failed();prepare.close(); long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime()-started); if(success && totalMs<250) return; try { var summaries=new LinkedHashMap(); providers.forEach((name,value)->summaries.put(name,value.summary())); - LOG.warn("Appointment work facts diagnostic: totalMs={}, firstlegCalls={}, firstlegRequests={}, firstlegMs={}, providers={}, failedStage={}", - totalMs,firstleg.calls,firstleg.requests,firstleg.millis(),summaries,success?"NONE":stage); - } catch(RuntimeException diagnosticFailure) { /* 仅忽略诊断故障,原事实错误仍沿原路径抛出。 */ } + LOG.warn("Appointment work facts diagnostic: totalMs={}, firstlegCalls={}, firstlegRequests={}, firstlegMs={}, providers={}, failedStage={}, requestId={}, traceId={}, scopeId={}", + totalMs,firstleg.calls,firstleg.requests,firstleg.millis(),summaries,success?"NONE":stage,org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId()); + } catch(Throwable diagnosticFailure) { /* 仅忽略诊断故障,原事实错误仍沿原路径抛出。 */ } } } /** 仅包含数量和单调时钟耗时的累加器。 */ @@ -117,7 +123,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { private String summary() { return "calls="+calls+",requests="+requests+",ms="+millis(); } } /** 每次生成建立独立读取器;没有实际需求读取时不引入原业务依赖。 */ - public RequirementReader openRequirements(List inputs) { + public RequirementReader openRequirements() { return new RequirementReader() { private RequirementReader reader; /** 首次实际读取才取得原域作用域,保留每份需求的完整性校验。 */ @@ -125,7 +131,7 @@ public class AppointmentWorkFactAdapter implements AppointmentWorkFactGateway { if(reader==null) { var provider=requirements.getIfAvailable(); if(provider==null) throw unavailable("原预约需求与批次清单适配尚未接入,不能生成不完整工作清单"); - reader=provider.openRequirements(inputs); + reader=provider.openRequirements(); } var result=reader.read(source,effectiveFlow); if(result==null || result.isEmpty()) throw unavailable("原预约需求清单缺失,不能以空清单完成路线生成"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java index 5912bbae3..e2af776af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java @@ -28,7 +28,7 @@ public interface AppointmentWorkFactGateway { SourcePage discover(String after, int size); ContainerSource container(String containerBookingId); /** 建立本次原预约需求读取作用域,保持完整批次及明确替代关系。 */ - RequirementReader openRequirements(List inputs); + RequirementReader openRequirements(); Map facts(List states); Owner owner(String id, Actor actor); Responsibility supplier(String id); @@ -43,6 +43,5 @@ public interface AppointmentWorkFactGateway { interface SupplierDirectory { Responsibility activeSupplier(String id); } /** 单次生成显式复用目录,不跨请求保存来源事实。 */ interface RequirementReader { List read(ContainerSource source,Flow effectiveFlow); } - record RequirementInput(ContainerSource source,Flow flow) { } - interface RequirementProvider { RequirementReader openRequirements(List inputs); } + interface RequirementProvider { RequirementReader openRequirements(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java index b8be03d3a..83e468a4e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java @@ -9,6 +9,7 @@ import java.util.*; import java.util.function.Function; import java.util.function.Predicate; import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -26,10 +27,16 @@ public class AppointmentWorkQueryService { Snapshot snapshot(Actor actor) { return snapshot(actor,s->true,true); } + /** 按原顺序构造完整快照,计时只分离读取、筛选、事实及投影。 */ private Snapshot snapshot(Actor actor,Predicate selected,boolean includeNext) { + observeTransaction();var timing=span(Stage.QUERY_GUARD); + try { String scopeVersion=store.requireInitialized(); + timing.close();timing=span(Stage.QUERY_DISCOVER); if(!Objects.equals(scopeVersion,facts.discover(null,1).scopeVersion())) throw conflict("已开船工作范围变化,请先完成服务端同步"); + timing.close();timing=span(Stage.QUERY_STORE); var all=store.all(); + timing.close();timing=span(Stage.QUERY_SELECT); var stateByKey=all.stream().collect(Collectors.toMap(AppointmentWorkState::getWorkItemKey,Function.identity())); var selectedKeys=all.stream().filter(s->!s.isRetired() && selected.test(s)).map(AppointmentWorkState::getWorkItemKey).collect(Collectors.toSet()); var needed=new HashSet<>(selectedKeys); @@ -41,12 +48,16 @@ public class AppointmentWorkQueryService { } } var states=all.stream().filter(s->!s.isRetired() && needed.contains(s.getWorkItemKey())).toList(); + timing.close();timing=span(Stage.QUERY_FACTS); var current=facts.facts(states); + timing.close();timing=span(Stage.QUERY_PROJECT); var visible=states.stream().filter(s->canRead(s,current.get(s.getWorkItemKey()),actor)).toList(); var items=visible.stream().map(s->project(s,current.get(s.getWorkItemKey()),actor,clock)).toList(); var byKey=items.stream().collect(Collectors.toMap(WorkItem::workItemKey,Function.identity())); + timing.close();timing=span(Stage.QUERY_NEXT); return new Snapshot(all,current,items.stream().filter(i->selectedKeys.contains(i.workItemKey())) .map(i->includeNext?withNext(i,next(i,byKey,stateByKey,new HashSet<>())):i).toList()); + } catch(RuntimeException|Error error){timing.failed();throw error;} finally{timing.close();} } private Action next(WorkItem item,Map items,Map states,Set visited) { if(!visited.add(item.workItemKey()) || item.reviewRequired()) return item.nextAction(); @@ -73,23 +84,34 @@ public class AppointmentWorkQueryService { } return item.nextAction(); } + /** 完整快照之后单独统计汇总耗时,不改变待办筛选语义。 */ public Summary summary(Actor actor) { - var items=snapshot(actor,s->true,false).items().stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList(); + var snapshotItems=snapshot(actor,s->true,false).items(); + return measure(Stage.QUERY_FINAL,()->{ + var items=snapshotItems.stream().filter(i->i.executionStatus()!=ExecutionStatus.COMPLETED).toList(); return new Summary(items.size(),items.stream().filter(i->followToday(i,clock)).count(),items.stream().filter(i->!i.overdueReasons().isEmpty()).count(),items.stream().filter(i->i.calendarDate()==null).count(),items.stream().filter(WorkItem::reviewRequired).count()); + }); } + /** 日历最终分组保持原顺序,单独记录快照后处理。 */ public CalendarResult calendar(LocalDate from,LocalDate to,Flow flow,Set types,Actor actor) { if(from==null || to==null || to.isBefore(from) || ChronoUnit.DAYS.between(from,to)>=62) throw bad("日历范围须为1至62天"); if(types!=null && types.contains(Type.ROUTE)) throw bad("路线待确认需求请从全局待办查看"); - var items=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items().stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to)) + var snapshotItems=snapshot(actor,s->s.getType()!=Type.ROUTE && matches(s,flow,types,null),false).items(); + return measure(Stage.QUERY_FINAL,()->{ + var items=snapshotItems.stream().filter(i->i.type()!=Type.ROUTE && i.calendarDate()!=null && !i.calendarDate().isBefore(from) && !i.calendarDate().isAfter(to)) .filter(i->flow==null || i.flow()==flow).filter(i->types==null || types.contains(i.type())).toList(); var groups=items.stream().collect(Collectors.groupingBy(WorkItem::calendarDate,TreeMap::new,Collectors.toList())); var days=groups.entrySet().stream().map(e->new DayAggregate(e.getKey(),e.getValue().size(),e.getValue().stream().collect(Collectors.groupingBy(WorkItem::type,()->new EnumMap<>(Type.class),Collectors.counting())),counts(e.getValue()))).toList(); return new CalendarResult(from,to,items.size(),counts(items),days); + }); } + /** 保留事实核对之后的最终过滤排序分页,仅拆出其耗时。 */ public PageResult items(View view,int page,int size,LocalDate date,Flow flow,Set types,String containerId,Bucket bucket,String keyword,Actor actor) { if(page<1 || size<1 || size>100 || keyword!=null && keyword.length()>128) throw bad("分页或搜索条件无效"); var word=keyword==null?"":keyword.trim().toLowerCase(Locale.ROOT); - var items=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items().stream() + var snapshotItems=snapshot(actor,s->matches(s,flow,types,containerId) && candidate(s,view),true).items(); + return measure(Stage.QUERY_FINAL,()->{ + var items=snapshotItems.stream() .filter(i->date!=null || i.executionStatus()!=ExecutionStatus.COMPLETED) .filter(i->date==null || date.equals(i.calendarDate())) .filter(i->flow==null || flow==i.flow()).filter(i->types==null || types.contains(i.type())) @@ -100,6 +122,7 @@ public class AppointmentWorkQueryService { .sorted(Comparator.comparing(WorkItem::followupDate,Comparator.nullsLast(Comparator.naturalOrder())).thenComparing(WorkItem::workItemKey)).toList(); long offset=(long)(page-1)*size; int start=(int)Math.min(offset,items.size()); return new PageResult<>(items.subList(start,Math.min(start+size,items.size())),page,size,items.size(),(items.size()+size-1L)/size); + }); } public BatchWorkResolution resolveBatch(String batchKey,String customerId,Actor actor) { if(batchKey==null || !batchKey.matches("(?:FBA_APPOINTMENT|DISPATCH):[1-9][0-9]*")) throw bad("请提供原业务返回的完整批次键"); @@ -117,10 +140,12 @@ public class AppointmentWorkQueryService { var selected=candidates.get(0); return new BatchWorkResolution(batchKey,selected.customerId(),detail(selected.workItemKey(),actor),candidates); } + /** 保留详情完整快照与退休事实读取,历史查询单独观察。 */ public WorkDetail detail(String key,Actor actor) { - var snapshot=snapshot(actor); var state=store.get(key); + var snapshot=snapshot(actor); var state=measure(Stage.DETAIL_STORE,()->store.get(key)); var fact=snapshot.facts().get(key); - if(state.isRetired() && state.getType()!=Type.ROUTE) fact=facts.facts(List.of(state)).get(key); + if(state.isRetired() && state.getType()!=Type.ROUTE) fact=measure(Stage.DETAIL_FACTS,()->facts.facts(List.of(state))).get(key); + try(var timing=span(Stage.DETAIL_PROJECT)) { if(!canRead(state,fact,actor)) throw forbidden("无权读取关联来源"); var item=state.isRetired()?project(state,fact,actor,clock):snapshot.items().stream().filter(i->i.workItemKey().equals(key)).findFirst().orElseThrow(()->missing("事项不存在")); var relatedKeys=new HashSet<>(state.getPrerequisites()); relatedKeys.addAll(state.getReplacementKeys()); @@ -128,9 +153,10 @@ public class AppointmentWorkQueryService { relatedKeys.addAll(effectiveKeys(relatedKeys,statesByKey)); snapshot.states().stream().filter(s->effectiveKeys(s.getPrerequisites(),statesByKey).contains(key)).map(AppointmentWorkState::getWorkItemKey).forEach(relatedKeys::add); var refs=new ArrayList<>(state.getReferences()); if(fact!=null) refs.addAll(fact.references()); - return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),store.history(key), + return new WorkDetail(item,snapshot.items().stream().filter(i->relatedKeys.contains(i.workItemKey())).toList(),refs.stream().distinct().toList(),measure(Stage.DETAIL_HISTORY,()->store.history(key)), state.isReviewRequired()?new Review(state.getReviewVersion(),List.of(Objects.toString(state.getReviewReason(),"关联安排变化"))):null, requirementOwned(state,fact)?new RequirementDetails(state.isBookingRequired(),Objects.toString(state.getReceivingRequirements(),""),state.getRequirementReason()):null,state.getReturnHandoff()); + } } WorkItem current(AppointmentWorkState state,Fact fact,Actor actor) { return project(state,fact,actor,clock); } private boolean matches(AppointmentWorkState state,Flow flow,Set types,String containerId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java index e2439d69a..b447b19a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java @@ -7,6 +7,7 @@ import org.slf4j.MDC; import org.springframework.stereotype.Component; import java.time.*; import java.util.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -21,13 +22,14 @@ public class AppointmentWorkStore { public void invalidateRequirements() { long started = System.nanoTime(); try { - if (mapper.invalidateRequirements() != 1) throw unavailable("工作事项同步守卫未初始化"); + if (measure(Stage.GUARD_UPDATE,mapper::invalidateRequirements) != 1) throw unavailable("工作事项同步守卫未初始化"); } finally { long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); if (elapsedMs >= 250) { // 语句耗时可能包含等锁或连接等待,不能仅凭日志认定具体锁根因;不记录请求业务载荷。 - LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}", - elapsedMs, MDC.get("requestId"), MDC.get("traceId")); + try { LOG.warn("Appointment work guard update slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}", + elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId()); + } catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ } } } } @@ -35,20 +37,23 @@ public class AppointmentWorkStore { public AppointmentWorkMapper.Guard lock() { long started = System.nanoTime(); try { - var guard = mapper.lockGuard(); + observeTransaction(); + var guard = measure(Stage.GUARD_LOCK,mapper::lockGuard); if (guard == null) throw unavailable("工作事项DDL尚未集成"); return guard; } finally { long elapsedMs = java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started); if (elapsedMs >= 250) { // 耗时包含连接及语句执行,单条日志不能区分具体等待原因,不记录业务载荷。 - LOG.warn("Appointment work guard lock slow: elapsedMs={}, requestId={}, traceId={}", - elapsedMs, MDC.get("requestId"), MDC.get("traceId")); + try { LOG.warn("Appointment work guard lock slow: elapsedMs={}, requestId={}, traceId={}, scopeId={}", + elapsedMs, MDC.get("requestId"), MDC.get("traceId"), currentScopeId()); + } catch(Throwable ignored) { /* 诊断输出失败不得覆盖原守卫结果或异常。 */ } } } } + /** 完整性守卫保持原语义,读取耗时与事务完成只作诊断。 */ public String requireInitialized() { - var guard=mapper.guard(); + observeTransaction();var guard=measure(Stage.GUARD_READ,mapper::guard); if(guard==null || !guard.isInitialized()) throw unavailable("完整工作范围尚未完成同步,不能显示零项"); if(guard.getRequirementVersion()!=guard.getSynchronizedRequirementVersion()) throw conflict("预约批次或运输关联已变化,服务端正在协调完整工作清单"); return guard.getScopeVersion(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java index f668a6fe6..4ef7a025d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java @@ -8,6 +8,7 @@ import java.time.*; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -23,7 +24,7 @@ public class AppointmentWorkSyncService { @Transactional public SyncResult synchronize(SyncRequest request) { if(request==null || request.size()<1 || request.size()>100) throw bad("同步批次须为1至100柜"); - var timing=new SyncTiming();boolean success=false; + observeTransaction();var timing=new SyncTiming();boolean success=false; try { var guard=store.lock(); if(request.afterContainerBookingId()!=null && !Objects.equals(guard.getScanCursor(),request.afterContainerBookingId())) throw conflict("来源同步游标已变化,请从首批重新核对"); @@ -36,16 +37,7 @@ public class AppointmentWorkSyncService { timing.next(SyncPhase.GENERATE); // 保留全部历史与稳定键顺序;逐柜更新立即进入本次工作集,不跨事务缓存。 var known=workingSet(); - var requirementInputs=page.containers().stream().filter(source->{ - // 坏来源仍由下面原顺序校验并报错,不能在后页原料提示阶段抢先覆盖前柜错误。 - try { validateSource(source);return true; } catch(RuntimeException invalid) { return false; } - }).map(source->{ - var flow=known.values().stream().filter(s->source.containerBookingId().equals(s.getContainerBookingId()) || s.getContainers().stream().anyMatch(c->source.containerBookingId().equals(c.containerBookingId()))) - .filter(s->s.getType()!=Type.ROUTE && !s.isRetired()).map(AppointmentWorkState::getFlow).findFirst().orElse(source.establishedFlow()); - return new RequirementInput(source,flow); - }).filter(input->input.flow()!=Flow.UNKNOWN).toList(); - // 提示仅用于原料批量读取,下面逐柜 existing/activeFlow 仍根据最新工作集决定。 - var requirementReader=gateway.openRequirements(requirementInputs); + var requirementReader=gateway.openRequirements(); int created=0; var ids=new HashSet(); for(var source:page.containers()) { validateSource(source); @@ -97,6 +89,8 @@ public class AppointmentWorkSyncService { private enum SyncPhase { GUARD, DISCOVER, GENERATE, TRANSPORT_FACTS, RECONCILE, ACTIVE_STORE, ACTIVE_FACTS, OBSERVE_CHECKPOINT } /** 仅统计本次方法执行,不含事务代理提交,不能当作精确锁持有时间。 */ private static final class SyncTiming { + private final Span body=span(Stage.SYNC_BODY); + private Span diagnosticPhase=span(Stage.SYNC_GUARD); private final long started=System.nanoTime(); private final long[] elapsed=new long[SyncPhase.values().length]; private long phaseStarted=started; @@ -106,19 +100,24 @@ public class AppointmentWorkSyncService { /** 完成前一阶段后开始下一阶段;事实阶段包含紧邻的数据库回读。 */ private void next(SyncPhase next) { long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted;phaseStarted=now;phase=next; + diagnosticPhase.close();diagnosticPhase=span(switch(next){ + case GUARD->Stage.SYNC_GUARD;case DISCOVER->Stage.SYNC_DISCOVER;case GENERATE->Stage.SYNC_GENERATE; + case TRANSPORT_FACTS->Stage.SYNC_TRANSPORT_FACTS;case RECONCILE->Stage.SYNC_RECONCILE; + case ACTIVE_STORE->Stage.SYNC_ACTIVE_STORE;case ACTIVE_FACTS->Stage.SYNC_ACTIVE_FACTS;case OBSERVE_CHECKPOINT->Stage.SYNC_CHECKPOINT;}); } /** 成功和失败共用一条慢调用摘要,未到达的计数保持为负一。 */ private void finish(boolean success) { long now=System.nanoTime();elapsed[phase.ordinal()]+=now-phaseStarted; + if(!success){diagnosticPhase.failed();body.failed();}diagnosticPhase.close();body.close(); long totalMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(now-started); if(totalMs<250) return; try { // 旧 activeFactsMs 仍包含工作表读取、解析及适配;新增明细不能改变旧指标口径。 long activeMs=java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[SyncPhase.ACTIVE_STORE.ordinal()]+elapsed[SyncPhase.ACTIVE_FACTS.ordinal()]); - LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, activeStoreMs={}, activeAdapterMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}", + LOG.warn("Appointment work sync slow: totalMs={}, guardMs={}, discoverMs={}, generateMs={}, transportFactsMs={}, reconcileMs={}, activeFactsMs={}, activeStoreMs={}, activeAdapterMs={}, observeCheckpointMs={}, pageContainers={}, transportFactsCount={}, activeFactsCount={}, complete={}, failedStage={}, requestId={}, traceId={}, scopeId={}", totalMs,millis(SyncPhase.GUARD),millis(SyncPhase.DISCOVER),millis(SyncPhase.GENERATE),millis(SyncPhase.TRANSPORT_FACTS),millis(SyncPhase.RECONCILE),activeMs,millis(SyncPhase.ACTIVE_STORE),millis(SyncPhase.ACTIVE_FACTS),millis(SyncPhase.OBSERVE_CHECKPOINT), - pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name()); - } catch(RuntimeException diagnosticFailure) { /* 诊断失败不能替换原业务异常或阻断提交。 */ } + pageContainers,transportFactsCount,activeFactsCount,complete,success?"NONE":phase.name(),org.slf4j.MDC.get("requestId"),org.slf4j.MDC.get("traceId"),currentScopeId()); + } catch(Throwable diagnosticFailure) { /* 诊断失败不能替换原业务异常或阻断提交。 */ } } /** 阶段仅输出整数毫秒,不携带业务标识。 */ private long millis(SyncPhase value) { return java.util.concurrent.TimeUnit.NANOSECONDS.toMillis(elapsed[value.ordinal()]); } @@ -140,7 +139,7 @@ public class AppointmentWorkSyncService { } /** 命令入口仍在调用方守卫事务中即时加载工作集。 */ List generate(ContainerSource source,Flow flow,List existing,Actor actor) { - return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements(List.of(new RequirementInput(source,flow)))); + return generate(source,flow,existing,actor,workingSet(),gateway.openRequirements()); } /** 与持久层全量读取保持稳定键顺序,并保留退役事项及原读取上限。 */ private Map workingSet() { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java new file mode 100644 index 000000000..8d491f4fe --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import com.zaxxer.hikari.HikariDataSource; +import org.aopalliance.intercept.MethodInterceptor; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.aop.support.AopUtils; +import org.springframework.beans.factory.config.BeanPostProcessor; +import javax.sql.DataSource; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +/** 仅观察原Hikari getConnection;同池同连接,unwrap保持容器bean self契约而非隐藏target身份。 */ +final class AppointmentWorkDataSourcePostProcessor implements BeanPostProcessor { + /** 不识别或已有代理的数据源保持原对象,明确记录未覆盖而不主动探测连接。 */ + @Override public Object postProcessAfterInitialization(Object bean,String name) { + if(!(bean instanceof DataSource))return bean; + if(!(bean instanceof HikariDataSource) || AopUtils.isAopProxy(bean)) { + warnUnavailable();return bean; + } + try { + var factory=new ProxyFactory(bean);factory.setProxyTargetClass(true); + factory.addAdvice((MethodInterceptor)invocation->{ + if(!invocation.getMethod().getName().equals("getConnection") || !active())return invocation.proceed(); + try(var span=span(Stage.CONNECTION_ACQUIRE)) { + try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;} + } + }); + return factory.getProxy(bean.getClass().getClassLoader()); + } catch(RuntimeException failure) { warnUnavailable();return bean; } + } + /** 不输出 Bean 名、数据源属性或异常内容。 */ + private static void warnUnavailable() { + try{org.slf4j.LoggerFactory.getLogger(AppointmentWorkDataSourcePostProcessor.class).warn("Appointment connection diagnostic unavailable: unsupported or already proxied datasource");} + catch(Throwable ignored){ /* 日志故障不改变 Bean 注册。 */ } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java new file mode 100644 index 000000000..72d04917e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import jakarta.servlet.*; +import jakarta.servlet.http.*; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import java.io.IOException; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +/** 仅测量服务端同步 filter dispatch,不代表客户端总耗时或异步响应完成时间。 */ +@Component +@Order(Ordered.HIGHEST_PRECEDENCE+10) +public class AppointmentWorkDiagnosticFilter extends OncePerRequestFilter { + private static final String BASE="/api/platform/v1/last-leg/appointment-work"; + /** 仅识别固定模板;路径参数、query string和载荷不进入上下文。 */ + static Route route(String method,String path){ + if("GET".equals(method)){ + if((BASE+"/summary").equals(path))return Route.SUMMARY; + if((BASE+"/calendar").equals(path))return Route.CALENDAR; + if((BASE+"/items").equals(path))return Route.ITEMS; + if((BASE+"/items/resolve").equals(path))return Route.RESOLVE; + if(path!=null && path.matches(BASE+"/items/[^/]+"))return Route.DETAIL; + } + if("POST".equals(method) && path!=null && path.matches(BASE+"/items/[^/]+/commands"))return Route.COMMAND; + return null; + } + /** 不采集其他端点,也不为诊断增加权限判断。 */ + @Override protected boolean shouldNotFilter(HttpServletRequest request){return route(request.getMethod(),request.getRequestURI())==null;} + /** 创建及清理线程作用域;不覆写上游相关标识、状态、响应或异常。 */ + @Override protected void doFilterInternal(HttpServletRequest request,HttpServletResponse response,FilterChain chain)throws ServletException,IOException{ + Scope scope=null; + try{scope=open(Origin.HTTP,route(request.getMethod(),request.getRequestURI()));}catch(RuntimeException ignored){ /* 无诊断仍执行原请求。 */ } + try{chain.doFilter(request,response);} + catch(ServletException|IOException|RuntimeException|Error error){if(scope!=null)scope.failed();throw error;} + finally{if(scope!=null){try{scope.status(response.getStatus());}catch(Throwable ignored){ /* 响应诊断读取失败不替换原异常。 */ }finally{scope.close();}}} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java new file mode 100644 index 000000000..25be649fc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java @@ -0,0 +1,128 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import org.slf4j.LoggerFactory; +import org.slf4j.MDC; +import java.util.*; +import java.util.function.LongSupplier; +import java.util.function.Supplier; + +/** 仅在当前线程的预约调用内累加固定计时桶,不保存业务参数或跨请求状态。 */ +public final class AppointmentWorkDiagnostics { + public enum Origin { HTTP, QUERY, COMMAND, SYNC } + public enum Route { INTERNAL, SUMMARY, CALENDAR, ITEMS, RESOLVE, DETAIL, COMMAND } + public enum Stage { + TRANSACTION_ADVISOR, QUERY_GUARD, QUERY_DISCOVER, QUERY_STORE, QUERY_SELECT, QUERY_FACTS, + QUERY_PROJECT, QUERY_NEXT, QUERY_FINAL, DETAIL_STORE, DETAIL_FACTS, DETAIL_PROJECT, DETAIL_HISTORY, + GUARD_LOCK, GUARD_READ, GUARD_UPDATE, FACTS_PREPARE, FACTS_FIRSTLEG, FACTS_RECEIVING, + FACTS_DELIVERY, FACTS_TRANSPORT, FACTS_DIRECT_TRANSPORT, FACTS_OTHER, FACTS_VALIDATE, + DELIVERY_SOURCE_MAP, DELIVERY_CARGO, DELIVERY_SNAPSHOTS, DELIVERY_ROWS, DELIVERY_RECEIVING, + DELIVERY_IMPACTS, DELIVERY_ASSEMBLY, SYNC_BODY, SYNC_GUARD, SYNC_DISCOVER, SYNC_GENERATE, + SYNC_TRANSPORT_FACTS, SYNC_RECONCILE, SYNC_ACTIVE_STORE, SYNC_ACTIVE_FACTS, SYNC_CHECKPOINT, CONNECTION_ACQUIRE, + DB_EXECUTOR_QUERY, DB_EXECUTOR_UPDATE, DB_PREPARE, DB_STATEMENT_QUERY, DB_STATEMENT_UPDATE, DB_RESULT_MAPPING + } + public enum StatementBucket { NONE, GUARD_LOCK, GUARD_READ, GUARD_UPDATE, WORK_STATE, WORK_HISTORY, DELIVERY, RECEIVING, OTHER } + public record Metric(long calls,long inclusiveNanos,long selfNanos,long failures,long firstOffsetNanos,long lastOffsetNanos) { } + public record Snapshot(String scopeId,String parentScopeId,Origin origin,Route route,String requestId,String traceId, + long elapsedNanos,boolean failed,int status,Map metrics,boolean transactionObserved,int completionStatus,long completionOffsetNanos,long startedNanos) { } + private static final org.slf4j.Logger LOG=LoggerFactory.getLogger(AppointmentWorkDiagnostics.class); + private static final ThreadLocal CURRENT=new ThreadLocal<>(); + private static final Span NO_SPAN=new Span(null,null,null,0); + /** 诊断工具无需实例化。 */ + private AppointmentWorkDiagnostics() { } + + /** 新建独立作用域;关闭时恢复调用方原作用域,不改变 MDC。 */ + public static Scope open(Origin origin,Route route) { return new Scope(origin,route,System::nanoTime); } + /** 返回纯诊断标识,缺少作用域时明确为空,不生成业务关联键。 */ + public static String currentScopeId() { var scope=CURRENT.get();return scope==null?null:scope.id; } + /** 无作用域时所有阶段计时均为空操作。 */ + public static boolean active() { return CURRENT.get()!=null; } + /** 只监听既有事务完成,不开启事务或改变其同步、隔离级别及连接属性。 */ + public static void observeTransaction() { + try { + var scope=CURRENT.get(); + if(scope==null || scope.transactionObserved || !org.springframework.transaction.support.TransactionSynchronizationManager.isSynchronizationActive() + || !org.springframework.transaction.support.TransactionSynchronizationManager.isActualTransactionActive())return; + org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization(){ + /** 回调发生在原事务完成后,仅记录相对时刻;不声称这是数据库精确释放锁时刻。 */ + @Override public void afterCompletion(int status){try{scope.completionStatus=status;scope.completionOffsetNanos=Math.max(0,scope.clock.getAsLong()-scope.started);}catch(Throwable ignored){}} + }); + scope.transactionObserved=true; + }catch(Throwable ignored){ /* 诊断监听失败不能改变事务。 */ } + } + /** 将可信相关标识限制为诊断字符集;任意文本不进入日志。 */ + private static String correlation(String value) { return value!=null && value.matches("[A-Za-z0-9_-]{1,80}")?value:null; } + /** 固定阶段开始计时;最多保留64层,避免异常递归造成诊断状态膨胀。 */ + public static Span span(Stage stage) { return span(stage,StatementBucket.NONE); } + /** 数据库阶段只接受固定语句桶,不记录 SQL、参数或动态 Mapper 名称。 */ + public static Span span(Stage stage,StatementBucket bucket) { + try { + var scope=CURRENT.get();if(scope==null || scope.stack.size()>=64)return NO_SPAN; + var span=new Span(scope,stage,bucket,scope.clock.getAsLong());scope.stack.push(span);return span; + } catch(RuntimeException ignored) { return NO_SPAN; } + } + /** 保留返回对象和原异常;测量只包围原有调用一次。 */ + public static T measure(Stage stage,Supplier operation) { + try(var span=span(stage)) { try{return operation.get();}catch(RuntimeException|Error error){span.failed();throw error;} } + } + /** 无返回值调用沿相同路径执行,不增加重试或业务判断。 */ + public static void run(Stage stage,Runnable operation) { measure(stage,()->{operation.run();return null;}); } + /** 读取当前统计仅用于固定诊断摘要和隔离验证。 */ + static Snapshot currentSnapshot() { var scope=CURRENT.get();return scope==null?null:scope.snapshot(); } + + public static final class Scope implements AutoCloseable { + private final Scope previous; + private final String id=UUID.randomUUID().toString(); + private final Origin origin;private final Route route;private final LongSupplier clock; + private final String requestId,traceId;private final long started; + private final Deque stack=new ArrayDeque<>(); + private final Map totals=new LinkedHashMap<>(); + private boolean failed,closed,transactionObserved;private int status,completionStatus=-1;private long completionOffsetNanos=-1; + /** 包内时钟注入仅供确定性验证,不修改全局时钟。 */ + Scope(Origin origin,Route route,LongSupplier clock) { + this.previous=CURRENT.get();this.origin=origin;this.route=route;this.clock=clock;this.started=clock.getAsLong(); + this.requestId=correlation(MDC.get("requestId"));this.traceId=correlation(MDC.get("traceId"));CURRENT.set(this); + } + /** 只标记失败,不保存异常类型以外的业务内容。 */ + public void failed() { failed=true; } + /** HTTP状态由容器响应读取,业务code不从载荷解析。 */ + public void status(int status) { this.status=status; } + /** 快照为累计已结束阶段;self仅扣同scope直接子段,独立子scope由parentScopeId关联且不可叠加。 */ + public Snapshot snapshot() { + var metrics=new LinkedHashMap();totals.forEach((key,v)->metrics.put(key,new Metric(v[0],v[1],v[2],v[3],v[4],v[5]))); + return new Snapshot(id,previous==null?null:previous.id,origin,route,requestId,traceId,Math.max(0,clock.getAsLong()-started),failed,status,Collections.unmodifiableMap(metrics),transactionObserved,completionStatus,completionOffsetNanos,started); + } + /** 只有慢调用或失败输出汇总;清理优先,日志异常不得覆盖原业务异常。 */ + @Override public void close() { + if(closed)return;closed=true; + try { + if(CURRENT.get()==this){if(previous==null)CURRENT.remove();else CURRENT.set(previous);} + var s=snapshot();if(!s.failed() && s.status()<500 && s.elapsedNanos()<250_000_000L)return; + // startedNanos及offset只可在同一JVM进程比较,不是数据库锁持有者或跨进程时钟。 + LOG.warn("Appointment attribution: scopeId={}, parentScopeId={}, origin={}, route={}, requestId={}, traceId={}, startedNanos={}, elapsedNs={}, failed={}, httpStatus={}, connectionCoverage={}, transactionObserved={}, completionStatus={}, completionOffsetNs={}, boundary=SERVER_DISPATCH_OR_ADVISOR, stages={}", + s.scopeId(),s.parentScopeId(),s.origin(),s.route(),s.requestId(),s.traceId(),s.startedNanos(),s.elapsedNanos(),s.failed(),s.status(), + s.metrics().containsKey(Stage.CONNECTION_ACQUIRE.name())?"OBSERVED":"NOT_OBSERVED",s.transactionObserved(),s.completionStatus(),s.completionOffsetNanos(),s.metrics()); + } catch(Throwable ignored) { /* 诊断设施故障不影响返回、原异常或线程清理。 */ } + } + } + + public static final class Span implements AutoCloseable { + private final Scope scope;private final Stage stage;private final StatementBucket bucket;private final long started; + private long children;private boolean failed,closed; + /** 保存常量阶段,不引用任何业务对象。 */ + private Span(Scope scope,Stage stage,StatementBucket bucket,long started){this.scope=scope;this.stage=stage;this.bucket=bucket;this.started=started;} + /** 失败计数只作为耗时解释,不改变原异常。 */ + public void failed(){if(scope!=null){failed=true;scope.failed=true;}} + /** 每个阶段只结束一次;父段self扣除子段inclusive,不能把两种口径相加。 */ + @Override public void close(){ + if(scope==null || closed)return;closed=true; + try{ + long elapsed=Math.max(0,scope.clock.getAsLong()-started); + if(scope.stack.peek()!=this)return; + scope.stack.pop();if(!scope.stack.isEmpty())scope.stack.peek().children+=elapsed; + var key=stage.name()+(bucket==StatementBucket.NONE?"":":"+bucket.name()); + var v=scope.totals.computeIfAbsent(key,ignored->new long[]{0,0,0,0,Math.max(0,started-scope.started),0});v[0]++;v[1]+=elapsed;v[2]+=Math.max(0,elapsed-children);if(failed)v[3]++; + v[4]=Math.min(v[4],Math.max(0,started-scope.started));v[5]=Math.max(v[5],Math.max(0,started+elapsed-scope.started)); + }catch(Throwable ignored){ /* 诊断计数故障不能替换原结果。 */ } + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java new file mode 100644 index 000000000..b47339767 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import com.baiyue.lastleg.appointmentwork.*; +import org.aopalliance.intercept.MethodInterceptor; +import org.springframework.aop.Advisor; +import org.springframework.aop.support.DefaultPointcutAdvisor; +import org.springframework.aop.support.StaticMethodMatcherPointcut; +import org.springframework.beans.factory.config.BeanDefinition; +import org.springframework.beans.factory.config.BeanPostProcessor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Role; +import org.springframework.core.Ordered; +import java.lang.reflect.Method; +import java.util.Set; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +/** 使用现有 Spring 代理设施观察事务边界,不新增 AspectJ 或改变事务属性。 */ +@Configuration(proxyBeanMethods=false) +public class AppointmentWorkDiagnosticsConfiguration { + /** 基础设施角色确保事务自动代理器纳入此 Advisor;顺序早于默认事务 Advisor。 */ + @Bean @Role(BeanDefinition.ROLE_INFRASTRUCTURE) + public Advisor appointmentWorkDiagnosticAdvisor() { + var pointcut=new StaticMethodMatcherPointcut() { + /** 仅匹配预约直接入口,不拦截其他模块或内部辅助方法。 */ + @Override public boolean matches(Method method,Class targetClass) { + return AppointmentWorkSyncService.class.isAssignableFrom(targetClass) && method.getName().equals("synchronize") + || AppointmentWorkCommandService.class.isAssignableFrom(targetClass) && method.getName().equals("execute") + || AppointmentWorkQueryService.class.isAssignableFrom(targetClass) && Set.of("summary","calendar","items","resolveBatch","detail").contains(method.getName()); + } + }; + MethodInterceptor interceptor=invocation->{ + boolean sync=invocation.getThis() instanceof AppointmentWorkSyncService; + Origin origin=sync?Origin.SYNC:invocation.getThis() instanceof AppointmentWorkCommandService?Origin.COMMAND:Origin.QUERY; + Scope owned=null; + try { if(sync || !active())owned=open(origin,Route.INTERNAL); } catch(RuntimeException ignored) { /* 无法计时仍执行原方法。 */ } + try(var span=span(Stage.TRANSACTION_ADVISOR)) { + try{return invocation.proceed();}catch(Throwable error){span.failed();if(owned!=null)owned.failed();throw error;} + } finally { if(owned!=null)owned.close(); } + }; + var advisor=new DefaultPointcutAdvisor(pointcut,interceptor);advisor.setOrder(Ordered.HIGHEST_PRECEDENCE+100);return advisor; + } + /** 静态 Bean 避免提早实例化业务依赖,只对既有 Hikari 实例增加委托代理。 */ + @Bean public static BeanPostProcessor appointmentWorkDataSourceDiagnostics() { return new AppointmentWorkDataSourcePostProcessor(); } + /** MyBatis 按现有插件入口注册,不注册 Mapper、执行 SQL 或改变缓存配置。 */ + @Bean public AppointmentWorkMyBatisDiagnostics appointmentWorkMyBatisDiagnostics() { return new AppointmentWorkMyBatisDiagnostics(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java new file mode 100644 index 000000000..088ccabf8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.executor.resultset.ResultSetHandler; +import org.apache.ibatis.executor.statement.StatementHandler; +import org.apache.ibatis.mapping.BoundSql; +import org.apache.ibatis.mapping.MappedStatement; +import org.apache.ibatis.cache.CacheKey; +import org.apache.ibatis.plugin.*; +import org.apache.ibatis.session.*; +import java.sql.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +/** query 为含映射的 inclusive 调用;结果读取另记子段,不推断为纯服务器锁等待。 */ +@Intercepts({ + @Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class}), + @Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class,CacheKey.class,BoundSql.class}), + @Signature(type=Executor.class,method="update",args={MappedStatement.class,Object.class}), + @Signature(type=StatementHandler.class,method="prepare",args={Connection.class,Integer.class}), + @Signature(type=StatementHandler.class,method="query",args={Statement.class,ResultHandler.class}), + @Signature(type=StatementHandler.class,method="update",args={Statement.class}), + @Signature(type=ResultSetHandler.class,method="handleResultSets",args={Statement.class}) +}) +public final class AppointmentWorkMyBatisDiagnostics implements Interceptor { + private static final ThreadLocal BUCKET=new ThreadLocal<>(); + /** 所有调用只 proceed 一次,异常原样返回;固定桶上下文在 finally 恢复。 */ + @Override public Object intercept(Invocation invocation)throws Throwable { + if(!active())return invocation.proceed(); + var previous=BUCKET.get();boolean executor=invocation.getTarget() instanceof Executor; + var bucket=previous==null?StatementBucket.OTHER:previous; + if(executor){bucket=bucket(((MappedStatement)invocation.getArgs()[0]).getId());BUCKET.set(bucket);} + Stage stage=executor?(invocation.getMethod().getName().equals("query")?Stage.DB_EXECUTOR_QUERY:Stage.DB_EXECUTOR_UPDATE) + :invocation.getTarget() instanceof ResultSetHandler?Stage.DB_RESULT_MAPPING + :switch(invocation.getMethod().getName()){case "prepare"->Stage.DB_PREPARE;case "query"->Stage.DB_STATEMENT_QUERY;default->Stage.DB_STATEMENT_UPDATE;}; + try(var span=span(stage,bucket)){ + try{return invocation.proceed();}catch(Throwable error){span.failed();throw error;} + }finally{if(executor){if(previous==null)BUCKET.remove();else BUCKET.set(previous);}} + } + /** 只有明确白名单语句有独立桶,其他名字一律合入 OTHER,绝不打印原 ID。 */ + private static StatementBucket bucket(String id){ + return switch(id){ + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.lockGuard"->StatementBucket.GUARD_LOCK; + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.guard"->StatementBucket.GUARD_READ; + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.invalidateRequirements","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.saveGuard"->StatementBucket.GUARD_UPDATE; + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.all","com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.get"->StatementBucket.WORK_STATE; + case "com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.selectHistory"->StatementBucket.WORK_HISTORY; + case "com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.batches","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.customer","com.baiyue.lastleg.inboundplan.work.AppointmentWorkDeliveryMapper.privateBatch"->StatementBucket.DELIVERY; + default->StatementBucket.OTHER; + }; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java index 7a116f3f4..216503c41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -28,28 +28,6 @@ public interface InboundFbaAppointmentQueryMapper { " ORDER BY appointment_id DESC LIMIT 1001"}) List relatedBatchIds(@Param("sources") List sources); - record RequirementSource(int groupOrdinal,String customerId,String sourceKey,String destinationKey) { } - record RequirementBatch(int groupOrdinal,long appointmentId) { } - /** 按原需求组返回历史归属,失活明细也保留;每组第1001条用于检测超限。 */ - @Select(""" - - """) - List requirementBatches(@Param("sources") List sources); - @Select("") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index 30b1f5498..cec90b860 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -34,7 +34,6 @@ public class InboundFbaAppointmentService { @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; @Resource private com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort receivingWork; @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges workChanges; - @Resource private com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads; @Transactional(readOnly=true) public List calendar(LocalDate from,LocalDate to) { @@ -112,42 +111,6 @@ public class InboundFbaAppointmentService { if(ids.size()>1000) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"); return ids.isEmpty()?List.of():responses(appointmentMapper.selectBatchIds(ids)); } - /** 同页原料只读一次,历史归属按原需求组保留;响应解析延后至原访问顺序。 */ - public java.util.function.IntFunction> prepareRelatedBatchSnapshots(List> groups) { - var errors=new HashMap(); - var requested=new ArrayList(); - for(int i=0;i100 || refs.stream().anyMatch(ref->ref==null || clean(ref.customerId())==null || clean(ref.sourceKey())==null || clean(ref.destinationKey())==null)) { - errors.put(i,bad("每次最多读取100个完整原来源的关联批次"));continue; - } - final int group=i; - refs.stream().distinct().forEach(ref->requested.add(new InboundFbaAppointmentQueryMapper.RequirementSource(group,ref.customerId(),ref.sourceKey(),ref.destinationKey()))); - } - var groupIds=new HashMap>(); - for(int start=0;start=groups.size()) throw new IllegalStateException("历史关联返回了请求外需求组"); - groupIds.computeIfAbsent(match.groupOrdinal(),ignored->new TreeSet<>()).add(match.appointmentId()); - } - } - groupIds.forEach((group,ids)->{if(ids.size()>1000) errors.put(group,new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"关联批次超过读取范围,请缩小原来源范围后继续核对"));}); - var ids=groupIds.entrySet().stream().filter(e->!errors.containsKey(e.getKey())).flatMap(e->e.getValue().stream()).distinct().sorted().toList(); - var rows=new LinkedHashMap(); - var items=new HashMap>(); - for(int start=0;startrows.put(row.getId(),row)); - requirementReads.items(batch).forEach(item->items.computeIfAbsent(item.getAppointmentId(),ignored->new ArrayList<>()).add(item)); - } - return group->{ - if(group<0 || group>=groups.size()) throw new IllegalArgumentException("需求组不属于本次读取范围"); - if(errors.containsKey(group)) throw errors.get(group); - var selected=groupIds.getOrDefault(group,Set.of()); - return rows.values().stream().filter(row->selected.contains(row.getId())).map(row->response(row,items.getOrDefault(row.getId(),List.of()))).toList(); - }; - } @Transactional(readOnly=true) public FbaAppointmentBatchContext batchContext(String key) { final long id; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java deleted file mode 100644 index c25768b2e..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepository.java +++ /dev/null @@ -1,28 +0,0 @@ -package com.baiyue.lastleg.inboundplan.work; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baiyue.lastleg.inboundplan.entity.*; -import com.baiyue.lastleg.inboundplan.mapper.*; -import org.springframework.stereotype.Repository; -import java.util.List; - -/** 需求生成的本域原料读取,历史归属不能从活动响应明细反推。 */ -@Repository -public class AppointmentRequirementReadRepository { - private final AppointmentWorkSourceMapper mapping; - private final InboundFbaAppointmentQueryMapper queries; - private final InboundFbaAppointmentMapper appointments; - private final InboundFbaAppointmentItemMapper items; - /** 保留本域 Mapper 与客户来源查询边界。 */ - public AppointmentRequirementReadRepository(AppointmentWorkSourceMapper mapping,InboundFbaAppointmentQueryMapper queries,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items) { - this.mapping=mapping;this.queries=queries;this.appointments=appointments;this.items=items; - } - /** 每柜独立上限由既有窗口查询保持。 */ - public List sources(List containers) { return containers.isEmpty()?List.of():mapping.containerSourcesBatch(containers); } - /** 分块只控制单条 SQL 输入,业务服务仍按需求组完整合并及核对上限。 */ - public List history(List sources) { return sources.isEmpty()?List.of():queries.requirementBatches(sources); } - /** 读取已确认关联的预约主记录。 */ - public List appointments(List ids) { return ids.isEmpty()?List.of():appointments.selectBatchIds(ids); } - /** 包括失活明细以维持取消与替代历史;响应过滤由业务服务执行。 */ - public List items(List ids) { return ids.isEmpty()?List.of():items.selectList(new LambdaQueryWrapper().in(InboundFbaAppointmentItem::getAppointmentId,ids).orderByAsc(InboundFbaAppointmentItem::getId)); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java index bf80098b6..611519640 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java @@ -8,6 +8,7 @@ import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.BatchReceiving; import com.baiyue.lastleg.security.OverseasPermissions; import org.springframework.stereotype.Component; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; import java.util.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; @@ -26,11 +27,12 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew this.appointments=appointments; this.cargo=cargo; this.commitments=commitments; this.deliveries=deliveries; this.sourceMapping=sourceMapping;this.receiving=receiving; } public boolean supports(Type type,Flow flow) { return type==Type.DELIVERY && flow==Flow.TRANSFER; } + /** 按原批次顺序读取,仅对固定步骤附加耗时,不更改来源或失败语义。 */ public List read(List requests) { var mapped=new HashMap>(); var privateSources=new HashMap>(); var containers=requests.stream().filter(r->r.batchKey()==null && r.requirementKey()!=null && r.requirementKey().startsWith("INBOUND:")).map(FactRequest::containerBookingId).distinct().toList(); - if(!containers.isEmpty()) sourceMapping.containerSourcesBatch(containers).forEach(s->mapped.computeIfAbsent(s.containerBookingId(),ignored->new ArrayList<>()).add(s.source())); + if(!containers.isEmpty()) measure(Stage.DELIVERY_SOURCE_MAP,()->sourceMapping.containerSourcesBatch(containers)).forEach(s->mapped.computeIfAbsent(s.containerBookingId(),ignored->new ArrayList<>()).add(s.source())); for(var request:requests) if(request.batchKey()==null && request.requirementKey()!=null && request.requirementKey().startsWith("INBOUND:")) { var sources=mapped.getOrDefault(request.containerBookingId(),List.of()); if(sources.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); @@ -40,20 +42,20 @@ public class AppointmentWorkDeliveryProvider implements AppointmentWorkFactGatew var refs=requests.stream().filter(r->r.batchKey()==null && !privateSources.containsKey(r.workItemKey())).flatMap(r->r.sources().stream()) .map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).distinct().toList(); var inspections=new HashMap(); - if(!refs.isEmpty()) cargo.inspectBatch(refs).forEach(i->inspections.put(i.reference(),i)); + if(!refs.isEmpty()) measure(Stage.DELIVERY_CARGO,()->cargo.inspectBatch(refs)).forEach(i->inspections.put(i.reference(),i)); var batches=requests.stream().map(FactRequest::batchKey).filter(k->k!=null && !k.startsWith("DISPATCH:")).distinct().toList(); var snapshots=new HashMap(); var deliveryRows=new HashMap>(); Map impacts=Map.of(); var receivingSnapshots=new HashMap(); if(!batches.isEmpty()) { - appointments.batchSnapshots(batches).forEach(a->snapshots.put(a.batchKey(),a)); - deliveries.batches(batches).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery())); - receiving.snapshots(batches).forEach(s->receivingSnapshots.put(s.batchKey(),s)); - impacts=commitments.impactTokens(requests.stream().filter(r->r.batchKey()!=null && r.batchKey().startsWith("FBA_APPOINTMENT:")).map(r->customerRequirement(r.batchKey(),deliveryCustomer(r.sources()))).distinct().toList()); + measure(Stage.DELIVERY_SNAPSHOTS,()->appointments.batchSnapshots(batches)).forEach(a->snapshots.put(a.batchKey(),a)); + measure(Stage.DELIVERY_ROWS,()->deliveries.batches(batches)).forEach(d->deliveryRows.computeIfAbsent(d.batchKey(),ignored->new ArrayList<>()).add(d.delivery())); + measure(Stage.DELIVERY_RECEIVING,()->receiving.snapshots(batches)).forEach(s->receivingSnapshots.put(s.batchKey(),s)); + impacts=measure(Stage.DELIVERY_IMPACTS,()->commitments.impactTokens(requests.stream().filter(r->r.batchKey()!=null && r.batchKey().startsWith("FBA_APPOINTMENT:")).map(r->customerRequirement(r.batchKey(),deliveryCustomer(r.sources()))).distinct().toList())); } var context=new BatchFacts(snapshots,deliveryRows,impacts,receivingSnapshots); - return requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList(); + return measure(Stage.DELIVERY_ASSEMBLY,()->requests.stream().map(r->read(r,privateSources.get(r.workItemKey()),inspections,context)).toList()); } private record BatchFacts(Map appointments,Map> deliveries,Map impacts,Map receiving) { } private Fact read(FactRequest request,List privateSources,Map inspections,BatchFacts context) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java index 6bab0f177..db8e65d91 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java @@ -17,85 +17,29 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa private final AppointmentCargoVerificationService cargo; private final InboundFbaAppointmentService appointments; private final AppointmentWorkSourceMapper sourceMapping; - @jakarta.annotation.Resource private AppointmentRequirementReadRepository reads; public AppointmentWorkRequirementProvider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper sourceMapping) { this.sources=sources; this.cargo=cargo; this.appointments=appointments; this.sourceMapping=sourceMapping; } - /** 本次生成共享原料,实例不会跨同步页或独立命令保存。 */ - public RequirementReader openRequirements(List inputs) { + /** 同次生成仅共享按需读取的目录;原业务数据继续逐需求核实。 */ + public RequirementReader openRequirements() { return new RequirementReader() { - private Prepared prepared; - /** 原料提示不替代实际路线判断;未提示的真实需求按同一实现即时读取。 */ + private AppointmentSourceService.ReadScope scope; + /** 直送和未建立尾程来源的需求不创建目录读取依赖。 */ public List read(ContainerSource container,Flow flow) { - if(flow==Flow.DIRECT) return original(container); - if(flow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); - if(prepared==null) { - var requested=new ArrayList<>(inputs);requested.add(new RequirementInput(container,flow)); - prepared=prepare(requested); - } - var data=prepared.containers.contains(container)?prepared:prepare(List.of(new RequirementInput(container,flow))); - return requirements(container,flow,data); + return requirements(container,flow,refs->{ + if(scope==null) scope=sources.openReadScope(); + return scope.resolveBatch(refs); + }); } }; } - /** 私人地址仍以原客户仓需求表示,直送不增加尾程读取依赖。 */ - private List original(ContainerSource container) { - return container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); - } - private record Group(ContainerSource container,String requirementKey) { } - /** 只含本次原料及延后执行器,不缓存已经解析的业务响应。 */ - private static final class Prepared { - private final Set containers=new LinkedHashSet<>(); - private final Map> mapped=new HashMap<>(); - private final Map ordinals=new LinkedHashMap<>(); - private List sources=List.of(); - private RuntimeException mappingError,sourceError; - private java.util.function.IntFunction> history; - private java.util.function.Function inspection; - } - /** 先批量读取原料,组级业务错误在原需求访问位置抛出。 */ - private Prepared prepare(List inputs) { - var data=new Prepared(); - inputs.stream().filter(i->i.flow()==Flow.TRANSFER).map(RequirementInput::source).forEach(data.containers::add); - var ids=data.containers.stream().map(ContainerSource::containerBookingId).distinct().toList(); - try { - for(int start=0;startdata.mapped.computeIfAbsent(row.containerBookingId(),ignored->new ArrayList<>()).add(row.source())); - } catch(RuntimeException error) { data.mappingError=error;return data; } - var groups=new ArrayList>(); - for(var container:data.containers) { - var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of()); - if(mapped.size()>1000) continue; - for(var requirement:original(container)) { - if(!"FBA".equals(requirement.destinationKind())) continue; - var inbound=requirement.requirementKey().startsWith("INBOUND:")?requirement.requirementKey().substring(8):null; - var orders=mapped.stream().filter(row->Objects.equals(row.sourceInboundId(),inbound)).toList(); - if(orders.isEmpty()) continue; - data.ordinals.put(new Group(container,requirement.requirementKey()),groups.size()); - groups.add(orders.stream().map(row->new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.id())).toList()); - } - } - if(groups.isEmpty()) return data; - try { data.sources=sources.openReadScope().resolveOrderGroups(groups); } - catch(RuntimeException error) { data.sourceError=error;return data; } - var historyGroups=data.sources.stream().map(group->group.error()==null?group.sources().stream().map(source->new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList():List.of()).toList(); - var valid=data.sources.stream().filter(group->group.error()==null).flatMap(group->group.sources().stream()).toList(); - if(valid.isEmpty()) return data; - try { data.history=appointments.prepareRelatedBatchSnapshots(historyGroups); } - catch(RuntimeException error) { data.history=ignored->{throw error;}; } - // 不提前解析货件,仍由原 history -> anyMatch 路径按实际访问顺序执行。 - try { data.inspection=cargo.prepareInspection(valid); } - catch(RuntimeException error) { data.inspection=ignored->{throw error;}; } - return data; - } /** 按当前实物分配生成客户需求,保留已撤销批次身份以退役旧事项。 */ private List requirements(ContainerSource container,Flow effectiveFlow, - Prepared data) { - var original=original(container); + java.util.function.Function,List> resolver) { + var original=container.requirements().stream().map(r->"PRIVATE".equals(r.destinationKind())?new Requirement(r.requirementKey(),r.batchKey(),r.batchNo(),"CUSTOMER",r.originName(),r.destinationName(),r.timezone(),r.references(),r.replacesRequirementKeys(),r.sources()):r).toList(); if(effectiveFlow==Flow.DIRECT) return original; if(effectiveFlow!=Flow.TRANSFER) throw conflict("需求清单必须明确有效作业路线"); - if(data.mappingError!=null) throw data.mappingError; - var mapped=data.mapped.getOrDefault(container.containerBookingId(),List.of()); + var mapped=sourceMapping.containerSources(container.containerBookingId()); if(mapped.size()>1000) throw unavailable("柜次尾程来源超过完整读取上限"); var result=new LinkedHashMap(); var privateDispatches=original.stream().anyMatch(r->"CUSTOMER".equals(r.destinationKind()))?sourceMapping.privateDispatches(container.containerBookingId()):List.of(); @@ -106,13 +50,9 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa if(sourceOrders.isEmpty()) { result.put(requirement.requirementKey(),requirement); continue; } if("CUSTOMER".equals(requirement.destinationKind())) { customer(requirement,sourceOrders,privateDispatches,result);continue; } if(!"FBA".equals(requirement.destinationKind())) { result.put(requirement.requirementKey(),requirement); continue; } - if(sourceOrders.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); - if(data.sourceError!=null) throw data.sourceError; - int ordinal=data.ordinals.get(new Group(container,requirement.requirementKey())); - var group=data.sources.get(ordinal); - var refs=group.references(); + var refs=canonical(sourceOrders.stream().map(s->new SourceReference(s.customerId(),"ORDER:"+s.id(),null)).toList()); List resolved; - try { resolved=group.requireSources(); } + try { resolved=resolver.apply(refs); } catch(org.springframework.web.server.ResponseStatusException error) { if(!AppointmentWorkSourceAvailability.unresolved(error)) throw error; // Keep the complete physical obligation and its canonical source identities. The fact @@ -123,10 +63,10 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa continue; } var complete=resolved.stream().map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).toList(); - var history=data.history.apply(ordinal); + var history=appointments.relatedBatchSnapshots(complete); var active=history.stream().filter(AppointmentWorkRequirementProvider::active).toList(); // Unknown remaining quantity is still work, never a zero or an absent requirement. - boolean remainder=resolved.stream().map(data.inspection).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); + boolean remainder=resolved.stream().map(cargo::inspect).anyMatch(c->c.availablePackages()==null || c.availablePackages()>0); var replaced=history.stream().filter(a->!active(a)).map(FbaAppointmentResponse::batchKey).toList(); if(remainder) { var restored=new LinkedHashSet<>(replaced); @@ -188,6 +128,18 @@ public class AppointmentWorkRequirementProvider implements AppointmentWorkFactGa result.put(batch,new Requirement(batch,batch,all.get(0).dispatchNo(),"CUSTOMER",requirement.originName(),requirement.destinationName(),requirement.timezone(),refs.stream().distinct().toList(),List.copyOf(replacements),all.stream().map(d->new SourceReference(d.customerId(),d.sourceKey(),d.destinationKey())).distinct().toList())); } } + private List canonical(List input) { + if(input==null || input.isEmpty() || input.size()>100) throw unavailable("Firstleg需求缺少完整客户货源关系"); + var orders=input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("ORDER:")).map(s->{ + try { return new AppointmentExternalSourceGateway.OrderRef(s.customerId(),Long.parseLong(s.sourceKey().substring(6))); } + catch(RuntimeException error) { throw unavailable("Firstleg原订单关联无效",error); } + }).toList(); + var result=new ArrayList(); + if(!orders.isEmpty()) result.addAll(sources.canonicalOrders(orders)); + input.stream().filter(s->s.sourceKey()!=null && s.sourceKey().startsWith("BOOKING:")).map(s->new AppointmentCoverageService.SourceRef(s.customerId(),s.sourceKey(),s.destinationKey())).forEach(result::add); + if(result.size()!=input.size()) throw unavailable("来源键必须明确为ORDER或BOOKING"); + return result.stream().distinct().toList(); + } /** 已取消且没有当前实物分配的记录只属历史;取消但仍有货物的批次继续产生需求。 */ private static boolean active(FbaAppointmentResponse batch) { if(batch.replacementBatchKeys()!=null && !batch.replacementBatchKeys().isEmpty()) return false; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java index 8eced830c..ea89cc6e8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java @@ -11,22 +11,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentCargoVerificationBatchTest { - /** 预取只读原料,未被 anyMatch 访问的坏 JSON 不提前报错;下一次准备仍重新读取。 */ - @Test void preparedAuthorizedSourcesKeepShortCircuitAndBoundedReads() { - var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); - var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); - org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentCargoReadRepository(mapper)); - var resolved=IntStream.rangeClosed(1,201).mapToObj(i->new AppointmentSourceService.Source("ORDER:"+i,"C",(long)i,"IN",null,"CNT","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of())).toList(); - var broken=new AppointmentCargoVerification();broken.setCustomerId("C");broken.setSourceKey("ORDER:2");broken.setPackageCount(10);broken.setShipmentReferencesJson("broken-json"); - when(mapper.latestBatch(anyList())).thenAnswer(c->c.>getArgument(0).stream().anyMatch(r->r.sourceKey().equals("ORDER:2"))?List.of(broken):List.of()); - var read=service.prepareInspection(resolved); - assertTrue(resolved.stream().map(read).anyMatch(c->c.availablePackages()!=null && c.availablePackages()>0)); - assertThrows(RuntimeException.class,()->read.apply(resolved.get(1))); - assertEquals(10L,read.apply(resolved.get(200)).availablePackages()); - verify(mapper,times(3)).latestBatch(anyList());verify(mapper,times(3)).allocatedBatch(anyList());verifyNoInteractions(sources); - service.prepareInspection(resolved);verify(mapper,times(6)).latestBatch(anyList());verify(mapper,times(6)).allocatedBatch(anyList()); - assertThrows(IllegalArgumentException.class,()->read.apply(new AppointmentSourceService.Source("ORDER:1","OTHER",1L,null,null,null,"100",null,"FBA:ONT8","ONT8",10,"v1",true,List.of()))); - } @Test void inspectionUsesTwoBoundedBatchesAndRetainsPerSourcePrerequisitesAndAllocations() { var sources=mock(AppointmentSourceService.class);var mapper=mock(AppointmentCargoVerificationMapper.class); var service=new AppointmentCargoVerificationService(sources,mapper,new ObjectMapper()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index be51c5bf3..13e21e691 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -146,10 +146,6 @@ class AppointmentPlanningAuthorizationTest { @Bean AppointmentCargoVerificationController verificationController(AppointmentCargoVerificationService service) {return new AppointmentCargoVerificationController(service);} @Bean AppointmentSourceCreateController sourceCreateController() { return new AppointmentSourceCreateController(mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class)); } @Bean AppointmentCoverageMapper destinationFacts() { return mock(AppointmentCoverageMapper.class); } - /** 权限测试只模拟业务服务,补齐其新增只读端口依赖而不触发数据库。 */ - @Bean AppointmentSourceReadRepository sourceReads() { return mock(AppointmentSourceReadRepository.class); } - /** 本测试验证入口权限,不执行货物核实原料读取。 */ - @Bean AppointmentCargoReadRepository cargoReads() { return mock(AppointmentCargoReadRepository.class); } @Bean AppointmentBatchPreviewService batchPreviews() { return mock(AppointmentBatchPreviewService.class); } @Bean AppointmentBatchPreviewController batchPreviewController(AppointmentBatchPreviewService service) { return new AppointmentBatchPreviewController(service); } @Bean AppointmentContainerService containers() { return mock(AppointmentContainerService.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java index ee5ff8d4d..3082da1b6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java @@ -12,56 +12,6 @@ import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; class AppointmentSourceServiceTest { - /** 同页共享原料但保留逐组权限、重复和前置错误,结果等于各组独立严格读取。 */ - @Test void groupedOrderResolutionSharesReadsWithoutSharingValidationOrFailures() { - var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); - var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); - var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); - org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentSourceReadRepository(orders,shipping,mock(AppointmentCoverageMapper.class))); - var a=new LastlegOrder();a.setId(1L);a.setCustomerId("A");a.setOrderType("FIRST_LEG");a.setSourceContainerBookingId("C1");a.setOverseasWarehouseId("100");a.setDestinationType("FBA");a.setFbaWarehouseCode("ONT8");a.setPackageCount(10); - var b=new LastlegOrder();b.setId(2L);b.setCustomerId("B");b.setOrderType("FIRST_LEG");b.setSourceContainerBookingId("C2");b.setOverseasWarehouseId("100");b.setDestinationType("FBA");b.setFbaWarehouseCode("ONT8");b.setPackageCount(20); - when(orders.selectBatchIds(anyCollection())).thenAnswer(c->List.of(a,b).stream().filter(o->c.>getArgument(0).contains(o.getId())).toList()); - var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId("C1");gate.setNodeSequence(40); - when(shipping.selectBatchIds(anyCollection())).thenReturn(List.of(gate)); - when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); - var ref=new AppointmentExternalSourceGateway.OrderRef("A",1L); - var groups=List.of(List.of(ref),List.of(ref),List.of(new AppointmentExternalSourceGateway.OrderRef("B",2L)),List.of(new AppointmentExternalSourceGateway.OrderRef("OTHER",1L)),List.of(ref,ref)); - var actual=service.openReadScope().resolveOrderGroups(groups); - assertEquals(actual.get(0).requireSources(),actual.get(1).requireSources()); - assertEquals("货物尚无已开船或后续实际船节点",((ResponseStatusException)actual.get(2).error()).getReason()); - assertEquals(404,((ResponseStatusException)actual.get(3).error()).getStatusCode().value()); - assertEquals(503,((ResponseStatusException)actual.get(4).error()).getStatusCode().value()); - assertEquals("来源键必须明确为ORDER或BOOKING",((ResponseStatusException)actual.get(4).error()).getReason()); - verify(orders,times(1)).selectBatchIds(anyCollection());verify(shipping,times(1)).selectBatchIds(anyCollection());verify(external,times(1)).resolveOrders(anyList());verify(warehouse,times(1)).catalog("token"); - assertEquals(service.resolveBatch(actual.get(0).references()),actual.get(0).requireSources()); - assertEquals(400,((ResponseStatusException)service.openReadScope().resolveOrderGroups(List.of(List.of(new AppointmentExternalSourceGateway.OrderRef("x".repeat(129),1L)))).get(0).error()).getStatusCode().value()); - var failure=new IllegalStateException("directory unavailable");when(warehouse.catalog("token")).thenThrow(failure); - clearInvocations(warehouse); - var failed=service.openReadScope().resolveOrderGroups(List.of(List.of(ref),List.of(ref))); - assertEquals(503,((ResponseStatusException)failed.get(0).error()).getStatusCode().value());assertSame(failed.get(0).error(),failed.get(1).error());verify(warehouse,times(1)).catalog("token"); - service.openReadScope().resolveOrderGroups(List.of(List.of(ref)));verify(warehouse,times(2)).catalog("token"); - } - /** 不同订单规范化成同登记仍合一,不同登记重复同实物订单仍拒绝。 */ - @Test void groupedCanonicalAliasesKeepOriginalDistinctAndMaterializedRules() { - var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class);var external=mock(AppointmentExternalSourceGateway.class);var warehouse=mock(AppointmentWarehouseClient.class); - var service=new AppointmentSourceService(orders,shipping,external,warehouse,"token"); - org.springframework.test.util.ReflectionTestUtils.setField(service,"readRepository",new AppointmentSourceReadRepository(orders,shipping,mock(AppointmentCoverageMapper.class))); - var order=new LastlegOrder();order.setId(1L);order.setCustomerId("A");order.setOverseasWarehouseId("100");order.setDestinationType("FBA");order.setFbaWarehouseCode("ONT8");order.setPackageCount(10); - when(orders.selectBatchIds(anyCollection())).thenReturn(List.of(order)); - when(warehouse.catalog("token")).thenReturn(ApiResponse.success(List.of(new AppointmentWarehouseClient.Warehouse(100L,"US-WH","仓","OVERSEAS","US"),new AppointmentWarehouseClient.Warehouse(200L,"ONT8","ONT8","FBA","US")))); - var booking=new AppointmentExternalSourceGateway.Source("A","B1",null,"RECEIVED","100","FBA:ONT8",1L,10,1L); - when(external.resolveOrders(anyList())).thenReturn(List.of(booking,new AppointmentExternalSourceGateway.Source("A","B1",null,"RECEIVED","100","FBA:ONT8",2L,10,1L))); - when(external.resolve(anyList())).thenReturn(List.of(booking)); - var input=List.of(List.of(new AppointmentExternalSourceGateway.OrderRef("A",1L),new AppointmentExternalSourceGateway.OrderRef("A",2L))); - assertEquals(1,service.openReadScope().resolveOrderGroups(input).get(0).requireSources().size()); - var alias2=new AppointmentExternalSourceGateway.Source("A","B2",null,"RECEIVED","100","FBA:ONT8",2L,10,1L); - when(external.resolveOrders(anyList())).thenReturn(List.of(booking,alias2)); - when(external.resolve(anyList())).thenReturn(List.of(booking,new AppointmentExternalSourceGateway.Source("A","B2",null,"RECEIVED","100","FBA:ONT8",1L,10,1L))); - assertEquals("同一货物存在重复来源,请保留原登记来源",((ResponseStatusException)service.openReadScope().resolveOrderGroups(input).get(0).error()).getReason()); - when(external.resolveOrders(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("A","x".repeat(161),null,"RECEIVED","100","FBA:ONT8",1L,10,1L))); - var invalid=service.openReadScope().resolveOrderGroups(List.of(input.get(0).subList(0,1))).get(0); - assertEquals("请选择完整的真实货源",((ResponseStatusException)invalid.error()).getReason()); - } /** 目录失败不缓存,成功只在当前作用域复用;来源、客户和目的地仍逐次校验。 */ @Test void explicitScopeRetriesFailedCatalogAndNeverCachesSourceOrCrossScopeDirectory() { var orders=mock(LastlegOrderMapper.class);var shipping=mock(ContainerShippingGateMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java index dc08f4468..e86ca683d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java @@ -10,6 +10,21 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.WorkTestFixture.*; class AppointmentWorkFactAdapterTest { + /** 原事实分批沿请求作用域关联,provider计数和返回顺序保持不变。 */ + @Test void diagnosticScopeKeepsTraceAndOriginalProviderBatchCount() { + var f=new DiagnosticFixture();var states=new ArrayList(); + for(int i=0;i<201;i++)states.add(f.work.state("private-receiving-"+i,Type.WAREHOUSE_RECEIPT,Flow.TRANSFER,null)); + org.slf4j.MDC.put("requestId","safe-request");org.slf4j.MDC.put("traceId","safe-trace"); + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)){ + var result=f.adapter.facts(states);assertEquals(201,result.size());assertEquals(List.of(200,1),f.providerSizes); + assertEquals(2,scope.snapshot().metrics().get("FACTS_RECEIVING").calls()); + assertEquals(2,scope.snapshot().metrics().get("FACTS_VALIDATE").calls()); + assertEquals("safe-trace",scope.snapshot().traceId());assertEquals("safe-request",scope.snapshot().requestId()); + assertFalse(scope.snapshot().toString().contains("private-"));verifyNoInteractions(f.firstleg); + }finally{org.slf4j.MDC.clear();} + } /** 慢成功仅追加聚合诊断,原二百项批量、调用次序和事实结果保持一致。 */ @Test void slowSuccessSummarizesOriginalBatchesWithoutBusinessContent() { var f=new DiagnosticFixture(); @@ -103,10 +118,10 @@ class AppointmentWorkFactAdapterTest { var business=(org.springframework.beans.factory.ObjectProvider)org.mockito.Mockito.mock(org.springframework.beans.factory.ObjectProvider.class); org.mockito.Mockito.when(business.orderedStream()).thenAnswer(c->java.util.stream.Stream.empty()); var adapter=new AppointmentWorkFactAdapter(null,business,null,providers,null,"token"); - var unused=adapter.openRequirements(java.util.List.of());org.mockito.Mockito.verifyNoInteractions(providers); + var unused=adapter.openRequirements();org.mockito.Mockito.verifyNoInteractions(providers); var source=new ContainerSource("1","CNT",java.time.Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.UNKNOWN,null,List.of()); var f=new WorkTestFixture(); - org.mockito.Mockito.when(f.gateway.openRequirements(java.util.List.of())).thenAnswer(c->adapter.openRequirements(java.util.List.of())); + org.mockito.Mockito.when(f.gateway.openRequirements()).thenAnswer(c->adapter.openRequirements()); org.mockito.Mockito.when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.sync.synchronize(new SyncRequest(null,10));assertEquals(Type.ROUTE,f.store.all().get(0).getType()); org.mockito.Mockito.verifyNoInteractions(providers); @@ -114,14 +129,14 @@ class AppointmentWorkFactAdapterTest { var provider=org.mockito.Mockito.mock(RequirementProvider.class); var delegate=org.mockito.Mockito.mock(RequirementReader.class); org.mockito.Mockito.when(providers.getIfAvailable()).thenReturn(provider); - org.mockito.Mockito.when(provider.openRequirements(java.util.List.of())).thenReturn(delegate); + org.mockito.Mockito.when(provider.openRequirements()).thenReturn(delegate); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); org.mockito.Mockito.when(delegate.read(source,Flow.TRANSFER)).thenReturn(List.of(requirement),List.of(requirement),List.of()); - var reader=adapter.openRequirements(java.util.List.of()); + var reader=adapter.openRequirements(); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER)); assertThrows(ResponseStatusException.class,()->reader.read(source,Flow.TRANSFER)); - org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(java.util.List.of()); + org.mockito.Mockito.verify(provider,org.mockito.Mockito.times(1)).openRequirements(); } @Test void incompleteAndDuplicateAuthorityResultsAreFailuresNotEmptySuccess() { var f=new WorkTestFixture(); var state=f.state("1",Type.EMPTY_RETURN,Flow.DIRECT,null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java index 172367892..af142038b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java @@ -79,7 +79,7 @@ class AppointmentWorkPersistenceTest { var requirement=new AppointmentWorkFactGateway.Requirement(AppointmentWorkPolicy.customerRequirement(later.batchKey(),"B"),later.batchKey(),"AP-2","FBA","海外仓","ONT8",null,List.of(),List.of(),List.of(new SourceReference("B","ORDER:2","FBA:ONT8"))); var source=new AppointmentWorkFactGateway.ContainerSource("1","CNT",null,LocalDateTime.of(2026,10,1,12,0),null,"v1",Flow.TRANSFER,null,List.of(requirement)); var facts=org.mockito.Mockito.mock(AppointmentWorkFactGateway.class); - org.mockito.Mockito.when(facts.openRequirements(org.mockito.ArgumentMatchers.anyList())).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); + org.mockito.Mockito.when(facts.openRequirements()).thenAnswer(c->(AppointmentWorkFactGateway.RequirementReader)(row,flow)->List.of(requirement)); tx.executeWithoutResult(status->new AppointmentWorkSyncService(store,facts).generate(source,Flow.TRANSFER,List.of(),ACTOR)); assertEquals(persisted.owner(),store.get(key).getOwner());assertEquals(persisted.nextFollowupDate(),store.get(key).getFollowupDate()); var third=new com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.Member("FBA_APPOINTMENT:3","B"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java index a0a419b3d..0665d195b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java @@ -12,6 +12,35 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkQueryServiceTest { + /** 开启计时不改变FOLLOW_TODAY分页、汇总与日历,最终后处理各自有独立桶。 */ + @Test void diagnosticScopePreservesRealListSummaryAndCalendarResults(){ + var f=new WorkTestFixture();var state=f.state("private-source",Type.PICKUP,Flow.TRANSFER,null);state.setFollowupDate(LocalDate.of(2026,10,14));f.add(state); + var expectedItems=f.queries.items(View.FOLLOW_TODAY,1,8,null,null,null,null,null,null,ACTOR); + var expectedSummary=f.queries.summary(ACTOR);var from=LocalDate.of(2026,10,1);var to=LocalDate.of(2026,10,31); + var expectedCalendar=f.queries.calendar(from,to,null,null,ACTOR); + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.ITEMS)){ + assertEquals(expectedItems,f.queries.items(View.FOLLOW_TODAY,1,8,null,null,null,null,null,null,ACTOR)); + assertEquals(expectedSummary,f.queries.summary(ACTOR));assertEquals(expectedCalendar,f.queries.calendar(from,to,null,null,ACTOR)); + assertEquals(3,scope.snapshot().metrics().get("QUERY_FINAL").calls()); + } + } + /** 详情阶段计时不得增加事实、完整工作集或历史读取,也不改变返回值。 */ + @Test void diagnosticScopeMeasuresOriginalDetailStagesWithoutAdditionalReads() { + var f=new WorkTestFixture();var state=f.state("private-container",Type.PICKUP,Flow.TRANSFER,null);f.add(state); + var expected=f.queries.detail(state.getWorkItemKey(),ACTOR);clearInvocations(f.gateway,f.mapper); + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)) { + assertEquals(expected,f.queries.detail(state.getWorkItemKey(),ACTOR)); + var metrics=scope.snapshot().metrics(); + for(var stage:List.of("QUERY_GUARD","QUERY_DISCOVER","QUERY_STORE","QUERY_SELECT","QUERY_FACTS","QUERY_PROJECT","QUERY_NEXT","DETAIL_STORE","DETAIL_HISTORY"))assertEquals(1,metrics.get(stage).calls(),stage); + assertFalse(scope.snapshot().toString().contains("private-container")); + verify(f.gateway).facts(anyList());verify(f.gateway).discover(null,1);verify(f.mapper).all(anyInt());verify(f.mapper).selectHistory(state.getWorkItemKey()); + } + assertFalse(com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.active()); + } @Test void sameBatchRequiresExplicitCustomerAndNeverChoosesTheFirstCandidate() { var f=new WorkTestFixture();var expected=new HashMap(); for(var customer:List.of("A","B")) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java index 670828b74..e234e88cb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java @@ -12,7 +12,20 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; class AppointmentWorkSyncServiceTest { - /** 百柜同页批量读取来源原料,下页与独立命令重读,生成状态保持不变。 */ + /** 启用诊断不增加同步回读或事实批次,原完整发布结果保持一致。 */ + @Test void diagnosticScopePreservesOriginalSyncReadCountsAndResult(){ + var f=new WorkTestFixture();when(f.gateway.discover(null,100)).thenReturn(new SourcePage(List.of(),null,true,"scope")); + var expected=f.sync.synchronize(new SyncRequest(null,100));clearInvocations(f.gateway,f.mapper); + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.SYNC, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.INTERNAL)){ + assertEquals(expected,f.sync.synchronize(new SyncRequest(null,100))); + var metrics=scope.snapshot().metrics(); + for(var stage:List.of("SYNC_BODY","SYNC_GUARD","SYNC_DISCOVER","SYNC_GENERATE","SYNC_TRANSPORT_FACTS","SYNC_RECONCILE","SYNC_ACTIVE_STORE","SYNC_ACTIVE_FACTS","SYNC_CHECKPOINT"))assertEquals(1,metrics.get(stage).calls(),stage); + verify(f.gateway,times(2)).facts(anyList());verify(f.mapper,times(3)).all(anyInt());verify(f.mapper).lockGuard();verify(f.mapper).saveGuard(any()); + } + } + /** 真实需求提供链百柜只读一次目录,下页和命令生成独立重读;来源查询仍逐次执行。 */ @Test void hundredFbaContainersShareOnlyCatalogWithinOneGenerationScope() { var f=new WorkTestFixture(); var orders=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); @@ -31,7 +44,6 @@ class AppointmentWorkSyncServiceTest { var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate();gate.setContainerBookingId(id);gate.setNodeSequence(40);return gate; }).toList()); var sources=new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService(orders,shipping,external,warehouse,"token"); - org.springframework.test.util.ReflectionTestUtils.setField(sources,"readRepository",new com.baiyue.lastleg.appointmentplanning.AppointmentSourceReadRepository(orders,shipping,mock(com.baiyue.lastleg.appointmentplanning.AppointmentCoverageMapper.class))); var cargo=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.class); var allocation=mock(com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService.Context.class); when(allocation.availablePackages()).thenReturn(1L);when(cargo.inspect(any(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source.class))).thenReturn(allocation); @@ -40,18 +52,13 @@ class AppointmentWorkSyncServiceTest { new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",1));}); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo, mock(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService.class),mapping); - wireBatches(provider,cargo,mapping); - when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0))); + when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); var rows=java.util.stream.IntStream.rangeClosed(1,100).mapToObj(i->new ContainerSource(""+i,"CNT"+i, Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null, List.of(new Requirement("INBOUND:"+i,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(1)).catalog("token"); - verify(orders,times(1)).selectBatchIds(anyCollection()); - verify(external,times(1)).resolveOrders(anyList()); - var reads=(com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository)org.springframework.test.util.ReflectionTestUtils.getField(provider,"reads"); - verify(reads,times(1)).sources(argThat(ids->ids.size()==100 && new HashSet<>(ids).size()==100)); - verify(cargo,times(1)).prepareInspection(argThat(values->values.size()==100)); + verify(orders,times(100)).selectBatchIds(anyCollection()); var before=f.store.all().stream().map(f.store::write).toList(); f.sync.synchronize(new SyncRequest(null,100));verify(warehouse,times(2)).catalog("token"); assertEquals(before,f.store.all().stream().map(f.store::write).toList()); @@ -59,27 +66,7 @@ class AppointmentWorkSyncServiceTest { verify(warehouse,times(3)).catalog("token"); f.sync.generate(rows.get(0),Flow.TRANSFER,f.store.container("1"),ACTOR); verify(warehouse,times(4)).catalog("token"); - verify(orders,times(4)).selectBatchIds(anyCollection()); - verify(reads,times(4)).sources(anyList());verify(cargo,times(4)).prepareInspection(anyList()); - } - - /** 预取提示不能抢先解析后方坏来源,原循环仍拒绝且不能发布扫描完成。 */ - @Test void invalidSourcesRemainOriginalOrderedFailuresInsteadOfBeingIgnoredByPreparation() { - var valid=new ContainerSource("1","CNT",Instant.parse("2026-10-01T00:00:00Z"),null,null,"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()))); - var missingId=new ContainerSource(null,"CNT",valid.departedAt(),null,null,"v1",Flow.TRANSFER,null,valid.requirements()); - var missingRequirements=new ContainerSource("2","CNT2",valid.departedAt(),null,null,"v1",Flow.TRANSFER,null,null); - for(var rows:List.of(java.util.Arrays.asList((ContainerSource)null),List.of(missingId),java.util.Arrays.asList(valid,null),List.of(valid,missingRequirements))) { - var f=new WorkTestFixture(); - when(f.gateway.discover(null,100)).thenReturn(new SourcePage(rows,null,true,"scope")); - when(f.gateway.openRequirements(anyList())).thenAnswer(c->{ - var hints=c.>getArgument(0); - assertEquals(rows.get(0)==valid?List.of(new RequirementInput(valid,Flow.TRANSFER)):List.of(),hints); - return (RequirementReader)(source,flow)->source.requirements(); - }); - var error=assertThrows(ResponseStatusException.class,()->f.sync.synchronize(new SyncRequest(null,100))); - assertEquals(503,error.getStatusCode().value());assertEquals("已开船来源缺少身份或真实开船依据",error.getReason()); - verify(f.mapper,never()).saveGuard(any()); - } + verify(orders,times(202)).selectBatchIds(anyCollection()); } /** 百柜同页只读三次完整工作集;新路线、既有历史及重复同步都不丢失。 */ @@ -129,9 +116,8 @@ class AppointmentWorkSyncServiceTest { var mapping=mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class); var scope=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.ReadScope.class); when(sources.openReadScope()).thenReturn(scope); - when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.>>getArgument(0).stream().map(group->{var r=sources.canonicalOrders(group);return new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.OrderGroup(r,sources.resolveBatch(r),null);}).toList()); + when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); var provider=new com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); - wireBatches(provider,cargo,mapping); when(mapping.containerSources("1")).thenReturn(List.of(new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(1L,"A","1","1","FBA","ONT8",1),new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.Source(2L,"B","2","1","FBA","ONT8",1))); when(sources.canonicalOrders(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef(r.customerId(),r.sourceKey(),r.destinationKey())).toList()); when(sources.resolveBatch(anyList())).thenReturn(refs.stream().map(r->new com.baiyue.lastleg.appointmentplanning.AppointmentSourceService.Source(r.sourceKey(),r.customerId(),Long.valueOf(r.sourceKey().substring(6)),null,null,"CNT",null,null,r.destinationKey(),"ONT8",1,"v1",true,List.of())).toList()); @@ -144,7 +130,7 @@ class AppointmentWorkSyncServiceTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(snapshot)); var root=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),refs); var source=new ContainerSource("1","CNT",null,LocalDateTime.parse("2026-10-01T10:00:00"),null,"v1",Flow.TRANSFER,null,List.of(root)); - when(f.gateway.openRequirements(anyList())).thenAnswer(c->provider.openRequirements(c.getArgument(0))); + when(f.gateway.openRequirements()).thenAnswer(c->provider.openRequirements()); when(f.gateway.discover(null,10)).thenReturn(new SourcePage(List.of(source),null,true,"scope")); f.guard.setRequirementVersion(1); f.sync.synchronize(new SyncRequest(null,10)); @@ -400,13 +386,4 @@ class AppointmentWorkSyncServiceTest { assertFalse(event.getFormattedMessage().contains("private-source-detail"));assertNull(event.getThrowableProxy()); } finally { logger.detachAppender(appender);appender.stop(); } } - /** 用既有原事实模拟组装回归,百柜来源查询由真实来源服务计数验证。 */ - private static void wireBatches(com.baiyue.lastleg.inboundplan.work.AppointmentWorkRequirementProvider provider,com.baiyue.lastleg.appointmentplanning.AppointmentCargoVerificationService cargo,com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper mapping) { - var reads=mock(com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository.class); - when(reads.sources(anyList())).thenAnswer(c->c.>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList()); - org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads); - when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect); - var appointments=(com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService)org.springframework.test.util.ReflectionTestUtils.getField(provider,"appointments"); - when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.>>getArgument(0);return (java.util.function.IntFunction>)(i->appointments.relatedBatchSnapshots(groups.get(i)));}); - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java index e621910b4..bc953d55a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java @@ -38,7 +38,7 @@ final class WorkTestFixture { when(gateway.facts(anyList())).thenAnswer(c->{ var result=new LinkedHashMap(); for(var state:c.>getArgument(0)) if(state.getType()!=Type.ROUTE) result.put(state.getWorkItemKey(),facts.getOrDefault(state.getWorkItemKey(),fact(state,null,null,ExecutionStatus.PENDING,null))); return result; }); when(gateway.discover(isNull(),anyInt())).thenReturn(new SourcePage(List.of(),null,true,"scope")); when(gateway.owner(eq("1"),any())).thenReturn(new Owner("1","运营甲")); - when(gateway.openRequirements(anyList())).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); + when(gateway.openRequirements()).thenAnswer(c->(RequirementReader)(source,flow)->source.requirements()); } AppointmentWorkState state(String container,Type type,Flow flow,String requirement) { var state=new AppointmentWorkState(); state.setWorkItemKey(key(container,type,requirement)); state.setContainerBookingId(container); state.setContainerNo("柜"+container); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java new file mode 100644 index 000000000..60ea11f11 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.*; +import jakarta.servlet.http.HttpServletResponse; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +class AppointmentWorkDiagnosticFilterTest { + /** 路由模板不保存路径参数或querystring,其他业务入口不建立scope。 */ + @Test void fixedRoutesDoNotRetainBusinessIdsAndUnrelatedEndpointsPassThrough()throws Exception{ + var filter=new AppointmentWorkDiagnosticFilter(); + var request=new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/items/private-id");request.setQueryString("token=private-secret"); + filter.doFilter(request,new MockHttpServletResponse(),(r,s)->{ + assertEquals(Route.DETAIL,AppointmentWorkDiagnostics.currentSnapshot().route()); + assertFalse(AppointmentWorkDiagnostics.currentSnapshot().toString().contains("private-")); + });assertFalse(active()); + filter.doFilter(new MockHttpServletRequest("GET","/other"),new MockHttpServletResponse(),(r,s)->assertFalse(active())); + } + /** 原filter异常原样透传,随后线程作用域清理。 */ + @Test void originalFailureIsNotReplacedAndScopeAlwaysCloses(){ + var error=new IllegalStateException("private-secret");var filter=new AppointmentWorkDiagnosticFilter(); + assertSame(error,assertThrows(IllegalStateException.class,()->filter.doFilter(new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/items"),new MockHttpServletResponse(),(r,s)->{throw error;}))); + assertFalse(active()); + } + /** 响应状态的诊断读取出错也不能阻止finally清理或覆盖原异常。 */ + @Test void brokenResponseStatusObservationCannotMaskBusinessFailure(){ + var response=mock(HttpServletResponse.class);when(response.getStatus()).thenThrow(new AssertionError("response failed")); + var error=new IllegalStateException("original");var filter=new AppointmentWorkDiagnosticFilter(); + assertSame(error,assertThrows(IllegalStateException.class,()->filter.doFilter(new MockHttpServletRequest("GET","/api/platform/v1/last-leg/appointment-work/summary"),response,(r,s)->{throw error;}))); + assertFalse(active()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java new file mode 100644 index 000000000..468c4f2aa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java @@ -0,0 +1,287 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.Actor; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.SyncRequest; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.SyncResult; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.WorkDetail; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkQueryService; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkStore; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkSyncService; +import com.zaxxer.hikari.HikariDataSource; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.plugin.Interceptor; +import org.apache.ibatis.session.SqlSessionFactory; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.support.AopUtils; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.AopTestUtils; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionSynchronization; +import javax.sql.DataSource; +import java.sql.Connection; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowable; +import static org.mockito.Mockito.mock; + +/** 使用真实Spring注册、Hikari及内存H2验证诊断不改变原事务与连接生命周期。 */ +class AppointmentWorkDiagnosticsInfrastructureTest { + /** 每个场景都必须退出当前线程的诊断作用域,不能污染后续测试或请求。 */ + @AfterEach + void noScopeLeaksFromTheTestThread() { + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + assertThat(AppointmentWorkDiagnostics.currentScopeId()).isNull(); + } + + /** 解包保持容器Bean自身语义和配置委托;内部target身份不同,但池与关闭行为不变。 */ + @Test + void hikariProxyRemainsInjectableAndTransactionAndMybatisUseTheSameBean() throws Exception { + HikariDataSource original; + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var dataSource=context.getBean(HikariDataSource.class); + original=AopTestUtils.getUltimateTargetObject(dataSource); + assertThat(AopUtils.isCglibProxy(dataSource)).isTrue(); + assertThat(dataSource).isNotSameAs(original); + assertThat(original.unwrap(HikariDataSource.class)).isSameAs(original); + assertThat(dataSource.unwrap(HikariDataSource.class)).isSameAs(dataSource); + assertThat(dataSource.getJdbcUrl()).isEqualTo(original.getJdbcUrl()); + assertThat(dataSource.getUsername()).isEqualTo(original.getUsername()); + assertThat(dataSource.getPassword()).isEqualTo(original.getPassword()); + assertThat(dataSource.getDataSourceProperties()).isSameAs(original.getDataSourceProperties()); + assertThat(dataSource.isWrapperFor(HikariDataSource.class)).isTrue(); + assertThat(context.getBean(ObservingTransactionManager.class).getDataSource()).isSameAs(dataSource); + assertThat(context.getBean(SqlSessionFactory.class).getConfiguration().getEnvironment().getDataSource()).isSameAs(dataSource); + try(Connection connection=dataSource.getConnection()) { + assertThat(connection.getClass().getName()).startsWith("com.zaxxer.hikari.pool.HikariProxyConnection"); + assertThat(dataSource.getHikariPoolMXBean()).isSameAs(original.getHikariPoolMXBean()); + assertThat(original.getHikariPoolMXBean().getActiveConnections()).isEqualTo(1); + } + assertThat(original.getHikariPoolMXBean().getActiveConnections()).isZero(); + assertThat(original.isClosed()).isFalse(); + } + assertThat(original.isClosed()).isTrue(); + } + + /** 自动注册的诊断Advisor必须包住事务提交,Mapper连续调用共用同一事务连接。 */ + @Test + void productionAdvisorStaysActiveThroughCommitAndPreservesTheCommittedWrite() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var probe=context.getBean(Probe.class); + var service=context.getBean(QueryProbe.class); + assertThat(AopUtils.isAopProxy(service)).isTrue(); + service.detail("commit",null); + assertThat(probe.bodyScope).isNotNull(); + assertThat(probe.events).containsExactly("body","commit-before","commit-after"); + assertThat(probe.transactionScopes).containsOnly(probe.bodyScope); + assertThat(probe.transactionActive).isTrue(); + assertThat(probe.boundResource).isTrue(); + assertThat(probe.firstSession).isEqualTo(probe.secondSession); + assertThat(probe.bodySnapshot.metrics().get("CONNECTION_ACQUIRE").calls()).isEqualTo(1); + assertThat(probe.bodySnapshot.metrics().get("DB_EXECUTOR_UPDATE:OTHER").calls()).isEqualTo(1); + assertThat(context.getBean(ProbeMapper.class).count()).isEqualTo(1); + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + } + } + + /** 原业务异常不能被诊断替换,回滚完成时仍有同一作用域,结束后清理。 */ + @Test + void originalFailureRollsBackAndEscapesUnchangedAfterDiagnosticCleanup() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var probe=context.getBean(Probe.class); + var failure=new IllegalStateException("原业务失败");probe.failure=failure; + assertThat(catchThrowable(()->context.getBean(QueryProbe.class).detail("rollback",null))).isSameAs(failure); + assertThat(probe.events).containsExactly("body","rollback-before","rollback-after"); + assertThat(probe.transactionScopes).containsOnly(probe.bodyScope); + assertThat(context.getBean(ProbeMapper.class).count()).isZero(); + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + } + } + + /** 已有HTTP作用域由查询复用,同步开启子作用域后恢复外层,均在finally中关闭。 */ + @Test + void httpScopeIsReusedAndNestedSyncRestoresItWithoutThreadLocalResidue() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var probe=context.getBean(Probe.class); + try(var outer=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) { + String outerId=AppointmentWorkDiagnostics.currentScopeId(); + context.getBean(QueryProbe.class).detail("outer",null); + assertThat(probe.bodyScope).isEqualTo(outerId); + assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(outerId); + context.getBean(SyncProbe.class).synchronize(new SyncRequest(null,1)); + assertThat(probe.syncScope).isNotNull().isNotEqualTo(outerId); + assertThat(probe.syncParent).isEqualTo(outerId); + assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(outerId); + assertThat(outer.snapshot().scopeId()).isEqualTo(outerId); + assertThat(outer.snapshot().metrics().get("TRANSACTION_ADVISOR").calls()).isEqualTo(1); + } + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + } + } + + /** 请求外的连接及SQL不创建诊断作用域,也不会被计入之后新开的请求。 */ + @Test + void outOfScopeDatabaseWorkDoesNotCreateOrPolluteTheNextScope() throws Exception { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + context.getBean(ProbeMapper.class).count(); + try(var connection=context.getBean(DataSource.class).getConnection()) { assertThat(connection.isClosed()).isFalse(); } + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + try(var scope=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) { + assertThat(scope.snapshot().metrics()).isEmpty(); + context.getBean(ProbeMapper.class).count(); + assertThat(scope.snapshot().metrics()).isNotEmpty(); + } + } + } + + /** 真正的afterCompletion分别记录提交和回滚,不能把方法返回时间当作事务完成时间。 */ + @Test + void realTransactionCompletionRecordsStatusAndOffsetBeforeTheHttpScopeCloses() { + for(boolean rollback:List.of(false,true)) { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var probe=context.getBean(Probe.class); + var original=new IllegalStateException("真实事务回滚"); + if(rollback)probe.failure=original; + try(var scope=AppointmentWorkDiagnostics.open(AppointmentWorkDiagnostics.Origin.HTTP,AppointmentWorkDiagnostics.Route.INTERNAL)) { + var error=catchThrowable(()->context.getBean(QueryProbe.class).detail("completion",null)); + if(rollback)assertThat(error).isSameAs(original);else assertThat(error).isNull(); + assertThat(probe.bodySnapshot.transactionObserved()).isTrue(); + assertThat(probe.bodySnapshot.completionStatus()).isEqualTo(-1); + assertThat(probe.bodySnapshot.completionOffsetNanos()).isEqualTo(-1); + var completed=scope.snapshot(); + assertThat(completed.transactionObserved()).isTrue(); + assertThat(completed.completionStatus()).isEqualTo(rollback + ?TransactionSynchronization.STATUS_ROLLED_BACK:TransactionSynchronization.STATUS_COMMITTED); + assertThat(completed.completionOffsetNanos()).isBetween(0L,completed.elapsedNanos()); + assertThat(AppointmentWorkDiagnostics.currentScopeId()).isEqualTo(completed.scopeId()); + } + assertThat(AppointmentWorkDiagnostics.active()).isFalse(); + assertThat(context.getBean(ProbeMapper.class).count()).isEqualTo(rollback?0:1); + } + } + } + + interface ProbeMapper { + /** 测试事务只向本地内存表写一行,验证原事务结果。 */ + @Insert("INSERT INTO diagnostic_probe(id) VALUES(1)") + int insert(); + /** 直接执行两次查询,确认实际事务复用同一H2会话。 */ + @Select("SELECT SESSION_ID()") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + long sessionId(); + /** 在事务外核对提交或回滚后的真实行数。 */ + @Select("SELECT COUNT(*) FROM diagnostic_probe") @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + int count(); + } + + static final class Probe { + final List events=new ArrayList<>(); + final List transactionScopes=new ArrayList<>(); + String bodyScope; + String syncScope; + String syncParent; + AppointmentWorkDiagnostics.Snapshot bodySnapshot; + long firstSession; + long secondSession; + boolean transactionActive; + boolean boundResource; + RuntimeException failure; + } + + public static class QueryProbe extends AppointmentWorkQueryService { + private final ProbeMapper mapper; + private final Probe probe; + private final DataSource dataSource; + /** 使用生产查询服务类型及原方法签名触发生产pointcut,不新增诊断匹配规则。 */ + QueryProbe(ProbeMapper mapper,Probe probe,DataSource dataSource) { + super(mock(AppointmentWorkStore.class),mock(AppointmentWorkFactGateway.class)); + this.mapper=mapper;this.probe=probe;this.dataSource=dataSource; + } + /** 只替换测试业务体,真实Spring事务、连接获取和生产诊断链不替换。 */ + @Override @Transactional(readOnly=false) + public WorkDetail detail(String key,Actor actor) { + AppointmentWorkDiagnostics.observeTransaction(); + probe.bodyScope=AppointmentWorkDiagnostics.currentScopeId();probe.events.add("body"); + probe.transactionActive=TransactionSynchronizationManager.isActualTransactionActive(); + probe.boundResource=TransactionSynchronizationManager.hasResource(dataSource); + mapper.insert();probe.firstSession=mapper.sessionId();probe.secondSession=mapper.sessionId(); + probe.bodySnapshot=AppointmentWorkDiagnostics.currentSnapshot(); + if(probe.failure!=null)throw probe.failure; + return null; + } + } + + public static class SyncProbe extends AppointmentWorkSyncService { + private final Probe probe; + /** 使用原同步服务类型与方法,使生产Advisor执行真正的嵌套作用域规则。 */ + SyncProbe(Probe probe) { super(mock(AppointmentWorkStore.class),mock(AppointmentWorkFactGateway.class));this.probe=probe; } + /** 同步探针不修改业务数据,仅记录事务内当前作用域。 */ + @Override @Transactional + public SyncResult synchronize(SyncRequest request) { + probe.syncScope=AppointmentWorkDiagnostics.currentScopeId(); + probe.syncParent=AppointmentWorkDiagnostics.currentSnapshot().parentScopeId(); + return new SyncResult(0,0,null,true); + } + } + + static class ObservingTransactionManager extends DataSourceTransactionManager { + private final Probe probe; + /** 在原事务管理器提交及回滚两侧观察诊断作用域,不替换数据库动作。 */ + ObservingTransactionManager(DataSource dataSource,Probe probe) { super(dataSource);this.probe=probe; } + /** 诊断必须覆盖真实提交前后,不能只包住业务方法体。 */ + @Override protected void doCommit(DefaultTransactionStatus status) { + observe("commit-before");super.doCommit(status);observe("commit-after"); + } + /** 原异常回滚路径同样保持作用域到数据库回滚完成。 */ + @Override protected void doRollback(DefaultTransactionStatus status) { + observe("rollback-before");super.doRollback(status);observe("rollback-after"); + } + /** 只记录执行顺序和内部scope标识,不收集凭据、SQL参数或业务资料。 */ + private void observe(String phase) { probe.events.add(phase);probe.transactionScopes.add(AppointmentWorkDiagnostics.currentScopeId()); } + } + + @Configuration(proxyBeanMethods=false) + @EnableTransactionManagement(proxyTargetClass=true) + @Import(AppointmentWorkDiagnosticsConfiguration.class) + static class Config { + /** 使用独立内存H2,池大小有界且由Spring负责关闭。 */ + @Bean(destroyMethod="close") HikariDataSource dataSource() { + var source=new HikariDataSource();source.setJdbcUrl("jdbc:h2:mem:diagnostic-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + source.setUsername("sa");source.setPassword("");source.setMaximumPoolSize(2);source.setMinimumIdle(0);return source; + } + /** 本地测试共享观测记录,不影响生产诊断实现。 */ + @Bean Probe probe() { return new Probe(); } + /** 将生产后处理后的同一个DataSource交给真实Spring事务管理器。 */ + @Bean ObservingTransactionManager transactionManager(DataSource dataSource,Probe probe) { return new ObservingTransactionManager(dataSource,probe); } + /** 使用生产注册的MyBatis插件与同一个DataSource,禁止另造未代理工厂。 */ + @Bean SqlSessionFactory sqlSessionFactory(DataSource dataSource,List interceptors) throws Exception { + new JdbcTemplate(dataSource).execute("CREATE TABLE diagnostic_probe(id INT PRIMARY KEY)"); + var configuration=new MybatisConfiguration();configuration.addMapper(ProbeMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setConfiguration(configuration);factory.setDataSource(dataSource); + factory.setPlugins(interceptors.toArray(Interceptor[]::new));return factory.getObject(); + } + /** 模板按原事务绑定复用连接,不直接创建业务SQL连接。 */ + @Bean SqlSessionTemplate template(SqlSessionFactory factory) { return new SqlSessionTemplate(factory); } + /** 真实Mapper由SqlSessionTemplate生成。 */ + @Bean ProbeMapper mapper(SqlSessionTemplate template) { return template.getMapper(ProbeMapper.class); } + /** 原查询类型交给Spring自动代理,测试不手动构造ProxyFactory。 */ + @Bean QueryProbe queryProbe(ProbeMapper mapper,Probe probe,DataSource dataSource) { return new QueryProbe(mapper,probe,dataSource); } + /** 原同步类型交给同一生产Advisor自动匹配。 */ + @Bean SyncProbe syncProbe(Probe probe) { return new SyncProbe(probe); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java new file mode 100644 index 000000000..278396684 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import ch.qos.logback.classic.Logger; +import ch.qos.logback.classic.spi.ILoggingEvent; +import ch.qos.logback.core.AppenderBase; +import ch.qos.logback.core.read.ListAppender; +import org.junit.jupiter.api.*; +import org.slf4j.*; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +class AppointmentWorkDiagnosticsTest { + /** 每项隔离测试都必须释放线程上下文。 */ + @AfterEach void noLeak(){assertFalse(active());MDC.clear();} + /** 同桶嵌套按完成次序累加,首尾时刻仍覆盖外层且self不重复相加。 */ + @Test void nestedSameBucketTracksInclusiveSelfAndTrueOuterOffsets(){ + var clock=new AtomicLong(); + try(var scope=new Scope(Origin.QUERY,Route.INTERNAL,clock::get)){ + clock.set(10);var outer=span(Stage.QUERY_FACTS);clock.set(20);var inner=span(Stage.QUERY_FACTS); + clock.set(50);inner.close();clock.set(100);outer.close();outer.close(); + var metric=scope.snapshot().metrics().get("QUERY_FACTS"); + assertEquals(2,metric.calls());assertEquals(120,metric.inclusiveNanos());assertEquals(90,metric.selfNanos()); + assertEquals(10,metric.firstOffsetNanos());assertEquals(100,metric.lastOffsetNanos()); + } + } + /** 独立同步scope仅以父标识关联,父self不假称已扣除另一作用域。 */ + @Test void independentScopeRestoresParentAndDoesNotSubtractItsIntervals(){ + var clock=new AtomicLong();MDC.put("traceId","safe-trace"); + try(var outer=new Scope(Origin.HTTP,Route.ITEMS,clock::get)){ + var span=span(Stage.QUERY_FACTS);clock.set(10); + try(var child=new Scope(Origin.SYNC,Route.INTERNAL,clock::get)){ + assertEquals(outer.snapshot().scopeId(),child.snapshot().parentScopeId()); + try(var nested=span(Stage.SYNC_BODY)){clock.set(50);} + } + assertEquals(outer.snapshot().scopeId(),currentScopeId());clock.set(100);span.close(); + assertEquals(100,outer.snapshot().metrics().get("QUERY_FACTS").selfNanos()); + } + assertEquals("safe-trace",MDC.get("traceId")); + } + /** 快速异常即使被上层映射为HTTP200,仍输出一次失败摘要且不含原异常文本。 */ + @Test void swallowedFastFailureStillMarksScopeAndPreservesOriginalException(){ + try(var log=new LogCapture();var scope=open(Origin.HTTP,Route.COMMAND)){ + var error=new IllegalStateException("customer-secret-business-id"); + assertSame(error,assertThrows(IllegalStateException.class,()->measure(Stage.QUERY_FACTS,()->{throw error;}))); + scope.status(200);assertTrue(scope.snapshot().failed());scope.close(); + assertEquals(1,log.appender.list.size());var text=log.appender.list.get(0).getFormattedMessage(); + assertTrue(text.contains("failed=true"));assertFalse(text.contains("customer-secret"));assertNull(log.appender.list.get(0).getThrowableProxy()); + } + } + /** 诊断日志设施自身抛错时清理仍完成,不向业务调用传播。 */ + @Test void loggingFailureDoesNotEscapeOrLeakScope(){ + var logger=(Logger)LoggerFactory.getLogger(AppointmentWorkDiagnostics.class); + var failing=new AppenderBase(){ + /** 模拟日志输出设施故障,不借业务异常路径。 */ + @Override protected void append(ILoggingEvent event){throw new AssertionError("logger failed");} + };failing.start();logger.addAppender(failing); + try{var scope=open(Origin.QUERY,Route.INTERNAL);scope.failed();assertDoesNotThrow(scope::close);assertFalse(active());} + finally{logger.detachAppender(failing);failing.stop();} + } + /** 快速成功无摘要,返回对象和调用次数均保持原样。 */ + @Test void successfulFastOperationIsTransparentAndOutOfScopeIsNoOp(){ + var value=new Object();var calls=new AtomicLong(); + assertSame(value,measure(Stage.QUERY_FACTS,()->{calls.incrementAndGet();return value;}));assertFalse(active()); + try(var log=new LogCapture();var scope=new Scope(Origin.QUERY,Route.INTERNAL,()->0)){ + assertSame(value,measure(Stage.QUERY_FACTS,()->{calls.incrementAndGet();return value;}));scope.close();assertTrue(log.appender.list.isEmpty()); + }assertEquals(2,calls.get()); + } + /** 非诊断格式的MDC值不进入摘要,原MDC内容不会被修改。 */ + @Test void arbitraryMdcTextIsExcludedWithoutChangingCallerContext(){ + MDC.put("requestId","customer password\nsecret");MDC.put("traceId","a".repeat(32)); + try(var scope=open(Origin.QUERY,Route.INTERNAL)){ + assertNull(scope.snapshot().requestId());assertEquals("a".repeat(32),scope.snapshot().traceId()); + }assertEquals("customer password\nsecret",MDC.get("requestId")); + } + static final class LogCapture implements AutoCloseable{ + final Logger logger=(Logger)LoggerFactory.getLogger(AppointmentWorkDiagnostics.class); + final ListAppender appender=new ListAppender<>(); + /** 只捕获本诊断logger,不改变全局日志配置。 */ + LogCapture(){appender.start();logger.addAppender(appender);} + /** 释放本测试附加器。 */ + @Override public void close(){logger.detachAppender(appender);appender.stop();} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java new file mode 100644 index 000000000..a8976c803 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.appointmentwork.diagnostics; + +import org.apache.ibatis.executor.Executor; +import org.apache.ibatis.mapping.*; +import org.apache.ibatis.builder.StaticSqlSource; +import org.apache.ibatis.session.*; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.*; + +class AppointmentWorkMyBatisDiagnosticsTest { + /** 插件不得重试、替换原异常或把动态语句标识与参数带入诊断。 */ + @Test void executorFailureIsTransparentAndBucketContextDoesNotLeak()throws Exception{ + var target=mock(Executor.class);var proxy=(Executor)new AppointmentWorkMyBatisDiagnostics().plugin(target); + var config=new Configuration(); + var statement=new MappedStatement.Builder(config,"private-customer-secret",new StaticSqlSource(config,"SELECT private_payload"),SqlCommandType.SELECT).build(); + var failure=new IllegalStateException("private-business-failure"); + when(target.query(statement,"private-token",RowBounds.DEFAULT,null)).thenThrow(failure); + try(var scope=open(Origin.QUERY,Route.INTERNAL)){ + assertSame(failure,assertThrows(IllegalStateException.class,()->proxy.query(statement,"private-token",RowBounds.DEFAULT,null))); + assertEquals(1,scope.snapshot().metrics().get("DB_EXECUTOR_QUERY:OTHER").failures()); + assertFalse(scope.snapshot().toString().contains("private-"));verify(target).query(statement,"private-token",RowBounds.DEFAULT,null); + } + var next=new MappedStatement.Builder(config,"com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.guard",new StaticSqlSource(config,"SELECT 1"),SqlCommandType.SELECT).build(); + var expected=List.of(new Object());when(target.query(next,null,RowBounds.DEFAULT,null)).thenReturn(expected); + try(var scope=open(Origin.QUERY,Route.INTERNAL)){ + assertSame(expected,proxy.query(next,null,RowBounds.DEFAULT,null)); + assertEquals(1,scope.snapshot().metrics().get("DB_EXECUTOR_QUERY:GUARD_READ").calls());assertFalse(scope.snapshot().metrics().containsKey("DB_EXECUTOR_QUERY:OTHER")); + }assertFalse(active());verify(target).query(next,null,RowBounds.DEFAULT,null);verifyNoMoreInteractions(target); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index 9471a3521..48402799f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -259,12 +259,6 @@ class FbaAppointmentPersistenceTest { @Bean AppointmentBatchPreviewMapper flags(){var mock=mock(AppointmentBatchPreviewMapper.class);when(mock.flags(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return ids.stream().map(id->new AppointmentBatchPreviewMapper.Flags(id,false,false,false)).toList();});return mock;} @Bean ObjectMapper json(){return new ObjectMapper().findAndRegisterModules();} @Bean AppointmentCoverageMapper destinationFacts(){return mock(AppointmentCoverageMapper.class);} - /** 新增只读原料端口仍接实际 Mapper;本测试未涉及船节点查询。 */ - @Bean AppointmentSourceReadRepository sourceReads(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,AppointmentCoverageMapper facts){return new AppointmentSourceReadRepository(orders,mock(com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper.class),facts);} - /** 核实原料沿真实持久 Mapper 读取,不替代事务断言。 */ - @Bean AppointmentCargoReadRepository cargoReads(AppointmentCargoVerificationMapper mapper){return new AppointmentCargoReadRepository(mapper);} - /** 历史预约原料沿本测试真实主单、明细及查询 Mapper 读取。 */ - @Bean com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository requirementReads(InboundFbaAppointmentQueryMapper query,InboundFbaAppointmentMapper appointments,InboundFbaAppointmentItemMapper items){return new com.baiyue.lastleg.inboundplan.work.AppointmentRequirementReadRepository(mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkSourceMapper.class),query,appointments,items);} @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java deleted file mode 100644 index 36e69f704..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentRequirementReadRepositoryTest.java +++ /dev/null @@ -1,244 +0,0 @@ -package com.baiyue.lastleg.inboundplan.work; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; -import com.baiyue.lastleg.inboundplan.entity.InboundFbaAppointmentItem; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentItemMapper; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentMapper; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper; -import com.baiyue.lastleg.inboundplan.mapper.InboundFbaAppointmentQueryMapper.RequirementSource; -import com.baiyue.lastleg.appointmentplanning.AppointmentCoverageService.SourceRef; -import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.apache.ibatis.executor.Executor; -import org.apache.ibatis.mapping.MappedStatement; -import org.apache.ibatis.plugin.Interceptor; -import org.apache.ibatis.plugin.Intercepts; -import org.apache.ibatis.plugin.Invocation; -import org.apache.ibatis.plugin.Signature; -import org.apache.ibatis.mapping.Environment; -import org.apache.ibatis.session.ResultHandler; -import org.apache.ibatis.session.RowBounds; -import org.apache.ibatis.session.SqlSession; -import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; -import org.h2.jdbcx.JdbcDataSource; -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; -import java.nio.charset.StandardCharsets; -import java.util.List; -import java.util.Objects; -import java.util.UUID; -import java.util.ArrayList; -import java.util.Collections; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -class AppointmentRequirementReadRepositoryTest { - /** 关联依据是原客户、来源及目的仓,不能因失活、取消或响应明细变空而消失。 */ - @Test - void historyKeepsInactiveAndReplacedAssociationsWithoutCrossCustomerOrDestinationLeak() throws Exception { - try (var fixture=new Fixture()) { - fixture.appointment(10,"CANCELLED",null,null); - fixture.appointment(20,"CANCELLED","[\"AP-30\"]","[\"FBA_APPOINTMENT:30\"]"); - fixture.appointment(30,"PENDING",null,null); - fixture.item(101,10,"A","ORDER:1","ABE4",false); - fixture.item(201,20,"A","ORDER:1","ABE4",false); - fixture.item(301,30,"A","ORDER:1","ABE4",true); - fixture.item(302,30,"B","ORDER:2","ABE4",true); - fixture.item(401,40,"B","ORDER:1","ABE4",true); - fixture.item(501,50,"A","ORDER:1","ONT8",true); - var refs=List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), - new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), - new RequirementSource(1,"B","ORDER:2","FBA:ABE4"), - new RequirementSource(2,"B","ORDER:1","FBA:ABE4")); - var history=fixture.repository.history(refs); - assertThat(history).filteredOn(row->row.groupOrdinal()==0).extracting(row->row.appointmentId()).containsExactly(30L,20L,10L); - assertThat(history).filteredOn(row->row.groupOrdinal()==1).extracting(row->row.appointmentId()).containsExactly(30L); - assertThat(history).filteredOn(row->row.groupOrdinal()==2).extracting(row->row.appointmentId()).containsExactly(40L); - assertThat(fixture.repository.items(List.of(10L,20L,30L))).hasSize(4) - .filteredOn(row->!Boolean.TRUE.equals(row.getActive())).extracting(InboundFbaAppointmentItem::getId).containsExactly(101L,201L); - assertThat(fixture.repository.appointments(List.of(20L))).singleElement().satisfies(row->{ - assertThat(row.getReplacementNosJson()).isEqualTo("[\"AP-30\"]"); - assertThat(row.getReplacementKeysJson()).isEqualTo("[\"FBA_APPOINTMENT:30\"]"); - }); - var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of( - List.of(new SourceRef("A","ORDER:1","FBA:ABE4")),List.of(new SourceRef("B","ORDER:2","FBA:ABE4")))); - assertThat(prepared.apply(0)).extracting(row->row.id()).containsExactly(10L,20L,30L); - assertThat(prepared.apply(0)).filteredOn(row->row.id()==10).singleElement().satisfies(row->{ - assertThat(row.appointmentStatus()).isEqualTo("CANCELLED"); - assertThat(row.items()).isEmpty(); - }); - assertThat(prepared.apply(0)).filteredOn(row->row.id()==20).singleElement().satisfies(row->{ - assertThat(row.items()).hasSize(1); - assertThat(row.replacementBatchKeys()).containsExactly("FBA_APPOINTMENT:30"); - }); - assertThat(prepared.apply(1)).singleElement().satisfies(row->{ - assertThat(row.id()).isEqualTo(30L); - assertThat(row.items()).extracting(item->item.customerId()).containsExactly("A","B"); - }); - } - } - - /** SQL 对每组各保留第1001条哨兵,不能以所有组总数截断历史。 */ - @Test - void windowLimitIsPerRequirementGroupAndDuplicateReferencesDoNotUseUpTheLimit() throws Exception { - try (var fixture=new Fixture()) { - for(int i=1;i<=1002;i++)fixture.item(i,i,"A","ORDER:1","ABE4",i%2==0); - for(int i=1;i<=1000;i++)fixture.item(10000+i,20000+i,"B","ORDER:2","ABE4",true); - var rows=fixture.repository.history(List.of(new RequirementSource(0,"A","ORDER:1","FBA:ABE4"), - new RequirementSource(0,"A","ORDER:1","FBA:ABE4"),new RequirementSource(1,"B","ORDER:2","FBA:ABE4"))); - assertThat(rows).hasSize(2001); - assertThat(rows).filteredOn(row->row.groupOrdinal()==0).hasSize(1001) - .extracting(row->row.appointmentId()).startsWith(1002L).endsWith(2L).doesNotContain(1L); - assertThat(rows).filteredOn(row->row.groupOrdinal()==1).hasSize(1000); - } - } - - /** 空输入直接结束,不向数据库提交无效IN列表。 */ - @Test - void emptyInputsReturnNoRows() throws Exception { - try (var fixture=new Fixture()) { - assertThat(fixture.repository.history(List.of())).isEmpty(); - assertThat(fixture.repository.appointments(List.of())).isEmpty(); - assertThat(fixture.repository.items(List.of())).isEmpty(); - assertThat(fixture.repository.sources(List.of())).isEmpty(); - } - } - - /** 100个来源和200个预约的分块不会退化成逐组SQL,预备结果重读不再访问数据库。 */ - @Test - void manyGroupsUseBoundedRealQueriesAndPreparedResponsesDoNotReadAgain() throws Exception { - try(var fixture=new Fixture()) { - var groups=new ArrayList>(); - for(int i=1;i<=201;i++) { - fixture.appointment(i,"PENDING",null,null); - fixture.item(i,i,"A","ORDER:"+i,"ABE4",true); - groups.add(List.of(new SourceRef("A","ORDER:"+i,"FBA:ABE4"))); - } - var prepared=fixture.service.prepareRelatedBatchSnapshots(groups); - for(int i=0;i<201;i++)assertThat(prepared.apply(i)).extracting(row->row.id()).containsExactly((long)i+1); - assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(3); - assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(2); - assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(2); - int before=fixture.counter.statements.size(); - assertThat(prepared.apply(0)).hasSize(1); - assertThat(fixture.counter.statements).hasSize(before); - } - } - - /** 同一需求的来源跨SQL分块时,相同预约只归属一次,空来源组仍独立。 */ - @Test - void oneGroupCrossingTheSourceChunkBoundaryDeduplicatesItsOriginalAppointment() throws Exception { - try(var fixture=new Fixture()) { - fixture.appointment(30,"PENDING",null,null); - fixture.item(301,30,"A","ORDER:1","ABE4",true); - fixture.item(302,30,"A","ORDER:2","ABE4",true); - var first=new ArrayList(); - for(int i=0;i<99;i++)first.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4")); - var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of(first,List.of( - new SourceRef("A","ORDER:1","FBA:ABE4"),new SourceRef("A","ORDER:2","FBA:ABE4")),List.of())); - assertThat(prepared.apply(0)).isEmpty(); - assertThat(prepared.apply(1)).singleElement().satisfies(row->{ - assertThat(row.id()).isEqualTo(30L);assertThat(row.items()).hasSize(2); - }); - assertThat(prepared.apply(2)).isEmpty(); - assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2); - assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(1); - } - } - - /** 每组1000条允许,1001条和101个原来源仅在访问本组时拒绝,不拖垮其他组。 */ - @Test - void groupLimitsAreCheckedAfterCompleteMergeAndErrorsStayWithTheirOriginalGroup() throws Exception { - try(var fixture=new Fixture()) { - for(int i=1;i<=1000;i++) { - fixture.appointment(i,"PENDING",null,null); - fixture.item(i,i,"A","ORDER:1","ABE4",true); - } - for(int i=1;i<=1001;i++)fixture.item(10000+i,20000+i,"B",i<=600?"ORDER:2":"ORDER:3","ABE4",false); - var validGroup=new ArrayList(); - validGroup.add(new SourceRef("A","ORDER:1","FBA:ABE4")); - for(int i=0;i<98;i++)validGroup.add(new SourceRef("NONE","ORDER:"+i,"FBA:ABE4")); - // 超限组的600条与401条来自两个SQL块,必须合并后才能准确拒绝。 - var prepared=fixture.service.prepareRelatedBatchSnapshots(List.of( - validGroup, - List.of(new SourceRef("B","ORDER:2","FBA:ABE4"),new SourceRef("B","ORDER:3","FBA:ABE4")), - Collections.nCopies(101,new SourceRef("A","ORDER:1","FBA:ABE4")))); - assertThat(prepared.apply(0)).hasSize(1000); - assertThatThrownBy(()->prepared.apply(1)).isInstanceOfSatisfying(ResponseStatusException.class, - error->assertThat(error.getStatusCode().value()).isEqualTo(503)); - assertThatThrownBy(()->prepared.apply(2)).isInstanceOfSatisfying(ResponseStatusException.class, - error->assertThat(error.getStatusCode().value()).isEqualTo(400)); - assertThat(fixture.counter.count("InboundFbaAppointmentMapper.selectBatchIds")).isEqualTo(5); - assertThat(fixture.counter.count("InboundFbaAppointmentItemMapper.selectList")).isEqualTo(5); - assertThat(fixture.counter.count(".requirementBatches")).isEqualTo(2); - } - } - - @Intercepts(@Signature(type=Executor.class,method="query",args={MappedStatement.class,Object.class,RowBounds.class,ResultHandler.class})) - private static final class QueryCounter implements Interceptor { - final List statements=new ArrayList<>(); - /** 记录真实Mapper执行入口,调用仍继续进入原SQL执行器。 */ - @Override public Object intercept(Invocation invocation) throws Throwable { - statements.add(((MappedStatement)invocation.getArgs()[0]).getId());return invocation.proceed(); - } - /** 按具体Mapper语句核对批量读取次数。 */ - long count(String suffix) { return statements.stream().filter(id->id.endsWith(suffix)).count(); } - } - - private static final class Fixture implements AutoCloseable { - final JdbcTemplate jdbc; - final SqlSession session; - final AppointmentRequirementReadRepository repository; - final InboundFbaAppointmentService service; - final QueryCounter counter=new QueryCounter(); - - /** 只在独立H2内使用基线表,并装配真实生产Mapper。 */ - Fixture() throws Exception { - var datasource=new JdbcDataSource(); - datasource.setURL("jdbc:h2:mem:requirement-history-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); - jdbc=new JdbcTemplate(datasource); - String baseline; - try(var input=getClass().getResourceAsStream("/db/lastleg.sql")) { - baseline=new String(Objects.requireNonNull(input).readAllBytes(),StandardCharsets.UTF_8); - } - for(var table:List.of("ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { - int start=baseline.indexOf("CREATE TABLE IF NOT EXISTS "+table+" ("); - assertThat(start).isGreaterThanOrEqualTo(0); - jdbc.execute(baseline.substring(start,baseline.indexOf(';',start)+1).replace(" STORED","").replaceAll(" ENGINE=InnoDB[^;]*;",";")); - } - var config=new MybatisConfiguration(); - config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),datasource)); - config.setMapUnderscoreToCamelCase(true); - config.addInterceptor(counter); - config.addMapper(AppointmentWorkSourceMapper.class); - config.addMapper(InboundFbaAppointmentQueryMapper.class); - config.addMapper(InboundFbaAppointmentMapper.class); - config.addMapper(InboundFbaAppointmentItemMapper.class); - session=new MybatisSqlSessionFactoryBuilder().build(config).openSession(); - repository=new AppointmentRequirementReadRepository(session.getMapper(AppointmentWorkSourceMapper.class), - session.getMapper(InboundFbaAppointmentQueryMapper.class),session.getMapper(InboundFbaAppointmentMapper.class), - session.getMapper(InboundFbaAppointmentItemMapper.class)); - service=new InboundFbaAppointmentService(); - ReflectionTestUtils.setField(service,"requirementReads",repository); - ReflectionTestUtils.setField(service,"json",new ObjectMapper().findAndRegisterModules()); - } - - /** 建立真实预约头,取消和替代分别保留原状态与去向。 */ - void appointment(long id,String status,String replacementNos,String replacementKeys) { - jdbc.update("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by,appointment_status,replacement_nos_json,replacement_keys_json) VALUES(?,?,'WH','ABE4','TEST',?,?,?)", - id,"AP-"+id,status,replacementNos,replacementKeys); - } - - /** 建立原来源历史,不以活动标记代替来源归属。 */ - void item(long id,long appointment,String customer,String source,String destination,boolean active) { - jdbc.update("INSERT INTO ll_inbound_fba_appointment_item(id,appointment_id,customer_id,source_key,fba_warehouse_code,active,package_count) VALUES(?,?,?,?,?,?,1)", - id,appointment,customer,source,destination,active?1:0); - } - - /** 测试结束关闭本地会话,不接触任何共享数据库。 */ - @Override public void close() { session.close(); } - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java index 85c9e8b36..acb30573e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java @@ -15,6 +15,16 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class AppointmentWorkDeliveryProviderTest { + /** 在真实provider上启用计时,固定批量阶段各执行一次,后续原业务断言继续验证结果。 */ + private static List diagnosticRead(AppointmentWorkDeliveryProvider provider,List requests){ + try(var scope=com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.open( + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Origin.HTTP, + com.baiyue.lastleg.appointmentwork.diagnostics.AppointmentWorkDiagnostics.Route.DETAIL)){ + var result=provider.read(requests); + for(var stage:List.of("DELIVERY_SNAPSHOTS","DELIVERY_ROWS","DELIVERY_RECEIVING","DELIVERY_IMPACTS","DELIVERY_ASSEMBLY"))assertEquals(1,scope.snapshot().metrics().get(stage).calls(),stage); + return result; + } + } @Test void confirmedCustomerIndependentFailedPlanAndUnplannedSiblingRemainSeparate() { String key="FBA_APPOINTMENT:1",zone="America/Los_Angeles"; var appointments=mock(InboundFbaAppointmentService.class);var commitments=mock(AppointmentWorkCommitmentService.class);var receiving=receiving(); @@ -33,7 +43,7 @@ class AppointmentWorkDeliveryProviderTest { when(commitments.impactTokens(anyList())).thenAnswer(call->{List keys=call.getArgument(0);return keys.stream().collect(java.util.stream.Collectors.toMap(k->k,k->k+":0"));}); var provider=new AppointmentWorkDeliveryProvider(appointments,mock(AppointmentCargoVerificationService.class),commitments,mock(AppointmentWorkDeliveryMapper.class),mock(AppointmentWorkSourceMapper.class),receiving); var requests=List.of("A","B","D").stream().map(c->new FactRequest("W"+c,Type.DELIVERY,Flow.TRANSFER,"1",key+"|CUSTOMER:"+c,key,null,List.of(key),List.of(),List.of(new SourceReference(c,"ORDER:1","FBA:ONT8")))).toList(); - var facts=provider.read(requests);var aFact=facts.get(0);var bFact=facts.get(1);var dFact=facts.get(2); + var facts=diagnosticRead(provider,requests);var aFact=facts.get(0);var bFact=facts.get(1);var dFact=facts.get(2); assertEquals(confirmedAt.toLocalDate(),aFact.confirmed().date());assertNull(aFact.target());assertEquals(PlanStatus.CONFIRMED,aFact.planStatus());assertTrue(aFact.blockReasons().isEmpty()); assertEquals(failedDate,bFact.target().date());assertNull(bFact.confirmed());assertEquals(PlanStatus.TARGET_SET,bFact.planStatus());assertEquals("沿原批次重新预约",bFact.action().label()); assertEquals(PlanStatus.UNPLANNED,dFact.planStatus());assertNull(dFact.target());assertNull(dFact.confirmed());assertTrue(dFact.blockReasons().isEmpty()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java index a0eb47036..3d5c93c7b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java @@ -11,32 +11,11 @@ import static com.baiyue.lastleg.appointmentwork.AppointmentWorkFactGateway.*; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkModels.*; class AppointmentWorkRequirementProviderTest { - /** 一组前置未就绪只保留自己的原义务,同页其他客户的需求与独立读取一致。 */ - @Test void pagePreparationKeepsUnresolvedGroupsLocalAndMatchesIndependentReads() { - var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); - var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); - when(mapping.containerSources(anyString())).thenAnswer(c->{String id=c.getArgument(0);return List.of(new AppointmentWorkSourceMapper.Source(Long.valueOf(id),"C"+id,id,id,"FBA","ONT8",10));}); - when(sources.canonicalOrders(anyList())).thenAnswer(c->c.>getArgument(0).stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList()); - when(sources.resolveBatch(anyList())).thenAnswer(c->{ - var ref=c.>getArgument(0).get(0); - if(ref.sourceKey().equals("ORDER:1")) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"货物尚无已开船或后续实际船节点"); - return List.of(new AppointmentSourceService.Source(ref.sourceKey(),ref.customerId(),2L,"IN2",null,"CNT2","100","仓","FBA:ONT8","ONT8",10,"v1",true,List.of())); - }); - var available=mock(AppointmentCargoVerificationService.Context.class);when(available.availablePackages()).thenReturn(10L);when(cargo.inspect(any(AppointmentSourceService.Source.class))).thenReturn(available); - var containers=List.of("1","2").stream().map(id->new ContainerSource(id,"CNT"+id,null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"v1",Flow.TRANSFER,null,List.of(new Requirement("INBOUND:"+id,null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of())))).toList(); - var page=provider.openRequirements(containers.stream().map(c->new RequirementInput(c,Flow.TRANSFER)).toList()); - var first=page.read(containers.get(0),Flow.TRANSFER);var second=page.read(containers.get(1),Flow.TRANSFER); - assertEquals(List.of(new SourceReference("C1","ORDER:1",null)),first.get(0).sources()); - assertEquals(List.of(new SourceReference("C2","ORDER:2","FBA:ONT8")),second.get(0).sources()); - assertEquals(first,provider.openRequirements(List.of()).read(containers.get(0),Flow.TRANSFER)); - assertEquals(second,provider.openRequirements(List.of()).read(containers.get(1),Flow.TRANSFER)); - } /** 取消历史没有活动货物时退役旧事项,仍按权威分配保留新批次和未分配余量。 */ @Test void cancelledEmptyHistoryDoesNotBlockCurrentBatchOrRestoreAlreadyAllocatedCargo() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -54,29 +33,29 @@ class AppointmentWorkRequirementProviderTest { when(current.batchKey()).thenReturn("FBA_APPOINTMENT:2");when(current.appointmentNo()).thenReturn("AP-2"); when(current.fbaWarehouseCode()).thenReturn("ONT8");when(current.items()).thenReturn(List.of(item));when(current.replacementBatchKeys()).thenReturn(List.of()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled,current)); - var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + var actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); when(allocation.availablePackages()).thenReturn(20L); - actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:2|CUSTOMER:C"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:1|CUSTOMER:C"),actual.get(0).replacesRequirementKeys()); when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(cancelled)); when(allocation.availablePackages()).thenReturn(60L); - actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1"),actual.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),actual.get(0).sources()); assertTrue(actual.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1|CUSTOMER:C")); // 非取消的空批次仍属缺失事实,不能静默跳过。 when(cancelled.appointmentStatus()).thenReturn("PENDING"); - assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER)).getStatusCode().value()); + assertEquals(503,assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)).getStatusCode().value()); } @Test void cancelledAndFailedBookingsRetainTheirAllocatedBatchAndOnlyAuthorityDefinesRemainder() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",60))); @@ -93,87 +72,73 @@ class AppointmentWorkRequirementProviderTest { when(appointments.relatedBatchSnapshots(anyList())).thenReturn(List.of(batch)); for(var status:List.of("CANCELLED","FAILED")) { when(batch.appointmentStatus()).thenReturn(status); - var result=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + var result=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("FBA_APPOINTMENT:1|CUSTOMER:C"),result.stream().map(Requirement::requirementKey).toList()); assertEquals(List.of(new SourceReference("C","ORDER:11","FBA:ONT8")),result.get(0).sources()); assertTrue(result.get(0).replacesRequirementKeys().contains("FBA_APPOINTMENT:1")); } when(allocation.availablePackages()).thenReturn(20L); - assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1","FBA_APPOINTMENT:1|CUSTOMER:C"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); when(batch.replacementBatchKeys()).thenReturn(List.of("FBA_APPOINTMENT:2")); - assertEquals(List.of("INBOUND:1"),provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); + assertEquals(List.of("INBOUND:1"),provider.openRequirements().read(container,Flow.TRANSFER).stream().map(Requirement::requirementKey).toList()); } @Test void unbookableSourceKeepsItsPhysicalRequirementWhileAuthorityFailuresStillAbort() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","FBA","ONT8",4))); var refs=List.of(new AppointmentCoverageService.SourceRef("C","ORDER:11",null)); when(sources.canonicalOrders(anyList())).thenReturn(refs); when(sources.resolveBatch(refs)).thenThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST,"当前货源类型不支持FBA预约")); - var actual=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + var actual=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(1,actual.size());assertEquals("INBOUND:1",actual.get(0).requirementKey()); assertEquals(List.of(new SourceReference("C","ORDER:11",null)),actual.get(0).sources()); verifyNoInteractions(cargo,appointments); doThrow(new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.SERVICE_UNAVAILABLE,"仓库目录暂不可用,请稍后重新核对")).when(sources).resolveBatch(refs); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->provider.openRequirements().read(container,Flow.TRANSFER)); } @Test void privateDispatchKeepsPartialRemainderAndRestoresCancelledQuantity() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class);var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"PRIVATE",null,"客户仓",null,List.of(),List.of(),List.of()); var container=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); when(mapping.containerSources("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.Source(11L,"C","10","1","PRIVATE",null,10))); var batch=new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","PRE_ARRANGED",11,"C","ORDER:11","CUSTOMER:11",4); when(mapping.privateDispatches("1")).thenReturn(List.of(batch));when(mapping.privateBatch("DISPATCH:20")).thenReturn(List.of(batch)); - var manifest=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER); + var manifest=provider.openRequirements().read(container,Flow.TRANSFER); assertEquals(List.of("INBOUND:1","DISPATCH:20"),manifest.stream().map(Requirement::requirementKey).toList()); assertEquals("D-20",manifest.get(1).batchNo());assertEquals("CUSTOMER:11",manifest.get(1).sources().get(0).destinationKey()); when(mapping.privateDispatches("1")).thenReturn(List.of(new AppointmentWorkSourceMapper.DispatchSource(20,"D-20","CANCELLED",11,"C","ORDER:11","CUSTOMER:11",4))); - var restored=provider.openRequirements(java.util.List.of()).read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); + var restored=provider.openRequirements().read(container,Flow.TRANSFER);assertEquals(1,restored.size());assertEquals(List.of("DISPATCH:20"),restored.get(0).replacesRequirementKeys()); verifyNoInteractions(sources,cargo,appointments); } @Test void chosenDirectRoutePreservesInboundKeyBeforeAnyActualUnloading() { var sources=mock(AppointmentSourceService.class); var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class); var mapping=mock(AppointmentWorkSourceMapper.class); - scoped(sources);var provider=provider(sources,cargo,appointments,mapping); + scoped(sources);var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.UNKNOWN,null,List.of(requirement)); - assertEquals(List.of(requirement),provider.openRequirements(java.util.List.of()).read(source,Flow.DIRECT)); + assertEquals(List.of(requirement),provider.openRequirements().read(source,Flow.DIRECT)); verifyNoInteractions(sources,cargo,appointments,mapping); } /** 无尾程映射时保留原需求,不读取或依赖仓库目录。 */ @Test void missingLastlegSourcesDoNotOpenDirectoryScope() { var sources=mock(AppointmentSourceService.class);var cargo=mock(AppointmentCargoVerificationService.class); var appointments=mock(InboundFbaAppointmentService.class);var mapping=mock(AppointmentWorkSourceMapper.class); - var provider=provider(sources,cargo,appointments,mapping); + var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); var requirement=new Requirement("INBOUND:1",null,null,"FBA",null,"ONT8",null,List.of(),List.of(),List.of()); var source=new ContainerSource("1","CNT",null,null,LocalDateTime.parse("2026-10-15T11:00:00"),"1",Flow.TRANSFER,null,List.of(requirement)); - var reader=provider.openRequirements(java.util.List.of());verifyNoInteractions(sources,cargo,appointments,mapping); + var reader=provider.openRequirements();verifyNoInteractions(sources,cargo,appointments,mapping); assertEquals(List.of(requirement),reader.read(source,Flow.TRANSFER));verifyNoInteractions(sources,cargo,appointments); } - /** 旧业务场景继续模拟相同原事实,查询形状由真实批量集成回归单独验证。 */ - private static AppointmentWorkRequirementProvider provider(AppointmentSourceService sources,AppointmentCargoVerificationService cargo,InboundFbaAppointmentService appointments,AppointmentWorkSourceMapper mapping) { - var provider=new AppointmentWorkRequirementProvider(sources,cargo,appointments,mapping); - var reads=mock(AppointmentRequirementReadRepository.class); - when(reads.sources(anyList())).thenAnswer(c->c.>getArgument(0).stream().flatMap(id->mapping.containerSources(id).stream().map(row->new AppointmentWorkSourceMapper.ContainerSource(id,row.id(),row.customerId(),row.sourceOrderId(),row.sourceInboundId(),row.destinationType(),row.fbaWarehouseCode(),row.packageCount()))).toList()); - org.springframework.test.util.ReflectionTestUtils.setField(provider,"reads",reads); - when(cargo.prepareInspection(anyList())).thenReturn(cargo::inspect); - when(appointments.prepareRelatedBatchSnapshots(anyList())).thenAnswer(c->{var groups=c.>>getArgument(0);return (java.util.function.IntFunction>)(i->appointments.relatedBatchSnapshots(groups.get(i)));}); - return provider; - } /** 保留原来源模拟,显式接入本次读取作用域。 */ private static void scoped(AppointmentSourceService sources) { when(sources.openReadScope()).thenAnswer(call->{ var scope=mock(AppointmentSourceService.ReadScope.class); - when(scope.resolveOrderGroups(anyList())).thenAnswer(c->c.>>getArgument(0).stream().map(group->{ - var refs=sources.canonicalOrders(group); - try { return new AppointmentSourceService.OrderGroup(refs,sources.resolveBatch(refs),null); } - catch(RuntimeException error) { return new AppointmentSourceService.OrderGroup(refs,List.of(),error); } - }).toList()); + when(scope.resolveBatch(anyList())).thenAnswer(c->sources.resolveBatch(c.getArgument(0))); return scope; }); } From 66a0beb6539242d77a3155fde5e2a6380c11085c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 11:49:47 +0800 Subject: [PATCH 008/113] test(lastleg): mark diagnostic fixture mapper for registration contract --- .../AppointmentWorkDiagnosticsInfrastructureTest.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java index 468c4f2aa..cdc9699af 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java @@ -12,6 +12,7 @@ import com.baiyue.lastleg.appointmentwork.AppointmentWorkStore; import com.baiyue.lastleg.appointmentwork.AppointmentWorkSyncService; import com.zaxxer.hikari.HikariDataSource; import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.plugin.Interceptor; @@ -177,6 +178,7 @@ class AppointmentWorkDiagnosticsInfrastructureTest { } } + @Mapper interface ProbeMapper { /** 测试事务只向本地内存表写一行,验证原事务结果。 */ @Insert("INSERT INTO diagnostic_probe(id) VALUES(1)") From aa33514ec6b896e0e1a8b87b4e5ee0f21b3e9c38 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 16:34:14 +0800 Subject: [PATCH 009/113] fix(lastleg): repair dispatch cancellation and return execution contracts --- deploy/sql/海外区域统一增量-MySQL8.sql | 38 +- .../service/CustomerVisitService.java | 12 +- .../mapper/DeliveryOperationMapper.java | 6 +- .../mapper/DispatchFeeCargoMapper.java | 7 +- .../delivery/model/DeliveryStatusPolicy.java | 15 +- .../DeliveryExecutionRepository.java | 52 +++ .../DispatchCargoSourceRepository.java | 18 + .../service/DeliveryExecutionGuard.java | 53 +++ .../service/DeliveryFeeCoverageService.java | 15 +- .../service/DeliveryTransferService.java | 40 +- .../service/DispatchCargoSourceQuery.java | 57 +++ .../dispatch/dto/DispatchResponse.java | 8 +- .../dispatch/fleet/DispatchFleetAdapter.java | 10 +- .../service/DispatchPdaWorkflowService.java | 2 + .../DispatchMutationRepository.java | 69 +++ .../dispatch/service/DispatchService.java | 127 ++++-- .../dto/ExceptionOrderRequests.java | 15 +- .../dto/ExceptionOrderResponses.java | 3 +- .../entity/ExceptionReturnItem.java | 2 + .../entity/ReturnCartonStockReceipt.java | 35 ++ .../mapper/ExceptionReturnItemMapper.java | 7 + .../ReturnCartonStockReceiptMapper.java | 34 ++ .../ReturnCartonReturnRepository.java | 51 +++ .../ReturnCartonSourceRepository.java | 19 + .../service/ExceptionOrderService.java | 98 +++- .../service/ReturnCartonReturnService.java | 78 ++++ .../service/ReturnReceiptPolicy.java | 11 +- .../trip/FleetBookingCargoRepository.java | 10 + .../fleet/trip/FleetBookingFreight.java | 6 +- .../fleet/trip/FleetBookingMapper.java | 9 +- .../fleet/trip/FleetBookingModels.java | 4 +- .../fleet/trip/FleetBookingReservations.java | 5 +- .../fleet/trip/FleetBookingService.java | 19 +- .../mapper/ReturnInventoryMovementMapper.java | 1 + .../transfer/fleet/FleetIncomingAdapter.java | 22 +- .../FleetIncomingActualSourceMapper.java | 20 + .../FleetIncomingActualSourceRepository.java | 18 + .../src/main/resources/db/lastleg.sql | 39 +- .../resources/openapi/fleet-trips-v1.yaml | 4 +- .../openapi/overseas-business-v1.yaml | 54 ++- .../service/CustomerVisitServiceTest.java | 20 + .../DeliveryFeeCoverageServiceTest.java | 9 + .../delivery/DispatchFeeCargoMapperTest.java | 32 +- .../model/DeliveryStatusPolicyTest.java | 2 +- .../service/DeliveryExecutionGuardTest.java | 32 ++ ...DeliveryReturnEvidencePersistenceTest.java | 1 + .../service/DeliveryTransferServiceTest.java | 10 + .../DispatchPdaWorkflowServiceTest.java | 1 + ...ppointmentCancellationPersistenceTest.java | 57 +++ .../service/DispatchBolPrintServiceTest.java | 4 +- .../service/DispatchRepairWorkflowTest.java | 430 ++++++++++++++++++ .../dispatch/service/DispatchServiceTest.java | 36 ++ .../service/ExceptionOrderLifecycleTest.java | 4 +- .../ReturnCartonReturnPersistenceTest.java | 196 ++++++++ .../service/ReturnReceiptPolicyTest.java | 4 +- ...verseasFeeCancellationPersistenceTest.java | 15 + .../fleet/trip/FleetBookingFreightTest.java | 10 +- .../fleet/trip/FleetTripPersistenceTest.java | 77 +++- .../ReturnInventoryMovementSqlTest.java | 3 +- .../fleet/FleetIncomingAdapterTest.java | 28 ++ ...rn-redispatch-acceptance-contract.test.mjs | 61 ++- .../fleet-return-redispatch-acceptance.mjs | 16 +- ...se5-overseas-fulfillment-contract.test.mjs | 66 ++- .../phase5-overseas-fulfillment.mjs | 67 +-- 64 files changed, 2061 insertions(+), 213 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryExecutionRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchCargoSourceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuard.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ReturnCartonStockReceipt.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonReturnRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonSourceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/FleetIncomingActualSourceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/FleetIncomingActualSourceRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuardTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 970c1e4a6..bd9b85dea 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -754,13 +754,17 @@ CREATE TABLE ll_exception_return_item ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', exception_order_id bigint NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id bigint NOT NULL COMMENT '原出库托盘明细ID', - inventory_position_id bigint NOT NULL COMMENT '原出库库存位置ID', + inventory_position_id bigint DEFAULT NULL COMMENT '普通原出库库存位置;与退货原箱互斥', + return_carton_id bigint DEFAULT NULL COMMENT '原生退货箱标识;与普通库存位置互斥', + return_carton_mark varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原生退货箱业务箱码快照', pallet_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照', sku_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照', max_return_packages bigint NOT NULL COMMENT '原出库可退箱数上限', approved_return_packages bigint NOT NULL COMMENT '批准退回箱数', created_at datetime(3) NOT NULL COMMENT '创建时间', received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数', + CONSTRAINT chk_exception_return_source CHECK((inventory_position_id IS NOT NULL AND return_carton_id IS NULL) OR (inventory_position_id IS NULL AND return_carton_id IS NOT NULL)), + UNIQUE KEY uk_ll_exception_return_carton(customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id), CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages), PRIMARY KEY (id), @@ -768,6 +772,38 @@ CREATE TABLE ll_exception_return_item ( KEY idx_ll_exception_return_order (customer_id,exception_order_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细'; +CREATE TABLE ll_return_carton_stock_receipt ( + id BIGINT NOT NULL COMMENT '原箱回仓凭据明细标识', + customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号', + exception_order_id BIGINT NOT NULL COMMENT '本次异常退回订单', + exception_return_item_id BIGINT NOT NULL COMMENT '本次冻结的原箱来源明细', + delivery_order_id BIGINT NOT NULL COMMENT '原运输派送单', + source_pick_id BIGINT NOT NULL COMMENT '原箱实际出库拣货凭据', + source_outbound_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号', + source_dispatch_item_id BIGINT NOT NULL COMMENT '原排车货物明细', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + return_carton_id BIGINT NOT NULL COMMENT '真实退货原箱标识', + carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照', + overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识', + target_pallet_id BIGINT NOT NULL COMMENT '实际回仓目标实物托盘', + target_pallet_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码', + target_location_id BIGINT NOT NULL COMMENT '实际回仓目标库位', + target_location_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码', + sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码', + product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '原出库快照中的中文品名', + quantity BIGINT NOT NULL COMMENT '本次原SKU实际恢复件数', + packages BIGINT NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零', + evidence_file_ids_json JSON NOT NULL COMMENT '本次实收证据文件引用', + received_by VARCHAR(128) NOT NULL COMMENT '本次原箱实收确认操作人', + received_at DATETIME(3) NOT NULL COMMENT '实际原箱回仓入账时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_carton_receipt_source(customer_id,source_pick_id,sku_code), + KEY idx_return_carton_receipt_exception(customer_id,exception_order_id,received_at), + KEY idx_return_carton_receipt_delivery(customer_id,delivery_order_id), + CONSTRAINT chk_return_carton_receipt_quantity CHECK(quantity>0 AND packages IN (0,1)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据'; + CREATE TABLE ll_finance_fee_command ( id bigint NOT NULL COMMENT '幂等命令ID', operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java index 801207e6c..9ceb5c8f1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java @@ -52,6 +52,7 @@ public class CustomerVisitService { @Resource private LastlegInboundOrderMapper inboundOrders; @Resource private CustomerVisitFileGateway files; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository deliveryExecution; public CustomerVisitResponses.Page page(String scopedCustomerId, CustomerVisitRequests.Query query) { String customerId = customerScope(scopedCustomerId, query.customerId()); @@ -76,6 +77,7 @@ public class CustomerVisitService { return detail(visit); } + /** 人工派送动作必须在真实出库后执行,取消统一走出库聚合命令。 */ @Transactional public CustomerVisitResponses.Completed updateDeliveryStatus(String customerId, String operatorId, String deliveryNo, @@ -84,8 +86,14 @@ public class CustomerVisitService { if (!DeliveryStatusPolicy.isEditableTarget(target)) { throw badRequest("不支持的派送状态"); } - DeliveryOrder delivery = deliveryOrders.lockByNo(customerId, deliveryNo); + var observed = deliveryExecution.findDelivery(customerId, deliveryNo); + if (observed == null) throw notFound("派送单不存在"); + // 不反向申请排车锁;按出库→派送顺序与取消、发车串行。 + var outbound = deliveryExecution.lockOutbound(customerId, observed.getOutboundNo()); + DeliveryOrder delivery = deliveryExecution.lockDelivery(customerId, deliveryNo); if (delivery == null) throw notFound("派送单不存在"); + if (!com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.matches(delivery, outbound)) + throw conflict("派送尚未实际出库,不能开始派送或拒签"); if (target.equals(delivery.getStatus())) { CustomerVisit existing = visits.findByDelivery(customerId, delivery.getId()); return new CustomerVisitResponses.Completed(existing == null ? null : existing.getId(), deliveryNo, @@ -99,7 +107,7 @@ public class CustomerVisitService { if ("DELIVERING".equals(target) && delivery.getDispatchedAt() == null) delivery.setDispatchedAt(now); delivery.setUpdatedAt(now); delivery.setVersion(version(delivery) + 1); - deliveryOrders.updateById(delivery); + deliveryExecution.save(delivery); String result = "派送状态由 " + from + " 修改为 " + target; if (StringUtils.hasText(request.remark())) result += ",备注:" + trim(request.remark()); recordOperation(delivery, operatorId, "UPDATE_DELIVERY_STATUS", result, now); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java index f6ccb1afa..e722a2460 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java @@ -12,6 +12,7 @@ import java.util.List; @Mapper public interface DeliveryOperationMapper extends BaseMapper { + /** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */ @Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " + "l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " + "JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " + @@ -27,7 +28,10 @@ public interface DeliveryOperationMapper extends BaseMapper { "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + "WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " + "AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " + - "WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} ORDER BY l.occurred_at,l.id") + "WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " + + "FROM ll_return_carton_stock_receipt r JOIN ll_outbound_pallet_item oi ON oi.id=r.source_outbound_pallet_item_id AND oi.customer_id=r.customer_id " + + "WHERE r.customer_id=#{customerId} AND r.delivery_order_id=#{deliveryOrderId} " + + "GROUP BY r.receipt_no,r.source_dispatch_item_id,oi.warehouse_pallet_id,r.received_at ORDER BY received_at,ledger_id") List returnReceipts(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId); @Select("SELECT COUNT(*) FROM ll_exception_order WHERE customer_id=#{customerId} AND delivery_order_id=#{deliveryOrderId} " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java index 0ef81e48c..1e1690c19 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java @@ -4,7 +4,7 @@ import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; import org.apache.ibatis.annotations.*; import java.util.List; -/** Reconstruct every selected inbound source, including shared pallets and already picked quantities. */ +/** 还原整托实际入仓组成及本次已拣数量,不按展示计划过滤合托来源。 */ @Mapper public interface DispatchFeeCargoMapper { @Select(""" @@ -22,13 +22,10 @@ public interface DispatchFeeCargoMapper { GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id WHERE di.dispatch_order_id=#{dispatchId} AND di.customer_id=#{customerId} - AND (di.plan_pallet_id IS NULL OR EXISTS ( - SELECT 1 FROM ll_inbound_plan_pallet_allocation a - JOIN ll_inbound_plan_line line ON line.id=a.line_id AND line.customer_id=di.customer_id - WHERE a.pallet_id=di.plan_pallet_id AND line.order_id=b.inbound_order_id)) GROUP BY di.id,di.warehouse_pallet_id,di.pallet_no,b.inbound_order_id,o.source_region,o.source_inbound_id,b.inbound_code,di.inbound_code HAVING SUM(p.available_packages+COALESCE(picked.packages,0))>0 ORDER BY di.id,b.inbound_order_id """) + /** 整托候选箱数来自物理库存,因此组成也必须包含该托所有实际来源。 */ List list(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java index 6ffb102b6..528b1a42c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java @@ -6,9 +6,9 @@ import java.util.Set; public final class DeliveryStatusPolicy { private static final Set EDITABLE_TARGETS = Set.of( - "WAIT_DELIVERY", "DELIVERING", "REJECTED", "CANCELLED"); + "WAIT_DELIVERY", "DELIVERING", "REJECTED"); private static final Map> TRANSITIONS = Map.of( - "WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED", "CANCELLED"), + "WAIT_DELIVERY", Set.of("DELIVERING", "DELIVERED", "REJECTED"), "DELIVERING", Set.of("DELIVERED", "REJECTED"), "DELIVERED", Set.of(), "REJECTED", Set.of(), @@ -16,23 +16,32 @@ public final class DeliveryStatusPolicy { "RETURNED", Set.of(), "CANCELLED", Set.of()); + /** 状态规则为纯策略,实际出库由命令及查询层提供。 */ private DeliveryStatusPolicy() { } + /** 人工只允许开始派送或拒签,取消必须由出库聚合命令处理。 */ public static boolean isEditableTarget(String status) { return EDITABLE_TARGETS.contains(status); } + /** 校验状态机,签收终态仍由签收资料命令触发。 */ public static boolean canTransition(String from, String target) { return TRANSITIONS.getOrDefault(from, Set.of()).contains(target); } + /** 返回状态本身允许的动作;执行动作还须满足实际出库事实。 */ public static List allowedActions(String status) { return switch (status == null ? "" : status) { case "WAIT_DELIVERY" -> List.of( - "START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + "START_DELIVERY", "REJECT_DELIVERY"); case "DELIVERING" -> List.of("REJECT_DELIVERY", "MARK_RETURN"); case "DELIVERED", "REJECTED", "RETURN_PENDING" -> List.of("MARK_RETURN"); default -> List.of(); }; } + + /** 未实际出库不得开始派送、拒签或创建运输退回。 */ + public static List allowedActions(String status, boolean outboundCompleted) { + return outboundCompleted ? allowedActions(status) : List.of(); + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryExecutionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryExecutionRepository.java new file mode 100644 index 000000000..2c70ea92a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryExecutionRepository.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 派送执行门禁的数据入口,保留取消单据历史。 */ +@Repository +public class DeliveryExecutionRepository { + private final OutboundOrderMapper outbounds; + private final DeliveryOrderMapper deliveries; + + /** 装配同库的出库及派送访问。 */ + public DeliveryExecutionRepository(OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries) { + this.outbounds = outbounds; + this.deliveries = deliveries; + } + + /** 按客户和业务编号读取原派送,供命令确定出库锁顺序。 */ + public DeliveryOrder findDelivery(String customerId, String deliveryNo) { + return deliveries.findByNo(customerId, deliveryNo); + } + + /** 先锁出库,再锁派送,与取消和发车保持一致。 */ + public OutboundOrder lockOutbound(String customerId, String outboundNo) { + return outbounds.lockByNo(customerId, outboundNo); + } + + /** 锁定客户范围内的派送,锁后由业务层复核关联。 */ + public DeliveryOrder lockDelivery(String customerId, String deliveryNo) { + return deliveries.lockByNo(customerId, deliveryNo); + } + + /** 读取客户范围内的出库事实,用于动作展示。 */ + public OutboundOrder findOutbound(String customerId, String outboundNo) { + return outbounds.findByNo(customerId, outboundNo); + } + + /** 发车时锁定全部派送,取消与执行只能产生一个合法结果。 */ + public List deliveries(OutboundOrder outbound, boolean lock) { + return lock ? deliveries.lockByOutbound(outbound.getCustomerId(), outbound.getId()) + : deliveries.findByOutbound(outbound.getCustomerId(), outbound.getId()); + } + + /** 保存已校验版本的派送动作。 */ + public void save(DeliveryOrder delivery) { + deliveries.updateById(delivery); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchCargoSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchCargoSourceRepository.java new file mode 100644 index 000000000..cdaf89544 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchCargoSourceRepository.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 复用库存组成及本次拣货来源,不把展示入仓码当作整托归属。 */ +@Repository +public class DispatchCargoSourceRepository { + private final DispatchFeeCargoMapper sources; + /** 装配实际货物组成查询。 */ + public DispatchCargoSourceRepository(DispatchFeeCargoMapper sources) { this.sources = sources; } + /** 按客户和当前排车读取逐入仓来源数量。 */ + public List sources(String customerId, long dispatchId) { + return sources.list(customerId, dispatchId); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuard.java new file mode 100644 index 000000000..4a0ef3779 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuard.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.util.Objects; + +/** 统一派送执行事实,防止状态编辑绕过出库及车次事务。 */ +@Service +public class DeliveryExecutionGuard { + private final DeliveryExecutionRepository repository; + + /** 装配执行事实的数据入口。 */ + public DeliveryExecutionGuard(DeliveryExecutionRepository repository) { this.repository = repository; } + + /** 展示动作只认同客户、同出库单的实际出库事实。 */ + public boolean outboundCompleted(DeliveryOrder delivery) { + return matches(delivery, repository.findOutbound(delivery.getCustomerId(), delivery.getOutboundNo())); + } + + /** 发车前要求派送覆盖完整、均未执行,锁定检查与出库提交同事务。 */ + public String departureBlockReason(OutboundOrder outbound, boolean lock) { + var rows = repository.deliveries(outbound, lock); + if (rows.isEmpty()) return "缺少对应派送单,请先核对转派送结果"; + if (rows.stream().anyMatch(row -> !Objects.equals(row.getCustomerId(), outbound.getCustomerId()) + || !Objects.equals(row.getOutboundOrderId(), outbound.getId()) + || !Objects.equals(row.getOutboundNo(), outbound.getOutboundNo()) + || !"WAIT_DELIVERY".equals(row.getStatus()) || row.getDispatchedAt() != null || row.getSignedAt() != null + || row.getPackageCount() == null || row.getPackageCount() <= 0)) + return "关联派送单已取消、已执行或来源不一致,不能发车"; + if (rows.stream().mapToLong(DeliveryOrder::getPackageCount).sum() + != Objects.requireNonNullElse(outbound.getPackageCount(), 0)) + return "关联派送数量未完整覆盖出库货物"; + return null; + } + + /** 真正发车时再次加锁校验,不能依赖列表查询结果。 */ + public void requireDeparture(OutboundOrder outbound) { + String reason = departureBlockReason(outbound, true); + if (reason != null) throw new ResponseStatusException(HttpStatus.CONFLICT, reason); + } + + /** 共享实际出库判据,不能只凭派送状态或发车时间推断。 */ + public static boolean matches(DeliveryOrder delivery, OutboundOrder outbound) { + return outbound != null && "OUTBOUNDED".equals(outbound.getStatus()) + && Objects.equals(delivery.getCustomerId(), outbound.getCustomerId()) + && Objects.equals(delivery.getOutboundOrderId(), outbound.getId()) + && Objects.equals(delivery.getOutboundNo(), outbound.getOutboundNo()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java index 6a512a4af..be764c7ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java @@ -78,14 +78,15 @@ public class DeliveryFeeCoverageService { return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围"; return null; } - public record Decision(Set includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set externalItems) { + public record Decision(Set includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set externalInboundCodes) { public static Decision none() { return new Decision(Set.of(), null, Set.of()); } public List uncovered(String service) { if (evidence == null) return List.of(); - return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalItems)).toList(); + return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalInboundCodes)).toList(); } } - public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set externalItems) { + /** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */ + public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set externalInboundCodes) { if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none(); var preview = preview(dispatchNo); if (!preview.sourceComplete()) throw conflict(preview.blockReason()); @@ -106,13 +107,13 @@ public class DeliveryFeeCoverageService { } var included = new HashSet(); for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE")) - if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalItems))) included.add(service); + if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalInboundCodes))) included.add(service); return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(), - List.copyOf(references), operator, Instant.now()), Set.copyOf(externalItems)); + List.copyOf(references), operator, Instant.now()), Set.copyOf(externalInboundCodes)); } private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service, - List references, Set externalItems) { - if ("DELIVERY_FEE".equals(service) && externalItems.contains(cargo.dispatchItemId())) return true; + List references, Set externalInboundCodes) { + if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) return true; return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference -> reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 793774c8b..a8c9e3496 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -132,6 +132,8 @@ public class DeliveryTransferService { @Resource private FleetDispatchBindingGuard fleetBindings; @Resource private FleetBookingFreight bookingFreight; @Resource private DeliveryFeeCoverageService feeCoverage; + @Resource private DeliveryExecutionGuard deliveryExecution; + @Resource private DispatchCargoSourceQuery cargoSources; @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public FleetBookingFreight.Preview externalFreight(String dispatchNo) { @@ -140,13 +142,10 @@ public class DeliveryTransferService { return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); } + /** 外接运费与车次绑定使用相同的实际来源和数量。 */ private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List items) { - return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), items.stream() - .map(item -> new FleetDispatchGateway.SourceCargo(item.getInboundCode(), - item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), - Objects.requireNonNullElse(item.getPackageCount(), 0), FleetDispatchGateway.destinationKey(item.getDestinationType(), - item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()))) - .toList()); + return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), + cargoSources.sources(dispatch, items)); } @Transactional @@ -169,6 +168,7 @@ public class DeliveryTransferService { return transferResolved(dispatch, operatorId, request); } + /** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */ private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId, DeliveryRequests.Transfer request) { String customerId = dispatch.getCustomerId(); @@ -197,10 +197,9 @@ public class DeliveryTransferService { if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash())) && !Objects.equals(freight.snapshotHash(), request.externalFreightHash())) throw conflict("外接实收或约定运费已变化,请重新打开转派送核对"); - var quotedIncomingNos = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet()); - var externalItems = items.stream().filter(item -> quotedIncomingNos.contains(item.getInboundCode())).map(DispatchItem::getId).collect(Collectors.toSet()); + var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet()); var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none() - : feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalItems); + : feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalInboundCodes); validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices()); if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes()); @@ -303,8 +302,10 @@ public class DeliveryTransferService { deliveries.add(delivery); } for (var quote : freight.quotes()) { - var source = items.stream().filter(item -> quote.incomingNo().equals(item.getInboundCode())).findFirst().orElseThrow(); - var destination = deliveries.stream().filter(item -> deliveryGroupKey(source).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow(); + var destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey( + item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(), + item.getDeliveryAddress(), item.getPostalCode()))).findFirst() + .orElseThrow(() -> conflict("外接约定目的地未生成对应派送单")); recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT", "外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 " + quote.currency() + " " + quote.amount().toPlainString(), now); @@ -320,7 +321,8 @@ public class DeliveryTransferService { } DeliveryOrder feeDelivery = deliveries.get(0); List feeInputs = request.fees() == null ? List.of() : request.fees(); - var quotedIncoming = freight.quotes().stream().map(FleetBookingFreight.Quote::incomingNo).collect(Collectors.toSet()); + var ordinaryCargo = freight.requiresDeliveryFee() ? cargoSources.sources(dispatch, items).stream() + .filter(source -> !externalInboundCodes.contains(source.inboundCode())).toList() : List.of(); for (DeliveryRequests.FeeItem input : feeInputs) { String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT); var uncovered = coverage.uncovered(feeCode); @@ -328,11 +330,19 @@ public class DeliveryTransferService { var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction()) ? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList() : "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee() - ? items.stream().filter(item -> !quotedIncoming.contains(item.getInboundCode())).toList() : items; + ? items.stream().filter(item -> ordinaryCargo.stream().anyMatch(source -> Objects.equals(source.warehousePalletId(), + item.getWarehousePalletId().toString()) && Objects.equals(source.destinationKey(), FleetDispatchGateway.destinationKey( + item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))).toList() : items; var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty() ? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow() : feeDelivery; LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems); + // 合托后按真实普通来源归属费用;多票时只绑定派送,不借展示入仓码挂到外接货。 + if ("RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && !externalInboundCodes.isEmpty()) { + var ordinaryOrders = cargoSources.ordinaryOrderIds(dispatch, externalInboundCodes); + firstOrder = ordinaryOrders.size() == 1 ? tailOrders.selectById(ordinaryOrders.iterator().next()) : null; + if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致"); + } if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) { var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList(); firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null; @@ -354,6 +364,7 @@ public class DeliveryTransferService { return result(outbound, deliveries, false); } + /** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */ private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) { if (!requestHash.equals(outbound.getRequestHash())) throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程"); @@ -1637,7 +1648,8 @@ public class DeliveryTransferService { allocateMetric(candidate.getSourceVolumeWeightKg(), candidate.getSourcePackageCount(), available, 3), allocateMetric(candidate.getSourceVolumeCbm(), candidate.getSourcePackageCount(), available, 4)); } - private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus()), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } + /** 派送动作由状态和实际出库共同决定,列表与详情复用同一资格。 */ + private DeliveryResponses.DeliveryRow deliveryRow(DeliveryOrder d) { return new DeliveryResponses.DeliveryRow(d.getId(), d.getCustomerId(), d.getCustomerName(), d.getDeliveryNo(), d.getOutboundNo(), d.getDispatchNo(), d.getOverseasWarehouseId(), d.getOverseasWarehouseName(), d.getStatus(), d.getWaybillNo(), d.getDeliveryMethod(), d.getCarrierName(), d.getPickupMethod(), d.getLoadingType(), d.getVehicleType(), d.getVehicleSize(), d.getVehiclePlate(), d.getDriverName(), d.getDriverPhone(), d.getAppointmentAccount(), d.getSellerName(), value(d.getPalletCount()), value(d.getPackageCount()), d.getSignedPackages(), d.getTotalWeightKg(), d.getTotalVolumeCbm(), d.getPlannedDeliveryAt(), d.getDispatchedAt(), d.getExpectedArrivalAt(), d.getSignedAt(), d.getRemark(), d.getCreatedAt(), value(d.getVersion()), DeliveryStatusPolicy.allowedActions(d.getStatus(), deliveryExecution.outboundCompleted(d)), d.getBolNo(), DeliveryDocumentSnapshot.summary(d.getDocumentsJson()), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), d.getDestinationType(), d.getDeliveryAddress(), d.getContactName(), d.getContactPhone(), d.getPostalCode()); } private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java new file mode 100644 index 000000000..b4b94c8f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** 外接绑定和约定运费共同消费同一份逐来源货物。 */ +@Service +public class DispatchCargoSourceQuery { + private final DispatchCargoSourceRepository repository; + /** 装配当前库存与本次拣货的真实来源入口。 */ + public DispatchCargoSourceQuery(DispatchCargoSourceRepository repository) { this.repository = repository; } + + /** 新增普通派送应收只绑定真实未约定来源;混合多票时保留派送归属。 */ + public Set ordinaryOrderIds(DispatchOrder dispatch, Set externalInboundCodes) { + return repository.sources(dispatch.getCustomerId(), dispatch.getId()).stream() + .filter(row -> !externalInboundCodes.contains(row.inboundCode())) + .map(row -> row.orderId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet()); + } + + /** 每条普通排车明细核平实际组成;箱级换标和退货保留其专用来源快照。 */ + public List sources(DispatchOrder dispatch, List items) { + if (items.isEmpty()) return List.of(); + var rows = repository.sources(dispatch.getCustomerId(), dispatch.getId()); + var result = new ArrayList(); + for (var item : items) { + String destination = FleetDispatchGateway.destinationKey(item.getDestinationType(), + item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()); + // 实收重派按不可变实收记录选货,箱级换标按箱来源选货;都不是普通整托候选。 + if (item.getReturnReceiptId() != null || Set.of("RELABEL", "RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(), ""))) { + result.add(new FleetDispatchGateway.SourceCargo(item.getInboundCode(), + item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), + item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), destination)); + continue; + } + var selected = rows.stream().filter(row -> Objects.equals(row.dispatchItemId(), item.getId())).toList(); + if (selected.isEmpty() || item.getPackageCount() == null || selected.stream().mapToLong(row -> row.packages()).sum() != item.getPackageCount()) + throw conflict("实际货物组成与排车箱数不一致,请核对收货、托盘及本次拣货来源"); + for (var row : selected) { + if (row.inboundCode() == null || row.inboundCode().isBlank() || row.packages() <= 0 + || !Objects.equals(row.warehousePalletId(), item.getWarehousePalletId())) + throw conflict("货物缺少真实入仓来源或实物托盘,不能识别为普通货跳过外接核对"); + result.add(new FleetDispatchGateway.SourceCargo(row.inboundCode(), row.warehousePalletId().toString(), + row.palletNo(), Math.toIntExact(row.packages()), destination)); + } + } + return List.copyOf(result); + } + + /** 来源不足直接阻断,不回退展示字段。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java index aadcd1ebe..df6187c20 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java @@ -13,7 +13,13 @@ public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, LocalDateTime createdAt, List items, List bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow, - List appointmentBatches) { + List appointmentBatches, + Actions actions) { + public record Actions(boolean canEdit, String editBlockReason, + boolean canCancel, String cancelBlockReason, + boolean canRemovePallet, String removePalletBlockReason, + boolean canTransfer, String transferBlockReason, + String outboundNo, String outboundStatus) { } public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, String overseasWarehouseId, String overseasWarehouseName, String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java index b4ee57d61..32e2e5b53 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -37,6 +37,10 @@ import java.util.Objects; @Component @Transactional(propagation = Propagation.MANDATORY) public class DispatchFleetAdapter implements FleetDispatchGateway { + @jakarta.annotation.Resource + private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution; + @jakarta.annotation.Resource + private com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery cargoSources; private final DispatchOrderMapper orders; private final DispatchPdaTaskMapper tasks; private final OutboundOrderMapper outbounds; @@ -137,6 +141,7 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物"; else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单"; else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认"; + if (reason == null) reason = deliveryExecution.departureBlockReason(outbound, lock); return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason); } @@ -151,12 +156,11 @@ public class DispatchFleetAdapter implements FleetDispatchGateway { private static String destination(String type, String code, String fba, String address, String postalCode) { return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode); } + /** 用逐入仓组成核对外接承诺,不使用整托展示来源。 */ @Override public java.util.List sourceCargo(String dispatchNo) { var dispatch=orders.findGlobalByDispatchNo(dispatchNo); if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在"); - return items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId()).stream().map(item->new SourceCargo(item.getInboundCode(), - item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),Objects.requireNonNullElse(item.getPackageCount(),0), - destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()))).toList(); + return cargoSources.sources(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId())); } @Override public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index 8e297a778..adc8c9907 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -38,6 +38,7 @@ import java.util.stream.Collectors; @Service public class DispatchPdaWorkflowService { + @Resource private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; @Resource private FleetDispatchBindingGuard fleetBindings; @Resource private DispatchOrderMapper orders; @@ -238,6 +239,7 @@ public class DispatchPdaWorkflowService { if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { throw conflict("Dispatch must be transferred to a pending outbound order before departure"); } + deliveryExecution.requireDeparture(outbound); DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); if (task == null || !"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java new file mode 100644 index 000000000..b52211565 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import java.util.List; + +/** 排车变更的数据操作,事务及状态规则由排车服务维护。 */ +public class DispatchMutationRepository { + private final DispatchOrderMapper orders; + private final DispatchItemMapper items; + private final DispatchPalletLockMapper locks; + private final OutboundOrderMapper outbounds; + private final DispatchCandidateMapper candidates; + private final RelabelApplicationMapper relabels; + private final DispatchPdaTaskMapper tasks; + + /** 复用现有 Mapper 装配,供原排车服务分步收口变更路径。 */ + public DispatchMutationRepository(DispatchOrderMapper orders, DispatchItemMapper items, DispatchPalletLockMapper locks, + OutboundOrderMapper outbounds, DispatchCandidateMapper candidates, RelabelApplicationMapper relabels, DispatchPdaTaskMapper tasks) { + this.orders=orders; this.items=items; this.locks=locks; this.outbounds=outbounds; + this.candidates=candidates; this.relabels=relabels; this.tasks=tasks; + } + /** 按业务号锁定唯一排车。 */ + public DispatchOrder lockOrder(String dispatchNo) { return orders.lockGlobalByDispatchNo(dispatchNo); } + /** 保存排车的业务变更。 */ + public void saveOrder(DispatchOrder order) { orders.updateById(order); } + /** 创建已校验客户和完整货量的新排车。 */ + public void createOrder(DispatchOrder order) { orders.insert(order); } + /** 读取当前客户排车的原始明细。 */ + public List items(DispatchOrder order) { return items.findByDispatchOrder(order.getCustomerId(), order.getId()); } + /** 锁定本排车的指定托盘明细。 */ + public DispatchItem lockItem(DispatchOrder order, Long itemId) { return items.lockById(order.getCustomerId(), order.getId(), itemId); } + /** 保存新换标排车明细。 */ + public void createItem(DispatchItem item) { items.insert(item); } + /** 保存退货换标来源快照。 */ + public void saveItem(DispatchItem item) { items.updateById(item); } + /** 删除尚未执行的排车托盘,历史单据由取消命令保留。 */ + public void removeItem(Long itemId) { items.deleteById(itemId); } + /** 读取该排车自己的活动占用,不触碰后续新排车。 */ + public List activeLocks(DispatchOrder order) { + return locks.selectList(new LambdaQueryWrapper() + .eq(DispatchPalletLock::getCustomerId, order.getCustomerId()).eq(DispatchPalletLock::getDispatchOrderId, order.getId()) + .eq(DispatchPalletLock::getStatus, "ACTIVE")); + } + /** 保存单条原排车占用的释放事实。 */ + public void saveLock(DispatchPalletLock lock) { locks.updateById(lock); } + /** 读取或锁定历史关联出库;业务层区分活动与取消。 */ + public OutboundOrder outbound(DispatchOrder order, boolean lock) { + return lock ? outbounds.lockByDispatch(order.getCustomerId(), order.getId()) : outbounds.findByDispatch(order.getCustomerId(), order.getId()); + } + /** 锁定换标申请,串行完整候选检查与整单状态推进。 */ + public RelabelApplication lockRelabel(String customer, String number) { return relabels.lockNo(customer, number); } + /** 读取锁定申请下的全部普通换标候选。 */ + public List relabelCandidates(String customer, String number) { return candidates.relabelAvailable(customer, null, number); } + /** 整单申请只允许一次转为有效排车。 */ + public int markRelabel(String customer, String number) { return candidates.markRelabelPreDispatched(customer, number); } + /** 取消未执行排车后恢复原申请候选。 */ + public void restoreRelabel(String customer, String number) { candidates.restoreRelabelPreDispatch(customer, number); } + /** 创建与新排车同事务的 PDA 任务。 */ + public void createTask(DispatchPdaTask task) { tasks.insert(task); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 9a6667969..fd2359dfe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -601,9 +601,10 @@ public class DispatchService { return response(responseOrder, assignedItems); } + /** 在申请锁内校验全托并原子建立整单排车。 */ @Transactional private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { - RelabelApplication application = relabelApplications.lockNo(customerId, request.relabelNo()); + RelabelApplication application = mutations().lockRelabel(customerId, request.relabelNo()); if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); } @@ -613,12 +614,15 @@ public class DispatchService { relabelBindingGuard.requireBound(application); relabelMeasurements.requireMeasured(application); List selected = java.util.stream.Stream.concat( - measuredRelabelCandidates(candidates.relabelAvailable(customerId, null, request.relabelNo())).stream(), + measuredRelabelCandidates(mutations().relabelCandidates(customerId, request.relabelNo())).stream(), returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList(); - Set requested = request.planPalletIds().stream().map(Long::valueOf).collect(Collectors.toSet()); - if (selected.stream().anyMatch(DispatchPalletCandidate::returnCartonSource) - && !requested.equals(selected.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()))) - throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标排车须选择本申请全部已绑定托盘"); + // 整单履约状态只能由完整托盘集合推进,普通换标与退货换标遵守同一门禁。 + Set requested = new HashSet<>(request.planPalletIds()); + if (requested.contains(null) || requested.size() != request.planPalletIds().size()) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标托盘不得为空或重复选择"); + if (selected.isEmpty() || !requested.equals(selected.stream() + .map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()))) + throw new ResponseStatusException(HttpStatus.CONFLICT, "换标排车须一次选择本申请全部已绑定托盘,请刷新候选后完整选择"); selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); String warehouse = selected.get(0).overseasWarehouseId(); @@ -626,17 +630,17 @@ public class DispatchService { LocalDateTime now = LocalDateTime.now(); DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); orders.insert(dispatch); - for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); items.insert(item); } - DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); pdaTasks.insert(task); - for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) { + dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); mutations().createOrder(dispatch); + for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); mutations().createItem(item); } + DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); mutations().createTask(task); + for (DispatchItem item : mutations().items(dispatch)) { if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId()) && Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) { - item.setSourceStage("RETURN_RELABEL"); items.updateById(item); returnRelabelStock.reserve(item); + item.setSourceStage("RETURN_RELABEL"); mutations().saveItem(item); returnRelabelStock.reserve(item); } } - if (candidates.markRelabelPreDispatched(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); - return response(dispatch, items.findByDispatchOrder(customerId, dispatch.getId())); + if (mutations().markRelabel(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); + return response(dispatch, mutations().items(dispatch)); } private void assertNoDetainedOrders(String customerId, List selected) { @@ -704,17 +708,14 @@ public class DispatchService { bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); } + /** 仅编辑未执行且无有效出库或车次占用的排车。 */ @Transactional public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) { - DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); + DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - fleetBindings.requireUnbound(dispatch.getId()); - if (!PENDING_DEPARTURE.equals(dispatch.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Only a pending dispatch can be edited"); - } + requirePalletEditingAllowed(dispatch); String customerId = dispatch.getCustomerId(); - List assignedItems = items.findByDispatchOrder(customerId, dispatch.getId()); + List assignedItems = mutations().items(dispatch); dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); dispatch.setDriverName(trimToNull(request.driverName())); dispatch.setDriverPhone(trimToNull(request.driverPhone())); @@ -726,62 +727,65 @@ public class DispatchService { dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); dispatch.setUpdatedAt(LocalDateTime.now()); - orders.updateById(dispatch); + mutations().saveOrder(dispatch); return response(dispatch, assignedItems); } + /** 移除普通来源托盘,整单换标只允许整单取消重排。 */ @Transactional public DispatchResponse removeItem(String dispatchNo, Long itemId) { - DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); + DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); requirePalletEditingAllowed(dispatch); - DispatchItem item = items.lockById(dispatch.getCustomerId(), dispatch.getId(), itemId); + DispatchItem item = mutations().lockItem(dispatch, itemId); if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found"); - if ("RETURN_RELABEL".equals(item.getSourceStage())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "退货换标按整单排车,请取消整张排车单后重新排车"); + if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) + throw new ResponseStatusException(HttpStatus.CONFLICT, "换标按整单排车,请取消整张排车单后重新排车"); - List activeLocks = locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, dispatch.getCustomerId()) - .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); + List activeLocks = mutations().activeLocks(dispatch); List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId()) || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId()))) .toList(); return removeLockedItem(dispatch, item, matchingLocks); } + /** 同事务释放当前托盘锁、预约分配并重算剩余排车货量。 */ private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item, List matchingLocks) { LocalDateTime now = LocalDateTime.now(); matchingLocks.forEach(lock -> releaseLock(lock, now)); - items.deleteById(item.getId()); + mutations().removeItem(item.getId()); appointmentAllocations.removeItem(dispatch.getId(),item.getId()); - List remaining = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()); + List remaining = mutations().items(dispatch); dispatch.setItemCount(remaining.size()); dispatch.setPalletCount(remaining.size()); dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) .mapToInt(Integer::intValue).sum()); dispatch.setRoute(itemRoute(remaining)); dispatch.setUpdatedAt(now); - orders.updateById(dispatch); + mutations().saveOrder(dispatch); return response(dispatch, remaining); } + /** 取消历史不占活动出库,已拣货或车次绑定仍禁止修改。 */ private void requirePalletEditingAllowed(DispatchOrder dispatch) { fleetBindings.requireUnbound(dispatch.getId()); if (!PENDING_DEPARTURE.equals(dispatch.getStatus()) - || Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) { + || Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0 + || Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) { throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets"); } - if (outboundOrders.findByDispatch(dispatch.getCustomerId(), dispatch.getId()) != null) { + var outbound = mutations().outbound(dispatch, false); + if (outbound != null && !CANCELLED.equals(outbound.getStatus())) { throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘"); } } + /** 只取消原未执行排车,取消出库历史保留且重复取消无副作用。 */ @Transactional public void cancel(String dispatchNo) { - DispatchOrder dispatch = orders.lockGlobalByDispatchNo(trimToNull(dispatchNo)); + DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); fleetBindings.requireUnbound(dispatch.getId()); if (CANCELLED.equals(dispatch.getStatus())) return; @@ -789,32 +793,32 @@ public class DispatchService { throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); } String customerId = dispatch.getCustomerId(); - if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) + if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0 + || Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱"); - if (outboundOrders.findByDispatch(customerId, dispatch.getId()) != null) + var outbound = mutations().outbound(dispatch, true); + if (outbound != null && !CANCELLED.equals(outbound.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车"); LocalDateTime now = LocalDateTime.now(); dispatch.setStatus(CANCELLED); workChanges.requirementsChanged(); dispatch.setUpdatedAt(now); - orders.updateById(dispatch); - for (DispatchItem item : items.findByDispatchOrder(customerId, dispatch.getId())) { + mutations().saveOrder(dispatch); + for (DispatchItem item : mutations().items(dispatch)) { + // 预约分配的有效占用按排车 CANCELLED 状态排除,原分配保留作历史证据。 if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item); } - items.findByDispatchOrder(customerId, dispatch.getId()).stream() + mutations().items(dispatch).stream() .filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) .map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct() - .forEach(relabelNo -> candidates.restoreRelabelPreDispatch(customerId, relabelNo)); - List activeLocks = locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, customerId) - .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); + .forEach(relabelNo -> mutations().restoreRelabel(customerId, relabelNo)); + List activeLocks = mutations().activeLocks(dispatch); for (DispatchPalletLock lock : activeLocks) { lock.setStatus("RELEASED"); lock.setActiveKey(null); lock.setReleasedAt(now); lock.setUpdatedAt(now); - locks.updateById(lock); + mutations().saveLock(lock); } } @@ -861,7 +865,36 @@ public class DispatchService { item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(), item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(), - bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches); + bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches, actions(order, itemList, workflow)); + } + + /** 返回与写命令相同的操作资格;权限仍由入口独立校验。 */ + private DispatchResponse.Actions actions(DispatchOrder order, List itemList, + com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { + if (workflow == null) workflow = fleetWorkflow.read(List.of(order.getDispatchNo())).get(order.getDispatchNo()); + var outbound = mutations().outbound(order, false); + boolean activeOutbound = outbound != null && !CANCELLED.equals(outbound.getStatus()); + String pendingReason = !PENDING_DEPARTURE.equals(order.getStatus()) ? "仅未执行的待发车排车可操作" + : Objects.requireNonNullElse(order.getPickedPackageCount(), 0) > 0 + || Objects.requireNonNullElse(order.getShippedPackageCount(), 0) > 0 ? "排车已开始拣货或发车" : null; + String mutationReason = workflow != null ? "请先在车次 " + workflow.tripNo() + " 中解除关联" : pendingReason; + String editReason = mutationReason != null ? mutationReason : activeOutbound ? "请先取消对应出库单" : null; + String removeReason = editReason != null ? editReason : itemList.stream() + .anyMatch(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) ? "换标按整单排车,请取消整张排车后重排" : null; + String transferReason = outbound != null ? (CANCELLED.equals(outbound.getStatus()) + ? "原出库已取消,请取消原排车后通过新预排重新安排" : "已转派送,请查看原出库及派送单") + : !Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART").contains(Objects.toString(order.getStatus(), "")) + ? "当前状态不能转派送" : itemList.isEmpty() || itemList.stream().anyMatch(item -> item.getWarehousePalletId() == null) + ? "请先完成收货并核对实物托盘" : null; + return new DispatchResponse.Actions(editReason == null, editReason, editReason == null, editReason, + removeReason == null, removeReason, transferReason == null, transferReason, + outbound == null ? null : outbound.getOutboundNo(), outbound == null ? null : outbound.getStatus()); + } + + /** 为本次变更路径集中组装数据访问,保持既有 Mapper 注入与事务连接。 */ + private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() { + return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository( + orders, items, locks, outboundOrders, candidates, relabelApplications, pdaTasks); } private static String candidateRoute(List selected) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java index 8aaa2d8b4..18754d6f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java @@ -26,16 +26,16 @@ public final class ExceptionOrderRequests { @Min(1) Integer packageCount, @Size(max = 128) String skuCode) {} - public record MarkReturn(@NotNull @Min(1) Long inboundOrderId, + public record MarkReturn(@NotNull @Min(1) Long lastMileOrderId, @NotBlank @Size(max = 1000) String reason, @NotBlank @Size(max = 32) String returnDirection, @Size(max = 128) String returnLogisticsNo, - @NotNull @Size(min = 1, max = 200) List<@NotNull ReturnItem> items, + @NotNull @Size(min = 1, max = 200) List<@NotNull @jakarta.validation.Valid ReturnItem> items, @NotBlank @Size(max = 255) String idempotencyKey) {} public record ReturnItem(@NotNull @Min(1) Long outboundPalletItemId, - @NotNull @Min(1) Long inventoryPositionId, - @Min(1) long packageCount) {} + @Min(1) Long inventoryPositionId, + @Min(1) long packageCount, @Min(1) Long returnCartonId) {} public record StartProcessing(@Min(0) long expectedVersion, @NotBlank @Size(max = 255) String idempotencyKey, @@ -45,15 +45,16 @@ public final class ExceptionOrderRequests { @NotBlank @Size(max = 255) String idempotencyKey, @NotBlank @Size(max = 2000) String handlingResult, @NotNull @Size(min = 1, max = 20) List<@NotNull @Min(1) Long> evidenceFileIds, - @Size(max = 200) List<@NotNull ReturnReceipt> receipts) {} + @Size(max = 200) List<@NotNull @jakarta.validation.Valid ReturnReceipt> receipts) {} public record ReturnReceipt(@NotNull @Min(1) Long outboundPalletItemId, - @NotNull @Min(1) Long inventoryPositionId, + @Min(1) Long inventoryPositionId, @Min(1) long receivedPackages, @NotNull @Min(1) Long targetPalletId, @NotBlank @Size(max = 128) String targetPalletNo, @NotNull @Min(1) Long targetLocationId, - @NotBlank @Size(max = 128) String targetLocationCode) {} + @NotBlank @Size(max = 128) String targetLocationCode, + @Min(1) Long returnCartonId) {} public record Cancel(@Min(0) long expectedVersion, @NotBlank @Size(max = 255) String idempotencyKey, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java index dd2aa3c41..7f70b98ec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java @@ -26,7 +26,8 @@ public final class ExceptionOrderResponses { List returnSources) {} public record ReturnSource(Long outboundPalletItemId, Long inventoryPositionId, - String palletNo, String skuCode, long maxReturnPackages) {} + String palletNo, String skuCode, long maxReturnPackages, + Long returnCartonId, String cartonMark) {} public record Detail(Row order, List returnSources, List returnReceipts) {} public record ReturnReceipt(String receiptNo, long receivedPackages, LocalDateTime receivedAt, List redispatches) {} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java index 6de63567f..9f1a60aeb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java @@ -15,6 +15,8 @@ public class ExceptionReturnItem { private Long exceptionOrderId; private Long outboundPalletItemId; private Long inventoryPositionId; + private Long returnCartonId; + private String returnCartonMark; private String palletNo; private String skuCode; private Long maxReturnPackages; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ReturnCartonStockReceipt.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ReturnCartonStockReceipt.java new file mode 100644 index 000000000..3fc0db0f9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ReturnCartonStockReceipt.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.exceptionorder.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** 原箱回仓的不可变逐SKU凭据,箱数仅计一次。 */ +@Data +@TableName("ll_return_carton_stock_receipt") +public class ReturnCartonStockReceipt { + @TableId(type=IdType.INPUT) private Long id; + private String customerId; + private String receiptNo; + private Long exceptionOrderId; + private Long exceptionReturnItemId; + private Long deliveryOrderId; + private Long sourcePickId; + private String sourceOutboundNo; + private Long sourceDispatchItemId; + private Long sourceOutboundPalletItemId; + private Long returnCartonId; + private String cartonMark; + private String overseasWarehouseId; + private Long targetPalletId; + private String targetPalletNo; + private Long targetLocationId; + private String targetLocationCode; + private String skuCode; + private String productNameZh; + private Long quantity; + private Long packages; + private String evidenceFileIdsJson; + private String receivedBy; + private LocalDateTime receivedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java index c7cf170dd..4869d2bb4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java @@ -10,6 +10,13 @@ import java.util.List; @Mapper public interface ExceptionReturnItemMapper extends BaseMapper { + /** 原箱回仓按本次派送和原出库明细分别累计,不能借用其他轮次。 */ + @Select("SELECT COALESCE(SUM(i.received_packages),0) FROM ll_exception_return_item i " + + "JOIN ll_exception_order e ON e.id=i.exception_order_id AND e.customer_id=i.customer_id " + + "WHERE e.customer_id=#{customerId} AND e.delivery_order_id=#{deliveryOrderId} " + + "AND i.outbound_pallet_item_id=#{itemId} AND i.return_carton_id=#{cartonId} AND i.inventory_position_id IS NULL") + long receivedByCartonSource(@Param("customerId") String customerId, @Param("deliveryOrderId") Long deliveryOrderId, + @Param("itemId") Long itemId, @Param("cartonId") Long cartonId); @Select("SELECT * FROM ll_exception_return_item WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionOrderId} ORDER BY id") List findByException(@Param("customerId") String customerId, @Param("exceptionOrderId") Long exceptionOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java new file mode 100644 index 000000000..0adec6c24 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.exceptionorder.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 原箱的出库归属证明和回仓凭据。 */ +@Mapper +public interface ReturnCartonStockReceiptMapper extends BaseMapper { + record Shipment(Long pickId, Long dispatchItemId, String dispatchNo,Long productQuantity) { } + /** 原箱必须真实出库且逐箱归属于当前异常、派送、客户及原尾程订单。 */ + @Select("SELECT p.id pick_id,di.id dispatch_item_id,d.dispatch_no,p.product_quantity FROM ll_exception_order e " + + "JOIN ll_exception_return_item frozen ON frozen.exception_order_id=e.id AND frozen.customer_id=e.customer_id " + + "AND frozen.outbound_pallet_item_id=#{itemId} AND frozen.return_carton_id=#{cartonId} AND frozen.inventory_position_id IS NULL AND frozen.approved_return_packages=1 AND frozen.received_packages=0 " + + "JOIN ll_delivery_order delivery ON delivery.id=e.delivery_order_id AND delivery.customer_id=e.customer_id " + + "JOIN ll_outbound_order o ON o.id=delivery.outbound_order_id AND o.customer_id=e.customer_id AND o.outbound_no=e.outbound_no AND o.status='OUTBOUNDED' " + + "JOIN ll_outbound_pallet_item oi ON oi.outbound_order_id=o.id AND oi.customer_id=e.customer_id AND oi.id=#{itemId} " + + "JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id AND d.customer_id=e.customer_id " + + "JOIN ll_dispatch_item di ON di.dispatch_order_id=d.id AND di.customer_id=e.customer_id AND di.warehouse_pallet_id=oi.warehouse_pallet_id AND di.source_stage='RETURN_RELABEL' " + + "AND di.inbound_code=oi.inbound_code AND COALESCE(di.destination_warehouse_code,'')=COALESCE(oi.destination_warehouse_code,'') " + + "JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id AND t.customer_id=e.customer_id " + + "JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND p.customer_id=e.customer_id AND p.dispatch_item_id=di.id AND p.return_carton_id=#{cartonId} AND p.inventory_position_id IS NULL AND p.packages=1 " + + "JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=e.customer_id AND a.order_id=e.last_mile_order_id AND a.packages=1 " + + "WHERE e.id=#{exceptionId} AND e.customer_id=#{customerId} AND e.status='PROCESSING' AND e.return_direction='OVERSEAS_WAREHOUSE'") + Shipment shipment(@Param("customerId") String customerId,@Param("exceptionId") Long exceptionId, + @Param("itemId") Long itemId,@Param("cartonId") Long cartonId); + /** 原出库每个SKU只允许恢复一次,独立于可变箱状态。 */ + @Select("SELECT * FROM ll_return_carton_stock_receipt WHERE customer_id=#{customerId} AND source_pick_id=#{pickId}") + List byPick(@Param("customerId") String customerId,@Param("pickId") Long pickId); + /** 异常详情按真实回仓凭据展示,重复请求不生成新记录。 */ + @Select("SELECT * FROM ll_return_carton_stock_receipt WHERE customer_id=#{customerId} AND exception_order_id=#{exceptionId} ORDER BY received_at,id") + List byException(@Param("customerId") String customerId,@Param("exceptionId") Long exceptionId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonReturnRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonReturnRepository.java new file mode 100644 index 000000000..9c87310fd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonReturnRepository.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.exceptionorder.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.lastleg.exceptionorder.mapper.ReturnCartonStockReceiptMapper; +import com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt; +import com.baiyue.lastleg.returnreceipt.entity.*; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 原箱回仓只写原生退货账本,不构造普通库存仓位。 */ +@Repository +public class ReturnCartonReturnRepository { + private final ReturnCartonStockReceiptMapper receipts; + private final ReturnInventoryCartonMapper cartons; + private final ReturnInventoryCartonItemMapper items; + private final ReturnInventoryMovementMapper movements; + private final ReturnInventoryBalanceMapper balances; + /** 装配同库原生库存及不可变出库证据。 */ + public ReturnCartonReturnRepository(ReturnCartonStockReceiptMapper receipts,ReturnInventoryCartonMapper cartons, + ReturnInventoryCartonItemMapper items,ReturnInventoryMovementMapper movements,ReturnInventoryBalanceMapper balances) { + this.receipts=receipts;this.cartons=cartons;this.items=items;this.movements=movements;this.balances=balances; + } + /** 读取与原箱对应的唯一实际出库。 */ + public ReturnCartonStockReceiptMapper.Shipment shipment(String customer,Long exceptionId,Long itemId,Long cartonId) { return receipts.shipment(customer,exceptionId,itemId,cartonId); } + /** 在同客户内锁定真实原箱。 */ + public ReturnInventoryCarton lockCarton(String customer,Long id) { return cartons.selectOne(new LambdaQueryWrapper().eq(ReturnInventoryCarton::getCustomerId,customer).eq(ReturnInventoryCarton::getId,id).last("FOR UPDATE")); } + /** 锁定原箱SKU内容,供与不可变出库快照核平。 */ + public List contents(Long id) { return items.lockCarton(id); } + /** 读取原出库逐SKU扣减事实。 */ + public List shipped(String customer,Long pickId) { return movements.selectList(new LambdaQueryWrapper().eq(ReturnInventoryOutboundMovement::getCustomerId,customer).eq(ReturnInventoryOutboundMovement::getOutboundPickId,pickId).orderByAsc(ReturnInventoryOutboundMovement::getSkuCode)); } + /** 已恢复的原出库不能再次入账。 */ + public boolean alreadyReceived(String customer,Long pickId) { return !receipts.byPick(customer,pickId).isEmpty(); } + /** 恢复原SKU的件数和原箱占位,不借用其他客户余额。 */ + public void restore(ReturnInventoryOutboundMovement shipment) { + if(balances.adjust(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),shipment.getCustomerId(),shipment.getOverseasWarehouseId(),shipment.getSkuCode(),shipment.getProductNameZh(),shipment.getQuantity(),shipment.getQuantity(),shipment.getCartonsBefore()-shipment.getCartonsAfter())<1) + throw new IllegalStateException("原箱SKU余额恢复失败"); + } + /** 保存不可变回仓行,唯一键防止重复恢复原出库。 */ + public void receipt(ReturnCartonStockReceipt receipt) { receipts.insert(receipt); } + /** 实际回仓后开放原箱,清除已完成换标的预留引用,保留原始退货归属。 */ + public int available(ReturnInventoryCarton carton,String pallet,String location,LocalDateTime now) { + return cartons.update(null,new LambdaUpdateWrapper().eq(ReturnInventoryCarton::getId,carton.getId()).eq(ReturnInventoryCarton::getCustomerId,carton.getCustomerId()).eq(ReturnInventoryCarton::getStatus,"OUTBOUNDED") + .set(ReturnInventoryCarton::getStatus,"AVAILABLE").set(ReturnInventoryCarton::getPalletCode,pallet).set(ReturnInventoryCarton::getLocationCode,location).set(ReturnInventoryCarton::getPutawayAt,now) + .set(ReturnInventoryCarton::getRelabelApplicationId,null).set(ReturnInventoryCarton::getRelabelNo,null).set(ReturnInventoryCarton::getRelabelStatus,null).set(ReturnInventoryCarton::getUpdatedAt,now)); + } + /** 查询原生退回历史。 */ + public List history(String customer,Long exceptionId) { return receipts.byException(customer,exceptionId); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonSourceRepository.java new file mode 100644 index 000000000..e5d3f5510 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonSourceRepository.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.exceptionorder.repository; + +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; + +/** 异常候选只读取得原箱业务快照,不写原生库存。 */ +public class ReturnCartonSourceRepository { + private final ReturnInventoryCartonMapper cartons; + private final com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returns; + /** 复用异常来源查询中的原箱数据入口。 */ + public ReturnCartonSourceRepository(ReturnInventoryCartonMapper cartons,com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper returns){this.cartons=cartons;this.returns=returns;} + /** 按不可变拣货引用取得原箱,再由服务核对客户和仓库。 */ + public ReturnInventoryCarton find(Long cartonId){return cartons.selectById(cartonId);} + /** 两类来源分别累计真实实收,禁止用空仓位合并不同原箱。 */ + public long received(String customer,Long deliveryId,Long itemId,Long positionId,Long cartonId){ + return cartonId==null?returns.receivedBySource(customer,deliveryId,itemId,positionId) + :returns.receivedByCartonSource(customer,deliveryId,itemId,cartonId); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 9a8c9f9a7..83a79ea80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -83,6 +83,8 @@ public class ExceptionOrderService { @Resource private InventoryCommandService inventoryCommands; @Resource private ExceptionEvidenceFileGateway evidenceFiles; @Resource private ObjectMapper objectMapper; + @Resource private com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper returnCartons; + @Resource private ReturnCartonReturnService nativeReturns; @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; public PageResult page(String scopedCustomerId, ExceptionOrderRequests.Query query) { @@ -116,7 +118,18 @@ public class ExceptionOrderService { ExceptionOrder order = exceptionOrders.findByNo(customerId, exceptionNo); if (order == null) throw notFound("异常订单不存在"); return new ExceptionOrderResponses.Detail(row(order), storedReturnSources(order), - receiptHistory(returnReceipts.history(customerId, order.getId()))); + returnHistory(order)); + } + + /** 普通仓位与原箱回仓都只展示实际入账凭据。 */ + private List returnHistory(ExceptionOrder order) { + var history=new ArrayList<>(receiptHistory(returnReceipts.history(order.getCustomerId(),order.getId()))); + if(returnItems.findByException(order.getCustomerId(),order.getId()).stream().anyMatch(item->item.getReturnCartonId()!=null)) { + var grouped=nativeReturns.history(order.getCustomerId(),order.getId()).stream().collect(java.util.stream.Collectors.groupingBy( + com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt::getReceiptNo,LinkedHashMap::new,java.util.stream.Collectors.toList())); + grouped.forEach((number,lines)->history.add(new ExceptionOrderResponses.ReturnReceipt(number,lines.stream().mapToLong(line->line.getPackages()).sum(),lines.get(0).getReceivedAt(),List.of()))); + } + return List.copyOf(history); } private List receiptHistory( @@ -183,10 +196,19 @@ public class ExceptionOrderService { .eq(LastlegOrder::getDeliveryOrderNo, deliveryNo) .orderByAsc(LastlegOrder::getOrderNo)).stream() .map(order -> withReturnSources( - relation(customerId, customerId, order.getInboundCode(), order.getOrderNo()), delivery)) + returnRelation(order), delivery)) .distinct().toList(); } + /** 以派送关联的真实尾程主单构建退回候选,换标主单无需普通入仓关系。 */ + private ExceptionOrderResponses.Relation returnRelation(LastlegOrder tail) { + LastlegInboundOrder inbound = optionalInbound(tail.getCustomerId(), tail.getInboundCode()); + return new ExceptionOrderResponses.Relation(tail.getId(), tail.getOrderNo(), + inbound == null ? null : inbound.getId(), tail.getInboundCode(), tail.getCustomerId(), tail.getCustomerName(), + tail.getOverseasWarehouseId(), tail.getOverseasWarehouseName(), tail.getDeliveryOrderNo(), + tail.getOutboundOrderNo(), tail.getPackageCount(), List.of()); + } + @Transactional public ExceptionOrderResponses.Created create(String scopedCustomerId, String operatorId, String operatorName, ExceptionOrderRequests.Create request) { @@ -196,6 +218,7 @@ public class ExceptionOrderService { request.skuCode(), null, null, "MANUAL", operatorId, operatorName); } + /** 按原出库冻结普通仓位或原箱来源,整箱引用与数量在同一派送锁内复核。 */ @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public ExceptionOrderResponses.Created markReturn(String customerId, String operatorId, String operatorName, String deliveryNo, ExceptionOrderRequests.MarkReturn request) { @@ -206,8 +229,8 @@ public class ExceptionOrderService { List candidates = returnCandidates(delivery); ExceptionOrderResponses.Relation selected = candidates.stream() - .filter(candidate -> Objects.equals(candidate.inboundOrderId(), request.inboundOrderId())) - .findFirst().orElseThrow(() -> conflict("所选入仓单不属于该派送单")); + .filter(candidate -> Objects.equals(candidate.lastMileOrderId(), request.lastMileOrderId())) + .findFirst().orElseThrow(() -> conflict("所选尾程主单不属于该派送单")); List available = selected.returnSources(); List requested = request.items(); validateReturnItems(available, requested); @@ -229,6 +252,7 @@ public class ExceptionOrderService { ExceptionReturnItem item = new ExceptionReturnItem(); item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setExceptionOrderId(created.id()); item.setOutboundPalletItemId(source.outboundPalletItemId()); item.setInventoryPositionId(source.inventoryPositionId()); + item.setReturnCartonId(source.returnCartonId()); item.setReturnCartonMark(source.cartonMark()); item.setPalletNo(source.palletNo()); item.setSkuCode(source.skuCode()); item.setMaxReturnPackages(source.maxReturnPackages()); item.setApprovedReturnPackages(requestedItem.packageCount()); item.setReceivedPackages(0L); @@ -302,6 +326,7 @@ public class ExceptionOrderService { return result; } + /** 一次实收的普通库存、原箱账本及完成状态同事务提交,失败全部回滚。 */ @Transactional public ExceptionOrderResponses.CommandResult complete(String customerId, String operatorId, String exceptionNo, @@ -312,6 +337,7 @@ public class ExceptionOrderService { ExceptionOrder order = lock(customerId, exceptionNo, request.expectedVersion(), "PROCESSING"); request.evidenceFileIds().stream().distinct().forEach(fileId -> evidenceFiles.validate(customerId, order.getId(), fileId)); ReturnedInventoryRestoreResponse restored = null; + long nativeRestored=0; List stored = List.of(); if ("RETURN".equals(order.getExceptionType()) && "OVERSEAS_WAREHOUSE".equals(order.getReturnDirection())) { var delivery = deliveryOrders.lockByNo(customerId, order.getDeliveryNo()); @@ -320,18 +346,21 @@ public class ExceptionOrderService { if (stored.isEmpty()) throw conflict("退回异常单缺少已冻结的原出库库存明细"); if (request.receipts() == null || request.receipts().isEmpty()) throw badRequest("请填写退回实收明细"); Map received = ReturnReceiptPolicy.quantities(stored, request.receipts()); - restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId, + var ordinaryReceipts=request.receipts().stream().filter(receipt->receipt.returnCartonId()==null).toList(); + if(!ordinaryReceipts.isEmpty()) restored = inventoryCommands.restoreReturnedInventory(customerId, operatorId, new ReturnedInventoryRestoreRequest(order.getExceptionNo(), order.getOutboundNo(), order.getOverseasWarehouseId(), order.getInboundOrderId(), - stored.stream().filter(item -> received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem( + stored.stream().filter(item -> item.getReturnCartonId()==null && received.containsKey(item.getId())).map(item -> new ReturnedInventoryRestoreRequest.SourceItem( item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getSkuCode(), item.getApprovedReturnPackages() - ReturnReceiptPolicy.received(item))).toList(), - request.receipts().stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt( + ordinaryReceipts.stream().map(receipt -> new ReturnedInventoryRestoreRequest.Receipt( receipt.outboundPalletItemId(), receipt.inventoryPositionId(), receipt.receivedPackages(), receipt.targetPalletId(), receipt.targetPalletNo(), receipt.targetLocationId(), receipt.targetLocationCode())).toList(), request.idempotencyKey().trim(), request.evidenceFileIds().stream().map(String::valueOf).toList())); - if (restored.restoredPackages() != received.values().stream().mapToLong(Long::longValue).sum()) + if(request.receipts().stream().anyMatch(receipt->receipt.returnCartonId()!=null)) + nativeRestored=nativeReturns.restore(order,stored,request.receipts(),request.evidenceFileIds(),operatorId); + if ((restored==null?0:restored.restoredPackages())+nativeRestored != received.values().stream().mapToLong(Long::longValue).sum()) throw conflict("退回库存实际入账数量与本次实收不一致"); for (ExceptionReturnItem item : stored) if (received.containsKey(item.getId())) { item.setReceivedPackages(ReturnReceiptPolicy.received(item) + received.get(item.getId())); @@ -350,7 +379,7 @@ public class ExceptionOrderService { order.setHandlerId(operatorId); order.setCompletedAt(completed ? now : null); order.setUpdatedAt(now); order.setVersion(version(order) + 1); exceptionOrders.updateById(order); closeReturnedDelivery(order, operatorId, now); - long restoredPackages = restored == null ? 0 : restored.restoredPackages(); + long restoredPackages = (restored == null ? 0 : restored.restoredPackages())+nativeRestored; ExceptionOrderResponses.CommandResult result = commandResult(order, restoredPackages, false); recordCommand(customerId, order.getId(), "COMPLETE", request.idempotencyKey(), requestHash, result); return result; @@ -474,12 +503,15 @@ public class ExceptionOrderService { readEvidenceFileIds(value.getEvidenceFileIdsJson()), allowedActions(value)); } + /** 原出库数量减去已实际回仓量,历史原箱与普通仓位分别核平。 */ private ExceptionOrderResponses.Relation withReturnSources(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery) { List sources = returnSources(delivery).stream() - .filter(source -> relation.inboundCode() == null || Objects.equals(source.inboundCode(), relation.inboundCode())) - .map(source -> source.withPackages(source.maxReturnPackages() - returnItems.receivedBySource( - delivery.getCustomerId(), delivery.getId(), source.outboundPalletItemId(), source.inventoryPositionId()))) + .filter(source -> source.lastMileOrderId() != null + ? Objects.equals(source.lastMileOrderId(), relation.lastMileOrderId()) + : StringUtils.hasText(relation.inboundCode()) && Objects.equals(source.inboundCode(), relation.inboundCode())) + .map(source -> source.withPackages(source.maxReturnPackages() - new com.baiyue.lastleg.exceptionorder.repository.ReturnCartonSourceRepository(returnCartons,returnItems).received( + delivery.getCustomerId(),delivery.getId(),source.outboundPalletItemId(),source.inventoryPositionId(),source.returnCartonId()))) .filter(source -> source.maxReturnPackages() > 0).map(Source::response).toList(); return new ExceptionOrderResponses.Relation(relation.lastMileOrderId(), relation.lastMileOrderNo(), relation.inboundOrderId(), relation.inboundCode(), relation.customerId(), relation.customerName(), @@ -487,6 +519,7 @@ public class ExceptionOrderService { relation.outboundNo(), relation.availablePackages(), sources); } + /** 合并实际出库拣货证据,来源类型始终保留为普通仓位或原箱。 */ private List returnSources(DeliveryOrder delivery) { OutboundOrder outbound = outboundOrders.findByNo(delivery.getCustomerId(), delivery.getOutboundNo()); if (outbound == null) throw conflict("派送单缺少原出库单据"); @@ -499,13 +532,14 @@ public class ExceptionOrderService { if (item == null || pick.getInventoryPositionId() == null) continue; String key = item.getId() + ":" + pick.getInventoryPositionId(); aggregated.merge(key, new Source(item.getId(), pick.getInventoryPositionId(), item.getPalletNo(), - item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L)), + item.getSkuCode(), item.getInboundCode(), Objects.requireNonNullElse(pick.getPackages(), 0L), null, null, null), (left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages())); } appendDispatchPickSources(delivery, outbound, itemById, aggregated); return new ArrayList<>(aggregated.values()); } + /** 按实际拣货和原订单分摊归属恢复来源,原箱不伪造库存仓位。 */ private void appendDispatchPickSources(DeliveryOrder delivery, OutboundOrder outbound, Map outboundItemById, Map aggregated) { @@ -518,7 +552,7 @@ public class ExceptionOrderService { .forEach(item -> dispatchItemById.put(item.getId(), item)); for (DispatchPdaPick pick : dispatchPdaPicks.listTask(customerId, task.getId())) { DispatchItem dispatchItem = dispatchItemById.get(pick.getDispatchItemId()); - if (dispatchItem == null || pick.getInventoryPositionId() == null) continue; + if (dispatchItem == null || (pick.getInventoryPositionId() == null && pick.getReturnCartonId() == null)) continue; List attributions = dispatchPdaPickOrders.listPick(customerId, pick.getId()); for (DispatchPdaPickOrder attribution : attributions) { LastlegOrder tail = lastMileOrders.selectById(attribution.getOrderId()); @@ -533,39 +567,53 @@ public class ExceptionOrderService { if (matchingItems.size() > 1) throw conflict("排车明细对应的原出库托盘项不唯一,拒绝退回来源"); if (matchingItems.isEmpty()) continue; OutboundPalletItem outboundItem = matchingItems.get(0); - // A mixed pallet has one physical outbound item; each source is identified by the actual pick attribution. - String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + tail.getInboundCode(); + // 同一实托可包含多票;逐票仓位或原箱以实际拣货归属分别确定。 + String key = outboundItem.getId() + ":" + pick.getInventoryPositionId() + ":" + pick.getReturnCartonId() + ":" + tail.getId(); + var carton = pick.getReturnCartonId() == null ? null : new com.baiyue.lastleg.exceptionorder.repository.ReturnCartonSourceRepository(returnCartons,returnItems).find(pick.getReturnCartonId()); + if (pick.getReturnCartonId() != null && (carton == null || !customerId.equals(carton.getCustomerId()) + || !Objects.equals(dispatchItem.getOverseasWarehouseId(), carton.getOverseasWarehouseId()) + || !"RETURN_RELABEL".equals(dispatchItem.getSourceStage()))) + throw conflict("原箱出库来源与客户、仓库或换标申请不一致"); long packages = Objects.requireNonNullElse(attribution.getPackages(), 0L); + if(pick.getReturnCartonId()!=null&&(packages!=1||!Objects.equals(pick.getPackages(),1L)))throw conflict("原箱出库数量凭据不一致"); aggregated.merge(key, new Source(outboundItem.getId(), pick.getInventoryPositionId(), - outboundItem.getPalletNo(), outboundItem.getSkuCode(), tail.getInboundCode(), packages), + outboundItem.getPalletNo(), outboundItem.getSkuCode(), tail.getInboundCode(), packages, + pick.getReturnCartonId(), carton == null ? null : carton.getCartonMark(), tail.getId()), (left, right) -> left.withPackages(left.maxReturnPackages() + right.maxReturnPackages())); } } } + /** 详情仅返回冻结来源的剩余可收量及原箱业务码。 */ private List storedReturnSources(ExceptionOrder order) { return returnItems.findByException(order.getCustomerId(), order.getId()).stream() .map(item -> new ExceptionOrderResponses.ReturnSource(item.getOutboundPalletItemId(), item.getInventoryPositionId(), item.getPalletNo(), item.getSkuCode(), - Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item))) + Objects.requireNonNullElse(item.getApprovedReturnPackages(), 0L) - ReturnReceiptPolicy.received(item), + item.getReturnCartonId(), item.getReturnCartonMark())) .filter(item -> item.maxReturnPackages() > 0).toList(); } + /** 申请的来源必须唯一、数量有效且不超过本派送原出库事实。 */ private void validateReturnItems(List available, List requested) { Set unique = new java.util.HashSet<>(); for (ExceptionOrderRequests.ReturnItem item : requested) { - String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId(); + ReturnReceiptPolicy.requireSource(item.inventoryPositionId(), item.returnCartonId()); + if(item.packageCount()<1||item.returnCartonId()!=null&&item.packageCount()!=1)throw badRequest("退回箱数必须为正数,退货原箱必须按一整箱申请"); + String key = item.outboundPalletItemId() + ":" + item.inventoryPositionId() + ":" + item.returnCartonId(); if (!unique.add(key)) throw badRequest("退回库存明细不能重复"); ExceptionOrderResponses.ReturnSource source = available.stream().filter(value -> sameSource(value, item)) - .findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和入仓单")); + .findFirst().orElseThrow(() -> conflict("所选退回库存不属于该派送单和尾程主单")); if (item.packageCount() > source.maxReturnPackages()) throw conflict("退回箱数超过原出库箱数"); } } + /** 出库项与两类互斥来源共同确定一条真实退回明细。 */ private boolean sameSource(ExceptionOrderResponses.ReturnSource source, ExceptionOrderRequests.ReturnItem item) { return Objects.equals(source.outboundPalletItemId(), item.outboundPalletItemId()) - && Objects.equals(source.inventoryPositionId(), item.inventoryPositionId()); + && Objects.equals(source.inventoryPositionId(), item.inventoryPositionId()) + && Objects.equals(source.returnCartonId(), item.returnCartonId()); } private ExceptionOrder lock(String customerId, String exceptionNo, long expectedVersion, String expectedStatus) { @@ -691,11 +739,13 @@ public class ExceptionOrderService { private static long version(ExceptionOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } private record Source(Long outboundPalletItemId, Long inventoryPositionId, String palletNo, - String skuCode, String inboundCode, long maxReturnPackages) { - Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages); } + String skuCode, String inboundCode, long maxReturnPackages, Long returnCartonId, String cartonMark, Long lastMileOrderId) { + /** 保留原始来源身份,仅修订剩余可退数量。 */ + Source withPackages(long packages) { return new Source(outboundPalletItemId, inventoryPositionId, palletNo, skuCode, inboundCode, packages, returnCartonId, cartonMark, lastMileOrderId); } + /** 以原箱业务码展示,内部来源标识仅用于提交核对。 */ ExceptionOrderResponses.ReturnSource response() { return new ExceptionOrderResponses.ReturnSource(outboundPalletItemId, inventoryPositionId, - palletNo, skuCode, maxReturnPackages); + palletNo, skuCode, maxReturnPackages, returnCartonId, cartonMark); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java new file mode 100644 index 000000000..0d0c5850d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.entity.*; +import com.baiyue.lastleg.exceptionorder.repository.ReturnCartonReturnRepository; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryOutboundMovement; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import java.util.*; + +/** 异常实收事务内按原出库证据恢复原箱,永不创建普通库存仓位。 */ +@Service +public class ReturnCartonReturnService { + private final ReturnCartonReturnRepository repository; + private final PalletPhysicalGateway pallets; + private final WarehouseResourceGateway warehouse; + private final ObjectMapper json; + private final com.baiyue.lastleg.service.LastlegOrderNoGenerator numbers; + /** 装配真实原箱数据入口和仓储资源校验。 */ + public ReturnCartonReturnService(ReturnCartonReturnRepository repository,PalletPhysicalGateway pallets,WarehouseResourceGateway warehouse,ObjectMapper json,com.baiyue.lastleg.service.LastlegOrderNoGenerator numbers) { + this.repository=repository;this.pallets=pallets;this.warehouse=warehouse;this.json=json;this.numbers=numbers; + } + /** 同一异常完成命令持有异常、派送行锁;任一原箱失败时全部库存和凭据回滚。 */ + @Transactional(propagation=Propagation.MANDATORY) + public long restore(ExceptionOrder order,List frozen,List inputs,List evidence,String operator) { + if(evidence==null||evidence.isEmpty())throw conflict("原箱实收必须保留有效证据文件"); + long packages=0; + for (var input:inputs.stream().filter(i->i.returnCartonId()!=null).sorted(Comparator.comparing(ExceptionOrderRequests.ReturnReceipt::returnCartonId)).toList()) { + var source=frozen.stream().filter(i->Objects.equals(i.getOutboundPalletItemId(),input.outboundPalletItemId())&&Objects.equals(i.getReturnCartonId(),input.returnCartonId())).findFirst().orElseThrow(()->conflict("退回原箱不属于本异常冻结来源")); + if(input.receivedPackages()!=1||source.getApprovedReturnPackages()!=1||ReturnReceiptPolicy.received(source)!=0||input.inventoryPositionId()!=null)throw conflict("原箱须按整箱实收且只能入账一次"); + var proof=repository.shipment(order.getCustomerId(),order.getId(),source.getOutboundPalletItemId(),source.getReturnCartonId()); + if(proof==null)throw conflict("原箱缺少本派送实际出库及原订单归属凭据"); + var carton=repository.lockCarton(order.getCustomerId(),source.getReturnCartonId()); + if(carton==null||!Objects.equals(carton.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!"OUTBOUNDED".equals(carton.getStatus())||repository.alreadyReceived(order.getCustomerId(),proof.pickId()))throw conflict("原箱已回仓或客户、仓库、出库状态不匹配"); + var shipped=repository.shipped(order.getCustomerId(),proof.pickId()); + var expected=new TreeMap(); + for(var movement:shipped) { + if(!Objects.equals(movement.getCartonId(),carton.getId())||!Objects.equals(movement.getOverseasWarehouseId(),order.getOverseasWarehouseId())||!Objects.equals(movement.getOutboundNo(),proof.dispatchNo())||movement.getQuantity()==null||movement.getQuantity()<1)throw conflict("原箱出库SKU事实不完整或归属不一致"); + expected.merge(movement.getSkuCode(),movement.getQuantity(),Math::addExact); + } + var current=new TreeMap(); + for(var item:repository.contents(carton.getId())) { + if(!Objects.equals(item.getCustomerId(),order.getCustomerId())||Objects.requireNonNullElse(item.getPackingReservedQuantity(),0L)>0)throw conflict("原箱内容客户或预留状态不一致"); + current.merge(item.getSkuCode(),item.getQuantity(),Math::addExact); + } + long shippedQuantity=expected.values().stream().mapToLong(Long::longValue).sum(); + if(expected.isEmpty()||!expected.equals(current)||shippedQuantity!=Objects.requireNonNullElse(carton.getTotalQuantity(),0L) + ||!Objects.equals(proof.productQuantity(),shippedQuantity))throw conflict("原箱件数与不可变出库SKU快照不一致"); + if(input.targetPalletId()==null||input.targetPalletId()<1||input.targetLocationId()==null||input.targetLocationId()<1||input.targetPalletNo()==null||input.targetPalletNo().isBlank()||input.targetLocationCode()==null||input.targetLocationCode().isBlank())throw conflict("请填写真实回仓目标托盘和库位"); + var pallet=pallets.resolve(input.targetPalletNo()); + var location=warehouse.resolveLocation(Long.parseLong(order.getOverseasWarehouseId()),input.targetLocationCode()); + if(pallet==null||pallet.palletId()!=input.targetPalletId()||!String.valueOf(pallet.warehouseId()).equals(order.getOverseasWarehouseId())||!Set.of("IDLE","OCCUPIED").contains(pallet.status())||location==null||location.locationId()!=input.targetLocationId()||!Objects.equals(pallet.locationId(),location.locationId())||!Set.of("ACTIVE","IDLE","OCCUPIED").contains(location.status())||"VIRTUAL".equalsIgnoreCase(location.locationType()))throw conflict("目标托盘、库位或海外仓不匹配,不能确认原箱回仓"); + LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT",order.getOverseasWarehouseId(),IdWorker.getId());boolean first=true; + for(var movement:shipped) { + repository.restore(movement); + var receipt=new ReturnCartonStockReceipt();receipt.setId(IdWorker.getId());receipt.setReceiptNo(receiptNo);receipt.setCustomerId(order.getCustomerId()); + receipt.setExceptionOrderId(order.getId());receipt.setExceptionReturnItemId(source.getId());receipt.setDeliveryOrderId(order.getDeliveryOrderId());receipt.setSourcePickId(proof.pickId());receipt.setSourceOutboundNo(order.getOutboundNo());receipt.setSourceDispatchItemId(proof.dispatchItemId());receipt.setSourceOutboundPalletItemId(source.getOutboundPalletItemId());receipt.setReturnCartonId(carton.getId());receipt.setCartonMark(carton.getCartonMark());receipt.setOverseasWarehouseId(order.getOverseasWarehouseId());receipt.setTargetPalletId(input.targetPalletId());receipt.setTargetPalletNo(pallet.palletCode());receipt.setTargetLocationId(input.targetLocationId());receipt.setTargetLocationCode(location.locationCode());receipt.setSkuCode(movement.getSkuCode());receipt.setProductNameZh(movement.getProductNameZh());receipt.setQuantity(movement.getQuantity());receipt.setPackages(first?1L:0L);receipt.setReceivedAt(now); + receipt.setReceivedBy(operator); + try{receipt.setEvidenceFileIdsJson(json.writeValueAsString(evidence));}catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException(error);} + repository.receipt(receipt);first=false; + } + if(repository.available(carton,pallet.palletCode(),location.locationCode(),now)!=1)throw conflict("原箱回仓状态已变化"); + packages++; + } + return packages; + } + /** 只展示已落账的原箱回仓凭据。 */ + public List history(String customer,Long exceptionId){return repository.history(customer,exceptionId);} + /** 拒绝不完整来源或实收,不保留部分入账。 */ + private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java index e8336d790..ef2bb97d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java @@ -12,12 +12,15 @@ import org.springframework.web.server.ResponseStatusException; final class ReturnReceiptPolicy { private ReturnReceiptPolicy() { } + /** 实收必须逐一匹配冻结的普通仓位或原箱来源,禁止两类标识同时提交。 */ static Map quantities(List sources, List receipts) { Map amounts = new LinkedHashMap<>(); if (sources.isEmpty() || receipts == null || receipts.isEmpty()) throw conflict("请填写已冻结来源的退回实收明细"); for (ReturnReceipt receipt : receipts) { + requireSource(receipt.inventoryPositionId(), receipt.returnCartonId()); ExceptionReturnItem source = sources.stream().filter(item -> Objects.equals(item.getOutboundPalletItemId(), receipt.outboundPalletItemId()) - && Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId())).findFirst() + && Objects.equals(item.getInventoryPositionId(), receipt.inventoryPositionId()) + && Objects.equals(item.getReturnCartonId(), receipt.returnCartonId())).findFirst() .orElseThrow(() -> conflict("退回实收明细不属于本异常冻结来源")); if (receipt.receivedPackages() <= 0) throw conflict("退回实收箱数必须大于零"); amounts.merge(source.getId(), receipt.receivedPackages(), Math::addExact); @@ -29,6 +32,12 @@ final class ReturnReceiptPolicy { return amounts; } + /** 普通仓位与原箱引用恰有一个,零值和负值不能作为来源。 */ + static void requireSource(Long positionId, Long cartonId) { + if ((positionId == null) == (cartonId == null) || positionId != null && positionId < 1 || cartonId != null && cartonId < 1) + throw conflict("库存仓位与退货原箱必须且只能填写一个有效来源"); + } + static long received(ExceptionReturnItem item) { return Objects.requireNonNullElse(item.getReceivedPackages(), 0L); } static boolean complete(List items) { return !items.isEmpty() && items.stream().allMatch(item -> received(item) == item.getApprovedReturnPackages()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java new file mode 100644 index 000000000..11216d647 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.fleet.trip; + +/** 原承运登记的真实入仓来源查询入口。 */ +public class FleetBookingCargoRepository { + private final FleetBookingMapper bookings; + /** 复用车次事务中的承运登记Mapper。 */ + public FleetBookingCargoRepository(FleetBookingMapper bookings){this.bookings=bookings;} + /** 按客户与真实入仓码定位承运,历史取消记录交由业务层分别处理。 */ + public FleetBookingEntity forInboundSource(String customerId,String inboundCode){return bookings.forInboundSource(customerId,inboundCode);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreight.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreight.java index 9183386d7..7376d48c3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreight.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreight.java @@ -26,7 +26,7 @@ public class FleetBookingFreight { public record Preview(String snapshotHash, boolean requiresDeliveryFee, List quotes, @com.fasterxml.jackson.annotation.JsonIgnore List priorInboundCodes) { } - /** Caller serializes transfer with the dispatch lock. Never lock the parent trip here. */ + /** 转派送持有排车锁,按真实入仓组成复核约定运费,不反向申请父车次锁。 */ public Preview preview(long dispatchId, String dispatchNo, String customerId, List sources) { var quotes = new ArrayList(); @@ -38,7 +38,7 @@ public class FleetBookingFreight { else grouped.computeIfAbsent(source.inboundCode(), key -> new ArrayList<>()).add(source); } for (var entry : grouped.entrySet()) { - var booking = bookings.incomingForFreight(entry.getKey()); + var booking = new FleetBookingCargoRepository(bookings).forInboundSource(customerId, entry.getKey()); if (booking == null) { requiresFee = true; continue; } if (!customerId.equals(booking.getCustomerId())) throw conflict("来货来源与排车客户不一致"); var accepted = reservations.receipt(booking); @@ -62,7 +62,7 @@ public class FleetBookingFreight { } var confirmed = new TreeMap(); for (var pallet : accepted.source().pallets()) confirmed.merge(pallet.warehousePalletId(), pallet.packages(), Math::addExact); - if (!actual.equals(confirmed)) throw conflict("外接发运数量与已确认实收不一致"); + if (!actual.equals(confirmed)) throw conflict("外接发运托盘或数量与已确认实收不一致;请先解除排车关联,在外接预订重新确认实际收货与运费后再绑定"); var cargo = reservations.cargo(booking); BigDecimal quantity = "PALLET".equals(cargo.priceBasis()) ? BigDecimal.valueOf(cargo.palletSpaces()) : BigDecimal.ONE; if (booking.getAgreedAmount() == null || booking.getAgreedAmount().signum() < 0 || cargo.unitPrice() == null diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingMapper.java index 39a65e9b4..f7666bccd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingMapper.java @@ -6,12 +6,13 @@ import java.util.List; @Mapper public interface FleetBookingMapper extends BaseMapper { + /** 按实际入仓来源定位原承运登记,取消记录保留供费用核对。 */ + @Select("SELECT booking.* FROM ll_fleet_external_booking booking JOIN ll_warehouse_transfer_order incoming " + + "ON incoming.customer_id=booking.customer_id AND incoming.transfer_no=booking.incoming_no " + + "WHERE booking.customer_id=#{customerId} AND incoming.inbound_code=#{inboundCode}") + FleetBookingEntity forInboundSource(@Param("customerId") String customerId, @Param("inboundCode") String inboundCode); @Select("SELECT * FROM ll_fleet_external_booking WHERE booking_no=#{number}") FleetBookingEntity byNo(@Param("number") String number); - @Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo} AND status <> 'CANCELLED'") - FleetBookingEntity incoming(@Param("incomingNo") String incomingNo); - @Select("SELECT * FROM ll_fleet_external_booking WHERE incoming_no=#{incomingNo}") - FleetBookingEntity incomingForFreight(@Param("incomingNo") String incomingNo); @Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} ORDER BY id") List list(@Param("tripId") long tripId); @Select("SELECT * FROM ll_fleet_external_booking WHERE trip_id=#{tripId} AND created_by=#{operator} AND idempotency_key=#{key}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java index 1f7143f4f..2f36d2a8f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java @@ -28,7 +28,9 @@ public final class FleetBookingModels { Instant estimatedArrivalAt,@Size(max=1000) String deliveryInstructions,@Size(max=1000) String privateAddress, @Size(max=32) String postalCode,@Size(max=128) String recipientName,@Size(max=64) String recipientPhone, @NotBlank @Size(min=8,max=128) String idempotencyKey) { } - public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces) { } + public record PalletSpaces(@NotBlank String warehousePalletId,@Min(1) int palletSpaces, + @DecimalMin("0.001") @Digits(integer=9,fraction=3) BigDecimal weightKg, + @DecimalMin("0.001") @Digits(integer=9,fraction=3) BigDecimal volumeCbm) { } public record AcceptReceipt(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long recordVersion, @NotBlank String receiptHash,@NotEmpty List<@Valid PalletSpaces> pallets, @NotNull @DecimalMin("0.00") @Digits(integer=10,fraction=2) BigDecimal unitPrice, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index a83167e41..5ffa4641c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -48,10 +48,11 @@ public class FleetBookingReservations { } public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);} private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());} + /** 按实际入仓来源绑定整票实收,合托不能掩盖外接承诺。 */ public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List sources,String operator){ var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode)); for(var entry:bySource.entrySet()){ - var row=bookings.incoming(entry.getKey());if(row==null)continue; + var row=new FleetBookingCargoRepository(bookings).forInboundSource(dispatch.customerId(),entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue; if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订"); if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认"); @@ -62,7 +63,7 @@ public class FleetBookingReservations { if(item.warehousePalletId()==null||!Objects.equals(item.destinationKey(),row.getDestinationKey()))throw conflict("排车货物托盘或目的地与外接实收不一致"); selected.merge(item.warehousePalletId(),item.packages(),Math::addExact); } - if(!selected.equals(expected))throw conflict("当前排车未完整包含此外接预订的实收货物"); + if(!selected.equals(expected))throw conflict("当前托盘与外接实收不一致;请在外接预订重新确认实际收货与运费后再关联排车"); row.setStatus("BOUND");row.setDispatchNo(dispatch.dispatchNo());touch(row,operator); if(row.getOfferId()!=null)poolOrders.boundDispatch(row.getId(),row.getCustomerId(),dispatch.dispatchNo(),operator); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index 154c15bd2..e21cbe43f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -143,12 +143,13 @@ public class FleetBookingService { save(trip,row,operator,"BOOKING_INCOMING","登记外接来货 "+evidence.incomingNo());return result(trip,row,false); } @Transactional + /** 首次或解除排车后的重新核对都要求当前实收摘要、版本、完整实托和容量一致。 */ public FleetBookingModels.Result acceptReceipt(String tripNo,String bookingNo,String operator,FleetBookingModels.AcceptReceipt request){ var trip=trip(tripNo,true);String vehicleBlock=execution.currentVehicleBlockReason(trip,true);var row=booking(trip,bookingNo);String hash=hash(request); if(Objects.equals(row.getReceiptKey(),request.idempotencyKey())){if(!hash.equals(row.getReceiptHash()))throw conflict("实收确认请求内容不一致");return result(trip,row,true);} if(vehicleBlock!=null)throw conflict(vehicleBlock); requireTrip(trip,request.tripRecordVersion());requireVersion(row,request.recordVersion()); - if(!"CONFIRMED".equals(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货"); + if(!java.util.Set.of("CONFIRMED","RECEIVED").contains(row.getStatus())||row.getIncomingNo()==null)throw conflict("请先登记来货并完成仓库实际收货;已绑定排车须先解除关联后重新核对"); var evidence=incoming.inspect(row.getCustomerId(),row.getIncomingNo(),true);requireEvidence(trip,row,evidence); if(!java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(evidence.status())||evidence.pallets().isEmpty())throw conflict("仓库尚未完成实收及测量"); if(!Objects.equals(evidence.receiptHash(),request.receiptHash()))throw conflict("实收数据已变化,请刷新后重新核对"); @@ -156,8 +157,19 @@ public class FleetBookingService { for(var entry:request.pallets())if(entry.palletSpaces()<1||spaces.put(entry.warehousePalletId(),entry.palletSpaces())!=null)throw conflict("请为每个实际托盘填写一次占用托位"); var physical=evidence.pallets().stream().map(FleetBookingIncomingGateway.Pallet::warehousePalletId).collect(java.util.stream.Collectors.toSet()); if(!spaces.keySet().equals(physical)||physical.contains(null))throw conflict("托位确认须与实际收货托盘一致"); + // 旧测量仅适用于同实托同箱数;组成变化后必须明确输入新的该票实测值。 + var measured=new java.util.ArrayList(); + for(var pallet:evidence.pallets()) { + var input=request.pallets().stream().filter(p->Objects.equals(p.warehousePalletId(),pallet.warehousePalletId())).findFirst().orElseThrow(); + var kg=pallet.weightKg()==null?input.weightKg():pallet.weightKg(); + var cbm=pallet.volumeCbm()==null?input.volumeCbm():pallet.volumeCbm(); + if(pallet.weightKg()!=null&&input.weightKg()!=null&&pallet.weightKg().compareTo(input.weightKg())!=0 + ||pallet.volumeCbm()!=null&&input.volumeCbm()!=null&&pallet.volumeCbm().compareTo(input.volumeCbm())!=0) + throw conflict("未变化托盘须使用仓储转运的原实测值,不能覆盖原收货证据"); + measured.add(new FleetBookingIncomingGateway.Pallet(pallet.warehousePalletId(),pallet.palletNo(),pallet.packages(),kg,cbm)); + } BigDecimal weight=BigDecimal.ZERO,volume=BigDecimal.ZERO;int count=0; - for(var pallet:evidence.pallets()){ + for(var pallet:measured){ if(pallet.packages()<=0||pallet.weightKg()==null||pallet.volumeCbm()==null||pallet.weightKg().signum()<=0||pallet.volumeCbm().signum()<=0)throw conflict("实际托盘缺少有效箱数、重量或体积"); weight=weight.add(pallet.weightKg());volume=volume.add(pallet.volumeCbm());count=Math.addExact(count,pallet.packages()); } @@ -168,7 +180,8 @@ public class FleetBookingService { reservations.segments(trip,plan.capacityPlan(),row.getId(),row); var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT"); if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对"); - row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(confirmed,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now()))); + var measuredReceipt=new FleetBookingIncomingGateway.Evidence(confirmed.incomingNo(),confirmed.inboundCode(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),confirmed.receiptHash(),java.util.List.copyOf(measured),confirmed.receivedPackages()); + row.setReceiptJson(write(new FleetBookingModels.AcceptedReceipt(measuredReceipt,java.util.List.copyOf(request.pallets()),operator,java.time.Instant.now()))); row.setStatus("RECEIVED");row.setReceiptKey(request.idempotencyKey());row.setReceiptHash(hash); if(row.getOfferId()!=null)poolOrders.acceptedReceipt(row.getId(),new Receipt( confirmed.incomingNo(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java index 57bda1bff..8a832d43a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java @@ -18,6 +18,7 @@ public interface ReturnInventoryMovementMapper extends com.baomidou.mybatisplus. "FROM ll_return_inventory_carton c JOIN ll_return_inventory_carton_item i ON i.carton_id=c.id WHERE c.source_batch_no='MERGED'" + " UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'OUTBOUND',outbound_no," + "-quantity,-quantity,occurred_at FROM ll_return_inventory_outbound_movement" + + " UNION ALL SELECT customer_id,overseas_warehouse_id,sku_code,product_name_zh,'EXCEPTION_RETURN_IN',receipt_no,quantity,quantity,received_at FROM ll_return_carton_stock_receipt" + " UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_OUT',relabel_no,-quantity,-quantity,completed_at FROM ll_return_sku_conversion" + " UNION ALL SELECT customer_id,overseas_warehouse_id,new_sku_code,product_name_zh,'SKU_CONVERSION_IN',relabel_no,quantity,quantity,completed_at FROM ll_return_sku_conversion" + " UNION ALL SELECT customer_id,overseas_warehouse_id,old_sku_code,product_name_zh,'SKU_CONVERSION_REVERSE_IN',relabel_no,quantity,quantity,reversed_at FROM ll_return_sku_conversion WHERE status='REVERSED'" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java index 8999ddc0c..b6215366c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java @@ -18,6 +18,7 @@ import java.util.*; @Component public class FleetIncomingAdapter implements FleetBookingIncomingGateway { + @jakarta.annotation.Resource private com.baiyue.lastleg.transfer.repository.FleetIncomingActualSourceRepository actualSources; @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; private final WarehouseTransferService service; private final WarehouseTransferOrderMapper orders; @@ -49,25 +50,36 @@ public class FleetIncomingAdapter implements FleetBookingIncomingGateway { var approved=service.review(input.customerId(),created.transferNo(),operator,new WarehouseTransferRequests.Review("APPROVE","外接承运已确认,按实际来货收货")); fleetPool.bindIncoming(input.bookingNo(),input.customerId(),approved.transferNo(),approved.lastlegOrderId(), approved.operatingWarehouseId(),key,operator); - return evidence(approved); + return evidence(input.customerId(),approved,false); } @Override @Transactional(propagation=Propagation.MANDATORY) public Evidence inspect(String customerId,String incomingNo,boolean lock){ var row=lock?orders.lockNo(customerId,incomingNo):orders.findNo(customerId,incomingNo); if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前客户无此外接来货单"); - return evidence(service.detail(customerId,incomingNo)); + return evidence(customerId,service.detail(customerId,incomingNo),lock); } @Override @Transactional(propagation=Propagation.MANDATORY) public Evidence confirm(String customerId,String incomingNo,String operator,String key){ var row=orders.lockNo(customerId,incomingNo); if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"外接来货单不存在"); - if("READY_FOR_DISPATCH".equals(row.getStatus()))return evidence(service.detail(customerId,incomingNo)); - return evidence(service.confirmMeasurement(customerId,incomingNo,operator,new WarehouseTransferRequests.CustomerConfirm(key,"运营代委托方确认外接实测货量"))); + if("READY_FOR_DISPATCH".equals(row.getStatus()))return evidence(customerId,service.detail(customerId,incomingNo),true); + return evidence(customerId,service.confirmMeasurement(customerId,incomingNo,operator,new WarehouseTransferRequests.CustomerConfirm(key,"运营代委托方确认外接实测货量")),true); } - private Evidence evidence(WarehouseTransferResponses.Detail value){ + /** 实收完成后按真实组成重建托盘;变化后的测量值必须重新确认,不能比例推算。 */ + private Evidence evidence(String customerId,WarehouseTransferResponses.Detail value,boolean lock){ var pallets=value.pallets().stream().filter(row->"COMPLETED".equals(row.status())).map(row->new Pallet( row.warehousePalletId()==null?null:row.warehousePalletId().toString(),row.palletNo(),row.cartonCount(),row.grossWeightKg(),row.volumeCbm())) .sorted(Comparator.comparing(row->Objects.requireNonNullElse(row.warehousePalletId(),""))).toList(); + if(java.util.Set.of("WAIT_CUSTOMER_CONFIRMATION","READY_FOR_DISPATCH").contains(value.status())) { + var measured=pallets; + pallets=actualSources.pallets(customerId,value.inboundCode(),value.operatingWarehouseId(),lock).stream().map(actual->{ + if(actual.palletId()==null||actual.palletId()<1||actual.palletNo()==null||actual.palletNo().isBlank())throw conflict("外接实收货物尚未绑定真实实物托盘"); + var prior=measured.stream().filter(p->Objects.equals(p.warehousePalletId(),String.valueOf(actual.palletId()))&&p.packages()==actual.packages()).findFirst().orElse(null); + return new Pallet(String.valueOf(actual.palletId()),actual.palletNo(),Math.toIntExact(actual.packages()),prior==null?null:prior.weightKg(),prior==null?null:prior.volumeCbm()); + }).toList(); + if(pallets.stream().mapToLong(Pallet::packages).sum()!=measured.stream().mapToLong(Pallet::packages).sum()) + throw conflict("实际库存箱数与仓储转运原实收量不一致,请先核对来源,不允许部分确认外接实收"); + } String destination=FleetDispatchGateway.destinationKey(value.destinationType(),value.destinationWarehouseCode(),value.fbaWarehouseCode(),value.privateDestinationAddress(),value.privateDestinationPostalCode()); int packages=pallets.stream().mapToInt(Pallet::packages).sum(); try{ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/FleetIncomingActualSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/FleetIncomingActualSourceMapper.java new file mode 100644 index 000000000..f47c60a7b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/FleetIncomingActualSourceMapper.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.transfer.mapper; + +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 外接实收复核读取同库实际库存组成,不复制旧收货托盘。 */ +@Mapper +public interface FleetIncomingActualSourceMapper { + record Pallet(Long palletId,String palletNo,Long packages) { } + /** 按仓位主键顺序锁定完整来源,确认期间拆合托必须等待同一库存行。 */ + @Select("SELECT p.id FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id " + + "WHERE p.customer_id=#{customerId} AND b.inbound_code=#{inboundCode} AND p.overseas_warehouse_id=#{warehouseId} ORDER BY p.id FOR UPDATE") + List lockSources(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode,@Param("warehouseId") String warehouseId); + /** 按真实入仓码及起运仓归并本票可用和已拣箱数,排除其他货主及其他来源。 */ + @Select("SELECT p.pallet_id,p.pallet_no,SUM(p.available_packages+COALESCE(p.picked_packages,0)) packages " + + "FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=p.customer_id " + + "WHERE p.customer_id=#{customerId} AND b.inbound_code=#{inboundCode} AND p.overseas_warehouse_id=#{warehouseId} " + + "GROUP BY p.pallet_id,p.pallet_no HAVING SUM(p.available_packages+COALESCE(p.picked_packages,0))>0 ORDER BY p.pallet_id") + List pallets(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode,@Param("warehouseId") String warehouseId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/FleetIncomingActualSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/FleetIncomingActualSourceRepository.java new file mode 100644 index 000000000..2e9947733 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/FleetIncomingActualSourceRepository.java @@ -0,0 +1,18 @@ +package com.baiyue.lastleg.transfer.repository; + +import com.baiyue.lastleg.transfer.mapper.FleetIncomingActualSourceMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 库内拆合托后的真实外接货源读取入口。 */ +@Repository +public class FleetIncomingActualSourceRepository { + private final FleetIncomingActualSourceMapper mapper; + /** 装配同库组成查询。 */ + public FleetIncomingActualSourceRepository(FleetIncomingActualSourceMapper mapper){this.mapper=mapper;} + /** 当前托盘由本票库存确定,旧收货托盘仅保留为历史测量证据。 */ + public List pallets(String customer,String inbound,String warehouse,boolean lock){ + if(lock)mapper.lockSources(customer,inbound,warehouse); + return mapper.pallets(customer,inbound,warehouse); + } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 0767e4e17..1ee3b0e5d 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2927,16 +2927,53 @@ CREATE TABLE IF NOT EXISTS ll_exception_order ( CREATE TABLE IF NOT EXISTS ll_exception_return_item ( id BIGINT NOT NULL COMMENT '异常退回明细ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细ID', - inventory_position_id BIGINT NOT NULL COMMENT '原出库库存位置ID', pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', + inventory_position_id BIGINT NULL COMMENT '普通原出库库存位置;与退货原箱互斥', + return_carton_id BIGINT NULL COMMENT '原生退货箱标识;与普通库存位置互斥', + return_carton_mark VARCHAR(128) NULL COMMENT '原生退货箱业务箱码快照', + pallet_no VARCHAR(128) NULL COMMENT '原出库托盘码快照', sku_code VARCHAR(128) NULL COMMENT '原出库SKU快照', max_return_packages BIGINT NOT NULL COMMENT '原出库可退箱数上限', approved_return_packages BIGINT NOT NULL COMMENT '批准退回箱数', created_at DATETIME(3) NOT NULL COMMENT '创建时间', received_packages BIGINT NOT NULL DEFAULT 0 COMMENT '已实际退回入仓的累计箱数', CONSTRAINT chk_exception_return_received CHECK(received_packages>=0 AND received_packages<=approved_return_packages), + CONSTRAINT chk_exception_return_source CHECK((inventory_position_id IS NOT NULL AND return_carton_id IS NULL) OR (inventory_position_id IS NULL AND return_carton_id IS NOT NULL)), PRIMARY KEY(id), UNIQUE KEY uk_ll_exception_return_source(customer_id,exception_order_id,outbound_pallet_item_id,inventory_position_id), + UNIQUE KEY uk_ll_exception_return_carton(customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id), KEY idx_ll_exception_return_order(customer_id,exception_order_id) ) ENGINE=InnoDB COMMENT='异常退回原出库库存冻结明细'; +CREATE TABLE IF NOT EXISTS ll_return_carton_stock_receipt ( + id BIGINT NOT NULL COMMENT '原箱回仓凭据明细标识', + customer_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + receipt_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号', + exception_order_id BIGINT NOT NULL COMMENT '本次异常退回订单', + exception_return_item_id BIGINT NOT NULL COMMENT '本次冻结的原箱来源明细', + delivery_order_id BIGINT NOT NULL COMMENT '原运输派送单', + source_pick_id BIGINT NOT NULL COMMENT '原箱实际出库拣货凭据', + source_outbound_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号', + source_dispatch_item_id BIGINT NOT NULL COMMENT '原排车货物明细', + source_outbound_pallet_item_id BIGINT NOT NULL COMMENT '原出库托盘明细', + return_carton_id BIGINT NOT NULL COMMENT '真实退货原箱标识', + carton_mark VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照', + overseas_warehouse_id VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识', + target_pallet_id BIGINT NOT NULL COMMENT '实际回仓目标实物托盘', + target_pallet_no VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码', + target_location_id BIGINT NOT NULL COMMENT '实际回仓目标库位', + target_location_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码', + sku_code VARCHAR(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码', + product_name_zh VARCHAR(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NULL COMMENT '原出库快照中的中文品名', + quantity BIGINT NOT NULL COMMENT '本次原SKU实际恢复件数', + packages BIGINT NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零', + evidence_file_ids_json JSON NOT NULL COMMENT '本次实收证据文件引用', + received_by VARCHAR(128) NOT NULL COMMENT '本次原箱实收确认操作人', + received_at DATETIME(3) NOT NULL COMMENT '实际原箱回仓入账时间', + PRIMARY KEY(id), + UNIQUE KEY uk_return_carton_receipt_source(customer_id,source_pick_id,sku_code), + KEY idx_return_carton_receipt_exception(customer_id,exception_order_id,received_at), + KEY idx_return_carton_receipt_delivery(customer_id,delivery_order_id), + CONSTRAINT chk_return_carton_receipt_quantity CHECK(quantity>0 AND packages IN (0,1)) +) ENGINE=InnoDB COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据'; + CREATE TABLE IF NOT EXISTS ll_exception_order_command ( id BIGINT NOT NULL COMMENT '异常处理命令ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户数据隔离键', exception_order_id BIGINT NOT NULL COMMENT '异常订单ID', action VARCHAR(32) NOT NULL COMMENT '异常处理动作', diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index 3d5770768..b0835f780 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -167,7 +167,7 @@ paths: parameters: [{$ref: '#/components/parameters/TripNo'}, {$ref: '#/components/parameters/BookingNo'}] post: summary: 运营确认委托方实收货量与运费 - description: '权限:platform:fleet:trip:booking-confirm;实际来货已完成测量,receiptHash和实托集合一致,重新核算容量和约定金额,引用真实收货证据并确认原来货流程;不重复入库。差异必须显式确认,超容量回滚。' + description: '权限:platform:fleet:trip:booking-confirm;CONFIRMED或尚未绑定排车的RECEIVED可核对。实际来货已完成测量,receiptHash和实托集合一致,重新核算容量和约定金额,引用真实收货证据并确认原来货流程;不重复入库。拆合托后先解除排车关联,再以新请求键及当前版本重新确认实收和运费,完整实收量与承诺车次限制不放宽。差异必须显式确认,超容量回滚。' requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/BookingAcceptReceipt'}}}} responses: {'200': {$ref: '#/components/responses/BookingResult'}} /api/platform/v1/last-leg/fleet/trips/{tripNo}/bookings/{bookingNo}/dispatch: @@ -450,6 +450,8 @@ components: properties: warehousePalletId: {type: string} palletSpaces: {type: integer, minimum: 1} + weightKg: {type: number, nullable: true, minimum: 0.001, description: 当前组成缺少可信测量时必填,为该票外接货实测毛重,不填整托其他来源重量} + volumeCbm: {type: number, nullable: true, minimum: 0.001, description: 当前组成变化后重新实测的该票货体积;不沿用旧托盘或计费材积重} BookingAcceptReceipt: allOf: - {$ref: '#/components/schemas/BookingCommand'} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 4fca6c337..bc034e68a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1044,7 +1044,7 @@ paths: /api/platform/v1/last-leg/exception-orders/{exceptionNo}/complete: post: summary: 完成异常处理并原子恢复退回库存 - description: 权限 platform:overseas-warehouse:exception-order:process;退回海外仓支持分批实收,每批使用新幂等键及当前版本,累计不得超过批准量。库存 RETURN_STOCK_IN 成功后累计实收,未收齐保留 PROCESSING,批准量收齐才转 COMPLETED。详情 returnSources 只返回剩余可收数量。派送仅在累计实际退回覆盖整单实发数量时写 RETURNED 和 RETURN_COMPLETED;非海外仓异常结案不证明货物交接完成。原 signedAt 保留,车次及资源不由工单关闭释放。 + description: 权限 platform:overseas-warehouse:exception-order:process;退回海外仓支持分批实收,每批使用新幂等键及当前版本,累计不得超过批准量。普通库存 RETURN_STOCK_IN 或原箱不可变回仓凭据成功落账后累计实收,未收齐保留 PROCESSING,批准量收齐才转 COMPLETED。详情 returnSources 只返回剩余可收数量。派送仅在累计实际退回覆盖整单实发数量时写 RETURNED 和 RETURN_COMPLETED;非海外仓异常结案不证明货物交接完成。原 signedAt 保留,车次及资源不由工单关闭释放。 security: [{ bearerAuth: [] }] parameters: [{ name: exceptionNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/ExceptionComplete' } } } } @@ -1065,11 +1065,11 @@ paths: responses: { '200': { description: 关联关系 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/return-inbound-options: get: - summary: 查询派送退回候选入仓单 - description: 权限 platform:overseas-warehouse:exception-order:list;返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。 + summary: 查询派送退回候选主单 + description: 权限 platform:overseas-warehouse:exception-order:list;每个候选以lastMileOrderId定位、lastMileOrderNo展示;inboundOrderId和inboundCode可空,换标主单不要求普通入仓单。返回按原出库拣货事实解析的 returnSources,页面不得自由填写 SKU 或猜测库存位置。每条来源返回outboundPalletItemId、inventoryPositionId、returnCartonId、cartonMark、palletNo、skuCode和maxReturnPackages;普通仓位与原箱互斥,箱码用于展示,内部标识仅作提交键。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] - responses: { '200': { description: 退回候选入仓单 } } + responses: { '200': { description: 退回候选主单及真实出库来源 } } /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/mark-return: post: summary: 将派送单标记为退回入仓 @@ -1081,7 +1081,7 @@ paths: /api/platform/v1/last-leg/delivery-orders/{deliveryNo}/status: put: summary: 更新派送状态 - description: 权限 platform:overseas-warehouse:delivery:status。人工只允许 WAIT_DELIVERY→DELIVERING/REJECTED/CANCELLED、DELIVERING→REJECTED;DELIVERED 仅由签收资料保存成功自动触发,同状态幂等,终态不可倒退。 + description: 权限 platform:overseas-warehouse:delivery:status。人工只允许实际出库后的 WAIT_DELIVERY→DELIVERING/REJECTED、DELIVERING→REJECTED;取消仅允许经未执行出库的聚合取消命令;DELIVERED 仅由签收资料保存成功自动触发,同状态幂等,终态不可倒退。 security: [{ bearerAuth: [] }] parameters: [{ name: deliveryNo, in: path, required: true, schema: { type: string } }] requestBody: { required: true, content: { application/json: { schema: { $ref: '#/components/schemas/DeliveryStatusChange' } } } } @@ -2865,8 +2865,9 @@ components: dispatch: { $ref: '#/components/schemas/DispatchResponse' } SmartDispatchPreArrange: type: object - description: planPalletIds与returnReceiptIds是两类正式来源,合计至少一项且最多500项。退回来源仅接受实际入账生成的实收记录,不得用退回实收ID冒充计划托盘ID;同一实物托盘不可重复选择。 + description: 普通来源使用planPalletIds或returnReceiptIds,合计至少一项且最多500项。指定relabelNo时planPalletIds必须是本换标申请全部已绑定实物托盘ID,普通与退货换标都不允许部分选择或混选其他来源;换标选择暂不持有统一草稿锁,仅提交时在申请锁内完整复核。退回来源仅接受实际入账生成的实收记录;同一实物托盘不可重复选择。 properties: + relabelNo: { type: string, nullable: true, description: 整单换标申请业务号;不与退回实收来源混选 } batchAllocations: type: array maxItems: 1000 @@ -2992,18 +2993,19 @@ components: type: object required: [status, expectedVersion] properties: - status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, REJECTED, CANCELLED] } + status: { type: string, enum: [WAIT_DELIVERY, DELIVERING, REJECTED] } expectedVersion: { type: integer, format: int64, minimum: 0 } remark: { type: string, nullable: true, maxLength: 500 } DeliveryAllowedAction: type: string - enum: [START_DELIVERY, REJECT_DELIVERY, CANCEL_DELIVERY, MARK_RETURN] - description: START/REJECT/CANCEL 使用通用 status 接口;MARK_RETURN 使用 mark-return 接口;派送完成由签收资料保存成功自动触发。 + enum: [START_DELIVERY, REJECT_DELIVERY, MARK_RETURN] + description: START/REJECT 仅在关联出库已实际 OUTBOUNDED 后使用通用 status 接口;取消统一经未执行出库取消命令;MARK_RETURN 使用 mark-return 接口;派送完成由签收资料保存成功自动触发。 DeliveryMarkReturn: type: object - required: [inboundOrderId, reason, returnDirection, items, idempotencyKey] + required: [lastMileOrderId, reason, returnDirection, items, idempotencyKey] + description: 按候选中真实尾程主单定位;普通及退货换标主单无需普通入仓单,不接受虚构入仓关系。 properties: - inboundOrderId: { type: string, pattern: '^[1-9][0-9]*$' } + lastMileOrderId: { type: string, pattern: '^[1-9][0-9]*$' } reason: { type: string, minLength: 1, maxLength: 1000 } returnDirection: { type: string, enum: [OVERSEAS_WAREHOUSE, CUSTOMER, OTHER] } returnLogisticsNo: { type: string, nullable: true, maxLength: 128 } @@ -3014,10 +3016,11 @@ components: maxItems: 200 items: type: object - required: [outboundPalletItemId, inventoryPositionId, packageCount] + required: [outboundPalletItemId, packageCount] properties: outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' } - inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } + inventoryPositionId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 普通库存来源;与returnCartonId必须二选一 } + returnCartonId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 原生退货箱来源;按整箱实收,不得虚构库存仓位 } packageCount: { type: integer, format: int64, minimum: 1 } ExceptionStartProcessing: type: object @@ -3046,10 +3049,11 @@ components: maxItems: 200 items: type: object - required: [outboundPalletItemId, inventoryPositionId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode] + required: [outboundPalletItemId, receivedPackages, targetPalletId, targetPalletNo, targetLocationId, targetLocationCode] properties: outboundPalletItemId: { type: string, pattern: '^[1-9][0-9]*$' } - inventoryPositionId: { type: string, pattern: '^[1-9][0-9]*$' } + inventoryPositionId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 普通库存来源;与returnCartonId必须二选一 } + returnCartonId: { type: string, nullable: true, pattern: '^[1-9][0-9]*$', description: 原生退货箱来源;按整箱实收,不得虚构库存仓位 } receivedPackages: { type: integer, format: int64, minimum: 1 } targetPalletId: { type: string, pattern: '^[1-9][0-9]*$' } targetPalletNo: { type: string, minLength: 1, maxLength: 128 } @@ -3122,8 +3126,9 @@ components: properties: { reason: { type: string, minLength: 1, maxLength: 1000 } } DispatchResponse: type: object - required: [appointmentBatches] + required: [appointmentBatches, actions] properties: + actions: { $ref: '#/components/schemas/DispatchActions' } id: { type: string } dispatchNo: { type: string } vehiclePlate: { type: string, nullable: true } @@ -3155,12 +3160,27 @@ components: items: { type: array, items: { $ref: '#/components/schemas/DispatchItem' } } workflow: nullable: true - description: 仅平台分页返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。 + description: 平台查询返回当前关联车次及两级审核;未关联为null。批量读取已有审核快照,不由执行状态推导;列表权限可读,审核操作仍需车次独立审核权限。 allOf: [{ $ref: '#/components/schemas/DispatchWorkflow' }] bolLines: type: array description: 仅详情接口返回的BOL打印聚合行;分页接口返回空数组,避免列表查询放大 items: { $ref: '#/components/schemas/DispatchBolLine' } + DispatchActions: + type: object + required: [canEdit, canCancel, canRemovePallet, canTransfer] + description: 服务端按执行事实计算;入口写权限仍需单独校验。取消按解除车次、取消出库、取消原排车顺序;取消单据保留,转派送原请求重放返回原取消结果。 + properties: + canEdit: { type: boolean } + editBlockReason: { type: string, nullable: true } + canCancel: { type: boolean } + cancelBlockReason: { type: string, nullable: true } + canRemovePallet: { type: boolean } + removePalletBlockReason: { type: string, nullable: true } + canTransfer: { type: boolean } + transferBlockReason: { type: string, nullable: true } + outboundNo: { type: string, nullable: true } + outboundStatus: { type: string, nullable: true } DispatchBatchSource: type: object required: [sourceKey, destinationKey, packageCount] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java index b0241ac14..e3e97ee54 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java @@ -24,8 +24,22 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CustomerVisitServiceTest { + /** 未出库不得开始或拒签,已发车也没有人工取消旁路。 */ + @Test void manualExecutionRequiresActualOutboundAndNeverAcceptsCancellation() { + var delivery = delivery("WAIT_DELIVERY"); + when(deliveryOrders.lockByNo("1001", "DL-1")).thenReturn(delivery); + when(outboundOrders.lockByNo("1001", "OUT-1")).thenReturn(null); + for (String target : java.util.List.of("DELIVERING", "REJECTED")) + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus(target, 0L, null))).hasMessageContaining("尚未实际出库"); + assertThatThrownBy(() -> service.updateDeliveryStatus("1001", "9001", "DL-1", + new CustomerVisitRequests.DeliveryStatus("CANCELLED", 0L, null))).hasMessageContaining("不支持"); + verify(deliveryOrders, never()).updateById(delivery); + verifyNoInteractions(deliveryOperations); + } @Mock private CustomerVisitMapper visits; @Mock private DeliveryOrderMapper deliveryOrders; + @Mock private com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper outboundOrders; @Mock private DeliveryOperationMapper deliveryOperations; @Mock private LastlegOrderMapper lastMileOrders; private CustomerVisitService service; @@ -33,6 +47,7 @@ class CustomerVisitServiceTest { @BeforeEach void setUp() { service = new CustomerVisitService(); + ReflectionTestUtils.setField(service, "deliveryExecution", new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders)); ReflectionTestUtils.setField(service, "visits", visits); ReflectionTestUtils.setField(service, "deliveryOrders", deliveryOrders); ReflectionTestUtils.setField(service, "deliveryOperations", deliveryOperations); @@ -97,6 +112,11 @@ class CustomerVisitServiceTest { delivery.setCustomerId("1001"); delivery.setDeliveryNo("DL-1"); delivery.setStatus(status); + delivery.setOutboundOrderId(22L); delivery.setOutboundNo("OUT-1"); + var outbound = new com.baiyue.lastleg.delivery.entity.OutboundOrder(); + outbound.setId(22L); outbound.setCustomerId("1001"); outbound.setOutboundNo("OUT-1"); outbound.setStatus("OUTBOUNDED"); + org.mockito.Mockito.lenient().when(deliveryOrders.findByNo("1001", "DL-1")).thenReturn(delivery); + org.mockito.Mockito.lenient().when(outboundOrders.lockByNo("1001", "OUT-1")).thenReturn(outbound); return delivery; } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java index b8fedd86a..a50c2a54a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java @@ -24,6 +24,15 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class DeliveryFeeCoverageServiceTest { + /** 同一排车明细中的外接货覆盖不能免掉普通来源的应收。 */ + @Test void externalFreightCoversOnlyItsOwnInboundEvenWhenAnItemIsShared() { + when(finance.sources(anyString(), any())).thenReturn(ApiResponse.success(List.of(fee(101, 11)))); + var preview = service.preview("DP-1"); + var selection = new DeliveryFeeCoverageModels.Selection(711L, 1, fee(101, 11).sourceHash(), List.of("LOADING_FEE")); + var result = service.confirm("DP-1", coverageRequest(preview.cargoHash(), List.of(selection)), "OP", Set.of("IN-A")); + assertThat(result.includedServices()).doesNotContain("DELIVERY_FEE"); + assertThat(result.uncovered("DELIVERY_FEE")).extracting(DeliveryFeeCoverageModels.Cargo::inboundCode).containsExactly("IN-B"); + } final DispatchOrderMapper dispatches = mock(DispatchOrderMapper.class); final DispatchItemMapper items = mock(DispatchItemMapper.class); final DispatchFeeCargoMapper cargo = mock(DispatchFeeCargoMapper.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java index f89490f9b..35cd2559a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java @@ -12,6 +12,34 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.*; class DispatchFeeCargoMapperTest { + /** 原退回实收只重派其已冻结货量,不借同托其他货物放大来源。 */ + @Test void returnedReceiptRetainsItsExactSourceInsteadOfBecomingAWholePallet() { + var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); + var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L); + item.setReturnReceiptId(701L); item.setSourceStage("RECEIVED"); item.setInboundCode("IN-A"); + item.setPackageCount(2); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8"); + var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( + new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper)); + assertThat(query.sources(dispatch, java.util.List.of(item))).singleElement().satisfies(source -> { + assertThat(source.inboundCode()).isEqualTo("IN-A"); assertThat(source.packages()).isEqualTo(2); + }); + } + /** 展示普通货或外接货均不能改变真实组成;保留同客户、同仓及本次拣货边界。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(strings={"IN-A", "IN-B"}) + void actualSourceQueryDoesNotDependOnWhichInboundWasChosenForDisplay(String displayInbound) { + jdbc.update("UPDATE ll_dispatch_item SET inbound_code=?,plan_pallet_id=801", displayInbound); + var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); + var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L); + item.setInboundCode(displayInbound); item.setPackageCount(20); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8"); + var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( + new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper)); + var sources = query.sources(dispatch, java.util.List.of(item)); + assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::inboundCode).containsExactly("IN-A", "IN-B"); + assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::packages).containsExactly(10, 10); + item.setPackageCount(19); + assertThatThrownBy(() -> query.sources(dispatch, java.util.List.of(item))).hasMessageContaining("箱数不一致"); + } JdbcTemplate jdbc; DispatchFeeCargoMapper mapper; @BeforeEach void setup() throws Exception { var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); @@ -41,10 +69,10 @@ class DispatchFeeCargoMapperTest { assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L); assertThat(mapper.list("202", 1)).isEmpty(); } - @Test void aSelectedPlanPalletCannotBorrowOtherInboundStockOnTheSamePhysicalPallet() { + @Test void aPhysicalSharedPalletIncludesEveryActualSourceRegardlessOfItsDisplayPlan() { jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)"); jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(901,'101',401)"); - assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11"); + assertThat(mapper.list("101", 1)).extracting(row -> row.sourceInboundId()).containsExactly("11", "12"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java index 341485da5..08f944cf2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java @@ -8,7 +8,7 @@ class DeliveryStatusPolicyTest { @Test void exposesOnlyServerAuthorizedActionsForEachState() { assertThat(DeliveryStatusPolicy.allowedActions("WAIT_DELIVERY")) - .containsExactly("START_DELIVERY", "REJECT_DELIVERY", "CANCEL_DELIVERY"); + .containsExactly("START_DELIVERY", "REJECT_DELIVERY"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERING")) .containsExactly("REJECT_DELIVERY", "MARK_RETURN"); assertThat(DeliveryStatusPolicy.allowedActions("DELIVERED")).containsExactly("MARK_RETURN"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuardTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuardTest.java new file mode 100644 index 000000000..36864ade2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuardTest.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DeliveryExecutionGuardTest { + /** 直接消费真实仓储访问适配器,状态和数量由生产门禁判定。 */ + @Test void departureRequiresCompleteExecutableDeliveriesAndLocksThem() { + var deliveries = mock(DeliveryOrderMapper.class); + var guard = new DeliveryExecutionGuard(new DeliveryExecutionRepository(mock(OutboundOrderMapper.class), deliveries)); + var outbound = new OutboundOrder(); outbound.setId(1L); outbound.setCustomerId("C"); + outbound.setOutboundNo("OUT-1"); outbound.setPackageCount(2); + var delivery = new DeliveryOrder(); delivery.setCustomerId("C"); delivery.setOutboundOrderId(1L); + delivery.setOutboundNo("OUT-1"); delivery.setStatus("WAIT_DELIVERY"); delivery.setPackageCount(2); + when(deliveries.lockByOutbound("C", 1L)).thenReturn(List.of(delivery)); + guard.requireDeparture(outbound); + delivery.setStatus("CANCELLED"); + assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("已取消"); + delivery.setStatus("WAIT_DELIVERY"); delivery.setPackageCount(1); + assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("完整覆盖"); + delivery.setPackageCount(2); delivery.setCustomerId("OTHER"); + assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("来源不一致"); + when(deliveries.lockByOutbound("C", 1L)).thenReturn(List.of()); + assertThatThrownBy(() -> guard.requireDeparture(outbound)).hasMessageContaining("缺少对应派送单"); + verify(deliveries, times(5)).lockByOutbound("C", 1L); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java index 7104318a5..f783af376 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java @@ -26,6 +26,7 @@ class DeliveryReturnEvidencePersistenceTest { jdbc.execute("CREATE TABLE ll_dispatch_pda_pick_order(customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); jdbc.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(20),business_order_no VARCHAR(20),outbound_order_no VARCHAR(20),business_type VARCHAR(40),delta_packages BIGINT,source_event_id VARCHAR(100),occurred_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_return_carton_stock_receipt(id BIGINT,customer_id VARCHAR(20),source_dispatch_item_id BIGINT,source_outbound_pallet_item_id BIGINT,packages BIGINT,received_at TIMESTAMP,delivery_order_id BIGINT,receipt_no VARCHAR(64))"); jdbc.update("INSERT INTO ll_delivery_order VALUES(1,'A',10,20,'OUT-1')"); jdbc.update("INSERT INTO ll_exception_order VALUES(2,'A',1,'EX-1',71)"); jdbc.update("INSERT INTO ll_exception_return_item VALUES(2,'A',21,41)"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index a632493aa..d2684922e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -134,6 +134,16 @@ class DeliveryTransferServiceTest { @BeforeEach void setUp() { service = new DeliveryTransferService(); + ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders))); + var actualSources = mock(DispatchCargoSourceQuery.class); + ReflectionTestUtils.setField(service, "cargoSources", actualSources); + org.mockito.Mockito.lenient().when(actualSources.sources(any(), any())).thenAnswer(call -> { + List selected = call.getArgument(1); + return selected.stream().map(item -> new com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo( + item.getInboundCode(), item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), + java.util.Objects.requireNonNullElse(item.getPackageCount(), 0), com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey( + item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()))).toList(); + }); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "bookingFreight", bookingFreight); ReflectionTestUtils.setField(service, "feeCoverage", feeCoverage); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index 839c7ed0f..7f2301a0c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -67,6 +67,7 @@ class DispatchPdaWorkflowServiceTest { @BeforeEach void setUp() { service = new DispatchPdaWorkflowService(); + ReflectionTestUtils.setField(service, "deliveryExecution", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.class)); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); objectMapper = new ObjectMapper(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java new file mode 100644 index 000000000..8390f535f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.inboundplan.work.AppointmentWorkDispatchMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.time.LocalDateTime; +import java.math.BigDecimal; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class DispatchAppointmentCancellationPersistenceTest { + /** 实际取消只解除有效预约运输占用,历史引用和后来重排的占用都保留。 */ + @Test void cancellationReleasesEffectiveBatchAllocationAndReplayCannotReleaseTheNewDispatch() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new JdbcTemplate(data);var tx=new TransactionTemplate(new DataSourceTransactionManager(data)); + var config=new MybatisConfiguration();var mappers=List.of(DispatchOrderMapper.class,DispatchItemMapper.class,DispatchPalletLockMapper.class,OutboundOrderMapper.class,FleetTripDispatchMapper.class,AppointmentWorkDispatchMapper.class); + mappers.forEach(config::addMapper);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();var session=new SqlSessionTemplate(factory.getObject()); + for(var mapper:mappers)for(var parent:mapper.getGenericInterfaces()) { + if(!(parent instanceof java.lang.reflect.ParameterizedType p)||p.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue; + var table=TableInfoHelper.getTableInfo((Class)p.getActualTypeArguments()[0]);var columns=new ArrayList();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); + table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType())));db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")"); + } + db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(64),customer_id VARCHAR(64),source_key VARCHAR(64),destination_key VARCHAR(64),package_count INT)"); + db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(10,'C','DP-OLD','PENDING_DEPARTURE')"); + db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,package_count) VALUES(30,'C',10,1,4)"); + db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,status) VALUES(40,'C',10,'CANCELLED')"); + db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(50,'C',10,'ACTIVE','RECEIVED:501')"); + db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,30,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)"); + var allocations=session.getMapper(AppointmentWorkDispatchMapper.class); + assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4); + var service=new DispatchService();ReflectionTestUtils.setField(service,"orders",session.getMapper(DispatchOrderMapper.class));ReflectionTestUtils.setField(service,"items",session.getMapper(DispatchItemMapper.class));ReflectionTestUtils.setField(service,"locks",session.getMapper(DispatchPalletLockMapper.class));ReflectionTestUtils.setField(service,"outboundOrders",session.getMapper(OutboundOrderMapper.class));ReflectionTestUtils.setField(service,"fleetBindings",new FleetDispatchBindingGuard(session.getMapper(FleetTripDispatchMapper.class),new ObjectMapper()));ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class)); + tx.executeWithoutResult(s->service.cancel("DP-OLD")); + assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=10",Long.class)).isEqualTo(1); + db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(11,'C','DP-NEW','PENDING_DEPARTURE')"); + db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(51,'C',11,'ACTIVE','RECEIVED:501')"); + db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(11,31,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)"); + tx.executeWithoutResult(s->service.cancel("DP-OLD")); + assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4); + assertThat(db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE id=51",String.class)).isEqualTo("ACTIVE"); + assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=50",String.class)).isNull(); + } + /** 测试表保留生产Mapper列映射,正式DDL由独立Schema合同验证。 */ + private static String sqlType(Class type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(4000)";} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index 447778249..6db7cd363 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -55,7 +55,7 @@ class DispatchBolPrintServiceTest { null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", - "NYEXX2609110214", null, null, 1)), null, List.of()); + "NYEXX2609110214", null, null, 1)), null, List.of(), null); var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, @@ -87,7 +87,7 @@ class DispatchBolPrintServiceTest { new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", "NYEXX2609110214", null, null, 1), new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", - "NYEXX2609110215", null, null, 1)), null, List.of()); + "NYEXX2609110215", null, null, 1)), null, List.of(), null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java new file mode 100644 index 000000000..354f984ba --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -0,0 +1,430 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.customervisit.dto.CustomerVisitRequests; +import com.baiyue.lastleg.customervisit.mapper.CustomerVisitMapper; +import com.baiyue.lastleg.customervisit.service.CustomerVisitService; +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard; +import com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery; +import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository; +import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; +import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; +import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; +import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.relabel.mapper.*; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.service.RelabelBindingGuard; +import com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.apache.ibatis.session.LocalCacheScope; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +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.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 连续调用真实服务、Mapper及事务;仅隔离外部通知、财务和MySQL专用候选聚合。 */ +class DispatchRepairWorkflowTest { + private JdbcTemplate db; + private TransactionTemplate transactions; + private SqlSessionTemplate session; + private final Map, Object> dependencies = new LinkedHashMap<>(); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private DispatchService dispatch; + private DeliveryTransferService delivery; + private CustomerVisitService visits; + private ExceptionOrderService exceptions; + private DispatchFleetAdapter fleet; + private DispatchCandidateMapper candidates; + private OverseasFeeSourceService fees; + + /** 每个用例使用独立H2库,不连接运行环境;真实Mapper元数据生成最小测试表。 */ + @BeforeEach + void setUp() throws Exception { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + db = new JdbcTemplate(source); + transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); + List> mappers = List.of(DispatchOrderMapper.class, DispatchItemMapper.class, + DispatchPalletLockMapper.class, DispatchBolMapper.class, DispatchPdaTaskMapper.class, + DispatchPdaPickMapper.class, DispatchPdaPickOrderMapper.class, OutboundOrderMapper.class, + OutboundPalletItemMapper.class, OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, + DeliveryOrderMapper.class, DeliveryOperationMapper.class, CustomerVisitMapper.class, + LastlegOrderMapper.class, LastlegInboundOrderMapper.class, LastlegOrderStatusHistoryMapper.class, + FleetTripDispatchMapper.class, FleetTripMapper.class, FleetBookingMapper.class, InventoryPositionMapper.class, + InventoryBalanceMapper.class, DispatchFeeCargoMapper.class, ExceptionReturnItemMapper.class, ExceptionOrderMapper.class, + RelabelApplicationMapper.class, RelabelItemMapper.class, PdaRelabelHistoryMapper.class, + RelabelPalletMeasurementMapper.class, ReturnInventoryCartonMapper.class, DispatchCandidateMapper.class, + WarehouseTransferOrderMapper.class); + mappers.forEach(configuration::addMapper); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(source); + factory.setConfiguration(configuration); + factory.afterPropertiesSet(); + session = new SqlSessionTemplate(factory.getObject()); + for (Class mapper : mappers) dependencies.put(mapper, session.getMapper(mapper)); + // 不复制正式SQL,也不声称验证生产DDL;只构造本文件使用的实体表。 + for (Class mapper : mappers) for (var parent : mapper.getGenericInterfaces()) { + if (!(parent instanceof java.lang.reflect.ParameterizedType generic) + || generic.getRawType() != com.baomidou.mybatisplus.core.mapper.BaseMapper.class) continue; + var table = TableInfoHelper.getTableInfo((Class) generic.getActualTypeArguments()[0]); + var columns = new ArrayList(); + if (table.havePK()) columns.add(table.getKeyColumn() + " " + sqlType(table.getKeyType()) + + (table.getIdType() == com.baomidou.mybatisplus.annotation.IdType.AUTO ? " AUTO_INCREMENT" : "") + " PRIMARY KEY"); + table.getFieldList().forEach(field -> columns.add(field.getColumn() + " " + sqlType(field.getPropertyType()))); + db.execute("CREATE TABLE IF NOT EXISTS " + table.getTableName() + " (" + String.join(",", columns) + ")"); + } + db.execute("CREATE UNIQUE INDEX test_dispatch_active_lock ON ll_dispatch_pallet_lock(customer_id,active_key)"); + // 普通换标与退货换标候选查询含MySQL专用聚合;固定输入不替代完整集合校验或状态写入。 + candidates = spy(session.getMapper(DispatchCandidateMapper.class)); + doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(), any(), any()); + dependencies.put(DispatchCandidateMapper.class, candidates); + dependencies.put(ObjectMapper.class, json); + dependencies.put(LastlegOrderNoGenerator.class, new LastlegOrderNoGenerator()); + dependencies.put(FleetDispatchBindingGuard.class, + new FleetDispatchBindingGuard(mapper(FleetTripDispatchMapper.class), json)); + dependencies.put(FleetDispatchWorkflowQuery.class, + new FleetDispatchWorkflowQuery(mapper(FleetTripDispatchMapper.class), mapper(FleetTripMapper.class), json)); + var execution = new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class)); + dependencies.put(DeliveryExecutionRepository.class, execution); + dependencies.put(DeliveryExecutionGuard.class, new DeliveryExecutionGuard(execution)); + dependencies.put(DispatchCargoSourceQuery.class, + new DispatchCargoSourceQuery(new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class)))); + dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class, + mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); + dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class, + mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class)); + fees = mock(OverseasFeeSourceService.class); + dependencies.put(OverseasFeeSourceService.class, fees); + var binding = wire(new RelabelBindingGuard()); + var measurements = wire(new RelabelPalletMeasurementService()); + var returnStock = wire(new ReturnRelabelDispatchStock()); + dependencies.put(RelabelBindingGuard.class, binding); + dependencies.put(RelabelPalletMeasurementService.class, measurements); + dependencies.put(ReturnRelabelDispatchStock.class, returnStock); + dispatch = wire(new DispatchService()); + delivery = wire(new DeliveryTransferService()); + visits = wire(new CustomerVisitService()); + exceptions = wire(new ExceptionOrderService()); + fleet = new DispatchFleetAdapter(mapper(DispatchOrderMapper.class), mapper(DispatchPdaTaskMapper.class), + mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class), mapper(DispatchItemMapper.class), + mapper(DispatchPdaPickMapper.class), new DispatchPdaWorkflowService(), mapper(DeliveryOperationMapper.class)); + wire(fleet); + } + + /** 历史取消出库仍可追溯;取消旧排车后同货重排,重复取消不得释放新锁。 */ + @Test + void cancelledOutboundThenDispatchCancellationAllowsNewArrangementWithoutReleasingItsLock() { + seedPendingOutbound(); + db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,plan_pallet_id,warehouse_pallet_id,source_pallet_key,active_key,status) VALUES(40,'C',1,900,501,'PLAN:900','PLAN:900','ACTIVE')"); + run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重新安排"))); + assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("CANCELLED"); + assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("CANCELLED"); + long operations = count("ll_delivery_operation"); + run(() -> dispatch.cancel("DP-1")); + assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("CANCELLED"); + assertThat(text("ll_dispatch_pallet_lock", "status", 40)).isEqualTo("RELEASED"); + assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=40", String.class)).isNull(); + doReturn(List.of(candidate(501).planPalletId(900L))).when(candidates).lockSelected("C", List.of(900L)); + var created = inTransaction(() -> dispatch.preArrange("C", "OP", + new DispatchRequests.PreArrange(List.of(900L), null, null, null))); + assertThat(created.dispatchNo()).isNotEqualTo("DP-1"); + assertThat(created.actions().canCancel()).isTrue(); + assertThat(created.actions().canTransfer()).isTrue(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); + run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重复取消"))); + run(() -> dispatch.cancel("DP-1")); + assertThat(count("ll_outbound_order")).isEqualTo(1); + assertThat(count("ll_delivery_operation")).isEqualTo(operations); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); + verify(fees, times(1)).listForBusiness("C", "DELIVERY_ORDER", 3L); + verify(fees, never()).voidSource(anyLong(), any(), any()); + } + + /** 真实车次关联和已拣事实分别阻止取消,失败事务不修改单据及占用。 */ + @Test + void bindingThenPickingIndependentlyPreventOutboundCancellation() { + seedPendingOutbound(); + db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(51,'TR-1',1,'DP-1','C')"); + assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消")))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("解除关联"); + assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); + db.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=1"); + db.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=1"); + assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消")))) + .isInstanceOf(ResponseStatusException.class); + assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); + assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); + assertThat(count("ll_delivery_operation")).isZero(); + } + + /** 未出库开始派送/拒签均拒绝;真实出库后允许开始,但普通状态编辑始终不能取消。 */ + @ParameterizedTest + @ValueSource(strings = {"DELIVERING", "REJECTED"}) + void deliveryActionsFollowActualOutboundAndNeverOfferAnIndependentCancellation(String target) { + seedPendingOutbound(); + assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + new CustomerVisitRequests.DeliveryStatus(target, 0L, "提前操作")))) + .isInstanceOf(ResponseStatusException.class); + assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); + assertThat(count("ll_delivery_operation")).isZero(); + db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=2 WHERE id=1"); + db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2"); + assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + new CustomerVisitRequests.DeliveryStatus("CANCELLED", 0L, "已发车取消")))) + .isInstanceOf(ResponseStatusException.class); + assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); + run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + new CustomerVisitRequests.DeliveryStatus(target, 0L, "合法操作"))); + assertThat(text("ll_delivery_order", "status", 3)).isEqualTo(target); + assertThat(db.queryForObject("SELECT version FROM ll_delivery_order WHERE id=3", Long.class)).isEqualTo(1); + long operations = count("ll_delivery_operation"); + var replay = inTransaction(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + new CustomerVisitRequests.DeliveryStatus(target, 0L, "同状态重放"))); + assertThat(replay.replayed()).isTrue(); + assertThat(count("ll_delivery_operation")).isEqualTo(operations); + } + + /** 已完成拣货仍须完整覆盖未执行派送;取消、缺失或数量失配均阻断发车。 */ + @Test + void departureManifestRejectsCancelledMissingAndIncompleteDeliveryCoverage() { + seedPendingOutbound(); + db.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=2 WHERE id=1"); + db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages) VALUES(103,'C',102,4,104,2)"); + assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNull(); + db.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=3"); + assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); + var guard = (DeliveryExecutionGuard) dependencies.get(DeliveryExecutionGuard.class); + assertThatThrownBy(() -> run(() -> guard.requireDeparture(mapper(OutboundOrderMapper.class).findByNo("C", "OUT-1")))) + .isInstanceOf(ResponseStatusException.class); + db.update("UPDATE ll_delivery_order SET status='WAIT_DELIVERY',package_count=1 WHERE id=3"); + assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); + db.update("DELETE FROM ll_delivery_order WHERE id=3"); + assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); + assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("READY_TO_DEPART"); + assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); + } + + /** 两托部分选取和重复ID都必须零副作用,完整预排取消后仍可整单重新预排。 */ + @Test + void ordinaryRelabelRequiresAllPalletsAndCanBeRearrangedAfterCancellation() { + db.update("INSERT INTO ll_relabel_application(id,customer_id,relabel_no,overseas_warehouse_id,status,fulfillment_status,delivery_method) VALUES(70,'C','RL-1','WH-1','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH','TRUCK')"); + for (long pallet : List.of(501L, 502L)) { + db.update("INSERT INTO ll_inventory_position(id,customer_id,pallet_id,pallet_no,overseas_warehouse_id,hold_packages) VALUES(?,'C',?,?,'WH-1',1)", pallet, pallet, "P-" + pallet); + db.update("INSERT INTO ll_relabel_item(id,customer_id,application_id,operation_type,source_inventory_position_id,carton_mark,notified_package_count) VALUES(?,'C',70,'CARTON_LABEL',?,?,1)", pallet, pallet, "BOX-" + pallet); + } + db.update("INSERT INTO ll_relabel_pda_history(id,customer_id,application_id,action,detail_json) VALUES(71,'C',70,'PALLET_BIND',?)", + "{\"cartonMarks\":[\"BOX-501\",\"BOX-502\"],\"targetPallets\":[\"P-501\",\"P-502\"]}"); + doReturn(List.of(candidate(501).orderRefs("RL-1").packageCount(1), candidate(502).orderRefs("RL-1").packageCount(1))) + .when(candidates).relabelAvailable("C", null, "RL-1"); + for (var selection : List.of(List.of(501L), List.of(501L, 501L, 502L))) { + assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(selection)))) + .isInstanceOf(ResponseStatusException.class); + assertThat(count("ll_dispatch_order")).isZero(); + assertThat(count("ll_dispatch_item")).isZero(); + assertThat(count("ll_dispatch_pda_task")).isZero(); + assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH"); + } + var first = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L)))); + assertThat(first.actions().canRemovePallet()).isFalse(); + assertThat(count("ll_dispatch_item")).isEqualTo(2); + assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("PRE_DISPATCH_CREATED"); + assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L))))) + .isInstanceOf(ResponseStatusException.class); + run(() -> dispatch.cancel(first.dispatchNo())); + assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH"); + var second = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L)))); + assertThat(second.dispatchNo()).isNotEqualTo(first.dispatchNo()); + assertThat(count("ll_dispatch_order")).isEqualTo(2); + } + + /** 同实托展示任一来源,都要按真实组成绑定完整外接货并保留普通货收费资格。 */ + @ParameterizedTest + @ValueSource(strings = {"IN-NORMAL", "IN-EXT"}) + void mixedPalletUsesBothActualSourcesForBindingAndFreight(String displayInbound) throws Exception { + seedPendingOutbound(); + db.update("UPDATE ll_dispatch_item SET inbound_code=?,package_count=5 WHERE id=4", displayInbound); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(80,'C',81,'IN-NORMAL'),(82,'C',83,'IN-EXT')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages) VALUES(84,'C',80,501,'P-501','WH-1',3),(85,'C',82,501,'P-501','WH-1',2)"); + db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code) VALUES(81,'C','LM-NORMAL','IN-NORMAL'),(83,'C','LM-EXT','IN-EXT')"); + // 该测试同时校验客户隔离,不能把同物理托盘编号的他客库存算进来。 + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(86,'OTHER',87,'FOREIGN')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(88,'OTHER',86,501,'WH-1',99)"); + db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); + db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)"); + var realCargo = mapper(DispatchFeeCargoMapper.class).list("C", 1); + assertThat(realCargo).extracting(row -> row.inboundCode()).containsExactly("IN-NORMAL", "IN-EXT"); + assertThat(realCargo).extracting(row -> row.packages()).containsExactly(3L, 2L); + var sources = inTransaction(() -> fleet.sourceCargo("DP-1")); + assertThat(sources).extracting(FleetDispatchGateway.SourceCargo::inboundCode) + .containsExactlyInAnyOrder("IN-NORMAL", "IN-EXT"); + assertThat(sources.stream().mapToInt(FleetDispatchGateway.SourceCargo::packages).sum()).isEqualTo(5); + seedExternalBooking(); + var reservations = new FleetBookingReservations(mapper(FleetBookingMapper.class), json); + var trip = new FleetTripEntity(); trip.setId(91L); trip.setTripNo("TR-1"); + run(() -> reservations.bindIncoming(trip, + new FleetDispatchGateway.Snapshot(1L, "DP-1", "C", "WH-1", 5), sources, "OP")); + assertThat(text("ll_fleet_external_booking", "status", 90)).isEqualTo("BOUND"); + db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(91,'TR-1',1,'DP-1','C')"); + var freight = new FleetBookingFreight(mapper(FleetBookingMapper.class), mapper(FleetTripDispatchMapper.class), reservations, json); + var preview = inTransaction(() -> freight.preview(1L, "DP-1", "C", sources)); + assertThat(preview.requiresDeliveryFee()).isTrue(); + assertThat(preview.quotes()).singleElement().satisfies(quote -> { + assertThat(quote.bookingNo()).isEqualTo("FB-EXT"); + assertThat(quote.incomingNo()).isEqualTo("WT-EXT"); + assertThat(quote.inboundCode()).isEqualTo("IN-EXT"); + assertThat(quote.amount()).isEqualByComparingTo("60.00"); + }); + assertThat(mapper(FleetBookingMapper.class).byNo("FB-EXT").getDispatchNo()).isEqualTo("DP-1"); + } + + /** 退货换标真实箱来源必须成为可退候选,不得伪造普通库存仓位通过门禁。 */ + @Test + void rejectedReturnRelabelRetainsItsNativeCartonSourceForExceptionReturn() { + seedPendingOutbound(); + db.update("UPDATE ll_delivery_order SET status='REJECTED' WHERE id=3"); + db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2"); + db.update("UPDATE ll_dispatch_item SET source_stage='RETURN_RELABEL',source_business_type='RELABEL_ORDER',source_business_no='RL-1',inbound_code='RL-1' WHERE id=4"); + db.update("UPDATE ll_outbound_pallet_item SET inbound_code='RL-1' WHERE id=5"); + db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,delivery_order_no,outbound_order_no,package_count,overseas_warehouse_id) VALUES(100,'C','LM-RL','RL-1','DL-1','OUT-1',2,'WH-1')"); + db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(104,'C','RL-1',2)"); + db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,carton_mark,pallet_code,overseas_warehouse_id,status,relabel_no,total_quantity) VALUES(101,'C','BOX-RL','P-501','WH-1','OUTBOUNDED','RL-1',6)"); + db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,6)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages,product_quantity) VALUES(105,'C',103,100,1,6)"); + var relations = inTransaction(() -> exceptions.returnCandidates("C", "DL-1")); + assertThat(relations).hasSize(1); + assertThat(relations.get(0).returnSources()).singleElement().satisfies(source -> { + var actual = json.valueToTree(source); + assertThat(actual.path("returnCartonId").asLong()).isEqualTo(101L); + assertThat(source.maxReturnPackages()).isEqualTo(1); + }); + assertThat(db.queryForObject("SELECT inventory_position_id FROM ll_dispatch_pda_pick WHERE id=103", Long.class)).isNull(); + assertThat(count("ll_inventory_position")).isZero(); + } + + /** 准备统一未拣货排车、出库及派送关系,避免用桩伪造取消资格。 */ + private void seedPendingOutbound() { + db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,overseas_warehouse_id,status,package_count,picked_package_count,shipped_package_count) VALUES(1,'C','DP-1','WH-1','PENDING_DEPARTURE',2,0,0)"); + db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,outbound_type,status,package_count) VALUES(2,'C','OUT-1',1,'DISPATCH_OUTBOUND','WAIT_OUTBOUND',2)"); + db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count) VALUES(3,'C','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2)"); + db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'C',1,501,'P-501','WH-1',2,'FBA','ONT8')"); + db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'C',2,501,'P-501',2,0,'WAIT_PICK','ONT8')"); + } + + /** 固定真实外接实收两箱及约定金额,后续绑定和报价核对均运行生产逻辑。 */ + private void seedExternalBooking() throws Exception { + var cargo = new FleetBookingModels.Cargo("FBA:ONT8", "外接货", 2, 1, BigDecimal.TEN, + BigDecimal.ONE, "PALLET", new BigDecimal("60.00"), "CNY", null, null, null); + var receipt = new FleetBookingModels.AcceptedReceipt(new FleetBookingIncomingGateway.Evidence( + "WT-EXT", "IN-EXT", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-v1", + List.of(new FleetBookingIncomingGateway.Pallet("501", "P-501", 2, BigDecimal.TEN, BigDecimal.ONE)), 2), + List.of(new FleetBookingModels.PalletSpaces("501", 1, null, null)), "OP", Instant.parse("2026-10-01T00:00:00Z")); + // 来货业务编号和库存入仓码是两个标识,必须经真实来货登记关联,不能混用。 + db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no,inbound_code,status) VALUES(93,'C','WT-EXT','IN-EXT','READY_FOR_DISPATCH')"); + db.update("INSERT INTO ll_fleet_external_booking(id,trip_id,trip_no,booking_no,incoming_no,customer_id,destination_key,status,agreed_amount,record_version,cargo_json,receipt_json) VALUES(90,91,'TR-1','FB-EXT','WT-EXT','C','FBA:ONT8','RECEIVED',60,1,?,?)", + json.writeValueAsString(cargo), json.writeValueAsString(receipt)); + } + + /** 构造来源输入;真实门禁仍由DispatchService及数据库锁决定。 */ + private DispatchPalletCandidate candidate(long pallet) { + return new DispatchPalletCandidate().warehousePalletId(pallet).palletNo("P-" + pallet) + .customerId("C").overseasWarehouseId("WH-1").sourceStage("RECEIVED") + .destinationType("FBA").destinationWarehouseCode("ONT8").fbaWarehouseCode("ONT8") + .packageCount(2).grossWeightKg(BigDecimal.TEN).volumeCbm(BigDecimal.ONE); + } + + /** 保留整单换标申请入口,不直接调用私有校验。 */ + private DispatchRequests.PreArrange relabel(List pallets) { + return new DispatchRequests.PreArrange(pallets, null, null, null, null, "RL-1"); + } + + /** 按类型注入实际依赖;未用到的外部能力保持未装配,调用时显式失败。 */ + private T wire(T target) { + for (var field : target.getClass().getDeclaredFields()) { + if (!field.isAnnotationPresent(Resource.class)) continue; + Object value = dependencies.get(field.getType()); + if (value != null) ReflectionTestUtils.setField(target, field.getName(), value); + } + return target; + } + + /** 从当前隔离库取得生产Mapper代理。 */ + private T mapper(Class type) { + return type.cast(dependencies.get(type)); + } + + /** 每条业务命令独立提交;失败必须整条回滚,不能依赖测试整体回滚隐藏副作用。 */ + private T inTransaction(Supplier action) { + return transactions.execute(status -> action.get()); + } + + /** 对无返回值命令应用同样的真实事务边界。 */ + private void run(Runnable action) { + inTransaction(() -> { action.run(); return null; }); + } + + /** 仅查询本文件常量指定的测试表,不接收用户输入。 */ + private long count(String table) { + return db.queryForObject("SELECT COUNT(*) FROM " + table, Long.class); + } + + /** 从数据库重新读取状态,避免断言内存实体误当持久化成功。 */ + private String text(String table, String column, long id) { + return db.queryForObject("SELECT " + column + " FROM " + table + " WHERE id=?", String.class, id); + } + + /** 测试表仅表达实体字段类型,DDL、索引及MySQL行为另由正式合同测试校验。 */ + private String sqlType(Class type) { + if (Number.class.isAssignableFrom(type) || type == long.class || type == int.class) + return type == BigDecimal.class ? "DECIMAL(24,6)" : "BIGINT"; + if (type == LocalDateTime.class || type == Instant.class) return "TIMESTAMP"; + if (type == java.time.LocalDate.class) return "DATE"; + if (type == Boolean.class || type == boolean.class) return "BOOLEAN"; + return "VARCHAR(32768)"; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 70558a329..f82ea5eed 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -51,6 +51,42 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class DispatchServiceTest { + /** 历史取消出库不阻止取消原排车,重放不影响后续占用。 */ + @Test void cancelledOutboundAllowsOriginalDispatchCancellationAndPreservesAppointmentHistory() { + var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("100"); + dispatch.setDispatchNo("DP-OLD"); dispatch.setStatus("PENDING_DEPARTURE"); + var outbound = new com.baiyue.lastleg.delivery.entity.OutboundOrder(); outbound.setStatus("CANCELLED"); + var item = item(11L, "ONT8"); + when(orders.lockGlobalByDispatchNo("DP-OLD")).thenReturn(dispatch); + when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); + when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); + var allocations = mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + ReflectionTestUtils.setField(service, "appointmentAllocations", allocations); + service.cancel("DP-OLD"); service.cancel("DP-OLD"); + assertEquals("CANCELLED", dispatch.getStatus()); + verify(allocations, never()).removeItem(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); + verify(orders).updateById(dispatch); + } + + /** 普通换标部分选择与重复选择均在创建排车前拒绝。 */ + @Test void ordinaryRelabelRequiresTheWholeLockedApplicationBeforeAnyMutation() { + var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class); + ReflectionTestUtils.setField(service, "relabelApplications", relabels); + ReflectionTestUtils.setField(service, "relabelBindingGuard", mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class)); + var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication(); + application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); + application.setDeliveryMethod("TRUCK"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + when(relabels.lockNo("100", "RL-1")).thenReturn(application); + when(candidates.relabelAvailable("100", null, "RL-1")).thenReturn(List.of(candidate(10L, 900L, "ONT8"), candidate(11L, 901L, "ONT8"))); + assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP", + new DispatchRequests.PreArrange(List.of(900L), null, null, null, null, "RL-1"))); + assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP", + new DispatchRequests.PreArrange(List.of(900L, 900L), null, null, null, null, "RL-1"))); + verify(orders, never()).insert(any(DispatchOrder.class)); + verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); + verify(candidates, never()).markRelabelPreDispatched(any(), any()); + } + @Test void detailReturnsEveryOriginalBatchSourceWithoutLegacyAppointmentNumber() { var dispatch=new DispatchOrder();dispatch.setId(1L);dispatch.setCustomerId("C");dispatch.setDispatchNo("DP-1"); when(orders.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java index efd67f94a..4a59c218e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java @@ -137,7 +137,7 @@ class ExceptionOrderLifecycleTest { when(lastMileOrders.selectList(any())).thenReturn(List.of()); ExceptionOrderRequests.Complete request = new ExceptionOrderRequests.Complete(1, "complete-1", "已退回入仓", List.of(101L), List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, 2, 201L, - "PLT-201", 301L, "LOC-301"))); + "PLT-201", 301L, "LOC-301", null))); var result = service.complete("1001", "9001", "EX-1", request); @@ -222,7 +222,7 @@ class ExceptionOrderLifecycleTest { private ExceptionOrderRequests.Complete receipt(long amount) { return new ExceptionOrderRequests.Complete(1, "batch-1", "本次实际退回", List.of(101L), - List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, amount, 201L, "PLT-201", 301L, "LOC-301"))); + List.of(new ExceptionOrderRequests.ReturnReceipt(51L, 61L, amount, 201L, "PLT-201", 301L, "LOC-301", null))); } private String hashFor(Object request) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java new file mode 100644 index 000000000..818a8ca1e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java @@ -0,0 +1,196 @@ +package com.baiyue.lastleg.exceptionorder.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.exceptionorder.dto.ExceptionOrderRequests; +import com.baiyue.lastleg.exceptionorder.entity.*; +import com.baiyue.lastleg.exceptionorder.mapper.*; +import com.baiyue.lastleg.exceptionorder.repository.ReturnCartonReturnRepository; +import com.baiyue.lastleg.returnreceipt.mapper.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ReturnCartonReturnPersistenceTest { + JdbcTemplate db;TransactionTemplate tx;ReturnCartonReturnService service; + ExceptionOrder order;ExceptionReturnItem item;SqlSessionTemplate session; + /** 隔离H2中运行真实Mapper、来源归属查询、原生库存更新及事务。 */ + @BeforeEach void setup() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data)); + var config=new MybatisConfiguration(); + var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class, + ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class, + DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchOrderMapper.class,DispatchItemMapper.class, + DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class); + mappers.forEach(config::addMapper); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); + session=new SqlSessionTemplate(factory.getObject()); + for(var mapper:mappers)for(var parent:mapper.getGenericInterfaces()) { + if(!(parent instanceof java.lang.reflect.ParameterizedType p)||p.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue; + var table=TableInfoHelper.getTableInfo((Class)p.getActualTypeArguments()[0]);var columns=new ArrayList(); + columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); + table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType()))); + db.execute("CREATE TABLE IF NOT EXISTS "+table.getTableName()+"("+String.join(",",columns)+")"); + } + db.execute("CREATE UNIQUE INDEX balance_scope ON ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code)"); + db.execute("CREATE UNIQUE INDEX receipt_source ON ll_return_carton_stock_receipt(customer_id,source_pick_id,sku_code)"); + db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(128),business_order_no VARCHAR(128),outbound_order_no VARCHAR(128),business_type VARCHAR(128),delta_packages BIGINT,source_event_id VARCHAR(128),occurred_at TIMESTAMP)"); + var repo=new ReturnCartonReturnRepository(map(ReturnCartonStockReceiptMapper.class),map(ReturnInventoryCartonMapper.class),map(ReturnInventoryCartonItemMapper.class),map(ReturnInventoryMovementMapper.class),map(ReturnInventoryBalanceMapper.class)); + var pallets=mock(PalletPhysicalGateway.class);var warehouse=mock(WarehouseResourceGateway.class); + when(pallets.resolve("P-NEW")).thenReturn(new PalletPhysicalGateway.PalletReference(501,"P-NEW",92,301L,"OCCUPIED","ONT8",1)); + when(warehouse.resolveLocation(92,"LOC-301")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(301,"LOC-301","RACK",BigDecimal.TEN,"ACTIVE")); + service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + db.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,delivery_order_id,last_mile_order_id,outbound_no,status,return_direction,overseas_warehouse_id) VALUES(1,'C','EX-1',3,100,'OUT-1','PROCESSING','OVERSEAS_WAREHOUSE','92')"); + db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)"); + db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')"); + db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code) VALUES(5,'C',2,500,'RL-1','ONT8')"); + db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no) VALUES(10,'C','DP-1')"); + db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code,source_stage,overseas_warehouse_id) VALUES(4,'C',10,500,'RL-1','ONT8','RETURN_RELABEL','92')"); + db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id) VALUES(102,'C',10)"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,8)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(105,'C',103,100,1)"); + db.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id,approved_return_packages,received_packages) VALUES(9,'C',1,5,101,1,0)"); + db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity,relabel_application_id,relabel_no) VALUES(101,'C','92','BOX-1','OUTBOUNDED',8,88,'RL-1')"); + db.update("INSERT INTO ll_return_inventory_carton_item(id,carton_id,customer_id,sku_code,quantity,packing_reserved_quantity) VALUES(201,101,'C','SKU-A',6,0),(202,101,'C','SKU-B',2,0)"); + db.update("INSERT INTO ll_return_inventory_outbound_movement(id,outbound_pick_id,customer_id,overseas_warehouse_id,carton_id,carton_mark,outbound_no,sku_code,quantity,cartons_before,cartons_after) VALUES(301,103,'C','92',101,'BOX-1','DP-1','SKU-A',6,1,0),(302,103,'C','92',101,'BOX-1','DP-1','SKU-B',2,1,0)"); + order=map(ExceptionOrderMapper.class).selectById(1L);item=new ExceptionReturnItem();item.setId(9L);item.setOutboundPalletItemId(5L);item.setReturnCartonId(101L);item.setApprovedReturnPackages(1L);item.setReceivedPackages(0L); + } + /** 每个SKU件数恢复一次,原箱只计一运输箱,结案查询读取真实回仓凭据。 */ + @Test void restoresOriginalCartonAndEverySkuExactlyOnceWithoutSyntheticPositions() { + tx.executeWithoutResult(s->assertThat(service.restore(order,List.of(item),List.of(receipt()),List.of(999L),"OP")).isEqualTo(1)); + assertThat(db.queryForList("SELECT total_quantity FROM ll_return_inventory_balance ORDER BY sku_code",Long.class)).containsExactly(6L,2L); + assertThat(db.queryForObject("SELECT SUM(packages) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=101",String.class)).isEqualTo("AVAILABLE"); + assertThat(db.queryForObject("SELECT relabel_no FROM ll_return_inventory_carton WHERE id=101",String.class)).isNull(); + assertThat(map(DeliveryOperationMapper.class).returnReceipts("C",3L)).singleElement().satisfies(fact->{assertThat(fact.packages()).isEqualTo(1);assertThat(fact.dispatchItemId()).isEqualTo(4);}); + assertThatThrownBy(()->tx.executeWithoutResult(s->service.restore(order,List.of(item),List.of(receipt()),List.of(999L),"OP"))).hasMessageContaining("已回仓"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(2); + } + /** 任一SKU凭据写入失败,前一SKU余额、原箱状态和所有凭据一起回滚。 */ + @Test void failureInTheSecondSkuRollsBackAllStockAndReceiptWrites() { + db.execute("ALTER TABLE ll_return_carton_stock_receipt ADD CONSTRAINT reject_second CHECK(sku_code<>'SKU-B')"); + assertThatThrownBy(()->tx.executeWithoutResult(s->service.restore(order,List.of(item),List.of(receipt()),List.of(999L),"OP"))).isInstanceOf(RuntimeException.class); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_return_inventory_balance",Long.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_return_carton_stock_receipt",Long.class)).isZero(); + assertThat(db.queryForObject("SELECT status FROM ll_return_inventory_carton WHERE id=101",String.class)).isEqualTo("OUTBOUNDED"); + } + /** 其他客户和伪造出库归属不能恢复库存。 */ + @Test void anotherCustomerCannotBorrowTheOriginalCartonOrShipment() { + order.setCustomerId("OTHER"); + assertThatThrownBy(()->tx.executeWithoutResult(s->service.restore(order,List.of(item),List.of(receipt()),List.of(999L),"OP"))).hasMessageContaining("出库及原订单"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_return_carton_stock_receipt",Long.class)).isZero(); + } + /** 完成命令真实落账后才推进派送并供车次结案消费,同键重放不重复恢复库存。 */ + @Test void realExceptionCompletionAndTripCompletionConsumeTheNativeReceipt() { + db.update("UPDATE ll_exception_order SET exception_type='RETURN',delivery_no='DL-1',version=0 WHERE id=1"); + db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',status='RETURN_PENDING',package_count=1,version=0 WHERE id=3"); + db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=1,overseas_warehouse_id='92' WHERE id=10"); + db.update("UPDATE ll_outbound_order SET package_count=1 WHERE id=2"); + db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no) VALUES(100,'C','LM-1','DL-1')"); + var business=business(); + var request=new ExceptionOrderRequests.Complete(0,"native-complete","实收原箱",List.of(999L),List.of(receipt())); + var completed=tx.execute(s->business.complete("C","OP","EX-1",request)); + assertThat(completed.status()).isEqualTo("COMPLETED");assertThat(completed.restoredPackages()).isEqualTo(1); + assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("RETURNED"); + assertThat(tx.execute(s->business.complete("C","OP","EX-1",request)).replayed()).isTrue(); + assertThat(db.queryForObject("SELECT SUM(packages) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(1); + var fleet=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(map(DispatchOrderMapper.class),map(DispatchPdaTaskMapper.class),map(OutboundOrderMapper.class),map(DeliveryOrderMapper.class),map(DispatchItemMapper.class),map(DispatchPdaPickMapper.class),mock(com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService.class),map(DeliveryOperationMapper.class)); + var outcome=tx.execute(s->fleet.completion("DP-1",true)); + assertThat(outcome.blockReason()).isNull();assertThat(outcome.outcomes()).singleElement().satisfies(row->{assertThat(row.returnedPackages()).isEqualTo(1);assertThat(row.remainingPackages()).isZero();}); + } + /** 不制造入仓单,从真实换标主单经过请求校验、标记、受理、回仓直至派送结案。 */ + @Test void nativeMasterWithoutInboundCanMarkAndCompleteAfterBeanValidation() throws Exception { + prepareMarkReturn(); + var business=business(); + var candidates=business.returnCandidates("C","DL-1"); + assertThat(candidates).singleElement().satisfies(row->{ + assertThat(row.lastMileOrderId()).isEqualTo(100L);assertThat(row.inboundOrderId()).isNull(); + assertThat(row.returnSources()).singleElement().satisfies(source->{ + assertThat(source.returnCartonId()).isEqualTo(101L);assertThat(source.inventoryPositionId()).isNull(); + }); + }); + var json=new ObjectMapper(); + var request=json.readValue("{\"lastMileOrderId\":100,\"reason\":\"拒签原箱退回\",\"returnDirection\":\"OVERSEAS_WAREHOUSE\",\"items\":[{\"outboundPalletItemId\":5,\"returnCartonId\":101,\"packageCount\":1}],\"idempotencyKey\":\"native-mark\"}",ExceptionOrderRequests.MarkReturn.class); + try(var factory=jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator=factory.getValidator();assertThat(validator.validate(request)).isEmpty(); + var missing=new ExceptionOrderRequests.MarkReturn(null,"原因","OVERSEAS_WAREHOUSE",null,request.items(),"missing"); + assertThat(validator.validate(missing)).anySatisfy(v->assertThat(v.getPropertyPath().toString()).isEqualTo("lastMileOrderId")); + var invalid=new ExceptionOrderRequests.MarkReturn(100L,"原因","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,0,101L)),"invalid"); + assertThat(validator.validate(invalid)).anySatisfy(v->assertThat(v.getPropertyPath().toString()).contains("packageCount")); + } + var created=tx.execute(t->business.markReturn("C","OP","操作员","DL-1",request)); + assertThat(tx.execute(t->business.markReturn("C","OP","操作员","DL-1",request)).replayed()).isTrue(); + assertThat(db.queryForObject("SELECT inbound_order_id FROM ll_exception_order WHERE id=?",Long.class,created.id())).isNull(); + tx.executeWithoutResult(t->business.startProcessing("C","OP",created.exceptionNo(),new ExceptionOrderRequests.StartProcessing(0,"native-start","核实回仓"))); + var result=tx.execute(t->business.complete("C","OP",created.exceptionNo(),new ExceptionOrderRequests.Complete(1,"native-close","实收原箱",List.of(999L),List.of(receipt())))); + assertThat(result.restoredPackages()).isEqualTo(1);assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("RETURNED"); + } + /** 同一派送的两个无入仓主单不能互借原箱,伪造主单不属于候选。 */ + @Test void nativeCandidatesStayBoundToTheActualMasterWithoutInbound() { + prepareMarkReturn(); + db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no) VALUES(101,'C','LM-2','DL-1','OUT-1')"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(104,'C',102,4,102,1,1)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(106,'C',104,101,1)"); + db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,carton_mark,status,total_quantity) VALUES(102,'C','92','BOX-2','OUTBOUNDED',1)"); + var business=business();var rows=business.returnCandidates("C","DL-1"); + assertThat(rows).hasSize(2); + assertThat(rows.get(0).returnSources()).singleElement().satisfies(source->assertThat(source.returnCartonId()).isEqualTo(101L)); + assertThat(rows.get(1).returnSources()).singleElement().satisfies(source->assertThat(source.returnCartonId()).isEqualTo(102L)); + var wrong=new ExceptionOrderRequests.MarkReturn(101L,"原因","OVERSEAS_WAREHOUSE",null,List.of(new ExceptionOrderRequests.ReturnItem(5L,null,1,101L)),"wrong-master"); + assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",wrong))).hasMessageContaining("不属于该派送单和尾程主单"); + var foreign=new ExceptionOrderRequests.MarkReturn(999L,"原因","OVERSEAS_WAREHOUSE",null,wrong.items(),"foreign-master"); + assertThatThrownBy(()->tx.execute(t->business.markReturn("C","OP","操作员","DL-1",foreign))).hasMessageContaining("主单不属于该派送单"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_exception_order",Long.class)).isZero(); + } + /** 将真实已出库换标主单置为拒签,不添加普通入仓关系。 */ + private void prepareMarkReturn() { + db.update("DELETE FROM ll_exception_return_item");db.update("DELETE FROM ll_exception_order"); + db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',overseas_warehouse_id='92',status='REJECTED',package_count=1,version=0 WHERE id=3"); + db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no,outbound_order_no,overseas_warehouse_id,package_count) VALUES(100,'C','LM-1','DL-1','OUT-1','92',1)"); + } + /** 使用真实Mapper组装异常业务服务,资源和文件网关维持隔离。 */ + private ExceptionOrderService business() { + var business=new ExceptionOrderService(); + org.springframework.test.util.ReflectionTestUtils.setField(business,"exceptionOrders",map(ExceptionOrderMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"returnItems",map(ExceptionReturnItemMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"commands",map(ExceptionOrderCommandMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"deliveryOrders",map(DeliveryOrderMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"deliveryOperations",map(DeliveryOperationMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"lastMileOrders",map(com.baiyue.lastleg.mapper.LastlegOrderMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"objectMapper",new ObjectMapper().findAndRegisterModules()); + org.springframework.test.util.ReflectionTestUtils.setField(business,"evidenceFiles",mock(com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"nativeReturns",service); + org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + org.springframework.test.util.ReflectionTestUtils.setField(business,"inboundOrders",map(com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundOrders",map(OutboundOrderMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundPalletItems",map(OutboundPalletItemMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundPdaPicks",map(OutboundPdaPickMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"dispatchItems",map(DispatchItemMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"dispatchPdaTasks",map(DispatchPdaTaskMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"dispatchPdaPicks",map(DispatchPdaPickMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"dispatchPdaPickOrders",map(DispatchPdaPickOrderMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(business,"returnCartons",map(ReturnInventoryCartonMapper.class)); + return business; + } + /** 只提交原箱来源和真实目标资源。 */ + private ExceptionOrderRequests.ReturnReceipt receipt(){return new ExceptionOrderRequests.ReturnReceipt(5L,null,1,501L,"P-NEW",301L,"LOC-301",101L);} + /** 根据实体类型建立测试表;正式DDL另由Schema合同验证。 */ + private static String sqlType(Class type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(8000)";} + /** 获取隔离库的真实Mapper代理。 */ + private T map(Class type){return session.getMapper(type);} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java index 233937ce7..7098a9b3f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java @@ -19,12 +19,12 @@ class ReturnReceiptPolicyTest { .hasMessageContaining("累计实收"); } @Test void wrongPositionCannotBorrowAnotherSourceApproval() { - var wrong = new ReturnReceipt(10L, 999L, 1, 20L, "P", 30L, "L"); + var wrong = new ReturnReceipt(10L, 999L, 1, 20L, "P", 30L, "L", null); assertThatThrownBy(() -> ReturnReceiptPolicy.quantities(List.of(source()), List.of(wrong))).hasMessageContaining("冻结来源"); } private ExceptionReturnItem source() { var value = new ExceptionReturnItem(); value.setId(1L); value.setOutboundPalletItemId(10L); value.setInventoryPositionId(11L); value.setApprovedReturnPackages(4L); value.setReceivedPackages(0L); return value; } - private ReturnReceipt receipt(long amount) { return new ReturnReceipt(10L, 11L, amount, 20L, "P", 30L, "L"); } + private ReturnReceipt receipt(long amount) { return new ReturnReceipt(10L, 11L, amount, 20L, "P", 30L, "L", null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java index 0141b02e0..3282b4e79 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java @@ -33,6 +33,21 @@ import static org.assertj.core.api.Assertions.*; @Isolated("Manual retry regression temporarily changes the JVM default time zone") class OverseasFeeCancellationPersistenceTest { + /** 相同派送费版本重复发布只保留一个真实Outbox事件,重试不制造新应收源。 */ + @Test void deliveryFeePublicationReplayKeepsOneImmutableEvent() { + var producer=new OverseasFeeOutboxService(); + ReflectionTestUtils.setField(producer,"mapper",events); + ReflectionTestUtils.setField(producer,"objectMapper",new ObjectMapper().findAndRegisterModules()); + var deliveryFee=new OverseasFeeSource();org.springframework.beans.BeanUtils.copyProperties(source,deliveryFee); + deliveryFee.setSourceFeeId(702L);deliveryFee.setSourceType("OVERSEAS_DELIVERY_FEE");deliveryFee.setSourceNo("DL-REPLAY"); + producer.publish(deliveryFee,"PUBLISHED"); + String key=OverseasFeeCancellation.eventKey(deliveryFee,"PUBLISHED"); + var first=events.findByEventKey(key); + producer.publish(deliveryFee,"PUBLISHED");producer.publish(deliveryFee,"PUBLISHED"); + var replay=events.findByEventKey(key); + assertThat(replay.getId()).isEqualTo(first.getId());assertThat(replay.getPayloadJson()).isEqualTo(first.getPayloadJson()); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_event_outbox WHERE event_key=?",Integer.class,key)).isEqualTo(1); + } JdbcTemplate jdbc; LastlegEventOutboxMapper events; OverseasFeeSourceMapper fees; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java index 1c4992e03..34074adb9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java @@ -17,7 +17,7 @@ class FleetBookingFreightTest { final FleetBookingReservations reservations = new FleetBookingReservations(bookings, json); final FleetBookingFreight service = new FleetBookingFreight(bookings, bindings, reservations, json); FleetBookingEntity booking; - final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:ONT8"); + final FleetDispatchGateway.SourceCargo source = new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 12, "FBA:ONT8"); @BeforeEach void setup() throws Exception { booking = new FleetBookingEntity(); booking.setId(1L); booking.setTripId(2L); booking.setBookingNo("FB-1"); booking.setIncomingNo("WT-1"); @@ -29,10 +29,10 @@ class FleetBookingFreightTest { booking.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt( new FleetBookingIncomingGateway.Evidence("WT-1", "IN-1", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-1", List.of(new FleetBookingIncomingGateway.Pallet("501", "PLT-501", 12, BigDecimal.TEN, BigDecimal.ONE)), 12), - List.of(new FleetBookingModels.PalletSpaces("501", 2)), "OP-1", Instant.parse("2026-10-01T00:00:00Z")))); + List.of(new FleetBookingModels.PalletSpaces("501", 2, null, null)), "OP-1", Instant.parse("2026-10-01T00:00:00Z")))); var binding = new FleetTripDispatch(); binding.setTripId(2L); binding.setDispatchId(3L); when(bindings.findByDispatchNo("DP-1")).thenReturn(binding); - when(bookings.incomingForFreight("WT-1")).thenReturn(booking); + when(bookings.forInboundSource(anyString(), eq("IN-1"))).thenReturn(booking); } @Test void quoteUsesConfirmedActualPriceAndSeparatesOtherCargo() { var preview = service.preview(3, "DP-1", "C-1", List.of(source)); @@ -53,9 +53,9 @@ class FleetBookingFreightTest { @Test void rejectsPartialOrChangedDestinationAndDetectsNewQuoteVersion() { var before = service.preview(3, "DP-1", "C-1", List.of(source)); assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of( - new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 11, "FBA:ONT8")))); + new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 11, "FBA:ONT8")))); assertThrows(ResponseStatusException.class, () -> service.preview(3, "DP-1", "C-1", List.of( - new FleetDispatchGateway.SourceCargo("WT-1", "501", "PLT-501", 12, "FBA:LAX9")))); + new FleetDispatchGateway.SourceCargo("IN-1", "501", "PLT-501", 12, "FBA:LAX9")))); booking.setRecordVersion(5L); assertNotEquals(before.snapshotHash(), service.preview(3, "DP-1", "C-1", List.of(source)).snapshotHash()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index d99381511..0d016c685 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -127,13 +127,18 @@ class FleetTripPersistenceTest { for (String table : List.of("ll_fleet_driver", "ll_fleet_driver_history", "ll_vehicle", "ll_fleet_trip", "ll_fleet_resource_assignment", "ll_fleet_trip_history", "ll_fleet_capacity_offer", "ll_fleet_capacity_offer_command", "ll_fleet_external_booking", "ll_fleet_trip_dispatch", "ll_dispatch_order", "ll_dispatch_pda_task", "ll_outbound_order", "ll_delivery_order", "ll_dispatch_item", "ll_dispatch_pda_pick", "ll_dispatch_pda_operation", "ll_dispatch_pallet_lock", "ll_delivery_operation", - "ll_outbound_pallet_item", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) { + "ll_outbound_pallet_item", "ll_return_carton_stock_receipt", "ll_exception_order", "ll_exception_return_item", "ll_inventory_ledger", "ll_dispatch_pda_pick_order")) { var match = Pattern.compile("(?s)CREATE TABLE(?: IF NOT EXISTS)? " + table + " \\(.*?;").matcher(schema); assertThat(match.find()).as(table).isTrue(); - jdbc.execute(match.group().replace(" JSON", " LONGTEXT") + jdbc.execute(match.group().replace(" JSON", " LONGTEXT").replaceAll("(?i) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci", "") .replaceAll("\\((receipt_id|return_receipt_id|return_carton_id) IS NOT NULL\\)", "(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)")); } + jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)"); + jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))"); + jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))"); var configuration = new MybatisConfiguration(); + configuration.addMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class); for (var mapper : List.of(DriverMapper.class, DriverHistoryMapper.class, VehicleMapper.class, FleetTripMapper.class, FleetResourceMapper.class, FleetTripHistoryMapper.class, FleetTripDispatchMapper.class, FleetBookingMapper.class, FleetCapacityOfferMapper.class, FleetCapacityOfferCommandMapper.class, DispatchOrderMapper.class, DispatchPdaTaskMapper.class, OutboundOrderMapper.class, DeliveryOrderMapper.class, DispatchItemMapper.class, DispatchPdaPickMapper.class, DispatchPdaOperationMapper.class, DispatchPalletLockMapper.class, DeliveryOperationMapper.class)) configuration.addMapper(mapper); @@ -161,6 +166,9 @@ class FleetTripPersistenceTest { var bindingMapper = session.getMapper(FleetTripDispatchMapper.class); bindingGuard = transactional(new FleetDispatchBindingGuard(bindingMapper, json), FleetDispatchBindingGuard.class); var workflow = new DispatchPdaWorkflowService(); + var deliveryExecution = new com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository( + session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class))); + ReflectionTestUtils.setField(workflow, "deliveryExecution", deliveryExecution); ReflectionTestUtils.setField(workflow, "orders", session.getMapper(DispatchOrderMapper.class)); ReflectionTestUtils.setField(workflow, "outboundOrders", session.getMapper(OutboundOrderMapper.class)); ReflectionTestUtils.setField(workflow, "items", session.getMapper(DispatchItemMapper.class)); @@ -175,6 +183,9 @@ class FleetTripPersistenceTest { var dispatchGateway = transactional(new DispatchFleetAdapter(session.getMapper(DispatchOrderMapper.class), session.getMapper(DispatchPdaTaskMapper.class), session.getMapper(OutboundOrderMapper.class), session.getMapper(DeliveryOrderMapper.class), session.getMapper(DispatchItemMapper.class), session.getMapper(DispatchPdaPickMapper.class), transactional(workflow, DispatchPdaWorkflowService.class), session.getMapper(DeliveryOperationMapper.class)), DispatchFleetAdapter.class); + ReflectionTestUtils.setField(dispatchGateway, "deliveryExecution", deliveryExecution); + ReflectionTestUtils.setField(dispatchGateway, "cargoSources", new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( + new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(session.getMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class)))); service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json, bindingMapper, dispatchGateway, reservations), FleetTripService.class); preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class); @@ -530,6 +541,10 @@ class FleetTripPersistenceTest { var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); var awaiting=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","WAIT_PDA_RECEIVING","empty",List.of(),0); when(incoming.create(eq("1"),any())).thenReturn(awaiting); + jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES('9','WT-EXT','WT-EXT')"); + jdbc.update("INSERT INTO ll_inventory_position VALUES(9001,'9',902,'92',9002,22)"); + jdbc.update("INSERT INTO ll_inventory_balance VALUES(9002,'9',9003,'WT-EXT')"); + jdbc.update("INSERT INTO ll_order VALUES(9003,'9','WAREHOUSE_TRANSFER',NULL)"); var request=new FleetBookingModels.StartIncoming(confirmed.tripRecordVersion(),confirmed.booking().recordVersion(),departure.minusSeconds(120),null,null,null,null,null,"incoming-start"); var started=bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request); assertThat(bookingService.startIncoming(number,draft.booking().bookingNo(),"1",request).replayed()).isTrue(); @@ -540,11 +555,11 @@ class FleetTripPersistenceTest { when(incoming.inspect("9","WT-EXT",true)).thenReturn(actual); when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(actual); assertThatThrownBy(()->bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), - List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成"); + List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt"))).hasMessageContaining("实收确认未完成"); assertThat(bookingService.list(number).records().get(0).agreedAmount()).isEqualByComparingTo("100.00"); when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(new FleetBookingIncomingGateway.Evidence(actual.incomingNo(),actual.inboundCode(),actual.warehouseId(),actual.destinationKey(),"READY_FOR_DISPATCH",actual.receiptHash(),actual.pallets(),actual.receivedPackages())); var accepted=bookingService.acceptReceipt(number,draft.booking().bookingNo(),"1",new FleetBookingModels.AcceptReceipt(started.tripRecordVersion(),started.booking().recordVersion(),actual.receiptHash(), - List.of(new FleetBookingModels.PalletSpaces("902",1)),new BigDecimal("55.00"),"accept-receipt")); + List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt")); assertThat(accepted.booking().status()).isEqualTo("RECEIVED");assertThat(accepted.booking().cargo().packageCount()).isEqualTo(22); assertThat(accepted.booking().agreedAmount()).isEqualByComparingTo("55.00"); assertThat(accepted.booking().quoteSource()).isEqualTo(source); @@ -568,6 +583,33 @@ class FleetTripPersistenceTest { service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(planned.recordVersion(),"DP-13","撤销未开工关联")); assertThat(bookingService.list(number).records().get(0).status()).isEqualTo("RECEIVED"); assertThat(bookingService.list(number).records().get(0).dispatchNo()).isNull(); + // 解除关联后重新核对已有实收,不创建新库存,也不重复预留承运容量。 + var reopenedPlan=execution.capacityPlan(number); + var refreshed=execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm( + reopenedPlan.recordVersion(),reopenedPlan.manifestHash(),List.of(measurement()),"解除关联后重新核对")); + var currentBooking=bookingService.list(number).records().get(0); + var receiptAgain=new FleetBookingModels.AcceptReceipt(refreshed.recordVersion(),currentBooking.recordVersion(),actual.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("902",1, null, null)),new BigDecimal("55.00"),"accept-receipt-again"); + var renewed=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain); + assertThat(renewed.booking().status()).isEqualTo("RECEIVED"); + assertThat(renewed.booking().cargo().packageCount()).isEqualTo(22); + assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain).replayed()).isTrue(); + assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); + // 实物换托后源摘要变化且旧测量失效;缺测量拒绝,显式实测后更新快照及容量。 + var moved=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","READY_FOR_DISPATCH","moved-receipt", + List.of(new FleetBookingIncomingGateway.Pallet("903","PALLET-903",22,null,null)),22); + when(incoming.inspect("9","WT-EXT",true)).thenReturn(moved); + when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(moved); + var withoutMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("903",1,null,null)),new BigDecimal("55.00"),"moved-confirm"); + assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withoutMeasure)).hasMessageContaining("实际托盘"); + var withMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("903",1,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-confirm"); + var measuredAgain=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure); + assertThat(measuredAgain.booking().cargo().weightKg()).isEqualByComparingTo("170"); + assertThat(measuredAgain.booking().cargo().volumeCbm()).isEqualByComparingTo("1.7"); + assertThat(measuredAgain.booking().receipt().source().pallets()).singleElement().satisfies(p->{assertThat(p.warehousePalletId()).isEqualTo("903");assertThat(p.weightKg()).isEqualByComparingTo("170");}); + assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure).replayed()).isTrue(); } @Test void externalInquiryDoesNotReserveUntilConfirmedAndCancellationReleasesExactlyOnce() { @@ -931,6 +973,31 @@ class FleetTripPersistenceTest { } finally { executor.shutdownNow(); } } + /** 取消与实际整车发车并发时,已拣货车只能发车;出库和派送不得产生取消终态。 */ + @Test void outboundCancellationRacingDepartureCannotCreateDepartedCancelledCargo() throws Exception { + String number=readyTrip();var loaded=confirm(number); + var cancel=new com.baiyue.lastleg.delivery.service.DeliveryTransferService(); + var config=new MybatisConfiguration();config.addMapper(OutboundOrderMapper.class);config.addMapper(DispatchOrderMapper.class); + var source=(javax.sql.DataSource)jdbc.getDataSource(); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(source);factory.setConfiguration(config);factory.afterPropertiesSet(); + var data=new SqlSessionTemplate(factory.getObject()); + ReflectionTestUtils.setField(cancel,"outboundOrders",data.getMapper(OutboundOrderMapper.class)); + ReflectionTestUtils.setField(cancel,"dispatchOrders",data.getMapper(DispatchOrderMapper.class)); + ReflectionTestUtils.setField(cancel,"fleetBindings",bindingGuard); + var start=new CountDownLatch(1);var executor=Executors.newFixedThreadPool(2); + try { + var departure=executor.submit(()->{start.await(5,TimeUnit.SECONDS);return execution.depart("92","DP-11","1",new FleetExecutionModels.Depart(loaded.recordVersion(),"race-depart","并发取消验证"));}); + var cancellation=executor.submit(()->{start.await(5,TimeUnit.SECONDS);try{ + new org.springframework.transaction.support.TransactionTemplate(transactions).executeWithoutResult(s->cancel.cancelOutbound("OUT-11","1",new com.baiyue.lastleg.delivery.dto.DeliveryRequests.Cancel("并发取消"))); + return false; + }catch(ResponseStatusException rejected){return rejected.getStatusCode().value()==409;}}); + start.countDown();assertThat(departure.get(20,TimeUnit.SECONDS).shippedPackages()).isEqualTo(20);assertThat(cancellation.get(20,TimeUnit.SECONDS)).isTrue(); + assertThat(jdbc.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsOnly("OUTBOUNDED"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE status='CANCELLED'",Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_operation WHERE operation_type='DEPART'",Integer.class)).isEqualTo(2); + } finally {executor.shutdownNow();} + } + @Test void changingAssignedDriverToDomesticBlocksPreviewAndDepartureUntilCorrected() { String number = readyTrip(); var confirmed = confirm(number); long driverId = Long.parseLong(service.detail(number).trip().driverId()); @@ -1125,6 +1192,8 @@ class FleetTripPersistenceTest { dispatchId + 1000, customer, "OUT-" + dispatchId, dispatchId, "out-key-" + dispatchId); jdbc.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_order_id,outbound_no,dispatch_order_id,delivery_group_key,idempotency_key,expected_arrival_at) VALUES(?,?,?,?,?,?,?,?,?)", dispatchId + 2000, customer, "DL-" + dispatchId, dispatchId + 1000, "OUT-" + dispatchId, dispatchId, "DEST", "dl-key-" + dispatchId, LocalDateTime.of(2030, 1, 1, 12, 0)); + jdbc.update("UPDATE ll_outbound_order SET package_count=10 WHERE id=?", dispatchId + 1000); + jdbc.update("UPDATE ll_delivery_order SET package_count=10 WHERE id=?", dispatchId + 2000); } private boolean attempt(CountDownLatch start, String number, long vehicle, long driver) throws Exception { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java index 9d044f40b..478c9da19 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java @@ -54,7 +54,8 @@ class ReturnInventoryMovementSqlTest { var boundSql = configuration.getMappedStatement(ReturnInventoryMovementMapper.class.getName() + "." + method) .getBoundSql(params); String sql = boundSql.getSql(); - assertEquals(7, sql.split("UNION ALL", -1).length); + assertEquals(8, sql.split("UNION ALL", -1).length); + assertTrue(sql.contains("ll_return_carton_stock_receipt")); assertTrue(!sql.contains("CONVERT(")); assertTrue(sql.contains("m.customerId=?")); assertTrue(sql.contains("m.overseasWarehouseId=?")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java index 9b67c7a46..5db7e8b61 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java @@ -13,6 +13,34 @@ import java.util.List; import static org.mockito.Mockito.*; import static org.assertj.core.api.Assertions.*; class FleetIncomingAdapterTest { + /** 真实组成拆成新托后旧重量体积失效;其他客户库存不进入该票实收。 */ + @Test void splitPalletsRefreshTheActualReceiptAndInvalidateOldMeasurements() throws Exception { + var data=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new org.springframework.jdbc.core.JdbcTemplate(data); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(64))"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,balance_id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,pallet_no VARCHAR(64),overseas_warehouse_id VARCHAR(64),available_packages BIGINT,picked_packages BIGINT)"); + db.update("INSERT INTO ll_inventory_balance VALUES(1,'C','IN-1'),(2,'OTHER','IN-1')"); + db.update("INSERT INTO ll_inventory_position VALUES(10,1,'C',501,'P-501','92',5,0),(11,2,'OTHER',999,'P-999','92',99,0)"); + var config=new com.baomidou.mybatisplus.core.MybatisConfiguration();config.addMapper(com.baiyue.lastleg.transfer.mapper.FleetIncomingActualSourceMapper.class); + var factory=new com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); + var session=new org.mybatis.spring.SqlSessionTemplate(factory.getObject()); + var service=mock(WarehouseTransferService.class);var orders=mock(WarehouseTransferOrderMapper.class); + var adapter=new FleetIncomingAdapter(service,orders,new ObjectMapper()); + org.springframework.test.util.ReflectionTestUtils.setField(adapter,"actualSources",new com.baiyue.lastleg.transfer.repository.FleetIncomingActualSourceRepository(session.getMapper(com.baiyue.lastleg.transfer.mapper.FleetIncomingActualSourceMapper.class))); + when(orders.findNo("C","WT-1")).thenReturn(new com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder()); + var detail=mock(WarehouseTransferResponses.Detail.class);when(detail.transferNo()).thenReturn("WT-1");when(detail.inboundCode()).thenReturn("IN-1");when(detail.operatingWarehouseId()).thenReturn("92");when(detail.destinationType()).thenReturn("FBA");when(detail.destinationWarehouseCode()).thenReturn("ONT8");when(detail.status()).thenReturn("READY_FOR_DISPATCH"); + var prior=mock(WarehouseTransferResponses.Pallet.class);when(prior.status()).thenReturn("COMPLETED");when(prior.warehousePalletId()).thenReturn(501L);when(prior.palletNo()).thenReturn("P-501");when(prior.cartonCount()).thenReturn(5);when(prior.grossWeightKg()).thenReturn(BigDecimal.TEN);when(prior.volumeCbm()).thenReturn(BigDecimal.ONE);when(detail.pallets()).thenReturn(List.of(prior)); + when(service.detail("C","WT-1")).thenReturn(detail); + var before=adapter.inspect("C","WT-1",false);assertThat(before.pallets()).singleElement().satisfies(p->assertThat(p.weightKg()).isEqualByComparingTo("10")); + db.update("UPDATE ll_inventory_position SET available_packages=0 WHERE id=10"); + db.update("INSERT INTO ll_inventory_position VALUES(12,1,'C',502,'P-502','92',2,0),(13,1,'C',503,'P-503','92',3,0)"); + var after=adapter.inspect("C","WT-1",false); + assertThat(after.receivedPackages()).isEqualTo(5);assertThat(after.receiptHash()).isNotEqualTo(before.receiptHash()); + assertThat(after.pallets()).extracting(FleetBookingIncomingGateway.Pallet::warehousePalletId).containsExactly("502","503"); + assertThat(after.pallets()).allSatisfy(p->{assertThat(p.weightKg()).isNull();assertThat(p.volumeCbm()).isNull();}); + db.update("UPDATE ll_inventory_position SET available_packages=1 WHERE id=13"); + assertThatThrownBy(()->adapter.inspect("C","WT-1",false)).hasMessageContaining("原实收量不一致"); + } @Test void createsRealCustomerDeliveryWithoutClaimingStockWasReceived() { var service=mock(WarehouseTransferService.class);var adapter=new FleetIncomingAdapter(service,mock(WarehouseTransferOrderMapper.class),new ObjectMapper()); org.springframework.test.util.ReflectionTestUtils.setField(adapter,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index 64e564473..884f156b7 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -9,9 +9,11 @@ import { selectReturnSources, verifyFleetReturnRedispatch, waitForRedispatchSign function fixture(fault = '') { const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = [] let returned = false, originalSigned = false, newSigned = false, departed = false - const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: name, inboundOrderId: String(index + 10), inboundCode: `IN-${name}`, + const masterIds = { A: '9007199254740993', B: '9007199254740995' } + const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: masterIds[name], lastMileOrderNo: `LM-${name}`, + customerId: 'C', overseasWarehouseId: 'W', inboundOrderId: fault === 'no-inbound' ? null : String(index + 10), inboundCode: `IN-${name}`, deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', returnSources: [{ outboundPalletItemId: String(index + 20), inventoryPositionId: String(index + 30), maxReturnPackages: index ? 3 : 4 }] })) - const tail = name => ({ orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, + const tail = name => ({ id: masterIds[name], orderNo: `LM-${name}`, deliveryOrderNo: 'DL-ORIGINAL', outboundOrderNo: 'OUT-ORIGINAL', orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' }) const order = name => name === 'DL-NEW' ? { deliveryNo: name, dispatchNo: 'DP-NEW', packageCount: 2, signedPackages: newSigned ? 2 : null, signedAt: newSigned ? 'new-signature' : null } @@ -28,9 +30,19 @@ function fixture(fault = '') { const delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned return envelope({ records: [{ inboundCode: code, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) } - if (path.includes('/orders/')) return envelope({ order: tail(path.split('/orders/')[1].split('?')[0]) }) + if (path.includes('/orders/')) { + const masterId = path.split('/orders/')[1].split('?')[0] + const name = Object.keys(masterIds).find(name => masterIds[name] === masterId) + assert.ok(name, `unknown master ${masterId}`) + return envelope({ order: tail(name) }) + } if (path.includes('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations) - if (path.includes('/mark-return')) return envelope({ exceptionNo: 'EX-NEW', version: 0 }) + if (path.includes('/mark-return')) { + assert.equal(body.lastMileOrderId, masterIds.A) + assert.equal(Object.hasOwn(body, 'inboundOrderId'), false) + assert.deepEqual(body.items, [{ outboundPalletItemId: '20', inventoryPositionId: '30', packageCount: 2 }]) + return envelope({ exceptionNo: 'EX-NEW', version: 0 }) + } if (path.includes('/start-processing')) return envelope({ status: 'PROCESSING', version: 1 }) if (path.includes('/exception-orders/') && path.includes('/complete')) { const replayed = returned; returned = true @@ -40,7 +52,7 @@ function fixture(fault = '') { if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') } if (path.endsWith('/pallets/100')) return envelope({ warehouseId: 'W', locationId: '200', palletCode: context.palletCode }) if (path.includes('/locations?')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] }) - if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: 'A', packageCount: fault === 'wrong-candidate' ? 4 : 2 }]) + if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: masterIds.A, packageCount: fault === 'wrong-candidate' ? 4 : 2 }]) if (path.endsWith('/drafts/containers')) return envelope({ id: '400' }) if (path.endsWith('/draft-locks/lock')) { assert.equal(body.sourceType, 'RETURN_RECEIPT'); assert.equal(body.planPalletId, undefined); return envelope(null) } if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, []); assert.deepEqual(body.returnReceiptIds, ['300']); return envelope({ dispatchNo: 'DP-NEW', packageCount: 2 }) } @@ -61,7 +73,7 @@ function fixture(fault = '') { async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } }, } const input = { customerId: 'C', warehouseId: 'W', pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] } - return { context, trip, input, dependencies, requests, checkpoints } + return { context, trip, input, dependencies, requests, checkpoints, relations, masterIds } } const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) @@ -88,6 +100,43 @@ for (const signedPackages of [undefined, null, '2', 0, 3]) test(`redispatch requ await assert.rejects(run(f), /two boxes actually signed/) }) +test('mark-return uses the selected master ID even without an ordinary inbound relation', async () => { + const f = fixture('no-inbound') + assert.ok(f.relations.every(row => row.inboundOrderId === null)) + assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') + const writes = f.requests.filter(row => row.path.includes('/mark-return')) + assert.equal(writes.length, 1) + assert.equal(writes[0].body.lastMileOrderId, f.masterIds.A) + assert.equal(Object.hasOwn(writes[0].body, 'inboundOrderId'), false) +}) + +for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'foreign-customer', 'foreign-warehouse', 'borrowed-master']) { + test(`return source identity ${fault} stops before mark-return and stock writes`, async () => { + const f = fixture(), request = f.context.request.bind(f.context) + if (fault === 'missing-master') delete f.relations[0].lastMileOrderId + if (fault === 'wrong-delivery') f.relations[0].deliveryNo = 'DL-OTHER' + if (fault === 'wrong-outbound') f.relations[0].outboundNo = 'OUT-OTHER' + if (fault === 'foreign-customer') f.relations[0].customerId = 'OTHER' + if (fault === 'foreign-warehouse') f.relations[0].overseasWarehouseId = 'OTHER' + if (fault === 'borrowed-master') f.context.request = async (...args) => { + const response = await request(...args) + if (args[1].includes(`/orders/${f.masterIds.A}`)) response.body.data.order.id = f.masterIds.B + return response + } + await assert.rejects(run(f), /real master ID|match this candidate/) + assert.ok(f.requests.every(row => row.method === 'GET')) + assert.equal(f.trip.returnRedispatch.exceptionNo, undefined) + }) +} + +for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) { + test('two-box return never borrows an inventory source from another master or a different source type', async () => { + const f = fixture(); mutate(f.relations[0].returnSources[0]) + await assert.rejects(run(f), /Selected master must contain/) + assert.ok(f.requests.every(row => row.method === 'GET')) + }) +} + test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => { const f = fixture(); f.input.sourceSelectionRecovery = true assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index 3fb9d3c2b..ddfafe976 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -113,9 +113,14 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, } const relations = await request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}/return-inbound-options`, customerId)) for (const relation of relations) { - check('Return source relation belongs to the original delivery', relation.deliveryNo === number && relation.outboundNo === current.order.outboundNo) + check('Return source relation belongs to the original delivery and has a real master ID', /^[1-9][0-9]*$/.test(id(relation.lastMileOrderId)) + && id(relation.customerId) === customerId && id(relation.overseasWarehouseId) === warehouseId + && relation.deliveryNo === number && relation.outboundNo === current.order.outboundNo) const detail = await tail(relation) - check('Tail source customer and warehouse match this run', id(detail.order.customerId) === customerId && id(detail.order.overseasWarehouseId) === warehouseId) + check('Tail source identity, customer and warehouse match this candidate', id(detail.order.id) === id(relation.lastMileOrderId) + && detail.order.orderNo === relation.lastMileOrderNo && detail.order.deliveryOrderNo === number + && detail.order.outboundOrderNo === relation.outboundNo + && id(detail.order.customerId) === customerId && id(detail.order.overseasWarehouseId) === warehouseId) rows.push({ relation, tail: detail.order, delivery: current.order }) } } @@ -123,9 +128,12 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, proof.domesticBefore = { A: await domestic(a), B: await domestic(b) } check('Both source inbounds must be unfinished in this fresh sample', proof.domesticBefore.A.processStatus !== 'DELIVERED' && proof.domesticBefore.B.processStatus !== 'DELIVERED') proof.sources = { A: copy(a), B: copy(b) }; await context.checkpoint() - const source = a.relation.returnSources.find(row => Number(row.maxReturnPackages) >= 2) + const source = a.relation.returnSources.find(row => /^[1-9][0-9]*$/.test(id(row.outboundPalletItemId)) + && /^[1-9][0-9]*$/.test(id(row.inventoryPositionId)) && row.returnCartonId == null + && Number.isSafeInteger(Number(row.maxReturnPackages)) && Number(row.maxReturnPackages) >= 2) + check('Selected master must contain the actual ordinary stock source for the two returned boxes', Boolean(source)) const marked = await request('POST', scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId), { - inboundOrderId: a.relation.inboundOrderId, reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE', + lastMileOrderId: id(a.relation.lastMileOrderId), reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RET`, items: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, packageCount: 2 }], idempotencyKey: key(), }) proof.exceptionNo = marked.exceptionNo diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index bd0dce82d..4077802ab 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, - completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, + createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -105,7 +105,7 @@ test('phase-five module proves receipt-save completion, cross-region convergence "visitResult: 'SATISFIED'", '/exception-orders', '/return-inbound-options', '/mark-return', "returnDirection: 'OVERSEAS_WAREHOUSE'", 'returnSources', 'outboundPalletItemId', 'inventoryPositionId', - 'value.returnSources.some', 'returnInbound?.deliveryNo === deliveryNo', + 'value.returnSources.some', 'returnCandidate?.deliveryNo === deliveryNo', "idempotencyKey: key(context, 'DELIVERY-RETURN')", '派送退回同键同载荷重放', 'RETURN_PENDING_WITH_COMPLETED_VISIT', ]) assert.ok(source.includes(token), token) @@ -183,6 +183,68 @@ function contextFor(request) { } const ok = data => ({ http: 200, body: { code: '200', data } }) +function deliveryReturnFixture() { + return { lastMileOrderId: '9007199254740993', lastMileOrderNo: 'LM-1', inboundOrderId: '81', inboundCode: 'IN-A', + customerId: '100', overseasWarehouseId: 'WH-1', deliveryNo: 'DL-1', outboundNo: 'OB-1', + returnSources: [{ outboundPalletItemId: '51', inventoryPositionId: '61', returnCartonId: null, maxReturnPackages: 2 }] } +} +const returnScope = { customerId: '100', warehouseId: 'WH-1', deliveryNo: 'DL-1', outboundNo: 'OB-1' } + +for (const ordinaryInbound of ['81', null, undefined]) test(`phase5 mark-return sends the actual master key; inbound=${ordinaryInbound}`, async () => { + const candidate = deliveryReturnFixture(); candidate.inboundOrderId = ordinaryInbound + if (!ordinaryInbound) candidate.inboundCode = null + const context = contextFor((method, path, body) => { + if (method === 'GET') { assert.match(path, /return-inbound-options/); return ok([candidate]) } + assert.equal(method, 'POST'); assert.match(path, /mark-return/) + assert.equal(body.lastMileOrderId, candidate.lastMileOrderId) + assert.equal(Object.hasOwn(body, 'inboundOrderId'), false) + assert.deepEqual(body.items, [{ outboundPalletItemId: '51', inventoryPositionId: '61', packageCount: 1 }]) + assert.equal(body.returnDirection, 'OVERSEAS_WAREHOUSE') + assert.ok(body.idempotencyKey) + return ok({ exceptionNo: 'EX-1', status: 'PENDING', version: 0 }) + }) + const result = await createDeliveryReturn(context, returnScope) + assert.equal(result.returned.data.exceptionNo, 'EX-1') + assert.equal(context.calls.filter(row => row.method === 'POST').length, 1) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + +test('phase5 keeps the selected master and its own source together when candidates share no inbound relation', async () => { + const first = deliveryReturnFixture(); first.inboundOrderId = null; first.inboundCode = null; first.returnSources = [] + const selected = { ...deliveryReturnFixture(), lastMileOrderId: '9007199254740995', lastMileOrderNo: 'LM-2', inboundOrderId: null, inboundCode: null, + returnSources: [{ outboundPalletItemId: '52', inventoryPositionId: '62', maxReturnPackages: 1 }] } + const context = contextFor((method, path, body) => { + if (method === 'GET') return ok([first, selected]) + assert.equal(body.lastMileOrderId, selected.lastMileOrderId) + assert.equal(Object.hasOwn(body, 'inboundOrderId'), false) + assert.deepEqual(body.items, [{ outboundPalletItemId: '52', inventoryPositionId: '62', packageCount: 1 }]) + return ok({ exceptionNo: 'EX-2' }) + }) + await createDeliveryReturn(context, returnScope) + assert.equal(context.calls.filter(row => row.method === 'POST').length, 1) +}) + +for (const [name, mutate] of [ + ['missing-master', row => { delete row.lastMileOrderId }], ['zero-master', row => { row.lastMileOrderId = '0' }], + ['foreign-customer', row => { row.customerId = 'OTHER' }], ['foreign-warehouse', row => { row.overseasWarehouseId = 'OTHER' }], + ['wrong-delivery', row => { row.deliveryNo = 'DL-OTHER' }], ['wrong-outbound', row => { row.outboundNo = 'OB-OTHER' }], + ['missing-source', row => { row.returnSources = [] }], ['missing-position', row => { row.returnSources[0].inventoryPositionId = null }], + ['ambiguous-source-type', row => { row.returnSources[0].returnCartonId = '701' }], +]) test(`phase5 refuses ${name} before any mark-return write even with an inbound ID`, async () => { + const candidate = deliveryReturnFixture(); mutate(candidate) + const context = contextFor((method) => { assert.equal(method, 'GET'); return ok([candidate]) }) + assert.equal(await createDeliveryReturn(context, returnScope), null) + assert.equal(context.calls.length, 1) + assert.ok(context.assertions.some(row => !row.passed)) +}) + +test('the phase5 lifecycle calls the tested request path and replays its exact returned body', () => { + assert.match(source, /const returnRequest = await createDeliveryReturn\(context, \{ customerId, warehouseId, deliveryNo, outboundNo \}\)/) + assert.match(source, /const \{ returned, returnBody \} = returnRequest/) + assert.doesNotMatch(source, /inboundOrderId:\s*id\(returnCandidate/) + assert.match(source, /lastMileOrderId: id\(returnCandidate\.lastMileOrderId\)/) +}) + test('partial completion rejects different Orders and an already delivered pending fragment', async () => { for (const second of [{ orderId: 'OTHER', processStatus: 'WAIT_SHIP' }, { orderId: 'ORDER', processStatus: 'DELIVERED' }]) { diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 8bf7373d4..03c355e1b 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -892,6 +892,42 @@ export async function pickAll(context, token, dispatchNo) { return success(replay.response) } +/** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */ +export async function createDeliveryReturn(context, { customerId, warehouseId, deliveryNo, outboundNo }) { + const eligibleSource = source => /^[1-9][0-9]*$/.test(id(source?.outboundPalletItemId)) + && /^[1-9][0-9]*$/.test(id(source?.inventoryPositionId)) && source?.returnCartonId == null + && Number.isSafeInteger(Number(source?.maxReturnPackages)) && Number(source.maxReturnPackages) > 0 + const returnOptions = await platform(context, 'GET', '查询派送退回可选主单', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId })) + const returnCandidate = Array.isArray(returnOptions.data) + ? returnOptions.data.find((value) => /^[1-9][0-9]*$/.test(id(value?.lastMileOrderId)) + && id(value?.customerId) === customerId && id(value?.overseasWarehouseId) === warehouseId + && value?.deliveryNo === deliveryNo && value?.outboundNo === outboundNo + && Array.isArray(value?.returnSources) && value.returnSources.some(eligibleSource)) : null + context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response, + id(returnCandidate?.lastMileOrderId) && returnCandidate?.deliveryNo === deliveryNo && returnCandidate?.outboundNo === outboundNo, + `candidate=${Boolean(returnCandidate)}; ${trace(returnOptions.response)}`, OPTIONS) + if (!returnCandidate) return null + const returnSource = Array.isArray(returnCandidate.returnSources) + ? returnCandidate.returnSources.find(eligibleSource) : null + context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response, + Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS) + if (!returnSource) return null + const returnBody = { + lastMileOrderId: id(returnCandidate.lastMileOrderId), reason: `${context.marker}-delivery-return`, + returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128), + items: [{ + outboundPalletItemId: id(returnSource.outboundPalletItemId), + inventoryPositionId: id(returnSource.inventoryPositionId), + packageCount: 1, + }], + idempotencyKey: key(context, 'DELIVERY-RETURN'), + } + const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody) + return { returned, returnBody } +} + export async function run(context) { context.createdResources.phase5OverseasFulfillment = { marker: context.marker, dispatchNo: null, outboundNo: null, deliveryNo: null, deliveryNos: [], @@ -1196,34 +1232,9 @@ export async function run(context) { nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS) context.createdResources.phase5OverseasFulfillment.manualExceptionNo = manualExceptionNo || null - const returnOptions = await platform(context, 'GET', '查询派送退回可选入仓单', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/return-inbound-options`, { customerId })) - const returnInbound = Array.isArray(returnOptions.data) - ? returnOptions.data.find((value) => Array.isArray(value?.returnSources) - && value.returnSources.some((source) => id(source?.outboundPalletItemId) - && id(source?.inventoryPositionId) && Number(source?.maxReturnPackages) > 0)) : null - context.assert(MODULE, '退回候选严格属于当前派送单', returnOptions.response, - id(returnInbound?.inboundOrderId) && returnInbound?.deliveryNo === deliveryNo && returnInbound?.outboundNo === outboundNo, - `candidate=${Boolean(returnInbound)}; ${trace(returnOptions.response)}`, OPTIONS) - if (!returnInbound) return - const returnSource = Array.isArray(returnInbound.returnSources) - ? returnInbound.returnSources.find((value) => id(value?.outboundPalletItemId) - && id(value?.inventoryPositionId) && Number(value?.maxReturnPackages) > 0) : null - context.assert(MODULE, '退回候选包含可追溯的出库托盘库存来源', returnOptions.response, - Boolean(returnSource), `source=${Boolean(returnSource)}; ${trace(returnOptions.response)}`, OPTIONS) - if (!returnSource) return - const returnBody = { - inboundOrderId: id(returnInbound.inboundOrderId), reason: `${context.marker}-delivery-return`, - returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RETURN`.slice(0, 128), - items: [{ - outboundPalletItemId: id(returnSource.outboundPalletItemId), - inventoryPositionId: id(returnSource.inventoryPositionId), - packageCount: 1, - }], - idempotencyKey: key(context, 'DELIVERY-RETURN'), - } - const returned = await platform(context, 'POST', '标记派送退回海外仓并创建退货异常', - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/mark-return`, { customerId }), returnBody) + const returnRequest = await createDeliveryReturn(context, { customerId, warehouseId, deliveryNo, outboundNo }) + if (!returnRequest) return + const { returned, returnBody } = returnRequest const returnExceptionNo = id(returned.data?.exceptionNo) context.assert(MODULE, '派送退回生成独立PENDING异常单', returned.response, nonBlank(returnExceptionNo) && returned.data?.status === 'PENDING' From db4354bda8fd48756192f02095d1f390140f1148 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 18:45:47 +0800 Subject: [PATCH 010/113] fix(lastleg): align return receipt fixtures and schema totals --- deploy/sql/README.md | 4 ++-- deploy/sql/海外区域统一增量-MySQL8.sql | 2 +- .../delivery/service/ReturnOutboundPdaPersistenceTest.java | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 10ac3e7aa..09da10211 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -45,7 +45,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 214 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 215 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -68,7 +68,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/214`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/215`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index bd9b85dea..2cb992618 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4657,4 +4657,4 @@ CREATE TABLE ll_compact_number_clock ( PRIMARY KEY (number_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 214 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 215 AS initialized_tables; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index 793fc2459..1510f5b3f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -46,7 +46,7 @@ class ReturnOutboundPdaPersistenceTest { // Persist production entities through production MyBatis mappers, with real transactions. for (Class entity : List.of(OutboundOrder.class, OutboundPalletItem.class, OutboundPdaTask.class, OutboundPdaPick.class, DeliveryOrder.class, ReturnInventoryCarton.class, ReturnInventoryCartonItem.class, ReturnInventoryBalance.class, - ReturnInventoryOutboundMovement.class, ReturnSkuConversion.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class)) { + ReturnInventoryOutboundMovement.class, ReturnSkuConversion.class, ReturnOrder.class, ReturnOrderItem.class, ReturnReceipt.class, ReturnReceiptItem.class, com.baiyue.lastleg.exceptionorder.entity.ReturnCartonStockReceipt.class)) { String columns = Arrays.stream(entity.getDeclaredFields()).filter(field -> !Modifier.isStatic(field.getModifiers())) .map(field -> field.getName().replaceAll("([A-Z])", "_$1").toLowerCase() + " " + (field.getType() == String.class ? "VARCHAR(2000)" : field.getType() == LocalDateTime.class ? "TIMESTAMP" From 4bef8577fac11bc5be80e13e38687f613e88d98d Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 19:21:12 +0800 Subject: [PATCH 011/113] test(lastleg): verify dispatch repair with current-run HTTP evidence --- scripts/admin-test-env-modules/README.md | 4 + ...spatch-repair-acceptance-contract.test.mjs | 304 ++++++++++++ .../dispatch-repair-acceptance.mjs | 434 ++++++++++++++++++ 3 files changed, 742 insertions(+) create mode 100644 scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index a5ceb318e..2fdcca5df 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -8,6 +8,10 @@ ## 当前新增脚本的执行边界 +`dispatch-repair-acceptance.mjs` 是本批排车修复的内部增量验收助手,不是独立 CLI。`verifyDispatchRepair` 默认只返回计划,显式执行要求 TEST、Lastleg/Admin 健康及 Schema 就绪、请求前后持久化检查点。`context.producerEvidence` 指定本轮生产回执 JSON 的路径及 SHA-256;文件须含 `environment=TEST`、`marker`、已部署 Lastleg 的 `sourceCommit` 和 `requests`。请求条目保留实际 `method/path/body/actor`、`status=RESPONDED`、`http/code/requestId/traceId/result`,不得保存登录凭据或 Token。助手从正式创建响应及父来源回执提取业务号、客户和仓库,形成资源白名单;所有 fixture 关联身份在首个请求前核对,不能仅声明“本轮新建”。缺样本标记 NOT_RUN,不借旧业务样本。 + +该助手核对取消出库/取消排车/释放重排、实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。只对已授权的新排车调用 DELETE 取消状态,不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。 + - 人工状态矩阵的 `FEE_CONFIRMING` 使用独立新订单,在测量提交成功后暂停审核,经正式 `fees/calculate` 生成费用;回查同一费用版本、入库客户身份、测量仍待审核及入仓码精确筛选结果。该状态是必验项,不再列入不可造数缺口;不发送费用、不推进该样本、不借历史数据。入口仍为 `--modules manual-state-matrix`,对应 `order-inbound-status-fixtures-contract.test.mjs` 和 `manual-state-matrix-contract.test.mjs`。 - `WAIT_PRICING` 另建保留原始 `APPROVED` 的订单,先形成未发布的 `FEE_CONFIRMING`,再通过客户实时渠道目录与报价选择同仓、同目的仓、同税制的不同渠道,以正式申报更新触发费用失效;回查 `WAIT_PRICING/UNPRICED`、新渠道及精确筛选。缺少可计价替换渠道、客户漂移或状态未收敛均失败;不写业务SQL,退出专用目录查询会话。该状态同样为必验项,原先借历史 `WAIT_SPLIT` 样本的兜底已移除。 diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs new file mode 100644 index 000000000..1c67ca6b9 --- /dev/null +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs @@ -0,0 +1,304 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { after } from 'node:test' +import { writeFileSync, unlinkSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { randomUUID, createHash } from 'node:crypto' +import { verifyDispatchRepair } from './dispatch-repair-acceptance.mjs' + +const commit = 'a'.repeat(40) +const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'synthetic-request', traceId: 'synthetic-trace' }) +const reject = (code = 'A0409', http = 409) => ({ http, body: { code }, requestId: 'synthetic-request', traceId: 'synthetic-trace' }) +const fresh = more => ({ customerId: 'C', warehouseId: 'W', ...more }) +const evidenceFiles = [] +after(() => { for (const file of evidenceFiles) { assert.ok(file.startsWith(join(tmpdir(), 'dispatch-repair-proof-'))); unlinkSync(file) } }) +function producerReport() { + const requests = [] + const event = (method, path, body, result, actor = 'PLATFORM') => requests.push({ method, path, body, result, actor, + status: 'RESPONDED', http: 200, code: '200', requestId: `actual-producer-${requests.length}`, traceId: `actual-trace-${requests.length}` }) + function stock(no, inboundCode, pallets, relabelNo) { + event('POST', '/api/platform/v1/last-leg/warehouse-transfers?customerId=C', { operatingWarehouseId: 'W', remark: 'CURRENT new stock' }, { transferNo: no, operatingWarehouseId: 'W' }) + event('POST', `/api/pda/v1/last-leg/warehouse-transfers/${no}/palletize`, {}, { transferNo: no, operatingWarehouseId: 'W', inboundCode, relabelNo, + pallets: pallets.map(([warehousePalletId, cartonCount]) => ({ warehousePalletId, palletNo: `PAL-${warehousePalletId}`, cartonCount })) }) + } + function candidate(planPalletId, warehousePalletId, inboundCode, extra = {}) { + const row = { planPalletId, warehousePalletId, inboundCode, orderRefs: inboundCode, customerId: 'C', overseasWarehouseId: 'W', sourceStage: 'RECEIVED', ...extra } + event('GET', '/api/platform/v1/last-leg/dispatches/candidates', undefined, [row]); return row + } + function dispatch(no, rows, relabelNo, packages = 1) { + event('POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', { planPalletIds: rows.map(x => relabelNo ? x.warehousePalletId : x.planPalletId), relabelNo }, + { dispatchNo: no, overseasWarehouseId: 'W', packageCount: packages, items: rows }) + } + function trip(no) { event('POST', '/api/platform/v1/last-leg/fleet/trips', { remark: 'CURRENT new trip' }, { trip: { tripNo: no, warehouseId: 'W' } }) } + function external(no, tripNo, transferNo, inboundCode, palletId, destinationPallet) { + trip(tripNo) + const booking = { bookingNo: no, tripNo, customerId: 'C', cargo: { priceBasis: 'PALLET', palletSpaces: 1, unitPrice: 2, currency: 'USD' }, agreedAmount: 2 } + event('POST', `/api/platform/v1/last-leg/fleet/trips/${tripNo}/bookings`, {}, { booking }) + event('POST', `/api/platform/v1/last-leg/fleet/trips/${tripNo}/bookings/${no}/incoming`, {}, { booking: { ...booking, incomingNo: transferNo } }) + event('POST', `/api/pda/v1/last-leg/warehouse-transfers/${transferNo}/palletize`, {}, { transferNo, inboundCode, operatingWarehouseId: 'W', pallets: [{ warehousePalletId: palletId, palletNo: `PAL-${palletId}`, cartonCount: 2 }] }) + event('POST', `/api/platform/v1/last-leg/fleet/trips/${tripNo}/bookings/${no}/receipt-confirmation`, {}, { booking: { ...booking, status: 'RECEIVED', incomingNo: transferNo, + receipt: { source: { receiptHash: 'OLD', inboundCode, destinationKey: 'DST', pallets: [{ warehousePalletId: destinationPallet, packages: 2 }] } } } }) + } + stock('TC', 'IN-C', [['101', 1]]) + dispatch('DP', [candidate('11', '101', 'IN-C')]) + stock('TR', 'IN-R', [['201', 2], ['202', 2]], 'RL') + for (const pallet of ['201', '202']) candidate(null, pallet, 'IN-R', { sourceStage: 'RELABEL', orderRefs: 'RL', orderNo: 'CUSTOMER-ORDER' }) + for (const i of [1, 2]) { + stock(`TN${i}`, `IN-N${i}`, [[`30${i}`, 3]]) + external(`BK${i}`, `T${i}`, `TE${i}`, `IN-E${i}`, `40${i}`, `30${i}`) + const row = candidate(`3${i}`, `30${i}`, i === 1 ? `IN-N${i}` : `IN-E${i}`) + dispatch(i === 1 ? 'DP-N' : 'DP-E', [row], undefined, 5) + } + external('BK', 'TR', 'TF', 'IN-F', '501', '31') + function pallet(palletId, palletCode, locationId) { + event('POST', '/api/platform/v1/overseas-warehouse-resources/W/pallets', { palletCode }, palletId) + event('GET', `/api/platform/v1/overseas-warehouse-resources/W/pallets/${palletId}`, undefined, { id: palletId, warehouseId: 'W', palletCode, locationId }) + } + pallet('31', 'CURRENT-NEW-GEOMETRY', '113') + pallet('601', 'CURRENT-SOURCE', '114'); pallet('111', 'CURRENT-TARGET', '112') + event('GET', '/api/platform/v1/overseas-warehouse-resources/W/locations', undefined, { records: [{ id: '112', locationCode: 'LOCATION' }] }) + event('GET', '/api/merchant/v1/iam/tenant/profile', undefined, { id: 'C' }, 'MERCHANT') + event('POST', '/api/merchant/v1/last-leg/return-orders', { remark: 'CURRENT new return' }, { order: { returnNo: 'RT', overseasWarehouseId: 'W' } }, 'MERCHANT') + event('GET', '/api/platform/v1/last-leg/return-inventory/cartons/BOX', undefined, { cartonMark: 'BOX', sourceReturnNo: 'RT', customerId: 'C', overseasWarehouseId: 'W', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] }) + event('POST', '/api/merchant/v1/last-leg/relabel-applications', { operationWarehouseId: 'W' }, { application: { relabelNo: 'RL-N', customerId: 'C', overseasWarehouseId: 'W' } }, 'MERCHANT') + dispatch('NDP', [candidate(null, '601', null, { sourceStage: 'RELABEL', orderRefs: 'RL-N', returnCartonSource: true })], 'RL-N', 1) + event('POST', '/api/platform/v1/last-leg/dispatches/NDP/transfer-delivery', {}, { outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }] }) + event('GET', '/api/platform/v1/last-leg/delivery-orders/D/return-inbound-options?customerId=C', undefined, [{ customerId: 'C', lastMileOrderId: '91', deliveryNo: 'D', outboundNo: 'O', returnSources: [{ returnCartonId: '81', cartonMark: 'BOX', inventoryPositionId: null }] }]) + return { environment: 'TEST', marker: 'CURRENT', sourceCommit: commit, requests } +} +function writeProducer(ctx, report = producerReport()) { + const bytes = Buffer.from(JSON.stringify(report)), path = join(tmpdir(), `dispatch-repair-proof-${randomUUID()}.json`) + writeFileSync(path, bytes); evidenceFiles.push(path); ctx.producerEvidence = { path, sha256: createHash('sha256').update(bytes).digest('hex') } +} +function context(handler = () => assert.fail('No request expected')) { + const ctx = { marker: 'CURRENT', environment: 'TEST', release: { lastlegHealthy: true, adminHealthy: true, schemaReady: true, lastlegCommit: commit }, + createdResources: {}, calls: [], checkpoints: [], + async checkpoint() { this.checkpoints.push(structuredClone(this.createdResources.dispatchRepairAcceptance)) }, + async request(method, path, body) { this.calls.push({ method, path, body: structuredClone(body) }); return handler(method, path, body) } } + writeProducer(ctx); return ctx +} +const execute = (ctx, fixtures) => verifyDispatchRepair(ctx, fixtures, { execute: true }) + +test('import/plan is zero-network and missing current fixtures never become a pass', async () => { + const ctx = context(); assert.equal((await verifyDispatchRepair(ctx, {})).status, 'PLAN_ONLY'); assert.equal(ctx.calls.length, 0) + const result = await execute(ctx, {}) + assert.equal(result.status, 'PARTIAL_NOT_ALL_VERIFIED') + assert.equal(Object.values(result.cases).every(row => row.status === 'NOT_RUN'), true) + assert.equal(result.sourceEventsVerified, false) +}) +for (const field of ['lastlegHealthy', 'adminHealthy', 'schemaReady']) test(`unready ${field} blocks before any HTTP`, async () => { + const ctx = context(); ctx.release[field] = false + await assert.rejects(execute(ctx, {}), /healthy TEST releases/); assert.equal(ctx.calls.length, 0) +}) +test('production target and an old run fixture cannot borrow TEST authorization', async () => { + const ctx = context(); ctx.environment = 'PRODUCTION'; await assert.rejects(execute(ctx, {}), /TEST releases/) + const owned = context(); await assert.rejects(execute(owned, { departedGate: fresh({ outboundNo: 'OLD-O', deliveryNo: 'OLD-D' }) }), /Unowned outbound/) + assert.equal(owned.calls.length, 0) +}) +test('self-declared current flags cannot authorize an old dispatch absent from producer receipts', async () => { + const ctx = context() + await assert.rejects(execute(ctx, { cancellation: fresh({ dispatchNo: 'OLD-DP', allowDispatchCancellation: true, + marker: 'CURRENT', createdInThisRun: true, producerRequestId: 'claimed-current-request', producerTraceId: 'claimed-current-trace', + transferBody: {}, rearrangeBody: { planPalletIds: ['11'] } }) }), /Unowned dispatch/) + assert.equal(ctx.calls.length, 0) +}) +test('a later foreign shipment blocks all cases before the first HTTP request', async () => { + const ctx = context() + await assert.rejects(execute(ctx, { cancellation: fresh({ dispatchNo: 'DP', allowDispatchCancellation: true, + transferBody: {}, rearrangeBody: { planPalletIds: ['11'] } }), departedGate: fresh({ outboundNo: 'OLD-O', deliveryNo: 'D' }) }), /Unowned outbound/) + assert.equal(ctx.calls.length, 0) +}) +test('changed producer hash or response warehouse binding is rejected with zero HTTP', async () => { + const ctx = context(); ctx.producerEvidence.sha256 = '0'.repeat(64) + await assert.rejects(execute(ctx, {}), /hash mismatch/); assert.equal(ctx.calls.length, 0) + const scoped = context(), report = producerReport() + report.requests.find(row => row.method === 'POST' && row.path.endsWith('/pre-arrange')).result.overseasWarehouseId = 'OTHER' + writeProducer(scoped, report) + await assert.rejects(execute(scoped, {}), /Dispatch birth response/); assert.equal(scoped.calls.length, 0) +}) +test('failed pre-send checkpoint leaves the command unsent', async () => { + const ctx = context(); ctx.checkpoint = async () => { throw Error('disk full') } + await assert.rejects(execute(ctx, { departedGate: fresh({ outboundNo: 'O', deliveryNo: 'D' }) }), /disk full/) + assert.equal(ctx.calls.length, 0) +}) + +test('new unpicked dispatch cancels outbound, retains cancelled history, re-arranges released pallets and replays old transfer', async () => { + let cancelled = false, dispatchCancelled = false, transfers = 0 + const ctx = context((method, path, body) => { + if (path.endsWith('/transfer-delivery')) return ok({ outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }], replayed: transfers++ > 0 }) + if (path.includes('/delivery-orders/') && path.includes('/status')) { assert.ok(['DELIVERING', 'REJECTED', 'CANCELLED'].includes(body.status)); assert.equal(body.expectedVersion, 0); return reject() } + if (path.includes('/delivery-orders/')) return ok({ order: { deliveryNo: 'D', outboundNo: 'O', status: cancelled ? 'CANCELLED' : 'WAIT_DELIVERY', version: 0, packageCount: 1 } }) + if (path.endsWith('/outbound-orders/O/cancel')) { cancelled = true; return ok(null) } + if (path.endsWith('/outbound-orders/O')) return ok({ outbound: { status: cancelled ? 'CANCELLED' : 'WAIT_OUTBOUND' } }) + if (method === 'DELETE') { assert.equal(path, '/api/platform/v1/last-leg/dispatches/DP'); assert.equal(cancelled, true); dispatchCancelled = true; return ok(null) } + if (path.endsWith('/dispatches/DP')) return ok({ status: dispatchCancelled ? 'CANCELLED' : 'PENDING_DEPARTURE', createdAt: '2026-10-18T16:00:00', actions: { canTransfer: true } }) + if (path.includes('/dispatches/candidates?')) return ok([{ planPalletId: '11' }]) + if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, ['11']); assert.equal(dispatchCancelled, true); return ok({ dispatchNo: 'DP-NEW', status: 'PENDING_DEPARTURE', items: [{ planPalletId: '11' }] }) } + assert.fail(path) + }) + const result = await execute(ctx, { cancellation: fresh({ allowDispatchCancellation: true, dispatchNo: 'DP', transferBody: { fees: [] }, rearrangeBody: { planPalletIds: ['11'] }, candidateQuery: { sourceStage: 'RECEIVED' } }) }) + assert.equal(result.cases.cancellation.status, 'PASS') + assert.equal(ctx.calls.filter(row => row.method === 'DELETE').length, 1) + assert.equal(ctx.calls.some(row => row.method === 'DELETE' && row.path.includes('/items/')), false) +}) + +test('domain rejection cannot be faked by a missing permission; actual shipment cancellation leaves facts unchanged', async () => { + for (const forbidden of [false, true]) { + const order = { status: 'WAIT_DELIVERY', version: 4, packageCount: 1, outboundNo: 'O' } + const ctx = context((method, path) => { + if (method === 'GET') return ok(path.includes('/outbound-orders/') ? { outbound: { status: 'OUTBOUNDED' } } : { order }) + return forbidden ? reject('A0403', 403) : reject() + }) + const run = execute(ctx, { departedGate: fresh({ outboundNo: 'O', deliveryNo: 'D' }) }) + if (forbidden) await assert.rejects(run, /Unexpected result/) + else assert.equal((await run).cases.departedGate.status, 'PASS') + } +}) + +for (const broken of [false, true]) test(`two-pallet relabel rejects partial selection with unchanged candidates then accepts all; broken=${broken}`, async () => { + const ids = ['201', '202'] + const ctx = context((method, path, body) => { + if (path.includes('/relabel-applications/')) return ok({ application: { relabelNo: 'RL', status: 'READY_FOR_OUTBOUND', fulfillmentStatus: 'WAIT_PRE_DISPATCH' } }) + if (path.includes('/candidates?')) return ok(ids.map(warehousePalletId => ({ orderRefs: 'RL', orderNo: 'CUSTOMER-ORDER', warehousePalletId, planPalletId: null }))) + assert.equal(method, 'POST'); assert.equal(body.relabelNo, 'RL') + if (body.planPalletIds.length === 1 && !broken) return reject() + return ok({ status: 'PENDING_DEPARTURE', dispatchNo: 'DP', items: body.planPalletIds.map(warehousePalletId => ({ warehousePalletId, planPalletId: null })) }) + }) + const run = execute(ctx, { wholeRelabel: fresh({ relabelNo: 'RL' }) }) + if (broken) await assert.rejects(run, /Unexpected result/) + else assert.equal((await run).cases.wholeRelabel.status, 'PASS') +}) + +function mixedFixture(fault = '') { + const samples = [1, 2].map(i => ({ displayKind: i === 1 ? 'ORDINARY' : 'EXTERNAL', dispatchNo: i === 1 ? 'DP-N' : 'DP-E', + ordinaryInboundCode: `IN-N${i}`, externalInboundCode: `IN-E${i}`, bookingNo: `BK${i}`, palletId: `30${i}` })) + const ctx = context((method, path) => { + if (path.includes('/inventory/balances') && !path.includes('/positions')) return ok({ total: 4, + records: [1, 2].flatMap(i => ['N', 'E'].map(kind => ({ id: `${kind}${i}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${kind}${i}` }))) }) + if (path.includes('/positions')) { + const balance = path.match(/balances\/([NE][12])\//)[1], kind = balance[0], i = balance[1] + return ok([{ id: `POS-${balance}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${balance}`, + palletId: `30${i}`, availablePackages: kind === 'N' ? fault === 'wrong-component' ? 4 : 3 : 2, pickedPackages: 0 }]) + } + const sample = samples.find(row => path.includes(row.dispatchNo)), i = sample.dispatchNo === 'DP-N' ? 1 : 2 + if (path.endsWith('/external-freight')) return ok({ requiresDeliveryFee: true, snapshotHash: `hash-${i}`, quotes: [{ bookingNo: `BK${i}`, inboundCode: `IN-E${i}`, + quantity: fault === 'wrong-quantity' ? 2 : 1, amount: fault === 'wrong-amount' ? 3 : 2, unitPrice: 2, currency: 'USD', destinationKey: 'DST' }] }) + return ok({ overseasWarehouseId: 'W', items: [{ warehousePalletId: `30${i}`, palletNo: `PAL-30${i}`, + inboundCode: i === 1 && fault !== 'wrong-display' ? 'IN-N1' : `IN-E${i}` }] }) + }) + if (fault === 'duplicate-sample') samples[1] = { ...samples[0], displayKind: 'EXTERNAL' } + return { ctx, fixture: fresh({ samples }) } +} +test('independent mixed display permutations prove per-source packages and commercial quantity/amount', async () => { + const f = mixedFixture(), result = await execute(f.ctx, { mixedSources: f.fixture }) + assert.equal(result.cases.mixedSources.status, 'PASS') + assert.deepEqual(result.cases.mixedSources.evidence.map(x => x.expectedSources.map(y => y.packages)), [[3, 2], [3, 2]]) +}) +for (const fault of ['duplicate-sample', 'wrong-display', 'wrong-component', 'wrong-quantity', 'wrong-amount']) test(`mixed source ${fault} cannot pass`, async () => { + const f = mixedFixture(fault) + await assert.rejects(execute(f.ctx, { mixedSources: f.fixture }), /independent|Displayed source|Physical pallet|external quote/) + assert.ok(f.ctx.calls.every(row => row.method === 'GET')) + if (fault === 'duplicate-sample') assert.equal(f.ctx.calls.length, 0) +}) + +test('changed receiving geometry requires fresh measurements and new key; exact confirmation replay remains identical', async () => { + const bodies = [] + const receipt = { source: { receiptHash: 'NEW', pallets: [{ warehousePalletId: '31', packages: 2 }] }, pallets: [{ warehousePalletId: '31', palletSpaces: 1, weightKg: 7, volumeCbm: 0.5 }] } + const ctx = context((method, path, body) => { + if (method === 'GET' && path.endsWith('/bookings')) return ok({ records: [{ bookingNo: 'BK', customerId: 'C', status: 'RECEIVED', dispatchNo: null, receipt: { source: { receiptHash: 'OLD' } } }] }) + if (method === 'GET') return ok({ tripRecordVersion: 5, recordVersion: 6, incoming: { receiptHash: 'NEW', pallets: [{ warehousePalletId: '31', weightKg: null, volumeCbm: null }] } }) + bodies.push(structuredClone(body)); assert.equal(body.receiptHash, 'NEW'); assert.equal(body.tripRecordVersion, 5); assert.equal(body.recordVersion, 6) + if (bodies.length === 1) { assert.equal(body.pallets[0].weightKg, undefined); return reject() } + assert.deepEqual(body.pallets, receipt.pallets) + return ok({ booking: { receipt }, replayed: bodies.length === 3 }) + }) + const result = await execute(ctx, { receiptRefresh: fresh({ tripNo: 'TR', bookingNo: 'BK', oldReceiptHash: 'OLD', unitPrice: 2, + measurements: [{ warehousePalletId: '31', weightKg: 7, volumeCbm: 0.5 }] }) }) + assert.equal(result.cases.receiptRefresh.status, 'PASS') + assert.notEqual(bodies[0].idempotencyKey, bodies[1].idempotencyKey); assert.deepEqual(bodies[1], bodies[2]) +}) + +function nativeFixture(fault = '') { + let marked = false, completeCount = 0 + const quantity = sku => sku === 'SKU-A' ? 3 : 2 + const page = (u, rows) => ok({ total: rows.length, records: rows.slice(Number(u.searchParams.get('page') || 1) - 1, Number(u.searchParams.get('page') || 1)) }) + const ctx = context((method, path, body) => { + const u = new URL(path, 'https://test.invalid'), sku = u.searchParams.get('skuCode'), Q = quantity(sku) + if (u.pathname === '/api/platform/v1/last-leg/return-inventory') { + let total = completeCount ? Q : 0 + if (fault === 'missing-sku' && sku === 'SKU-B') total = 0 + if (fault === 'replay-adds-stock' && completeCount >= 2) total = Q * 2 + return page(u, [{ customerId: 'OTHER', overseasWarehouseId: 'W', skuCode: sku, totalQuantity: 999, availableQuantity: 999, cartonQuantity: 9 }, + { customerId: 'C', overseasWarehouseId: 'W', skuCode: `${sku}-FUZZY`, totalQuantity: 999, availableQuantity: 999, cartonQuantity: 9 }, + { customerId: 'C', overseasWarehouseId: 'W', skuCode: sku, totalQuantity: total, + availableQuantity: fault === 'availability-not-restored' ? 0 : total, + cartonQuantity: completeCount && fault !== 'cartons-not-restored' ? 1 : 0, + updatedAt: fault === 'replay-updates-time' && completeCount >= 2 ? '2026-10-18T16:02:00' : completeCount ? '2026-10-18T16:01:00' : '2026-10-18T16:00:00' }]) + } + if (u.pathname.endsWith('/return-inventory/movements')) { + const base = { customerId: 'C', overseasWarehouseId: 'W', skuCode: sku, occurredAt: '2026-10-18T16:00:00' } + const incoming = { ...base, movementType: 'EXCEPTION_RETURN_IN', referenceNo: 'NATIVE-RECEIPT', totalDelta: Q, availableDelta: Q, occurredAt: '2026-10-18T16:01:00' } + const rows = [{ ...base, skuCode: `${sku}-FUZZY`, movementType: 'OUTBOUND', referenceNo: 'NDP', totalDelta: -999, availableDelta: -999 }, + { ...base, movementType: 'OUTBOUND', referenceNo: fault === 'wrong-outbound-reference' ? 'O' : 'NDP', totalDelta: -Q, availableDelta: -Q }] + if (completeCount && !(fault === 'missing-movement' && sku === 'SKU-B')) rows.push(incoming) + if (fault === 'duplicate-incoming' && completeCount) rows.push(incoming) + if (fault === 'replay-adds-movement' && completeCount >= 2) rows.push({ ...incoming, occurredAt: '2026-10-18T16:02:00' }) + return page(u, rows) + } + if (path.includes('/return-inventory/cartons/')) return ok({ cartonMark: 'BOX', status: completeCount ? 'AVAILABLE' : 'OUTBOUNDED', + customerId: 'C', overseasWarehouseId: 'W', palletCode: fault === 'wrong-target' ? 'WRONG-TARGET' : 'CURRENT-TARGET', locationCode: 'LOCATION', putawayAt: completeCount ? '2026-10-18T16:01:00' : null, + items: [{ skuCode: 'SKU-A', quantity: 3, availableQuantity: completeCount ? 3 : 0 }, { skuCode: 'SKU-B', quantity: 2, availableQuantity: completeCount ? 2 : 0 }] }) + if (path.includes('/return-inbound-options')) return ok([{ lastMileOrderId: fault === 'wrong-master' ? '92' : '91', customerId: 'C', inboundOrderId: null, inboundCode: null, deliveryNo: 'D', outboundNo: 'O', + returnSources: [{ returnCartonId: '81', inventoryPositionId: null, outboundPalletItemId: '71', cartonMark: 'BOX', maxReturnPackages: 1 }] }]) + if (path.includes('/mark-return')) { + assert.equal(body.lastMileOrderId, '91'); assert.equal(Object.hasOwn(body, 'inboundOrderId'), false) + assert.deepEqual(body.items, [{ outboundPalletItemId: '71', returnCartonId: '81', packageCount: 1 }]) + const replayed = marked; marked = true; return ok({ exceptionNo: 'EX', version: 0, replayed }) + } + if (path.includes('/start-processing')) return ok({ version: 1 }) + if (path.includes('/complete')) { assert.equal(body.receipts[0].inventoryPositionId, null); assert.equal(body.receipts[0].returnCartonId, '81'); return ok({ status: 'COMPLETED', restoredPackages: 1, replayed: completeCount++ > 0 }) } + if (method === 'GET' && path.includes('/exception-orders/')) return ok({ returnReceipts: [{ receiptNo: 'NATIVE-RECEIPT', receivedPackages: 1, receivedAt: '2026-10-18T16:01:00' }] }) + if (method === 'GET' && path.includes('/delivery-orders/')) return ok({ order: { status: 'RETURNED', outboundNo: 'O', packageCount: 1, version: 2 } }) + assert.fail(path) + }) + ctx.requestMultipart = async (method, path, file) => { assert.match(new TextDecoder().decode(file.content), /SYNTHETIC TEST/); return ok({ fileId: '101' }) } + return { ctx, fixture: fresh({ cartonMark: 'BOX', returnCartonId: '81', lastMileOrderId: '91', deliveryNo: 'D', outboundNo: 'O', + target: { targetPalletId: '111', targetPalletNo: 'CURRENT-TARGET', targetLocationId: '112', targetLocationCode: 'LOCATION' } }) } +} +test('native recovery reads exact paged SKU stock between first completion and replay', async () => { + const f = nativeFixture(), result = await execute(f.ctx, { nativeCartonReturn: f.fixture }) + const evidence = result.cases.nativeCartonReturn.evidence + assert.equal(result.cases.nativeCartonReturn.status, 'PASS') + assert.deepEqual(evidence.S.stock.map(x => x.total), [0, 0]); assert.deepEqual(evidence.R.stock.map(x => x.total), [3, 2]); assert.deepEqual(evidence.Rprime, evidence.R) + assert.deepEqual(evidence.R.stock.map(x => x.availableView), [3, 2]); assert.deepEqual(evidence.R.stock.map(x => x.cartons), [1, 1]) + const completes = f.ctx.calls.map((row, i) => row.path.includes('/complete') ? i : -1).filter(i => i >= 0) + assert.equal(completes.length, 2) + assert.ok(f.ctx.calls.slice(completes[0] + 1, completes[1]).some(row => row.path.includes('/return-inventory?'))) + assert.ok(f.ctx.calls.some(row => row.path.includes('page=3'))) +}) +for (const fault of ['wrong-master', 'missing-sku', 'availability-not-restored', 'cartons-not-restored', 'missing-movement', 'duplicate-incoming', 'replay-adds-stock', 'replay-adds-movement', 'replay-updates-time', 'wrong-target', 'wrong-outbound-reference']) test(`native recovery ${fault} cannot pass`, async () => { + const f = nativeFixture(fault) + await assert.rejects(execute(f.ctx, { nativeCartonReturn: f.fixture }), /Actual native|Every original|Native replay|exact validated|Original single-carton/) + if (fault === 'wrong-master') assert.ok(f.ctx.calls.every(row => row.method === 'GET')) +}) +test('an old target pallet cannot be authorized by a valid native carton fixture', async () => { + const f = nativeFixture(); f.fixture.target.targetPalletId = 'OLD-PALLET' + await assert.rejects(execute(f.ctx, { nativeCartonReturn: f.fixture }), /Unowned pallet/) + assert.equal(f.ctx.calls.length, 0) +}) + +test('read-only list permission allows detail but rejects cancellation with unchanged outbound', async () => { + const outbound = { status: 'WAIT_OUTBOUND', version: 0, packageCount: 1 } + const ctx = context(() => ok({ outbound })) + ctx.requestReadonly = async (method) => method === 'GET' ? ok({ outbound }) : reject('A0403', 403) + assert.equal((await execute(ctx, { permissions: fresh({ outboundNo: 'O' }) })).cases.permissions.status, 'PASS') +}) + +test('unknown command response is checkpointed and never automatically retried', async () => { + const ctx = context((method) => method === 'GET' ? ok({ outbound: { status: 'OUTBOUNDED' } }) : { http: 0, body: null }) + // The uncorrelated read itself stops before any command or automatic recovery. + ctx.request = async function (method, path) { this.calls.push({ method, path }); return { http: 0, body: null } } + await assert.rejects(execute(ctx, { departedGate: fresh({ outboundNo: 'O', deliveryNo: 'D' }) }), /Unknown transport/) + assert.equal(ctx.calls.length, 1); assert.equal(ctx.checkpoints.at(-1).requests.at(-1).status, 'UNKNOWN_RESULT') +}) diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs new file mode 100644 index 000000000..ba6952558 --- /dev/null +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs @@ -0,0 +1,434 @@ +import { randomUUID, createHash } from 'node:crypto' +import { readFile } from 'node:fs/promises' + +const BASE = '/api/platform/v1/last-leg' +const id = value => value == null ? '' : String(value) +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const must = (value, reason) => { if (!value) throw Error(reason) } +const success = r => r?.http === 200 && String(r?.body?.code) === '200' +const rejected = r => [400, 409, 422].includes(r?.http) + || r?.http === 200 && ['A0400', 'A0409', 'A0422'].includes(String(r?.body?.code)) +const key = () => `dispatch-repair-${randomUUID()}` +const scope = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}` +const cases = ['cancellation', 'departedGate', 'wholeRelabel', 'mixedSources', 'receiptRefresh', 'nativeCartonReturn', 'permissions'] + +/** The allowlist is derived from successful producer API receipts, never from fixture ownership flags. */ +async function loadProducerResources(context) { + must(context.producerEvidence?.path && /^[a-f0-9]{64}$/.test(context.producerEvidence?.sha256 || ''), 'Hashed current-run producer evidence required') + const bytes = await readFile(context.producerEvidence.path) + must(createHash('sha256').update(bytes).digest('hex') === context.producerEvidence.sha256, 'Producer evidence hash mismatch') + const report = JSON.parse(bytes.toString('utf8')) + must(report.environment === 'TEST' && report.marker === context.marker && report.sourceCommit === context.release.lastlegCommit + && Array.isArray(report.requests), 'Producer report run/environment/release mismatch') + const own = Object.fromEntries(['transfer', 'inbound', 'candidate', 'dispatch', 'outbound', 'delivery', 'relabel', 'trip', 'booking', 'return', 'carton', 'pallet'].map(k => [k, new Map()])) + const actors = new Map() + function add(kind, key, customerId, warehouseId, event, extra = {}) { + must(key && warehouseId, `Producer ${kind} response lacks identity/warehouse`) + const previous = own[kind].get(id(key)) + if (previous) must(id(previous.customerId) === id(customerId) && id(previous.warehouseId) === id(warehouseId), 'Producer resource scope drifted') + own[kind].set(id(key), { ...previous, ...extra, key: id(key), customerId: id(customerId), warehouseId: id(warehouseId), + requestId: event.requestId, traceId: event.traceId }) + } + for (const e of report.requests) { + if (e.status !== 'RESPONDED' || e.http !== 200 || String(e.code) !== '200' || !e.requestId || !e.traceId || !e.result) continue + const u = new URL(e.path, 'https://producer.invalid'), path = u.pathname, data = e.result + if (e.method === 'GET' && path === '/api/merchant/v1/iam/tenant/profile' && data.id) actors.set(e.actor, id(data.id)) + if (e.method === 'POST' && path === `${BASE}/warehouse-transfers` && String(e.body?.remark).includes(context.marker)) { + must(data.operatingWarehouseId === e.body.operatingWarehouseId && u.searchParams.get('customerId'), 'Transfer birth scope mismatch') + add('transfer', data.transferNo, u.searchParams.get('customerId'), data.operatingWarehouseId, e) + } + if (e.method === 'POST' && path === `${BASE}/fleet/trips` && String(e.body?.remark).includes(context.marker)) + add('trip', data.trip?.tripNo, '', data.trip?.warehouseId, e) + let match = path.match(/\/fleet\/trips\/([^/]+)\/bookings(?:\/([^/]+)(?:\/incoming|\/confirm|\/receipt-confirmation|\/dispatch)?)?$/) + if (e.method === 'POST' && match && own.trip.has(decodeURIComponent(match[1])) && data.booking) { + const trip = own.trip.get(decodeURIComponent(match[1])), b = data.booking + if (!match[2] || own.booking.has(b.bookingNo)) { + add('booking', b.bookingNo, b.customerId, trip.warehouseId, e, { tripNo: b.tripNo, booking: data.booking }) + if (b.incomingNo) add('transfer', b.incomingNo, b.customerId, trip.warehouseId, e, { externalBookingNo: b.bookingNo }) + } + } + match = path.match(/\/warehouse-transfers\/([^/]+)(?:\/palletize|\/confirm-measurement|\/review)?$/) + const transfer = match && own.transfer.get(decodeURIComponent(match[1])) + if (transfer && data.transferNo === transfer.key && id(data.operatingWarehouseId) === transfer.warehouseId && data.inboundCode) { + const pallets = data.pallets || [], received = pallets.reduce((n, row) => n + Number(row.cartonCount || 0), 0) + add('inbound', data.inboundCode, transfer.customerId, transfer.warehouseId, e, { receivedPackages: received, + kind: transfer.externalBookingNo ? 'EXTERNAL' : 'ORDINARY', externalBookingNo: transfer.externalBookingNo }) + for (const row of pallets) if (row.warehousePalletId) add('pallet', row.warehousePalletId, transfer.customerId, transfer.warehouseId, e, { palletCode: row.palletNo }) + if (data.relabelNo) add('relabel', data.relabelNo, transfer.customerId, transfer.warehouseId, e) + } + if (e.method === 'POST' && path === '/api/merchant/v1/last-leg/relabel-applications' && actors.has(e.actor)) { + const a = data.application + must(a && id(a.customerId) === actors.get(e.actor) && id(a.overseasWarehouseId) === id(e.body?.operationWarehouseId), 'Relabel producer scope mismatch') + add('relabel', a.relabelNo, a.customerId, a.overseasWarehouseId, e) + } + if (e.method === 'POST' && path === '/api/merchant/v1/last-leg/return-orders' && actors.has(e.actor) + && String(e.body?.remark).includes(context.marker)) add('return', data.order?.returnNo, actors.get(e.actor), data.order?.overseasWarehouseId, e) + match = path.match(/\/overseas-warehouse-resources\/([^/]+)\/pallets(?:\/([^/]+))?$/) + if (match && e.method === 'POST' && !match[2] && String(e.body?.palletCode).includes(context.marker)) + add('pallet', typeof data === 'object' ? data.id : data, '', decodeURIComponent(match[1]), e, { palletCode: e.body.palletCode }) + if (match && e.method === 'GET' && match[2] && own.pallet.has(decodeURIComponent(match[2]))) { + const old = own.pallet.get(decodeURIComponent(match[2])) + must(id(data.warehouseId) === old.warehouseId && data.palletCode === old.palletCode, 'New pallet readback drifted') + add('pallet', old.key, old.customerId, old.warehouseId, e, { locationId: id(data.locationId), palletCode: data.palletCode }) + } + match = path.match(/\/overseas-warehouse-resources\/([^/]+)\/locations$/) + if (e.method === 'GET' && match) for (const pallet of own.pallet.values()) { + if (pallet.warehouseId !== decodeURIComponent(match[1]) || !pallet.locationId) continue + const location = (Array.isArray(data) ? data : data.records || []).find(row => id(row.id) === pallet.locationId) + if (location?.locationCode) pallet.locationCode = location.locationCode + } + match = path.match(/\/return-inventory\/cartons\/([^/]+)$/) + const returned = match && own.return.get(data.sourceReturnNo) + if (e.method === 'GET' && returned && data.cartonMark === decodeURIComponent(match[1])) { + must(id(data.customerId) === returned.customerId && id(data.overseasWarehouseId) === returned.warehouseId, 'New return carton scope mismatch') + add('carton', data.cartonMark, returned.customerId, returned.warehouseId, e, { sourceReturnNo: data.sourceReturnNo, + ...(data.status !== 'OUTBOUNDED' ? { originalSkuItems: data.items } : {}) }) + } + if (e.method === 'GET' && path === `${BASE}/dispatches/candidates` && Array.isArray(data)) { + for (const row of data) { + const parent = row.sourceStage === 'RELABEL' ? own.relabel.get(row.orderRefs) : own.inbound.get(row.inboundCode) + if (!parent || id(row.customerId) !== parent.customerId || id(row.overseasWarehouseId) !== parent.warehouseId) continue + add('candidate', row.sourceStage === 'RELABEL' ? `RL:${row.warehousePalletId}` : row.planPalletId, parent.customerId, parent.warehouseId, e, { candidate: row }) + } + } + if (e.method === 'POST' && path === `${BASE}/dispatches/pre-arrange`) { + const sources = (e.body?.planPalletIds || []).map(value => own.candidate.get(e.body.relabelNo ? `RL:${value}` : id(value))) + must(sources.length && sources.every(Boolean) && sources.every(row => row.customerId === sources[0].customerId && row.warehouseId === sources[0].warehouseId), 'Dispatch creation lacks owned source receipts') + must(id(data.overseasWarehouseId) === sources[0].warehouseId && same((data.items || []).map(row => id(row.warehousePalletId)).sort(), sources.map(row => id(row.candidate.warehousePalletId)).sort()), 'Dispatch birth response differs from producer selection') + add('dispatch', data.dispatchNo, sources[0].customerId, sources[0].warehouseId, e, { dispatch: data, arrangeBody: e.body }) + } + match = path.match(/\/dispatches\/([^/]+)\/transfer-delivery$/) + const dispatched = match && own.dispatch.get(decodeURIComponent(match[1])) + if (e.method === 'POST' && dispatched && data.outboundNo && !data.replayed) { + add('outbound', data.outboundNo, dispatched.customerId, dispatched.warehouseId, e, { dispatchNo: dispatched.key }) + for (const row of data.deliveries || []) add('delivery', row.deliveryNo, dispatched.customerId, dispatched.warehouseId, e, { outboundNo: data.outboundNo, dispatchNo: dispatched.key }) + } + match = path.match(/\/delivery-orders\/([^/]+)\/return-inbound-options$/) + if (e.method === 'GET' && match && own.delivery.has(decodeURIComponent(match[1])) && Array.isArray(data)) { + for (const relation of data) for (const source of relation.returnSources || []) { + const carton = own.carton.get(source.cartonMark) + if (carton && source.returnCartonId && source.inventoryPositionId == null + && id(relation.customerId) === carton.customerId && relation.deliveryNo === decodeURIComponent(match[1]) + && relation.outboundNo === own.delivery.get(decodeURIComponent(match[1])).outboundNo) + add('carton', carton.key, carton.customerId, carton.warehouseId, e, { returnCartonId: id(source.returnCartonId), lastMileOrderId: id(relation.lastMileOrderId), deliveryNo: relation.deliveryNo }) + } + } + } + return own +} +function requireOwned(own, kind, key, fixture) { + const row = own[kind].get(id(key)) + must(row && row.warehouseId === id(fixture.warehouseId) && (!row.customerId || row.customerId === id(fixture.customerId)), `Unowned ${kind} ${id(key)}; producer receipt identity/customer/warehouse mismatch`) + return row +} +function validateAllResources(own, fixtures) { + for (const [name, f] of Object.entries(fixtures || {})) { + must(f.customerId && f.warehouseId, 'Fixture customer and warehouse required') + if (name === 'cancellation') { + const d = requireOwned(own, 'dispatch', f.dispatchNo, f) + must(same(d.arrangeBody.planPalletIds.map(id).sort(), f.rearrangeBody?.planPalletIds?.map(id).sort()), 'Re-arrangement must reuse only the exact producer sources') + } + if (['departedGate', 'permissions', 'nativeCartonReturn'].includes(name)) { + requireOwned(own, 'outbound', f.outboundNo, f) + if (f.deliveryNo) { + const d = requireOwned(own, 'delivery', f.deliveryNo, f) + must(d.outboundNo === f.outboundNo, 'Delivery/outbound producer relation mismatch') + } + } + if (name === 'wholeRelabel') requireOwned(own, 'relabel', f.relabelNo, f) + if (name === 'receiptRefresh') { + const b = requireOwned(own, 'booking', f.bookingNo, f) + must(b.tripNo === f.tripNo, 'Booking/trip producer relation mismatch') + for (const row of f.measurements || []) requireOwned(own, 'pallet', row.warehousePalletId, f) + } + if (name === 'mixedSources') { + must(f.samples?.length === 2 && new Set(f.samples.map(x => x.dispatchNo)).size === 2 + && new Set(f.samples.map(x => id(x.palletId))).size === 2 && new Set(f.samples.map(x => x.bookingNo)).size === 2, 'Mixed-source samples must be independent dispatches, pallets and bookings') + for (const sample of f.samples) { + requireOwned(own, 'dispatch', sample.dispatchNo, f); requireOwned(own, 'pallet', sample.palletId, f) + const normal = requireOwned(own, 'inbound', sample.ordinaryInboundCode, f), external = requireOwned(own, 'inbound', sample.externalInboundCode, f) + const booking = requireOwned(own, 'booking', sample.bookingNo, f) + must(normal.kind === 'ORDINARY' && external.kind === 'EXTERNAL' && external.externalBookingNo === booking.key + && normal.receivedPackages > 0 && external.receivedPackages > 0, 'Independent ordinary/external receipt quantities required') + } + } + if (name === 'nativeCartonReturn') { + const carton = requireOwned(own, 'carton', f.cartonMark, f), pallet = requireOwned(own, 'pallet', f.target?.targetPalletId, f) + const delivery = own.delivery.get(f.deliveryNo), dispatch = own.dispatch.get(delivery.dispatchNo) + must(carton.returnCartonId === id(f.returnCartonId) && carton.lastMileOrderId === id(f.lastMileOrderId) + && carton.deliveryNo === f.deliveryNo && carton.originalSkuItems?.length && dispatch.dispatch.packageCount === 1, + 'Native carton/master must come from the owned single-carton shipment') + must(pallet.palletCode === f.target.targetPalletNo && pallet.locationId === id(f.target.targetLocationId) && pallet.locationCode === f.target.targetLocationCode, 'Return target must match the newly created physical pallet/location') + } + } +} + +/** Internal, explicit TEST-only runner. Import and plan mode never send requests. */ +export async function verifyDispatchRepair(context, fixtures, { execute = false } = {}) { + if (!execute) return { status: 'PLAN_ONLY', cases, excluded: ['item DELETE negative branch', 'UI/PDA hardware', 'external document authenticity'] } + must(context.environment === 'TEST' && context.release?.lastlegHealthy && context.release?.adminHealthy + && context.release?.schemaReady && /^[a-f0-9]{40}$/.test(context.release?.lastlegCommit || ''), 'Matching healthy TEST releases and schema readback required') + must(context.marker && typeof context.checkpoint === 'function', 'Current run marker and durable checkpoint required') + const own = await loadProducerResources(context) + validateAllResources(own, fixtures) + const proof = { marker: context.marker, release: context.release, status: 'CHECKING', requests: [], cases: {}, + externalEvidence: 'Synthetic test records only; not real appointment/POD proof', sourceEventsVerified: false } + context.createdResources ??= {} + must(!context.createdResources.dispatchRepairAcceptance, 'No automatic restart of an existing acceptance run') + context.createdResources.dispatchRepairAcceptance = proof + async function call(method, path, body, { failure = false, file = null, cancel = false, actor = 'PLATFORM' } = {}) { + must(method !== 'DELETE' || cancel && /^\/api\/platform\/v1\/last-leg\/dispatches\/[^/?]+$/.test(path), 'Item deletion and arbitrary DELETE are excluded') + const entry = { method, path, actor, body: body === undefined ? undefined : structuredClone(body), status: 'SENDING' } + if (file) entry.file = { name: file.name, type: file.type, size: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } + proof.requests.push(entry); await context.checkpoint() + let response + try { + if (actor === 'READ_ONLY') must(typeof context.requestReadonly === 'function', 'Own read-only session required') + response = file ? await context.requestMultipart(method, path, file) + : await (actor === 'READ_ONLY' ? context.requestReadonly : context.request).call(context, method, path, body) + must(Number.isInteger(response?.http) && response.http >= 200 && response.http < 500 + && response.requestId && response.traceId && response.body && typeof response.body === 'object' + && (success(response) || /^[A-Z]\d{4}$/.test(String(response.body.code))), 'Unknown transport or uncorrelated response; preserve command, do not replay') + } catch (error) { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint(); throw error } + Object.assign(entry, { status: 'RESPONDED', http: response.http, code: response.body.code, + requestId: response.requestId, traceId: response.traceId }); await context.checkpoint() + must(failure === 'FORBIDDEN' ? response.http === 403 || response.http === 200 && String(response.body.code) === 'A0403' + : failure ? rejected(response) : success(response), `Unexpected result: ${method} ${path}`) + return response.body.data + } + const number = value => { const n = Number(value); must(value != null && Number.isSafeInteger(n), 'Exact safe integer quantity required'); return n } + const cents = value => { const v = String(value); must(/^\d+(?:\.\d{1,2})?$/.test(v), 'Exact nonnegative money value required'); const [a, b = ''] = v.split('.'); return BigInt(a) * 100n + BigInt(b.padEnd(2, '0')) } + async function pages(path) { + const rows = [], url = new URL(path, 'https://test.invalid') + for (let page = 1; page <= 100; page++) { + url.searchParams.set('page', String(page)); url.searchParams.set('size', '200') + const data = await call('GET', url.pathname + url.search) + must(Array.isArray(data?.records) && number(data.total) >= 0, 'Complete paged API evidence required') + rows.push(...data.records) + if (rows.length === number(data.total)) return rows + must(rows.length < number(data.total) && data.records.length > 0, 'Paged evidence has inconsistent total or missing page') + } + throw Error('Paged evidence exceeded bounded limit; not verified') + } + async function nativeSnapshot(f, skuCodes) { + const stock = [], movements = [] + for (const skuCode of skuCodes) { + const query = new URLSearchParams({ customerId: f.customerId, overseasWarehouseId: f.warehouseId, skuCode }) + const exact = row => id(row.customerId) === id(f.customerId) && id(row.overseasWarehouseId) === id(f.warehouseId) && row.skuCode === skuCode + const balances = (await pages(`${BASE}/return-inventory?${query}`)).filter(exact) + must(balances.length <= 1, 'Duplicate exact native SKU balance') + const row = balances[0] + stock.push({ skuCode, present: Boolean(row), total: row ? number(row.totalQuantity) : 0, + availableView: row ? number(row.availableQuantity) : 0, cartons: row ? number(row.cartonQuantity) : 0, + updatedAt: row?.updatedAt ?? null }) + movements.push(...(await pages(`${BASE}/return-inventory/movements?${query}`)).filter(exact).map(row => ({ + customerId: id(row.customerId), warehouseId: id(row.overseasWarehouseId), skuCode: row.skuCode, + type: row.movementType, referenceNo: row.referenceNo, totalDelta: number(row.totalDelta), availableDelta: number(row.availableDelta), occurredAt: row.occurredAt }))) + } + return { stock: stock.sort((a, b) => a.skuCode.localeCompare(b.skuCode)), movements: movements.sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) } + } + const shipment = value => ({ status: value?.status, version: value?.version, outboundNo: value?.outboundNo, + packageCount: value?.packageCount, signedAt: value?.signedAt, signedPackages: value?.signedPackages }) + const dispatch = value => ({ status: value?.status, packageCount: value?.packageCount, items: value?.items }) + async function run(name, action) { + const fixture = fixtures?.[name] + if (!fixture) { proof.cases[name] = { status: 'NOT_RUN', reason: 'Current-run producer fixture missing' }; return } + proof.cases[name] = { status: 'CHECKING', producerEvidenceSha256: context.producerEvidence.sha256 } + try { proof.cases[name].evidence = await action(fixture); proof.cases[name].status = 'PASS' } + catch (error) { Object.assign(proof.cases[name], { status: 'FAILED', reason: error.message }); throw error } + finally { await context.checkpoint() } + } + try { + await run('cancellation', async f => { + must(f.allowDispatchCancellation === true && f.dispatchNo && f.transferBody && f.rearrangeBody, 'Owned cancellation fixture and explicit business cancel scope required') + const path = `${BASE}/dispatches/${encodeURIComponent(f.dispatchNo)}` + const original = await call('GET', path) + must(original.status === 'PENDING_DEPARTURE' && original.createdAt && original.actions?.canTransfer, 'New unpicked execution required') + const transferBody = { ...f.transferBody, idempotencyKey: key() } + const transfer = await call('POST', `${path}/transfer-delivery`, transferBody) + must(transfer.outboundNo && transfer.deliveries?.length, 'New outbound and delivery required') + const dpath = scope(`${BASE}/delivery-orders/${encodeURIComponent(transfer.deliveries[0].deliveryNo)}`, f.customerId) + const before = await call('GET', dpath) + must(before.order?.status === 'WAIT_DELIVERY' && before.order.outboundNo === transfer.outboundNo, 'Delivery must belong to new unexecuted outbound') + for (const status of ['DELIVERING', 'REJECTED', 'CANCELLED']) { + await call('PUT', scope(`${BASE}/delivery-orders/${encodeURIComponent(before.order.deliveryNo)}/status`, f.customerId), + { status, expectedVersion: before.order.version, remark: context.marker }, { failure: true }) + } + must(same(shipment((await call('GET', dpath)).order), shipment(before.order)), 'Rejected status commands changed delivery') + const opath = `${BASE}/outbound-orders/${encodeURIComponent(transfer.outboundNo)}` + await call('POST', `${opath}/cancel`, { reason: context.marker }) + must((await call('GET', opath)).outbound?.status === 'CANCELLED', 'Outbound cancellation did not persist') + must((await call('GET', dpath)).order?.status === 'CANCELLED', 'Linked delivery cancellation did not persist') + await call('DELETE', path, undefined, { cancel: true }) + must((await call('GET', path)).status === 'CANCELLED', 'Original dispatch was not retained as cancelled') + const candidateQuery = new URLSearchParams(f.candidateQuery) + const candidates = await call('GET', `${BASE}/dispatches/candidates?${candidateQuery}`) + const expected = f.rearrangeBody.planPalletIds.map(id) + must(expected.length && expected.every(value => candidates.some(row => id(row.planPalletId) === value)), 'Cancelled source pallets were not released') + const replacement = await call('POST', `${BASE}/dispatches/pre-arrange`, f.rearrangeBody) + must(replacement.dispatchNo && replacement.dispatchNo !== f.dispatchNo && replacement.status === 'PENDING_DEPARTURE' + && same(replacement.items.map(row => id(row.planPalletId)).sort(), [...expected].sort()), 'Released source was not re-arranged exactly once') + const replay = await call('POST', `${path}/transfer-delivery`, transferBody) + must(replay.outboundNo === transfer.outboundNo && replay.replayed, 'Old transfer replay generated another outbound') + return { originalDispatchNo: f.dispatchNo, cancelledOutboundNo: transfer.outboundNo, replacementDispatchNo: replacement.dispatchNo } + }) + await run('departedGate', async f => { + const opath = `${BASE}/outbound-orders/${encodeURIComponent(f.outboundNo)}` + const dpath = scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}`, f.customerId) + const outbound = await call('GET', opath), before = await call('GET', dpath) + must(outbound.outbound?.status === 'OUTBOUNDED' && before.order?.outboundNo === f.outboundNo, 'Actual current-run shipment required') + await call('POST', `${opath}/cancel`, { reason: context.marker }, { failure: true }) + await call('PUT', scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}/status`, f.customerId), + { status: 'CANCELLED', expectedVersion: before.order.version, remark: context.marker }, { failure: true }) + must(same(shipment((await call('GET', dpath)).order), shipment(before.order)), 'Departure cancellation gate changed delivery') + return { outboundNo: f.outboundNo, deliveryNo: f.deliveryNo } + }) + await run('wholeRelabel', async f => { + const apath = scope(`${BASE}/relabel-applications/${encodeURIComponent(f.relabelNo)}`, f.customerId) + const cpath = `${BASE}/dispatches/candidates?${new URLSearchParams({ sourceStage: 'RELABEL', orderNo: f.relabelNo })}` + const before = await call('GET', apath), candidates = await call('GET', cpath) + const ids = candidates.filter(row => row.orderRefs === f.relabelNo).map(row => id(row.warehousePalletId)).sort() + must(ids.length === 2 && new Set(ids).size === 2, 'Exactly two real pallets from the owned relabel application required') + await call('POST', `${BASE}/dispatches/pre-arrange`, { relabelNo: f.relabelNo, planPalletIds: ids.slice(0, 1) }, { failure: true }) + must(same((await call('GET', apath)).application, before.application), 'Partial relabel rejection changed the application') + must(same((await call('GET', cpath)).filter(row => row.orderRefs === f.relabelNo).map(row => id(row.warehousePalletId)).sort(), ids), 'Partial relabel rejection retained a lock or lost cargo') + const full = await call('POST', `${BASE}/dispatches/pre-arrange`, { relabelNo: f.relabelNo, planPalletIds: ids }) + must(full.status === 'PENDING_DEPARTURE' && full.items?.length === 2 && same(full.items.map(row => id(row.warehousePalletId)).sort(), ids), 'Full relabel selection did not preserve both pallets') + return { relabelNo: f.relabelNo, dispatchNo: full.dispatchNo, pallets: ids } + }) + await run('mixedSources', async f => { + must(same(f.samples.map(row => row.displayKind).sort(), ['EXTERNAL', 'ORDINARY']), 'Both physical display permutations required') + const evidence = [] + for (const sample of f.samples) { + const normal = own.inbound.get(sample.ordinaryInboundCode), external = own.inbound.get(sample.externalInboundCode) + const booking = own.booking.get(sample.bookingNo).booking + const current = await call('GET', `${BASE}/dispatches/${encodeURIComponent(sample.dispatchNo)}`) + const displayed = current.items?.find(row => id(row.warehousePalletId) === id(sample.palletId)) + const expectedDisplay = sample.displayKind === 'ORDINARY' ? normal.key : external.key + must(displayed?.inboundCode === expectedDisplay && displayed.palletNo && id(current.overseasWarehouseId) === id(f.warehouseId), 'Displayed source does not match its independently produced ordinary/external origin') + const balances = (await pages(`${BASE}/inventory/balances?${new URLSearchParams({ customerId: f.customerId, overseasWarehouseId: f.warehouseId })}`)) + .filter(row => id(row.customerId) === id(f.customerId) && id(row.overseasWarehouseId) === id(f.warehouseId)) + const quantities = new Map(), positions = [] + for (const balance of balances) { + const rows = await call('GET', `${BASE}/inventory/balances/${encodeURIComponent(id(balance.id))}/positions?${new URLSearchParams({ customerId: f.customerId, palletNo: displayed.palletNo })}`) + must(Array.isArray(rows), 'Actual inventory positions required') + for (const row of rows.filter(row => id(row.palletId) === id(sample.palletId))) { + must(id(row.customerId) === id(f.customerId) && id(row.overseasWarehouseId) === id(f.warehouseId), 'Physical pallet source scope mismatch') + const quantity = number(row.availablePackages) + number(row.pickedPackages) + if (quantity > 0) quantities.set(row.inboundCode, (quantities.get(row.inboundCode) || 0) + quantity) + positions.push({ positionId: id(row.id), inboundCode: row.inboundCode, quantity }) + } + } + must(quantities.size === 2 && quantities.get(normal.key) === normal.receivedPackages && quantities.get(external.key) === external.receivedPackages, + 'Physical pallet composition differs from independent receiving facts') + const freight = await call('GET', `${BASE}/dispatches/${encodeURIComponent(sample.dispatchNo)}/external-freight`) + const quote = freight.quotes?.[0], cargo = booking.cargo, receipt = booking.receipt?.source + const quantity = cargo?.priceBasis === 'PALLET' ? number(cargo.palletSpaces) : 1 + must(freight.requiresDeliveryFee === true && freight.quotes?.length === 1 && quote.bookingNo === sample.bookingNo + && quote.inboundCode === external.key && quote.currency === cargo.currency && quote.destinationKey === receipt?.destinationKey + && number(quote.quantity) === quantity && cents(quote.unitPrice) === cents(cargo.unitPrice) + && cents(quote.amount) === cents(booking.agreedAmount) && cents(quote.amount) === cents(cargo.unitPrice) * BigInt(quantity) + && freight.snapshotHash, 'Actual external quote quantity/price/amount/currency differs from producer commercial facts') + evidence.push({ dispatchNo: sample.dispatchNo, palletId: sample.palletId, displayKind: sample.displayKind, + expectedSources: [{ inboundCode: normal.key, packages: normal.receivedPackages }, { inboundCode: external.key, packages: external.receivedPackages }], + positions, quote, snapshotHash: freight.snapshotHash }) + } + return evidence + }) + await run('receiptRefresh', async f => { + must(f.tripNo && f.bookingNo && f.oldReceiptHash && Number(f.unitPrice) >= 0, 'Owned booking and previous actual receipt required') + const bookingsPath = `${BASE}/fleet/trips/${encodeURIComponent(f.tripNo)}/bookings` + const listing = await call('GET', bookingsPath) + const booking = listing.records?.find(row => row.bookingNo === f.bookingNo) + must(booking && id(booking.customerId) === id(f.customerId) && booking.status === 'RECEIVED' && !booking.dispatchNo + && booking.receipt?.source?.receiptHash === f.oldReceiptHash, 'Previously accepted, currently unbound own-customer booking required') + const path = `${bookingsPath}/${encodeURIComponent(f.bookingNo)}` + const before = await call('GET', `${path}/incoming`) + must(before.incoming?.receiptHash && before.incoming.receiptHash !== f.oldReceiptHash && before.incoming.pallets?.length + && before.incoming.pallets.some(row => row.weightKg == null || row.volumeCbm == null), 'Fresh split/merge geometry requiring measurement must be proven') + must(f.measurements?.length === before.incoming.pallets.length, 'Independent new physical pallet measurements required') + const body = { tripRecordVersion: before.tripRecordVersion, recordVersion: before.recordVersion, receiptHash: before.incoming.receiptHash, + pallets: before.incoming.pallets.map(row => ({ warehousePalletId: id(row.warehousePalletId), palletSpaces: 1 })), unitPrice: f.unitPrice, idempotencyKey: key() } + await call('POST', `${path}/receipt-confirmation`, body, { failure: true }) + body.idempotencyKey = key() + body.pallets = body.pallets.map(row => ({ ...row, ...f.measurements.find(value => id(value.warehousePalletId) === row.warehousePalletId) })) + must(body.pallets.every(row => Number(row.weightKg) > 0 && Number(row.volumeCbm) > 0), 'Positive new measurements required') + const confirmed = await call('POST', `${path}/receipt-confirmation`, body) + const replay = await call('POST', `${path}/receipt-confirmation`, body) + must(confirmed.booking?.receipt?.source?.receiptHash === body.receiptHash && replay.replayed === true + && same(replay.booking.receipt, confirmed.booking.receipt), 'Re-confirmed receipt or exact replay drifted') + return { tripNo: f.tripNo, bookingNo: f.bookingNo, receipt: confirmed.booking.receipt } + }) + await run('nativeCartonReturn', async f => { + must(f.cartonMark && f.returnCartonId && f.lastMileOrderId && f.target, 'Owned native carton, actual master and actual target resources required') + const cartonPath = `${BASE}/return-inventory/cartons/${encodeURIComponent(f.cartonMark)}` + const before = await call('GET', cartonPath) + must(before.status === 'OUTBOUNDED' && id(before.customerId) === id(f.customerId) && id(before.overseasWarehouseId) === id(f.warehouseId), 'Native carton must be actually shipped for this customer and warehouse') + const relations = await call('GET', scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}/return-inbound-options`, f.customerId)) + const relation = relations.find(row => id(row.lastMileOrderId) === id(f.lastMileOrderId) && row.deliveryNo === f.deliveryNo && row.outboundNo === f.outboundNo) + const source = relation?.returnSources?.find(row => id(row.returnCartonId) === id(f.returnCartonId) && row.inventoryPositionId == null && row.cartonMark === f.cartonMark && Number(row.maxReturnPackages) === 1) + must(source, 'Actual native carton source is missing; do not invent an inventory position or inbound') + const body = { lastMileOrderId: id(f.lastMileOrderId), reason: `${context.marker} synthetic refusal`, returnDirection: 'OVERSEAS_WAREHOUSE', + items: [{ outboundPalletItemId: id(source.outboundPalletItemId), returnCartonId: id(source.returnCartonId), packageCount: 1 }], idempotencyKey: key() } + const markPath = scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}/mark-return`, f.customerId) + const marked = await call('POST', markPath, body), replayMark = await call('POST', markPath, body) + must(marked.exceptionNo && replayMark.exceptionNo === marked.exceptionNo && replayMark.replayed, 'Native mark-return replay drifted') + const path = `${BASE}/exception-orders/${encodeURIComponent(marked.exceptionNo)}` + const started = await call('POST', scope(`${path}/start-processing`, f.customerId), { expectedVersion: marked.version, idempotencyKey: key(), handlingAction: 'SYNTHETIC TEST CARTON RETURN' }) + const file = { name: `${context.marker}-native-return.txt`, type: 'text/plain', content: new TextEncoder().encode('SYNTHETIC TEST EVIDENCE; not a real external POD') } + const uploaded = await call('POST', scope(`${path}/evidence`, f.customerId), undefined, { file }) + const completeBody = { expectedVersion: started.version, idempotencyKey: key(), handlingResult: 'SYNTHETIC TEST ACTUAL RETURN', evidenceFileIds: [uploaded.fileId], + receipts: [{ outboundPalletItemId: id(source.outboundPalletItemId), inventoryPositionId: null, returnCartonId: id(source.returnCartonId), receivedPackages: 1, ...f.target }] } + const born = own.carton.get(f.cartonMark), expected = new Map(born.originalSkuItems.map(row => [row.skuCode, number(row.quantity)])) + must(expected.size === born.originalSkuItems.length && [...expected.values()].every(n => n > 0), 'Unique original native SKU quantities required') + const skus = [...expected.keys()].sort(), S = await nativeSnapshot(f, skus) + const dispatchNo = own.delivery.get(f.deliveryNo).dispatchNo + const outboundEvidence = S.movements.filter(row => row.type === 'OUTBOUND' && row.referenceNo === dispatchNo) + must(outboundEvidence.length === skus.length && skus.every(sku => outboundEvidence.filter(row => row.skuCode === sku).length === 1 + && outboundEvidence.find(row => row.skuCode === sku).totalDelta === -expected.get(sku)), 'Original single-carton per-SKU outbound movement does not match producer carton') + const completed = await call('POST', scope(`${path}/complete`, f.customerId), completeBody) + const R = await nativeSnapshot(f, skus), history = await call('GET', scope(path, f.customerId)), after = await call('GET', cartonPath) + must(completed.status === 'COMPLETED' && number(completed.restoredPackages) === 1 && history.returnReceipts?.length === 1 + && number(history.returnReceipts[0].receivedPackages) === 1 && history.returnReceipts[0].receiptNo && history.returnReceipts[0].receivedAt, 'Native receipt identity/box count is incomplete') + const receiptNo = history.returnReceipts[0].receiptNo + const incoming = R.movements.filter(row => row.type === 'EXCEPTION_RETURN_IN' && row.referenceNo === receiptNo) + must(!S.movements.some(row => row.referenceNo === receiptNo) && incoming.length === skus.length + && same(incoming.map(row => row.skuCode).sort(), skus) && skus.every(sku => { + const previous = S.stock.find(row => row.skuCode === sku), restored = R.stock.find(row => row.skuCode === sku) + const movement = incoming.find(row => row.skuCode === sku) + return restored.present && restored.updatedAt && restored.total - previous.total === expected.get(sku) + && previous.total === 0 && previous.availableView === 0 && previous.cartons === 0 + && restored.availableView === expected.get(sku) && restored.cartons === 1 + && movement.totalDelta === expected.get(sku) && movement.availableDelta === expected.get(sku) && movement.occurredAt + }), 'Every original SKU must restore its exact total, visible availability, carton count and one immutable receipt movement') + must(after.status === 'AVAILABLE' && id(after.customerId) === id(f.customerId) && id(after.overseasWarehouseId) === id(f.warehouseId) + && after.cartonMark === f.cartonMark && after.palletCode === f.target.targetPalletNo && after.locationCode === f.target.targetLocationCode && after.putawayAt, + 'Native carton did not return to the exact validated physical pallet/location') + const deliveryAfter = await call('GET', scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}`, f.customerId)) + must(deliveryAfter.order?.status === 'RETURNED', 'Actual single-carton recovery did not close its delivery') + const replay = await call('POST', scope(`${path}/complete`, f.customerId), completeBody) + const Rprime = await nativeSnapshot(f, skus), historyAgain = await call('GET', scope(path, f.customerId)), cartonAgain = await call('GET', cartonPath) + const deliveryAgain = await call('GET', scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}`, f.customerId)) + must(replay.replayed === true && same(Rprime, R) && same(historyAgain.returnReceipts, history.returnReceipts) + && same({ pallet: cartonAgain.palletCode, location: cartonAgain.locationCode, at: cartonAgain.putawayAt, status: cartonAgain.status }, + { pallet: after.palletCode, location: after.locationCode, at: after.putawayAt, status: after.status }) + && same(shipment(deliveryAgain.order), shipment(deliveryAfter.order)), 'Native replay changed SKU stock, receipt rows/times, target or delivery') + return { exceptionNo: marked.exceptionNo, cartonMark: f.cartonMark, dispatchNo, receiptNo, S, R, Rprime, + expectedSkuQuantities: Object.fromEntries(expected), target: f.target, + availableQuantityMeaning: 'HTTP availableQuantity is a capped visible value; total and immutable availableDelta verified, no naked-ledger availability claim' } + }) + await run('permissions', async f => { + must(typeof context.requestReadonly === 'function' && f.outboundNo, 'Dedicated read-only session and owned fresh outbound required') + const path = `${BASE}/outbound-orders/${encodeURIComponent(f.outboundNo)}` + const before = await call('GET', path, undefined, { actor: 'READ_ONLY' }) + must(before.outbound?.status === 'WAIT_OUTBOUND', 'Current unexecuted outbound required for permission isolation') + await call('POST', `${path}/cancel`, { reason: context.marker }, { failure: 'FORBIDDEN', actor: 'READ_ONLY' }) + const after = await call('GET', path) + must(same(shipment(after.outbound), shipment(before.outbound)), 'Forbidden read-only command changed outbound') + return { outboundNo: f.outboundNo, readAllowed: true, cancellationForbidden: true } + }) + proof.status = cases.every(name => proof.cases[name]?.status === 'PASS') ? 'HTTP_CASES_PASSED_REVIEW_REQUIRED' : 'PARTIAL_NOT_ALL_VERIFIED' + return proof + } catch (error) { proof.status = 'FAILED'; proof.error = error.message; throw error } + finally { await context.checkpoint() } +} From 2c330bf9df4df39f78fae9155f5ab602667b48f3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 19:29:35 +0800 Subject: [PATCH 012/113] test(lastleg): derive merchant relabel scope from profile receipt --- .../dispatch-repair-acceptance-contract.test.mjs | 16 +++++++++++++++- .../dispatch-repair-acceptance.mjs | 7 +++++-- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs index 1c67ca6b9..b7aab25aa 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs @@ -61,7 +61,7 @@ function producerReport() { event('GET', '/api/merchant/v1/iam/tenant/profile', undefined, { id: 'C' }, 'MERCHANT') event('POST', '/api/merchant/v1/last-leg/return-orders', { remark: 'CURRENT new return' }, { order: { returnNo: 'RT', overseasWarehouseId: 'W' } }, 'MERCHANT') event('GET', '/api/platform/v1/last-leg/return-inventory/cartons/BOX', undefined, { cartonMark: 'BOX', sourceReturnNo: 'RT', customerId: 'C', overseasWarehouseId: 'W', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] }) - event('POST', '/api/merchant/v1/last-leg/relabel-applications', { operationWarehouseId: 'W' }, { application: { relabelNo: 'RL-N', customerId: 'C', overseasWarehouseId: 'W' } }, 'MERCHANT') + event('POST', '/api/merchant/v1/last-leg/relabel-applications', { operationWarehouseId: 'W' }, { application: { relabelNo: 'RL-N', overseasWarehouseId: 'W' } }, 'MERCHANT') dispatch('NDP', [candidate(null, '601', null, { sourceStage: 'RELABEL', orderRefs: 'RL-N', returnCartonSource: true })], 'RL-N', 1) event('POST', '/api/platform/v1/last-leg/dispatches/NDP/transfer-delivery', {}, { outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }] }) event('GET', '/api/platform/v1/last-leg/delivery-orders/D/return-inbound-options?customerId=C', undefined, [{ customerId: 'C', lastMileOrderId: '91', deliveryNo: 'D', outboundNo: 'O', returnSources: [{ returnCartonId: '81', cartonMark: 'BOX', inventoryPositionId: null }] }]) @@ -80,6 +80,20 @@ function context(handler = () => assert.fail('No request expected')) { } const execute = (ctx, fixtures) => verifyDispatchRepair(ctx, fixtures, { execute: true }) +test('merchant relabel summary derives omitted customer from authenticated profile receipt', async () => { + const ctx = context() + assert.equal((await execute(ctx, {})).status, 'PARTIAL_NOT_ALL_VERIFIED') + assert.equal(ctx.calls.length, 0) +}) + +test('an explicitly different relabel customer cannot borrow merchant profile scope', async () => { + const ctx = context(), report = producerReport() + report.requests.find(e => e.path === '/api/merchant/v1/last-leg/relabel-applications').result.application.customerId = 'OTHER' + writeProducer(ctx, report) + await assert.rejects(execute(ctx, {}), /Relabel producer scope mismatch/) + assert.equal(ctx.calls.length, 0) +}) + test('import/plan is zero-network and missing current fixtures never become a pass', async () => { const ctx = context(); assert.equal((await verifyDispatchRepair(ctx, {})).status, 'PLAN_ONLY'); assert.equal(ctx.calls.length, 0) const result = await execute(ctx, {}) diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs index ba6952558..ef726f6a6 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs @@ -58,8 +58,11 @@ async function loadProducerResources(context) { } if (e.method === 'POST' && path === '/api/merchant/v1/last-leg/relabel-applications' && actors.has(e.actor)) { const a = data.application - must(a && id(a.customerId) === actors.get(e.actor) && id(a.overseasWarehouseId) === id(e.body?.operationWarehouseId), 'Relabel producer scope mismatch') - add('relabel', a.relabelNo, a.customerId, a.overseasWarehouseId, e) + const customerId = actors.get(e.actor) + must(a && (a.customerId === undefined || id(a.customerId) === customerId) + && id(a.overseasWarehouseId) === id(e.body?.operationWarehouseId), 'Relabel producer scope mismatch') + // Merchant summaries omit customerId; the authenticated profile receipt supplies its scope. + add('relabel', a.relabelNo, customerId, a.overseasWarehouseId, e) } if (e.method === 'POST' && path === '/api/merchant/v1/last-leg/return-orders' && actors.has(e.actor) && String(e.body?.remark).includes(context.marker)) add('return', data.order?.returnNo, actors.get(e.actor), data.order?.overseasWarehouseId, e) From 45fbd6eff8ddc81e8a0da91b5719ce4c38c1061e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 18 Oct 2026 19:53:35 +0800 Subject: [PATCH 013/113] fix(lastleg): reconfirm received bookings after releasing all own cargo --- .../trip/FleetBookingCargoRepository.java | 2 + .../fleet/trip/FleetBookingReservations.java | 19 ++++- .../fleet/trip/FleetBookingService.java | 3 +- .../fleet/trip/FleetReceiptCapacityTest.java | 80 +++++++++++++++++++ .../fleet/trip/FleetTripPersistenceTest.java | 31 +++++-- ...spatch-repair-acceptance-contract.test.mjs | 10 ++- .../dispatch-repair-acceptance.mjs | 7 +- 7 files changed, 139 insertions(+), 13 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetReceiptCapacityTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java index 11216d647..8a518868b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java @@ -7,4 +7,6 @@ public class FleetBookingCargoRepository { public FleetBookingCargoRepository(FleetBookingMapper bookings){this.bookings=bookings;} /** 按客户与真实入仓码定位承运,历史取消记录交由业务层分别处理。 */ public FleetBookingEntity forInboundSource(String customerId,String inboundCode){return bookings.forInboundSource(customerId,inboundCode);} + /** 在车次事务内读取全部承运预留,统一计算区段容量。 */ + public java.util.List listForTrip(long tripId){return bookings.list(tripId);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index 5ffa4641c..7c2d1aa87 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -28,12 +28,27 @@ public class FleetBookingReservations { row.getOfferId()==null?null:offers.selectById(row.getOfferId()).getOfferNo(),row.getExpectedPalletCount()); } public FleetQuoteCandidateModels.SourceSnapshot quoteSource(FleetBookingEntity row){return row.getQuoteSourceJson()==null?null:read(row.getQuoteSourceJson(),FleetQuoteCandidateModels.SourceSnapshot.class);} + /** 使用当前自有货计划与全部未绑定预留计算区段,不重复计入待替换承运。 */ public List segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own, Long excludedId, FleetBookingEntity addition) { + return segments(trip,own.pallets(),excludedId,addition); + } + /** 已实收承运解除唯一排车后,自有货为零仍可按本票新实测和其他预留重新核容。 */ + public void requireReceiptCapacity(FleetTripEntity trip,FleetExecutionModels.Planning own,FleetBookingEntity row) { + boolean releasedReceipt=own.cargo().isEmpty() && "RECEIVED".equals(row.getStatus()) && receipt(row)!=null; + if(!releasedReceipt && (!own.planValid() || own.blockReason()!=null)) + throw conflict("请先重新核对自有货容量计划,再确认外接实收"); + // 空自有货只用于已有实收的重新确认;不创建计划、不允许首次实收跳过自有货计划。 + List pallets=releasedReceipt?List.of():own.capacityPlan().pallets(); + segments(trip,pallets,row.getId(),row); + } + /** 所有承诺使用同一容量计算,空自有货也必须保留其他承运占位及车辆上限。 */ + private List segments(FleetTripEntity trip,List own, + Long excludedId,FleetBookingEntity addition) { var stops=stops(trip); var loads=new ArrayList(); - for (var pallet:own.pallets()) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(), + for (var pallet:own) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(), new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm()))); - for(var row:bookings.list(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row); + for(var row:new FleetBookingCargoRepository(bookings).listForTrip(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row); if(addition!=null) add(loads,stops,addition); try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); } catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index e21cbe43f..fde620dc2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -176,8 +176,7 @@ public class FleetBookingService { int totalSpaces=spaces.values().stream().reduce(0,Math::addExact);var old=reservations.cargo(row); apply(trip,row,new FleetBookingModels.Cargo(old.destinationKey(),old.cargoName(),count,totalSpaces,weight,volume,old.priceBasis(),request.unitPrice(),old.currency(),old.contactName(),old.contactPhone(),old.remark())); var plan=execution.capacityPlan(tripNo); - if(!plan.planValid()||plan.blockReason()!=null)throw conflict("请先重新核对自有货容量计划,再确认外接实收"); - reservations.segments(trip,plan.capacityPlan(),row.getId(),row); + reservations.requireReceiptCapacity(trip,plan,row); var confirmed=incoming.confirm(row.getCustomerId(),row.getIncomingNo(),operator,"FLEET:"+bookingNo+":RECEIPT"); if(!"READY_FOR_DISPATCH".equals(confirmed.status())||!Objects.equals(confirmed.receiptHash(),evidence.receiptHash()))throw conflict("来货实收确认未完成或内容发生变化,请重新核对"); var measuredReceipt=new FleetBookingIncomingGateway.Evidence(confirmed.incomingNo(),confirmed.inboundCode(),confirmed.warehouseId(),confirmed.destinationKey(),confirmed.status(),confirmed.receiptHash(),java.util.List.copyOf(measured),confirmed.receivedPackages()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetReceiptCapacityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetReceiptCapacityTest.java new file mode 100644 index 000000000..ab8615184 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetReceiptCapacityTest.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetReceiptCapacityTest { + private final FleetBookingMapper bookings=mock(FleetBookingMapper.class); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private final FleetBookingReservations reservations=new FleetBookingReservations(bookings,json); + private FleetTripEntity trip; + private FleetBookingEntity row; + + /** 已有实收只保留本票承运,当前自有货清空,车辆和路线仍为权威容量依据。 */ + @BeforeEach void setup() throws Exception { + trip=new FleetTripEntity();trip.setId(2L);trip.setTripNo("TR-1");trip.setMaxPalletSpaces(2); + trip.setMaxLoadKg(new BigDecimal("100"));trip.setMaxVolumeCbm(BigDecimal.TEN); + trip.setStopsJson(json.writeValueAsString(List.of(new FleetTripModels.Stop("起运仓",null,null),new FleetTripModels.Stop("目的仓",null,"FBA:ONT8")))); + row=booking(1L,1); + row.setReceiptJson(json.writeValueAsString(new FleetBookingModels.AcceptedReceipt( + new FleetBookingIncomingGateway.Evidence("WT-1","WT-1","92","FBA:ONT8","READY_FOR_DISPATCH","old", + List.of(new FleetBookingIncomingGateway.Pallet("901","PLT-901",1,BigDecimal.TEN,BigDecimal.ONE)),1), + List.of(new FleetBookingModels.PalletSpaces("901",1,null,null)),"1",Instant.parse("2026-10-01T00:00:00Z")))); + when(bookings.list(2L)).thenReturn(List.of(row)); + } + + /** 构造待替换或其他仍有效承运,测试中不发布任何库存或计划。 */ + private FleetBookingEntity booking(long id,int spaces) throws Exception { + var value=new FleetBookingEntity();value.setId(id);value.setTripId(2L);value.setBookingNo("FB-"+id); + value.setStatus("RECEIVED");value.setDestinationKey("FBA:ONT8"); + value.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8","测试",1,spaces, + BigDecimal.TEN,BigDecimal.ONE,"PALLET",BigDecimal.ONE,"USD",null,null,null))); + return value; + } + + /** 空货物不伪造一份有效自有货计划。 */ + private FleetExecutionModels.Planning emptyOwn() { + return new FleetExecutionModels.Planning("TR-1",1L,List.of(),"empty",null,false,"请先关联本车自有货排车单",List.of()); + } + + /** 已实收重核从零自有货计算;当前承运只作为替换项计算一次。 */ + @Test void receivedReconfirmationFitsWithoutCreatingAnOwnCargoPlan() { + assertThatCode(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).doesNotThrowAnyException(); + verify(bookings,never()).updateById(any(FleetBookingEntity.class)); + } + + /** 首次实收和没有原实收凭据的记录不能借用重核入口。 */ + @Test void firstReceiptOrMissingPreviousReceiptStillRequiresValidOwnPlan() { + row.setStatus("CONFIRMED"); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).hasMessageContaining("自有货容量"); + row.setStatus("RECEIVED");row.setReceiptJson(null); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).hasMessageContaining("自有货容量"); + } + + /** 自有货仍存在时必须保持最新计划,不能借重新实收忽略计划失效。 */ + @Test void nonemptyStaleOwnPlanRemainsBlocked() { + var cargo=new FleetExecutionModels.Cargo("DP-1","9","1","902","PLT-902",1,"FBA:ONT8","目的仓"); + var own=new FleetExecutionModels.Planning("TR-1",1L,List.of(cargo),"stale",null,false,null,List.of()); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,own,row)).hasMessageContaining("自有货容量"); + } + + /** 其他承运仍占位,不能把空自有货解释为清空整车预留。 */ + @Test void otherConfirmedReservationsStillConsumeCapacity() throws Exception { + var other=booking(3L,2);other.setStatus("CONFIRMED");when(bookings.list(2L)).thenReturn(List.of(row,other)); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).isInstanceOf(ResponseStatusException.class); + } + + /** 本票新实测也不得超出当前车辆载重。 */ + @Test void remeasuredExternalCargoStillCannotExceedVehicleWeight() throws Exception { + row.setCargoJson(json.writeValueAsString(new FleetBookingModels.Cargo("FBA:ONT8","测试",1,1, + new BigDecimal("101"),BigDecimal.ONE,"PALLET",BigDecimal.ONE,"USD",null,null,null))); + assertThatThrownBy(()->reservations.requireReceiptCapacity(trip,emptyOwn(),row)).isInstanceOf(ResponseStatusException.class); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 0d016c685..bc0abf03f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -532,10 +532,11 @@ class FleetTripPersistenceTest { } finally { executor.shutdownNow(); } } - @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(booleans={false,true}) - void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic) { + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.CsvSource({"false,false","false,true","true,false","true,true"}) + // 同时验证保留自有货与解绑全部自有货后的实收重新核对,避免依靠额外货物解开前置环。 + void externalReceiptChangesReservationOnlyAfterExplicitActualQuantityAndPriceConfirmation(boolean automatic,boolean emptyOwn) { var source=enableQuote(); - String number=readyTrip();var basis=execution.capacityPlan(number); + String number=emptyOwn?unpickedPlanningTrip():readyTrip();var basis=execution.capacityPlan(number); execution.confirmCapacityPlan(number,"1",new FleetExecutionModels.Confirm(basis.recordVersion(),basis.manifestHash(),List.of(measurement()),"计划")); var draft=bookingService.create(number,"1",new FleetBookingModels.Create(service.detail(number).trip().recordVersion(),9L,externalCargo(2),"receipt-create",quoteReference())); var confirmed=bookingService.confirm(number,draft.booking().bookingNo(),"1",new FleetBookingModels.Command(draft.tripRecordVersion(),0L,"receipt-reserve",null)); @@ -595,15 +596,26 @@ class FleetTripPersistenceTest { assertThat(renewed.booking().cargo().packageCount()).isEqualTo(22); assertThat(bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",receiptAgain).replayed()).isTrue(); assertThat(execution.capacityPlan(number).availableSegments().get(0).remaining().palletSpaces()).isEqualTo(28); + if(emptyOwn) { + for(var binding:service.detail(number).dispatches()) + service.unbindDispatch(number,"1",new FleetTripModels.BindingCommand(service.detail(number).trip().recordVersion(),binding.dispatchNo(),"重新核对前解除全部实际排车")); + assertThat(service.detail(number).dispatches()).isEmpty(); + assertThat(execution.capacityPlan(number).blockReason()).contains("关联本车自有货"); + } + long movedTripVersion=service.detail(number).trip().recordVersion(); // 实物换托后源摘要变化且旧测量失效;缺测量拒绝,显式实测后更新快照及容量。 var moved=new FleetBookingIncomingGateway.Evidence("WT-EXT","WT-EXT","92","FBA:ONT8","READY_FOR_DISPATCH","moved-receipt", List.of(new FleetBookingIncomingGateway.Pallet("903","PALLET-903",22,null,null)),22); when(incoming.inspect("9","WT-EXT",true)).thenReturn(moved); when(incoming.confirm(eq("9"),eq("WT-EXT"),eq("1"),anyString())).thenReturn(moved); - var withoutMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(), + var withoutMeasure=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), List.of(new FleetBookingModels.PalletSpaces("903",1,null,null)),new BigDecimal("55.00"),"moved-confirm"); assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withoutMeasure)).hasMessageContaining("实际托盘"); - var withMeasure=new FleetBookingModels.AcceptReceipt(renewed.tripRecordVersion(),renewed.booking().recordVersion(),moved.receiptHash(), + var overCapacity=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), + List.of(new FleetBookingModels.PalletSpaces("903",31,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-over-capacity"); + assertThatThrownBy(()->bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",overCapacity)).isInstanceOf(ResponseStatusException.class); + assertThat(bookingService.list(number).records().get(0).receipt()).isEqualTo(renewed.booking().receipt()); + var withMeasure=new FleetBookingModels.AcceptReceipt(movedTripVersion,renewed.booking().recordVersion(),moved.receiptHash(), List.of(new FleetBookingModels.PalletSpaces("903",1,new BigDecimal("170"),new BigDecimal("1.7"))),new BigDecimal("55.00"),"moved-confirm"); var measuredAgain=bookingService.acceptReceipt(number,currentBooking.bookingNo(),"1",withMeasure); assertThat(measuredAgain.booking().cargo().weightKg()).isEqualByComparingTo("170"); @@ -1147,6 +1159,15 @@ class FleetTripPersistenceTest { assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment", Integer.class)).isEqualTo(2); } + /** 未拣货的真实自有货计划,允许通过正式命令解绑全部排车。 */ + private String unpickedPlanningTrip() { + String number=assignedTrip();dispatch(11,"CLIENT-A","92");dispatch(12,"CLIENT-B","92"); + service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(1L,"DP-11","加入")); + service.bindDispatch(number,"1",new FleetTripModels.BindingCommand(2L,"DP-12","加入")); + for(long id:List.of(11L,12L)) + jdbc.update("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,warehouse_pallet_id,pallet_no,package_count,source_stage,destination_warehouse_code,overseas_warehouse_id) VALUES(?,?,?,901,'PALLET-901',10,'RECEIVED','ONT8','92')",id+3000,id,id==11?"CLIENT-A":"CLIENT-B"); + return number; + } private String readyTrip() { String number = assignedTrip(); dispatch(11, "CLIENT-A", "92"); dispatch(12, "CLIENT-B", "92"); delivery(11, "CLIENT-A"); delivery(12, "CLIENT-B"); diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs index b7aab25aa..d3ab96a18 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs @@ -188,8 +188,13 @@ function mixedFixture(fault = '') { const samples = [1, 2].map(i => ({ displayKind: i === 1 ? 'ORDINARY' : 'EXTERNAL', dispatchNo: i === 1 ? 'DP-N' : 'DP-E', ordinaryInboundCode: `IN-N${i}`, externalInboundCode: `IN-E${i}`, bookingNo: `BK${i}`, palletId: `30${i}` })) const ctx = context((method, path) => { - if (path.includes('/inventory/balances') && !path.includes('/positions')) return ok({ total: 4, - records: [1, 2].flatMap(i => ['N', 'E'].map(kind => ({ id: `${kind}${i}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${kind}${i}` }))) }) + if (path.includes('/inventory/balances') && !path.includes('/positions')) { + const inboundCode = new URL(path, 'https://test.invalid').searchParams.get('inboundCode') + const all = [1, 2].flatMap(i => ['N', 'E'].map(kind => ({ id: `${kind}${i}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${kind}${i}` }))) + // Without source filtering equal-SKU groups expose only the first source's representative balance IDs. + const records = inboundCode ? all.filter(row => row.inboundCode === inboundCode) : all.filter(row => row.id.endsWith('1')) + return ok({ total: records.length, records }) + } if (path.includes('/positions')) { const balance = path.match(/balances\/([NE][12])\//)[1], kind = balance[0], i = balance[1] return ok([{ id: `POS-${balance}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${balance}`, @@ -208,6 +213,7 @@ test('independent mixed display permutations prove per-source packages and comme const f = mixedFixture(), result = await execute(f.ctx, { mixedSources: f.fixture }) assert.equal(result.cases.mixedSources.status, 'PASS') assert.deepEqual(result.cases.mixedSources.evidence.map(x => x.expectedSources.map(y => y.packages)), [[3, 2], [3, 2]]) + assert.deepEqual(f.ctx.calls.filter(x => x.path.includes('/inventory/balances?')).map(x => new URL(x.path, 'https://test.invalid').searchParams.get('inboundCode')), ['IN-N1', 'IN-E1', 'IN-N2', 'IN-E2']) }) for (const fault of ['duplicate-sample', 'wrong-display', 'wrong-component', 'wrong-quantity', 'wrong-amount']) test(`mixed source ${fault} cannot pass`, async () => { const f = mixedFixture(fault) diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs index ef726f6a6..7e1d42e75 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs @@ -309,8 +309,11 @@ export async function verifyDispatchRepair(context, fixtures, { execute = false const displayed = current.items?.find(row => id(row.warehousePalletId) === id(sample.palletId)) const expectedDisplay = sample.displayKind === 'ORDINARY' ? normal.key : external.key must(displayed?.inboundCode === expectedDisplay && displayed.palletNo && id(current.overseasWarehouseId) === id(f.warehouseId), 'Displayed source does not match its independently produced ordinary/external origin') - const balances = (await pages(`${BASE}/inventory/balances?${new URLSearchParams({ customerId: f.customerId, overseasWarehouseId: f.warehouseId })}`)) - .filter(row => id(row.customerId) === id(f.customerId) && id(row.overseasWarehouseId) === id(f.warehouseId)) + // The list groups equal SKUs across inbounds; constrain each real source before taking representative balance IDs. + const sourceBalances = [] + for (const inboundCode of [normal.key, external.key]) sourceBalances.push(...await pages(`${BASE}/inventory/balances?${new URLSearchParams({ customerId: f.customerId, overseasWarehouseId: f.warehouseId, inboundCode })}`)) + const balances = [...new Map(sourceBalances.filter(row => id(row.customerId) === id(f.customerId) + && id(row.overseasWarehouseId) === id(f.warehouseId)).map(row => [id(row.id), row])).values()] const quantities = new Map(), positions = [] for (const balance of balances) { const rows = await call('GET', `${BASE}/inventory/balances/${encodeURIComponent(id(balance.id))}/positions?${new URLSearchParams({ customerId: f.customerId, palletNo: displayed.palletNo })}`) From e8f6bb32dc5fcb7351376e1a2e7087adf7cf8529 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 10:23:09 +0800 Subject: [PATCH 014/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 62 ++++ .../docs/warehouse-transfer-pickup.md | 15 + .../fleet/pickup/FleetPickupMapper.java | 72 +++++ .../fleet/pickup/FleetPickupModels.java | 40 +++ .../fleet/pickup/FleetPickupRepository.java | 58 ++++ .../fleet/pickup/FleetPickupService.java | 269 ++++++++++++++++++ .../lastleg/fleet/pickup/FleetPickupTask.java | 22 ++ .../pickup/PlatformFleetPickupController.java | 81 ++++++ .../TransferPickupProgressController.java | 55 ++++ .../fleet/trip/FleetBookingService.java | 3 +- .../fleet/trip/FleetCapacityOfferService.java | 5 +- .../fleet/trip/FleetResourceMapper.java | 5 + .../lastleg/fleet/trip/FleetTripEntity.java | 2 + .../fleet/trip/FleetTripExecutionService.java | 2 + .../lastleg/fleet/trip/FleetTripModels.java | 7 +- .../fleet/trip/FleetTripRepository.java | 59 ++++ .../lastleg/fleet/trip/FleetTripService.java | 157 +++++++--- .../trip/PlatformFleetTripController.java | 11 + .../service/WarehouseTransferService.java | 10 +- .../src/main/resources/db/lastleg.sql | 54 ++++ .../openapi/fleet-transfer-pickup-v1.yaml | 159 +++++++++++ .../resources/openapi/fleet-trips-v1.yaml | 31 ++ .../fleet/pickup/FleetPickupMapperTest.java | 42 +++ .../fleet/pickup/FleetPickupServiceTest.java | 106 +++++++ .../fleet/trip/FleetTripPersistenceTest.java | 23 +- .../WarehouseTransferCartonCountTest.java | 3 + .../service/WarehouseTransferServiceTest.java | 3 + scripts/README.md | 4 + scripts/db/fleet-transfer-pickup-20261017.sql | 60 ++++ scripts/local-transfer-pickup-acceptance.mjs | 241 ++++++++++++++++ 30 files changed, 1612 insertions(+), 49 deletions(-) create mode 100644 lastleg-service/docs/warehouse-transfer-pickup.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java create mode 100644 scripts/db/fleet-transfer-pickup-20261017.sql create mode 100644 scripts/local-transfer-pickup-acceptance.mjs diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d58001da3..79598ecf6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8366,3 +8366,65 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; + +-- 2026-10-17 仓储转运上门取货,目标 baiyue_lastleg。 +-- 目标数据库:baiyue_lastleg;仅增加结构,不修改既有业务数据,可重复执行。 +USE baiyue_lastleg; +SET @pickup_column_sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_trip' AND column_name='trip_purpose')=0, + 'ALTER TABLE ll_fleet_trip ADD COLUMN trip_purpose VARCHAR(16) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''DELIVERY出库派送 PICKUP上门取货入仓''', + 'SELECT 1'); +PREPARE pickup_column_stmt FROM @pickup_column_sql; +EXECUTE pickup_column_stmt; +DEALLOCATE PREPARE pickup_column_stmt; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_plan ( + trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号', + request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要', + PRIMARY KEY(trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_task ( + transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号', + trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空', + status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', + actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收', + picked_at DATETIME NULL COMMENT '实际确认取货UTC时间', + arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间', + evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据', + remark VARCHAR(1000) NULL COMMENT '取货备注', + updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间', + PRIMARY KEY(transfer_order_id), + UNIQUE KEY uk_pickup_source(customer_id,transfer_no), + KEY idx_pickup_trip(trip_no,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_command ( + trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号', + request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要', + PRIMARY KEY(trip_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_upload ( + transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单', + trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次', + file_id BIGINT NOT NULL COMMENT '平台归属文件ID', + PRIMARY KEY(file_id), + KEY idx_pickup_upload_source(transfer_order_id,trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号', + trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号', + action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME NOT NULL COMMENT '操作UTC时间', + detail VARCHAR(2000) NULL COMMENT '操作说明', + PRIMARY KEY(id), + KEY idx_pickup_history_source(customer_id,transfer_no,id), + KEY idx_pickup_history_trip(trip_no,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; diff --git a/lastleg-service/docs/warehouse-transfer-pickup.md b/lastleg-service/docs/warehouse-transfer-pickup.md new file mode 100644 index 000000000..57acd95b8 --- /dev/null +++ b/lastleg-service/docs/warehouse-transfer-pickup.md @@ -0,0 +1,15 @@ +# 仓储转运上门取货与车辆排车 + +客户选择仓库上门取货并提交,平台审核通过后,原转运单进入车辆排车的“待取货”。一个取货车次可取多名客户的多票货,目的海外仓必须相同。客户自送、未审核、库存转运和测量已完成的订单不进入候选。 + +业务入口为 `fleet/pickup/PlatformFleetPickupController`,按 Controller → Service → Repository → Mapper 持久化,复用原车次编号、车辆司机分配和资源占用。`tripPurpose=PICKUP` 区别于原 `DELIVERY`,取货车次不关联出库排车、不发布拼车余位、不执行原出库装载和扣库存。 + +车次按 `PLANNED → IN_TRANSIT → COMPLETED` 执行。逐票取货任务为 `ASSIGNED → PICKED → ARRIVED`:发车复验车辆司机资格和实际占用;取货必须填写实提箱数并上传本票凭证;到仓后才开放原 PDA 清点测量。实提箱数独立保存,不覆盖客户预报箱数、SKU件数和箱唛。全车送仓可结束运输,车辆与司机任务分别沿原入口释放,不需要等仓内测量。 + +计划阶段取消车次会保留操作历史、释放资源和原单关联,使原单重新可安排;发车后禁止取消。原车次分配入口支持发车前改派。所有取货命令在车次行锁、版本校验和持久幂等摘要下执行;来源安排同时锁原单,避免一单多车。前端遇到结果未知保留原请求和请求键,阻止刷新卸载或改派后重新生成命令。 + +平台待取货使用服务端分页;客户详情、平台尾程详情按原转运单查询同一运输进度。凭证只允许当前单已确认引用,本车其他客户文件不能借用或下载。文件数据归 File 服务,Lastleg仅持有文件归属和引用。 + +公开接口与请求字段见 `src/main/resources/openapi/fleet-transfer-pickup-v1.yaml`。数据库增量为 `../../scripts/db/fleet-transfer-pickup-20261017.sql`,目标 `baiyue_lastleg`,可重复执行,无业务 DML。 + +本机验收入口 `../../scripts/local-transfer-pickup-acceptance.mjs` 创建带 TEST 标记的新业务记录并保存报告。覆盖待取货、未审核、自送、异仓拒绝、共车、取消改派、资源冲突、同键重放与异载荷拒绝、跨客户文件隔离、未送仓禁止PDA和送仓后完整测量。合成图片仅验证上传与文件归属,不证明物理取货。本机构建、单测和HTTP验收与远端部署、真实页面视觉验收分别记录。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java new file mode 100644 index 000000000..161a4281d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.fleet.pickup; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 取货持久化只访问 Lastleg 自有库,客户与原业务号始终成对使用。 */ +@Mapper +public interface FleetPickupMapper extends BaseMapper { + String ELIGIBLE = " FROM ll_warehouse_transfer_order w LEFT JOIN ll_fleet_pickup_task p ON p.transfer_order_id=w.id " + + "WHERE w.delivery_method='WAREHOUSE_PICKUP' AND w.transfer_no=w.inbound_code " + + "AND w.status='WAIT_PDA_RECEIVING' AND (p.transfer_order_id IS NULL OR p.trip_no IS NULL) " + + "AND (#{keyword} IS NULL OR w.transfer_no LIKE CONCAT('%',#{keyword},'%') OR w.delivery_address LIKE CONCAT('%',#{keyword},'%')) "; + /** 分页读取已经审核、尚未安排的上门取货需求。 */ + @Select("SELECT w.customer_id,w.transfer_no,w.lastleg_order_no,w.operating_warehouse_id AS warehouse_id," + + "w.delivery_address AS pickup_address,w.pickup_contact_name AS contact_name,w.pickup_contact_phone AS contact_phone," + + "w.package_count,w.estimated_arrival_at AS expected_arrival_at,NULL AS warehouse_code,NULL AS warehouse_name" + ELIGIBLE + "ORDER BY w.created_at,w.id LIMIT #{limit} OFFSET #{offset}") + List candidates(@Param("keyword") String keyword,@Param("limit") int limit,@Param("offset") int offset); + /** 与分页使用同一资格条件,避免浏览器用当前页推总数。 */ + @Select("SELECT COUNT(*)" + ELIGIBLE) + long countCandidates(@Param("keyword") String keyword); + /** 锁定源订单,串行化补箱、清点与取货任务关联。 */ + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE") + WarehouseTransferOrder lockSource(@Param("customer") String customer,@Param("number") String number); + /** 读取明确客户下的源订单,不接收来自客户端的客户范围推断。 */ + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}") + WarehouseTransferOrder source(@Param("customer") String customer,@Param("number") String number); + /** PDA 作业仍按已绑定海外仓锁定原单。 */ + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouse} AND transfer_no=#{number} FOR UPDATE") + WarehouseTransferOrder lockPdaSource(@Param("warehouse") String warehouse,@Param("number") String number); + /** 读取取货任务,原订单锁负责同票并发互斥。 */ + @Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id}") + FleetPickupTask task(@Param("id") long id); + /** 同票安排在原单锁后使用当前读,不受早先查询快照影响。 */ + @Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id} FOR UPDATE") + FleetPickupTask lockTask(@Param("id") long id); + /** 一车多票按原订单编号稳定排序。 */ + @Select("SELECT * FROM ll_fleet_pickup_task WHERE trip_no=#{tripNo} ORDER BY transfer_order_id") + List tasks(@Param("tripNo") String tripNo); + /** 解除计划中的取货关联,保留历史后允许再次安排。 */ + @Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'") + int unassign(@Param("tripNo") String tripNo); + /** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */ + @Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})") + int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip, + @Param("action") String action,@Param("operator") String operator,@Param("detail") String detail); + /** 关联取货后禁止再把同一票登记为另一条拼车来货。 */ + @Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link p JOIN ll_fleet_external_booking b ON b.id=p.booking_id WHERE p.customer_id=#{customer} AND p.incoming_no=#{number} AND b.status!='CANCELLED'") + long poolLinks(@Param("customer") String customer,@Param("number") String number); + /** 创建请求保存完整来源摘要,原单后续变化不影响未知结果重试。 */ + @Insert("INSERT INTO ll_fleet_pickup_plan(trip_no,request_hash) VALUES(#{trip},#{hash})") + int insertPlan(@Param("trip") String trip,@Param("hash") String hash); + /** 查询已提交的取货计划请求。 */ + @Select("SELECT request_hash FROM ll_fleet_pickup_plan WHERE trip_no=#{trip}") + String planHash(@Param("trip") String trip); + /** 车次行锁内核对同键同内容。 */ + @Select("SELECT request_hash FROM ll_fleet_pickup_command WHERE trip_no=#{trip} AND request_key=#{key}") + String commandHash(@Param("trip") String trip,@Param("key") String key); + /** 命令结果与取货业务变更同事务提交。 */ + @Insert("INSERT INTO ll_fleet_pickup_command(trip_no,request_key,request_hash) VALUES(#{trip},#{key},#{hash})") + int command(@Param("trip") String trip,@Param("key") String key,@Param("hash") String hash); + /** 上传阶段保存精确单据归属,避免同车不同客户的凭证串票。 */ + @Insert("INSERT INTO ll_fleet_pickup_upload(transfer_order_id,trip_no,file_id) VALUES(#{source},#{trip},#{file})") + int upload(@Param("source") long source,@Param("trip") String trip,@Param("file") long file); + /** 只能引用本票本车次已上传的文件。 */ + @Select("SELECT COUNT(*) FROM ll_fleet_pickup_upload WHERE transfer_order_id=#{source} AND trip_no=#{trip} AND file_id=#{file}") + int ownsUpload(@Param("source") long source,@Param("trip") String trip,@Param("file") long file); + /** 发车时核对车辆司机仍被本车次实际占用。 */ + @Select("SELECT COUNT(*) FROM ll_fleet_resource_assignment WHERE trip_id=#{trip} AND ((resource_kind='VEHICLE' AND resource_id=#{vehicle}) OR (resource_kind='DRIVER' AND resource_id=#{driver}))") + int resources(@Param("trip") long trip,@Param("vehicle") long vehicle,@Param("driver") long driver); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java new file mode 100644 index 000000000..5c9544cfb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.fleet.pickup; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +/** 入仓取货的请求与投影,不把取货箱数当成仓库实收。 */ +public final class FleetPickupModels { + /** 工具类不允许实例化。 */ + private FleetPickupModels() { } + public record Source(@NotBlank String customerId, @NotBlank String transferNo) { } + public record Query(@Min(1) Integer page, @Min(1) @Max(100) Integer size, @Size(max=128) String keyword) { + /** 缺省分页只读取当前工作列表。 */ + public Query { page = page == null ? 1 : page; size = size == null ? 20 : size; } + } + public record Candidate(String customerId, String transferNo, String lastlegOrderNo, String warehouseId, + String pickupAddress, String contactName, String contactPhone, int packageCount, + LocalDateTime expectedArrivalAt, String warehouseCode, String warehouseName) { } + public record Create(@NotNull @Size(min=1,max=100) List<@Valid Source> sources, + @NotNull Instant plannedDepartureAt, @NotNull Instant expectedDriverEndAt, + @Size(max=1000) String remark, + @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Command(@NotNull @Min(0) Long recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotBlank @Size(max=1000) String reason) { } + public record Pickup(@NotNull @Min(0) Long recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey, + @Min(1) @Max(5000) int actualPackages, + @NotNull @Size(min=1,max=10) List<@NotNull @Positive Long> evidenceFileIds, + @Size(max=1000) String remark) { } + public record Task(String customerId, String transferNo, String lastlegOrderNo, String warehouseId, + String pickupAddress, String contactName, String contactPhone, int packageCount, + String status, String tripNo, String vehiclePlate, String driverName, String driverPhone, + Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt, + String remark, List evidence) { } + public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { } + public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java new file mode 100644 index 000000000..70eb1ebd5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.fleet.pickup; + +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.baiyue.lastleg.fleet.trip.FleetTripMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 封装取货单与同库车次的持久化,不承载状态流转。 */ +@Repository +public class FleetPickupRepository { + private final FleetPickupMapper mapper; + private final FleetTripMapper trips; + /** 注入所属域的 Mapper。 */ + public FleetPickupRepository(FleetPickupMapper mapper,FleetTripMapper trips) { this.mapper=mapper;this.trips=trips; } + /** 查询当前分页候选。 */ + public List candidates(String keyword,int limit,int offset) { return mapper.candidates(keyword,limit,offset); } + /** 查询匹配需求总数。 */ + public long count(String keyword) { return mapper.countCandidates(keyword); } + /** 读取或锁定客户原单。 */ + public WarehouseTransferOrder source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); } + /** 按 PDA 仓库范围锁定原单。 */ + public WarehouseTransferOrder pdaSource(String warehouse,String number) { return mapper.lockPdaSource(warehouse,number); } + /** 读取原单唯一取货记录。 */ + public FleetPickupTask task(long id) { return mapper.task(id); } + /** 读取车次全部取货成员。 */ + public List tasks(String number) { return mapper.tasks(number); } + /** 写入首次安排。 */ + public int insert(FleetPickupTask task) { return mapper.insert(task); } + /** 保存已有取货执行记录。 */ + public int update(FleetPickupTask task) { return mapper.updateById(task); } + /** 读取或锁定共享车次。 */ + public FleetTripEntity trip(String number,boolean lock) { return lock?trips.lockByNo(number):trips.findByNo(number); } + /** 按原幂等键查询已创建车次。 */ + public FleetTripEntity created(String operator,String key) { return trips.findCreated(operator,key); } + /** 创建资格必须以锁定后的当前记录为准。 */ + public FleetPickupTask lockTask(long id) { return mapper.lockTask(id); } + /** 释放未开始的成员关联。 */ + public int unassign(String number) { return mapper.unassign(number); } + /** 保存不可变业务操作记录。 */ + public void history(FleetPickupTask task,String action,String operator,String detail) { mapper.history(task.getCustomerId(),task.getTransferNo(),task.getTripNo(),action,operator,detail); } + /** 核对旧拼车承运关系,防止同票走两条运输链。 */ + public boolean hasPoolLink(String customer,String number) { return mapper.poolLinks(customer,number)>0; } + /** 写入取货计划来源摘要。 */ + public void insertPlan(String trip,String hash) { mapper.insertPlan(trip,hash); } + /** 读取计划原请求摘要。 */ + public String planHash(String trip) { return mapper.planHash(trip); } + /** 读取命令的幂等摘要。 */ + public String commandHash(String trip,String key) { return mapper.commandHash(trip,key); } + /** 保存已成功执行的命令。 */ + public void command(String trip,String key,String hash) { mapper.command(trip,key,hash); } + /** 保存本票的上传归属。 */ + public void upload(long source,String trip,long file) { mapper.upload(source,trip,file); } + /** 核对凭证是否属于同票同车次。 */ + public boolean ownsUpload(long source,String trip,long file) { return mapper.ownsUpload(source,trip,file)==1; } + /** 两种资源须同时属于当前车次。 */ + public boolean ownsResources(long trip,long vehicle,long driver) { return mapper.resources(trip,vehicle,driver)==2; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java new file mode 100644 index 000000000..f454ebfb4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java @@ -0,0 +1,269 @@ +package com.baiyue.lastleg.fleet.pickup; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory; +import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.util.*; + +/** 入仓取货只记录运输事实,不创建第二份订单、不写库存和测量完成状态。 */ +@Service +public class FleetPickupService { + private final FleetPickupRepository repository; + private final FleetTripService trips; + private final FleetWarehouseDirectory warehouses; + private final DriverService drivers; + private final VehicleRepository vehicles; + private final TripOperationsEvidenceGateway files; + private final ObjectMapper json; + /** 共用现有车次、司机资格、车辆占用及鉴权文件能力。 */ + public FleetPickupService(FleetPickupRepository repository,FleetTripService trips,FleetWarehouseDirectory warehouses, + DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) { + this.repository=repository;this.trips=trips;this.warehouses=warehouses;this.drivers=drivers; + this.vehicles=vehicles;this.files=files;this.json=json; + } + /** 审核通过的原单自动出现在候选中,客户自送和库存转运不参与。 */ + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public PageResult candidates(FleetPickupModels.Query query) { + String keyword=blank(query.keyword());long count=repository.count(keyword); + var rows=repository.candidates(keyword,query.size(),Math.multiplyExact(query.page()-1,query.size())); + var directory=warehouses.options("OVERSEAS").stream().collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.driver.DriverModels.WarehouseOption::id,v->v)); + var enriched=rows.stream().map(row->{var warehouse=directory.get(row.warehouseId()); + return new FleetPickupModels.Candidate(row.customerId(),row.transferNo(),row.lastlegOrderNo(),row.warehouseId(),row.pickupAddress(),row.contactName(),row.contactPhone(),row.packageCount(),row.expectedArrivalAt(),warehouse==null?null:warehouse.code(),warehouse==null?"仓库已停用":warehouse.name());}).toList(); + return new PageResult<>(enriched, + query.page(),query.size(),count,(count+query.size()-1)/query.size()); + } + /** 锁定源订单创建同仓多点取货车次,任一来源失败时整车回滚。 */ + @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) + public FleetTripModels.Detail create(String operator,FleetPickupModels.Create request) { + String requestHash=hash(write(request)); + var prior=repository.created(operator,request.idempotencyKey()); + if(prior!=null) { + if(!"PICKUP".equals(prior.getTripPurpose()) || !requestHash.equals(repository.planHash(prior.getTripNo()))) + throw conflict("同一安排请求不能更换来源或时间"); + return trips.detail(prior.getTripNo()); + } + var sources=request.sources().stream().sorted(Comparator.comparing(FleetPickupModels.Source::customerId) + .thenComparing(FleetPickupModels.Source::transferNo)).toList(); + if(new HashSet<>(sources).size()!=sources.size())throw bad("同一取货单不能重复选择"); + var orders=new ArrayList(); + for(var source:sources) { + var order=source(source.customerId(),source.transferNo(),true);eligible(order); + // 相同请求并发等待原单锁后,允许返回第一次已经成功的车次。 + var committed=repository.created(operator,request.idempotencyKey()); + if(committed!=null) { + if(!"PICKUP".equals(committed.getTripPurpose()) || !requestHash.equals(repository.planHash(committed.getTripNo())))throw conflict("同一安排请求不能更换来源或时间"); + return trips.detail(committed.getTripNo()); + } + var task=repository.lockTask(order.getId()); + if(task!=null && task.getTripNo()!=null)throw conflict("转运单已安排车次:"+task.getTripNo()); + if(repository.hasPoolLink(source.customerId(),source.transferNo()))throw conflict("该单已有拼车承运安排,不能重复安排取货"); + orders.add(order); + } + String warehouseId=orders.get(0).getOperatingWarehouseId(); + if(orders.stream().anyMatch(order->!warehouseId.equals(order.getOperatingWarehouseId())))throw bad("同车取货必须送往同一海外仓"); + var warehouse=warehouses.required(Long.valueOf(warehouseId),"OVERSEAS"); + // 站点按原订单稳定生成,最后一站固定为客户选择的收货仓。 + var stops=new ArrayList(); + for(var order:orders)stops.add(new FleetTripModels.Stop(order.getTransferNo(),order.getDeliveryAddress(),null)); + stops.add(new FleetTripModels.Stop(warehouse.name(),null,"PICKUP_WAREHOUSE:"+warehouseId)); + var created=trips.createPickup(operator,new FleetTripModels.Create(Long.valueOf(warehouseId),request.plannedDepartureAt(), + request.expectedDriverEndAt(),stops,request.remark(),request.idempotencyKey())); + var trip=repository.trip(created.trip().tripNo(),true); + repository.insertPlan(trip.getTripNo(),requestHash); + for(var order:orders) { + var task=repository.task(order.getId());boolean fresh=task==null; + if(fresh) { task=new FleetPickupTask();task.setTransferOrderId(order.getId());task.setCustomerId(order.getCustomerId());task.setTransferNo(order.getTransferNo()); } + task.setTripNo(trip.getTripNo());task.setStatus("ASSIGNED");task.setUpdatedAt(now()); + if(fresh)repository.insert(task);else repository.update(task); + repository.history(task,"ASSIGN",operator,"安排仓储转运上门取货"); + } + trips.recordExecution(trip,trip.getRecordVersion(),"PICKUP_PLAN","关联原转运单取货",operator); + return trips.detail(trip.getTripNo()); + } + /** 按车次读取取货成员,只有专用用途的车次能进入此链路。 */ + @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public List tasks(String tripNo) { + pickupTrip(tripNo,false); + return repository.tasks(tripNo).stream().map(task->project(source(task.getCustomerId(),task.getTransferNo(),false),task)).toList(); + } + /** 客户和平台详情读取同一取货进度,但只返回当前票的凭证。 */ + @Transactional(readOnly=true) + public FleetPickupModels.Task progress(String customer,String number) { + var source=source(customer,number,false); + if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !source.getTransferNo().equals(source.getInboundCode()))return null; + return project(source,repository.task(source.getId())); + } + /** PDA 开始作业前校验已送仓,防止把司机取货冒充仓库实收。 */ + public WarehouseTransferOrder requireMeasuringSource(String warehouse,String number) { + var source=repository.pdaSource(warehouse,number); + if(source==null)throw missing("仓储转运单不存在"); + if(!"WAIT_PDA_RECEIVING".equals(source.getStatus()))throw conflict("当前转运单不允许测量"); + requireArrived(source); + return source; + } + /** 已有原单行锁的清点、照片和完成入口复用相同到仓门禁。 */ + public void requireArrived(WarehouseTransferOrder source) { + if("WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) && source.getTransferNo().equals(source.getInboundCode())) { + var task=repository.task(source.getId()); + if(task==null || !"ARRIVED".equals(task.getStatus()))throw conflict("该单为仓库上门取货,请先完成车辆取货送仓"); + } + } + /** 分配后复验车辆和司机,发车不触发任何出库扣库存。 */ + @Transactional + public FleetTripModels.Detail depart(String number,String operator,FleetPickupModels.Command request) { + var trip=pickupTrip(number,true);String hash=commandHash("DEPART",request); + if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number); + version(trip,request.recordVersion()); + if(!"PLANNED".equals(trip.getStatus()) || repository.tasks(number).isEmpty())throw conflict("仅有取货任务的计划车次可以发车"); + requireResources(trip); + for(var task:repository.tasks(number))eligible(source(task.getCustomerId(),task.getTransferNo(),true)); + trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now()); + trips.recordExecution(trip,request.recordVersion(),"PICKUP_DEPART",request.reason(),operator); + repository.command(number,request.idempotencyKey(),hash);return trips.detail(number); + } + /** 取货凭证上传只绑定当前票所属车次,上传本身不推进取货状态。 */ + @Transactional + public FleetPickupModels.Evidence upload(String number,String customer,String transferNo,String operator,MultipartFile file) { + var trip=pickupTrip(number,true);var task=member(trip,customer,transferNo); + if(!Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()))throw conflict("当前车次不能上传取货凭证"); + if(file==null || file.isEmpty() || file.getSize()>10L*1024*1024)throw bad("请上传不超过10MB的取货凭证"); + var value=files.upload(trip.getId(),operator,file); + repository.upload(task.getTransferOrderId(),number,Long.parseLong(value.fileId())); + return new FleetPickupModels.Evidence(String.valueOf(value.fileId()),value.fileName(),value.contentType(),value.sizeBytes()); + } + /** 逐票确认实提箱数与凭证,数量可以与预报不同,留给仓库独立实收。 */ + @Transactional + public List picked(String number,String customer,String transferNo,String operator,FleetPickupModels.Pickup request) { + var trip=pickupTrip(number,true);String hash=commandHash("PICKED:"+customer+":"+transferNo,request); + if(replayed(trip,request.idempotencyKey(),hash))return tasks(number); + version(trip,request.recordVersion());var task=member(trip,customer,transferNo); + if(!"IN_TRANSIT".equals(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("请先发车,且该单尚未确认取货"); + eligible(source(customer,transferNo,true)); + var evidence=request.evidenceFileIds().stream().distinct().map(id->{ + if(!repository.ownsUpload(task.getTransferOrderId(),number,id))throw bad("取货凭证不属于当前订单或车次"); + var file=files.metadata(trip.getId(),id); + return new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes()); + }).toList(); + task.setStatus("PICKED");task.setActualPackages(request.actualPackages());task.setPickedAt(now()); + task.setEvidenceJson(write(evidence));task.setRemark(blank(request.remark()));task.setUpdatedAt(now());repository.update(task); + repository.history(task,"PICKED",operator,"实提"+request.actualPackages()+"箱;"+Objects.toString(task.getRemark(),"")); + trips.recordExecution(trip,request.recordVersion(),"PICKUP_PICKED","确认原单取货",operator); + repository.command(number,request.idempotencyKey(),hash);return tasks(number); + } + /** 到仓确认只开放 PDA 清点,不覆盖预报箱数和 SKU 件数。 */ + @Transactional + public List arrive(String number,String customer,String transferNo,String operator,FleetPickupModels.Command request) { + var trip=pickupTrip(number,true);String hash=commandHash("ARRIVED:"+customer+":"+transferNo,request); + if(replayed(trip,request.idempotencyKey(),hash))return tasks(number); + version(trip,request.recordVersion());var task=member(trip,customer,transferNo); + if(!"IN_TRANSIT".equals(trip.getStatus()) || !"PICKED".equals(task.getStatus()))throw conflict("请先确认该单已取货"); + source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setUpdatedAt(now());repository.update(task); + repository.history(task,"ARRIVED",operator,request.reason()); + trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator); + repository.command(number,request.idempotencyKey(),hash);return tasks(number); + } + /** 全部送仓后结案运输,车辆和司机仍沿原有入口分别结束任务。 */ + @Transactional + public FleetTripModels.Detail complete(String number,String operator,FleetPickupModels.Command request) { + var trip=pickupTrip(number,true);String hash=commandHash("COMPLETE",request); + if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number); + version(trip,request.recordVersion());var tasks=repository.tasks(number); + if(!"IN_TRANSIT".equals(trip.getStatus()) || tasks.isEmpty() || tasks.stream().anyMatch(task->!"ARRIVED".equals(task.getStatus()))) + throw conflict("请先确认本车全部取货订单已送达海外仓"); + var completedAt=tasks.stream().map(FleetPickupTask::getArrivedAt).max(Comparator.naturalOrder()).orElseThrow(); + trip.setStatus("COMPLETED");trip.setCompletedAt(completedAt); + // 这里保存运输结束凭据;不虚构派送签收单,也不等待仓库测量完成。 + trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,completedAt.toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of()))); + trips.recordExecution(trip,request.recordVersion(),"PICKUP_COMPLETE",request.reason(),operator); + repository.command(number,request.idempotencyKey(),hash);return trips.detail(number); + } + /** 读取已确认凭证时必须核对具体客户、订单和文件引用,不因共车暴露其他客户照片。 */ + public byte[] evidence(String customer,String transferNo,long fileId) { + var order=source(customer,transferNo,false);var task=repository.task(order.getId()); + if(task==null || evidence(task).stream().noneMatch(file->file.fileId().equals(String.valueOf(fileId))))throw missing("当前订单没有该取货凭证"); + var trip=pickupTrip(task.getTripNo(),false);return files.download(trip.getId(),fileId); + } + /** 组装运输进度,车次资源修改后自然回显最新分配。 */ + private FleetPickupModels.Task project(WarehouseTransferOrder source,FleetPickupTask task) { + var trip=task==null || task.getTripNo()==null?null:repository.trip(task.getTripNo(),false); + String status=task==null || task.getTripNo()==null?("PENDING_REVIEW".equals(source.getStatus()) || "DRAFT".equals(source.getStatus())?"PENDING_REVIEW":"WAITING"):task.getStatus(); + return new FleetPickupModels.Task(source.getCustomerId(),source.getTransferNo(),source.getLastlegOrderNo(),source.getOperatingWarehouseId(), + source.getDeliveryAddress(),source.getPickupContactName(),source.getPickupContactPhone(),source.getPackageCount(),status, + trip==null?null:trip.getTripNo(),trip==null?null:trip.getVehiclePlate(),trip==null?null:trip.getDriverName(),trip==null?null:trip.getDriverPhone(), + trip==null?null:instant(trip.getPlannedDepartureAt()),task==null?null:task.getActualPackages(),task==null?null:instant(task.getPickedAt()), + task==null?null:instant(task.getArrivedAt()),task==null?null:task.getRemark(),task==null?List.of():evidence(task)); + } + /** 解析任务已确认文件清单。 */ + private List evidence(FleetPickupTask task) { return task.getEvidenceJson()==null?List.of():read(task.getEvidenceJson(),new TypeReference<>(){}); } + /** 当前车次成员由服务端复核,客户 ID 不能单独授权操作。 */ + private FleetPickupTask member(FleetTripEntity trip,String customer,String number) { + var source=source(customer,number,false);var task=repository.task(source.getId()); + if(task==null || !trip.getTripNo().equals(task.getTripNo()))throw missing("当前车次没有该取货订单");return task; + } + /** 按客户与单号读取原订单。 */ + private WarehouseTransferOrder source(String customer,String number,boolean lock) { + var source=repository.source(customer,number,lock);if(source==null)throw missing("仓储转运单不存在");return source; + } + /** 原业务资格每次执行复核,禁止未审、自送、库存转运和已完成测量单进入。 */ + private static void eligible(WarehouseTransferOrder order) { + if(!"WAREHOUSE_PICKUP".equals(order.getDeliveryMethod()) || !order.getTransferNo().equals(order.getInboundCode()) + || !"WAIT_PDA_RECEIVING".equals(order.getStatus()))throw conflict("该单不是已审核待收货的仓库上门取货单"); + if(blank(order.getDeliveryAddress())==null || blank(order.getPickupContactName())==null || blank(order.getPickupContactPhone())==null) + throw bad("请补齐原转运单的取货地址、联系人和电话"); + } + /** 取货用途与派送用途不允许互相调用执行命令。 */ + private FleetTripEntity pickupTrip(String number,boolean lock) { + var trip=repository.trip(number,lock);if(trip==null)throw missing("车次不存在"); + if(!"PICKUP".equals(trip.getTripPurpose()))throw conflict("当前车次不是入仓取货车次");return trip; + } + /** 发车复验资源资格,避免分配后车辆停用或证照过期。 */ + private void requireResources(FleetTripEntity trip) { + if(trip.getVehicleId()==null || trip.getDriverId()==null)throw conflict("请先分配车辆和司机"); + if(!repository.ownsResources(trip.getId(),trip.getVehicleId(),trip.getDriverId()))throw conflict("车辆或司机占用与当前车次不一致,请重新核对分配"); + var vehicle=vehicles.lockById(trip.getVehicleId()).orElseThrow(()->conflict("车辆不存在")); + if(!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status()))throw conflict("当前车辆不可出车"); + var driver=drivers.requireDispatchable(trip.getDriverId(),instant(trip.getExpectedDriverEndAt())); + if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机"); + } + /** 已成功命令同键同内容重放,禁止同键更换动作或订单。 */ + private boolean replayed(FleetTripEntity trip,String key,String hash) { + String previous=repository.commandHash(trip.getTripNo(),key); + if(previous!=null && !previous.equals(hash))throw conflict("同一请求标识不能提交不同的取货内容");return previous!=null; + } + /** 当前版本防止调度和现场操作相互覆盖。 */ + private static void version(FleetTripEntity trip,long version) { if(trip.getRecordVersion()!=version)throw conflict("车次已变化,请刷新后重试"); } + /** 动作与请求一起摘要,隔离共用请求键。 */ + private String commandHash(String action,Object request) { return hash(action+":"+write(request)); } + /** 固定序列化持久化命令和凭证。 */ + private String write(Object value) { try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException("取货数据序列化失败",error);} } + /** 解析服务器保存的凭证数据。 */ + private T read(String value,TypeReference type) { try{return json.readValue(value,type);}catch(Exception error){throw new IllegalStateException("取货凭证读取失败",error);} } + /** 创建不可逆请求摘要,不在日志记录完整请求。 */ + private static String hash(String value) { try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);} } + /** 统一以 UTC 保存车次履约时刻。 */ + private static LocalDateTime now() { return LocalDateTime.now(ZoneOffset.UTC).withNano(0); } + /** 转换可空的数据库时刻。 */ + private static Instant instant(LocalDateTime value) { return value==null?null:value.toInstant(ZoneOffset.UTC); } + /** 空白输入统一为空。 */ + private static String blank(String value) { return value==null || value.isBlank()?null:value.trim(); } + /** 返回明确的业务冲突。 */ + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); } + /** 返回参数错误。 */ + private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST,message); } + /** 隐藏不属于当前业务范围的资源。 */ + private static ResponseStatusException missing(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java new file mode 100644 index 000000000..99895c641 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.fleet.pickup; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** 每票转运单唯一一条取货执行记录,改派历史另行保存。 */ +@Data +@TableName("ll_fleet_pickup_task") +public class FleetPickupTask { + @TableId(type=IdType.INPUT) private Long transferOrderId; + private String customerId; + private String transferNo; + @TableField(updateStrategy=FieldStrategy.ALWAYS) private String tripNo; + private String status; + private Integer actualPackages; + private LocalDateTime pickedAt; + private LocalDateTime arrivedAt; + private String evidenceJson; + private String remark; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java new file mode 100644 index 000000000..a636bbd01 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.fleet.pickup; + +import com.baiyue.common.core.api.*; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.trip.FleetTripModels; +import jakarta.validation.Valid; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +/** 取货调度复用车次既有独立权限,所有入口必须为平台主体。 */ +@Validated +@RestController +@RequestMapping("/api/platform/v1/last-leg/fleet/pickups") +public class PlatformFleetPickupController { + private final FleetPickupService service; + /** 注入取货业务编排。 */ + public PlatformFleetPickupController(FleetPickupService service) { this.service=service; } + /** 查询自动汇入的待取货需求。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> candidates(@AuthenticationPrincipal AuthenticatedUser user,@Valid FleetPickupModels.Query query) { + operator(user);return ApiResponse.success(service.candidates(query)); + } + /** 创建一仓多点取货车次,不重复创建尾程单。 */ + @PostMapping + @PreAuthorize("hasAuthority('platform:fleet:trip:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetPickupModels.Create request) { + return ApiResponse.success(service.create(operator(user),request)); + } + /** 查看车次取货成员。 */ + @GetMapping("/{tripNo}/tasks") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> tasks(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo) { + operator(user);return ApiResponse.success(service.tasks(tripNo)); + } + /** 取货车次发车不进入仓内出库链。 */ + @PostMapping("/{tripNo}/depart") + @PreAuthorize("hasAuthority('platform:fleet:trip:assign')") + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetPickupModels.Command request) { + return ApiResponse.success(service.depart(tripNo,operator(user),request)); + } + /** 记录原订单的取货凭证上传归属。 */ + @PostMapping(value="/{tripNo}/tasks/{customerId}/{transferNo}/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo, + @PathVariable String customerId,@PathVariable String transferNo,@RequestPart MultipartFile file) { + return ApiResponse.success(service.upload(tripNo,customerId,transferNo,operator(user),file)); + } + /** 逐票确认实提,不覆盖客户预报。 */ + @PostMapping("/{tripNo}/tasks/{customerId}/{transferNo}/picked") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse> picked(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo, + @PathVariable String customerId,@PathVariable String transferNo,@Valid @RequestBody FleetPickupModels.Pickup request) { + return ApiResponse.success(service.picked(tripNo,customerId,transferNo,operator(user),request)); + } + /** 确认送仓后开放 PDA 清点。 */ + @PostMapping("/{tripNo}/tasks/{customerId}/{transferNo}/arrive") + @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") + public ApiResponse> arrive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo, + @PathVariable String customerId,@PathVariable String transferNo,@Valid @RequestBody FleetPickupModels.Command request) { + return ApiResponse.success(service.arrive(tripNo,customerId,transferNo,operator(user),request)); + } + /** 全车送仓后仅结案本次取货运输。 */ + @PostMapping("/{tripNo}/complete") + @PreAuthorize("hasAuthority('platform:fleet:trip:complete')") + public ApiResponse complete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid @RequestBody FleetPickupModels.Command request) { + return ApiResponse.success(service.complete(tripNo,operator(user),request)); + } + /** 平台调度入口不接受客户会话借同名权限越权。 */ + private static String operator(AuthenticatedUser user) { + if(user==null || user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录"); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以安排取货"); + return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java new file mode 100644 index 000000000..44102cadc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.fleet.pickup; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.security.OverseasPermissions; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; + +/** 原转运单详情读取运输投影,客户 ID 始终从认证上下文取得。 */ +@RestController +public class TransferPickupProgressController { + private final FleetPickupService service; + /** 注入只读进度能力。 */ + public TransferPickupProgressController(FleetPickupService service) { this.service=service; } + /** 客户只能读取本企业订单的取货进度。 */ + @GetMapping("/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress") + @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_TRANSFER_LIST+"')") + public ApiResponse merchant(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo) { + return ApiResponse.success(service.progress(customer(user),transferNo)); + } + /** 平台详情以明确客户和业务号读取,不暴露整车其他货物。 */ + @GetMapping("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress") + @PreAuthorize("hasAuthority('"+OverseasPermissions.LAST_MILE_ORDER_LIST+"')") + public ApiResponse platform(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@RequestParam String customerId) { + platform(user);return ApiResponse.success(service.progress(customerId,transferNo)); + } + /** 客户凭证只允许读取当前票确认过的引用。 */ + @GetMapping("/api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}") + @PreAuthorize("hasAuthority('"+OverseasPermissions.MERCHANT_TRANSFER_LIST+"')") + public ResponseEntity merchantEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@PathVariable long fileId) { + return download(customer(user),transferNo,fileId); + } + /** 平台调度或尾程详情沿本票授权下载凭证。 */ + @GetMapping("/api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}") + @PreAuthorize("hasAnyAuthority('platform:fleet:trip:list','"+OverseasPermissions.LAST_MILE_ORDER_LIST+"')") + public ResponseEntity platformEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String transferNo,@PathVariable long fileId,@RequestParam String customerId) { + platform(user);return download(customerId,transferNo,fileId); + } + /** 文件不进入浏览器公共缓存,文件名由调用方已授权元数据提供。 */ + private ResponseEntity download(String customer,String number,long file) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.APPLICATION_OCTET_STREAM) + .header("X-Content-Type-Options","nosniff").body(service.evidence(customer,number,file)); + } + /** 认证客户范围禁止从请求参数取得。 */ + private static String customer(AuthenticatedUser user) { + if(user==null || user.tenantId()==null || user.tenantId()<=0)throw new ResponseStatusException(HttpStatus.FORBIDDEN,"请使用客户账号");return user.tenantId().toString(); + } + /** 平台读取必须验证零作用域和应用身份。 */ + private static void platform(AuthenticatedUser user) { + if(user==null || !Long.valueOf(0).equals(user.tenantId()) || !"PLATFORM".equals(user.appCode()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以查询"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index fde620dc2..b7f6a252d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -284,7 +284,8 @@ public class FleetBookingService { private FleetBookingModels.Result result(FleetTripEntity trip,FleetBookingEntity row,boolean replayed){return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),replayed);} private FleetTripEntity trip(String number,boolean lock){var value=lock?trips.lockByNo(number):trips.findByNo(number);if(value==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在");return value;} private FleetBookingEntity booking(FleetTripEntity trip,String number){var row=bookings.lock(trip.getId(),number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前车次无此外接预订");return row;} - private static void requireTrip(FleetTripEntity trip,long version){if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");} + /** 外接预订只服务出库派送车次,并校验当前版本。 */ + private static void requireTrip(FleetTripEntity trip,long version){if("PICKUP".equals(trip.getTripPurpose()))throw conflict("取货车次不能承接出库拼车预订");if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");} private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");} private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}} private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index becab1ea6..ca291fe1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -295,9 +295,12 @@ public class FleetCapacityOfferService { } static TripCapacityPolicy.Load min(TripCapacityPolicy.Load a,TripCapacityPolicy.Load b){return new TripCapacityPolicy.Load(Math.min(a.palletSpaces(),b.palletSpaces()),a.weightKg().min(b.weightKg()),a.volumeCbm().min(b.volumeCbm()));} private static boolean positive(TripCapacityPolicy.Load value){return value!=null&&value.palletSpaces()>0&&value.weightKg().signum()>0&&value.volumeCbm().signum()>0;} - static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return "PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());} + /** 余位交货截止只适用于尚未发车的派送车次,取货车不接受出库拼车。 */ + static boolean cutoffValid(FleetTripEntity trip,LocalDateTime cutoff,Instant now){return !"PICKUP".equals(trip.getTripPurpose())&&"PLANNED".equals(trip.getStatus())&&cutoff!=null&&trip.getPlannedDepartureAt()!=null&&now.isBefore(instant(cutoff))&&!cutoff.isAfter(trip.getPlannedDepartureAt());} + /** 返回不可接单的具体原因,防止使用已有发布绕过用途及时间门禁。 */ static String openBlock(FleetCapacityOfferEntity row,FleetTripEntity trip,Instant now){ if(!"OPEN".equals(row.getStatus()))return "发布未开放接单"; + if("PICKUP".equals(trip.getTripPurpose()))return "取货车次不发布出库拼车余位"; if(!"PLANNED".equals(trip.getStatus()))return "车次已发车、结案或取消,不再接受新意向"; if(!cutoffValid(trip,row.getCutoffAt(),now))return "交货截止已过或不符合当前发车时间";return null; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java index ea620ef7f..dccb04145 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java @@ -5,6 +5,11 @@ import java.util.List; @Mapper public interface FleetResourceMapper { + /** 一次读取当前候选的实际占用,不按车次计划状态推断空闲。 */ + @Select({""}) + List candidates(@Param("kind") String kind, @Param("ids") List ids); @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}") Assignment find(@Param("kind") String kind, @Param("id") long id); @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java index 0dacd3636..0f3919276 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java @@ -15,6 +15,8 @@ public class FleetTripEntity { private String idempotencyKey; private String requestHash; private String status; + /** 区分出库派送与上门取货入仓。 */ + private String tripPurpose = "DELIVERY"; private Long warehouseId; private String warehouseCode; private String warehouseName; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index 39481891c..1c377d3e0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -327,7 +327,9 @@ public class FleetTripExecutionService { private static void requireWarehouse(FleetTripEntity trip, String warehouseId) { if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次"); } + /** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */ private static void requireVersionAndPlan(FleetTripEntity trip, long version) { + if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作"); if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认"); if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java index 1c036eab9..24ae4d6f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java @@ -8,6 +8,11 @@ import java.time.Instant; import java.util.List; public final class FleetTripModels { + /** 分配候选只查询本次下拉框的资源,限制批量大小。 */ + public record ResourceQuery(@NotBlank @Pattern(regexp="VEHICLE|DRIVER") String kind, + @NotEmpty @Size(max=100) List<@NotNull @Positive Long> resourceIds) { } + /** 可用仅表示未被别车占用,证照、区域和容量仍由分配命令复验。 */ + public record ResourceOccupancy(String resourceId, String occupiedTripNo, boolean available) { } private FleetTripModels() { } public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword, String status, @Positive Long warehouseId) { @@ -41,7 +46,7 @@ public final class FleetTripModels { Integer maxPalletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm, Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, List stops, String remark, long recordVersion, Instant updatedAt, - FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease) { } + FleetCompletionModels.ReleaseInfo vehicleRelease, FleetCompletionModels.ReleaseInfo driverRelease, String tripPurpose) { } public record Detail(Response trip, DriverQualificationPolicy.Qualification driverQualification, List dispatches, ReviewState loadingReview, List previousDispatches,String appointmentHistoryUnavailableReason) { } public record AuditSnapshot(Response trip, List dispatches, FleetExecutionModels.ConfirmedLoad confirmedLoad, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java new file mode 100644 index 000000000..cf51f9ffb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 车次主流程的数据访问集中经过 Repository,事务和状态决策留在 Service。 */ +@Repository +public class FleetTripRepository { + private final FleetTripMapper trips; + private final FleetTripDispatchMapper bindings; + private final FleetTripHistoryMapper histories; + private final FleetResourceMapper resources; + /** 注入车次、成员、历史及资源 Mapper。 */ + public FleetTripRepository(FleetTripMapper trips,FleetResourceMapper resources,FleetTripHistoryMapper histories,FleetTripDispatchMapper bindings) { + this.trips=trips;this.resources=resources;this.histories=histories;this.bindings=bindings; + } + /** 按筛选分页查询车次。 */ + public Page pageTrips(Page page,Wrapper filter) { return trips.selectPage(page,filter); } + /** 查找原创建请求。 */ + public FleetTripEntity findCreated(String operator,String key) { return trips.findCreated(operator,key); } + /** 锁定原创建请求处理唯一键并发。 */ + public FleetTripEntity lockCreated(String operator,String key) { return trips.lockCreated(operator,key); } + /** 新增车次记录。 */ + public int insertTrip(FleetTripEntity row) { return trips.insert(row); } + /** 按业务号查询车次。 */ + public FleetTripEntity findTrip(String number) { return trips.findByNo(number); } + /** 按业务号加锁读取车次。 */ + public FleetTripEntity lockTrip(String number) { return trips.lockByNo(number); } + /** 按版本条件原子保存车次。 */ + public int updateTrip(FleetTripEntity row,Wrapper filter) { return trips.update(row,filter); } + /** 读取车次当前排车成员。 */ + public List listBindings(long tripId) { return bindings.listByTrip(tripId); } + /** 锁定单据当前关联。 */ + public FleetTripDispatch lockBinding(long id) { return bindings.lockByDispatch(id); } + /** 保存成员审核。 */ + public int updateBinding(FleetTripDispatch row) { return bindings.updateById(row); } + /** 新增车次成员。 */ + public int insertBinding(FleetTripDispatch row) { return bindings.insert(row); } + /** 删除尚未执行的成员关联。 */ + public int deleteBinding(long id) { return bindings.deleteById(id); } + /** 使当前车次成员的旧审核失效。 */ + public int updateBindings(Wrapper filter) { return bindings.update(null,filter); } + /** 分页读取不可变车次历史。 */ + public Page pageHistory(Page page,Wrapper filter) { return histories.selectPage(page,filter); } + /** 读取原成员绑定历史。 */ + public List bindingHistory(long id) { return histories.bindingHistory(id); } + /** 保存当前操作快照。 */ + public int insertHistory(FleetTripHistory row) { return histories.insert(row); } + /** 查询资源实时占用。 */ + public FleetResourceMapper.Assignment resource(String kind,long id) { return resources.find(kind,id); } + /** 批量读取候选资源占用,避免列表逐项查询。 */ + public List resourceOccupancy(String kind,List ids) { return resources.candidates(kind,ids); } + /** 唯一键防止车辆或司机同时承接两趟任务。 */ + public int reserve(String kind,long id,long tripId,String number) { return resources.reserve(kind,id,tripId,number); } + /** 仅释放属于原车次的占用。 */ + public int release(String kind,long id,long tripId) { return resources.release(kind,id,tripId); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 967680592..5d44b4731 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -29,10 +29,8 @@ import java.util.*; public class FleetTripService { @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges; @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; - private final FleetTripMapper trips; - private final FleetResourceMapper resources; - private final FleetTripHistoryMapper histories; - private final FleetTripDispatchMapper bindings; + private final FleetTripRepository repository; + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups; private final FleetDispatchGateway dispatches; private final VehicleRepository vehicles; private final DriverService drivers; @@ -41,20 +39,23 @@ public class FleetTripService { private final Clock clock; private final FleetBookingReservations bookingReservations; + /** 注入车次持久化、资源资格和跨域业务协作依赖。 */ @Autowired - public FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories, + public FleetTripService(FleetTripRepository repository, VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json, - FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) { - this(trips, resources, histories, vehicles, drivers, warehouses, json, bindings, dispatches, bookingReservations, Clock.systemUTC()); + FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations) { + this(repository, vehicles, drivers, warehouses, json, dispatches, bookingReservations, Clock.systemUTC()); } - FleetTripService(FleetTripMapper trips, FleetResourceMapper resources, FleetTripHistoryMapper histories, + /** 注入可控时钟,供车次规则和并发持久化验收统一时间。 */ + FleetTripService(FleetTripRepository repository, VehicleRepository vehicles, DriverService drivers, FleetWarehouseDirectory warehouses, ObjectMapper json, - FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) { - this.trips = trips; this.resources = resources; this.histories = histories; this.vehicles = vehicles; + FleetDispatchGateway dispatches, FleetBookingReservations bookingReservations, Clock clock) { + this.repository = repository; this.vehicles = vehicles; this.drivers = drivers; this.warehouses = warehouses; this.json = json; this.clock = clock; - this.bindings = bindings; this.dispatches = dispatches; this.bookingReservations = bookingReservations; + this.dispatches = dispatches; this.bookingReservations = bookingReservations; } + /** 按平台筛选条件分页查询车次并补齐仓库与资源信息。 */ public PageResult page(FleetTripModels.Query query) { if (query.status() != null && !query.status().isBlank() && !Set.of("PLANNED", "IN_TRANSIT", "COMPLETED", "CANCELLED").contains(query.status())) throw bad("车次状态无效"); @@ -64,20 +65,50 @@ public class FleetTripService { if (query.keyword() != null && !query.keyword().isBlank()) filter.and(part -> part.like(FleetTripEntity::getTripNo, query.keyword().trim()) .or().like(FleetTripEntity::getVehiclePlate, query.keyword().trim()).or().like(FleetTripEntity::getDriverName, query.keyword().trim())); filter.orderByDesc(FleetTripEntity::getUpdatedAt, FleetTripEntity::getId); - var page = trips.selectPage(new Page<>(query.page(), query.size()), filter); + var page = repository.pageTrips(new Page<>(query.page(), query.size()), filter); return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } + /** 按全局车次号读取计划、关联单据及审核状态。 */ @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) public FleetTripModels.Detail detail(String tripNo) { FleetTripEntity trip = required(tripNo); return detail(trip); } + /** 下拉候选批量展示实际占用,本车资源仍可选;分配时必须再次锁定复验。 */ + @Transactional(readOnly = true) + public List resourceOccupancy(String tripNo,FleetTripModels.ResourceQuery query) { + var trip=repository.findTrip(tripNo); + if(trip==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"车次不存在"); + if(query.kind()==null || !Set.of("VEHICLE","DRIVER").contains(query.kind()) || query.resourceIds()==null + || query.resourceIds().isEmpty() || query.resourceIds().size()>100 + || query.resourceIds().stream().anyMatch(id->id==null || id<=0))throw bad("请选择有效的车辆或司机候选"); + var ids=query.resourceIds().stream().distinct().toList(); + var occupied=new HashMap(); + for(var row:repository.resourceOccupancy(query.kind(),ids))occupied.put(row.resourceId(),row); + return ids.stream().map(id->{ + var row=occupied.get(id); + return new FleetTripModels.ResourceOccupancy(id.toString(),row==null?null:row.tripNo(),row==null || row.tripId()==trip.getId()); + }).toList(); + } + + /** 创建派送用途车次,保留原有请求幂等语义。 */ @Transactional public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) { - String hash = hash(write(request)); - var existing = trips.findCreated(operator, request.idempotencyKey()); + return createPlan(operator, request, "DELIVERY"); + } + + /** 取货车次首次历史即标识取货用途,复用统一的资源和计划规则。 */ + @Transactional + public FleetTripModels.Detail createPickup(String operator, FleetTripModels.Create request) { + return createPlan(operator, request, "PICKUP"); + } + + /** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */ + private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) { + String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request)); + var existing = repository.findCreated(operator, request.idempotencyKey()); if (existing != null) return replay(existing, hash); validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); @@ -85,15 +116,15 @@ public class FleetTripService { trip.setId(IdWorker.getId()); trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash); - trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id())); + trip.setTripPurpose(purpose); trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id())); trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark())); trip.setRecordVersion(0L); trip.setCreatedBy(operator); trip.setUpdatedBy(operator); trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt()); - try { if (trips.insert(trip) != 1) throw conflict("车次创建失败"); } + try { if (repository.insertTrip(trip) != 1) throw conflict("车次创建失败"); } catch (DuplicateKeyException duplicate) { - var concurrent = trips.lockCreated(operator, request.idempotencyKey()); + var concurrent = repository.lockCreated(operator, request.idempotencyKey()); if (concurrent == null) throw conflict("车次编号冲突,请重试"); return replay(concurrent, hash); } @@ -101,11 +132,12 @@ public class FleetTripService { return detail(trip); } + /** 锁定车次及资源,复核司机证照和车辆容量后原子改派。 */ @Transactional public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); requireReason(request.reason()); - // All assigners serialize first on the vehicle, then on the driver. Resource uniqueness is the final safeguard. + // 分配始终先锁车辆再锁司机,唯一占用记录作为并发分配的最终约束。 var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); if (!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status())) throw bad("请选择在役海外车辆"); if (vehicle.maxPalletSpaces() == null || vehicle.maxLoadKg() == null || vehicle.maxVolumeCbm() == null @@ -129,7 +161,7 @@ public class FleetTripService { trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber()); trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone()); - for (var binding : bindings.listByTrip(trip.getId())) { + for (var binding : repository.listBindings(trip.getId())) { var dispatch = dispatches.lockForAssignment(binding.getDispatchNo()); if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致"); dispatches.assign(dispatch, assignment(trip)); @@ -140,6 +172,7 @@ public class FleetTripService { return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason()); } + /** 修改未发车计划;取货车次保留原单取货点和目的仓。 */ @Transactional public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); @@ -148,9 +181,10 @@ public class FleetTripService { bookingReservations.requireRoute(trip, request.stops()); if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt()); boolean unchangedRoute = response(trip).stops().equals(request.stops()); + if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线"); boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt())) && trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt())); - for (var binding : bindings.listByTrip(trip.getId())) { + for (var binding : repository.listBindings(trip.getId())) { if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo()); else dispatches.lockForPlanning(binding.getDispatchNo()); } @@ -160,6 +194,7 @@ public class FleetTripService { return detail(trip); } + /** 逐张审核客户排车单,不以同车其他客户审核代替本单。 */ @Transactional public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) { var trip = planned(tripNo, request.recordVersion()); @@ -172,20 +207,29 @@ public class FleetTripService { if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机"); } var dispatch = dispatches.lockForAssignment(dispatchNo); - var binding = bindings.lockByDispatch(dispatch.dispatchId()); + var binding = repository.lockBinding(dispatch.dispatchId()); if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion()))); - if (bindings.updateById(binding) != 1) throw conflict("排车单审核保存失败"); + if (repository.updateBinding(binding) != 1) throw conflict("排车单审核保存失败"); trip.setLoadingReviewJson(null); persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator); return detail(trip); } + /** 取消未执行车次,释放资源并将取货原单退回待安排。 */ @Transactional public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); requireReason(request.reason()); - if (!bindings.listByTrip(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次"); + if ("PICKUP".equals(trip.getTripPurpose())) { + // 取消车次与释放原单关联同事务,失败时全部回滚。 + for (var task : pickups.tasks(tripNo)) { + if (!"ASSIGNED".equals(task.getStatus())) throw conflict("已开始取货,不能取消车次"); + pickups.history(task,"UNASSIGN",operator,request.reason()); + } + pickups.unassign(tripNo); + } + if (!repository.listBindings(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次"); if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次"); if (trip.getVehicleId() != null) release("VEHICLE", trip.getVehicleId(), trip.getId()); if (trip.getDriverId() != null) release("DRIVER", trip.getDriverId(), trip.getId()); @@ -195,29 +239,32 @@ public class FleetTripService { } @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; + /** 读取当前车次可关联的客户排车候选。 */ @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public com.baiyue.common.core.api.PageResult dispatchCandidates(String tripNo,String keyword,int page,int size) { - var trip=trips.findByNo(tripNo); + var trip=repository.findTrip(tripNo); if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在"); if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物"); return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size); } + /** 关联同仓客户排车单并同步车辆司机,禁止取货车次混用。 */ @Transactional public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { var trip = planned(tripNo, request.recordVersion()); + if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次不能关联出库排车单"); requireReason(request.reason()); if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId()); if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓"); - var bound = bindings.lockByDispatch(dispatch.dispatchId()); + var bound = repository.lockBinding(dispatch.dispatchId()); if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo()); var binding = new FleetTripDispatch(); binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId()); binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount()); binding.setBoundBy(operator); binding.setBoundAt(now()); - try { if (bindings.insert(binding) != 1) throw conflict("排车单关联失败"); } + try { if (repository.insertBinding(binding) != 1) throw conflict("排车单关联失败"); } catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); } bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator); var route = matchedStops(response(trip).stops(), dispatches.manifest(dispatch.dispatchNo(), false).cargo()); @@ -250,74 +297,85 @@ public class FleetTripService { return List.copyOf(result); } + /** 解除尚未执行的排车关联并使原装载审核失效。 */ @Transactional public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { var trip = planned(tripNo, request.recordVersion()); requireReason(request.reason()); var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); - var binding = bindings.lockByDispatch(dispatch.dispatchId()); + var binding = repository.lockBinding(dispatch.dispatchId()); if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null)); - if (bindings.deleteById(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试"); + if (repository.deleteBinding(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试"); bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator); persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator); return detail(trip); } + /** 分页读取不可变车次历史,用于追溯计划和执行变更。 */ public PageResult history(String tripNo, int page, int size) { var trip = required(tripNo); - var result = histories.selectPage(new Page<>(page, size), new LambdaQueryWrapper() + var result = repository.pageHistory(new Page<>(page, size), new LambdaQueryWrapper() .eq(FleetTripHistory::getTripId, trip.getId()).orderByDesc(FleetTripHistory::getRecordVersion)); return new PageResult<>(result.getRecords().stream().map(event -> new FleetTripModels.History(event.getRecordVersion(), event.getAction(), event.getReason(), event.getOperatorId(), instant(event.getOccurredAt()), read(event.getSnapshotJson(), FleetTripModels.AuditSnapshot.class))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } + /** 在唯一资源占用记录内锁定车辆或司机,阻止并发重复分配。 */ private void reserve(String kind, long resourceId, FleetTripEntity trip) { - var current = resources.find(kind, resourceId); + var current = repository.resource(kind, resourceId); if (current != null) { if (current.tripId() == trip.getId()) return; throw conflict(("VEHICLE".equals(kind) ? "车辆" : "司机") + "已被车次 " + current.tripNo() + " 占用"); } - try { if (resources.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); } + try { if (repository.reserve(kind, resourceId, trip.getId(), trip.getTripNo()) != 1) throw conflict("车次资源预留失败"); } catch (DuplicateKeyException duplicate) { throw conflict("车辆或司机已被其他车次占用,请刷新后重试"); } } + /** 仅释放属于本车次的资源占用。 */ private void release(String kind, long resourceId, long tripId) { - if (resources.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成"); + if (repository.release(kind, resourceId, tripId) != 1) throw conflict("车次资源占用不一致,操作未完成"); } + /** 锁定读取车次,不存在时返回明确的业务错误。 */ private FleetTripEntity required(String number) { - var trip = trips.findByNo(number); + var trip = repository.findTrip(number); if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); return trip; } + /** 校验车次版本及未发车状态,防止陈旧页面覆盖新安排。 */ private FleetTripEntity planned(String number, long version) { - var trip = trips.lockByNo(number); + var trip = repository.lockTrip(number); if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试"); if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); return trip; } + /** 复核创建请求摘要,同键不同内容不允许复用。 */ private FleetTripModels.Detail replay(FleetTripEntity trip, String hash) { if (!Objects.equals(trip.getRequestHash(), hash)) throw conflict("创建请求标识已用于不同车次内容"); return detail(trip); } + /** 组合车次、排车关联与预约历史,形成一致的详情快照。 */ private FleetTripModels.Detail detail(FleetTripEntity trip) { var currentBindings=bindingResponses(trip); var history=previousDispatches(trip,currentBindings); return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason()); } + /** 将客户隔离的排车关联转换为详情展示模型。 */ private List bindingResponses(FleetTripEntity trip) { - var current=bindings.listByTrip(trip.getId()); + var current=repository.listBindings(trip.getId()); var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList()); return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(), binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList(); } + /** 组装当前车次关联单据和预约追溯信息。 */ private record AppointmentHistory(List dispatches,String unavailableReason) { } + /** 读取预约工作批次的历史排车引用,保留历史不可用原因。 */ private AppointmentHistory previousDispatches(FleetTripEntity trip,List current) { - var history=histories.bindingHistory(trip.getId()); + var history=repository.bindingHistory(trip.getId()); if(history.size()>1000) return new AppointmentHistory(List.of(),"原车绑定历史超过完整读取上限,请分页核对车次历史"); String unavailableReason=null; var seen=new HashSet>(); @@ -338,13 +396,16 @@ public class FleetTripService { } return new AppointmentHistory(List.copyOf(previous),unavailableReason); } + /** 构造稳定预约批次关联键,避免将同名记录误合并。 */ private static List referenceKey(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference batch, com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource source) { return List.of(batch.batchKey(),batch.customerId(),source.sourceKey(),source.destinationKey()); } + /** 生成跨域同步所需的车辆司机分配快照。 */ private static FleetDispatchGateway.Assignment assignment(FleetTripEntity trip) { return new FleetDispatchGateway.Assignment(trip.getVehiclePlate(), trip.getDriverName(), trip.getDriverPhone()); } + /** 组装车次计划、用途、资源及释放记录的对外响应。 */ private FleetTripModels.Response response(FleetTripEntity trip) { List stops; try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); } @@ -353,8 +414,9 @@ public class FleetTripService { text(trip.getVehicleId()), trip.getVehiclePlate(), text(trip.getDriverId()), trip.getDriverName(), trip.getDriverPhone(), trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm(), instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()), instant(trip.getDepartedAt()), instant(trip.getCompletedAt()), List.copyOf(stops), trip.getRemark(), trip.getRecordVersion(), instant(trip.getUpdatedAt()), - releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson())); + releaseInfo(trip.getVehicleReleaseJson()), releaseInfo(trip.getDriverReleaseJson()), trip.getTripPurpose()); } + /** 校验发车和任务结束时间以及路线站点完整性。 */ private void validatePlan(Instant departure, Instant end, List stops) { if (departure == null || end == null || !end.isAfter(departure) || !end.isAfter(clock.instant())) throw bad("司机预计任务结束时间须晚于计划发车及当前时间"); try { @@ -363,17 +425,19 @@ public class FleetTripService { if (stops == null || stops.size() < 2 || stops.size() > 200 || stops.stream().anyMatch(stop -> stop == null || trim(stop.name()) == null)) throw bad("车次须包含起点和目的地,最多200个站点"); } + /** 按乐观版本保存车次变更并记录不可变操作快照。 */ private void persist(FleetTripEntity trip, long version, String action, String reason, String operator) { if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null); if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null); if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null); - if (Set.of("ASSIGN", "PLAN").contains(action)) bindings.update(null, new LambdaUpdateWrapper() + if (Set.of("ASSIGN", "PLAN").contains(action)) repository.updateBindings(new LambdaUpdateWrapper() .eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null)); trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator); - if (trips.update(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1) + if (repository.updateTrip(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1) throw conflict("车次已被修改,请刷新后重试"); record(trip, action, reason, operator); } + /** 为当前版本保存操作人、原因和完整车次历史。 */ private void record(FleetTripEntity trip, String action, String reason, String operator) { workChanges.changed(trip); var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion()); @@ -382,26 +446,39 @@ public class FleetTripService { : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null : read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()), trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId())))); - if (histories.insert(event) != 1) throw conflict("车次历史保存失败"); + if (repository.insertHistory(event) != 1) throw conflict("车次历史保存失败"); } + /** 将车次快照序列化为持久化 JSON。 */ private String write(Object value) { try { return json.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("车次序列化失败", e); } } + /** 解析可空的资源任务结束记录。 */ private FleetCompletionModels.ReleaseInfo releaseInfo(String value) { if (value == null) return null; var released = read(value, FleetCompletionModels.Released.class); return new FleetCompletionModels.ReleaseInfo(released.actualEndAt(), released.endLocation(), released.vehicleStatus()); } + /** 在调用方已有事务内保存执行事实及对应历史。 */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void recordExecution(FleetTripEntity trip, long version, String action, String reason, String operator) { persist(trip, version, action, reason, operator); } + /** 按指定模型解析历史 JSON,损坏记录明确报错。 */ private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } } + /** 为幂等请求生成不可逆内容摘要。 */ private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } + /** 使用统一时钟获取 UTC 业务时间。 */ private LocalDateTime now() { return utc(clock.instant()); } + /** 将统一时刻转换为数据库 UTC 时间。 */ private static LocalDateTime utc(Instant value) { return LocalDateTime.ofInstant(value, ZoneOffset.UTC); } + /** 将可空的数据库时间转换为统一时刻。 */ private static Instant instant(LocalDateTime value) { return value == null ? null : value.toInstant(ZoneOffset.UTC); } + /** 将可空的长整型标识转换为字符串,避免前端精度丢失。 */ private static String text(Long value) { return value == null ? null : value.toString(); } + /** 将空白备注归一为空值。 */ private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + /** 关键变更必须填写操作原因,便于审计追溯。 */ private static void requireReason(String reason) { if (trim(reason) == null) throw bad("请填写操作原因"); } + /** 创建参数不满足业务要求的错误。 */ private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + /** 创建状态或版本冲突的业务错误。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java index de7d3c019..21cec563c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -57,6 +57,17 @@ public class PlatformFleetTripController { public ApiResponse plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) { return ApiResponse.success(service.updatePlan(tripNo, operator(user), request)); } + /** 仅平台查看本次候选的实时占用,沿车次列表权限,不产生资源预留。 */ + @GetMapping("/{tripNo}/resource-occupancy") + @PreAuthorize("hasAuthority('platform:fleet:trip:list')") + public ApiResponse> resourceOccupancy( + @AuthenticationPrincipal AuthenticatedUser user,@PathVariable String tripNo,@Valid FleetTripModels.ResourceQuery query) { + if(user==null || !"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以查询车次资源占用"); + return ApiResponse.success(service.resourceOccupancy(tripNo,query)); + } + + /** 分配前重新锁定并核对车辆司机,候选空闲不代表已预留。 */ @PostMapping("/{tripNo}/assignment") @PreAuthorize("hasAuthority('platform:fleet:trip:assign')") public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Assign request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 6b5984cb3..534792f67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -92,6 +92,7 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { + @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; @Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; @@ -909,6 +910,7 @@ public class WarehouseTransferService { WarehouseTransferRequests.CountCartons request) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); if (transfer == null) throw notFound("当前仓没有该转运单"); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); String customerId = transfer.getCustomerId(); List cartons = transferCartons.listOrder(customerId, transfer.getId()); // 客户补箱后旧设备快照不可确认短收,必须刷新取得新增的预报箱唛。 @@ -1034,11 +1036,9 @@ public class WarehouseTransferService { return cartons.stream().filter(box -> "COUNTED".equals(box.getStatus())).toList(); } + /** 上门取货必须先送仓;按 PDA 绑定仓锁定后继续使用原测量流程。 */ private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) { - WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); - if (transfer == null) throw notFound("Warehouse transfer order not found"); - if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("Warehouse transfer is not awaiting PDA receiving"); - return transfer; + return pickupService.requireMeasuringSource(String.valueOf(warehouseId), transferNo); } private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) { @@ -1056,6 +1056,7 @@ public class WarehouseTransferService { WarehouseTransferRequests.Palletize request) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); if (transfer == null) throw notFound("Warehouse transfer order not found"); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse"); } @@ -1622,6 +1623,7 @@ public class WarehouseTransferService { String transferNo, MultipartFile file) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); if (transfer == null) throw notFound("Warehouse transfer order not found"); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { throw conflict("Measurement photos can only be uploaded before palletization completes"); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 1ee3b0e5d..8c824d878 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1086,6 +1086,7 @@ CREATE TABLE ll_fleet_vehicle_record_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史'; CREATE TABLE ll_fleet_trip ( + trip_purpose VARCHAR(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓', id BIGINT NOT NULL COMMENT '内部主键', trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', @@ -4324,3 +4325,56 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock ( -- 库存转运无需再次预约入仓;目标库:lastleg-service 所属数据库。可重复执行。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; + +-- 仓储转运上门取货,共用车辆和司机资源。 +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_plan ( + trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号', + request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要', + PRIMARY KEY(trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_task ( + transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号', + trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空', + status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', + actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收', + picked_at DATETIME NULL COMMENT '实际确认取货UTC时间', + arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间', + evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据', + remark VARCHAR(1000) NULL COMMENT '取货备注', + updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间', + PRIMARY KEY(transfer_order_id), + UNIQUE KEY uk_pickup_source(customer_id,transfer_no), + KEY idx_pickup_trip(trip_no,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_command ( + trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号', + request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要', + PRIMARY KEY(trip_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_upload ( + transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单', + trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次', + file_id BIGINT NOT NULL COMMENT '平台归属文件ID', + PRIMARY KEY(file_id), + KEY idx_pickup_upload_source(transfer_order_id,trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号', + trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号', + action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME NOT NULL COMMENT '操作UTC时间', + detail VARCHAR(2000) NULL COMMENT '操作说明', + PRIMARY KEY(id), + KEY idx_pickup_history_source(customer_id,transfer_no,id), + KEY idx_pickup_history_trip(trip_no,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; diff --git a/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml new file mode 100644 index 000000000..39b1a15f8 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml @@ -0,0 +1,159 @@ +openapi: 3.0.3 +info: + title: 仓储转运上门取货与车辆排车 + version: 1.0.0 + description: | + 审核通过的 WAREHOUSE_PICKUP 外来转运单自动进入待取货候选;客户自送、库存转运、未审核及已完成测量单不进入。 + 入仓取货和出库派送共用 ll_fleet_trip 及车辆司机占用,tripPurpose 分别为 PICKUP / DELIVERY,禁止混用库存出库命令、外接预订及余位发布。 + 同车多单必须送往同一公共海外仓,原客户归属与转运单保留,不重复生成尾程单、不修改预报箱唛或库存。 + 取货确认必须填写实提箱数并引用本票本车已上传的 PDF/JPG/PNG 凭证;送仓后开放 PDA 清点。 + 司机实提箱数不替代仓库实收。全部送仓后结案运输,车辆和司机仍通过原 resources/release 分别结束任务。 + 取消 PLANNED 车次原子释放资源和未执行取货关联,原单重新成为候选;已发车不可取消。 + 写命令具备车次版本及请求键,同键同内容重放不重复写入,同键不同内容拒绝。普通业务返回 HTTP 200 + body.code。 +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/pickups: + get: + summary: 分页读取待取货需求 + description: 仅平台主体,权限 platform:fleet:trip:list。返回客户与原单、取货地址联系人、预报箱数、期望入仓时间及仓库名称编码。 + parameters: + - {name: page, in: query, schema: {type: integer, minimum: 1, default: 1}} + - {name: size, in: query, schema: {type: integer, minimum: 1, maximum: 100, default: 20}} + - {name: keyword, in: query, schema: {type: string, maxLength: 128}} + responses: {'200': {description: ApiResponse 分页候选}} + post: + summary: 选单创建同仓多点取货车次 + description: 仅平台主体,权限 platform:fleet:trip:create;创建后沿原 assignment 接口分配车辆司机,复用原 plan 接口调整时间,取货路线由原单地址确定。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sources, plannedDepartureAt, expectedDriverEndAt, idempotencyKey] + properties: + sources: + type: array + minItems: 1 + maxItems: 100 + items: + type: object + required: [customerId, transferNo] + properties: + customerId: {type: string} + transferNo: {type: string} + plannedDepartureAt: {type: string, format: date-time} + expectedDriverEndAt: {type: string, format: date-time} + remark: {type: string, maxLength: 1000} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + responses: {'200': {description: ApiResponse FleetTripModels.Detail,trip.tripPurpose=PICKUP}} + /api/platform/v1/last-leg/fleet/pickups/{tripNo}/tasks: + parameters: [{$ref: '#/components/parameters/tripNo'}] + get: + summary: 读取本车所有逐票取货任务 + description: 仅平台主体,权限 platform:fleet:trip:list。任务状态 WAITING / ASSIGNED / PICKED / ARRIVED;凭证按本票返回,不暴露文件公共直链。 + responses: {'200': {description: ApiResponse Task 数组}} + /api/platform/v1/last-leg/fleet/pickups/{tripNo}/depart: + parameters: [{$ref: '#/components/parameters/tripNo'}] + post: + summary: 确认取货发车 + description: 仅平台主体,权限 platform:fleet:trip:assign。复验车辆状态、司机资格、任务覆盖和两类资源占用。只推进车次 IN_TRANSIT,不触发出库。 + requestBody: {$ref: '#/components/requestBodies/command'} + responses: {'200': {description: ApiResponse FleetTripModels.Detail}} + /api/platform/v1/last-leg/fleet/pickups/{tripNo}/tasks/{customerId}/{transferNo}/files: + parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] + post: + summary: 上传本票取货凭证 + description: 仅平台主体,权限 platform:fleet:trip:edit。最大10MiB,File沿 FLEET_TRIP_OPERATION_EVIDENCE / tenantId=0 / businessId=车次ID校验格式;Lastleg另存当前票上传归属。 + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: {file: {type: string, format: binary}} + responses: {'200': {description: ApiResponse 文件ID、名称、类型和大小}} + /api/platform/v1/last-leg/fleet/pickups/{tripNo}/tasks/{customerId}/{transferNo}/picked: + parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] + post: + summary: 逐票确认取货 + description: 仅平台主体,权限 platform:fleet:trip:edit。车次已发车且任务 ASSIGNED,核对本票本车上传归属后转 PICKED,实提箱数不覆盖预报。 + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, idempotencyKey, actualPackages, evidenceFileIds] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + actualPackages: {type: integer, minimum: 1, maximum: 5000} + evidenceFileIds: {type: array, minItems: 1, maxItems: 10, items: {type: string, description: 正Long文件ID}} + remark: {type: string, maxLength: 1000} + responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}} + /api/platform/v1/last-leg/fleet/pickups/{tripNo}/tasks/{customerId}/{transferNo}/arrive: + parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] + post: + summary: 确认本票送达海外仓 + description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;开放原 PDA 清点,不直接完成测量或入库。 + requestBody: {$ref: '#/components/requestBodies/command'} + responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}} + /api/platform/v1/last-leg/fleet/pickups/{tripNo}/complete: + parameters: [{$ref: '#/components/parameters/tripNo'}] + post: + summary: 全车送仓后结案取货运输 + description: 仅平台主体,权限 platform:fleet:trip:complete。全车非空且均 ARRIVED,车次转 COMPLETED;原单继续仓库作业,不自动释放车和司机。 + requestBody: {$ref: '#/components/requestBodies/command'} + responses: {'200': {description: ApiResponse FleetTripModels.Detail}} + /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress: + parameters: [{$ref: '#/components/parameters/transferNo'}] + get: + summary: 客户读取本企业本票取货安排 + description: 权限 merchant:overseas-warehouse:transfer:list;客户ID从认证上下文取得,自送及库存来源返回null。仅返回本票运输与凭证,不授予整车读取。 + responses: {'200': {description: ApiResponse Task或null}} + /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-progress: + parameters: + - {$ref: '#/components/parameters/transferNo'} + - {name: customerId, in: query, required: true, schema: {type: string}} + get: + summary: 平台尾程详情读取本票取货安排 + description: 仅平台主体,权限 platform:overseas-warehouse:last-mile-order:list。 + responses: {'200': {description: ApiResponse Task或null}} + /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}: + parameters: [{$ref: '#/components/parameters/transferNo'}, {$ref: '#/components/parameters/fileId'}] + get: + summary: 客户下载本票已确认凭证 + description: 权限 merchant:overseas-warehouse:transfer:list;复验客户、原订单、文件引用及File归属,no-store。 + responses: {'200': {description: application/octet-stream 文件;业务错误仍为ApiResponse}} + /api/platform/v1/last-leg/warehouse-transfers/{transferNo}/pickup-evidence/{fileId}: + parameters: + - {$ref: '#/components/parameters/transferNo'} + - {$ref: '#/components/parameters/fileId'} + - {name: customerId, in: query, required: true, schema: {type: string}} + get: + summary: 平台下载本票已确认凭证 + description: 仅平台主体,权限 platform:fleet:trip:list 或 platform:overseas-warehouse:last-mile-order:list;复验本票文件引用,不因共车授权其他客户文件。 + responses: {'200': {description: application/octet-stream 文件;业务错误仍为ApiResponse}} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + parameters: + tripNo: {name: tripNo, in: path, required: true, schema: {type: string}} + customerId: {name: customerId, in: path, required: true, schema: {type: string}} + transferNo: {name: transferNo, in: path, required: true, schema: {type: string}} + fileId: {name: fileId, in: path, required: true, schema: {type: integer, format: int64}} + requestBodies: + command: + required: true + content: + application/json: + schema: + type: object + required: [recordVersion, idempotencyKey, reason] + properties: + recordVersion: {type: integer, format: int64, minimum: 0} + idempotencyKey: {type: string, minLength: 8, maxLength: 128} + reason: {type: string, maxLength: 1000} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index b0835f780..0138397ec 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -27,6 +27,36 @@ info: security: - bearerAuth: [] paths: + /api/platform/v1/last-leg/fleet/trips/{tripNo}/resource-occupancy: + get: + summary: 批量读取当前下拉候选的实际车辆或司机占用 + description: 仅平台且须platform:fleet:trip:list。本车已占用仍可保留;别车占用不可选。只反映占用,不代表证照或容量合格,不产生资源预留,保存分配时再次复验。 + parameters: + - {in: path, name: tripNo, required: true, schema: {type: string}} + - {in: query, name: kind, required: true, schema: {type: string, enum: [VEHICLE, DRIVER]}} + - in: query + name: resourceIds + required: true + style: form + explode: false + schema: {type: array, minItems: 1, maxItems: 100, items: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 标准业务响应;data为候选占用数组,HTTP200亦需检查code。业务无权限、参数错误及资源不存在沿统一错误码。 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: array + items: + type: object + properties: + resourceId: {type: string} + occupiedTripNo: {type: string, nullable: true} + available: {type: boolean} /api/platform/v1/last-leg/fleet/trips/calendar: get: summary: 读取美国车次用车日历来源 @@ -746,6 +776,7 @@ components: type: object properties: tripNo: {type: string} + tripPurpose: {type: string, enum: [DELIVERY, PICKUP], description: 派送车次或入仓取货车次} status: {type: string, enum: [PLANNED, IN_TRANSIT, COMPLETED, CANCELLED]} warehouseId: {type: string} warehouseCode: {type: string} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java new file mode 100644 index 000000000..1a6a7af91 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.fleet.pickup; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.h2.jdbcx.JdbcDataSource; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import static org.assertj.core.api.Assertions.*; + +class FleetPickupMapperTest { + JdbcTemplate db;FleetPickupMapper mapper; + @BeforeEach void setup() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_no VARCHAR(64),operating_warehouse_id VARCHAR(64),delivery_address VARCHAR(500),pickup_contact_name VARCHAR(100),pickup_contact_phone VARCHAR(40),package_count INT,estimated_arrival_at TIMESTAMP,status VARCHAR(32),delivery_method VARCHAR(40),created_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_fleet_pickup_task(transfer_order_id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),trip_no VARCHAR(64),status VARCHAR(32),updated_at TIMESTAMP)"); + for(int i=1;i<=6;i++)db.update("INSERT INTO ll_warehouse_transfer_order VALUES(?,? ,? ,? ,? ,'100','Ontario Test Address','Contact','0000000000',5,CURRENT_TIMESTAMP,'WAIT_PDA_RECEIVING','WAREHOUSE_PICKUP',CURRENT_TIMESTAMP)",i,"C"+i,"WT-"+i,"WT-"+i,"LL-"+i); + db.update("UPDATE ll_warehouse_transfer_order SET delivery_method='CUSTOMER_DELIVERY' WHERE id=2"); + db.update("UPDATE ll_warehouse_transfer_order SET status='PENDING_REVIEW' WHERE id=3"); + db.update("UPDATE ll_warehouse_transfer_order SET status='WAIT_CUSTOMER_CONFIRMATION' WHERE id=4"); + db.update("UPDATE ll_warehouse_transfer_order SET inbound_code='OLD-INBOUND' WHERE id=5"); + var config=new MybatisConfiguration();config.addMapper(FleetPickupMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetPickupMapper.class); + } + @Test void candidateQualificationPaginationAndCustomerScopeUseActualSql() { + assertThat(mapper.countCandidates(null)).isEqualTo(2); + assertThat(mapper.candidates(null,1,0)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-1"); + assertThat(mapper.candidates(null,1,1)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-6"); + assertThat(mapper.countCandidates("WT-6")).isEqualTo(1); + assertThat(mapper.source("C2","WT-1")).isNull();assertThat(mapper.source("C1","WT-1").getPackageCount()).isEqualTo(5); + } + @Test void assignmentDisappearsFromPoolAndCancellationRestoresIt() { + db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','ASSIGNED',CURRENT_TIMESTAMP)"); + assertThat(mapper.countCandidates(null)).isEqualTo(1);assertThat(mapper.unassign("TR-1")).isEqualTo(1); + assertThat(mapper.countCandidates(null)).isEqualTo(2);assertThat(mapper.task(1L).getTripNo()).isNull(); + } + @Test void pickedCargoCannotBeReleasedBackToPendingByCancellationSql() { + db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)"); + assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java new file mode 100644 index 000000000..04c6342e5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java @@ -0,0 +1,106 @@ +package com.baiyue.lastleg.fleet.pickup; + +import com.baiyue.lastleg.fleet.driver.*; +import com.baiyue.lastleg.fleet.reporting.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class FleetPickupServiceTest { + FleetPickupRepository repository;FleetTripService trips;FleetPickupService service; + TripOperationsEvidenceGateway files;FleetTripEntity trip;WarehouseTransferOrder source;FleetPickupTask task; + final Map commands=new HashMap<>(); + @BeforeEach void setup() { + repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class); + service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),mock(DriverService.class),mock(VehicleRepository.class),files,new ObjectMapper().findAndRegisterModules()); + trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L); + source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED"); + when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip); + when(repository.source(eq("C1"),eq("WT-1"),anyBoolean())).thenReturn(source); + when(repository.task(1L)).thenReturn(task);when(repository.lockTask(1L)).thenReturn(task);when(repository.tasks("TR-1")).thenAnswer(c->List.of(task)); + when(repository.commandHash(eq("TR-1"),anyString())).thenAnswer(c->commands.get(c.getArgument(1))); + doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString()); + doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); + } + @Test void pickupMustArriveBeforePdaAndWrongWarehouseCannotRead() { + when(repository.pdaSource("100","WT-1")).thenReturn(source); + assertThatThrownBy(()->service.requireMeasuringSource("100","WT-1")).hasMessageContaining("先完成车辆取货送仓"); + task.setStatus("ARRIVED");assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source); + assertThatThrownBy(()->service.requireMeasuringSource("200","WT-1")).isInstanceOf(ResponseStatusException.class); + } + @Test void selfDeliveryKeepsOriginalMeasurementPath() { + source.setDeliveryMethod("CUSTOMER_DELIVERY");when(repository.pdaSource("100","WT-1")).thenReturn(source); + assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);verify(repository,never()).task(anyLong()); + } + @Test void cannotScheduleSelfDeliveryOrAnotherWarehouseOrDuplicateSource() { + var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1"); + source.setDeliveryMethod("CUSTOMER_DELIVERY");assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("不是已审核"); + source.setDeliveryMethod("WAREHOUSE_PICKUP");when(repository.task(1L)).thenReturn(null);when(repository.lockTask(1L)).thenReturn(null); + var other=source(2L,"C2","WT-2","200");when(repository.source("C2","WT-2",true)).thenReturn(other); + var mixed=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1"),new FleetPickupModels.Source("C2","WT-2")),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-2"); + assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).create(anyString(),any()); + var duplicate=new FleetPickupModels.Create(List.of(plan.sources().get(0),plan.sources().get(0)),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-3"); + assertThatThrownBy(()->service.create("OP",duplicate)).hasMessageContaining("重复选择"); + } + @Test void alreadyScheduledSourceIsNotScheduledAgain() { + var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1"); + assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).create(anyString(),any()); + } + @Test void pickupQuantityDoesNotOverwriteForecastAndReplayDoesNotWriteTwice() { + when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true); + when(files.metadata(9L,88L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("88","取货现场.jpg","image/jpeg",500)); + var request=new FleetPickupModels.Pickup(2L,"pickup-key-1",7,List.of(88L),"现场多提两箱,等待仓库核实"); + var result=service.picked("TR-1","C1","WT-1","OP",request); + assertThat(task.getActualPackages()).isEqualTo(7);assertThat(source.getPackageCount()).isEqualTo(5);assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + assertThat(result.get(0).status()).isEqualTo("PICKED");assertThat(result.get(0).evidence()).hasSize(1); + service.picked("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task); + var changed=new FleetPickupModels.Pickup(2L,"pickup-key-1",9,List.of(88L),null); + assertThatThrownBy(()->service.picked("TR-1","C1","WT-1","OP",changed)).hasMessageContaining("不能提交不同"); + } + @Test void evidenceCannotCrossCustomerEvenWithinTheSameTrip() { + assertThatThrownBy(()->service.picked("TR-1","C1","WT-1","OP",new FleetPickupModels.Pickup(2L,"pickup-key-1",5,List.of(99L),null))).hasMessageContaining("不属于当前订单"); + assertThatThrownBy(()->service.evidence("C1","WT-1",99L)).hasMessageContaining("没有该取货凭证"); + verifyNoInteractions(files);verify(repository,never()).update(any()); + } + @Test void cannotArriveBeforePickupAndCannotCompleteWithPendingMembers() { + var command=new FleetPickupModels.Command(2L,"arrive-key-1","确认送仓"); + assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",command)).hasMessageContaining("先确认该单已取货"); + assertThatThrownBy(()->service.complete("TR-1","OP",command)).hasMessageContaining("全部取货订单"); + } + @Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() { + task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC)); + service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台")); + assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + service.complete("TR-1","OP",new FleetPickupModels.Command(3L,"complete-key-1","全部送仓")); + assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("complete-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + } + @Test void staleVersionAndWrongTripPurposeAreRejected() { + assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化"); + trip.setTripPurpose("DELIVERY");assertThatThrownBy(()->service.tasks("TR-1")).hasMessageContaining("不是入仓取货"); + } + /** 车次展示的资源不能代替真实占用事实。 */ + @Test void departureRequiresBothResourcesAndTheirCurrentOwnership() { + trip.setStatus("PLANNED"); + var command=new FleetPickupModels.Command(2L,"depart-key-1","开始取货"); + assertThatThrownBy(()->service.depart("TR-1","OP",command)).hasMessageContaining("先分配车辆和司机"); + trip.setVehicleId(20L);trip.setDriverId(30L); + when(repository.ownsResources(9L,20L,30L)).thenReturn(false); + assertThatThrownBy(()->service.depart("TR-1","OP",command)).hasMessageContaining("占用与当前车次不一致"); + assertThat(trip.getStatus()).isEqualTo("PLANNED"); + verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); + } + /** 构造有业务含义的原转运单,箱唛与库存由原链路持有。 */ + static WarehouseTransferOrder source(long id,String customer,String number,String warehouse) { + var row=new WarehouseTransferOrder();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number); + row.setOperatingWarehouseId(warehouse);row.setDeliveryMethod("WAREHOUSE_PICKUP");row.setStatus("WAIT_PDA_RECEIVING");row.setPackageCount(5); + row.setDeliveryAddress("1580 S Grove Ave, Ontario, CA 91761");row.setPickupContactName("取货测试联系人");row.setPickupContactPhone("0000000000");return row; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index bc0abf03f..a08940bff 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -186,8 +186,8 @@ class FleetTripPersistenceTest { ReflectionTestUtils.setField(dispatchGateway, "deliveryExecution", deliveryExecution); ReflectionTestUtils.setField(dispatchGateway, "cargoSources", new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(session.getMapper(com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper.class)))); - service = transactional(new FleetTripService(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), vehicles, drivers, directory, json, - bindingMapper, dispatchGateway, reservations), FleetTripService.class); + service = transactional(new FleetTripService(new FleetTripRepository(session.getMapper(FleetTripMapper.class), resources, session.getMapper(FleetTripHistoryMapper.class), bindingMapper), vehicles, drivers, directory, json, + dispatchGateway, reservations), FleetTripService.class); preparation = transactional(new FleetTripPreparationService(service,session.getMapper(FleetTripMapper.class),session.getMapper(FleetCapacityOfferCommandMapper.class),json),FleetTripPreparationService.class); planning=mock(FleetDispatchPlanningGateway.class); ReflectionTestUtils.setField(service,"planning",planning); @@ -368,6 +368,25 @@ class FleetTripPersistenceTest { assertThat(service.assign(second, "1", new FleetTripModels.Assign(0L, vehicle, driver, "重新安排")).trip().vehicleId()).isEqualTo(Long.toString(vehicle)); } + /** 下拉占用来自真实资源表,本车可保留、别车不可选,查询不预留资源。 */ + @Test void candidateOccupancySeparatesResourceKindAndReleasesAfterCancellation() { + long occupiedVehicle=vehicle(true), freeVehicle=vehicle(true), occupiedDriver=driver(true); + String first=trip(), second=trip(); + service.assign(first,"1",new FleetTripModels.Assign(0L,occupiedVehicle,occupiedDriver,"候选占用验收")); + var query=new FleetTripModels.ResourceQuery("VEHICLE",List.of(occupiedVehicle,freeVehicle,occupiedVehicle)); + var other=service.resourceOccupancy(second,query); + assertThat(other).hasSize(2); + assertThat(other.get(0)).isEqualTo(new FleetTripModels.ResourceOccupancy(Long.toString(occupiedVehicle),first,false)); + assertThat(other.get(1)).isEqualTo(new FleetTripModels.ResourceOccupancy(Long.toString(freeVehicle),null,true)); + assertThat(service.resourceOccupancy(first,query).get(0).available()).isTrue(); + assertThat(service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("DRIVER",List.of(occupiedDriver))).get(0).available()).isFalse(); + assertThat(service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("VEHICLE",List.of(occupiedDriver))).get(0).available()).isTrue(); + assertThatThrownBy(()->service.resourceOccupancy(second,new FleetTripModels.ResourceQuery("VEHICLE",List.of()))).hasMessageContaining("有效的车辆或司机候选"); + service.cancel(first,"1",new FleetTripModels.Cancel(1L,"取消释放候选资源")); + assertThat(service.resourceOccupancy(second,query)).allMatch(FleetTripModels.ResourceOccupancy::available); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_resource_assignment",Integer.class)).isZero(); + } + @Test void failedReassignmentKeepsOldResourcesAndRollsBackTheNewVehicleClaim() { long oldVehicle = vehicle(true), occupiedVehicle = vehicle(true), replacement = vehicle(true); long oldDriver = driver(true), occupiedDriver = driver(true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index 4bf3aaddb..eeb0558c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -26,6 +26,9 @@ class WarehouseTransferCartonCountTest { @BeforeEach void setup() { service = spy(new WarehouseTransferService()); ReflectionTestUtils.setField(service, "transfers", orders); + var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); + org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> orders.lockForPda(call.getArgument(1), call.getArgument(0))); + ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "transferCartons", cartons); ReflectionTestUtils.setField(service, "transferItems", items); ReflectionTestUtils.setField(service, "transferPallets", palletMapper); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 8ea7bbb47..9d5867743 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -231,6 +231,9 @@ class WarehouseTransferServiceTest { return work.apply(new WarehouseTransferReceiptCoordinator.Context(target, 51L, 61L)); }); ReflectionTestUtils.setField(service, "transfers", transfers); + var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); + org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> transfers.lockForPda(call.getArgument(1), call.getArgument(0))); + ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "transferItems", transferItems); ReflectionTestUtils.setField(service, "transferCartons", transferCartons); ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); diff --git a/scripts/README.md b/scripts/README.md index a78fb315a..23eea8f66 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,9 @@ # 后端脚本管理与全量 HTTP 验收索引 +`local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。创建16张明确 TEST 标记的业务单、专用PDA账号、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机缺失的正式车次取消/结案/释放权限仅补权限资源并授权新建专用验收角色,不修改现有角色授权。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。 + +`db/fleet-transfer-pickup-20261017.sql`:本功能 MySQL8 可重复执行结构增量,显式指向 `baiyue_lastleg`。为原车次增加 `trip_purpose`,新增取货计划摘要、原单任务、命令幂等、上传归属及历史五表;不写业务数据。结构同步于模块 `lastleg.sql` 和统一部署增量。需在已授权环境用运行者显式数据库凭据执行;失败保留诊断,修正后可重复执行,不删旧结构或业务数据。 + `appointment-work-test-env.mjs`:预约事项与稳定批次 TEST 定向 HTTP 验收,默认 `--plan` 零网络;显式 `--execute --environment=TEST --run-id=<新批次> --mode=read|workflow --fixture=<本轮清单>` 才执行。运行者用 `node --env-file=<明确TEST配置>` 注入平台、客户及只读平台凭据,变量名和清单合同见 `--plan`。清单必须引用本轮正式业务生产者报告及 SHA256,不借用历史业务样本。read 只登录、查询、退出自身会话;workflow 还写一项跟进并验证原键重放、陈旧版本、异载荷和权限拒绝,可按清单登记客户仓无需预约要求,不代替排车/PDA/签收原域事实。报告写入独占的新 `reports/appointment-work-/`;失败保留报告和业务记录,不自动重发未知结果、删除、重置或执行 SQL。离线合同为 `appointment-work-test-env.test.mjs`;只读或离线通过不代表完整运输履约、真实页面通过。 `../lastleg-service/scripts/selfoperated-test-env-acceptance.mjs`:自营统计 TEST Gateway 验收,默认仅输出计划;显式 `--execute --environment TEST --source-account <账号键> --warehouse <仓库编码> --other-warehouse <第二仓> --report <报告路径>` 才登录并查询。凭据由运行者通过脚本之前的 `node --env-file=<明确TEST文件>` 注入,需平台、只读平台、无权限平台及客户身份。`--probe-off-command` 仅在确认 OFF 后提交预期被拒绝的命令并核对无新增任务;不采集、发布、造数或写 SQL。`--synthetic-fixture <审核后的说明文件>` 验证合成样本,不能证明真实来源覆盖。报告脱敏记录各项结果及请求标识;失败保留报告,重跑使用新报告路径并重新登录,结束退出自身会话,不清理业务样本。离线合同为同目录 `selfoperated-test-env-acceptance.test.mjs`。 diff --git a/scripts/db/fleet-transfer-pickup-20261017.sql b/scripts/db/fleet-transfer-pickup-20261017.sql new file mode 100644 index 000000000..760a50379 --- /dev/null +++ b/scripts/db/fleet-transfer-pickup-20261017.sql @@ -0,0 +1,60 @@ +-- 目标数据库:baiyue_lastleg;仅增加结构,不修改既有业务数据,可重复执行。 +USE baiyue_lastleg; +SET @pickup_column_sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_trip' AND column_name='trip_purpose')=0, + 'ALTER TABLE ll_fleet_trip ADD COLUMN trip_purpose VARCHAR(16) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''DELIVERY出库派送 PICKUP上门取货入仓''', + 'SELECT 1'); +PREPARE pickup_column_stmt FROM @pickup_column_sql; +EXECUTE pickup_column_stmt; +DEALLOCATE PREPARE pickup_column_stmt; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_plan ( + trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号', + request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要', + PRIMARY KEY(trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_task ( + transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号', + trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空', + status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', + actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收', + picked_at DATETIME NULL COMMENT '实际确认取货UTC时间', + arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间', + evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据', + remark VARCHAR(1000) NULL COMMENT '取货备注', + updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间', + PRIMARY KEY(transfer_order_id), + UNIQUE KEY uk_pickup_source(customer_id,transfer_no), + KEY idx_pickup_trip(trip_no,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_command ( + trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号', + request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要', + PRIMARY KEY(trip_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_upload ( + transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单', + trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次', + file_id BIGINT NOT NULL COMMENT '平台归属文件ID', + PRIMARY KEY(file_id), + KEY idx_pickup_upload_source(transfer_order_id,trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属'; + +CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号', + trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号', + action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME NOT NULL COMMENT '操作UTC时间', + detail VARCHAR(2000) NULL COMMENT '操作说明', + PRIMARY KEY(id), + KEY idx_pickup_history_source(customer_id,transfer_no,id), + KEY idx_pickup_history_trip(trip_no,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; diff --git a/scripts/local-transfer-pickup-acceptance.mjs b/scripts/local-transfer-pickup-acceptance.mjs new file mode 100644 index 000000000..35f68c727 --- /dev/null +++ b/scripts/local-transfer-pickup-acceptance.mjs @@ -0,0 +1,241 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { createHash, randomUUID } from 'node:crypto' + +// 本入口只访问显式指定的本机统一 Gateway;所有业务数据均经正式 HTTP 创建。 +const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '') +if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('仅允许显式配置本机 Gateway') +if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD || !process.env.LOCAL_TRANSFER_PDA_PASSWORD) throw new Error('缺少本机平台与PDA验收凭据') +const priorReport = process.env.PICKUP_ACCEPTANCE_RESUME ? JSON.parse(await readFile(resolve(process.env.PICKUP_ACCEPTANCE_RESUME), 'utf8')) : null +if (priorReport?.passed) throw new Error('本批次已验收完成,不重复执行业务;请查看原报告或创建新批次') +const marker = priorReport?.marker || `PICKUP-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}` +const output = resolve(process.env.PICKUP_ACCEPTANCE_RESUME || process.env.PICKUP_ACCEPTANCE_OUTPUT || `reports/${marker}.json`) +const report = priorReport || { marker, environment: 'LOCAL', evidence: 'SYNTHETIC_TEST_PNG_NOT_REAL_PHYSICAL_PICKUP', startedAt: new Date().toISOString(), passed: false, orders: [], trips: [], checks: [] } +const transferBase = '/api/platform/v1/last-leg/warehouse-transfers', pickupBase = '/api/platform/v1/last-leg/fleet/pickups', tripBase = '/api/platform/v1/last-leg/fleet/trips' +const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=','base64') +const attemptStart = report.checks.length +report.attempts ||= [] +const attempt = { startedAt: new Date().toISOString(), checkOffset: attemptStart, passed: false } +report.attempts.push(attempt) +let admin, pda + +/** 每个业务请求保留真实错误码和诊断,不保存会话、密码和完整客户资料。 */ +async function api(token, method, path, body, expectedCode) { + const form = body instanceof FormData + // 写入前留脱敏意图,网络结果未知时可用原请求键定位,不能当作未提交再次造单。 + if (method !== 'GET' && !path.startsWith('/api/auth/')) { + report.intents ||= [] + report.intents.push({ method, path, idempotencyKey: body?.idempotencyKey, payloadSha256: form ? undefined : createHash('sha256').update(JSON.stringify(body ?? null)).digest('hex'), at: new Date().toISOString() }) + await save() + } + const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) }) + const value = await response.json().catch(() => null), code = String(value?.code) + const passed = response.status === 200 && (expectedCode ? code === expectedCode : code === '200') + report.checks.push({ method, path, code, http: response.status, expectedCode, passed, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId, message: code === '200' ? undefined : value?.message }) + await save() + if (!passed) throw new Error(`${method} ${path}: ${response.status}/${code} ${value?.message || ''}`) + return expectedCode ? value : value.data +} +/** 断言必须对应服务端读回结果。 */ +function check(condition, label) { report.checks.push({ label, passed: Boolean(condition) }); if (!condition) throw new Error(label) } +/** 每个创建阶段立即落检查点,失败不删除本轮已成功单据。 */ +async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report, null, 2)) } +/** 本地模拟资料仅验证文件归属、签名和上传链路,不冒充实际照片或驾驶证。 */ +function image(name) { const body = new FormData(); body.append('file', new Blob([png], { type: 'image/png' }), `${marker}-${name}-TEST.png`); return body } +/** 所有执行操作使用当前车次版本和全新命令标识。 */ +const command = (trip, reason) => ({ recordVersion: trip.trip.recordVersion, idempotencyKey: randomUUID(), reason }) +/** 以既有有效客户创建本轮新业务单,不读取或推进历史订单。 */ +async function order(customer, warehouse, index, method = 'WAREHOUSE_PICKUP', review = true) { + if (priorReport?.orders[index - 1]) return priorReport.orders[index - 1] + const products = ['手机硅胶保护壳', '折叠收纳盒', 'USB-C充电线', '宠物饮水器', '不锈钢保温杯', '桌面理线器'] + const counts = index % 3 === 0 ? [3, 2, 1] : index % 2 === 0 ? [4, 2] : [3] + const payload = { operatingWarehouseId: warehouse.id, destinationType: 'PRIVATE', privateDestinationAddress: '100 TEST Distribution Road, Ontario, CA', privateDestinationPostalCode: '91761', privateDestinationContactName: '取货验收收件人', privateDestinationContactPhone: '0000000000', + deliveryAddress: `${1580 + index} TEST Pickup Ave, Ontario, CA 91761 (${marker})`, pickupContactName: `取货验收员${index}`, pickupContactPhone: '0000000000', + packageCount: counts.reduce((sum, value) => sum + value, 0), volumeCbm: 0, deliveryMethod: method, estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString().slice(0, 19), + estimatedShipAt: new Date(Date.now() + 172800000).toISOString().slice(0, 19), remark: `${marker} 场景${index} 本地业务接口验收单`, needRelabel: false, idempotencyKey: `${marker}-${index}`, + items: counts.map((packageCount, j) => ({ skuCode: `${marker}-${index}-${j + 1}`, productNameZh: products[(index + j) % products.length], quantity: packageCount * (8 + j * 4), packageCount, shippingMark: `${marker}-CUSTOMER-${index}-${j + 1}` })) } + let detail + if (index === 1) { + const token = await merchant(customer.id) + detail = await api(token, 'POST', '/api/merchant/v1/last-leg/warehouse-transfers', payload) + detail = await api(token, 'POST', `/api/merchant/v1/last-leg/warehouse-transfers/${detail.transferNo}/submit`) + check(detail.status === 'PENDING_REVIEW', '客户正式下单提交到待审核') + } else detail = await api(admin, 'POST', `${transferBase}?customerId=${customer.id}`, payload) + if (review) detail = await api(admin, 'POST', `${transferBase}/${detail.transferNo}/review?customerId=${customer.id}`, { decision: 'APPROVE', remark: `${marker} 本地取货验收` }) + const row = { customerId: customer.id, transferNo: detail.transferNo, lastlegOrderNo: detail.lastlegOrderNo, warehouseId: warehouse.id, method, packageCount: detail.packageCount, status: detail.status, scenario: `场景${index}`, cartonMarks: detail.cartons.map(box => box.cartonMark), declaredQuantity: detail.items.reduce((sum, item) => sum + Number(item.quantity || 0), 0) } + report.orders.push(row); await save(); return row +} +/** 创建取货车次后保留单号,便于页面人工验收。 */ +async function plan(sources, scenario) { + const existing = report.trips.find(row => row.scenario === scenario) + if (existing) return api(admin, 'GET', `${tripBase}/${existing.tripNo}`) + const body = { sources: sources.map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date(Date.now() - 300000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), remark: `${marker} ${scenario}`, idempotencyKey: randomUUID() } + const detail = await api(admin, 'POST', pickupBase, body) + report.trips.push({ tripNo: detail.trip.tripNo, scenario, sources: body.sources, status: detail.trip.status }); await save() + const replay = await api(admin, 'POST', pickupBase, body); check(replay.trip.tripNo === detail.trip.tripNo, '安排重试保持原车次') + return detail +} +/** 创建本轮专用司机和车辆,避免占用既有业务资源。 */ +async function resources(warehouse, index) { + if (report.resources?.[index - 1]) { const row = report.resources[index - 1]; return { vehicleId: row.vehicleId, driverId: row.driverId } } + const driverBase = '/api/platform/v1/last-leg/fleet/drivers' + let driver = await api(admin, 'POST', driverBase, { name: `${marker}取货司机${index}`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: `${marker}-${index}`, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), remark: `${marker} TEST licence image, local fixture only`, profile: { warehouseId: warehouse.id, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario local TEST route' } }) + driver = await api(admin, 'POST', `${driverBase}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image(`driver-${index}`)) + driver = await api(admin, 'POST', `${driverBase}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: `${marker} 本地模拟资质审核` }) + const vehicle = await api(admin, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { plateNumber: `TEST-PU-${marker.slice(-8)}-${index}`, model: '26ft Box Truck', fuelConsumptionPer100Km: 22, mileageKm: 18000 + index * 700, driverOrDispatchCompany: `${marker}本地验收车队`, phone: '0000000000', region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', remark: `${marker} 上门取货验收专用`, maxPalletSpaces: 12, maxLoadKg: 8000, maxVolumeCbm: 45 }) + report.resources ||= []; report.resources.push({ driverId: driver.id, driverNo: driver.driverNo, vehicleId: vehicle.id, plateNumber: vehicle.plateNumber }); await save() + return { vehicleId: vehicle.id, driverId: driver.id } +} +/** 分配资源沿原接口,取货车与派送车共用占用规则。 */ +async function assign(trip, resource) { if (trip.trip.vehicleId === resource.vehicleId && trip.trip.driverId === resource.driverId) return trip; return api(admin, 'POST', `${tripBase}/${trip.trip.tripNo}/assignment`, { recordVersion: trip.trip.recordVersion, ...resource, reason: `${marker} 分配取货车与司机` }) } +/** 发车使用取货链,不拣货、不生成出库单。 */ +async function depart(trip) { if (trip.trip.status !== 'PLANNED') return trip; return api(admin, 'POST', `${pickupBase}/${trip.trip.tripNo}/depart`, command(trip, `${marker} 车辆已出发取货`)) } +/** 逐票取货并验证同键重放,不重复扣减或改写客户预报。 */ +async function picked(trip, source, actual = source.packageCount) { + const existing = (await api(admin, 'GET', `${pickupBase}/${trip.trip.tripNo}/tasks`)).find(row => row.customerId === source.customerId && row.transferNo === source.transferNo) + if (existing && ['PICKED', 'ARRIVED'].includes(existing.status)) return { trip, file: existing.evidence[0] } + const path = `${pickupBase}/${trip.trip.tripNo}/tasks/${source.customerId}/${source.transferNo}` + const file = await api(admin, 'POST', `${path}/files`, image(source.transferNo)) + const body = { recordVersion: trip.trip.recordVersion, idempotencyKey: randomUUID(), actualPackages: actual, evidenceFileIds: [file.fileId], remark: actual === source.packageCount ? '取货现场箱数已核对' : '实提与预报不同,等待仓库独立清点' } + await api(admin, 'POST', `${path}/picked`, body); await api(admin, 'POST', `${path}/picked`, body) + await api(admin, 'POST', `${path}/picked`, { ...body, actualPackages: actual + 1 }, 'A0409') + const detail = await api(admin, 'GET', `${transferBase}/${source.transferNo}?customerId=${source.customerId}`) + check(detail.packageCount === source.packageCount && detail.status === 'WAIT_PDA_RECEIVING', '实提不覆盖预报箱数或完成测量') + check(JSON.stringify(detail.cartons.map(box => box.cartonMark)) === JSON.stringify(source.cartonMarks), '取货运输保留原预报箱唛') + return { trip: await api(admin, 'GET', `${tripBase}/${trip.trip.tripNo}`), file } +} +/** 到仓确认后才允许仓库作业。 */ +async function arrive(trip, source) { + if ((await api(admin, 'GET', `${pickupBase}/${trip.trip.tripNo}/tasks`)).some(row => row.customerId === source.customerId && row.transferNo === source.transferNo && row.status === 'ARRIVED')) return trip + await api(admin, 'POST', `${pickupBase}/${trip.trip.tripNo}/tasks/${source.customerId}/${source.transferNo}/arrive`, command(trip, `${marker} 货已送达入仓月台`)) + return api(admin, 'GET', `${tripBase}/${trip.trip.tripNo}`) +} +/** 以平台正式代登录接口取得客户会话,仅用于本轮读回与越权测试。 */ +async function merchant(customerId) { + const ticket = await api(admin, 'POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 验证客户本票取货回显与隔离` }) + return (await api('', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code })).accessToken +} + +/** 送仓后走原 PDA 托盘、逐箱清点、照片及差异确认完整链,运输不代替仓内事实。 */ +async function measure(source) { + const path = `/api/pda/v1/last-leg/warehouse-transfers/${source.transferNo}` + const current = await api(pda, 'GET', path) + if (current.status !== 'WAIT_PDA_RECEIVING') { check(current.status === 'WAIT_CUSTOMER_CONFIRMATION', '续验测量完成状态'); return } + let detail = current.pallets.length ? current : await api(pda, 'POST', `${path}/pallets/drafts`, { idempotencyKey: randomUUID() }) + let pallet = detail.pallets[0] + if (pallet.status === 'DRAFT') detail = await api(pda, 'POST', `${path}/pallets/${pallet.id}/generate`, { idempotencyKey: randomUUID() }) + pallet = detail.pallets.find(row => row.id === pallet.id) + if (pallet.status !== 'BOUND') detail = await api(pda, 'POST', `${path}/pallets/${pallet.id}/bind`, { idempotencyKey: randomUUID(), palletCode: pallet.palletNo }) + const declared = detail.items.flatMap(item => Array.from({ length: item.packageCount }, () => ({ skuCode: item.skuCode, quantity: Number(item.quantity) / item.packageCount }))) + check(declared.length === detail.cartons.length, '预报SKU箱数与固定箱唛总数一致') + const boxes = detail.cartons.map((box, index) => ({ cartonMark: box.cartonMark, transferPalletId: pallet.id, length: 40 + index, width: 30, height: 25, weight: 4 + index / 10, items: [{ ...declared[index], quantity: declared[index].quantity - (index === 0 ? 1 : 0) }] })) + if (!detail.cartons.some(row => row.countedAt)) detail = await api(pda, 'POST', `${path}/cartons/count`, { idempotencyKey: randomUUID(), forecastPackageCount: detail.packageCount, boxes }) + const photo = await api(pda, 'POST', `${path}/measurement-photos/files`, image('measurement')) + await api(pda, 'POST', `${path}/palletize`, { idempotencyKey: randomUUID(), photoFileIds: [photo.fileId] }, 'A0400') + detail = await api(pda, 'POST', `${path}/palletize`, { idempotencyKey: randomUUID(), photoFileIds: [photo.fileId], discrepancyReason: '本地验收:首箱SKU实点短收1件,已复点并拍照留存' }) + check(detail.status === 'WAIT_CUSTOMER_CONFIRMATION' && detail.orderStatus === 'INBOUND_COMPLETED', '取货送仓后PDA完整测量成功,进入客户确认') + check(JSON.stringify(detail.cartons.map(box => box.cartonMark)) === JSON.stringify(source.cartonMarks), '测量完成保留客户预报箱唛身份') + check(detail.items.reduce((sum, item) => sum + Number(item.quantity), 0) === source.declaredQuantity, '测量不覆盖客户SKU预报件数') + source.palletNo = pallet.palletNo + source.scenario = '取货送仓后已完成PDA测量,SKU短收1件且已填写原因' + await save() +} + +try { + const login = await api('', 'POST', '/api/auth/login', { loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD }) + admin = login.accessToken + // 本机旧权限目录可能未初始化取消/结案/释放资源;只补正式权限资源并授权本轮专用账号。 + let permissions = await api(admin, 'GET', '/api/platform/v1/iam/permissions') + for (const [suffix, name] of [['cancel', '取消车次'], ['complete', '车次货物结案'], ['release', '结束车辆司机任务']]) { + const code = `platform:fleet:trip:${suffix}` + if (!permissions.some(row => row.permissionCode === code)) { + const row = await api(admin, 'POST', '/api/platform/v1/iam/permissions', { type: 'BUTTON', code, name, parentId: permissions.find(item => item.permissionCode === 'platform:fleet:trip:list').id, httpMethod: 'POST' }) + permissions.push(row) + } + } + if (!report.operatorUsername) { + const existingRole = (await api(admin, 'GET', '/api/platform/v1/iam/roles')).find(row => row.roleCode === `${marker}_OPERATOR`) + const role = existingRole || await api(admin, 'POST', '/api/platform/v1/iam/roles', { roleCode: `${marker}_OPERATOR`, roleName: `${marker}本地取货验收角色`, description: '沿当前验收账号权限增加正式车次取消、结案和释放,仅供本轮本机验收' }) + const allowed = new Set([...login.authorities, 'platform:fleet:trip:cancel', 'platform:fleet:trip:complete', 'platform:fleet:trip:release']) + await api(admin, 'PUT', '/api/platform/v1/iam/roles/permissions', { roleId: role.id, permissionIds: permissions.filter(row => allowed.has(row.permissionCode)).map(row => row.id) }) + const username = `pickup_op_${marker.replace(/\D/g, '')}` + await api(admin, 'POST', '/api/platform/v1/iam/users', { username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD, displayName: `${marker}本地取货验收`, phone: `18${String(Date.now()).slice(-9)}`, tenantId: '0', roleIds: [role.id], status: 'ACTIVE' }) + report.operatorUsername = username; await save() + } + admin = (await api('', 'POST', '/api/auth/login', { loginId: report.operatorUsername, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD })).accessToken + const tenants = await api(admin, 'GET', '/api/platform/v1/iam/tenants?page=1&size=200') + const customers = tenants.records.filter(row => row.status === 'ACTIVE').sort((a, b) => Number(!String(a.tenantName || a.name).includes('测试')) - Number(!String(b.tenantName || b.name).includes('测试'))) + check(customers.length >= 2, '本地至少两名有效客户用于共车隔离验收') + const warehouses = (await api(admin, 'GET', '/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records + const warehouse = warehouses.find(row => row.warehouseCode === 'US-LAX-01') || warehouses[0], otherWarehouse = warehouses.find(row => row.id !== warehouse?.id) + check(warehouse && otherWarehouse, '本地至少两个有效海外仓用于异仓拒绝验收') + report.warehouseId = warehouse.id + const roles = await api(admin, 'GET', '/api/platform/v1/iam/roles'), role = roles.find(row => row.roleCode === 'OVERSEAS_WAREHOUSE_PDA_OPERATOR' && row.status === 'ACTIVE') + check(role, '存在海外PDA作业角色') + const username = `pda_pickup_${marker.replace(/\D/g, '')}` + if (!report.pdaUsername) await api(admin, 'POST', '/api/platform/v1/iam/users', { username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD, displayName: `${marker}取货验收PDA`, phone: `18${String(Date.now()).slice(-9)}`, email: `${username}@example.com`, tenantId: '0', roleIds: [role.id], overseasWarehouseId: warehouse.id, status: 'ACTIVE' }) + pda = (await api('', 'POST', '/api/auth/login', { loginId: username, password: process.env.LOCAL_TRANSFER_PDA_PASSWORD })).accessToken + report.pdaUsername = username + const orders = [] + for (let i = 1; i <= 15; i++) orders.push(await order(customers[i % 2], warehouse, i, i >= 13 && i <= 14 ? 'CUSTOMER_DELIVERY' : 'WAREHOUSE_PICKUP', i !== 15)) + const differentWarehouse = await order(customers[0], otherWarehouse, 16) + const waiting = orders[0], sharedA = orders[1], sharedB = orders[2] + waiting.scenario = '待取货,供页面手工选单'; orders[12].scenario = '客户自送,不进入取货池'; orders[13].scenario = '客户自送,不进入取货池'; orders[14].scenario = '未审核,不进入取货池' + // 候选关键字查地址,取货地址中使用本轮标识;按单号定向复验不依赖全局数据量。 + const single = await api(admin, 'GET', `${pickupBase}?page=1&size=100&keyword=${waiting.transferNo}`) + check(single.records.some(row => row.transferNo === waiting.transferNo), '已审上门取货单自动进入候选') + for (const excluded of [orders[12], orders[13], orders[14]]) { + const result = await api(admin, 'GET', `${pickupBase}?page=1&size=100&keyword=${excluded.transferNo}`) + check(result.total === 0 || Number(result.total) === 0, '自送或未审核单不进入待取货池') + } + const invalid = { sources: [waiting, differentWarehouse].map(({ customerId, transferNo }) => ({ customerId, transferNo })), plannedDepartureAt: new Date().toISOString(), expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), idempotencyKey: randomUUID() } + await api(admin, 'POST', pickupBase, invalid, 'A0400') + await api(pda, 'POST', `/api/pda/v1/last-leg/warehouse-transfers/${waiting.transferNo}/pallets/drafts`, { idempotencyKey: randomUUID() }, 'A0409') + let shared = await plan([sharedA, sharedB], '两客户多点共车取货,全流程验收') + await api(admin, 'POST', pickupBase, { ...invalid, sources: [{ customerId: sharedA.customerId, transferNo: sharedA.transferNo }], idempotencyKey: randomUUID() }, 'A0409') + const resource = await resources(warehouse, 1); shared = await assign(shared, resource) + let cancelTrip = await plan([orders[3]], '取消后原单回到取货池') + await api(admin, 'POST', `${tripBase}/${cancelTrip.trip.tripNo}/assignment`, { recordVersion: cancelTrip.trip.recordVersion, ...resource, reason: '验收同一资源不能同时占用两车' }, 'A0409') + if (cancelTrip.trip.status !== 'CANCELLED') cancelTrip = await api(admin, 'POST', `${tripBase}/${cancelTrip.trip.tripNo}/cancel`, { recordVersion: cancelTrip.trip.recordVersion, reason: `${marker} 改派验收` }) + check((await api(admin, 'GET', `${pickupBase}?keyword=${orders[3].transferNo}`)).records.some(row => row.transferNo === orders[3].transferNo), '取消车次后原单可重新安排') + const replanned = await plan([orders[3], orders[4]], '取消后的重新安排,保留计划中供页面验收'); orders[3].scenario = '已改派重新安排'; orders[4].scenario = '已排车未分配车辆' + shared = await depart(shared) + if (!(await api(admin, 'GET', `${pickupBase}/${shared.trip.tripNo}/tasks`)).some(row => row.status !== 'ASSIGNED')) await api(admin, 'POST', `${pickupBase}/${shared.trip.tripNo}/complete`, command(shared, '验收未取货不能结案'), 'A0409') + await api(admin, 'POST', `${tripBase}/${shared.trip.tripNo}/cancel`, { recordVersion: shared.trip.recordVersion, reason: '验收已发车不可取消' }, 'A0409') + const pickedA = await picked(shared, sharedA, sharedA.packageCount - 1); shared = pickedA.trip + const pathB = `${pickupBase}/${shared.trip.tripNo}/tasks/${sharedB.customerId}/${sharedB.transferNo}` + if ((await api(admin, 'GET', `${pickupBase}/${shared.trip.tripNo}/tasks`)).some(row => row.transferNo === sharedB.transferNo && row.status === 'ASSIGNED')) await api(admin, 'POST', `${pathB}/picked`, { recordVersion: shared.trip.recordVersion, idempotencyKey: randomUUID(), actualPackages: sharedB.packageCount, evidenceFileIds: [pickedA.file.fileId] }, 'A0400') + const pickedB = await picked(shared, sharedB); shared = pickedB.trip + shared = await arrive(shared, sharedA); shared = await arrive(shared, sharedB) + const firstMerchant = await merchant(sharedA.customerId), secondMerchant = await merchant(sharedB.customerId) + const progress = await api(firstMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-progress`) + check(progress.status === 'ARRIVED' && progress.evidence.length === 1 && progress.tripNo === shared.trip.tripNo, '客户详情回显本票取货车次、进度和凭证') + await api(secondMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-progress`, undefined, 'A0404') + await api(secondMerchant, 'GET', `/api/merchant/v1/last-leg/warehouse-transfers/${sharedB.transferNo}/pickup-evidence/${pickedA.file.fileId}`, undefined, 'A0404') + const binary = await fetch(`${gateway}/api/merchant/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pickup-evidence/${pickedA.file.fileId}`, { headers: { Authorization: `Bearer ${firstMerchant}` } }) + check(binary.status === 200 && Buffer.from(await binary.arrayBuffer()).equals(png), '本票取货凭证真实文件可下载') + await api(pda, 'POST', `/api/pda/v1/last-leg/warehouse-transfers/${sharedA.transferNo}/pallets/drafts`, { idempotencyKey: randomUUID() }) + check((await api(admin, 'GET', `${transferBase}/${sharedA.transferNo}?customerId=${sharedA.customerId}`)).status === 'WAIT_PDA_RECEIVING', '送仓后PDA可开始,运输不冒充测量完成') + if (shared.trip.status !== 'COMPLETED') shared = await api(admin, 'POST', `${pickupBase}/${shared.trip.tripNo}/complete`, command(shared, '全车已送仓,结束取货运输')) + for (const resourceKind of ['VEHICLE', 'DRIVER']) if (!shared.trip[resourceKind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) shared = await api(admin, 'POST', `${tripBase}/${shared.trip.tripNo}/resources/release`, { ...command(shared, '实际车辆司机任务结束'), resourceKind, actualEndAt: new Date().toISOString(), endLocation: warehouse.warehouseName || '本地测试海外仓', vehicleStatus: resourceKind === 'VEHICLE' ? 'IN_SERVICE' : null }) + sharedA.scenario = '已送仓并已开始PDA测量,司机实提少一箱'; sharedB.scenario = '已送仓,取货运输已结案,仓库尚未清点' + await measure(sharedB) + // 保留三类不同履约阶段,方便用户进入页面继续验收。 + let onRoad = await plan([orders[5], orders[6]], '已取货未送仓供页面验收'); onRoad = await assign(onRoad, resource); onRoad = await depart(onRoad); onRoad = (await picked(onRoad, orders[5])).trip; onRoad = (await picked(onRoad, orders[6])).trip + orders[5].scenario = orders[6].scenario = '已取货未送仓' + const secondResource = await resources(warehouse, 2) + let assigned = await plan([orders[7], orders[8]], '已分配车辆司机尚未发车供页面验收'); assigned = await assign(assigned, secondResource) + orders[7].scenario = orders[8].scenario = '已分配车辆司机,尚未发车' + for (const entry of report.trips) { + const detail = await api(admin, 'GET', `${tripBase}/${entry.tripNo}`); entry.status = detail.trip.status; entry.vehiclePlate = detail.trip.vehiclePlate + } + for (const entry of report.orders) { + const detail = await api(admin, 'GET', `${transferBase}/${entry.transferNo}?customerId=${entry.customerId}`); entry.status = detail.status + const progress = await api(admin, 'GET', `${transferBase}/${entry.transferNo}/pickup-progress?customerId=${entry.customerId}`); entry.pickupStatus = progress?.status || null; entry.tripNo = progress?.tripNo || null + } + check(replanned.trip.tripNo && assigned.trip.vehicleId, '改派与已分配阶段单据持久保留') + check(report.checks.slice(attemptStart).every(row => row.passed), '本次执行所有断言通过;前次失败保留审计记录') + attempt.passed = true; attempt.completedAt = new Date().toISOString(); + report.passed = true; delete report.error; report.completedAt = new Date().toISOString(); await save() + console.log(JSON.stringify({ passed: true, marker, orders: report.orders.length, trips: report.trips.length, checks: report.checks.length, report: output })) +} catch (error) { attempt.error = error.message; report.error = error.message; await save(); console.error(JSON.stringify({ passed: false, marker, error: error.message, report: output })); process.exitCode = 1 } From fe1b2a6a46b635b699f14a5fdd286a42f1ce2447 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 11:54:29 +0800 Subject: [PATCH 015/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/docker-compose.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index a32c3906f..c4d3c1d27 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -285,9 +285,14 @@ services: image: ${IMAGE_REPOSITORY}/lastleg-service:${IMAGE_TAG} container_name: firstleg-${DEPLOY_ENV:-test}-lastleg expose: ["9303"] + # 仅限制尾程服务,避免 JVM 异常时拖垮测试服务器. + mem_limit: 3g + memswap_limit: 3g + cpus: "2.0" environment: <<: *common-environment SERVER_PORT: 9303 + JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1536m -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" SELFOP_EXECUTION_ENABLED: ${SELFOP_EXECUTION_ENABLED:-false} SELFOP_COLLECTION_MODE: ${SELFOP_COLLECTION_MODE:-OFF} SELFOP_SOURCE_ACCOUNT: ${SELFOP_SOURCE_ACCOUNT:-lingxing-primary} @@ -333,3 +338,5 @@ networks: infra-network: external: true name: ${INFRA_NETWORK:-firstleg-${DEPLOY_ENV:-test}-infra-network} + + From aa195cef66b347b78ae3c34584b67a098bd828c4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 12:15:43 +0800 Subject: [PATCH 016/113] feat(appointments): add four-type calendar sources and align release contracts --- deploy/sql/README.md | 2 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 62 ------------------- deploy/sql/海外区域统一增量-MySQL8.sql | 57 ++++++++++++++++- .../ContainerCalendarSourceController.java | 28 +++++++++ .../dto/ContainerCalendarSource.java | 8 +++ .../mapper/ContainerCalendarSourceMapper.java | 44 +++++++++++++ .../ContainerCalendarSourceRepository.java | 19 ++++++ .../ContainerCalendarSourceService.java | 46 ++++++++++++++ .../appointment-calendar-sources-v1.yaml | 55 ++++++++++++++++ .../ContainerCalendarSourceMapperTest.java | 44 +++++++++++++ .../ContainerCalendarSourceServiceTest.java | 45 ++++++++++++++ .../calendar/WarehouseCalendarController.java | 29 +++++++++ .../calendar/WarehouseCalendarMapper.java | 42 +++++++++++++ .../calendar/WarehouseCalendarModels.java | 20 ++++++ .../calendar/WarehouseCalendarRepository.java | 15 +++++ .../calendar/WarehouseCalendarService.java | 27 ++++++++ .../appointment-calendar-warehouse-v1.yaml | 56 +++++++++++++++++ .../calendar/WarehouseCalendarMapperTest.java | 39 ++++++++++++ .../WarehouseCalendarServiceTest.java | 22 +++++++ .../fleet/trip/FleetTripApiContractTest.java | 2 +- .../trip/FleetTripAuthorizationTest.java | 2 + 21 files changed, 599 insertions(+), 65 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java create mode 100644 firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java create mode 100644 lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 09da10211..24ec7e9e8 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -45,7 +45,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 215 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 220 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 79598ecf6..d58001da3 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -8366,65 +8366,3 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; - --- 2026-10-17 仓储转运上门取货,目标 baiyue_lastleg。 --- 目标数据库:baiyue_lastleg;仅增加结构,不修改既有业务数据,可重复执行。 -USE baiyue_lastleg; -SET @pickup_column_sql = IF((SELECT COUNT(*) FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_trip' AND column_name='trip_purpose')=0, - 'ALTER TABLE ll_fleet_trip ADD COLUMN trip_purpose VARCHAR(16) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''DELIVERY出库派送 PICKUP上门取货入仓''', - 'SELECT 1'); -PREPARE pickup_column_stmt FROM @pickup_column_sql; -EXECUTE pickup_column_stmt; -DEALLOCATE PREPARE pickup_column_stmt; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_plan ( - trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号', - request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要', - PRIMARY KEY(trip_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划'; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_task ( - transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一', - customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', - transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号', - trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空', - status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', - actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收', - picked_at DATETIME NULL COMMENT '实际确认取货UTC时间', - arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间', - evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据', - remark VARCHAR(1000) NULL COMMENT '取货备注', - updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间', - PRIMARY KEY(transfer_order_id), - UNIQUE KEY uk_pickup_source(customer_id,transfer_no), - KEY idx_pickup_trip(trip_no,status) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行'; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_command ( - trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号', - request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键', - request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要', - PRIMARY KEY(trip_no,request_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录'; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_upload ( - transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单', - trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次', - file_id BIGINT NOT NULL COMMENT '平台归属文件ID', - PRIMARY KEY(file_id), - KEY idx_pickup_upload_source(transfer_order_id,trip_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属'; - -CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history ( - id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水', - customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', - transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号', - trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号', - action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作', - operator_id VARCHAR(64) NOT NULL COMMENT '操作人', - occurred_at DATETIME NOT NULL COMMENT '操作UTC时间', - detail VARCHAR(2000) NULL COMMENT '操作说明', - PRIMARY KEY(id), - KEY idx_pickup_history_source(customer_id,transfer_no,id), - KEY idx_pickup_history_trip(trip_no,id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 2cb992618..82e10bf4b 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2928,6 +2928,7 @@ CREATE TABLE ll_fleet_vehicle_record_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆运维记录不可变历史'; CREATE TABLE ll_fleet_trip ( + trip_purpose VARCHAR(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓', id BIGINT NOT NULL COMMENT '内部主键', trip_no VARCHAR(32) NOT NULL COMMENT '车次业务编号', idempotency_key VARCHAR(128) NOT NULL COMMENT '创建请求幂等键', @@ -4657,4 +4658,58 @@ CREATE TABLE ll_compact_number_clock ( PRIMARY KEY (number_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 215 AS initialized_tables; +-- 仓储转运上门取货表归尾程服务,仅用于空库初始化。 +USE baiyue_lastleg; +CREATE TABLE ll_fleet_pickup_plan ( + trip_no VARCHAR(64) NOT NULL COMMENT '现有车辆车次号', + request_hash VARCHAR(64) NOT NULL COMMENT '创建来源与计划摘要', + PRIMARY KEY(trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运取货计划'; + +CREATE TABLE ll_fleet_pickup_task ( + transfer_order_id BIGINT NOT NULL COMMENT '原仓储转运单ID,一票唯一', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原仓储转运单号', + trip_no VARCHAR(64) NULL COMMENT '当前取货车次,解除后为空', + status VARCHAR(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', + actual_packages INT NULL COMMENT '司机实提箱数,非仓库实收', + picked_at DATETIME NULL COMMENT '实际确认取货UTC时间', + arrived_at DATETIME NULL COMMENT '实际确认送仓UTC时间', + evidence_json LONGTEXT NULL COMMENT '本票已确认凭证元数据', + remark VARCHAR(1000) NULL COMMENT '取货备注', + updated_at DATETIME NOT NULL COMMENT '最近修改UTC时间', + PRIMARY KEY(transfer_order_id), + UNIQUE KEY uk_pickup_source(customer_id,transfer_no), + KEY idx_pickup_trip(trip_no,status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='仓储转运逐票取货执行'; + +CREATE TABLE ll_fleet_pickup_command ( + trip_no VARCHAR(64) NOT NULL COMMENT '取货车次号', + request_key VARCHAR(128) NOT NULL COMMENT '成功命令幂等键', + request_hash VARCHAR(64) NOT NULL COMMENT '命令动作与内容摘要', + PRIMARY KEY(trip_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货命令幂等记录'; + +CREATE TABLE ll_fleet_pickup_upload ( + transfer_order_id BIGINT NOT NULL COMMENT '凭证所属原单', + trip_no VARCHAR(64) NOT NULL COMMENT '凭证所属车次', + file_id BIGINT NOT NULL COMMENT '平台归属文件ID', + PRIMARY KEY(file_id), + KEY idx_pickup_upload_source(transfer_order_id,trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货上传凭证精确单据归属'; + +CREATE TABLE ll_fleet_pickup_history ( + id BIGINT NOT NULL AUTO_INCREMENT COMMENT '操作流水', + customer_id VARCHAR(64) NOT NULL COMMENT '客户隔离键', + transfer_no VARCHAR(64) NOT NULL COMMENT '原转运单号', + trip_no VARCHAR(64) NOT NULL COMMENT '当时取货车次号', + action VARCHAR(32) NOT NULL COMMENT '安排解除取货到仓动作', + operator_id VARCHAR(64) NOT NULL COMMENT '操作人', + occurred_at DATETIME NOT NULL COMMENT '操作UTC时间', + detail VARCHAR(2000) NULL COMMENT '操作说明', + PRIMARY KEY(id), + KEY idx_pickup_history_source(customer_id,transfer_no,id), + KEY idx_pickup_history_trip(trip_no,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 220 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java new file mode 100644 index 000000000..67ec93b9c --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java @@ -0,0 +1,28 @@ +package com.baiyue.firstleg.terminaldrayage.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import com.baiyue.firstleg.terminaldrayage.service.ContainerCalendarSourceService; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import java.time.LocalDate; + +@RestController +@RequestMapping("/api/platform/v1/terminal-drayages") +public class ContainerCalendarSourceController { + private final ContainerCalendarSourceService service; + /** 装配只读日历业务服务。 */ + public ContainerCalendarSourceController(ContainerCalendarSourceService service) { this.service = service; } + /** 预约列表可读基础柜信息,还柜事实继续核验原业务读取权限。 */ + @GetMapping("/calendar-sources") + @PreAuthorize("hasAuthority('platform:appointment:list')") + public ApiResponse calendar(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from, + @RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.calendar(from, to, user.permissions())); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java new file mode 100644 index 000000000..110682deb --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java @@ -0,0 +1,8 @@ +package com.baiyue.firstleg.terminaldrayage.dto; + +import java.time.LocalDateTime; + +/** 四类日历使用的柜级来源,日期均保留原业务当地值。 */ +public record ContainerCalendarSource(Long containerBookingId, String cabinetNo, String containerNo, + String vesselName, String voyageNo, String destinationPortName, String destinationWarehouseCode, + LocalDateTime arrivalAt, String arrivalType) { } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java new file mode 100644 index 000000000..00c852160 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface ContainerCalendarSourceMapper { + /** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */ + @Select(""" + SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo, + c.vessel_name vesselName,c.voyage_no voyageNo,c.destination_port_name destinationPortName, + c.destination_warehouse_code destinationWarehouseCode, + COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) arrivalAt, + CASE WHEN COALESCE(n.actual_at,c.actual_port_arrival_at) IS NOT NULL THEN 'ACTUAL' ELSE 'ESTIMATED' END arrivalType + FROM fl_container_booking c + LEFT JOIN fl_terminal_drayage t ON t.container_booking_id=c.id + LEFT JOIN fl_carrier_schedule_node_fact n ON n.id=( + SELECT f.id FROM fl_carrier_schedule_node_fact f WHERE f.container_booking_id=c.id + AND f.standard_node='ARRIVED_DESTINATION_PORT' AND f.actual_at IS NOT NULL + ORDER BY f.observed_at DESC,f.id DESC LIMIT 1) + WHERE c.record_status='ACTIVE' + AND EXISTS(SELECT 1 FROM fl_outbound_dispatch d WHERE d.container_booking_id=c.id + AND d.confirmed_at IS NOT NULL AND d.reversed_at IS NULL AND d.status='WAITING_FOR_LOADING') + AND ( + COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at) IS NULL + OR (COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)>=#{arrivalFrom} + AND COALESCE(n.actual_at,c.actual_port_arrival_at,c.estimated_port_arrival_at)<#{to}) + OR (t.actual_empty_return_at>=#{from} AND t.actual_empty_return_at<#{to}) + OR (c.unloading_completed_at>=#{returnFrom} AND c.unloading_completed_at<#{to}) + OR EXISTS(SELECT 1 FROM fl_carrier_schedule_node_fact r WHERE r.container_booking_id=c.id + AND r.standard_node='EMPTY_RETURNED' AND r.actual_at>=#{from} AND r.actual_at<#{to}) + OR EXISTS(SELECT 1 FROM fl_direct_delivery_unloading u WHERE u.container_booking_id=c.id + AND u.actual_at>=#{returnFrom} AND u.actual_at<#{to}) + ) + ORDER BY c.id LIMIT 2001 + """) + List sources(@Param("from") LocalDateTime from, + @Param("arrivalFrom") LocalDateTime arrivalFrom, @Param("returnFrom") LocalDateTime returnFrom, + @Param("to") LocalDateTime to); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java new file mode 100644 index 000000000..9e6588345 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java @@ -0,0 +1,19 @@ +package com.baiyue.firstleg.terminaldrayage.repository; + +import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource; +import com.baiyue.firstleg.terminaldrayage.mapper.ContainerCalendarSourceMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDate; +import java.util.List; + +@Repository +public class ContainerCalendarSourceRepository { + private final ContainerCalendarSourceMapper mapper; + /** 使用本服务 Mapper 读取日历所需柜级来源。 */ + public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; } + /** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */ + public List sources(LocalDate from, LocalDate to) { + return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(), + from.minusDays(4).atStartOfDay(), to.atStartOfDay()); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java new file mode 100644 index 000000000..4903f8062 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java @@ -0,0 +1,46 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.View; +import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import java.time.LocalDate; +import java.time.temporal.ChronoUnit; +import java.util.*; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class ContainerCalendarSourceService { + public record Result(List containers, List emptyReturns, + boolean emptyReturnsReadable) { } + private final ContainerCalendarSourceRepository repository; + private final TerminalDrayageService drayages; + /** 复用真实还柜业务的截止时间和完成事实,不在日历另算还柜状态。 */ + public ContainerCalendarSourceService(ContainerCalendarSourceRepository repository, TerminalDrayageService drayages) { + this.repository = repository; this.drayages = drayages; + } + /** 按日期范围读取到港、默认到仓来源及可见的还柜记录。 */ + public Result calendar(LocalDate from, LocalDate to, Set permissions) { + if (from == null || to == null || !to.isAfter(from) || ChronoUnit.DAYS.between(from, to) > 42) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历查询范围必须为1至42天"); + var sources = repository.sources(from, to); + if (sources.size() > 2000) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历来源超过2000柜,请缩小范围或补齐未定日期"); + boolean readable = permissions != null && permissions.contains("platform:firstleg:terminal-drayage:list"); + var returns = new ArrayList(); + if (readable) { + var ids = sources.stream().map(ContainerCalendarSource::containerBookingId).toList(); + for (int start = 0; start < ids.size(); start += 200) { + for (var row : drayages.authorize(drayages.views(ids.subList(start, Math.min(start + 200, ids.size()))), permissions)) { + var at = row.actualEmptyReturnAt() != null ? row.actualEmptyReturnAt() : row.emptyReturnWarning().deadlineAt(); + if (at != null && !at.toLocalDate().isBefore(from) && at.toLocalDate().isBefore(to)) returns.add(row); + } + } + } + // 未授权还柜读取时,也不能通过仅因还柜日期命中的旧柜候选推断受保护事实。 + var visibleSources = readable ? sources : sources.stream().filter(row -> row.arrivalAt() == null + || !row.arrivalAt().toLocalDate().isBefore(from.minusDays(2)) && row.arrivalAt().toLocalDate().isBefore(to)).toList(); + return new Result(List.copyOf(visibleSources), List.copyOf(returns), readable); + } +} diff --git a/firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml b/firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml new file mode 100644 index 000000000..2faa734e9 --- /dev/null +++ b/firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml @@ -0,0 +1,55 @@ +openapi: 3.0.3 +info: {title: 四类日历柜级来源, version: 1.0.0} +security: [{bearerAuth: []}] +paths: + /api/platform/v1/terminal-drayages/calendar-sources: + get: + summary: 查询日历到港、默认到仓来源和还柜事实 + description: >- + 仅平台,platform:appointment:list。返回已确认国内出仓、未撤销且有效的柜次。 + 到港取最新已观测实际到港节点、柜次实际到港、预计到港,按此顺序选一个日期。 + 到港候选前移两天覆盖计划到仓默认日期;缺到港日期的柜次同时返回,不能伪造日期。 + 还柜候选含原截止日期与真实完成日期,最终由原码头拖车业务复核,完成后只按实际还柜日期归日历。 + 没有platform:firstleg:terminal-drayage:list时emptyReturns为空且emptyReturnsReadable=false;不把缺权限当没有安排。 + 最多2000个候选,超出返回400;来源读取失败返回错误,不以空列表代替。 + x-permissions: [platform:appointment:list] + parameters: + - {name: from, in: query, required: true, schema: {type: string, format: date}, description: 包含的原业务当地日期} + - {name: to, in: query, required: true, schema: {type: string, format: date}, description: 不包含的原业务当地日期,范围最多42天} + responses: + '200': + description: 成功业务响应,code为字符串200 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: object + required: [containers, emptyReturns, emptyReturnsReadable] + properties: + emptyReturnsReadable: {type: boolean} + containers: + type: array + items: + type: object + properties: + containerBookingId: {type: string} + cabinetNo: {type: string, nullable: true} + containerNo: {type: string, nullable: true} + vesselName: {type: string, nullable: true} + voyageNo: {type: string, nullable: true} + destinationPortName: {type: string, nullable: true} + destinationWarehouseCode: {type: string, nullable: true} + arrivalAt: {type: string, format: date-time, nullable: true} + arrivalType: {type: string, enum: [ACTUAL, ESTIMATED]} + emptyReturns: + type: array + description: 沿terminal-drayage-v1的View结构,allowedActions按当前权限过滤;含actualEmptyReturnAt及emptyReturnWarning.deadlineAt + items: {$ref: './terminal-drayage-v1.yaml#/components/schemas/TerminalDrayageView'} + '400': {description: 范围无效或候选过多} + '403': {description: 非平台或没有预约读取权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java new file mode 100644 index 000000000..c5695c497 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java @@ -0,0 +1,44 @@ +package com.baiyue.firstleg.terminaldrayage.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; + +class ContainerCalendarSourceMapperTest { + /** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */ + @Test + void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:four-type-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var connection = ds.getConnection(); var sql = connection.createStatement()) { + sql.execute("CREATE TABLE fl_container_booking(id BIGINT,cabinet_no VARCHAR,container_no VARCHAR," + + "vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," + + "actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)"); + sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)"); + sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,container_booking_id BIGINT,standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)"); + sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)"); + sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)"); + sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " + + "(1,'D1','2026-10-30 12:00:00','ACTIVE'),(2,'D2','2026-11-10 12:00:00','ACTIVE')," + + "(3,'D3',NULL,'ACTIVE'),(4,'D4','2026-01-01 12:00:00','ACTIVE')," + + "(5,'D5','2026-11-10 12:00:00','CANCELLED'),(6,'D6','2026-11-10 12:00:00','ACTIVE')"); + sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-01-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking WHERE id<>6"); + sql.execute("INSERT INTO fl_terminal_drayage VALUES(4,'2026-11-05 12:00:00')"); + sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES(1,2,'ARRIVED_DESTINATION_PORT','2026-10-25 12:00:00','2026-10-26 12:00:00')"); + } + var config = new MybatisConfiguration(); + config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); config.addMapper(ContainerCalendarSourceMapper.class); + try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var from = LocalDateTime.parse("2026-11-01T00:00:00"); + var rows = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusDays(30)); + assertEquals(java.util.List.of(1L, 3L, 4L), rows.stream().map(row -> row.containerBookingId()).toList()); + assertEquals("2026-10-30T12:00", rows.get(0).arrivalAt().toString()); + assertNull(rows.get(1).arrivalAt()); + } + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java new file mode 100644 index 000000000..fe5ee0ff0 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java @@ -0,0 +1,45 @@ +package com.baiyue.firstleg.terminaldrayage.service; + +import com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource; +import com.baiyue.firstleg.terminaldrayage.dto.TerminalDrayageDtos.*; +import com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ContainerCalendarSourceServiceTest { + private final ContainerCalendarSourceRepository repository = mock(ContainerCalendarSourceRepository.class); + private final TerminalDrayageService drayages = mock(TerminalDrayageService.class); + private final ContainerCalendarSourceService service = new ContainerCalendarSourceService(repository, drayages); + private final LocalDate from = LocalDate.of(2026, 11, 1), to = from.plusDays(30); + /** 缺还柜读取权限时保留范围说明,不读取受保护事实。 */ + @Test void keepsMissingReturnPermissionExplicitWithoutReadingProtectedFacts() { + when(repository.sources(from, to)).thenReturn(List.of( + new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED"), + new ContainerCalendarSource(2L,"D2",null,null,null,null,null,from.minusMonths(2).atStartOfDay(),"ACTUAL"))); + var result = service.calendar(from, to, Set.of("platform:appointment:list")); + assertEquals(1, result.containers().size()); assertFalse(result.emptyReturnsReadable()); + verifyNoInteractions(drayages); + } + /** 实际还柜日期优先于截止日期,范围外完成事实不回落到原提醒日。 */ + @Test void usesOriginalDeadlineButActualReturnSupersedesTheDeadlineDate() { + when(repository.sources(from, to)).thenReturn(List.of(new ContainerCalendarSource(1L,"D1",null,null,null,null,null,null,"ESTIMATED"))); + var row = mock(View.class); + when(row.actualEmptyReturnAt()).thenReturn(to.atStartOfDay()); + when(row.emptyReturnWarning()).thenReturn(new Warning("RESOLVED", from.atStartOfDay(),0,0)); + when(drayages.views(List.of(1L))).thenReturn(List.of(row)); + when(drayages.authorize(anyList(), anySet())).thenAnswer(call -> call.getArgument(0)); + assertTrue(service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns().isEmpty()); + when(row.actualEmptyReturnAt()).thenReturn(null); + assertEquals(List.of(row), service.calendar(from,to,Set.of("platform:firstleg:terminal-drayage:list")).emptyReturns()); + } + /** 非法日期范围在访问持久化层前拒绝。 */ + @Test void rejectsInvalidRangesBeforeDatabaseAccess() { + assertThrows(ResponseStatusException.class, () -> service.calendar(from,from,Set.of())); + assertThrows(ResponseStatusException.class, () -> service.calendar(from,from.plusDays(43),Set.of())); + verifyNoInteractions(repository); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java new file mode 100644 index 000000000..d5b1cbb5d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.calendar; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.security.OverseasPermissions; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/appointment-calendar") +public class WarehouseCalendarController { + private final WarehouseCalendarService service; + /** 装配整柜到仓日历读取服务。 */ + public WarehouseCalendarController(WarehouseCalendarService service) { this.service = service; } + /** 使用预约和原订单读取权限,写资格继续使用原订单编辑权限。 */ + @PostMapping("/warehouse-arrivals/query") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse> calendar(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody WarehouseCalendarModels.Query query) { + if (user == null || !"PLATFORM".equals(user.appCode()) || user.tenantId() == null || user.tenantId() != 0L) + throw new ResponseStatusException(HttpStatus.FORBIDDEN, "仅平台可读预约日历"); + return ApiResponse.success(service.calendar(query, user.permissions().contains(OverseasPermissions.LAST_MILE_ORDER_EDIT))); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java new file mode 100644 index 000000000..b3666a448 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.calendar; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.time.LocalDateTime; +import java.util.List; + +@Mapper +public interface WarehouseCalendarMapper { + /** 同柜全部客户合并为一条;人工时间和已建入库计划的时间不能被到港推算覆盖。 */ + @Select(""" + + """) + List calendar(@Param("from") LocalDateTime from, + @Param("to") LocalDateTime to, @Param("ids") List ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java new file mode 100644 index 000000000..84672c842 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.calendar; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +/** 只读日历沿用整柜预计到仓版本,不保存独立计划台账。 */ +public final class WarehouseCalendarModels { + /** 禁止构造工具类。 */ + private WarehouseCalendarModels() { } + public record Query(@NotNull LocalDate from, @NotNull LocalDate to, + @NotNull @Size(max = 2000) List<@NotBlank @Pattern(regexp = "[1-9][0-9]*") String> sourceContainerBookingIds) { } + public record Entry(String sourceContainerBookingId, String containerNo, String warehouseName, + String warehouseCode, LocalDateTime plannedAt, LocalDateTime actualAt, int version, + long planCount, boolean canEdit) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java new file mode 100644 index 000000000..8051ff96a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.calendar; + +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public class WarehouseCalendarRepository { + private final WarehouseCalendarMapper mapper; + /** 只通过本服务 Mapper 查询柜级到仓事实。 */ + public WarehouseCalendarRepository(WarehouseCalendarMapper mapper) { this.mapper = mapper; } + /** 同时覆盖跨月人工改期的柜与本次到港来源柜。 */ + public List calendar(WarehouseCalendarModels.Query query) { + return mapper.calendar(query.from().atStartOfDay(), query.to().atStartOfDay(), query.sourceContainerBookingIds()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java new file mode 100644 index 000000000..25b1fc487 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.calendar; + +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.temporal.ChronoUnit; +import java.util.HashSet; +import java.util.List; + +@Service +public class WarehouseCalendarService { + private final WarehouseCalendarRepository repository; + /** 装配日历只读仓储查询。 */ + public WarehouseCalendarService(WarehouseCalendarRepository repository) { this.repository = repository; } + /** 明确权限、版本与计划冻结资格;不以默认计划改写真实到仓时间。 */ + public List calendar(WarehouseCalendarModels.Query query, boolean canEdit) { + if (query.from() == null || query.to() == null || !query.to().isAfter(query.from()) + || ChronoUnit.DAYS.between(query.from(), query.to()) > 42 + || new HashSet<>(query.sourceContainerBookingIds()).size() != query.sourceContainerBookingIds().size()) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "日历范围须为1至42天,来源柜不能重复"); + var rows = repository.calendar(query); + if (rows.size() > 2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "到仓记录过多,请缩小日期范围"); + return rows.stream().map(row -> new WarehouseCalendarModels.Entry(row.sourceContainerBookingId(), row.containerNo(), + row.warehouseName(), row.warehouseCode(), row.plannedAt(), row.actualAt(), row.version(), row.planCount(), + canEdit && row.version() >= 0 && row.planCount() == 0 && row.actualAt() == null)).toList(); + } +} diff --git a/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml new file mode 100644 index 000000000..02ad3504d --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml @@ -0,0 +1,56 @@ +openapi: 3.0.3 +info: {title: 四类日历整柜到仓来源, version: 1.0.0} +security: [{bearerAuth: []}] +paths: + /api/platform/v1/last-leg/appointment-calendar/warehouse-arrivals/query: + post: + summary: 查询日历整柜人工到仓计划与冻结事实 + description: >- + 仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 + 返回所选来源柜,以及查询区间内已人工维护或已有入库计划的到仓记录,支持跨月人工改期。 + 同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0或已经建计划时才有值。 + 未人工维护、未建计划的日历默认日期由到港来源加2个自然日;此只读接口不写入库计划、不补造时刻。 + actualAt只读PDA首次收货时间,不替换计划日期。 + 具备原尾程订单编辑权限、版本一致、无入库计划且未实际到仓时canEdit=true。 + 保存复用overseas-business-v1的整柜expected-arrival入口和原版本、幂等键、冻结门禁。 + 无法核平的整柜版本为-1,不能用默认日期覆盖。最多2000条,超出返回400。 + x-permissions: [platform:appointment:list, 'platform:overseas-warehouse:last-mile-order:list'] + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [from, to, sourceContainerBookingIds] + properties: + from: {type: string, format: date} + to: {type: string, format: date, description: 左闭右开,最多42天} + sourceContainerBookingIds: {type: array, maxItems: 2000, uniqueItems: true, items: {type: string, pattern: '^[1-9][0-9]*$'}} + responses: + '200': + description: 成功业务响应,code为字符串200 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: array + items: + type: object + properties: + sourceContainerBookingId: {type: string} + containerNo: {type: string, nullable: true} + warehouseName: {type: string, nullable: true} + warehouseCode: {type: string, nullable: true} + plannedAt: {type: string, format: date-time, nullable: true} + actualAt: {type: string, format: date-time, nullable: true} + version: {type: integer, minimum: -1} + planCount: {type: integer, minimum: 0} + canEdit: {type: boolean} + '400': {description: 日期、来源柜或数量不符合约束} + '403': {description: 非平台或缺少任一读取权限} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java new file mode 100644 index 000000000..2e84dc2b9 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.calendar; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class WarehouseCalendarMapperTest { + /** 同柜跨客户不重复,人工改期与已建计划日期分别保留。 */ + @Test void deduplicatesCustomersPreservesManualAndFrozenDatesAndFindsCrossMonthRescheduling() throws Exception { + var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:warehouse-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var connection=ds.getConnection(); var sql=connection.createStatement()) { + sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR," + + "overseas_warehouse_id VARCHAR,overseas_warehouse_name VARCHAR,overseas_warehouse_code VARCHAR," + + "estimated_warehouse_at TIMESTAMP,arrival_time_version INT)"); + sql.execute("CREATE TABLE ll_inbound_plan(source_order_id BIGINT,customer_id VARCHAR)"); + sql.execute("CREATE TABLE ll_receiving_container_session(source_container_booking_id VARCHAR,receiving_at TIMESTAMP)"); + sql.execute("INSERT INTO ll_order VALUES(1,'A','1','CONT1','W','Warehouse','W','2026-10-01 09:00:00',0)," + + "(2,'B','1','CONT1','W','Warehouse','W','2026-10-01 09:00:00',0)," + + "(3,'A','2','CONT2','W','Warehouse','W','2026-11-10 09:00:00',2)," + + "(4,'A','3','CONT3','W','Warehouse','W','2026-11-12 09:00:00',0)"); + sql.execute("INSERT INTO ll_inbound_plan VALUES(4,'A')"); + } + var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); + config.addMapper(WarehouseCalendarMapper.class); + try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1")); + assertEquals(3,rows.size()); + assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt()); + assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString()); + assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java new file mode 100644 index 000000000..279e6902c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.calendar; + +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class WarehouseCalendarServiceTest { + /** 读取资格不代替写权限,已有入库计划仍冻结时间。 */ + @Test void keepsReadAccessSeparateFromWriteAndFreezesExistingPlans() { + var repository=mock(WarehouseCalendarRepository.class); var service=new WarehouseCalendarService(repository); + var query=new WarehouseCalendarModels.Query(LocalDate.of(2026,11,1),LocalDate.of(2026,12,1),List.of("1")); + var open=new WarehouseCalendarModels.Entry("1","CONT","Warehouse","W",null,null,0,0,false); + var frozen=new WarehouseCalendarModels.Entry("2","CONT2","Warehouse","W",null,null,1,1,false); + when(repository.calendar(query)).thenReturn(List.of(open,frozen)); + assertTrue(service.calendar(query,true).get(0).canEdit()); assertFalse(service.calendar(query,true).get(1).canEdit()); + assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit)); + assertThrows(ResponseStatusException.class,()->service.calendar(new WarehouseCalendarModels.Query(query.from(),query.to(),List.of("1","1")),true)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index 1d8750c3c..6d26a90cd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -46,7 +46,7 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(53); + assertThat(count).isEqualTo(54); verifyRefs(document, document); verifyRefs(preparation, preparation); verifyRefs(pool, pool); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 2998c50ce..7c434ea61 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -87,6 +87,8 @@ class FleetTripAuthorizationTest { } @Configuration @EnableMethodSecurity static class Config { + /** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */ + @Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); } @Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); } @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); } @Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); } From fafcf7501458f41d972ad10a99df627d011bbd86 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 12:21:57 +0800 Subject: [PATCH 017/113] fix(appointments): exclude pickup trips from delivery calendar --- .../com/baiyue/lastleg/fleet/trip/FleetTripMapper.java | 6 ++++-- .../src/main/resources/openapi/fleet-trips-v1.yaml | 1 + .../lastleg/fleet/trip/FleetTripCalendarMapperTest.java | 7 ++++++- 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java index 01617e259..882d206cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -7,12 +7,14 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface FleetTripMapper extends BaseMapper { + /** 预约日历只读海外仓出库派送,取货入仓车次不属于排车类型。 */ @Select(""" SELECT trip_no,status,warehouse_id,warehouse_code,warehouse_name,planned_departure_at, expected_driver_end_at,departed_at,completed_at,stops_json FROM ll_fleet_trip - WHERE (departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to}) - OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to}) + WHERE trip_purpose='DELIVERY' AND ( + (departed_at IS NOT NULL AND departed_at>=#{from} AND departed_at<#{to}) + OR (departed_at IS NULL AND planned_departure_at>=#{from} AND planned_departure_at<#{to})) ORDER BY COALESCE(departed_at,planned_departure_at),trip_no LIMIT 2001 """) java.util.List calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index 0138397ec..d19cadd9c 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -63,6 +63,7 @@ paths: description: >- 仅PLATFORM且平台公共主体(tenantId=0),权限 platform:fleet:trip:list。from/to 是明确UTC时间点,已发车按实际发车时间、尚未发车按计划发车时间读取半开区间,最多44天; 该保护性窗口供界面最多42个当地日历日加时区边界使用,不扩大界面范围。 + 仅出库派送用途(DELIVERY);上门取货入仓用途(PICKUP)不属于此日历排车来源。 最多2000条候选,超限明确拒绝并要求缩小日期范围,不分页扫描、不静默截断。 一车一条,保留取消及完成状态;保留原计划与实际时间,已执行事实不随计划改期移动,不表示收货预约已确认。 已知非美国仓排除;未知仓库/国家/地理IANA时区进入unresolvedRecords,不落入美国当地日槽。 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java index 19174d845..77fe8fdf7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java @@ -11,16 +11,21 @@ import java.util.*; import static org.assertj.core.api.Assertions.*; class FleetTripCalendarMapperTest { + /** 同时验证计划和实发的半开范围,取货车次不能混入送仓日历。 */ @Test void queriesAHalfOpenUtcWindowWithoutStatusFilteringAndCapsCandidateRows() 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_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))"); + jdbc.execute("CREATE TABLE ll_fleet_trip(trip_no VARCHAR(64) PRIMARY KEY,status VARCHAR(32),trip_purpose VARCHAR(32) DEFAULT 'DELIVERY',warehouse_id BIGINT,warehouse_code VARCHAR(64),warehouse_name VARCHAR(100),planned_departure_at TIMESTAMP,expected_driver_end_at TIMESTAMP,departed_at TIMESTAMP,completed_at TIMESTAMP,stops_json VARCHAR(2000))"); var from=LocalDateTime.of(2026,11,1,0,0);var to=from.plusDays(1); String insert="INSERT INTO ll_fleet_trip(trip_no,status,warehouse_id,planned_departure_at,expected_driver_end_at,stops_json) VALUES(?,?,1,?,?, '[]')"; jdbc.update(insert,"TR-BEFORE","PLANNED",from.minusSeconds(1),to); jdbc.update(insert,"TR-FROM","CANCELLED",from,to); jdbc.update(insert,"TR-LAST","COMPLETED",to.minusSeconds(1),to); jdbc.update(insert,"TR-TO","IN_TRANSIT",to,to.plusDays(1)); + jdbc.update(insert,"TR-PICKUP-PLAN","PLANNED",from,to); + jdbc.update(insert,"TR-PICKUP-ACTUAL","IN_TRANSIT",from.minusDays(2),to); + jdbc.update("UPDATE ll_fleet_trip SET trip_purpose='PICKUP' WHERE trip_no LIKE 'TR-PICKUP-%'"); + jdbc.update("UPDATE ll_fleet_trip SET departed_at=? WHERE trip_no='TR-PICKUP-ACTUAL'",from.plusHours(1)); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class); From 21128b895f206f55f5c8816d2c32a9d79829e620 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 14:34:33 +0800 Subject: [PATCH 018/113] feat(trucking): refine truck demand contacts vehicle and service times --- deploy/sql/海外区域统一增量-MySQL8.sql | 16 +- .../fleet/business/FleetPoolOrderBridge.java | 6 +- .../trucking/dto/UsTruckOrderRequests.java | 62 +++--- .../trucking/dto/UsTruckOrderResponses.java | 11 +- .../lastleg/trucking/entity/UsTruckOrder.java | 13 +- .../repository/UsTruckOrderRepository.java | 67 +++++++ .../trucking/service/UsTruckOrderService.java | 180 +++++++++++------- .../src/main/resources/db/lastleg.sql | 16 +- .../openapi/overseas-business-v1.yaml | 50 ++--- .../business/FleetPoolOrderBridgeTest.java | 8 +- .../trucking/UsTruckOrderPersistenceTest.java | 64 +++++++ .../trucking/UsTruckOrderRequestsTest.java | 35 ++++ .../UsTruckOrderSchemaContractTest.java | 9 +- .../UsTruckOrderControllerTest.java | 10 +- .../service/UsTruckOrderServiceTest.java | 58 ++++-- ...eet-capacity-pool-schema-contract.test.mjs | 4 +- .../phase4-overseas-business.mjs | 12 +- 17 files changed, 424 insertions(+), 197 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderRequestsTest.java diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 82e10bf4b..0f6f2397d 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -2725,13 +2725,12 @@ CREATE TABLE ll_us_truck_order ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '订单号', - transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', + vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空', origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地址', destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地址', - container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', - pallet_count int NOT NULL DEFAULT '1' COMMENT '客户申报托盘数', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报', package_count int DEFAULT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count int NOT NULL DEFAULT '0' COMMENT 'PDA实收托盘数', allocated_pallet_count int NOT NULL DEFAULT '0' COMMENT '已分配到合车行程的托盘数', @@ -2739,9 +2738,12 @@ CREATE TABLE ll_us_truck_order ( total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算金额', currency varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '币种', - contact_name varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人姓名', - contact_info varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '联系人联系方式', - requested_date date NOT NULL COMMENT '要求服务日期', + origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', + origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话', + destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', + destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话', + pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区', + delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '美国卡车订单状态', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', @@ -2754,7 +2756,7 @@ CREATE TABLE ll_us_truck_order ( UNIQUE KEY uk_ll_us_truck_order_no (customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key (customer_id,idempotency_key), KEY idx_ll_us_truck_order_list (customer_id,status,created_at), - KEY idx_ll_us_truck_order_date (customer_id,requested_date,created_at) + KEY idx_ll_us_truck_order_date (customer_id,pickup_at,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车运输订单'; CREATE TABLE ll_us_truck_order_operation ( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index 96d7e93db..b16a9e1af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -73,11 +73,11 @@ public class FleetPoolOrderBridge { loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order. } else { var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); - order.setTransportType(transport);order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel()); - order.setContainerType(null); // Pool inquiry does not collect a container specification. + order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel()); order.setPalletCount(in.expectedPalletCount());order.setPackageCount(in.packageCount());order.setReceivedPalletCount(0);order.setAllocatedPalletCount(0); order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency()); - order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate()); + // 来源未提供的车型、目的联系人与送货时间保持未知。 + order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());order.setPickupAt(in.plannedDepartureAt()); order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo()); order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now); trucks.insert(order); // No simulated ll_us_truck_order_pallet rows. diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java index 97ec10406..22e6a1ab7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java @@ -11,75 +11,73 @@ import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import java.math.BigDecimal; -import java.time.LocalDate; +import org.springframework.format.annotation.DateTimeFormat; + +import java.time.LocalDateTime; import java.util.List; public final class UsTruckOrderRequests { + /** 仅提供请求模型。 */ private UsTruckOrderRequests() {} + /** 客户或平台提交独立本土卡车需求,时间按填写值保存。 */ public record Create( - @Size(max = 32) String transportType, + @NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType, @NotBlank @Size(max = 255) String origin, @Size(max = 500) String originAddress, @NotBlank @Size(max = 255) String destination, @Size(max = 500) String destinationAddress, - @Size(max = 32) String containerType, - @NotNull @Min(1) Integer palletCount, @Min(0) Integer packageCount, @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal budgetAmount, @Size(min = 3, max = 3) String currency, - @NotBlank @Size(max = 128) String contactName, - @NotBlank @Size(max = 128) String contactInfo, - @NotNull LocalDate requestedDate, + @NotBlank @Size(max = 128) String originContactName, + @NotBlank @Size(max = 128) String originContactPhone, + @NotBlank @Size(max = 128) String destinationContactName, + @NotBlank @Size(max = 128) String destinationContactPhone, + @NotNull LocalDateTime pickupAt, + @NotNull LocalDateTime deliveryAt, @Size(max = 1000) String remark, - @NotBlank @Size(max = 255) String idempotencyKey) { - /** Backward-compatible constructor for clients still sending legacy transport fields. */ - public Create(String transportType, String origin, String originAddress, String destination, - String destinationAddress, String containerType, Integer packageCount, - BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, - String currency, String contactName, String contactInfo, LocalDate requestedDate, - String remark, String idempotencyKey) { - this(transportType, origin, originAddress, destination, destinationAddress, containerType, - packageCount == null || packageCount < 1 ? 1 : packageCount, packageCount, - totalWeightKg, totalVolumeCbm, budgetAmount, currency, contactName, contactInfo, - requestedDate, remark, idempotencyKey); - } - } + @NotBlank @Size(max = 255) String idempotencyKey) {} public record Query( @Min(1) long page, @Min(1) @Max(200) long size, - String orderNo, String routeKeyword, String status, String transportType, - String customerId, LocalDate requestedFrom, LocalDate requestedTo) {} + String orderNo, String routeKeyword, String status, String vehicleType, + String customerId, @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupFrom, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupTo) {} public record MerchantQuery( @Min(1) long page, @Min(1) @Max(200) long size, - String orderNo, String routeKeyword, String status, String transportType, - LocalDate requestedFrom, LocalDate requestedTo) { + String orderNo, String routeKeyword, String status, String vehicleType, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupFrom, + @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime pickupTo) { + /** 客户范围由认证身份决定,查询模型不接收客户标识。 */ public Query scoped() { - return new Query(page, size, orderNo, routeKeyword, status, transportType, - null, requestedFrom, requestedTo); + return new Query(page, size, orderNo, routeKeyword, status, vehicleType, + null, pickupFrom, pickupTo); } } + /** 待审核订单可更新需求;两端联系信息和时间须完整填写。 */ public record Update( @NotNull @Min(0) Long version, - @Size(max = 32) String transportType, + @NotBlank @Pattern(regexp = "PALLET_15|PALLET_30") String vehicleType, @Size(max = 255) String origin, @Size(max = 500) String originAddress, @Size(max = 255) String destination, @Size(max = 500) String destinationAddress, - @Size(max = 32) String containerType, - @Min(1) Integer palletCount, @Min(0) Integer packageCount, @DecimalMin("0.00") BigDecimal totalWeightKg, @DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal budgetAmount, @Size(min = 3, max = 3) String currency, - @Size(max = 128) String contactName, - @Size(max = 128) String contactInfo, - LocalDate requestedDate, + @NotBlank @Size(max = 128) String originContactName, + @NotBlank @Size(max = 128) String originContactPhone, + @NotBlank @Size(max = 128) String destinationContactName, + @NotBlank @Size(max = 128) String destinationContactPhone, + @NotNull LocalDateTime pickupAt, + @NotNull LocalDateTime deliveryAt, @Size(max = 1000) String remark) {} public record ChangeStatus( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java index d3f9ccad2..ac4556fa8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java @@ -1,20 +1,21 @@ package com.baiyue.lastleg.trucking.dto; import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; public final class UsTruckOrderResponses { + /** 仅提供响应模型。 */ private UsTruckOrderResponses() {} - /** Pool orders retain null containerType; forecast physical pallets do not imply a container type. */ + /** 独立订单展示客户填写的车型和两端信息;拼车来源未知字段保持为空。 */ public record Summary( - Long id, String customerId, String orderNo, String transportType, - String origin, String destination, String containerType, int palletCount, int packageCount, + Long id, String customerId, String orderNo, String vehicleType, + String origin, String destination, int palletCount, int packageCount, int receivedPalletCount, int allocatedPalletCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, - String currency, String contactName, String contactInfo, LocalDate requestedDate, + String currency, String originContactName, String originContactPhone, + String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) {} public record History( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java index 6f6d5eb79..49cace0b8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java @@ -6,7 +6,6 @@ import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; @Data @@ -15,12 +14,11 @@ public class UsTruckOrder { @TableId(type = IdType.INPUT) private Long id; private String customerId; private String orderNo; - private String transportType; + private String vehicleType; private String origin; private String originAddress; private String destination; private String destinationAddress; - private String containerType; private Integer palletCount; private Integer packageCount; private Integer receivedPalletCount; @@ -29,9 +27,12 @@ public class UsTruckOrder { private BigDecimal totalVolumeCbm; private BigDecimal budgetAmount; private String currency; - private String contactName; - private String contactInfo; - private LocalDate requestedDate; + private String originContactName; + private String originContactPhone; + private String destinationContactName; + private String destinationContactPhone; + private LocalDateTime pickupAt; + private LocalDateTime deliveryAt; private String status; private String remark; private String idempotencyKey; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java new file mode 100644 index 000000000..603047e03 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.trucking.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import com.baiyue.lastleg.trucking.entity.*; +import com.baiyue.lastleg.trucking.mapper.*; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import java.util.List; + +/** 卡车订单持久化边界;事务和状态决策由服务负责。 */ +@Repository +public class UsTruckOrderRepository { + private final UsTruckOrderMapper orders; + private final UsTruckOrderPalletMapper pallets; + private final UsTruckOrderStatusHistoryMapper histories; + private final UsTruckOrderOperationMapper operations; + + /** 绑定本服务的订单、托盘和不可变历史 Mapper。 */ + public UsTruckOrderRepository(UsTruckOrderMapper orders, UsTruckOrderPalletMapper pallets, + UsTruckOrderStatusHistoryMapper histories, UsTruckOrderOperationMapper operations) { + this.orders = orders; this.pallets = pallets; this.histories = histories; this.operations = operations; + } + + /** 按客户、路线、状态、车型和取货时间组合分页条件。 */ + public Page page(String customerId, UsTruckOrderRequests.Query query) { + LambdaQueryWrapper w = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId) + .like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo()) + .and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword()) + .or().like(UsTruckOrder::getDestination, query.routeKeyword())) + .eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus, + StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) + .eq(StringUtils.hasText(query.vehicleType()), UsTruckOrder::getVehicleType, + StringUtils.hasText(query.vehicleType()) ? query.vehicleType() : null) + .eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId()) + .ge(query.pickupFrom() != null, UsTruckOrder::getPickupAt, query.pickupFrom()) + .le(query.pickupTo() != null, UsTruckOrder::getPickupAt, query.pickupTo()) + .orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId); + return orders.selectPage(Page.of(query.page(), query.size()), w); + } + /** 按客户和订单号读取。 */ + public UsTruckOrder findNo(String customerId, String orderNo) { return orders.findNo(customerId, orderNo); } + /** 在当前事务中按客户和订单号加行锁。 */ + public UsTruckOrder lockNo(String customerId, String orderNo) { return orders.lockNo(customerId, orderNo); } + /** 按客户和创建幂等键读取。 */ + public UsTruckOrder findKey(String customerId, String key) { return orders.findKey(customerId, key); } + /** 保存新订单。 */ + public void insertOrder(UsTruckOrder order) { orders.insert(order); } + /** 保存已锁定订单的当前状态。 */ + public void updateOrder(UsTruckOrder order) { orders.updateById(order); } + /** 读取本客户订单的托盘。 */ + public List pallets(String customerId, Long orderId) { return pallets.listOrder(customerId, orderId); } + /** 保存实际托盘记录。 */ + public void insertPallet(UsTruckOrderPallet row) { pallets.insert(row); } + /** 更新实际托盘处理状态。 */ + public void updatePallet(UsTruckOrderPallet row) { pallets.updateById(row); } + /** 读取本客户订单的不可变历史。 */ + public List histories(String customerId, Long orderId) { return histories.listOrder(customerId, orderId); } + /** 保存订单历史。 */ + public void insertHistory(UsTruckOrderStatusHistory row) { histories.insert(row); } + /** 按客户、订单和幂等键读取操作结果。 */ + public UsTruckOrderOperation findOperation(String customerId, Long orderId, String key) { return operations.findKey(customerId, orderId, key); } + /** 保存不可变操作摘要。 */ + public void insertOperation(UsTruckOrderOperation row) { operations.insert(row); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index e94948bce..e2537549f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -1,20 +1,14 @@ package com.baiyue.lastleg.trucking.service; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.core.container.ContainerSpecificationCatalog; import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; import com.baiyue.lastleg.trucking.dto.UsTruckOrderResponses; import com.baiyue.lastleg.trucking.entity.UsTruckOrder; import com.baiyue.lastleg.trucking.entity.UsTruckOrderOperation; import com.baiyue.lastleg.trucking.entity.UsTruckOrderPallet; import com.baiyue.lastleg.trucking.entity.UsTruckOrderStatusHistory; -import com.baiyue.lastleg.trucking.mapper.UsTruckOrderMapper; -import com.baiyue.lastleg.trucking.mapper.UsTruckOrderOperationMapper; -import com.baiyue.lastleg.trucking.mapper.UsTruckOrderPalletMapper; -import com.baiyue.lastleg.trucking.mapper.UsTruckOrderStatusHistoryMapper; import com.baiyue.lastleg.security.OverseasPermissions; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -26,12 +20,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; -import java.math.BigDecimal; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.time.LocalDate; import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; import java.util.HexFormat; import java.util.HashSet; import java.util.List; @@ -44,8 +35,6 @@ public class UsTruckOrderService { @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String MANAGE_PERMISSION = OverseasPermissions.TRUCK_STATUS; public static final String PENDING_REVIEW = "PENDING_REVIEW"; - /** Kept for source compatibility; new orders use PENDING_REVIEW. */ - @Deprecated public static final String PENDING_CONFIRMATION = PENDING_REVIEW; public static final String WAITING_INBOUND = "WAITING_INBOUND"; public static final String RECEIVING = "RECEIVING"; public static final String WAITING_CONSOLIDATION = "WAITING_CONSOLIDATION"; @@ -63,43 +52,41 @@ public class UsTruckOrderService { PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, REJECTED, CANCELLED); private static final Set CANCELLABLE = Set.of(PENDING_REVIEW, WAITING_INBOUND, RECEIVING, PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, PARTIALLY_ALLOCATED, ALLOCATED, PENDING_DISPATCH); - private static final Set TRANSPORT_TYPES = Set.of("PORT_PICKUP", "WAREHOUSE_TO_WAREHOUSE", "WAREHOUSE_TO_DOOR"); + private static final Set VEHICLE_TYPES = Set.of("PALLET_15", "PALLET_30"); - @Resource private UsTruckOrderMapper orders; - @Resource private UsTruckOrderOperationMapper operations; - @Resource private UsTruckOrderPalletMapper pallets; - @Resource private UsTruckOrderStatusHistoryMapper histories; + @Resource private com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository repository; @Resource private ObjectMapper objectMapper; + /** 创建卡车需求,校验两端资料并保留创建幂等结果。 */ @Transactional public UsTruckOrderResponses.Detail create(String customerId, String operatorId, UsTruckOrderRequests.Create request) { String key = request.idempotencyKey().trim(); String hash = hash(request); - UsTruckOrder replay = orders.findKey(customerId, key); + UsTruckOrder replay = repository.findKey(customerId, key); if (replay != null) return replay(customerId, replay, hash); - if (StringUtils.hasText(request.transportType())) validateTransport(request.transportType()); + validateRequirements(request.vehicleType(), request.originContactName(), request.originContactPhone(), + request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt()); LocalDateTime now = LocalDateTime.now(); UsTruckOrder order = new UsTruckOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setOrderNo(orderNoGenerator.next("TRUCK", request.origin(), order.getId())); - order.setTransportType(StringUtils.hasText(request.transportType()) ? norm(request.transportType()) : null); + order.setVehicleType(request.vehicleType()); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); - order.setContainerType(StringUtils.hasText(request.containerType()) - ? ContainerSpecificationCatalog.require(request.containerType()).containerType() - : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); - order.setPalletCount(request.palletCount()); order.setPackageCount(value(request.packageCount())); + // 车型只表达用车需求;实际托数由收货记录,不能按车型虚构货量。 + order.setPalletCount(0); order.setPackageCount(value(request.packageCount())); order.setReceivedPalletCount(0); order.setAllocatedPalletCount(0); order.setTotalWeightKg(request.totalWeightKg()); order.setTotalVolumeCbm(request.totalVolumeCbm()); order.setBudgetAmount(request.budgetAmount()); order.setCurrency(defaultCurrency(request.currency())); - order.setContactName(request.contactName().trim()); order.setContactInfo(request.contactInfo().trim()); - order.setRequestedDate(request.requestedDate()); order.setStatus(PENDING_REVIEW); + order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim()); + order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().trim()); + order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); order.setStatus(PENDING_REVIEW); order.setRemark(trim(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(hash); order.setCreatedBy(operatorId); order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); - try { orders.insert(order); } + try { repository.insertOrder(order); } catch (DuplicateKeyException e) { - UsTruckOrder concurrent = orders.findKey(customerId, key); + UsTruckOrder concurrent = repository.findKey(customerId, key); if (concurrent != null) return replay(customerId, concurrent, hash); throw e; } @@ -107,57 +94,48 @@ public class UsTruckOrderService { return detail(customerId, order.getOrderNo()); } + /** 按客户范围分页读取订单并批量关联承运安排。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public PageResult page(String customerId, UsTruckOrderRequests.Query query) { validateQuery(query); - LambdaQueryWrapper w = new LambdaQueryWrapper() - .eq(StringUtils.hasText(customerId), UsTruckOrder::getCustomerId, customerId) - .like(StringUtils.hasText(query.orderNo()), UsTruckOrder::getOrderNo, query.orderNo()) - .and(StringUtils.hasText(query.routeKeyword()), x -> x.like(UsTruckOrder::getOrigin, query.routeKeyword()) - .or().like(UsTruckOrder::getDestination, query.routeKeyword())) - .eq(StringUtils.hasText(query.status()), UsTruckOrder::getStatus, - StringUtils.hasText(query.status()) ? norm(query.status()) : null) - .eq(StringUtils.hasText(query.transportType()), UsTruckOrder::getTransportType, - StringUtils.hasText(query.transportType()) ? norm(query.transportType()) : null) - .eq(StringUtils.hasText(query.customerId()), UsTruckOrder::getCustomerId, query.customerId()) - .ge(query.requestedFrom() != null, UsTruckOrder::getRequestedDate, query.requestedFrom()) - .le(query.requestedTo() != null, UsTruckOrder::getRequestedDate, query.requestedTo()) - .orderByDesc(UsTruckOrder::getCreatedAt).orderByDesc(UsTruckOrder::getId); - Page result = orders.selectPage(Page.of(query.page(), query.size()), w); + Page result = repository.page(customerId, query); var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList()); return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } + /** 读取本客户的订单、实际托盘及操作历史。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public UsTruckOrderResponses.Detail detail(String customerId, String orderNo) { - UsTruckOrder order = orders.findNo(customerId, orderNo); + UsTruckOrder order = repository.findNo(customerId, orderNo); if (order == null) throw notFound(); - return detail(order, histories.listOrder(customerId, order.getId())); + return detail(order, repository.histories(customerId, order.getId())); } + /** 按版本更新待审核订单的车型、联系人和取送时间。 */ @Transactional public UsTruckOrderResponses.Detail update(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Update request) { UsTruckOrder order = lock(customerId, orderNo); if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); if (!PENDING_REVIEW.equals(order.getStatus())) throw conflict("Only pending review orders can be edited"); - if (request.transportType() != null) { validateTransport(request.transportType()); order.setTransportType(norm(request.transportType())); } + validateRequirements(request.vehicleType(), request.originContactName(), request.originContactPhone(), + request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt()); + order.setVehicleType(request.vehicleType()); setText(request.origin(), order::setOrigin); setPresent(request.originAddress(), order::setOriginAddress); setText(request.destination(), order::setDestination); setPresent(request.destinationAddress(), order::setDestinationAddress); - setText(request.containerType(), v -> order.setContainerType( - ContainerSpecificationCatalog.require(v).containerType())); - if (request.palletCount() != null) order.setPalletCount(request.palletCount()); if (request.packageCount() != null) order.setPackageCount(request.packageCount()); if (request.totalWeightKg() != null) order.setTotalWeightKg(request.totalWeightKg()); if (request.totalVolumeCbm() != null) order.setTotalVolumeCbm(request.totalVolumeCbm()); if (request.budgetAmount() != null) order.setBudgetAmount(request.budgetAmount()); if (request.currency() != null) order.setCurrency(defaultCurrency(request.currency())); - setText(request.contactName(), order::setContactName); setText(request.contactInfo(), order::setContactInfo); - if (request.requestedDate() != null) order.setRequestedDate(request.requestedDate()); setPresent(request.remark(), order::setRemark); - touch(order); orders.updateById(order); + order.setOriginContactName(request.originContactName().trim()); order.setOriginContactPhone(request.originContactPhone().trim()); + order.setDestinationContactName(request.destinationContactName().trim()); order.setDestinationContactPhone(request.destinationContactPhone().trim()); + order.setPickupAt(request.pickupAt()); order.setDeliveryAt(request.deliveryAt()); setPresent(request.remark(), order::setRemark); + touch(order); repository.updateOrder(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, "UPDATE", LocalDateTime.now()); return detail(customerId, orderNo); } + /** 按版本幂等取消允许取消的订单。 */ @Transactional public UsTruckOrderResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ChangeStatus request) { @@ -172,6 +150,7 @@ public class UsTruckOrderService { order.getStatus(), CANCELLED, request.remark()); } + /** 审核订单,仅对真实已知预报建立待收托盘。 */ @Transactional public UsTruckOrderResponses.Detail review(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Review request) { @@ -186,6 +165,7 @@ public class UsTruckOrderService { PENDING_REVIEW, next, request.remark()); } + /** 记录实际收货,首次实收确定独立订单托数。 */ @Transactional public UsTruckOrderResponses.Detail receivePallets(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ReceivePallets request) { @@ -198,9 +178,12 @@ public class UsTruckOrderService { } createPlannedPallets(order); - List current = pallets.listOrder(customerId, order.getId()); - int planned = value(order.getPalletCount()); + List current = repository.pallets(customerId, order.getId()); int actual = request.actualPalletCount(); + // 独立下单没有预报托数,首次实收形成确认基准,后续超量仍需审核。 + boolean firstReceipt = value(order.getPalletCount()) == 0; + int planned = firstReceipt ? actual : value(order.getPalletCount()); + order.setPalletCount(planned); List packageCounts = request.palletPackageCounts() == null ? List.of() : request.palletPackageCounts(); if (!packageCounts.isEmpty() && packageCounts.size() != actual) { @@ -213,7 +196,7 @@ public class UsTruckOrderService { LocalDateTime now = LocalDateTime.now(); for (int sequence = 1; sequence <= actual; sequence++) { UsTruckOrderPallet pallet = sequence <= current.size() ? current.get(sequence - 1) - : newPallet(order, sequence, "PDA_OVERAGE", now); + : newPallet(order, sequence, firstReceipt ? "PDA_RECEIPT" : "PDA_OVERAGE", now); pallet.setStatus(sequence > planned ? "OVERAGE_PENDING_REVIEW" : "RECEIVED"); if (!packageCounts.isEmpty()) pallet.setPackageCount(packageCounts.get(sequence - 1)); else if (actual == 1) pallet.setPackageCount(request.packageCount()); @@ -221,9 +204,9 @@ public class UsTruckOrderService { pallet.setUpdatedAt(now); if (pallet.getId() == null) { pallet.setId(IdWorker.getId()); - pallets.insert(pallet); + repository.insertPallet(pallet); } else { - pallets.updateById(pallet); + repository.updatePallet(pallet); } } order.setReceivedPalletCount(actual); @@ -232,12 +215,13 @@ public class UsTruckOrderService { : actual == planned ? WAITING_CONSOLIDATION : RECEIVING; order.setStatus(next); touch(order); - orders.updateById(order); + repository.updateOrder(order); recordOperation(order, "RECEIVE_PALLETS", request.idempotencyKey(), request, operatorId, now); history(order, "RECEIVE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now); return detail(customerId, orderNo); } + /** 审核后续实收超出已确认托数的部分。 */ @Transactional public UsTruckOrderResponses.Detail reviewPalletOverage(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.ReviewPalletOverage request) { @@ -250,14 +234,14 @@ public class UsTruckOrderService { throw conflict("Only pallet-overage-review orders can be reviewed"); } boolean approve = "APPROVE".equals(norm(request.decision())); - List rows = pallets.listOrder(customerId, order.getId()); + List rows = repository.pallets(customerId, order.getId()); LocalDateTime now = LocalDateTime.now(); int accepted = value(order.getPalletCount()); for (UsTruckOrderPallet pallet : rows) { if (!"OVERAGE_PENDING_REVIEW".equals(pallet.getStatus())) continue; pallet.setStatus(approve ? "RECEIVED" : "OVERAGE_REJECTED"); pallet.setUpdatedAt(now); - pallets.updateById(pallet); + repository.updatePallet(pallet); } if (approve) { accepted = value(order.getReceivedPalletCount()); @@ -267,13 +251,14 @@ public class UsTruckOrderService { } order.setStatus(WAITING_CONSOLIDATION); touch(order); - orders.updateById(order); + repository.updateOrder(order); recordOperation(order, "REVIEW_PALLET_OVERAGE", request.idempotencyKey(), request, operatorId, now); history(order, "REVIEW_PALLET_OVERAGE", PALLET_OVERAGE_REVIEW, WAITING_CONSOLIDATION, operatorId, request.idempotencyKey(), request.remark(), now); return detail(customerId, orderNo); } + /** 分配已实收且尚未分配的订单托盘。 */ @Transactional public UsTruckOrderResponses.Detail allocatePallets(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.AllocatePallets request) { @@ -287,7 +272,7 @@ public class UsTruckOrderService { } Set requested = new HashSet<>(request.palletIds()); if (requested.size() != request.palletIds().size()) throw badRequest("palletIds must be unique"); - List rows = pallets.listOrder(customerId, order.getId()); + List rows = repository.pallets(customerId, order.getId()); List selected = rows.stream().filter(row -> requested.contains(row.getId())).toList(); if (selected.size() != requested.size() || selected.stream().anyMatch(row -> !"RECEIVED".equals(row.getStatus()))) { throw conflict("Selected pallets must belong to the order and be received but unallocated"); @@ -296,7 +281,7 @@ public class UsTruckOrderService { for (UsTruckOrderPallet pallet : selected) { pallet.setStatus("ALLOCATED"); pallet.setUpdatedAt(now); - pallets.updateById(pallet); + repository.updatePallet(pallet); } int allocated = (int) rows.stream().filter(row -> "ALLOCATED".equals(row.getStatus())).count(); int accepted = value(order.getReceivedPalletCount()); @@ -306,13 +291,14 @@ public class UsTruckOrderService { order.setAllocatedPalletCount(allocated); order.setStatus(next); touch(order); - orders.updateById(order); + repository.updateOrder(order); recordOperation(order, "ALLOCATE_PALLETS", request.idempotencyKey(), request, operatorId, now); history(order, "ALLOCATE_PALLETS", from, next, operatorId, request.idempotencyKey(), request.remark(), now); return detail(customerId, orderNo); } + /** 将全部分配完成的订单推进到待派车。 */ @Transactional public UsTruckOrderResponses.Detail prepareDispatch(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Command request) { @@ -320,24 +306,28 @@ public class UsTruckOrderService { PENDING_DISPATCH, request); } + /** 确认订单已派车并记录操作历史。 */ @Transactional public UsTruckOrderResponses.Detail dispatch(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Command request) { return command(customerId, orderNo, operatorId, "DISPATCH", PENDING_DISPATCH, DISPATCHED, request); } + /** 确认订单进入运输阶段。 */ @Transactional public UsTruckOrderResponses.Detail startTransit(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Command request) { return command(customerId, orderNo, operatorId, "START_TRANSIT", DISPATCHED, IN_TRANSIT, request); } + /** 确认运输完成并保留幂等结果。 */ @Transactional public UsTruckOrderResponses.Detail complete(String customerId, String orderNo, String operatorId, UsTruckOrderRequests.Command request) { return command(customerId, orderNo, operatorId, "COMPLETE", IN_TRANSIT, COMPLETED, request); } + /** 复核状态版本后执行单一履约动作。 */ private UsTruckOrderResponses.Detail command(String customerId, String orderNo, String operatorId, String operationType, String expectedStatus, String nextStatus, UsTruckOrderRequests.Command request) { @@ -357,22 +347,24 @@ public class UsTruckOrderService { expectedStatus, nextStatus, request.remark()); } + /** 原子更新状态并写入操作和历史。 */ private UsTruckOrderResponses.Detail transition(UsTruckOrder order, String operatorId, String operationType, String idempotencyKey, Object request, String from, String to, String remark) { LocalDateTime now = LocalDateTime.now(); order.setStatus(to); touch(order); - orders.updateById(order); + repository.updateOrder(order); recordOperation(order, operationType, idempotencyKey, request, operatorId, now); history(order, operationType, from, to, operatorId, idempotencyKey, remark, now); return detail(order.getCustomerId(), order.getOrderNo()); } + /** 核对操作幂等键和请求摘要。 */ private UsTruckOrderResponses.Detail replayOperation(UsTruckOrder order, String operationType, String idempotencyKey, Object request) { String key = idempotencyKey.trim(); - UsTruckOrderOperation existing = operations.findKey(order.getCustomerId(), order.getId(), key); + UsTruckOrderOperation existing = repository.findOperation(order.getCustomerId(), order.getId(), key); if (existing == null) return null; if (!operationType.equals(existing.getOperationType()) || !hash(request).equals(existing.getRequestHash())) { throw conflict("Idempotency key was reused with a different truck operation payload"); @@ -380,6 +372,7 @@ public class UsTruckOrderService { return detail(order.getCustomerId(), order.getOrderNo()); } + /** 记录不可变操作摘要和结果版本。 */ private void recordOperation(UsTruckOrder order, String operationType, String idempotencyKey, Object request, String operatorId, LocalDateTime now) { UsTruckOrderOperation operation = new UsTruckOrderOperation(); @@ -392,19 +385,23 @@ public class UsTruckOrderService { operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); operation.setCreatedAt(now); - operations.insert(operation); + repository.insertOperation(operation); } + /** 阻止旧版本覆盖当前订单。 */ private void requireVersion(UsTruckOrder order, Long requestedVersion) { if (!requestedVersion.equals(value(order.getVersion()))) { throw conflict("Order was changed; refresh and retry"); } } - private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = orders.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; } + /** 按客户范围锁单并校验独立订单资格。 */ + private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; } + /** 复核创建请求摘要并返回既有订单。 */ private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } + /** 读取订单资料与实际托盘和操作记录。 */ private UsTruckOrderResponses.Detail detail(UsTruckOrder o, List hs) { - List palletDetails = pallets.listOrder(o.getCustomerId(), o.getId()).stream() + List palletDetails = repository.pallets(o.getCustomerId(), o.getId()).stream() .map(this::pallet) .toList(); return new UsTruckOrderResponses.Detail(summary(o), o.getOriginAddress(), o.getDestinationAddress(), @@ -413,30 +410,37 @@ public class UsTruckOrderService { h.getFromStatus(), h.getToStatus(), h.getOperatorId(), h.getIdempotencyKey(), h.getRemark(), h.getCreatedAt())).toList()); } + /** 将实际托盘记录转换为响应。 */ private UsTruckOrderResponses.Pallet pallet(UsTruckOrderPallet p) { return new UsTruckOrderResponses.Pallet(p.getId(), p.getPalletNo(), value(p.getSequenceNo()), p.getSource(), p.getStatus(), p.getWarehousePalletId(), p.getPackageCount(), p.getReceivedAt()); } + /** 构造客户及业务订单关联键。 */ private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(UsTruckOrder o) { return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(o.getCustomerId(), "US_TRUCK", o.getId()); } + /** 转换需求字段与承运安排摘要。 */ private UsTruckOrderResponses.Summary summary(UsTruckOrder o) { return summary(o, fleetPool.arrangement(poolKey(o))); } - private UsTruckOrderResponses.Summary summary(UsTruckOrder o, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getTransportType(), o.getOrigin(), o.getDestination(), o.getContainerType(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getContactName(), o.getContactInfo(), o.getRequestedDate(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt(), arrangement); } + /** 转换需求字段与承运安排摘要。 */ + private UsTruckOrderResponses.Summary summary(UsTruckOrder o, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new UsTruckOrderResponses.Summary(o.getId(), o.getCustomerId(), o.getOrderNo(), o.getVehicleType(), o.getOrigin(), o.getDestination(), value(o.getPalletCount()), value(o.getPackageCount()), value(o.getReceivedPalletCount()), value(o.getAllocatedPalletCount()), o.getTotalWeightKg(), o.getTotalVolumeCbm(), o.getBudgetAmount(), o.getCurrency(), o.getOriginContactName(), o.getOriginContactPhone(), o.getDestinationContactName(), o.getDestinationContactPhone(), o.getPickupAt(), o.getDeliveryAt(), o.getStatus(), value(o.getVersion()), o.getCreatedAt(), o.getUpdatedAt(), arrangement); } + /** 保留订单状态变更和操作人证据。 */ private void history(UsTruckOrder o, String operationType, String from, String to, String operator, String idempotencyKey, String remark, LocalDateTime at) { UsTruckOrderStatusHistory h = new UsTruckOrderStatusHistory(); h.setId(IdWorker.getId()); h.setOrderId(o.getId()); h.setCustomerId(o.getCustomerId()); h.setOperationType(operationType); h.setFromStatus(from); h.setToStatus(to); h.setOperatorId(operator); h.setIdempotencyKey(trim(idempotencyKey)); h.setRemark(trim(remark)); - h.setCreatedAt(at); histories.insert(h); + h.setCreatedAt(at); repository.insertHistory(h); } + /** 仅为已知的预报数量建立待收托盘。 */ private void createPlannedPallets(UsTruckOrder order) { - int existing = pallets.listOrder(order.getCustomerId(), order.getId()).size(); + int existing = repository.pallets(order.getCustomerId(), order.getId()).size(); LocalDateTime now = LocalDateTime.now(); for (int sequence = existing + 1; sequence <= value(order.getPalletCount()); sequence++) { UsTruckOrderPallet pallet = newPallet(order, sequence, "ORDER_PLAN", now); pallet.setId(IdWorker.getId()); - pallets.insert(pallet); + repository.insertPallet(pallet); } } + /** 建立指定序号的订单托盘记录。 */ private UsTruckOrderPallet newPallet(UsTruckOrder order, int sequence, String source, LocalDateTime now) { UsTruckOrderPallet pallet = new UsTruckOrderPallet(); pallet.setOrderId(order.getId()); pallet.setCustomerId(order.getCustomerId()); @@ -445,17 +449,45 @@ public class UsTruckOrderService { pallet.setCreatedAt(now); pallet.setUpdatedAt(now); return pallet; } - private void validateQuery(UsTruckOrderRequests.Query q) { if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); if (StringUtils.hasText(q.transportType())) validateTransport(q.transportType()); if (q.requestedFrom() != null && q.requestedTo() != null && q.requestedFrom().isAfter(q.requestedTo())) throw badRequest("requestedFrom must not be after requestedTo"); } - private void validateTransport(String v) { if (!TRANSPORT_TYPES.contains(norm(v))) throw badRequest("Unsupported transport type"); } + /** 校验状态、车型及取货时间筛选区间。 */ + private void validateQuery(UsTruckOrderRequests.Query q) { + if (StringUtils.hasText(q.status()) && !STATUSES.contains(norm(q.status()))) throw badRequest("Unsupported truck order status"); + if (StringUtils.hasText(q.vehicleType()) && !VEHICLE_TYPES.contains(q.vehicleType())) throw badRequest("请选择有效的车辆类型"); + if (q.pickupFrom() != null && q.pickupTo() != null && q.pickupFrom().isAfter(q.pickupTo())) throw badRequest("取货时间起点不能晚于终点"); + } + + /** 校验两端必填资料和先取后送的时间顺序;不进行时区转换。 */ + private void validateRequirements(String vehicleType, String originName, String originPhone, + String destinationName, String destinationPhone, + LocalDateTime pickupAt, LocalDateTime deliveryAt) { + if (vehicleType == null || !VEHICLE_TYPES.contains(vehicleType)) throw badRequest("请选择有效的车辆类型"); + if (!StringUtils.hasText(originName) || !StringUtils.hasText(originPhone) + || !StringUtils.hasText(destinationName) || !StringUtils.hasText(destinationPhone)) throw badRequest("请完整填写起运地和目的地联系人及联系电话"); + if (pickupAt == null || deliveryAt == null) throw badRequest("请填写取货时间和送货时间"); + if (deliveryAt.isBefore(pickupAt)) throw badRequest("送货时间不能早于取货时间"); + } + /** 计算请求摘要以校验幂等重放。 */ private String hash(Object v) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(objectMapper.writeValueAsBytes(v))); } catch (JsonProcessingException | NoSuchAlgorithmException e) { throw new IllegalStateException("Cannot hash truck request", e); } } + /** 推进订单版本和更新时间。 */ private static void touch(UsTruckOrder o) { o.setVersion(value(o.getVersion()) + 1); o.setUpdatedAt(LocalDateTime.now()); } + /** 仅更新非空业务文字。 */ private static void setText(String v, Consumer c) { if (StringUtils.hasText(v)) c.accept(v.trim()); } + /** 更新请求明确携带的可选文字。 */ private static void setPresent(String v, Consumer c) { if (v != null) c.accept(trim(v)); } + /** 统一处理可选文字空值。 */ private static String trim(String v) { return v == null || v.isBlank() ? null : v.trim(); } + /** 统一状态与币种编码大小写。 */ private static String norm(String v) { return v.trim().toUpperCase(); } + /** 未指定币种时使用美元。 */ private static String defaultCurrency(String v) { return StringUtils.hasText(v) ? norm(v) : "USD"; } - private static int value(Integer v) { return v == null ? 0 : v; } private static long value(Long v) { return v == null ? 0 : v; } + /** 将计数空值转换为零。 */ + private static int value(Integer v) { return v == null ? 0 : v; } + /** 将计数空值转换为零。 */ + private static long value(Long v) { return v == null ? 0 : v; } + /** 构造请求校验错误。 */ private static ResponseStatusException badRequest(String m) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, m); } + /** 构造状态或并发冲突错误。 */ private static ResponseStatusException conflict(String m) { return new ResponseStatusException(HttpStatus.CONFLICT, m); } + /** 构造客户范围内订单不存在错误。 */ private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "US truck order not found"); } } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 8c824d878..86b64ec2e 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2784,13 +2784,12 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', order_no VARCHAR(64) NOT NULL COMMENT '订单号', - transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的地址', - container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', - pallet_count INT NOT NULL DEFAULT 1 COMMENT '客户申报托盘数', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '确认托盘数;独立下单首次实收确定,拼车来源取真实预报', package_count INT NULL COMMENT '可选箱数,零或空值均有效', received_pallet_count INT NOT NULL DEFAULT 0 COMMENT 'PDA实收托盘数', allocated_pallet_count INT NOT NULL DEFAULT 0 COMMENT '已分配到合车行程的托盘数', @@ -2798,9 +2797,12 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算金额', currency VARCHAR(3) NOT NULL DEFAULT 'USD' COMMENT '币种', - contact_name VARCHAR(128) NOT NULL COMMENT '联系人姓名', - contact_info VARCHAR(128) NOT NULL COMMENT '联系人联系方式', - requested_date DATE NOT NULL COMMENT '要求服务日期', + origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', + origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话', + destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', + destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话', + pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区', + delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空', status VARCHAR(32) NOT NULL COMMENT '美国卡车订单状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '业务幂等键', @@ -2813,7 +2815,7 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order ( UNIQUE KEY uk_ll_us_truck_order_no(customer_id,order_no), UNIQUE KEY uk_ll_us_truck_order_key(customer_id,idempotency_key), KEY idx_ll_us_truck_order_list(customer_id,status,created_at), - KEY idx_ll_us_truck_order_date(customer_id,requested_date,created_at) + KEY idx_ll_us_truck_order_date(customer_id,pickup_at,created_at) ) ENGINE=InnoDB COMMENT='美国卡车运输订单'; CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet ( diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index bc034e68a..f0c18bbe1 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1421,9 +1421,9 @@ paths: - { name: orderNo, in: query, schema: { type: string } } - { name: routeKeyword, in: query, schema: { type: string } } - { name: status, in: query, schema: { $ref: '#/components/schemas/TruckStatus' } } - - { name: transportType, in: query, schema: { type: string } } - - { name: requestedFrom, in: query, schema: { type: string, format: date } } - - { name: requestedTo, in: query, schema: { type: string, format: date } } + - { name: vehicleType, in: query, schema: { type: string } } + - { name: pickupFrom, in: query, schema: { type: string, format: date-time } } + - { name: pickupTo, in: query, schema: { type: string, format: date-time } } responses: { '200': { description: 当前客户本土卡订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/TruckPageResponse' } } } } } post: summary: 客户创建美国本土卡订单 @@ -3610,11 +3610,10 @@ components: id: { type: string, pattern: '^[0-9]+$' } customerId: { type: string } orderNo: { type: string } - transportType: { type: string, nullable: true } + vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空', nullable: true } origin: { type: string } destination: { type: string } - containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ } - palletCount: { type: integer } + palletCount: { type: integer, description: 已确认托数;新建独立订单为0,首次实收确定,不从车型推导 } packageCount: { type: integer } receivedPalletCount: { type: integer } allocatedPalletCount: { type: integer } @@ -3622,9 +3621,12 @@ components: totalVolumeCbm: { type: number } budgetAmount: { type: number, nullable: true } currency: { type: string, nullable: true } - contactName: { type: string, nullable: true } - contactInfo: { type: string, nullable: true } - requestedDate: { type: string, format: date, nullable: true } + originContactName: { type: string, nullable: true, maxLength: 128 } + originContactPhone: { type: string, nullable: true, maxLength: 128 } + destinationContactName: { type: string, nullable: true, maxLength: 128 } + destinationContactPhone: { type: string, nullable: true, maxLength: 128 } + pickupAt: { type: string, nullable: true, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } + deliveryAt: { type: string, nullable: true, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } status: { $ref: '#/components/schemas/TruckStatus' } version: { type: integer, format: int64 } createdAt: { type: string, format: date-time } @@ -4031,45 +4033,47 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } TruckCreate: type: object - required: [origin, destination, palletCount, totalWeightKg, totalVolumeCbm, contactName, contactInfo, requestedDate, idempotencyKey] + required: [vehicleType, origin, destination, totalWeightKg, totalVolumeCbm, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, idempotencyKey] properties: - transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' } origin: { type: string, maxLength: 255 } originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 } - palletCount: { type: integer, minimum: 1 } packageCount: { type: integer, nullable: true, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } budgetAmount: { type: number, nullable: true, minimum: 0 } currency: { type: string, minLength: 3, maxLength: 3, default: USD } - contactName: { type: string, maxLength: 128 } - contactInfo: { type: string, maxLength: 128 } - requestedDate: { type: string, format: date } + originContactName: { type: string, maxLength: 128 } + originContactPhone: { type: string, maxLength: 128 } + destinationContactName: { type: string, maxLength: 128 } + destinationContactPhone: { type: string, maxLength: 128 } + pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } + deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } remark: { type: string, nullable: true, maxLength: 1000 } idempotencyKey: { type: string, maxLength: 255 } TruckUpdate: type: object - required: [version] + required: [version, vehicleType, originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt] properties: version: { type: integer, format: int64, minimum: 0 } - transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } + vehicleType: { type: string, enum: [PALLET_15, PALLET_30], description: '15拖或30拖;拼车来源未知时为空' } origin: { type: string, maxLength: 255 } originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 } - palletCount: { type: integer, minimum: 1 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } budgetAmount: { type: number, nullable: true, minimum: 0 } currency: { type: string, minLength: 3, maxLength: 3 } - contactName: { type: string, maxLength: 128 } - contactInfo: { type: string, maxLength: 128 } - requestedDate: { type: string, format: date } + originContactName: { type: string, maxLength: 128 } + originContactPhone: { type: string, maxLength: 128 } + destinationContactName: { type: string, maxLength: 128 } + destinationContactPhone: { type: string, maxLength: 128 } + pickupAt: { type: string, format: date-time, description: '取货日期时分,使用无偏移量的本地时间,不转换时区' } + deliveryAt: { type: string, format: date-time, description: '送货日期时分,不能早于取货时间,不转换时区' } remark: { type: string, nullable: true, maxLength: 1000 } LooseboardStatus: { type: string, enum: [PENDING_ACCEPTANCE, WAITING_INBOUND, RECEIVING, WAITING_CONSOLIDATION, PENDING_DISPATCH, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED] } LooseboardStatusChange: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index 2bb70c73c..fb281fd2b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -96,14 +96,14 @@ class FleetPoolOrderBridgeTest { incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op"); assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull(); } - @Test void truckInquiryPreservesUnknownContainerWhileZeroActualCountsRemainRealUntilReceipt(){ + @Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){ var source=input("US_TRUCK");service.confirm(source,"op"); - assertThat(truckOrder.getContainerType()).isNull();assertThat(truckOrder.getPalletCount()).isEqualTo(source.expectedPalletCount()); + assertThat(truckOrder.getVehicleType()).isNull();assertThat(truckOrder.getPalletCount()).isEqualTo(source.expectedPalletCount()); assertThat(truckOrder.getReceivedPalletCount()).isZero();assertThat(truckOrder.getAllocatedPalletCount()).isZero(); - assertThat(truckOrder.getPackageCount()).isEqualTo(source.packageCount());assertThat(truckOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE"); + assertThat(truckOrder.getPackageCount()).isEqualTo(source.packageCount());assertThat(truckOrder.getDestinationContactName()).isNull();assertThat(truckOrder.getDeliveryAt()).isNull(); assertThat(truckOrder.getOriginAddress()).isNull();assertThat(truckOrder.getDestinationAddress()).isNull();assertThat(truckOrder.getBudgetAmount()).isNull(); incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op"); - assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);assertThat(truckOrder.getContainerType()).isNull(); + assertThat(truckOrder.getReceivedPalletCount()).isEqualTo(2);assertThat(truckOrder.getVehicleType()).isNull(); } @Test void incomingMustMatchCustomerWarehouseAndDestinationAndNeverBindSecondTailOrder(){ service.confirm(input("LOCAL_LOOSEBOARD"),"op"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderPersistenceTest.java new file mode 100644 index 000000000..114b41743 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderPersistenceTest.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.trucking; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.mapper.*; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +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.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class UsTruckOrderPersistenceTest { + /** 使用模块基线和真实 Mapper 验证新字段保存、回读、更新与客户隔离。 */ + @Test + void contactsVehicleAndLocalMinuteTimesRoundTripThroughActualMapper() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + String baseline; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_us_truck_order ("); + String ddl = baseline.substring(start, baseline.indexOf(';', start)); + // H2 使用真实字段定义,去掉 MySQL 专属的索引和表选项。 + ddl = ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", ""); + ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")"; + ddl = ddl.replaceAll(",\\s*\\)$", ")"); + new JdbcTemplate(ds).execute(ddl); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(UsTruckOrderMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(UsTruckOrderMapper.class); + var repository = new UsTruckOrderRepository(mapper, mock(UsTruckOrderPalletMapper.class), mock(UsTruckOrderStatusHistoryMapper.class), mock(UsTruckOrderOperationMapper.class)); + var order = new UsTruckOrder(); + order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1"); + order.setVehicleType("PALLET_15"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); + order.setOriginContactName("发货人"); order.setOriginContactPhone("+1 (555) 0100"); + order.setDestinationContactName("收货人"); order.setDestinationContactPhone("+1-555-0101"); + order.setPickupAt(LocalDateTime.of(2026, 10, 20, 9, 30)); order.setDeliveryAt(LocalDateTime.of(2026, 10, 20, 15, 45)); + order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO); + order.setCurrency("USD"); order.setStatus("PENDING_REVIEW"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64)); + repository.insertOrder(order); + var saved = repository.findNo("C-1", order.getOrderNo()); + assertEquals("PALLET_15", saved.getVehicleType()); + assertEquals("发货人", saved.getOriginContactName()); assertEquals("+1 (555) 0100", saved.getOriginContactPhone()); + assertEquals("收货人", saved.getDestinationContactName()); assertEquals("+1-555-0101", saved.getDestinationContactPhone()); + assertEquals(order.getPickupAt(), saved.getPickupAt()); assertEquals(order.getDeliveryAt(), saved.getDeliveryAt()); + assertEquals(0, saved.getPalletCount()); + assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip")); + saved.setVehicleType("PALLET_30"); saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15)); + repository.updateOrder(saved); + var updated = repository.findKey("C-1", "roundtrip"); + assertEquals("PALLET_30", updated.getVehicleType()); assertEquals("+1-555-0102", updated.getDestinationContactPhone()); + assertEquals(LocalDateTime.of(2026, 10, 20, 16, 0), updated.getDeliveryAt()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderRequestsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderRequestsTest.java new file mode 100644 index 000000000..5539a87d3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderRequestsTest.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.trucking; + +import com.baiyue.lastleg.trucking.dto.UsTruckOrderRequests; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.stream.Collectors; +import static org.junit.jupiter.api.Assertions.*; + +class UsTruckOrderRequestsTest { + /** 新增的七项需求必须由服务端校验,不能仅依赖页面星号。 */ + @Test + void bothEndpointsVehicleAndTimesAreMandatory() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var request = new UsTruckOrderRequests.Create(null, "LA", null, "ONT", null, 0, + BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", " ", null, null, "", null, null, null, "required"); + var fields = factory.getValidator().validate(request).stream().map(v -> v.getPropertyPath().toString()).collect(Collectors.toSet()); + assertEquals(java.util.Set.of("vehicleType", "originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt"), fields); + } + } + + /** 两种当前车型及国际电话原样受理,未知车型拒绝。 */ + @Test + void onlyCurrentVehicleTypesAreAccepted() { + try (var factory = Validation.buildDefaultValidatorFactory()) { + for (String vehicle : java.util.List.of("PALLET_15", "PALLET_30", "OTHER")) { + var request = new UsTruckOrderRequests.Create(vehicle, "LA", null, "ONT", null, 0, + BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "+1 (555) 0100", "B", "+1-555-0101", + LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, "vehicle"); + assertEquals(!vehicle.equals("OTHER"), factory.getValidator().validate(request).isEmpty()); + } + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java index 1258d91fd..4eca2719d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java @@ -18,7 +18,7 @@ class UsTruckOrderSchemaContractTest { assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_pallet")); - assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 1")); + assertTrue(sql.contains("pallet_count INT NOT NULL DEFAULT 0")); assertTrue(sql.contains("received_pallet_count INT NOT NULL DEFAULT 0")); assertTrue(sql.contains("allocated_pallet_count INT NOT NULL DEFAULT 0")); assertTrue(sql.contains("CREATE TABLE IF NOT EXISTS ll_us_truck_order_operation")); @@ -50,7 +50,7 @@ class UsTruckOrderSchemaContractTest { } @Test - void poolUnknownContainerSurvivesOmittedNullInsertColumnInBothBaselines() throws Exception { + void newTruckDemandFieldsExistAndRemovedInputsAreAbsentInBothBaselines() throws Exception { String baseline; try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); @@ -59,8 +59,9 @@ class UsTruckOrderSchemaContractTest { for (String sql : java.util.List.of(baseline, unified)) { String normalized = sql.toLowerCase().replace("create table if not exists", "create table"); String block = tableBlock(normalized, "ll_us_truck_order"); - assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(), - "Unknown container must remain null when MyBatis omits its insert column"); + for (String column : java.util.List.of("vehicle_type", "origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone", "pickup_at", "delivery_at")) assertTrue(block.contains(column)); + for (String removed : java.util.List.of("transport_type", "container_type", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.contains(removed)); + } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java index f486612ee..bb353860b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java @@ -75,9 +75,9 @@ class UsTruckOrderControllerTest { @Test void platformCreateUsesExplicitCustomerAndCreatePermission() { - var request = new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, - "Ontario", null, "40HQ", 1, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, - null, "USD", "Alice", "+1-555-0100", java.time.LocalDate.now(), null, "create-key"); + var request = new UsTruckOrderRequests.Create("PALLET_15", "Los Angeles", null, + "Ontario", null, 0, java.math.BigDecimal.ZERO, java.math.BigDecimal.ZERO, + null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", java.time.LocalDateTime.now(), java.time.LocalDateTime.now().plusHours(1), null, "create-key"); platform.create(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_CREATE)), "100", request); verify(service).create("100", "7", request); @@ -85,8 +85,8 @@ class UsTruckOrderControllerTest { @Test void platformUpdateUsesExplicitCustomerAndEditPermission() { - var request = new UsTruckOrderRequests.Update(0L, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null); + var request = new UsTruckOrderRequests.Update(0L, "PALLET_30", null, null, null, null, + null, null, null, null, null, "Alice", "100", "Bob", "200", java.time.LocalDateTime.now(), java.time.LocalDateTime.now().plusHours(1), null); platform.update(user(0L, "PLATFORM", Set.of(com.baiyue.lastleg.security.OverseasPermissions.TRUCK_EDIT)), "100", "TK1", request); verify(service).update("100", "TK1", "7", request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index bc9413b18..d5e6a928f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -54,18 +54,15 @@ class UsTruckOrderServiceTest { void setUp() { service = new UsTruckOrderService(); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "pallets", pallets); - ReflectionTestUtils.setField(service, "histories", histories); - ReflectionTestUtils.setField(service, "operations", operations); + ReflectionTestUtils.setField(service, "repository", new com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository(orders, pallets, histories, operations)); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service, "orderNoGenerator", orderNoGenerator); order = order(UsTruckOrderService.PENDING_REVIEW, 0L, 2); lenient().when(orders.lockNo("100", "TK1")).thenAnswer(call -> order); - when(orders.findNo("100", "TK1")).thenAnswer(call -> order); - when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows); + lenient().when(orders.findNo("100", "TK1")).thenAnswer(call -> order); + lenient().when(pallets.listOrder(anyString(), anyLong())).thenAnswer(call -> palletRows); lenient().when(histories.listOrder("100", 1L)).thenAnswer(call -> historyRows); - when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { + lenient().when(histories.insert(any(UsTruckOrderStatusHistory.class))).thenAnswer(call -> { historyRows.add(call.getArgument(0)); return 1; }); lenient().when(pallets.insert(any(UsTruckOrderPallet.class))).thenAnswer(call -> { @@ -82,18 +79,39 @@ class UsTruckOrderServiceTest { } @Test - void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { + void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() { when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1"); when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows); - when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { - order = call.getArgument(0); - return 1; - }); + when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; }); + var request = create("PALLET_15", "new-truck"); + var detail = service.create("100", "operator", request); + assertEquals("PALLET_15", detail.summary().vehicleType()); + assertEquals("Alice", detail.summary().originContactName()); + assertEquals("Bob", detail.summary().destinationContactName()); + assertEquals("+1-555-0101", detail.summary().destinationContactPhone()); + assertEquals(request.pickupAt(), detail.summary().pickupAt()); + assertEquals(request.deliveryAt(), detail.summary().deliveryAt()); + assertEquals(0, detail.summary().palletCount()); + assertTrue(detail.pallets().isEmpty()); + assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", create("OTHER", "invalid"))); + } - var detail = service.create("100", "operator", create(null, "default-type")); + @Test + void firstActualReceiptAcceptsRealQuantityWithoutForecastOverage() { + order.setPalletCount(0); + service.review("100", "TK1", "operator", new UsTruckOrderRequests.Review(0L, "APPROVE", "review-new", null)); + assertTrue(palletRows.isEmpty()); + var received = service.receivePallets("100", "TK1", "receiver", new UsTruckOrderRequests.ReceivePallets(1L, 3, 6, List.of(2, 2, 2), "first-real", null)); + assertEquals(UsTruckOrderService.WAITING_CONSOLIDATION, received.summary().status()); + assertEquals(3, received.summary().receivedPalletCount()); + assertTrue(palletRows.stream().allMatch(row -> "RECEIVED".equals(row.getStatus()))); + } - assertEquals("40HQ", detail.summary().containerType()); - assertThrows(BizException.class, () -> service.create("100", "operator", create("40HC", "retired-type"))); + @Test + void reversedTimesAreRejectedBeforeCreatingAnOrder() { + var request = create("PALLET_30", "reversed"); + var invalid = new UsTruckOrderRequests.Create(request.vehicleType(), request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.deliveryAt(), request.pickupAt(), request.remark(), request.idempotencyKey()); + assertThrows(ResponseStatusException.class, () -> service.create("100", "operator", invalid)); } @Test @@ -188,16 +206,16 @@ class UsTruckOrderServiceTest { return new UsTruckOrderRequests.Command(version, key, key); } - private UsTruckOrderRequests.Create create(String containerType, String key) { - return new UsTruckOrderRequests.Create("PORT_PICKUP", "Los Angeles", null, "Ontario", null, - containerType, 1, 0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", - "+1-555-0100", LocalDate.now().plusDays(1), null, key); + private UsTruckOrderRequests.Create create(String vehicleType, String key) { + return new UsTruckOrderRequests.Create(vehicleType, "Los Angeles", null, "Ontario", null, + 0, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", + LocalDateTime.of(2026, 10, 20, 9, 30), LocalDateTime.of(2026, 10, 20, 15, 45), null, key); } private UsTruckOrder order(String status, long version, int pallets) { UsTruckOrder value = new UsTruckOrder(); value.setId(1L); value.setCustomerId("100"); value.setOrderNo("TK1"); - value.setTransportType("PORT_PICKUP"); value.setOrigin("Los Angeles"); value.setDestination("Ontario"); + value.setVehicleType("PALLET_15"); value.setOrigin("Los Angeles"); value.setDestination("Ontario"); value.setPalletCount(pallets); value.setPackageCount(0); value.setReceivedPalletCount(0); value.setAllocatedPalletCount(0); value.setStatus(status); value.setVersion(version); value.setCreatedAt(LocalDateTime.now()); value.setUpdatedAt(value.getCreatedAt()); diff --git a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs index d05c38d18..fcc783607 100644 --- a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs @@ -56,11 +56,13 @@ test('customer identity joins use the same explicit collation as existing fulfil }) test('commercial schemas do not invent container or palletizing facts for pool orders', () => { - for (const name of ['ll_local_looseboard_order', 'll_us_truck_order']) { + for (const name of ['ll_local_looseboard_order']) { const definition = table(overseas, 'baiyue_lastleg', name) assert.match(definition, /container_type\s+varchar\(32\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i) assert.doesNotMatch(definition, /container_type[^\n]*DEFAULT '40HQ'/i) } + assert.doesNotMatch(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /container_type/i) + assert.match(table(overseas, 'baiyue_lastleg', 'll_us_truck_order'), /vehicle_type/i) assert.match(table(overseas, 'baiyue_lastleg', 'll_local_looseboard_order'), /pallet_required\s+varchar\(8\)(?:\s+COLLATE\s+\w+)?\s+(?:NULL\s+)?DEFAULT NULL/i) const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql') assert.equal((sql.match(/MODIFY COLUMN/g) || []).length, 3) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index d537777cc..2a78f8a75 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -270,11 +270,11 @@ async function procurementFlow(context, prerequisite) { function truckPayload(context) { const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) return { - transportType: 'PORT_PICKUP', origin: `验收起运-${suffix}`, originAddress: 'Long Beach', - destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', containerType: '40HQ', - palletCount: 1, packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, - currency: 'USD', contactName: '四期验收联系人', contactInfo: '13800000000', - requestedDate: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10), remark: context.marker, + vehicleType: 'PALLET_15', origin: `验收起运-${suffix}`, originAddress: 'Long Beach', + destination: `验收目的-${suffix}`, destinationAddress: 'Ontario', + packageCount: 2, totalWeightKg: 100, totalVolumeCbm: 2.5, budgetAmount: 88, + currency: 'USD', originContactName: '验收发货人', originContactPhone: '+1-555-0100', destinationContactName: '验收收货人', destinationContactPhone: '+1-555-0101', + pickupAt: `${new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)}T09:30:00`, deliveryAt: `${new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 10)}T15:45:00`, remark: context.marker, idempotencyKey: `${context.marker}-phase4-truck`.slice(0, 255), } } @@ -367,7 +367,7 @@ async function truckFlow(context, customerId) { const detail = await overseas(context, 'GET', '查询 marker 本土卡车详情', path) const before = summary(detail.data) const edited = await overseas(context, 'PATCH', '编辑 marker 本土卡车订单', path, - { version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` }) + { ...payload, idempotencyKey: undefined, version: Number(before?.version), destination: `${payload.destination}-EDIT`, remark: `${context.marker}-truck-edited` }) const after = summary(edited.data) context.assert(MODULE, '卡车编辑版本递增且普通字段回显', edited.response, after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 From 26a203b4eba926e03b348991380249100849a122 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 15:00:25 +0800 Subject: [PATCH 019/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/overseas-inventory-mutation-flow.png | Bin 0 -> 241628 bytes docs/overseas-inventory-mutation-flow.svg | 90 ++++++++++++++++++ docs/overseas-inventory-mutation-test-map.md | 40 ++++++++ .../mapper/CargoDiscardInventoryMapper.java | 30 ++++-- .../relabel/mapper/RelabelItemMapper.java | 10 ++ .../mapper/InboundSnapshotMapper.java | 22 +++++ .../TransferSourceReservationMapper.java | 24 +++++ .../TransferInventorySourceRepository.java | 28 ++++++ .../TransferReturnSourceRepository.java | 9 +- .../service/WarehouseTransferService.java | 57 +++++++++-- .../CargoDiscardAvailableStockMapperTest.java | 70 ++++++++++++++ .../service/TransferInventorySourceTest.java | 19 ++-- .../service/TransferReturnSourceTest.java | 8 ++ .../service/WarehouseTransferServiceTest.java | 7 ++ 14 files changed, 394 insertions(+), 20 deletions(-) create mode 100644 docs/overseas-inventory-mutation-flow.png create mode 100644 docs/overseas-inventory-mutation-flow.svg create mode 100644 docs/overseas-inventory-mutation-test-map.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardAvailableStockMapperTest.java diff --git a/docs/overseas-inventory-mutation-flow.png b/docs/overseas-inventory-mutation-flow.png new file mode 100644 index 0000000000000000000000000000000000000000..e6447284e3e3cdfe246f76f0ea70859f672a02db GIT binary patch literal 241628 zcmd3NcQjk^-+!w{)QT#CAVE*Ry7vM` zjvLNjE)AR;M*REHK?n)7hW`7N2w`C4f1Fcf|HtY7qCfvHx|N9k_bUCby3-km_c1oE zo~Pn)ofP${C{s)tdp4Xh-5XWvB<6&fYqs3`&K||2bmoyu^xG{8ye{qF^EGs&22*c1 zJn~j^gpF?F$ruhbs5MZnZW`n@b&f(z4B#7&bRH?`DW7krJe_;@>^%QvMNz<^-`1IC zV`}{Ri&dJet0d89PvPg=Tr7*3PRPpQPu!)xGf54dyq@os&ic8W*>8-%$0^oPK*HIp zrGeSlwi{pUrR2^MhE}6QO_e9a7B-uOve@*LD$2jp5MDC><9Gl8Lk65{D-MXyMvJbT z%X533JQ;{OUk&unojpIqc||Ip@2j?9J0HjWP<$#+7*4Q1tyexQn2nlN{A|4adn|TZ zm7ac@7OZ&qEof@QiAGPP!R-$%KNxjV=ZN1#`gJvEX36#W8Xs)VwKX2*I#+M8XK#c_{^%w4XZ^=eN^eX|HCbsXOjgl)h2oak7i4(1WlBXmqTXi&ewU* z4TqFtPui}HW0f*Z8p~x*arTj=L^tg%;@NIi(XqnrAe~Ux0n5hqSEK3UNAkUSg%we! zv2sOMRPigw-tQ+zUrMPy@9d_qBfTqr6Fy&00xG21XUJGNjicHtpefu63z4&3+ejZp zLMlUA&&(amYXuU~wytkKVVeIKh;6e9n@7XfG?IJ$I(9Ew}bjV+dd67wB zJ!dvQl!@e?)$8Pgm0fDxiE2EY>eU#-6aAKt+f0f6Qk(LV+Z*9Y2lAwzYokY3|FQne zRJe?{tIp5NPNoBQyB!f`b`co?r`)4+nZC<0v(teyp}0cG3>(0`BtwNhhc9m_alfIE3K!*$dv1J ziWyV0Aa+V4CPG$^lkqF<@~k$s|jaP}-Y$6yWWy?M{@Msvw^ z2lVIqo?gMHzg}*hGf{A`(TjpxVv>gph{L1>C4i)ezSyI9mqbS;2mDr*xLj|RMCrZ)YBGP0;bb<)p6e+~5S%9}&1d-AXpqj0We=EeXP%Ms3-w<#M#a|` zr%&}=4n1nv-@!5s!LHktU%-LHPT%VE^UVQRNkfg-C?ilnv*jM4+o6tKS}=M){Zzc; zP*eRMN6;%Fi<$mM{F#p=KzfEVM4Ir-Vb6)c!_`SsjGa&RI~&%pwL#$+!u=Y;=qMEe zk)KZpD<%3mSkLn^puN+VD4-UPwv5+9&rf@m?QWC?9?U#Y%((PmZm8R|o`+;q&(rTI zs@{Sm*2M;>b@XSQ&2t^sOpaGRq0@LO6yXwhvUke0gH;o(%HYSA-q4C$L~hIsAHT18 z6Ww^cpcqNR#pPimmvc%3UxyN+tt{oZgP z+Y?T`q55R_@IiW<;+}))6A7APl&in=^Hnirxw>^8PsM{ojci@L`adhx#Is2rO(`cl zLA$Yh{n+9Da+{&g@JXou&}j)x#>4>0HP4O7a>2HDl&R}UiPt|COFc+`jPiEtr}PI~ zH+;cLv*qE*GaqB#qpMfjc8czOZqIlEX>|k49T;;YFz*R|R{`PpVr95G16~BT6)O01!Xp2GBMfe9-Dvz4&?@aWZuJPF7+TB@(-*znbbSG#KB+v2J zSKu;R;I&U1b<;-DjkzkqeE)OgsMPL+cwMc_G|1)CAS~#?4HpJ*hOgjMxF5+qb_$v4 zXjI>js#0;rBM63&{m9CI<}z9#Uj>BFc49IJa;~I+PMI%DVA0*tGOQ;x{r2LD?7@@H z{w~Qb%z%*ibKbLJkLV_@@4Up`B4?99Pv;0D)9L_Y?}y=}jw0{l(;MFi2jrG~zc0#S zPIDsp%r!ZrXlw72%oLB@;+ukJpBG=VHCNHA#u&ZuY0%~IcXPrvisGLG8ab1RA1%cG z9PLgb(NbeVmi8h*K4~wyg%3V2ME->_gn_y!z%};pl9rEJ2fw_O!9RU?PTRbgN+=tC zz1lv!Wlto~P0N?r@Grr#eC^E1&mx4nZ(+u(6;x(=rhzHyNM?0a7XgX@H|Qy#PzAMy zMr>Sq`W|xQy%4bc8o~Nl`P^bUVF$RK)Bv8gdb)uzKH!Q`0T7UKHR~S$LqB8{2CV{q z@4{7W1xk5vf7lz{^l$GEWrXPPSWOC+)H}N13AN6LD{Kkx!7*{g$gI8R;7XOdA84B2 zgTJFJ3J^Sw!4oAezXu;e93++UdE#^^5wZ8EjLavZJZanSSb7X0|LmC$ zAZsyno$<~fJ;GF7o@8-;Si3nqkZ>A@U*i!6ldOO|Xq5<{`>fn8krsvNJ6Abx1Z^(J zOqZaeT6F6_qqaZ9IA$qV1z2rWVdGX-LhIaz2HJk!?)9`Dom&;LxaP$ZO+sDYu%&=f z=oh?73EVkViz1%y4&IMgx}Erfp2w7Q^U0GmBUoiJ2p;M4qk9CM#-;?Hp+u;67A;Oq zf--~p-sNKBq`)sO5M?o|!Z7%(0ka#N5`p=4zMDov^Suu1-P%-> zyOn(H|i^s2($}NB>$OD#Z2F#qV=4o`AxHD@@V&PW=EYZ~y2o<7$xB&sjcd z30W@1l(-$6E_=DfG-dgc%d2?*bkF9Yq|bd);MtahQJmirS(6CM2 zV0Br>2eWYvXSuUoou#mLK6T>x(O7`vLZtYu$NuP7Z{@aPCeSnImHy377IwmW50y{T ziBil58ouWtK){w$iGs5C5m4I|84Ex=H=IsI22&1^0BR_3krDz|bQ{s4LSVcMx1RS=K>N;T%W|@eg2WjoN8eQSI(=CT z-#rP>3UGN$`J!uXXIfNIl$Lp{@^p_MD49REKKaHy23te|04HAw$o zy}nEckbPJ8GuH;nd|nYQ+5R)-C2l;K3q^4IN9 z>EuX;%|DU1lW45QoQ^~H`wPwf!rD8s`De$h0NN*9W3q>uc%0S2nCS-B)}Oz#q9;ru zW+y2>^9f*9?Zalrd~)ZTrg1HZ1D~KXQ&7BLgpTXzLPTfY8z?n_a7m_afp`!XEio?LOwnQ;D#BKSju~)~f`o)jn{I=Jr_$b;Jo$ zucg;m$p9Z8@KF3Pt}fcT3UCWG zt5Yw<%%wP`AwkTaUs84}Ya)p+MKHj(mAsp}^n@oQ+zm%kkTB;ny5299difOB9ToiA zYo(_spxIEX2e-mrb%R^VvGWaGRj}=dVeUqWTaOnh5%BQi^^iJDkO%qS9-Z$bd-B?8 zR{ltrflj@SFkSXO_dd_6sZvnUKMv=uCfM9@SgbfO#$57UcTSu`(W2|m*D7aWUUIdb zm`rK6fCRcrXKlpF_w1)^Fdr(!zoFie_!=?x9{Svy)TU^}_N>Jp-8Nx32%#&9N!q-8 za$34!xDa>W?Y&FGK|MIRKFiE_;8LuDknhb*gj|T1E=0R|X{yky!!D_FQd&4D-dCH- zi1JOk%=C@4=^s=4nUZwLC0|ww*hBUL@ac~hO4YzCiMiyFSFaLb#EUQ!u`L}fvooW_ zjc@qot!UNFHC9b;wdLwLEj@t8B`5q@Tj_$&18t2h#@E!%~}=HS&Jex3uXZRZE| zyw%*tA!>R>==E1%$H!%M0HB~;yyR%7) zWNKc8^|*u^T?(5xkk`y`d19pMIA?GA1sGpQQonhZ{!ZO+|FPnFa1j?B`n6D}emFb= zwb6z@SH0d!sp?3^Jv`9mQ^>c)jZppEL?I0)c&T^*A$s{VQVKNF=+%)YlFYv6eiS9-d{7uKIbD@<-{EHdeJ9$Im2k#kb7uj){3c35GF-&z8b6P;l(1&6pVvlW z05AXc-cG`NuNIMC=pmoU!p7lB8rx8qKo>1q4#oy={M*uhX)Sdq@S@xYDQ0MR&%bwf zMAh5{Bd#`IoDpNy#zzJ2Xik4o|IK{|qTWSMefTh%(a}Rn<4mfQBQq0lv+i3I7Pa~P zE1ldE&8HdoU{H?t7_}OgDVZ^6(Z2D&jGl2*Up2F}z{LKzC?gysE@b|R;`S`6K)fyb z(e!WP@9G_(4+u6v=6dQ8I@tNzo>&Nw8p`cFZ1&+#l47d#AOpNPJgmi~ea6wP?#T!H zSu0B>ysCi^kXmm{7A|rqz|5eaIH!ENbnx<>?(xZ|f5*+gFHpwGPD5r8f63)S%Ao1x zi?4)#-b(U`1e57v z+)b&;N`HkMKlY1*2ddqkFe?NsGwBY2(^EX0)fiWT3V!6K3|>P6=xai6(wx0hHIx>B&1Uex^dSCnMWdFL;bS zgwUt)_3H>n5W1KEFDBK?0xbOW#&21vayGCy-N#jzkU87*;cY`Ftg|DT0^_N zsPuc!8BPmh56q-vev^;^wvRA^>@}l@H!J5U5xBf74Wg&fuQE_|^XdaI zztyH#lmgh`FYF0OosOr z5nOouU!Y;iI9LqY`%%yq7Z~7l`^AJS@msO5rox-H0aaSG#6-(SN}N=l_z3S;6{L!xsBVUI?}%4QP)p8P;c8kLmMNvi)p+sdg|+6K8rYs z_AXcH2EU`z%ecJiL19rMO6r`lHeS-T0;bugR?_~=x04-KK5Rd`jQ$5<F0(7pAj3&9fcy)DA?O2q`UDIm?&o|(JA8}rv)l}5^&!>#(RGES5rd^jaJ^0C_ISzEIA0hDU=;IM=}Tk!p0Ajguba9$XM6T9Qbdm zk(rQ%Iayd3dJ$8}+c#YJjydSN1FZSD>`oa^0I#!!YeoB}zcjFnnU8UZqlOM=O4tbe zX+83Y;ssC)ZwP^JM{8ABlwAU3&7VH+(k#Y@BsV;+d2sRKFfjMu7TiB1$OFDT8R};e z5vU5TwfXT=#Qpg@;(568aZkjr+B6l}hb%o;1)sqSzS0A48SvlaGZDFRn-$5s;m`W zZR+FUedpS|mZnjnOcv#yj$6SfEH?a54H?G#uN+_(g}Z4|E?Ee%9nSoDm;bo#GV{$~ z8c9BF8RsXImAfhKRw^r@6EP1Vd6_tS_%NA*+|8_$B2L5#1M_j_1r5M4mDJq$zRw=L zt?;IUXI>0&1_f`z7RfhU`^$zKoC+cB|7RIY;5sD>ukY=X%qn-8T0M>oPW%924zHS` z;OK)FPL}WD{!JIBu+RgVNTJ8U&OyL9>F7$;t`@gEF(Fg>u!BLaPw!M}yPE=4$)k!+ z%MC;2nuEO05+u&5_otpF)7X?9^mbq~&I6*9ud$my9rFfzMJPulWYUNH41r9Wwqe66lneFIfFF z4VjX*iz_3QY2G+I&QG2-93Xy@<9E|9jc9Z5i&%XTPcfq(4lH;!by}veo{^q4$ei;u zw(=}Q$Zc|KH^hkox}bVsiGkFOOa^+Ite?(!pL_X&AN-Y^ADI@cbyWSBsCl{WHY)w) zU#;~rxe<49CI7+iG?hk z-4evwtk_^E5dh1Z9`}rK=_(PmQja~*pHtq`9wcmUxs_H8xG|H%_acjIRH= z=g3om^5gT&JM7CBvUtk;+@Q2)_oOi8h~&%jU@)}z+KB}IWGb0djML#;%O{I8TvmDm zWPV`PuLG=`Gq{-K!@2ugPw`i#Rh*q`b$%V>_!{d7ZQ+~GV5P>HSeo{S5OqH-LYb2l z)LSGiwp&Lre3cTnTWhA<@09amRS@(au$%{@-Tx|8S^4GtbwsM4}D^Cduoe|A^`JZI$1#zS%oTh zc^VUd@tV4seH0d)!AUOP2lB&G4!x8gGKZ5U$ivCRwe{_#I2{(0;G(NV!`K~xJh83X zdfy*bRroGs*=MV>UM<4J*fxiSG|e@j^TAtPAX*@cH;de<6f)L8(u*0KpyxEm#n*!4`K z5^qm|X9DM5!TWCm&Ij!(6ar{wZZ^=kehrOJV|f5+U6p24K=4$Ol#d?mE7VNX7ay!Q z{{FhIP|xG;YAW0TF08$@`ypG{$YiDbj#Kyj&oC_S01-)cFC0hi?r3gD*ZD%{9XQ9l zI)|A|$ud^2F+@K1Dm{4yM8rQH*$C%E@6DrHKm9dRRxUl8CZtrGC4lRf_iDbx03D~? zKYcvjPIIowNGofdcVe)b%nI0fJ;uNe#XD%jsq^`S(}a)68)&u8XEKVP&~Wm7e0>h)+_91<#ne@kbxTD*Y|9pNocNB2)Aw&QSbGtE}9V1yI_H=JIf?dd3>ltn* zy&U)}eIJoA6IA%Gjh&nPXfL0v0k`BCDRF zaF)i+ua`_4I#+jV-QTGIa}9~ljrR|}R}%{?V!aOUHJlS~rK`cjdb0(H)7EZIoj%#K z)-_}!@x~^Yhb{swCuXV6dy$a z$zvnQZ9F#D&y6R{E^|<-_qiC%fsFH}84kgPUYT|RgKiwb>~S6B{sYp*etptdD9;p3 zu%+@pLK%|B!aX-{nmT2;>*(u{+RVkBhySi}tbGWaI9_54nSG`S6*rtaBTF|~dxD4L z-Tf$w<(t^Pz`fqdhvK-qz5aRa1{jMK_@*aYP#G8cMXUJ)v0_#(Ie?T{SRq)c649*O z@C@#~>DRr0?Q(e`_)rw_=pC_S`oP30sBC#>9;2QHc&+m%U<-G@H*Ql8ska?A9D1`= z0S0(}3cqB*ynGIDL}f}y=N)n~fBWGGYE75L+oXp}ZIaEF+uL1tV2=OD0w4ryo<<$G zPIXmVHTXBtijq_V;P46Hn|>>r>%e{_h-Nb^fYg%RPGNEL)hA@!{8rQ*0l07r37FaM z@~$s0gA(`i-Pc`7dMj|Y)m8@`g-B-lpin98H65p+LBp!`vyqx^Jf35B_@}!615L&>LC%Sdo9hl|st~3j z<4|qCm#6J(iZcxF=#l`a3qy*X1XYNDy4}iyN8b3v_9Jr)k($r>=+y2OLC-h{SehlJ zeGTU!QWnkXrL?)kcnygA&WZT=@u@G)=ZM_?(yt!lp4Y78p>5(7PkvA)V*?;r-&!lcx7&bChCFQr- zLGv3C;1vT2?6X!N6|M)UA+`6V=u&SZvPZ|bQ#IKQzEO#a6U=OtgDw6wWnAf}oXdp- zGJ}_m-}?xk7r-{g-YQGr_4GarSE!^Qc;wK(-P8Hm1>_cjQ$X8)pm`ZzCIgid2Rv>A!(m^)38EslP>$iwITF95n|Sji~VZk2lBUF79u2@E^0T0@f{ zLBsv$KWSGJc6>b_6;1uf~svt-EQy&sGlqK7U<+()nPG zY+8w(os55-(WZsIDL`_LF*B9?nMt1e>md}WfNIVibn7H->{o6l&yh{o#~H-m*NrOu ztI3_;ae;T6@QLdJNI~s4fs7GDE94J0Mo5a)I_`YW?Y?sak65tKSiCSKY96`5kp+kL zw4t1?t9v3qCqGB6PG1b;oDJ}<7iCZx3)=IOo|*V@{(WIhxTgE3>HqM;_FI!ZkL{u) z63m>-Wq2)B8{;pD+y)YTHm~z%;gKSsg@N?v!%qm`mpQl%ApgYa47OZ70!_KRtlngZU=eK%88T2SfsZP6 zb{XYvXOYc}1hBhkP9HA3sJC!@umT8E!ob)Qttfar@fqHCl1R?yb3IPu;;KvCmB@yJ zj1X#reW?1iS^uLaoQSIl5wG$pzwYwJ28jClJ=JQ^8M{+5JxGg=zHY~n*SFtquh)1M z8#~?XTcrzm?$8@5v`#g41Luj#aH7}$*EXj@*j?&P9Zc~f+j2hgG*cX)0cO<;{K8=` z)hRJxgOH^zZ!dxy^7IapvL%<&xm=L+?G^x?$mOva>dgIn@8GX1KXfbH=J)QAslT_M zYo=;-k?Li%W~PgsCUf?6xj8t6XCrCoA*5@;x7bSclAa)CA&eMctof^!1>l~$b#i=a zkaBtIO9Xs^F1VBIkee;|sHJFt5CVMKd{$~5_7Qhe>3E}ocTXWsx9h^)>7RUGS@ul$ zQk+%K<9E|k*RDh@R_mByM$C8M6E3X@!Mh=W%8DpO|!(3t6b6&@Yqe`mrv{HW4l5q!$?~2b~1u z%i|4Xc-F5gkyVDq1o@!&YWeqE%C1&@sE_}`I%E=~@$&Uox`N+t(!I2VHqY+%y8IfF zv57nV3fnNa>Fw?~=R3+lmap_Yc2scju|jkRha<3E!#Aft-ibTGslpUcKF<}=P{6lb z0NThb&^Omd$peZPP(R9jl9xd5d$ikPB_!dEJhgT&nVPOC|d%S{bkMXkZ<-#b{)1m=`lVgqrE}H-D zdTqMtrM58S-rxzsre9Lq3_PeHN=ZIEo14;%o5$Uklh_i5J~t`F4cSs6=vUC!Co&??PkW05X|5;5g@C84f?BKu!@f-uko$c?GkTvP!!bpq6Ul?gM)+$~q$^T?r6 zFgs`XDuRlv0N$u01Qg!J#T4`^N7bqB&*`p0;2u_F@C8c?Jm(G&ERlHW+>mCz;Am`0 z^HJUW3z+f4H}zI7M6z`q@>;95HGryn{Tgs~J#le{9#T9&=)khOUX`YrDZF#_da3H? z-()+tEe<}$^(1gw{mK0N z7haZcQeH}w9 z!JSK&5-rB?uE<7gqG{?g#u>kvmN=3-52gIdm4vtP<&)B4e>@H|F6n*VMlE7s3VU^A z&-CNv8=bObr;p>;WACuW@wo^QBby`t%&1jrAl2OWoaljuHf$I;0(%)9QH+vckn@buitq;g9`q$1SvlI|wyA3+)6GTi0CN=0l;siPeW zw^~5U7LQ(97;-&yBur6>S@p-((X8;;CJN!(>j+fc1$S#ujR|-}B}?R3yd%b+$BUN) zJ+jZ@N|yWx%TTsL(&_B1?pJzeU%fkL-=}Lf_s@VUxw_WM{#9asuanRI-xtW9Oh>J_ zZI#|>jmrD=<4B4?C{W>`U*aGX5rh{2J${Tjp4N(#q3Pst(A|u8A%hR4pSzW&6W3S zHqMySfbyA7GWd1mln*I)HpXj;wgs-e3?ihkFtp3^Hw6Ofht z(2?rsh!}8ksYaCx=>sdQGo{puo3&(?q|$akY0hs&rqYszkzJH@MBhkizx}lGVUAoImD|Se8A2Ep zNeq^cbThqO-A>H%7}9Cu7$Dr1$M*|mUM~^F?*ts3#tJ+i8nVb}>e=$(gE$gSik#Pv z13=D<>F!xc|d?p6rex;hQV(mU=_%GbHXJPMlpgus`)~2;D!}V;??)CG zrdsm#pAQnwzrOth#k=m--71*zlgDkUxSHfv?7xu| zH1g|_%;dAR!0kJ2fAiAh>_d-neg=JnveMq$pTPQ)LWZQzp`gjUXyzg>IlrHGIpVGX zZq+FbUsW$w9a_)RDuD?mx7*S8Bu}BdthGNW^ZS97*H>1~hpYn&ZYp?hqvfBxn&#&Y ztsGA-E{^5Wiu+&)!)9~Fx$?XKXybJd~vMN)0vFQ}uxJDB^A>;qkis>oV*?`LoAa8Lygei)J;? z;)i4JDJVGai?)5Z#kPBTO3rrQ41h1c{h~d$&pMD7fWHq}6PRd!Z~<81%h3pIomKi! z6xd$9DR;V_jnCq!s;yL9q;A)3fAKT=J3MDXzvcJI?*UkJ<;7vu=zGxv9_WCkgWc2I z9s@EzFbg_x2kTFFouQ9w&ki5C!{mBZX`)S4yRx);kyG1abj8t7?$5%2I$_II^ z9-jpK7RG5rRi0*;^hC{^bQN`7@>tmV^QFL-bKJm~Ce*FFVK1m*n6l73b+w{$4jVfy zJv^^uGeF~!R?=u|-*pFWQ&YvcJ}~|0zdI8-xOpkqVuA1p^m^uUh~Y~!eA5+5M08u! zePZ5V2rz^x>?(a`1`fpSeq`OY&Qwx-*67@pI-6--R3GMr<67$JfJhBqqkP(kqf;o} zs>YdfHHjN=w8tqoE}Y7X)$Z>H(bWQgpmOmGE!a5tqe+(8mnc~NyYRtSlYCSm<(#1q zzc^y0n{1+;^Q&8d(%lGZEx3vLDh>IuYj{t|`8}4*BBS7g`ogF_rLymn->R-~FsBEr zM381K7MQ{LW-atvUr)A*2hNfs5_6i|s7o=QR3f}6slDLeP}PG26wY=SkIl=G(@e8M zj%zkHX21IPB|7jUDrOOFw_|zpR6y`qpof>D<$ddv^-sMP5GOw1;+XMTRfqRdW5joW zg-{aLy;&<}SUtzr5S0#aMML_nR1|}&84!z8yx9VzPkHgNBE`21XM z?s%P?1$=tlb*y}E!>T>_n3EQr0dPYB0p78K9$H+HRJgC!^><~M!ERGs_ftx8xDu`8 zPQBYwZ7STjE&J3nxqz)8-e5d0N%J&R+WVf|;#uVh9QAlW@N@1aXPh%!`x%+zM=f=L zdI`lNAj7%x=<>MZh1vjk_xITHPqGvDx}Fcvdc0aYtoW8S{lrNmXKGd5MOvK-TDOt% zdrq1aaEN^8(H28;*inDsW;k(X>${tAFcB?Lx378b_hN2U(YtrxTvljDw3J!fDk#32 zdHDrh`^>~~RM<$c%PH8bdL&@@WwcyU1SpAlJ-Qb}NKL$MmC#uDKeB$%D;laG;?^G5 z4W?WO&=S+6(ytDo#I2RDa?KrHUyySRlfrZZd1p!Dpe=v&o54Ro`m4U4;`*D|hh34_ zyxEkg} zPZ7h`MdtTzv$u^1r_xI&9(AuQcU3n+8kcSCBIEBuMv zJZ+$A3E7Pg%m}eJ7!gEV54iZ5M;ccGQ%W8bVWqm2t$;Sqm{)d_3Vx$ zui=zKYs56u)LoQ*JJE|p+m}HiQU#^nvX#C~t~oHF=exB!Bu~eE{I%o*0`<0`q%`D3 zL6(H>>@ST#t74`!3S*nClNnjKrQ3|=57nPr0Ce=|mQ8eL^uo9m7NLyikCj}N8F3!~ z=As*(pL}X`gP+nS+!z$HdM+{{7U-&0mGE%@bnlpnVy4)X&5L#DXwS;0qqmEqP&m(hb=FyOYux|ASqHfg4TcDsKW$@DxyLc6y)=6%K@%AeTz3 zDn0909-h(|kcXw(Ee$CQ-)7!&V&FCDo}o8S)?Q>yi1+sDCmzE<`DtieYf2WK(7maW z09R@#fO(7<=^gbA`9cQ~y`gRGGbm^DC~VJMr7IL;S)f4Ow0o+X?JU7bQ(7?@r&MqJ zn3EZwOSIT+;S#;EnG`rjl4G0+#TXLSy?_A(f zmxY4e*G@GL?!es)wv=r8Ufg6N=S^s5m2!A2sVw!@p78jV{h7RqG_01JbI=gzenBSv zRO0ttXQFMD&V@p6Lh58%y<1iGF$EOiu~D~~qJ@PB%}%J8sx0ZaWzX)l9RtEG+^(AW zk6#*6l-$21T~R%|)AyeFePNm`JMt!wFEO+(cn80O&l7&WXpFmMc&6j_@*9usc&ovF zk$max#p>Ut=|?R$(&w&M{JD33I$sg;I1HX2&5pL-xGE;a1{j?H;?5bb$ZCH*OtS!WZ9ApU|=*=be396UEa!=70^kc>Ip`S z#_~A~uQ?{rz7L^>%tKRwVv_S8>BZRw7QKhFg&UuOFEd$$9Nm$+OnwnTXYk_GI}D@u zGzEakBlFhoOaLV=p9j6{^@BZ`Zu|`-lP;H|>4s~(mFrF2hnWp;;dUvc z5s5;=ua{mYi0tv%A~5_7yg{#QZWxYbp}W9SAEfVwDo(W-&Vx zWU-e1L57q;_o|o*r#d9+5h<(|E9a_c>E1eUK~1{u1Aw;cHxM0oq!4yriH`IjS?CKb z>H)(`a0^5a+eQI(ya;}*{FMCP zEb|th*m0pI%_&P*%s9d~8QWS!0BF#6X1;@|rytUjnnQqU@*WLXI3TFkRjvtey|b#n zT&U-POoxQG@Xt$l4-T0{^=uX93s!o(C8~_|_{;zcb@u`@W)TC`>JlQ$ODR^sG9d#! z4374aBd=`gJX4-%Y^X{Hipv>kKA~BQ&rPc4_Qm&aEU_p^LXo3>@fx}iJxg7P zK(d#7zC^M#JAmED|K_A|iwT_t+&}BpH*%5N@Gc%!_=xabD67+kV z?O(Y-@~i5s^!d%dpLl7D-z7#{n3-pPX zLMJ2^b>{1W7`O`cqdQs-IMhAi?ALwA#CzX`eR{6gE=cXl+m4Jyhdz@!;mj6E6rzCo z3nyB=_82w3jE-K|UEs~|vlyEGOF6{O4GA7{Yq+tC7NS_|O>U9_WGT+gSNiB8)nTJhb5Y9zBm zVX`^yYkT*fE^J%2`_Dw*SbRiSubhdiOFC=){ch3cf8NJ>0GSASLxqT*T^l57LY`|% zYRJ(KfZiWwO%g%+EwLmpdJ>oNB=pt|FKyn3Eb@%Cjf3V_>E+Q$mYQ-o57TS%(^5jW zfL3sg6n4d#yzPh-b)G)U{rC(!G{pS3SEP%^+Ta%tsQFlU$im@hrY=m>f1wF;%?ItS3#x=IhU;-@} z3Q05(C~?0AMR{)6hh;41uX383v^lJr^mb|<6o7Q+e37>u5(&;H zN(kh;lMmR}=3(rYbF>`t+Qg%Sq!=4Hr08$$ht;2MgenU0S zW=>GhGZQQRTOm|2!p*E2nHOy1UsvPWt}%Q{}ao9*kk~oJ&p@!yqpyeO$K5+xPm7 zMRJ}b_yRXBMKV({!CQLR+3FgQFUOz+@G{X5)c|B;v#1zbGl)Hmcv$BhGB;56L(W!a z>*P*^ZM(DjgPvx^iK8YfN5p#J(`}eRy9@8f>GnF~CnI9DUWXAQLKGfO1I=XD;b{5I zhqK>NDjn^i`iv;G{(P$km^77+e3mhomH*^K7z8wCqx*{ezb$ZbAzf@v2~7|rxA{Ja z3lPXIKQI;qzt8`2Ejw8CdruRo=a|>1{c-fY^!hXc4fThIUAWghUZZj`fM0_^{Kgq@ zmNxrRZ>VFPi?p>@h4YTPT+#aJjYUz;3VGaT-P63VeBDf61;Y=nL{Kjx05=E#BjPQt zwrG9T@e^*-Y5iiJrykwZ*Jh8nRHBE-p>}x(MxulnbUp%eLq40L#o-LVOn`Oxh{~GB z)iKtoVh}xZB<%=C_(>E4mRu-4bhvBe(qh)976tOfJ}@d&0b$<9rpecLYl)+NEs$TV znUH_;_2N&*VoJoy>v->(+ek6=7#&U*WMs!)x0#^!z}RL?&*1_YJUepD-Of=i9mmjU z&45B~aVZEJq3=>MFQNr0pq7Cvp>OWK+i9hTa?g=w3C`b}uQAffvHcP4#I{@TGTVT7 znGFx4k5z8w^)B=TsieGD%OHv4V;)0VeG^?Ign-|c^X|!-<3xvP(esarcT5D~&Iei5 z!#WV{Y@+1iA8rsvV8BYSf8wWb6aw=TztQhd zIL2@5V5_!n^3nyxmq*z5FNSEMM1YT(0r!dP@4^TaXYEsSxm)D;;k+WB;VZ_K`10E2 zBY|Q&!=SQm)7o@H+v!1>k<^F$vWy5wYe?3%_A$?1_=VJl-rB6#W*4N+O2NG+;?tpZ zaa(jVta|D{-Ap)q;9u!hipJ8VtNh(edP1K5bGikGB+!MhSx|(;uIgKl`XpGwOc{*>r zgits35e?t}VeYM?qI%o5VI_y2ks5jil#&=ix?4dM5v2tNq+4KUfsqbTkOt}Q5Tv9A zB&9>7ySwAv-uHXo-|t!9_t*E=GkdLBYxdr=Yn}VLj_W*+-<^(@xq9mdbRUI?vO%N10pRql!NS z+F21nI2JyZPW&v5y=4+AV$8cVNhUa#D~Yi7_0-~uDNDOTB3+*S6axNxvw^-Y?nvK@ zt*`TmFA!)F)_Zr5#6&YDBV@K8L_hZtp{ft0>&zfEc%wIvg|5O*XBcgzD2sQK_~+!D zB7*$|;&ZA1z}A5tU8HPb?no+kLGR46{r>!;D>cqI_ytWA%8=zq7y=TFtc$4Tvd!fw zWd=mPu2c5VPT+Z=#D_YEoPSZ(ogR5(hEYXkNTx%DiH7%ODJA1zt@TXqU3)L@`}aAv zQ!WUw;7E(_tjOt=@5yY};XX^dod0dZS|*;a#1i5Wp`{fhh@XSpp|X5AxS1-#e&Bs< z7See_XMlKO+4RjAr7Q(6KrkXLeprO=liA;ilr`NlO2e3l1z*G4#`7+W+!cqvqdZ*Cz3eHQZL)1hfAv{S&T(Xg%4M5WC_rEYAQ zB&@}vtHk@J^P@!G_hmx_JI0U!=nJ8ZdyK{p0%TEntZ@{}6rSV8Wdk3imOU0Zz%Rx@ z#8v6f$74KG_Ei6{9A%yFI~kz?9;3sdLrS1SD%8$BLiiP%9~j%+BI-yuyxTG*7t46> zo}yR!EY4lByF^GSq!$xlH+x4pQV^hcDe!7vY8%0*>4XLMC)*6G?Q@DO z6TNG{8X_UgMLQGzeAO|P9OTYR9V)~I4s@6e7ON6y3PLb6u*+W>+0D~H<2IeLUW^?D z7&>oOG|hdV*5kwjMs{f*tp7|`7y?0J3ux^16VPz2M?Bg$-N#e#3s9_ObQjR3>d8=O&#Ru{G(i9k=$>>tI z46pPe8)T;h@iiC=&WXs4<7P5r=)(9c6s8f>oKxG-tfeAo>L&!RI2ve{2 zHJK%`y)?y%{E%SuktO*pq6sOYuE;GB;&XPGuaoK43|HDzkq;*=u-oSnKe^J0UB%A8 zPfZ2f6(sxFJ*QqiIElsk_0l2T`x9B{fz5(!uq+dwv$g532NY(Bo_5c%jKQz{(ri~}e{)Gx}h zfTw5`cEP>+)EyUhf4s$-deUzlA>?nw zgi~S*7qPdZd59cPL`zS-lvwS&jp349+bw_F?~N6|MaFHDBh;MzjV$O0z)@@`t@p|K>4JN1XBRL zZ7?CuvJ-YA@a6e$Z6*btOE{nBKYm9^f*20X-4Ff|pGZp$pg;Hm6CRYyY-4m!d^=N7%boTdq%+BJL3KI&o!)#;pPXY#L9in8n$c&cGkFN&8%nNl5`g9cs zRo`Swx~K~r^@f~k@7k4N96wIe#}_S({$SHf#h zcUdBNycZH`YJjjEgGg*HIOabPp4>xyO#LjHsW~|p+lm5U@<>34dr^|ignhGOmoZmV zqaWCY;Aa?I;?WjM%^>N}ujm%?<~XnF7H2Lr=@!rj0Ib~w$?oI*xd`WR%y!JL1z9vJ zJ@-tKaqo&Q{FJ?P66eQi3%PI+b?eIA*7+Pm_hOezH5ch=w)5-O#-W)tdwnANW?037 zXy_>il5AY&NT$ljwpBZ>e(WTf&YxX#b(sdEDMe-$X{2Z{*nHjt?M?KnkQ?c-y%BX0 z!DnQ2fxlz7y=Tlmq6G%E#jEI{IDT4(&8h0R7E~IpzlLR9)ibF2T5*B{%Yk(HQno3B zDryP!^d8!{JLN=~eft;Q1fb+YMDnx3#rW}@x#*ajL|K!-%KE{x=8WmZtC;1#TsVrT zbT-n}ucrFF#iQ>> z5 z%{`2(u*8p@Qh}&p>5Mqjx{!Lw&?7M@RT!MJWpH-%2eZUyLlu1)jZvsIT1GZ%N!hZsv-nx z2QsJ+r8(04T>T+5Eukkg4YhagVrL2k>W2RTvAML$SXu0{f^!1jPCf6h1bQry;@;1J zWg(}`%o^YLGko?57d$`?65{jP`QOwHB{J8)jc{O2VT`c6`Sg&kFLqxE($xl%ysbw` zC(0n>OpAhxoY#d(pgumEGTHE%#QY>yQIO`Ip1bZ}>}EXNDurCe{EObAD@ zwNC#ywQP1t%?CZHx;w>aSac-c9Vs>=mi#QrwOvAm&Mf)CI1^H}YaQBpG?Z5OR5W|y zS)E1>`etdWm!YUoX``HCdri-hQ58oC6E9oB2B5}X>lo^R?)~6eIKCZ2xFgEtKpKme zSz!6SFy8{txKex0Q}Df$uP^3I7`h*^yESM-6liW~r1PE8f*awY@5Qo7Gy{uXum7%k z8cAXTJN`j0?=BF?kLO~23bOaYkJ8#ee^+r{aPryVp1}b$+b>)}0n@xDkFNQG`%;7; zINEVR^j}g`03dKkUThlk9dby$`$?$+BG=_rR%3=2`k zF)2+Kxb}bmNl!jkljP1r?7XzbG(M>7sN1qx!7L3j*)g zqW3=h8hi@9JLO_<3naYmzh+EF3?qt1oVO%H&uD|>6uu%Ge|VxBvx%Ydo*c>wa_zC; zooI)KeQ(%anb238O^oXlVd`Tz&dv!?8Gp?Y_8nXnem6W}nrv~7&pN}11z*(3tt+4luZD-> zF1JnexkI8LGZ1OW(vVbu#lTxm^edr`4}A9x>nm8_CYutg{(kL6-0J=Ls#g`{|Ec zc+`}39C=`odaIE%X8UgioYh&9IL9)$Wgn|;v3vfx>9xN`#phbYm`hl6<>$4s^FL(P z@<5Q%M~F-n`&?f0DD8`7?wru*UHVcH-^MuiV_P-IcEPGvVFN!=d{a_Yk{7mH-m|I( zk;~3-Cy0}W#ME10lv@W)8nWH`AzwawQv*<7rud!paz(+AV4soADL5MWbQAVEP(nC0 z*d+|DDO>C?z)rOyUP)ICRqj}q2u-*C4BDMLacK`HD)}Ia}y$ju# zRin)U_&@L2e_g8nwc;Pz%D=z=VNUe_V>7NC(SNe}|9<-a;5`3bLGk}65&yOQf6?au zMVIpbdP)uyURRs56imd|s{8%QX;k*UXO{b8LCrihUa)^--G6^02@;8?h-pc5NfINb zVzK7q4}Ru0bIe>Xz1e#I&;7{J#Z0~h&+}wNguKoQXV*CXYeWa^#V;#KH0U%^1z=Fj zrMw5pKfPkr-hspmYO-ZSzyq@4G5_Gec18N7q6yKNJ)&pUZK41Ci_K(9YlP21K65&j zaazM=dAj0%#i&3CxC;J4jeMs7d9EzJq0@c&ub}w%m#Ojxm1rwNLbRmpJyr3j|8Xt? z-KfMkKy2P{IGF3o_^&wq_g6FnMHS;CUfkAH)TR9Yi0o(v%KvQHloyZuABQ9SzlWP} z{KxqfTx~`gC_w+v&H@|%{^rK{9|wlu%Kcr$BmQwnfKU8S9{b-d?0|pWKXgEUSN(tQ z&FPUk zA!-sc(_$~V46fw(4jarm5`t!QlFL^8{~jbe>`hT*d69yt=`_kM4|SGL(oyuAn%aCI z@ijo$-1^rpK+5v(E`L9M&w<6^z5eOa!9o;|RMFY^XWw7Z(oIWy3m2n3+YQXvMvK>~ zp=>9Y8v7H%O@olg$#=K%UlP1ZP{=3YI*>7qv+{IatlCoYEJ4*y0wIT$g}Og3Zv8{mXM3<6Oo z^rK1oCB5^&BZChuG05Gz8r0$So5>Kx3!_6%`HqX895+flO(A_Bv+@ofHgWM?-xm!{ z@0?y6BbJY|HM@5=v|tqmjlMINT70_&o%MtP9ET+y853JqtOw`x!re{Mw>Qe)fwf+p zj{iz-{EZ4qkg}}wg3;XB2j{JJX7687A|lN94EfslV^R`*+FKyed-DR^^3mHWz)nUt zMQmL1(F5@7-~d0|u-Dpwf3H15CJ$I@vj@sDu0sR-=+fx_L{*qs8?pl$m6yIVQM*1b zRx)d|QCE|%F^!8Q?l;E^p}429^{@T4 z-gKtq*q5>mXWP|1d0CEH7QpuGe`s&0|K}OXyDvpG?2VmQWqpvo4v%JOW2Sb4RU`H_ z{^%T*pK2 znA0x<#@d5E9W6hSHg{-7B@z9N`!mmTw#l@Pf8O#o?Hew0Jc_=+zg`%s3k%Kd_8GFB zF(^ZsU1C1O%@$y`eM+BQ`W&aMoWq10E?gb^7`6{58l$8PrVoBBo&=G(_Wt{+EUOBU zEE)2Azu8$hF8)2nJJ9&Ox^`m>U$23_&73^@){fU6kjQge>5cQ<7&t9Tc3ixk**H&j z-tnl_uDi)r>XfQ*MLKSNVxujQ3=E~)(J{1{I`BEZ_~1m`v@F{h!Y%&z-#5WxoyK(R zEpgBj(b3?^l(M&J;zKS~Q&Jr@7}a`_Mc~HmS)m_SvTdExa=LIlfDxYMm2a9E8vR_k zUi)i_@$Hwz^T5}$mJwCvo{(dBQ4S57N8Bg=eBF1Mzf)D13{oRJ=Hu6EW> zFME4<8*c25Z?B_@H-DE1r}lo|ZZR1RjO#QXw$>@V5<)VF@ib=w{DI0gF3Q|fkF`sP%}llpEwVsf^A zbjp3Fb*j!`o5-GG{7+-={`0?)%a$s0c2Upt`Wy!^Bf6RI2zs@;aViy=BIemH^4_R? ze1FyaCk;_)gQep!jkFVDtMkYtYs-3woozV4*U{baw%cWLfR9mt15drt%g%A8`rEgL zxO=P)R3WqC8(U)x=ckgo^zQvd8_JN+q|2_y+p1F~p7Snll$G|LWxjWFC>V7>IE!be zkz$3}Tx1DeikY~hPqX*kD#vZb2fu0@HIE^y2;xWMa(f@oKT3&s@p|T+?{OOu67!S# z&2DQh`L6fNWpbb}dDiXnrlfy#*BfALe^@5_9u=mXHuI9+@kEOS_V#*hDXbuZ3_sQ5 z@kvl0>WECi#-}O^>p_zIiTJtym!}M(YnO*TOcCps&tfor-p`W9P^q=X^9KuShi|pa z_OCMsN>+-kcXf5?#g@CbB$@^YG=CE_U#7g1P0~0FUq7roWh2+OnG*Z=rJyh^-(Ih7 ztTKYb{paIOg6}3Uofqkma;!!g#Taq7O*BZ3$M*8B#ya;urZ#v@L$^>(hj;caq7#p1 zOs!|UNu(iqKD`J|I-jz5mQ>FpXE_0KUd*7>V)C*$u4Fl+v-a2Pd2}kZtPO4^E0X5G z@1~frIr6fEhDRBz8AU)0-4a<|OC7%04?y-8uk6?@D>iN$M8ZqSO1)o6Rpw7zuQ_jd zieJ=f&z_R}(M>?YDE+333eVts**`jvIuI?+yP zY9={!)Oxp>URmbo;p+ecWZay!(7?y0^0T^=a%O)O+E| z+O7~oyCqsw53uT=IO@HvB)$$jJ|wGqE-k)dlDu73IO!a8exx1;y-qOb$fp<{PCG3iza=o-w zr$Gpru}H?6wI1@9XxdZv)>Rhts4xF?J{0Fn!Jg|}KI;MNmeURnmZ|%qw_ypfbkZI-(Og>KSc0Ew_ zNn5?7Ce6O5^WPpO{hD}AwIzHMWM^VlY9#RFwRc09{5!`UeauBt-D+8d z*W#)CYzL3SQC+3dg`GD3aOzo){^K;V-RAugOO3z%w1YngJuXrHZUpG7rbfw58#O-@ zNUeT=2)JXOjz*Q7Nrtm=0fyTlOkDL0?yssVQAVj>mgPd6$3o&bhPw$qD5_~AjLH%Wz_N%3Y-7bhA-Xy^pO?yxP6 zNvBsnZlL+wMT^WI7Ml-zt`e8{sm;CkP^@v=DTyH=8n;t1A1hZcQaZkg$5GGk3Hj;2 zz7eT+h58h2YtU|%M$FWkpC?Ya`||^6X|<4P;c9Iu8FjDW<;`4bD2#-KA^7>AQU2&;vB)q~gv>VL_zcp>_9*5~3tL-@c+6f*ovs8F??uT%{@qJCYzYmbn@X+Q>v`woA zHIlg%B|OhLLoaB!h_{;FHx5)~yjN%HGiV=b_f(z`GkfjOthL$1ba_*iUOG3E(F8!- zCMQyz90j=_3saiy=s2^#53G zgc@cZh=2GDV<6ReLb-AWL$T$wJ`FC7dcPZ9Eps*{&h)x@H@=ize9S-QO+z&LsYRW6 zrY&N2NlNuMwm-Fr$hfhDnM6)xJlR=s=QVm?6oj^UC4F2`$9)nn+d|*F+pX?>7j^QR zN!Os*US2`S$Gp;Ux3|!a@%*Wp>Ah6@SI0NpEW3-NOsOH&630)H=6S6N6*ME;UUjTq z4Sa`#Hva{Ve;dH2J#Ri>lY>qT95mj{%SNWTx|kVt82u!pVLhHEqom?150afwOs17a z@R&R|dd_6QKIW7jqpqt)1yxF-^^ZSG476BO?ydQwz5AMonN`!(&gA7XJ_Y&14B9;! zA_-PnR^z5l^~$nEj;r`Z{Hae)Y^Zop!t~H{UbQ7IMr{$JUV{2oZ3V|-qwp43UlfOw zug96M(Osn!1mu9F{x_M;ny_h0WcRRnvM9oDZ(U$uXOmGo8GQ5pmN&`GzM4HWB8Y28 ze0ye==8{1gjc6LWp#j(-2B}9$WouCYuJ=QrdMdgyubm~r=HeG~4K%l@h)R#c3s!+) z5Q!Sh%XSbV`6i9&`-tRm@N4?|O}?*#=Kh`B+i++bII2x4Y=4KTH{@#1SeOMVR{w}5 zE#aB7w2wtv?5Vq``J7)EqLp;2wz%TqC+GIv?3v4bi54^1M!|w=n|v`iF5O3NYgOjD+9X)E)2% zg36v`{+Vm)0OC_BSM|<$DWC}7X-_U>UX5Y_oRo)75&q>l$Qcu$4vlsF5DjfZMM8g| zI+dy>CJbpLRj~;Hp-~cG2IRICJK(m~Isa($aS5b>l*aQc?7hEn_$}WUQhVNa?E7eW zv~27}W^ZbgkZBecoQ^eb@sWWXSC0bGvFq&*oYqOV>Dl@{D)%2Z3!&$uyE%J_BnCDg z)r5sG^G6u#X_(X9Lw0WtHt>NSH=5KuSq4g+L^O11ZY%F=5};=%VE%n4V)gpVEbg+d ze>tJNWi3zhT?nVV#djkIk}_5!n--ORC6eyYM}*d9=nvZTODN*s;rK;N;K!5ty{u?N zY8gjp`Pwm<&+WcTyA_Ev?DTbaGt!1GUFKyCgjuMjT13)~E={ zk50?n&xL_2=f4OA$zYVmVSq7H?5!!^4ojH zD`^5m6*Z4|#p-6Ikx3Bz7J1(&4)?QJe_6-`1(h-v7PuG@o`o$ToeV8nJRJ%NYe~UX z!B-#@m6@@^-3B+;h*%#A#WX-1zqyUt2%_ z&1xq!mD-Dn$QvDAXE(9d-rox=Gun$Jd8p3=FJ-3}=>+q&2-Lyfd94_r zT#x0s{uptzw;~RWpHJHBl4?p0Y#3folNT2ZO|9bO+MIY$X zCoc^^PrI2J2hq1Wc|s7-HyXRtd}78?o@FOlXb`ia0{rUbgwASfZW~?*-Hjb^`-Q?@ z)oy+jf_CN3sje8*;~CHt!}j|!*u3(6anNKiL_(`Z`4*I(8-P#Y+*&n{;HeVd5s*x6 zYE6dtbma!t1IE0I?#x%v1<4ggpQ8?G*U8ZyGDS@C(d3z{ay<_Mx=F0fQsaW-^bI% zh&SN`MZ2w=h}*l%&)h5UT12Q0Ub4t7XtcP~WWJDxFy4!iwMGpM;K&JWJZ~l!O^a9( zwO*O_BR~V`8sP#@V@(|!8`8iI8<8PLMq%je#Pa})$IT|(^rhwr=5=vrjrt%HAM0WJ zNrg`PCl`9c19hkwvJ3`1)P-BXf@&*0}4 z)*_LIlWSqL-c9`{zDJbNu|el|ZY%NRr#Y<1zlq}C@z&FV6@A&sugmG)w$k$ZSjT+& zaNEAX67g6u0jRV!E*!FqQAUrPyR}NbwQHC7;MqlAI(d{aed=7wG*TtgH5GRJkeR}o zZw8+&O1BxlJBJz;nfc9>x5E|(k;cMv6)P5d03bPjINL-YVAXr%V1X&`|X_%HJci&BF9CN&P zZS*5#pcCy_>3AC8G*3%DwWlrCq|P~Xr>(!A72H&FD)omI>{RlK>S6S!pxv6p(85%i zT7jy14-Tay2uO3#%S+GWb1e@QiH0(6fbxjXC)#|0-SbmBfROn5;keB0W4a%kWa)nI zw7FahQ_J8r!_>RMCmO|~nAWR4p(xK}6|2z|$zH5G_+c5cwy9rHLaEJTR`Bp(Oqp+rts1;lbg`R z=_W~%`EjH28%Xmc!=OC^GbDwWTF_3491Twa5w15`y(6@Kg2rxFRgjvZLms_Vp1v55 zj=by@K&idyp2l{J++;T6@aDXxvBoU^=KqOcGGf}4EQxN4axnw=w3+O+-|}U;^uyin z`XeQQRx2y*N#(mT(yzOD55Oi>Y~6v0`8n>Nfp8dV%8(4S4VsneR~>axZ-FIzQLl84Oe)#jcz%qU`im2(>xd$qjRUm0xvsbrMHDH3FNAH0| znOuodGoy@W6shiCI8-#gQ-Wp9h6Q8Z;>hi2is8+7wRkUl>ulimQT8ew1#R-jv+Tyz zHH&yDbgxCuP{Rck-i-acQbN7iA!nvTave?;7zB#K4x4hUZazINCXdYQ)uNH!sK#B1 zT>mam()h|s+o!U6>$z8RQC!`#=>UgcEi<7O{mx!wz1z<$3*U52x0Wma&A>^2#+r{S z5zRQP&T&GLe5PeR=lbz>IrRaQ*96wK)&l!PYNsKe|0)KX1FP_SC}Ha*wW+~V7pT&F z{71g{QB^V-+8FO=jT!N<5js?Y#hw?)x*E`+3-b{ee+)$RI}2Zr6HO*hwi_#{B-G`1rf%a|Ic)%$kh z1)3rn8wvscv3@GEnGL-z`c@AHB{U2MhUz|j3UJ}I+OWf2RViMgdW@@#{JWN-yX2_! zlpuD1Tx;^ZebqY>XiOdeuaLn;#c`2=QlCO@BQ2OT_9SQ6j;-3~Fd4j0X_Uk$Fa|!p zs4}G|FbO(3#mT1Vtj)qm@+!E`@9;$~tn)N)kRX(JU+BTTl$!U5$W9*x!RMDI&Qpop zVIJWqt|jQWoQP?!rItwS%tNrD!`_^F^DR|Sc2pR{5Yv z8{CcJtgiT0S@@Z-8Ij;-zA2R>-qDv%zw2jVPtsq7YFtQQ-dd53g(_sE7@xh}>0U+J z20Mz;lR>{h#4O!<-G|(*T&B(K)NA6`>>cr#xvfVQ)Gv`!kC3P%`y%=EQ<2*`VPejU z7$4i{U`t=wBvQYR&M<8S-Pa`vkIWm$ynjvC)PcS4T@&YLg|IG3DB!=sl^dfvVt`^I z=wr5HO#qEqNR~-ML~ur*Yl)?qD|ifkrBpa*m0z6qMF!Bj+c$CcYvlu7TT30)3Prik z+i+4p#Mrd{L!{vLryA;$`xlwDR5)@gJfl-z4YslC>~`P~t3l;QpG_#}Skz-iUv_Jq zZ2=vnPY~aGDLl^y*H35-@QSR#&CK_bJgG9YgCY-ptH2~4|}(+M!|~}W3>Vbe+-?N>F&vPib)g2#wDe?0| z2m1^DQ5muR%3}6uz!bY{nIXVJW0i+#X()u$Q1#=^CQs&F7af3?Tv8!oYH|-tX33Bj z*DrgsTM?Z42`XkyKnYc~9xcuzL_-~Ev(*;r1pC-hKQ1-#NK_oQ2!ty$5TG>_L{qpb zY5@tt=90&`A&Q%*rOyx3=#)`B{$Y9#kyh7xNd9eaje=qt}1p zOpfLa#IJ9-nE7-E-3Gt$N1K-i4;F1ShB+2nu}`ONXM#Z`Gs$8Q^N)g()L|+($M4>7 zZR`!1%KquT)andz5Hp$|@D5v(q58S+t`puN66*mTwhMbH*>UqNbc&VlOZjtoujR|H z+$=d0QXaCQgXHxB9cu#IZ#oriHCUhzRA^MhTh98XKh)e??1-eF9jelR5;)W{4}GPT z4TdUThU(_(5unM&s6n}`hY#t^5lUJS5zw(A6~hbiDYMa!9oL)e{vW$qJR(IEmo)4g zm^boY2`UicaZO#V=pu-EC{BT%CO!fww^7W~DA-|z3FB3x?hz{O}z$taT>aFhxk zj4LO=r(5}Y#GQ|vj9e48AnJXj2?QYyHv6GovVFm$dH`9*tERDV+k22sL9CT9`ciJ0 zj?!c9)f>3Eb6L?drSDQyn*A4Llp$!)=U#lV4dM57$|CZ71L?diK43hDm4_dYN>bm< z?OwawqgYq(GTqbGkXzk;F-^#3lN^7>r13E(!E*f$#;RiQS75=Q)!og0T`V|hp_Xw8 z;YnOxOUTz?i?-^0-LV;vNdvx)j2tYy=y_3eLZ^Uzx`B=OjAEN)70FZp@wiVcP{vj={qv5`A!j4*i)*LCb1BF+ft?v zjcuE1Bp%@&`M9u46({3Xx!<;t%uI-m)+SUg? z6IO)ghhAK7pC*2hkR$F0`EXnJ8q^fA6?Y69X0S!ivip`_DujArMhav=)%?N1dF~wG z9kActuTHxyA-}!77Gal*>jVkJuleG3qaK_OxTG_jYKh>C_%oOj+=K5YVH@-3*moa` z@H^ji`iGCOa-C?SUgBd@(J0@SgMR+fM^wNTsz{j{3suvu${xqam24PLVk0@hN}LpU z7aN|)_MJGj_u6X!-G42Ng2L8cfdz1^)nN_$Y#kg62QM&qc>Xs1bgD41oZNsvBRGo7 zVJH+F?Dfrg6KWr?&GY*wkbc$o(E^~IK*`qc^;5PBa{2q@o~+;1E?!{EDr3!?SN}1l zFuwb~C9{%4$zA$_!)LYX;STxfmsK!Glu`bzMi6%t^Sgy%SgXq!2LNhIpo8aAwMW)h zc58ZVg~=TzR$SIEolQJz$im&L%rJd<4=L7VSUT8|D@l)WU_`<`xBB7`(O?e|%Pct) z!oe8JcZ$W1Sc~{rQqArd4vg!?bw%-tVZEguNiG z&x=L?5zfEgG_*Mv3-!>h+y9^_r9+9h+N*De`KQ0%&)~dgw|k=3a|TfdWMRS21Let( zUH+z|)N<_GwH_r1uAFthkod&16qb=?U;DH&NgS}O?}LYw4^ls9r=oXmV#R8@+(`U)R4pa?y(g=9&!HXRju_NT?FsGTR*F=iG zZ$)gYZY^GamNv_ND9OxVAwPQDd$J|0y{Pflf74j)>FUl_=kvbA#5(8d6^=#W!@J>w zri=5u*UB#27_YnL-y$*B0JE2ao!=W6gos=Xy&4{ukeol}H(>I{U4NpZ$?vA#K}H`o zM3H&4egx^==i4Jy=O?9f`RRmhqwz*kM>9}B0fg?=ivK*EHZ=P|hPk3IIp<)XM%FA0 zd9>mp4pD=DdWkECX2&>8S({?1bcDmkHj5`Tw7>DsDCyv4EHHPlTL!>!IIV)31Z|Bc zd0`vW9NoXC#!v2n5{Sy*eoaI6^}WF&0owV)-0QoWHSd}NzH0T!JSSS=ggayd`kwpi zk91ZYcrMge_IQ9!!Cc?17)PYsMWJd1~nT)0EF|5^l`@*37C!zU|UJ`&^C+$&lFEp z;!y%m1UE5s^lB?Q z5fEhJy=FB(CLU4a9997jl{hrF%74B(a;qYF7Xe_}pVdP{Xqhe?{}vttr~t%7HuR3B zwF7dYJ1hRNEcbwLlQdOtb;E*m50{6J8t}e`DMi}m|KWQa_LvCsUhq>H@OnzCZUZV3 z^tAYmc}sgEH%?rrOcxdw3#*Idu!r>vB%dJC0{CO$TdC0(VlZMgEkA_896r}au_upe zxPRjtVve%&SqLKOfj$U}bkX$aT4ZL9-eGlaumczdu0W}5MiYq@*0Lfwt1%(WPmT2= zNG~X-WUEU!!dA7lbBrXyU0cu6ny9bANSg5(n+t}?{cxtnvfntZ1W3KY(NI6O7wd3C z<0Upqszb^ZdFZe6Mx>gLZT}F%n2$BVO?YFp2^UT)Vo1Q8g_%Fa-WZl%TA(yR%FYZb zdC0_TBXIEgH~csfQS~d%czU(z*B8FtT#V3N8sV4K&B(xMMAIP`%>z)FCTpped@ePqt}f?C&-kUIITMP2ZH>rskqZx)HH_2pVJ zeYeIsIlvGHTjMt7{Dl^5sSf@#Sv{+8PP@%sobgbcnC@BDRxu%Z*(2{MEX!d0Nzmh( zJYcdDhS7&XAN|jQe#9#M$$kVX#G>+M@|O9&>sRw`E&``jeo7&of*-5OgEyhH?Csat zR;4a|P_EvVQLoO>>AVMk5=4n-70qX&JX?V&^CpJl?e}kb70=WiXy+M+IsvP9ATPgf zAh0U;yqzkrm^?aIihoQexN0;e`A@V-j1N%K8@!FMcb+kdq{@C!t86+RTtUW1rr2x3 zoiO$Ag&$(Y{wh#@IxKikH`*)WML54N@#RX*6TWWqR@QbDrM2;l^bXmHnU9`GwuGpw zlO;LxS-OUYHm2VsW$;AgNnaL+QV5!t-VhvpIE%L(wOGqE$VgpOftUU_6MO>pbLh}x zGM)W?9>WtBFuUgKRO2*!X;75ECp%x>x`e0kae4wOO=ClYZbjx)5>*6ev{kYnD>O-` zO^h50<>NAlr=W^KL{tLrj*eN?;V)kf{9G#e;zo&0PV~54KtZ%26X80)Y~XDl&H=mt zU**M$hfp!f3V%k0-j9)k67KBXmW6h@0#)JtCm(28E7hpZX7W@6%0-V|7j72*bjyXc zq{bK;k1R9^lr2uSn;MyK?u{=6HsmYQPntP8bkDp08Q>Y|oAX?_G6HCS zk*h$is!&+H?2a<{hYU4Gmo_?wqZu)693IxGfgLeUYN7Ysk)bN{$P@PceMi|$Y@^ED zKxb^vGgLv(!kn3}JEvY_d9`kFZ~8^`=%?jVN)FWd^`{hnU{XVn`&M_K!j4M|XD;4A}hU7eyY$sa@RM;?j->O${SUhLapLojP z#S7ix`?@c6{NhGSGaR#83QSdpHY3d(%%(o3tAk&4dor$9h@d+DkeL_i747%xZcIH< zqFRYIRxp+52!7*oFqL|Z*SvjZkPEoONRI^%x*|l#MNC0zz6dsGvY57zAF&Rs4>P`C%2ohh7tWt2#&jh-C^3kph zAjcyme8PDlG{yn)Ei$f+>SZc-*_U=LE0F0*lz#DbXxw3Tt*wrC;#`E((M}Hl1lW#G z)N$pQ65{0Ty*nVm$9On-MqIQT{|xyXcFg7N20&w%M`>~6 z2>b_`B;NZ&HG)IeQ)9({0Mh~$0V(CNQ1D@wN82g~)9T_q6OLx`j7tkouiJ@K2#V%g z-HM>U+jG6{NmR1fEh6yXk5Z#@f7md0X-Hxw-#6CSChvb$dQL!oWqYQH7cb&)TZ3lf zCx$$SEd{h$qy`_84?PVEb@W#x4Im4!MQ=D04}1tZyHPaAAWJ%!c_OKcIN?vXdX;SA z|8m6kCw`+YqdsvYRDQ_YQY^clkTNUN=m%zvHh*5b3;}+fnXb}Cfd&8kh&4kYxV=t0 zex8zCuG8=59OJs3_Vhl@2BoEWd7v9A%gyZ0swW|trqshzwg!G3qv$U@I0jUyC>7%? zo@p2EA zAqr%t<{AP)k$HS=SnO2H3QFZ`p^3O^P`%>YnqerBxzwi2gffO$bP9r4nU zp4y6~_~<*|-<947pYUV&>-Tf0X*-x zPVSvgD(5IY&bqap{kbT?5omv#wU}+;@5<`~y47zfV;f@dkY9xlz)BwKQ{K8gR4fwP zBK-l;fvbzoMj$zD)k}Ps92=$!z^cPduNwK1TM8iPe23^9;f^)=i^br;A~&+LR4|8V z>cA5P9A^wL(3isQ7hbo{OK8&{ubHSvt@pVbP=Vg0&Lj@Qz3(m>2OowXLb)NGt+F`0O0%ruqs&hJAFoSBtlreVlv&a7RG`VjktPN@MH1!3(s7HG{f{;)6R*f zv%uT>@zlJj=lTan2-0yYo%_&Z%7#-8wl!!75p;O_AOL(CyBSjDQzTlaKE~NgU=@N7 z8B)OnCFELMKZ3>y9(FfZf$l)rAfd3^`HGe{BN^&Lp1e8z}0WWOLyQ*hq(Ll4X!3_2r6sWk4HfX+F^blUx&0g1k{0 z<+Uw^SzjCrzmoxT-jx(kL-VoZY<{qO80ax?E9;gCtc7q?oiXj^f~jeWYVBCFp&1(n zz9X?{f!YVX#35a;lkhl=0nXUDn=o~7$)7s7 zYJxU~MDD548m)X9+(ll^orbpfONm--+YFo}(Y^+ci;;`!4}km^u~rKRZKkoOo^d0e zD9~%+$}zsys*B+1 zCwGQT1IQDWCW>7OHIZp%lQvlp7h;A{txXXgK06BfhA;G7;W9VQf(euN~y6 z<5^J;cHzU166>oX+p^ae(4j%uCZ>OV_z0x8ek%vddV?eh>{7fs+1l``WdzC~H~g;1 z%g37NvBW~4BAUu9wfMC^UU_PAqkKY!raqW{MG8q|#^h}5^UP~jf~i?6gS^lTOX08b z!wn$gi|r;4FwVF~?N&_uO`cs<fR^iOeb__0q@xjDx2~6_>J4d(r#x zTZ-tn|3lq-M>Vyr@uF@lpokEqcY=z5H0ecpQ$(ao7ZH%&I{^`dNJkKmk{}?x_nHVu zQA+3?BE19%JwOP&#oqhev(LGI+&5mi<2@Y%9b>JAg*g=>4+yYj=JyO#7e3H;748S=jz?wIrLn@sq) zIJbUow`)8(V|!74sfem2vbjOk`DdU*QoB-_m|AVS-Fu>+yvC6FO+!fyGM%SIkD zD?hxI*OQ_Q@P-XMW|DDHZi{WL>pX{EO$)>Qb}0^s@_E>IY2U)@F6X7S zTw8H67ude!0=?v;r(W?^@~_iaJ%oPun^iBk z^cFSJf7I!jS^ws5wPA6lZ+0c1k%An=3mPGz&9e+MSMPc}t+zI(Js0P5KT{kRjxm~i zL&=NwA@LKV))l<2o4z7;zqu6JH&WkP_evR_`&V#zUO@61L7S)Z9wP^UHr=ut>*(Bu<@IUUp zz{UK9={aa(n*D_#d6pi_bapmT_OyJ23viL-mytEVW0t{|K% zddKwRzSK57vy7Fw$nQy-N^T9``;ypk7-qHq-4h9b)vXn099#tKJ;S}EPlx~OA)l3CjS)auN_OH| z&U^ps;xX%(?}a9n>@~tNDE;%>2dBp0>}L>}7y57iL|1rVC!v4h$o~G}OZrywt(2|F zjS!x_S(P06|IvW#Vl+HW__%rCQPJ(bu>ZQWma&%S01T8NuQ!L`pAQ|^spL=zy8_kk zQ^R*pkDMlQa8dXF34>kZ%$RxU23&6>`4E!l=#7oHCp5xo4gNXiigfVR0jvJEv&X-O zuk0RFD|5!=)6`NmBZohCP{4g%w`XCoiI&203?@t*5I` zWLpkZ&bd)t{wIpICig8=Ian{t>YHqx$@ozVeuBF@=d(;J=s3oCzCyHO!fwS{4Q=?P z7~*77Ge?Lzc1iu`>#|8R>NA=%T6%s}Cx28kY5-7Ti5H0D0!OsC~ z5W|=AbcFBoovf;$|3LV%^jqaD(f4+V^e!JC^I3Dx&SoTjlN~4Ym^RkwWSRA{lZ-vM z`OhadUVL>P+!YncW$IOH?KMK`sH+8);oIe0C;Lom`;|HVsj?K3%dH%FDI9O@YTeag z-KeR`WV^)+@J)yQ3Hbc`&cU|i+5i`}+qa**ZP2{h$}6YYohSZwG2;Se`}ZGJl%+58 z6I$WxqLA$%LjXN&*Ys(OJ~Aa(jPb~B(hSfNp zroKw^pN)U^?e&n2H4hk68U{SD&4tUrH;dorNi`)285-*;m-~-fZ_mCIw{r{aS`xg_ zh^Z@^O!YO?7VyCV>xki!|mhKN=`1Kl_hKEVXbb*~vJF{3Xm%4Zy5(Xya&# z7gZ|H2td)+KSmd4+Hrytr8GoAefSC>oD?F&GPGrX{Ktemm&a=2wSZ!bqLz8#?n=Kf zsdN*_7-7Ny;?y_VG;=&$K>qkKSn_xfQ)$HF3-)t6n|5qQ6k-EdCFkY?u6BzQ*{%zh zqEh44zdSCS!j4?lAVZZjSiETIX(w$sm>#0;;oh?FGTV!~%)*I9^N`>2eDOB}!o@f* zsv(`^H9#aurDo~P0)fwe&R28SbmL1Vt3>ck< zbg6wT*K>-1Tn%lkKw&Ga553ILj~fYb9F3c^k0t!yxb)R6H^)fw8R55liLd>85-&5b=?V?-%i5!*A(zi@>c2P`5m3JH;2OpmX>Ustjvy=f@WNK zSuHunyuNcLxgQ$~Qup%7U@PyK_8|6yArXX5(|%Em7cO`+q?ve62iCum{o%^aM3wF z>Z&Y#Fgx^z{J(d^Me5R$80ZNkBh!!`OL>ky7>{+NA>pgv*~-*=<=jOV*z0-RHZT#u zVUl*xym#h_(lw~EGH@x`JDJgQUe+a4BF{UPDH9bzupwOHZoQSP^EA+lz}Yc#X|%ca zYdXNcRg!DwqFs(fdr4LSF|z#C(RARtiA~jAAg_d6IlwyflrU_+0DYic5now1s?2jq zKC20E`Tk@gLA;Xj?!SlnC$8QzMArMa4dm(E^1M`()hQAWvi4|{rOjkyg+csc*dqwT z8%AZeHo1MNIa^XdGoy-x5+&n=m~UC))YW_L|CE^7Iq+QSEf zhFdPf2I_9Wwwg*O*TiBG2!nU{nK|o3PJS=Rjs0EvmD=l8`e={9nH1kPQ(7})6e3U@ zS(gOF+;dzC1Ye-}3wyXM>|1MP|Y4G5GVQvgt5dU@hm4364&jO83VSVDZD*JJS~$ zYc^RXS9_)LcY|b2!}Cm{LR=;ng|x7vJm=j{pYF79KUU!ifM9ScR`FKPO1UZYO3izx zFzYu$1l_)hzyN(Sg@nTF6Ya1*dHLA296YmwR}6HUJ;l``I3ZK!c<-ap%oHSA5;=G9 zBA#ME6w*tBXw-KpWfb}^SqXAxsKm77CW6WGA54h;{+Rmdi&7J&xn8M|wl;Fw3-_ppeu7>Gkf@Q43Nf?`;#;b&e+uY1dB_8oA> zT!(3Xy~QTJ@V(OJ=Je#GoL$A6Y5X2f*&ekc;?I2*%K1K!$n_}^nhW}Y#JzEyufgHS zqPMhmbsrPFQFpHR2G}~yYsQb^7r?79SE6OlPE`JxGArFu@6#trP|ZL&H18ZoL!8iw z-sy_nc&E+F$#T9NI~0!DT7A4As-S4zY@ysFRX&3kQ~mcy|6(JV^Sn*+g!8y}CgwqV z%Ee)&NyDqkzCtmP0)no)OfHFjUXicDAGGAU8~Qiy4Sa2{2j8D!&S&No`*5mrXm z`GPN26|wIF9y^iQ_V(*cJ)GgUid>|6y4l%9Yetr*u-Orn_)w|TVnBy` z0$O$Ix^;0j?dHRHYt%!OQu=r0PAw;a=%p0Z>R*^4RrM_sFOm@d7?(bkuo(B)|2oJg zOo_~`we`TQ)hDKSc#}B&CKg%l$0qFo^grHbUk9)=SbKeqvd>T?!fV!S&ll@!ud1nC zI*ai>7m_c#u_m(ca-AH^Or0;e2UK^+jz0_61Rrb7)zDF#s*)NiUt;$49eq&!0IkO8 zhg(qSUV$z~5{9+?&36|QugHTbkc}$?6ETMbHGq_CrYJb%-;?^c0^tbaSKoVbXF#vDZO{uyO7m<3+d#rz?k61^XRM*9`6 z6l(fH1F{K`Cw|7bm~(G`pJ=49xBBtoggnJ4`GsBFpVE~tBmJsmcB6O zj9gZ~7%AQjsWR+r+Fxc^@&Wj_P03ny+!clF+|#v}d$@*@lnd0a$cB6$SGl3tcPFMk zO!TII9W?wIimLOp4R_d==zseb>ci4(i$TQSslETU)-t`1oDD5vVjk`;8)~2Q; z-@C_3`$D6c^kKLngs9}Jf3NP}Tc&3It2K=81Kpt7=pBEpATnleTkrC7c;M-ZE1D$+ znK9@0cy~Gx2j<#wL!T+At;x>qfU+7*SkoH)UD%#~i*r}hQFedDMbU?%nK8uus}|s@ z{5mOk``egO_C5JIKs`l_vGP`&<5XZX&#yFI*f%St{`t?=d=5--wRcE9GDfbp`&}c) z40=tUpLPD)V}_XGS5X`)F%J4|aTuSYi4_d2@WkgMSK4+5#^b8|l{sOjQp`3z3hU+P z(-$3L^*>J1xYEUaBRyT(^cOITt!ocB2PeDp5 zcMU#MB>l$wF&XXAuCIICrMO+*X_m=EEOqQ2M#Lt3rnM9Cw(;WVN9kW}je3$U55BS> zzJ3Etp{37vcu>Cv^8|(AZuvCiDRi9!y{v8H1}O*lVJdm|T6r_?V6NBxc}OWPns{_( zyk{Vm*2?C&_szCAp;(bq55sNhhf*LzXX(wTO5+zN1gDt1fFmvewFfXog5QnKZxlN6 zw!i){0e$|Dh|phU;2S|NavfWTIms{s4)$u6CxL-3vTQ_bk!(|0K!)uY_tx#0X2YsRU-8t{zoEOc_LUrW6Lf5e?DFU^yt5n#&cN1(Uz!8 zn0eMhgBq)%eZh?viVMvWZpt%malCiTOGe#*;-^=Gk!wrmG{#;kAW#^;Dxtj&c5w!I z!&d77#oBYSk8GR~&ha1yT?6T-)*}^XN7SamGjQYEnw!yP_^lK0ZA!V@0+oX3d-Ckn zcW}~MP4iMCD^HNV&(TJ|QX-gIJXrkX%(9685uEsw8*01qE=B9_IE(PrI_1vTQNh7C z(Xf5nu$64)*V)V_fcaVv7s^h{I?qs16L8!~S>iF18ohNnF1#}F z;X_5VZ%>b%)Tf4Fx&Lwm0PIche|Fd<`m>PgpTUX?B_96YH~n9x_xeA@0rvmQP5&SI z6xGsKBa(JN+=|2Ua&B~2Njkq+qwOgWe@LGTBp+70rTGc0} zt4S-r4k=rXMKy>vF0P$T*aVmMJojHL1y@96L=B9VFhHDU50$Om#&}$}t61)oBja!R z*q@M2{1IcC4%t~(s6;mU2ZG`fGswU4`S_oR;YWG5C&MQuJ})r;3S8# zfv8?3J)|AZb?!+{!-1Nc;YJmnmWR7lYZI{pcPheQbf0JTepF7R$4K*lD@?v(zmn5G zI%&_;FsyL=_9u;-xfefVDQ#m?^Rm#Z@cm0A%s%>uC%XppMjM@q8`wA89lZXJrw(TK zgb^QAYxh$9_GhW7^7GpMS@ z(#N=k3ZL$z3j#SVikj92oXDGCQ@T&-!&Z5c_@4n2!KKol875b(?9el@klk7hK~d3K zhuVRTw{}B%Jv@`M6B?%4RZ5eK8BY`uy|0~R zmTtTcKY(x-lh&QBOm$sPL6M6X~>MeUt6xZTuZ(ua-=g$Z4+Om4xZO* zYBp4eBqXP%5@w$F8MI+-t$&=$qZKODlx326W~(|_;!{(Nx)~r{?O7}jP7>idnW`3w;vlTO1kKB*?9avgszk|Ren<1;cGvX^X#dv@Y?OlNOz@l?%{Qz^=m z!CSKt(V^Np85bO{i6bfHK_Sf*DSeaNt>qx_9lA8&0jU~{TY2V+< zQ;J6tjpbMtTtPfM3l^%`$`x?x(T2BQkYYo4a2m)k~%{%atSCfMc-lf{hr zjMUVr8tUsqs;y?^Vlq?PEz0#lBYnB%E3(N0Q-!-giS zppJjNKCCl0#!la@zbezw(Q(ScC~?f;iQVm6Fo9WVagOy{T0lxkL9yYNI6Ic-Bmuo* z6XEw%hQ@<9f8|?0E07Ak9-m7EnTVA#srXBYe5kjhp$@IBgl{B4_SZT~0)W|XIW(Wu zLj~-nWN!9#55;Y4{EwG^Ah4_%(XZkoefn9Tz~_5T@zQ$)PMk9yy+x-ZWC9%8H|9-G zhpJU9FNC*_4QyhyHN{=Qhm)*WHOeU(-XTB|NX;rUM< zom73p=yCq3aB^-!+;q&{{kBJPO%59B4>C7{p0tJ6NZL=Xrq9Y+$Sf~g1=b1zxryot zno{X1p?*FeyJDGJfDYQM;hC(;*$6z_NolJX!4^&B;}^L*9~)0cScD$vk5j}sF64a` zrG}`XcVf1-103h8dO@ODrm)QKz~sQ<_#8@dC0tJ2|B{FVj56AYb|$Ad8B;LUTTJ8N zw9NdWY6hc{>WTVmuUNnO(WI}oa2kV^>ShVOm=JV;cjp&Nnf=qIG0$~rpBOnvIQ~K- zQ3@+&PkFyO8P9Y&R+#PFSPgIlj0jSTlcK9?lr(@K2)dokXKFA=SiS#iVSXuTpmk_x zMv=8YsY+9g_$EgYjbGn3CP~c%=F*UCc1}-kU_Gy0d(NauzZ}Eh8zFP^;`0$qlG%Y1 zTQDc~$xGMkmb_}w+FQrGn%tz-y zkh^#>(nRVcsDfo(bZ#fTLF0Ze7tXw8s_@UR&d~U%(y485v5E#W<);#s2)j?0kuQ;^OED>hJAU zzc36hY6kwI50&;KniI4z?UN=<1y)Vrb~xZ~RMLDb<&csrjwi-x)-9i?CRUSjs_Y}- z=`=5`@GN~f?hO-kJrYEDg} zlE1U1Cnoe~M;%t0F<*0kp>T(iSWCG7t_={ISE$fOC(rDQKVNgR*zrB{v|^B1i<%cy zPkY3Up8^`>>Y?1oTr?PF{6xu?ry5xBzuRKM4+prgf!msGd*>^GXZoH!_4?Mt@RPC3 zVlS)4y|3mIa=FGS2leK%GZxpR&x|J$MVbkYGYgJ^uiko|nBlDz;P5itK%cJsB&t#% z@M#n-U0FFblx4MSSvV^Oz9zSO-Dm4ZW zGMY~Fy{z3Sw*MJEGI4KgCLVIKhF(Wlea}KsF3i{@&A#aby2i`aMb&USGV4^$nXz~q z)&wj}89kP9ZMB=I4e)x?vM)}WDzO6BsHG?~iM* ztHhgITZ*D_in(TiGbS;VxP|w2TMuyp=Y+Pn6|0rQ=Etd(uWU*iSoCLI1M8X1pQATA zXjvBeD?Zy@NUxV~2g+Yi^59o$#pUOi`1AqX@^j?bBpxupyoL?h#qamRXY$7or7_1jr#>_Zvcq;y3 zhcxUo_P|_j-^*RLt59BP@cDhGot`NNwK?h0Yt#fbYH(F=gw#&s-W-Ny^>Myp9x%b} z@zEF?75Rikr)5yw{i`lx-0ey${u(*zYzleKi{E02{t2T$(4yn1G#{F34;VlHp+-+% zC-B0e>!=SK^Xsfr6!*X^OjG2{>4#Y#R-ZmGMzYk*C0Yf}tW6ufkc@{j4s1c_vKQ(( z!eUDOAWl7LX3R7IF`hp9VH6pYfWzVYP@L0oLG=VW&;mDqdJbfBNLw-+Z-QNBXzOC6 zbDeh1k!Ppx?AOAD+XYZjkZ(fo$L6__VtL+VfecJxvT2Sg>CMN@XIq#?Ytp2Whc_RQ z-=h1%V~S|-W-b>g*_<=as`9qwN1GE~Rz>KwKqucS?8jKFp=TlI!8xT4sH{F7QxzGl zu`wuND&|leA-2qdL~69{u^qM_(_W@vIASq(v49OBtc;phPb!e^ zl}OA-pdl5%TMcE0)v}}D3J)(MeZf>LXrb1(_YNwGJETsTpx~iUq=pKL*R!s*ITrsj zad(``_4t?kv)pdlptGXa8vBj7{@5My^)cJ#ziExZ#GLYP z4aw?FiFZK2m(h%Hz&4Q-xSyM{;oG0zF5pJP%k2=xVdk18ANyAT%NzwFi9tPnIjD4v z)P?$Y{-v?5?~s9NuQ8LdS9R-=vZ@Kjv_%Gr*~+F@Dz~qMvS9uEkEccVf}|k4QMN$2 zJ6J@dan(2Av`|Usx#IEgJZa;GF7tWeWsPD#W9v*m=wzhZYp7A9x%Qz`BeS8@{f1I5 zu;&NltXNt4+wFGjruGJUGgdGhB1-1nBt^{*q!6#|p&DeNFTG6BlD`%Le7~0}doJ#6(uD`%{JKW>L_wz3AWU0>CZ@Ts%&zH+QJ7wvg=&P}2BY$+w zuI{nPicD@_i?WcxZOTv*EcI&`h^6#ke36IFy0iMilN6@sJZ3sB4{w1FBER?E2+9Ag zx_A>JbBj@GcA?8H9GmokyOqr%gXk;4#EF8$CDe9!78$2vax>6et8t7qR@k@q96!?X z;lcTw3X7;eR)*Qjym3tU_O6}7zI6{Ly$Px2SQIRm&(Z>RpdoYS=80n07^5Wl7#lIB zlg{P)%*Y1g)X*xf@6cxmFdmar;Vvr}@&v`6-TE2_F+*=|n~s&b11($0+}*Ls+WLV* zoJBGhCIKd>YlI0?dWWwOF?F@Ivjfq;glWh-pP7hTF&p85@07WfblvAmONCgYcq|4S z!{Bkf=(0ysD@UZ^oie8jJ2yfAp}Tyw^+_p;>ruC<7n{WtG|3#YOG>^9S#U@F)ciQLXn2x(~{ z-mqy%Kt5iJH%d|N)enXy#!F80FBK_sLR!E8rIps%rf-xRJIbGCwjErOr z1&8X_A6~jqjPKzDpUPa0K)c5=qB5l#O3}lYe6SX%#I?rO2Po?oti!KnwNC^F=WZLk z&oGS=nZx~ZqHMD{5DzKx1#zBs7`*x6l!oAmx~bfAGV|3sk%&>=qyeNklgIK(Ztz*4 zoT9LU`%9PzA0v3R6g=ZfpO1@|fxVDW&3=C=KSpvPa(hT7eVo7Dx|ys+XftbVYHcBK z79iLb(`wdVQ}+EX=2@DB!hDid)p1X=n;)iYN9dcv4fa2Pf|v$U*=}=FOrj4wgD9Z= zYFTiJ+a$p(TYE_eEy0u(oBfMf?7hF6%Rgz62UiUsh9zJ%x=n zu=GFZ^>6}^%rs@8(1-N2f2Cl?^4pt7``vujfz1TOd|h+2q?z1Ppa!W@_9!qg;I|d=(dFm8u;-(Cff8r)8@tBS z;W}S+oh8(-wsH=wB<4#XcOx!$@9}p=5EMCgan?U41(tJNdfrRzDfvQ>y;Jj^qxlt;eznKloz1(-*NF_IRMxZi#lesg^~hP>!LCND^-tOG;0Fk+p?G;k z0>Hb&g{b*e{lui}oG?T%l2GAc_k%Jh?yxeu_L`lvuyij7$iL_ln_kRQ?MC#u~RQ5CXgy=DJc zUW+34nsHnCc=+=V`ab;Zy^P(*v0!f%TFxIqv)REY zNa|(El_vQvVh&r&2yDVQyaF`hOh)X*&K=5=0+;_Nt!zYdH)Q zDs)X3VDGrLrf28Ch*o9Imev=pX3fsOBaomA*ND2y7V;uPJnB>UlRJS!1Q&H(cFFu_ zZ42D61&D;;lX*nF4L{nHKnrO-KRH9RJCQHA(*L2Y#`@#!7y7!j!q@EUp_GsTzCjvY zG8lAuUwfGC`}ed%QkzAl0oQVwlXzRmpc{VHl-G8hF469Kh?q-=3cI{?AI(6`P{IK1 z{Dg{aaMlI)pm5%3CI%q zgJ*9v6|d^T7bMx}VLMzvJH!@OYanWWOPQ z9iVVdM|Zoy&H~YG-TKEjK_c^>YG;X-*ib=ZG1at@L_~yNtYbQe4=MDVAudT8hVw+N^JyV*^4HY@=R*SBHcPLp@gUkGx^?yqTsiTH*4 zLLn*dfL}u{MHuuXnz{}t;fDLY`qAZy0#bCdhe!PzIbp;!*`P2Pncvy2fjsixNuwK_ zyHgS#Ag$V4Tm);fuQNRgkQX@}dD4CkjUQ$@{TaZK5Qk+d=EF%H8eioE`eC;anO6?q zCi%(V;lH`}vq6B7Tx=`KfV9G_rkIBP*LLCYX^6|QS)iw^DctAT3+aaP^;oJgUk~FCzIvPCGLEue>?H5W^2{YhCU6&NT2yAXzEIl-uT0 z%9F6qyt61oMYDiZv-Fi#9s5G!OxXq|-^nGOG1j8+AljjIy8HB+!oL75`dBV%ILzf{ z{!T>=rB8Jvfi2|K#uN6~*D`=SX0sRFqX>=5x2+0?ki<4g3y;1aeef7pbgWkO$&Mdg z64!6jEPfLS>eZ(r$=Whz^qck}Je3OPILHGluH_wL83O`#Vt6JY|CvjmTx!v0v!dYBAX&#k&D(F@9Sa|(gF|}k=%^$~^ z_c!6}RCXh>3=9MGBfhn?HXmygoy7>H1U3HHN{{=+?%CSkki|GD=Dfg@Zl#G2ge@-c zCrXk{th5@DxrB8V+E$r0vWD(J#;%%R^VxmxjW ztp}t|2Nc>_ZoG0dCP+_so6K#D>JeAKWT9D#>s_uJ^l=uv#Q=)@UR=x3 zpb3#VyR)m>%7j1;IV3>7QT1iW*2Db5`W4NU#OV7MepdQiPoZQ<3zcRdU~tbE1aA*> zJK3)JtL>^H#n5}&rrcBQrMIMo8H7Ea4y_zdI)+iNczX_VPsl8W?w!Y>yznzPXEsXD z{mG${b+c^Rd)05_2Dh^#D6a|4xT|0^S2O6)j>R84}aXir*hXTH25`56e#71W~0c_p>?2dGbkR z3OEDl__s;DB&OwTua$%ZG-qvYyp7zDV(IW$r`i>x_UI)E$^R-n7@yNIYS?k+jqLjl zP>=8qp(*NVmA4DF2LS4nIwiBu(ROKJejQ%V0;xPGsQ#8s#k*nr!$q>m$_0Ns*If(E zU`CMvPLrhhx4b*LSTp%U|cR<%-7*J=8GQL*|m>4m(GR=roCI z21M4|$0R;KmHs_D&^ge#u==i2RfPebT14GL`twJS7_D2O>T$H2LRK+Mwu=B=ZQX3_?rx*$nzGjD4HTVH*OY}HIEfBvT1Ann$_oKpVlzJtlHbAMS+tjFo z0t_|+k+3ic=1`DWoW}u`T}f8kX1m8H9>L7LybV6xO-`phy}%ihR#u$-NLRx73+ew-A$z9E8&RaLX-6LoCW zw6*lkbltAhDXM8#G%T%rWCiLQWHxnB#!DKaTSwDmHJPR)T&nhG$tYlsJ2x_nPUKkw z5nkC(uAJ)Z9|khBqEstvP3Z4m?p2;JSLKi=kK?~dOREp|VD9kHvU|;+?<+SpWjn(5 z<47I?Qx+y+??G}=fFBIso|FJzK^PL5^Y9-&1VT*d-;2#7~Y{Z5eI2AC9JVSFpUW^<}EnGVYO*eE9 z1cw0(Ex51hIr)KEjyfbUZIhW+t*X;;vZ^!vhJ=1jh@<_H#0eWXTuY#YU6t%)pcDRv zfMqgP{!GF7M22oQp3OgYzTQ^@F?t_p6D-tVwm)Kix@=lW=KI>a{$+aY+bvp%gTovDA;$D(;84Ko390>r5sV2)~e=bPgyuq^Uv;n@1+7 zvE5eov%IB>8~}#ntGVpt!smPf=XbfqF_33g6XmmHA!RIYoSsaTKvcF3)9174^M{B%zDFr z_`jY$%4BBiL6=o5TN~}1g=(c0>R%SHTx-NsknW+X@3s1$Z+u-*NkYY>J*pqawPUgA zQOT!mD>^@&^ePH{$DsX!4V*ydk+l&8C9Y7{}hI$JJV!SLc}eGLZu=v_}GqcV@JsOs@w!a|5oD zgUv7ANJq_hfkX2}@Sp&M@tVMuzPvjI+0TAqVm#X4-g(Wt1}Y>nAJt2WB}o$!PQD09 z!B$sg$>i4KlzG0}USX3rG<2#SRetlNIVC&l`&Y2-Iw*fSuXl%8u?HZDeNaGj*?aio zQ@f*YoXQHUH?t%{(Ey#p%_XGx(Y|LNv1Tr5u!kUL@WS40kC!M=(1R6QhpHmw<^sbw zO@k=Iw&C_*9(5ljaxz6Ke|htaMSl{Z{~6L z0~8KemNWl)_**1qE3nF6iXV9`x_+*O=Q&%Y9$)_P1n=|NT7W;r_r1(Gj&I2YpYG}+ z;x;m09PQ`PvSB}dQKg6zKYM=*pkrY!gaeIi%5Z?i|I4%8%47GQXpLVC;O#CioUZ0f ztR(Nn*Ut^lGun85EpcuU_7c_&c3$ne{!Lpt#;4b8l5xCDhU>=1zbiAn+9W^>GlH~0 z=R3YZ)^Gf)b9Cu9!t>eagXK;bK}<|^qp4RfcUg*(J-k{s9uss);oy5G9FqXVL`}Kl zf_3??l$&Qjad0sY<#O@?zC*T?@(*&7A<@IEiN2m=cX(Qp`_S$(1CqcNhLDhZH;4Vf ztg&YZUzloSF+jlbiU7|tGg>H_o5FMK@OwN;)ZWSbo$|J=yYXaB!5VMsz4bg1pjcen zca&)1zVKdw$3BG|;OUr`#R$Cvj%IJG4j=vo_Ox#UQHn;MZ=|A83?AvW4xoV+KYP9) z3brzSsiigZ`GCDtC<)*f;2TlzWmV4)&v&byW!L`TZfp={2YMIKs`DZaw~F-Et-#bn z!*MnRZW*flMZaxrDnH+Jr0u zNvqlWNJ!?@FAQwAJv|kkEjC(8D=9}o-$zRmBD)WV&?5t@=uoFOLG?g>0q%-YJwL6LVBw*h_h!3K~x_pC-{Pu~e79J0B2LkkFtcC(YX zOxk}2QUjCUP_D)m9dI$r4iPGn@?!^6?HU>q@la@+H`A9P#I)XgSi)Jj{J&yDGiA|Z z_Uyqvi&M8OPf5 zeKWP)*NDmkVor&Zjsn){N6;X&PlArM|3`6 z>0vAx-T6-aoN3=gKu>4IBD*!(?{j$LT=wV;1H7Dl&Ksbbjg*`b5Phq(q02h$bG7xt zYq;Ts6hDo8em%r(ly|d+J_JH6^>QoF;^(O+k-0XeuIU=v3fts1H&Wt(BsP@ixkdi= z#KWdQV}^N)VaBv(D;tHN?NODms_ry7L#+!hcmn|Vfc(-w9u{^2u|?`N6`(p+Je{az zD|V>nick{_1*tFbiO^5re5PII1m5}vp^H(84lG{Yc>{WSwUt99(usS|tw)cD?er~r zq1!E#UD?n~^Ur3ZK>qZlEn+>IQ`=6+{Sgzc2WLh`guLNA zvWLr+^!eDltUDh2m)gXXGod%wbXi{;4u5O)1A0_5czoxN{I;f{kbxwU zpJ+#P22Ovo9%&q?X~u(v0>)TFY_(SR*-jnl)Cl)ec%#qnSH+9;J+*2wp*tm`SV5AY zG)mj3=m?#k0F%qZx08wpWyMs$?!kcOx{v=R_kd;%nv(&E|5WPBJ9yObKK$$| zN4RP?wM`s|WQg)-uY7`6!$7VQ3pLn)eo4;V$~O6{2G;(HxozGp)%K^oLO0JxszLy6 zq4Us;7sC8r%B)2>b<2|Tf`cedAY9H#VWp&AS9))+Lg&R({G~}n;m>~b9;(?!+3ogK z1BYO)lta3{D+BuMlpWNd{(bHqrHZ?s-MQZ808D>P(%|7?>n0l z%y<4)?Rt3W*~xED6re?)XwusaEMZIez()n3z!l@#dne9aN7pxDewJHB^HM|lyu2lt zJ`VUgHDLd>yiD4v7#mtKRMuz2lO`)-d}PDte~!p5%0!&GNR1IqHN`| zpSCMzym0ZhQ5jZS7w?mY(u-$LziC@wRPav;FHMwH9e&olFArpUOMKuILz&;VHO{w)No#Kx7|LQwm7H2_RYK6Yk*xg`+M~Tpe=g<4BoXds~Y~E93pcu_2FO= zN3HPK3UZg*0yn|Bf*r0W#qIz}RHoBlK)8-={&*(_G&*Qu)nlRqiwJWRW&&jUc(Hg6KKIedB`y?iJ8j>@Tq(s!XHUy1X)9FZ8FC}Tu*pTJ1eA&rMY zCd?HY)KdtheX~V1=K1?)lGTV|6y z`?A4s5dF<;p)o@I-kNF_w&X_0Wv*+S0i9<{9Hg}_n%U>NP}k`6#h zE?Fykv=YzPcT21orT5IJJWnBrB90e^@j|zm6v!P#1dHIh(Nmc5jRv_Gt0c4wTF<%I zk~)e3JH39~*%8Tu0zxVWwiKo=ID*m0t)fT!?MXF}O7*f6_GI!$%h;H%Lz#n5_D6;B zl-~fWjOIoNVaxk%VZzrQ!G&`(mb!a`J{R%nLnd!mc(LgahlViP_R5?Sxn!lu$FGKC z%>hG_O4riFw(J_26N?QS%Zu*39Ov2F<1wkv*|J_?%nzpD69bbESva#k-z>hocbxYA zUs>xI{vwOmmVOEk6M6r(qfhjA_^Y&$d{GbX-u{r64CG;)?n#(uJc$~%nPj=rG#*1E zIVF6&_!Xu}$e)f)t^S5=Q-w)~QShI%|J;<;riB5yG=Npxz6!KdiIFq9FQB*sl=pum z3b)9rxp{3{^=k5H$AlC}yy{3=i;qJrlP6^-6)E}JO~?{N!_X4(D zscbUuL#?;i?0NQdC;FYBcN$f$!tKtFgUZ&2L9NuMZC;|eB65^@pPxHllF)!wZt5g~bKgO+ zj-R1@F*3*5k$MHncP>;NWb|sQ?~BrS1e?gRQvMtm!aGBe{LA-S{iUIv&<8A~^)}xZ z+NIMN`7>gplf^B?)cW;@Q?gu|dnDO!d_iym5vtrx&n`M)_V)DyugB&Kd(Ikzlo@mT zF5_!SG`MJKnZh{7R8+T=6qT_8>)m486i1m4cvWOwmgsWx0r)_>jcvg?EALF`!^3&b zgKCQ&ISR#Bk#6bkJ7n#~4Uu(HryGe^RIj$C>$5hk&9x{!eg5)vG$pii_Nafs<7%rA z&nlq}?(~$^_;9wN_)2TNUD9V^{4H_JZt>_&uWQeTG51DU?_X4+iJek1OVyr4bL0T% zYRCJ+J;1r`w$`^Jjv4e4Z?SWj1O#U$JfJuv3feAo(8SvSNFqkb2Q>JLh@VonG*Ek# zwUHSp+rx0ynmymlnHwB$=jESBvAN0)+9^OSkVIH0eSt}%WQ80D8QR^r#~ix?DzMj= z-28b%e$^)X>GTCJoG*Lq%vw(5pjPY_fhr$vG>1Lq8a*CJtc*&*|0=BbN;D@TT2vXU z?A}&qB%Je_^&U9(s^r|Z4NpQI&(Z#BlA4ndSgU}DE%a67Q_iHBs(ZrMGhdxB4*Vaa zy>(DrP4_N3gF^@mlAsd?x8N`g5ZqlOI0U!g5P}VXKyU~U9D+lH;O;g+a0oWI`y{w? zhxhy5-#zEnxmCC7@()$Bdw1_%Eo-f3t*1M5BEx*dn%-s0E*X6Y-s)Sq`Ux6b=aY%I z;83q;KgZKJ(54k-NxHJ+$IT1o`Y9bsYB>43ImGmzUSiD?bXbBzfBrK|1eZpN2*Lp0{8_L28q%e)UwqgW-4vjx%g%!)lTxK z?`(8KQBidM=VUfn<@_ynom`4$R%ui-tCTZluwmU7$|Vg5t|ZsFE0@s~bi z&!+$sF)-E@ESKF$u7u~F97ldWL%@lMPDzeS64r6A70C#o4XcJmYB)Q*tnjoZlH)HU z?%nJ01ORro{!n>2VR2g*0mFI+FdVkEnm<7lRc3SRtQ5KOK!{*nj3J%*gKe?-oS!Epsn+JH|u;bY0lgU++w_+cGtoy1}^!w}@w%8%|s4NdDJeLkH z-oGCM%SU-l(ND_}+Rc&yceo{{k3gV15a8#LMTlrWzsgZM&N$4ob*(JFKKr zdbq(WUOxTo@|%<@U0FE~i}g7|S&rJPK}e32biSwFLhNP>It!mU%o*I66Fmj5(yEO; znN}V%NQa1X!sGPjZr3Ce_X*_UTApt28j_N`-Cih3DrBee^$is4>WpoU;aJXn8N9(~ z^JDoIO58=|>_VsTTjixxF`U;B%x6=+ZOV&~Upq+`YEB zxP%8BuQUcD8a=~?FSt(@_HrL?{J70y%%cuGKKAy{lk~hSTR0>8{9Ww<-p@dc&3Tmmt$nt+XM(ije5p{Q>TZF|e866D@cWAW59zh+ zi8{}vruzZrIVVZWmB^~qFT39^$gBb{J#1=;mLe~MjwP>M*T+$W#mR1DQsN^iV@DP|jf+Car+SEe zH_Q3u*It98nQ~^VVx4}_G87pDf^GU|o;-8#6)jmf=?o3wE*RL2d*dYXX zk3bvpH7F6C;cZSHZfmlukzY(Y_83J<0-ItyE&-7ta1cV1JNTWBG=2UB39D{{p8o(x zt2-(u-SwcTkKr($+;0Y>LoGr!>j;0mbj5=aY_Er6W`A)?v{z*^7H4|AwO_Ix7$Ag%7>jOuClB$ zmrZ!tDo;i-|+QEY}x>|&|WY}(<3qAvUt1H%uE zqB0t$XC7N?D&SY^g#)6}-jdGB>{!VE_Qu}Q9*-gqcKxLOP=+D++G(yTSWR*svWZgU zxvYLn^@ZwZ&jTLU$1;JRJX>mhsaC_f6z{F7NH$Hlg(D6=_a8`gRkRy*J;%UUR2(}N*%dMoLVIgn0Hek0A=h8=odo%Mk3cmtQm}XLJi;MM3m;UcKCz45?edt^B=9Z@ zKG>JL@%8GUnCZ+9tzv5%(X8yd%imxk{fi2`eg6$NWjiHYDoQl#Zaw?f_Hq_qR*mIk zinUYSIQ)yvz^n8NPL3Pf;t9fho<1g~p5>>FF7-V@8upx_2>e(1TriwdviBOfymvgv zTHl@DCTZ4s&!3~G8+ttrqF%8QgfuPq5+xV3)i^FOX!Dq_QL*n?!n5!NHyB`Uol4Cx zdJLXt6!xRNuFHgW#Y<(t!w*D7#V1O}tlQ6i4L?tfmAn}U@vKlp%AVvk|W zU<-&&(((d7iw~j5=ZkVL?#2ft)le`0ei8Mac&t!IQb!)V@#7)rwHUzp8+3o*QdrI@ z&PzZokBap>;*xZqgh1Q>@VgnKdg!H}~tV)1Zvu&eRr5H%Us=}Hy-%^D7d$nrbC9&sXd*PKtT2&UnRGNsA>40gpVz3#`UgY!EfuU9zysW zlk==L#}06(l_iCC#6Am@D7VM`tIrIRER5yl(c@!}(9_eKSYU_fX=`hvtpTm)&!1QO z17*upxw*Obn1zW)!x^LHVr4A>rI%K{!{t?my!!}m+9QAgG(YG3eR7q)?aMJ{O>)4 zmfXrp+l>2H&Htuc-=tzuZ?WXLSRuM+ry9VV zh=>S`4i-%Bt;OhVGZQ0`*#Yr}Zle~{ynh7#zliA{5&fsv=}7Z~UCAci-X`>g1F03( zjUoYmbN}~m+yH|vFSA3%odZNrvI4mt^&%eoQU9yRAJym2SN@v|a6f4GvI6gNaP06p zzX|gxj#eKm`bV_JzGeRoEtD1%IyEe8R z*kB)WiF9ri+T=EkylFY?Ke3i9bf~T!>~nvv|HJoS?D^4F!q%y{vAz?>*HXdl@5 ze**d=lX^`F#By+czih&tea-sOiC6;FhDdsEAI$g5TlHszN62l|N|D@Uv;X z>o@b7^9`^&THQxYac5@0d`Bl1>a)GTrOr^4@e;a}_sJp|`hYGc!%Jk^!5C4}KGL zqP5!n445_mEcaDDw??*D^bM;D9|*SkH9P0k_?)#f9^Kt+8*f(4mhHwN7fx>wyCNr> z6mN!=yUnBh&?7$oz;|kyU%E=*!anp!7 zXcODoTlX_?e+2Q6+E2fB74FkA)r-#X+q78}u$r8CVzUYNFg$zW3GJ<2g>6yg)2~u| zclCNA;!@GE2YcIb)9R*La^XG|OFiM$WGImVlRUlZ-t|s7Lkzh}32D?7z2eEdMjGA> zY=|ok&?CJZYEaR}KQ}gv&)#4+d~J5fJT0vU(hoz2iT!^R9@Ji7jI?^51yy=a7v8XP z%%QA*F1%J4GB9t5&M-IG6YuA8b~=S7c20&~j|-sWXBR!lFyFqh`7t}U6mC|ZbrDRL-T^fnBupBTSc8DeuWB~fe&K;ve^~_AxxviQrne6z|X!1#Rh$Deio&D|;0so60@`CJFubLL4QX4sRik|A5I*aj4yFzXX ziv6z-GJ)2!6&u0nMMI`g$$*^3UE#67!Kz!(uBz|FjdtHK|Kru4m>Gzn35Ps^q?fO2 zy{{H9k9LJ61;Mx){AW-6=JrO`3p1-OzX?n7iYpEa67Q{57zG9*Pj3P(Vq16r*ooX_ zK|8ar=5L~jFBLfEr*n0lm-KgA|kFU(Z8PwZfvbI`VPFwWLeQK}vb5EELYGK_0=YGQD zfrl8Cw#MO+r)C#XkJ5MAyNT#x+^-@&t$Q`X!T>RSRNJTP7vstApp*f?CD>;1qQEb3 zm~X7Mz`RYypMd~H;RA`ap&)32RmBOrZ@^XV!lkMY_(J@!xzsy6UW z`N5~moVuG;a>80FdyC8k6(PI!F7wXD8VvPhJNsT723Te5V}=l*vFy z`c~IorAM1tShkIT=+oBDGI2yUVM|+!i{LoVV`Fz@4os?7E~seXzBnx&;!LSAA z*ANdVJnppnwn7}T8)v8bX;dEYvc46G0dYi~9+)Z++LB5sN#>ll{Z;ZWKv5or4Fy}Y zZq{CG)zUvR)JAz_`DZ2BG!P4*rX{faak7{LF$`3ZhH&dSEJ2ZjVpNJto6os_E-}l1 z`{umo`Ek_EOIpVZSm12(ZK>KcMq7;fbPWi^pSrtUDyzPkqdTTyrsZ=8Ty7ntTY{K7 zjQe5AnBE=rCJ#tz?!LLLaI{PSc5FW$Q8Urk(+u)$eS70CV6Jbzb~YtZpD>3L|31mv zL8q#>qZ%bS)t#8}OdHi`1TmI9&X3+h>ki_?NRd24bh1byZI-6#tCU-KAq}| zVQoaVtUB+PUycTy1Wkgyhs2z^jA75Xj$;)grfS97emIC)lZt_(BkrIjt#EYUc6fR#0TA9+I=kMb8$87>FFYPO$4C! zewG=k<|+Y4&&Ex<8GN$uWJLvv{Rz9{0d148SJ6bNHPgmCJt%_te~enReZ(8RbnaR? z<4B$*7r(63BTc1{oD)C*lR$$82@4{fIm#GLjT@04sV6RaY)<=~5#U?_$+BHvhPPyRAsxBV+@H095L00Mc}z zLw^|AJZ^~g)h4xfIhZIY6~^Vm_iIy)1YeT_;;2&OVK`b?W+4}8hPgn?J1Q-6i)gWY zKfyL^l(96jzAz+YY@8JBhMCej8rT$U0~dl&lEe9V9k?gg1Cj|}~9UW{4$ zTS|k8ye`zbJSge=uE-h(&B}7%Af#G;q1W z^!+}}$~b_LJ%k47Ec^DwHtSg6n*Op06PKNAfyh2r{A`8=s=N}cMP4wx8c6gAclgaN z=m`115TF<3plOYY$pM_8jkP#Bpk~IE*&>hphkCfUF!0uC z$JkLybfD6O$|(;@*wFP>_nVEyg@esoMVlYUy%yB@DANOi2A1T=0GL(4+Ucg#!p5m| zy2`t;?AaD!C(ja+5+}5S)dQ5dc0NpXrGk$nd6rrHGHub`M8>1~-XA*Fn*SgLeV&nk z@Zv+{$>;WciPggbR%zFs>AUhB3Io_KQx8*HP8=o|L&Z0E+9LQ^6MD|G@U7qiL1D+b z+p7hbCM{!L@J_$XBh7u^oo4g#Pk#=1#6ufh1C0`me~wnp)nCNL?k4>5B2yoi0wpSS?s&GWpG`M~z>SJ$f4%=!`aclGy|-sg50iJ}J0GaP zH>c(4DyACuSPGI~J{-hn{R(S@>uu8tnAen5x`1W30z8?D+0*D$?mRH5Mp3R zzHEp4)?y=z?AQ9=$kP+lX;@P>MZ@3KK}@CfiE6@S2P_1 zMfAg!GmvdlXlvIYqSM#MEb_*IRDl|UBv{wg-*1{=bg`jr_pP(kv4RIsK08@#5VU5I1qgMe&#?RW1GpXhz*o`Q7Sr^ct_=Z_<=S-K?=wksmP& zJ*kAaN?P|P7Msr7i@yWurT!4tv?|z-%O-8=v>2B8H2M;g2vH<;R8<3IzOaxKNIbo< zn4-6aN3&ra@U=(n?$mA@2D45^LYZb0-Mtb#o-!S`<`FGBiw4-7wfhkZmaj&^hY5DQxh3IeX42B$wD+M*d2O8RelRg=hk+zR$+fozRh=NpM>OrBf=RuelBykTrTQM3$LLV1>zZd!z(%)J<*&(3JtrGSg6OP&SuwR`pcM)pNoqb zY0VThOoeq*DS21y%q#Ry7&&n`hmC@Cf7G)Il<$ZS2#;0a4p?~YRd@iT$M6x z(i!Y&0&@Zq{s8fDEx7*NE*f-hOxAcS;X280#=67FG05}_U;|4utv&$-Y5nLUQjXBh z*r26rrMK4%84}WdtG)TCkh0rOQ)R-%rx<+-ZMLfr9{Lz%hS~KSDN7*@uQrxHF;;-* z5(3H{Z>vpxB$<#Cq9dv5>_Voh!EE%o6(y!@*><>lEK>r8Xv5Gmxcaq?0J9VN&O#Ji zTK91}GmTe@yVsY=NkC2^rBfPto{{^3^sU0l@QPkQLTo@fpkwMPdf5JTGTTklq?t1| zXpDQTHpjG=|B_dGTmI%LKEp&)PmNY1uP%9!vnq}6?F>3Z!E2W17Tv3dMvKuhIwQ}^ zUuIfo)+X6znZ`&4JTs5b1}tB5AuDI;;9N9Pcv52Twq^SHPeojP%vGG)wSca2(g$MR zJq4$JzFU^K>2vwz(yQPtUqpvE-L?3Mh9oZO%|4(;f-NPvB5Lu3R+1AR?;`Lj+~On# z2oiN%aIy#)IM4t$$ z@%8&3ZXVDTIPNxU>w+OnV6IK71Bb1Qf{k@z#i;`Q>Jr)H&wC!5s-mM#h*0*NG0Yk`n1R&2!!! zOc17EwgU#~TH`10FinuC)14s~;i-c4T=7LOopotJn14 zudwhE#bEcOW1XCYsARA<)$-rX+!@i0Ah+S!qha|2q%Eb%KtP(PiLFP63_aKC0VCpTZWk{?HMKrXQp`h9K%OH#QKpyZfw*W=jp& z!uNo}aoj|qZ^PBdJ1K|J$=7H8Mk)h^fRKs3t8NSl_rQFOl@Ym@HtxU9MYgGQNd1>s~vrTffwi` zX`zVV*599vU!O2ydd?#C2~T!X6L$p=kndq7)*9&iB{^M!3}>XAWR0jxgCR_gN) z?)mY2A-q+<@>PO$Bb#zA^Uv8UA?A1E9i3BLiR)LOX&IGG?-zw==^--7&vepC{`SC-58YSu4aB6NPy5P^ z>tAWSo(vcsGx?k4vZhWeR2f$fzS{UHJ1E=2+RxA3QCSX;t7&6-Zd@O)vl#zZl3j)> zS?Qrn&#`tjmNIeU7q$~h;n|NGDqr%&SbB!qx;blxOGniIwb0Z(1`~=JiK4&LByd42 zLzNSc48AaG(_n-hFfS#WhRFlyf1N)dTrG%52%*3uA#eJwO!gsIH0s>^GpuqRXviN) zi*QP@7SK~3UKi{4QzRIbFK&(cSnmiv`aVp^5hF$uZ-G$0POp{lAn{2B(A!nP9$R;m z4V8pgi1&+tze-7+DyHG3t`B*wiCG_=3zc<{=T!ve8m1_RcJ?#r6doGdIV@rM46O@f zDx+bn&z;wFCX|*Yl)Rm4`jzvvt#8Zab(mMpzLlJHGXUB#kH@am06cfsSF5(n z1VoJ?n4eQ^FzjV=VixWpnACMDf}Dn*v=7+DgX{@e%EEti6X8R!T+D5NuTvpbcu(K~ za&~0x_~$AV4>c@p*f8+y?pgyb3f!9nR!Qp+V**#%uc-*SMWGO;(WPBQcBco9;dUzSvk4zKgz(6 zJG<$ljpQQDUwsY}k8!kn@;x=@Kv zrHcxI!Nhcu-_~B?kX-RTi|q5GwB@7Z#$hqdOpg*>>=h@GcM}CK#BCW5&*wTZ~exW;Izd?xz&wjWoDxFzkVBtie56`(i zkrT8j23YnSeu_yv(u1R;ge5vrS)zd=HN!wc!rCLcOdQcx6Zeh}&4}T;VoAS1wXWBM zGxIU7d5HmxWU4@Wc*`&Rk2vI5MEpV7!*psX&JM&e>XQ(^#lmU|DJ2h@FY1#8rOTvZ zse&q04i(XLD7!_!s`fa$NTXU^}Y0>%;AQfNn+0XGlw8k%-s^xtsVG&hibh?KT$;21yiGi=uN zqk_EuIlBUn@i$K%Xzndj1BN2Ez?--Q3mY-UC|*pOxXmg5_+xLFk{?@7fD+BlQfhP| zWG_2eaEN}9J>rNmOb|{0lAnKMQWId$nlUu+@hn;S2b=2vCdZAEtaVS`ZAE1GO3e&x z1NfcLUWqP^&weC}CiBrOKk{81_=iO=)6SnicPTy(-K7du`TF-tc$r_l3XZ#3Eu#|- zjQ;Duv7(h+)5AyfCutZ)gN}y#hh4;)(!FVjGIacG7-oHvIs3TMLd|6<;8TO!fD6{- z(q2Hn1{88R@H6iNV~W1dwdt08-#LcS%0+iyr19%989k5JY;%jQ@eI>^8I0PVI|gG! zz~X71tM-`J-sN|I4o97+h-YPB7>k2hCy0eePGp}sZL>K?H`+8Cq$rFjAVn7!m74V( z@t0Tk(WWp$-rkP(0CrJ+R>ey4mhk!`b#6Fi?69rz=A*{CTCd_=Gehkj~zKg7v~H)20At?@sra1b5%x`8f#b|TR$0k3B`wp;^tREkDS&>44v;-c5<+aen9OD$ zjW%!2^MM_$G~?|}b#7Iuz8;bFeyuD;H)}M6ai7Lxd&L6k$C~(TW!#!he}Wf;cZtDj zB_fm@Af9AG(wRvJ7CKK%k&eRoXZ=hrZG zGjA&Rcetuf)#-itWbpf^v{Kq=cHHNdBY>{wPiVa}U^&o(irm)`0F7asa>tm&xODpv z9E}|!`EFDJMM#^hH;XB?1t|K^=SZXVv~^p=yf8SRWc%eVU^xgY<6+VfP>Zt)t?6jt ze_nge-@F~a#JM>M;?ai3k=K99tz;&bV%z50EugFp(7ud5hjgO0ikgMYUib4~e>#{$ zEodt9_20FL>)5HtWwe4JnSKiuXG(4u4!ZU;^{VXJzK?30)tHK!bnCK^r*x|K{rALl z_tE?S*J9aJ-g#l?-g6aI4h&S=!e-!Etm5R9Qu?8)EOv=F3r$&^-s-5U%FijCX?eN) zKLEV~ej5u#FiMt@RxL-D9Dy20^3Tnu4c8J`!?76GK)$0W!Opp#r199iuj{4ggTkL_`o?BL{|dD{ zskbGvhDEq6Rz_VlD@qO<%WAKS7`Yd4pj&gQ#xZ^P@UtXg(6C5kPiXm}igg+yA`gAr4z z++1zMtVRfw#}H z>Ri-ck29%}&X~}?5FDLmn{nr;;BYQ;=kmK}N4*)p4Ku|71HYJ9vOF)J-{$j&e=ZCR ziH4xFi@xGXCNSK$T)4Tg&0pemp16*EuSSuh?Ft|*uD9UY7I&aL&jcc1D#T05FB?E z>`ux&rURfCb=t@A&Q46sbjc3yz#`qiz*DyPb)sX7R#HOf@<;Iu{Kf`@D(M}~%mDfl zR%4F!eoppZR1~XWw6Dr~FQ^_rPO4j{*pquTnJnrr0b#-*k)*b8oFUp>c0U;pC4fXl zbXbr&N(^1NT!>WKBcCMOCn4P}fBc&ZfL~oD+48{~N8JmOaGY^&T3ohVlBpPm?`x;WrXeJ{j>U%nsD z@MIRZ%g{;)O%~rwj9&U!SrK4D*VEr z1XAl_tvKf7kS^(cQT)f0ayME2Wr4H~I(sQsa3uw_!5Gu|3^|d*WruJ{Bo!f z(BWL@6+tWy5vV4ldEU}t7GxLW5@mjAaVb8{7f5xZwkgmnq=rF@~>#9Qm*LGxAf7JaZV;i2YQDZ zvtDtK`73z=FN;K9{Qh+)*{LQ41i~P>nAZ|3jaDgAhH})%dlPP80qa7B?m5bTI1{3xCHnx|@{9!z((&Y%b(UvZRH>DF&u? zTlUjU_Xg!KddO2%Io6irTU+nwarRZr4{!d(5U1tfamg=n(|wT?)v2g^HG;0on||>4zXODC1#vR&<85DEJ#D zE2mB_v1^>FTn_44OLlQBSrWqpZV0S1pM{3Vk2V^?xufIq!P5opoK*?M%irFjEkLI* zA6cES8XU357#;JcsX!xhL>c4sBVl(Qh6vdZ-|QI(K0|pM3bmoj$p~fF^9rI zG#GLTXN2&PSi*u*%563OI{IyOX6$Wmuj4OB+|6Giv?}O(Dc8ZcFhYk{pG;VY(1-Z! zq2a#(Sp)3_o7b)SJppiP!UJ+p%|qWQBX$L5>XB9ocwo-Mqs8^;{aWp8Y{RxnY)DsW zk8*=YK3Z|9GZYl%A4VhJ7)59+S98F5-+;r*)L0*i2Ra5BdGm|$wqv5F?ODh~$a8I} z>Bj)`dqFTk)MRLA>=Z+cpwxvIPept>L`-<*O8R#BfY|__B75$F2_`Jk&of(XYOSmU z4*~sm!^gBcfMh@6&Rfm-Zq>70Gd76pWawZ#GJYb};`4VE{RNN|-dxuKA9s($EfRS% z(CsU1luqhIfo-EZ=FGAKE}d;IGEwhv91Dd$e<%|;Q-CX+aF_7a%j}OoIpkWmHrD8# z@{;W+nU+{n8k!Tmm1rx6PXCM6ZlkcHvpS?8dEJ(0FD79wLk5bLh-Rx^?l5qV8f9ws zH_A73$CSBLn`ht0 zn@*=VUN6@l>_1HkTa0 zl!(zW>5NLl1YvB=sqJ?$eM4toOHlg%@H<8-{p`yJ1AE9C2b-~Vp7GKo*u;JZ{7NGg> z-v%$qh?(DMeAXq4B{U?p>JCi}#qEDeODNxdP_u{~HEVJ8j{DtM z4VBRKR`$4eQdK_MksCl<{F*Mmmhp77*DBF|au6B748(poFI*N2jlPl~0QD45Iu*P9 zm=~K?pyBX9Ctv=DSCFz*&HFHeO)93i5j}0Wh@TP}hu6dvgUsl*7hS#_x?zd_r6_v% z-okz)v74J8gOzZEc!`;P6-2>h6G*l`(WbAeD90d6O@KG9tY_cq7(y*kWvFNHjg8(w zE5qgYlyiN9z{J|&2P;^DrL|-X#~1dKASI+CL5V4{0Y?Y%py+K4JkFNGdj3j=LwAy1 zl#N_e83WIidqofnXUxuwS2;}@Kp*oK7?VHT5yYPD3g?74X3wJO{Kv`p)XWhWjnWO~ z2kO8BhHp-90&tGx*o#!{5Z?I_op!$nYj|(?T%vBQ+;#+zV?Wnt$Gq6kyn(FOM|J{^ zn&;eq7>}tj@$3vs+e`4!sLXi9m*%&)9)!8@vV>_0NAmb&QX%YOOu&j3ozyF= zj^;6oIYNv+N_w1hOYn!+h znwsFHKmcNNi<=G6Y(_5X%{3V~)6D=nJ{YPRb*-L2tn7*>=>1xTd#&wg0Q89cDblF1 zV(Jjd@{_4+!Th#ym=qr2jVFTGc*h{{Vjb5LHL;R_81EcW_1q?8ey{f&Xl{{s%U~zV z{65B&;6|$CPC;~3{~L75-OQe;^mxvwvpN|cr5y=3GZJo`T#mjBj_BRaN zVdI7!0E30PJ*D*nyd&3JbYKbYy6fU3E2ZS?a&UVxAbs;y0~`;H68SMRDY|t}nZw91Z65JF0gw)nWXuv$VtFkCtXOC?bTc>x-exs8 zHm4_1u6MEUgILLl%p?ntZSOw}>AUp?zmz~b478K;oj&h_yG(ncvi-z1M4ZiD>1C#} zwTs9bzCoW_4S3)>raa4?q2&0>^Yhoze7T`G0&-l)_6x-!ljU0q8&~M|&9!J$3xLA) z*#!xl!s#8y5@27g;blNXBtGTC-VlqZ9m3zpj+J%sSyhwAX_FBhPgyBQWB+GB|b zu?g{rhjo;sv%|q=a)S8K5NV9Fr_gZUG*KXFb&^I(6-218%$!~cV=FPE(5Xel~_fJ6h*DFd~Jben^ZA|bU;}G)!Ba1NzyQ`e5N{9jg zlaocGVWVYezNC>Wwuq(sv&bGI!m#L~CPCM8wP8ZYK7Ei)RdVlkW@Zr-$8Cdn@n+y_2Jx{Q%< zeROIgd7knyv8Tu7`?bP79igKTGejIQ>}ChftI}+rdk1$}*l>CiH9HF-+LWF!ojYq| zXCM!syRsc3O`be-#K6EfvPP%h=$L#;J06JM>2bq{hnJgJa}ZfY6!g~IcK$l z23ZBzt6vVt5qQzU*sYKN8OdC?_!!}lF4mP|Bc*5yuedTv7jHwvcP0vo&6-ICLY zQ*7MnzYd9{kI=psI^yV?H{bvoqeDnBr>FZIbhkNy;`I| z@PP^mGxo0w(PdUk-^7%G=o>Y2n<|$iIU}Cgt-qaF{4+LW6-txTr$3*MleGA$2VV6K z(Du%b><1%(1r_TU5ODfo>LIJKsFczg$(KWSo)7Fp+zeqZX{%Ac1T6wPI;syuyYW3o z;gR<~)P^iv2PRqX>=W=2e)AzZYgOVzSM5?JTAiV%3=<$99?B<@f6Dld5lax}?y7>> z;ldwAz=V1F=X*L!gyqn0qUC2B7dU8MdyFby+J5yPAU*i4@^F~$l@h}Ta{@>e0$&d^ zgex-qYnK8B!d7en6E0p#RRbzh$R~rq+rPmEFJg)vb5Ug?0af5#5VNM`Xm}+BSj#+) zvjYn+`D*0!gh=E3GfHN%Pm8_4q(734_xTtm*NTP@;L2<*&+QCbiH3a{FG zpBVJX73hoG77+=t^5Vs`C>N8fcB2bKQy`)bYRkc`#Gf4^MnBioPU~I@Zs*g(LG3-^ z%@#Vc6YlfVN1L@7FQ*?HxwAL`oKu$`S;u`*Fo!1S{KM{Xgh4VY-uXMqQI&|xO2N!# zWgfV{s1lw2I~f;FWY+CZ49LHwlY&>J{!;NO^jq|wQxmYAe64%)=fxhh0vNKjGbUcR zo3dRFiRtZTMtVaHk#QQH-KAquXfNm8Nmg^;aAeKn^cXsI6@A<`MZ-f$_E)BKf(z)x zAU50=V$*l4I;o^!JW8vZVrY)jeUw(Go1+O0E5Dk5eMR@v@JkXw`avCjqKX2HB>A$7>H1K^qEPy4NUm#0ySq7PG7#-c;}yR>w2o{0O? zd(q%(yr_#3Oy0w~bOg|khG=gKX--!a9PL>``Q?@>rGSPKWt`$VS+z$LcMuETT-rSH zkLblf@8!mQI~}^0;0jBaEZ$Ce0I{qvCT}}Qsw#6ub`o!!zVySoR>;Hsm+VxAkM_bW zuyEfr=+Dg%Xyg_bC-mSAy=RGk!H*TnUVem}>yx}w-V`cGjOdRuvJUYt3 zhc9QJS7Y6-JyhO9`61EUie?0&S%FdE0>pYzMuK-%raN5 zZ?G;N%A4+)n*{OQ z?B%=R%ar<|vnK%=6@EKo(Hp1DxIWD@cy|85qJk8E%BZa;`$l)PLMz{Qgb8#b^j{J%JR z%c!`PtzEbqNN6ND!QBZKAh-t)4oPqc7TjGENPyt(?m>e)1cFLT2-^EX3ct@IVan6Z7Ld$Arw@@d3a?mJtnHJQOAhhy{1=Y(W=xKEqQ_%%Qq;BG z?n^IT<+uGP%{HMGcByF%hY!()ZMcWB)0KH5%D$cS$cIf%?cX1rr;+fPN;EInrKfzY zPs!^3%GvcDa6f86eTc@4Kn`Q(1OPO|4Aw}J$`B1yM;Tx{}vKGgc(ir1GaKb;hjdx*Z zgSk>V-MTm(u>8^vmAFrQ_UDWjGbg`ZSbGuAv~X<9h3D}i!Yh0NN8=?c3d5R4x`STw zIU-~>{kS`vR|3diS?&uYEUgjMhRrACTkC#EC@RD|@z`h{#HLv@7wK#^PZ*^&y1ogH zc^Mt6+t~}wS&2_`ojP5A88rFe^s&h*Rf1lDUibQ#SAo|Bpk_ZKzfmwLnK!4MQkZ{< z@-=}Plt9GgAYnfWD*y8R13F}D7ZZ2+nIAEyw)g&Mbq+jkzLP0Rf1Ksy+8tSIMi1&Y z63@GQDtIQtg-KlEd`k3PmnG*B5@%$BR2dsPZu0wn(fHCaz|N(PKO(MpLd)a-QT3yDoY5!!EKquQTB)3eV|iC46(qLND^em8rcAgY%jfpWJ74N?uSt8bN#5saT1Qn zqY;Ol{p6YPqtB`#7wFM4H%_kTpIZ44BIh&|z>!0wZJDREkxs(6Ji-1V004a_F)OL+~$Tq@r>e1r1ze|=uPC{DH!nY21( zLgZc`;rlhkAToQ7O-CGqe1V7n|GD=H{%qC9v4;tTjdrM{R#;yrGju-00_8k_SztpV zKo@oz#!niQh6-N#IM`{@ew;PgBN_JTK3uzDu}09w@Eg2MCYH`5&`dU}-w2vrLkq7? zt&iPXUgWfaWu`GM`ShpX;hFWCeu(pZOhiM7zv z)Bsau%_x4vHxJ#UcxLAw)h{bg_1iI9)^xX}<*nUE!4^#Emryh51L859FK52H8F(0HFLt=jF&A2Odk)(fK0Zh;OBsQsm^8N|p{ zN0UlUO6x2V7S2gnYxaB=oO0L=L8=r6nFO{Y4Q>8|1=z-Rv!r8VD^D}n!xcd_n9*9WJj_ zB`}udh+fd0&1aW+?+$bpdk+i7S`azhW7shmKGvtNEDw6RQ7ME}!MAL0_p0MOdMF<7Shb@n1P+$+oD${^IZ^4nn(R$-?iuuuevHjU)S9ilZ6U zOlecA-le+D^NrkF-eL})6PsO6^$30NtwP#}*}o941Iu5vUN14@(QxZuFH=uaEk8%Y zNNaHBH6*(9wSDVW@8+?I!{KrJ2C>ICLEPq8SlLaS25YV7>QF0a6rO4HTnl><9e;Z4 zqfQ2UzgI>O*52EAEXU|(Zx?nrF%j}DQ(m9avuC$m$sF9J37s=mjz23XE3NNEU9{oO zC+US-6xt;}Dl5%DzN|e4#$4R^7 zLP&cK9(qfOSWnxO?i;>zfo0GwZZMr_A*~m7zqJA%aX)FC;MZFGwg+K9cVVu~7XYNd zA~*{uQdV4MP4C#lL_r3;x80~#8?qd+?9Q+VHhoX+K65K&k8fL&Hb}guj zV;sckY1iJ8r4oVlExPyHol3eLg91tlO^(uW{LyHreeub=mE6u2eEh@ZwaPO7WkVNAFgj!34@-xfuNR!*4 zmy&>y0^_wvkr5PR{z(BlW~x_wT*AwR{iWU;f9Rk%BY+gzqH{>)MQnS%K{{DU9*vjs>{f>N1m#s!I{K6}ypIa%1u}t1-TtiU%x?Aat(2x( z1?cA;uus+EAg4wS-azGvEqy~2QF?N?h2{>fX+H?pEp=3O5TVzPvP2Qt^drLQ3B2wP zRKf;qY-9{F#zarR{;w}qcA!T(IOGB@CrR-7fI><{EV}s;I7*QIqMAkX!N`~IPU<3E zc!!dYS5K%?o*e&PRk7^3FGhxx`1&XQ7yC)r8ohQW!V_pcZr764@Mhu`*CSb$!PlRN z$#%XSL{&sH{A4Yzy8$&jvN#b3<*ZJFA-r5ZFga>kTmatQ^0tmd#Ww0XdPn4x zav}gJNEwEJVQ&TcAop|KN1q}(6dWUt9%UYT60zCUP%tuwMy~=>%(}zhz%WCC(kw^R z#2o~N0ZX|w-mfIWsUBB)1b07LO8m;WEp3=RoSMIswlFFl4@(N zhLnJS@s8u>T0V*}!W1UD?32{2h~&joNbrQM{qD-^YspizqZII|T!rK*7ow)h zwVSiA;u8`7jrssnRp6}h8~>1oCCnTv6XFk?k%f^`_9oQ7@=wbturU`2Y#ws{2812lm3h^F`=KMCCh}yX0p2r6eU-cEK9HrmkoYxxBr1)# zsw@J<1*38SJwtKpYpw#ok_G};k>_b_6B62;zp$t~skeNS(az`bw5ZE4&pNDm?2(!#;m@m63T@~j_?sP^)cjuc9;YvFAS~gYGKmvH zM`Y6`xB^L>zcyvMIdb*5zBRLL3^$0w&VJ3YaAS2m?ko0 z<(VhTN{=tET32$1m>jQCtoX7m{j2hMC|pOBE>NU|MuG-Jg6xwP)i>7GP!`#v4M_rs zSf7jqLOpj8F&ND>5=Rc;QL&*|Su34PyFCzw9n>H79T(fwfaw&jUCosBEY)@VW+Qkg zb&`oV2kS`0w7LsTXj9-b=J1wENY>5gO_Y=>N5^lGH;NlcLv;6s9CldIT?ZtHh{6go z&%dbtD(j~{|7f+Sgm<|qRafwr4dy4FNI3eG%nHPaL-(uHXxA%$*2uR!LdeY!!Cmq& zS(SlgaZNcPy>s(d+z1$OlrLux0#U&fGnh`w?p(N0WE62o7}u>*A2hEJ{Nwj{R~+$` z%6X6=-@KI1NP62uuNCxclP(X?=T&*a+b?8O1PAfRV}d*|m(BF#pbI${napm838lq0*@6jR<=5Q+BU8 ze4WWfM6G2*9VVADu?c|Ogb(FG2Kb_rh69#}^b=$}n~S@PiLd6|Sw4=mw@)A{*l%=I zH(9EVA_L)Rc3(^3{CEvh$5_$d$hdl;0=GQmWrCeu+Hd$RNp0B1D+@F2>8j zJB6EXR2O|(ek@oBAR~8uX+Y9x?iz6Nk`)O-kKXiPA3u$Mjuhx^UQ>M~4hu=&7*&gE zCJ(_$X|Rkg^1x@20`m%y=b%&hMg;O_ecx2A2nl>?iIh?P&1xkdzjH%I820AfG3#wS zMpY2$H?Q|;F8X@Vi%aDX#-3!P052lZd=2wRl3JhLc;_}Cl0kBcE7DE5n&c{MQ&Pop z)MZV5a|iBbQx&=R2N}C#mpxt;c-M$`VeEj6%c1^cCQ9-(B^THlBy&S6!h1c@Cc2o; z6mO{SadS7BC_8aM>-6jnJqo(I|w^r3?XDe)++U+9&beR#1KGX9FcBY%NS9^ZQY&_ z1)P*k9u!+jI|0Dw1R~yK6&T;sL=;w7Ot`I{Pj%#c*l0<-(?p+7J4y~_o+=eb9x)?# zit=2qg($zwG02_EBHrQIv+cT6;Qzgl6=Uy$qMsJHZ1Zb=(B$EJnHk+**$tvD_VOp8 z4n@O66CeJxa6+_Q3^y;0D;0xqzjF8k+;^YTq)*=2CQS){^^(xF z=&5g6rf&pi?1wRXcK=6uVQ{QsL?to;ZQo&JbaqSIm|mqSE5da1nEGcQ)cwVz=y2jiuAzL zRjl6eyG*^8+J8iOvTixKpC!7Jv8RH^dkO4>WDe<)SUL;n7cmbB5Fw^h?tyoLFJoww z1Cqmu^ z&2&sm_F%jNZ_Zy=RF5>_a(m7x(s|M1`#6_|J%>mQ0kzt5`{Rz3sj4cgLZL8Z;3R%z zc%ARcHv;Kn6)r40{h&-yc;HuiweRG8*Z{^ImvnERVEdJ=&wy_8U@qm_C7|FClykdo zs{l3-R7;}E+7N#rTYwlk;v(2z+f%1}~*? z9zwz`o~!fQy`%5pgWuWr2~I<(hQg_-y;+Yl(IK6flnOLiHa$h5--L}rorG5Pr+7C| zmuQ%r8W1q9hlo>12i{>x$-gAoaWrq|^qk6B?@0VH;Fv;*MMK?(5`^}_Gfqu!b)_q) zqkc)fJBVe7EGChc=hKa`SM#*sgKxrN-$Ogfib24=w-4$hnN$uI&5N-P&@6=`Ul4?P z8r;d1R}}rF1alG~4R+eeQwNK;+seH|)ClL3?RvE$8RIXLb77bPc15+|{riAH$!IHY z?i-ricF>naOCUUnkt!FvtEnx^gXN}921j|OO_|(m22e{x`s)m_weVj1m7|f8*mXbi zYVriQ3?)^*Y+-&2{ibd2Qz9sZ5&U_4sjKs3#LZU@A*yp@tX)Eqxu-a}tw~52%dSy| zxPi3kE2BG9;+d7`l+!y?b}-T(L6qrhx9zYX874UlFa}(NSMsf=KS_L5Ra3CtY>!7g z!A%uETZBcBE&BBfcZq`he%RajWH?A@u=JcR>uvFaY2-1aOrT*86q`xNMF?a)L ztdkrDZpyk~3T4+du6E|PA`pxywRq2cF84fMw_L`0v;|DJ#|0L0L{X2i3nseZinYx# z;AY;#vb+$Z&k!XpQp;@hgSj-Cyz*g88Z>(dV9U4xXE+!d{ zSfjk-!F&B+sRm*GR7Y7JhZ|#_70+%yH2d*jERdv{IJQ}ft(cty#CAH1mwk0NV5;zUaShEB&K z_rXLUoChIro^l(7$gpX8EJ^{o@c5SJEg6F{HtSmvX>*w{3IQ%(auF3vO@ML}#@>S` z4J9z>TTCztIN{_ z^4GSx=Ub*WzzS}aGrvA2QO;8pk>?gSG zlhUEw_7u{?dxiO#YQ4-c(!s#P6dppey>C3jG9e@rS{wrJ4@Hi_Ny`FG3G$)t2!O{J zvC+iq3#5kIA4rHtb>5AA{c~<(x>Gs$B$e+&6Rjv-bj!#~?5oyLnHhtB*-WaHMLm8C zUW!J6NN8bUU{OdX$@-!z>xd(K-ictb4;ftVNO;O7Z435A7S7FC-{o{n+QI9u=wi&U z>5LogIRL#D&;8WD)e%`5MgJm06>|CBaC%Jdu`j}aadcknu4K;n54yIysOS+(wbb-? zYNIX{!eP-tO_!D(*FJ_NJ|Ct%>_fNGBMD;&Ba6{F5oVV;;ugLQI*r%vKATx0MFt?$ z^sU$;5KDwF%iNiIvP2QcxRd?&r}bOH@CN`QcVh<0hd3TRam#{TIgGP>mi5xUPC-1w zA)KKcKrQdS0xx0f_$j48fGtgcvm_syG4UEtkt;!@bJz#rtzy^PAt z$8yKTFf>!Je^-$n&8G^90u*0t96DaE$oD9g`QpQiu6X&(N=j)Se;X@geIWeGP9h2B63ZSxf(gnO57_u4p1ygrlXoeT9gX;KF1SM;Y~ z|G5%DBi>5iZv;G6CbTLn0F-F`TWk7G?LIA-qI=6H;Q9qB;LhlHo_txsoco<3x|DdQ zonU$Y((`L%YAZ&Oo`%z<>|LHAoZWA&1L;kazHB)9>G%65p8{Qn@F-q$VW@ax!--{S zx|y9O5Di+ti5i4*4;G&vz zp8ai*{Vb>kIQPyMkv&&b(^UnWTDxG~Zsa4!a^_dLLGP#Np< z7zs3gJ1bx}5X6Tt3;y+()rm~~u-<(K{n0M-vlj_PNJd^0%NZDPVN z7%r&yd^X?d+z#v;n$+|8Q`|h@4cXFV)d)0*@blqwKvKXFt)187LKnPk*BKjX#;~3y zY*SYELN*H>6L>Pnm8>m1b_+;lij)i#loae+t3<%+P<&Uh<_tU50{bs<5%{=}@ir>& zNiNv?mFx`O%YAG~0%Zx2Yrmg~JxxoVJ|%4uzcQV z#rXRAP#nFFubYukC!JN}?Z~zNR(IqbbRsz@DHPb3SxGbZopr^-UHz|=zFr`6$j^H2TH>D4|@D&|`|JlnU>n&{Y>rMQ-7(=i>WAR_0e}hJ_ zKaX6yyOw`D$Kpl&QVeA-X0;Wh*(9zWI4a3nG=i9V1~}FI`2Gq0J7>kYI;}`TDBCNe zPjCGmBVwafV}wcaj=L2G8K!D*He~>~L#-@FwVFwKX`jS{O{MOJhgd6Iq!`C3qgN+8 zm|r5cqct2Ml|!}>(G{WJeFM=U@|0qPQd~0&iMF1q*nQyx5HLlghrj`p^pJ|999>*n zpCh4ADoKSnS6phP3O~UX3Wt7GqF1K0m+%`X1xir(e)3FAoMpJw1~K7M#K)rI?F@Rd zUo=7w9t{sV$P3iUBZALprufZoGIIDGrQf&ecVi&`W36)u9bpqj7z4)#*|U_npn{hm z1E`(89tRSpnGn+;F2;4_a+9fVnI#7>bXtPo9J40Jv_TXT>RD`KGIKfMnERSiQ+D&F zJ0&MQdh4wmL~syrlz9IbSBGh-TnlzKpy;52D7T6ec~0CYU)Mnt63e}LBO-Rv!@XM{ zP+V6^i#`6i5~agP^ub!_lihqdm#0h6Euvp&=2yxAnQ-Txkf*uInEsh?F@XsEk1&R6 z79Dzujm=c`Rl?CPHQpXr2PM{d>KyZDjm(qs(QG)=*oV`1mRnPT*k8QZ{1Sr`4;a@) zG~3?BM{=mB1YU2cg5#iZtN2*}e`1H5SsZ7LA8ej;l2T!A_y*uScWzE;0bvG2MshnF z=W5nCtOnS9NEY{x`tfw2WRZcE$<81QzzE5T#7S4CeGsN7q$(WES>;pX+LE0FHJ*-n zD!#{>gb>5q=6WfhzNg{Zyx#R*k zAT2d&T9o1$HMt2Zcy)a*{?n=4YW=Qu#(m;^tYZ(1n@hT=ojfI;^>ieAEw*NuOZX9n zy5+eJ1?6(p%(8R+5{S&mr1Pg#m#s$o{N2!hB< zRkOl^jwV5{n0a{Aa)_pPSCN%CC+aYxADo@$G3p8w&4TBX?@#J&r=?3HUoo}QB4@G| zY(N-ECIB7!3za~KJ-L;sXuC}KZMGrGj}FW}JIv6J4n!Nzu-Wi$*vggu!QJ%(F;~p1 z{Ht$ljX=vc#;~xrJTraQ@W@Xa?^j}69#yVf38yo}2W8O7aw;sVgaE$6hL9fZVqIBN zZc{H)cV|jL=?;)&5VzsQ=FttUklCg`UjR<#+VI}j?z>yFl zIYKEYCD6LRYQM`bV6ak9s)5f#$kV;#9^tKU#F~K@w=bm`a_#+etAq(z%f+f5A5#fdK2Hx#G1n=r`+cP1z_7B389J%MZ z+Q=GLd!Jth>;MbKfZRAT6NA%~R3NF-)>~MpV*Ng#ONesB)vXLj1bL=JgPKxyO2^jr ziOmeN#Jn=Lo^4sxA4oIzAE`=VLli=#vM6}Dv-rU=+WDE4@r{rlg1GqrVHI1X=aOA@ z)m}SBmkT)|OCcj!%|O*PN`?I^3uOd)+e?2uuc{JQU9D8ZCYJ{K{R-y{**n3Y`D{h8 zr;x7sPo0P=H7L9NLd~0uh*!%k;jf+(0QL?=l~`O_3r-+E`jtJko|kmO z<=Wr^)U)y&L4@7hs^&)@hBYR;xcZ0mWNT=mb3fREM6d1WqjNPb-cb(bqld`s@lKte zBor_e3IUkBB~K#nThUfDU`w7o#DU4JmDqbt3q>2leerW+&OGvZ@VN6L~ zE9wP~u5deM$mmE-SGhZnOIomH1XbiOljFMfR4SVP9A@VU`4v`J@PLdRt@@ZsO?cSi zM2=yVDw8M>kA~c3CIqH9fS2hue@8jQDgm9q%~T8rToOD^-rl}DY%IXZH^N$)L!@8T zk<+?bP)1a}Gu1MpGjOhB^?WRMyibLEuSN3#7zVt6x5v~bmDscEVj@YDw0gTvrumvm zQets1KX97TU#Bzva*QPZUNG{V$e^S9>FB5Hj1kiNq){&x#wR*f_u-m~r+yzFtt+iv z70)0_KX%mqR>{fE7uv&>K?BpR^hoDbaS}oj=zyjq;^^087Y~RIuswQDOile!K_wJ! z*w=96jQ%ZccnOQ_kqYwaqsvbWuP)My$|IwTl8B8)-mEYm_B#q=YSi2AprO3OYitcD z+Scnf{Dn6sDAL@nqak7s5Kf6Ou)Jy)itr}X`h*HH=@_U0F1d2W6SfcWLczYY!pMk7aZO zwgtG^7NTKz&ryv#k^@i6>U{4o?2nmpnhZ|ARFszA_USgw;=r8*cq8YzzU8k$?fXSi zJOML!dbh8oniv_aq=oK}Q7(4R)def&;Kcyt`*-Mxu&GMMEjB`Zk`n-0%{a(>8;nP@ z_j6LPXr@~JPEV?Jd3tHNgD440`St4;KG=^)wOwk~e8_IigJtFg%2Tc{e-OKZ?dI(9*0kv--eut?u}~vw>p{V8++wFDBG@W{$&E8*A!P zwHgX1dB@7;nzC(0fp9B>JHMu9hVqza)PF}offLi~jva7Ns8xNj;q;>@YxMK#YIWuQ z6p2mx5&&0tko(}Y^GdbhxN;3)m^WOk>-=7m% zsx(7eHLHj2Bu8HhswgxZ-=}&wPsAU*zwmuf-o8vXo@Z2(EwJCu6ugZZy87x-?d|}8 z;5n1E8+x& zc-=q$gaY({m2T}G|F-4y_v5h#>^vj+>DkTRtD&uNk3Ia8$?;2o3Sf47eAz#|$H6}> z*obGXWBuQ{WDS?#=Q=&arO_0>b~{7VQ|J9UH?xxc!c<4=W^Qs~@G9U1G8Yi9=0wS- zyz6E!0d1&;xOU1;b9(6YLd$;2*2#qRbC-P!5&&BNtER|hU)Zq*HQ9M>{K;Ps>(6{O zuGVt{H>&(jt3Y==Hqz11`7z5Q&-(Q*NoX8>z1A3w284h)rg7L?B59TZV(KJmmkQKb z-I@FdmVs5SpORMhzDd_)`&liuutn@wP{J>@y>v zyR0f3HZ~bd`=wK72`!5rbj}hqjJTQh?bMQ!l9{hd>)W?Pd~k1+%?8nuJSOHUr`8nqpb%=1vE;u+R-HXn{-i4$QokD=XW?Q!#6?O&lqLxn=7XC@Byd2dAh zfbo(%_G-dfov5aFK(l^iiu12#>e_67DPjHDyVVJMu%nx7M_OGX7q`!^JA5Euhqlht zX=KT1JK^sNl5CfL#a{Ozy%QVNxSvi!8v0NMM9LY(u|53tYbE;p@eDkUs@6@a^Ayiq z_29kWk(H{+*!;822T^{lIA6-{Bm6D9l4g!;@V%3B>8h>HT&cuPP4e{21r84|+G+?o zO$-{#4fxT>EM7SOrc}N`g4p=jPlQw!D%<{8iZC2+`SL|}C(`1=LjL{?dya!I&$Y(O z%8S#&*Yy`w1Z637&blx4C${feM0kx>mLs+KF+beE)({U-a-n+cf%>T8GFfx&Q_T0VZ4jc5-pW<8y+BcH}c6ga~^A_9Uo zUi#|=_u#Tq=a6#=GfQ>NZs|iuBSSdM20NyWNLW?eON6UC1n!PeNJxqbsxp8phqr#g zrlY@>DnKEouSwV+VmCqOt8Mn0jh(-&q+RO+(_iAN3!Cl19H1@r3me{ATx)18PY{bQ zEU-&i*@Wn}=tUPxNq=b*n%PJ^LASv11{$kt`xD}e(sgn!_?hqG@Rl+b)ehCplu1x3 z>emZt*=y?na2uHdkPWTW`TF~xzleXl`SpurcEwWNqN4C&tk!6X&|~uCY-Pvc4b{(E zNF{d$j3)w9*REV%BiCTac)hanbNQsct+IWMXO+XVl(5}1O^#{7VCW*{;C!m$!B?uk znwPtzRi9WxW$H&l-p6|PqNR88u*-+}7 zxjSb$#1r=s@yZ;}_$fVA0~VpCOeOKx)g;fjKw5|Ql`f>#DzCR1?m6~(H-=`TP1omn z4Mr1{rA$*sWQGEHeF@YMD(T5H;20U{As@*fQ_Dn!tY)mH_^qZSq0UivG@O<0+ccVA zltRG`NeeK6Rz&x=ze>OAyng0+aNY94b&%=eXTtve!o2&>THI*|C1VNnr=p#x##lqr zv74%GI;UiUQ}W5R(2|9wxxHzY^K3&rP<=a8K$(f5`-<(+3KFMc9!gBF)Zt-89c=UPG$^xk)a-XCOO>E)cmtIB+J zRU!jrG~6)1Zb=YCL@`_{b=nW3NWHuRi6~eTPmT=EwS2v-$V1lGgLLtKC>$_t*cc9H-=%mH<(Jbh=Q;)o{reawZ6DrKb@Iz zwB`T)box1Hn!l$v-=f9#B5ZT{Zf&p&VGfkhSMWqxi`{qWL0NW=+jT%0FSau@9kP2VoV;`$n&kf+48kL>7Bu&R zJ}s1=cC*z>wGn=3hu}VP1P}=1yKHbDgvD)jf;HiDr8Z$TBt2o>*9`(`C~G~}zv5hQ zE_jm3{0PVhY9s#vd>-!k&mXGT!{KW}{ajRo#8#h-;erg-=Wlm7YJ z|KlV7uj=*xnCY+E{%6y)kt6)OY5u?7`hT{=|6?6M|F`YZu4&Sj$9sq3P;d$nCjX;l zeJNLzwhRu=cVMbz zz{kv)bf!NX@<+amo=H2$@C*0#ef6-5PofEA&(5t6^^%?-a-aOGym7M0)#fx@kJcZs zcJhI6E2;A<*1`7FXP21ZKgz=)Kv5wLZz6RQa@E84ce7g_FynR`OM!O9I!p#&{E;yu zFrt+!>T_&nXyTuEK~1#4aH{!@6BsC#B2yDMEUGzC~u+uJ8ymJ9BWXdxx+4OpSf$d2=2rra>VRhtcfI6qUSWE(r!|cJATe^HYk4O z>yt6;NNapM{khlGFh$Z;Da1c=+eY%(R#2}^6(7%s3J|;Tx@zM4S1Vbc+E&pmt;##h z_BHR!eNd&jf&5u{mYC-W$0lzM?nmY4I9aD1A}X?gyZS~d*lRrxKGVN`ExGhD_1WyT zU-H~gw#V#uX#BC#7N&Q`AI}B-5>E?m!gFKWQOy-!>+MPYRL_?X;*A3PlyW zO}F?h>iGWu)cEhKfB)bs+9UB#7GS79T>f_$|Ns9`Aj5JnN68K@>cjO)Thsjg%>UFz zHy(uxVncX|Jc0J`V7C8=&zDHjlP)|O=D%R}p%A@?-c*8ksW9JQr`Gvq1N z#>^9_|Ef!(s_AeenUh7**nbje6amd2l58Exej@Ui^Vniu;h~zx9UVSrZ@HOZpw=eJHaHeMkfO z{WIlc2#pxdUxqwh;yV8TpIy&Zez$eviQkjc ze>-;jS?k^mBRiyQeVW1@PUT=gV@{jpZvYDL>2tvf+DnbT#s_ zUe6acx{-cY9R$Kld3+$aZM_MjpLi&aMP^KiG_;9O;2CN=O7wGecWCM)-}Aj!>J3&+ z{2uy|u&AiZ2VZIo6fisTQV@f7$?m~^V$k4+u3Nf?Vj-29okJ@X%TFWC!_njG;Q0O{e{IPP2g)= z?&l@~H_l`8Cgp_d+S5(6CMMK&O*X^}=AhZliqvQo>oXzm|4cp)_v7``%Nak7+b<}% zPMF5phkn5QJyYwW<*B?GCeiK-|2@wBeo{u5t;s@DO!!I5g*_Y6bBIN8aY3Tr271|g z!0%rBJDXBBkI#GKdYObi>YQ2n#|G8(&jbngc%|3L|IYC6Pef;kI)!LYM*WR5aT&0h zn5O^@2Tb{7Pyfs`m*)sGouA>YcX+G|C<99|q%$k6BHgMiC$+|~OXWWo52#%W!`xbJ zsA~O4MKq18F24UG6Ljn3)hA-P*~fnW^!-EO^k@nPiEDys*fVr1ZUz`n%P$tUqSyBA zzt|`qto>^dFJRBP63ACfZ~v`30D{$Asoh{DAWZ#7Eqy7o9cOqB&Qchse1EGVbS^`u zdP$Xtc>h*KhDg;no718jO_sb2FxI;P`Mix^-@#7)w<@AXvH;{;NtXI6A0Wdw@scmh zul|R|Ld;U(1-Wo9+nD?e{;jhR`cK!fpXJ(_%Koc7B8Kp8B7Y0v-wMzeITTM4P6E~p7Ls%JcZLK$z$UdoHm;6@oesHWYOTRMwCi&e5Ol(FKNyCFi^SlRT=th zL}Yf6W0-TBi{=1!M4*GMYATSk}e%t#c;{+e~OAQ>8A+{KCl$s8c94)lAI-@UZv zR;=_~_8Lzev%3}L8m*R=udHX{&UlAmcWo5=(TY-yov)rYoa7=tn-lUHm*aMX;@6sP zbOj%4SgW_P?;Y4Ekl7DAyN+c~kj&>zw7Q=i7jGm-x(mCP>80PXIJge@56>dP2V5V> z-wc!!}&`FJT28#WhaMZ~CF;iFX| zptoW=-FSaixg#irW?kk83okQUwP{=*l8W|`W`MV7E0289o^RMKwXw%-Vj} z#k62?I2FS%^5Syj*Ao=3OSIj&?YsSrCpRniu<#34)vtL%WQoq_(|4{DD{jL3F16e) z3$S1E`_qdKMBH{Nj-@qaXVM(s?~aa>=g?ITyoQ>135FTquRL0tMuc@Q+5#c0dmA`n_3^N%H1`>IxAxFsw|3fHfx66xZQVOU|I;KOe1ClZ_&`w}oA0wd z)PYnSD;F-DF1JDix`wn+o$%_=q4XHh%Y)YXxrOrykMyC0Z?RivPx}<_JoOys@#bCg zH8&cHMWd@G+&sp{=T~Kx@qD~8Mt7te(o273(V#Gr+?NT@k_eusm)6m&ocyRiUe6oZ z**P3<9I@jFu_y5FbFS+4@0y=iJ5Ll`zsuK^ZOZHWSh>>C9pZ3*7)*2nL`F*-C?rT9 z{z7`cijw+lu7x%>)U@_KQ`v;$r1>r#+H*mZc0>>V(;7Bd5?;qTQ6|d?;r6PZzTLj= z8ae8oZi>6)dt0XYbZ#Rte*UzZ%UYd;#Ny0;g7JR;zbnpzTJf-hJzr-5KQDk9Jb`a!jBi`L{95sd7{_}k@pP{6q0B4dK$Hq5S^!=lxjrt>=KD(Vmg4u?A;jyfP{>B<#SBom1KL3x> z^v|1C68f_@8cFxwL>3-bw$75x(Ng8pp1U<>+6DJ8$u!Ij#7TzNI3>zvnG08oZQ&(Q zJRyC*6keiN%RWLLa_}s_n5S{?GE>K_R)b5Q*6%Q%0h`@gyVv)oe@@XonneoPU-(evhzf^Zw-BD(bz%NtFh18#FPeR|u5A{n!oSh%(yW@0oQf6~@@XT|zWiENFWkR^_TH&6@n5fSa>TzQbc7ea~ zV`*+b*4pc)`gPp<*Qo6QS}n`MW7;s9Kh_MuP+%a)2I)ZMc<$ou?z|M0i&Av!IWcz< zBRtWn?PifX6LV`c{3}NyfY2-5fOdW^ytc^E!-~NFs2S%qJ9LPKDlg)K{6x;<g{s%1YHuQH9a4A1@t5H;AAYAIXeoC_`ky% zFcuwRu|x1!HSp*1t(K=A-rai}YXO_`0vr{wwrsStYyKW_~ zC9zpOZN;wHK2uC>&gG}{Ih=r}AuMw%x_1*ZI$vIqKfljq+AubV*Yb3-Y9cDQe85zm z(Ep`WXrbCR`@p`%^!8dx?(-*mP6UMhjM(l?M)~*LoNw+A4hSU@3m9e06SFc4^P15t zI>;^0WNUv6_xBBcHsUzRdmAGg%kUNT$4_#oN6kfn7w=m{81r^z|M|@3{00AsTDm;Y z6%+Ghui4fASp9#$AsZyNFz+~-vHCp?B@7K87`UdeD+a&Aw)qmXT4tRZdUs}@#)GZm z4PJX&4_ysS6Z5C0pxN`_J4W4Sf$LN{@=43U-H(hTtHQr~6zF*J1t2XSLhTeUOlgAfw z2G*l2ST6z~=jAFZY12E3Yg(;78peJ~6H4j#1$OBYQ=WFGN$uS|EOL0D%|MnLmct?N+}ul@Q-d(sfOcKQ1j?$Uw9XkI@1S%h@( zd)Slhb6X$2Rw`6%D0K3$yU(=SjUkRX>}JI_yk;%2TKC zK&K_}XWU>Idwc)+ai&m!Nr|5Pvup;nH_(04F6SuG$?IRW*@iT*+FSyE_P|O4fA@pF zuTS(^D-h54UWM<=KZ|~*`ldVf1Hrr=Z4EoJaQcZvu0`fS8LB8j6Z_t2N~UOn|3J^7 zLj*6O3GuoihGtj!e6SAof92r8oL9W@IJ4s1Xf!Am>;UXw$w4yWqH1p&&g|aKjh;3( zLsjyI`*2?sooTNa5`0`rv+pBs-1$~xmKRX5$Pyzud0tKW!W`pnRq;a7Bu$Pg*0M@Y zD7Fgf?Z!U6+r*&)8<`Sv|6DP!K2&D8Xu<(`-8WiV5|XA%h9=iP)GtvZ2yw`q@Gy)v z9lKb3yb70(Mt~rN_5<+%`h# zUs(}b0r*QXGaoBq_ZheHX@1}nN-+CmUE){EXY{54AY%$+ROJk`#Px&M>q@vWrOB>3 zd1y%~A(E}KnL zZLNsjy1W5=j85e+9Lsw#FDKh4p_q*VNpp7n-v<>o0jK71b|ai2 zC7=+^$+&}wL)cV4WEH;m+tPCC)K(K!4z_i&JLZPaBkD780aF6>mbkC$v?=DtJ&VLv zTR|Z+>Al!#s*28&)9mG@X}Lp8_)Atv-uLw7+VCG zOEb*EfxR0fVpF5kTE$~~v(@y(37Dd2t1?xK|H}}Ju-wL*Z@sG?x6sR6-sn2Tl4qfA z<;QMr_m`YEkK`t=?{0rv@oU{larfZtV)<>woekz*PAQW$oRvHRmgiP)tf<>;fiW?< zk02fFyiqyWqnjX0m--=;#e<{gN1~E$ge)Dy+l6fa)#o8;@X~ua1dHHWiQ~o+(_->| z{xmhHSS6|ob-=P0p?z7-8iau+-TCV3W;=n0qy?oy>>HI5aHa7JNxdvRPqD;S zhI6`GI!{xqav2g_D~me^SWGMZ{XB#5f+?%1KN_wr%tcNE;}NjZ)e}jTnhojyUc)T) zv8jWil_za14<6(XJD7zCs{a(UfH!DNRW&v_I{O?SVgV-1dM%oM6F|f5X)keoWQ20l zPqwl^2ceocM0a9(;Z_yy?OK2jE4kc#ixB>TFPIbv+&{N-RkS#GcQRxEatAon`9HSc z0<`k6hG^1TUJZ-c^Hf8|H?n^frxbT3##wB^#Ubz1@64s|Kk7?={8nFEn;6-x>8~3w zu?ieWI3<8o?y&=bE45DA*{s-DJm?YP;btMVO4=)T5$Nq5>*b8IQ#Mwehdn~k^LK;i z0jm_Z>VR_O?KXcqDog@3Jdi+CHiy@D`obbIr~NO0&FUZXWGMaYg)R#Otti5@nS^zV)pde87;*< z9mRk;qb_B7Rqq)h=wle)V|tmox#eF1ix9w5SbkbQKJvP$>+d2G@~ipWR|9xxV>3=d zU${**fO*U-L=*bBtw#h(H3df&PB=J%Vk9&|C^GTyDBlfxdVnV=j zw2E-8Jb@j*@L~)31vo;sR4)`sH##f;6!sM{W*&CHG_+jp-sVIAu=Uoteh0b_%4Q?@ zu1prQ^$Jdq-!ti8`9oj*d6jDmO9OIEm@Qst8$r+g@IC>fl5~QW z?5Y`*v))??e73?Q`$4t`(Wqaj^_ks}l|yP#Dc!CKT_p`AEFM3=n+%25N_GyVHQTs4)ew*j)#6m-WcUT+&>Ad95$+x`RH=Hm7r}K zNT?S+QMTapYBLb|_p+$AlD9HViyU$fy26i-`i*kcN^%Os^tI%Kgh-0y4^M}ET-tZo z&W`kmMH}sg?R)nKMs?N&^UTAp4Zp*#H5s{fKmqXL5&*(|)I&{*dAn|3grLl1(l<|~ zSKW^TS3m#nRj`hdHPlP=%5S>jfuu!R*|B$3Q7}hUfS^vHP(^5x(IZWp^9-K3o{uMA zJ#jv9pPp}h5ra5x1VM6281y*DRNHybBKE?>x5F{eEgI9t2d{od+t$Wv;|j{%v+YIrL&mK9v2vecuss@rBxbZN|KLybvsUjq8+V#S+d619) zS=s;(3m}~942aMRo9ih+j0Qt_yZ#|p(Fx;N)O;FRL60m)Zh2Jul>}rhuzIL1 z<;`k@+4SEe_HR=pR?`--ZT#X8a?Lm=eb=I_vDe;p@)X&GAv6SZCRe$F*7m1^eB9e0 z38+HVW>uq@I!$)_8J15&C@@ozi%ho+?0GO*^Rk7Z_L%u@;_9$j;y87l|>)CV|?WL zI_(|!B&7>++eEa?<1{=d)pz=!gmDI7d?ax!I!ify1w%#RZh;nZ^a_x7FPb_+qn1yG zQse@_OZCA;2K9ak>K)cIPmo;&1hBkoOV*K}y)jK>bg8{WR4TzBTpR2A0V6JkgH) z?d3~OD0sqMPkTaN0f7oXyIzy4+QRwI-o^Lku!jwJ=jxG@pwJ`IOP6+$sX3l_${O^( z2T;3nDe}D+Plk8CKF_}#5b1n|)VCY`!EC}Ljkuft_}pmjV>14g$~7(7nJ!&E3_lsA z4+qI%oV0%t1B#?sr)wFS1ZI41Lfu9}v8Sk|eR(HqA7In(jAzMaorC+yiX5mHez z^|=A2U2_SWGm~Y9)+`83>Rb_B*lImnK;ZPT_M#THL@!OEe(Ui(@p#QeJLO0OED8pL zayLFqpQ#(vU}Uc{8=8+{-c64+HAvJuypyQ3k2_f+KLsuv)hhso2kyBbH}B2oJn?ac zL$zvE!oLYW?fZ9}9v?0JI2ZxXDB`-6$?H0p%qGX-9@xYrVpfEcGfRS#4X`M)3VQXX zi&P`r)HgAr3HXY(6SK@@TqW`JoM5Zk-TJBYNAQfBcYz-$+2i^ey5#jwL=0eMkQxo< zNej1Q>Lo4`)-Qp%>_=+@u^i6NyLf79XRlt%qZv=!Zn=-pe5VXM+`N1>Fs-!O4EEa~ z+~;C(TZ5J=DaV!fHW>VD)&~5IZzDkUGU7g-3Dc6tNjpk}AK^N%oiqP;j~v((Ec|SX zr<;C~Fbm0%n6cnzgL=IDJ>R>91T^zc!p)4e`5@O&Wh@8~@N#?Z2zfkjhZE|C)Y1+= z+>*m&U|iPqYqH#J<~z#VCR^bq{uhu66vEH=6Y5{;*`PS9YaU6$ig?@bvFN?>OsOto zakb&kfJk_2NktZ^YV(xKdx@YQLd^WYqb&>Y@Ty6}d77dbhjIc}j{!(7;z(KTdv_%6 zPT;r8KAXR+q%0AjLVN9L?Dw-1xJlWI>H4-Pfg_-852$z$5d)vyF9)GY*Bld}cKlc~U?RuTOW%2lxWxQeP`x zpQYiqno-aj>D>xDx#83=ivaF#KqYOYSI)dJl7%-&`PTq`QI7iizxyT)#{|ClxL-#} zfBWFxmt4GewIRK&Y&T8g{>5FjO&UE8Edr+l&jt#!(p;Els)w&-O^ee1?0YZOe=QPt zg3-l%NoI)Kfq8Ttm??~x@mt7}u>Nbo!$0^MWj`&bD1ZijAYuKn7Vu^RQd4XtKYx%p zSxxh|4?hlZJ4lOgnnrc!S0FjBC*e!YYtu`MMRR=h*9kqhRNx^0Czh}gbN3v)Z6(QAMG1MvpEhl!t2L_&ijCtx~aSAM@q$HL|W4`Q>@ zo;PoQF=R$aHJ;!8`vByIP~^d(1f8hd82@QZGGzFFzXki_CGM)!aS z=_06qn=W6rA5!mz6<1UFGe%Acr$H(OBJ~sOGx$x$g^+>ezQFd2ws}2qN*EO>is7yP z@6D6uyo+v92E&)gFIr%BPdlxw+5-O9vsk?(_RJC)**`^ZP8#^vZx1i!F=f_xR-Lq& zX#^bH75gz3SSO6%3LN>heXu#EI90bizp{uuK3?}9wM^&c@zC&E*(^&tvnY@~Sz`1t zsr1eeJ-N~4hrJmV?bGiW*r}FQ`6d0l@V7CKX?dUI#Ew`0SbD%|Ie|ECKT{z-scd#d z#BCn0p81H8Dd41Sp;R_uthCFctnPlNdd@7N4NV2XX6&c=Jjo=W?{{*|&Yfo8+9dc$ zEx5WJyq56U3^X4v*6F`ox?sas*VlBdp-r*7|Hz@C=C_CE#vC6W>mL_zkUSumC0>Ss z^HyB1xiXL?lH;v>ikm1{g)U)F4@`W{1 zF||EahV>c5dmhX@^6A&%Q(V;RHQO0H+O?=_o;!cf-02)x;_frKy7z6@hR@Thx$%s3 zJh%U$QUCtgTldYme2Y?{Sc=i}VWq+M-&VF0=$ENWv`@b;lNtQFDs$lhWG-8~v*wB^ z00&sYxr@}6358yF_ix2y;(yq<0w!z6VC2l^k0~`zxlRzK$n-p4w;yk~F)O7kA>}@H zCD_zux~e}-Z(+Xf!ByPm{N7Pt)tCc$CjT%6-J?mkh{qWbtaz{S;OsWJ!8eohHF!5U zwRb>5VoS1`|He;Ik$u8xb8|BnWQM)hm@eIBGk!GaL*e=PFm+A6Rs!~!L}x+8@Fu_w zb;0 z@DNZdOY!A&F}SaPPrw} zhBxzb2Zo;K%`?;Y@YN6I3R?>ck?SIg{ewT7Gt(<&Qu8c z^BA-017cX>N15m0Mjx>SvUKYg-c2=EGn=7V*5#bxOnqOw5vg)za)9YGoRo zn+|8zo)#49$Vo@S_?mNa8w>fohSUaGg=(iN3Xdgp?~J1_MG(*O+F6=UuW)7>J+K+* z+nx`u@9#DL6rXWRV4?IHF`7o|_wkaQ(KH2-mf2lm^wdb;6zjelV7d88*3)Gtvgac( zB{i3deec`tv2`6&^E}bfBRf5pK7U@;&mH)?-4%N?0hPZftrpX;@6&vX$}GE6qV~M@ z;I6dd(CcKMWB6JKwmuW=wBjbOC$VwvU7Be&E{2zCr5SO2hi(dIyz7FGbH0XzY^8)oi(a+mp?+a!962 z6+U#Joa$k=izI!hcJo4}!q(5IP%bhEi5K2W8f7!>vlSOxc7wPyNq}QfZK){3A~RVD z_0oGD>MU}s`dG3fwkoJe!#Hfdqog4)EQb6ERDNxuPx4yeks%xlS>Gt{oyPtQi%!>P z3;DS9aB)agS}?1MQX|ja!IvR<_SeVe41Z>@mhaNWBY(v&hz1gJxhkVIgHmi$;H!w& z1!L-=sJo;%LQHbi#>NW#8?j^@8-Qv2$)?(`Fa#6=S z^n69Cg*jkmQK`K^PgAH8)Nm@0i*}0P(#30-B@!lSQ`jF}SRA&Ni{hu<6odC1@b-;bt>~V-@(Q~}F+K1iiKWI{AITALR`I_16jWVa+9(;*p^toB( ze_wp;gL}~@a!Di^sX@d(8S%??5m?+6Dj%t1?PD1!D9zL(;+PCdCh4Gxku9x?fO*-> zrj^$_3Zt@iR(UNIxTL$%?l_yva3pD!jr;8tP0qYSzfW-~Yeyz_Hnqnl^O8_<<0Cd? z>?ln7+?nd%!-}+4Su?opS=BtMANI-|D!N`f)^J(hE;@vX_lA3L zz_&V#^gy8e(uX3Bb4T`!0jWu2a{6&>NO*RrvoVI<>|9300<3GJNguElbrz@*h3RM5 zo-B5lae}HM{TzNC=)LC6pvZ!M5|+LmqtZ+9eK+%CPczLLr@U5x{$%wfz_802HV~t& zZx|m6*vvuZ&x*DNem^O{&Xc1*3g$7?eOh-+G0h-LR#YV^_gK)Btg?-0Xid?x@xn6( zAGTAR9UR*1`Ty6})LVnF$LhhEquWKqH=#l#$QHXvW|6uZ59jP=j6|Lp+9_TwP910{ zAg2tF#BtQU9l{12%z&>JvH%uxY4f#yTAd-Mlm&ZL|3gQpOq+9%%5e4Optqafu2zT+ z1+Ul4focI~h-M2dywr+Q270x?#VJ$PX2!XR_-QAz!yxCesoql|r74|l4Z>q8BG{+3 z2G}z89qWhF+xzF|b!W1o@XdIK&m{!caih!Bh?M8F`LdW(4IVBzm}=SO?B7pJ9jugU zAEILm)R2jnv*5_$$K`j6nybFDIL;5NItX0bO3q3_zRue8?T|Gko6-^eDNKyGecm=16*_sjP;z^ zUIuwgXVZRq7iq8U^b+&x!)Xb1r}3>j3?ISS^rbY_#VU9U$_y1^w0_~_Hv#6v$z`y< zdbq56i8t9+f(DBjSF%}{B#BupqynY<1-z@(a%0+Ab6!v~u1AK*wsi=DS~=Q&_OChK zI&9x}EK^GVnn$8({c6@iP>*EU?vv@d(|fa|%VwwlJ+$DCqQb zVc_~8EUjkCyBhwoqeCQbeC(3aiF!dCgEZXSxK5KOmTXm&Q!D&pOh+LkCC3R<-=4G9 z)9`YsXZgm_yunDPR{A2PX%6J`aq1@(qty{60?eq_XkF4{oJp$Uvc3hffKUEnO-B6r z_?DB)5&4M1rhgisv6DW}o`yW1IHVtR8vXcBY(;vzr5?V1cgC$_nQVkk3$}M;8_99pSK!p%Kp0Ic9opO6DaDF z_2aWCkaeq>D1{ttceH-Br{g4jO(4DlgMaIXnJy_CaI*+@M#5%TbQ$gA+E|`k7-^M0 zmXV;5?TWpl4+3>~j}jLO@Z^wO*mNl@FE!^)8@Zp2Qq%tN33S7*q_!7^n-nS`K_#&_u3u8 zL3rD2K9&7ZOBPaW$Q*p6EK4*!&To(i2447Z$I#l=cZZ2`C-_aWtpB_O^XTh@WE1at+WF(pr$7>q3gbU|oh(9? z7|j>Z%+RSSf~9Uf8bGohbIt4&t%C{n2I9Oxa)nIGKS**f0CAFv7%FL#C58upI!K^9 zzq84>!Yt;o2KETWV3mk^Ne_m8YXS@twh)rG(ZYos-Jg7I zl#i_(i2{ltkH6Q{C^sbq?kmTg4c6JdSc-=%ww|6BM(UqAg*ZuW;d{-W36fB&Ml?U{ zb||N|-kuib*JTPo=6qBu1Jr+{Ers*9Wo^JWf{8fy{M)oY#_Q{bPiC-F*e%8xyfvSv zAp5o)0;Q`@k;PC?*593cOCKp40rNSszTB#-Q_`Aj?|?g*tOy+KD)4r(1fIo6 zIleM=W|U$OX#s-i2lZ-EPXSFfO4wGauFF=0a!gATEC2{oojdEw!NvUW$M8tI;cY5G>+QK^($`zA849H|k z8-F$^+^aW>da$w8?dm&mUeZHydYU~i`~u$)M2C!bJ)BU{O5@AcnheqJGp4`InO z`Zp=$purPTKaDZgaf2BR;(0P2Asr=!P$0u48~K4z$||p4U8^f-)()y!^|KW~lt&8} zHe@%EhQs*8$(=;+VV7u9Hf64qcRY?V+P=bMbwDDu*>F{)=)=&;_6^6*Me}#5xvTRp&C|jme0U1^!d%l(=}^y zDz8F+8FSE;u=qN}zIhHUcbe>UGq7yAcr!=ZT$Kb%%|hL)MnGE4dc}uEO=8TI)#`Bc zfH@m!V>lah(df-oS?;F|)hPf`?As5q7|zo(egR}*aJCNdD=0}FfGkq*bT=XeNC2Du zU5EsBeqV57L}comOcwUCqS`H}RxMH3HA?PNe!;$mlRS|3Q9+EXqwA0TgToE^1WDSq z%eK2x9OqOMx@hOi9wz{Cgqp`KiEvd}uJp2NKji?&@_9U7_N! z9&v{)TbnfNLNgMc3?&KuSy2~xvOwCw2toqLXWU6Y+LXn}l&+PGfBF1uxhwC2`f1L* za+%iS{&KDC3dELiN#a|#9Z1DZx$r8|CaU4c3^Y+)_rhui-oFA=`rfDkswflwTiE3@s zFpE$q@xmH~$q>!!9^;RXA-@p%LpK&9*YDJFxs z`^*yV@qC|ihp?q;qpT+$C-FYskQAhXOJs=1_BnQBp=Gfhk zg^ZkI!VY6vJ+-rF?yLdoHSdIvJ}l-67IJx?aFs_~&tD3ds|wed$?5xnRrv#;N#bNN zkmI_86g^?`b(`DYyy(eWpGU*kr19rlH|{X;m^%J;$rl3BFw_#3AmB8fY%R9Zw)TLv z%+!Bm?ez?adPs@ zI_#O`ibyL+0m9h`!@aTlnApJbGnqjCb5Kl#I_|BQ0n`7`EIk1X_Q_w7%VIHxvGm%Sg8 zo4*FPXm}CC=-0Uf$=yh|nnWwiF8H|F9NSP4oub?Y6AjVeSCJUHbie$Uqam+N}^g{R>T0;@Wwiv(AcD)f1 zXLRLMu%0vNG<5$~WO+|YO^-bj?iqQm!ZeMuRx$gGHgO2(-vGqQ?EP~SV&lBx#(G;2 zc(F${_u9G){D2GwQYIySP@ZK&{?wQ~iSvdMhgjE&K#GgITRyWp3M^`+$q{P+`xkN-O*@3ej`brB;_p z7vtjDo&L^I{Xwd4n)wD{S;wWvnhiyhcM;kE556dxFOH&eRl5hfludpEB~%*Afbdu4 zmG2w%2+`D*dE8U0!{7FrOy5Zf$ZyO8H)ynYl{}A9Y1svY07b4ey}oCcZ*bnBb8i2X z-mKVs3;RW@wtngWqK(Jj9!prQ|Z4wE3n^e93V z){HMK!6qZZYN=d*u@KJv=ta-zueGOV6M8iwJ_&X_#d~7-v>dpqSEls*@I9W&dtEGv zOT0$uW7Q7GvWlWV>u*dYL~~8hA)3#~jh-1b&`K3!m4Isj(q=lcB&F}t^B7<6+ybi| zzle*IG=8Z~9Ws!!U0?CrB^X@P!OA4&Inq1_aL+rz&H|Yo{S3=Rkj@L{VfkbfFMd;w z#0q4WOv7-&=FVdaVPnN-^*ZXiB5uq*W zxXK!SIR z=Fqg;m}lS^^fx6y<2bdym(6D)BS-&c%0%1fCHNtR5^?mb%xvj3x%>q&6fTRQ{LuEW zXD`8lNK7r0&vaq>Y~WBOek#IO0t1n9Qf#CAT5284$)s~q=*0dX(U~_HrGIS0aKDqyW3c15)f<;&E4L>^af>#d2cRc_>{9m}KuotyKf1uI zO`k^5!v>#e-+4l|UZqhS#p5|PSAnvYx4NT5;1tRT`#0GMY)fItJ_Cs0h?5~Fk7Zdv5G!Y#FfFc*efARajqB_xqp#UcQ zpN$WITmHEOh+KB55g=>q>;K#xNLK-p1{OLl?hQDazj8SBpH}?kW8YpE{ht>A&k(R^ z`PbDKXa@Bh2(hSK|NRSAKu!O@`}rT@<-+IzD)Yas7?2!8YiNwV3IdKKQlW`mGADv>5}7;B?>VxhlOeb3H|_=9x8`(y6z*K@ZiDbs^z#daIB2Y6b24p_}&s>MOr; z#uYxWc{LnNNShB_v{6yic>ldKP`K_$)=5GVn{#R`UN;9j<9SO1aX}|A^`?um% z)Z*pgr~9I3?w#6k{X#~3!PttGiB&=SQvo3O%u&kQ3_dWe;MDg&j&7cfXs!v&G)UO~ z^_Z36u^9(!vZ}viw(c>kO%qV#z~__c8cV`P<~7j#=B=dk04lfUrB_K(bg5+-Ya`S9^y27_x#i(9 zf_qbVY`>qhWPV0NsZwT3Q;EbfQ{vMLvP;|nggras*AQ`hJeFf-AZpj~8V{rCY-_WVky6V#AsK;|I}NU%xaqTD@q7Y)7?k5(qWLRSHZror1SLdT2T; zd{wkfpRpIU2aXunMVa7q%_{7&o6BhEpplb_=~8LJH=x^X{AY_5-0T%6KOtUbca<)C z^X@OA;;%yBnXSH{zt2yNZ2NrjW&in8Y?!MplBNZk&AKM@z3`tyd+JK8VoMi=|70+j2 z-fNHcXS~#$2qWsS8EiO^!gC|!V`;y^?HpxN&jKczx+?_?%NB#bI0^br0_j)uoX$60 zkLoQrST`Lfm`DYg?EMqs1N|}#3p^&CE>t;;doDCnr6Gbk^ z^K=A{q7w=VYtDRKe6#t`yU#Lz3E?A2s6Rhhexd z6U}~+e-L1A%1#ic@2#!Z6i1uGm;F8f8|-mr>qLcq?=KLu%iDV8YVey=DM6=v@UZSS zQ1MgTzKK5Dc|NU7Xu=mH&jz$ftEeP>Rh+#Rt1i)Braj-n$n3mzvdzCVvX@&!)f{~K z6izF8sMNl!D%2i!$KmWwf#nX{oPaH}1-}x>$^hJ#+NB>r6f@w+YpbVSqom84)(=A3j zQ~+<16?)gNjjuYjYxJcorcXEIu4FhdzY=>iJ;Fw(C=B|YL*~QfkXXkGV+_Ek8E=oa zk8*P9ViGSC@<&eK0@UP#CpWJ}GtjY7uq!wWIenxerrXPCwTvKt=2gES@NSR3-WVX9 zA^lRK@N-XYQ_c)qkkhvhmy@xKTjUJu2JajPN-aA(v-6L4>&S`GuEPY2<*Ka&3TxZG z4(WhyDIkoydjB-o8;7_fzU?tyEPpfDR5m@Z(i&hrtoa0{;GZ3&Wwq;4`S@Ro#MNUbqofY=kh=`?j*a=-9(fzs`Vqdh({-#MZOLBI z9yAYp-<-Vw`e4P6T5Lnw&X`RouEG!T`jx-B9{XUekzw6l9N9&0(Xce%u>6d zNG#%6Jq$OK>$p7d-DRI?yGJV7o){g?-~N1K8=_;o_dqFXdvq+-mILgHCIZVv@qGd* zz)FzMghMn>)0cRSA1s1#;cKQ&X;3<_{)4tY>pOsv*yt}PcAL$kOP|%(8CC3I(WQLd zC*eE@W-c0Zj$h4n22=!>?T&!6~Sf|hy$5_17DaiChpc)2;Fk+bWfrrsupm*JD3E=_ zDEEdOCkRZXoM&#z@ZQ1lc9N6swd~7L;?7==-SUW^m4;EsHR(%s{!+3y?Bup+ev}Q& zS^Wj);qvEDLo6{mqf#VCul493wj9UZ;3Cqyy_)F-b8eWg-g1x!o$s1mYTz>wI2br@dna7=w()moJc!`9KI>* zRr6iu%`NK?x{{6V33oj@gi_Imy;*IRr&nPBpw;Flq2?b&JZUyaVCQx+ctQX3INkY5 zF*UOE-muKm;6_R9%xe2;je2+^Frauf$ASTFz??VGZUS+Ru~@#7u87Z0{ zubXuljy9?hTRrA*(AJ064rq=m*?5@wpfXDuf(%!y3TCR0TYnj1N3Acwag`OQu(zJI z>f`;4x{@=QFLCq7wsmzstWB0uShEg9U4__rzTHsKcanvt9Od}R=4~kOP}Xd~+66}R zO*mbwPDl#j)!edzBwh_PCey9n`>c`7UEJCh7;*^D3#~fk{dW*6eS>-)YGvAlg$N;6K1?TkFXoMsrOU9$Ov82`lbczBz+=g3$2T~)Wwu*t z3s68o$#LwO&xfOO618SkVl*_!TPcDHVD_2};MdLva=cP}99phPwRVUSP+5ofEqis@F&G%&Y1OKJG-2$G`)+fm2V|KLDo7e&~o#tGK-S$BR z?_Lz?4X&?un1?%5qJiS<`pD>~O~C-p4&3ueDgV|znhT+5vt$PXB3!!&$)W&Xa_$#Q zN1l5Kzd<062{D@9e8Jaf(Uq(7P8{_rb}ZZ>Bgg!k?|=(;YKq>9y+J0x664;EW(t9i3=Np$$;ctWnFCCC*p!J|-UnEC9Sj zmQ3F3h6TH7VD+yFY$u+%aQLz&IJJlI5W$1i4%>!E3a8HwUTPsX^JTM%xvJ$1Lu8wt znpOVt!rXax;7A?^1qXbPk!Rh~%1qWD?po=tM!N$>ew+gsTb$C0yKxK>s9F<0Wq9c% z>@{TfRoDsu0(2Hc;VI9@?L+wQ&gcf%MA5mtUWVAtgrdmJxRe(hzB5*{rM|~(k;Fl# zuPVJVL^)1n%O>0^*%@sm-6yg_IS~2mH$o1D+y@Jdpa=&&yV`k|8J5JOGQAcjMWIIq zCOJ|%T#t5zNk-2nq&~07k1jb-V!F1Ob)sBi4N?+GTfs8L%LXiHlXI=JE&f^;6syzj zwCBlJz9}t`*~R85_IzfG4=0U#9jR21!NbjiZ0&I z3F-iL9O;QiQ}I!k)V=9rPKJCF8dsXy%ohhz7?CENy@0E#`exR7d%oNDl9N=PpBYyB zj#B0;8l(Ei(7DviFSJoW@vw13&ZDf3OT7BlLeqEsaOxB_{}e_7wTt;n#-*PDc06vc zDgIsli^A%ZLNGBp$HQUUi8o|`CUQ;GcUdrfXlW5}2IK0x-ZZp-b1Q)CiXFtXvcB zsT479#+%|t&d8}$0ciSajMQ}aW8}_=3Rs!Bh)EA*n1FsBUv&1YvrT&vc%YHn&lA!2 z&(bWx{F(qV7vbcV&Z$<0C6;iXig4K9R{u8AIk{dvG)1 zc*JZRqL&H~$2~6L+Y=nQ^NVe(;0)J_@Pk(#(IbK*V(_6;9%L^a&9mF-NLRqm{5u89 z^l@TBLK@nRHEvwW>0ygU^#gohhy0#|+8&lf$9$=aO+!4C_G#@v^$2o5WO0JfmX49+ zMHxyI3`E1w%pamjrt#^S{8Cu-qpI$QD&0H4w4u2S2GD>iAdzeh+_^uqskjJ3Jy+Uh zLMd3$G<~OXoHQwDhbQgl3pDQwu5tB#8@VHouRJTOi|8rNDKQqRc?Rr@G*?vd?S3+l zh_U9-;(~Uvh-J9XUsMaGYAiY0W2=ed2hw%fxl$xLbLMtpk##uWGmpqrOmp zae=N%?c=0?1ZGBkwK!6iLtdjFzgU>5t}IBhFZF%#^+2FV$N*8UVH;7to&X7yWDb-l z4k#;AbSjydG^v1oQuS5Tb;y?mkQ*H%G)m0zaxK%m3!jBd$jxOF`!m?4_`L^mk9-D*OOH*K?8R217!f%Bkc&TQ zA$0JX*8a1E>w>!?BqhZqL}Nf)cI*nmrNqm6=2=@O-Av_B#{C%G5}%h;G0R|kT2dTK zrrd$lIG}w}ESz22P-(DhT=&|i+Bj;8g*p}<}+T&40HYVqIcf3Mk$@NwOL z(xNXpRza>er(PuBL8M^EZK#!jPhJaXMW&(m{5ZY=d#dl%ad6Y;1Bn@&0MD znB79Nu?d;HY%4Z2^!C&bjnk0h#R1F{l8|3RfXG=KwY6}2bK7>3O6kioM^cL&v{q<- z5Z+p?#PKcp_9Y$Z_<}6NREY~&AFp?FZEjZYjUp@tuFqmPIpdT+n%srp1`yMCd6SWc z!#caNNp@>d zal*l?`%i=H5EiK)6#Z6r_2N}(@zM=Xr2kBi~|U)o=>nvwh28Pnok}6 z+B5WMadO+gR|&|oT972kK2&zvTv)zdNoy}eVy0Q5v@I~p>w%-`Uzc1p9Fp=u(bPJ{ z(&~%7vsHJr(ukMEC=1G+kDD%ezE(m4TCi|Y;Bu55C@DDK`1ss;1C4$3UK&tXt}}I% zJ*geHasW-48ZK+20NaLe7vxI@5(04BPTcmHCP1p4H*^L_^xN~z6-bEvTS5cOUa<02 z3KTSzJdDe7lqCF>1a0DtDoP;+@^%72CmeG_J6+3HN|N1N4qZr$cCuNQg4DGxYucCI z;Nez5eY%FGz>QpY9MGgaU`ce(qi!-z-`L>@u%u62vs{#=AbK~7>WpiMtp8?g#^#|K z@`NI~3SxA1$|YB}tvF@vS$b2(^v}9$XkrSnaDyoxd*$PH_1A-25zOPH&(y-89o?$hOCjvxI6KXQ zC)FZg?74iNB#FGET;aG?f)}11{l;@>PTR>jkzxf%^l?r|z**5$TtfppHk~qabCi5W zfDzvB{2LN$UWahT2F=W3y}!BRv&6p)|c@de~dH* zX541cf%a&Ur*zC}Lq{Q~T}6Y8UK8*v)pMPrzz$JrQ&cmMexYW5!6IHd1TJd?PjeM# z2TJ|)ODiu~RgNcT$gNns!qd} z7EqLTT;B2h)j)uMt%Pl33Ls_Qp)Z8^-;A&8%a^{%VaFJ+Qi>d)Tk_S#f}~JksS#6O zJ{1yMMTySs=4t-^o$9+@zTmU~goVnhE43v%O%ufED|AP8dz>C>vc_Did6tSN8CUO9 z4Jbz!?u8QHvsx9UZ-yZD>76bWy{sh8SbHn{>)k4Y;??xj36SB{*c_x==!R@cH_InU z0X@<}z;J5U8JZ;@29#W6h~A+{`d+nuVJ-o)tu}Wt4>M6^;(a-Xwd3tYoCfUWY?6%gQ*m zL^x!x`(2;U_jljF`*%O?$NN9;w{z{+d|ub};bxca8@(~&Cmj(P8Bg~BWnE=5M&QAO z&eO|S;vIY*Y{OoZ|Ka<+{d${wO*}JzQtBk0W`-GEd(D&ndEB5{hW{ZoP9M&F+l9`p z&WGWz+4_ocOaZ>f@Cf^ttr2WIAA=d`DK0pd=x-Y#0 zlz?MTgf)JZe+CGYpSRme=d)J5ZT86=Z3Gz0HC~KO0lr>K>agiSV`UW(_iZ$)9%e?D z4e10(tlu!pivconhIxSeix+&AShieiE2?H|0tZ+g#3^q5;&acaQ*7GKhveeV%|DN} z9$p5sl99vXR>m&MF|~JqQ@oNyt|EpCWkVO$qsizV*nS%eQ7vsi(dY8{Jn_s4yxu9+ zTIQepw)9o8-@Q+|hCF4rqiv<|GKKWJmjZRO>Z5FEiOpnNI4~1q)Z!7Py^Exip~FCA z^oPKK^thjzxp}F83ZPU^4#iqm0q3pD{|)_2xrE*m$kWY&=~iuFy>6)2KeRgFh$%0O z6PmNS2GUrjNTr)ms`DYr9?*nb=@L2zU8 zh&)?xbOQ*Qk@Ef6H=p0LWk!c-qrQp_3#%E2QV0qI;X*l_@*AD0VX?7?kLIGik@<|T zpm(7h{DA(u$1s6b{XsfoT@2XMI@;vzbPANFe!xL-Pod?ipF-rN@+&G;OHt;B1S_GR z{O(=@_9RsS1OIFR`7!gz5oG%)r%R>uc24Q}`|DDV)cHP@)|n9uxKWpxRA%sS{c+jG z96z;bGn$RDnJnbi{;?~Kf!UxMcOsJ-%>NAT@!WkWI`2R6oS|HgHHG&kRSnl%}^%X ziI_c_R{5S_L-MTm1|kZggz)IQnFse)6lLcEDdC$E6IG6}oxz|%;<(g6#4Bs)y?hOvu3W%eX zGT=*qT^GM3wvOw@VzA7;GfTb3&&6s`Ow}+deCSIIGSkC za;~ATLB-9bzk`ZWlj?4YN@TEI4Dlky;+qQ%SQb~o9_imGMW*vn1!+)6Et`&3@)77HJQhY0@n>5M|vJUqUjFSB{&`*l*| zK^x*Zz#e`xsv}TDsAk`Lx}L1|GA93ho{Bc-2bROo!Umsjwjjvn=daX0cue_IdTF6%Z66xK>K~D$dxp^177$|9GZJ>}(vrrJQQB`D80VuhAawpJP`5;mR|81i;9zj##ia0W` zX8>{Jslh88mA@N8?PnRe6g}BhD^jNGGY(?1)0#J2-vixW1S3PFFFNt*bfi)1*IbLo zNMK_xBhl<|ksM%a&{Ap!HJCjJX(RllD4t8RW-2yRFHEIZ2{~!!ait)wdsku)*uSYs+&1oEE3p(PF)?%J_7Z_39BQ+lYFaIV+l2n)P6wB+fL6u&Qx zy&wOMXJ`ZlSv2QDGA5|>9wsQTVk7iJCd}(G_%d&9SLE5}6d=GJCL?w1|RI&#T z0jq(==vY}@oN*%i6ve{6A%njA?@E9Zb&_0h=dOC}0*XQi+}agLe8S3$$XGhu+nRHd z@-=!GXY{(DI8S9#z2}>of)L^NMhHZR3d6|sk_@ey4W&S1R4;PiP-o6mO%8~ZyIl88 z(6emGwUDJw_g7=uAC|Ep=smR2V^#QW=$@(xZ66hVwTv0P>S;32x`6vhu^jF>)`IkF zBi!F%5KY^hQ*;H2tmgt{$Cxt9oD=}Q=vn4h~5RW&#PKH?0q*$VR$nMgBujyjX~R2G|i=^X+gMW5EI z^tSp<20JruR4QESCF=aa2W7K4*D_2+TWtnwNidqn=kORrTNk8rjH5UpAdDiZ(60D- z;gV!6s7p#bMKN%>w3teWrzc3Me&_?#L@PREUDp4e%dLLtjmqGlF6s%tI z%60p~qI_|^O@q@MY||`Z1hQfNPT0wlC<@Jkt3oyWj>Ty|>bDJBwI_vs5M0Q%ikIr}?J2%5d`}&z!4XX2w<{Ap#lE~u zw18fxc7o0uI5!mAY(4?znurYBZU&z1EH*E?rd*cqBC2IV%CUe=H;Da2}1R5n_FdaOhI613%rAp@@1 zV>BY@hLd#&q2GSnkg&(AnR3UNfxOCt%xHVy@%FxL?l-;)L-goNJ68ibJyeC!poL{J zH0(?pZJ4M6qMxSe5F_)Arq!;dez5}mq~XRj{K)uLCl$yvHd-*WH#wBB(?)flYmq7E zS=W_-*nt6TySD}y9V^!@W%NS@)WAQ_!&elL^K?NK%HNSbT3izl`eL@ix~cZ^@O5)D?{P%i zm?J!FtodlU8a_1fE+(mWw=UE|rdRjR=n@Pd20lJ_`4fV;P>7I^%a7`3(vXJSo`}Y< zpj^i`?bo*(90GG=d63YCSH3}9kYvfnGmeNsb1;f_thxue^6qd$-KN3hs)nFK9wA3} z9BuwED}p*_0?ZL>x(Y0^UtQyzj0VOE61Kk+#fLa9JHd~mmG~0*v|fg*^d87@{FvX~ z6_1W&qYdVgF^FW~(!KAxR07H7%Y4kLC(a%=A1Nj7P8N*)K&4K{qdW2_6-%!R;_#Fd8HL}$TIPyAwu!HLP>8=wFq?Errv=mfeY!8e z)GY?Ty5U0QO+O6n#CtiDNPkE)$USi;0CAYu=#D*$jP^8v(ssTGcuoWlnG9?L_vCBb z;$USWOpo2;muBMqv}vk};CyNCkP=q_a(_?MnV}*A8GNMfYde)AjMgSar^HI3#E6|9 zsjHTcQxO#2B*?mBOHw1)_z}-fK-l^|5yeTYu2R0zf1UyDGm7TgbriU{&JaZhS<=2y z@@i03-AJxuUGhtlRDY27)!2j2C2cQw%k5l$!c~TsvxohYp66VJwe8`{|0tk_@2ZhU z^}NhK&MJ_79)Sh5b)*Op}D3hi=0X6S)Gq~c4Z?IH_2PeY!Xb1 zVIu>qAiJMjkWm7w6FMH0&MF%k4o>>7ZrTJ{avC2Lzj7ya38n#*Yq4V)#d-5b!Gt!A zU2%Sho?v{_{CBzgdb?RLlBI+n@f=gX(iu3CK?}5@mA~T1qlTq6?-YEym}dxs^auhA z#n4QghSi;BU*Bt(Jd=L(n@cyc$zS}s|LbCY_GX`9bt;ZOUH8ue0H*ElRQNJgu_ULK zx00UDHSj^Kf+q_1=eWd2<7*~LOqO<7)`ip!p|rrK%@SBbILBjBQRCB#kKNImLwvvs z+`23RtA51^54e)A5H&H5C5NaA8dSa z8h4mhOy@L_aM8N4Co0$G^YxX>`bXciBE^6MohiJ_Y%5n#ozui8>$V!-*jkcpq5HW$ zn`d61=PpB6-hQoPmEZ8>{oDL(nd&mp){AFv69V^tQH}yGN5u0X;c1>#TrsH0cUx~e z@RMy`>nl;?iKP8IG_NYmQj{zj>b{-^;=7+++5ufBBTv*xo>f;=PcNGF?a-ojE9LjW z;)+`LZOr`*r76ji)5WK8O0iXbhh3?T*pj@si`KyX#@`q3O==fewkzVDeqot=6vFkp z;^Tuh_r{$%mfpVI6ZqR7I(E+II!hU*xZCVO&F=7RR01?S({a8YkIM_jcfV{_J{~q> zPYD% zh_5^W4zn3Lo@T$`vGkw5x^yupjk{DoNpWS7T9qG?yr{Yw{L0FIvhA|ZeLrN& zDIzcVS@X8LVjSe!j1dn+>HKrn`j@xQSkHsHGriXr9IV&-*BDx6*IOoBCoQHwJ214S z&Y3q#{2B}-zpz{Rp5WAWkB8Ggd!pDXiKMMQN){G~fKhgG4Yg)oU?+gv4}aHfA4#64 zRb6;h75#votsEj#E88|k?t2HPG+%66%X^99zWC3*cL@j*LHOZLF=Xo@$}<^rXAkf5 zx~Hxwp8w49?id^tnqr-JTlOT}sS9Uj2XWiGxhD59lE5tR;yDEXlf zx@N}{>>HZkz`N zo5<{x;Eps-sxD9VJ~;1d<+Nd?;}wKw@x3<-+iInhe?@1>w;TWm&7QCL24_?@o_CC3 z2OAC!yp}W4N}A15WGJ#EXZ?0pCcfe>Pw@dczVZILj}t^QeN9h-gLksT;SSF{t1>PV zHB|uF%1;O8VgpM}6`JYyc$$$>ay@gIlY6hnRvcMGa>O`ZPN}dz+P+ppF)(%SinNef zl`q~m0oW^?qwIV-czN^{SDeM}XWMhB4BDtJ@J9_Q%gvX3w9~);k-7kP zd01#S+UZMQf;1_-+>qCjy0&8J-rL7e!hU&hMr(4)Yvs{p)i=R|+F6mt>91LMo~C29 z*{p-GYH??81xRAVzW_9=a&m5*a;8^T@UVL zY~3!o29TDWeT258VLYg;@&ZE8=PGvpr(BoYp$U4IK1Y2)xRm_WYJthKq08NE?`k@Q zgEJ?sJhjdb*MCEjk>2BUAM?nlb{pl{%kn1f5u8rolm%G54^pqTt@WkN$?)*YKNdgz zF26q17NqV}bNzxu-a7gvU=nPH?MUU!f7-J@z1$16p6CGlPTR8xY@TVJ))@oxxJfH7 zBVOkGz4%51b!0HC_EL*mW_1D&jE2Ap>%M}|(9%$Xzc7h6TiZ@}(@z}U;S9M}I3$In zKfE^a5W)sHdgskN9qCKwRhQ4Tc}HkwtltHHn#kSY^CJU9r~Fd-US*jbm9vAgA?<{H z77&84w`}_;)ylQM{z3)ZC33O*MQ{{%zC1Z%Ch(C~B|NT=2=2`9Q*md66F=-LAb(o; z`Ct`M4gn+P@HaIQ12x;yg>^$!w6m$BJ_ai|~&=14n7hnH+2gI2bD>SWpxFzGJ%2&~N)L@U&|-8e6?b zTX`#K5`E}&VjlU*SJBE66ZEZDD z^wLUi6|TN=#)3R~6X)a<_}hh2G4*kw%292K$Wu!iH#Wnn-lT0sJ_l`~?o+N}1BLzo z{gsy=c_7wl6Pez${$F`)ejkxl-3dN;*M(K6D{XAWovksy{J-8%Q*jKLs=Vd$HmRl8}i>hrlYT1HB*Ql6%*Ume!yY{2GZK0JKh0pbYONxBP6`*mt?e|!iFIN+QR4x*T& zyhJ@wX8;k%va$t#Ivr^REpSWEn#V*FenCZ%q9bxW+;gs9xL*2b2kD$$#|uw7B8$z! z+hG^i_B?kmF;!#OyZ0ufNYH2TxI15kAiI`&RgDik*wxeeSL)}iiS-G!lkWbAHWF@cNn1aoTz8>RF zx#y_FsEnuOn8N@8?AQ#lf&DlkIL#qLPYNzFH;(SyIy>ti)btoN(LJ12#b#TY)*5N6 zP?O4!9JIn_s_n`k;F_=_7E;wr#wdaDP30}_&%0P^H^E-Gro*dh-xCEf zc)7$~#u8<}NR=Fbm&YmcP|OeEZ^A?oMXRk5mBeR9!DVM!Y(|`tt2AVPY^g55o~^e7 zz~i&9-?cQNt>3a) z+;R~fH`Mf_gt=nqc#w9@b4%tP5^L?D$S*12=@!}1;09;_;^DC&S*QLE`~~=m=ne|ye2k$po!*P}hPYCfI50S0+ zIAr&9 z`LP9OnVw%N9Zh=v#NMY_754?B+urU@uQUpd>vBNe`z+(JZx1n#123f=CK77rGRyHF z{$rb{x5q8<5YVG;&?$P0`C`i6&9WsHIsGJwc3YT^ae6Hvw*yUWgpGW@JybU@OaH*Y zQIZM7!FAnm{^jJR6m> zdSF2-SnPPbbg)hY58w=2K)`BZpS8+A0*8%3sN10n0k ziT<1g6ytiSvF#08yh=j{Zny^{Vx+nA$QPMYV6;+nXZ6kY`AJ1frCl4|kcDdz-!L)z z+MO1G5*zpgM7QkjU*DOH@B3~{t9O?c^{XUOl-;{~NGsdjpOq{LEa>74M23Ha4PdXW z6fj`5RSA`?efEO9If!Z)5Mw)*81u=cAB)9}c?OoUGQ|GL0nP2i*9H6@!X!x~lc!@1 zmmD->-2*4Trp+DgHl_61$&sJ_rald8*TR#N7*nTVT+hyOtcb5~t1dQK+}UBJ1Bcy( zW=E=|C&*xYZn{KyTU3ltf%_GVCKUsz8{SlJ%SI~ikkA(GZzSIiQ#txoI9S>QqH^dx zUd{=rFz>s$l0d~PNrKktms>q^3Re;h6g|6(K^(nJvh8=!n&@D;+8Q$y&&YRc)09y| zL>4mcS0$+t0Hc8G!1()|j^}hIlG|1~nUpSH2v}=*5?sV@e|s6abPg$Rj2GDkMF%%- zUF~ZbruF6c`M&a@&U55t8i+&o-k|Q$j#Tx%1%Jyrd+=Q|BH0L?WXGRU8c_9Yp(W`p zEli6c)VcA(+udM4_%|lTRr`&~q-Z?^#4%A%2+*M7~ zWt*Dq*Ja8o@;N1?HzQ5J5oJp}9YW2!>Ra|CXfH+0eM%D>!ULJmVthf$m||~1gvR{| z*|C+#kxCH9q#4nGS75Trd{C*qz}p0-iLL^e9N>9goAK0u!hT@3a+Dq>6k9-HFL*xj z4)bFhbb2BU1RN2JGv||gc0JkQaoo^l#_wV&AdbF`CDG>A6zxd%#Q@EM(B)@GiFhy? z7Eki~hbp%EE?uEOG2Iu)23Vb^arU0VfijzUwMn`RRtVDn;Z9_ z82S$`?2w`dt&SMJsv)gdimT=GR27jfk*b-Sya`D&8Z&B>pHprFrb`V?U4hO^iPlWJ zfpemosmwVU$v}o2kx8Ct5&rLdZ44SOE`QhdO|3;0+@5R8(`J;S4fuSxwzkuU;q%IP zSgQtw@%weZql~2jUxf)ziMHyY4>lF@04Ew)iN<8;D_NaO23{jP5D=+ib-<$t=*5cy zit&QYjc`kt4QJB`H(I1|YWCRgy?j8qDt0#|aNG`70DZ zQMc%^TYdw{=zZM{Bgaswiz78>Y~Bqztaf|3oX*xrY2D2vbsH3E#pg=SQ5F8ln@nTV z6z_(hYh%9}aChY7L^sruqoAW~kx1-7q52xUys!Xr( zTHQfYl-YvnN^{D#izQab!W5O>OJUnUbhikIBR?fIKuccPuTL>%3LTA`3vM3D&d6D0i#uE_Kw}Hs4b)}mqbi8^B#63yS2nyTOPgCKZ#u#LaY({kX@>QAOYgq`LN=s^XCOi zjkMr(zN7x_i}!WnkJ17*4>L(S5?~f7+npC$4)!|FJM`@2`zt$NdPBY99R+N+sjT|n z@ifLVa`WHTQf3Ve^zE5*XG17Rlc2NLCaVPNTIUskumvFDC{R0zDWa90w&EWP@>IZ) zx)6CO*IA|FE5ZktF^A3@$xp8ituWq?s4d1&&M%p@m5g)lQ=t;^=gZ?e%tv`5cDK8< z7M!A*I!hKjBUg4Qkd4HJT-5BUGg?RixdpBs#}hpkhU3!8{D60Fs|wT-wgYB}Cbmq> zWTrqit)>dd7>g&ZNa&ocBJ~=o@)B!h_Dvw2d@1D^i7ALBXRQKigxw9A2Tb<#EPfFS z*R8Pk)pQU_>0hikN(P*leo*DqgQ!FM&g&^mjX;c6YGrR1RH_Q%u=ChJxU^7Ufgvb_ zN|I)x|Ac;KN(kZE-=PF?NsioLB*JR6?A4G2Z-zD`U3r|o)R3wFNv?6MD)ZxmlYFW| z<9m2n1C$0Rz1zrC%*R{d=0cOPAdqTnAN0SW3041Oubd2owIPeVv*|KRQJXECfR)d3 z4+^V(vL_hH515!L!@4T5=UjI=`1nFMA>j4Qe51JhLJ~kNl5Fir2O>ov$!^6)SwRJp zd-;yvbc~b2RVGtI3E?3n_#B#)RJZ$0d|fkDEdeuKB_6OMCa#Bp{? z43)%yF-mn5FntjP{~Mol(*tr5lH##4RmD`V{ku10H^xA`1X!xWlg9bO30Yo{ABO&dF=ypIb;fZUwFl$O9V z(4Ar+ju>@%6(I_3Wa6g*R@P2v;nmU?tBUt`NI0&4Dgknd?amu@?Ge|h!B+{nIl@O? z;}MU^01f3gZ>{-BH%vvlh9or}CQb74qTtXo_Q}H0cclPGre^G@xA>FGZksb2B(d`C zko*m+4+?EFy==jQ*Y;jeX+8>bz+K)nF+`mgf1=3l5B#nIsO4&WoJEWx`O+DW0A$e# z-{3#t0DH&8**bITA+#Unu!~SGUR4~8c!=b&L)Zx?^ z%SaQ0*}QGh^sA?Lea=>9S76^Ebay1<7`dr5P2`;p8F5S-XT_C{<2#m(Yzz4YK7ren zN}(WD%@7Z(>EtXxcjuwVxgE{6mTq8hNyCF3$9}I(+xuj@DAgDL#E*eM2tC-&@Y+EZ zu3zynFix>|R=ver6G0lgjvSh3{RK}7yAM(k?6I&Ue9dmxcH0V@e9v)f%ewXLo-Z0a z>2*mi00$<3`xf#8QfR35fG)5+TXZP6nY!I*d}sJW z1P6%2=|$3cMzED~i6ixQC@OI>t*!RrPKJv1GiH=%Z_u0xv}hxxt^OU!fAsTPKnXX9 zZ5sc6*+X|Vvg-@^G1Loi&gp~^sO_)>^-x@-Au?uBPkl@d*4|?P^sr<5??VWqWkLFE6?uE)Z0>W z0@KN5e_~NnGPWQ1N$!ZjuVfJ`azG@c_8fREjfoX>bF7jekt(hHNin_YC!0}>mN$$Q zsGIk0=1{;4)t(5*-}#|^XUZ(OSfv~R)oD3bY23UuLc}oVHqjnbwixgssr+%{&%7V+ zNC4`=NJ=VFCD)S;DU1tE^z0a-oqh==X~*q86d|^|yH_Jy_BvCt((Y*KD5u5{hQH4x z=}mHryAR*u1zqsfsxtSs`b3GVcd~{NbxNuQK$4d`>LgBX7C(RJ09mP47B3Ja>|_?q z>XfOMEK34T zY3dQ~y9JaG!*gC~l5zbyjz)@xU9Z6MAK6F^rn*+GRS}of!$(OCu2G|v!f209vps)m zA8kDLCasD!Lu}+5_n2$W=iCqedSGP@tStjz7;0U_XWyXF=^Ze@umQ=qh?%?2G-ygL zYfayK{1C${X{Td0a^O7{_U*X#zT+Ux3DPDrZAz78#=0NJvqcEOskO~f2k9LL(W)L1Qvn}qOoZL>i-ir6s0 z3Gei&!@m;`$X8dWIonHqSrrH5CUO%>K!kkFQpU3MA?v^es^Uyjl$n^oNFIFuQ)N~G@g4L{xRU{VZQ*odlj#PiQR^CPn|R4B-BSo*eQ5*d1=X6hSN zH2W89_e*F;g5UJVLUDHS8wJk(!c^o`w7YwdcAVi(tvK_gmLa;GrK12RN$|%p`N;_x zJ)V%7yBX>7nI>cNTzv%E*BH2|TtjSExp?^)jMiC(5{$ikB0$us4c1D&Zl0t33d;vG zv-^4b*^poMp!fLEWLoil@J|x-BL^7Pkp6?1!6OMGcq7xH7lb&A5I%m@rC^K6bnd+= zCv}@nh*TxXHnzbCNHL+@8V{f>not)0g3IIdEwS;u$={>WHF5ezX~s-6AGd1ENt_}L zV_lw|$cH5~sDR;Dit@#GZT*Qk%!$O^1)UWvUC&x*!FWNhg%S=TxEyVUk46deQccbF zq<68OyB=?V%M5pq>cb^@4dO|^11Kf}n{GVL;FZG(=_xfBXRVNxNn4)(Y_Lc};4aD< zVL7Z1jnz}SL1L(d60>E_ls8k>sL&pHmPSIkoW_6RD9{e;5s;_qR|Gd$_onPl`3sHf zLzn&RF#ek~at5fxL;LrjwWq`OME_>a)(qPf+dSMNbrzPz{bH4&kv#-8>G`@yJ78nW zxqul-rD)SiYfoFZgZRlqd$z*^1OwRp+otQca2u^yJ? zED_UMbQq+Hi|P)MSdNHgXb=bB&glT5v1Cba5y)m82>^l(5pnV0nef@wg@lv3z>OS7 zcs7@SF9aZJO*>^EYvNsUtH%7}Rdg+Jh}S>v z^08vOY^=AcDXQAhy0`SFGr$kj85Mo|Ms-4|tk|m8xhZsjDQHT3cx(H+4mnr$C#{{- zBZN{)rCMWv8M9m5UdRnLyB4^OK1s2lo08erm-$(d6FJ`dE;s(9RKI>mVC?RBsO5o5 z#38m)bC|?!Y0cYzEJ&(AVUC|Ona!ifWmn!aZ(uXVsp?odlG<#z(pOQlpNZhqL9i$5 zkK5zx;*ig%!TN6(S)%zyX@T(I3*a~?!S`5sT++VE^erw`a~@8_J-cnAd!;BG0?4ES z?wJHCabhG$qk^i_Sf`$^z7A|#NW5to8lmlnF7t8HT8$1nqLCWp$@BY#TEa>%xiXtVyh1Yb{Dn)k9G85uS3(h#C`P z*PlBZEP z@^v+@=)u8Qj3Rii$USz`_5s<((KiF=@+qWBcrZEsCI^IC+54wxFA;IXgoIMSNtM?BV*^e_m2C^L*O!hg@G(lwy61z^aZ8HPUcni&DHP-)g_m{(UaYX}=Bm0SQ1tPj zAY!tpQ~qcVHK@G=0ggsI@gu>8g6)A}H6<#sINjrcPDKgO=|PK%$HCeLH_a;9QU# zqtXT&WGOLKYIV;HyGdO`IP^wE+ry`7gqGJ*QUKEPxD^JI*-C2I&Dx;yg(+Qysk(g- zvX?Ef&`K`(qo9FwP0f%CmnWG11V|m3czHm)Fl>H=ZoHNfu3pC4St{VtttIzzMJ^eh zj6Mf!_!?Dj@{4CbpXh76){IRQ)QT;hV+-5l(0I`uAoc5w{4~G}%_Y_FPitYL77Mj4 zJe5f1`KWKJk)pj^R@btfKA(gvt9>q}c?m4f-%LpP)uBX5HR8VSzEVKgz&oA^s`d1)~o{z zz(xUjyqu_WW_4Ix&!V$YY`fZ)ReR*V(V;)v*B!QFm#(dwery>vuT`t^2+_pwZz;a$MEB-L>*Q_gs4;lSYrcm0* zHThZ?+oPG#gf8s+aM$l?LFH6ohrn!X;P-UHC@xg-B`^Ea<{Dz)n>`=bvb9W^H?EAI zXKRg9gRvH&$Wwx1eroXEQDgw>-5aw@$v8DpBDm(D$K}eQ@&s9ju$ii76Wpw70NF6^L% z^ur3Y&(_lV7NPEcs3jv-H@g|JBSy1q2^5biK6i;Z`dFQ*5|R=*lbGz@Or!Z-FC!iY)t{ooP%UZ=H4O+ zo84@}-H2Y9oYQ#eDbH3rOguL~W0X2foXY3bAmJd9Mx2+)BCp*{I_n=BU~5CA!agVN zoXe*(_ht(hLk%PkBKm^@%q!`Km01IwLwWQ1{|nzw0;U+Gjj#niUft?!`y3f}iOEa0du%vZ?yT@7ZFO z!bJ?f*rSTi$E3mC-#HFUF>O@aZwj=*%5jNb9Na1RR$ou<#>)Pz;&Zkq52<4Npi6bg zQXn>TS@h z=U4r$q^lao7Qu~}6R&%*?Kv#&FcB|5V2euB(`Y2*hzXmSqGnveU zbfx-jyQ?39l@E5g7jhGl7MhlG#2vL!Zuc(Ox3~aL{S7~t&&~k9U%0fEpXXT{CNrx3^@lXB__7|l;}Rtm;CaGa2zT*iXK!I6qjsCg0JJhE4;J^-2I zp_Z-q=a=j~kJjw;uEMMkrM99FVmk{KCqIn-VhN8t+?^DEHS7h(Nq1W@r`_~}chG1U z8;NPO{A7R3GX^=20;feDZ#^6B}=*JPk#bF)>p)d3ZOZUE5wQETEy#;3;Y5Z z587zKd*@&IsY`x9lqbKLq&~o~%~fTGcYZ?)|+OO4I?9kU^8gh3N9Wrrhq9zw6a*QEb)fD1ZA zGgLcUTj|R`Gq2lKsHT;ll&Ki?2a$Qb6$xW+xM>v(drS{A z`5eS)bmy?}56RoJm>NWIq;=LQc7P5Uv^oE2@BKvon^v7SWaoTu1bK2BKm!kaQHh*8 zc-o|};QZbK+{LtGa${H6AK36VQD@K}#|!-WU;6My)xXRma9-j+6Rz~n8z5*1<^T*z zNh{#&#wEIc$-}#-;gtOU-)rE57v{h=%uhfb^DmqTaFG8UWF)8u;6yjUf8j)cko@l; zK868~_DgoB2ad{&m5CH~*W1{3Dlv0{^Mp z-;5)GI^O-$8Gpb1g9HC5@Neb*A5H%c<@ty41iI>fLYfOmUPx<;pT_fxkG@!igJTBk zqO4bMRKER(1=tFf_%&5qg1-eIQZKCn4!c^n`|tP$T^y%l*`BYC0sLCy>-gssV|K+D-G@p@F+9OF@Q6I*~%5I}#mu(hq>8AiYF zGhb^3&dR>!2*8(1tvqB_e!>I6%TszoE!$CPqtW5ptvfl~>+7btr-1_nKfkw}E__Bb z1dk(|#Vv1MCXFo3H3ng=h8jFOJmV%D47_8jT3gJvd9Qyu>xp-QLX%hv{^I@XbK@w%X93j*vY~F_G}Ar*UJl z?P(M-P3!?2!uK0|N+)&{kD3#tpRI2mS|j()M{u(G+rpN&xDUIU?wA&5z1>%j0`N`! zdgU{njl;6b;CCmb2h%koByPbjQ+0)3*Y;*iro7jE+ngsqm=y8=f~8duVk@0%c{B)g zWaY)SuVpRGslv{0!MI?qXX(D%O94|7fxY>RVRnpCN|`ze`=7IJoh|_6sl$MGV0Gg) zK{-qi_g1Ox+f?$47iQ>md(Qdqu5*o+S9ySyB#v^MShLpodHU<@cWsY?5wp_N2kzOk zcB25ri$&(`u{t-YkdLRoD1G}MwH$Z6*KBLtO3_v84z4#|`_aX*RI=WFv#QlH2)lCg z;20oV=i2?u{#|FEE=kcse1o5U)@1^)!K0J)D29ZKc$xs8h3WNrhS}khlf6qf-y94f z7%sEKAg~tt!UwN=_@;2MfhXu!@6FNFdS%H~Z)|q3<@sEar2WEhkAm!(Xhe3iDfF-j z7o0J(y2kU`5j&{=mBD(Gt=8>RkzA|lQ9YNQJ~MMN5yL+WXIDRZ@~XZFR`_c42$C^*cdX-kr}!m!8a~ zUC|rFap_AbzyIEAf!uc@46J$)e6d^cc9bZX@zf8dTknFCy$!;5_fNzsMZk$S)h`-( zI^v6PkLs1~vR{>1`+i&~1u>6(SHO4;7nXN2Hf35;Wm0#MDbGIJ?>bM+aj>6fN- zAP^me%>*erGx}sfm3H=j9%^6NxsN*<%P0gch?_V)Nyy6y?E7)}gLF{1wSjDBdt!Ef zAyv=Yaq;yWx2d^HoG205B_KqK~W;e4cn) zbJ*_6tktcbEgu@}%xrAwI9LX0E#W-Qn&sA-FQ-!z0cI)qN54qxePHBeY@pdTZ^JpF zdYb4Jly6Kd2cDn5+0T@9mWe_*d(aYZmQvU2iX$R@X(74D6_+yIKp0Y6LzVmBXKK9? z<3~^0CokJITYmRwk6aTsnQmmENHN=rW)^VY2#3!Ioi9?xH$g3Z`Xduo8bUXhSL&5y zhV?Hu7D~u1aKZ0nN?w==Es~=9k!ymYBC?Iq`dOqF_n9I6&XdjFRUmFs4$tHd&p^00 zcf_Lsv3*kzqE!DQN4zfjmbiaxZB>SHCVLbM2@#wB;QGXx)%3*Nvcm3{w?8LlF2D7A zKiiCGYpJfIA!hM;DtuH!F=!W>UB;&*`(tuXGe~_)y0=C5@pWmYV}?cH&4a6Bhg+b* z^>gx}ZU$ui?nIet^?=>`c>w0Lz-H|6%B*(WkLm`I2_Y{G zTx+`R&H}&FS(Exj<~4``Twt>EjInXxDuC6BA$0t9A4yN>Ng*S2QA$vL|5cNEN^i^D zz~h0F+WWA?W@uS^L9uRnjHl-C;NmT$1J&pK7+gc8HEH7ZHv);Ya3a#N%4}D933tl& zx7&X9Uv8}hN(w*L&JKR(`vsCzy-7O&Y|Of!Td~kc^#i+|RK(+d$52^+)k8rnI5v#` zvQDyz6j)4gpamcwQ$g>moewPBw_3mKT}tBH^6O$I^Zc@x`3csQ&(AR5kzDVSX5MVb zPyq#_zVIXnd z)bIM{LKg z^H0}wW#+4mp0-WBawFNz4wn7BtcgmzrL{$tC6h!#01UNWt4gG0cRgCAH}im(_e^Q_ zWccex#Z?EnSB0U8ob+$1P!p}5W+VJo$z*|pOBJNaqLA; z0Omm37omJw>kvX_6@B-%cmn2@5c|*)W8gI<+P3GTZ=}c8IOC%ze9oMa*}6H;-{kc; zP{~Wuxv7v+02w>^Yq?e5_#hsX%lKa3Xg|U>wTi4$ig7+&Y@rGtu*E4}MSF{Pm;Q)& z*R)KUAWUH>iUF7Y>K=_y?>m;Dty^|-p*8qrr)%$M@Hdm%3L=@Cx-f#{E#QPBGUthe z@`7L9%&kIl^Rfo9dPo6M8pw2d2%QEg1~SRDnBIp`-xFnF?_PYABpdQ$V_R5Pmqz!A zaby7%&yz5j2$`6c0U~)q3#HSy5xEjQS}FBU&Dq^p{m99w{e?2H85pzWx1ZZau5>hr zkZC|=H70>kN-UuT%d$l~da5RQ6V$A!w|m>Y>uLbIZByAT-O1Ye+MEQqm4Eg%cb@xU z_zKu4NZd)1!t)T73x4bAkt5bZ+K@g&*KaonsP!^bexRQc(4hokDvI+N=1cv8vlS4k z$YP^>5~1h7M&^!~+GSs*G`o)VWaEng|A}}x8|11uaB8az5DE?>Cb6J@eij41ZA!Y$ zx)i>6x}c4KT6tr;ygB#lB67=UC}4t~yFmymNt6aVcJ9$j-m!Ot1q#6i_ohA%u@EB; zJ(`K4j6wT>Bbd%>s|oJ0*;(J-ep>XUo&oxv zPPve=F(A9l##J`B7~3sFVinIV(4>8|=NfyoAZK_#&9vCS`}$r$SlIq|gcS?fYZVy< z@K3PLk#bCLxa;e$3Amb%gmZ*5N4d=}u1v?qMP4pVJ_s4C-j0;QRmv4igQ|W!Yi}DS zhl3;zjf`K0=fP=VQB#MR_qrLp?nkhefUgD6g~7aTEQ2`eUOr6U?gN{I*oNFz$|Ho; zNfN?a$)sYsNKn;xq`?FhV03f*o?2&!UBObEC#$ObTIQaPftEs#(=MS)#{3`=T_*-aE3-elRpRF2`J)&PcgCxWC7qGg zupWHuyJmPLG@&R)IWB&F&WOR~p?=hxNkcw{13m~w3rG+8c2QDIuM7}om(;m9K?3U! z`TdHfbN1x<*b4U~w{I^ip^oj2(NrG)i@5)eifUQh7vvRr|PY+qezT$?WpMSulHUp#TWz=6Vn1)5C4fHM6vI7JNS%Z*KHtd@i#`%31`(oPvm%4ZVVdy{hn?sO z6Wz&@zB1=sd6MuhBF;)BqlCwnnuMEe{OliD^}{~hF6cg}1n#dZRHILA+9>caCMZ|} z9%Qi09@_(?=ld0=fd18tgNa<6^e4=tW;`&{I};wF=- zmvNZWao8Y`7LrvcoC#B)x@VeMhiZ4qXr)EM)a|t8LoD4*{0D}@+zG?6Bv&}cO!{wR z!NAtw(rqNwq{w53cIi)5bE3+XT1+}K1YbWen;~Whm@t(hTKSjUNU9tW)JKupt8!PG zIb!3#w3j}EbPC53sxwMhz-`J@cW%h(Vz^+2d|91Bx*0K^(RN)wW3|b@^`c$FsThro zYw*0%ICT4Db#Qv360B2)wBZldfPuUGk~GC=EsO!&bY6B+qVKG>YlY->H2_EzK3B_oz`SjJ4YR{Fw-ubUu!c)b!%) zLVxM#8xm>0C3Cg?I^Ygy93F8)M-=Gb(1bgb!NyE*hA~J=7>mph-3)>w^F_v8Zx$Jt z8(0P|8~Y&hEGqF+!~7{UE<*76ZLKAA6Biz?tHDF3h$JX8V0pL8Ge|05O$%VRY8xv= z5xrWH7il#iIp3!`9CgEMsxcy`6q)aoBEZ-(oKB5Go^ z*`Vz!u9OnhDMF7T6l$=-8CybKveM7q1K$k#cUmsh$q@4NnF>?pRT}en5+>o$vf;#Q zs|?Wjr~0Xjxf&AY@A1%q9d!lb^3FTdkGhD1cl;a|h0goxYo?aMG-GOt;_N1Z;vU+J z_9b6x<_Om%bF`aDTja*2B$H?QGO57mSYn_*yZHS{R2yMwpW@7xN2;X4Enw9n`xtd# zcBu!rhm--Zsc$!gWZ?*Gq^`JfGbn^m9p264A-jJ}aV7Bav?PWX(UfAg*HY>grtoLG zg+5ND!S-(J2KgPS5xxN`xmgR=M9d*KXS3k}&>3mEFxHTnuyahd=1NsA`$a^5FO#lmLE>}$gh&fh4#QBj6hF#d%--%^_=4m(@HB! z)C~4ccQBuXX+R#1EkZ7olkq>kY73lZ6+62Wj!Tpc;I#5EYO97)EF_K36cf}uNLY~M zLzES2ifHhAr!d1x>}fzN#Q5p4@XL(Lrq`wJES;7=`a~Z8!i+1xL6I)1AOFl&qJ$fd zB7+3O3_pSW=zxN^TRf2NI-{D4*yq(JXt%@3JZS_@S=MoFD(W!Qva(K%uJtN0g3g>C zpe({-*-r~l3u=s==Ihb*Ao<0dPcCCFHE+auvx#FJNm46F`j+M9K*#KL*GP6vZm0?Y zs}`Wb>hoYIpDM*I#Xgb{(N8UCwt5D#LbOW^XT@*s-q*PD+E<`MAu|a#c!`NL<_+8S z*y1Z?vJT-V4E`X#!?=~k1J!h2)UqFh*k?;`Dki@m}vpCVN zM!bvcB+LWJZ8jjmWBwySB-b9`8kknZAoRwYSRoJkB0NL!BJg(sKwb9}d~vcwt}X#L zxCj-Fy9FzsW3X2cj)(2^t1Fs;^X!uw}D<{YC+E1IKIcOkp63u%9n4z?Z=^i^1DY&ZRH$Y0OoN;YToNku5W}Cad9rMdg(i-8Dy8 z$b&wwrIUPS&f_Br^}2^ z`uc5~Ob?x^HED(|31njUXYzQ+{G+-4yF*ukggOR< z778@og>2b}^<8h@Q&J?=V?R)(*1$4jS|D68cW8k^K1NY2owa-7CVY-(l~;>*Xqk=36~8_acN$yR@A@goMmtai5$?E=!VPP?qw19D*?T8?H%rp6+J95e(#9qs?+538RxB3}XnE58-Tn zI1EuGdW|`x=HIexmhr|n1|yo#6uzD^FO&2jOmmWnuIwvj7o-Yvc5DS?{5cmCDkWa0 zzLR(Qy6$FeDbu3W83ds25_DVzKx%-)Q~S~$#sh%781N_%Wo)l(zt1FctEWEqz&wfr zwUiRedo{_XON1V?S*c;mGduC=k=zv-unRs1s98UC15JSbx(VZrI6}mJjw-*99x0#W z2n$~cjvQk;V3QN;7F`u8Nqr!57BIlD(Nr+2I=_3JhrEr)R~LBurlT-#RAx|I`A8#x zsK%noJ`D(u`Z|-jZY_VU*<-;9^QMv_p0aJfyco|JY#U34E!=EAA!Ww5QDWMGTq6C0 z9~-pt_&dPLj9~wmwsLL|@;E+jp3$Osjwq#&?k-d{T*<^85%D6A!^i;}L@Eq3T93F@Gvja}x$KZS<)R?AWJdpZhe$B^j+3z$ zc>(^BRm_Z&#l{%k0L-aRU@hLy_rdX!s*8xZmwzAejt{!HK*w-mz%~EC3|i# z#H1GTJ>9>FrL3XD#6@C@icvnGZyS?g4H)ipCiQEtm3Q&%;Yfs)pYUTW4p*+Ux4;%` z2x+(RS_O~jMOKEM`muI7y(?hpXY^O##m-%wRMmYCkUsa@%V&8WoW+(-C@jM6-Rh zgG}1RSoyxN9s7N!DNJ1LWN=_BWEzBjI7KYq--$h~7!oxlq*d^|P$XQ&gn4l-l zXM=Nb-ZTzOe?Svo>(6^yNT+x+I_(7i(UIuC0nnZyxJ|yUt~ZxCMM#8VfDyO!vKPC* zL9aYfz`&Bvmdh};cj0Hp5l#Vv>TYgRoEwgFkvEJh*&W>9vNo8 z56{OZg~uUkN{(HU$*`@^;A1_yYta0q%FCj6KHal#mv|i%CzUAW^Z5e8%Pnl_BL`i3 zF2z(H)<{3w5o6v0ohg%rk-;ORk_HroqJEgg*qJO@P-urrf>WHN;)@il*JI{qw3nPn zUVomW!EOgo40=oiOnE_x3|UF)lj1R)MAy_!t7}I?E?nL=rWGI`r>`%@f@DqxH$Jh$!}eKM zAK+YDID}rXDt)4DTObjZD;GLQ!*|UNI!}8XFar!CuK6FLBk_}sZCdr?Qz)-y;)1l( zbTwQBhzFwvc@=OgwOEL9{~KypKYzZ#`0_(0?`CXRo#M>-`Ha2uf$icEp*Zy2l? zBA85>kgh3cBJ}*VYo9zbQ3~Pplu}y)?$X=u*vZcSaO~T|A&vI;VchSN_|FO|9m5bbGjjyClg2s$B6v{hk z7V{#lXTD@*oUf03YoF-TJ&y_}lwe{^Paxqen|%?l3LIVoQBs|$*Vk^`OJl-R9x@B{ z5+k>P`lHo~HFPWaTM*`(OMwp+3OKuP_{)UHyaTt)z-kfS!>jqh(0yZDP)^>Ku`)}F z6NAT=1s?thMy2#eR6WyN_pb8oU?~a#@y)#gA3{U|jIryfR_6UU(zIFCBWjTgD5tVM z@{-U&#(X5rc=iq6D|DDL3WReh(0Ymt?>>^TyX?9P@CU)xIyleu`!})rogl%Pu!Mah z+y+CYP1)3i^o6_{^`oG7tJJVy$#ESWp{i&KyhMx~5Lk*aH$@IHK$)BBA1-ID9bH|! z6cTVlpQECI`Sz!S_G$Ss--IqNhLY*Vk_h6mg`*M*Udq$Rjxgm;+$~_8jt2=7U;u2 zlz*QxW!l>&h}TzdYY2_M%e_#qb2;xL?V5CKMdYwyO)!Oyw?% z>ScUjdu_8jR+@C!F|BO`tBet1s0x`Y9qTw(thd92mTei9!-UPrWJdbxtyzfMz$7V} z9Ji{nz9YEkKzQ2LN?H&+r$`Z^%*(uOR#R#bSABCo!o6&X1lRdOxN7lvr(Sz7XT-p2 z?u#G7UZeB_O|j2qqws4%4!sd^<#N^r%mAl4MupY>9`>rXa`n+L&7G81C9i-xBsN5+ za}~@sT2bHxr4`@^qvVZPV14TiwNoNXj^*LR7~%$Z#aq+_rLWKi3u%)Pm46QN+nx6) zH2ny+Pq<2Z@j`TtOBC1lKflMKpQn}P!==ma0jW6}s?0Z@Dvu2rD>2;mKO5UEg+0gI zV1a67wNmJkkz!VJJ~}cv_MorkB7OnlSiIDk!j`-0yRvjqK7n#KBc&RMM^o_C$f7NCtK=jun?f#cjNgC~J&uRI zbiwnHW{3}Fbx+N=5LT%Lhw_}ONykiB<+0`C;y#1&P=t2~u3biv$9T}qgiGZqgiV4H z%he_~BRWU-_y(|Z!s!xuEi`kw4;de{xr1Ohh<=UTlK6L(fmmLX48B@}p;SMZBIcOa z_-c7aUBjD?;^dI+xufK1@N&Zq%w`Iw z4mZ4jMFfBrW7Gr%eKU?Js=ga+D~ReV{}vZ4XJ-2qhQ~D)G!+ z%FTdNFaUb$0e3LnK&Miq2$5TO7+^2F#@i75NeGFbJ+me(s5Wh_?D1b7WFJxRO*hjC*Hv9-MFKyQX zAs-v+8@M|`M^?6y#Ov`_t2rXTH9bq;0Mn(w%PRu6FRrA?(Q6aMQfu1fP4q zZ0EpwE)ug=^cNPO{e|cZ*5#(P&nAH$y{YetaLp(JwwArO`Te!7G>1n~SehlXn7f#9 z%?z`XL$n<<6sYYv|LD%&T{$dfuZBF_bs}b)vt> zsGLYLBt32@uP3@898IbvAX&gP;%r(L$9UYmUG|C2OeA3oUJfTN zSrJD5@hLFlQ+k_Vsip70L$15O3_Moj4wyLRX9wc z`z*elkkKBdp>sTxBo#+yoDddCQW^L8+6@2W*2hm?lieu)Tb_Sk21a3%#md<sLNCk~UB#vMJp_DB-oEnR7d7RpT8;EY$zVxcFjxi3D) z9QG+jzU#LX;}=L&m#HO$wm_T8LYGqSoXNN1)<~rW;q#4bj$N>NDvxI`Ii2|mgoG76 zU+@A@N-?>p_}GSg2*F3O$0QyGa?)0eNV)O4Y zzU)RAWL9c{7?*~7j}hiMiGdZSrOX}@FF+0vCYwg^ej$tvmI`ZJ79;N1N+v0QB4-&* zdNDtJp;GMNb=07z_J3u`5cTKTg?<~y2^)nXM3gliXSDafTzv!LvmL{duKK)M2CtStVIr*vca^Q;=sL=1XtO)?uHeWG9?N1`I$^r!yH7tWc-tbs&A zR@t(*KvZ|3r?SbSap;YsI_U+*bPd~Hw_DKGIuMB?G0>D+s zAG)PU@o*sz2>fu?r5eM|rYbfMW2(?!eOr9vJcoipMbj+#KV_@M;-BTh|clo;! zMb^cSgpIR@tPQh9+F}vkTCL>Qf+gFR7fQpkt{z+W^E1l!RP!l~E{;;ljY1*e*v^Aw zFiH&iPq6IIRH`Ys!Cqp`hm2S0O_n^vt#ey2=i;2cIoWSbx;`r-w`W&~BQi|FC<9o9 z3jbDC;cLNc7cjaVzu1vn)FG?~3!$ou=h@f5_w1NQD)5<1Ur)Ehfx+ z?C2HAv+0L#_mYI3l?mFDoOU1lK@z>oQ(0_Og}(}8mIa}T4eOa(RUo?vkCxHE%3T}* z);l?}K5JzaNx6)T`OE5`b7rd!{7!Y`wWU%E-nT-qb5rm|Dz>lA!>k;0tB zn)o__^i5?__L*2sAtolm7A3mRR|1JG*VLJ=n%`lLBnZ~TxEsS;`or!r=@ZP><6d{7 zMc_Dv(DHmFb3CGMdGQ1RqSzz(5=u0cRT%<9n31QUZ4EHYJtK_<$>xVloceJkGi5a< za(wn-AvRIu4BPz_Lj4{R4KJv9yI<$T*KEL|l&`wre`f+)4v=gEgj4+iqNX_yDHx+L^GGpep^zMWRndW$Ps4b>B*+KA3#@ZG5Wyuh{$X3hl+V!%=Fw-Np>B}(b`Bb@E z;Rh*L?(@@2tMJffBp>sOU1 zAel6b*;;j$1i4d;M$h1%U;tlm%#Ei^`zaUgB54|Zs-l#B?vC!c#N4(WU;`v#*g+Gje z(){X~V+J^h$RzCARVL?tg=5eM-auZ4wI9o{w#UA-HCLJ52Ts8wd4=+F+Acq)nGnw; zr@|xc2G#5bF`G&`1mUKY+H}g248ic6OT=L31wylQrS3FKY@@O?6N+TbNFw7W=TQQy z{rZRTu}vjaOG$x6Y^vp~`>95htB)cclnEEP9gFhHku*ki zNZtWf5#EDIZGqVHvT_okcO54aCQetpK2%OUfCP&njzYz#W$Z~}86XOoGMwS1jE(Z6 z9^+#-(?Db?!h!+Td7s{r7<=n&D=kSbaGbA(IKS%$R!Luh8kKpC22m%YVcly_8;Lik zGf8!^f7zm|5g_Z;y`^c9Af>I=RsGbvFDqW

06HyVCFYvLhI3lpZK+ks_(Hi0w|* z;?5qLC3*WuK!ZERurQj4D^xxcg(WN<-As5Fre&A!%~q%+Y#+FHrC!B60YdFkGiv%{ zy)@Y66-LUH#hVtEp7z%>8EfP3K2Fc*pAY4Zm!9?VVp*h%3#QPyq&Sqe$gVs|ggJgp zt1)QZC7xi4g<)HmTyvmfQ335172WBcfitj1Hkc880cUd!gj6b)oYxEa**2k6EzI5y zL*%Cot=BTM{S;-?I)g-8w?q={H$LEfJ<&NZ529U*zGbg+uncE!cS^vtTQz^onAg)C zam$5G=SWT$#azUq>Yw=IIBq2FQdVeU$GKr#8O-r`(UERcdCYrwLcb~EKv zzO`#gT{|)Sn+)c<;4MnYvD>%o^r8Nr1fZ)AY=o0jdz%lLl5;LYoA_pWXiOD`D*H5$ zt3gAs{M?UXFw(4dTr@jfm`7aw;w_aMO6FXfIV0Kos5p~6>D6uq73o^QINE`Iw z$#o9GRXQcJ&?8@8f)2$&jE?(c+_PBWgv^tYu6>Qr(S1X!-Kt8E{;2-AQqrrRJwlT8 z;31dEiF3am8|DtRTt_rsO}I>J8ujH=|2J9-4}x9IzJKJzY^j8|3rWbMPfEv z?q1ZHd|tos4b=sh=||Y^n-jW|%a(!{rNZQnu=D7QRm?prQOQFY9Qszyrhb}_PbSYY z9Hws}&mw*$n7zYndh*J@h`7~sZR&dn$ZLC!X=*!TGI^15v4r@QdfBBU6E(1(Yd@My zdLKZsQ*VrY@Uphc(CbJ2$i%JIIRBpIM&x~-&Sj#6eyCsd#KkT{<8<8ErN5NoCv3g@ zq4S{h%-iR!qIxxl6;PQqp0OHpdAn0`nllgyk47wI+R&bv)xVx>m{;Zk;O<@`Thj}D zyNpqZy#>IBsq<8j8_`K0xt=$D2-G}zb|0ztrSf=a0haCpZ~K*Im-)E^>PMJ}a@|}F zvMx>rQjU1g!Z+}x5|xz~^GlY!HP(YS02}8OEY@tnpqdBgd@Nroy@qX4`f8r~G!^rF zRbI*PN7a#Qp(SZww^cJCAPGP1`JIpbI-4I%+X*iH+h~cx1KX^!?oD2N6*PYJ)039! zGI|oi=(6B&lHvUfWUdHHYugy8)D<``RcwD#>~Gh9WolKzNS7ri_PpQD8hK!Bau>+V zY;CNPgJySqQlVXv$5`UGzVYCO*Q*mX`pyRIXX}6tKCtsGw=?uUetKDpd;5Uf(Pv#9e()D+A%}JF0tE-@`!_#dWb*^L*L1 zjHd29;niwP5;5G-YGSVopQj8ebB9mB?_K@7rd=CDG1R+P$h!ZuK7`v}BC29?K2kMg z1@m9KLM9XDcMVZ&iZD4}7PK*J+-r7u6phjZ1Z0AQQQ0&$Z?~b0c|OTACrP#JcOdkw zx4G^M)TQW7T@Wzld2gWLmd{6fkDq2NuD5ka=(7~~du*N6MuLRUt$Po?hEffMOV(CY zHj$s7Ts)bHc5|8jYo_F%B(1|ALW|$navDwrahF5Bm31)wRJfTb&TQeASBR-yAEWCgCiNWtq&ezb|VtM$! zd*mu9qcHW&qb2vf%PBVx7@>Cv-EAlDfqZp+Z3kMf1GuL3{-YTrQJ&)~@ZhKyhEDrm z@8vAGqd(}?L=VAwczG` z-C-Fj=~Erv7@#3$35G7hT9j(yql60wFpsiPN?BbK_RS{iGa2B{_K(^K;yjL4pu|d(QVyJ2e!^0<~1wUPYmm z)roB%?y1oWW9L3B)nQT=2_u%0mx)^HG(5r^P3WP0=+$Um$M7<-o#VO4`C@&@_iq+4 zl**i{Y%en_d{6Zh^X(k!#ca-_(QRZxAweHUcvLdTjMGOXj`H3+U+4|L^YY)oFi>IZ zP_|{QbX}K~EU{ZH*)Lt+4RG&rgK=xbemill-8;e>wwC<2sJM%QCFQZGs`KXJ&@@>Y#Y7nSvX^ymoovOJc*Wc{87>T$EN3Y!&M`QX?Oj~J16 zmEXp;#y70cspOG~QmlX-6}JEU`Lz~=&Uf4J!0zoI8_3duXZ^Ibjaf&f{Kw-i zfM4fZUg+=#)b32o+pd9ZVo<{}D8>nXVu~Ik=qh#SK+SJ{Hjo~fsD%cFt6Y&mHwFIl z=VP`$c@>bz8U1tX{y#oqhyF-J)Ay^YfV<$^+vMo)(Z4TjK-7s^KT9!kz+31R{{MXR zC!wT}zeoa|1b*tEH+~ThCr!fPK^vTECQ>lW6a%)3ZGL1t z8aVj+OCta1Yb3>?ttmePw81YL{kovNH)`ah;MjNA=l^(Ja%RuIc;D6=CGdggO0)gc zyR|{^zU%t#|MkA{sS$YJph0R~HK2^Ds>J7GgLNfWjrPCEWr=#B^2V-BvDNBH)z=1) zB-*)S-wF4?p1tY;U^~PK!EU z=Bn5GSE4wPl?0pRK46PrKGk4nF`@4U9m!MBlPuOwMvt!l9ws}(<0AL>-rOISo|InD zT-BnZM!g=yQ+=z($y03qT6MT&ANSumxdqp?7J4lFSY%Rw``&^UU8Yb~iJzOCQ%SYO zSBrn`4J&N0m*@=>(*G`GILfUv3BGw4zZD&y5qbRKV^#6z|DC%o@mk&2=Qq|lIQo`1 z;+acTTBLh4hrxI0PI>j>*n$mC1{3AZ(i0VaAAU~L{~65c#DkCAOW?QcyjQyi#j$8O z3Z;ZzPBr*xPrO@;AJn}U+-#@1aR*W2$L-hf{o9WMqokpJw|^4+kyV&xYB%mKBm}uwYKVQN%Ix! z;nS*nc15GTID^0D@}D#4Z>>_p6Bh88=kZO$WF95M(Z=WQwlf71FQr&Bs`n;V6|-(h z`lthC&^_MroNVeAJ9MW4DF;Y;xD%K`76o$EtqI3#sr=MVU|{@Hh-li-D(7EE08lD# z{%u5|_4!ZD_)A~@r1$4)05sD7sBW}20=?#3SiSWkNRejKv&+Ym8|b3$E+ov@b>%fg z$mC*slVH~f3|!kqJ$!M09ZYH-)JEB^cnT0Ldc6R2XWAI~c3Bd;cdK&OPr0Bqull7} z_*N}8Zg3;~bGIQV=@VO@z+&cvW}DQ9BGJmX9QMoNxShk2r&D`d+kIeHT=D|-Yk$|p%!0g4~@`#6#Jc% zNZ-Rm?T+=t8S(ww1Lze@IOOcY4jFAoC${2#7?J?$KqzK<^i*E~mq9ePxqt=xZ+g1F z#&S|rX`7s{SY*roWJTCvP#fG};ZS6{aTXROh%j4t?dSz0n$s*E-q z!Zkha{*0eCYJ2|hITF3FX3D_dTdJN&s$ZOZo}WdT)IVBZ?)zD_Uk!ZWU=Pu_J(<(i zgwm-830Eh@LVxR#9^c(gQ~dQruV|z?x+ZVb$(a#ujUISC>=#uepy{Y3bpdiUZ z{AaY}ADrJ%V7Um0VfT$RPi-(-tu+Ku7327=MiwLPs87qi@R_UDBQ7Iq`wOl?rJQSP ziKC70I)63G2s1uYunKT&!gBEJ2QwGH?UkwFlmVUZ@`oGQe18$l4&C6GYH-o>{{HDQ zmdIiXz7U<=%yt^`4qDa<_UEp8C$UyEx9|Sf=o6-{T0avz|K9WppX5KI56#-oc?2Lc zDMuS@ukc;FH?zIdkt*oAF2PZiyH|07vA%6I+y!rTbTIaRw0t4uxp-XVSpw+$xgSF3 z@^{*2#JwuA^fgvuxdseVHmj$*Wa9jH>FUb8W6*Uid3qv=BSBRj;>W)#|8w%6QdZeb zC72v_>)ofhY`&~*GmB7rM0Dt-}R|Fyeq_AGXq%FoK#c&y!Y3>T*D1 zGR~?;l|T=a|I(0*NgUH@dNzrAb^9?t%fa1}mV+NZV9fR4BL8f%VwtOi6k>|_*lEKA z)Pg=}0HhR%?(;$ zztK7*j2tTWi`vOZG2Zw9Rsy!rKVqvl(ZsewfX}R2_8!;Eias zLe`<9o--0V50Oyt>e?f(N0%=_XJuVXzy($kVy8F$Y;}Qed5;i+NZl^@N!VYEX1&$* zPcE=~r#wuzds-MPxj%uHfb3Hke8=FVN896ZM3BGJkAv?)Uni`=gP@jJT@+nxM}rLEU`{#$QMv z{(>hmrQwO}8dc_hVF9#MEYO^jLPp6O+?&HeH51`1e^s;2lJyOMK)%_ak3cuQW(`IK zuQUhs*EXRG{*r&wa`!z%f$*kZTsETqw)n}A&-Z}uyTjtc%P&77(mtvSFD33~C>+N> zA239gDgyB(nmTu2r|@5CreQJq!H<^m>nT)`VFpOjVfAeuU0}@#3X8&*0}leP29fPP zl6~&wpHv(Lh$JNEJQWfL>s33s%gV*DSi@n!_w9Oo*Gp(7Lh1Y+2jjnolG7}Gh|^N3 zQK|F8V;$9~f5%!c;$fNo z@!`Gu@6FxrA2hwPJ`P0`yEF)Iw+G73eL#2AJHdfWXCD!p9}i1IfUNnORUP2BLDaK_ z2`+fr4g}MZ{h0ePj|+LJN@-oiE%D>-d6^9CtmW^|9tFAQJp&E5AB{8<4EYVfw_kol z)f`(d8}3J*>SD;DeV-k z`b^&xyp$3;nx``@8C@>kL!cM2-(L>fdRdk7%EvpRjs%+&f>m7nI4r1w+;>GX{kOM% z4*UhGP59lg;1%IYs(WWOQ1Rwq=dUqJfrkH|nv7`f?-Q!4h|+$6-2#*N=z1T6@+);J!se9u23jpQwZ;4ogjl9Tro=3blc zy+Uf&B%l8`%}ZN!S@fIMkHsE~ZwlUqq0eJ6Ldbdo9LwW3dSu73Kn49?zi{u-B8RDK zgl|R8@oNAMcW*^n@o+WVF6}q|E*bw{ud*%OT;SL&Vtr5hXSGKwy@wGzeN#8yp0WH> z9FH z!?)g`)*db38JMx7G{hu1Xy7NLR~)KO z%bSj^GDO6UZ(ba@?iuL46otXvqZ>oP`cWbdUlU#!J~E_LiPiA0-;qdBs}ohL+a25+ z3AD6BgzjcKceU>Q$UJaxsZ%s9~Ia`vk9yl@t70p#UE>(ySpEG#(j{c{ZM?(|UXP>~1OOLD6|)mZVv`?aWW z^5MnMY~@k@Y1A0ihBDIB&roDz%4Vn`dbGN2+G5Dx*6+bd2f_}~a8f)|fN=0}ynh0p z(Jdno6V6*&P5t=T@+1;ZfXf~=3A0ZkUt#4@K0Qn*8sx6#m8mAsqObjfDW~>=Z zAyF5nm0UBMq|Y~0{m;+2PTKko#0qvYN2d=Qch5KAV`-O9t`VZ9 z#zXazm?!h|ri;V7Q_b(Ijbk;?5fM@df$;IrJnv2N)@kD%>0M7!!j7F$V-LyTRw0(J z&z26S@CItB>Y+F*02A z9agQmOeR|M=t+1~O*x)wCY5f_W1D8Xtop@n?w+k5wNKpuD>~yqolz$06EXJ_TFK-2 z@V0W1Y2O**zFoCCiyu!VOg-DjsQ4fK#?&v%l46Wh*imTehzgzX-k0)o34dctLyiDP zq~e|$QfAK*yTf!ADM9MVAQ&)Aop~~p}-`^xqB7kFn68?2W{ixxkma| zFP+RbpJ#dRB0fA$JFc)Jcrx99aw0zo)}t-s3#z10{yX`SXZd9Fdau7L2Ji@t3!a<+F6 zxMVl88e2J5`qI&D@Ph|Q1^Fbl(R-04U&uoz+vfFH5{x-sE7Pn4p|c`sLP)HTpV4l5 zX52mTi;_W=+l?mvhk3;y!Fydn08VgNkq)lbIz>lm-S+_3HLr!pg96j@5$3jX)XskN zM5WC6xrxP4#wNGw%+%x20iW4> zlIQ)_SMt%?JsQ(d3^S7}5JJf5Fwre?NLr&%oXteIRCOP`ql&aQ+uO-~@IHTVjp>F^ zt&n5|RF)|ND2R~9*Uv`UKay9SBVZ20t3r#l#a(21katcnxepibDddBfwneFZop+5; zt1|)`p;_);-s{Ek^7kyZ#lQI?=7qgX=^r#`ZJ+1wINU5$Bc$Yi_*>WfIo5)uQuT5M zpE-?)Mvipu7AXg8nflkbR6JSZm>H|~nnay#EOT-6I!m*WLkN}M++Iyyu&--OkxGqp z)oJ3NvC3oMo2b!R7jm0Ou@mzUU5i>i&-VM?m&W5V6%m^*5`}U#T;z5djka3eP9t@dQ^?faU$)f$RtlT}apUeNWQXD56aJclY>wc! z&NiKBowU5`zi-&M?l!5{aGVi?*^D|5;yS%YdN4$_d0=zZjK(@B4L7(($>ZEOR$%im z+H`&BZTihd^*lc5t>e+<0#%mjd&frLeuE3sO<(%BPFdBGE|+?^ z3^ubx>QhBKHzSk%GY^F3i*nU$r(%VJmu$4#z#Wv8zl^h#uSut7a=tl;wPq?~{ zL$P&0S)=U@s8+Fi)6rzcJuj*=_gL3&i)_|wc!wm#EQI4nAA5aEj6hbnHNSJzc)Yr@LTjIWejS)8|mXWoT=VuoC*@_~uOA|P`icq2kwUXaxEZ;YQ|;H<)1qyh2J(h8 zI*ueX#g%P_gq~H0$0;r`&)-7$*MS@p>Em^FaJ#kf%m22q7aDI1+jk%1IsSkm2*xy%cAwo97bsVm{J50cE~+v#rlMRSidvG7hRg(t3MupPJg1 zSyx1qaY8rzr$UkfozlYI{aZ&RN`*a5z~%N36SFHuS-ek|)>jO!@a)Zb6~lF3<6^WF zReN;mld=9a6InIq9!k=2aS&?AAA4ojJ;Q5>GQV*101LrgNFZuCUGhHb?lS<0OL$L> zcj-35N6=?FVSdaNA$``S0)FLDS)k@QEUDFf#{1A}aYQ#;7;ZayVTOB3-CY-^W^ zXsK$)@j??jJrA+P#QAa`YPjz1*yLuhQlxH?dIrku&h8iiWobquVTV14^}Aij8|koM zOyHne$OaN@>b*613Mtt(>J_wgn+ed(ym&Oqm_D#S`<&0`n=UZE03EQq`J5+P-`Bx< zkdoo1*xLJ^;Td25o?P~%wfqF=t)9EGXj{9}+kjL_RhUAjfSg7wGmb1V;L-$qXgV{LM5ghEbECL_H+ zX!Uu0Nz)V9R3)7!@Nab3HgE%1iI#kcLt85T=H+K!UB{SPdcLuKb}u3#vC8tGD%Xb zC9TH>|79WwJ#rQ<`0cdqT7%tzWR90);fN>Kd(!C9gT82YF80j>u|!_w;7okU|#DTR0rNUw9#g zx$ARAg$d8iFCMCA+oTM>+V{dUIxIz*BPGs45%#02aMaQ&73tSZ%3Hs)`qL-JC_^Xr zlwB`}Ue2x-Irx2+m`B}**2{hH$qvWNnc5)ymRy1Vv}4C3n_g$p4t_4^hEuCS{e+q> zw#62DqvM2V9D6X_J^AL&g0J?g`Ux>uv8Zq|Zm`bIy?UPIDLfsz{E(GN++;zpIN2)- zY?6;ArG>p0U#f^W)szo?Op2gsHv4K5710WXyzSiGoUA_Jo#Y{(Iy#P(oRo+ z?7U9Il8l)V2d4@J6Qa0Xca zYF-fo?$|iwku=HXsAj$*gPH697^17m9~w43Lzbp9@1!o5BlPS@NFQTPuknMj%K<~P zPLh;9Glr$k`t~9EgwRDG{+ye_-$!I|QFWNiCsujpqK`HttjD)K0218mEVdKn)b~SK zZnh2&c>%)p;N>l^muF0EAa;;7A+ypTS#i-(L?Uz-r&4(Jy+%GGFHpu?5G;vor12_Lna>^a0P091W zoC3f+`#9w~r}g#lYAv!MsQQK9+soM4BSW2mL2kZ&uaNP%Q1@=gV#+~# z2KGxEr)r?%P}KZbYDK9!Zb~sb<`?HvO$jQ@D)$+VLVW8y%1uJe{Z?F-y;=o-)bj?e z2QC3yg&8oVSg~E_-Sj0c#hORk6qv(2lL_`&?I!*jPMIUsU;$Z=Je+y0Q+H4qIvl`a z^reW2bC*YOtVn3n32FMASv^}zOfZn~UIr!Fvi!Gh_%&e^=!~Xqre_mmbZRz14$BIj zdV^j^j+xNz%1{LS4U{a!gEy$G@mj#vm`g*EN!>{@mZ5|L3m?RqH*H|T5F897Fw9tr zz>Ouqmnu{Boe?^HyjAf58L6Ux*|TF8R`h;y@jPX+WrP<~hJD{zGx^Z>jw~eV(3lLe zAg%_EvnJ~r^cXfQGp~F{9*`>XV|Kb@(04#z%ifF`$vw7}f}_AD_mGf)kXdm2{Iv-} zI&R=`%eo?I((9RT;0i`+<86`OS^DX;ZziON;tLRZ{wO?~*hX(vQ>2t4l?o4`-zM&H zyE8zY@gL16g%lbAq(9MMX|Mm9wcEtZ_24k)_c&a*n(wdsov+KQ*6w3zvre^`|Xj}h(`s}Aqe=eOl$r$3<+t9<&mTUQtV~Cb0K5FF6 z`iAOiPpY9_*yHFle=(l?zDG-v`(M8Ior-Or74aYr`xS@Vh&S%&P47vS7T#gMO3$DR zpIN0Wef~qpK0{!)&%EFK)hIHmT##(Q|4v9M7Egw!hm+_oW{Q=a~Z6dE?7+N9IrJGgKOX4Ig`%uliSkUsU6dbiCehq zjiwAltV+ojKl>MU(EhLAdHjXs_($Q-!TOI~6xyfw69DmFpZ~%{{A17Z3)b*Ig?~ak zes3MLi$1M^6uiHQ9Zk$HT*p5O{{&h5gV_HU9Q+C3_y-4Q1IGUl3kuM9lz$M5F8YO% z`K^xsZ#bY<@pWiZ{60PUDE}=YE1^GKL^w-i3GGXfeQG5*@^EDN)C7@6R-*s1?=Slv z+Ojss4(0Y;@4Jnh2)ABdX&Krx_D~(Ltu^pI(Q5?nrNw`U=XYMt34J9@T%r+a;@_h- zKslR#UE`Hqi6r0o*EgaCDBpQq&_kg&4c;15IAm@|3E1N8M0=?Y8OKVdaM#ZDyZ)%A z8j44nrYRuAb-|1L20i-2`jZ=QU+;8r|M1mnV8bRf<3YotLcyJ`)3~MiX?ghkTRgx= zv>1s+ok%7o*fP6Bw-rBL`VtW($wVX|_F8O>xA zttzNCW6p8l&j$Zy_PUa2n)*ma#%-mCGob`{l(I0g@Y6tsN4_Rk#+-3DJG{MH`|8S_ zl*GEAZSeN!x(A_X!0$dM(zXHAIGFd{=K{BLF^lDTL|-WXt-_2(awk_MbJsW8`Bs%B z8eDV=&sL*2_8~K}cM;w%sAq{D@Sm+5ZFeJcerRko#*wfaB#YeaMcWO+AssY@W`$Pl zBkm2FtKGUUnl(=}k#@C+GGo>c)T96#lWIQ@ScH~ih{2y5YYMfv`E279#u|@S$2SYq z>&5_n^JhcA#N%|LOS}I||7^YKvLjlGp5w0tjdN_ayRN*s98Lm7S$kH4tUi`#VygQz4#9R)s%7iAcDXv%q$HFga`}g=#YQ3o8a z45OTcP4n#AE}$nKVr22J-vA3P=hq=0N9=3#t}G{?X*{HZlfl%7lH<~s9D0mx(;N@IUgNa_ zC zgtEv1Uy*NEJ`O-n!I!r%i>-=J=5&33RF~iZ(OfpzlFIYw;!j_W`Y#+hDZ^8yj31n& zk?fPbGbf+xqi}#>Kyr5P!1!Jk3tmx@rU@!o5<1t_06x&{NOC%x>G~qLSx<@UppyFzS5w)aW*2^9Vk%-`Etn)H*O1q=Y*^j?py6|k;0NL+iu=%{(oob7a1X#H} z8~Nj*^_sAfyzAc?@@((l3?T$t(0I5(3-xb*sT2cj|FWR6>rkdJZdYlOeeSK9&*x1d z7342&+sv}A+kuvYyUy8!v+EX@?yg?XwvuEM6ShTs-fY|_l>yVeaTDm#fx2B4>vq)+nK;2sRgL*ofO?WmnY%eu2;nkaG_AFqv^?=V&eq9Q?5r^&dW5#F#ew&n$G6dDow9qAtYSBXQT-cOaMeIu zA~%C|YQSs4ux7AkwEq5Y92@={$MIsaUn#IT-yZ!sVb2ky@yaxa)|>1-)M-BcxUm$T z$fo2;>#XkKdF$D9P;>k-dhE6*Jtf`OO1)&&_xk#za&y@t8x;j@xuKp?h)q5Bj*sY-BI}Hvwyj?Pat|Xp20kFKD*~S7P@J$uzN-{+`w#G z-2(+I&soWw&T9XR?b@I5WA66TG3h6%H{ThZOv@Cglls;$oa>|*vNm0O(ZSsJ%J90f znl*1(9c!_#@@?zMNLxmw>-0dKywF5wA*=jP8DmhUNPdXiNL(Q^Y|@%fclE}#OiE~f z)`{qzIygm&-t`af3Gkc-3DUgE@ILGLQMA?h7(?{JQf=LxJ;P1LD1&M7R&1$h2H1xp zT`1C``1Z&C_v-E2PU2!WV9GdZ!1?@QzwggyeS6P4^hl(V`%d788RwcD8UOryB)0{m|a`Mpg-Qz~wTsW0D(h<($+{Y>C?UR&= zo94~w!*#}Q1@?Zu#?E@}cNAH(F(HD{v%iya@HE6ktJ^dDGq-)q$v5`Sj2Zh8dm8JW zlmj2ez)}@wxN?D^`12VMe*%yUfgAEX_B$T;@^@FY^o$BQy7!53{k2eRZP|KkFr z+qx?lq@SvvG}kN&R*md8o*`+|cq~)CUZ12R4>J3ajfcmS+rBq*8{49H1Jc`D-O`D8 zG*hl^ncue)co*3MMnR4IJ>;Jip7AK^>^Xn9s!=$X_pNLDu`TMouhI3Gui44>UQyyLJcjpAv&+H_hbm*g| z7AoqKo(~+QEgg&^&cQ9W18K}9bNSI4ISQ_K$J~)kcE?}6XtEURbEi&d-|xAz{WXrL zb3vClYlF_i>sXDHQkO_N*|$I!+788f(rK~!Uh9$C#cp>W+Yw0MmQN3qjPdv?%487T ziAPs$9ZHIb(=T_In}QE(?siLD4xFhPybiHQAJ*J>&7mO~u~;h}eT86n1^*ThS)N3W zi(dCsZ@2i|Of^l48PgW#KsCZwk&*m4A!_mDnRI0(^`>%!-&&3%Bg0P)oy;YF=hqvg zA5K3F(OP$C5@_Ui6+dZ`8Vo!|1{Bya8w-0#YMDysE2eDH_Dj8gSdC#K>`oj?i z5SmDbt`H2T;h^H))IGlAHLR07j2WG3_P!8?Ekm#wV?vjG6xYJO=fG1(py(mQld4+I zvFe^#Z=8z-_v=irUAnKV(4&~ZU=92=&S=i@fqw-)Z#qWx4kGE)m>00&`)&imD&ajb zA3Bz%r+R)2ZPft2Vf208ZKd3IH##YQ1fqn)P1Bg)uCv2AehdDZJnAD5TC`{~j=qfx z5|e)Zq7OLG*$}0CdiHyD1g|&RuhXp_W!L_s?6drqHmq@XnMu$?uMnx)Glk;IYHT^3 z?l#Zv%2)fGXHpTySob4t>U2(zVE4+YC-^!i9sW5a&;^j+KmK)MYoMc5I<&-u`Uo8r z9M4!MEdljCX(|x3s0&m6PnP>s4AyB0E3M)ZB8x3=tzZL5@SyUAs-`9&e7E6qNU~Yq ztC=&~3{3xA-KYGTsC4tUenc2*FkBzo4r*lOD3!cF$K4(>?j@WLxpeEi8pW(=$<^6i z&?{vZl3}AgQ1TI%OP?!ah&GBZ%aYv9EbA(I2#<2Z(xG#MgU6(dGYpp{VBx-Q8Fq$y zEu0UP4?#Lm_N;t~RVFE-=?6k-2UCHmh3E+J`@m}&&_l!>cVX>6HU%vA2B~NKIjCe~ zu`=`ht3qA{Lcms+Zxhl6j?{rey;^EP@2OiF`qFLDCEiPuWIuAMjH?#R%XFr)c1R7L z>i4{oQ+mY1NY0R7`L8#v)nP(A5oKMo$Y{}p;!>O zt@`-e1`G#Erk{UMGcE^7spxwGs@`0n_Cxa3)8PH=8(*ppl5rpQ>tah*ndb~W5d7@` zhJRh|Kp0a}dmBY>2i>)d5UShhZb&&IBs|k%calzxGTnmMP6mpRg~l?zoqVK9L?Co9 z&k{~1D>PD^rNkO#RI)EMzHJiMwc*4$x=sng*X{!>0tF%PDUZePH~sX zD8riwmbmt423Jehs|%-B8#^WgzlRMN`UvOBGgfyayl64s!eD>h=uwwB9)h9XxnHn5 z#15fo9Y>c4R<{20EWjP*0;oAxX7{1x))~sexLP8o-jINS$u~djzFLh}Blu*UTu^iv zHMvmlnBzsU)NUZmG#WV-QQZ1psHp&B`z5 zDI?wVTXKG%*=Xn>#;cjeblR>a1eYm>);=xF8Rf;EuPQSc+``jPKk0^yFyD`#_^G2t zG^kI1)=1;@HD!x`8B<*=F)LjtIj7y@7(h?9?sw{@#T=C;*x&|Ql`odM&feT4$3B{e zeIZ{&__{wKCNI1$Y;;UO0PtIQ8xgX#tw~%B@r+@T*&udaXEOy?G5%!Opu_GyY7@IK zW-P6I$Nr_}IbQ4o$}_x_&l+lDH>@JjOZyI{CFc~Gj5S0VW{gBfQpTQiJcI_Th&ajx z!DQvN4LQ|siZD=! zq1yL5u%CNDKz?NJ>ynn40T@>tJ6@)k?3se;Bt7bJk{!B)Io*%9N=GIm{>0>>gqB;yBS`UCdpuR4cr2(ZI&{{Z4kI9=N?Ls8$BHCWz7dY}GsN(OCgC zz{RkUEN|}*c@Tv94LIq%`!Zblb_rl!I4ti0GEN_t=G5pnvNP-AobA7|N_ViO`)oTW zRAdFrrvs{GC>2f}-G;%lRSQp1J0s5(cYnSY$O>qj4@%LZb|=J&V$o_H>?Qu|B!W+~ zWdp_H{PeKzOFC0O2MgBGv)f^o-I{4+(hAuGxjzt{PFi5__qo)Gs&V(E9#EnXVrSK} zdxhv;F#OgwBXBP&0!es@fL{6Zq>J*NoSF+Zd-+U7VWw#|V(d}%2vS2~z-VZg)QR9R z=3d8_5~Ol7c8K6#Ds&$NfNM_X{`)X^RthM}FqR2UTCUK!>lVGECfLB**pJcpI-2~n z%2M(b;j*YjT51TULj<2r4RD@*zti{lte~@mUYyY^^wVJbkS$&Sy{$rM8r|b#isO|B zozM4_qjGa8as46Rc&|C&HR`#5&t-?QaM=EcsNi*L2={TaQ^Y$-sa{#d@1+cT0*I8b z)??guq4dxanZc-~ykk;{zZqF=IsiH!{a6>L_Bm{5`f{G-JoAPS%)nKpTOTb+)#Jdi z8&mVBmxU1%Fp)E&_FP!g-=D?+Y&Rc2!61-?H@1<0Rg^Fs7xUwAmlee!bTq-5bA3PJ z!bG)Ss$#*50uVMHWE*K7K^7It*n}Xf)3gnWY!dI)-3{F8FXFP+YX1Uxe;hcU-99te zsryT}HXz09KKYJmkVqZ^?)$~4^_Xm?37C=W$8#TzWM+Yseq&veuU?9c#AfDd*dNVZ?DG`!Khv7d$H;)2*TAdn39vKb;UW=wzmn zpP@R@sldBCTb&C$6))y?k@uM=?n%~({JT$ILrcg@B0I@Co6({LOICMZ@{-1`7MN>E zha(si8!ESJj(PjC9cb$lx=ivU9{$%|G{6f8LD4b0q;oBN-(0P;a@XY0HVtxZGR zV%TVl1mappM35#jCBnYN4!rYqlfb-h7&;fqPsWwN%^mM~3n<98mUOgitHZL9WOAE> zO~My&9Bjc?GQ@=X0w_n-mZ3u?f+)9kjXcH~jFy1T_?vzU60Cy@>+2zX7%8|TF*b%s zE<=bX<1UE{LFEUb_R-^Se*E7y4kiVkJWGI-@4PpEEKR3h1XKmf>oZ4PulqLjUtK$-6p{;Vr7jDDEabH#`9q%Hm^G5@vz(%dExkamt z<(p-s#zG;6o&LwC&tgwy7h$8BZ!10bW;Sj@{h<}$qY%D_WgHtndgT8QXn4;frWAOg z!{Kf-+wOo)nM(p=(OQ~9$ca?LU@39o={iX+Sgj|+Q;jZh^liP`z^~MhjQN(m&wvO` zGqFQHz51^Zq&E;&&pm$ieB%evA;k(OG;2p1wpJlih?^2(fLprhJ{U_7 zZC1Vc`cn}mRk*<oZB!DGbt^@0_j2Q;>{NuP1*`6UP*IQ&{JCYV zlv3}77LNm`+lhsCbw{~tsrGP?7Cc*g3c&{IAWV?smei7uX~zhkO0`c%Gt9^;qC|9w z+1sCCfiN2&u8JWguPZEY4`NfKu~toQs|V`%+9>OxgwyG{5vB@10_6pz4ilZL@|~II zKb$i#?|;5Gk@SE6o|F0Y+djcM7-8%$Xae=#w*f4wGF}=Re-2Tl1Tk3vL8=J*1R~q) z$hibKyEA)XgiiHfH<=aui5Kp$G;D7|mCV%#od(1bWQWvYyL^2=FR$d~aWVoOUKCC+ zio_*R>+y#WIXsgRksYuSD_9pYcj!7b=nk|)92t?(KI}O(xk6j<MACKB`@UllVz`%j z+HeTqQ1AvI0|V>>UhwFqpfg8K^mC-+Az`!7Zc*ZjfxbIm5>U&g4Wq+hppguBV$hjr z5Ek3@%P;)e7}*x-Dm=gGaI&PMtL3iA1fl17*}$)l;9_so21O!SARCg!9Xr0WnwKv9^pyes4e?@fOQ{l4={e&i z?W56bFP-U6&n2c=aNtM2!+R2Y5GNz|{Xo8{@h_2^Se~Vi(c6V&TPc(<>;_Yx1mx=+ zOjQ@X;oDVi(+dR9n=zNDf_o=YX2_2=*)QMLd5Aww3!r-P%b7Bc66pjBXRkz!;_U5b zS<3+nkoIE!DNlsD84yvOZRGr6BqmJF9{ZNB_mqLYp9`E-4$|r0eaX?;Oo(ft6pi3N z-6qA(0GAzq(K)ca!eJ4;$juG-eG8am(7{obzLiHYjK#mKfS_o#91&#?p_vhuvE=gFLgHx5187rswL1B=4K&ISBS zg-G&L#xMlc9)i~7zF)}S100_~qE~C-DeV+fQHnW3|tbZt0aPTC?a>_v(Nb7-PTF!K>BKB@!X zv6UZMG~muouhFXr`a=dSD+NLfIVIDxP%CtRh+eD`?@0{-g}i^v1Fw*h!%+);8)O>& z$GUfm#)49lTx$Ly6YFP^bz*HSmfMRcdNavT(I8|v?5_pF$hPC*FO{)v4QOwzk1uz^WlJ#6D{|v?|c%l{NjA~>{ zxsQ)7VPghzJXuTpKA>@ye^F9#4)k#9YZ0`7F4e+LB=Uwf%BAdn{U{dq=TNJxKZsu^ znKA=T*H2=>rx^)P!KKH%6;oLrf!X4FwyJi9?7*DdNxq54EVb6c{(vcWOZ0EKxPa>* z>|MP3Svny-xhun~>MaYWrS$In-Lz};dYvw8%^Q;Ⓢg(hDT~MfzNZL!m(}57o1U7 zZvHl%Eic7W%~{#{uEf>0c>M%YFQb! z5ihCO%(Jvnu(p)%jV?e3d^EtCWS`q#^D_LuNc`aE(pNLRAXZyf3`ZK&RG=3pyLvmb zQNQ$C%*Jf6j({=ZS@6 zp?~*_(k4J1c*uv_JVm8ycKG&f4A!fHg^9^;Pp^Duv;5Hv_YA32v^k4jV$!4 z?l-XmSnA1&^SYf}4CgZkedf}WxqNu-^FyM3+t+i;aY~}B#!i>G5>kq-*V90ax~QUn z`SFo+E3=pdp_OxBce02ZK^#C9~MRS5iw>5gw_(;=j zqyc!Y@uK7+>T(ojhO9Fe23_(KvNsVHKKfEr%w!mVGG!VQCEl!D6d?GjpUh>N_m&x` z*P}UQk*;5n^1wy=ZzTP&$HH=`Y=(GkLd1r|yR{NYrXJB^&jYcB;ZTVJuqIn0@X(h#Nc)9yFU$Y-+du#Vtg-5`nGeXQp798*) zh0ZBlbR28 zLvNo!*H}>PY@$CBn3Y1!ujIeI89HO`tjuS7!pPq3RF)mmyXs}G^d^dgsvYp zSDH2g=;_rHWRsYg!R{sdt=vy>;QqJR@J=VbD8w}ObcxHV=MDglaOtNl^pG}ku;_=p zx;gfy$EWz(iXxtr>93nAa&%n~j$m}C0w7eKM0#eNZ+~m$y3wF^4rvdhWMJ3HyuBGz{_ecKotHzh_sH5|e2BuyCD9&5X!@9Kli<fj(x`^O~m?rRUmoPP1e|A*#% zV}a10y)36oGnBo>O*!nRv?sOpQ8oO@ir#4>zW4gTQ))s$&KpF*P!|ISEP)EN5x~=~ zI>$u-kF`53_fzveq0lQ@9SnJtnAk&bsQe%;j-JgQuw+XL0Kan|qva%7(Vv$V97xhw zWM#l=z>es$ikhu6m6$@*JcFzdeUgeDX(@R1im|v`sXngowf|vB^SBNyB1vU}UuDvr zHGiSu6wwd(yp2#rG=^Lli|E9%nY(|c2ot?fgs2S3NGan{5T|;%(kp%*fk~wf$8F>t z|1Zl2K$*OpE5DuE-s?dY+};bRpZRDLXsY3Xqaz>VE7h%egY96bbR`6m@)IWR>Z&T& zmwVA&hgZ2IeSxpj5eLB;?=G(_wX};b!sXettJS`xi6xcGXDb5k7InHNsm}QicAwV) zC=TI+o@FM_&*zI)cMd!?(Dz#|rJR5h!zGr@ruR0MPsj0YcJTGC(*{qVI!_#K;BdwI zajS3}dZ*~~hUr|9!|6&9^4<`*YUiQgrXn_6?mj)x8-it{YxA`_&lK#NLx9O$UQFY& z^SvoQQqpp@S}2b}JJ=CFpbD^Z84Dm0Ei*@l{?|7rwim@e6}N&FYt#%VX3(!|)l zGH+>2FfRlJ97;tu5V^M@Eetd4`M0HL7aEKz(yw25iHmg^%m?GsGj>Y7i6yZ|i9O5R zF>!GdVfOQO0fHMYZuygrN;Wrw|w1a2&w#Cqq(W^yo7K+WIx7o;ptX<;063H zehzKL-@V**!inxfP;Ua0Bj8;+k+`pI6kHM??w(4-`K-l74Rtx!W|Dm>cxu=)l>fkp zp+HQYQ|aCQ!_K#t_%VBc$SY&h3B^erqEo}_bxL%-I3QShR=d*NDa?>uMb2(3lt{8T zI!s)~1uQx4OcX0WQeT7r(X2e4KsGSK9z0g0(#l>KCTb6TGzY?rk1c{}oc@{B=m&#^b_E;^9{h=$oU@_8)rf>VfK)CpzbJEBy;; z3~ql{9;MNsN*(E!#(IcA)jk7%yEY^4~qetIkhz$qpUs4>Ni_X+vh+?Wf zmqA6}Rn{`xjc0t4Ef3pc2R6V*x-_zU^_iJX*zV3keQX2jjTt+Ht^$vuyxk`=|0Njs z;C=I7F0W$OAXp5^(QEx;jNOJhhv?9w6^FfwaM(~EDep^3I|7azFbZctG~a@QJ5WY()|-R zK}$BXvLN9zO=}%zoBo2@0pzth%!-jv!sQ^@fAQ&7``f4{i0gf|+G;l1k*eq!M7;}f zWpd)^U;6Ix4UlPUpXf%g=L_Ok!7cvB1)xQQPi8n9{*j1~%x8hfa<_Aa@?1JWgIP%p z-c0b-S&%TwRZJfq_tNjN^%ZxnKNdZ7Gb#vmfBO(RmBDA54*@^D_!>ZH;OsBQ`wt8R zXr)rVVnHf4#=V+~H_JhD0Q2Wl%JR_u?$sWZKjtLxm-l{Nh0rZGlNR za#gsLosJ0#Eagw5jVAAB?a!~9+Qi_#25$}-PtyG;8}T*B1Km2V8SEIB-INlZ^3&nZ{HTNn+W=EbC4jYlA=CqkNboR90Xo&M6O=&^?0o3I9{kvttLLS<~RJ zGMV^tu8a}c>66()Bn_m<6QQqzmt{_>JnD3$)RKD)e#saPw;Z?ddt~oD6rA@k+?z)< z4`wQ2!{Q$b@-c6q2USVtn&>*vsPw?IX;%B5eF^LF7Xcor<0pX#3;65*bWx zn{l6{qI_MR`F04mFy74QbeNDas;^+o(*8uNf=pQsAY2WFc^wP za^E~5FhhU}HLmZ*#&WU{l39Tc6T6*9%r(Sj)Xzp<52C*?=Y|@7cUIta`;$x>-ZM(i zpI{#N2eaCYNP-c)-3Y2l^9=SYL1zH^(CjW%fDB9QYZZPaMbQabd$Ar~jJ`{?I zcNU7Kg-3UI)q3U#EhK~l->KLMe_=MC?IKH1-*q9sAiI!VsO*#fu8hIx3gGaX__xaL z)%u-Y^!ydro#tesXtXJl`lXDcsmR2Uo)ehc+q5Y*XzCmTn^a>iwSL--3m3^`hN8(u z663cOQfb|AYZ*cH`lKq*S$#nOk>W;R9Fo5g! zcgO@n=*bVAsR7pL-^J{&9Nk}L3AzLw9MHu7*;)?#iq?G3HT)Mnss2Cz3%m~rv;p8& zxEFs}e1AE7K(hOvgzmpD!FvW!{+|Ed6<~uy{^J<^yZ8Std{O%B?^ikNg6S&%GhqWb zY_UKH^bK+!mHhpRhhTu0`2RKopk@EP=)Zple)$Xe-alW+eWyQx|6AVx&0L_-zzy%G z$o&t{=6hh}-{0>)`QPLJv#a|5+~9wYe-G9D{rSI7_rGuI^DDV$&Hbwg0`ea}#4ffi zA~#cyw5Os|j;>OymdDnuokuUWRwuSkYc@*CPJEBIEw-y_Hi%n%Yj;+;kEsA4#`5QI zmCZUPnn+Jifv6lUaEbQaE$BkXz|+aDg|;QrK^V9ufkX`$hNl`ZFR!ZstF{$0?)UVjx-4sl!m7juU}fHHyAp2tfskE z;oSEhdb_{L*WKCGk(PNkFV|)~pT!+)ofp2#W0hVIub6KS7MEuKSj@z!>IsU>)hy|~ zcveD(gm0HLDp$~6nt1Hheb&{*1A3nTb4s9-TJouo)l-5 z)`+R6ME;-|4j%+$Q7{=3Pm=5OLiwDP;0f#79wtEDr z%DaoZQtNQWTx(`UhjeNgZ$4~Fju+No8br)5myVA|o{MOwBCoCOt>t+9{68&mPs(v;_*d2Q~&nN@>57TiOV}uP0Rr|vthw5iWjFd$=-Iz+iJ{H!v6P# z_H}nh8%v)T-)q!c0`8~7tDSs6$^ujZLt{{eg7l^u{r zkF}-KvQ)#+6KhIrvyo6UKPBWce4#V8#l?(>2y#*fcMpWWW>i}d`E$w0PO>vLVU{qUc|5Q0Npqbm zIntQPFnnM^FUMPpINBTw{j5I~zNzP=!_~IM>q)EPP7Piogqbh>q%}sEWE`*Ilss%P za9ddWFnGBdzLdjlbJ`RwtaI%%_fhGOJGI(beVp|dgwrI3Yt2@}wTxp)r-@W+mN>VatNJYESY2;XOE&(E%^ zo7Hk$INsk8jpK+uk40*DU(3B-!f%+uI{fHHebM47bo}A!vN)J@Btbyd{BoNu`1^o; zJ(IIa(qS6w`7SY$&`E!a_Eq)Y0t_>x7!!3l^E=DCK*rSJCW@5ce1P56A2k8p)YT%Z zMpJCg-Zw{4#-Z|>HH#eT&5gRS*P)H^sku7>xH$o_p46^BtV#$hkGFlLL%AyBD`qTn zc8%bCRMbw3hz5r@q8}d(EGaE5u7vxsO#NA+Oi34D3c+h#1M&`$MC&1L{_U~Q?W=~I zhvN3bXm!In_Z4wZ-E+NYfd&YJN@8;Vkt->yEoF!~T9ZJj0{q}GA9n8Ea91<-Hj&w|q@|CE)MP#(BQ6^Xe?Gx)t4!*L;9 zD&46U-+Sm|y|Dj@QL-H0CGsRtEaawps$sjC{d#Sct-kv3NZWtP{XA=2TVG|7#WRo_ zk2&dh(`>17ySaxr!<(vOs_X28I^0BeWWJg;)J+PS!Q`F07*vSoh?H6PtY zPLui4{&J%(svV_7iI{^<4kxPW~9Zk1XyW`AhSZTiR$>!7G^5mcaQJ9ULUw zKsr-sQ|#Ho&hNN!BMUuct*&_kLGYp0u@u=PTRIU^^Y&VE$%Ivj`h(P}P+>wIlRvs3 zA+>dBg)==PBI8`yRj>=6zqWdMcit5^AlOi3dS8t3e}fMc*9vJOpp=P112Kh}iCQ6% zxcyJZ&Hv2iR)o@BOv_a-rKzMJTvfdd1o9ypZ15p{(sIO+V5i@4dhUa#m}1ah?H9{0 z5S$zH5*!RkeaO7oROp&%MLezG=8g`=J{QcbNkfT*G{v78`a%rW_za@&g#4)R?P%+y zgl+M)pMqI)sJIlPp$@+3=ZYWssH2q$;gNHOUcRWb8mbY$)WX(^cECZ0^EXBZA%JTV z-wVLF$zO3#f(;TY=ku#hgE&eXByIIlvh-o#na0fUP_D-Ay4h zn_(FqCYHLr5GB&^-m44?baQ8Bn2a_j_sBaqnQplEnmsmH@Iw=b5 z=BI>Lr7@D(JQ8(M#!T^$>@bl1er7*S>s0~)%i%_fJy&B>{XEX|yyS31MhtSn_b!oScTwk1&4BG+d+o*(_ zK>4!$zU$b_p%xs`@6hnDmO6XD>qw`__#>ml;hnsraXP3~Y4{~Q`sVK{y2c|x1d1pd zO~>js^ElgbNsCJih>RJTOJO<>^1+=7+8kQ7M_Ke`gRE5iYVwVJ%ug`I9+f6=7PP{o zHm&CH%7)Y0`a1G!ueL$60gY={aZF{~NkWUY8K7~~epzp8!2CDp1L%4{oqvd1TmV;| zspyFcPh6;>b;01M>bN~6Lu38Nx>rCc8P_Tq$`P4p#^0Ynt19%nSI_Y#t?Myt=?}RED+%s#7G&bewTASerze;cl@QLa1@3#^3~Kh;c=N~B8VX58zM4>9G1!)@wQnqFXS$U zRFNBn!w!-)7JmT>9fsC2}Kj6VVL5lF>KCHUs4MmdaG+VS0C@zarq zE>Xxt`;VjKe#sv%xgS{pcGe+9(oc{F>X}RkMpJfukzwyF?CA4fJN$={7ry32%u77m zi+L{vttZ7N)n$%c-RPxnhtA@chS7Dzc-P4U`^Taf#j9*09Gu>C? zoV$X;6dq9WQXL7s<{BoQ;ZmdYd*OG9dx^9_lWwiBo1C;irx|~J*fC?QPtw#s^APg0 zi_<%Qd63I9yyax_0cbEwVbKsH6Z80H`Svm*e>yw93q_9?eRhb9N)y}&%Hmj_JU zta$_MFDWLh!j%K+j6~l{?FQ(Ajccz3NxkH-VsBStYu$1$LfGAcOab)@Xy=Tu=ozjRaBM_mQU7MSCd;P}Thp<%JB zRW!*SbZ&+*^PQ2XI;|x*fXexqT2A7y`yr&OS4MNPQf3Bi>5n#|K1E3N1UzV_?o;$- zL%)%`?hLkdR@nA(>QXo_PB;xfi1W8bYRVH@FKm4h(!2@esQ1+wr57MR^ld!{RFi(Q z0#yw)|Ck$FJ)9L-fTk9V(Y%YIzdpcE<>o79i-s+9`fw5Yv(=1 z!X;-P+)!WD*xrbRWHhUY)f8l3Ln6grH59ag9w#h||oRz?{Sps*IT2II5L zWES3`LBVC@Y7?NCccIJx`dvH#NZ(vq&h_(K3clhX@Kmj#sn{UA#`WjGWPMrYP`GV0 zRhn11*H?fIla)3DP91 z40-Xxc{6)Xf(^cb5Ufi2-YpVB-o3Y+nqb!I$_T)F+n*5p?ZiEEYQ`#hJ$D{#;4y8k zJHgn?#av6dSMs5qfsk5ytm&gEIrf?vvUR)MC8@T4Q7xokNCzKg6))ggnTh znce)wBo4e&XMn!^A41c9TG{nPI?&Lp@;X~jN54#+7@XZmV^M}bh?$9-;exqNc*&8U zRt}Uv)p3KkW_La?L*5?wRgj_@5%>PmYP2zf%9}Hc5A*MUgxD$R%EcvNYaVMIsaQWZ z_T6bbb}sd`H0OtJPQE@su@xcWVY^oBewe}XjNo0A*au#liT>g_1i=#Cu()5=Lg7>R zR=)VSP2=?8d$wXUVu?nY=Y^2sz&9}67LddqNYR1K59$_f_=r3MR44)NoG~%mcKMgdh14BrOEYIaAG)GJ$DgQG#gea**Li+fYr`08sZr_Z z4Ol!W=?#Fl2NA7No?c`IR1k5E5~RqyG-5v_olC7NRhkQ*9#e)r5WZ#lZNK-99P~@D z?nl~(T@=;#)jObHkpzR@R-s}02M;fb_QI!Cs;s%f45CL~v$zAbH$E)*1*Y@T?nMS* zyAFR3XP)%ACD8KRiM=hwyx_XN`{Xq&Ff1F_nK}7?p%Odui`*MqZ37)s1v&4 zN+*&St@PBHqOGLed~KJ|n08ZM*u*vS(bJchg}x*4-K2nIX~b^0`#LSDToU7(QjrS9 zauSp1!}ZKr$FD9NVj2{LqR0LHiaHT*i?qyCBy3Env))4AFF=otaiMa>owz3sWB>s| z#Pj+wAqx~MS*!2zA{AhXiP-0_%w2{6(-)9RhB8B*lcg!}NrNm3pRE{u&^C+tSq|!u zKCHqf(|P`3x=}^p)4Ou-D;4qHIxN_8SO0Ud0cu<(?*&0(un7(E@F`r7h8Z>8#{Bpz zdjM4wTQ_k|=)1VK2MMrBeTMfD znpJ$pbi};6;Rli|7OuX0=9kkEM|FwfEEw7)$K+*C!@lyRyeW4CMj=%?D&5`GzP3EH z63CwhhViVmfmrxty-vQ;=UEhUy0~7{~LNXk^Y6&(+rdH0XZnwj{pyP zC`c1aMA9qI>i{Ay>qVO$4SQ5ET=s1mN1v!VsAp9P%7{m)X*U;P7}7L=)~h<)c1_0m zQvmDD+l04M<%F@*W1(UpcQV$;_1F>urMiNb-=dhobuR0mugv~2Yb3F85Y!|i=b7w= zFa8e9htpi|^)9_K*Qwf^^kv9}Ff-(IMd30wW)M&~~OTOkhE5J!sc%T@*e?Vf(^Y z_Z%eyxs0RQH6}}9HO2gen_J-18X(tYi4yxn0+t;sXkLG^c=FC-53i4TnnOy|i(~e2 z&sCepfYPKu{w8D$kSeyI+raC@N1e3p>}#~O23WU)ER4$B#vX+Oohkz-({dDaA-xg|*^sxUU3@8D# zD@ZEX?~|FNxM2TuHUv<&gDusj0%?jl4q+-G1~mhO-Z4Q3`?snwnA7Fbp**%eO$ zY@MH3I0&f(z>h&4PT81gfWd0oi2U`yX1E z9pvBJ@q)>jZocIhY%xF*)67zbRYzAIm&7w1_Gkx6QtXn1(m0FDgBo>VxT z=t(SJ2ThC{xzkizYWmFHd~`OHnd%t(PO-fWFe30>(92jzg;z@93|G`X_7^`2+DJu|VX_*nCj^zY4 z2OO0pDt?!r?S1o|V=fcI^@lhYEk&I@X zOqzpx*&@_wVDqTVM|qj#L_c)-$fr~<#0et(dRLV$MPRE;v4&}Ump6k<4n2^e?(|cB?q1Sc>Db_b{%mJG~wG5=~kt+)S_;S zzXvXI#cFB5@DQW;r5=oL^DBOlI2in*B;6DiAf`{PkAdtGw||1#2F_uKOE?nUvym$b z6u+TlR{2yMU(u4qWg&4>7!ZNJ$>lHb2f+*jK~{t4t2jAqAETbRi^fz zxV8TeVQ(E3W!uI7(hMLllpr~Dsfe`HfTWa2cS*N&cQ;5#s+5GlfTT1-mxy$?bjQd5 z=f>xG-`{)AI%l25A1oHL=AOCdntknS@6Y$UYmXy)dwVzt3!YOkVUMZ;2rTuNgFRmG zaP6Sa7UBAJ7veh0e4r<8v_)g{85(L!RTx4Ha1r8Kw3ldP-qK=SVvi=~~$pmneO7!9!cm@z`+@1z&OiKoT5uE-O zY0|=AmBBg;ag|<<-wFLtvq(w2`+S7ce);54C{uOfvzI^UIoLq0$uvWUoIdee_PteK zH_{gL1MI8`W=l5EKK*;h_*Gmu3!UV%qk6J_ps_P;noWt053a^4eOZ z94^OhM|wM(rP%W7fc{8lbih;-4gXp-4Dg#k=Su~QKXo$~s)f$lF4EFGBS^TXt3B30^awuW|JDh_qawEszP%(_k$ zl7zfvB3U9?B#bHo48&qJX`FyO2QX}6RD`?@ftpLu=+-jC7~6nC-7c+}Q7&WwJ#qIZ z&QZu-0#uGF3$(+~X~K(BVd^xKy~4%-CWgaH*0SxuJK=bQfJlR$MKJb?k_XiPo++Or zk1s$(15+7>s!9hbdm1-d_1jA4tUG}6*~$+xP?tX# zgJX7|SE?Q*)l!fTq^=&99@OOr^j*$~+cRKEc%exK{g1V^p9vZNhXn{W*my(*Os{s4 z|CkCT=Rvfq#Z85fkdIJpCD?>f0k%5!O^43IE@KXH7r#JuCM3?`^p!Qu6@+1KLgXOE zz;+*y&!Zz%gp2x3 zwOL|={-TV)3zJ@&+{6}00$Xb~)|G*6q!4WdXw+_>$4Pwjt|*miu|X#|HC_jX9w)KS z5MYGN2uj8L1Qe#tMOGbwYLOC~Fi@!Yur6z2i##7z)~AWMhZrdbIkUYDbnpI{OGn*8 zF(sepf1!AL8jSuBjUM%_Za8t|lQu5e(d&U4Zk%iqeA)Z28;N_-C@R2w=9v-=+2UyVA|7uW2*U1LCrnoI3iO+ffNEysafdz!J&+Y*P=Q=W`e!LL zaQ?F@$3IruUOiNYq1%B|DcKdGar9#7)axXe+MNO>ZHeE4*m%$d^8ys`c#=K9sAak) zKXJ2+t!bh7dMR|@znb`z_#07u6qZm|eGMa;pfC>ti$~k$jGj}q%L1Ed)xx*dN%wp`!JNE~oO1!aGSKjwF% z2oG{Uogk?gS7Nua(v++6-|tiC^m6>j@AqB^m_b>C**(;FvIN?97XbG;sl9B7sn{pU zyWheck7!IUtYIt@-;QX2m+|P^8w(}#C*4L2k4Wjl8gy@cuT*cyK*wVz1jgtr3;Ni>;+F zkct9+=*K|_T431ny_cL|C#GcSqi3BIX8h}3;ztmfAcz^lQ_RyJf@X3iU#$Do?X^;H z(&J0{-l#HS(4#l{4rGBVgln?=L)pm-WyR?HHBOf^Om!){r#JOP27B$g%ZI~KPvZISzRlqTBK47+ z^XFED?R#>6zMN9xysvV;W~jNCmjwSVcoGlpGJqh{zxMJXLJU;ouk*b8nr?5mw{xWF z9srdwDV)*?x!HD!GW3ofd=KH~KAGZIT);Biu=+lb94+3|vb5B!+4$p|4y83&zi@y@4ek|`U?{5~DlAScK(BK$G z8+-bXc(h@67K)Y+OCxEH9}pyd$

fo+m}QhsKX(^4JkyIrXK+_g8y#x!}W4x=6nQj);TD^l$=o$b;92aJqgzW+GSVQAM-h^KW|0 zNuaj)QGq=9uw^Z`g=utp@5x$_xnmVeAkc*}t4tm>Kahba&gfeb*Y0di&YAuKn$JMU zYIwQ#%6=B2wSO%iY$tGmMTc?xMWn3e6=bMGH{QO4_zL@Z2$(z8Pkfh6J|*(5+zVc$ zEnA{*abqlM@ioRR^z~l^e#2z$J6&l}oOD zZ;7sYF}hha@@Pj20-q^nUWiK9pRx@BHe34S_#9V{%1^`S_S~<2)ajRWV0|t1!?=u# z+c)+LK(oV`peR-1LuV%{%dIBB^6}TD)wUe%Oe>e%%vaU2ZIMx`h zm;Ks~B9Ab3G-b%tkgL}zt)Ddar=Vl%%Q6E%eBtCr4#IA#7_*4)CH}SUj8yKX_t%={ z+M~TCYcr!e-o46s<-La^M&6&KKBlS^sMLN32?~d>*Aa{n3kQwDch`~AqP}075H1=# zAeWPAK2$^2pm)JtNz@_0NTIbuBJjqGY5|B~%&1%yXE6WKb*;bdo(`-IqpWO-UvbyC>g(mj9g2q%3#Anrt=sLaK%|QP+9f zaV0PCuSzshRrS${t4keTCN8_dJU+S*9=3^Gss{ZCJe0d{$-D?$3cl*fG|&i>O{TSd z>T_G|NPLUaPgpK6wovdGoJ^`S@fH0z&W%gl{k56Btl-;yXw}R2Hnpu#y9WuJlgBb| zFe_6tDA6EODBrnv_Yu;oOqeL;_4C-vF|da&*%&x>6rJQ^RPxte(k6kY#`Q-!pA9&j z#YbZ7slU)L8AeuCH(N4cx+l41J^WNYsE$ZY;l7HE>>t~Fe=OHbJ}H|leiR0d(m9o5 z>DH7&MVZ3pPR^jK4C2e_P4tyF=c~l`>GF;~?DLA0OK`cFgTtJk z28UA^JYTrYT*TQjsaJO+@@Wz~mD&xeq;CT>&lG`zHNuEY2N} zxMe{XutQURidyFs*zhF#`QEEV$yj6Zc*<1N`E(R3CWGaV8)Q3g(em6ThNXXi6SwcK z0L$SVuStG8W&*uO^IWA^lN(L3qKp(!W}j)XQnXGnQ@zF<+ZW$WS+G_irjKoTf|P>k zrw|#)>lXye-meV_6nRjnGQ1dlBKZY*ndXU58B)OOwvgp zpN1igeS#EmR|>S1I;xq+O>KjDM1G8P`?mQQ`%`{3rk+Zt>UT{a3Ev4Y2#1#>lr?bJ zpP54pA^+^*(nH}at&7v9-%eeZfm1)0Y;jAl3{rq5xE@gucr2@3AT@;hROjE7E58gq zv&O{Wn54a!jTA|OB)&>Dfcqc4EG@nKvMT+y?4(cOUGExY8&v>C{7ro0R1$T; zmOtwq*?>vB@KY_3lVp&8ex-f|DgUtHCS=s6qp<2#QXPBl4OBEW)UE1B<!OBpP za?pazt<5bgt728o_FOnU@G!$O3!~o(X?_nYP>Q4d#dgfT9#Re|jN;W~ydVatS(+*? zA_qKLzo|6ebCAHGbf7dKuiQ4K8>xvw|CuJX&K7s^C#Yw;_Upw*W0Bd^D3Z7kzZ<6! zQ}OboK(so5^>k9qmt%bgx2l|QY>^35!b&Ws88Z9uz(2=fBWZT#_yz4v@cpAQ$UJ~$Q=?sq| zK9nP1uOZn(?YZMu0y8$R#%WrR!;tp8O*~ST=fw{t1MU;CE1|!jCqOu=#xmr{{SQkT z`L;t#7+^^M{Nqyxe2M>po!Ed%^et)0Jt_)pBGx84f7#5x+~{A|-B7UgADmED|Ko`5 zV76{*&js<*|Jj;3f|YXUs0WM@0ddg|0R*cD~^LkN@F3fAW%mSl`%X4Ita% zuUP-R=FiT1D)#=5zogPVt@K}>_AgU;{~zEP|3y;&0a=-3V_%r6{}+w@&)fOmYcV|p zt*CRpj<09Cs*f;A^!FJK*^WwIl;7n)EL{p=fc?L}$@_Tr_g1I@so~U;2%q0+f$dLo z53w9WpUbYE%h&%9v20K7LEJa@`0o9UX8RX}Rn`bP;_`R0c(FP^H@=_HlBrofH+d;~ z|0w{N>?^!`y!T)B_OH9~KUb26*iq8F_|J3ve>^Hh2l!}QzudoS;J#Y@-#;46&e1r) z8q*eUygHTcz~j2r@A@ansi6W@z1{9LrQ;JX@vln_h1UUwj^mRFwKneptzdl-2iOw^B$EaWLpZ~0x`beN?VYA80At!St5!z+OIt=!dl zQp7<%_vbi%Qh&MYoQm*It>kUh+XwECdq10Vj&nM4JsP*Qo2hFVu>>aD*L<_Tj^DWw z%c&Yw9-%(e2+q#9mW|f16@OL9rORKs=9NFtKWRT*j!N>-m-Q1=?!hn8@p8lOpX$!B ziKq9-=qjjGb8KA9^#H{AUJI|x6LgeXzoc|`k(gJ!Q8snpv2^2NC>L;kV@8FZLA>R38eO&yO%10TsH?NdOts|9t0pq zRM)&6Hn}`mZ~EyYidl_({g$^9!FLM@%8FV`$5pwCz*jD$my6m<^~Fv2jP(eo!cSQ} zca=UL#RBNnaeR(jF!lUTMx0@~^c04T_?+*0hlKu39*#=4n|@hPVq`}XegGSqX5G^O z{A6TZC*9}qZo#c z*6>ve8*l>K;t4fwgO{0Hb{>m(RAjfGkJ!T8r6|AM;)^{u@!e*JTLWH%Ixaajx`+6! ze$&;a5BBPO`r`Z{qF(e&{l!JIZ3NkSy1Yvp&Ow&k>)h=0){DV$AGnC{&Y{g_mrC(? zMMJIQO2+ijlJEJJ=%`2KXWur9PYRAL;y39Wt?R-x>)zKY5w|ZEJP+5dyE&*Cof83c ze7w2JcOEfSsNm38dEA=9)ynO6c;!OXT@p3$>D64jk4tR8d5f9NwdflWzPmUR_x*32 zj!NQU=byQ0wiHt1uJXC=$}djI7hE0xoDj7ws5q@%>>m1`5yFhz9<ph{YOaotT`zcS3`I2In37TfCOEJpChKD#PSzpq zdBks?b68wBX`kHAEL!Q^?Rbi9-2z=0S9A_jG9&$Hl;oz7?{50`*p}g=i^fTH`DMC* zjl8CQOUh5szTMk10rL8j%MVt71~A-*)|P3iTcr$AyDb*X6`!>(>WRdhnp#36S?Y7| zU9c=3_wjfO1A(geDeD_Z^Ld9*?UVkf1s0R1TAFuR1Da_GVIV^gi_+yrfpG-1V+q8o zB)ALEH_lvDE>j0Lh7sYod$u29%?=yxs#3VD9?#1hC#UL$pqT7;W*x}i-BPttT-<`5 ztdL*(ZL;yM_WJuB@YS{_FRA3qZl|AMAa`^Kr%QB8q_*ANJ2rT>%K}YF9`veRjqBoU!_v^t$A$zH z#W<4Zto7ngw(ZaQ1flN^XRFQ53%+Z=YkuO8FaFR@+&m`Qa;8CUi_^l4kl054y9>En z!I#h~6w?PP(p7tbguVVKY$db$Q9W=T-x^nOrGDM>)n>hkg7TuctWo7Oe+(Wm$CENVP!=M z)2;tW*i`reIi^f7Mbi+l=BnoofVqU&y|vMAF(j<1dz|W8#DYMmXsC+gH@1IVO{={f z6UltbtEjE3hh5MxG%&auDFoLRdu@V_f**MZRER9k7b!=Gh1nY>1afIcI5~LH(Q2{e zGo3Ao9%LW*s5Y#@NHtEGat~?^X{?#xpgx2%QAW~h!fNl;j$)%lw7oxuF+=o zIolq6Ml`3nM_LQ5z*;G*shl0{Q3F4r5YaK6cEjVdi^4l@oC0}D5c<;?L1qE-azFX?e z4XE{@Ly#sA9yOj$%vL_p)eT?;X2ea!XejsI5uqH{Uoo|QcW70=xjPzvT1G{8atSf~ zh{{UI-RgSfxYNteXWj027k=`}~-i{wen0$H|5Myh6?dwNjKVS6cproS~2wDME0{H7rSv|EtK=2&}F0`w&VuyvP(~ zNmkFe$77KRyvex5mT^k6Aa1!B3D<3%Go*3+OLE0aL55^)R?;c-l!&!)LzT*4nSn+F zT2_U2irZEgo$tc*d5hLf>D;NfcfTwdX5osJc&+7s6=6Y(TW+4BpjVxnM~9>hd(~i$;gj|aC$!kmTg~9d@=_Ejhk(HpfMc36<1>P=X};DDwE%;Pi{(uanZlM+ZQnmN?|k{= zh+8xL(*GqWQqhjRS6)pjp2)ogYBngR>1pg@McU577`URTRnFr=q4|#(#J5Mw@|x`ITa}`8+Ut>kHG=g`6L%W z2bC5!0l!cTS#>PZSa*+*d1d;g`ctfhyHwYbCjJ@1Qgm9*7FTu%o2@82 zxsS&f-?I*7hpj9_HSGXvo4-dwU}2)jIkDAY{HDw^h%#pqD!M%6N<_-jN z27^TFSB_W`_3i->@ldmEtQkkl%0{GW^bR8#h@A<(Rq&g#d!Z=s^i%QH;bl`S$jltH z)odg?Cl%cVtV?B;ATT5o^07c3Am0SnFJyE5gaCKVe$g;lJ7Lb#aS9AcVt=WcI#I%L z6g2uyoI@|=gy8+3BW7SswPMB$=alkwMReEG9>k}>*y$@R^aH+hEI^5@Oj5bLHMrXA zi5p5P%l7_3QyUGYG19i}Gd{kS($wQo$0jjs={efa8w^R!&ob#Ry4ieP>s?wnKw}Fd zxlztk-JDWwMb-~M?U7!jKh@~L47fc)kWGdWJ7HN7{)Fw%2=>3s7&mP4iVwL%o-**W^_JD)m`?M@Z*AKdO@l#> zt#|#i5j->-iALF)cS^aFOyD=3qnD7kVXOe^K{6L+-_38bq)qk+3U1fh6r;KBwq&gw z(HbDpJ2KdCC;y(erMNq4WMf=DVG_<(V7?d&kUV zt$QHkgXE~oJ|hQ;%$&PoBrTao>6QjF6qn=r&Tf7kIti? z;bz7Xw|5Z7@{%zr1SP_wMx=-*eCcqGVg`b?7}jSk1)j1^JH z8TsXpW->D~%WL|c%m-jFiX5=_b6+^eBoQzjd8x)eRgLyVM91?gqtOR3gki~?9|uHx z*c%|y7rpPQrkI=x6!4rA&1^ZqQrJ%`4GhWu)*Cv-(C9f2*(8&Xitblr?1nSKs_Qo~ z)?UlNDI0aXCIs$(;)xrp5Pd^19*AaA?h3!XL{pOFEev(s5OS$uu%U!P-o7Uao2!;f z>R|()2!uVrt@>Uqk9vdAxi9PyXEkbkQNWOELRjMec~AQSQZV=7Gu{-dGx)PXn+27e zv*$N7`Y`|1d`mj?4`eyqAnu0)SlPJJ|eMK=33LY&M2!;0HLQO%qDsXGit-Z6% z9(TXj%K3@*@5NFgd}o+7odS#G(e!g=V&KH(MJS0}g&^eNNZ1<(UcfaFMX#G}EomWiD|&?Joi-2Dd|5fz`JIIpem&i__)(@eO%{V z(6e^`ipF6FVvu!mY>Oe$YL&}X@%y-~u8SJ)XXN%e-63&icanh9=YFiB%zDi6NGuJU z4z_?@*?*jR&AsI;w_XVf&34QU;bNVdfTPeKC;; zv%D#PG?I+eTD&jKL*Xxo~_ha1R18+&mg(5 zkV2@;JN1L@H$b>}v+G=t+NbftR>rb!UsRTxeM!ZX*I2wjdj~ak>p;?O&P_b_lM}`^R|vt@-|UG6uL-~`nkY2OO#J4A!MZLWUi8I zxV8>*74pDn{Mx;`Q~NuyP&i{X=WppN1QZHOC+g>w=aue$w*l_N?&iZa?wIdUDuBwZ zB(Ht9nS6QviU+CE_znOAh9xEC?QZ~Fd_?~W#oW~a+ve8Q70S{&KKIJw@mar#7@aKNX+AR!r*qqoNj%p?Z1{g71s&Je-v(a43j)){ z8m-Pdi4UhuZ~2OwnxfkYa+bsT%YQ4(L_=~12cViydajQ=DGgzb**w1J7s=T%sv{+z z#z^%Zl`X<@nrS;q)$yT`ykRS z)G2U4Ua#K!GF$jJoQyv7_P&C7O&?fNpncrVd*&#lIY+L^ z{6TLs(Y6nTQNad=Ou4Q_1RP+7(i44LAB?2J$X>694Duss>PJAJ9S%*$zw&ZplL2&b%(Ob@4HFp4B(%FFOTERpcq5F5(0oD(D+ry zX@egxn#n*9vWg>bi(zM-@zFH^{Q2dQb3$jK9m!<$fkkw=@AT_#I~;T3sj@L^Ex8ZTB=;R9~ExE0=w)2xDytm()l9{x0n zcwJ52=*opE>$V=e)%j`}%+opTu=!sfWs&kXY%Id~lBTvc-tcb4)sGcwD%oH5jN!mg z+eoDXm>)REsT^{^dGp|-*hMc;r~hv-MbBq&US@hh^j>OJnjw|d)U}4>N8(W8(Q82t zOiAKWIVrCeF60&5p(B;xU6UFsS}fg1z~1#CV2ue<<0p=ge2N3{;a-=Y=%D3()j{A~ zNy>%ePNDXac;*-C!!;;7!6`+slGvS`Ol)=M^XMs3ESv(*(ciPHzNx3{`6#bel}xgS zONl`8x`Te9pzz(6ic(|VHqj-p-d~}VA*0O9<$E&X4bQb>O74LH&qYF?iBN6S1t3PP zN%cu84r*cY;)JR)MUuVF2hg<1^`NSdjM-l(<4oYiNuf5S&x-MFLVH)UbSN_Jr`hCx zV*jv@5@M>hG9vd{iQ>U@Kzn~n6@$5T`Sy4|BvIqW^&R1hWczPEVB)T4i#eWAyD6qv z-6k0NxYG0@5zxM^;y#*>fg^q(Ob~h`ihcxL`P^&r+&}vgeP48a1#eqOSFEd7XV?80 zv6elic?ohen2gvns-68|mn`Enk(>iJ)?mcx+`&FP**Pu^0PBcW_}hxWQG*8_z*?oj z9aMZPvD^`nlm&NF$sXNTjl{o>S%9>vtcjSuzoJX3-o9L70q0K30;05(5xI*Kp|Ls^=XeYzjhSICz}VRGckyN&mOs{swy-4#uYG3h zFQ>xJ=j*{!>uWF~0!IIjj~MOP55p4Bc@F&S#GQFA!=~?i@2bI|OSg;hZmn21-2`Bl z%kQdI&cWwS%!pfZ`88v@F5aeLN7Ux?4%1E*2hgAx&}x|mnD|q<*O7u?FW1%JYP=Tf zN@Va%H*`7k(N+EvavQ64a(Dza`7wV%5-NRi5m!$>K*ZKX$b_QpyOKHo_mMf#FNx?SXpsWkKB5U%J{_5`-3YE{w2>hFFd#kbO8OKdSsk>|69SG;8zvXRvqM1F7?4tHoLh zh^wH_D;~XikOxVm9b`+2iqC++G}T>%HC78;KyJRS(hw{m&}Ly>u__E`z|588;RdR9 zfX^ikzVD*8N$}yOwrmr&iK_<5>9N5W+r;5?$kpw0i%+U$)U2Qe3+0xx%|DK-70*YO zh0Q4?{Ph|w&Dp^+mVT`XES%U`(bfRuQA(cnsH3Me?j51z9!S#bDKGugmEBCu`2=Z* zRC8zg#)SCmzY2|9`Mm zB(+HqiD%jjih`nu*rXCdRfYr1rfCO#xp=*cl+~W(BY%Q`dI3|su#p?>{k~EKN9S$g zH=w#dYnIb&c_{Ia!ieKcxeH?~!ZRk5Uc{~aDr{)6X23ql4-AQ{^iTHU}2r z?0HV3Xc7~@lr(+32QK@(#-GW^c)<{#Z{c=joj&1KMd2oHdollF1b~ivUYyQaqX74f zt>r@H?e{Fhk9Au7ktjQ!u~iTM8z{&7EI!wuJ)tv!*4v2L*jN#UtVMi{H~ys^TlHBX zLKO>;=6PZ?t?I1Eyy08K!HHrGdKH`450r|EMh0XwWq<07!jgT73z7sk*Ss?2p7)i? z-L`Juvi5B7RHcLDty@*3effLPlgf$Li4@Z?Yt;871StW8@_U?S zj5ltP|Bvw?NYF&QYpQs2!-1sG&agESF4sF8;} zP{sbP40B*YGubo9h$fn2Bm#ByGB)tXMR$!dDmsgua65q9vk!}Iw!DEwYG8`t5X0?g zO^qoTI4w{8lH02pXeF9{W0YaL&$T@gAce3sNpnn#= z4!Unw#$7^&_UX#zkD`zledAza>m$3YpWF|F5;fMRr+u0Af!vE4OqVWh$$%k=RLDJD z;XIlfg9fT>x-e#hWxO&Z=ElNLUr!;;WalvB`tUdq(mRlWf_cdj1kQ^Z?h#|S=#I3x zz=5an$5oElGmox$zMRh=W4oZRBLSVlykG_xtxSN|R?*0`6|#?Or1|s681U5=sdCTm zLD_q9!LYhE>%Mr5Jtr?p;1eiFo}ds0)mAozDuyELdGk}fP~DjmXrf?dckZTJ6ecwF zCRz*^onpt{^K`*Xru2N`NB{f6cs$u;mOzCZ$aM3GHBY7{21->hz!m z|G#-8kh--?>brUt$3x^`Y{YcQDm|apAutAnPYI7CiqU+F*;@P$Df&g!vU%|J&mz%x$?7t;$<u z4cOp@UygpzVx1=?IBmHEO5eWBalEAB`BzgcV6-MvGyU0*z>pEaLj@BG7^qD#1y9U) zJdjaF&9a+3Z(rr^IMRCq)jyjlo8j58*nDn`T1gFV9va#$&eF&$y!aqAmS8PkE~PE( zJXyscw&+nx#~|>^`=l@P;u9F1P{0Z;XT1y?DC(RnIPKR^^2WV^NNM-25w_-G=K)N; zPb9@Nf8parQhneA(UF_Vxxo|~nUWFSUK_kTO=@|RqYC15Oqk%cM_U2>j|}sQf6csv zJqF(&XP$y(D!LC-$=HxsRZB{eH`;*jG@iToxpsfQt-> zl;~m)gQlR`LkV|nWYRv`{EDx&?+okEk`EI#gn){vodAN06TP31eKMU5v-8&@^H&bN z1Jt>kK*=$vW{ptGJz0=exE+JYRKgVIyTO$&6mr`|OR< zz$jr8EjnMHlBF~baPOCU5A9>P(}`r`2pk#Lb21};1u7i~ zC2&q93(GEk$4no#!4i~6F!t^-103L| zI?(qI0&GATzQ^hLPZItGaxX$9EqE}mDsRw4^v#9y0W%oQ7o%6j0N+UJ)){gkT8Id9 zA=`TIe5z25_2Rz~u!>NwXrQ8p<@uZNP6dBG+HybuK$A`CA&DJ4`toZ-L15PH!lDVRTfldtx)JjTx6pc%t8TP->i z6iIr+#>N2O#2f>IwwV}FhUFyz9ypkFK?9MInG{Q557-T~G;(o$3E_)IqgEI{XSg@E zSb2-yiFfg6P9#^%S|sSO-E!H8JuzMu796jqX>_i;ZA6QXsfe4CaxPLB0s%59PcCA* zb3KX3<-yv%FHj^~_pbN>X$QKmrSBb_)ip&8G?0~XauwYv7-z`+Jm;pVA$PVe$)Nrlflmd7@1BQ2b1zupIN#((h3xE$5U)Hn4W1Ui{TA9 z=4U)jMqA-!d+f45?%QeOoT*|}2O)$un-=1jk~w>jdg*LOw6rfSvqoOa&i&|fVLwS; zD@<#_fk{N#fHXuR*4P8adD&irwme?tgX-|9B$Neb&DKP%nLf$0OmG^z|7s=r-eW`I zP^_(FLhTcM1LZZt!ylny6mWrnOm|H;ObC!P2m&E${wJEqF-3(CbWTFEP6^%+h(mII zEnkfBmRW#NCLioPJI{pi3ot8;g~j*5bwEIJ{Qe4}XI9<*P|C;wv#puG{dCHyJ;^4_pmC4WHc8h{w^kE7j4-9tHi;Do`$BKpHBTxr$wkxs%*Y$oL}@m^}5B}D92R$0%86f8|i_;Fywq01^_j{DO^$V^)C|*S@BR%x;H44ohn2Y*#Y9fl~*!OB@oM#Jo}kOl;v7D zPD%>YmCw5mzwT8`Kgo*1VlE3}W8;g9Gk<~B{i#m%$Qt;+bh)9=#JaxCrRN(+Hf@-> z|22Fj@(nNQ&xSr5qR@gm4Zm^%vs7-*J@B#2cV@I;D$b+K3m=u77My%UiiXe4MU3PR zh6MVpl?HIVXTn&bB>y!QKUtoRv$8{aWjH-ow+!QJhKFg5I7;Ak`4aQC%jsfHBW@Md zmL=K?`*k09g~<{TbAJw)>r`HtS5Ib9h9FWqriInR-e!cpcT=#xe<46Y`zS{Q$fnRR z_%wO8&Y5=jeg%LRBgaiMwRlCrJj697A+l6uwKI-TGwgH9OxD@eq4!E*(B%hrfqvb& zX0~@~WW}jIv_IkQlIM-GO>v3$PM93P4=tQ1e+`wGT$AD3zMnIs1N4Cpe{GwEhu|#y z*jMHH56HtcBfb}x>|Zm@RToUt&R_L9fO0Zen;DEXCtT2fSV(C~dozXdW(QMoB6E%))mi(zlvJYsdUN&%SGog+pa0ox|4UF73us4; z+5XbY)Z`qz_~x&CDMS2s3j_wFS1*^{JC*)>O8)B(3P6~c|H78{xMeb?(LP|q`JWmO zfYbe-2mJU8ZQ`}gI@AA`Yx+MPp!atu%p8ClANtnbd!4rZV+Q(vd{Xb9>0cP~|MvmF zb^rN%?p;O!&?q{n1^A1F;&{lq?(h(r1pU@rtzHPCZDK)wD`(|zHy|K^S zOX=M7`M!;ZQGA02E z$3K_avAIS>^y&=BO?RC&u`6;iUnWZDa+gg$o@PH6F|royF}{W-eaxf|sDBC}=%D}TFb+5c2L&gvt! ze|wk9x>-1}%m3hPp3`!;V*fzBOZbrRvc6vAN?rGQtmxqG_yb+V()20cq{DRffj<(V zO4oH-`K0t_;v(n!&usFgFRe?XEbBEV1_!e@{m=wvmZ%1dr3SO9g9AUG%L4_K=A%FU zy@=`4%85LQzizIAICz6sUsyL=T9@_}G<`EH=C7qPA>+c&Ce>Rl4_p1Z_Ktd<%Eq7i8O?yTlf5@awH`UBth9@cl^~%wbj? zd#=uJ%fDXjC5=)`qDZZgeaa|eai=y;Xe?jD9(IPQPndM~tMzgsYyd zcRA!g-z*X)Ffldq_b-K?6CPr3gfWt(vdP1K+8%m;dpH47QwadaHPJuK5JJy-EC7T zS;X6-zHDi7vcB{pWlwmqH5=r)hY)yhoekidS*@;c>(r}Sd&RAoRl2RuPqZb#h1!%N zYdbpIKj&zv-SzsF^u(2g%RVzpsP%*Q4+3_&&#re5Y(y)}%O=&AMn7z?(Y%!CtsH4( z5k(FSq6btg894hiAul4rE24I^TV3Y8u5sFr5hIPa6-3;GGK~*2L!rbyj&weY{!4_? zGbg?qI1cfSG%gO8zBu=QU=CDJ^+Mq2e1LodZIo-Y=|#)+Ew*0*9t?CB+y;-oV7PT` zb{@Y`nQU#OlRtTNcNeq6lk0P{V6b$YvSgU{u%*5hEAi^bv{XDX?I7;Qe$yu9-e`=fu^I5m&3GRS7bBZr&J=f(7(R-}|w zU#`{&&`cFQdD+IjDD1H2rLV;+u4DCgCq`d6A0RM3#9l#$ho=&j2173PH|lR8KaFh( zMQ@R3ZzrHTO@3Zc^A_^UP#7f3P>cQBO9vurWVh&*Iw4$GloE6R1xOW;>rjJ;>G4elR9jT(cjiP1h` zPukY+u^q5{@yd7m?K)HQW3_Xj2>rHf;dku+D4TipB_dBd8c7<~eghb(o7)OjP}J(g zoPyS`FuLTv=0xeL;{@59oV0){2W{@{MOLFPcQPW#nZu`t4{7HxBtvBv%Sb`;SOGo= z`-QD1l}$!+cIjXB$C(peE(6){>t5SU$QOnvW~-g_FEXg~P@S!ib z{z>zGcLjL6pPfIPr83D$foxwLAw){Xon3EZeP&ZCzSAAD{Pv~u)r<~MLRD@Y2y+>Q zZH75(G6JiCa|il`bZ=FJ=)LboAJ2twfG-`jEi&)3c?#fYn86x~!PVAwFmiAwl#XGG z5Mrr%+@^;q2^9DNiU8d@03amZzL$zEis>3{sOCoP)hbdhemwlVJdp9jZm)mYu?**Z z>mrvw+kIomq?<=d6gddol%h4RY}ISb+qP`yUoy7ri!bI#cN}MPBESW$m*t5GB46X})zk zz2S4+!SS#N;hl%>M8;_5 z42p|vt}8eku!B#iX+7lK4(?mFqqh7(l5$UJY0s~1cMmhRR%?zsd@a3>v5qq?>G?Th zK>DmmnbVBKUPE=z20i%Q0pja}mZcWA@_xtI&wZt|ouz+k>42Mr8PXr95Kd>aX_^kk zACkIjJ3 zqpn>zFNbE9$@f!lWq7m*3r1&&X#yjwF(xzTs#Sci0CDk&zpQ#|ctd(yR85=j`u0Nj z8&iK@qPpF)GK)#RCP9z&1YYpwmLSX6GP?D^I-jGzu`&PyD4bs^&;l}5AQZaqr@#V! zaanIdww7=D#J$?Sz*H-lD>nVs_%S3fx|$!QY3EY2+>V}J#UGtJ>qW*!2I$M8N{u(W z_kG5zV5VdRil0EnHttH;33|-t{X7_gjfyjJ8z%5@N?}ZmoAy2AuA(=0X>^6;YAXoJ zl#OxOXe@1#^t49;FxV-jc6_1(GdhXpc_LlVtx+u8y(H1(!0s&_^n!~PJ=qB@N&;$f z9$WT*QTLWnaV=ZhXhM(%0%=?WjRXmv;O;aYAi*U-aCd7UKtl-b5ZomY2<{NvAwURD z;{puw4tj@oCUW?93ENqFo7*TBhsnM5wXZc$Qhcbgp?*TD7Y%ChFl z2=t@%nAZy6-q2lqHO9K-;!BY(J!?E)HIT`g(sW7tEq?BytO|1;@u0)VR%MJZ7W+{X zBfY>@(`v(qB`X5i5yvmL0aOM zAuVy;L51Gb%ZIS!w@KLq0GjAQx#y3IgL#$fT%3f-Zo*}nUV*~P_?AZxad5auxwQD? zto6C=aUa?1b>4emV@1y?>4i^bA10X2X3g1MT)!$-lFypV80{+F&>N=#$v+p zagG-s(nA-c5J5T51DT}r;>CVuqFD0REo(Y_O|5i%vyhr?FTE&gB=1)_d7+qU#kN>- zset?-_5LD^R*Sbl&_}rh8`x;qN5!rHKy4)Qp{LElR#Dkp#_o=O2ctpJrXaAEb9*u@O*9<{ z@`aaV8$ea4Ik4}4!DKM{0`L!pSwHJ;s><5d`+dr+2|$px*27d~Eaw8u55Klk7?G** zcyh@~tHe$~6_hk*)QrJZm<~jal^Fv#Pn^=sQ$U=Mcc5}>y#lA5x2 zMmTZkIDC*c1uuE$U;;@%XqAS~aXWdQyxOOCW>P zc8$|}z{7-*jNJa#x}~v6f0RRz5faQ0s}bUrXD=o5JdN+RyLGKi9={O11QcXHM{Y3w zD^>MZ4rt(loj*ObLG`n!=@vO=+5}Fk`B= z)t8CI{O;I(gnB)<+@W;AxoTnim2>02t=^fjAuC}xIkYKZ#9p*a55VSnKltd^G{b(7 zP)YccPD(g-76Q?_KLJzs*VZX5+!@bKa}yfb7{;lVKY=5JJs}{ zGND4z;HNYkPw_d&;>4BFlck}hmvcAr?NI-IVIp#0wXCFL=mtZ!rm$RUZi4~Z3+A5m z7{~^VEu@bj$Lipv2W+$o&DL^yyeQT4JMzK@NkR3FRivelMDv=V1^C?q?HJTforH>B z%+!{r$2ow35=NP?y7c5%{F7a$7tdvAn`vZyOa4kQkBQLjqC`$k(_J_W?Gc%~xJxH=KuGF8vpF5Vu=CM$ryeC--# zPY%eoYxy8pLw!!q8GFQkX>;*Q^ON62pdB@WhAr0vIT|M;(jG1%o@+hRk)(@8spya- z&c;U=o`hdLd)E3HQHz_0I$gl)a&{ep1Z!!a1s1*&%E+aA8{-pq!Asmj9IO|xg}EoA zZ$oXmTbw9*QOe5zU%Hd{D`OX45B&(ud5U%#pZCY@Ts^!hokIZf@yE1p*kld~)7B$$ zCm)4QYmmPR@)N%(t6)^Mg~+-toE$<}!RM!D{;DNYGozQ@FSD}y2rE6AfGCN`Q|^lP zFkY53O?v6o^OzpfAL06zQ#phx%xCfX3*UpWEcBZeyS>QvX-daJC zh(=@aMg?ymqoSwxA9^o&E9YJso14 zn$kfgTX7Mmix+p2nScBaF9pjxr)09x@WTDA%I`yMwwih_A2UGf?0m zgNa|QcJ$!|AMmXZU#9a$IOaZxG#+G-)mJNTUVX(gdVIPqRZfBX{^)9L1^wNTxDUt^ z0NRvP*J*{LuX^QQC>%(Amv{hJyWJnftb^V$OdZdI2OSqcp4;Ku#fyw=Q-Dzp@T>9} zIcM*(p?ig8MKo(3qEFGlX{;fA1i~>!Byj0>5SPDyllNq={ZLF%r|WT$~T zQ@e80uY67z!K$%KR$Wn@!g5471=}Km0r%eU@uk*jKPrsG=Pg6@Q(O+Tc`}Pve(boG z7zMO5z&=7*yUyCgt%MInO_Hf0h>wVzvI}cD;U;x#DC{5sA-z=f8=gb^GgB;RSXZA% zM4Cd)(WZuN5r{U>T_MLTHoeqaEI5t_G72DJu-(*TXO7c>*9Xg10Psy0kQbnWN%!}1 zlW$97SZ|3zCM{NJ<3fotqN+)Ix8XMUd5znU14iIM=|Q?|z=jZyKHkO(&*IE^yK=CG zuU9@XB|~c2f9Oe0zmhh4MzW7mQ<`~+eC+3IdW9~f7g9U*)x0h@Siba?l2jI>KgXw{ z$_;|z&E7ZtL0yLr_-s;n9GQ~EAVbs+UaQT`o!y4Zme1mXY^3lrH)-uof{QSWaX^>) z6c(R&$L%^97E>J`Up%FwSlq534n{ZP!t;D{)YG!+qUMMy z4R=iS@OgA;D-Idlb}M%?PH%lXo6U#%b{{xB`VkvY4kg13bR{mFvf*b(aAS;vE+?EK~X8tDp>y^#oGNPYkY+~OODjmEo|KS&(Jm*Q4~-x11C zJk>7WFt`y>=W|@(RXz=A3_{`poQB}Ft=b1-ukkUD#YrAQ(|1Otq&_@bG1laRep4aL zxDF>?bCNRZ>c@zw5uDBzH9*COv6VN#jX2xh)o;O#X$r&d$5o_wh}!_H8Of07D{*S* zD=7^ErZ?aRTgO@`O8AY(tfmwLUTbZ5AGsd7(3SyqazFbk5FBmnZcIonh8S=WBO?M> z=t#N^x`Z7#Eu*YATyX%JtFM!m{4iBO%_G?S{fX1E4)3{Do}n!h25LmG)vjD%#k|Gv z8hxmP0k8iS_*ANQ#jg7_4vEUA03ml16a8Kd(yP%OtxG*IZhmg}qBTtI6cakR41qQIgj1iNpAGz5XKb2RJNRwfI{WtS)sTWZF~ zlYqQ?WA1}J(EJ{-&fKsZYNUv|zX5`8R8@a19;@~;!~#j7!^8=N(~(?KM0SatV z)gYWwNh=4_C;A3yHex=9SWb)M!CDoQmf&0rZY<3rvRtYNZGd&sR zyu)`6R0_)jeObLS*$~AhKOq`AO*KxluolSen5g0>zMQrfT%14#l-2Di^W}8&ZivCM zz=GBV>dlNMvUurJBRp#tzOkANQK)VghyG6s*Sy8Z{UxXGunzY&e(Aza_|$nZIftA= z&ozYZpgGC_cce>Si*s^V<@e;0+Hq$DFK8>w1tYM$#@8H}6t6GsQnW#bvy^lxF!lQ& zBLdcPx|$?}LZ{p6i_VNoIolm{FRz50kZYT)_g|$H3yA1)goZ^7)YSBqC}Ph`*EWLd zKHU@}FvNxFx#Mr*r6r6mfQ;K*g%;K5@aXH#yAlo@x-x>8`%Zj($6&Di;rVE45@zFt z_7?om_r~Ls)kG-xvtYp-cgUO7i}QSy>OX#(enwU>b>}NuWaJqTkkK7FCR=N<*(m{G z_8ZSEgpM*|1U!rZBYpCGt?=ftrTh&;e@w3J@L3}jFGJ7AGcpd+H0|5qfR)M(FNz}` zF|s}@t(|A(LzwNOOI@F3aoPFZp{7v%qY1CnGRHRt_=BJ+Xxzampl=`2fR`LDr;uC7 zkcvU9GBn_I$RaHc8|Keu_;2G|v2L<-8193^D`YKl4ScS8VK6D*hmx(uYjN74o+9aQ z^6z8z)w-NEEklqs{9XY#K}x>^(OCm9;4x)QCTyp7ByXbU4)bT8FXz)#>3YsfN|WuE zFB@e^MnsrXO?}udZM469AFx-{=CI;BF!_vQZuDSbN+)L5)A%Zatuw#%=4Z^6T?jhu zJx4O>cY;)+7@QX*8~un&xtmlS0k7mJ^X_1a_o6ZZn8Hsoq+#sPhwt|phOw?iW0HFA zy>`30%pk&Ev-Ey~e$=)I1h5QlY{wy>@W2}BP05u zisxMverxAILhGFXO_`Uh)S|1#Fv_VlM-FbEhD6beQ(+`3BP6VEo%({@jz0iP&~m*d z7ZCDP-esuDc1G|=s+ zdy2Gtpm6YL`5b}HB1nhE&2u(rx`fn{w9uaz#b0W(NO0 zV3+V;1SJxFQQw5rhgEKJV*!4_hXX&S1+oDhffF~&U@JxB)1nO;2w6rIkvvL7c!v2%JnK7f}L5^Xu7Oyi<{!1V=n=@=(0 znn5L#H4sYfSuRYu+ss`+hX-Q-zQOH2bTFiH6VCPPwEZrq?WQOGCpGDw|i=oWFuq zVB)s3vaeiQieOZ$(c#&`IKBJS=;!K-K`G=Nm69Rb&3odhV|JpDa8_3 zK2dppT+wjsyCdAys(GnZnk2u%ZWPBGK>Nu!3qr;LWd&D|t`8QQabaiz#{jc+-B?bw z%#YD#H1h#b7opqFC4T^6{JH{j=|qA?&P+zMQtxYoK#bBfyq%U2bbjnGp2?Red52&S zpIy^J=Cu;A1087;4c-+gHQvtorn|1NU9pb4gHinGtM62be(^kwOz*jL51LWg0L;Ig z{p{tPvc4z{R!tMRj1~rbG*>)}YsV*6RCArKsM3;#jn#PH=pAP^E}l%g`ss5h4fpbS z$6X?d1u1oJ7w@FXASLDjmB}M4S3XKSBI}s}p>rC}$z1FE?qHI3>B_R7`m^gz0%c8-l;C-P-BB*Ss@#s`zAhvFc($s>kkr?6(lE<#`%y6nV9k!%cz z?lK=!Ty`H6m?c0|WOey;Aj_KDz!};U$Vd4{LvQE>*4vE%S*koyVwX=-AD#lB3HKbs z`&V1sf~6K2j!<$SZXB?r0ci8o#HP^-VCDXpHudyg>aoJYM~F-;lE<@}c>d>nG9;n3 z;l$4nQa8KLz(=<@OvWz7&8@QBp?(Z>$5?VnmtfZvihlm zFav8P+gSE6F_J&FKNeC@MRBO)a|69;IL=v9oavffxE-wo_$> zD6uiKI3yP=o8|vDVB23DwM{0Bo+)&nHW+|HcBK}uOq(h1i2?Xmz%l9$*|kd-jg?#~ zQ>~%K6aWqjjQz1}q$JR-gK*BTZ?B<0I}*m${+J`sg5@3MGpH8(qVeVqlP1A%=oS07>E55EZ`FU@10oQ2AZal_($BNUNvYWz$Z zrOYVz5xnE>H;`4`&cpH<&Xx^eK^HL2hg&0b}M<G*t@kp{rgMp~H%`!9if- z+0HBkQY)29Gt&L!ZLIMd+Dp=wm{z_Z?zRVrk7?mSs@9AxB1Wh%T#i1w=PHS*Y4^t- zjAOOv@Fx1Zq!C5kj-O9DSpc_^;wyT9ZCILg;pGh%g5?h0!=)$(YDLh3shyV}9XS!l#u}?(MZX z^6FJj_c0mU`H2;fm21){_ZbH@S8D~IKpD7SXQ^*N2hHZ2O))H`P zO*Wo8BIyf^SOGv*SlPuA%#qxOp6<*({rIp7*sB4lK`emSapuo5uCK9!r*D^ji9=}p zvND4nZ5X7gg^|%fs2B>qO23uh5z1DzoFYR<%WzAM`VPl4o+H}V*D*Y?ffV$-NVox2nx_@7W1E>J#o0fM^5e(ixG^GII|`N7%Lu?Yl;yEzQKJh zJ*;}LD13(Va>WY|IOF0m3`XF}7zrhuvK{JP*x`{mnb7EDNpcnR*6z>JMqu?PN6l}yB^am|dNnFe zkLCkPxg~!sUoSQE-vUK;1UMOJ_G8js8Ty>KVdR#1K7j10=Akcr;dH58 zO*x_ZvD?zP(w;5W&eYIX{`PjxA$VRPuks|{+cJDj$ho|7L=_l2ctR zc$N0L_^ZeH)EJEyj1&PG-EK>2@_y_FX|fc-(BM}xR7oSGNL1xmXB3U$cH~Ff@*mbN z$uUQon;BjuPQC-dJLyuNL_`X)T?%_3JYm-KrGTZu6xZm{uoeieI|9?tO@s-Tj>6#e zUl#?mc6nRreRMk2Ih_x^)=6!VT6Ls5jAJiY-qK&#NRJbjyX**jxXqMqw+p#a5f%p= zNyi8AGdhfeYc44jR4?hdivP?tV(_)hLJvO2(Np0kL)NR9Q`rDan%oGjujt~Ac>Cj9 znPa4E!&EC|(H$o4@VW-3{x{VM){rwkNd$44T!{jOj5p`ecQELZwaEKWd&)stI+5w3 zS7rb}Lj<^ISyadX{``5Y>*NfbP7QU7*jb~DZSq3T3^Aww5D*tci9+8iN2AKfe4~8& z5qX-H@YH7FGe0gehKlpg?_XpR`LLh*MP#Nvc%SO?8Q-MEO5M-=jU;*!(^WJAe{jTBo`-_^FOYbK?3%QNKXv7JHobDb={a&xjJ; zv_G-W7@t>(in8%PSO6gAO+C4D5%A(52Z4r7vb7>#3z?cfd)(7@$Z3N;WP_iyjRZA| zD`^V>tlw8iOKlac=5bV?#MwIwzE6L&p|$ATC{GSEBIPDq z0+oL)>g6Z=Vcht68FJRJSq(Vy@t+^72m_(0k-<1Q7qUC7+==ZhC=Z1-7#FDuRg%8B zjsIvnhqgt(VJf-l9(EdLIEY@9NDer_B&WPfN5#Q-221C9w6o2_;BL^Oe1qAAkGshr zcbw)h*NHUht-$PGDDCjxxXtd6d>N~u*{h2mpFp~yXHxRLIMRlF?$ZfCWh%9_zrZ4! zG7j;~D~vG6b{{O1wYF=XR58gpROIkXac?2V39*N&R5v}%kW~`BBPu3JW=}_=IyW}v z)4u_C87*Jum%G9M`fc^jl@;?}hRYZz)8=t~m}3_v3jym-p>3SW3dw~n$qz>t>(|IDf{Y<0tRbUtNjyH}K5AULq^2wP<>xs(H zQuYd@zqyS%8!&<$efsLP4&x%5>F6>U1NUS1c_=xOH)`;IeIy7jqOQrRi>yoKi366a zSQSW^>2vi#YSvM(vc8!2Ga$Dwy6#4&LuYCEi<$3!L7&9%~RFj zCHbSqJ{e`>4}J7s%WjlB${bJKqZ-IY*6i}b6$!isuN?$ADPGAN`J;2aq|>Fcjv;e^ zb>4I09P#cLTff1ne|QG=l^hBoB^kn9#WO_1;mu)0@(Q1qvR~7681`8>bSbYDLU3^j zJJNKH!#WxEN6zKuxzsOBp!)x@IutCXLuanZtnn zsMW6)t5WaF{Zn;X1{}iz09Ue_8bna@ED_F(s}+*ICb#yW&ob;~{zt#b^(361*?qKZ zbxYR_k7M8wZq$KkJ$J1Lqx-KJF8;!c&!t0fOJb}Z)ett$)A28wLtqSIl{R z?frqHJa7tPSFt3BtFsu;XL9|~*Z5(Vo%*$nEy)KVl}r+1#IU~KYKToNE`#~8ceGWv zTuNMc1C6hB@>>a8GGn(u00H+Ld^9`a6j$gK@0!%0tQO9!o)+y3iFx8U6pJ|U3W@G+ z?!^xSgJi@d%K$V@IYF?~YT&A!MEl}hWQX+O^g-b1yZ9{!&e^W6_fP1I2CB6ULp2|zBbn+m3FB92ND3JPiH^yHKqj3SXu^g~gK z)lCoTt){JC`zCDN9%41I)mx3%pu06=8}2xtJG zY^3|`xqu{!`bjMiGzVayEqef%^uGz70F3j$LEdlxl}MP-<@^8-EvcsCbL6JhlfExIoBW0==kF8$pH?y#j+{K?`fb27H z%T)m7&#o;!N?2wdz+Q{qUHYcjGUa;f$&<=o)89zgMs^9qd|~^~^x1(1C^}3D5xMQ& zDwYZ6JlSb`S(c(j^ip`HX#!IQM1@g;sH(4tHq+yg|3@9Hyz6yYDsF5||E?l`Bcse_ z&)+z*7N|l7q4k}G_`d6nj8_|os*|%T!@%S9~tY*yDQDaL*SzmstPaB|qIqQeJl>zi5CTix1Fv;oX{(pzSM{id09*2an3gycHc zfrt~fVc{wrPW>8(aH4RqxWdb(S__-W;rrwM^|JXU3CgMB_Xl&uKvfYv>z;?dtNIiv zs5jdylkThdkM5LunR)1y_^eqO6rXqF0>i>F)#&ZzxfUg1nbNp7f6n4rb3j&`u`LY9 z21mGqQO*4#k6AkKvO4mLMLvg;6zKaH&s1OIu|cTdzaFHx_nYwiV{{pi_{Nrn{@G!|VxjGtBhersJW@`S!zmho80MpIIn9 zfJS$6wuKCBkKg;O*EYD`?V+%3UZXDN)rzqHIJ}vY*OwFrjka}+442u^&@P_1f0PxV)OOrGCoDjPmZ(rNB9~=l*hL zWxP~*w~9bKxF}*iL;dXb3a|0()~-zCO$HAH;bW)Bpa0x4)&9`M;*_7v3=p}AnbXaS z&{FvxU3*3CD%h9bF%g|{hkrs4W0fHAeO+gZ=8CoIeHguWg<-%?2@q8`dIs!71whRs zN^N!ohzv}#oE``_J3Cw**fRyDAm7Lr%T? zRq3*Eys3dmuJPIV35fOxTMev9D9!bhrCKrd&9wCI_=(`6)jyO&^m(Fn4g;lCi<3Rr zAK{2A_2wBVpm|eYji)P4X53nJW`FYdx;W3&c@#(ui}f4`iZtp#gM6JjWG&VbPW4{{ zK@a&0nxps0U&!H!BVMl!c*K3GT-<~RKO*&5Xw0j+NnlcSZeUTCAfkrr)@x=6^j3v|9T5?|9T5?NQO^KO6GseM@Ff*7u(iR?amu&!~$;t->cTH?3}!$C!}Kk?ZkV( z@ak`TFYY|g!aFefcXV9FR(SHxxxbuJr0w&8B0qV@3forcNLb(0 zHAWKT#F%P$Y3a_ydT=GDrCQo9=1 ze6%bA_cv7*3r$s46Q8TxCRtg4355TR+lYoo=pLr+y5O<7R_0S(lQpH<9b%-zVjbc0 z%uv{t^TJHy=K1Z#g8bIpZ3+TultsG-7BFD;c5-n9y{ z;%Hgrg4)EEjGuNhslwAMP@s`N&}ts1oT;14nhGB|^IukwR1X@})i`}xNG0+ZTd1v~Y? zJq=%XP0!6xgNg&Tyf~Ed@Tp#u9twk51?z&Yfy0e7yJ2NSs)zp7h8d-0o1PpaJs;o^ z-f1Kpocy2&Ni1E6G8MVEY_R?ruvxNgnuo{Q($*do=Lqn>1t~Vdeo9n_>!?rj@74j0 z++6%4nFVp840&;?O5COMO*BG)QnctEa2mgK+E?xwX_1m+T%(0>Uv_Jx7uj&uXlFbF zipnwO0ie#`KY#xHU%!A00G#mE@9+QD#Xpw-zVp9d{2gxdUl-+mhajPd|GlJK5fHfp z#P0m^jK+@;Qx{}71&B8jTB>GD zbo}ltTv+v#ixs7v<2Y?X$Fhs0rF<~{pHnY3P{A*Ce$-*%3+K~n7L;}e8Y2A9FNuhb zttM?}b|aPkE*3`+hxv8A&933}0CV0D0LFmFZOeH^n|gcn`Q&OW++v&o`W0v03N#X7KlDEHU+2BCSZF`BSPB`kSi9P_@Xk}Wn>za_ z^6DRB#(_J8vWtR+%3`|E?o!Q-r_(WH(A45NXUoM5ghqCp#`6zJ5gE(;V*uAW@%+Hy zrbGE@mg*RAv=2INu$Tp%N|xptX9>@;4==H%@9 zAM)eKEa@cXB^DqSDi$l2@>`+>8Rn{NG_+exV?~ty{!aTJF_NlF=7<%mR072ya}CF_ zCkx5ppB$T6zm(u_htIm^rZt}D0`Z9Q8Quv%z7`w}?gtBH*EkNRJP8UtFBcGKx+d$Sv5YJa^O4qgVb(xZl>e#zG#taj9U zMzQyL08qicK6QpV8sYl4qX|`RW$l^&Q{A76JKK<$Fg3m)#wP53T3=}@0{Zu+OU!h} z_R77WmG>`=14;+W`bU1AfQJA(kriQe&dxR)0oQ9LyubAUp7d_2!COOv)_JZFX(_2U zKcRdhlL-wtF#ol!W4Q*hz3K4T=ixG6fEr)J?1sAc(gXfh1~_6k8c$peRo7G+&6`#O z3OU+oye2EJFjS*d*{fKQ^&ijdTbs5pY2dXQ3%qlGqU+|nt53WY(lU*T~a=9 zm+NW`6%hQR$!eH8V!=NH{=KNLWKY-Gd}sxZg@5gSFe55wx+llf70d6XH^89}jchB_ zbYoBG&i`-afYThY>ub^{+9P9f$12yrs|*fRFD+vuHT?mci2wG)qCjOPVB=vn`uX%< z&s+*|Ih8t|Hp{rg1P-)+EuCZ}x+1nM_U{Mkvj0x?^Ver7D8d(hFY|xGxBq%85P=G4 z>E9`F{(8qB3ki_3i96~3D*JaX85N=P=mGeJbJjrjy0r=`=)(fVVu{f%?yn`sT# zNZ6d@MgT- z6uIGL@%DAm{_3u!2ew-g7*NOdy1TzlL_qJyTW=bjdnBB;?rD7|P_ZoJ^Xl#T6UkLh8!k z{#WZ{Wb@^o%yrA;9Ff7kPq*(Gy@7&GjgMef#_q~ffyT+F)YcJP+dTQchXn?E6eF97 zb4jcXdl7yf_bVf(-oD69?%u;{9=qYFPV3Dx-ii^Fqb{_IsrPL6p1%aHeeXIZQE^>U z^l2g9Mb8d;KG!^!4D2p|JHC@bbOczEJAg z=V8H_)|F<@tgWFbyq%9{VM9|Ck$BzQbTf1tVZ9_&U|D3@DP+F~(J0g#sV~Lf2aB&h zenAqgI9E2A-+I=vkv_hJiemcZv)fD?9-=7$KI7a*Zy#K-QHN{>- z;Yj-_TvldvGCZj7%WM6n4IbYnSEUn|Yuz#1*15Nd%xCG|4x{X1!M{Q#E4-U8evV9z zl<8T`-i}3FQ=Y~@zFOGYJoBR1YFZzvB{HMxb_)JBB786Sl;?U`i9h(T(#3TLws)>E z@7U|~7}tEI?QVDFT)B0M{nm74{`im?WhvZkcx3jtAa;1yQ}^EL^h>2~p{*OAtI)5c z6JH;_|3205)94Gs8l8{2DavQiJe)t%^Z17rvo?<#cuLb8$`O zdmLL`Gcr37isdF;sKR;MW5+{zlz!hZ)?Hnu<5cWD*1@8s?|oz7Q?IgNcb~Ce#uv3J zj$42#=DsfxdFs7Gp?&e%5*N;7SK&3uhabSF1w>u6T<#59sn5g8hOKI*jt`V@BjZ?Q zf3ln>&(wwAUsRqRUsI{byi`tJm*)}VJ-T zA^!x1E-Lw-PmKR7u|kM~F#~A8FPK!esN+Y15EZ)C>kQsg_|5UT`m_;rc^lzVEh_`2 zuB3kRngHH}c%E|mhWV9cB^f9g3J!`BM;1D6XG)tScb)r`cHX#IdU;tsQ8VS0i2_qq z1o-uqB7Ix+2&EBv9+g!7?8u!bcq4n(n9JJ*R{K%h^Se>HbkUtF*&2vmYG>wmQjF%( z{Yq%CXIA%TGFDjfB`OQ$_$jw^i2k-zOaE2Io|nP_aI2f9j#V0gqCq;J@`d3XVIQWF znhDXd(u`3>a7;v)B#(UbtB(UE@^w520>Lu%zV7Eir9hPI{s`Cv+M&(fF=H

k5K1a5Drg`h@^)hGj1D;v)5@Ls%(c;r~21RVnMJ&yBY zh^xjI+i`H*Dhg>J*>*+)GjJu zFn~_1hlmWCraDsx+dm==KknX6c0Y`t1VTuM%yBtkKPhXb-JpoJ%SgfK zjL>>bjN*I>J1UTbF3QPd*sjc3-ow}$2Q2o|W|xI;J@NH&{Smlnhr)5)YvmqKSjioC zCHEVluhL<|1mUVxfIu@x*EDxOPub&p^`u>3&$~`dKi-+Xz4SRKCyaIM{ zTj`sRl4ERX9jU*lDSo38Ge$7X_pMMdMu{@t*i{X*J^gsJTFHhz{ej=%Q@08@C^Iys z&`LQe)wdvwx`(c2^Wf*PixJz^&FHl)=L-|gk9h^@UPZ^kpx7UfCj&p|HlkXr9Y5h4eBFa8viV@*N~~&C#v@2Ma)TEj!YqYFj`m791986U?bO zSi8lYkfTj{lsaJh>xO0Xr+itXccvu$GpA1(iW`qDZiXB8v7$dveQJERpehB9Fm17C z9@tLAtxMAn-^J~CToZjQ#R!Ua=soxhV?m)>6O^S(XvX|xEJ&!D=I|{;sW0+N5x_0Nkg4KAcbQF}AbB@3ppb@cp;5S4Htf}SNi&0F z-^62X1SjZ!f60OwtWH{ip$d_UN_aF4Cx7;aU{-(ErS6bbjh~TMPAdNy1XTQS*b!oM zEi0SnD$=V7+IwAYDZAXLN*ujY=S#hC|My2b^9sw*BQfds%~r zEo+x11tv1VnzU@!3+U8nM;X3sG_zD`{APnHm^xWzRxRQf_c*n_yCw>QZ1ooWZ62Ab zr+CILsv?2bWV=6}IxXN__~NTNZ({RZ-LNdeRX!Da%qjYv9V3WO#>HDkUW_=q5Udh(%Gqt@KMPl|?Hm1Ginw+D=z+@gY>d{}X@s3Knxz+wwQlu+i%y+o; za)~nNMo*90WvmKMU_{MT>p$z9-Kz~{#};y!DzvT{yXMHnXzy&J5gFt&djWJ z?#f_jnc~VbP$C^|S+>HvxZp1VmnsSVio25Az>cG$Qsi1GW5Rp4M(!*xs4>H{+u-1%YjV)B3{^kjboW3cLmZO%@=&Io!~DSk?4C| zE23Szy*(ZD!sT4Vcghw@`5HIyki3~uUaXgk2+2idyJ-Tw9bsD`qoj5Rn@{7994(IQ z@u~QEme#|&Ab6I8uo2jqStPxyr5@_tP?M01W3YI4MzLkm4{Uk}lp^g^$K{T_8CiL2i!f{R%^Rg%1L1m$({MU!>i>2h* z{Lc`DU3~SE`dQEADy4?f9WkgXo){@Ex@`YGRb7JV$I&RB+{w=nAMq&ZR-RmQMy2Sg zK@D814-^88$L?`Y@{lbAr7J3#--P5+WhI5ct=@o93k>W}qhhk+Y_PJCY5mbz8?Js1 zZ$lAcpB}uU;PJC(jkHPKpo?09cDf5qDxTgB)U&T8jjQEKrhPuaRGm8hEE)G#0?_xmnJDh=eG_S80m)NBI~Zdg+Pq1$PpM9z_b>!a`IN4>fOu zj9%c{Q-!N9ygO&ki+Z!d0*bD@sohC|GKx!LJ5p1$-UJC8BZ6K{?+3>Q?d*@?!>Mwq z88uYT;N+ogH)4e`N-KiVCGzpOb&CGrU)#Ncc^`DOhB?>iH;^9UU&CLm>b_F1WWEoE zeomNQ)m>;^{Af5krwG>fKAulY&%8-|r&F(26bg9^odzfk9#I?i2}h@6iV-soyr=l! zvL@jbt`Sm2c|e>oAF05MLKU?Tx?xwh!jyH)f6gr1wIl$s$`Z;>6QhzoDMgp?WVGCg6{3)|DzCS z83BEY(ax#vZ$B|~er09wmAd{Vh_(Z44h;gt5y6Wal!F6TG2&Ex->16cq3p}CR@75WSGE!(|vcSHT~R+0>(TNTSvjC{6-v%&EJ6D zegr#QL;0I5{+{H1kT86`uu9ezTgi;TxzHCualyd8(U-+XdNo`cjCw-0;^3Kra&i>9 zE3^0Ja{gzmCDHokChEAdVB>fr_Crkq_=+M(yi+62L+V0mx?@P&=)FyjvX(Vb&r25^ zc%0SE2*Q?Q`S8#{nB?$I5>fAaf0`d&#%N*|ta?QtO)}bg-`7xI^-h+ymEnGx?H1Jj zoOUQm@@A;JQGp~Lf*g}gD{Op?1D|O5cpH&a>Ea(X61p9-a^CiVa$_WXQqO6iiR#*M zX`(ilU#wSf=UHuJinqfTNYnmO&j;)M5hg~}D{faj__x$ZZ-%n2a$>4iZouJi*qgpI zOAh zfGGQk{K>IINmR}B?jlLOGDSsbpnX4Qy5k-}y)T&w1dRY5H>iN+|5ZV8^gL|0<#6~i zO>#PHoOLJnXi7$oJ%%=nQ#}qpFH!HgTy78qcD&n_ma9!FBSu^isp?F!^v(%#$I=dWAGM*Roo{<}*lq zk3hFzUV2)i93QUBG&s#wJ5!)`S9v3#8r_U+c)2i$wfBqzO3>|eQp%hWdN@Q`Ht&I@ z+6>!Eb3+U?v+(u25d(Qq-o0U5-Y;t~QF`3H5lu`RO=3Wsxnp4ohUdOzvl?2_&!H4^ zKYtYIdKTzd`9B-|-&Td+2QE=?MWNzHepK(O?8omOcS=IB$Bc9D#XHM?Awn5$W!Y*M zBfp^Rf~i5BFK~XAL2~aYFt2;x|A2sSBFZV_II;M;uc^(T>ME@4TqlZQWAV9Ns^Jh? zkHCvL>vIXTn0yOSv0evp=&OVZeE7qjgdVVzn20|ZeOx)4F?ZM;#gO&|$uWhl=5;cd zgGy5VBi=JR*^Gq953j`VkG!}Hle(ec1V)~BZ_?mKlGHUyk{n6LMRX6Rzn~d1KHdC~ zc=b!Mr~1Q11OlfJ_VBG~^ij?Yo1p3-=2(YCn0y#2)g3A$hupo`x>B5^6n>)j9rQxV zR|>T!F{V2BySvYbgqdsl1}27L5AhcA(%hx*ZnW&!53fV7v=-xXhRDn-Uk%O5 zScR~6xU+FH%RzTWw1l!eTmWjlNyPhsgk%hE_BpWJ2o8J6c+&SjzcaY6*IuY`Vr%6{ zd@T76@(@Sh{%9cFv4UOB;{_NW5TSI>h4{2{CcFd-a%96M-#6d{BeM^EGWlZ2OCOzB zx2*)|6a^di>(m_YP`m`};;MAVE|DA}BSX2}qHm^xhJx20@x2O{7Q_ zkro7^fV2ccm7)+(KzgqcL^?zPrGwH!M+h~vyE*50-rw`S_m5|ux%bZHpJbRldsf!k z`&&L`tz){^N^75=%O9T6Mo(hDjoB}TFT7@1>*QBI*FZDv$_QX0$jFuY6WD?XE6ZV4 zZWk3X@XWqic)nTpnY?ZL5qepu=2x^|KVfj3KXCY+KU>E`+&Jxzp&Wy`B=)RqbDj9j zr@(XBuEDO^8mIDT@_FRpjQ@sjixXRS8*lR16SqJ|&c+YQ!}sam{vQwOJBY1vnny|) zm|M*Yn)U_Ia^9IL!-jg9Kiw(_Tsb{tdhflU1xztE5VwgeTVG8p#kAi*_=MH>i&WQE zUN^<&F54Fetoy22`!lM%nt9ki8J~MY&1^H$hGWXT(-#qq&4A12xC@GQ?sGl43OlPM zhIkz}+4a;i`Cw5KywQlwSiY4xIr5kidWJVzDELER_xy08MaF*ZmhAlJtD_pPv2EaC zhe%3jpFqoP{!uDMxncN(k!HJNtIVh_`}Tp6pT2GbusXbCdCqN^0T3X$U6$s=M*WZC zCGC@6zKvQE>5JOzmfcIe;jz&DJXP#XuFw~BK>OoPR}HN=kJMt#I)ArrnnG7@^DudC zw~7|WFvYnERho>&NUo9GDF9IDG$!k4;J*l9&poot^mp`fqy4$IlcsQE(moUG7M}-E zOOM5=BaQkivWMh8R;0;k3{MUSNB9k2jdUDIIHc|9st5lGzZe_<0tkTGSkz86P+(p* z3?cD~USwK@qfMGL2@W`iEz4I~Asi)t;}c#zj>=+vk){D*cP(pPc1?41N}l#(C^h;; zS#P8B5Q%#Vd=uj`vjxliUX>&@bBJL}H1{*J4`g?kpR5|RYJ0!VfI7So8-qpYoCP)W zjECGb#EJ6AQk@P*72`f~Wk$8wCnu@q;M^CL+gUH{sn_PS90DgEJfMKeGrs9!)6QPJbgq`dGfC+%_Z4 z!J{ZG+}%*KBhpU&wYsnqQR2YBqXECJK%Vj3Tc`KU7NZ4!X8jDVo>g)oS=iCGmDY=8 z!xaxV%La788r0vARNB`ad}a_;VU{PD=y3-3@L zDb%h9i}}W%Uf{5Z>!LI2e7NDm-Q2mNDtwD4rH>wXi6AX~xhrBH98M{R?Lwl4obEV2 z`v^Q>GQPZ=spotY`+%J`gG%Ezs-JQCnDUyb9(+?R*U4=a{cstj zduO%WMcAz&&Sgd$D-joAl|*R#p|P!kpA*X&`kY;N~b zWCN86g9go*C$NilVkWqlmHeETgmYo<@>sbZsMmaU99e#uXt{!|#d(`4sv#oo<}he> zMikX2e4&c9+2L>7KJ?z~QZ+_+h-pP~YG431@XT%B1~#$j$i|~{%47Ok803qRfVNj84Jzp46NN0=relTtNj__BlY2S2~Pvn{5T*a!fa^@`>hxQqEym?uPXL2wJL zj}D@QU$?u`sK{_T9M3gu7F-@<^GYHICESM7`$^gDqt(4S92<1NHYhmgD>6hdTSY=L z{QANT(P942s@Pv>pDk?I?W2qh5G+*x-=3j6w(uY|RsWvG`IP8lddwr3facm~nG*hg zW8>dw-M`QWFvtI(AOBtfeEB=mzx#IqfG>Zq0pEXK_20S`aMAt=yrTZ6H2^+O(1Q6p z;Qt)N|Md3%503P|bt~YaODCE9KYq<$y$aaAAB$k@ayMW5dy-qdI_*S@3W_tgqM|-&|yIrS&_A z(^u>nBtY8elL*VlORf!N_vY4;4nhtFJQtFgn6?gmx8r>Vp8B_ujO+J$>lP+7**VH~ zru`?M?i?6l;l2P+(OmchXNBiO;4K6yBRO|Z_R2)CF!Yy4(h{c3o&sV)#!p{b@PH1*_2HvM4 zZlsK^E$lZ~JfHo(nfAR(XYye7oJnC+f?#@Wdp0mq9^EvrXOq(r%)@QdiWaVIR)mtt zWyR()ONISqQu%}n6>(`D2SsIn^hGND1v^;o8kb)d>E z6~K-ch%9R|I`kfaBhFI;n_WH62yMJkKWV6z1FJ-&%>$O|{f|<9l9eIpKEqoMeVFU^ z;S)>DN25P{0;g&c$s?_#!bTp#9{6>8bG}PMoqPSkB1=HS8mYi3ob>6|tvau?bD1_#a3oK&(*%#jf_pUw@j%_k|=n_A>4Sh zz3H0&Bz@;yG=E!0^7dXo{OrX_mwI`Kb5T`Hw0@zk zWXkLNG~uW$%}Xq1$s=U^JxNfyfdR1&NnTd4}xe6b1{V~4vw!HP({v{uI#HyvyuyaeP6t~b$^*|_O zrgn}zO>@zih7?Nb^=ft>bMF-0Yc#g2`fUHKkai-%=8pHun3VTXv7ht?X{bziRjzgG z^}{i@7}Zx%f#j8(0>Ac^fa`w4&$y+~7c?s6O~SNMT%*2|!Phs|mM#6?h3xYO;zugu zUIh$i`O_Yy2TGDLA!hnVHe`pTqrM`@40(##F?Lor__yYgONP;yt>nMa@2|)w53*!Y zt}c!*wt2TlE4!MZSMz&OQ0nSIw~btz6ZzMXW=1#7M<~eT4sQd)>_JY3zYC++cM1#s zC&pvy=mDWeZ4ZCp1wfM)2Np=@<{-`Ov;!scl~JwVxYrOhjYxZe;!BL{bW$ZxKxc3d zt5z)ih-X8&SEj0I#n_E^?@f+Ylc3^#i*IfWFv{S9e0gqWi1f)} zRqg7w73a#KoYORN5F67`x)jtbMJ@TA%FNqRy*Ypd9_>7 zf&(vp^y@y09X})9wVz%m*5S%XdA4M;Em3mVhFQ_rCDfJ?j&hbm=%}`_oQx zJXuRQjCH{%di6uU$YTc7c;|1ZO{d2*LEBIWE|Oz`2{no@s#Lcy4vi?R6QaT7C@Ypp zXas}ZF(dp;+@SFInA$La=KDddfQHaqscG^fypc<`6B8f2D5bu;q8YZlBJ-9l^ymJ*N+bpi;Q=cQJ0Bg#4Qm)N7Tzsgk zNfe=z+uRiopizky8Et1`SZu_8^2BZD#c(Ympt0~%o&ivz^!4NV&K&8Uu0(bf@P-MF zi#AU6+LL@i7NUyQJadClH7an{&?wD!H=(a^8?nOFat6`tl2 zLg_@lk+4t#2A&`eqZoC3Fq+bjaqLZO{IbYKB#y;1LJZk1#<(>7{7J9_fosW9J*3#$dx{6lW?WDga{nx&dq2xI}^-1nAU2Q zzi``YwFh2km5)F_wsCbGs1HEk;%+l7ZcpK?frW2nBHe32&KkAfc|d#KA2K$2+lL&( zzp-6X@CA;N@nc5{PHIj~%03k4x?`Z5Ya4yPCc{v<9e>f>o-~?$?|*yQXDRzU-&;KO z66m_lIfOPaR8AV9R_=R{tJxVzc^}0b(T%NDWi2_?ZGAfix{EM7DE_1c*Mh|N5rW;u zKkz~fGJ(GHU(`*&3G_D%XLJq0h>_UCMIO~f$P-l)STsvO%XSJ9 z@VT&bupn+un%HzfX~rE=DWNJ9R(EyDSP0+HBgSQ}Vf8bwk38K*8)#?mK@prvcjEhA ziA9D5AwHpsK_!t8wMKvYEum1spM+FT2aAF0@3eAzT|A@R@XKB+pVO~q2hoSMp(n{GFg01SrN${!KzD+=Tx;`5 ze(efxQ{Au)JeZz}7t6#H<*(mpJmVT@37OINZaVys0kF-^#PeDm`fKpQAb?NqmDaii`d&`gZL46tA+fM zHFi`#rp$s9!*1-%l$*Ta?i-5vka_$7)cj&vEw4m4z~DN_pl)p8?d*+Vrr6v53Yqb0 z(9sAtbtFXjzGHlu*^^OtRIFXSR%B^dZw=?Kfs1qJJc2#rM&z%TCt!rQ8BCuBj zK=3ULpL%!edIW<#OqFp9(+#^lb1vejTe0_SEZV2@Hst&gBI81=Ar7@GOK%ck5h%VD zYmQ6PbKGgxo;J2F*8_8wCv&jMR#s7TgebdAtafF)Lq*ag%m zB$st(FE%Jv;3GdZoNH?Llh{%f)bz2OB2TaFWuD%MrT-je6fNqR6c}1f^j!PJ9y?;0 z@S%BfeND~(8y5w%Xg0M(|4-xpSM%An!3%C0B3@!a>JP{dpbvJF)veW_ zG&9()Hg?QqfEN0K z$~p9QZg2l*`yPie1$rIUC8U)CmsHaAlXw9ig;|Mqw%j=QvkW-yllk4tQ~4p%C98#07vuI) z6+71xV%A5Hl+dE3__uSZo;{qF$)nimnMV3S(=l-vwdVmQcuaiZ8r=6P`a6hk)%~<( zIV8Eu|L0KJ7(LKUa(^)r!gN)%5wY1KRCK5@O|BA_w(#j|mdihnqIUIHT0|@zKfKmH zI#gQ4RNAtT5?Rxc3LG4P6FOMOzHLA%1d-!^VIg)Jv+$>mz}u9Fg74j9IgC8W5Wio zi5z4Sd@ig4Kr&JqO~(QSTrYQ|TTrWz`=M;ckhOEUYJHN>S>GUIC2NmMNt*4HNR5=@ zPZq+4R9YA_i2hP(+^HnL26}gdwJMnMq4wRiOeOTapEw zzAhczm=@{L$jQS84c`U1H?8ye-9Aa?q*qJjmM|ouvwSsW@!af7Is?1OyK`r$@h;h> zK-z6#CUM;Yso?Gz&T-+;`Li}-@5unIomrN9-TDQ{CXjB-ye?I;_wuZFP3!)Ga+ri_ z-hE1OYMoy3HnADe*pgI5S0SWigh8~ZJO~qsZDIuoWDZV#>8JQh^EmrG$rlP4&QxuC zBPkobP!Pudt_rhzhcs71oBtF(OrtVl(|egJ7;Dqgc;Wnx@*F?EIf{L!+G7%(znQ>O z16M)sLw1xby8LIlXbvnSlWufp^=*$g-VW(%UO=i_Pj;aQPN_+&dM>C8cRGz_55@By*waHVzsTi?yDp!_wud1mB1Q`jR9KiRvnM@!At2hClydxp97 z9TDjd97U`~sF!#FiS%qox;cBNATs7ujophr1pnmUe{g1pZq8ezn&WP78Sz-6Jnluv zHgI9ci&-gg@4B8e53KcT5$oSy&NRv?X<-9YQ@#*yMHfPuhHf7+ zII@jxSgNZ4hBWu!&6RAug{2X!0x>Dp(Zx62Q{+$R{Tpu;UMl{5)SvKF`|NoYMlbkF ziIQW{_t{Z9G9O8@Je>KntHv)L-L1{dxEB2TeY{vi7;lU9-qqYn8K44lh(ES)4L!I9 z#r5$~SZ$ScZN%7^STn$=zsHLgTw?r~51~Pq+Nf`)ElaYII-M-D1Nm5XP3zjMx|wW+ zri5E08m6D8<8wnO1A9e-g`*JQRzIlzZa3^KrvNx{9MWXs{jr~ZRXIuE@EhW8bctzB zi2z8Nsy_OVCz2X*SYZMCz5;8e1c@} z%a0BDCPVLNt>Es4HPx1UZqg*OKRPf60;xcZLGN}%ih|c6NQKP2U6vGsTTOuABsiO8 z=jI{7=o$;&)VvT27eH#`X_=t6%nMR&HuBqg@O1o%Nn^mbYyt<`RCP)+sNO~oy1XA` z%P7wvtqEIpxv2<#hRw%y+m3sU`xp^JpVjbFo3>rL_dBF`Z4>%YWwOgL zP@dvy+dGCw05yj_R_k@|-F+kfJ`Ek`2b5kY2r+s<*h5qc?IP;ntH6lc2k*d;M=Tv~ z3d9%J-rLi`lJzTtkRh0xkx%RtG{s6U(HGRa`Y)VW=<)(iUBQ?KpPKdD_5SzqqM8^a4GjiT zow>y+d#KD77`3@-4Xw|(yV3>AsJ5Uk9Z&~z>emIZw^a*(_zi}%1ur#UezHd|#<_)I z<$B_?xlF@i={YOpSvFR;u)1G1^wan~rw@E1lAzWukv#%re_TAezgrqvcnRSfcEQ3R z-YFM0F1mI}ZhKQA>G`o1m{V)1v#-^-CjMu^WfW`}flCNR4?uaI^M8BZy&SPop?W?~ z;0&ET>vi82sPtfO4JOg+v&^LE z`5@3fN=TLzw9(w|h0I~#vlH99#;?O~GG~eP;&E?FLAFiZr3hg&nv}{9{pZC!I7#0pR2~xncSEbP=V+{P=C(x1Yq+R*# zh<)+wK=@Afvo)2 z1pHj-3vH7+m+&%|EHh3t<%;pWM^=r4DsG@=N_~a8>N?!jl1V&Nsu6)qRnO67`a^?bTb3<9B`WOhyg0ON#6Y+)I>ALc9b!qN zSgtuTTzwI}^sDsEYPY4B)##8>Yy_(%ncbsE0~=_L%@?MGlEMZc{B5jDNd7>9T^MLtf@hW9*jlKv#)lh6MaPyKiF|4$P4e@uD*w{G>%MgKnLCyd!& z{T4gLzwz$`O8x(WO8y`J>Mj9*8GJ7zkcH0 zULZiw#ypl)Jf4aWZ8*n-KW-{+oAf7+`}-$SuKGSyCO`hPPeGb$q|}e>NS&20tle4- zsyaR4vvc^Act}G=9>x_cZB$fEmFEw82!pyiisYFO3i}66E*)#6C82E{4&m80j2 zJ`Rf?%R-~3=gedC=t*yG6e9&46j9rECC-{ciBMC*bF|k(C-C zSCgcreYal%J8GR8c{=s00zo0AUn1|c=C8zfYhAWYq*oFVvLZnIbYSG`B&TeCNL;Jb z1H3UVL!S4?g_jDUhOqMpF@PSw`HbU%C}||pCL6{4JXTFIE8!w z4mvx~!mj@>ML_vA63X*+%i`_c`epLHLIT&S|X{iwr1P( zCwoi4bkZ=>cLmEGb<|(4-vF7SaDY4gXFwpg=<14-X7W^Q7k^Pl;$%O-5N1A3XJ!z5mEZ0CXvFQhq145Km z=4N3gbNs<*JO>56r|961As4*FuNPdh_&DdoZ?&V|NX1Q5Swg$?Z0f=Oom05yi%~m zc^IqViND%5>=CcTYMwEx z93%=h?dsrcI+ldo*Y^`7j@Er;FY#wlixV0pv?**=Qp<`eXQQ9C@(e!O3sO?;gQTl| zh_dGtSHf~eqgBUe?n?JIEan_kNfP`ZG%b02DuS5u0)=RZ-2Q78ph?%So6m1yTj)}z zgwkK9xUq4CKl%9z^Ly0*pO&> zF6}zypK>0JZD-UDt4xW(EzSZIB6i-|dH^$M)wcTUdV0qoh#0+TBhbmm^@8sA>A{QC z8WV|xnX#L$o#mX(=ozP5<5x$6Au~sYygnGbKl0XW&%ytyb%5k7!+^4+Ro?B(up~^0 zbWT78z>0xYednaVG*5Zp&@f(T7dX^`|9rFAoP3gjGf)poys|UmzjpO-a+hsbl(@mt z_r0p8Ym}iw1!C2)l#gcMy45zB4b+ynhhb-JnH}Ep@*AvZYjRXxksKC!FRd@PvyskY zp`ENrzR=XRcb)&5op@K16YP|S9QcM^EjD1zeTLhP`Wn=Eh?ibx*1o8;r^P?I?XvH^ zQpJ6?07s%Ix2XYMt}p~<;vNN=T0BQY0)(WKzk+Y&7Cj0}D?}jnhIY?`RA#uF$tz#h zW}hfCKr!SisBXL7yU8fFT!w7gN5GJi^9!%zca?= z?9>~9S5v9x)Ir@ae;{3N$kRz* zCB@Uf@1|{$-Qk;oWGnZBv?4cNhHi{RFID;WkbKUo{0T$=9Tghjj8taxmwc0XpqQ1RHQ4v++*ejS>1@{$OD9 zO~vkoEEykl5ryrD(l;=W+S$Hf!W1i)L?`lT{iflAZgf#=0Z9Nx=P6}7WS7Zvb;;#h>*VkBr!Tk5PuZ#sOcI$$S?YS0A zS8t{2v(v0=@9Ep_Hi_F4MGU@j#MksNF~iP!DD*n+HlNx^+zZ2X%Y)3CV?~tFr)Fxo zuI#B}D0pVgd#Qa@H1?&RD9sc1+3ftdfQpnnG@Le$s;yn6~f_PTk2 zdg1^KZEcpyd}lVSMfUVE+bxPN-FW~~VO%xAHTa5!?{u0861r+&21cB_-KoJDE2VhQy)NJf z_GsX2gJE;-f>acW1m^P3ijENc6t@n#9uOQ911@yYXDu%&7L__o91jS0nO$GYC%v9l zlc}aI^l6bQjheBb4&dcKd|j`~-r5iboJ4=!CsMi;5I4>Q-NCUwzOs4?)HJZF941)5 zOCAl4XI?S)6e{UGu=*|i?G1YAEyJ#t&0MNB5M2Gn?{~d}F#Pjs0V)0|A1Ev+ee+{<{IJP;XjnNw1fKUK)3KoXYfxH7>r$4ekJ&Kg%O8pdKQDDVz8UUvfoUH!xV?TowV`!<~Xy4 zD|C3vd%m0NDKbr8ygp5TN4k7hb}UaReIg*rOp=RzmHUp$s{0UOTOxGBrPfdL1C$va zu`Vu_$AD5^UV>3t;W-`P@mKARxzsyEHOU$Tbz4Tu7mtI>*&EIbPm~@zQhQPi-`~Ex ze0yghiup_;iDgy?6CY-=>SZ37%Q;jd%vl16W7=wj=VOK_EGMq&uBoYQcf_-2=~Ao2 z&Ukqk{AoA-YU!e9wZXA>lv8o%zNx*wIQ-kr@NGH5>fi};8+{fOJ>O)wq8xUIlkVk* zh%dVmujH@s)3z%quqrs|5xs4B(3?=*FhRA>;sP6Lp7R({B_F*$ZM)Lf(}I}y^FdBC{z^k>_qt4w4^&1r%CLlNiQGAwX2&#?k%Qg9Xxb=kKlLhyj?65CerE0 zDZOx+DHxorVuHBpsvP#wHvJwYl$c^;vNE|F_oIjqDBrqrx&zpjFQ^cO#WJy+&Qbzx@P+zq6z`4L%T@W8ky4+aE0eXPinq7asO? zeG)Oi)lIjm9eh%xHZs=SPS(%9X?I+$d5@sa>fMRiprqz@ANOijs^%Qa2MYeixeVv= zqE#mj&$n17++KBHE7OH)SNDlUNJT_(jOXQGFyHqsg4dxDCZStX>y<~EyMB(u(+7mF z9&T%qeT`mOxl~qwMaA&{ScOtPz2W+MTvR4f$Ex`|54dL$JQWDt(QLrpg!7w!mpEca zksk~mQqZD^=J8{5pR#;utPKc2z}8T8I2m#g%)6Pv;so3NHhBRk$B&9*KMGE8^eu)O zD`;d$_gR6!+;sB8U=%3Lk@hyi2%Gg$A$sT?)-LQ6eNOgOrN-hX2Rgy!{+DXP+EvhQ zA7L!<%`~Sq0-M38RLd6oMFa504(wabw*oa=f!LsI2u0_Y1o_(-O;*cJOTak=6N`~> zsB&-NCvFLW;Hl8cynR*vb}&kEJRlG$PZHgE<0Q}S&OF6BH6UfsLjY7gv~|Lr)Y{949m>)Tv?e%TOiX+WjUh z`Z>9Sh}@#J%@4H0sZJl1t*~BKvl?`2;AyK(Gr)OD@|krh+#ZkcjlUY@#6w}l=EotL zpZ{S-K|1$PZq}O4XEP5lEm;jGVwXbv+&1Td(V0icvid)5oWydb?sE)Yg3oOn^@#rR zb*S~0h^L-?e%zK~_WrY8YMiB}tGX7O z=67;frvWvx{OV}MxH@hdt1J>YuI4)kr(JXV`)D<0}93*-6Uz+Q3Shf^e&Qv zUnhDR0y#hLqv3eL{m%W~xG#Ru+FR=gqi7>AXDnE8M`7c60fhVTOU=&`$8bXhNfce` zcL9^D!Dw!XgGBVj4*u8}P`ZI|wc{A^UK+$!k#CyZIvPP*YuA}E0VLg7aB%Fz4L^(6 zAGmJ2WOnU~@*r)g2|&iXp%jc3OUjmFRs+ZOa`k8j-!!CvqIoaDUphA|1ySy$%p8=m zO@FynH5pfQr7zNJkeYz${ix9UEI#rc*rOn}rD1BWCT;1wsMZ--!1xTZ;+`t8e~|jp z+Hm_mrIllZqAlAFYhe#U>$nVAGT`L;H2<<49S=%jE$*szcuhc~3Z=cOGvjv@o~^N`AqrtoMy88j-SD7J&u# zp}zRz&hyLqx|SqGSLbbOu~Kg`Wcl-ofqYc2snEw`m)|_=llEbuyMY~k(>HQkp$==6 zJHOLvjeI~o=u)f{Gylt2#JMAXKG66QmGc-I?Y(}$n5#Jwf*yEir~(}ZH$bl& zgg|D7Vx4h`T$#YZEVEzaxW6+sV`^Y)aO4~bfXR_@l@|dY>$fH0zRXP(Tcn{f{YJ7m z7fg)2108CmDHN)Fi834aQVCSU-QRLY#GDLlN11~A5QK5?&kfqQ2Ts>YCc@MUg;r_r zKD%wGrRaH_uYv$X4%l0d3OA}wpzyA@8q#-A-avHd2pgM;0Qvt4+UU(q`!VZvdWU(A zfAE-ca3V#!{J&J1UrYRHng6hFhj~6k#mO3Oo_vF25DL9MX~JsHOT~g3$yA zc=mur3-9+Z(7OT7sn?XxP~(R&3dxqLu1YhogPzAf1CG7E_mG13eh5K5e|HZr0FY(J zRNOs=TyB&V$k3KK_SCgID`q==`9-uhnuafOpUs7HD@oSIU)8+`P&vawB=&AQ4m$R1 zK>_G`drAF4P90)1PL=QL{go3LzcD90KU~HoJHa+OHp<^+xDJy6IPkG*7Iy{KM=H_3 z0x0 zSkF&*BX+u7T3rVE0TYC?F)*9MJdy_*wK6IHt*9#5g-HZB0U!;+c%t3;x1Q_II0G)S z3v-I}4nKVEKRrq){g8T}`*59To&^hQt^3w&gMKR{Afz zVBmJpX_RX^EV(HfbDyTi=EUX#CWwt!7YTD{v%Ch7m0{f2N;{r{aua2%Rgx!hU&p3g+PyYUb zz1g~oAhzH30ElarqBFVRDHEsgPkVMQ-8QFI*#-4US;h{7Y~1zC5K6CDLBZ1dU(VVg z_~Ru_Q&8-Er#gaBAKA~^o;mX(-6Jaq@ak4Cf1h;c2A-!Y&CzO)_;a+trFP72fW2Uj zpxSz83M`FrjMc^5>&h69)e2L?iJi@Tsb*TkQw|np?V^v(Y%eUupP2q(wPj{&bsmuA zq+>7o!i>AT!d**IHYP$*+9I;Ov>jqCj8pGfu(A!mN63S23gA?8eAxZDGqXWwwc1{d zE!|%1rCw0NNspSX-YBgXz9`N9d-er@SQalGKE?&3_gRTSBIhAXc~go%51>)fR5*>S zorg-~WD&Vj9_+(9@QK<12!V^`?`#gDq4_2?e;Bb8PyfYh*0l>r{Hub%91_1U+Dv+OZ@`Rp7C1?Q^@buH}jT1E;4R) zwGcMW^yQ7n7EF>97}1;7f;4XFc;hp6hWOsiS|jkFf6YVqO&CHdij*k7aro|+%o1^; z$zAn-?Wyz+4TM((=X3_|l$Op5{Ygc@=Rf4fpV6Is{(b!)F8;4;|A)NI-=NI@0t|p? z{TFx%AT|Fng#UlCLjO0T0wzWkfYkpqX(zeoFGa5Ezrux+U-i$7Ug+HCKw^6vr&>4X zWY;eFt%egVw3sXd{Rlg|2P8tjc2Oe$-zQoklL1gUzOjl}>Bph9R=k@N2&|0prW!{uZ!tUeQved-Sm+8~$gf*<8+bGtcLDRs%OXdi{~pD_^D? zkF`l;pCf?%VJaYenEo82&`W%Dhf(41fIdy}L{b~DvGCqTh6C>`#e3+s#uf9Pio?%$ zU}uualsGq*@1m6vxc;S@t?tWOAbJ(h#gmuI2u{RS06e<&bzd}*JicR54~(U4x#7Rm z^XHq3Xat+fisk03=W9n_)f0}|QWKCBd+eVX-`=&Cme80JDS79 z)Np&b2Jm~J8~3^{_8AcHa?8>)J*x)x&nSN6yVj>2%@LP!d}xJqpnOLs__+hq1Mxh{ z{nnMnW%5(N2{$Y1^6`j=HN5J;0vmAM zJakK2Ax#z^A4UQeN{1s^M&eTI>o=?E!u8vgt;GC3qCLw>Y_$B+)PZ;=&V2c}&C(Tn z!j-uvhiH>_q2Esg1Dow}gxfBYuiq>u^j6P|1norN-vE`ak6B>Dmvo1d&^4`KAy}{! zir;@f>5}4-Dy#VNgFnCo8}QdYSGd9%*XG+%;d)@*;D3-n!2vK)C$>R-Dz^Ra0k8M3 zcosQq0_^i=yrsiu!NmR9Se8{7t>8gEY}C51yu9mO+tyYURku{b_7Tk+-PDvy+20@B z<&oyzV?=|fq&Z)C0JuNfz|j^&QVeXmoHbE-&E6sv0KzSg*Np#Jr@x!uq7y-AJaz~Q z_ya5dnE!4x8^6%xjVr=0L_D+Kh9jQA#Yoys>=< zSQ5A7*^yJpCv=RYLd@HTEGg;4B#Ib$l8S=9etDb+_xkSWP%p3z0kBf;X%NUsqio`c zDW8(s6!+yBQvsiW>rtF1xz-z-4iXlaEaMc#aY7^%!EZZT9iX~AT<~S&gau$jmNo9l z#a7p<^dw~cQS=@=A|Tq2)onFzCmyNEFG}aUd^Ypi(UXdvehz(k*CAp$u<^s*7?*C4 z^fiw<86!vPI*y_$n`zQ4(eoo7(97%SrxyK(J7n3luF-vfc^Z3uJexwSWwylz zGe8f$Z#+ToFwtEL@$~%5C{|R~rL3B6gMKz^+PL_m&WWn|*-}TKvwwPvxM%*tL96`K z{4i4vP)!q#Wf}jedjASO(@szjQzhz>L!RPtb)d{_b3W;eLP&;BB=Nbf$n!%B&D12O zeD}w3^9r|U83ShyFF=d%DhJLtJLivNfqz%^dB4v0Mho)dSndaG|neArFv14`D;>%u)tp*1LHbcVUO?U90P zKF0`PUmm!a#t3>N4um_Q8c$f(fQx;RVOcOG$)1>ooDB4&-?JMgCOZdI9LO=a1u~!K ztJbfsbrl_>rljIKp9{aI5n3D5&iG9e!Y?#j)%ZFw!r z649Hk<6(YN0H@m#XvvO8AS3?C%l}9Zx@bH(o=X%I|3Ez*XWPk{r`_&!as^C!N**(S zhYxKzN^4)i_}S#^nvoIqr4de7fUJgLR`xwb(+E1l_}ZXunU7{r$>H+})A$r%^=X&- zUv)<+_q|*Z=CFZ$;DcVnmwiefX%3>a?OgieDfqf<$RZ$S5yF4VbZEy>jQ8Ar9e`{~ zC2%xeT53Eu+mLZ#mPr!CaZ-|VaPcY%!~AL>c6DbBt6Q&at2PgToNt1vdwouZXL z(m6`IM-#CII@NgwaU{;@YVy(k?rla3fFlSySo+}|p}vOzH)jq`cH7NlF0(9f(W@Oq z8cRRgR!<}DCa0SpjFs!cI%^wkaMi}$MZv~=JiHP8+}NQBivWfUzdb_Qj@A0L1y#q# zcfihG)QpU6!*h$%sh9m_8J;GT>^Q!YhxXt1qINL{gg6KH68_I}#iq|_orGKCB z?z8KD4|p;bxHYA7xPv++7MN@X2<|3Q$Xw0buNLy4B^)?VQ@W>CTGm3aV~Gfa)DD$M zAA@ubL1?JkQ(`3$B zz}SGmSa|W+`rvt$+x^^Vf7R|+r~N*=Dh_L}ou<8f{C|=6mQhi)ZU68kf({_kh)B$k z(j}ckHv&T=NH++GG$ z44ZxKeV)hht3!}AV0m@(kumi+U4q4;&Lx!wN`m+k)v3wAzMS_FV0T{9(-g>B6;?Bc zjVJTutfxHR^M7U9K6*FjFe{X15S@G}W^2{XbqLs7@O?Nh-rkrcyf{MG69p@1VA>!z zB{X9wSuf}++K%pgFz>yNK|uZ~N&gmis{_u&SuM_sZ7E($oXz#bIoprUb@ZnCIEQtS z6`N@}ijGi)v~DNA!XJEqv7%TR=YgrBUH%@zE}kp;`74;IM!NY=07 z+qqZH1{i^_>ZbAY!*yq^R&<#Mb@0Vv=%$I{<93U-sT#LUd)M8tK?q}XnJu_?OOn1? zOaf(-;;X)PCeXhlDDcS0$A0mqDTNkyK(7=wmP*p`ZJ@z4LDw zgu1Iy$uCq8fxaHojAFmWT&-^ub$y;|FA*lEt;PtmAOD{L0D6$+rZmv z?#n+h6VPN(f)_OWkz?^v-~Sol`yhMWa(rWU(5Obh|Bn7+>q~<`VCrCTFZ%sWK~2m9 z3rVWHAcgKcLQuuiPpK@};ulf{N{U}JUtxhbPFgtFVGoG}0o4IQ_F3%s=44Q8-6$ZQ z+PY%*+tjg9KJ%xR9dq=j@g;pVL{K--shns@BKgA{?jU( z%#eJ~EHz-Lf{$@xet>~H^v$4OAzF6c={e3r{nsX}F4DI0WPPRKB;GNq0K@z2HKC7p z?sNLb(q0_}X#MBn>w%@NCMD#3ECX`MaPHWY9iMZ(`p_rERGPebx_Xio?e{NxJ4J~g z(beCj9%@g>6ni!30smpEy2PifmIyGQ;FaQ*qL)u=Kzkwmf|PdBO5-} zO!O9r`Ape3kbV{nM5n?uXPv8@zV#RURv(nlE2+z};4$}JS*uYCpySzw2Y&7ARh1b@ zEMP9ThLSxb{G1PFlgRK<{`F49QBl|L1#w@)CR29Cs5xi`pb?ZtaGCiBOp?RZE>>M) z28dcROO21h2vO;Syp_mRNwKRO6~^AMI;$6EPl_Yx%Y7DO^Rfm|kG}qw^Y2fL0!fD4J--{PzlN?#d%eSn^Z|MCqUEJuPRnf4bST zGzFxP8%Y`$AB*=<5ItTP4~_!LaS^8R<|Kybjuo-Z@@;Gh9 z<6V5;csT)R#+!KWIYso^YqrCL@uEbkb=-%(kd>5--9^IS=?0=1)nZwCy5d%z4ut?E zi=PS*OEDXa>dXL>{_15#=@HX3M)h6Srb*-c-1n=GP~BHA)qa{-@C6ckjh-Cr6P)6; z+h_(l$Tuf=u8Ga;mBijoYYU8yYVg$K93`Gd|J*Qk-LRbY$XcaL_y~Un`AH2F%GtM# zY=1Ax-AE%5R_hLq}*J2t64pdhd+DFT%_O%Hq@I( zqkjj@a9+e?groISnfNrPZ%c#~62Yk}r|o-%B(%49kt(5(&yqKHCe2CeB9EKj&-Hj?uiB#7_5@*N@ zztRi>=c&JZ!IH3Dsz{)jL*^CyAHdBDE%pS$>5Ely^fcofl?sBn7)$A z2*auE2(^g!uLEl;Qm=-;Q7e8oB{2xGcyq2N=AM~lh{(nKT1mp zNgDKL89XOQHDb-!=Vg!d8iyClMbP;&Yg5EN1Z>h*jVxKS9u@yKqzAt8A`F%^xJMWp zL2Z$UBO{p!Bw6W8eT8$md*g~{J=))X$(_~hG;lPQOVFWtG9&fFae@W^5wf;VT{B45 zgX~+W-^>r3aL%YH1g~E{`RR2jWXPACGAdbiME`nHlwiw2B_oBRP$|Ga|pE}(!w1aocjjL^Bo9cC*C?eVQQfFlSRTVoKh_kKVX zguWHPtP;RwbeN_r-ldvEwK%7`N`c9*Ofu)=$8DLhJ7M&@3cW7v3K`Q$OzHwt#*pe16cqyqv@aA|Bn{n&%WfJ zjxqr(^9t-{rjc3WpBN`C3g+4%iJWf@84mzNfFBR8^{Va%-)D{{iWjm(masZyq1-!e zR*}B(36B%QOBu-&`G6?Q#Bgb%s@TkX1eCudEugJz%Aua7*_*iv2zjz7W~g8>1G~bf zNvJK9Ua^s=LbVFdeI2Cu0usHIn?b@2 zdK(|+!4@#YnX{f=lHa|fG+G0!U~JxFY-&tg33xD25-apU5@JpJh%q5xha1{Erta_nc9~>vi7}wW^Q=D>hdouDx zwPIzcC19v=TXpe=>f&*QfZ%yt7WZN(P9-Vp z(ylVWi5U+tgafu4R_MK}BlC(GG%Iglz{AKSg{oI>N*On(-96W2(UNT%iI=;%JD^zfsHQ;(6O&N*XDQ9+%yQjC311QE2k&~bl_pKR%0;@g5FFaCQRNQ} z`eogfPzD|cAil4`u&vlp*-%WaLmQV^Ps9KoC|2_DpO$yPll zoSEt&_IyGAfYhLsF_52v#beMNgURh`Ds=n)X8%Dx#(7lQG-KMA`jPYy(6j&)5~<%QRje1S`46fb^%NteJky)8I`FtOX`5{6d?TpDwy6aouqDCHIZ(y)E zz6aRMt?^$ixN#ecBmY?m^i9D8|TGAQgKuejxqF_!O5Gu9OAN_7S(5kXl*y>^*i*p&L-z z2md!_OXdzaz0Q73{+>${oCwBn$vr{^nLX9Icn2bD5X?Y+WYd@V-GTS6&{!Yjr&*s^b+^qEftZV>tPKp&Cd# z_~RV)vwNuyU1HsuIV4Bae>B1A&Il5fB>%>Ko)^H|7TwKzsyB{z4`x}%L2ag_w1E_9;V-)M1M}U_}>wmGE#re82S&}58Mcar@(Lh zi{vB3{xf3xUlihhUz8LS$PxS3@BMiN(f>AL{eR`k^94$$%fj!|1b7cDSud6znV-;X zI9BzB@1pfoL(ZMq?8NSIwl#o_JcW+B250>B`Yf;I{KS49>%GB)4uTQxdG94> z^A5wv>49or$K?!LO8kKZdsUes7@)_5MyN}Nf6{~?I}ywA(>nJH*a z72=X0D^ri0Oyq4hUgPNplSP-?J|E=eV#&K3eso-6Ry3Ol|H9E%PqP!Z=*yF>K!H`x z_9n~p;15b_g13h==dlUnqs|=uBB~LmI7s3b)vt$Uk>p8BqTH;2Td4o+JRj*Wv@Wn9 z^v%(v&D;HI^z<~WEk>A5Rj<NR!Qv-Vdv%_$Esn z9Eh6+4I-PCYk2fsb@9qdTdFU#-Xx!{ORDW=#Tp4pgHPY|jo4s&Bp?kZPr4axhl1cp4e3I>Rkkn&- zo?zBikiWJ4XZcyes8cfiGS}HpQHchf zy}{lOKKfUKjUFnlZ6<=JxBI+Hk-b@2yvwJuZ#l~7pQ2YvMO2=+E$}8I>rv2y-GzXu zr>2OMDlspbJ?|gkF8jM}T`;1g4~~MDhfh|Rdb84uB`AWg0vr!=&kndH;Ng9fa6kXZ z`jgqg23OxK_8ulF(y^z ze@)q+@D={XV&OEvx2x%V{EdWSXZM~vdSn3b-31~}as2S7ablq?2=-5V( z7aPA?!V2IX{sPVuOMZ?h!Tpkqi5ovt6b*cKgFV32!ngY9tjf!7Wd60$so35Sy|y4z zn=5b{6dmb-5E>L$@5+mum80U%$d@`5V70uM~VoRDj$NL9!9INMv#OD0#_6p?cyE>ChxN9p!eHL8_;MN*&YYm@ezQ(+ze$rrt z5ag`Qc3eci0RgS@>|#Si8B`Kfovk0`XO6x$x3a#ngnZVMiKX!zI5)R|CX3yk4b$|D z+|N$zc{IIY`^bbCKI+X8JyEcIM*PH?2K~mu7@O3rRe+~rHx)haJ1T8{5@a8D%39j^ zMrdsfK#)3)zcjH|#;KkN(mPs<3(R}Wy)@jq9PDYKB(T>a;1N%!M{lMWC7eVb%>ELk z4fio=+o$ZcWy%!WSkx#iuAzW*iP>FnRy;XAt666?&!sBNw{Knh_E@)9BEUX$%EuNM zh@;v15Z%C>z36*dr#_~+KGeQ&@;R6LG--NOLIB0?C$ttVtK8F9_eCT-$SA@0alWr; z03mp)8Z7fbGe2d&-Z$jXx!vLOo2hBa?C#q+!Z(jAwk0k*$@~JWT=!S}&JGn{BlX^X zyPluA%v<$FOT*z5PcA8W#jeHCExwsPjenn^e;ps4?zrXX)HCT;aAIm5l;6;*3M1{q&@s*Zm?hCyq=IhW?yC{ab4lgdT;@B1O_ zptL+IGYZpKrdXeY{fs>xT9ECF{%TS4je12ku=(Kuu(vvp%d6P%b_|)LVS%$Z~M^!@TI=1ZO8VF}Q{JG_FOZ?Z;?J&4Qeo29>?z|bU+>aKE z5oA{#(1HNduVntf$SJ!Q0dTl$9wAE5HE7mXbo%At$z?(IV{1^}l@awVsB=Crv80p@ zRD93DZ1u%MmD8xFbM%^<^4)~y84+F0D~S;dJ@;z-fz#+o={f-%246?cnq_}6zq}h` zU-oj{pnn>l_GxnxfUv=8te|0%EZwgk?@3dZsyjAshe@s&of64tK|o&WBUR3gO}i=qwburNBF=_R-+W^%V!l!7aZj~O5nnh@jC&LvC~p4P znp!jdq~=SY+qYlckr#_vPP*)%!2o%hq@H|mFwi8ann!WszB1WDyVXu3i2vTU{;DYi zkGUWh{rh{A$SrPDMQzfAgX=P+E>7ot>Hs?njC34+#^4Ob!nCmc`f+4eK}e6wgA z+9Tj0#ud)M_&Jo>D%UQ_s{iBwI}>WAwdi-Dt7%u@!+7+fsgi&EhQ8{VEefY zJr>A>ABt9q8@umJNJ|t zuuDe*TNlqwtcN~<>t38#1~8>FjAVV@)nv_j?isDYz;^hqoVUg@3U=M+7qy*~JxUe7 zCinSObfW0h8n3O$JzLI!nd{3+=O_DYhT_YP&;qnQR?`xAe(*rm$9m+>YO(LtN3|pt z46ZL;%b79p(GzVb-RVF5R3 zTGRyFv-28BA;u=M6Vbycy(hr&M22bFzMqu+UbN>C!`TuU;`pgqwp%Yc?Z38-`PA-c+B!i0i?kN8{Uu zsJyWa4jgD-@CN>g7|TfW&P5!ydT@9DqZdhCAk-kH2ramo2kWt9&;CBiN+sS5c|oeXC5)+j5K8WN0_SXEg~9Z@e%vug%RGocKZ|jk1BMh_U>! zb&azAVle9$X%Xb<+ard(xb zLJ6O_t7$keid!02tc2$1j2R13lV2QxtMQH!dPCcgTE;1hz2DI^gpQh))=Wkp{HV3= zsR9VYgwjrO8cM^G#-@9*91NG#0XlXEdJ#+~cep_fvk_C09Kaq_ zBcZyjR%1d*ks50f=CPM7+3$R*MY%7}HIQGfL$;u6CfMf9We!oW4v+bw zRj8rq-aFuV-I-Odpj#7P_0&5pCAY?t+ABV8$`C(l&I*jR&)?+bt^8tuBNanq-d+Y? z$0eP|6}rjP?a^&FMlg+Uoy@p#>|RDf|LJS~YD7Zu%dZYE+%B3>UB`IEb(RZN2v#6 z21G*L76M5|umA`+bw1x9NO38f(#I;^xvVkCRF#$GW~=|F|4XT)UFjHzY;Ms^I$ zgSD4<4OqtD;Ko5-{Gokm_YWB?NN-|9E)BJ(F9m+ohk2_c9O154#)E9Qovzu=Kpm>+ zEP|1|dz<3CFQ~a5m?!eF*6B%3=rJmmB-5uMiR}FjtNC35hA5gYEPITpKrY>Zr000y z;HW){C=fn9xzxk+L7}y_+0z0R=3qOgcS8D}oNG*#gXC+-P}S_XHENW;?q`k7-b*y! zqg+-Hf{`G~sB*QQJi4TYwJ|r!sh&(K#y)ndt+oBB{hf`HaiTT4)%H!nTDdvT$z(?$ zgEa8Dl=oL2?l0{$zrms`@X)C?rdrv1vRshV$o=@J>x~%~PJ3e8^LfiNm6xrqa0{CB z_W2I4WpQ=fG1v0mr*l3VI2%uaLp|vOFw?AAmBDon@kru|7R$W2;??l=3rL!(Oci__4a>K#s2DvU+pFs9&npJnq1=|Zc*`{)(r25$*5K~xYd~t zChP!vkN!Zgsg|BIU~>a~DNuUSDdWL@Iq1ma4F|s>A*1HpS-=tr}#GfbC>X>&UssG zzsk=EvT9@VX!|>#B(dlYx7W{c>2QtdmvA|e$t5(l?k>7E z#C|(JCZv2kXZ}!`phg`=d1qSTb!j=l9V1TF`0VZFWFH!8q5F>Pa<+xDI>Uo=9!9KD ziY)Sk)s%O;$O4nlaqlU$O6CYS9B9ll&4=CJMfJxwVjCRCG+G%kgKWd2zV!jsHA&Ci zZE~v_ehW*IocH!Ty7AS{z z&!8mBo*XxpJSz_Ho80Zoe|2`5*t==0$w(yqH z^72dy%cNr2P3hlF_8dBE>84_WVt1y9^%a4KN_$O%me%BB;x)jmW3j!>qmi0VoUolg zRAjo_>Uzji*)dJR5_H(?s|uxkAWa~rt=d0l4~u}nWBFm)8s#A}78x9mtD2k z*Z}h9)RO8DV$LLiu`G?QSEHL1xe+QXG=+XZmSvm)rHOyaYM4WQ8*h9=S%}P!iTFm} zvcy#1vA~FL##|eMr!bDYXf|E!qxX0vLB;g!hF%5s`E&@|VI`k$Pcb2IHxmorDe%w3 z^kib}UaVO;OA&OCow#_*_GnA95)HS=ZM3X3aWDg=z}_dLqdL#w$r0b#x7(4U6SHFU z_5ZG5ir<%n{O;RI+Gr9|5nH*c1T_x{15iL_7EZ_y94Qvi9HT%SVNe~pV@6#JHM<-z zBoRhStDbA4+Ly3F+Km@GImqb9vb*R>nP1q?Agr~(m18OpER)IIp>D6n5^&=& zC?-F}#1@nnDLw_vq%W*t!VcEH@Gl1GgupH;?s?X|x(X_h$ftfEcRM*TfFkKKyeEo0YD&v)es3Qjaf zd7s|loZN)*`;8PI^{KrK*{LeM273^pTjrOG1EaX!?dSTb)1{G01XCu5-vw(`=Sv5A z<0|zDtQ4bs*O6iBF-)X>VbeCY+y>I?BBbwBPnl%a>E7?GH@DWa_7?%c8egtibW5ke z^|SD+*?26|*XukIDWrv8FckY-pg(Cr#pJRzhx${Dpz{@6w5=Wx&tJ8cJY_UZd0&{& zq5X7uGxL@B`A{JPr#XmFY)Gf{-_^%uDE4R9n)H*ju=~M;#O(^=jAz^R3q?+s$Jg+> zc8+m2-KwuS@cLf^{g#Z1N60vQ6=BVFGA()#ie+>T6G9pb6YNUuU-DVaZbwMZ2~3qm zm{B3!B5_K>JJ&e01?tQJ%_BCc7A11`M1mtcinO~qR0F&7M@9YnDxjmemGkBi8q5=5 z=MXBq&J0R5gDNP0J1Ci)8=nGrsHZ?sClbk=rvHRE44r15h#5rHjMmA>8YKsEIZKoi zA?^)cN&+2YZi_gpw)43`22iZ04x-#qci}OF~I!jLkrx1=d*sdG_E_b*u0e~!Fjb$3JN)4q@DUA&_ zaH}05(WFU*K>DzkR-^+9MlILis$gaL3>=(S5?gJsD4+zK*PL&`U4tim(&scKA8-`K zMc%rlNUVA~w;Oei9=i|t(FLdh$3Vg8kTG!AhMQQHm09L-ZT1IK@D3ko6)DM)i1S%j z+;U3)eHi=&q*vUv$w)4>e0%(DU`xDY5*VMMR7^CbTY|bydfq10Qu~yJH0?oNO)&NZ z8;<9~MZGx=CS;P!a$6Ws!eMu|!Lzc0^+|R&W|$bG^y%=JI|0^brp&Ori;Z~c%O66x zKxvlx(fZQfkoV8GqUfp-Q`-uCS}`^cvW38)OwW zmvd@EVPsYWLSgmua6Jctf9#6Kb-)siiQass2-Gox5CLI=({~!j=v^N&v>jjtoxwbP z)#LnRzOe=j&k=5O+|^^@o@JD=MD4HXWP9OBRWOV|>|r&WQH2J2LhGg)edKKJ*mj_>%KX@93L>{=&zd`m}D~Yd#jw%`Qh66s5ao zi&#lnT6PQE`UO-G1`+FM#>s5Qz}VH#=$GgEonEU3t1}u0BW{SfEV8OA=g0;n^?#$Q zTKMdrUG1CF3&?k2as(@5ciNd1QDZWz zK7r$2t+iv>|q1Gzli_u%&wLPIHz^+`l7~)Jub!uG3-djq-85_MFx6$_b3E9=O@C z>pVPYsHZuv!9aESecbh+_VFGehxR>E7lgy8$46?gN8HJR``B*yP2k5atK!+n*B#=n zF-I2_z@qw45yh35_UN-4+e67;=tq2)&(qS-Q|bwqniP*uJf=d7rUY^Ya8UZry{9uN zkPOappFO6V?5GD*!T~UN>^9HPB@(pe$=&0rn&*3tWENC&uzqp<4=ejmzaUl0%P6!R z?)0(Eh}rv$s$|49qpD%r93xig9{uC;?uHrk&$#-KGn6LDT5?*-l4!dE>ZGvu1b`;> z3g;@b!GF|5qYt=ISC+UBo||Zx>!TbL;MQ zkpRT(T~z$11qd3`<$1%3xtR#?*f7yb7QM~e;!@|wH^0mksm?CNA|!xu(oWd&Diwvv zj%XeyvTEFk>+nKXYgrq8xOqr`uae6921sb;la<|r%62=xo(`A6(d^Dxr?KC4F=}t0 zxY-odq8d0(DkTc7z6D7!o)WGZ-OmiVJzK0ON5XBMvepPXWhOkGdja&_=AX%-F&d`d z=pAan8+ucRf=Az1)tNNr==F*&WDi|ASKM>#Z2!dm>mzMAMonHDa=N{M_zOy8ddNUt zsa9uEkoLg%Ywl6oPs>6;giZQgd5$)O!3dW6P5*%dsV1<|{dbqlsNOepj_G?I2!l%h zR)-cL4XEd#tZx2McDI2<9nc3S^A7=s9xAR1$QNKaJzJ3>&b%8VFioDV4QpCI#?O@{ z*f{E?2#eCPc=N38fJ0(Ghv0J*WNujPWblC`A9W}E@*X;1X?2w1$Nogqg6Czs1aGaw zaA54I+|;2^*LA3!w0PuW;8w&ipUPp1q2KvH#@jaoJpL{f!~SAxjeID7Bdg-aL^ySm zGQTuBrtT3Rs}#^R6!l5I92<|Gw!|P}mR3kw>YAk{ccW93 z3#O^odlkdjJ3#IJL9{2>ikUh#AGWKpdtA8C@MbJYe+Li?ym<*DRU`gz1za4qJ^h3D zJ8AK(wmBmDBQoNn5X02EifQmr)yNa%m*5lA3G1j#d&VA#+45&HN-~Xep+`LyyxR5v z@_yE}gO0CT&fMUmtzDC1)IhkcVCm*@2ZJQ3HC5#q27LF2Ah^QO`+Z@!W z53M<2LK*!0!``_Z8E@J!ra;0?nMBGcwzedKBDzGb=PqjaK#l_3CD>kfXH9rzDKnHU zdFHgnk9m}?f&2^1vqp^aQ4d4l-E-;%7HZ6AtB=N#@`q$jv;C|yW=&4snGYM}I=GSS z3JZA{F_q!>@*Df0{4^?Pnnt-N66&BIYhswE)yRU&?wopgFuL9$cTi}#tynE<)^WW*<(?A3>Uk=drRXY(Oehr7GGLa&08~5Nrx)Wlo0G7e z>Nt1vc6Hn>6Za--DteyS%K=WOrfkAeWEiE{uOfR)bGlqjA0xy#p(fZ;FUvAY7kK}j z4_Rl81Dx`119>zVwCST8P0m)T{Uev_&EYrBWz^`!ER;?U0RRphbFb2=5NEd zW03ZS*2%B3m&FGJ?d`EXSoos7myd!*pH@t}`Y1sa+&M8~xI6j3iq}$F&j%r>5pM`R z9MN%pb|r7AN!o2b^9>4GO2jQ)W!S?xE9>el7^4h(xL1>ro2R@9_O+DqM)&oVlCv{> zjSuth0X3KL{rUR@o4);~u-x@BCth(IU>EGuRfZ=mJ_BKdm^4VO%1@-) z?)Q_F`v}+Y;#qY+b9D@K(UK~vCe-zPX(j?@gE7$;>wQXxhi@5wor_%>tzo@zc#+=C z#8YD%%|cIr8p(Ub{z8y(wLF)M7{GN!>0q(kj@m*9QkLFU0FCqm5uDU2l0)7SaI7ON zb23*K)V0+gJO!YI@IPolmGd{;&Fr>=$Lw)dJNW3=0FUC}fU7vER56sXbK-WBcD9j9 z*Oa#AYzKMr!Zxrf2ZLZ4Ye&E}VOOph{U%Qz%ms;go+DLExo?TZf_|6$K1^~ALVRkJ z!7Drs39JdxiBwpuiFeGogy*51b9*V$U`}yB{Gsr53#9<%eU|+u>sS7>>IUUNx75Uu zPZJRNN5MrHG8Y3+NLtqNg_t~pDYbyY{fY@T<=i5hHF7%9i()hL9k+)FX9$~@YDGPT z#p8YDW0;?#%T=6m6x0Iu@`>0~#Ng#y0H4^};L{^qjCx1@yiQ|s#@=rx^&>csf_o{l zm>>_Mno)NgQUXw0ca~)*xptowuZgyz{8L>$4}nlwHJNfru~e_ z8suYo$Kq(=^%}*ibuKUKr8Nm2MIUR%xz{qj!l%MKDXx&T!uzW1+)B3)>DprGD%rRq zz|^QA*Y1jUf;)|44rx5JDtqonnN+nAd2gPTpt&BjW?)-F{f}MoF9N6O#55-<`7P)% zCN@;`ryetn_ZU5wI1bo5q*@1t1k2Z6k>_;=B7M&KXUCp}&nxc@(3HNkRewyJng{n`%ti5->8xk zEgn!zf3GYxikZmi35EUCgwJJ&zX{Rb<(dYddAS`0brvxy3Uq~Q{{7S6AOCOVFu+!Q znhyxE{==|-B*7h$-@uN=VgPr-5>DkMr zZx~93;WhvuyYfXG<<8{x^BhEJAPe@DA+r2c2 zyzED$2^K$*H`feD8f^L4}{PQH79CK9lr-wZ4GyDvHSUX4_vjVJ;RXNOn1b*C4- z`~h)|?JZ}^W(@YV82T*|dDd)Fp5Wp#S$^7edpUHvGKkt2krOUszd>L6bPan*d^_?G zEX-cA$|l5q_q=I;Z0R&9#l&Pr&`F~0d~dT^57oO5P=npSH5j?6bLiB~O$4A)>Vbvj zA5v@ytoCR8mXhI}w#MLkIEwsv-Sn$R zgg@2i+#}%itE-)FxGU9#=&qV|nwSKjp%+wZ|55UT;fui1<oye1FlEZJ~%dc%r6-Nil#F_d4!+XYb}PpHTb_a zkPaNF67HgSp%wI_UNs33?)TYVWlKtUj3E-$2PX$d$^nn>XZadmT~9ZvuHXf9v z5&M;RR>|)Fj8TCM6T5Qym#7yYev*+0j8zhD;8yoe`CZ!Ej;HxL_Gb4Wotu@vVQf~m zUj>b<{eo0a`pe_PI>zx8h+TbqLArK}f_G z&(o@7zld{@ArHS?%Zd2|R%|(~^=*Lgh`(V(_Rb0W?+nI#Sh`+fKc!@-bZSR``sIzh zzUI|(y^8bft2&&Ct=!{crxNISc})qGxpTWN93_sqT)7`Wuhw#wt^uz$QJwAsq$0!t zEo1zfSNvkPp()0Z!zbcp$2Xng5OP!&^17pU{M?B+?2;exFsR{mE})vRVGOq)cNvVB-8bBf3Ft z+U)u)+CxlmOUz2_)@=277;)QD=_q0~e>f9oZM^bd&jA!i%3z;+_WZ*_02%s)yIcCs zU%C->A7ES*(OrVqAmon*dC`p>lV?^JLlSC&yo&0q8WyH9n4WnS%dw#!uJ!4)fJtWdstXC%?H3}MdS6uQIS_9E3YQ`+_6Q7N{Z+NrThmBi% zAW8n9`S#dLbopv<6_DaELC=mqoX{N4_dhaDs|fcuW&5plDs-#okD=+LgWJYnndMQU zLF&P(Hv+3nK))x^HoIp|eLGBCJKoMlUZ@$1yxp#Jq5=k!jA(g9ZyX){)-@@X^HA1a zRP(I!eT6B`=s?3rhF&2v!!id=WF*zJk;^tKW%zb5$Tot30nNno2F?sE+ z%C!<27kkrx+)K6RE1rjV>R!NaB(G`pgL(IX9E-94(Tml+fQqNBYZoX338uc^wE2hM zaY%Hxet@EpZZ!&L|Kf^PU0`wjE%4lZ@Errv!=|Pm5xoNBdQ`PY(SrYEr~Ix3zB+^f zeANjc=9r&RnoOYaN*`v(`aLZ*7TMYH!YE#-fAr>u~7zE-s29> z^Zk+Suin)Ta7EmHWALsZnPt*8j7p;C?n-Xr7G`x=6erDUeTIec14V-I8i7G_`c0T2Ns;8UU{QNLNS>19EK>Z|V(i(|2B_taRD-?DQ6 zNLTj`ul3XqUYo~sqsMmK-audWE3wH}e`VEVw&VcOD#s2CvZ3;;Be%SI3i?Mx{JS>k zyp0Tu4Y%$xF?NfNXf>#^bm?2;C!Vbw3ZUyD4pyPis)mZR{lMvLCSq;g{R4#w`C1U; z-XeY!)TS9Jum-M_&X8g8u!AbBxouj3_5)QZqn z0f-|2Y*XRKbaP=rP|>lnIU4z1faXl#h8k^y4pLW267O4lWbE%)w3@!9IcMYQp93Xu z9Vy0;saZex+~6#>`-48N_9+_{#Jr{$C;Cl2?BW<>w%4V?vI|Bj5v)B?AM&82F~Q!4 z1^9ZYd`Ck=(+ZOq&Sz{Liw=i<|a%eGNFRfYdf1iV5V_O>-G> zwpEBxLsqjcm_OH2wwEd z&<;I%vMcB~G?(^YubU09e49s=?Otn30Om42sPh_O#?fpqd0b-F9~j$j$zscMm7$38 zUSKOR16YQHldFfs12Aum?p_O_$tabfYykH%$AtV=Ydq^v?k36s8JnExcyr$Hf63!R z_0hYKNhv%(p@|VY9T)tWcr>w1_Y^L%e<+9eyxKQt?|wNFM9pzMj z42Q3OhM%TA^h7K|rx3){1tV+Gzz%eWfM(@_Su1AJ#fiCd4yjzW zqv>u4J}2sw%F^Z&G^+3S!x2g&wPAEXroQ&bb*?%7^%=$3C z8d^n>*R~GBmBKVO%ur^D2O!Jwwu-lFGmzVXUYk=CBS?q|H1F)O2Bu9ipVLXx$(Gnt z^zDDy$_JVq(Il8cCzM!s0oVaBrE~kpCFcISdfZtL3x4xZPSLW>>Jw&@=I^Ofx5~BCM`VunZjoc&<&t~7eOrgzdv_cIzw-U(ZIl5t_*Z?v@mHh~ zt)cT3qTpTMBCQ4yDf$)(OaVo7?pxZv`?!jrU@`L?wb2WOhhOgE3szVUA~>G?Sgc*<9j+C)xE<%Mu@+ zWL8Nd_^9q6*YYVlSK8Ul*24)+EJBo0_aowKg;KPMH{Tpf*pzmpMzBJK_o}-9>awcm zsAn~kj`4!s}5)u);==ddn+|;eHAho?@IF7Wh_Ww1*HEz%0%c ztz#ucv|%obA+KM|>CVM!#tpQquOAF*R)|lB(M+AN(c~V!o(UwdO+!7XWry8Nnmp)( zRy@P*gkqvS0qkDViybCud~Beex%SH>yV>=L{)S%5a@ zst=KSC>#KN$88e1*sx~j)L;v|a&=e(22ZCDQih=EW47#e<}5`75=y-LY{q zl4U(zI6(b4c?0q^ZioL&rvq@|T<{eL7+onVwj5q&9?dkjHkNbLt=qeYJ6>Vj!cH*- z&IGDtP30a@(*@SB_NQ}hQ;zclOjpzix9h90M?7hi-D~2SvDmK-B5K6J?0c^H3bST# zMO((KIQl+f#t&{jZ9IrhqH2 zhqQVHW_X=Ha5x&;uzT={FTdQ2n`6u+u#4JlKqIK_IGRtU=o52TLHlRJ=H?~e319%R zWZT%k7W z#obSPKb5_W0R)lfo=bCtTSlvveC+%3N%~KWOzJV6O2K)%*3>i_VYXe=+OB;!C>r0_ zYeJYV65AjiCmZ>Jh8hX47C;{Bz(Pbg0Sp5?eqv4(7&~p6fw=v)?Y`k7VxFQTVPPw7 ztuU(jnO0^H$7(OSt>opPkLznJNH*2_H5;zfo>$aedg|UVmzZx+N(2J+y6bc3ql!%g zlhy6P&8b46Qo=4LgU$aOeSg|iXduA3_2jS&^WgQbS>6Op=>{(B7nR+zK7%zMtm=T> zwC3E0<(X$x!~@2@BLlxa!7iErE#L(Wp4yg&u*La;-dzYu@zG}jtjV{H7%<2bXibY9 zDQWMdC9syyoEi-$Fa@1E>qCNVUgF72VOgMZSryWP@0CQ^SWoK=6W`Ur*L3Nhb5Id< zzML>mz|2W{TCSwO^k7-4{$av`%7)fv$j6sr!X;_qqW+`#*n7?lR z9bZDoXN}te2hEO)Hl;zr>ZiYsG%Ci{Of6MmEen0Xsd3(Ez3ppwt~d|#QjUezgV(&B zO7%6DSHUv9S&>fwuVr<)m|yDA5x;x9KvrbQ zx(DLB7688-SJw*?JjdUHX35^X*gg!`9u65t=Vc&j%Mf6jnSPK&ukZ%RF+zGaG3E(C#Gmmk`;abSZiW zXU0@pE(QqEFYW{g0u;$0p0Uhj{qNcGu%3dcW<$s|l~s&Wl9MTqa4QiEySiE#nlUuQ z`vUXlFXQ#fl2W@_(MgUkfIrBptT#gJ`nI7`3(qx`JCw^z1>!}RGuk%8@md|ZmL7K$ z`G2ar&Zwrcu07)r4WI~8M5P2!6bvs-Y0`U9hG3{7hzZRA0)k4PNg^mcAfbg`gaAQ8 zU_hfN0s%pqlK`OzLO?-KL`tad<^5h~(C^QzS?gPCzWm6|$~t$SefK%{?0fEhp1n); zYz^%+A#r@5IMHTa9VtJ+yH)BOdAwoCDTm@aMybe-nV_Xl9W8g=o}EJ;uxRa480X&G zfc{8|&N2P0VHaDSBV8cBeaxZNzf-V=y>^|s1^BQup3hp+FVqoFO!90z|tko%d;v>Ax;L1;Y0+*HnT0y}AiO0_caN z-zS{40Yo5Rx|dIOpy#G;;x4v%1p!;fV*5gJSRRI15N~v7U*m(B1gA99Q{wqpVX`VgJ=<)=;ApR)LydMQWTPqe%f*>XF_by2X1cysku;uv&D20ekRawjQF}JswW>7#QKHZ| z9dx~fE8jX(QG#G(PGWc+!s?!b$(q^s%iD1n_c8?}R#lJrWOE#=w8(&aC=pf+I3x~~ z)gcPR)t@7En*0VJDojULyj~{5YD5j{b;Ljbn>8QonYrqrpUruDQrgOm&LH1Z>WNa| z*5H8S#o$;;(bem(A4RuS4%a2cTqHLCf+zf}qpG?Ych@*flgW3cqTQB@^v+z<(M(dX zbTV$QVw14`r~QkoFe4`1)uMjX9#u0JjNMYuZN>NDu1y$NHY$jwJQk0*YPjrIiKhp> zID~}2Hnv-jv+)HWgn4K(1WIE%*WCHI&AWWecCr4IVKq?}dW1 z7$j)o3Q*W`UCgB~-EMdVm(g2!_thb-5O7loaRgvvmBM)O(}_aG`vfQM;ftuF04GJ^ zv--1zm#BD4Ti(y36u-*qmeM~_PVwkZg^~bcR**q>33-up5nl5UFKi>pVtP}!jWQ{? za*)@8F-u6$ycl8#ww5z^+~YLZCk^J~g0Ddk1IEmEBkZ1uvRX+SeTVt`Xoc6(;IdO4 zvut|G9K0Q}p6=Z>CqCHklwnZ3f2iwYc)c%u(aaI*X&;$%GxfZPi+{&uehvBaT{T^H zRm*waktt(F#}yZ}VQR~p6`Y5G(!JO{6PO!jELhZ{|5DZb5$5_uvv#)Y2g&BoYBI(9 zrUjnIAWymRC4+wLbi`E2hn==@XDyc#wxJjR!5diPepv>*SnuY2rV=GQ!bpe9Nb)uq zM+I%vCuB0ZrMwT$X%FRe`(OaZ@u2+ifliWN#`?=yv-%oo@%zAJHYgVHi9(p7lepf5 zZK=JvV4eNOBa6w%o6O7wp7$@%IL15I1D>*j^*S<(!Jf-yKAMzTvRrVTvSsT~iLfrO zHRpP6)||?gA^ZP(dXvkUC|`J&3HCegVi_dFAgfB1n%$;?m>{>SE=57?Z4DwG3zE!=~*rS!FFNJnSXyA^D; zJ{F4$P|8^%B?T%bK%nht1x%963B<+NUg(~uJ;4M{m#rM48dG+D02jHjUVyqDug5Vy zQIlhl(w94Q?IWc`0!6NlupL5%2eK?kpF>dXLsB*xy0LA;=G9R|CFe#v?tWntUOcUc zJ*=fNW#725)&zgAjN_8A-{|W5oy;5D(w*{Se$(mx=QxKMD)3DeBQ{hA{m{kpLg!jWuK}Qme`3*F{LI|Z`RK4gDZ4>Qy1FI&N=+oCg{6oM z=co8iIT^yrOhMxPyp{Nx4+d)i@~q6sf$E!3I^M}Zw^#uMGybgwT6HN`XHxC$qX=rn zGq?R##X}Tnw=Br8J5%vHFSqw3xbWOU!{BK2lE9wxMM}MqzdI<0K4k!RJKhg#oK;etm3>UuVE zbA~vP!%pEA$!IVaQoaAj?UK)Ozu6bf#717^tZx^KF3RnQX>;x&E|cjv_7o4{mNQ0sztBK{zBe;%=U2(ou>HYXztC zOLLwh&Nkar0(qRjY2w88roxqg99cCYirMtYh2_r@R%mc0(Ntiio#W}R#PXVCj$%1b zy|B60$L(=-JD~)kN>>ZN+(>qE;6Z@sd9374a7U|O z(0h+-c0a!eVSM614QWlG=AUCOL{T1l8wHFrn7YSrlrTF#5YiF1i&q?HmizL2!UwFqg@Py6fa6Q4xLLgY}7(yx7=&WMG2C7`- z!(pN&SuNk(-v9#?Zr>kLL0iw-?oilUW;l~}HXP3KF}0%8>z?v*(!HhhD; zDyl*`dATm^6JoeK$RhO%a%hX^!ek2cab;;Q5 zYdYg;Tje|L=i07vq#3lTJ+m3t#~Cs6xAzWd4ARP>1lA4#9NzD9+2WDY*FANAL) z&zU~gWwO)m^r8^d5;IkK3m*#lYQG(Gd0!d3?WZL#=Txw6&AXfiicJew^VBH#CTW5S zG(b`KzhW%xNHGp0cTU??`HTKhN^K|JeBcf}dugA?tE^nIcZ=*u8v5aOXLvYFse?NW zx@i85YP$CIx4i;1>&3Fu(0+N)5D&Mbn%z{2!l;Xn;O;CBZEHN19?KTo$rBEgFPC>3 zf1>tON`^Jm{8GGYsP9(1p;G;-=@_o2cGjHW$Ce6`Tf__7!d+IAoRzB=A@B%dKL&~< zk}UMp#;HqPF=x`-hB|CRf_B@Z!UO717tlOSr8^i~(uyI3KxY?9Aks?i-Nx7KX#bHr zyYD*niFfnFSYY5Kt|l2A_Dj{}rLAjKYP$v$7u397-XB+?UMd3Twe))2OO+n42-Q_eUs+K8+6Z=r+}}PZeaMhQ)Fndv2jF97WNAp!zk>cZS}ys& literal 0 HcmV?d00001 diff --git a/docs/overseas-inventory-mutation-flow.svg b/docs/overseas-inventory-mutation-flow.svg new file mode 100644 index 000000000..fc3c9ae65 --- /dev/null +++ b/docs/overseas-inventory-mutation-flow.svg @@ -0,0 +1,90 @@ + + + + + + + + + 海外业务|库存变动测试总流程图 + 测试看库存:先看库存状态,再看数量、位置、流水和幂等;灰色节点只改状态/资源,不直接改可用库存。 + + + + 绿色:库存增加/恢复 + 红色:库存扣减 + 橙色:冻结/释放 + 紫色:SKU/箱唛变化 + 灰色:状态/资源变化 + 所有写操作:租户隔离 + 版本/锁 + 幂等键 + + + + + A. 库存来源与入仓 + + 头程装柜完成事件生成尾程单 / 冻结明细在途:不可用库存 + + + 平台转换入仓计划按入仓单 / 推荐托数建计划仍未形成可用库存 + + + PDA 签收 → 实收 → 上架完成后入仓库存入账AVAILABLE + 位置 + 库存流水 + + + 可用库存池客户 × 海外仓 × 目的仓入仓单汇总;托盘只做作业维度 + + 门禁与失败照片、仓绑定、客户/柜号任一失败:库存与状态整体回滚 + + + + + B. 从可用库存出发的四类作业 + + 排车 / 一件代发拣货AVAILABLE → 冻结 → PICKED发车/出库 → 扣减整托扫描也按入仓单组成核平 + + 换标冻结 hold_packages旧 SKU 转出 → 新 SKU 转入箱唛/托盘绑定变化,箱数守恒 + + 仓储转运来源仓冻结/扣减目标仓收货 → 增加可联动换标;来源/目标核对 + + 废弃申请仅锁箱:DISCARD_PENDINGPDA 实际确认 → 扣减 → DISCARDED驳回释放;平台结案不再扣一次 + + + + + C. 出库后的结果与恢复 + + OUTBOUNDED / 派送中库存已扣减,保留出库事实签收:不再二次扣库存派送费用与库存动作分开核对 + + + 异常退回入仓只能选原出库拣货事实RETURN_STOCK_IN → 目标托盘/库位库存恢复 + 异常完成 + 派送 RETURNED + + 盘点调整实盘数量 − 账面数量 = 差异按正/负生成调整流水无差异不造流水;需 adjust 权限 + + 移库 / 拆合托 / 换绑同仓位置或托盘组成变化数量净额 = 0记录位置流水;资源锁防并发 + + + + + + + + D. 测试统一断言(每个库存场景都要检查) + ① 数量守恒:客户 / 海外仓 / 目的仓 / 入仓单 / 托盘组成逐层核平 + ② 三账一致:库存余额 = 位置明细汇总 = 库存流水结果 + ③ 幂等并发:重复事件、重复请求、并发锁定不得产生第二笔变动 + ④ 原子回滚:权限、门禁、资源锁、跨服务失败时,库存/单据/流水一起回滚 + ⑤ 隔离校验:客户、仓、来源入仓单、原出库事实不可伪造或串用 + + diff --git a/docs/overseas-inventory-mutation-test-map.md b/docs/overseas-inventory-mutation-test-map.md new file mode 100644 index 000000000..8e9e7fff7 --- /dev/null +++ b/docs/overseas-inventory-mutation-test-map.md @@ -0,0 +1,40 @@ +# 海外业务库存变动测试整理 + +范围依据:`lastleg-service/AGENTS.md`、`docs/backend-business-index.md` 及当前海外仓库存、入仓、出库、换标、转运、废弃、异常退回和 PDA 作业入口。 + +## 先统一库存口径 + +- **可用库存**:按客户、海外仓、目的仓、入仓单汇总箱数;SKU、箱唛和托盘是明细快照/作业维度。 +- **冻结库存**:拣货、换标、废弃、转运等处理中暂时不可重复使用;冻结不等于已经扣减。 +- **库存流水**:每一次入账、冻结、释放、扣减、恢复、SKU 转换和位置变化都应能按业务单号、幂等键追溯。 +- **托盘不是库存归属**:合托后一个物理托盘可以包含多个入仓单,出库、换标和退回必须按托盘组成明细核平。 + +## 测试场景矩阵 + +| 场景 | 触发与前置 | 库存变动 | 测试重点 | +|---|---|---|---| +| 头程接柜/海外入仓 | 装柜完成事件 → 生成尾程单/入仓计划;PDA 签收、实收、上架、完成 | 在途冻结 → 入仓后 `AVAILABLE` 增加;写库存位置和流水 | 柜号/客户/入仓单隔离;卸柜照片门禁;重复事件不重复入账;失败整事务回滚 | +| 普通排车出库/派送 | 计划已入仓;排车审核、拣货、箱数核平、发车 | `AVAILABLE` → 冻结/已拣;原子出库后可用库存扣减 | 整托/部分托按组成明细核平;未审核、数量不平、车辆门禁时不得出库;同键重放幂等 | +| 一件代发 | 已完成入库且有可用箱唛/SKU;创建 `WAIT_PICK` 出库单 | SKU/箱唛库存冻结 → PDA 拣货 → `OUTBOUNDED` 扣减 | 候选只能来自真实可用库存;一单不得跨客户/跨仓;整托扫描任一明细失败必须整体回滚 | +| 客户换标 | 历史入仓库存可用;客户申请、PDA 扫旧 SKU/箱唛、完工、客户确认 | 旧 SKU/箱唛冻结;换标完成后旧库存转出、新 SKU/箱唛转入;库存总箱数守恒 | 同箱多 SKU 不重复冻结;只移动本单 `hold_packages`;已生成出库单禁止再次拆托;照片/复核/客户确认门禁 | +| 仓储转运 | 来源库存可用;创建转运、测量、换标(可选)、出库、目标仓收货 | 来源仓冻结/扣减 → 目标仓入账;换标时 SKU 转换 | 来源客户、入仓单和箱数核对;部分转运数量守恒;目标仓与来源仓不能串;重复完成不重复入账 | +| 货物废弃 | 一件代发/库存货物申请废弃;审批后由绑定海外 PDA 实际确认 | `AVAILABLE` → `DISCARD_PENDING`;实际确认后 SKU、余额、位置扣减并记 `DISCARDED` | 申请阶段只锁箱不扣库存;驳回释放;批准不是实际扣减;实际数量与逐箱流水一致;失败整批回滚 | +| 异常退回入仓 | 只能选择原出库拣货事实;异常单受理并完成退回 | 原出库来源核验 → `RETURN_STOCK_IN` 恢复到真实目标托盘/库位 | 不能伪造出库来源、借用其他客户主单;退回数量与原实发核平;异常单完成与派送 `RETURNED` 同事务 | +| PDA 盘点调整 | 绑定海外仓;盘点锁定库存位置并提交实盘数量 | 按差异产生增加/减少调整流水;无差异不产生虚假变动 | 需要 `inventory:adjust`;并发版本校验;差异正负方向正确;盘点重复提交幂等 | +| PDA 移库/托盘拆合/换绑 | 同仓有效托盘/库位;资源锁成功 | 数量净额不变,仅位置/托盘组成变化,写位置流水 | 不跨仓;托盘组成明细保留;资源锁释放;出库/换标占用时禁止绕过协调单独移动 | + +## 不应误判为库存变动 + +- 目的仓修改、暂扣/解除、报价、排车预排、外接预约、车辆/司机资料:只改变状态、资格、资源占用或费用快照。 +- 海外采购样品履约:按当前契约走 Admin 台账,**不接入 PDA,也不写 WMS 可用库存**。 +- 退货申请/废弃申请/转派送建单:申请或建单阶段只形成冻结/候选/出库单,必须到实际收货、实际扣减或实际出库节点才确认数量变化。 + +## 每条库存用例的统一断言 + +1. 变动前后按客户、海外仓、目的仓、入仓单、托盘组成核对数量守恒。 +2. 查询余额、位置明细和库存流水三处结果一致,且业务单号/幂等键可追溯。 +3. 重复请求、重复事件、并发操作不得新增第二笔库存变动。 +4. 任一校验、权限、资源锁或跨服务依赖失败时,库存、单据状态和流水一起回滚。 +5. 客户越权、跨仓、跨客户、伪造来源入仓单/出库单均应拒绝。 + +流程图:`overseas-inventory-mutation-flow.svg` / `overseas-inventory-mutation-flow.png` diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java index 67cad5b89..758b47ccd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -13,6 +13,15 @@ import java.util.List; @Mapper public interface CargoDiscardInventoryMapper { + // 箱唛状态可能保留入库快照;废弃必须同时存在当前客户、同托盘的真实可用库存。 + String AVAILABLE_STOCK = " AND EXISTS (SELECT 1 FROM ll_dropship_order_profile dp " + + "JOIN ll_inventory_position pos ON pos.id=dp.inventory_position_id AND pos.customer_id=dp.customer_id " + + "AND pos.pallet_id=dp.warehouse_pallet_id " + + "JOIN ll_inventory_balance bal ON bal.id=pos.balance_id AND bal.customer_id=pos.customer_id " + + "WHERE dp.customer_id=c.customer_id AND dp.order_id=c.order_id AND dp.id=c.profile_id " + + "AND dp.status='ACTIVE' AND pos.status='OCCUPIED' AND pos.available_packages>0 " + + "AND bal.available_packages>0) "; + @Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE") com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); @@ -50,10 +59,12 @@ public interface CargoDiscardInventoryMapper { int changeGenericCartonStatus(@Param("customerId") String customerId, @Param("cartonId") Long cartonId, @Param("fromStatus") String fromStatus, @Param("toStatus") String toStatus); - @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' ORDER BY c.profile_id,c.id") + /** 仅列出仍有实际可用库存的箱子,排除已出库托盘的残留箱唛。 */ + @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK + " ORDER BY c.profile_id,c.id") List availableOrderCartons(@Param("customerId") String customerId, @Param("orderId") Long orderId); - @Select("") List lockSelected(@Param("customerId") String customerId, @Param("orderId") Long orderId, @@ -72,6 +83,7 @@ public interface CargoDiscardInventoryMapper { @Select("SELECT DISTINCT carton_mark FROM ll_inventory_ledger WHERE customer_id=#{customerId} AND position_id=#{positionId} " + "AND business_type IN ('STOCK_IN','RETURN_STOCK_IN') AND carton_mark IS NOT NULL AND carton_mark<>'' ORDER BY carton_mark") List looseCartonMarks(@Param("customerId") String customerId, @Param("positionId") Long positionId); + /** 按当前可用仓位筛选入库单,不能只依赖箱唛的入库状态。 */ @Select("") List candidates(@Param("customerId") String customerId, @Param("inboundCode") String inboundCode); - @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + + /** SKU 可选数量仅统计实际仍在可用仓位的箱子。 */ + @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK + "AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " + "WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})") long countAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("skuCode") String skuCode); - @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + + /** 托盘 SKU 数量与候选及提交使用相同库存门禁。 */ + @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK + "AND c.profile_id=#{profileId} AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " + "WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode})") long countAvailableProfile(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("profileId") Long profileId, @Param("skuCode") String skuCode); - @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + + /** 全部废弃锁箱时排除已出库或无实际可用库存的托盘。 */ + @Select("SELECT c.* FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} " + AVAILABLE_STOCK + "AND c.status='AVAILABLE' AND EXISTS (SELECT 1 FROM ll_dropship_carton_sku cs " + "WHERE cs.customer_id=c.customer_id AND cs.carton_id=c.id AND cs.sku_code=#{skuCode}) " + "ORDER BY c.profile_id,c.carton_sequence,c.id LIMIT #{limit} FOR UPDATE") List lockAvailable(@Param("customerId") String customerId, @Param("orderId") Long orderId, @Param("skuCode") String skuCode, @Param("limit") int limit); - @Select("SELECT COUNT(*) FROM ll_dropship_carton WHERE customer_id=#{customerId} AND order_id=#{orderId} AND status='AVAILABLE'") + /** 入库单按实际可用托盘统计物理箱数,不重复累计混装 SKU。 */ + @Select("SELECT COUNT(*) FROM ll_dropship_carton c WHERE c.customer_id=#{customerId} AND c.order_id=#{orderId} AND c.status='AVAILABLE' " + AVAILABLE_STOCK) long countAvailableOrder(@Param("customerId") String customerId, @Param("orderId") Long orderId); @Update("UPDATE ll_dropship_carton SET status=#{toStatus},version=version+1,updated_at=NOW(3) " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java index 3ef755bed..6248b1cda 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java @@ -6,6 +6,16 @@ import org.apache.ibatis.annotations.Mapper; @Mapper public interface RelabelItemMapper extends BaseMapper { + /** 批量查询退货箱已完成的换标事实,避免逐箱查询。 */ + @org.apache.ibatis.annotations.Select("") + java.util.List completedReturnCartonIds(@org.apache.ibatis.annotations.Param("customerId") String customerId, + @org.apache.ibatis.annotations.Param("cartonIds") java.util.List cartonIds); + @org.apache.ibatis.annotations.Select("SELECT ri.* FROM ll_relabel_item ri JOIN ll_relabel_application ra ON ra.id=ri.application_id " + "WHERE ri.customer_id=#{customerId} AND ri.return_carton_id=#{cartonId} AND ri.application_id<>#{currentApplicationId} " + "AND ra.status NOT IN ('CANCELLED','REJECTED') AND ra.customer_confirmed_at IS NOT NULL " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java index 01a2cfa45..8e40695fe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java @@ -5,11 +5,22 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface InboundSnapshotMapper { record SourceCarton(String cartonMark, String skuCode, String productNameZh, Long quantity, String destinationWarehouseCode, String destinationWarehouseName, String skuItemsJson) {} + /** 查询来源单当前仍可转运的箱级明细,排除已经存在有效换标记录的原箱。 */ @Select(""" SELECT c.carton_mark cartonMark,c.sku_code skuCode,NULL productNameZh,NULL quantity, c.destination_warehouse_code destinationWarehouseCode,c.destination_warehouse_name destinationWarehouseName,NULL skuItemsJson FROM ll_inbound_plan_carton c JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND o.overseas_warehouse_id=#{warehouseId} + AND NOT EXISTS ( + SELECT 1 FROM ll_relabel_item ri + JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id + WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code + AND ra.overseas_warehouse_id=#{warehouseId} + AND UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark)) + AND ra.status NOT IN ('CANCELLED','REJECTED') + ) + + UNION ALL SELECT c.carton_mark,i.sku_code,i.product_name_zh,c.counted_quantity, t.destination_warehouse_code,t.destination_warehouse_name,c.sku_items_json @@ -18,6 +29,17 @@ import org.apache.ibatis.annotations.Select; JOIN ll_order o ON o.id=t.lastleg_order_id AND o.customer_id=t.customer_id JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND i.transfer_order_id=t.id WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} AND t.operating_warehouse_id=#{warehouseId} AND c.status='COUNTED' + AND NOT EXISTS ( + SELECT 1 FROM ll_relabel_item ri + JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id + WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code + AND ra.overseas_warehouse_id=#{warehouseId} + AND (UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark)) + OR UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(NULLIF(c.customer_carton_mark,'')))) + AND ra.status NOT IN ('CANCELLED','REJECTED') + ) + + ORDER BY cartonMark """) java.util.List sourceCartons(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java new file mode 100644 index 000000000..e2c1b5cb4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.transfer.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +/** 使用提交单据的原箱快照表达业务占用,不提前扣减实物库存。 */ +@Mapper +public interface TransferSourceReservationMapper { + /** 按同一入仓快照串行提交,防止不同草稿同时抢占原箱。 */ + @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND id=#{inboundOrderId} FOR UPDATE") + Long lockInbound(@Param("customerId") String customerId, @Param("inboundOrderId") Long inboundOrderId); + + /** 读取已提交转运的原箱;驳回、取消和草稿不占用,可重新选择。 */ + @Select("SELECT COALESCE(NULLIF(c.customer_carton_mark,''), c.carton_mark) FROM ll_warehouse_transfer_carton c " + + "JOIN ll_warehouse_transfer_order t ON t.id=c.transfer_order_id AND t.customer_id=c.customer_id " + + "WHERE t.customer_id=#{customerId} AND t.operating_warehouse_id=#{warehouseId} " + + "AND t.inbound_code=#{sourceCode} AND t.transfer_no <> t.inbound_code " + + "AND t.status NOT IN ('DRAFT','REJECTED','CANCELLED') " + + "AND COALESCE(NULLIF(c.customer_carton_mark,''), c.carton_mark) IS NOT NULL") + List occupiedMarks(@Param("customerId") String customerId, + @Param("warehouseId") String warehouseId, @Param("sourceCode") String sourceCode); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java new file mode 100644 index 000000000..85fc1c81b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.transfer.repository; + +import com.baiyue.lastleg.transfer.mapper.InboundSnapshotMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 封装库存转运来源箱查询,SQL和客户隔离由Mapper统一执行。 */ +@Repository +public class TransferInventorySourceRepository { + @Resource private InboundSnapshotMapper snapshots; + @Resource private com.baiyue.lastleg.transfer.mapper.TransferSourceReservationMapper reservations; + + /** 同一来源入仓单的所有库存转运先取相同的行锁。 */ + public Long lockSource(String customerId, Long inboundOrderId) { + return reservations.lockInbound(customerId, inboundOrderId); + } + + /** 读取当前客户及操作仓中已提交转运的原箱快照。 */ + public List occupiedMarks(String customerId, String warehouseId, String sourceCode) { + return reservations.occupiedMarks(customerId, warehouseId, sourceCode); + } + + /** 读取当前来源单未被有效换标申请使用的箱级快照。 */ + public List sourceCartons(String customerId, String warehouseId, String inboundCode) { + return snapshots.sourceCartons(customerId, warehouseId, inboundCode); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java index 7a7be18df..11d28d7d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java @@ -6,6 +6,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; @@ -17,6 +18,7 @@ public class TransferReturnSourceRepository { @Resource private ReturnOrderMapper orders; @Resource private ReturnInventoryCartonMapper cartons; @Resource private ReturnInventoryCartonItemMapper items; + @Resource private RelabelItemMapper relabelItems; /** 按当前客户和退货单号查询来源单据。 */ public ReturnOrder findOrder(String customerId, String returnNo) { @@ -25,10 +27,15 @@ public class TransferReturnSourceRepository { /** 查询当前仓内已完成清点且可用的退货箱,不影响其他页面的历史库存查询。 */ public List availableCartons(String customerId, String warehouseId, String returnNo) { - return cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream() + var available = cartons.currentReturnCartons(customerId, warehouseId, returnNo).stream() .filter(carton -> "AVAILABLE".equals(carton.getStatus()) && "CONFIRMED".equals(carton.getCountStatus()) && carton.getRelabelApplicationId() == null) .toList(); + if (available.isEmpty()) return available; + // 已完成换标的箱虽然释放了当前占用字段,但不能再次作为原箱来源转运。 + var completed = new java.util.HashSet<>(relabelItems.completedReturnCartonIds(customerId, + available.stream().map(ReturnInventoryCarton::getId).toList())); + return available.stream().filter(carton -> !completed.contains(carton.getId())).toList(); } /** 批量读取已通过客户及仓库隔离校验的来源箱明细。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 534792f67..d53b25d82 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -149,6 +149,7 @@ public class WarehouseTransferService { @Resource private InventoryCommandMapper inventoryCommands; @Resource private ObjectMapper objectMapper; @Resource private TransferReturnSourceRepository returnSources; + @Resource private com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository inventorySources; /** 查询来源单据当前可用的箱唛及箱内 SKU,兼容普通入仓与退货库存。 */ public List sourceCartons(String customerId, String warehouseId, String inboundCode) { @@ -164,22 +165,36 @@ public class WarehouseTransferService { List cartonIds = cartons.stream().map(ReturnInventoryCarton::getId).filter(Objects::nonNull).toList(); Map> items = cartonIds.isEmpty() ? Map.of() : returnSources.cartonItems(cartonIds).stream().collect(Collectors.groupingBy(ReturnInventoryCartonItem::getCartonId)); - return cartons.stream().map(carton -> { + return unreservedSourceCartons(customerId, warehouseId.trim(), source, cartons.stream().map(carton -> { List rows = items.getOrDefault(carton.getId(), List.of()); List contents = rows.stream() .filter(item -> item.getQuantity() != null && item.getQuantity() > 0) .map(item -> new WarehouseTransferResponses.SourceCartonSku(item.getSkuCode(), item.getQuantity())).toList(); return new WarehouseTransferResponses.SourceCarton(carton.getCartonMark(), null, carton.getTotalQuantity(), carton.getDestinationWarehouseCode(), carton.getDestinationWarehouseName(), contents); - }).toList(); + }).toList()); } - return inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source).stream().map(row -> { + var sourceRows = inventorySources != null + ? inventorySources.sourceCartons(customerId, warehouseId.trim(), source) + : inboundSnapshots.sourceCartons(customerId, warehouseId.trim(), source); + return unreservedSourceCartons(customerId, warehouseId.trim(), source, sourceRows.stream().map(row -> { var carton = new WarehouseTransferCarton(); carton.setSkuItemsJson(row.skuItemsJson()); var contents = cartonContents(carton).stream().map(item -> new WarehouseTransferResponses.SourceCartonSku(item.skuCode(),item.quantity())).toList(); if (contents.isEmpty()) contents = List.of(new WarehouseTransferResponses.SourceCartonSku(row.skuCode(),row.quantity())); return new WarehouseTransferResponses.SourceCarton(row.cartonMark(),row.productNameZh(),row.quantity(), row.destinationWarehouseCode(),row.destinationWarehouseName(),contents); - }).toList(); + }).toList()); + } + + /** 待审核即按原箱快照占用;所有来源查询复用此过滤,避免重复提交同一实物箱。 */ + private List unreservedSourceCartons(String customerId, + String warehouseId, String source, List cartons) { + if (inventorySources == null) return cartons; + Set occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).stream() + .filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT)) + .collect(Collectors.toSet()); + return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim() + .toUpperCase(java.util.Locale.ROOT))).toList(); } private static LocalDateTime arrivalTime(WarehouseTransferRequests.Create request) { return StringUtils.hasText(request.inboundCode()) ? null : request.estimatedArrivalAt(); @@ -334,7 +349,7 @@ public class WarehouseTransferService { } /** Platform-created transfers keep the existing create-and-submit behavior. */ - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public WarehouseTransferResponses.Detail createAndSubmit(String customerId, String operatorId, WarehouseTransferRequests.Create request) { WarehouseTransferResponses.Detail draft = create(customerId, operatorId, request); @@ -456,7 +471,7 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } - @Transactional + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) /** 提交草稿前重新校验来源箱归属,防止草稿保存后库存已发生变化。 */ public WarehouseTransferResponses.Detail submit(String customerId, String transferNo, String operatorId) { WarehouseTransferOrder transfer = transfers.lockNo(customerId, transferNo); @@ -464,6 +479,13 @@ public class WarehouseTransferService { if (PENDING_REVIEW.equals(transfer.getStatus())) return detail(customerId, transferNo); if (!DRAFT.equals(transfer.getStatus())) throw conflict("Only a draft warehouse transfer order can be submitted"); + if (!transfer.getTransferNo().equals(transfer.getInboundCode())) { + // 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。 + if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null) + throw conflict("库存来源已失效,请重新选择来源单"); + if (inventorySources != null) reserveSourceCartons(customerId, transfer); + } + if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { Set allowedMarks = !transfer.getTransferNo().equals(transfer.getInboundCode()) ? sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode()) @@ -500,6 +522,29 @@ public class WarehouseTransferService { return detail(customerId, transferNo); } + /** 提交时逐箱核对来源快照并记录占用,禁止并发提交时静默替换用户选择。 */ + private void reserveSourceCartons(String customerId, WarehouseTransferOrder transfer) { + var available = new java.util.LinkedHashMap(); + for (var box : sourceCartons(customerId, transfer.getOperatingWarehouseId(), transfer.getInboundCode())) { + available.put(box.cartonMark().trim().toUpperCase(java.util.Locale.ROOT), box.cartonMark()); + } + var selected = transferCartons.listOrder(customerId, transfer.getId()); + if (selected.size() != transfer.getPackageCount() || selected.isEmpty()) + throw conflict("转运箱数与来源箱明细不一致,请刷新后重新提交"); + var reserved = new java.util.LinkedHashMap(); + for (var carton : selected) { + String mark = StringUtils.hasText(carton.getCustomerCartonMark()) + ? carton.getCustomerCartonMark() : carton.getCartonMark(); + if (!StringUtils.hasText(mark)) throw conflict("转运箱缺少来源箱号,请刷新来源单"); + String canonical = available.remove(mark.trim().toUpperCase(java.util.Locale.ROOT)); + if (canonical == null) throw conflict("来源箱已被提交或不可用,请刷新来源单:" + mark); + reserved.put(carton.getId(), canonical); + } + for (var carton : selected) { + carton.setCustomerCartonMark(reserved.get(carton.getId())); + transferCartons.updateById(carton); + } + } @Transactional(readOnly = true) public PageResult page(String customerId, WarehouseTransferRequests.Query query) { return page(customerId, query, true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardAvailableStockMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardAvailableStockMapperTest.java new file mode 100644 index 000000000..bd325c5ad --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardAvailableStockMapperTest.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.discard; + +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** 使用真实 Mapper SQL 验证已出库但箱唛状态残留时的废弃门禁。 */ +class CargoDiscardAvailableStockMapperTest { + /** 全部出库不可选;部分托盘仍有库存时只返回该托盘,提交旧箱号同样失败。 */ + @Test + void filtersShippedStockFromCandidatesCountsAndSelection() { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(source); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(20),inbound_code VARCHAR(50),inbound_order_no VARCHAR(50),overseas_warehouse_id VARCHAR(20),overseas_warehouse_name VARCHAR(50),created_at TIMESTAMP,order_type VARCHAR(30))"); + db.execute("CREATE TABLE ll_dropship_order_profile(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_position_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(20))"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT,status VARCHAR(20),available_packages BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),available_packages BIGINT)"); + db.execute("CREATE TABLE ll_dropship_carton(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,profile_id BIGINT,status VARCHAR(20),carton_sequence INT)"); + db.execute("CREATE TABLE ll_dropship_carton_sku(customer_id VARCHAR(20),carton_id BIGINT,sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_dropship_sku_inventory(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,profile_id BIGINT,sku_code VARCHAR(20),product_name VARCHAR(50))"); + db.update("INSERT INTO ll_order VALUES(1,'C','EXXHWK2809191130','IP-WH-20260917-979650','WH','测试仓',CURRENT_TIMESTAMP,'ONE_CLICK_DROPSHIP')"); + db.update("INSERT INTO ll_dropship_order_profile VALUES(10,'C',1,100,1000,'ACTIVE'),(20,'C',1,200,2000,'ACTIVE')"); + db.update("INSERT INTO ll_inventory_position VALUES(100,'C',10000,1000,'OCCUPIED',0),(200,'C',20000,2000,'OCCUPIED',0)"); + db.update("INSERT INTO ll_inventory_balance VALUES(10000,'C',0),(20000,'C',0)"); + // 复现库存已扣完,箱唛仍为 AVAILABLE 的状态,不能因此暴露可废弃库存。 + db.update("INSERT INTO ll_dropship_carton VALUES(11,'C',1,10,'AVAILABLE',1),(21,'C',1,20,'AVAILABLE',1)"); + db.update("INSERT INTO ll_dropship_carton_sku VALUES('C',11,'SKU-A'),('C',21,'SKU-A'),('C',21,'SKU-B')"); + db.update("INSERT INTO ll_dropship_sku_inventory VALUES(1,'C',1,10,'SKU-A','货物A'),(2,'C',1,20,'SKU-A','货物A'),(3,'C',1,20,'SKU-B','货物B')"); + var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source)); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(CargoDiscardInventoryMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession(true)) { + var mapper = session.getMapper(CargoDiscardInventoryMapper.class); + assertTrue(mapper.candidates("C", null).isEmpty()); + assertEquals(0, mapper.countAvailableOrder("C", 1L)); + assertTrue(mapper.availableOrderCartons("C", 1L).isEmpty()); + assertTrue(mapper.lockSelected("C", 1L, "SKU-A", List.of(11L)).isEmpty()); + assertTrue(mapper.lockAvailable("C", 1L, "SKU-A", 20).isEmpty()); + + db.update("UPDATE ll_inventory_position SET available_packages=1 WHERE id=200"); + db.update("UPDATE ll_inventory_balance SET available_packages=1 WHERE id=20000"); + session.clearCache(); + assertEquals(2, mapper.candidates("C", "EXXHWK").size()); + assertEquals(1, mapper.countAvailableOrder("C", 1L)); + assertEquals(1, mapper.countAvailable("C", 1L, "SKU-A")); + assertEquals(0, mapper.countAvailableProfile("C", 1L, 10L, "SKU-A")); + assertEquals(1, mapper.countAvailableProfile("C", 1L, 20L, "SKU-B")); + assertEquals(List.of(21L), mapper.availableOrderCartons("C", 1L).stream().map(c -> c.getId()).toList()); + assertEquals(List.of(21L), mapper.lockSelected("C", 1L, "SKU-A", List.of(11L, 21L)).stream().map(c -> c.getId()).toList()); + assertEquals(List.of(21L), mapper.lockAvailable("C", 1L, "SKU-A", 20).stream().map(c -> c.getId()).toList()); + assertTrue(mapper.candidates("OTHER", null).isEmpty()); + assertEquals(0, mapper.countAvailableOrder("OTHER", 1L)); + + // 仓位与余额必须属于相同客户,不能借用其他客户的正库存。 + db.update("UPDATE ll_inventory_balance SET customer_id='OTHER' WHERE id=20000"); + session.clearCache(); + assertTrue(mapper.candidates("C", null).isEmpty()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java index 8bc35b8ca..e3696f080 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java @@ -24,23 +24,30 @@ class TransferInventorySourceTest { assertFalse(request(null,null,null,null).isInboundModeValid()); assertTrue(request(null,null,"CUSTOMER_DELIVERY",LocalDateTime.now()).isInboundModeValid()); } + /** 已换标或正在换标的原箱不再展示;撤销、驳回及其他客户的记录不能误删当前箱。 */ @Test void originalBoxesIncludeBothSourcesButNeverAnotherCustomerWarehouseOrInbound() { var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); var db=new JdbcTemplate(ds); db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,inbound_code VARCHAR,overseas_warehouse_id VARCHAR)"); db.execute("CREATE TABLE ll_inbound_plan_carton(customer_id VARCHAR,order_id BIGINT,carton_mark VARCHAR,sku_code VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR)"); - db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,operating_warehouse_id VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR)"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,operating_warehouse_id VARCHAR,destination_warehouse_code VARCHAR,destination_warehouse_name VARCHAR,inbound_code VARCHAR,status VARCHAR)"); db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR,transfer_order_id BIGINT,sku_code VARCHAR,product_name_zh VARCHAR)"); - db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_item_id BIGINT,carton_mark VARCHAR,counted_quantity BIGINT,sku_items_json VARCHAR,status VARCHAR)"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_item_id BIGINT,carton_mark VARCHAR,customer_carton_mark VARCHAR,counted_quantity BIGINT,sku_items_json VARCHAR,status VARCHAR)"); + db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR,status VARCHAR,customer_confirmed_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_relabel_item(application_id BIGINT,customer_id VARCHAR,source_inbound_code VARCHAR,source_carton_mark VARCHAR,carton_mark VARCHAR)"); + db.execute("CREATE TABLE ll_relabel_pda_unit_fact(item_id BIGINT,result VARCHAR)"); db.update("INSERT INTO ll_order VALUES(1,'A','IN','WH'),(2,'B','IN','WH'),(3,'A','IN','OTHER'),(4,'A','OTHER','WH')"); - db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')"); - db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'A',1,'WH','ONT8','Amazon')"); + db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('A',1,'RELABELED','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')"); + db.update("INSERT INTO ll_relabel_application VALUES(51,'A','WH','COMPLETED',CURRENT_TIMESTAMP()),(52,'A','WH','PROCESSING',NULL),(53,'A','WH','CANCELLED',NULL),(54,'A','WH','REJECTED',NULL),(55,'B','WH','COMPLETED',CURRENT_TIMESTAMP()),(56,'A','OTHER','COMPLETED',CURRENT_TIMESTAMP())"); + db.update("INSERT INTO ll_relabel_item VALUES(51,'A','IN','RELABELED','RELABELED'),(52,'A','IN',NULL,'CUSTOMER-OLD'),(53,'A','IN',NULL,'FIRST'),(54,'A','IN',NULL,'FIRST'),(55,'B','IN',NULL,'FIRST'),(56,'A','IN',NULL,'FIRST')"); + db.update("INSERT INTO ll_relabel_pda_unit_fact VALUES(51,'RELABELLED')"); + db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'A',1,'WH','ONT8','Amazon','IN','DRAFT'),(20,'A',1,'WH','ONT8','Amazon','IN','PENDING_REVIEW')"); db.update("INSERT INTO ll_warehouse_transfer_item VALUES(11,'A',10,'SKU','Product')"); - db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('A',10,11,'MEASURED',20,NULL,'COUNTED'),('A',10,11,'EXPECTED',NULL,NULL,'EXPECTED'),('B',10,11,'FOREIGN2',20,NULL,'COUNTED')"); + db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('A',10,11,'MEASURED',NULL,20,NULL,'COUNTED'),('A',10,11,'RELABELED-TRANSFER','CUSTOMER-OLD',20,NULL,'COUNTED'),('A',10,11,'EXPECTED',NULL,NULL,NULL,'EXPECTED'),('A',20,11,'FIRST',NULL,20,NULL,'COUNTED'),('B',10,11,'FOREIGN2',NULL,20,NULL,'COUNTED')"); var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class); try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) { var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN"); - assertEquals(List.of("FIRST","MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); + assertEquals(List.of("FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); assertEquals("ONT8",result.get(0).destinationWarehouseCode()); assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java index f7532f5ff..7546fde91 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java @@ -6,6 +6,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnOrder; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonItemMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; import com.baiyue.lastleg.transfer.repository.TransferReturnSourceRepository; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -23,12 +24,15 @@ class TransferReturnSourceTest { var orders = mock(ReturnOrderMapper.class); var cartons = mock(ReturnInventoryCartonMapper.class); var items = mock(ReturnInventoryCartonItemMapper.class); + var relabelItems = mock(RelabelItemMapper.class); var repository = new TransferReturnSourceRepository(); ReflectionTestUtils.setField(repository, "orders", orders); ReflectionTestUtils.setField(repository, "cartons", cartons); ReflectionTestUtils.setField(repository, "items", items); + ReflectionTestUtils.setField(repository, "relabelItems", relabelItems); var service = new WarehouseTransferService(); ReflectionTestUtils.setField(service, "returnSources", repository); + ReflectionTestUtils.setField(service, "inventorySources", mock(com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository.class)); var order = new ReturnOrder(); order.setOverseasWarehouseId("WH-1"); when(orders.findNo("C-1", "RET-1")).thenReturn(order); @@ -38,6 +42,7 @@ class TransferReturnSourceTest { var sku = new ReturnInventoryCartonItem(); sku.setCartonId(1L); sku.setSkuCode("SKU-1"); sku.setQuantity(3L); when(items.listCartons(List.of(1L))).thenReturn(List.of(sku)); + when(relabelItems.completedReturnCartonIds("C-1", List.of(1L))).thenReturn(List.of()); var result = service.sourceCartons("C-1", "WH-1", "RETURN_ORDER:RET-1"); @@ -45,6 +50,9 @@ class TransferReturnSourceTest { assertEquals("BOX-1", result.get(0).cartonMark()); assertEquals("SKU-1", result.get(0).items().get(0).skuCode()); assertEquals(3L, result.get(0).items().get(0).quantity()); + when(relabelItems.completedReturnCartonIds("C-1", List.of(1L))).thenReturn(List.of(1L)); + assertTrue(service.sourceCartons("C-1", "WH-1", "RETURN_ORDER:RET-1").isEmpty(), + "释放占用后的已换标退货箱不再回到原单明细"); assertThrows(ResponseStatusException.class, () -> service.sourceCartons("C-1", "OTHER-WH", "RETURN_ORDER:RET-1")); verify(cartons, never()).currentReturnCartons("C-1", "OTHER-WH", "RET-1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 9d5867743..bb587c13a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -80,6 +80,7 @@ import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.clearInvocations; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) @@ -247,6 +248,12 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "warehouseResources", warehouseResources); ReflectionTestUtils.setField(service, "measurementPhotoFiles", measurementPhotoFiles); ReflectionTestUtils.setField(service, "inboundSnapshots", inboundSnapshots); + var inventorySourceRepository = new com.baiyue.lastleg.transfer.repository.TransferInventorySourceRepository(); + ReflectionTestUtils.setField(inventorySourceRepository, "snapshots", inboundSnapshots); + var reservations = mock(com.baiyue.lastleg.transfer.mapper.TransferSourceReservationMapper.class); + lenient().when(reservations.lockInbound(anyString(), any())).thenReturn(101L); + ReflectionTestUtils.setField(inventorySourceRepository, "reservations", reservations); + ReflectionTestUtils.setField(service, "inventorySources", inventorySourceRepository); ReflectionTestUtils.setField(service, "inboundOrders", inboundOrders); ReflectionTestUtils.setField(service, "inboundPlans", inboundPlans); ReflectionTestUtils.setField(service, "inboundPlanLines", inboundPlanLines); From dff040b50998dd3b6f048c44364085f1614b8385 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 15:11:15 +0800 Subject: [PATCH 020/113] feat(looseboard): require route contacts and pickup delivery times --- deploy/sql/README.md | 2 + deploy/sql/海外区域统一增量-MySQL8.sql | 13 +- .../fleet/business/FleetPoolOrderBridge.java | 93 ++++++----- .../business/FleetPoolOrderRepository.java | 56 +++++++ .../dto/LocalLooseboardRequests.java | 61 ++++--- .../dto/LocalLooseboardResponses.java | 10 +- .../entity/LocalLooseboardOrder.java | 24 +-- .../repository/LocalLooseboardRepository.java | 67 ++++++++ .../service/LocalLooseboardService.java | 155 +++++++++--------- .../src/main/resources/db/lastleg.sql | 13 +- .../openapi/overseas-business-v1.yaml | 52 +++--- .../business/FleetPoolOrderBridgeTest.java | 9 +- .../LocalLooseboardPersistenceTest.java | 62 +++++++ .../LocalLooseboardRequestsTest.java | 51 ++++++ .../LocalLooseboardSchemaContractTest.java | 11 +- ...PlatformLocalLooseboardControllerTest.java | 9 +- .../service/LocalLooseboardServiceTest.java | 62 ++++--- ...rchantOverseasCustomerApiContractTest.java | 2 +- .../TaillegRealConsumerAcceptanceTest.java | 8 +- .../phase4-overseas-business.mjs | 8 +- 20 files changed, 530 insertions(+), 238 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 24ec7e9e8..61905f623 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。 + `migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。 `migrations/overseas/baiyue_lastleg/V22__selfop_warehouse_daily_sales.sql` 新增自营仓库每日汇总表及销量版本 `warehouse_daily_ready` 标记。与匹配 Lastleg 一起发布;汇总随 SKU 日事实批次原子构建,完整性验证后方可发布。已有未构建汇总的销量版本须重新构建发布,不回填旧版本、不回退旧查询;空版本同样必须有就绪证据。源基线与海外空库基线同步。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 0f6f2397d..d4a6b28a1 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1487,22 +1487,21 @@ CREATE TABLE ll_local_looseboard_order ( customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', lastleg_order_id bigint DEFAULT NULL COMMENT '关联尾程主订单ID', order_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', - transport_mode varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载', pallet_required varchar(8) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', - transport_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', + transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设', origin varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', origin_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址', destination varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', destination_address varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号', - container_type varchar(32) COLLATE utf8mb4_unicode_ci NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', + product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货', package_count int NOT NULL DEFAULT '0' COMMENT '货物件数', total_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)', total_volume_cbm decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', budget_amount decimal(18,2) DEFAULT NULL COMMENT '预算总价', currency char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种', - contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名', - contact_info varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式', - requested_date date DEFAULT NULL COMMENT '要求服务日期', + origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话', + destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话', + pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', remark varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', idempotency_key varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', @@ -1516,7 +1515,7 @@ CREATE TABLE ll_local_looseboard_order ( UNIQUE KEY uk_ll_local_looseboard_key (customer_id,idempotency_key), UNIQUE KEY uk_ll_local_looseboard_lastleg (lastleg_order_id), KEY idx_ll_local_looseboard_list (customer_id,status,created_at), - KEY idx_ll_local_looseboard_date (customer_id,requested_date,created_at) + KEY idx_ll_local_looseboard_date (customer_id,pickup_at,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单'; CREATE TABLE ll_monthly_exchange_rate ( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index b16a9e1af..19d19c6cb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -3,9 +3,7 @@ package com.baiyue.lastleg.fleet.business; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.looseboard.entity.*; -import com.baiyue.lastleg.looseboard.mapper.*; import com.baiyue.lastleg.trucking.entity.*; -import com.baiyue.lastleg.trucking.mapper.*; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.HttpStatus; @@ -22,34 +20,32 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*; /** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */ @Service public class FleetPoolOrderBridge { - private final FleetPoolOrderMapper links; - private final LocalLooseboardOrderMapper loose; - private final LocalLooseboardHistoryMapper looseHistory; - private final UsTruckOrderMapper trucks; - private final UsTruckOrderStatusHistoryMapper truckHistory; + private final FleetPoolOrderRepository repository; private final FleetPoolOrderNumberGenerator numbers; private final ObjectMapper json; private final FleetPoolDeliveryEvidence deliveryEvidence; private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources; - public FleetPoolOrderBridge(FleetPoolOrderMapper links,LocalLooseboardOrderMapper loose, - LocalLooseboardHistoryMapper looseHistory,UsTruckOrderMapper trucks, - UsTruckOrderStatusHistoryMapper truckHistory,FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, + /** 注入同库商业委托仓储及履约协作服务。 */ + public FleetPoolOrderBridge(FleetPoolOrderRepository repository, + FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) { - this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks; - this.truckHistory=truckHistory;this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; + this.repository=repository; + this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; this.appointmentSources=appointmentSources; } + /** 批量读取预订对应的当前商业履约阶段。 */ @Transactional(readOnly=true) public Map fulfillmentStages(List bookingIds) { if(bookingIds.isEmpty())return Map.of(); var result=new HashMap(); - for(var link:links.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage()); + for(var link:repository.bookings(bookingIds))result.put(link.getBookingId(),link.getFulfillmentStage()); return Map.copyOf(result); } + /** 在承运确认事务内创建唯一商业订单,重放时核对来源哈希。 */ @Transactional(propagation=Propagation.MANDATORY) public BusinessOrderRef confirm(ConfirmedBooking in,String operator) { validate(in); - String hash=hash(in); var prior=links.lockBooking(in.bookingId()); + String hash=hash(in); var prior=repository.lockBooking(in.bookingId()); if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);} var now=LocalDateTime.now();var row=new FleetPoolOrderLink(); row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo()); @@ -62,15 +58,16 @@ public class FleetPoolOrderBridge { String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR"; if("LOCAL_LOOSEBOARD".equals(in.businessType())) { var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); - order.setTransportMode("SHARED_TRUCK");order.setTransportType(transport); - // A pallet count/space is not a palletizing service request or a container specification. - order.setPalletRequired(null);order.setContainerType(null); + order.setTransportType(transport); + // 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。 + order.setPalletRequired(null);order.setProductName(in.cargoName());order.setEstimatedPalletCount(in.expectedPalletCount()); order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel());order.setPackageCount(in.packageCount()); order.setTotalWeightKg(in.weightKg());order.setTotalVolumeCbm(in.volumeCbm());order.setCurrency(in.currency()); - order.setContactName(in.contactName());order.setContactInfo(in.contactPhone());order.setRequestedDate(in.plannedDepartureAt().toLocalDate()); + order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone()); + order.setPickupAt(in.plannedDepartureAt()); order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo()); order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now); - loose.insert(order); // No placeholder ll_order: incoming owns the one physical tail order. + repository.insertLoose(order); // No placeholder ll_order: incoming owns the one physical tail order. } else { var order=new UsTruckOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); order.setOrigin(in.warehouseName());order.setDestination(in.destinationLabel()); @@ -80,33 +77,36 @@ public class FleetPoolOrderBridge { order.setOriginContactName(in.contactName());order.setOriginContactPhone(in.contactPhone());order.setPickupAt(in.plannedDepartureAt()); order.setStatus(row.getFulfillmentStage());order.setRemark(in.remark());order.setIdempotencyKey("FLEET_POOL:"+in.bookingNo()); order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now); - trucks.insert(order); // No simulated ll_us_truck_order_pallet rows. + repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows. } - links.insert(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now); + repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now); return ref(row); } + /** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */ @Transactional(propagation=Propagation.MANDATORY) public void bindIncoming(String bookingNo,String customerId,String incomingNo,long lastlegOrderId, String warehouseId,String destinationKey,String operator) { - var row=links.lockBookingNo(bookingNo); - if(row==null){if(links.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;} + var row=repository.lockBookingNo(bookingNo); + if(row==null){if(repository.poolBooking(bookingNo)>0)throw conflict("池来源预订缺少唯一商业订单关联");return;} owned(row,customerId); if(!Objects.equals(row.getWarehouseId(),warehouseId)||!Objects.equals(row.getDestinationKey(),destinationKey))throw conflict("来货仓库或目的地与池来源商业委托不一致"); if(row.getIncomingNo()!=null){if(!row.getIncomingNo().equals(incomingNo)||!Objects.equals(row.getLastlegOrderId(),lastlegOrderId))throw conflict("商业委托已绑定其他来货");return;} if(!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("商业委托当前不能登记来货"); - row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());links.updateById(row); + row.setIncomingNo(incomingNo);row.setLastlegOrderId(lastlegOrderId);touch(row,LocalDateTime.now());repository.updateLink(row); if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) { - var order=loose.lockNo(customerId,row.getBusinessOrderNo());requireOrder(order!=null); + var order=repository.lockLoose(customerId,row.getBusinessOrderNo());requireOrder(order!=null); if(order.getLastlegOrderId()!=null&&!order.getLastlegOrderId().equals(lastlegOrderId))throw conflict("散板商业单已存在另一尾程来源"); - order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());loose.updateById(order); + order.setLastlegOrderId(lastlegOrderId);order.setVersion(order.getVersion()+1);order.setUpdatedAt(row.getUpdatedAt());repository.updateLoose(order); } appointmentSources.materialized(customerId, bookingNo, lastlegOrderId); } + /** 按真实来货开始事件推进收货状态。 */ @Transactional(propagation=Propagation.MANDATORY) public void receiving(String customerId,String incomingNo,String operator) { - var row=links.lockIncoming(customerId,incomingNo);if(row==null)return; + var row=repository.lockIncoming(customerId,incomingNo);if(row==null)return; if("WAITING_INBOUND".equals(row.getFulfillmentStage()))move(row,"RECEIVING",operator,"PDA开始实际收货 "+incomingNo,LocalDateTime.now()); } + /** 沿已确认实托及实占托位保存实际收货。 */ @Transactional(propagation=Propagation.MANDATORY) public void acceptedReceipt(long bookingId,Receipt evidence,int palletSpaces,String operator) { var row=requiredBooking(bookingId); @@ -123,6 +123,7 @@ public class FleetPoolOrderBridge { row.setActualPackageCount(packages);row.setActualPalletCount(unique.size());row.setActualPalletSpaces(palletSpaces); move(row,"WAITING_CONSOLIDATION",operator,"实收核量确认 "+evidence.incomingNo(),LocalDateTime.now()); } + /** 沿真实排车关联推进商业履约。 */ @Transactional(propagation=Propagation.MANDATORY) public void boundDispatch(long bookingId,String customerId,String dispatchNo,String operator) { var row=requiredBooking(bookingId);owned(row,customerId); @@ -130,6 +131,7 @@ public class FleetPoolOrderBridge { if(!"WAITING_CONSOLIDATION".equals(row.getFulfillmentStage())||dispatchNo==null||dispatchNo.isBlank())throw conflict("商业单仅实收核量后可关联发运"); row.setDispatchNo(dispatchNo);move(row,"PENDING_DISPATCH",operator,"关联实际排车 "+dispatchNo,LocalDateTime.now()); } + /** 校验商业履约边界或组装真实业务引用。 */ @Transactional(propagation=Propagation.MANDATORY) public void unboundDispatch(long bookingId,String customerId,String dispatchNo,String operator) { var row=requiredBooking(bookingId);owned(row,customerId); @@ -137,6 +139,7 @@ public class FleetPoolOrderBridge { if(!Objects.equals(row.getDispatchNo(),dispatchNo)||!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单当前不能解除发运关联"); row.setDispatchNo(null);move(row,"WAITING_CONSOLIDATION",operator,"解除未执行排车 "+dispatchNo,LocalDateTime.now()); } + /** 校验商业履约边界或组装真实业务引用。 */ @Transactional(propagation=Propagation.MANDATORY) public void cancelBeforeIncoming(long bookingId,String customerId,String operator,String reason) { var row=requiredBooking(bookingId);owned(row,customerId); @@ -144,19 +147,21 @@ public class FleetPoolOrderBridge { if(row.getIncomingNo()!=null||!"WAITING_INBOUND".equals(row.getFulfillmentStage()))throw conflict("池来源已登记来货,首期不支持取消或跨车改配"); move(row,"CANCELLED",operator,reason,LocalDateTime.now()); } + /** 沿真实整车发车事实推进运输状态。 */ @Transactional(propagation=Propagation.MANDATORY) public void departed(String customerId,String dispatchNo,Map sourcePackages,String operator,LocalDateTime at) { - for(var row:links.lockDispatch(customerId,dispatchNo)) { + for(var row:repository.lockDispatch(customerId,dispatchNo)) { if(row.getActualPackageCount()==null||!Objects.equals(sourcePackages.get(row.getLastlegOrderId()),row.getActualPackageCount()))throw conflict("商业单实际出库来源或完整货量不匹配"); if("IN_TRANSIT".equals(row.getFulfillmentStage()))continue; if(!"PENDING_DISPATCH".equals(row.getFulfillmentStage()))throw conflict("商业单未关联实际发运"); move(row,"IN_TRANSIT",operator,"整车实际出库 "+dispatchNo,at); } } + /** 核对全部签收证据后推进商业完成。 */ @Transactional(propagation=Propagation.MANDATORY) public void signed(SignedDelivery delivery,Map actualSourcePackages,String operator,LocalDateTime at) { if(delivery.dispatchNo()==null)return; - for(var row:links.lockDispatch(delivery.customerId(),delivery.dispatchNo())) { + for(var row:repository.lockDispatch(delivery.customerId(),delivery.dispatchNo())) { if(!actualSourcePackages.containsKey(row.getLastlegOrderId()))continue; if(row.getActualPackageCount()==null||actualSourcePackages.get(row.getLastlegOrderId())!=row.getActualPackageCount().longValue())throw conflict("商业单签收实际来源数量不完整"); if("COMPLETED".equals(row.getFulfillmentStage()))continue; @@ -165,12 +170,14 @@ public class FleetPoolOrderBridge { move(row,"COMPLETED",operator,"真实全部签收 "+delivery.deliveryNo(),at); } } + /** 校验商业履约边界或组装真实业务引用。 */ private boolean wholeSourceSigned(FleetPoolOrderLink row) { return deliveryEvidence.fullySigned(row.getCustomerId(),row.getIncomingNo(),row.getLastlegOrderId(),row.getActualPackageCount()); } + /** 批量读取预订的业务编号。 */ public Map businessOrderNumbers(Collection ids) { if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap(); - links.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result; + repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result; } static void validate(ConfirmedBooking in) { if(in==null||in.bookingId()<=0||in.businessType()==null||!Set.of("LOCAL_LOOSEBOARD","US_TRUCK").contains(in.businessType()) @@ -183,37 +190,47 @@ public class FleetPoolOrderBridge { ||("US_TRUCK".equals(in.businessType())&&(in.expectedPalletCount()==null||in.expectedPalletCount()<1))) throw conflict("池来源商业委托缺少完整预报、独立实托数或运输身份"); } + /** 在同一事务更新商业阶段与不可变历史。 */ private void move(FleetPoolOrderLink row,String stage,String operator,String reason,LocalDateTime at) { String from=row.getFulfillmentStage();if(stage.equals(from))return; - row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);links.updateById(row); + row.setFulfillmentStage(stage);row.setStageOccurredAt(at);touch(row,at);repository.updateLink(row); if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) { - var order=loose.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); - order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);loose.updateById(order); + var order=repository.lockLoose(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); + order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at);repository.updateLoose(order); } else { - var order=trucks.lockNo(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); + var order=repository.lockTruck(row.getCustomerId(),row.getBusinessOrderNo());requireOrder(order!=null); order.setStatus(stage);order.setVersion(order.getVersion()+1);order.setUpdatedAt(at); if(row.getActualPalletCount()!=null)order.setReceivedPalletCount(row.getActualPalletCount()); if(row.getActualPalletCount()!=null)order.setAllocatedPalletCount(row.getDispatchNo()==null?0:row.getActualPalletCount()); - trucks.updateById(order); + repository.updateTruck(order); } history(row,from,stage,operator,reason,at); } + /** 保存来源业务号和履约事件的不可变记录。 */ private void history(FleetPoolOrderLink row,String from,String to,String operator,String reason,LocalDateTime at) { String key="FLEET_POOL:"+row.getBookingNo()+":"+to; if("LOCAL_LOOSEBOARD".equals(row.getBusinessType())) { var h=new LocalLooseboardHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId()); - h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);looseHistory.insert(h); + h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertLooseHistory(h); } else { var h=new UsTruckOrderStatusHistory();h.setId(IdWorker.getId());h.setOrderId(row.getBusinessOrderId());h.setCustomerId(row.getCustomerId()); - h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);truckHistory.insert(h); + h.setOperationType("FLEET_FULFILLMENT");h.setFromStatus(from);h.setToStatus(to);h.setOperatorId(operator);h.setIdempotencyKey(key);h.setRemark(reason);h.setCreatedAt(at);repository.insertTruckHistory(h); } } + /** 校验商业履约边界或组装真实业务引用。 */ private String hash(Object input){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsString(input).getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} - private FleetPoolOrderLink requiredBooking(long bookingId){var row=links.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;} + /** 锁定并读取必需的商业委托。 */ + private FleetPoolOrderLink requiredBooking(long bookingId){var row=repository.lockBooking(bookingId);if(row==null)throw conflict("池来源预订缺少唯一商业订单关联");return row;} + /** 校验商业履约边界或组装真实业务引用。 */ private static void touch(FleetPoolOrderLink row,LocalDateTime at){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedAt(at);} + /** 校验商业履约边界或组装真实业务引用。 */ private static BusinessOrderRef ref(FleetPoolOrderLink row){return new BusinessOrderRef(row.getBusinessType(),row.getBusinessOrderNo());} + /** 校验商业履约边界或组装真实业务引用。 */ private static void owned(FleetPoolOrderLink row,String customer){if(!Objects.equals(row.getCustomerId(),customer))throw conflict("商业委托客户归属不一致");} + /** 校验商业履约边界或组装真实业务引用。 */ private static boolean blank(String value){return value==null||value.isBlank();} + /** 校验商业履约边界或组装真实业务引用。 */ private static void requireOrder(boolean ok){if(!ok)throw conflict("池来源商业订单不存在");} + /** 校验商业履约边界或组装真实业务引用。 */ private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderRepository.java new file mode 100644 index 000000000..b21ec7bf8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderRepository.java @@ -0,0 +1,56 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.lastleg.looseboard.entity.*; +import com.baiyue.lastleg.looseboard.mapper.*; +import com.baiyue.lastleg.trucking.entity.*; +import com.baiyue.lastleg.trucking.mapper.*; +import org.springframework.stereotype.Repository; +import java.util.List; +import java.util.Collection; + +@Repository +public class FleetPoolOrderRepository { + private final FleetPoolOrderMapper links; + private final LocalLooseboardOrderMapper loose; + private final LocalLooseboardHistoryMapper looseHistory; + private final UsTruckOrderMapper trucks; + private final UsTruckOrderStatusHistoryMapper truckHistory; + + /** 注入同库商业委托及历史 Mapper,事务由服务统一管理。 */ + public FleetPoolOrderRepository(FleetPoolOrderMapper links, LocalLooseboardOrderMapper loose, + LocalLooseboardHistoryMapper looseHistory, UsTruckOrderMapper trucks, UsTruckOrderStatusHistoryMapper truckHistory) { + this.links=links;this.loose=loose;this.looseHistory=looseHistory;this.trucks=trucks;this.truckHistory=truckHistory; + } + /** 批量读取预订关联。 */ + public List bookings(Collection ids) { return links.bookings(ids); } + /** 锁定预订关联。 */ + public FleetPoolOrderLink lockBooking(long id) { return links.lockBooking(id); } + /** 按预订业务号锁定关联。 */ + public FleetPoolOrderLink lockBookingNo(String number) { return links.lockBookingNo(number); } + /** 查询预订是否属于运力池。 */ + public int poolBooking(String number) { return links.poolBooking(number); } + /** 按客户和来货号锁定关联。 */ + public FleetPoolOrderLink lockIncoming(String customer, String number) { return links.lockIncoming(customer, number); } + /** 按客户和排车号锁定关联。 */ + public List lockDispatch(String customer, String number) { return links.lockDispatch(customer, number); } + /** 保存唯一商业关联。 */ + public void insertLink(FleetPoolOrderLink row) { links.insert(row); } + /** 更新已锁定商业关联。 */ + public void updateLink(FleetPoolOrderLink row) { links.updateById(row); } + /** 保存散板商业主单。 */ + public void insertLoose(LocalLooseboardOrder order) { loose.insert(order); } + /** 锁定同客户散板主单。 */ + public LocalLooseboardOrder lockLoose(String customer, String number) { return loose.lockNo(customer, number); } + /** 保存已锁定散板事实。 */ + public void updateLoose(LocalLooseboardOrder order) { loose.updateById(order); } + /** 保存卡车商业主单。 */ + public void insertTruck(UsTruckOrder order) { trucks.insert(order); } + /** 锁定同客户卡车主单。 */ + public UsTruckOrder lockTruck(String customer, String number) { return trucks.lockNo(customer, number); } + /** 保存已锁定卡车事实。 */ + public void updateTruck(UsTruckOrder order) { trucks.updateById(order); } + /** 保存散板履约历史。 */ + public void insertLooseHistory(LocalLooseboardHistory history) { looseHistory.insert(history); } + /** 保存卡车履约历史。 */ + public void insertTruckHistory(UsTruckOrderStatusHistory history) { truckHistory.insert(history); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java index beef08211..427f5081e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java @@ -10,49 +10,72 @@ import jakarta.validation.constraints.Size; import java.math.BigDecimal; import java.time.LocalDate; +import java.time.LocalDateTime; +import jakarta.validation.constraints.AssertTrue; +import com.fasterxml.jackson.annotation.JsonIgnore; public final class LocalLooseboardRequests { + /** 仅作为散板请求或响应模型的命名空间。 */ private LocalLooseboardRequests() { } public record Create( - @NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode, - @NotBlank @Pattern(regexp = "YES|NO") String palletRequired, - @NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType, @NotBlank @Size(max = 255) String origin, @Size(max = 500) String originAddress, @NotBlank @Size(max = 255) String destination, @Size(max = 500) String destinationAddress, - @Size(max = 32) String containerType, + @Size(max = 255) String productName, + @NotNull @Min(0) Integer estimatedPalletCount, @NotNull @Min(0) Integer packageCount, @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal budgetAmount, - @Size(min = 3, max = 3) String currency, - @Size(max = 128) String contactName, - @Size(max = 128) String contactInfo, - LocalDate requestedDate, + @Pattern(regexp = "USD|CNY") String currency, + @NotBlank @Size(max = 128) String originContactName, + @NotBlank @Size(max = 128) String originContactPhone, + @NotBlank @Size(max = 128) String destinationContactName, + @NotBlank @Size(max = 128) String destinationContactPhone, + @NotNull LocalDateTime pickupAt, + @NotNull LocalDateTime deliveryAt, @Size(max = 1000) String remark, - @NotBlank @Size(max = 255) String idempotencyKey) { } + @NotBlank @Size(max = 255) String idempotencyKey) { + /** 取送时间均须填写,送货不能早于取货。 */ + @JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间") + public boolean isScheduleValid() { + return validSchedule(pickupAt, deliveryAt); + } + } public record Update( @NotNull @Min(0) Long version, - @NotBlank @Pattern(regexp = "SHARED_TRUCK|PRIORITY_CONSOLIDATION") String transportMode, - @NotBlank @Pattern(regexp = "YES|NO") String palletRequired, - @NotBlank @Pattern(regexp = "PORT_PICKUP|WAREHOUSE_TO_WAREHOUSE|WAREHOUSE_TO_DOOR") String transportType, @NotBlank @Size(max = 255) String origin, @Size(max = 500) String originAddress, @NotBlank @Size(max = 255) String destination, @Size(max = 500) String destinationAddress, - @Size(max = 32) String containerType, + @Size(max = 255) String productName, + @NotNull @Min(0) Integer estimatedPalletCount, @NotNull @Min(0) Integer packageCount, @NotNull @DecimalMin("0.00") BigDecimal totalWeightKg, @NotNull @DecimalMin("0.00") BigDecimal totalVolumeCbm, @DecimalMin("0.00") BigDecimal budgetAmount, - @Size(min = 3, max = 3) String currency, - @Size(max = 128) String contactName, - @Size(max = 128) String contactInfo, - LocalDate requestedDate, - @Size(max = 1000) String remark) { } + @Pattern(regexp = "USD|CNY") String currency, + @NotBlank @Size(max = 128) String originContactName, + @NotBlank @Size(max = 128) String originContactPhone, + @NotBlank @Size(max = 128) String destinationContactName, + @NotBlank @Size(max = 128) String destinationContactPhone, + @NotNull LocalDateTime pickupAt, + @NotNull LocalDateTime deliveryAt, + @Size(max = 1000) String remark) { + /** 取送时间均须填写,送货不能早于取货。 */ + @JsonIgnore @AssertTrue(message = "送货时间不能早于取货时间") + public boolean isScheduleValid() { + return validSchedule(pickupAt, deliveryAt); + } + } + + /** 统一校验平台及客户新增、编辑的需求时间。 */ + public static boolean validSchedule(LocalDateTime pickup, LocalDateTime delivery) { + return pickup != null && delivery != null && !delivery.isBefore(pickup); + } public record Query( @Min(1) long page, @@ -61,7 +84,6 @@ public final class LocalLooseboardRequests { String orderNo, String routeKeyword, String status, - String transportMode, LocalDate requestedFrom, LocalDate requestedTo) { } @@ -71,7 +93,6 @@ public final class LocalLooseboardRequests { String orderNo, String routeKeyword, String status, - String transportMode, LocalDate requestedFrom, LocalDate requestedTo) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java index c822371f8..19bfe8057 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java @@ -1,19 +1,19 @@ package com.baiyue.lastleg.looseboard.dto; import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; public final class LocalLooseboardResponses { + /** 仅作为散板请求或响应模型的命名空间。 */ private LocalLooseboardResponses() { } - /** Pool orders retain null containerType/palletRequired when the inquiry did not specify these facts. */ + /** 需求与内部安排分别返回;未知联系人、商品及时间不补造。 */ public record Summary( - Long id, String customerId, String orderNo, String transportMode, String palletRequired, - String transportType, String origin, String destination, String containerType, int packageCount, + Long id, String customerId, String orderNo, String palletRequired, + String transportType, String origin, String destination, String productName, Integer estimatedPalletCount, int packageCount, BigDecimal totalWeightKg, BigDecimal totalVolumeCbm, BigDecimal budgetAmount, String currency, - String contactName, String contactInfo, LocalDate requestedDate, String status, long version, + String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String status, long version, LocalDateTime createdAt, LocalDateTime updatedAt, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement fleetArrangement) { } public record History( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java index 1017aaf3d..cba5b1ee9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java @@ -1,12 +1,13 @@ package com.baiyue.lastleg.looseboard.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.FieldStrategy; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import lombok.Data; import java.math.BigDecimal; -import java.time.LocalDate; import java.time.LocalDateTime; @Data @@ -16,24 +17,27 @@ public class LocalLooseboardOrder { private String customerId; private Long lastlegOrderId; private String orderNo; - private String transportMode; private String palletRequired; private String transportType; private String origin; - private String originAddress; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String originAddress; private String destination; - private String destinationAddress; - private String containerType; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String destinationAddress; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String productName; + private Integer estimatedPalletCount; private Integer packageCount; private BigDecimal totalWeightKg; private BigDecimal totalVolumeCbm; - private BigDecimal budgetAmount; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private BigDecimal budgetAmount; private String currency; - private String contactName; - private String contactInfo; - private LocalDate requestedDate; + private String originContactName; + private String originContactPhone; + private String destinationContactName; + private String destinationContactPhone; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime pickupAt; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private LocalDateTime deliveryAt; private String status; - private String remark; + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String remark; private String idempotencyKey; private String requestHash; private String createdBy; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java new file mode 100644 index 000000000..e62d7e8e6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.looseboard.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.looseboard.entity.*; +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import org.springframework.util.StringUtils; +import com.baiyue.lastleg.looseboard.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +@Repository +public class LocalLooseboardRepository { + @Resource private LocalLooseboardOrderMapper orders; + @Resource private LocalLooseboardHistoryMapper histories; + @Resource private LocalLooseboardOperationMapper operations; + @Resource private LastlegOrderMapper lastlegOrders; + @Resource private LastlegOrderStatusHistoryMapper lastlegHistories; + /** 按客户和创建幂等键读取订单。 */ + public LocalLooseboardOrder findKey(String customer, String key) { return orders.findKey(customer, key); } + /** 按客户和业务单号读取订单。 */ + public LocalLooseboardOrder findNo(String customer, String number) { return orders.findNo(customer, number); } + /** 锁定同客户订单保护编辑及状态推进。 */ + public LocalLooseboardOrder lockNo(String customer, String number) { return orders.lockNo(customer, number); } + /** 在客户范围内组合路线、状态与取货日期筛选并返回分页需求。 */ + public Page page(LocalLooseboardRequests.Query query) { + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId())) + .like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo())) + .and(StringUtils.hasText(query.routeKeyword()), value -> value + .like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword())) + .or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword()))) + .eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus, + StringUtils.hasText(query.status()) ? query.status().trim().toUpperCase() : null) + .ge(query.requestedFrom() != null, LocalLooseboardOrder::getPickupAt, query.requestedFrom() == null ? null : query.requestedFrom().atStartOfDay()) + .lt(query.requestedTo() != null, LocalLooseboardOrder::getPickupAt, query.requestedTo() == null ? null : query.requestedTo().plusDays(1).atStartOfDay()) + .orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); + return orders.selectPage(Page.of(query.page(), query.size()), wrapper); + } + /** 规范查询文字,缺失值不参与筛选。 */ + private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + /** 保存散板需求。 */ + public void insertOrder(LocalLooseboardOrder order) { orders.insert(order); } + /** 更新已锁定需求。 */ + public void updateOrder(LocalLooseboardOrder order) { orders.updateById(order); } + /** 读取同客户订单历史。 */ + public List histories(String customer, Long id) { return histories.listOrder(customer, id); } + /** 保存不可变需求历史。 */ + public void insertHistory(LocalLooseboardHistory history) { histories.insert(history); } + /** 读取状态命令幂等记录。 */ + public LocalLooseboardOperation findOperation(String customer, Long id, String key) { return operations.findKey(customer, id, key); } + /** 保存命令幂等记录。 */ + public void insertOperation(LocalLooseboardOperation operation) { operations.insert(operation); } + /** 保存唯一尾程来源。 */ + public void insertTailOrder(LastlegOrder order) { lastlegOrders.insert(order); } + /** 读取关联尾程来源。 */ + public LastlegOrder tailOrder(Long id) { return lastlegOrders.selectById(id); } + /** 更新关联尾程事实。 */ + public void updateTailOrder(LastlegOrder order) { lastlegOrders.updateById(order); } + /** 保存尾程状态历史。 */ + public void insertTailHistory(LastlegOrderStatusHistory history) { lastlegHistories.insert(history); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 33a90ac0c..d2d5fa6ee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -1,23 +1,17 @@ package com.baiyue.lastleg.looseboard.service; -import com.baiyue.common.core.container.ContainerSpecificationCatalog; +import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.common.core.api.PageResult; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; import com.baiyue.lastleg.looseboard.dto.LocalLooseboardResponses; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardHistory; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOperation; -import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardHistoryMapper; -import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; -import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOperationMapper; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; @@ -48,36 +42,32 @@ public class LocalLooseboardService { public static final String COMPLETED = "COMPLETED"; public static final String CANCELLED = "CANCELLED"; private static final Set STATUSES = Set.of(PENDING_ACCEPTANCE, DISPATCHED, IN_TRANSIT, COMPLETED, CANCELLED, "WAITING_INBOUND", "RECEIVING", "WAITING_CONSOLIDATION", "PENDING_DISPATCH"); - private static final Set MODES = Set.of("SHARED_TRUCK", "PRIORITY_CONSOLIDATION"); private static final Map> TRANSITIONS = Map.of( PENDING_ACCEPTANCE, Set.of(DISPATCHED, CANCELLED), DISPATCHED, Set.of(IN_TRANSIT, CANCELLED), IN_TRANSIT, Set.of(COMPLETED)); - @Resource private LocalLooseboardOrderMapper orders; - @Resource private LocalLooseboardHistoryMapper histories; - @Resource private LocalLooseboardOperationMapper operations; - @Resource private LastlegOrderMapper lastlegOrders; - @Resource private LastlegOrderStatusHistoryMapper lastlegHistories; + @Resource private LocalLooseboardRepository repository; @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @Resource private ObjectMapper objectMapper; + /** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */ @Transactional public LocalLooseboardResponses.Detail create(String customerId, String operatorId, LocalLooseboardRequests.Create request) { String key = request.idempotencyKey().trim(); String requestHash = hash(request); - LocalLooseboardOrder replay = orders.findKey(customerId, key); + LocalLooseboardOrder replay = repository.findKey(customerId, key); if (replay != null) return replay(customerId, replay, requestHash); LocalDateTime now = LocalDateTime.now(); LocalLooseboardOrder order = new LocalLooseboardOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId())); - apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), - request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), + apply(order, request.origin(), + request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), - request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark()); + request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); order.setStatus(PENDING_ACCEPTANCE); order.setIdempotencyKey(key); order.setRequestHash(requestHash); @@ -88,9 +78,9 @@ public class LocalLooseboardService { LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now); order.setLastlegOrderId(lastlegOrder.getId()); try { - orders.insert(order); + repository.insertOrder(order); } catch (DuplicateKeyException exception) { - LocalLooseboardOrder concurrent = orders.findKey(customerId, key); + LocalLooseboardOrder concurrent = repository.findKey(customerId, key); if (concurrent != null) return replay(customerId, concurrent, requestHash); throw exception; } @@ -101,44 +91,34 @@ public class LocalLooseboardService { return detail(customerId, order.getOrderNo()); } + /** 按客户、状态及取货日期分页查询需求,并批量装配承运摘要。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public PageResult page(LocalLooseboardRequests.Query query) { validateQuery(query); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(StringUtils.hasText(query.customerId()), LocalLooseboardOrder::getCustomerId, trim(query.customerId())) - .like(StringUtils.hasText(query.orderNo()), LocalLooseboardOrder::getOrderNo, trim(query.orderNo())) - .and(StringUtils.hasText(query.routeKeyword()), value -> value - .like(LocalLooseboardOrder::getOrigin, trim(query.routeKeyword())) - .or().like(LocalLooseboardOrder::getDestination, trim(query.routeKeyword()))) - .eq(StringUtils.hasText(query.status()), LocalLooseboardOrder::getStatus, - StringUtils.hasText(query.status()) ? norm(query.status()) : null) - .eq(StringUtils.hasText(query.transportMode()), LocalLooseboardOrder::getTransportMode, - StringUtils.hasText(query.transportMode()) ? norm(query.transportMode()) : null) - .ge(query.requestedFrom() != null, LocalLooseboardOrder::getRequestedDate, query.requestedFrom()) - .le(query.requestedTo() != null, LocalLooseboardOrder::getRequestedDate, query.requestedTo()) - .orderByDesc(LocalLooseboardOrder::getCreatedAt).orderByDesc(LocalLooseboardOrder::getId); - Page result = orders.selectPage(Page.of(query.page(), query.size()), wrapper); + Page result = repository.page(query); var arrangements = fleetPool.arrangements(result.getRecords().stream().map(this::poolKey).toList()); return new PageResult<>(result.getRecords().stream().map(o -> summary(o, arrangements.get(poolKey(o)))).toList(), result.getCurrent(), result.getSize(), result.getTotal(), result.getPages()); } - /** Customer-scoped query that ignores any client-supplied customer filter. */ + /** 客户查询强制使用认证客户范围,沿同一需求筛选合同分页。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public PageResult page(String customerId, LocalLooseboardRequests.MerchantQuery query) { return page(new LocalLooseboardRequests.Query(query.page(), query.size(), customerId, - query.orderNo(), query.routeKeyword(), query.status(), query.transportMode(), + query.orderNo(), query.routeKeyword(), query.status(), query.requestedFrom(), query.requestedTo())); } + /** 按客户与业务单号读取需求及历史。 */ @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) public LocalLooseboardResponses.Detail detail(String customerId, String orderNo) { - LocalLooseboardOrder order = orders.findNo(customerId, orderNo); + LocalLooseboardOrder order = repository.findNo(customerId, orderNo); if (order == null) throw notFound(); return response(order); } + /** 锁定待接单需求并校验版本,保存变更前后快照。 */ @Transactional public LocalLooseboardResponses.Detail update(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.Update request) { @@ -148,27 +128,28 @@ public class LocalLooseboardService { throw conflict("Only pending-acceptance looseboard orders can be edited"); } Map before = snapshot(order); - apply(order, request.transportMode(), request.palletRequired(), request.transportType(), request.origin(), - request.originAddress(), request.destination(), request.destinationAddress(), request.containerType(), + apply(order, request.origin(), + request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), - request.currency(), request.contactName(), request.contactInfo(), request.requestedDate(), request.remark()); + request.currency(), request.originContactName(), request.originContactPhone(), request.destinationContactName(), request.destinationContactPhone(), request.pickupAt(), request.deliveryAt(), request.remark()); Map after = snapshot(order); if (before.equals(after)) return response(order); LocalDateTime now = LocalDateTime.now(); touch(order, now); - orders.updateById(order); + repository.updateOrder(order); history(order, "UPDATE", order.getStatus(), order.getStatus(), operatorId, null, changeDetail(before, after), null, now); return response(order); } + /** 按版本和独立幂等键推进允许的状态,同时记录不可变历史。 */ @Transactional public LocalLooseboardResponses.Detail changeStatus(String customerId, String orderNo, String operatorId, LocalLooseboardRequests.ChangeStatus request) { LocalLooseboardOrder order = lock(customerId, orderNo); String key = request.idempotencyKey().trim(); String requestHash = hash(request); - LocalLooseboardOperation existing = operations.findKey(customerId, order.getId(), key); + LocalLooseboardOperation existing = repository.findOperation(customerId, order.getId(), key); if (existing != null) { if (!"STATUS".equals(existing.getOperationType()) || !requestHash.equals(existing.getRequestHash())) { throw conflict("Idempotency key was reused with a different looseboard status payload"); @@ -186,81 +167,95 @@ public class LocalLooseboardService { LocalDateTime now = LocalDateTime.now(); order.setStatus(next); touch(order, now); - orders.updateById(order); + repository.updateOrder(order); syncLastlegOrder(order, next, now); LocalLooseboardOperation operation = new LocalLooseboardOperation(); operation.setId(IdWorker.getId()); operation.setOrderId(order.getId()); operation.setCustomerId(customerId); operation.setOperationType("STATUS"); operation.setIdempotencyKey(key); operation.setRequestHash(requestHash); operation.setResultVersion(order.getVersion()); operation.setCreatedBy(operatorId); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.insertOperation(operation); history(order, "STATUS", from, next, operatorId, key, null, request.remark(), now); return response(order); } - private void apply(LocalLooseboardOrder order, String mode, String palletRequired, String transportType, + /** 保存申报货物与当地取送时间,不预设内部安排或实际收货。 */ + private void apply(LocalLooseboardOrder order, String origin, String originAddress, String destination, String destinationAddress, - String containerType, Integer packageCount, java.math.BigDecimal totalWeightKg, + String productName, Integer estimatedPalletCount, Integer packageCount, java.math.BigDecimal totalWeightKg, java.math.BigDecimal totalVolumeCbm, java.math.BigDecimal budgetAmount, String currency, - String contactName, String contactInfo, java.time.LocalDate requestedDate, String remark) { - order.setTransportMode(norm(mode)); - order.setPalletRequired(norm(palletRequired)); - order.setTransportType(norm(transportType)); + String originContactName, String originContactPhone, String destinationContactName, String destinationContactPhone, LocalDateTime pickupAt, LocalDateTime deliveryAt, String remark) { + if (!StringUtils.hasText(originContactName) || !StringUtils.hasText(originContactPhone) + || !StringUtils.hasText(destinationContactName) || !StringUtils.hasText(destinationContactPhone)) { + throw badRequest("请填写起运地和目的地联系人及联系电话"); + } + if (!LocalLooseboardRequests.validSchedule(pickupAt, deliveryAt)) { + throw badRequest("取货和送货时间必填,送货时间不能早于取货时间"); + } + order.setProductName(trim(productName)); + order.setEstimatedPalletCount(estimatedPalletCount); order.setOrigin(origin.trim()); order.setOriginAddress(trim(originAddress)); order.setDestination(destination.trim()); order.setDestinationAddress(trim(destinationAddress)); - order.setContainerType(StringUtils.hasText(containerType) - ? ContainerSpecificationCatalog.require(containerType).containerType() - : ContainerSpecificationCatalog.DEFAULT_CONTAINER_TYPE); order.setPackageCount(packageCount); order.setTotalWeightKg(totalWeightKg); order.setTotalVolumeCbm(totalVolumeCbm); order.setBudgetAmount(budgetAmount); order.setCurrency(StringUtils.hasText(currency) ? norm(currency) : "USD"); - order.setContactName(trim(contactName)); - order.setContactInfo(trim(contactInfo)); - order.setRequestedDate(requestedDate); + order.setOriginContactName(originContactName.trim()); + order.setOriginContactPhone(originContactPhone.trim()); + order.setDestinationContactName(destinationContactName.trim()); + order.setDestinationContactPhone(destinationContactPhone.trim()); + order.setPickupAt(pickupAt); + order.setDeliveryAt(deliveryAt); order.setRemark(trim(remark)); } + /** 拒绝幂等键绑定不同内容,重复请求返回原需求。 */ private LocalLooseboardResponses.Detail replay(String customerId, LocalLooseboardOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different looseboard data"); return detail(customerId, order.getOrderNo()); } + /** 锁定客户订单并拒绝池来源的普通编辑。 */ private LocalLooseboardOrder lock(String customerId, String orderNo) { - LocalLooseboardOrder order = orders.lockNo(customerId, orderNo); + LocalLooseboardOrder order = repository.lockNo(customerId, orderNo); if (order == null) throw notFound(); fleetPool.requireStandalone(customerId, "LOCAL_LOOSEBOARD", order.getId()); return order; } + /** 装配需求详情、真实承运安排与不可变历史。 */ private LocalLooseboardResponses.Detail response(LocalLooseboardOrder order) { return new LocalLooseboardResponses.Detail(summary(order), order.getOriginAddress(), order.getDestinationAddress(), order.getRemark(), order.getCreatedBy(), - histories.listOrder(order.getCustomerId(), order.getId()).stream() + repository.histories(order.getCustomerId(), order.getId()).stream() .map(value -> new LocalLooseboardResponses.History(value.getOperationType(), value.getFromStatus(), value.getToStatus(), value.getOperatorId(), value.getIdempotencyKey(), value.getChangeDetail(), value.getRemark(), value.getCreatedAt())).toList()); } + /** 构造同客户商业订单的承运查询身份。 */ private com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey poolKey(LocalLooseboardOrder order) { return new com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.OrderKey(order.getCustomerId(), "LOCAL_LOOSEBOARD", order.getId()); } + /** 装配原值需求摘要,预计托数与实际履约保持独立。 */ private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order) { return summary(order, fleetPool.arrangement(poolKey(order))); } + /** 装配原值需求摘要,预计托数与实际履约保持独立。 */ private LocalLooseboardResponses.Summary summary(LocalLooseboardOrder order, com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.Arrangement arrangement) { return new LocalLooseboardResponses.Summary(order.getId(), order.getCustomerId(), order.getOrderNo(), - order.getTransportMode(), order.getPalletRequired(), order.getTransportType(), order.getOrigin(), - order.getDestination(), order.getContainerType(), Objects.requireNonNullElse(order.getPackageCount(), 0), + order.getPalletRequired(), order.getTransportType(), order.getOrigin(), + order.getDestination(), order.getProductName(), order.getEstimatedPalletCount(), Objects.requireNonNullElse(order.getPackageCount(), 0), order.getTotalWeightKg(), order.getTotalVolumeCbm(), order.getBudgetAmount(), order.getCurrency(), - order.getContactName(), order.getContactInfo(), order.getRequestedDate(), order.getStatus(), + order.getOriginContactName(), order.getOriginContactPhone(), order.getDestinationContactName(), order.getDestinationContactPhone(), order.getPickupAt(), order.getDeliveryAt(), order.getStatus(), value(order.getVersion()), order.getCreatedAt(), order.getUpdatedAt(), arrangement); } + /** 写入带操作者和变更快照的订单历史。 */ private void history(LocalLooseboardOrder order, String type, String from, String to, String operator, String idempotencyKey, String changeDetail, String remark, LocalDateTime now) { LocalLooseboardHistory history = new LocalLooseboardHistory(); @@ -275,9 +270,10 @@ public class LocalLooseboardService { history.setChangeDetail(changeDetail); history.setRemark(trim(remark)); history.setCreatedAt(now); - histories.insert(history); + repository.insertHistory(history); } + /** 为独立下单创建唯一尾程来源,不按预计托数生成实物托盘。 */ private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) { LastlegOrder order = new LastlegOrder(); order.setId(IdWorker.getId()); @@ -302,21 +298,23 @@ public class LocalLooseboardService { order.setSourcePayloadHash("sha256:" + requestHash); order.setCreatedAt(now); order.setUpdatedAt(now); - lastlegOrders.insert(order); + repository.insertTailOrder(order); return order; } + /** 在同客户边界内同步关联尾程的商业状态。 */ private void syncLastlegOrder(LocalLooseboardOrder source, String status, LocalDateTime now) { if (source.getLastlegOrderId() == null) return; - LastlegOrder order = lastlegOrders.selectById(source.getLastlegOrderId()); + LastlegOrder order = repository.tailOrder(source.getLastlegOrderId()); if (order == null || !source.getCustomerId().equals(order.getCustomerId())) { throw conflict("Linked tail-leg order is missing"); } order.setSubOrderStatus("LOCAL_LOOSEBOARD:" + status); order.setUpdatedAt(now); - lastlegOrders.updateById(order); + repository.updateTailOrder(order); } + /** 追加尾程状态历史。 */ private void lastlegHistory(String customerId, Long orderId, String from, String to, String remark, LocalDateTime now) { LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); @@ -327,44 +325,47 @@ public class LocalLooseboardService { history.setEventId("LOCAL_LOOSEBOARD"); history.setRemark(remark); history.setCreatedAt(now); - lastlegHistories.insert(history); + repository.insertTailHistory(history); } + /** 校验状态与取货日期筛选边界。 */ private void validateQuery(LocalLooseboardRequests.Query query) { if (StringUtils.hasText(query.status()) && !STATUSES.contains(norm(query.status()))) { throw badRequest("Unsupported looseboard order status"); } - if (StringUtils.hasText(query.transportMode()) && !MODES.contains(norm(query.transportMode()))) { - throw badRequest("Unsupported looseboard transport mode"); - } if (query.requestedFrom() != null && query.requestedTo() != null && query.requestedFrom().isAfter(query.requestedTo())) { throw badRequest("requestedFrom must not be after requestedTo"); } } + /** 冻结本次编辑的业务字段用于差异审计。 */ private Map snapshot(LocalLooseboardOrder order) { Map value = new LinkedHashMap<>(); - value.put("transportMode", order.getTransportMode()); value.put("palletRequired", order.getPalletRequired()); value.put("transportType", order.getTransportType()); value.put("origin", order.getOrigin()); value.put("originAddress", order.getOriginAddress()); value.put("destination", order.getDestination()); value.put("destinationAddress", order.getDestinationAddress()); - value.put("containerType", order.getContainerType()); + value.put("productName", order.getProductName()); + value.put("estimatedPalletCount", order.getEstimatedPalletCount()); value.put("packageCount", order.getPackageCount()); value.put("totalWeightKg", order.getTotalWeightKg()); value.put("totalVolumeCbm", order.getTotalVolumeCbm()); value.put("budgetAmount", order.getBudgetAmount()); value.put("currency", order.getCurrency()); - value.put("contactName", order.getContactName()); - value.put("contactInfo", order.getContactInfo()); - value.put("requestedDate", order.getRequestedDate()); + value.put("originContactName", order.getOriginContactName()); + value.put("originContactPhone", order.getOriginContactPhone()); + value.put("destinationContactName", order.getDestinationContactName()); + value.put("destinationContactPhone", order.getDestinationContactPhone()); + value.put("pickupAt", order.getPickupAt()); + value.put("deliveryAt", order.getDeliveryAt()); value.put("remark", order.getRemark()); return value; } + /** 生成字段前后值的不可变变更快照。 */ private String changeDetail(Map before, Map after) { Map changes = new LinkedHashMap<>(); before.forEach((field, oldValue) -> { @@ -383,6 +384,7 @@ public class LocalLooseboardService { } } + /** 计算规范请求哈希以约束创建和状态命令幂等。 */ private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") @@ -392,20 +394,27 @@ public class LocalLooseboardService { } } + /** 递增锁定订单的版本并更新时间。 */ private static void touch(LocalLooseboardOrder order, LocalDateTime now) { order.setVersion(value(order.getVersion()) + 1); order.setUpdatedAt(now); } + /** 将未初始化的版本视作初始版本。 */ private static long value(Long value) { return value == null ? 0 : value; } + /** 规范选填文字,空白保存为未知。 */ private static String trim(String value) { return value == null || value.isBlank() ? null : value.trim(); } + /** 统一内部枚举和币种大小写。 */ private static String norm(String value) { return value.trim().toUpperCase(); } + /** 返回明确的输入校验失败。 */ private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } + /** 返回版本、幂等或状态冲突。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + /** 隐藏客户范围外订单并返回未找到。 */ private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "本土散板订单不存在或无权查看"); } diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 86b64ec2e..afd7a2b27 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -2865,16 +2865,17 @@ CREATE TABLE IF NOT EXISTS ll_us_truck_order_status_history ( CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', lastleg_order_id BIGINT NULL COMMENT '关联尾程主订单ID', - order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', transport_mode VARCHAR(32) NOT NULL COMMENT '运输方式:拼车或优先拼载', - pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NOT NULL COMMENT '运输类型', + order_no VARCHAR(64) NOT NULL COMMENT '本土散板订单号', + pallet_required VARCHAR(8) NULL DEFAULT NULL COMMENT '是否需要打盘:YES或NO;拼车来源未提供时为空', transport_type VARCHAR(32) NULL DEFAULT NULL COMMENT '接单运输安排;下单不预设', origin VARCHAR(255) NOT NULL COMMENT '起运地', origin_address VARCHAR(500) NULL COMMENT '起运详细地址', destination VARCHAR(255) NOT NULL COMMENT '目的地', destination_address VARCHAR(500) NULL COMMENT '目的详细地址、码头或仓号', - container_type VARCHAR(32) NULL DEFAULT NULL COMMENT '柜型;拼车来源未提供时为空,不推断为整柜', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', + product_name VARCHAR(255) NULL COMMENT '商品名称', estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货', package_count INT NOT NULL DEFAULT 0 COMMENT '货物件数', total_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '总重量(千克)', total_volume_cbm DECIMAL(18,6) NOT NULL DEFAULT 0 COMMENT '总体积(立方米)', budget_amount DECIMAL(18,2) NULL COMMENT '预算总价', currency CHAR(3) NOT NULL DEFAULT 'USD' COMMENT '预算币种', - contact_name VARCHAR(128) NULL COMMENT '联系人姓名', contact_info VARCHAR(128) NULL COMMENT '联系人联系方式', - requested_date DATE NULL COMMENT '要求服务日期', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人', origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话', + destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人', destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话', + pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)', delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)', status VARCHAR(32) NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', remark VARCHAR(1000) NULL COMMENT '备注', idempotency_key VARCHAR(255) NOT NULL COMMENT '创建幂等键', request_hash CHAR(64) NOT NULL COMMENT '创建请求内容哈希', created_by VARCHAR(128) NULL COMMENT '创建人ID', version BIGINT NOT NULL DEFAULT 0 COMMENT '乐观锁版本', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -2883,7 +2884,7 @@ CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ( UNIQUE KEY uk_ll_local_looseboard_lastleg(lastleg_order_id), UNIQUE KEY uk_ll_local_looseboard_key(customer_id,idempotency_key), KEY idx_ll_local_looseboard_list(customer_id,status,created_at), - KEY idx_ll_local_looseboard_date(customer_id,requested_date,created_at) + KEY idx_ll_local_looseboard_date(customer_id,pickup_at,created_at) ) ENGINE=InnoDB COMMENT='本土散板订单'; CREATE TABLE IF NOT EXISTS ll_local_looseboard_history ( diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index f0c18bbe1..75f671884 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1455,7 +1455,6 @@ paths: - { name: orderNo, in: query, schema: { type: string } } - { name: routeKeyword, in: query, schema: { type: string } } - { name: status, in: query, schema: { $ref: '#/components/schemas/LooseboardStatus' } } - - { name: transportMode, in: query, schema: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } } - { name: requestedFrom, in: query, schema: { type: string, format: date } } - { name: requestedTo, in: query, schema: { type: string, format: date } } responses: { '200': { description: 当前客户本土散板订单分页, content: { application/json: { schema: { $ref: '#/components/schemas/LooseboardPageResponse' } } } } } @@ -3664,25 +3663,28 @@ components: LooseboardSummary: type: object description: 散板商业主单摘要;池来源附带同客户fleetArrangement,独立订单该字段为null。池来源不创建第二张占位尾程来源。 - required: [id, customerId, orderNo, transportMode, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] + required: [id, customerId, orderNo, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, status, version, createdAt, updatedAt] properties: id: { type: string, pattern: '^[0-9]+$' } customerId: { type: string } orderNo: { type: string } - transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } palletRequired: { type: string, nullable: true, enum: [YES, NO], description: 是否委托打盘服务;池询价未提供时未知,不从实托数或托位推断YES } - transportType: { type: string } + transportType: { type: string, nullable: true } origin: { type: string } destination: { type: string } - containerType: { type: string, nullable: true, enum: [40GP, 40HQ, 45HQ], description: 已知时为平台统一柜型;池询价未提供时未知,不默认40HQ } + productName: { type: string, nullable: true, maxLength: 255 } + estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 } packageCount: { type: integer } totalWeightKg: { type: number } totalVolumeCbm: { type: number } budgetAmount: { type: number, nullable: true } currency: { type: string, nullable: true } - contactName: { type: string, nullable: true } - contactInfo: { type: string, nullable: true } - requestedDate: { type: string, format: date, nullable: true } + originContactName: { type: string, nullable: true } + originContactPhone: { type: string, nullable: true } + destinationContactName: { type: string, nullable: true } + destinationContactPhone: { type: string, nullable: true } + pickupAt: { type: string, format: date-time, nullable: true, description: 当地日期时间不带时区;按分钟输入 } + deliveryAt: { type: string, format: date-time, nullable: true, description: 当地日期时间,不能早于取货时间 } status: { $ref: '#/components/schemas/LooseboardStatus' } version: { type: integer, format: int64 } createdAt: { type: string, format: date-time } @@ -4086,47 +4088,49 @@ components: remark: { type: string, nullable: true, maxLength: 1000 } LooseboardCreate: type: object - required: [transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] + required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm, idempotencyKey] properties: - transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } - palletRequired: { type: string, enum: [YES, NO] } - transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } origin: { type: string, maxLength: 255 } originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ, maxLength: 32 } + productName: { type: string, nullable: true, maxLength: 255 } + estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } budgetAmount: { type: number, nullable: true, minimum: 0 } currency: { type: string, minLength: 3, maxLength: 3, default: USD } - contactName: { type: string, nullable: true, maxLength: 128 } - contactInfo: { type: string, nullable: true, maxLength: 128 } - requestedDate: { type: string, format: date, nullable: true } + originContactName: { type: string, minLength: 1, maxLength: 128 } + originContactPhone: { type: string, minLength: 1, maxLength: 128 } + destinationContactName: { type: string, minLength: 1, maxLength: 128 } + destinationContactPhone: { type: string, minLength: 1, maxLength: 128 } + pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 } + deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 } remark: { type: string, nullable: true, maxLength: 1000 } idempotencyKey: { type: string, maxLength: 255 } LooseboardUpdate: type: object - required: [version, transportMode, palletRequired, transportType, origin, destination, packageCount, totalWeightKg, totalVolumeCbm] + required: [originContactName, originContactPhone, destinationContactName, destinationContactPhone, pickupAt, deliveryAt, version, origin, destination, estimatedPalletCount, packageCount, totalWeightKg, totalVolumeCbm] properties: version: { type: integer, format: int64, minimum: 0 } - transportMode: { type: string, enum: [SHARED_TRUCK, PRIORITY_CONSOLIDATION] } - palletRequired: { type: string, enum: [YES, NO] } - transportType: { type: string, enum: [PORT_PICKUP, WAREHOUSE_TO_WAREHOUSE, WAREHOUSE_TO_DOOR] } origin: { type: string, maxLength: 255 } originAddress: { type: string, nullable: true, maxLength: 500 } destination: { type: string, maxLength: 255 } destinationAddress: { type: string, nullable: true, maxLength: 500 } - containerType: { type: string, enum: [40GP, 40HQ, 45HQ], maxLength: 32 } + productName: { type: string, nullable: true, maxLength: 255 } + estimatedPalletCount: { type: integer, minimum: 0, description: 申报预计托数,独立于实际收货 } packageCount: { type: integer, minimum: 0 } totalWeightKg: { type: number, minimum: 0 } totalVolumeCbm: { type: number, minimum: 0 } budgetAmount: { type: number, nullable: true, minimum: 0 } currency: { type: string, minLength: 3, maxLength: 3, default: USD } - contactName: { type: string, nullable: true, maxLength: 128 } - contactInfo: { type: string, nullable: true, maxLength: 128 } - requestedDate: { type: string, format: date, nullable: true } + originContactName: { type: string, minLength: 1, maxLength: 128 } + originContactPhone: { type: string, minLength: 1, maxLength: 128 } + destinationContactName: { type: string, minLength: 1, maxLength: 128 } + destinationContactPhone: { type: string, minLength: 1, maxLength: 128 } + pickupAt: { type: string, format: date-time, description: 当地日期时间不带时区;按分钟输入 } + deliveryAt: { type: string, format: date-time, description: 当地日期时间,不能早于取货时间 } remark: { type: string, nullable: true, maxLength: 1000 } securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index fb281fd2b..f1cd3e630 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -28,7 +28,7 @@ class FleetPoolOrderBridgeTest { FleetPoolOrderBridge service; FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder; @BeforeEach void setup(){ - service=new FleetPoolOrderBridge(links,loose,looseHistory,trucks,truckHistory,numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); + service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001"); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); @@ -86,15 +86,16 @@ class FleetPoolOrderBridgeTest { } @Test void looseboardInquiryDoesNotInventContainerOrPalletizingServiceFromForecastPallets(){ var source=input("LOCAL_LOOSEBOARD");service.confirm(source,"op"); - assertThat(looseOrder.getContainerType()).isNull();assertThat(looseOrder.getPalletRequired()).isNull(); - assertThat(looseOrder.getTransportMode()).isEqualTo("SHARED_TRUCK"); + assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount());assertThat(looseOrder.getPalletRequired()).isNull(); + assertThat(looseOrder.getOriginContactName()).isEqualTo(source.contactName()); + assertThat(looseOrder.getDestinationContactName()).isNull(); assertThat(looseOrder.getTransportType()).isEqualTo("WAREHOUSE_TO_WAREHOUSE"); assertThat(looseOrder.getOrigin()).isEqualTo(source.warehouseName());assertThat(looseOrder.getDestination()).isEqualTo(source.destinationLabel()); assertThat(looseOrder.getPackageCount()).isEqualTo(source.packageCount()); assertThat(looseOrder.getTotalWeightKg()).isEqualByComparingTo(source.weightKg());assertThat(looseOrder.getTotalVolumeCbm()).isEqualByComparingTo(source.volumeCbm()); assertThat(looseOrder.getOriginAddress()).isNull();assertThat(looseOrder.getDestinationAddress()).isNull();assertThat(looseOrder.getBudgetAmount()).isNull(); incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op"); - assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getContainerType()).isNull(); + assertThat(looseOrder.getPalletRequired()).isNull();assertThat(looseOrder.getEstimatedPalletCount()).isEqualTo(source.expectedPalletCount()); } @Test void truckInquiryPreservesUnknownVehicleWhileZeroActualCountsRemainRealUntilReceipt(){ var source=input("US_TRUCK");service.confirm(source,"op"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardPersistenceTest.java new file mode 100644 index 000000000..8bb58d9f7 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardPersistenceTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.looseboard; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.mapper.*; +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.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.UUID; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.mock; + +class LocalLooseboardPersistenceTest { + /** 使用模块基线和真实 Mapper 验证四项联系人、取送时间保存回读、更新及客户隔离。 */ + @Test + void contactsAndLocalMinuteTimesRoundTripThroughActualMapper() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + String baseline; + try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { + baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); + } + int start = baseline.indexOf("CREATE TABLE IF NOT EXISTS ll_local_looseboard_order ("); + String ddl = baseline.substring(start, baseline.indexOf(';', start)); + // H2 使用真实字段定义,去掉 MySQL 专属的索引和表选项。 + ddl = ddl.replaceAll("(?m)^\\s*(?:UNIQUE KEY|KEY) [^\\n]*\\n?", ""); + ddl = ddl.substring(0, ddl.indexOf(") ENGINE=")) + ")"; + ddl = ddl.replaceAll(",\\s*\\)$", ")"); + new JdbcTemplate(ds).execute(ddl); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(LocalLooseboardOrderMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LocalLooseboardOrderMapper.class); + var repository = new com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository(); + org.springframework.test.util.ReflectionTestUtils.setField(repository, "orders", mapper); + var order = new LocalLooseboardOrder(); + order.setId(1L); order.setCustomerId("C-1"); order.setOrderNo("TRUCK-TEST-1"); + order.setOrigin("Los Angeles"); order.setDestination("Ontario"); + order.setOriginContactName("发货人"); order.setOriginContactPhone("+1 (555) 0100"); + order.setDestinationContactName("收货人"); order.setDestinationContactPhone("+1-555-0101"); + order.setPickupAt(LocalDateTime.of(2026, 10, 20, 9, 30)); order.setDeliveryAt(LocalDateTime.of(2026, 10, 20, 15, 45)); + order.setTotalWeightKg(BigDecimal.ZERO); order.setTotalVolumeCbm(BigDecimal.ZERO); + order.setCurrency("USD"); order.setStatus("PENDING_ACCEPTANCE"); order.setIdempotencyKey("roundtrip"); order.setRequestHash("0".repeat(64)); + repository.insertOrder(order); + var saved = repository.findNo("C-1", order.getOrderNo()); + assertEquals("发货人", saved.getOriginContactName()); assertEquals("+1 (555) 0100", saved.getOriginContactPhone()); + assertEquals("收货人", saved.getDestinationContactName()); assertEquals("+1-555-0101", saved.getDestinationContactPhone()); + assertEquals(order.getPickupAt(), saved.getPickupAt()); assertEquals(order.getDeliveryAt(), saved.getDeliveryAt()); + assertNull(repository.findNo("C-2", order.getOrderNo())); assertNull(repository.findKey("C-2", "roundtrip")); + saved.setDestinationContactPhone("+1-555-0102"); saved.setDeliveryAt(saved.getDeliveryAt().plusMinutes(15)); + repository.updateOrder(saved); + var updated = repository.findKey("C-1", "roundtrip"); + assertEquals("+1-555-0102", updated.getDestinationContactPhone()); + assertEquals(LocalDateTime.of(2026, 10, 20, 16, 0), updated.getDeliveryAt()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java new file mode 100644 index 000000000..69bdddd0a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.looseboard; + +import com.baiyue.lastleg.looseboard.dto.LocalLooseboardRequests; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; +import jakarta.validation.Validation; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class LocalLooseboardRequestsTest { + /** 验证真实 Bean Validation:新增和编辑均要求四项联系人及两个时间。 */ + @Test void requiresContactsAndBothLocalTimes() throws Exception { + try (var factory = Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + var json = new ObjectMapper().findAndRegisterModules(); + ObjectNode valid = json.valueToTree(request()); + assertTrue(validator.validate(request()).isEmpty()); + for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone", "pickupAt", "deliveryAt")) { + ObjectNode missing = valid.deepCopy(); missing.remove(field); + assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Create.class)).isEmpty(), field); + missing.remove("idempotencyKey"); missing.put("version", 0); + assertFalse(validator.validate(json.treeToValue(missing, LocalLooseboardRequests.Update.class)).isEmpty(), field); + } + for (String field : List.of("originContactName", "originContactPhone", "destinationContactName", "destinationContactPhone")) { + ObjectNode blank = valid.deepCopy(); blank.put(field, " "); + assertFalse(validator.validate(json.treeToValue(blank, LocalLooseboardRequests.Create.class)).isEmpty(), field); + } + ObjectNode reversed = valid.deepCopy(); reversed.put("deliveryAt", "2030-01-02T10:29:00"); + assertFalse(validator.validate(json.treeToValue(reversed, LocalLooseboardRequests.Create.class)).isEmpty()); + } + } + + /** JSON 往返保持当地分钟,不包含已删除字段。 */ + @Test void roundTripsLocalTimesAndSplitContacts() throws Exception { + var json = new ObjectMapper().findAndRegisterModules(); + var valid = request(); var tree = json.valueToTree(valid); + var restored = json.treeToValue(tree, LocalLooseboardRequests.Create.class); + assertEquals(valid, restored); + for (String field : List.of("transportMode", "containerType", "contactName", "contactInfo", "requestedDate", "scheduleValid")) assertFalse(tree.has(field)); + } + + /** 创建具有独立预计托数和件数的最小完整需求。 */ + private LocalLooseboardRequests.Create request() { + return new LocalLooseboardRequests.Create("LA", null, "ONT", null, "货物", 3, 8, + BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "发货人", "1", "收货人", "2", + LocalDateTime.of(2030, 1, 2, 10, 30), LocalDateTime.of(2030, 1, 2, 15, 45), null, "create-1"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java index a6785bff9..bef6ae5c0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java @@ -45,7 +45,7 @@ class LocalLooseboardSchemaContractTest { } @Test - void poolUnknownContainerAndPalletizingSurviveOmittedNullInsertColumnsInBothBaselines() throws Exception { + void demandFieldsAndUnknownPalletizingRemainSeparateInBothBaselines() throws Exception { String baseline; try (var input = getClass().getResourceAsStream("/db/lastleg.sql")) { baseline = new String(input.readAllBytes(), StandardCharsets.UTF_8); @@ -53,8 +53,13 @@ class LocalLooseboardSchemaContractTest { String unified = Files.readString(Path.of("..", "deploy", "sql", "海外区域统一增量-MySQL8.sql"), StandardCharsets.UTF_8); for (String sql : java.util.List.of(baseline, unified)) { String block = tableBlock(sql.replace("CREATE TABLE IF NOT EXISTS", "CREATE TABLE"), "ll_local_looseboard_order").toLowerCase(); - assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*container_type\\s+varchar\\(32\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(), - "Unknown container must remain null when MyBatis omits its insert column"); + assertTrue(block.contains("product_name varchar(255)")); + assertTrue(block.contains("estimated_pallet_count int")); + assertTrue(block.contains("pickup_at datetime(3)")); + assertTrue(block.contains("delivery_at datetime(3)")); + for (String field : java.util.List.of("origin_contact_name", "origin_contact_phone", "destination_contact_name", "destination_contact_phone")) assertTrue(block.contains(field)); + for (String field : java.util.List.of("transport_mode", "contact_name varchar", "contact_info", "requested_date")) org.junit.jupiter.api.Assertions.assertFalse(block.matches("(?s).*[,\\n]\\s*" + field + ".*")); + org.junit.jupiter.api.Assertions.assertFalse(block.contains("container_type")); assertTrue(java.util.regex.Pattern.compile("(?:^|,)\\s*pallet_required\\s+varchar\\(8\\)\\s+(?:collate\\s+\\w+\\s+)?(?:null\\s+)?default\\s+null\\b").matcher(block).find(), "Forecast pallets must not invent YES or NO palletizing service"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java index ccd1af921..f153bae1c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java @@ -40,18 +40,15 @@ class PlatformLocalLooseboardControllerTest { @Test void updateRequiresIndependentEditPermission() { - var request = new LocalLooseboardRequests.Update(0L, "SHARED_TRUCK", "NO", "PORT_PICKUP", - "LA", null, "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, - null, "USD", null, null, null, null); + var v = create(); + var request = new LocalLooseboardRequests.Update(0L, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark()); ResponseStatusException error = assertThrows(ResponseStatusException.class, () -> controller.update(user(Set.of(OverseasPermissions.LOOSEBOARD_CREATE)), "100", "LB1", request)); assertEquals(403, error.getStatusCode().value()); } private LocalLooseboardRequests.Create create() { - return new LocalLooseboardRequests.Create("SHARED_TRUCK", "NO", "PORT_PICKUP", "LA", null, - "Ontario", null, null, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", - null, null, LocalDate.now(), null, "key-1"); + return new LocalLooseboardRequests.Create("LA", null, "Ontario", null, "货物", 1, 1, BigDecimal.ZERO, BigDecimal.ZERO, null, "USD", "A", "1", "B", "2", java.time.LocalDateTime.of(2030, 1, 2, 9, 30), java.time.LocalDateTime.of(2030, 1, 2, 15, 30), null, "key-1"); } private AuthenticatedUser user(Set permissions) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index 4b1d8a5f1..c89e42be6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -24,6 +24,7 @@ import org.springframework.web.server.ResponseStatusException; import java.math.BigDecimal; import java.time.LocalDate; +import com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository; import java.time.LocalDateTime; import java.util.List; import java.util.HashMap; @@ -54,11 +55,13 @@ class LocalLooseboardServiceTest { void setUp() { service = new LocalLooseboardService(); org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries.class)); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "histories", histories); - ReflectionTestUtils.setField(service, "operations", operations); - ReflectionTestUtils.setField(service, "lastlegOrders", lastlegOrders); - ReflectionTestUtils.setField(service, "lastlegHistories", lastlegHistories); + var repository = new LocalLooseboardRepository(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(repository, "orders", orders); + ReflectionTestUtils.setField(repository, "histories", histories); + ReflectionTestUtils.setField(repository, "operations", operations); + ReflectionTestUtils.setField(repository, "lastlegOrders", lastlegOrders); + ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories); ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong())) @@ -86,31 +89,29 @@ class LocalLooseboardServiceTest { assertEquals("100", detail.summary().customerId()); assertEquals(LocalLooseboardService.PENDING_ACCEPTANCE, detail.summary().status()); - assertEquals("SHARED_TRUCK", detail.summary().transportMode()); - assertEquals("40HQ", detail.summary().containerType()); + assertEquals("Alice", detail.summary().originContactName()); + assertEquals("Bob", detail.summary().destinationContactName()); + assertEquals("家居用品", detail.summary().productName()); + assertEquals(3, detail.summary().estimatedPalletCount()); + assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt()); verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.argThat(value -> "LOCAL_LOOSEBOARD".equals(value.getOrderType()) && value.getSourceOrderNo().equals("LB-WH-20260901-000001") && value.getInboundCode() == null + && value.getPalletCount() == 0 && "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus()))); verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> "CREATE".equals(value.getOperationType()))); } @Test - void createDefaultsTheContainerTypeAndRejectsRemovedTypes() { - AtomicReference saved = new AtomicReference<>(); - when(orders.insert(any(LocalLooseboardOrder.class))).thenAnswer(call -> { - saved.set(call.getArgument(0)); - return 1; - }); - when(orders.findNo(anyString(), anyString())).thenAnswer(call -> saved.get()); - when(histories.listOrder(anyString(), any())).thenReturn(List.of()); - - var detail = service.create("100", "7", create("default-type", null)); - - assertEquals("40HQ", detail.summary().containerType()); - assertThrows(BizException.class, () -> service.create("100", "7", create("retired-type", "40HC"))); + void validatesRequiredTimesAndChronologyWithoutInventingActualPallets() { + var pickup = LocalDateTime.of(2030, 1, 2, 10, 30); + org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null)); + org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, null)); + org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(null, null)); + org.junit.jupiter.api.Assertions.assertFalse(LocalLooseboardRequests.validSchedule(pickup, pickup.minusMinutes(1))); + org.junit.jupiter.api.Assertions.assertTrue(LocalLooseboardRequests.validSchedule(pickup, pickup.plusMinutes(1))); } @Test @@ -193,27 +194,22 @@ class LocalLooseboardServiceTest { } private LocalLooseboardRequests.Create create(String key) { - return create(key, "40HQ"); - } - - private LocalLooseboardRequests.Create create(String key, String containerType) { - return new LocalLooseboardRequests.Create("SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", - "Los Angeles", "Address A", "Ontario", "Address B", containerType, 12, - new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), - "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null, key); + return new LocalLooseboardRequests.Create("Los Angeles", "Address A", "Ontario", "Address B", + "家居用品", 3, 12, new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), + "USD", "Alice", "+1-555-0100", "Bob", "+1-555-0101", LocalDateTime.of(2030, 1, 2, 9, 30), LocalDateTime.of(2030, 1, 2, 10, 30), null, key); } private LocalLooseboardRequests.Update update(long version) { - return new LocalLooseboardRequests.Update(version, "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", - "Los Angeles", "Address A", "Ontario", "Address B", "40HQ", 12, - new BigDecimal("100.000"), new BigDecimal("2.500000"), new BigDecimal("500.00"), - "USD", "Alice", "+1-555-0100", LocalDate.now().plusDays(2), null); + var v = create("unused"); + return new LocalLooseboardRequests.Update(version, v.origin(), v.originAddress(), v.destination(), v.destinationAddress(), + v.productName(), v.estimatedPalletCount(), v.packageCount(), v.totalWeightKg(), v.totalVolumeCbm(), v.budgetAmount(), v.currency(), + v.originContactName(), v.originContactPhone(), v.destinationContactName(), v.destinationContactPhone(), v.pickupAt(), v.deliveryAt(), v.remark()); } private LocalLooseboardOrder order(String status, long version) { LocalLooseboardOrder order = new LocalLooseboardOrder(); order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); - order.setTransportMode("SHARED_TRUCK"); order.setPalletRequired("YES"); + order.setPalletRequired("YES"); order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD"); order.setStatus(status); order.setVersion(version); order.setCreatedAt(LocalDateTime.now()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java index c547cb562..0ecfb8737 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java @@ -107,7 +107,7 @@ class MerchantOverseasCustomerApiContractTest { void looseboardAndInboundQueriesUseAuthenticatedCustomerScope() { AuthenticatedUser merchant = user(101L, "MERCHANT"); LocalLooseboardRequests.MerchantQuery looseboardQuery = new LocalLooseboardRequests.MerchantQuery(1, 20, - null, null, null, null, null, null); + null, null, null, null, null); InboundPlanRequests.Query inboundQuery = new InboundPlanRequests.Query(1, 20, null, null, null, null, null, null, null, null, null, null, null, null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java index 99c59b069..b0f77f613 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java @@ -60,7 +60,7 @@ class TaillegRealConsumerAcceptanceTest { @EnableAutoConfiguration(excludeName = "com.baiyue.common.security.BaiyueSecurityAutoConfiguration") @Import({LoadingCompletedConsumer.class, LastlegOrderNoGenerator.class, DropshipOrderService.class, InboundPlanService.class, InventoryCommandService.class, RelabelApplicationService.class, - WarehouseTransferService.class, LocalLooseboardService.class}) + WarehouseTransferService.class, LocalLooseboardService.class, com.baiyue.lastleg.looseboard.repository.LocalLooseboardRepository.class}) @MapperScan({"com.baiyue.lastleg.mapper", "com.baiyue.lastleg.dropship.mapper", "com.baiyue.lastleg.inboundplan.mapper", "com.baiyue.lastleg.inboundplan.pda.mapper", "com.baiyue.lastleg.inventory.mapper", "com.baiyue.lastleg.inventory.command.mapper", "com.baiyue.lastleg.relabel.mapper", @@ -117,9 +117,9 @@ class TaillegRealConsumerAcceptanceTest { if (!exists("ll_local_looseboard_order", MARKER + "-LOOSEBOARD-KEY")) { looseboard.create(CUSTOMER, "real-acceptance", new LocalLooseboardRequests.Create( - "SHARED_TRUCK", "YES", "WAREHOUSE_TO_WAREHOUSE", "洛杉矶海外仓", "Los Angeles", - "ONT8", "FBA ONT8", "40HQ", 3, new BigDecimal("30.00"), new BigDecimal("0.40"), - new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", REQUESTED_DATE, + "洛杉矶海外仓", "Los Angeles", + "ONT8", "FBA ONT8", "验收货物", 2, 3, new BigDecimal("30.00"), new BigDecimal("0.40"), + new BigDecimal("120.00"), "USD", "验收联系人", "13800000000", "验收收货人", "13800000001", REQUESTED_DATE.atTime(9, 30), REQUESTED_DATE.atTime(15, 30), MARKER, MARKER + "-LOOSEBOARD-KEY")); } diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 2a78f8a75..9f00735d3 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -282,11 +282,11 @@ function truckPayload(context) { function looseboardPayload(context) { const suffix = context.marker.replace(/[^A-Za-z0-9]/g, '').slice(-16) return { - transportMode: 'SHARED_TRUCK', palletRequired: 'YES', transportType: 'WAREHOUSE_TO_WAREHOUSE', + productName: '散板验收货物', estimatedPalletCount: 2, origin: `散板起运-${suffix}`, originAddress: 'Los Angeles', destination: `散板目的-${suffix}`, - destinationAddress: 'Ontario', containerType: '40GP', packageCount: 3, totalWeightKg: 80, - totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', contactName: '散板验收联系人', - contactInfo: '13800000001', requestedDate: new Date(Date.now() + 8 * 86400000).toISOString().slice(0, 10), + destinationAddress: 'Ontario', packageCount: 3, totalWeightKg: 80, + totalVolumeCbm: 1.8, budgetAmount: 66, currency: 'USD', originContactName: '散板验收联系人', originContactPhone: '13800000001', + destinationContactName: '散板验收收货人', destinationContactPhone: '13800000002', pickupAt: '2026-10-20T09:30:00', deliveryAt: '2026-10-20T15:30:00', remark: context.marker, idempotencyKey: `${context.marker}-phase4-looseboard`.slice(0, 255), } } From eb167347f6ae4d3d30b096512a9170238bcdc65c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 15:19:09 +0800 Subject: [PATCH 021/113] test(transfer): prepare source snapshot before foreign relabel ownership check --- .../transfer/service/WarehouseTransferServiceTest.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index bb587c13a..d68230850 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -513,12 +513,19 @@ class WarehouseTransferServiceTest { verifyNoInteractions(transfers); } + /** 来源箱快照先通过占用校验,再单独拒绝草稿中的外部换标箱。 */ @Test void submittingOldDraftRechecksRelabelCartonOwnership() { var transfer = transfer(WarehouseTransferService.DRAFT); transfer.setOperatingWarehouseId("WH-1"); transfer.setInboundCode("IN-1"); transfer.setNeedRelabel(true); + transfer.setPackageCount(1); + var selected = new WarehouseTransferCarton(); + selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX"); + when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(selected)); + when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")) + .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null))); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); var binding = new com.baiyue.lastleg.transfer.entity.WarehouseTransferRelabelBinding(); binding.setCustomerCartonMark("FOREIGN-BOX"); From 44c76d89536fae17f4d094f48ef1dba1a16ed811 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 15:54:19 +0800 Subject: [PATCH 022/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../transfer/service/WarehouseTransferService.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index d53b25d82..46d16fc16 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -190,7 +190,9 @@ public class WarehouseTransferService { private List unreservedSourceCartons(String customerId, String warehouseId, String source, List cartons) { if (inventorySources == null) return cartons; - Set occupied = inventorySources.occupiedMarks(customerId, warehouseId, source).stream() + var occupiedMarks = inventorySources.occupiedMarks(customerId, warehouseId, source); + if (occupiedMarks == null || occupiedMarks.isEmpty()) return cartons; + Set occupied = occupiedMarks.stream() .filter(StringUtils::hasText).map(mark -> mark.trim().toUpperCase(java.util.Locale.ROOT)) .collect(Collectors.toSet()); return cartons.stream().filter(box -> !occupied.contains(box.cartonMark().trim() @@ -483,7 +485,6 @@ public class WarehouseTransferService { // 所有同源提交锁定同一入仓记录;取锁后按最新可用箱校验并保存逐箱占用快照。 if (inventorySources != null && inventorySources.lockSource(customerId, transfer.getInboundOrderId()) == null) throw conflict("库存来源已失效,请重新选择来源单"); - if (inventorySources != null) reserveSourceCartons(customerId, transfer); } if (Boolean.TRUE.equals(transfer.getNeedRelabel())) { @@ -499,6 +500,11 @@ public class WarehouseTransferService { } } + // 来源箱归属校验通过后再写入占用快照,保证错误箱号返回明确提示。 + if (!transfer.getTransferNo().equals(transfer.getInboundCode()) && inventorySources != null) { + reserveSourceCartons(customerId, transfer); + } + LastlegOrder order = requireTailOrder(customerId, transfer); LocalDateTime now = LocalDateTime.now(); transfer.setStatus(PENDING_REVIEW); From e0677e7ae7e131629f1e34adf4b119424c875b9c Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 16:41:20 +0800 Subject: [PATCH 023/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...atformDropshipPalletizationController.java | 22 +++ .../dto/DropshipPalletizationDtos.java | 9 ++ .../mapper/DropshipPalletizationMapper.java | 39 +++++ .../DropshipWorkspaceRepository.java | 39 +++++ .../service/DropshipPalletizationService.java | 32 ++++- .../openapi/dropship-palletization-v1.yaml | 20 +++ .../DropshipPalletizationServiceTest.java | 51 ++++++- .../OutboundQuantityRepository.java | 30 ++++ .../service/DeliveryTransferService.java | 4 +- .../discard/model/CargoDiscardQuantities.java | 26 ++++ .../discard/pda/dto/PdaCargoDiscardDtos.java | 3 +- .../pda/service/PdaCargoDiscardService.java | 12 +- .../CargoDiscardQuantityRepository.java | 19 +++ .../service/CargoDiscardOutboundService.java | 20 ++- .../discard/service/CargoDiscardService.java | 12 +- .../service/DeliveryTransferServiceTest.java | 17 +++ .../service/PdaCargoDiscardServiceTest.java | 22 +++ .../CargoDiscardOutboundServiceTest.java | 19 +++ .../service/CargoDiscardServiceTest.java | 29 ++++ scripts/admin-test-env-acceptance.mjs | 3 +- scripts/admin-test-env-modules/README.md | 2 + .../dropship-mixed-palletization.mjs | 134 ++++++++++++++++++ 22 files changed, 546 insertions(+), 18 deletions(-) create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipWorkspaceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java create mode 100644 scripts/admin-test-env-modules/dropship-mixed-palletization.mjs diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java index cec0a9079..d19e174bb 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformDropshipPalletizationController.java @@ -35,6 +35,28 @@ public class PlatformDropshipPalletizationController { @Resource private DropshipPalletizationService service; @Resource private InboundCartonLabelPrintService printService; + /** 跨票识别箱码只读预览,清点仍按原任务领取并提交。 */ + @GetMapping("/cartons/resolve") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse resolveWorkspaceCarton( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Positive long warehouseId, + @RequestParam @jakarta.validation.constraints.NotBlank @Size(max = 80) String boxCode) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.resolveWorkspaceCarton(warehouseId, boxCode, user.userId())); + } + + /** 获取当前实物托盘及客户组成,不要求托盘已与当前票绑定。 */ + @GetMapping("/pallets/resolve") + @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") + public ApiResponse workspacePallet( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam @Positive long warehouseId, + @RequestParam @jakarta.validation.constraints.Pattern(regexp = "DP-[0-9]{6}-[0-9]{4}") String palletNo) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.workspacePallet(warehouseId, palletNo)); + } + @GetMapping("/tasks/pending") @PreAuthorize("hasAuthority('platform:firstleg:pda:view')") public ApiResponse> pending( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java index f3aedcf35..c4a359958 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/DropshipPalletizationDtos.java @@ -18,6 +18,15 @@ public final class DropshipPalletizationDtos { public record ClaimRequest(@Positive long warehouseId) {} + /** 跨票只读识别结果;确认清点前仍须领取对应任务。 */ + public record ScanContext(Task task, Carton carton) {} + + /** 当前实物托盘及逐客户、逐票组成。 */ + public record PalletWorkspace(Pallet pallet, List members) {} + + public record PalletMember(Long taskId, Long inboundId, String inboundCode, + Long customerId, String customerName, Integer cartonCount) {} + public enum DiscrepancyAction { MATCH, ACCEPT_VARIANCE } public record CountCartonRequest(@Positive long warehouseId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java index bceb2bfca..1de5ea97e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DropshipPalletizationMapper.java @@ -15,6 +15,45 @@ import java.util.List; /** 一件代发逐箱清点、待绑托与封托事实。 */ @Mapper public interface DropshipPalletizationMapper { + /** 在指定仓库按稳定箱码识别任务,多个匹配由业务层拒绝。 */ + @Select(""" + SELECT DISTINCT t.id FROM fl_dropship_pallet_task t + JOIN fl_inbound_order o ON o.id=t.inbound_id AND o.tenant_id=t.tenant_id AND o.deleted=0 + JOIN fl_inbound_carton_label l ON l.inbound_id=t.inbound_id AND l.tenant_id=t.tenant_id + WHERE t.warehouse_id=#{warehouseId} AND l.box_code=#{boxCode} AND l.batch_status='ACTIVE' + AND o.fulfillment_type='ONE_CLICK_DROPSHIP' + """) + List workspaceTaskIds(@Param("warehouseId") long warehouseId,@Param("boxCode") String boxCode); + + /** 扫码预览为只读查询,不能把未知或已封托托盘默认成开放。 */ + @Select("SELECT * FROM fl_dropship_pallet WHERE warehouse_id=#{warehouseId} AND pallet_no=#{palletNo}") + PalletRow workspacePallet(@Param("warehouseId") long warehouseId,@Param("palletNo") String palletNo); + + /** 按实际成员汇总客户与票,空的预生成托盘不虚构成员。 */ + @Select(""" + SELECT c.task_id,c.inbound_id,o.inbound_code,c.tenant_id customer_id,o.customer_name,COUNT(*) carton_count + FROM fl_dropship_pallet_carton c + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + WHERE c.pallet_id=#{palletId} + GROUP BY c.task_id,c.inbound_id,o.inbound_code,c.tenant_id,o.customer_name + ORDER BY o.inbound_code,c.task_id + """) + List workspaceMembers(@Param("palletId") long palletId); + + /** 混托成员必须与当前票同渠道、同目的仓,客户可以不同。 */ + @Select(""" + SELECT COUNT(*) FROM fl_dropship_pallet_carton c + JOIN fl_inbound_order o ON o.id=c.inbound_id AND o.tenant_id=c.tenant_id + JOIN fl_dropship_pallet_task t ON t.id=#{taskId} + JOIN fl_inbound_order target ON target.id=t.inbound_id AND target.tenant_id=t.tenant_id + WHERE c.pallet_id=#{palletId} AND ( + NOT (o.channel_code <=> target.channel_code) + OR NOT (o.destination_type <=> target.destination_type) + OR NOT (o.destination_warehouse_id <=> target.destination_warehouse_id) + OR NOT (o.destination_code <=> target.destination_code)) + """) + int workspaceIncompatibleMembers(@Param("taskId") long taskId,@Param("palletId") long palletId); + @Insert(""" INSERT IGNORE INTO fl_dropship_pallet_task (id,tenant_id,inbound_id,warehouse_id,task_status,total_cartons,counted_cartons,allocated_cartons,discrepancy_cartons,version,created_at,updated_at) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipWorkspaceRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipWorkspaceRepository.java new file mode 100644 index 000000000..4556ea8a0 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/DropshipWorkspaceRepository.java @@ -0,0 +1,39 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.dto.DropshipPalletizationDtos.PalletMember; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper; +import com.baiyue.firstleg.mapper.DropshipPalletizationMapper.PalletRow; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 仓内跨票扫码与实物托盘组成查询,客户归属始终从箱唛事实取得。 */ +@Repository +public class DropshipWorkspaceRepository { + @Resource private DropshipPalletizationMapper mapper; + + /** 按仓库和稳定箱码查找作业任务,不接受客户端指定客户归属。 */ + public List taskIdsForBox(long warehouseId, String boxCode) { + return mapper.workspaceTaskIds(warehouseId, boxCode); + } + + /** 只读查询托盘,不因扫描托盘产生绑定或状态变化。 */ + public PalletRow pallet(long warehouseId, String palletNo) { + return mapper.workspacePallet(warehouseId, palletNo); + } + + /** 保留每票客户与任务身份,供混托核对和按原任务移箱。 */ + public List members(long palletId) { + return mapper.workspaceMembers(palletId); + } + + /** 归托前锁定实物托盘,与封托互斥。 */ + public PalletRow lockPallet(String palletNo) { + return mapper.palletByNo(palletNo); + } + + /** 按原始入仓快照检查运输兼容性,不接受客户端声明。 */ + public boolean compatible(long taskId, long palletId) { + return mapper.workspaceIncompatibleMembers(taskId, palletId) == 0; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java index 049625e77..81e88c854 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/DropshipPalletizationService.java @@ -21,6 +21,34 @@ import java.util.*; public class DropshipPalletizationService { @Resource private DropshipPalletizationMapper mapper; @Resource private FirstlegDocumentNumberGenerator documentNumbers; + @Resource private com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspaceRepository; + + /** 只读识别其他客户的箱码;不自动领取、清点或绑定托盘。 */ + public ScanContext resolveWorkspaceCarton(long warehouseId,String boxCode,long operatorId){ + String code=normalize(boxCode); + if(code==null) throw invalid("箱码不能为空"); + List ids=workspaceRepository.taskIdsForBox(warehouseId,code); + if(ids.isEmpty()) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该一件代发箱码"); + if(ids.size()!=1) throw conflict("箱码匹配多个任务,请联系后台核对"); + Task target=task(ids.get(0),warehouseId,operatorId); + Carton carton=resolveCarton(target.taskId(),warehouseId,code,operatorId); + // 未领取任务仅允许预览,员工确认后由正式领取与清点接口再次检查权限和状态。 + if("PENDING".equals(target.taskStatus()) && Boolean.TRUE.equals(target.palletSetupReady()) + && "请先领取任务".equals(carton.blockReason())){ + carton=new Carton(carton.cartonId(),carton.boxCode(),carton.inboundId(),carton.inboundCode(), + carton.skuLines(),carton.countStatus(),carton.palletId(),carton.palletNo(),true,null); + } + return new ScanContext(target,carton); + } + + /** 按仓库返回真实托盘状态及全部成员,跨客户不改变每箱的原归属。 */ + public PalletWorkspace workspacePallet(long warehouseId,String palletNo){ + String code=normalize(palletNo); + if(code==null) throw invalid("托盘码不能为空"); + PalletRow row=workspaceRepository.pallet(warehouseId,code); + if(row==null) throw new BizException(ResultCode.NOT_FOUND,"当前仓库未找到该托盘码"); + return new PalletWorkspace(palletView(row,null),workspaceRepository.members(row.getId())); + } @Transactional public void createTask(InboundOrderEntity inbound,long operatorId,String operatorName) { @@ -195,11 +223,13 @@ public class DropshipPalletizationService { .map(p->palletView(p,task.getInboundCode())).toList(); } + /** 跨客户归托仍须同仓、同渠道、同目的仓,并锁定开放托盘。 */ private PalletRow requireAssignablePallet(TaskRow task,String palletNo){ - PalletRow existing=mapper.palletByNo(palletNo); + PalletRow existing=workspaceRepository.lockPallet(palletNo); if(existing==null) throw new BizException(ResultCode.NOT_FOUND,"托盘码不存在,请扫描测量审核后生成的 DP 托盘码"); if(!Objects.equals(existing.getWarehouseId(),task.getWarehouseId())) throw conflict("托盘码不属于当前作业仓库"); if(!"OPEN".equals(existing.getPalletStatus())) throw conflict("托盘已封托,不能继续绑定箱"); + if(!workspaceRepository.compatible(task.getId(),existing.getId())) throw conflict("托内货物与当前票的渠道或目的仓不同,不能混托"); return existing; } diff --git a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml index 5ce98de85..eae296386 100644 --- a/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml +++ b/firstleg/src/main/resources/openapi/dropship-palletization-v1.yaml @@ -4,6 +4,26 @@ info: version: 3.0.0 description: 测量环节录入预计托数,审核通过后预生成全部 DP 托盘码并可合并打印。PDA 支持先扫箱或先扫托,同一国内作业仓可跨票共用开放托盘,实物装满后再封托。 paths: + /api/platform/v1/first-leg/pda-dropship/cartons/resolve: + get: + summary: 同仓跨客户识别箱码并预览原任务 + description: 只读,不领取或清点;返回 task 与 carton,确认时仍须领取原任务后按原 taskId 清点。拒绝其他作业员已领取任务及箱码歧义。 + x-required-authority: platform:firstleg:pda:view + parameters: + - {$ref: '#/components/parameters/WarehouseId'} + - {name: boxCode, in: query, required: true, schema: {type: string, minLength: 1, maxLength: 80}} + responses: + '200': {description: 原任务和箱内 SKU 预览} + /api/platform/v1/first-leg/pda-dropship/pallets/resolve: + get: + summary: 查询当前仓库真实托盘状态与逐客户逐票组成 + description: 扫描不产生归托;返回 pallet 与 members,成员保留 taskId、inboundId、inboundCode、customerId、customerName、cartonCount。不存在或跨仓的托盘拒绝,已封托返回真实状态。 + x-required-authority: platform:firstleg:pda:view + parameters: + - {$ref: '#/components/parameters/WarehouseId'} + - {name: palletNo, in: query, required: true, schema: {type: string, pattern: 'DP-[0-9]{6}-[0-9]{4}'}} + responses: + '200': {description: 实物托盘状态和成员汇总} /api/platform/v1/first-leg/pda-dropship/tasks/pending: get: summary: 查询待领取或本人已领取任务 diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java index 33b31b3e7..760c1e4c9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/DropshipPalletizationServiceTest.java @@ -24,7 +24,56 @@ class DropshipPalletizationServiceTest { private final FirstlegDocumentNumberGenerator numbers=mock(FirstlegDocumentNumberGenerator.class); private final DropshipPalletizationMapper mapper=mock(DropshipPalletizationMapper.class); private final DropshipPalletizationService service=new DropshipPalletizationService(); - DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); } + private final com.baiyue.firstleg.repository.DropshipWorkspaceRepository workspace=mock(com.baiyue.firstleg.repository.DropshipWorkspaceRepository.class); + /** 使用正式仓储入口的等价锁定查询,保留既有扫码测试的事实准备。 */ + DropshipPalletizationServiceTest(){ ReflectionTestUtils.setField(service,"mapper",mapper); ReflectionTestUtils.setField(service,"documentNumbers",numbers); ReflectionTestUtils.setField(service,"workspaceRepository",workspace); when(workspace.lockPallet(anyString())).thenAnswer(call -> mapper.palletByNo(call.getArgument(0))); when(workspace.compatible(anyLong(),anyLong())).thenReturn(true); when(mapper.labelSkus(anyLong(),anyLong())).thenAnswer(call -> java.util.List.of(labelSku())); when(mapper.cartonSkus(anyLong())).thenAnswer(call -> java.util.List.of(cartonSku())); } + + /** 跨客户预览不领取任务,不写入清点事实。 */ + @Test void workspaceResolvesPendingCustomerWithoutMutating(){ + ReflectionTestUtils.setField(service,"workspaceRepository",workspace); + TaskRow other=task(); other.setTenantId(2L); other.setTaskStatus("PENDING"); other.setAssigneeId(null); + when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L)); + when(mapper.task(10L,100L)).thenReturn(other); + when(mapper.label(10L,"BOX-1")).thenReturn(label()); + when(mapper.palletCountByInbound(2L,30L)).thenReturn(other.getExpectedPalletCount()); + var result=service.resolveWorkspaceCarton(100L," BOX-1 ",7L); + assertThat(result.task().tenantId()).isEqualTo(2L); + assertThat(result.carton().canCount()).isTrue(); + verify(mapper,never()).claim(anyLong(),anyLong(),anyLong(),anyString()); + verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt()); + } + + /** 不允许从跨票扫码接管其他作业员的任务。 */ + @Test void workspaceRejectsOtherOperatorsTaskAndAmbiguousBox(){ + ReflectionTestUtils.setField(service,"workspaceRepository",workspace); + when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L)); + TaskRow other=task(); other.setAssigneeId(9L); when(mapper.task(10L,100L)).thenReturn(other); + assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("其他人员"); + when(workspace.taskIdsForBox(100L,"BOX-1")).thenReturn(java.util.List.of(10L,11L)); + assertThatThrownBy(()->service.resolveWorkspaceCarton(100L,"BOX-1",7L)).isInstanceOf(BizException.class).hasMessageContaining("多个任务"); + } + + /** 托盘预览必须返回已封托的真实状态,跨仓不存在时拒绝。 */ + @Test void workspacePalletNeverInventsOpenState(){ + ReflectionTestUtils.setField(service,"workspaceRepository",workspace); + when(workspace.pallet(100L,"DP-260911-0001")).thenReturn(sealedPallet()); + when(workspace.members(20L)).thenReturn(java.util.List.of()); + when(mapper.cartons(20L)).thenReturn(java.util.List.of()); + assertThat(service.workspacePallet(100L,"DP-260911-0001").pallet().status()).isEqualTo("SEALED"); + assertThatThrownBy(()->service.workspacePallet(101L,"DP-260911-0001")).isInstanceOf(BizException.class).hasMessageContaining("未找到"); + } + + /** 即使不同客户允许混托,运输渠道或目的仓不同仍须拒绝。 */ + @Test void incompatibleDestinationCannotBeMixed(){ + when(mapper.task(10L,100L)).thenReturn(task()); + when(mapper.palletCountByInbound(1L,30L)).thenReturn(2); + when(mapper.label(10L,"BOX-1")).thenReturn(label()); + when(mapper.palletByNo("DP-260911-0001")).thenReturn(pallet()); + when(workspace.compatible(10L,20L)).thenReturn(false); + assertThatThrownBy(()->service.countCarton(10L,new CountCartonRequest(100L,"BOX-1",java.util.List.of(new CountSkuLine(40L,"SKU-1",12,DiscrepancyAction.MATCH,null)),"DP-260911-0001","wrong-destination"),7L,"张三")) + .isInstanceOf(BizException.class).hasMessageContaining("渠道或目的仓不同"); + verify(mapper,never()).addCounted(anyLong(),anyLong(),anyInt(),anyInt()); + } @Test void sharedPalletCountsDistinctSkuCodesWithoutMergingInboundDetails(){ when(mapper.inboundCode(1L,30L)).thenReturn("IN-1"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java new file mode 100644 index 000000000..27f9a21bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.HashSet; + +/** 出库查询按实物箱汇总,退货混装 SKU 不重复计箱。 */ +@Repository +public class OutboundQuantityRepository { + @Resource private OutboundPalletItemMapper pallets; + + /** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */ + public Integer boxes(OutboundOrder outbound) { + if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount(); + var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId()); + if (lines.isEmpty()) return outbound.getPackageCount(); + var returnBoxes = new HashSet(); + int count = 0; + for (var line : lines) { + if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) { + returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks()); + } else { + count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0)); + } + } + return Math.addExact(count, returnBoxes.size()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index a8c9e3496..2f54fb267 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -94,6 +94,7 @@ import java.util.stream.Collectors; @Service public class DeliveryTransferService { + @Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities; @Resource private DispatchOrderMapper dispatchOrders; @Resource private DispatchItemMapper dispatchItems; @Resource private OutboundOrderMapper outboundOrders; @@ -1631,7 +1632,8 @@ public class DeliveryTransferService { d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); } - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } + /** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */ + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java new file mode 100644 index 000000000..7c4e06f51 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.discard.model; + +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import java.util.HashSet; +import java.util.List; + +/** 废弃单汇总使用物理箱数,退货明细的数量仍保留为 SKU 件数。 */ +public final class CargoDiscardQuantities { + /** 数量规则不需要实例化。 */ + private CargoDiscardQuantities() { } + + /** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */ + public static int boxes(List lines, boolean actual) { + var returnBoxes = new HashSet(); + int total = 0; + for (var line : lines) { + int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages(); + if (line.getReturnCartonId() != null) { + if (quantity > 0) returnBoxes.add(line.getReturnCartonId()); + } else { + total = Math.addExact(total, quantity); + } + } + return Math.addExact(total, returnBoxes.size()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java index 24b6a37fe..7ac9e9f50 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java @@ -19,8 +19,9 @@ public final class PdaCargoDiscardDtos { List<@NotBlank String> scannedCartonMarks, Boolean confirmAllCartons) { public Confirm(List lines, List photoFileIds) { this(lines, photoFileIds, null, false); } } + /** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */ public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, - Integer actualPackages, String status) { } + Integer actualPackages, String status, String quantityUnit) { } public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, String palletNo, Long profileId, String status, List skus, String outboundNo, String scanMode, List cartonMarks) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index ac5707bfa..b17b3483e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -117,8 +117,8 @@ public class PdaCargoDiscardService { .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); if (items.listApplication(application.getCustomerId(), application.getId()).stream() .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { - int actualTotal = items.listApplication(application.getCustomerId(), application.getId()).stream() - .mapToInt(i -> i.getActualDiscardPackages() == null ? 0 : i.getActualDiscardPackages()).sum(); + int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes( + items.listApplication(application.getCustomerId(), application.getId()), true); application.setTotalPackages(actualTotal); application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal))); discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, @@ -160,16 +160,20 @@ public class PdaCargoDiscardService { } private static final String LOOSE = "散箱"; + /** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */ private List palletTasks(CargoDiscardApplication application) { var allItems = items.listApplication(application.getCustomerId(), application.getId()); var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList())); return grouped.entrySet().stream().map(entry -> { CargoDiscardItem first = entry.getValue().get(0); var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(), - i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus())).toList(); + i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(), + isReturnItem(i) ? "件" : "箱")).toList(); String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList(); - boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream().anyMatch(i -> Boolean.TRUE.equals(i.getCartonScanRequired())); + // 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 + boolean cartonScan = LOOSE.equals(entry.getKey()) || entry.getValue().stream() + .anyMatch(i -> isReturnItem(i) || Boolean.TRUE.equals(i.getCartonScanRequired())); if (cartonScan && marks.isEmpty()) throw conflict("散箱废弃任务缺少箱唛"); return new PdaCargoDiscardDtos.PalletTask(application.getDiscardNo(), application.getCustomerId(), application.getOverseasWarehouseId(), entry.getKey(), first.getInventoryPositionId() != null ? first.getInventoryPositionId() : isReturnItem(first) ? first.getReturnCartonId() : first.getProfileId(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java new file mode 100644 index 000000000..64acc82c5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.model.CargoDiscardQuantities; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; + +/** 从申请明细读取物理箱汇总,不修改历史库存或件数。 */ +@Repository +public class CargoDiscardQuantityRepository { + @Resource private CargoDiscardItemMapper items; + + /** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */ + public int boxes(CargoDiscardApplication application) { + var lines = items.listApplication(application.getCustomerId(), application.getId()); + return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index 0fc044fe6..b7cd7fc4c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -39,7 +39,7 @@ public class CargoDiscardOutboundService { return outbound == null ? null : outbound.getOutboundNo(); } - // The caller holds the application lock and owns the approval transaction. + /** 审核事务持有申请锁时创建出库投影,汇总箱数与退货件数分开保存。 */ public void create(CargoDiscardApplication application, String operatorId) { if (outbounds.findByIdempotency(application.getCustomerId(), key(application)) != null) return; var lines = items.listApplication(application.getCustomerId(), application.getId()); @@ -51,12 +51,13 @@ public class CargoDiscardOutboundService { outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE); outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); - outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(application.getTotalPackages()); + outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false)); outbound.setIdempotencyKey(key(application)); outbound.setRequestHash(key(application)); outbound.setVolumeWeightKg(BigDecimal.ZERO); outbound.setVolumeCbm(BigDecimal.ZERO); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(at); outbound.setUpdatedAt(at); var snapshots = new java.util.ArrayList(); var palletCodes = new java.util.HashSet(); + var returnBoxIds = new java.util.HashSet(); for (var line : lines) { boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType()); String palletNo; @@ -83,11 +84,12 @@ public class CargoDiscardOutboundService { } if (palletNo != null && !palletNo.isBlank()) palletCodes.add(palletNo); OutboundPalletItem snapshot = new OutboundPalletItem(); - // Stable one-to-one projection key in a separate table, never used to deduct stock. + // 出库明细与申请明细一一对应,仅作投影,不通过此表扣减库存。 snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId()); snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode()); snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks); - snapshot.setPackageCount(line.getDiscardPackages()); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK"); + snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages()); + snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK"); if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages()); snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at); snapshots.add(snapshot); @@ -96,15 +98,21 @@ public class CargoDiscardOutboundService { snapshots.forEach(pallets::insert); } + /** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */ public void sync(CargoDiscardApplication application) { OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application)); if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果"); var lines = items.listApplication(application.getCustomerId(), application.getId()); + var returnBoxIds = new java.util.HashSet(); for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) { var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst() .orElseThrow(() -> conflict("废弃出库明细关联失效")); + boolean returned = line.getReturnCartonId() != null; + int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages(); + snapshot.setPackageCount(boxes); if ("COMPLETED".equals(line.getPdaStatus())) { - snapshot.setPickedPackageCount(Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); + snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0) + : Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); snapshot.setStatus("PICKED"); } if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED"); @@ -117,7 +125,7 @@ public class CargoDiscardOutboundService { }; outbound.setStatus(status); outbound.setLxCodes(null); - if ("OUTBOUNDED".equals(status)) outbound.setPackageCount(application.getTotalPackages()); + outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status))); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index ad35cd767..dcc4c3df0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -50,6 +50,7 @@ import java.util.Set; @Service public class CargoDiscardService { + @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve"; public static final String PENDING_REVIEW = "PENDING_REVIEW"; @@ -138,6 +139,7 @@ public class CargoDiscardService { supported ? null : "当前库存缺少完整箱唛,可按全部库存废弃"); } + /** 校验并预留所选库存,物理箱汇总与退货 SKU 件数分别保留。 */ @Transactional public CargoDiscardResponses.Detail create(String customerId, String operatorId, CargoDiscardRequests.Create request) { @@ -198,7 +200,8 @@ public class CargoDiscardService { } else if (!warehouseId.equals(itemWarehouseId)) { throw conflict("One cargo discard application cannot span operating warehouses"); } - total = Math.addExact(total, input.discardPackages()); + // 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。 + if (!returnSource) total = Math.addExact(total, input.discardPackages()); } for (Long cartonId : returnCartonCache.keySet()) { @@ -209,6 +212,7 @@ public class CargoDiscardService { .map(i -> i.skuCode().trim()).collect(java.util.stream.Collectors.toSet()); if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存"); } + total = Math.addExact(total, returnCartonCache.size()); LocalDateTime now = LocalDateTime.now(); if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); CargoDiscardApplication application = new CargoDiscardApplication(); @@ -506,10 +510,12 @@ public class CargoDiscardService { return names; } + /** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */ private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map names) { + int boxes = quantities.boxes(value); return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), - value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), value.getTotalPackages(), - value.getTotalFee(), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), + value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes, + value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index d2684922e..b16a4b0f1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -134,6 +134,9 @@ class DeliveryTransferServiceTest { @BeforeEach void setUp() { service = new DeliveryTransferService(); + var outboundQuantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository(); + ReflectionTestUtils.setField(outboundQuantities, "pallets", outboundPallets); + ReflectionTestUtils.setField(service, "outboundQuantities", outboundQuantities); ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders))); var actualSources = mock(DispatchCargoSourceQuery.class); ReflectionTestUtils.setField(service, "cargoSources", actualSources); @@ -731,6 +734,20 @@ class DeliveryTransferServiceTest { assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes()); } + /** 已有废弃出库即便快照误存五件,列表及详情汇总也应显示一箱。 */ + @Test + void discardOutboundSummaryCountsReturnCartonOnceAcrossSkuLines() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "outbound-301"); + outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + var first = pallet(501L, "PALLET-1", null, null, "RT-1", 2); + first.setCartonMarks("BOX-1"); first.setReturnSkuQuantity(2L); + var second = pallet(502L, "PALLET-1", null, null, "RT-1", 3); + second.setCartonMarks("BOX-1"); second.setReturnSkuQuantity(3L); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(first, second)); + assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount()); + } + @Test void dispatchOutboundStillRequiresItsDeliveryDocument() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index 6d10762ba..f558e0df9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -189,6 +189,28 @@ class PdaCargoDiscardServiceTest { verify(photoService).confirm(same(application), eq("散箱"), eq(List.of(101L)), eq(false)); } + /** 退货一箱五件按指定箱作业,不得按五箱或整托处理。 */ + @Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() { + item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5); + var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); + carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1"); + when(returnCartons.selectById(31L)).thenReturn(carton); + when(applications.selectList(any())).thenReturn(List.of(application)); + var task = service.resolve(identity, "PALLET-1", "CD-1"); + assertEquals("CARTON", task.scanMode()); + assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks()); + assertEquals(5, task.skus().get(0).plannedPackages()); + assertEquals("件", task.skus().get(0).quantityUnit()); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5))); + verifyNoInteractions(adjustments); + var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)), + List.of(101L), List.of("RETURN-BOX-1"), false); + service.confirm(identity, "CD-1", "PALLET-1", confirm); + assertEquals(1, application.getTotalPackages()); + assertEquals(new BigDecimal("5.00"), application.getTotalFee()); + verify(adjustments).discardPalletItem(eq("CUSTOMER"), same(application), same(item), anyList(), eq(5), eq("OP"), any()); + } + @Test void partialBoundPalletOnlyExposesSelectedBoxesAndRequiresTheirScan() { looseTask(); item.setCartonScanRequired(true); var stockService = (com.baiyue.lastleg.discard.service.CargoDiscardStockService) ReflectionTestUtils.getField(service, "stockService"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java index dc5997d36..4a9d22b56 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java @@ -104,4 +104,23 @@ class CargoDiscardOutboundServiceTest { assertThrows(ResponseStatusException.class, () -> service.create(application, "OP")); verify(outbounds, never()).insert(any(OutboundOrder.class)); verify(pallets, never()).insert(any(OutboundPalletItem.class)); } + + /** 同一退货箱含多个 SKU 时箱数只记一次,件数仍逐 SKU 保留。 */ + @Test void returnCartonProjectionSeparatesOneBoxFromFivePiecesAndSyncsCompletion() { + first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); first.setDiscardPackages(2); + second.setReturnCartonId(31L); second.setSourceType("RETURN_CARTON"); second.setDiscardPackages(3); + var carton = new ReturnInventoryCarton(); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("99"); + carton.setCartonMark("BOX-31"); carton.setPalletCode("PALLET-1"); + when(returnCartons.selectById(31L)).thenReturn(carton); + service.create(application, "OP"); + assertEquals(1, document.getPackageCount()); + assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPackageCount).sum()); + assertEquals(5, snapshots.stream().mapToLong(OutboundPalletItem::getReturnSkuQuantity).sum()); + first.setPdaStatus("COMPLETED"); first.setActualDiscardPackages(2); + second.setPdaStatus("COMPLETED"); second.setActualDiscardPackages(3); + application.setStatus("PDA_COMPLETED"); + service.sync(application); + assertEquals(1, document.getPackageCount()); + assertEquals(1, snapshots.stream().mapToInt(OutboundPalletItem::getPickedPackageCount).sum()); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index 6ee27dd9f..1915af51e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -99,6 +99,9 @@ class CargoDiscardServiceTest { @BeforeEach void setUp() { service = new CargoDiscardService(); + var quantities = new com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository(); + ReflectionTestUtils.setField(quantities, "items", items); + ReflectionTestUtils.setField(service, "quantities", quantities); ReflectionTestUtils.setField(service, "warehouseDirectory", warehouseDirectory); lenient().when(warehouseDirectory.receivingWarehouses(any())).thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of( new com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient.ReceivingWarehouse(99L, "LAX", "洛杉矶仓", "US", "ACTIVE")))); @@ -227,6 +230,32 @@ class CargoDiscardServiceTest { assertEquals("SKU-RETURN", result.get(0).skus().get(0).skuCode()); } + /** 创建和已有单据查询均按退货箱去重,五件不可计为五箱。 */ + @Test void returnMixedSkuCartonCreatesOneBoxAndExistingSummaryReadsOneBox() { + var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100"); + carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("RT-1"); carton.setCartonMark("BOX-1"); + carton.setStatus("AVAILABLE"); + var skuA = new ReturnInventoryCartonItem(); skuA.setSkuCode("A"); skuA.setQuantity(2L); + var skuB = new ReturnInventoryCartonItem(); skuB.setSkuCode("B"); skuB.setQuantity(3L); + when(returnCartons.lockId(901L)).thenReturn(carton); + when(returnCartons.selectById(901L)).thenReturn(carton); + when(returnCartonItems.lockSku(901L, "A")).thenReturn(skuA); + when(returnCartonItems.lockSku(901L, "B")).thenReturn(skuB); + when(returnCartonItems.lockCarton(901L)).thenReturn(List.of(skuA, skuB)); + when(returnCartons.markDiscardPending(901L, "100")).thenReturn(1); + var detail = service.create("100", "OP", new CargoDiscardRequests.Create(List.of( + new CargoDiscardRequests.Item(null, 901L, "A", 2), + new CargoDiscardRequests.Item(null, 901L, "B", 3)), "return-five-pieces", null)); + assertEquals(1, storedApplication.get().getTotalPackages()); + assertEquals(new BigDecimal("5.00"), storedApplication.get().getTotalFee()); + assertEquals(1, detail.summary().totalPackages()); + assertEquals(5, storedItems.stream().mapToInt(CargoDiscardItem::getDiscardPackages).sum()); + storedApplication.get().setTotalPackages(5); storedApplication.get().setTotalFee(new BigDecimal("25.00")); + var existing = service.detail("100", storedApplication.get().getDiscardNo()); + assertEquals(1, existing.summary().totalPackages()); + assertEquals(new BigDecimal("5.00"), existing.summary().totalFee()); + } + @Test void approvalPushesToPdaWithoutDeductingInventory() { CargoDiscardApplication application = pendingApplication(); diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index b38392d65..c95e05789 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -11,7 +11,7 @@ const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-fol 'manual-pda-ready-fixture', 'manual-pda-ready-verify', 'manual-pda-batch-fixture', 'manual-pda-batch-verify', 'manual-loading-complete-fixture', 'manual-loading-complete-verify', - 'coload-groups', 'dropship-pallet-matrix', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification'] + 'coload-groups', 'dropship-pallet-matrix', 'dropship-mixed-palletization', 'dropship-physical-carton-chain', 'dropship-browser-fixture', 'mixed-container-receiving', 'pda-loading-link-regression', 'fleet-vehicle-care', 'fleet-driver-qualification'] const AVAILABLE_MODULES = new Set([...CONFIGURED_MODULES, ...OPTIONAL_MODULES]) const A0_LOCATION_PAGE_SIZE = 200 const A0_MAX_LOCATION_MUTATIONS = 8 @@ -61,6 +61,7 @@ const MODULE_DEPENDENCIES = new Map([ ['pda-loading-link-regression', ['merchant-qualification-fixture']], ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], ['dropship-pallet-matrix', ['merchant-qualification-fixture']], + ['dropship-mixed-palletization', ['merchant-qualification-fixture']], ['dropship-physical-carton-chain', ['merchant-qualification-fixture']], ['dropship-browser-fixture', ['merchant-qualification-fixture']], ['mixed-container-receiving', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 2fdcca5df..a98160ebb 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -172,6 +172,8 @@ export async function run(context) { `dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。 +`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。 + `dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。 `dropship-physical-carton-chain.mjs` 复用同一条正式链路,专门覆盖多 SKU 共用物理箱:20 箱×2 SKU/2 托、5 箱×3 SKU/1 托、7 箱×2 SKU/预计 2 托后合为 1 托。每个场景从新订单、测量、箱唛冻结和 PDA 清点开始,按去重箱唛核对预装、装柜、尾程计划、海外整托收货、库存候选和一件代发出库;每箱必须保留全部 SKU 明细,但任何汇总不得按 SKU 行重复累计箱数。使用 `--modules dropship-physical-carton-chain`,会保留本轮业务数据和 JSON 报告,不自动清理。 diff --git a/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs new file mode 100644 index 000000000..db9f86a2e --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs @@ -0,0 +1,134 @@ +import { run as createOrder } from './orders.mjs' +import { run as measure } from './inbound-measurement.mjs' +import { run as createCustomer } from './merchant-qualification-fixture.mjs' + +const MODULE = 'dropship-mixed-palletization' +const BASE = '/api/platform/v1/first-leg/pda-dropship' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } + +// 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。 +export async function run(context) { + const originalMarker = context.marker + const savedContext = { ...context.context } + const originalManualCount = context.options.prepareDropshipManualCount + const records = [] + const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false } + context.createdResources.dropshipMixedPalletization = result + function check(name, response, passed, details = '') { + context.assert(MODULE, name, response, passed, details, OPTIONS) + if (!passed) throw new Error(name) + } + async function command(name, method, path, body, headers = {}) { + const response = await context.request(method, path, body, undefined, headers) + const value = context.apiSuccess(MODULE, name, response, '', OPTIONS) + if (String(response?.body?.code) !== '200') throw new Error(`${name}: ${response?.body?.code}: ${response?.body?.message}`) + return value + } + async function rejected(name, method, path, body) { + const response = await context.request(method, path, body) + check(name, response, response.http === 200 && ['A0404', 'A0409'].includes(String(response.body?.code)), response.body?.message) + } + async function readTask(record) { + return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`) + } + try { + check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId)) + for (const [index, count] of [2, 3, 1].entries()) { + const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index] + context.marker = `${originalMarker}-${label}` + if (index === 1) await createCustomer(context, { publishEnvironment: false }) + for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key] + for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] + context.context.orderDestinationType = 'OVERSEAS' + context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP' + context.context.orderProfile = { taxMode: 'BUY_DECLARATION', pickupMethod: 'SELF_EXPRESS', containerMode: 'LCL' } + context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count], + totalPackageCount: count, palletCount: 1, skuCodes: [`MIX-SAME-${originalMarker}`.slice(-60), `MIX-SECOND-${originalMarker}`.slice(-60)] } + if (records.length) context.context.waitSplitRoute = { originWarehouseName: records[0].order.receivingWarehouseName, + channelCode: records[0].order.channelCode, destinationIndex: 0 } + context.options.prepareDropshipManualCount = true + const start = context.steps.length + await createOrder(context) + await measure(context) + const source = context.createdResources.dropshipManualCount + check(`${label}真实下单审核测量全部通过`, null, Boolean(source?.taskId) + && !context.steps.slice(start).some((step) => !step.passed)) + records.push({ label, order: { ...context.createdResources.orders }, measurement: { ...context.createdResources.inboundMeasurement }, source: { ...source } }) + check(`${label}实测物理箱数正确`, null, source.totalCartons === count) + } + check('两客户三票保持独立归属', null, records.length === 3 && new Set(records.map((r) => r.order.tenantId)).size === 2) + check('三票同仓同渠道同目的仓', null, records.every((r) => r.source.warehouseId === records[0].source.warehouseId + && r.order.channelCode === records[0].order.channelCode && r.order.destinationWarehouseId === records[0].order.destinationWarehouseId)) + context.marker = originalMarker + const warehouseId = records[0].source.warehouseId + const query = `warehouseId=${warehouseId}` + const generated = await command('读取第一票预生成托盘', 'GET', `${BASE}/inbound-orders/${records[0].source.inboundId}/pallets?accountId=${records[0].order.tenantId}`) + const palletNo = generated?.[0]?.palletNo + check('取得空的预生成DP托盘', null, Boolean(palletNo) && generated[0].status === 'OPEN' && generated[0].cartonCount === 0) + result.palletNo = palletNo; result.warehouseId = warehouseId + const palletPath = `${BASE}/pallets/resolve?${query}&palletNo=${encodeURIComponent(palletNo)}` + const initial = await command('先扫空托盘只读识别', 'GET', palletPath) + check('空托盘不会虚构客户组成', null, initial.pallet.cartonCount === 0 && initial.members.length === 0) + await rejected('无效托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?${query}&palletNo=DP-000000-0000`) + await rejected('跨仓托盘码被拒绝', 'GET', `${BASE}/pallets/resolve?warehouseId=1&palletNo=${encodeURIComponent(palletNo)}`) + await rejected('无效箱码被拒绝', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=NO-SUCH-${originalMarker}`) + result.stage = 'COUNTING' + const counted = [] + // 交替扫描客户 A/B,证明页面无需先手工切换入仓单。 + const scans = records.flatMap((record) => Array.from({ length: record.source.totalCartons }, (_, index) => ({ record, boxCode: `${record.source.inboundCode}#${record.source.totalCartons}-${index + 1}` }))) + scans.sort((a, b) => Number(a.boxCode.split('-').at(-1)) - Number(b.boxCode.split('-').at(-1))) + for (const [index, { record, boxCode }] of scans.entries()) { + const resolved = await command('跨客户只读扫码预览', 'GET', `${BASE}/cartons/resolve?${query}&boxCode=${encodeURIComponent(boxCode)}`) + check('箱码返回原客户和原入仓任务', null, String(resolved.task.taskId) === record.source.taskId + && String(resolved.task.tenantId) === record.order.tenantId && resolved.carton.skuLines.length === 2) + const before = await readTask(record) + check('读取箱码未改变清点和归托事实', null, before.countedCartons === counted.filter((x) => x.taskId === record.source.taskId).length) + if (before.taskStatus === 'PENDING') await command('确认时领取原任务', 'POST', `${BASE}/tasks/${record.source.taskId}/claim`, { warehouseId }) + const body = { warehouseId, boxCode, palletNo: index === 0 ? null : palletNo, + skuLines: resolved.carton.skuLines.map((line) => ({ inboundDetailId: line.inboundDetailId, skuCode: line.skuCode, + actualQuantity: line.expectedQuantity, discrepancyAction: 'MATCH' })), operationId: `${originalMarker}-count-${index}` } + const submitted = await command('按原任务确认逐箱实收', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body) + const replayed = await command('清点同键重放不重复入账', 'POST', `${BASE}/tasks/${record.source.taskId}/cartons/count`, body) + check('重放保持箱身份和清点数量', null, submitted.carton.cartonId === replayed.carton.cartonId && submitted.countedCartons === replayed.countedCartons) + counted.push({ taskId: record.source.taskId, boxCode, cartonId: submitted.carton.cartonId, inboundId: record.source.inboundId, skuLines: body.skuLines }) + if (index === 0) await command('先扫箱再扫托盘批量归托', 'POST', `${BASE}/tasks/${record.source.taskId}/pallets/bind-pending`, { warehouseId, palletNo, operationId: `${originalMarker}-bind` }) + } + const beforeSeal = await command('封托前逐客户汇总核对', 'GET', palletPath) + check('六个实物箱两客户三票汇总守恒', null, beforeSeal.pallet.cartonCount === 6 && beforeSeal.members.length === 3 + && new Set(beforeSeal.members.map((m) => String(m.customerId))).size === 2 + && beforeSeal.members.every((m) => m.cartonCount === records.find((r) => r.source.taskId === String(m.taskId))?.source.totalCartons)) + const moved = counted.find((item) => item.taskId === records[1].source.taskId) + await command('按客户B原任务移回待绑托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-move-out` }) + const movedTask = await readTask(records[1]) + check('只改变原票归托数量', null, movedTask.unboundCartons === 1 && movedTask.countedCartons === 3) + await command('客户B已点箱归回当前混托', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: palletNo, operationId: `${originalMarker}-move-back` }) + const open = await command('归回后读取权威托盘', 'GET', palletPath) + const sealPath = `${BASE}/tasks/${records[0].source.taskId}/pallets/${open.pallet.palletId}/seal` + await command('混托整托封托', 'POST', sealPath, { warehouseId, expectedVersion: open.pallet.version, operationId: `${originalMarker}-seal` }) + let sealed = await command('回查已封托状态', 'GET', palletPath) + check('托盘已封托且六箱完整', null, sealed.pallet.status === 'SEALED' && sealed.pallet.cartonCount === 6) + for (const record of records) { + record.task = await readTask(record) + const inbound = await command('封托后回查原票待分柜', 'GET', `/api/platform/inbounds/${record.source.inboundId}?tenantId=${record.order.tenantId}`) + check('参与票全部完成且进入待分柜', null, record.task.taskStatus === 'COMPLETED' && inbound.summary.processStatus === 'WAIT_SPLIT') + } + const actual = sealed.pallet.cartons + check('每箱客户归属和双SKU实收未合并', null, actual.length === counted.length && actual.every((carton) => { + const source = counted.find((item) => item.boxCode === carton.boxCode) + return source && String(carton.inboundId) === source.inboundId && carton.skuLines.length === 2 + && carton.skuLines.every((line) => source.skuLines.some((expected) => String(line.inboundDetailId) === String(expected.inboundDetailId) && line.actualQuantity === expected.actualQuantity)) + })) + await rejected('已封托禁止移箱', 'POST', `${BASE}/tasks/${moved.taskId}/cartons/${moved.cartonId}/move`, { warehouseId, targetPalletNo: null, operationId: `${originalMarker}-sealed-move` }) + await command('未入装柜计划允许解除封托', 'POST', `${BASE}/tasks/${records[0].source.taskId}/pallets/${sealed.pallet.palletId}/unseal`, { warehouseId, expectedVersion: sealed.pallet.version, operationId: `${originalMarker}-unseal` }) + for (const record of records) check('解封同步恢复所有参与任务', null, (await readTask(record)).taskStatus === 'CLAIMED') + const reopened = await command('回查解封后开放状态', 'GET', palletPath) + await command('核对后重新封托', 'POST', sealPath, { warehouseId, expectedVersion: reopened.pallet.version, operationId: `${originalMarker}-reseal` }) + sealed = await command('最终托盘事实', 'GET', palletPath) + result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted + result.stage = 'MIXED_PALLET_SEALED_VERIFIED' + for (const record of records) record.task = await readTask(record) + } finally { + context.marker = originalMarker; context.context = savedContext + context.options.prepareDropshipManualCount = originalManualCount + } +} From a563f6385714cd2839751f9f4b7cb3a7b24c9d58 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 17:02:25 +0800 Subject: [PATCH 024/113] feat(appointments): expose destination warehouse options --- .../AppointmentCargoService.java | 5 +++++ .../AppointmentPlanningController.java | 8 ++++++++ .../openapi/appointment-planning-v1.yaml | 20 +++++++++++++++++++ .../AppointmentCargoServiceTest.java | 15 ++++++++++++++ .../AppointmentPlanningAuthorizationTest.java | 14 +++++++++++++ 5 files changed, 62 insertions(+) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java index 507af0b90..08a784e18 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java @@ -35,6 +35,11 @@ public class AppointmentCargoService { return new PageResult<>(rows, page, size, total, (total + size - 1) / size); } + /** 查询下拉框与货物分页共用权威目录范围,不从当前页猜测目的仓。 */ + public List destinations() { + return scope().destinations().stream().distinct().sorted().toList(); + } + private record Scope(List origins, List destinations) { } private Scope scope() { final List catalog; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java index 752a0c99f..2d78bfbcf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java @@ -53,6 +53,14 @@ public class AppointmentPlanningController { return ApiResponse.success(new Resolved(source,result.get(0))); } + /** 目的仓选项沿用货物查询权限,只返回同范围的美国FBA仓码。 */ + @GetMapping("/cargo/destinations") + @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") + public ApiResponse> destinations(@AuthenticationPrincipal AuthenticatedUser user) { + requirePlatform(user); + return ApiResponse.success(service.destinations()); + } + @GetMapping("/cargo") @PreAuthorize("hasAuthority('platform:appointment:list') and hasAuthority('platform:overseas-warehouse:last-mile-order:list')") public ApiResponse> cargo(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml index cae7341c6..9ea4eb474 100644 --- a/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/appointment-planning-v1.yaml @@ -6,6 +6,26 @@ info: security: - bearerAuth: [] paths: + /api/platform/v1/last-leg/appointment-planning/cargo/destinations: + get: + operationId: listAppointmentCargoDestinations + summary: 读取预约货物查询的目的FBA仓选项 + description: >- + 仅平台;同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 + 与货物分页共用权威启用美国FBA目录,返回去重、按仓码排序的完整选项,不受货物当前分页或关键词限制。 + 不增加仓库主档读取权限;目录失败返回503,不当作空选项。 + responses: + '200': + description: 业务响应;data为目的FBA仓码数组 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: {type: array, items: {type: string}, example: [ABE4, ONT8]} + '403': {description: 非平台或缺少任一读取权限} + '503': {description: 权威目录不可用} /api/platform/v1/last-leg/appointment-planning/sources/resolve-order: get: operationId: resolveOrderAppointmentSource diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java index 280e1dc8a..820e9359a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java @@ -8,6 +8,21 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class AppointmentCargoServiceTest { + /** 选项取完整权威美国FBA目录,不依赖当前页货物或新增仓库主档权限。 */ + @Test void destinationOptionsUseTheSameAmericanFbaScopeWithoutQueryingCargoRows() { + var mapper = mock(AppointmentCargoMapper.class); + var warehouses = mock(AppointmentWarehouseClient.class); + when(warehouses.catalog("token")).thenReturn(ApiResponse.success(List.of( + new AppointmentWarehouseClient.Warehouse(1L,"US-WH","美国仓","OVERSEAS","US"), + new AppointmentWarehouseClient.Warehouse(2L,"ONT8","ONT8","FBA","US"), + new AppointmentWarehouseClient.Warehouse(3L,"ABE4","ABE4","FBA","US"), + new AppointmentWarehouseClient.Warehouse(4L,"YYZ1","YYZ1","FBA","CA"), + new AppointmentWarehouseClient.Warehouse(5L,"ONT8","ONT8","FBA","US")))); + assertEquals(List.of("ABE4","ONT8"),new AppointmentCargoService(mapper,warehouses,"token").destinations()); + verifyNoInteractions(mapper); + when(warehouses.catalog("token")).thenThrow(new IllegalStateException("offline")); + assertEquals(503,assertThrows(ResponseStatusException.class,()->new AppointmentCargoService(mapper,warehouses,"token").destinations()).getStatusCode().value()); + } @Test void authenticUsDirectoryScopesTheDatabaseQueryAndPreservesServerPagination() { var mapper = mock(AppointmentCargoMapper.class); var warehouses = mock(AppointmentWarehouseClient.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java index 13e21e691..da1ac5ea4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java @@ -17,6 +17,20 @@ import static org.mockito.Mockito.*; class AppointmentPlanningAuthorizationTest { private static final String LIST="platform:appointment:list", CARGO="platform:overseas-warehouse:last-mile-order:list", WRITE="platform:appointment:followup"; + /** 目的仓选项不要求仓库管理权限,但保留原两项读取权限和平台身份边界。 */ + @Test void destinationOptionsRequireTheExistingCargoReadPermissionsAndPlatformIdentity() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(AppointmentPlanningController.class); + var service=context.getBean(AppointmentCargoService.class); + login(LIST);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM"))); + login(CARGO);assertThrows(AccessDeniedException.class,()->controller.destinations(user(0L,"PLATFORM"))); + login(LIST,CARGO); + assertThrows(ResponseStatusException.class,()->controller.destinations(user(1L,"CLIENT"))); + verifyNoInteractions(service); + when(service.destinations()).thenReturn(List.of("ABE4","ONT8")); + assertEquals(List.of("ABE4","ONT8"),controller.destinations(user(0L,"PLATFORM")).data()); + } finally { SecurityContextHolder.clearContext(); } + } @Test void orderLookupUsesCanonicalBookingSourceAndEnforcesCustomerAndReadPermissions() { try(var context=new AnnotationConfigApplicationContext(Config.class)) { var controller=context.getBean(AppointmentPlanningController.class); From 920a7e083dc39807f43c8bd34f28be662e387d96 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Mon, 19 Oct 2026 17:02:59 +0800 Subject: [PATCH 025/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/admin-test-env-modules/README.md | 2 +- ...ship-mixed-palletization-contract.test.mjs | 33 +++++++++++++++++++ .../dropship-mixed-palletization.mjs | 32 +++++++++++++++++- .../orders-contract.test.mjs | 2 +- scripts/admin-test-env-modules/orders.mjs | 19 +++++++++++ 5 files changed, 85 insertions(+), 3 deletions(-) create mode 100644 scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index a98160ebb..9efc81f42 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -172,7 +172,7 @@ export async function run(context) { `dropship-browser-fixture.mjs` 通过总入口 `--modules dropship-browser-fixture` 执行,自动依赖客户资质准备;复用矩阵生产者创建一票四箱两托,完成国内装柜、船节点和按柜生成海外入库计划,明确停在 `READY_FOR_BROWSER_RECEIVING`。只准备新测试数据,不调用海外 PDA 收货或出库、不创建海外操作员;后续由浏览器页面验收。报告仍保存于 `createdResources.dropshipPalletMatrix`,准备通过不代表全流程通过,不自动删除数据。 -`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。 +`dropship-mixed-palletization.mjs` 通过总入口 `--modules dropship-mixed-palletization` 执行,自动依赖客户资质准备。经正式 HTTP 创建两客户三票一件代发(2/3/1 个物理箱,每箱两个 SKU),完成下单审核和测量,交替调用跨客户箱码预览、领取、清点、先箱后托批绑、移回/归回、六箱混托封托、解封和重新封托。核对客户/入仓单/SKU/数量守恒、只读识别无写入、同键重放、未知码/跨仓拒绝及逐票待分柜;结果保存在 `createdResources.dropshipMixedPalletization`。另外保留四票已测量、未领取未组托样本:同客户两票(2/1 箱)和跨客户两票(2/3 箱),逐票核对 Admin `WAIT_PALLETIZE` 列表可见,保存 `manualRecords` 和 `manualStage=WAIT_PALLETIZE_VISIBLE`。显式启用 `orders` 的 `orderFixtureOnly` 来源生产范围,保留审核收敛、重放与受理启动,默认订单全量验收仍覆盖申报修改;称重复用当前仓已有托盘,不要求固定编号。使用显式目标环境文件,开发机获授权时 Gateway 必须显式指向本机。会创建并保留客户、订单、测量与托盘数据,不自动清理;失败必须检查报告,重跑创建新批次。终点为 `MIXED_PALLET_SEALED_VERIFIED`,不代表装柜、海外履约或 Android 真机验收通过。离线合同为 `dropship-mixed-palletization-contract.test.mjs`。 `dropship-pallet-matrix.mjs` 验证下述四场景整托手工加入、整托移除、智能推荐、独立订舱与文件放舱、真实柜号关联、预装确认、PDA 整托拣货和五类证据上传;异步装柜完成必须等到任务 `COMPLETED` 并逐票回查 `LOADED`。智能装柜的托盘汇总与装柜详情逐票组成分别核对,不能把共托汇总行的首票编号当作全托归属。随后经正式报关、国内离仓及人工船节点接口推进至 `PICKUP_READY`,再按柜生成逐票海外入库计划,检查来源托盘完整绑定、共享托盘身份一致和同键重放。海外收货使用本轮创建并绑定接柜仓的 PDA 操作员,上传三类照片、签收、逐实物托盘收货、逐票箱数核平和完成入库,同键重放及同托新键扫码均不得重复入账。随后从本轮逐票库存候选创建一件代发专用出库,携带原始箱唛由 PDA 拣货、完成,核对终态、幂等和可用库存扣减;finally 停用本轮操作员。出库重放按业务身份和数据库毫秒时间精度核对。离线合同为 `dropship-pallet-matrix-contract.test.mjs`;脚本覆盖不等于真实 HTTP 已通过,须检查当轮报告和库存审计。 diff --git a/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs b/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs new file mode 100644 index 000000000..22b568ccc --- /dev/null +++ b/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' + +const source = readFileSync(new URL('./dropship-mixed-palletization.mjs', import.meta.url), 'utf8') +const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') + +test('mixed pallet acceptance uses formal producers and fails on any failed preparation assertion', () => { + assert.match(source, /await createOrder\(context\)/) + assert.match(source, /await measure\(context\)/) + assert.match(source, /!context.steps.slice\(start\).some\(\(step\) => !step.passed\)/) + assert.match(source, /if \(!passed\) throw new Error/) + assert.match(source, /context.context.allowAnyExistingPallet = true/) +}) + +test('retained manual fixtures cover same and different customers and remain visible and unclaimed', () => { + assert.match(source, /\[2, 1, 2, 3\].entries\(\)/) + assert.match(source, /index < 3 \? savedContext.merchantQualificationFixture : secondCustomer/) + assert.match(source, /index < 2 \? 'SAME_CUSTOMER' : 'CROSS_CUSTOMER'/) + assert.match(source, /mainStatus=WAIT_PALLETIZE&inboundCode=/) + assert.match(source, /task.taskStatus === 'PENDING' && task.countedCartons === 0 && task.boundCartons === 0/) + assert.match(source, /result.manualStage = 'WAIT_PALLETIZE_VISIBLE'/) +}) + +test('explicit order producer scope preserves approval convergence and default declaration acceptance', () => { + const branch = orders.indexOf('if (context.context.orderFixtureOnly === true)') + assert.ok(branch > orders.indexOf('const inboundResult = await waitForInbound')) + assert.ok(branch > orders.indexOf('await verifyOrderApprovalReplay(context')) + assert.ok(branch < orders.indexOf('const editableDetailResponse')) + assert.match(orders.slice(branch, orders.indexOf('const editableDetailResponse')), /await finishOrderLifecycle\(context/) + assert.match(orders, /申报修改保持订单号入仓码和渠道且版本递增/) + assert.match(orders, /申报修改异步同步到Firstleg入库事实/) +}) diff --git a/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs index db9f86a2e..a732f79de 100644 --- a/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs +++ b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs @@ -12,6 +12,7 @@ export async function run(context) { const savedContext = { ...context.context } const originalManualCount = context.options.prepareDropshipManualCount const records = [] + let secondCustomer = null const result = { marker: originalMarker, stage: 'PREPARING', records, fullFlowVerified: false } context.createdResources.dropshipMixedPalletization = result function check(name, response, passed, details = '') { @@ -32,11 +33,13 @@ export async function run(context) { return command(`${record.label}回查任务`, 'GET', `${BASE}/tasks/${record.source.taskId}?warehouseId=${record.source.warehouseId}`) } try { + context.context.orderFixtureOnly = true + context.context.allowAnyExistingPallet = true check('存在本轮已审核客户', null, Boolean(context.context.merchantQualificationFixture?.tenantId)) for (const [index, count] of [2, 3, 1].entries()) { const label = ['A-2BOX', 'B-3BOX', 'B-1BOX'][index] context.marker = `${originalMarker}-${label}` - if (index === 1) await createCustomer(context, { publishEnvironment: false }) + if (index === 1) { await createCustomer(context, { publishEnvironment: false }); secondCustomer = context.context.merchantQualificationFixture } for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key] for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] context.context.orderDestinationType = 'OVERSEAS' @@ -127,6 +130,33 @@ export async function run(context) { result.pallet = sealed.pallet; result.members = sealed.members; result.counted = counted result.stage = 'MIXED_PALLET_SEALED_VERIFIED' for (const record of records) record.task = await readTask(record) + // 保留同客户两票和跨客户两票独立样本,均停在未领取、未组托状态。 + result.manualRecords = [] + for (const [index, count] of [2, 1, 2, 3].entries()) { + context.marker = `${originalMarker}-MANUAL-${index + 1}` + context.context.merchantQualificationFixture = index < 3 ? savedContext.merchantQualificationFixture : secondCustomer + for (const key of ['orders', 'inboundMeasurement', 'dropshipManualCount']) delete context.createdResources[key] + for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) delete context.context[key] + context.context.orderDestinationType = 'OVERSEAS' + context.context.orderFulfillmentType = 'ONE_CLICK_DROPSHIP' + context.context.waitSplitVariant = { skuCount: 2, packageCountPerSku: count, packageCounts: [count, count], + totalPackageCount: count, palletCount: 1, skuCodes: [`MANUAL-COMMON-${originalMarker}`.slice(-60), `MANUAL-SECOND-${originalMarker}`.slice(-60)] } + context.options.prepareDropshipManualCount = true + const start = context.steps.length + await createOrder(context) + await measure(context) + const source = context.createdResources.dropshipManualCount + check('人工打托样本正式下单测量通过', null, Boolean(source?.taskId) && !context.steps.slice(start).some((step) => !step.passed)) + const order = { ...context.createdResources.orders } + const list = await command('回查一件代发打托列表可见', 'GET', `/api/platform/v1/orders?mainStatus=WAIT_PALLETIZE&inboundCode=${encodeURIComponent(source.inboundCode)}&page=1&size=20`) + const row = list.records?.find((item) => String(item.orderId) === order.orderId) + check('人工样本出现在用户的一件代发打托页签', null, Boolean(row) && row.mainStatus === 'WAIT_PALLETIZE') + const task = await readTask({ label: `MANUAL-${index + 1}`, source }) + check('人工样本未领取未清点未归托', null, task.taskStatus === 'PENDING' && task.countedCartons === 0 && task.boundCartons === 0) + result.manualRecords.push({ label: `MANUAL-${index + 1}`, group: index < 2 ? 'SAME_CUSTOMER' : 'CROSS_CUSTOMER', + order, source: { ...source }, measurement: { ...context.createdResources.inboundMeasurement } }) + } + result.manualStage = 'WAIT_PALLETIZE_VISIBLE' } finally { context.marker = originalMarker; context.context = savedContext context.options.prepareDropshipManualCount = originalManualCount diff --git a/scripts/admin-test-env-modules/orders-contract.test.mjs b/scripts/admin-test-env-modules/orders-contract.test.mjs index 00f019527..597b210df 100644 --- a/scripts/admin-test-env-modules/orders-contract.test.mjs +++ b/scripts/admin-test-env-modules/orders-contract.test.mjs @@ -57,7 +57,7 @@ for (const [status, history] of [['PROCESSING', ['APPROVED', 'ACCEPTED', 'PROCES test('approved stop runs after full approval replay, inbound readiness and shared measurement handoff', () => { const source = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') const run = source.slice(source.indexOf('export async function run(')) - const finish = run.indexOf('await finishOrderLifecycle(') + const finish = run.lastIndexOf('await finishOrderLifecycle(') for (const fragment of ['await verifyOrderApprovalReplay(', '隔离入库单 marker 来源与可测量状态正确', 'context.createdResources.orders.inboundId = inboundId', 'context.context.createdInbound =', '履约任务与本轮订单和客户一致']) { assert.ok(run.indexOf(fragment) >= 0 && run.indexOf(fragment) < finish, fragment) diff --git a/scripts/admin-test-env-modules/orders.mjs b/scripts/admin-test-env-modules/orders.mjs index a727e8dd2..06f97550a 100644 --- a/scripts/admin-test-env-modules/orders.mjs +++ b/scripts/admin-test-env-modules/orders.mjs @@ -1110,6 +1110,25 @@ export async function run(context) { await verifyOrderApprovalReplay(context, { tenantId, orderId, inboundId, inboundCode: id(inboundResult.detail?.summary?.inboundCode) }) + // 专项造单仅生产已审核的真实入库来源;默认全量验收仍继续验证申报修改。 + if (context.context.orderFixtureOnly === true) { + const inbound = inboundResult.detail + Object.assign(context.createdResources.orders, { + inboundId, inboundCode: inbound.summary.inboundCode, + customerRemark: inbound.publicRemark, + internalRemark: `${context.marker} Admin 订单链路验收`, + cargoRemark: payload.cargoes?.[0]?.cargoRemark || null, + processStatus: inbound.summary.processStatus, taskId: id(inboundResult.task.id), + fixtureOnly: true, + }) + const source = { tenantId, inboundId, marker: context.marker } + context.createdResources.ocrInbound = source + context.createdResources.testInbound = source + for (const key of ['inboundWeightOcr', 'ocrInbound', 'testInbound', 'createdInbound']) context.context[key] = source + await finishOrderLifecycle(context, { tenantId, orderId, firstLegDetailPath }) + return + } + const editableDetailResponse = await context.request('GET', firstLegDetailPath) const editableDetail = context.apiSuccess(MODULE, 'Admin 读取审核后客户申报资料', editableDetailResponse) const declarationDetail = editableDetail?.order?.order ? editableDetail.order : editableDetail From 5368da03d823dd0117b7e2505d89f79041b24125 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 17:39:54 +0800 Subject: [PATCH 026/113] feat(order): accept optional courier tracking on customer drafts --- .../com/baiyue/order/dto/OrderRequests.java | 12 +++-- .../service/OrderApplicationService.java | 5 ++ .../PlatformLegacyOrderImportService.java | 2 +- .../src/main/resources/openapi/order-v1.yaml | 1 + .../MerchantOrderCommandBindingTest.java | 18 +++++++ .../OrderApplicationServiceStateTest.java | 52 +++++++++++++++++++ 6 files changed, 84 insertions(+), 6 deletions(-) diff --git a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java index 992ececd6..9be3cb9fd 100644 --- a/order/src/main/java/com/baiyue/order/dto/OrderRequests.java +++ b/order/src/main/java/com/baiyue/order/dto/OrderRequests.java @@ -81,7 +81,9 @@ public final class OrderRequests { @Schema(description = "Selected customer pickup address ID; required for PICKUP / 已选客户取货地址 ID;PICKUP 时必填") @Min(1) Long senderAddressId, @Schema(description = "Selected private destination address ID; required for PRIVATE / 已选私人目的仓地址 ID;PRIVATE 时必填") - @Min(1) Long privateDestinationAddressId + @Min(1) Long privateDestinationAddressId, + @Schema(description = "Optional customer courier tracking number for SELF_EXPRESS / 快递自提选填快递单号;其他取货方式不保存", maxLength = 128) + @Size(max = 128) String expressTrackingNo ) { public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, String destinationCountryCode, Long destinationWarehouseId, @@ -98,7 +100,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, inboundCode, fulfillmentType, null, null, null); + shipmentId, referenceId, inboundCode, fulfillmentType, null, null, null, null); } public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, @@ -116,7 +118,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, inboundCode, null, null, null, null); + shipmentId, referenceId, inboundCode, null, null, null, null, null); } public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, @@ -133,7 +135,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, requestedPickupAt, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, null, null, null, null, null); + shipmentId, referenceId, null, null, null, null, null, null); } public SaveRequest(Long customerId, String customerName, String transportMode, String tradeTerm, @@ -150,7 +152,7 @@ public final class OrderRequests { sender, receiver, cargoes, pickupMethod, null, receivingWarehouseId, receivingWarehouseName, channelCode, channelName, carrierId, carrierName, taxMode, pricingTaxMode, containerMode, customsInspection, destinationType, amazonWarehouseCode, - shipmentId, referenceId, null, null, null, null, null); + shipmentId, referenceId, null, null, null, null, null, null); } @AssertTrue(message = "承运商 ID 和名称必须同时填写或同时留空") diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 13c7dc282..39ccc4a2e 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -642,6 +642,7 @@ public class OrderApplicationService { default -> false; }; } + // 保存客户下单资料;快递单号仅属于快递自提,切换方式时清空避免误带。 private void apply(OrderEntity order, long tenantId, OrderRequests.SaveRequest request, OrderMasterDataGuard.Snapshots snapshots, AddressSnapshots addresses, boolean allowLegacyInboundCode) { @@ -662,6 +663,10 @@ public class OrderApplicationService { order.setExpectedArrivalAt(request.expectedShipDate() == null ? null : request.expectedShipDate().atStartOfDay()); order.setCustomerRemark(request.customerRemark()); order.setPickupMethod(request.pickupMethod()); + if (order.getStatus() == OrderStatus.DRAFT || order.getStatus() == OrderStatus.REJECTED) { + order.setExpressTrackingNo("SELF_EXPRESS".equals(request.pickupMethod()) + ? normalizeRemark(request.expressTrackingNo()) : null); + } order.setRequestedPickupAt("PICKUP".equals(request.pickupMethod()) ? request.requestedPickupAt() : null); order.setReceivingWarehouseId(request.receivingWarehouseId()); order.setReceivingWarehouseName(snapshots.receiving().warehouse().warehouseName()); diff --git a/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java b/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java index 510c75797..a3626c0ae 100644 --- a/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java +++ b/order/src/main/java/com/baiyue/order/service/PlatformLegacyOrderImportService.java @@ -162,7 +162,7 @@ public class PlatformLegacyOrderImportService { "买单".equals(required(row, 5, "买单/退税")) ? "BUY_DECLARATION" : "TAX_REFUND", channel.channelName().contains("包税") && !channel.channelName().contains("不包税") ? "TAX_INCLUDED" : "TAX_EXCLUDED", "LCL", false, destinationType, amazonCode, shipmentId, referenceId, inboundCode, fulfillmentType, - "ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null); + "ONE_CLICK_DROPSHIP".equals(fulfillmentType) ? referenceId : null, null, null, null); } private Customer fixedCustomer() { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index c7ed83a02..ea2281f74 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -1451,6 +1451,7 @@ components: pricingTaxMode: {$ref: '#/components/schemas/PricingTaxMode'} senderAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PICKUP 时必填;必须是本客户 PICKUP 地址} privateDestinationAddressId: {type: string, pattern: '^[1-9][0-9]*$', description: PRIVATE 时必填;必须是本客户 PRIVATE_DESTINATION 地址} + expressTrackingNo: {type: string, nullable: true, maxLength: 128, description: 快递自提(SELF_EXPRESS)时选填;留空清空单号,其他取货方式不保存。客户仅能在下单或编辑草稿时填写} customsInspection: {type: boolean, nullable: true, description: 非木制品可空;任一货物 woodenProduct=true 时必须为 true} cargoes: type: array diff --git a/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java b/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java index 2473d5bef..ad2340653 100644 --- a/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java +++ b/order/src/test/java/com/baiyue/order/controller/MerchantOrderCommandBindingTest.java @@ -52,6 +52,24 @@ class MerchantOrderCommandBindingTest { }).build(); } + /** 客户创建入口绑定选填单号,并拒绝超过持久化长度的输入。 */ + @Test + void courierTrackingIsOptionalAndLengthBoundedOnCreate() throws Exception { + String createPath = "/api/merchant/v1/orders/first-leg"; + for (String tracking : new String[] {null, "", "SF123456789"}) { + String body = tracking == null ? DRAFT : DRAFT.replace("\"customerName\"", "\"expressTrackingNo\":\"" + tracking + "\",\"customerName\""); + mvc.perform(post(createPath).header("Idempotency-Key", "courier-create").contentType(MediaType.APPLICATION_JSON).content(body)) + .andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200")); + verify(service).createDraft(eq(11L), eq(7L), eq("operator"), + argThat(request -> java.util.Objects.equals(tracking, request.expressTrackingNo())), eq("courier-create")); + } + clearInvocations(service); + String invalid = DRAFT.replace("\"customerName\"", "\"expressTrackingNo\":\"" + "A".repeat(129) + "\",\"customerName\""); + mvc.perform(post(createPath).header("Idempotency-Key", "courier-create").contentType(MediaType.APPLICATION_JSON).content(invalid)) + .andExpect(status().isBadRequest()); + verifyNoInteractions(service); + } + @Test void platformEditRequiresVersionEnvelopeAndKey() throws Exception { String path = "/api/platform/v1/orders/first-leg/1?tenantId=11"; diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index 2de0dceaa..d926c26fb 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -111,6 +111,58 @@ class OrderApplicationServiceStateTest { && order.getInboundCode() == null && "STANDARD".equals(order.getFulfillmentType()))); } + /** 快递草稿支持填写或留空单号,非快递方式不能携带单号。 */ + @Test + void courierDraftPersistsOptionalTrackingNumber() { + for (String pickupMethod : List.of("SELF_EXPRESS", "SELF_TRUCK")) { + for (String tracking : List.of(" SF123456789 ", "", " ")) { + var json = new ObjectMapper().valueToTree(request(null, pickupMethod, null, null, "OVERSEAS")); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).put("expressTrackingNo", tracking); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).remove("carrierSnapshotComplete"); + var draft = new ObjectMapper().convertValue(json, OrderRequests.SaveRequest.class); + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + when(masterData.validate(10001L, draft)).thenReturn(snapshots()); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + + service(repository, mock(OrderEligibilityGuard.class), masterData) + .createDraft(10001L, 2L, draft, "courier-draft"); + + String expected = "SELF_EXPRESS".equals(pickupMethod) && !tracking.isBlank() + ? tracking.trim() : null; + verify(repository).insertOrder(argThat(saved -> java.util.Objects.equals(expected, saved.getExpressTrackingNo()))); + } + } + } + + /** 修改草稿允许更换、清空单号,切换交货方式后不保留旧单号。 */ + @Test + void editingCourierDraftReplacesOrClearsTrackingNumber() { + for (String pickupMethod : List.of("SELF_EXPRESS", "SELF_TRUCK")) { + for (String tracking : List.of(" SF-NEW ", "")) { + var json = new ObjectMapper().valueToTree(request(null, pickupMethod, null, null, "OVERSEAS")); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).put("expressTrackingNo", tracking); + ((com.fasterxml.jackson.databind.node.ObjectNode) json).remove("carrierSnapshotComplete"); + var draft = new ObjectMapper().convertValue(json, OrderRequests.SaveRequest.class); + var order = persistedOrder(OrderStatus.DRAFT); + order.setExpressTrackingNo("SF-OLD"); + OrderRepository repository = mock(OrderRepository.class); + OrderMasterDataGuard masterData = mock(OrderMasterDataGuard.class); + when(repository.findOwned(10001L, 1L)).thenReturn(order); + when(repository.saveOrder(10001L, order)).thenReturn(1); + when(repository.completeIdempotency(anyLong(), any(), anyLong())).thenReturn(1); + when(masterData.validate(10001L, draft)).thenReturn(snapshots()); + + service(repository, mock(OrderEligibilityGuard.class), masterData).updateDraft(10001L, 2L, 1L, + new OrderRequests.DraftUpdateRequest(0, draft), "courier-edit"); + + assertThat(order.getExpressTrackingNo()).isEqualTo( + "SELF_EXPRESS".equals(pickupMethod) && !tracking.isBlank() ? tracking.trim() : null); + verify(repository).saveOrder(10001L, order); + } + } + } + @Test void creatingDraftPreservesProductImageDimensionsAndRemark() { OrderRepository repository = mock(OrderRepository.class); From bb917c954c07f6f208a9effbdbb4a93a6ccf6612 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 17:48:34 +0800 Subject: [PATCH 027/113] fix(order): persist cleared courier tracking numbers --- .../com/baiyue/order/entity/OrderEntity.java | 2 ++ .../MerchantOrderCommandTransactionTest.java | 24 +++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java index ec298ab30..8b94f22b7 100644 --- a/order/src/main/java/com/baiyue/order/entity/OrderEntity.java +++ b/order/src/main/java/com/baiyue/order/entity/OrderEntity.java @@ -64,6 +64,8 @@ public class OrderEntity extends BaseEntity { private Long rootOrderId; private Integer splitSequence; private String expressCompany; + // 客户清空单号或改用其他交货方式时,必须把 NULL 写入数据库。 + @TableField(updateStrategy = FieldStrategy.ALWAYS) private String expressTrackingNo; private LocalDateTime submittedAt; private LocalDateTime completedAt; diff --git a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java index 22c4c69df..fe71ec2e1 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java @@ -44,6 +44,30 @@ class MerchantOrderCommandTransactionTest { private OrderRequests.SaveRequest draft; private com.baiyue.order.client.FirstlegInboundClient firstleg; + /** 通过真实 MyBatis 和内存数据库验证单号更新及清空,避免空值被更新策略忽略。 */ + @Test + void courierTrackingCanBeClearedAndSwitchingPickupMethodClearsItInDatabase() { + var fields = (com.fasterxml.jackson.databind.node.ObjectNode) json.valueToTree(draft); + fields.remove("carrierSnapshotComplete"); + fields.put("expressTrackingNo", " SF-ORIGINAL "); + var filled = json.convertValue(fields, OrderRequests.SaveRequest.class); + long id = service.createDraft(11L, 7L, "operator", filled, "courier-create"); + assertThat(db.queryForObject("SELECT express_tracking_no FROM ord_order WHERE id=?", String.class, id)) + .isEqualTo("SF-ORIGINAL"); + + fields.remove("expressTrackingNo"); + var blank = json.convertValue(fields, OrderRequests.SaveRequest.class); + service.updateDraft(11L, 7L, id, new OrderRequests.DraftUpdateRequest(0, blank), "courier-clear"); + assertThat(db.queryForObject("SELECT express_tracking_no FROM ord_order WHERE id=?", String.class, id)).isNull(); + + service.updateDraft(11L, 7L, id, new OrderRequests.DraftUpdateRequest(1, filled), "courier-restore"); + fields.put("pickupMethod", "SELF_TRUCK"); + fields.put("expressTrackingNo", "SPOOFED"); + var truck = json.convertValue(fields, OrderRequests.SaveRequest.class); + service.updateDraft(11L, 7L, id, new OrderRequests.DraftUpdateRequest(2, truck), "courier-switch"); + assertThat(db.queryForObject("SELECT express_tracking_no FROM ord_order WHERE id=?", String.class, id)).isNull(); + } + @BeforeEach void prepare() throws Exception { JdbcDataSource source = new JdbcDataSource(); From dcfba1053c1389d2695619ddda8c3db54972b7e0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 19 Oct 2026 17:56:36 +0800 Subject: [PATCH 028/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...appointment-calendar-release-manifest.json | 23 + deploy/sql/README.md | 8 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 16 +- deploy/sql/海外区域统一增量-MySQL8.sql | 207 +- .../file/service/BusinessFileCategory.java | 7 + .../openapi/dispatch-evidence-v1.yaml | 167 + .../service/DispatchEvidenceFileTest.java | 245 + firstleg/AGENTS.md | 141 +- firstleg/docs/business-index.md | 107 + .../com/baiyue/firstleg/dto/FeeItemDtos.java | 24 +- .../baiyue/firstleg/entity/FeeItemEntity.java | 6 + .../firstleg/service/FeeItemService.java | 2 + .../db/phase2-fee-item-and-inbound-detail.sql | 1 + .../main/resources/openapi/fee-item-v1.yaml | 2 + .../controller/FeeManagementContractTest.java | 4 + .../service/FeeManagementServiceTest.java | 42 +- .../main/resources/db/lastleg-permissions.sql | 4 +- .../db/phase5-delivery-permissions.sql | 59 +- iam/src/main/resources/openapi/iam-v1.yaml | 4 +- .../controller/IamOpenApiContractTest.java | 6 +- ...erseasPhase4PermissionSqlContractTest.java | 9 +- ...ase5DeliveryPermissionSqlContractTest.java | 40 +- ...se5DeliveryPermissionSqlExecutionTest.java | 37 +- .../AuthorizationSnapshotServiceTest.java | 10 +- lastleg-service/AGENTS.md | 88 +- lastleg-service/docs/business-index.md | 79 + .../AppointmentWorkDispatchPort.java | 18 +- .../DeliveryTransferController.java | 29 +- .../MerchantDeliveryController.java | 9 - ...PlatformDeliveryFeeCoverageController.java | 14 +- .../dto/DeliveryFeeCoverageModels.java | 4 - .../delivery/dto/DeliveryRequests.java | 41 - .../delivery/dto/DeliveryResponses.java | 8 - .../delivery/entity/DeliveryFactsChanged.java | 23 + .../entity/DispatchDeliveryArrangement.java | 21 + .../delivery/entity/OutboundPalletItem.java | 8 + .../mapper/DeliveryFactsChangedMapper.java | 58 + .../delivery/mapper/DeliveryOrderMapper.java | 12 + .../mapper/DeliverySourceEvidenceMapper.java | 12 +- .../DispatchDeliveryArrangementMapper.java | 26 + .../delivery/mapper/OutboundOrderMapper.java | 8 + .../delivery/model/DeliveryCargoGroups.java | 98 + .../model/NormalDeliveryCompletionPolicy.java | 53 + .../DeliveryCancellationRepository.java | 95 + .../DeliveryDocumentRepository.java | 65 + .../DeliveryFactsChangedRepository.java | 50 + .../DispatchDeliveryRepository.java | 71 + .../service/DeliveryDocumentService.java | 79 +- .../service/DeliveryFactsChangedService.java | 118 + .../service/DeliveryFeeCoverageService.java | 122 - .../service/DeliveryTransferService.java | 436 +- .../service/DispatchDeliveryFees.java | 179 + .../service/DispatchDeliveryService.java | 354 ++ .../controller/DispatchActorFactory.java | 27 + .../DispatchAggregateController.java | 144 + .../controller/DispatchController.java | 45 +- .../controller/DispatchQueryController.java | 14 +- .../controller/SmartDispatchController.java | 172 +- .../dispatch/entity/DispatchAggregate.java | 46 + .../entity/DispatchCommandRecord.java | 8 + .../dispatch/entity/DispatchDraft.java | 4 +- .../dispatch/entity/DispatchDraftCommand.java | 17 + .../entity/DispatchDraftContainer.java | 1 + .../lastleg/dispatch/entity/DispatchItem.java | 4 + .../dispatch/fleet/DispatchFleetAdapter.java | 390 +- .../fleet/FleetDispatchPlanningAdapter.java | 78 +- .../fleet/FleetDispatchPlanningMapper.java | 25 +- .../gateway/DispatchEvidenceGateway.java | 15 + .../gateway/HttpDispatchEvidenceGateway.java | 96 + .../mapper/DispatchAggregateMapper.java | 105 + .../dispatch/mapper/DispatchBolMapper.java | 19 +- .../mapper/DispatchCandidateMapper.java | 50 +- .../mapper/DispatchCommandMapper.java | 24 + .../mapper/DispatchDraftClaimMapper.java | 42 + .../mapper/DispatchDraftCommandMapper.java | 21 + .../mapper/DispatchDraftContainerMapper.java | 17 + .../dispatch/mapper/DispatchDraftMapper.java | 32 +- .../mapper/DispatchExecutionReadMapper.java | 66 + .../dispatch/mapper/DispatchItemMapper.java | 3 + .../dispatch/mapper/DispatchReadMapper.java | 65 + .../dispatch/mapper/DispatchSourceMapper.java | 97 + .../mapper/ReturnDispatchCandidateMapper.java | 15 +- .../dispatch/model/DispatchBolData.java | 14 + .../pda/controller/DispatchPdaController.java | 75 +- .../dispatch/pda/entity/DispatchPdaTask.java | 1 - .../pda/mapper/DispatchPdaPickMapper.java | 3 + .../mapper/DispatchPdaPickOrderMapper.java | 6 +- .../pda/mapper/DispatchPdaTaskMapper.java | 8 +- .../mapper/DispatchWholePdaReadMapper.java | 37 + .../service/DispatchPdaWorkflowService.java | 411 +- .../lastleg/dispatch/port/DispatchApi.java | 33 + .../DispatchAppointmentAmendmentPort.java | 24 + .../dispatch/port/DispatchCommands.java | 95 + .../dispatch/port/DispatchCompletionPort.java | 9 + .../dispatch/port/DispatchContracts.java | 95 + .../dispatch/port/DispatchDeliveryModels.java | 91 + .../dispatch/port/DispatchDeliveryPort.java | 24 + .../dispatch/port/DispatchExecutionPort.java | 28 + .../dispatch/port/DispatchPlanningModels.java | 83 + .../dispatch/port/DispatchPlanningPort.java | 39 + .../dispatch/port/DispatchResults.java | 25 + .../dispatch/port/DispatchSignalPort.java | 21 + .../lastleg/dispatch/port/DispatchTypes.java | 23 + .../lastleg/dispatch/port/DispatchViews.java | 80 + .../dispatch/port/DispatchWorkModels.java | 22 + .../DispatchAggregateRepository.java | 64 + .../repository/DispatchCommandRepository.java | 20 + .../repository/DispatchDraftRepository.java | 58 + .../DispatchExecutionRepository.java | 23 + .../repository/DispatchPdaReadRepository.java | 27 + .../repository/DispatchPickingRepository.java | 35 + .../DispatchSourceDepartureRepository.java | 17 + .../repository/DispatchSourceRepository.java | 72 + .../service/DispatchAggregateService.java | 824 +++ .../DispatchAppointmentAllocations.java | 60 + .../service/DispatchBolPrintService.java | 28 +- .../service/DispatchBolReadService.java | 31 + .../service/DispatchCommandCodec.java | 102 + .../service/DispatchCommandHasher.java | 86 + .../service/DispatchCommandLedger.java | 94 + .../service/DispatchCompletionService.java | 68 + .../DispatchDeliveryCompletionWorker.java | 32 + .../service/DispatchDepartureService.java | 65 + .../service/DispatchDraftService.java | 355 ++ .../service/DispatchPdaQueryService.java | 64 + .../service/DispatchPickingService.java | 121 + .../dispatch/service/DispatchService.java | 668 +-- .../service/ReturnRelabelDispatchStock.java | 14 +- .../ReturnCartonStockReceiptMapper.java | 6 +- .../service/ExceptionOrderService.java | 2 +- .../FleetHandoverInspectionService.java | 2 - .../exception/FleetHandoverPdaQuery.java | 4 +- .../fleet/exception/FleetTransitEvidence.java | 4 +- .../fleet/trip/FleetBookingReservations.java | 6 +- .../fleet/trip/FleetBookingService.java | 22 +- .../fleet/trip/FleetDispatchBindingGuard.java | 11 +- .../fleet/trip/FleetDispatchGateway.java | 21 +- .../trip/FleetDispatchPlanningGateway.java | 4 +- .../trip/FleetDispatchWorkflowQuery.java | 21 +- .../fleet/trip/FleetExecutionModels.java | 15 + .../fleet/trip/FleetResourceMapper.java | 3 + .../trip/FleetTripCompletionService.java | 4 +- .../lastleg/fleet/trip/FleetTripDispatch.java | 3 - .../fleet/trip/FleetTripDispatchMapper.java | 14 + .../fleet/trip/FleetTripExecutionService.java | 195 +- .../lastleg/fleet/trip/FleetTripModels.java | 4 +- .../trip/FleetTripPreparationService.java | 81 - .../fleet/trip/FleetTripRepository.java | 12 + .../lastleg/fleet/trip/FleetTripService.java | 379 +- .../PlatformFleetCapacityOfferController.java | 2 +- .../trip/PlatformFleetTripController.java | 57 +- .../InboundFbaAppointmentQueryMapper.java | 3 +- .../FbaAppointmentExecutionPort.java | 2 +- .../FbaAppointmentTransportPort.java | 6 +- .../receiving/FbaReceivingService.java | 21 +- .../work/AppointmentWorkDispatchBinding.java | 169 +- .../AppointmentWorkDispatchCandidates.java | 58 +- .../work/AppointmentWorkDispatchMapper.java | 65 +- .../work/FbaAppointmentExecutionAdapter.java | 25 +- .../work/FbaAppointmentTransportAdapter.java | 77 +- .../mapper/ReturnStockReceiptMapper.java | 3 +- .../mapper/CustomerOrderProgressMapper.java | 26 +- .../lastleg/security/OverseasPermissions.java | 9 +- .../src/main/resources/db/lastleg.sql | 202 +- .../openapi/fleet-preparation-v1.yaml | 24 +- .../resources/openapi/fleet-trips-v1.yaml | 41 - .../openapi/overseas-business-v1.yaml | 4654 +++++++++++++++-- .../openapi/overseas-pda-operations-v1.yaml | 323 +- .../delivery/DeliveryCargoGroupsTest.java | 30 + .../delivery/DeliveryDispatchFixtures.java | 28 + .../DeliveryFactsChangedPersistenceTest.java | 100 + .../DeliveryFeeCoverageServiceTest.java | 112 - .../DeliverySourceEvidenceMapperTest.java | 23 +- ...iverySourcePublicationPersistenceTest.java | 2 +- ...tchDeliveryArrangementPersistenceTest.java | 109 + .../delivery/DispatchDeliveryFeesTest.java | 121 + .../delivery/DispatchDeliveryServiceTest.java | 120 + .../NormalDeliveryCompletionPolicyTest.java | 47 + .../PlatformDispatchOutboundContractTest.java | 63 +- .../MerchantDeliveryControllerTest.java | 105 +- .../service/DeliveryDocumentServiceTest.java | 13 +- .../service/DeliveryTransferServiceTest.java | 437 +- .../DispatchCandidateMapperContractTest.java | 5 +- .../dispatch/DispatchSchemaContractTest.java | 31 +- .../SmartDispatchOpenApiContractTest.java | 90 +- .../DispatchQueryControllerTest.java | 19 +- .../SmartDispatchControllerTest.java | 165 +- .../HttpDispatchEvidenceGatewayTest.java | 68 + .../DispatchPdaWorkflowServiceTest.java | 20 +- .../port/DispatchPortCompileContract.java | 72 + .../port/DispatchPublicContractTest.java | 12 + .../port/DispatchPublicJsonContract.java | 107 + .../DispatchAggregatePersistenceTest.java | 331 ++ .../DispatchAppointmentAllocationsTest.java | 35 + .../DispatchCommandLedgerPersistenceTest.java | 115 + .../DispatchCompletionPersistenceTest.java | 66 + .../DispatchDeliveryCompletionWorkerTest.java | 23 + .../DispatchDeparturePersistenceTest.java | 92 + .../service/DispatchDraftPersistenceTest.java | 305 ++ .../DispatchDraftResourceLockTest.java | 51 + .../DispatchPdaReadPersistenceTest.java | 37 + .../DispatchPickingPersistenceTest.java | 104 + .../service/DispatchRepairWorkflowTest.java | 20 +- .../dispatch/service/DispatchServiceTest.java | 273 +- .../DispatchWholeSourcePersistenceTest.java | 73 + ...xceptionOrderDispatchReturnSourceTest.java | 21 +- .../exception/FleetHandoverPdaQueryTest.java | 10 +- .../FleetHandoverTransactionTest.java | 2 +- .../exception/FleetTransitEvidenceTest.java | 71 +- .../trip/FleetDispatchWorkflowQueryTest.java | 44 +- .../trip/FleetTripAuthorizationTest.java | 26 +- .../fleet/trip/FleetTripPersistenceTest.java | 1323 +---- ...mentWorkPreArrangementPersistenceTest.java | 19 +- .../FbaAppointmentPersistenceTest.java | 8 +- .../service/FbaReceivingPersistenceTest.java | 25 +- .../AppointmentWorkDispatchBindingTest.java | 47 +- ...ppointmentWorkDispatchPersistenceTest.java | 143 + .../FbaAppointmentExecutionAdapterTest.java | 41 +- .../FbaAppointmentTransportAdapterTest.java | 119 +- .../CustomerOrderProgressMapperTest.java | 30 +- .../mapper/SharedDispatchReturnQueryTest.java | 87 + .../pda/PdaOperationsOpenApiContractTest.java | 15 +- .../pda/PdaOutboundDispatchContractTest.java | 15 +- scripts/AGENTS.md | 1 + scripts/README.md | 2 + ...eet-capacity-pool-schema-contract.test.mjs | 4 +- scripts/agents-index-contract.ps1 | 99 +- scripts/backend-full-regression.ps1 | 6 +- scripts/frontend-full-regression.ps1 | 19 +- .../full-regression-entry-contract.test.mjs | 2 +- scripts/local-check.ps1 | 199 + scripts/local-check.test.mjs | 112 + 232 files changed, 15655 insertions(+), 6005 deletions(-) create mode 100644 .logs/appointment-calendar-release-manifest.json create mode 100644 file/src/main/resources/openapi/dispatch-evidence-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java create mode 100644 firstleg/docs/business-index.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryCargoGroupsTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFactsChangedPersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFeeCoverageServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/NormalDeliveryCompletionPolicyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedgerPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorkerTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/mapper/SharedDispatchReturnQueryTest.java create mode 100644 scripts/local-check.ps1 create mode 100644 scripts/local-check.test.mjs diff --git a/.logs/appointment-calendar-release-manifest.json b/.logs/appointment-calendar-release-manifest.json new file mode 100644 index 000000000..146fe4870 --- /dev/null +++ b/.logs/appointment-calendar-release-manifest.json @@ -0,0 +1,23 @@ +{ + "deploy/sql/README.md": "4c7475566d5b3ab7ebaeb26a9369c3b2daa2911adf648bd9df1cae348de9f5c1", + "deploy/sql/头程数据库统一增量-MySQL8.sql": "55c9736c6998f59d48a76c5ba60e2a801dd0b8acbf209fcf0dae4830fb3fb485", + "deploy/sql/海外区域统一增量-MySQL8.sql": "3fda99ff623735dc42844a50d9a80090ab478aaf916ebdf5fa21e5a6d054117a", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/controller/ContainerCalendarSourceController.java": "74f1f56fb224798004382e0f4b36140cf81a073f50a1ac212bc393e0d2dde159", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/dto/ContainerCalendarSource.java": "58edea28fff5a8357aa662b794d23983d7da987f7f11f9b4bc64a8b406e466c5", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java": "76556dc0b615c19d8b3aa8c4453af56dce72bb8782343fce6a09b8a36163a467", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java": "881f000a5f9a2ebe10858ee4ab97fb2595189aef641bb90dc73b9c37bc6d1d7b", + "firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceService.java": "44c3927abb813c5d6f5c8d553795c8bf50fb945e9862984f1fe011da20d3695d", + "firstleg/src/main/resources/openapi/appointment-calendar-sources-v1.yaml": "8fffd13df663c6eaaf7eaffaa063b24d531f33241d7adb94850ee146af6d22dd", + "firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java": "f38e11e2509a030854c0b6f40f8f9577ed729bcbbff0666b180504f38aa93c0b", + "firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/service/ContainerCalendarSourceServiceTest.java": "87a0fbe6f7b9a271e331641c7c8da27282a6f1df2ae9b3167d543bf78119f7e6", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java": "17102d4a32640ccfd92a7e7e738a248d4c1f5ae8906b8b965f455eb9be572603", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java": "80f3a96bee0fa7630afff100ddb051977e283902c4e7ef247b8152fb37c2400f", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java": "88824f1a88d24b9bd34c9e7023aaa00d2653849a9131bf619d7df0f5fd3a7a0f", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java": "4395cce32c574ebee0b4437f6ec8bbe0a5aee62c19a6a451ee0f243eab7be558", + "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java": "886e9e5460e47962f5701ceeb5e94ba9d2a123c4c83f2791b739178c8bfe874a", + "lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml": "06965771d34d761b869f90ee6ff3f2dca7589121c67a5adbe2081677ce5a563c", + "lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java": "a17e359b39000036fd9bd80d6b0744018541fc7bbe846a0585ece629ff09dc6c", + "lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java": "9ed34bea7163166d0f79f882f12b23033dae13ad13bd4eedc7e202aed5defb8f", + "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java": "c3a4cedac09f16f37442a6709b9d704a607f2e0238e9997687e44153c378f27a", + "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java": "5d9e8aded16aa4de3338d65edd1a2e6b4e42961498f22d99c702e0f8d491bcd6" +} \ No newline at end of file diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 61905f623..6c452094b 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,9 @@ # 单测试环境数据库空库初始化 +费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 + +排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;活动来源键与真实物理托盘分别唯一,防止跨车重复占用。智能草稿按拥有者、起运仓和版本维护,容器保存真实来源键,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。 + 本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。 `migrations/overseas/baiyue_lastleg/V23__align_return_movement_collation.sql` 将退货进销存 UNION 涉及的八张表、22 个文本字段及表默认规则显式统一为 `utf8mb4_unicode_ci`,同步 Lastleg 两份源基线与海外统一空库基线。执行前核对字段长度、默认值、可空性及目标规则下唯一索引无冲突;只修改规则,不回填或删除数据,不重放空库基线。DDL 非事务,失败时停止并复查实际已完成项;可按最终定义重跑。脚本存在不表示测试库已执行,执行后用原始计数/分页查询验证,无需发布查询转换代码。 @@ -47,7 +51,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 220 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 225 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -86,7 +90,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 所属库 | 保留内容 | 不包含 | |---|---|---| -| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、419 个权限、600 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | +| Auth / IAM | 平台主体、唯一平台管理员、8 个系统角色及企业管理员模板、454 个权限、618 条角色权限、7 个数据范围、客户编号起点;无人工完成订单权限 | 测试客户、固定客户账号、额外扩权 | | Warehouse | 既有公共仓目录 318 条及地址;义乌、东莞、洛杉矶、纽约 4 个作业仓收货能力 | 示例供应商、快递、往来单位、托盘、库位、预占、库存 | | Firstleg | 112 个费用项目、15 个渠道目录、7 个始发地别名、4 个公共仓映射 | 客户报价、订单、入库、实测或履约记录 | | File / Finance | 导出互斥键 `GLOBAL_CREATE`、默认平台财务主体 | 文件任务、资金、账单或交易流水 | diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index d58001da3..e52387846 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1995,6 +1995,7 @@ CREATE TABLE fl_fee_item ( fee_code varchar(50) NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码', fee_type_name varchar(128) NOT NULL COMMENT '费用类型名称', supplier_category varchar(16) DEFAULT NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商', + default_price decimal(12,2) DEFAULT NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改', pickup_payable tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应付是否参与', pickup_receivable tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应收是否参与', warehouse_payable tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应付是否参与', @@ -6356,10 +6357,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6381,6 +6381,9 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107062,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:edit','编辑海外库位',NULL,'PUT',762,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107063,0,'PLATFORM','BUTTON',5107060,'platform:overseas-warehouse:location:delete','删除海外库位',NULL,'DELETE',763,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create','新增排车单',NULL,'POST',765,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete','确认装车完成',NULL,'POST',766,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart','确认实际发车',NULL,'POST',767,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107072,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST',772,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6458,7 +6461,6 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305020,0,'MERCHANT','MENU',5300000,'merchant:finance:account:view','资金账户','/finance/account','GET',50,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5305021,0,'MERCHANT','BUTTON',5305020,'merchant:finance:recharge:create','提交充值申请',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); -INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306100,0,'MERCHANT','MENU',0,'merchant:cross-border:procurement:list','海外采购','/cross-border/procurement','GET',70,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306101,0,'MERCHANT','BUTTON',5306100,'merchant:cross-border:procurement:create','创建海外采购单',NULL,'POST',10,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5306110,0,'MERCHANT','MENU',0,'merchant:cross-border:domestic-trucking:list','美国本土卡','/cross-border/domestic-trucking','GET',80,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); @@ -6737,7 +6739,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107011,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107012,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107013,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107014,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107016,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107017,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107018,0,CURRENT_TIMESTAMP(3)); @@ -6761,6 +6762,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107062,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107063,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107065,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107066,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107067,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107070,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107071,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107072,0,CURRENT_TIMESTAMP(3)); @@ -7038,6 +7042,9 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107050,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107060,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107064,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107065,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107066,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107067,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107070,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107071,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000004,5107072,0,CURRENT_TIMESTAMP(3)); @@ -7121,7 +7128,6 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305020,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5305021,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306000,0,CURRENT_TIMESTAMP(3)); -INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306001,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306100,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306101,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000100,5306110,0,CURRENT_TIMESTAMP(3)); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index d4a6b28a1..39047fe4f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -325,11 +325,13 @@ CREATE TABLE ll_destuff_pallet_cost_allocation ( CREATE TABLE ll_dispatch_draft ( id bigint NOT NULL COMMENT '主键ID', draft_token varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能排车草稿随机标识', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户租户ID', - operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿操作人ID', - plan_ids_json varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所选入仓计划ID列表JSON', + operator_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿拥有者身份,仅本人可维护及恢复命令', + warehouse_id BIGINT NOT NULL COMMENT '本草稿唯一起运公共仓库引用', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '草稿并发版本,显式零版本合法', + sources_json LONGTEXT NOT NULL COMMENT '服务端核验的真实来源及客户版本快照数组', + plan_ids_json longtext COLLATE utf8mb4_unicode_ci COMMENT '所选入仓计划ID列表JSON,容纳最多五百个完整长整型引用', containers_json longtext COLLATE utf8mb4_unicode_ci COMMENT '草稿装车布局JSON快照', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态', + status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '草稿状态:ACTIVE有效、RELEASED释放、CONVERTED已整批转换', expires_at datetime(3) DEFAULT NULL COMMENT '草稿失效时间', released_at datetime(3) DEFAULT NULL COMMENT '草稿释放时间', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', @@ -345,13 +347,32 @@ CREATE TABLE ll_dispatch_draft_container ( draft_token varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '智能排车草稿随机标识', sequence_no int NOT NULL COMMENT '草稿车厢顺序号', capacity int NOT NULL COMMENT '草稿车厢托盘容量', + source_keys_json LONGTEXT NOT NULL COMMENT '本装车容器已取得本草稿占用的来源键数组', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_draft_container_sequence (draft_id,sequence_no), KEY idx_ll_dispatch_draft_container_token (draft_token) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车托盘'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车容器,每个非空容器转换一张整车单'; + +CREATE TABLE ll_dispatch_draft_command ( + id BIGINT NOT NULL COMMENT '草稿命令内部主键', + idempotency_key VARCHAR(128) NOT NULL COMMENT '草稿操作全局唯一幂等键', + operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作主体,恢复必须为本人', + action VARCHAR(32) NOT NULL COMMENT '草稿创建、维护、选货、释放或转换动作', + target_token VARCHAR(128) NOT NULL COMMENT '命令目标草稿标识或创建范围标识', + request_hash CHAR(64) NOT NULL COMMENT '原动作、主体、版本及完整载荷摘要', + result_json LONGTEXT NULL COMMENT '本事务完成后保存的不可变原结果,不提交空结果', + created_at DATETIME(3) NOT NULL COMMENT '命令创建UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_ll_dispatch_draft_command_key (idempotency_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿持久命令及原结果'; CREATE TABLE ll_dispatch_item ( + customer_name VARCHAR(255) NULL COMMENT '本条真实客户名称快照', + source_key VARCHAR(255) NOT NULL COMMENT '本条货物稳定权威来源键', + source_version CHAR(64) NOT NULL COMMENT '选货时核对的真实来源版本摘要', + source_snapshot_json JSON NOT NULL COMMENT '本条货物不可变来源及关联事实快照', id bigint NOT NULL COMMENT '主键ID', dispatch_order_id bigint NOT NULL COMMENT '派车单ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', @@ -359,7 +380,7 @@ CREATE TABLE ll_dispatch_item ( plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', order_id bigint DEFAULT NULL COMMENT '订单ID', - order_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '订单号', + order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本条真实来源订单业务号快照', order_refs varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联订单信息', inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓单号快照', pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘号', @@ -392,42 +413,69 @@ CREATE TABLE ll_dispatch_item ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车货物冻结明细'; CREATE TABLE ll_dispatch_order ( - id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - dispatch_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派车单号', - overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', - overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', - vehicle_plate varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车牌号', - driver_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机姓名', - driver_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机电话', - carrier_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运商名称', - route varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '运输路线', - depart_at datetime(3) DEFAULT NULL COMMENT '发车时间', - fba_appointment_no varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA预约号', - fba_appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT 'FBA预约状态', - vehicle_appointment_status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '车辆预约状态', - estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '预计到达时间', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_DEPARTURE' COMMENT '业务状态', - item_count int NOT NULL DEFAULT '0' COMMENT '明细数量', - pallet_count int NOT NULL DEFAULT '0' COMMENT '托盘数', - package_count int NOT NULL DEFAULT '0' COMMENT '箱数', - picked_package_count int NOT NULL DEFAULT '0' COMMENT 'PDA累计已拣箱数', - shipped_package_count int NOT NULL DEFAULT '0' COMMENT '累计已出库箱数', - partial_completion_reason varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '部分完成原因', - created_by varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + id BIGINT NOT NULL COMMENT '整车排车主单内部主键', + dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', + trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', + overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', + overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '起运海外仓名称快照', + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '主单生命周期:ACTIVE有效、CANCELLED已取消', + stage VARCHAR(32) NOT NULL DEFAULT 'BEFORE_LOADING' COMMENT '作业阶段:BEFORE_LOADING装车前、LOADING装车中、AFTER_LOADING装车后、DELIVERING派送中、DELIVERED派送完成', + record_version BIGINT NOT NULL DEFAULT 0 COMMENT '整车公共并发版本,首版为零', + cargo_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本趟货物修订版本', + cargo_hash CHAR(64) NOT NULL COMMENT '当前整车真实货物及来源摘要', + review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', + review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次', + review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照', + loading_review_json JSON NULL COMMENT '当前理货审核事实快照', + resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', + vehicle_plate VARCHAR(64) NULL COMMENT '本趟车辆真实车牌快照', + route VARCHAR(2000) NULL COMMENT '真实运输路线展示快照', + planned_departure_at DATETIME(3) NULL COMMENT '计划发车时间,草稿未知时为空', + estimated_arrival_at DATETIME(3) NULL COMMENT '预计到达时间', + loading_completed_at DATETIME(3) NULL COMMENT '实际确认装车完成时间', + loading_completion_hash CHAR(64) NULL COMMENT '实际装车完成事实摘要', + loading_confirmation_json JSON NULL COMMENT '装车确认时逐实托真实实载完整快照', + arrangement_key VARCHAR(128) NULL COMMENT '本车唯一派送安排事实标识', + arrangement_business_hash CHAR(64) NULL COMMENT '本车派送安排业务内容摘要', + departed_at DATETIME(3) NULL COMMENT '本趟实际发车时间', + delivery_completed_at DATETIME(3) NULL COMMENT '全部派送正常签收后的整车完成时间', + item_count INT NOT NULL DEFAULT 0 COMMENT '当前排车作业明细数', + pallet_count INT NOT NULL DEFAULT 0 COMMENT '真实实物托盘去重数', + package_count INT NOT NULL DEFAULT 0 COMMENT '本车计划箱数', + picked_package_count INT NOT NULL DEFAULT 0 COMMENT 'PDA真实累计已拣箱数', + shipped_package_count INT NOT NULL DEFAULT 0 COMMENT '本车实际发运箱数', + remark VARCHAR(2000) NULL COMMENT '排车业务备注', + created_by VARCHAR(128) NULL COMMENT '可信建单主体ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '主单创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '主单最后更新时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_order_no (dispatch_no), - KEY idx_ll_dispatch_order_list (customer_id,status,depart_at), - KEY idx_ll_dispatch_order_vehicle (customer_id,vehicle_plate) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程派车单'; + UNIQUE KEY uk_dispatch_trip (trip_id), + KEY idx_ll_dispatch_order_list (overseas_warehouse_id,status,stage,created_at,id), + KEY idx_ll_dispatch_order_vehicle (vehicle_plate,status,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台整车排车作业主单,客户归属在真实明细'; + +CREATE TABLE ll_dispatch_history ( + id BIGINT NOT NULL COMMENT '排车历史内部主键', + dispatch_id BIGINT NOT NULL COMMENT '本次历史所属整车主单ID', + record_version BIGINT NOT NULL COMMENT '操作完成后的整车公共版本', + action VARCHAR(40) NOT NULL COMMENT '本次真实业务动作', + operator_id VARCHAR(128) NOT NULL COMMENT '可信操作主体ID', + reason VARCHAR(2000) NULL COMMENT '审核驳回或变更原因', + snapshot_json JSON NOT NULL COMMENT '动作完成当时的完整主单事实快照', + occurred_at DATETIME(3) NOT NULL COMMENT '真实操作发生时间', + PRIMARY KEY (id), + KEY idx_dispatch_history_version (dispatch_id,record_version,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车版本与审核轮次的追加历史'; CREATE TABLE ll_dispatch_pallet_lock ( id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', source_pallet_key varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘业务键', active_key varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的资源排他键', + active_warehouse_pallet_id bigint DEFAULT NULL COMMENT '活动占用的真实仓库托盘,释放后为空,跨客户及来源别名唯一', plan_id bigint DEFAULT NULL COMMENT '入仓计划ID', plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', @@ -444,6 +492,7 @@ CREATE TABLE ll_dispatch_pallet_lock ( return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), + UNIQUE KEY uk_ll_dispatch_active_warehouse_pallet (active_warehouse_pallet_id), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车托盘排他锁'; @@ -524,8 +573,7 @@ CREATE TABLE ll_dispatch_pda_pick_order ( CREATE TABLE ll_dispatch_pda_task ( id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - dispatch_order_id bigint NOT NULL COMMENT '派车单ID', + dispatch_order_id bigint NOT NULL COMMENT '唯一整车排车单引用,客户归属保留在扫码明细', task_no varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA任务号', status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务状态', operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', @@ -536,9 +584,9 @@ CREATE TABLE ll_dispatch_pda_task ( created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', PRIMARY KEY (id), - UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (customer_id,dispatch_order_id), + UNIQUE KEY uk_ll_dispatch_pda_task_dispatch (dispatch_order_id), UNIQUE KEY uk_ll_dispatch_pda_task_no (task_no) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车PDA拣货任务'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车PDA拣货任务,一张排车单仅一条任务'; CREATE TABLE ll_dropship_carton ( id bigint NOT NULL COMMENT '主键ID', @@ -1827,7 +1875,71 @@ CREATE TABLE ll_outbound_order ( KEY idx_ll_outbound_list (customer_id,status,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程出库单'; +CREATE TABLE ll_dispatch_command ( + id BIGINT NOT NULL COMMENT '持久命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '命令所属整车排车主单ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '命令所属真实排车业务号', + action VARCHAR(40) NOT NULL COMMENT '本次整车作业动作', + operator_id VARCHAR(128) NOT NULL COMMENT '原始可信操作主体ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '跨操作主体全局唯一的原始请求键', + record_version BIGINT NOT NULL COMMENT '原请求明确提交的排车版本,零为合法首版', + command_hash CHAR(64) NOT NULL COMMENT '绑定原主体动作业务号版本和完整载荷的严格摘要', + result_json LONGTEXT NOT NULL COMMENT '按动作类型恢复的不可变成功结果JSON', + created_at DATETIME(3) NOT NULL COMMENT '成功命令记录时间,使用UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_command_key (idempotency_key), + KEY idx_dispatch_command_history (dispatch_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车公共持久命令账本'; + +CREATE TABLE ll_dispatch_delivery_arrangement ( + id BIGINT NOT NULL COMMENT '安排事实内部主键', + dispatch_order_id BIGINT NOT NULL COMMENT '唯一整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + arrangement_key VARCHAR(128) NOT NULL COMMENT '本次唯一安排业务事实标识', + business_hash CHAR(64) NOT NULL COMMENT '排除操作者及纯并发版本的安排业务摘要', + cargo_hash CHAR(64) NOT NULL COMMENT '本车实际拣货来源摘要', + loading_completion_hash CHAR(64) NOT NULL COMMENT '装车完成事实摘要', + original_command_key VARCHAR(255) NOT NULL COMMENT '首次安排的全局请求键', + original_operator_id VARCHAR(128) NOT NULL COMMENT '首次安排操作主体', + result_json JSON NOT NULL COMMENT '全部客户出库及客户目的地派送不可变结果', + cargo_json JSON NOT NULL COMMENT '逐客户逐拣货归属的不可变真实来源', + created_at DATETIME(3) NOT NULL COMMENT '本次安排实际创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_arrangement_dispatch (dispatch_order_id), + UNIQUE KEY uk_dispatch_arrangement_key (arrangement_key), + UNIQUE KEY uk_dispatch_arrangement_command (original_command_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车唯一派送安排事实'; + +CREATE TABLE ll_delivery_facts_changed ( + id BIGINT NOT NULL COMMENT '持久信号内部主键', + customer_id VARCHAR(128) NOT NULL COMMENT '真实签收货物客户ID', + dispatch_id BIGINT NOT NULL COMMENT '整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + delivery_id BIGINT NOT NULL COMMENT '本客户派送单ID', + delivery_no VARCHAR(128) NOT NULL COMMENT '真实派送业务号', + delivery_version BIGINT NOT NULL COMMENT '触发信号的真实派送版本', + facts_hash CHAR(64) NOT NULL COMMENT '该版本真实签收事实摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '签收事实实际发生时间', + state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '待核对PENDING或已核对PROCESSED', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已记录的汇总失败次数', + next_attempt_at DATETIME(3) NOT NULL COMMENT '下次可重试时间', + last_error_code VARCHAR(64) NULL COMMENT '最近失败诊断码不保存敏感异常正文', + processed_at DATETIME(3) NULL COMMENT '与主阶段汇总同事务确认时间', + PRIMARY KEY (id), + UNIQUE KEY uk_delivery_fact_version (customer_id,delivery_id,delivery_version), + KEY idx_delivery_fact_due (state,next_attempt_at,id), + KEY idx_delivery_fact_dispatch (dispatch_id,state,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正常签收事实变化的本地持久待汇总信号'; + CREATE TABLE ll_outbound_pallet_item ( + return_sku_quantity BIGINT NULL COMMENT '本来源退货出库SKU件数,空表示整箱出库', + dispatch_item_id BIGINT NULL COMMENT '本次整车排车作业明细ID', + source_attribution_id BIGINT NULL COMMENT '不可变PDA真实拣货归属ID', + source_order_id BIGINT NULL COMMENT '本行真实来源尾程订单ID', + source_inventory_position_id BIGINT NULL COMMENT '本行实际拣货库存仓位ID', + source_return_receipt_id BIGINT NULL COMMENT '本行实际退回再派实收记录ID', + source_return_carton_id BIGINT NULL COMMENT '本行实际退货换标来源箱ID', + carton_marks VARCHAR(4000) NULL COMMENT '本行真实拣货箱唛快照', id bigint NOT NULL COMMENT '主键ID', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', outbound_order_id bigint NOT NULL COMMENT '出库单ID', @@ -1848,6 +1960,7 @@ CREATE TABLE ll_outbound_pallet_item ( created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', PRIMARY KEY (id), UNIQUE KEY uk_ll_outbound_pallet (customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,sku_code), + UNIQUE KEY uk_outbound_source_attribution (customer_id,source_attribution_id), KEY idx_ll_outbound_pallet_list (customer_id,outbound_order_id), KEY idx_ll_outbound_pallet_destination (customer_id,destination_warehouse_code) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='出库托盘明细'; @@ -2946,8 +3059,8 @@ CREATE TABLE ll_fleet_trip ( max_pallet_spaces INT NULL COMMENT '分配时托位容量快照', max_load_kg DECIMAL(12,3) NULL COMMENT '分配时载重千克', max_volume_cbm DECIMAL(12,3) NULL COMMENT '分配时可用容积立方米', - planned_departure_at DATETIME(3) NOT NULL COMMENT '计划发车UTC', - expected_driver_end_at DATETIME(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + planned_departure_at DATETIME(3) NULL COMMENT '计划发车UTC,空单草稿可未填写,提交审核前核齐', + expected_driver_end_at DATETIME(3) NULL COMMENT '司机预计任务结束UTC,含返程转场,草稿可未填写', departed_at DATETIME(3) NULL COMMENT '实际发车UTC', completed_at DATETIME(3) NULL COMMENT '实际运输完成UTC', stops_json JSON NOT NULL COMMENT '按顺序保存的路线站点', @@ -3119,20 +3232,18 @@ CREATE TABLE ll_fleet_resource_assignment ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; CREATE TABLE ll_fleet_trip_dispatch ( - dispatch_id BIGINT NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', - dispatch_no VARCHAR(64) NOT NULL COMMENT '客户排车业务单号', - customer_id VARCHAR(128) NOT NULL COMMENT '排车单自身客户归属', - trip_id BIGINT NOT NULL COMMENT '共享运输车次内部引用', - trip_no VARCHAR(32) NOT NULL COMMENT '共享运输车次业务编号', + dispatch_id BIGINT NOT NULL COMMENT '整车排车单内部引用,一车一个有效资源关联', + dispatch_no VARCHAR(64) NOT NULL COMMENT '整车排车业务单号', + trip_id BIGINT NOT NULL COMMENT '本趟运输资源记录内部引用', + trip_no VARCHAR(32) NOT NULL COMMENT '本趟运输资源业务编号', package_count INT NOT NULL COMMENT '关联时待发运箱数快照', bound_by VARCHAR(64) NOT NULL COMMENT '关联操作人', bound_at DATETIME(3) NOT NULL COMMENT '关联UTC时间', - review_json LONGTEXT NULL COMMENT '客户排车调度审核快照,空值为待审核', PRIMARY KEY (dispatch_id), UNIQUE KEY uk_fleet_trip_dispatch_no (dispatch_no), - KEY idx_fleet_trip_dispatch_trip (trip_id,dispatch_id), + UNIQUE KEY uk_fleet_trip_dispatch_trip (trip_id), CONSTRAINT fk_fleet_trip_dispatch_trip FOREIGN KEY (trip_id) REFERENCES ll_fleet_trip(id) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='整车排车与本趟资源记录一对一关联,历史独立保留'; CREATE TABLE ll_fleet_trip_operations_record ( id BIGINT NOT NULL COMMENT '出车实测历史主键', diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 52f3dc3ba..850060c90 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -9,6 +9,8 @@ import java.util.Arrays; enum BusinessFileCategory { FLEET_DRIVER_LICENSE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -67,7 +69,12 @@ enum BusinessFileCategory { .orElseThrow(() -> new BizException(ResultCode.BAD_REQUEST, "不支持的附件业务类型")); } + /** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */ void validateOwner(long tenantId) { + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) + && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); + } if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台"); } diff --git a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml new file mode 100644 index 000000000..e384ffb41 --- /dev/null +++ b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml @@ -0,0 +1,167 @@ +openapi: 3.0.3 +info: + title: 平台排车车辆与司机证据内部文件契约 + version: 1.0.0 + description: >- + 复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。 + 两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, + 不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用 + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。 + File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、 + PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId + 联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。 + 上传生成不可变文件和真实元数据,不自动绑定证据或通过审核,不返回公开 URL 或存储路径。 + Lastleg 负责排车存在性、仓库与角色权限、当前及历史附件引用和审核状态;File 不查询 Lastleg 业务表。 + 本文只描述排车网关实际消费的上传、元数据和内容三个操作,不改变通用文件接口的其他用途。 + 统一异常处理使用 HTTP 200 JSON 业务错误,调用方必须检查 code;内容接口错误也返回 JSON,不能当作文件。 +security: + - InternalServiceToken: [] +paths: + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + post: + operationId: uploadDispatchEvidence + summary: 上传本排车的车辆或司机证据 + parameters: + - name: X-Operator-Id + in: header + required: true + description: Lastleg 已认证操作者的正数 Long ID + schema: {$ref: '#/components/schemas/PositiveLongString'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + description: 非空且不超过10485760字节;application/pdf、image/jpeg或image/png,并校验对应内容签名 + responses: + '200': {$ref: '#/components/responses/MetadataOrError'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + operationId: readDispatchEvidenceMetadata + summary: 按本排车与证据类别联合校验并读取元数据 + responses: + '200': {$ref: '#/components/responses/MetadataOrError'} + /internal/file/v1/business-files/{tenantId}/{businessType}/{businessId}/{fileId}/content: + parameters: + - {$ref: '#/components/parameters/TenantId'} + - {$ref: '#/components/parameters/BusinessType'} + - {$ref: '#/components/parameters/BusinessId'} + - {$ref: '#/components/parameters/FileId'} + get: + operationId: downloadDispatchEvidence + summary: 按完整复合归属读取文件内容 + responses: + '200': + description: >- + 成功返回真实文件;错误返回 application/json 统一业务错误。 + A0400 表示作用域、ID或文件格式无效,A0403 表示内部令牌无效, + A0404 表示文件不存在、非有效文件或复合归属不匹配。 + headers: + Cache-Control: {schema: {type: string, enum: [no-store]}} + Content-Disposition: {schema: {type: string}, description: 成功时为attachment及UTF-8文件名} + Content-Length: {schema: {type: integer, format: int64}, description: 成功时为真实文件字节数} + content: + application/pdf: {schema: {type: string, format: binary}} + image/jpeg: {schema: {type: string, format: binary}} + image/png: {schema: {type: string, format: binary}} + application/json: {schema: {$ref: '#/components/schemas/ErrorEnvelope'}} +components: + securitySchemes: + InternalServiceToken: {type: apiKey, in: header, name: X-Internal-Service-Token} + parameters: + TenantId: + name: tenantId + in: path + required: true + description: 两类排车证据只属于平台,客户作用域一律拒绝 + schema: {type: string, enum: ['0']} + BusinessType: + name: businessType + in: path + required: true + schema: {$ref: '#/components/schemas/DispatchEvidenceType'} + BusinessId: + name: businessId + in: path + required: true + description: 排车内部正数Long ID,上限9223372036854775807;Lastleg以标准十进制字符串传入 + schema: {$ref: '#/components/schemas/PositiveLongString'} + FileId: + name: fileId + in: path + required: true + schema: {$ref: '#/components/schemas/PositiveLongString'} + responses: + MetadataOrError: + description: >- + 统一JSON响应,业务成功code为200。A0400为无效平台作用域、ID、操作者或文件; + A0403为内部服务令牌无效;A0404为文件不存在、非ACTIVE、已删除或复合归属不匹配; + S0503为存储不可用,S0500为元数据保存失败等内部异常。 + content: + application/json: + schema: + oneOf: + - {$ref: '#/components/schemas/MetadataEnvelope'} + - {$ref: '#/components/schemas/ErrorEnvelope'} + schemas: + PositiveLongString: + type: string + pattern: '^[1-9][0-9]*$' + maxLength: 19 + description: 正数Long的十进制字符串,值不得超过9223372036854775807 + DispatchEvidenceType: + type: string + enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE] + MetadataEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, enum: ['200']} + message: {type: string} + data: {$ref: '#/components/schemas/Metadata'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + Metadata: + type: object + required: [fileId, ownerScope, tenantId, businessType, businessId, fileName, sha256, contentType, sizeBytes, createdAt] + properties: + fileId: {$ref: '#/components/schemas/PositiveLongString'} + ownerScope: {type: string, enum: [PLATFORM]} + tenantId: {type: string, enum: ['0']} + businessType: {$ref: '#/components/schemas/DispatchEvidenceType'} + businessId: {$ref: '#/components/schemas/PositiveLongString'} + fileName: {type: string, minLength: 1, description: 清理路径和控制字符后的原始文件名} + sha256: {type: string, pattern: '^[0-9a-f]{64}$'} + contentType: {type: string, enum: [application/pdf, image/jpeg, image/png]} + sizeBytes: + type: string + pattern: '^[1-9][0-9]*$' + description: 实际字节数,范围1到10485760;公共Jackson配置将Long输出为字符串 + example: '1024' + createdAt: {type: string, description: File真实上传时间,LocalDateTime的ISO本地时间表示,无时区偏移} + ErrorEnvelope: + type: object + required: [code, data] + properties: + code: {type: string, enum: [A0400, A0403, A0404, A0422, S0500, S0503]} + message: {type: string} + data: {type: object, nullable: true, description: 业务错误为null;参数校验错误可携带字段信息} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} diff --git a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java new file mode 100644 index 000000000..2444e080c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java @@ -0,0 +1,245 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.controller.InternalBusinessFileController; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; +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 DispatchEvidenceFileTest { + private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE"; + private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE"; + private static final int MAX_BYTES = 10 * 1024 * 1024; + @TempDir Path root; + + /** 初始化真实 Lambda 查询列映射,使测试能逐列核对复合归属条件。 */ + @BeforeAll static void metadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "dispatch-evidence"), + FileObjectEntity.class); + } + + /** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var upload = pdf(); + for (long customer : new long[]{-1, 1, 900}) { + assertThatThrownBy(() -> service.upload(customer, type, "71", 7, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(customer, type, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(customer, type, "71", 501)).isInstanceOf(BizException.class); + } + for (String id : new String[]{"0", "-1", "DISPATCH-71", "../71", "9223372036854775808"}) { + assertThatThrownBy(() -> service.upload(0, type, id, 7, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, id, 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, id, 501)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> service.upload(0, type, "71", 0, upload)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, "71", 0)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 0)).isInstanceOf(BizException.class); + verifyNoInteractions(files, storage); + } + + /** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void storesPdfJpegPngAndRejectsEmptyUnsupportedAndSpoofedContent(String type) throws Exception { + var storage = storage(); + var category = BusinessFileCategory.require(type); + for (var file : new MockMultipartFile[]{pdf(), + new MockMultipartFile("file", "vehicle.jpg", "image/jpeg", image("jpeg")), + new MockMultipartFile("file", "driver.png", "image/png", image("png"))}) { + var saved = storage.store(0, "71", file, category); + assertThat(storage.read(saved.objectKey())).isEqualTo(file.getBytes()); + assertThat(saved.contentType()).isEqualTo(file.getContentType()); + assertThat(saved.size()).isEqualTo(file.getSize()); + assertThat(saved.sha256()).matches("[0-9a-f]{64}"); + } + for (var file : new MockMultipartFile[]{ + new MockMultipartFile("file", "empty.pdf", "application/pdf", new byte[0]), + new MockMultipartFile("file", "fake.pdf", "application/pdf", "plain text".getBytes(StandardCharsets.UTF_8)), + new MockMultipartFile("file", "fake.jpg", "image/jpeg", image("png")), + new MockMultipartFile("file", "fake.png", "image/png", image("jpeg")), + new MockMultipartFile("file", "fake.pdf", "text/plain", pdf().getBytes()), + new MockMultipartFile("file", "fake.pdf", "application/octet-stream", pdf().getBytes()), + new MockMultipartFile("file", "missing.pdf", null, pdf().getBytes())}) { + assertThatThrownBy(() -> storage.store(0, "71", file, category)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> storage.store(0, "71", null, category)).isInstanceOf(BizException.class); + } + + /** 10MiB 为包含边界,超限在读取内容或落盘前拒绝。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void acceptsExactlyTenMibAndRejectsOneByteOverBeforeReading(String type) throws Exception { + var storage = storage(); + var category = BusinessFileCategory.require(type); + byte[] content = Arrays.copyOf(pdf().getBytes(), MAX_BYTES); + var boundary = new MockMultipartFile("file", "boundary.pdf", "application/pdf", content); + var stored = storage.store(0, "71", boundary, category); + assertThat(Files.size(root.resolve(stored.objectKey()))).isEqualTo(MAX_BYTES); + var oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); + when(oversized.getSize()).thenReturn((long) MAX_BYTES + 1); + assertThatThrownBy(() -> storage.store(0, "71", oversized, category)) + .isInstanceOf(BizException.class).hasMessageContaining("10MB"); + verify(oversized, never()).getInputStream(); + verify(oversized, never()).transferTo(any(Path.class)); + } + + /** 上传生成独立不可变对象和真实元数据;替换引用不覆盖原文件。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void uploadsKeepPlatformDispatchScopeAndImmutableObjects(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = storage(); + var service = service(files, storage); + var ids = new AtomicLong(500); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(call -> { + FileObjectEntity row = call.getArgument(0); + row.setId(ids.incrementAndGet()); + return 1; + }); + var first = service.upload(0, type, "71", 7, pdf()); + var secondFile = new MockMultipartFile("file", "updated.pdf", "application/pdf", + "%PDF-1.7\nupdated".getBytes(StandardCharsets.US_ASCII)); + var second = service.upload(0, type, "71", 7, secondFile); + var rows = org.mockito.ArgumentCaptor.forClass(FileObjectEntity.class); + verify(files, times(2)).insert(rows.capture()); + assertThat(first.ownerScope()).isEqualTo("PLATFORM"); + assertThat(first.tenantId()).isZero(); + assertThat(first.businessType()).isEqualTo(type); + assertThat(first.businessId()).isEqualTo("71"); + assertThat(first.fileId()).isEqualTo(501L); + assertThat(second.fileId()).isEqualTo(502L); + assertThat(first.fileName()).isEqualTo("evidence.pdf"); + assertThat(first.contentType()).isEqualTo("application/pdf"); + assertThat(first.sizeBytes()).isEqualTo(pdf().getSize()); + assertThat(first.sha256()).matches("[0-9a-f]{64}"); + assertThat(first.createdAt()).isNotNull().isEqualTo(rows.getAllValues().get(0).getCreatedAt()); + assertThat(rows.getAllValues().get(0).getObjectKey()).isNotEqualTo(rows.getAllValues().get(1).getObjectKey()); + assertThat(storage.read(rows.getAllValues().get(0).getObjectKey())).isEqualTo(pdf().getBytes()); + assertThat(storage.read(rows.getAllValues().get(1).getObjectKey())).isEqualTo(secondFile.getBytes()); + verify(files, never()).updateById(any(FileObjectEntity.class)); + } + + /** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var row = new FileObjectEntity(); + row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71"); + row.setOriginalName("evidence.pdf"); row.setContentType("application/pdf"); + row.setObjectKey("business-files/0/71/evidence.pdf"); row.setSizeBytes(pdf().getSize()); + row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now()); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(boundValue(query, "status")).isEqualTo("ACTIVE"); + assertThat(boundValue(query, "deleted")).isEqualTo(false); + return Long.valueOf(0).equals(boundValue(query, "tenant_id")) + && type.equals(boundValue(query, "business_type")) + && "71".equals(boundValue(query, "business_id")) + && Long.valueOf(501).equals(boundValue(query, "id")) ? row : null; + }); + when(storage.read(row.getObjectKey())).thenReturn(pdf().getBytes()); + var metadata = service.metadata(0, type, "71", 501); + assertThat(metadata.fileId()).isEqualTo(501L); + assertThat(metadata.ownerScope()).isEqualTo("PLATFORM"); + assertThat(service.download(0, type, "71", 501).content()).isEqualTo(pdf().getBytes()); + verify(storage).read(row.getObjectKey()); + clearInvocations(storage); + String otherType = VEHICLE.equals(type) ? DRIVER : VEHICLE; + for (String candidate : new String[]{otherType, "FLEET_DRIVER_LICENSE", "FLEET_TRIP_OPERATION_EVIDENCE"}) { + assertThatThrownBy(() -> service.metadata(0, candidate, "71", 501)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + assertThatThrownBy(() -> service.download(0, candidate, "71", 501)) + .isInstanceOf(BizException.class).hasMessageContaining("归属不匹配"); + } + assertThatThrownBy(() -> service.metadata(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.metadata(0, type, "71", 502)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class); + verifyNoInteractions(storage); + } + + /** 内部服务令牌在业务访问前校验,不对未授权请求暴露元数据或文件。 */ + @ParameterizedTest + @ValueSource(strings = {VEHICLE, DRIVER}) + void invalidInternalTokenCannotUploadReadMetadataOrDownload(String type) { + var service = mock(InternalBusinessFileService.class); + var controller = new InternalBusinessFileController(); + ReflectionTestUtils.setField(controller, "expected", "dispatch-test-token"); + ReflectionTestUtils.setField(controller, "service", service); + for (String token : new String[]{null, "", "wrong-token"}) { + assertThatThrownBy(() -> controller.upload(token, 7, 0, type, "71", pdf())).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.metadata(token, 0, type, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> controller.download(token, 0, type, "71", 501)).isInstanceOf(BizException.class); + } + verifyNoInteractions(service); + } + + /** 从实际 SQL 绑定中取指定列的等值条件,缺少任一隔离条件直接失败。 */ + private static Object boundValue(LambdaQueryWrapper query, String column) { + var matcher = Pattern.compile("(? 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 -- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 -- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 -- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 -- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 -- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 -- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 -- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 -- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 -- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 -- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 -- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。 -- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 -- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 -- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。 -- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 -- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 - -## 国内货柜履约装柜与 PDA 资源结算 - -- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 -- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 -- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 -- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 -- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 -- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 -- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 -- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 -- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 -- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。 +- 平台接口核对目标客户、权限、版本、幂等、历史与审计;客户查询只使用认证客户。不得跨服务读库或在本模块复制 Order、Warehouse、Finance、File 的权威事实。 +- Mapper 显式 `@Mapper`,只用启动类根包扫描;新增/移动 Mapper 或扫描配置变化时,从后端根执行 `node scripts/mapper-scan-contract.mjs`。 +- Java 签名与行为变更分别遵循后端根的测试编译及直接受影响测试门禁;本模块测试从后端根执行 `mvn -pl firstleg -am test`。 +- 移包或跨切片依赖变化时,从后端根执行 `node --test firstleg/scripts/analyze-firstleg-dependencies.test.mjs` 并核对内部模块化基线;禁止用基线更新掩盖临时循环依赖。 +- 对外 API、数据库、公共签名、事件或权限变化按后端根对应门禁核对直接消费者,不为文档整理启动 Java、数据库、HTTP 或部署。 +- 本文件或详细索引变更后,统一执行一次 `./scripts/agents-index-contract.ps1`;详细条款迁移不得丢失业务语义,不提高读取预算掩盖增长。 ## 全量验收导航 -本模块承载订单履约、入库测量计费、订单编排、柜次、排柜、订舱、拖车、PDA 和 `LOADED` 门禁等全量链路;按本次验收范围读取对应业务域文档、Controller、DTO、OpenAPI、当前 SQL 定义和调用方,不以历史规划终点阻断联调。PDA 按 uni-app 在线更新安排开发与验收,不另设设备型号门禁。 +本服务覆盖测量计费、订单编排、装柜出仓及直接事件链。验收按受影响链路进入详细索引和正式业务域文档;PDA 设备证据与服务端合同分别核对,不以历史规划终点限制联调。仅作索引调整时运行离线门禁。 diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md new file mode 100644 index 000000000..f161fd472 --- /dev/null +++ b/firstleg/docs/business-index.md @@ -0,0 +1,107 @@ +# Firstleg 业务详细索引 + +本文件保存当前入口、业务边界与修改细则,作为 `../AGENTS.md` 的按需补充,不是历史归档。状态、权限、计价、资源或事件任务必须核对对应条款;不要求普通定位任务全文读取。 + +所有代码、SQL、脚本及 `../`、`../../` 路径均以 `firstleg/` 服务目录起算;业务包简写相对 `src/main/java/com/baiyue/firstleg/`,不相对于本文件所在的 `docs/`。从后端根用 `rg -n -F '类名或业务词' firstleg/docs/business-index.md` 读取命中行和相关规则段。 + +维护时只修改所属条款,主 AGENTS 表只维护路由;接口以服务端合同为准,架构及运行状态沿工作区根指定主契约核对。 + +## 模块入口 + +预约日历到港只读:`portschedule/controller/PlatformPortScheduleController.java` 的 `/arrival-calendar`,沿 `PortScheduleService`、`PortScheduleMapper` 查询有效柜次及实际到港节点;合同 `src/main/resources/openapi/arrival-calendar-v1.yaml`,权限 `platform:appointment:list`。不在日历维护第二套船节点。 + +表内 `controller/`、`service/`、`mapper/`、`loading/` 等业务包路径相对 `src/main/java/com/baiyue/firstleg/`;`src/`、`docs/`、`scripts/` 和 `../` 开头路径相对本服务目录。裸文件名沿同一条目最近业务包定位,必要时在该包用 `rg --files` 查找,不从全仓猜路径。 + +事件人工重试:`controller/EventOperationsController.java` 提供内部 `/internal/firstleg/event-operations/{entryId}` 查询及 `/retry` 写入,只允许装柜完成与船节点事件;独立 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可修改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务提交,脚本见根 `scripts/event-outbox-operations.mjs`。 + +柜次号与散货托盘码:`service/FirstlegDocumentNumberGenerator.java` 为新柜生成全局连续且不按日期重置的 `D0001`、`D0002` 柜次号;`service/LoadingPalletLabelService.java`、`mapper/LoadingPalletLabelMapper.java`、`controller/PlatformLoadingListController.java` 负责散货托盘码,契约为 `src/main/resources/openapi/loading-lists-v1.yaml` 的 `/pallet-labels/prepare` 与 `/pallet-labels/print`。仅在计划 `LOADING/COMPLETED` 且真实柜号有效时,为非一件代发的 FBA 明细按平台柜次号、FBA 地址内部编号、三位序号生成冻结标签;托盘码不依赖可跨提单复用的真实柜号,内部编号从 Warehouse 启用主数据读取,缺失或同柜重复时拒绝生成。重复生成须核对已有标签与明细一致,冲突直接拒绝。全部或按仓指定张数打印统一调用 Print `/internal/print/v1/pdfs/fba-pallet-labels`,模板 `FBA_PALLET_A4_LANDSCAPE_V1` 每托一页 297×210 mm,顶部为 SCK8 等 FBA 仓码,条码和底部文字为完整托盘号,与海外补打共用版式;装柜完成通过 `LoadingCompletedOutboxService.java` 传递来源托盘。 + +| 目标 | 入口 | +|---|---| +| 内部模块化基线与依赖分析 | `docs/firstleg-internal-modularization-baseline.md`、`scripts/analyze-firstleg-dependencies.mjs`、`scripts/analyze-firstleg-dependencies.test.mjs`、`src/test/resources/architecture/firstleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | +| 履约任务/分配 | `controller/PlatformFulfillmentAssignmentController.java`、`service/FulfillmentTaskService.java` | +| 客户工作台筛选范围 | `service/MerchantWorkbenchFilterService.java`、`mapper/MerchantWorkbenchFilterMapper.java`、`dto/MerchantWorkbenchFilters.java`;内部入口 `InternalInboundOrderController` 的 `/unified-orders/workbench-filters`,按客户只读计算履约、费用待确认与最新已装柜计划的报关待上传订单范围,Order 负责最终筛选和分页 | +| 入库单查询/聚合 | `controller/PlatformInboundOrderController.java`、`service/InboundOrderQueryService.java` | +| 入库单扣货、履约门禁与凭证 | `service/InboundHoldService.java`、`InboundHoldGuard.java`、`InboundHoldEvidenceService.java`、`client/InboundHoldFileClient.java`;凭证固定按客户、入库单和 `INBOUND_HOLD_EVIDENCE` 精确归属,只有进入不可变扣货历史的文件可下载 | +| Order 申报同步与渠道变更履约门禁 | `controller/InternalInboundOrderController.java` 的 `/order-channel-changes/validate`、`/order-declaration-updated`,`service/OrderDeclarationSyncService.java`、`mapper/OrderDeclarationSyncMapper.java`、`dto/OrderDeclarationSync.java`;契约 `src/main/resources/openapi/firstleg-v1.yaml`,只接受内部服务令牌 | +| 平台测量/OCR 与箱规计费快照 | `controller/PlatformInboundMeasurementController.java`、`service/InboundMeasurementService.java`、`service/CartonMeasurementService.java`、`service/CustomerDimensionWeightingService.java`;手工单箱测量保存实际尺寸、可选外箱修正、`chargeRuleId` 与 `coefficientMode`,计算结果以服务端返回值为准 | +| 箱唛箱码生成、打印与测量绑定 | 审核建单生成钩子 `controller/InternalInboundOrderController.java`;生成/解析 `service/InboundCartonLabelService.java`、`repository/InboundCartonLabelRepository.java`;平台与客户打印 `controller/PlatformInboundOrderController.java`、`MerchantInboundCartonLabelController.java`、`service/InboundCartonLabelPrintService.java`。一件代发箱唛以 `fl_inbound_carton_label` 表示物理箱,`fl_inbound_carton_label_sku` 冻结箱内一条或多条 SKU;生成时经 `client/OrderCargoClient.java` 获取。缺 SKU 时禁止生成;所有头程箱唛打印面均不显示 SKU,SKU 只保留在业务快照及一件代发 A4 托盘信息单;`box_code` 与二维码内容仍只携带稳定箱码。基线/升级 SQL 为 `src/main/resources/db/phase2-inbound-carton-label.sql`、`phase2-carton-label-versioning-migration.sql`、`../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 一件代发测量后 PDA 清点组托 | `controller/PlatformDropshipPalletizationController.java`、`service/DropshipPalletizationService.java`、`mapper/DropshipPalletizationMapper.java`;测量审核通过进入 `WAIT_PALLETIZE`,按预计托数预生成 `DP` 托盘码并下发独立任务,不创建国内库存;托盘信息单固定为每托一页 A4,包含托盘 Code 128 一维码、入仓码、总箱数与 SKU/商品名称/箱数/实点数量,不展示作业状态。PDA 逐箱清点,支持先扫箱或先扫托、归托、逐托封托和在未进入装柜计划前解除封托;合托解封时同步恢复所有参与任务及入仓单。预计托数与已生成托盘数不一致时禁止作业,全部箱和实际参与托盘完成后进入 `WAIT_SPLIT`。契约 `src/main/resources/openapi/dropship-palletization-v1.yaml` | +| 墨家设备测量上传 | `controller/OpenApiInboundMeasurementController.java`、`dto/MojiaMeasurementDtos.java`、`service/MojiaMeasurementOpenApiService.java`、`security/MeasurementRequestAuthenticator.java`;接口契约见 `src/main/resources/openapi/firstleg-v1.yaml` 与 `../../docs/墨家设备测量上传接口说明-V3.md` | +| 称重确认与整票库位块 | `service/MeasurementLocationOccupationService.java` 的 `ensureForConfirmedWeight/ensureForSubmission`、`InboundMeasurementService.java`、`MeasurementSubmissionService.java`、`client/WarehousePalletLocationClient.java`;Warehouse 块服务见 `../warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java`,接口与上架引导见 `src/main/resources/openapi/firstleg-v1.yaml` | +| 审核与初始计价 | `controller/PlatformInboundMeasurementReviewController.java`、`service/MeasurementReviewService.java`、`service/InitialInboundPricingService.java`、`dto/MeasurementApprovalResponse.java` | +| 客户四档箱规与默认算法 | `controller/PlatformCustomerDimensionConfigController.java`、`service/CustomerDimensionConfigService.java`、`repository/CustomerDimensionConfigRepository.java`;固定 `SMALL/MEDIUM/LARGE/EXTRA_LARGE`,默认算法只允许 `ADD/MULTIPLY` | +| FBA 主数据/计费与客户规则 | `controller/PlatformFbaMasterDataController.java`、`PlatformFbaPricingController.java`、`service/FbaPricingService.java`、`repository/FbaPricingRepository.java`;客户规则可保存多个版本,但每个客户仅一条默认规则,计算请求可提交已确认的重量/体积选择值 | +| 客户专属价格簿、柜级阶梯运费与调价边界 | `controller/PlatformPriceBookController.java`、`service/PlatformPriceBookService.java`、`pricing/ActivePriceBookEstimator.java`、`service/FbaPricingService.java`、`pricing/ContainerTierAmountAllocator.java`;公共价格簿固定 `customerId=0`,客户专属版本按客户独立发布与替换,计价优先专属 ACTIVE、缺失才回退公共 ACTIVE。`REGION_WEIGHT_TIER` 只对同柜同客户中当前费用快照同为区域重量阶梯的有效成员聚合实际计费量命中一个阶梯,再按这些成员的计费量守恒分摊;同柜允许与 `FBA_WAREHOUSE_RATE` 等固定模型混装,固定模型成员不得参与阶梯聚合或被重算。装柜成员变化调用 `recalculateUnlockedContainerCustomerFees`,已确认/已发送或客户已确认的版本不得改写。人工调整以系统基准量为中心限制上下 10%(含边界),保存版本、原因和二次确认;一件代发 `WAIT_PALLETIZE` 可在预装确认前试算,`markInboundCalculated` 只更新费用并保留该托盘化状态 | +| 入库费用明细 | `controller/PlatformInboundFeeDetailController.java`、`service/InboundFeeDetailService.java`、`dto/InboundFeeDetailDtos.java`;`PRIVATE_LAST_MILE_DELIVERY` 是独立私人仓尾程派送费,只允许 `destinationType=PRIVATE`,按稳定费用代码校验而不按中文名判断;Admin 消费端见 `../../apps/admin-web/src/modules/unified-orders/api/inboundFeeDetails.ts` | +| 入库/柜次直接已付快照与凭证校验 | `service/FeePaymentSnapshotValidator.java`、`client/FeePaymentVoucherFileClient.java`;写入入口分别为 `InboundFeeDetailService.java`、`ContainerFeeDetailService.java`,File 业务类型固定 `FINANCE_VOUCHER` | +| 首页实时指标、客户运营统计/在途柜量 | 首页物理在途柜 TEU 与待清关票数从 `dashboard/PlatformDashboardMetricsController.java` 进入,契约见 `src/main/resources/openapi/dashboard-metrics-v1.yaml`;客户维度统计为 `customerstatistics/adapter/in/web/PlatformCustomerTransitStatisticsController.java`、`customerstatistics/application/CustomerTransitStatisticsService.java`,契约见 `src/main/resources/openapi/customer-statistics-v1.yaml` | +| 费用项目与价格簿 | `controller/PlatformFeeItemController.java`、`PlatformPriceBookController.java`、`MerchantPriceBookController.java` | +| 客户入库结果与头程费用确认 | `controller/MerchantInboundBillingController.java`、`service/MerchantInboundBillingService.java`、`FbaPricingService.java`;查询与确认接口为 `/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing`、`/confirm`,契约 `src/main/resources/openapi/firstleg-v1.yaml` | +| 价格簿查询内部切片 | `pricebookquery/application/port/in/PriceBookQueryUseCase.java`、`application/PriceBookQueryService.java`、`application/port/out/PriceBookQueryReader.java`、`adapter/out/persistence/PriceBookQueryMapper.java`;查询行模型位于 `application/model/PriceBookQueryRows.java` | +| 财务费用来源快照 | `controller/InternalFinanceFeeSourceController.java`;入库费用与柜次费用分别按来源 ID、版本向 Finance 提供不可变快照 | +| 柜次费用明细与跨客户分摊组 | `controller/PlatformContainerFeeDetailController.java`、`service/ContainerFeeDetailService.java`、`dto/ContainerFeeDetailDtos.java`;跨客户公共柜费使用 `allocation-groups` 按柜内真实客户和订单原子分摊,入仓 ID/入仓码同时填写或同时留空;各行金额合计必须等于原始柜费,审核、驳回、调整、作废均整组处理,单行入口拒绝分摊组;契约 `src/main/resources/openapi/container-fees-v1.yaml`;Admin 消费端见 `../../apps/admin-web/src/modules/loading-lists/api/containerFees.ts` | +| 财务整柜成本分摊快照 | `loading/controller/InternalFinanceCostSnapshotController.java`、`loading/service/FinanceCostSnapshotService.java` | +| 分柜、预装、真实柜号后补与正式装柜清单生命周期 | `split/controller/PlatformSplitPreloadController.java`、`controller/PlatformLoadingListController.java`、`service/LoadingListService.java`;契约 `src/main/resources/openapi/loading-lists-v1.yaml`,详情阶段先查 `/loading-plans/{planId}/lifecycle`。创建柜次时原子生成唯一预装计划,加入入仓码明细时同步创建入库预装聚合与关联,不设推送步骤;同一 `planId` 在 `EDITING/LOADING` 返回 `PRELOAD`,`COMPLETED` 返回 `LOADING_LIST`。订单协作备注以 Order 为权威,`OrderRemarksSyncService` 负责订单列表与全部未冻结预装明细双向同步,完成装柜后保留冻结快照。订舱关联、预装确认和 PDA task 创建不依赖真实集装箱号;可在 `EDITING/LOADING` 首次补录,最终 PDA 完成前必须存在;已有值纠正仅限 `EDITING` 且任务未领取、无物理作业事实 | +| 同柜不可拆组 | `loading/coload/controller/PlatformColoadGroupController.java`、`service/ColoadGroupService.java`、`persistence/ColoadGroupMapper.java`;契约 `src/main/resources/openapi/coload-groups-v1.yaml`。请求按订单 ID 建组,服务端允许待审核、待测量/测量中、待分柜及装柜中订单,冻结每票全部有效入仓片段与满箱数;成员全部未进柜时创建未锁定组,成员已满箱进入同一装柜计划时直接创建锁定组,部分占用或跨柜时拒绝;仅要求同国内仓、同渠道,允许跨客户及不同目的仓港;分配、移除、取消均保持整组原子 | +| 货柜与全局智能排柜 | `controller/PlatformContainerController.java`、`PlatformSmartLoadingController.java`、`service/SmartLoadingService.java`、`repository/SmartLoadingRepository.java`、`client/PendingReviewOrderClient.java`、`loading/`;全局候选合并本服务履约投影与 Order `/internal/order/v1/pending-review-orders` 权威待审核页,工作台柜次从 `/inbound-candidates`、`/containers` 进入;普通箱数、待审核占位、同柜组和全部未拣移除分别走 `/inbound-assignments`、`/pending-review-assignments`、`/group-assignments`、`/unpicked-items`,明细规格拆分留在服务端 | +| 货柜资料、PDA 证据与业务总览 | `controller/PlatformContainerController.java` 的 `/{id}/overview`、`/{id}/documents/{documentType}`,聚合 `mapper/ContainerOverviewMapper.java`;契约 `src/main/resources/openapi/phase3-container-overview-v1.yaml`,四类资料固定为目的港清关、国内出口报关、DO、PU 的当前唯一版本 | +| PDA 拣货与装柜、证据幂等上传 | `controller/PlatformLoadingPdaController.java`、`loading/controller/PlatformLoadingDeliveryController.java`、`loading/service/CabinetLoadingEvidenceService.java`;证据写入和 multipart 上传均先以任务内幂等键匹配请求哈希,上传哈希覆盖仓库、版本、证据类型、锁码、备注、文件名、媒体类型、大小与文件内容;完全重放返回当前视图且不得再次上传 File,相同键不同内容返回冲突 | +| 装柜完成直连事件 | `service/LoadingCompletedOutboxService.java`、`LoadingCompletedOutboxRelay.java`、`repository/LoadingCompletedOutboxRepository.java`、`mapper/LoadingCartonManifestMapper.java`、`loading/mapper/CabinetLoadingEvidenceMapper.java`、`client/LastlegEventClient.java`;当前发布 V3,从 `fl_inbound_order.destination_type` 冻结 `orders[].destinationType`(`FBA` 或 `OVERSEAS`),OVERSEAS 按最终实装与稳定箱码冻结,FBA 可仅冻结箱数,并冻结物理柜五类有效装柜照片引用;真实生产验收 `src/test/java/com/baiyue/firstleg/service/TaillegRealProducerAcceptanceTest.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 海外卸柜完成事件回写 | 内部入口 `controller/InternalOverseasContainerEventController.java`、消费者 `service/OverseasContainerEventConsumer.java`、Inbox `entity/ContainerEventInboxEntity.java` 与 `mapper/ContainerEventInboxMapper.java`;事件模型见 `../common-event-contract/AGENTS.md` | +| 海外暂扣状态回写 | 同一内部事件入口、消费者 `service/OverseasInboundOrderDetainedConsumer.java`;按来源入仓单 ID 更新 `fl_inbound_order.hold_flag/current_hold_reason`,复用跨区 Inbox 并写 `fl_inbound_hold_record` | +| 海外派送完成回写 | 同一内部事件入口、消费者 `service/OverseasDeliveryCompletedConsumer.java`;按来源入仓单、订单、入仓码和 FBA 仓校验后推进当前入仓单 `LOADED→DELIVERED`,同一 Order 全部有效入仓单完成后才经 `OrderFulfillmentCompletionClient` 推进 Order `COMPLETED` | +| 国内货柜履约海运订舱、资料/打印、关联候选与出仓前船期资料 | `oceanbooking/controller/PlatformOceanBookingController.java`、`oceanbooking/service/OceanBookingService.java`、`OceanBookingDocumentService.java`、`OceanBookingPrintService.java`、`oceanbooking/mapper/OceanBookingMapper.java`、`portschedule/controller/PlatformPortScheduleController.java`;文件经 `client/OceanBookingFileClient.java` 进入 File,关联候选 `/api/platform/v1/ocean-bookings/link-candidates`,契约 `src/main/resources/openapi/ocean-bookings-v1.yaml`,表结构见 `src/main/resources/db/phase3-ocean-booking-schema.sql` | +| 海外仓业务国内出仓后船节点定时查询 | `outbound/dispatch/service/OutboundDispatchService.java` 在确认出仓事务内幂等创建现有 `CarrierScheduleQuery`;编排与租约 `portschedule/service/CarrierScheduleQueryScheduler.java`,任务/十节点事实/异常事实/不可变 attempt `CarrierScheduleQueryService.java`,平台查询与人工补录入口 `PlatformCarrierScheduleQueryController.java` 的 `/{queryId}/manual-nodes`;停止只由实际 `EMPTY_RETURNED` 节点触发,不提供无节点停止入口;外部船司/轨迹调用经 `integration-service/`;标准见 `../../docs/海外仓业务/船节点标准化契约.md` | +| 已开船预约事项完整来源 | `portschedule/controller/InternalAppointmentWorkSourceController.java`、`service/AppointmentWorkSourceService.java`、`mapper/AppointmentWorkSourceMapper.java`;按完整来源版本游标提供所有实际开船或后续节点的有效货柜,保留无仓绑定与完成货柜;原始当地时间无时区时不得转UTC;契约 `src/main/resources/openapi/appointment-work-source-v1.yaml` | +| 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` | +| 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | +| 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | +| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | +| 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | +| 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | +| 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | +| 跨模型桥接 | `loading/bridge/controller/PlatformLoadingModelBridgeController.java` | +| 跨服务客户端 | `client/OrderCargoClient.java`、`WarehousePalletLocationClient.java`、`MeasurementImageClient.java`、`LoadingEvidenceFileClient.java`、`LastlegEventClient.java` | +| 仓库公共供应商快照 | `client/WarehouseSupplierClient.java`、`service/WarehouseSupplierSnapshotService.java`;报关、订舱和拖车分别消费 `customs-v1.yaml`、`ocean-bookings-v1.yaml`、`truck-appointments-v1.yaml` 中的供应商 ID,并由 Warehouse 返回服务端名称快照 | +| SQL/OpenAPI | `src/main/resources/db/phase2-*.sql`、`src/main/resources/db/phase3-*.sql`、`src/main/resources/openapi/`;部署统一 `../deploy/sql/头程数据库统一增量-MySQL8.sql` | +| 测试/验证 | `src/test/java/com/baiyue/firstleg/`;`mvn -pl firstleg -am test` | + +## 修改约束 + +- 移动包、拆分端口/适配器或引入新的跨切片依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-firstleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 平台接口必须校验目标客户、权限、乐观锁、幂等键、历史和审计;商户查询只能使用登录态客户。 +- 价格簿查询消费者依赖 `PriceBookQueryUseCase` 或稳定查询端口,不得反向依赖持久化 Mapper;移动旧查询类时同步更新生产调用、测试、Mock 与 import,避免同一职责在根 `repository/mapper` 和 `pricebookquery/` 双写。 +- 第三方回传必须签名、时间窗、防重放、请求哈希,并绑定订单货物和 File 证据;不得信任外部客户字段。 +- 稳定箱码映射以入库快照和 Order 货物明细为事实;订单审核创建入库单时必须幂等生成,打印接口只复用或补齐同一映射,用户是否下载箱唛不得决定箱码有效性。手工测量、墨家设备和 PDA 扫码统一通过 `InboundCartonLabelService` 校验客户、入库单、货物明细、总箱数/下标和有效批次,不得在测量时临时伪造箱码或接受跨客户、跨入库箱码。 +- 一件代发以 `fl_inbound_carton_label_sku` 作为物理箱内不可变 SKU 快照;一个稳定 `box_code` 可以对应多条 SKU,PDA 扫箱后必须一次清点全部 SKU,不得将 SKU 塞入二维码或以 SKU 替代箱码。实际打托后的查询必须能按“现场托盘 -> 客户 -> SKU -> 箱数”聚合;同一国内作业仓的多票允许共用一个开放托盘,单票全部归托不自动封托,只有参与该票装箱的开放托盘阻止该票完成,预生成但未使用的空托盘不得阻塞;禁止按货名、箱规或箱号猜测 SKU。修改本链路时同步检查 `OrderCargoClient`、Order 内部货物接口及 PDA 打托实现。 +- 测量审核通过后直接进入 `WAIT_SPLIT`,费用仅作为独立初算和人工维护结果,不得阻塞或回退订单主流程;计费量、单价和附加费调整必须版本化并由服务端重算金额。状态或 DTO 变更时逐个检查 Order/Warehouse/File/Integration 消费者。 +- 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 +- 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 +- Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 +- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 +- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 +- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 +- 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 +- 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 +- 国内出口报关在装柜完成后启用,不阻塞物理 `LOADED`。整柜 `fl_customs_container.status=COMPLETED` 才能推进“待发区”门禁;报关完成后以系统身份、确定性幂等键立即尝试确认待发区,其他物理/运输事实未齐时由受控定时补偿重试。待发区之后才推进离仓、进港和船期;甩柜/改配只在原柜船期上人工修改船信息并保留历史,不回写报关资料、回执或客户确认,禁止部分入仓成功或重复创建离仓/船司查询事实。 +- 国内出仓确认以真实 `containerBookingId` 聚合全部入仓绑定并锁定校验;装柜证据按物理货柜只计算一次,PDA `taskId` 仅用于来源审计和任务授权,File 所有权仍固定 `tenantId=0 + containerBookingId`;拖车状态和独立船期只作参考。全部门禁在同一事务通过后推进所有技术行、记录一条可见历史并幂等创建 `CarrierScheduleQuery/DEPARTED_WAREHOUSE`,不允许单入仓局部成功。 +- 手工船节点的预计时间只追加事实,不推进 `latestStandardNode`;携带 `actualAt` 时标准节点序号必须严格大于最新实际节点,重复、相同或回退节点返回冲突。已审核实际时间修正只能更新同一事实的时间/证据,不得制造第二条同节点实际事实或绕过单调推进。 +- 海外卸柜完成事件只接受 `OVERSEAS -> CN` 的 `OVERSEAS_CONTAINER_UNLOADED` V1,并通过内部服务令牌进入。消费者必须校验信封、Payload 哈希、来源货柜和时间顺序,以 event ID + Payload 哈希幂等,使用事件聚合版本阻止乱序覆盖;预计报空时间固定按卸柜完成时间加 72 小时计算,实际报空时间可为空。 +- Firstleg 只拥有业务费用及履约事实;Finance 拥有费用入池、账期、账单、收款、成本分摊结果和财务状态。修改费用来源版本、发布条件、柜次归属或体积快照时同步检查 `../finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java`、同目录 `FirstlegCostSnapshotClient.java` 及双方契约测试,不在 Firstleg 复制财务状态机。 +- `DIRECT_PAID` 仅适用于 `PAYABLE`,必须同时保存 `paidAt`、合法 `paymentMethod`,并至少提供 `paymentReferenceNo` 或一个当前客户范围内的 `FINANCE_VOUCHER`;`RECEIVABLE` 与 `ON_ACCOUNT` 不得残留付款字段。创建、调整和审核前均通过 `FeePaymentSnapshotValidator` 失败关闭复核。 + +## 国内货柜履约装柜与 PDA 资源结算 + +- 新增模拟柜只保存智能装柜的 9 类计划渠道 channel_group_code;未关联已放舱订舱时 channel_code/channel_name 为空,关联后才冻结订舱的实际渠道。智能装柜全局工作台和订舱关联候选均按计划渠道组匹配;服务端把业务渠道分组映射为实际渠道,并分别分页查询候选与 `WAIT_LOADING/EDITING/LOADING` 柜次;候选支持按创建日期和预计到货日期闭区间筛选,状态筛选使用最多四项的 `orderStatuses`,未传表示 `PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING` 全部未完成状态。状态只用于搜索和展示,能否加入以响应 `canAssign` 为准,由有效柜次占用、剩余箱数、容量、组锁和版本等事实决定;人工加入允许订单国内仓及渠道与柜次不同,智能推荐允许跨国内仓但仍只选择柜次渠道。容积和重量均允许达到柜型额定值的 150%,超过即拒绝。待审核事实必须经 `PendingReviewOrderClient` 重新核验 Order 权威 `SUBMITTED` 订单,待审核或待测量可先占位,但确认预装和启动 PDA 前仍必须进入 `WAIT_SPLIT`。计划明细通过 `totalPackageCount/remainingPackageCount` 返回可调总量和余量:测量前取 Order 原始申报箱数,进入 `WAIT_SPLIT/LOADING` 后取最新已审核测量箱数;只读补充失败时保守回退当前已分配数,调整写入仍重新查询并校验权威数量。同柜不可拆组只有全部成员命中状态集合时才返回,且必须成员完整、满箱并作为单个分页与分配单元,不能跨页或拆分。一次性移除未拣明细时,普通未拣项直接移除;同柜组仅全组未拣时整组移除,任一成员已拣则整组保留,单事务只递增一次计划版本。推荐顺序按入库时间先进先出。真实集装箱号允许在关联已放舱订舱时暂缺,但确认预装及创建 PDA 任务前必须补齐;历史已建但缺号的 PDA 任务可在预装详情首次补录,补录后按柜次当前真实集装箱号恢复作业。装柜计划状态为 `EDITING -> LOADING -> COMPLETED`,PDA 全部明细处理完成后自动完成。 +- 预装进入 `CONFIRMING/CONFIRMED` 只固化确认快照,不得锁死尚未完成柜次的智能装柜分配;装柜计划仍处于 `EDITING/LOADING` 时可继续新增,未产生实物拣货的明细可调整或移除,已拣明细保持锁定。一件代发智能装柜候选以每个未被有效柜次占用的已封托托盘为独立分页、展示和分配单位;同一入仓单有多托时必须逐托返回,其中一托已分配不得隐藏或禁用其余未分配托盘。智能推荐必须逐个调用整托分配,容量不足时跳过整托,不得回退按箱分配;全局工作台传入当前 `customerId/inboundCode` 筛选范围时,服务端只能推荐同时命中的候选,同一次推荐按受影响客户统一重算费用,不得逐托重复重算。单行删除必须按该行 `palletId` 整托移除。装柜完成事件必须按计划项 `palletId` 精确冻结每托箱唛与 SKU 清单,不得仅按入仓单或货物明细关联而造成多托交叉重复。 +- 客户入库费用只在登录客户范围、测量审核通过、`feeStatus=SENT` 且存在已发布明细时可见;确认必须具备 `merchant:order:edit`,携带当前 `expectedFeeVersion` 与幂等键。成功后记录确认成员和时间;客户未确认或该票尚未装柜完成时,平台仍可修改箱规规则、生成新费用版本并重新发送,新版本必须清空旧版客户确认快照。仅当“客户已确认且该票已装柜完成”同时成立时最终冻结;装柜完成不得代替客户自动确认。旧版本、未发送或重复异参确认均返回冲突,不做兼容覆盖。 +- PDA 普通散箱拣货以 `fl_inbound_carton_label.box_code` 对应的稳定箱唛箱码为扫描身份;审核建单、重复打印、手工测量和墨家测量复用同一映射,版本字段只用于并发控制,不进入扫码文本。一个有效且未重复的箱码扫描表示拣出一箱,不得以 SKU、商品件数、入库单号或页面展示字段替代箱码。一件代发已封托的 `SEALED_PALLET` 是不可拆整托单元,PDA 可扫描计划快照中的 `palletNo`、用该托任一成员 `boxCode` 反查所属整托,也可在明细卡片手工确认整托;三种入口最终都必须携带权威 `palletNo` 以 `PICK_PALLET` 一次性提交,不能按成员箱逐箱累计、部分拣货或跳拣。PDA 首页从 Warehouse 主档选择启用国内仓,一件代发与装柜待办列表必须按所选 `warehouseId` 服务端过滤;装柜任务内部仍允许跨国内仓,仓库与渠道限制统一由上游装柜编排负责,PDA 只按已生成任务读取各入仓单自身的 `CONFIRMED` 库位资源,不得再用柜次仓库过滤任务明细、库存快照或拣货写入。任务下发与提交契约见 `src/main/resources/openapi/phase3-pda-convergence-v1.yaml`。 +- PDA 部分拣货生成二级子入库单时,数量必须守恒:`已拣数量 + 子单数量 = 拣货前主单可用数量`。子单只允许一层,不能继续拆分。 +- 首张称重图片确认时按 `max(已确认称重图片数, ceil(有效体积 / 默认托盘容量))` 尽力预留并确认整票库位块;有效体积先取入库单已落地的正数实测体积,否则对最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值,再无有效值才查询 Order 最新客户申报体积,全部无效则拒绝。后续确认绑定最小空槽,所需槽数增长时通过 Warehouse `/blocks/expand` 原子追加,既有 `blockId/slotNo/reservationId/locationId/sharedOverflow` 不得变化。库位指引失败只记录警告,不阻断称重确认;测量提交后以最新已确认图片调用 `ensureForSubmission` 再次补齐和校验,失败同样不回滚已提交的测量主流程。Firstleg 必须显式保存 `weightResultId -> blockId/slotNo/reservationId/locationId/locationCode`,按 `confirmedAt + weightResultId` 稳定顺序绑定,禁止按列表下标推断;普通连续位不足的新增槽由 Warehouse 放入共享 A0,不阻断确认。 +- 删除称重图片只解除图片和托盘快照绑定,将槽位恢复为 `RESERVED_EMPTY`;槽位和整票块继续归当前入仓码。解绑失败时不得调用文件删除,文件删除失败时本地解绑和重量结果删除必须回滚。 +- 头程快照必须保存 Warehouse `blockId + slotNo + reservationId`;A0 可被多个整票块共享 `locationId`,业务资源身份始终使用 `reservationId`,不得把 `locationId` 当作唯一标识。 +- PDA 部分拣货不得释放块内单槽;存在余量子单时通过 `/internal/warehouse/v1/pallet-locations/blocks/transfer` 将整段转移给子单,源单与余量子单可各自保留同一块槽及称重快照,因此 `fl_inbound_pallet_allocation` 的槽位与称重唯一键必须包含 `inbound_id`。整票全部拣完时只通过 `/internal/warehouse/v1/pallet-locations/blocks/release` 一次性释放全部普通位和 A0 预约,禁止恢复逐槽 `settlements/release-selected` 协议。 +- 数据库字段变更直接维护当前模块基线与 `../deploy/sql/头程数据库统一增量-MySQL8.sql`;已有 `phase3-pda-release-allocation-migration.sql` 只作为当前结构来源,不为历史开发数据再新增兼容迁移。 + diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java index 019125cae..5f56392b0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/FeeItemDtos.java @@ -4,6 +4,9 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.Digits; +import java.math.BigDecimal; /** 费用项目请求模型。 */ public final class FeeItemDtos { @@ -21,26 +24,9 @@ public final class FeeItemDtos { @NotNull Boolean warehousePayable, @NotNull Boolean warehouseReceivable, @NotNull Boolean allocationFee, - @Size(max = 500) String description + @Size(max = 500) String description, + @DecimalMin("0.00") @Digits(integer = 10, fraction = 2) BigDecimal defaultPrice ) { - public SaveRequest(String category, String tradeScope, String feeCode, - String feeTypeName, Boolean pickupPayable, - Boolean pickupReceivable, Boolean warehousePayable, - Boolean warehouseReceivable, Boolean allocationFee, - String description) { - this(category, tradeScope, feeCode, null, feeTypeName, pickupPayable, - pickupReceivable, warehousePayable, warehouseReceivable, - allocationFee, description); - } - - /** Backward-compatible constructor for callers predating the stable fee code. */ - public SaveRequest(String category, String tradeScope, String feeTypeName, - Boolean pickupPayable, Boolean pickupReceivable, - Boolean warehousePayable, Boolean warehouseReceivable, - Boolean allocationFee, String description) { - this(category, tradeScope, null, null, feeTypeName, pickupPayable, pickupReceivable, - warehousePayable, warehouseReceivable, allocationFee, description); - } } public record StatusRequest( diff --git a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java index 2cc751bb3..414877558 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/entity/FeeItemEntity.java @@ -1,11 +1,14 @@ package com.baiyue.firstleg.entity; import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.FieldStrategy; +import com.baomidou.mybatisplus.annotation.TableField; import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import com.baiyue.common.core.model.BaseEntity; import lombok.Getter; import lombok.Setter; +import java.math.BigDecimal; /** 费用项目主数据。 */ @Getter @@ -20,6 +23,9 @@ public class FeeItemEntity extends BaseEntity { private String feeCode; private String supplierCategory; private String feeTypeName; + /** 业务选费时带入的默认价格,可清空且不限制实际成交金额。 */ + @TableField(updateStrategy = FieldStrategy.ALWAYS) + private BigDecimal defaultPrice; private Boolean pickupPayable; private Boolean pickupReceivable; private Boolean warehousePayable; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java index 6d7258f87..8948e0317 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FeeItemService.java @@ -101,6 +101,7 @@ public class FeeItemService { } } + /** 保存项目属性及可清空的默认价格,实际费用仍以业务录入金额为准。 */ private static void copy(FeeItemEntity value, SaveRequest request) { boolean payable = Boolean.TRUE.equals(request.pickupPayable()) || Boolean.TRUE.equals(request.warehousePayable()); @@ -111,6 +112,7 @@ public class FeeItemService { value.setSupplierCategory(payable ? FeeItemSupplierCategory.normalize(request.supplierCategory()) : null); value.setFeeTypeName(request.feeTypeName().trim()); + value.setDefaultPrice(request.defaultPrice()); value.setPickupPayable(request.pickupPayable()); value.setPickupReceivable(request.pickupReceivable()); value.setWarehousePayable(request.warehousePayable()); diff --git a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql index 1338459d4..9b3d5e4ec 100644 --- a/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql +++ b/firstleg/src/main/resources/db/phase2-fee-item-and-inbound-detail.sql @@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS fl_fee_item ( fee_code VARCHAR(50) NOT NULL COMMENT '稳定费用编码;平台公共主数据固定编码', supplier_category VARCHAR(16) NULL COMMENT 'PAYABLE供应商类别约束;为空时可选任意ACTIVE供应商', fee_type_name VARCHAR(128) NOT NULL COMMENT '费用类型名称', + default_price DECIMAL(12,2) NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改', pickup_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应付是否参与', pickup_receivable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '提柜应收是否参与', warehouse_payable TINYINT(1) NOT NULL DEFAULT 0 COMMENT '仓储应付是否参与', diff --git a/firstleg/src/main/resources/openapi/fee-item-v1.yaml b/firstleg/src/main/resources/openapi/fee-item-v1.yaml index 31ba1ec1d..b8f0e5097 100644 --- a/firstleg/src/main/resources/openapi/fee-item-v1.yaml +++ b/firstleg/src/main/resources/openapi/fee-item-v1.yaml @@ -102,6 +102,7 @@ components: feeCode: {type: string, maxLength: 50, pattern: '^[A-Za-z0-9_-]+$'} supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier} feeTypeName: {type: string, maxLength: 128} + defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;为空不自动带价,业务录入时可修改,不追改已有费用} pickupPayable: {type: boolean} pickupReceivable: {type: boolean} warehousePayable: {type: boolean} @@ -118,6 +119,7 @@ components: feeCode: {type: string} supplierCategory: {type: string, enum: [BOOKING, CUSTOMS, TRUCKING, CERTIFICATE], nullable: true, description: PAYABLE供应商类别约束;为空时可选任意ACTIVE Warehouse Supplier} feeTypeName: {type: string} + defaultPrice: {type: number, nullable: true, minimum: 0, maximum: 9999999999.99, multipleOf: 0.01, description: 默认价格CNY;仅用于业务选择费用项目时带入} pickupPayable: {type: boolean} pickupReceivable: {type: boolean} warehousePayable: {type: boolean} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java index 1f89e555e..d1e6b7f6b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FeeManagementContractTest.java @@ -96,6 +96,10 @@ class FeeManagementContractTest { Map schemas = stringMap(stringMap(document.get("components")).get("schemas")); assertThat(schemas).containsKeys("FeeItemSaveRequest", "FeeItemResource", "FeeItemPage", "FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse"); + for (String schema : List.of("FeeItemSaveRequest", "FeeItemResource")) { + assertThat(stringMap(properties(schemas, schema).get("defaultPrice"))) + .containsEntry("nullable", true).containsEntry("minimum", 0); + } for (String response : List.of("FeeItemApiResponse", "FeeItemPageApiResponse", "VoidApiResponse")) { assertThat(list(stringMap(schemas.get(response)).get("required"))).contains("timestamp"); assertThat(properties(schemas, response)).containsKeys( diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java index a0fdc7b00..1cf2d0953 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FeeManagementServiceTest.java @@ -110,6 +110,7 @@ class FeeManagementServiceTest { assertThat(created.getId()).isEqualTo(11L); assertThat(created.getAllocationFee()).isTrue(); + assertThat(created.getDefaultPrice()).isEqualByComparingTo("60.00"); assertThat(created.getWarehouseReceivable()).isTrue(); assertThat(created.getWarehousePayable()).isTrue(); assertThat(created.getSupplierCategory()).isNull(); @@ -122,7 +123,7 @@ class FeeManagementServiceTest { @DisplayName("自定义费用编码统一转为大写并检查客户内唯一性") void normalizesCustomFeeCode() { FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", - "storage_fee", "仓库租赁费", false, false, true, true, true, "应付仓库租赁费"); + "storage_fee", null, "仓库租赁费", false, false, true, true, true, "应付仓库租赁费", null); FeeItemEntity created = feeItems.create(9L, "admin", request); @@ -134,7 +135,7 @@ class FeeManagementServiceTest { @DisplayName("并发创建相同费用编码返回业务冲突") void rejectsConcurrentDuplicateFeeCode() { FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", - "STORAGE_FEE", "仓库租赁费", false, false, true, true, true, null); + "STORAGE_FEE", null, "仓库租赁费", false, false, true, true, true, null, null); doThrow(new DuplicateKeyException("duplicate fee code")).when(repository).insertItem(any()); assertThatThrownBy(() -> feeItems.create(9L, "admin", request)) @@ -156,6 +157,38 @@ class FeeManagementServiceTest { verify(repository, never()).itemCodeExists("STORAGE_FEE", 11L); } + /** 清空默认价只修改公共项目,不触碰已生成的费用。 */ + @Test + void clearsDefaultPrice() { + FeeItemEntity existing = item(true, true); + existing.setDefaultPrice(new BigDecimal("60.00")); + when(repository.findItem(11L)).thenReturn(existing); + when(repository.updateItem(any())).thenReturn(1); + FeeItemDtos.SaveRequest request = new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", + null, null, "仓库租赁费", false, false, true, true, true, null, null); + + assertThat(feeItems.update(11L, 9L, "admin", request).getDefaultPrice()).isNull(); + verify(repository).updateItem(existing); + } + + /** 请求价格允许空值和零元,拒绝负数、过多小数和超出字段范围的金额。 */ + @Test + void validatesDefaultPrice() { + try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + for (String price : List.of("0", "60.25", "9999999999.99")) { + var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null, + "操作费", false, true, false, false, false, null, new BigDecimal(price)); + assertThat(validator.validate(request)).isEmpty(); + } + for (String price : List.of("-1", "60.255", "10000000000")) { + var request = new FeeItemDtos.SaveRequest("仓储成本", "DOMESTIC", null, null, + "操作费", false, true, false, false, false, null, new BigDecimal(price)); + assertThat(validator.validate(request)).isNotEmpty(); + } + } + } + @Test @DisplayName("任一客户引用公共费用项目后都禁止删除") void rejectsDeletingItemBoundByAnyCustomer() { @@ -478,9 +511,10 @@ class FeeManagementServiceTest { eq(10001L), eq(21L), eq(31L), eq(1), any(LocalDateTime.class)); } + /** 构造带默认价格的公共费用项目。 */ private static FeeItemDtos.SaveRequest itemRequest() { - return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", "仓库租赁费", - false, false, true, true, true, "应付仓库租赁费"); + return new FeeItemDtos.SaveRequest("仓储成本", "INTERNATIONAL", null, null, "仓库租赁费", + false, false, true, true, true, "应付仓库租赁费", new BigDecimal("60.00")); } private static InboundFeeDetailDtos.SaveRequest detailRequest(String direction) { diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index cb9388409..1fa6fa1b3 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -31,7 +31,6 @@ VALUES (5107011,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:create','创建海外入仓计划',NULL,'POST',711,'ACTIVE',1,0), (5107012,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:edit','编辑海外入仓计划',NULL,'POST',712,'ACTIVE',1,0), (5107013,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:fba-appointment','FBA预约',NULL,'POST',713,'ACTIVE',1,0), - (5107014,0,'PLATFORM','BUTTON',5107010,'platform:overseas-warehouse:inbound-order:smart-dispatch','智能排车',NULL,'POST',714,'ACTIVE',1,0), (5107015,0,'PLATFORM','BUTTON',5107010,'lastleg:pda:operate','海外仓PDA入仓作业',NULL,'POST',715,'ACTIVE',1,0), (5107020,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:relabel-order:list','海外换标单','/overseas-warehouse/relabel-orders','GET',720,'ACTIVE',1,0), (5107021,0,'PLATFORM','BUTTON',5107020,'platform:overseas-warehouse:relabel-order:create','创建海外换标单',NULL,'POST',721,'ACTIVE',1,0), @@ -283,8 +282,7 @@ SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' AND permission.permission_code IN ( - 'platform:overseas-warehouse:inbound-order:fba-appointment', - 'platform:overseas-warehouse:inbound-order:smart-dispatch' + 'platform:overseas-warehouse:inbound-order:fba-appointment' ) WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN' diff --git a/iam/src/main/resources/db/phase5-delivery-permissions.sql b/iam/src/main/resources/db/phase5-delivery-permissions.sql index 5d850ee2b..69a5fc79e 100644 --- a/iam/src/main/resources/db/phase5-delivery-permissions.sql +++ b/iam/src/main/resources/db/phase5-delivery-permissions.sql @@ -9,6 +9,9 @@ BEGIN IF EXISTS ( SELECT 1 FROM iam_permission WHERE (id = 5107064 AND permission_code <> 'platform:overseas-warehouse:dispatch:list') + OR (id = 5107065 AND permission_code <> 'platform:overseas-warehouse:dispatch:create') + OR (id = 5107066 AND permission_code <> 'platform:overseas-warehouse:dispatch:loading-complete') + OR (id = 5107067 AND permission_code <> 'platform:overseas-warehouse:dispatch:depart') OR (id = 5107016 AND permission_code <> 'platform:overseas-warehouse:dispatch:edit') OR (id = 5107017 AND permission_code <> 'platform:overseas-warehouse:dispatch:cancel') OR (id = 5107018 AND permission_code <> 'platform:overseas-warehouse:dispatch:transfer-delivery') @@ -25,10 +28,12 @@ BEGIN OR (id = 5107081 AND permission_code <> 'platform:overseas-warehouse:customer-visit:edit') OR (id = 5107082 AND permission_code <> 'platform:overseas-warehouse:delivery:status') OR (id = 5306000 AND permission_code <> 'merchant:warehouse:delivery:list') - OR (id = 5306001 AND permission_code <> 'merchant:warehouse:delivery:create') ) OR EXISTS ( SELECT 1 FROM iam_permission WHERE (permission_code = 'platform:overseas-warehouse:dispatch:list' AND id <> 5107064) + OR (permission_code = 'platform:overseas-warehouse:dispatch:create' AND id <> 5107065) + OR (permission_code = 'platform:overseas-warehouse:dispatch:loading-complete' AND id <> 5107066) + OR (permission_code = 'platform:overseas-warehouse:dispatch:depart' AND id <> 5107067) OR (permission_code = 'platform:overseas-warehouse:dispatch:edit' AND id <> 5107016) OR (permission_code = 'platform:overseas-warehouse:dispatch:cancel' AND id <> 5107017) OR (permission_code = 'platform:overseas-warehouse:dispatch:transfer-delivery' AND id <> 5107018) @@ -45,7 +50,6 @@ BEGIN OR (permission_code = 'platform:overseas-warehouse:customer-visit:edit' AND id <> 5107081) OR (permission_code = 'platform:overseas-warehouse:delivery:status' AND id <> 5107082) OR (permission_code = 'merchant:warehouse:delivery:list' AND id <> 5306000) - OR (permission_code = 'merchant:warehouse:delivery:create' AND id <> 5306001) ) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限ID或编码冲突'; END IF; @@ -59,8 +63,11 @@ INSERT INTO iam_permission permission_name, route_path, http_method, sort_no, status, built_in, deleted) VALUES (5107064,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET',764,'ACTIVE',1,0), + (5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create','新增排车单',NULL,'POST',765,'ACTIVE',1,0), + (5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete','确认装车完成',NULL,'POST',766,'ACTIVE',1,0), + (5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart','确认实际发车',NULL,'POST',767,'ACTIVE',1,0), (5107016,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:edit','编辑海外排车',NULL,'PUT',716,'ACTIVE',1,0), - (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE',717,'ACTIVE',1,0), + (5107017,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST',717,'ACTIVE',1,0), (5107018,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:transfer-delivery','排车转派送',NULL,'POST',718,'ACTIVE',1,0), (5107070,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET',770,'ACTIVE',1,0), (5107071,0,'PLATFORM','BUTTON',5107070,'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST',771,'ACTIVE',1,0), @@ -74,8 +81,7 @@ VALUES (5107092,0,'PLATFORM','MENU',0,'platform:overseas-warehouse:delivery:list','海外派送单','/overseas-warehouse/delivery-orders','GET',767,'ACTIVE',1,0), (5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST',769,'ACTIVE',1,0), (5107093,0,'PLATFORM','BUTTON',5107090,'platform:overseas-warehouse:outbound:cancel','取消海外出库单',NULL,'POST',768,'ACTIVE',1,0), - (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0), - (5306001,0,'MERCHANT','BUTTON',5306000,'merchant:warehouse:delivery:create','创建海外仓派送单',NULL,'POST',10,'ACTIVE',1,0) + (5306000,0,'MERCHANT','MENU',0,'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET',60,'ACTIVE',1,0) ON DUPLICATE KEY UPDATE permission_type=VALUES(permission_type), parent_id=VALUES(parent_id), permission_name=VALUES(permission_name), route_path=VALUES(route_path), @@ -92,6 +98,9 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -117,8 +126,7 @@ SELECT role.tenant_id, role.id, permission.id, CURRENT_TIMESTAMP(3) WHERE role.tenant_id>0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') AND permission.status='ACTIVE' AND permission.deleted=0; -- 仓库后台岗位按最小职责授权;PDA 岗位不得获得后台排车、出库或派送权限。 @@ -130,6 +138,9 @@ CREATE TEMPORARY TABLE tmp_phase5_delivery_role_permission ( ) ENGINE=MEMORY; INSERT INTO tmp_phase5_delivery_role_permission (role_code, permission_code) VALUES ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:list'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:create'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:loading-complete'), + ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:depart'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:cancel'), ('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:transfer-delivery'), @@ -160,6 +171,9 @@ WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code IN ('WAREHOUSE_OPERATOR','WAREHOUSE_TALLY','OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -186,7 +200,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) AND permission.permission_code=expected.permission_code AND permission.status='ACTIVE' AND permission.deleted=0; --- 客户默认管理员获得派送列表/详情只读及独立创建;其他客户角色不自动扩权。 +-- 客户默认管理员只获得派送列表和详情读取;整车派送由平台统一安排。 INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, created_at) SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) FROM iam_role role @@ -194,8 +208,7 @@ SELECT 0, role.id, permission.id, CURRENT_TIMESTAMP(3) WHERE role.tenant_id=0 AND role.app_code='MERCHANT' AND role.role_code='MERCHANT_OWNER' AND role.status='ACTIVE' AND role.deleted=0 AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') AND permission.status='ACTIVE' AND permission.deleted=0; DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions; @@ -209,6 +222,9 @@ BEGIN WHERE tenant_id=0 AND status='ACTIVE' AND deleted=0 AND permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -224,8 +240,7 @@ BEGIN 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', 'platform:overseas-warehouse:customer-visit:edit', - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create')) <> 18 THEN + 'merchant:warehouse:delivery:list')) <> 20 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:权限目录不完整'; END IF; IF (SELECT COUNT(*) FROM iam_role_permission grant_row @@ -234,6 +249,9 @@ BEGIN WHERE role.role_code='PLATFORM_ADMIN' AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -248,7 +266,7 @@ BEGIN 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:customer-visit:edit')) <> 16 THEN + 'platform:overseas-warehouse:customer-visit:edit')) <> 19 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台管理员授权不完整'; END IF; IF EXISTS (SELECT 1 FROM iam_role role @@ -260,6 +278,9 @@ BEGIN WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -274,14 +295,14 @@ BEGIN 'platform:overseas-warehouse:delivery:status', 'platform:overseas-warehouse:delivery:documents', 'platform:overseas-warehouse:customer-visit:list', - 'platform:overseas-warehouse:customer-visit:edit')) <> 16) THEN + 'platform:overseas-warehouse:customer-visit:edit')) <> 19) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:平台自定义管理员授权不完整'; END IF; SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0), COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0) INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count FROM tmp_phase5_delivery_role_permission; - IF v_warehouse_operator_permission_count <> 16 + IF v_warehouse_operator_permission_count <> 19 OR v_warehouse_tally_permission_count <> 5 THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:仓库岗位最小权限矩阵异常'; END IF; @@ -291,6 +312,9 @@ BEGIN WHERE role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR') AND permission.permission_code IN ( 'platform:overseas-warehouse:dispatch:list', + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', 'platform:overseas-warehouse:dispatch:transfer-delivery', @@ -313,11 +337,10 @@ BEGIN ON grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id LEFT JOIN iam_permission permission ON permission.id=grant_row.permission_id AND permission.permission_code IN ( - 'merchant:warehouse:delivery:list', - 'merchant:warehouse:delivery:create') + 'merchant:warehouse:delivery:list') WHERE role.role_code='MERCHANT_OWNER' AND role.app_code='MERCHANT' AND role.status='ACTIVE' AND role.deleted=0 - GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 2) THEN + GROUP BY role.id HAVING COUNT(DISTINCT permission.permission_code) <> 1) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='五期海外派送权限初始化失败:客户默认管理员授权不完整'; END IF; END$$ diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index cd0957681..d96d8e78d 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -146,7 +146,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 + description: 返回当前有效平台成员权限编码;只要求有效 PLATFORM JWT,不依赖 `platform:role:list`。入库单扣货与解除扣货返回独立写权限 `platform:firstleg:inbound:hold`,不会由 `platform:firstleg:inbound:list` 查看权限自动获得。资金账户充值审核与手工调账分别返回 `platform:finance:recharge:review`、`platform:finance:customer-account:adjust`,并与 `platform:finance:customer-account:list` 只读权限分离。海外仓返回 `platform:overseas-warehouse:dispatch:list`、`platform:overseas-warehouse:dispatch:create`、`platform:overseas-warehouse:dispatch:loading-complete`、`platform:overseas-warehouse:dispatch:depart`、`platform:overseas-warehouse:dispatch:edit`、`platform:overseas-warehouse:dispatch:cancel`、`platform:overseas-warehouse:dispatch:transfer-delivery`、`platform:overseas-warehouse:outbound:list`、`platform:overseas-warehouse:outbound:create`、`platform:overseas-warehouse:outbound:cancel`、`platform:overseas-warehouse:delivery:list`、`platform:overseas-warehouse:delivery:return`、`platform:overseas-warehouse:delivery:status`、`platform:overseas-warehouse:delivery:documents`、`platform:overseas-warehouse:exception-order:list`、`platform:overseas-warehouse:exception-order:create`、`platform:overseas-warehouse:exception-order:process`、`platform:overseas-warehouse:customer-visit:list`、`platform:overseas-warehouse:customer-visit:edit`;海外 PDA 盘点、移库、换标和现场查询统一返回 `lastleg:pda:operate`,且仅限 `OVERSEAS_WAREHOUSE_PDA_OPERATOR`。货柜双工作台以 `platform:firstleg:container:list/create/edit` 维护国内送柜报关,国外清关提柜独立返回 `platform:firstleg:terminal-drayage:list`、`platform:firstleg:terminal-drayage:document:edit`、`platform:firstleg:terminal-drayage:fleet:edit`、`platform:firstleg:terminal-drayage:pickup-appointment:edit`、`platform:firstleg:terminal-drayage:empty-return:confirm`,不以 `container:list/edit` 兼容放行国外操作。派送三类资料管理使用独立写权限 `platform:overseas-warehouse:delivery:documents`;读取和下载仍使用 `platform:overseas-warehouse:delivery:list`。列表权限包含详情和只读辅助接口,写操作独立授权。 responses: '200': description: 当前平台成员权限查询成功 @@ -364,7 +364,7 @@ paths: get: tags: [IAM Resources / IAM 资源] summary: 查询当前用户有效权限 - description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板的列表入口分别返回 `merchant:cross-border:procurement:list`、`merchant:cross-border:domestic-trucking:list`、`merchant:cross-border:pallet-shipping:list`,创建和编辑另行授权;海外仓入库返回 `merchant:warehouse:inbound:list`,尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:overseas-warehouse:*` 权限。客户派送返回 `merchant:warehouse:delivery:list` 和 `merchant:warehouse:delivery:create`。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。 + description: 返回当前有效客户成员权限编码。资金账户和流水只读返回 `merchant:finance:account:view`,提交充值申请返回 `merchant:finance:recharge:create`,两者独立授权。海外采购、本土卡、本土散板的列表入口分别返回 `merchant:cross-border:procurement:list`、`merchant:cross-border:domestic-trucking:list`、`merchant:cross-border:pallet-shipping:list`,创建和编辑另行授权;海外仓入库返回 `merchant:warehouse:inbound:list`,尾程订单、换标、转运、废弃、退货预报和退货库存均返回各自 `merchant:overseas-warehouse:*` 权限。客户派送仅返回 `merchant:warehouse:delivery:list`,整车派送由平台统一安排。列表权限包含详情只读,创建、文件、确认、取消和转换操作独立授权。 responses: '200': description: 当前商户成员权限查询成功 diff --git a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java index d23670498..936df385e 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/IamOpenApiContractTest.java @@ -10,6 +10,7 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class IamOpenApiContractTest { + /** 核对当前授权接口公开说明,整车写权限独立且客户派送只读。 */ @Test void shouldHandleCase01() throws IOException { String document; @@ -21,6 +22,9 @@ class IamOpenApiContractTest { assertThat(document).contains("/api/platform/v1/iam/authorization/permissions:") .contains("/api/merchant/v1/iam/authorization/permissions:") .contains("platform:overseas-warehouse:dispatch:list") + .contains("platform:overseas-warehouse:dispatch:create") + .contains("platform:overseas-warehouse:dispatch:loading-complete") + .contains("platform:overseas-warehouse:dispatch:depart") .contains("platform:overseas-warehouse:dispatch:edit") .contains("platform:overseas-warehouse:dispatch:cancel") .contains("platform:overseas-warehouse:dispatch:transfer-delivery") @@ -45,7 +49,7 @@ class IamOpenApiContractTest { .contains("platform:firstleg:terminal-drayage:empty-return:confirm") .contains("不以 `container:list/edit` 兼容放行国外操作") .contains("merchant:warehouse:delivery:list") - .contains("merchant:warehouse:delivery:create") + .doesNotContain("merchant:warehouse:delivery:create") .contains("merchant:cross-border:procurement:list") .contains("merchant:cross-border:domestic-trucking:list") .contains("merchant:cross-border:pallet-shipping:list") diff --git a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java index 4314ad8fe..619cd0d41 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/OverseasPhase4PermissionSqlContractTest.java @@ -39,8 +39,7 @@ class OverseasPhase4PermissionSqlContractTest { private static final Pattern CANONICAL = Pattern.compile("platform:overseas-[a-z0-9:-]+"); private static final Set CUSTOM_ADMIN_PERMISSIONS = Set.of( - "platform:overseas-warehouse:inbound-order:fba-appointment", - "platform:overseas-warehouse:inbound-order:smart-dispatch"); + "platform:overseas-warehouse:inbound-order:fba-appointment"); private static final List REQUIRED = List.of( "platform:overseas-warehouse:last-mile-order:list", "platform:overseas-warehouse:discard:list", @@ -48,7 +47,6 @@ class OverseasPhase4PermissionSqlContractTest { "platform:overseas-warehouse:warehouse-transfer:approve", "platform:overseas-warehouse:inbound-order:list", "platform:overseas-warehouse:inbound-order:fba-appointment", - "platform:overseas-warehouse:inbound-order:smart-dispatch", "platform:overseas-warehouse:relabel-order:status", "platform:overseas-warehouse:pallet:list", "platform:overseas-warehouse:inventory:adjust", @@ -77,7 +75,7 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).contains(REQUIRED.toArray(String[]::new)); assertThat(sql).contains("'lastleg:pda:operate'"); assertPdaPickerGrant(sql); - assertThat(catalog).hasSize(71).noneMatch(code -> code.endsWith(":view")); + assertThat(catalog).hasSize(70).noneMatch(code -> code.endsWith(":view")); assertThat(sql).contains( "(5107003,0,'PLATFORM','BUTTON',5107000,'platform:overseas-warehouse:warehouse-transfer:approve'"); assertThat(sql).contains("CALL assert_warehouse_transfer_approval_permission()", @@ -85,7 +83,8 @@ class OverseasPhase4PermissionSqlContractTest { assertThat(sql).doesNotContain( "'platform:overseas-warehouse:warehouse-transfer:list','仓储转运审核'"); assertThat(sql).contains("permission_code LIKE 'platform:lastleg:%'", "status='INACTIVE'", "deleted=1"); - assertThat(sql).doesNotContain(":carton:", ":product:"); + assertThat(sql).doesNotContain(":carton:", ":product:", + "platform:overseas-warehouse:inbound-order:smart-dispatch"); assertThat(catalog).contains("platform:overseas-warehouse:delivery:list"); assertCustomAdminGrant(sql); assertCustomAdminNavigationGrant(sql); diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java index ee5ac806d..9d97b9d25 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlContractTest.java @@ -16,6 +16,9 @@ class Phase5DeliveryPermissionSqlContractTest { private static final Pattern CODE = Pattern.compile("(?:platform:overseas-warehouse|merchant:warehouse):[a-z0-9:-]+"); private static final Set REQUIRED = Set.of( "platform:overseas-warehouse:dispatch:list", + "platform:overseas-warehouse:dispatch:create", + "platform:overseas-warehouse:dispatch:loading-complete", + "platform:overseas-warehouse:dispatch:depart", "platform:overseas-warehouse:dispatch:edit", "platform:overseas-warehouse:dispatch:cancel", "platform:overseas-warehouse:dispatch:transfer-delivery", @@ -31,15 +34,16 @@ class Phase5DeliveryPermissionSqlContractTest { "platform:overseas-warehouse:delivery:documents", "platform:overseas-warehouse:customer-visit:list", "platform:overseas-warehouse:customer-visit:edit", - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"); + "merchant:warehouse:delivery:list"); + /** 核对整车权限目录只包含当前正式编码。 */ @Test void definesOnlyTheFrozenPhase5DeliveryCatalogWithoutAliases() throws Exception { String sql = sql(); assertThat(codes(sql)).containsExactlyInAnyOrderElementsOf(REQUIRED); assertThat(codes(sql)).noneMatch(code -> code.endsWith(":view")); + assertThat(sql).doesNotContain("merchant:warehouse:delivery:create"); assertThat(sql).contains( "guard_phase5_delivery_permission_ids", "权限ID或编码冲突", @@ -47,27 +51,32 @@ class Phase5DeliveryPermissionSqlContractTest { "列表权限同时覆盖列表和详情只读"); } + /** 核对菜单、动作方法及独立装车发车权限。 */ @Test void keepsMenuRoutesAndHttpMethodsAlignedWithTheFrozenAdminAndClientContracts() throws Exception { String sql = sql(); assertThat(sql).contains( "'platform:overseas-warehouse:dispatch:list','海外排车单','/overseas-warehouse/dispatches','GET'", - "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", + "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST'", "'platform:overseas-warehouse:exception-order:list','海外异常订单','/overseas-warehouse/exception-orders','GET'", "'platform:overseas-warehouse:exception-order:create','新增海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:exception-order:process','处理海外异常订单',NULL,'POST'", "'platform:overseas-warehouse:delivery:return','派送单标记退货',NULL,'POST'", "'platform:overseas-warehouse:delivery:status','编辑派送单状态',NULL,'PUT'", + "(5107065,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:create'", + "(5107066,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:loading-complete'", + "(5107067,0,'PLATFORM','BUTTON',5107064,'platform:overseas-warehouse:dispatch:depart'", "(5107094,0,'PLATFORM','BUTTON',5107092,'platform:overseas-warehouse:delivery:documents','管理派送资料',NULL,'POST'", "'platform:overseas-warehouse:customer-visit:list','客户回访','/overseas-warehouse/customer-visits','GET'", "'platform:overseas-warehouse:customer-visit:edit','保存客户回访',NULL,'PUT'", "'merchant:warehouse:delivery:list','海外仓派送单','/orders/delivery','GET'"); assertThat(sql).doesNotContain( - "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'POST'", + "'platform:overseas-warehouse:dispatch:cancel','取消海外排车',NULL,'DELETE'", "'merchant:warehouse:delivery:list','海外仓派送单','/overseas-warehouse/delivery-orders'"); } + /** 核对后台操作岗位、理货只读岗位与PDA之间的最小授权边界。 */ @Test void grantsAdministratorsAndWarehouseRolesByTheFrozenMinimumMatrix() throws Exception { String sql = sql(); @@ -76,6 +85,9 @@ class Phase5DeliveryPermissionSqlContractTest { "UPPER(role.role_code) IN ('PLATFORM_ADMIN','PLATFORM_CUSTOM_ADMIN')", "tmp_phase5_delivery_role_permission", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:list')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:create')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:loading-complete')", + "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:depart')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_OPERATOR','platform:overseas-warehouse:exception-order:create')", @@ -92,6 +104,9 @@ class Phase5DeliveryPermissionSqlContractTest { "role.role_code IN ('OVERSEAS_WAREHOUSE_PDA_OPERATOR','CN_FIRSTLEG_PDA_OPERATOR')", "PDA岗位错误获得后台权限"); assertThat(sql).doesNotContain( + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:create')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:loading-complete')", + "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:depart')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:dispatch:edit')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:outbound:create')", "('WAREHOUSE_TALLY','platform:overseas-warehouse:exception-order:create')", @@ -104,6 +119,7 @@ class Phase5DeliveryPermissionSqlContractTest { "('CN_FIRSTLEG_PDA_OPERATOR','platform:overseas-warehouse:"); } + /** 核对临时角色矩阵一次读取及完整性判断。 */ @Test void validatesTheWarehouseRoleMatrixWithOneTemporaryTableReadPerStatement() throws Exception { String sql = sql(); @@ -114,7 +130,7 @@ class Phase5DeliveryPermissionSqlContractTest { "SELECT COALESCE(SUM(role_code='WAREHOUSE_OPERATOR'),0)", "COALESCE(SUM(role_code='WAREHOUSE_TALLY'),0)", "INTO v_warehouse_operator_permission_count, v_warehouse_tally_permission_count", - "IF v_warehouse_operator_permission_count <> 16", + "IF v_warehouse_operator_permission_count <> 19", "OR v_warehouse_tally_permission_count <> 5 THEN", "IF EXISTS (SELECT 1 FROM iam_role role", "AND UPPER(role.role_code)='PLATFORM_CUSTOM_ADMIN'", @@ -126,10 +142,11 @@ class Phase5DeliveryPermissionSqlContractTest { "OR (SELECT COUNT(*) FROM tmp_phase5_delivery_role_permission"); } + /** 客户默认管理员只获得本客户派送读取权限。 */ @Test void grantsOnlyTheDefaultCustomerAdministratorAutomatically() throws Exception { String sql = sql(); - int marker = sql.indexOf("-- 客户默认管理员获得派送列表/详情只读及独立创建"); + int marker = sql.indexOf("-- 客户默认管理员只获得派送列表和详情读取"); int assertion = sql.indexOf("DROP PROCEDURE IF EXISTS assert_phase5_delivery_permissions", marker); assertThat(marker).isGreaterThanOrEqualTo(0); assertThat(assertion).isGreaterThan(marker); @@ -137,11 +154,11 @@ class Phase5DeliveryPermissionSqlContractTest { String grant = sql.substring(marker, assertion); assertThat(grant).contains( "role.role_code='MERCHANT_OWNER'", - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"); - assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN"); + "merchant:warehouse:delivery:list"); + assertThat(grant).doesNotContain("MERCHANT_OPERATOR", "ORDER_VIEWER", "role.role_code IN", "merchant:warehouse:delivery:create"); } + /** 国内空库基线必须同步当前权限与默认角色关联。 */ @Test void domesticCleanInitializationContainsTheCompletePermissionCatalog() throws Exception { String unifiedSql = repositoryFile("deploy/sql/头程数据库统一增量-MySQL8.sql"); @@ -151,7 +168,10 @@ class Phase5DeliveryPermissionSqlContractTest { .contains("(4000004,5107064,0,CURRENT_TIMESTAMP(3))") .contains("(4000004,5107094,0,CURRENT_TIMESTAMP(3))") .contains("(4000100,5306000,0,CURRENT_TIMESTAMP(3))") - .contains("(4000100,5306001,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107065,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107066,0,CURRENT_TIMESTAMP(3))") + .contains("(4000004,5107067,0,CURRENT_TIMESTAMP(3))") + .doesNotContain("merchant:warehouse:delivery:create", "(4000100,5306001,0,CURRENT_TIMESTAMP(3))") .doesNotContain("guard_phase5_delivery_permission_ids", "tmp_phase5_delivery_role_permission"); } diff --git a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java index 3d58e8216..cd2209cc3 100644 --- a/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java +++ b/iam/src/test/java/com/baiyue/iam/repository/Phase5DeliveryPermissionSqlExecutionTest.java @@ -20,6 +20,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { private static final Pattern PLATFORM_CODE = Pattern.compile("platform:overseas-warehouse:[a-z0-9:-]+"); + /** 没有自定义管理员时不产生虚假的缺权错误。 */ @Test void noCustomAdministratorDoesNotCreateAnAssertionViolation() throws Exception { try (Connection connection = database()) { @@ -29,6 +30,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { } } + /** 按当前完整目录补齐管理员授权,重复执行不增加关联。 */ @Test void onePartiallyGrantedCustomAdministratorIsCompletedIdempotently() throws Exception { try (Connection connection = database()) { @@ -37,50 +39,53 @@ class Phase5DeliveryPermissionSqlExecutionTest { assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(16); + assertThat(grantCount(connection, 11L)).isEqualTo(19); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); - assertThat(grantCount(connection, 11L)).isEqualTo(16); - assertThat(totalGrantCount(connection)).isEqualTo(16); + assertThat(grantCount(connection, 11L)).isEqualTo(19); + assertThat(totalGrantCount(connection)).isEqualTo(19); } } + /** 分别核对每个管理员,缺少任一权限不能被其他角色抵消。 */ @Test void multipleCustomAdministratorsExposeTheSingleIncompleteRole() throws Exception { try (Connection connection = database()) { insertRole(connection, 21L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 22L, "Platform_Custom_Admin"); - grantFirstPermissions(connection, 21L, 16); - grantFirstPermissions(connection, 22L, 15); + grantFirstPermissions(connection, 21L, 19); + grantFirstPermissions(connection, 22L, 18); assertThat(customAdministratorViolations(connection)).isEqualTo(1); executeGrant(connection); - assertThat(grantCount(connection, 21L)).isEqualTo(16); - assertThat(grantCount(connection, 22L)).isEqualTo(16); + assertThat(grantCount(connection, 21L)).isEqualTo(19); + assertThat(grantCount(connection, 22L)).isEqualTo(19); assertThat(customAdministratorViolations(connection)).isZero(); } } + /** 完整管理员重复授权保持各自权限集合稳定。 */ @Test void multipleCompleteCustomAdministratorsRemainCompleteAfterReplay() throws Exception { try (Connection connection = database()) { insertRole(connection, 31L, "PLATFORM_CUSTOM_ADMIN"); insertRole(connection, 32L, "platform_custom_admin"); - grantFirstPermissions(connection, 31L, 16); - grantFirstPermissions(connection, 32L, 16); + grantFirstPermissions(connection, 31L, 19); + grantFirstPermissions(connection, 32L, 19); assertThat(customAdministratorViolations(connection)).isZero(); executeGrant(connection); executeGrant(connection); - assertThat(grantCount(connection, 31L)).isEqualTo(16); - assertThat(grantCount(connection, 32L)).isEqualTo(16); - assertThat(totalGrantCount(connection)).isEqualTo(32); + assertThat(grantCount(connection, 31L)).isEqualTo(19); + assertThat(grantCount(connection, 32L)).isEqualTo(19); + assertThat(totalGrantCount(connection)).isEqualTo(38); } } + /** 管理员授权不得扩散到理货只读岗位或停用账号。 */ @Test void administratorGrantDoesNotEscalateAReadOnlyRoleOrDisabledAdministrator() throws Exception { try (Connection connection = database()) { @@ -99,7 +104,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { executeGrant(connection); executeGrant(connection); - assertThat(grantCount(connection, 41L)).isEqualTo(16); + assertThat(grantCount(connection, 41L)).isEqualTo(19); assertThat(grantCount(connection, 42L)).isEqualTo(1); assertThat(grantCount(connection, 43L)).isZero(); try (Statement statement = connection.createStatement(); @@ -206,6 +211,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { } } + /** 逐角色核对当前后台权限全集,避免跨角色合并计数。 */ private static int customAdministratorViolations(Connection connection) throws Exception { String placeholders = String.join(",", platformPermissions().stream().map(code -> "?").toList()); String sql = """ @@ -216,7 +222,7 @@ class Phase5DeliveryPermissionSqlExecutionTest { AND (SELECT COUNT(*) FROM iam_role_permission grant_row JOIN iam_permission permission ON permission.id=grant_row.permission_id WHERE grant_row.role_id=role.id AND grant_row.tenant_id=role.tenant_id - AND permission.permission_code IN (%s)) <> 16 + AND permission.permission_code IN (%s)) <> 19 """.formatted(placeholders); try (PreparedStatement query = connection.prepareStatement(sql)) { int index = 1; @@ -239,11 +245,12 @@ class Phase5DeliveryPermissionSqlExecutionTest { return sql.substring(start, end); } + /** 从实际管理员授予语句提取当前后台权限全集。 */ private static Set platformPermissions() throws Exception { LinkedHashSet codes = new LinkedHashSet<>(); String grant = administratorGrantSql(); PLATFORM_CODE.matcher(grant).results().map(result -> result.group()).forEach(codes::add); - assertThat(codes).hasSize(16); + assertThat(codes).hasSize(19); return codes; } diff --git a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java index f8d83d00f..f5359e6c1 100644 --- a/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/AuthorizationSnapshotServiceTest.java @@ -64,18 +64,16 @@ class AuthorizationSnapshotServiceTest { verify(mapper).findSnapshot(42L, 0L, 9L, "PLATFORM"); } + /** 按当前有效角色返回客户派送读取权限。 */ @Test - void exposesPhase5DeliveryPermissionsFromCurrentRoleAssignments() { + void exposesCustomerDeliveryReadPermissionFromCurrentRoleAssignments() { when(mapper.findSnapshot(42L, 7L, 9L, "MERCHANT")).thenReturn(rows(List.of("MERCHANT_OWNER"), List.of( - "merchant:warehouse:delivery:list", - "merchant:warehouse:delivery:create"))); + "merchant:warehouse:delivery:list"))); var result = service.currentPermissions(new AuthenticatedUser(42L, 7L, 7L, 9L, "owner", "MERCHANT", "MERCHANT", java.util.Set.of()), "MERCHANT"); - assertThat(result).containsExactlyInAnyOrder( - "merchant:warehouse:delivery:create", - "merchant:warehouse:delivery:list"); + assertThat(result).containsExactly("merchant:warehouse:delivery:list"); } @Test diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 6e6fbc6e6..5964dc0a0 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节为分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处。根规则已在上下文时不重复读取,本服务不另设例外。 ## 职责与边界 @@ -16,7 +15,7 @@ 业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。 -拼车接货与车次准备:`fleet/trip/`、`fleet/business/`;合同`fleet-preparation-v1.yaml`,流程见 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 +拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;车次准备查同包 `FleetTripPreparationService.java`,本土业务接线查 `fleet/business/`;合同 `fleet-preparation-v1.yaml`,流程变更再查 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 @@ -55,8 +54,8 @@ | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | -| 平台排车列表/详情与智能预排 | `dispatch/controller/DispatchQueryController.java` | -| 排车编辑、PDA 出库、派送与交接 | `dispatch/controller/DispatchController.java` | +| 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | +| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` | @@ -73,10 +72,10 @@ | 司机档案、证照上传/审核、资格预览与历史 | `fleet/driver/PlatformDriverController.java`、`DriverService.java`;OpenAPI `fleet-drivers-v1.yaml`,表 `ll_fleet_driver` / `ll_fleet_driver_history`;资格规则 `fleet/domain/DriverQualificationPolicy.java` | | 车辆运维:保养、加油、维修、故障、救援、异常 | `fleet/maintenance/PlatformVehicleCareController.java`、`VehicleCareService.java`;合同`fleet-vehicle-records-v1.yaml`。按车/类型隔离,单位/币种明确,处理不等付款;创建/附件幂等,更正留版本历史,不改主档里程/状态。维修救援经`financefee/fleet/VehicleCareFinanceAdapter`入公司成本,Finance为付款权威,未关联UNLINKED,不生成客户应收 | | 车次计划、车/司机分配与占用、客户排车关联 | `fleet/trip/PlatformFleetTripController.java`、`FleetTripService.java`;OpenAPI `fleet-trips-v1.yaml`;`FleetDispatchGateway` 由 `dispatch/fleet/DispatchFleetAdapter.java` 实现,`FleetDispatchBindingGuard` 控制子单独立修改/发车;车辆通过 `VehicleRepository` 和 `VehicleAssignmentGuard` 协作,fleet不直读车辆持久化适配器 | -| 调度与理货审核 | `FleetTripService.reviewDispatch`、`FleetTripExecutionService.reviewLoading`;每张客户排车单先调度通过,整车确认实载后理货通过。`FleetDispatchBindingGuard.requirePickingApproved` 在新拣货/完成前核对当前单审核;已成功操作的同键重放保留原结果 | +| 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | -| 实物装载、逐段容量与整车PDA发车 | `fleet/trip/FleetTripExecutionService.java`、`FleetExecutionModels.java`;平台 `/fleet/trips/{tripNo}/loading`,PDA沿用 `/dispatches/{dispatchNo}/depart` 但提交车次版本并返回整车结果;`DispatchPdaWorkflowService.depart` 为车次事务内子操作,不独立对外调用 | +| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 | | 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | @@ -85,29 +84,7 @@ ## 海外仓全流程开发路由 -- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 -- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 -- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 -- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 - -- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。 - -- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 -- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 -- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 -- 排车转派送允许在 `PENDING_DEPARTURE`、`PICKING`、`READY_TO_DEPART` 阶段幂等生成一张 `WAIT_OUTBOUND` 出库单,并按目的FBA仓生成多张派送单,但必须早于发车;BOL和签收资料均绑定拆分后的单仓派送单,派送费用仍只发布一次。PDA 排车详情由 `DispatchPdaWorkflowService` 返回 `canDepart`、`departBlockReason`,只有车辆信息完整、拣货任务完成且箱数核平、排车为 `READY_TO_DEPART`、关联出库单仍为 `WAIT_OUTBOUND` 时才允许发车,PDA 不得自行推导该门禁。 -- 共享车次关联取排车单自身客户与起运仓;排车有效关联唯一,多客户不合并库存归属。`FleetDispatchBindingGuard` 在子单行锁内阻止独立编辑/移托/取消及绕过车次协调单独发车;转派送的新建请求不能覆盖车次车辆司机,已成功转换的重放继续返回既有单据。PDA详情同步返回车次统一发车阻断原因;关联与改派通过 `FleetDispatchGateway` 同事务同步尚未执行的派送快照。已拣货禁止直接解除关联或变更路线;发车前可统一换车/司机或保持路线调整时间,保留拣货事实与到达时间,重验司机任务覆盖及替换车辆对已确认装载的容量,成功后调度审核、理货审核及原装载确认失效;仅修改备注保留审核与装载。已出库/发车不允许此类修改。 -- 实物装载按仓库托盘ID去重计量,客户货量与物理占位分别保存;重量/体积为明确确认的实际值,不读取计费材积重回填。路线卸货站的 `destinationKey` 必须匹配原货物目的地,同实托不同目的地先拆托。装载确认保存来源hash、版本、操作人和历史;改计划/改派/关联变化使确认失效。整车发车在同一Lastleg事务中复核所有子单、车辆占用及司机证照,调用原子单出库命令后统一提交 `IN_TRANSIT` 和幂等结果;任一失败全车回滚,`DEPARTING`只在事务内部使用。PDA列表按车次复用发车门禁查询,再按当前客户排车单审核返回canPick/pickBlockReason;客户A通过不能代替客户B通过。重新确认装载或重新调度审核使理货审核待审,审核历史不删除;过期前30天提醒与阻断分别返回。 -- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 -- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 -- 排车执行与 `delivery/` Controller 和其他尾程业务接口一同注册,不受功能开关控制。平台排车列表与详情仍由 `lastleg.dispatch-query-api.enabled=true` 独立启用;入库单智能排车候选、草稿锁定/解锁/释放和预排创建仍由 `lastleg.smart-dispatch-api.enabled=true` 独立启用。这些排车平台入口均不接收 `customerId`:排车列表跨客户分页,详情按全局排车单号定位后使用记录自身客户范围加载明细;智能排车候选未传计划 ID 时跨客户查询,传入时根据计划 ID 解析范围;托盘移入草稿时立即以操作人和随机草稿标识锁定,移除或关闭时释放,草稿锁超时失效;预排创建根据所选托盘 ID 在服务端解析并校验唯一客户范围,并将当前操作人的草稿锁原子转换为正式排车锁。 -- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 - -- 外接预订以父车次行锁串行确认容量;DRAFT不占位,CONFIRMED占位,CANCELLED释放,创建/确认/取消保留请求键与内容摘要。报价与记录快照进入既有车次历史。重新核对自有货计划不得占用已确认外接容量,换小车须能容纳两者;同车只换司机不额外要求先重新核对容量。CONFIRMED和RECEIVED尚未绑定实际发运时阻止整车发车;通过真实来货实测、运费确认及来源核对后转为BOUND,解除未开工排车关联回RECEIVED。报价或登记来货不冒充库存/已出库;取消未执行预订仅释放承运容量,来货及已入库货物保留。 -- 外接约定运费由`fleet/trip/FleetBookingFreight`按排车来源、绑定车次、目的地和完整实收量复核;`DeliveryTransferService`在原转派送事务内发布到对应目的地派送单的`OVERSEAS_DELIVERY_FEE`,通过既有费用Outbox进入Finance。预览只读且使用dispatch:list;确认原货物/报价摘要后写入,全部外接货禁止再填派送应收,额外装车/买约仍独立。明确零约定留操作事实不发布虚构零费用。首版确认承运币种限现行费用支持的CNY/USD,原头程费用覆盖按下条处理。 -- 原运费覆盖由`delivery/service/DeliveryFeeCoverageService`与`DispatchFeeCargoMapper`按实际库存组成及本次已拣数量还原,核平每条排车箱数;不可用MIN展示入仓码替代整托全部来源。`FinanceFeeCoverageClient`批量只读Finance,平台转派送按费用ID/版本/hash和当前货物/目的地摘要复核,只有全部相关货物已覆盖的服务才免新增应收,供应商应付独立。客户接口禁止自行声明覆盖。确认快照保存到`ll_outbound_order.fee_coverage_json`,原费用不复制、不抵扣金额、不生成伪零费用;详情按本派送货物过滤只读快照,之后财务更正不自动改写旧发运。普通无引用请求保持原新增收费要求。 -- 派送出库取消按dispatch→outbound加锁,核对排车未拣货/发车并先解除车次关联;取消出库不代替取消排车占用。来源订单按本次转派送历史的原状态恢复,显式清空出库/派送关联并增加版本,不统一写RECEIVED。只对本派送当前已审核有效的DELIVERY_FEE应收申请作废,其他费用保留核对,完整取消原因分段保存在派送操作记录。 -- `financefee/state/OverseasFeeCancellation`从不可变费用Outbox批量读取voidStatus;请求/拒绝/未知结果保留ACTIVE,只有Finance匹配APPLIED/DUPLICATE后,在投递审计同事务中落VOIDED。撤销候选必须等待原发布成功,未满足前置的PENDING撤销在LIMIT前排除,避免堵住后续投递;过期PROCESSING仍按租约恢复。费用调整使用READ_COMMITTED并在来源行锁后检查未决撤销,禁止旧快照漏检。前端与API区分待财务确认、待财务处理和确认作废,不能以物流取消或HTTP请求发出认定账务已撤销。 +本服务覆盖尾程入仓、库存、库内作业、排车/PDA、派送、异常、采购和车队。状态、数量、资金、资源或跨单联动变化前,须查 `docs/business-index.md` 本节条款;同柜/车/托不合并客户、仓库或来源,页面资格只取服务端结果。 ## 数据库表索引与注释约束 @@ -119,53 +96,16 @@ ## 事件建单规则 -- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 -- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 -- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 -- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 -- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 -- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 -- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 -- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 -- 通用 `/internal/lastleg/events` 只白名单接收装柜完成、实际船节点与资金结果事件;未知或IAM安全事件必须拒绝。 -- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 -- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 +校验事件身份、客户/来源、版本/hash、Inbox 幂等与事务;装柜事件不自动完成入仓。修改前查 `docs/business-index.md` 本节条款,ACK/重试按统一通信契约。 ## 查询与租户约束 -- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 -- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 -- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 -- 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。普通尾程入库只确认某入仓单在托盘上的箱数,没有逐箱唛到托盘的快照;贴标下单由客户填写我方箱唛,库存仓位只作为冻结记账锚点和现场大概位置,不能据此声称箱唛位于某个具体托盘。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。 -- 换标箱唛经现场确认后执行 `pallet-operations/relabel-disassemble`:允许仅移动该换标单对应的 `hold_packages`,不得解冻为普通可用库存;事务内同步更新 `ll_relabel_item.source_inventory_position_id`。若客户确认后已生成换标出库单,还必须在拣货开始前同步拆分或改写 `ll_outbound_pallet_item` 的托盘、箱唛、箱数与来源仓位快照,使 PDA 后续按新旧托盘分别扫描且每个指定箱唛只出库一次。 -- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 -- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 -- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 -- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 -- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 -- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 -- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 +查询、详情、导出与写入保持一致客户隔离、访问域和绑定仓,不信任请求归属。采购、废弃、主/子状态变化前查 `docs/business-index.md` 本节条款。 ## 修改与验证 -- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。 - -- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。 - -- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。 - -- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。 - -- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。 - -- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。 - -- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 - -- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 - -- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 -- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 -- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。 -- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 -- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 +- 后端根数据库分层、中文注释、API、Mapper、事件和权限门禁全部适用,SQL遵循本文件数据库约束。 +- 换标/托盘/返工、客户状态、锁与并发行为变化前,须查 `docs/business-index.md` 本节及对应条款。 +- 跨子域依赖变化核对 `docs/lastleg-internal-modularization-baseline.md`,从后端根运行 `node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs`;不以基线掩盖循环依赖。 +- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`;事件回归覆盖重复/来源唯一性、回滚、并发单号、内部身份及客户越权。 +- 指引/索引变更统一运行 `./scripts/agents-index-contract.ps1`,不以离线检查代替部署/验收。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 0ec4d6a1e..37aa6e714 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -80,3 +80,82 @@ | 一件代发与废弃 | `ll_dropship_*`、`ll_cargo_discard_*` | 托盘/箱唛库存、流水和废弃审批 | | 采购、卡车与本土散板 | `ll_overseas_procurement_*`、`ll_us_truck_*`、`ll_local_looseboard_*` | 海外采购、美国卡车运输及本土散板订单 | | 通用资料 | `ll_overseas_quote*`、`ll_monthly_exchange_rate*`、`ll_vehicle` | 海外仓报价、月度汇率及车辆档案;不保存车辆派送履约历史 | + +## 海外仓全流程开发路由 + +- 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 +- 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 +- 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 +- 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 + +- 车辆履约投影:`fleet/trip/PlatformVehicleFulfillmentController.java` → `VehicleFulfillmentService` / `VehicleFulfillmentMapper`,合同`fleet-vehicle-fulfillment-v1.yaml`。派送同时要求vehicle:list与delivery:list,更换历史要求vehicle:list与trip:list;客户与排车双键关联,不跨库。按不可变DEPART/ASSIGN关联车辆,首次分配不算更换,不按当前车牌猜历史。只读重复读事务分页;不返回内部ID或整份客户快照。 + +- 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 +- 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 +- 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 +- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 +- 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 +- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 +- 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 +- 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 +- 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 +- 仓库 ID、类型、状态、地址和能力唯一权威归国内 `warehouse-service`;海外库位、实物托盘和资源锁归 `warehouse-overseas-service/` 且只引用同一国内 `warehouseId`。Lastleg PDA 通过 Warehouse Overseas 内部资源入口校验该国内共享收货仓,不得要求海外数据库重复建仓;浏览器 API 统一经 `gateway/`。 + +- 外接预订以父车次行锁串行确认容量;DRAFT不占位,CONFIRMED占位,CANCELLED释放,创建/确认/取消保留请求键与内容摘要。报价与记录快照进入既有车次历史。重新核对自有货计划不得占用已确认外接容量,换小车须能容纳两者;同车只换司机不额外要求先重新核对容量。CONFIRMED和RECEIVED尚未绑定实际发运时阻止整车发车;通过真实来货实测、运费确认及来源核对后转为BOUND,解除未开工排车关联回RECEIVED。报价或登记来货不冒充库存/已出库;取消未执行预订仅释放承运容量,来货及已入库货物保留。 +- 外接约定运费由`fleet/trip/FleetBookingFreight`按排车来源、绑定车次、目的地和完整实收量复核;`DeliveryTransferService`在原转派送事务内发布到对应目的地派送单的`OVERSEAS_DELIVERY_FEE`,通过既有费用Outbox进入Finance。预览只读且使用dispatch:list;确认原货物/报价摘要后写入,全部外接货禁止再填派送应收,额外装车/买约仍独立。明确零约定留操作事实不发布虚构零费用。首版确认承运币种限现行费用支持的CNY/USD,原头程费用覆盖按下条处理。 +- 原运费覆盖由`delivery/service/DeliveryFeeCoverageService`与`DispatchFeeCargoMapper`按实际库存组成及本次已拣数量还原,核平每条排车箱数;不可用MIN展示入仓码替代整托全部来源。`FinanceFeeCoverageClient`批量只读Finance,平台转派送按费用ID/版本/hash和当前货物/目的地摘要复核,只有全部相关货物已覆盖的服务才免新增应收,供应商应付独立。客户接口禁止自行声明覆盖。确认快照保存到`ll_outbound_order.fee_coverage_json`,原费用不复制、不抵扣金额、不生成伪零费用;详情按本派送货物过滤只读快照,之后财务更正不自动改写旧发运。普通无引用请求保持原新增收费要求。 +- 派送出库取消按dispatch→outbound加锁,核对排车未拣货/发车并先解除车次关联;取消出库不代替取消排车占用。来源订单按本次转派送历史的原状态恢复,显式清空出库/派送关联并增加版本,不统一写RECEIVED。只对本派送当前已审核有效的DELIVERY_FEE应收申请作废,其他费用保留核对,完整取消原因分段保存在派送操作记录。 +- `financefee/state/OverseasFeeCancellation`从不可变费用Outbox批量读取voidStatus;请求/拒绝/未知结果保留ACTIVE,只有Finance匹配APPLIED/DUPLICATE后,在投递审计同事务中落VOIDED。撤销候选必须等待原发布成功,未满足前置的PENDING撤销在LIMIT前排除,避免堵住后续投递;过期PROCESSING仍按租约恢复。费用调整使用READ_COMMITTED并在来源行锁后检查未决撤销,禁止旧快照漏检。前端与API区分待财务确认、待财务处理和确认作废,不能以物流取消或HTTP请求发出认定账务已撤销。 + +## 事件建单规则 + +- 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 +- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 +- `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 +- 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 +- 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 +- 相同事件重复投递返回既有结果,不重新生成单号或插入明细。来源入仓单唯一约束是最终防线。 +- 装柜完成事件只把尾程订单建立为 `PENDING_ASSIGNMENT`。`FIRST_LEG` 在途库存使用不可变来源入仓码作为计划生成前的 `inbound_order_no` 展示快照;平台人工转换成功后,同一事务将订单推进为 `INBOUND_PLANNED`,并把对应库存余额的该快照收口为新 `planNo`。 +- 内部接口先执行常量时间令牌校验;缺失或错误令牌不得进入消费事务。 +- 通用 `/internal/lastleg/events` 只白名单接收装柜完成、实际船节点与资金结果事件;未知或IAM安全事件必须拒绝。 +- PDA 入仓任务完成前必须先通过 `/unload-photos` 保存非空且不重复的卸柜照片文件 ID;照片上传时间由服务端记录,预计报空时间固定为照片上传时间加两天。完成入仓时,库存入账、计划/任务状态推进与 `OVERSEAS_CONTAINER_UNLOADED` V1 Outbox 必须处于同一事务;事件绑定来源头程货柜身份,不得从前端请求拼装或回查头程数据库。 +- 海外 PDA 柜号签收只允许平台 `OVERSEAS_WAREHOUSE_PDA_OPERATOR` 使用 `lastleg:pda:operate`;国内头程 PDA 角色不得进入。PDA 不接收 `customerId`,候选按来源柜跨客户聚合,同柜允许多个客户;选柜会话、三类照片及整柜完成事实只绑定 `sourceContainerBookingId + containerNo`,文件与柜级跨区事件使用 `tenantId=0` 平台技术范围。签收后按每个入仓计划自身客户生成任务,扫码、SKU 实收、托盘和库存始终使用入仓码所属票的真实客户。未完成会话按平台操作账号和柜号分别全局唯一;平台管理员和客户账号不得进入该作业入口。 + +## 查询与租户约束 + +- 平台查询范围由可信身份/平台权限决定;商户查询只能使用认证身份中的客户 ID。 +- 尾程平台只读查询接口的 `customerId` 为可选筛选条件:平台身份省略时查询全部客户数据,传入时按指定客户过滤;商户身份忽略客户端传入值并始终按 Token 中的自身客户隔离。创建、编辑、审核等需要明确目标客户的写接口仍必须传入 `customerId`。 +- 常规入仓库存余额以客户、海外仓、目的仓和入仓单为唯一业务维度并汇总箱数;SKU 继续保存在箱唛、实收和流水快照中,余额 SKU 仅作单 SKU 展示或以 `MIXED` 标识多 SKU,不参与当前唯一维度。 +- 托盘是 PDA 出库的作业单位,但不是库存归属单位:`ll_inventory_position` 通过余额和托盘保存“托盘包含哪些入仓单、各多少箱”的组成明细。合托后的同一实物托盘允许对应多个入仓单,换标冻结和后续出库核算必须按这些明细分别记账;整托出库可扫描托盘一次完成,部分出库先拆托或按明细核平,不得用 FIFO 猜测来源入仓单。普通尾程入库只确认某入仓单在托盘上的箱数,没有逐箱唛到托盘的快照;贴标下单由客户填写我方箱唛,库存仓位只作为冻结记账锚点和现场大概位置,不能据此声称箱唛位于某个具体托盘。贴标任务以我方箱唛为主键,同箱多 SKU 只存为子明细且不重复冻结。 +- 换标箱唛经现场确认后执行 `pallet-operations/relabel-disassemble`:允许仅移动该换标单对应的 `hold_packages`,不得解冻为普通可用库存;事务内同步更新 `ll_relabel_item.source_inventory_position_id`。若客户确认后已生成换标出库单,还必须在拣货开始前同步拆分或改写 `ll_outbound_pallet_item` 的托盘、箱唛、箱数与来源仓位快照,使 PDA 后续按新旧托盘分别扫描且每个指定箱唛只出库一次。 +- `FIRST_LEG` 在途、计划转换与 PDA 入账统一使用尾程订单冻结的实际接柜海外仓 ID/编码/名称,禁止使用国内来源仓或 FBA 目的仓冒充;接柜仓变更必须在同一事务内修订尚未形成实物仓位的在途余额并写零差额审计流水。余额分页、汇总和 CSV 默认排除所有数量桶均为零的维度,跨仓入账清空的无仓位来源余额必须原子删除。 +- 列表、详情、导出和更新必须使用同一租户边界,不能仅在列表过滤而让详情或更新越权。 +- 目的仓变更必须写状态/操作历史或审计证据,并使用版本控制防止并发覆盖。 +- 一件代发废弃:申请只锁箱 `AVAILABLE→DISCARD_PENDING`;平台批准(`lastleg:cargo-discard:approve`)只授权。绑定仓的海外 PDA(`lastleg:pda:operate`)实际确认时,同事务调用 discard adjustment,扣 SKU 件数及库存余额/位置,实际箱转 `DISCARDED` 并写唯一逐箱流水,数量须守恒;未废弃预留箱释放,失败整体回滚。按实际数量汇总费用,平台结案不再扣减。终态同完整载荷重放不新增流水/历史,异数量拒绝;驳回只释放锁箱。完整流程见 `../../docs/头程到尾程分阶段流程/阶段五-海外接柜库存与库内服务.md` 第9.5节。 +- 海外采购样品履约按 `../../docs/海外仓业务/采购订单样品履约流程变更需求.md` 执行:通用编辑仅限内部备注,实收、装箱、面单、两段交寄和资金走独立节点。采购使用 Admin 履约台账,不接入 PDA、不写 WMS 可用库存。 +- 派车状态只允许 PDA 拣货、完成和出库动作推进,普通派车编辑接口不得修改状态。PDA 写操作要求 `lastleg:pda:operate`;后台库存表单操作要求 `lastleg:inventory:manage`,盘点调整另要求 `lastleg:inventory:adjust`,转仓库存操作要求 `lastleg:warehouse-transfer:manage`。 +- `ll_order.current_status` 只保存尾程主流程状态;换标、转仓、出库和派送等子单精确状态保存到各自单据并汇总到 `sub_order_status`,不得再用子单状态覆盖主流程状态。PDA 入仓及库存落账完成后,主流程状态固定推进为 `INBOUND_COMPLETED`。 + +## 修改与验证 + +- `RelabelBindingGuard` 校验已保存组托历史与当前库存托盘一致,箱唛/混合换标正常完工、客户确认与排车提交均复验;纯 SKU 不要求移托,异常手动完工仍须在客户确认前补齐绑定。创建申请按库存余额串行检查箱唛既有申请,拒绝重复申请;历史扫码全局唯一约束尚未改为多轮复用。 + +- 已确认箱唛改绑复用 `PdaPalletOperationService.relabelDisassemble` 不带 `actualSource` 的精确来源分支,按现有绑定移动 HOLD 库存,不重新分配冻结锚点;成功同样记录 `PALLET_BIND` 以展示最新目标。`requireUnallocatedRelabel` 在入口与事务锁单后限制排车/出库占用,已创建出库单也不可直接改绑。回归 `PdaPalletOperationServiceTest`。 + +- PDA旧SKU单扫:`PdaRelabelService.scanSku` 和 `/relabel-tasks/{relabelNo}/skus/scan`,按箱itemId+skuIndex核对旧SKU,服务端取新SKU/数量,逐条记录现有 `SKU_SCAN` 历史,版本、幂等、认领和仓库校验不变;同箱全部SKU完成再推进SKU阶段,混合单需两阶段才生成一条箱事实。无新增字段;前端按历史当前轮次隐藏已完成子项,重复旧SKU须由操作员选实际箱。 + +- 数量拆托成功在现有 `ll_relabel_pda_history` 写 `PALLET_BIND`,保存本轮、箱唛和目标托盘;同轮已组托箱从候选排除并在事务内防重复,返工使用新的 reviewedAt 轮次。PDA 通过详情 history 展示组托记录;不需要新增库存表字段。 + +- 换标按数量拆合托:`palletops/PdaPalletOperationService.relabelSources` 返回原入仓库存池的托盘组成,不能视为箱唛定位;`relabel-disassemble` 的 `actualSource` 携带仓位版本及现场箱数,`cartonMarks` 为本批目标绑定确认。实际托盘与冻结锚点不同时,在同余额/批次内仅重新分配本单冻结桶后合托,库存余额及原入仓单归属不变,保留逐仓位流水、物理资源补偿及幂等。每次一条实际来源仓位,可分批多托;已生成出库单禁止此重分配。认领人处理中即可新增托盘,不需客户确认;此能力不等于已具备真机打印或整单强制组托门禁。 + +- 换标申请生成的 `RELABEL_APPLICATION` 尾程主单使用 `WAIT_RELABEL`(待换标),不进入普通入库计划池;子状态仍区分处理中/待客户确认。客户确认后统一为 `WAIT_OUTBOUND`(待出库),卡车分支按换标申请的 `WAIT_PRE_DISPATCH` 资格参与排车,不重复入库。存量修正脚本 `../scripts/db/relabel-wait-status-incremental.sql` 仅调整尚未生成入库单的遗留待分配换标主单。 + +- 混合换标 `CARTON_AND_SKU` 使用现有 PDA 历史保存 `MIXED_CARTON/MIXED_SKU` 分项事实(itemId + 退回轮次);同一项防重复,两项全部完成才写一条箱事实并增加整箱计数。详情返回 `cartonProcessedPackages/skuProcessedPackages`;不得将单项完成当作整箱完成,返工后旧轮次不计入当前分项进度。 + +- 换标复核闭环:`PdaRelabelService.complete` 直接提交待客户确认,`RelabelApplicationService.confirmCompletion` 原子完成 PDA 复核;平台 `RelabelApplicationController` 的 `/review-task`、`/rework` 按状态权限提供带版本的退回。退回备注必填,上一轮事实及照片引用写入 `REWORK_ARCHIVE` 历史,原事实标记 `REWORK_ARCHIVED` 并释放扫描唯一键,当前计数归零;客户已确认不可退回。PDA 不再自行批准复核。 + +- 移动包、拆分端口/适配器或引入新的跨子域依赖时,先核对内部模块化基线并运行 `node --test scripts/analyze-lastleg-dependencies.test.mjs`;确需更新依赖基线时必须说明稳定边界变化,不能把临时循环依赖登记为长期豁免。 +- 表字段、注释和索引同步维护基线 SQL 与统一增量 SQL;生产/存量库只执行增量脚本。 +- 事件契约变化同时检查 `common-event-contract`、直接生产者/消费者、Outbox/Inbox 和 DeliveryAck 的 eventId/payloadHash;按 `../../docs/统一认证与服务通信契约.md` 的逐事件表验证,不引入旧 Payload 兼容。 +- 定向验证:从后端根执行 `mvn -pl lastleg-service -am test`。 +- 事件链路变更至少覆盖重复事件、相同来源入仓单、事务回滚、并发单号、内部令牌和租户越权测试。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 0428cd0ff..80182e0a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -2,18 +2,26 @@ package com.baiyue.lastleg.appointmentwork; import jakarta.validation.constraints.*; import java.util.List; +import java.util.Map; /** Real dispatch allocation boundary, implemented by the original appointment domain. */ public interface AppointmentWorkDispatchPort { - record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotBlank String customerId, + record Allocation(@NotNull Long planPalletId,@NotBlank String batchKey,@NotNull @PositiveOrZero Long recordVersion,@NotBlank String customerId, @NotBlank String sourceKey,@NotBlank String destinationKey,@Positive int packageCount) { } - record PhysicalItem(Long itemId,Long planPalletId,String sourceStage,Integer packages) { } + record PhysicalItem(Long itemId,String customerId,Long planPalletId,String sourceStage,Integer packages) { } record BatchSource(String sourceKey,String destinationKey,int packageCount) { } - record BatchReference(String batchKey,String batchNo,String customerId,List sources) { } + record BatchReference(String batchKey,String batchNo,Long recordVersion,String customerId,List sources) { } + record CandidatePhysical(String customerId,Long planPalletId) { } + /** sources数量由本托真实逐票量和原批次剩余量共同限定;不包含零余量的伪可选项。 */ + record CandidateBatches(List appointmentBatches,List blockReasons,boolean occupied) { } + /** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */ + Map candidateBatches(List candidates); /** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */ java.util.Map> references(List dispatchIds); - void bind(String customerId,long dispatchId,List items,List allocations); - void requireReplay(String customerId,long dispatchId,List allocations); + /** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */ + void lockBatches(Map expectedBatchVersions); + /** 仅替换已核实的受影响实物分配,保留同车其他客户及其他实物。 */ + void bindItems(long dispatchId,List touchedItems,List allocations); void requireDeparture(String dispatchNo); /** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */ String departureBlockReason(String dispatchNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index b2410ec24..7658c2a67 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -6,6 +6,10 @@ import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.dto.DeliveryResponses; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; +import com.baiyue.lastleg.delivery.service.DispatchDeliveryService; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -22,23 +26,34 @@ import org.springframework.web.server.ResponseStatusException; @Tag(name = "Outbound and delivery") public class DeliveryTransferController { @Resource private DeliveryTransferService service; + @Resource private DispatchDeliveryService dispatchDelivery; + /** 只读辅助入口复用同一整车组合预览,不保留单客户报价协议。 */ @GetMapping("/dispatches/{dispatchNo}/external-freight") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") - public ApiResponse externalFreight( + public ApiResponse> externalFreight( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { requirePlatform(user); - return ApiResponse.success(service.externalFreight(dispatchNo)); + return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups()); } + /** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */ + @GetMapping("/dispatches/{dispatchNo}/delivery-arrangement") + @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") + public ApiResponse arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + requirePlatform(user); + return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo)); + } + + /** 平台一次安排本车全部客户,身份只从认证上下文获取。 */ @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") - @Operation(summary = "Create one pending outbound order and one delivery order per destination FBA warehouse") - public ApiResponse transfer( + @Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单") + public ApiResponse transfer( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody DeliveryRequests.Transfer request) { + @Valid @RequestBody ArrangeDelivery request) { requirePlatform(user); - return ApiResponse.success(service.platformTransfer(dispatchNo, operator(user), request)); + return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request)); } @PostMapping("/outbound-orders/one-click-dropship") @@ -160,6 +175,8 @@ public class DeliveryTransferController { private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } + /** 平台安排不能使用请求体提供的客户或操作者身份。 */ + private Actor actor(AuthenticatedUser user) { return new Actor(operator(user),ActorMode.PLATFORM,null); } private String listScope(AuthenticatedUser user, String requestedCustomer) { requirePlatform(user); return requestedCustomer == null || requestedCustomer.isBlank() ? null : requestedCustomer.trim(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java index 9c0827c27..ca3a5ddb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java @@ -31,14 +31,6 @@ public class MerchantDeliveryController { return ApiResponse.success(service.deliveryPage(customer(user), query)); } - @PostMapping - @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_CREATE + "')") - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam String dispatchNo, - @Valid @RequestBody DeliveryRequests.Transfer request) { - return ApiResponse.success(service.merchantTransfer(customer(user), dispatchNo, operator(user), request)); - } - @GetMapping("/{deliveryNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DELIVERY_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @@ -71,5 +63,4 @@ public class MerchantDeliveryController { if (!"MERCHANT".equals(user.appCode()) || user.tenantId() <= 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Merchant identity is required"); return String.valueOf(user.tenantId()); } - private String operator(AuthenticatedUser user) { return user.userId() == null ? user.username() : String.valueOf(user.userId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java index b18bd481e..8878c851b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java @@ -2,8 +2,10 @@ package com.baiyue.lastleg.delivery.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; -import com.baiyue.lastleg.delivery.service.DeliveryFeeCoverageService; +import com.baiyue.lastleg.delivery.service.DispatchDeliveryService; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.PreviewGroup; import com.baiyue.lastleg.security.OverseasPermissions; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -16,13 +18,15 @@ import org.springframework.web.server.ResponseStatusException; @RequiredArgsConstructor @RequestMapping("/api/platform/v1/last-leg/dispatches") public class PlatformDeliveryFeeCoverageController { - private final DeliveryFeeCoverageService service; + private final DispatchDeliveryService service; + /** 原费辅助查询复用真实整车分组,不能再从头部推定单客户。 */ @GetMapping("/{dispatchNo}/fee-coverage") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") - public ApiResponse preview(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse> preview(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { if (user == null || user.tenantId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) throw new ResponseStatusException(HttpStatus.FORBIDDEN); - return ApiResponse.success(service.preview(dispatchNo)); + String operator=user.userId()==null?user.username():user.userId().toString(); + return ApiResponse.success(service.preview(new Actor(operator,ActorMode.PLATFORM,null),dispatchNo).groups()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java index 805b90c08..64e3c9e8d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java @@ -9,10 +9,6 @@ public final class DeliveryFeeCoverageModels { private DeliveryFeeCoverageModels() { } public record Cargo(Long dispatchItemId, Long warehousePalletId, String palletNo, Long orderId, String sourceRegion, String sourceInboundId, String inboundCode, long packages, String dispatchInboundCode) { } - public record Preview(String dispatchNo, boolean sourceComplete, String blockReason, String cargoHash, - List cargo, List feeSources) { } - public record Selection(@NotNull @Positive Long financeFeeId, @Min(1) int sourceVersion, - @NotBlank @Size(max=128) String sourceHash, @NotEmpty @Size(max=3) List<@NotBlank @Pattern(regexp="DELIVERY_FEE|LOADING_FEE|APPOINTMENT_FEE") String> services) { } public record Reference(FinanceFeeCoverageClient.Fee source, List services) { } public record Evidence(String cargoHash, List cargo, List references, String operatorId, Instant confirmedAt) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java index e4943f8a5..029acd54c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java @@ -6,7 +6,6 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Size; import jakarta.validation.Valid; -import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Selection; import java.util.List; import java.util.Collections; import java.math.BigDecimal; @@ -16,46 +15,6 @@ import java.time.LocalDate; public final class DeliveryRequests { private DeliveryRequests() {} - public record Transfer(@NotBlank @Size(max = 255) String idempotencyKey, - @NotNull LocalDateTime plannedDeliveryAt, - @NotBlank @Size(max = 32) String deliveryMethod, - @Size(max = 128) String waybillNo, - @Size(max = 255) String carrierName, - @NotBlank @Size(max = 64) String pickupMethod, - @NotBlank @Size(max = 64) String loadingType, - @Size(max = 64) String vehicleType, - @jakarta.validation.constraints.Pattern(regexp = "15托|30托") String vehicleSize, - @Size(max = 128) String driverName, - @Size(max = 64) String driverPhone, - @Size(max = 64) String vehiclePlate, - @Size(max = 128) String appointmentAccount, - @Size(max = 128) String sellerName, - @Size(max = 1000) String remark, - @NotNull @Valid @Size(max = 20) List fees, - @Size(max = 64) String externalFreightHash, - @Size(max = 64) String feeCoverageCargoHash, - @Size(max = 2000) List<@NotNull @Valid Selection> feeCoverage) {} - - public record FeeItem(@NotBlank @Size(max = 64) String feeType, - @NotBlank @Size(max = 128) String feeName, - @NotBlank @jakarta.validation.constraints.Pattern(regexp = "RECEIVABLE|PAYABLE") String direction, - @NotBlank @jakarta.validation.constraints.Pattern(regexp = "CNY|USD") String currency, - @NotNull @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal amount, - @Min(1) Long supplierId, - @Size(max = 200) String supplierName, - @Size(max = 32) String supplierCategory, - @jakarta.validation.constraints.Pattern(regexp = "ON_ACCOUNT|DIRECT_PAID") String settlementMode, - LocalDateTime paidAt, - @jakarta.validation.constraints.Pattern(regexp = "ONLINE|BANK_TRANSFER|CASH|OTHER") String paymentMethod, - @Size(max = 128) String paymentReferenceNo, - @Size(max = 20) List<@NotBlank String> paymentVoucherFileIds, - @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal chargeableQuantity, - @Size(max = 32) String quantityUnit, - @jakarta.validation.constraints.DecimalMin(value = "0.00", inclusive = false) BigDecimal unitPrice, - LocalDate periodStart, - LocalDate periodEnd, - @Size(max = 500) String remark) {} - public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String documentNo, String lxCode, String warehouseId, String status, String dispatchNo, String waybillNo, String vehiclePlate, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java index a30ed1d3e..15d65e1de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java @@ -8,14 +8,6 @@ import com.fasterxml.jackson.annotation.JsonInclude; public final class DeliveryResponses { private DeliveryResponses() {} - public record TransferDelivery(String deliveryNo, String deliveryStatus, - String destinationWarehouseId, String destinationWarehouseCode, - String destinationWarehouseName, int palletCount, int packageCount) {} - - public record TransferResult(String dispatchNo, String outboundNo, String outboundStatus, - int palletCount, int packageCount, - List deliveries, boolean replayed) {} - public record OutboundRow(Long id, String outboundNo, String dispatchNo, String overseasWarehouseId, String lxCodes, String status, int palletCount, int packageCount, BigDecimal grossWeightKg, BigDecimal volumeWeightKg, BigDecimal volumeCbm, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java new file mode 100644 index 000000000..d59c14ccc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.delivery.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 签收事务内保存的本地待汇总事实,不携带整车已完成结论。 */ +@Data +public class DeliveryFactsChanged { + private Long id; + private String customerId; + private Long dispatchId; + private String dispatchNo; + private Long deliveryId; + private String deliveryNo; + private Long deliveryVersion; + private String factsHash; + private LocalDateTime occurredAt; + private String state; + private Integer attemptCount; + private LocalDateTime nextAttemptAt; + private String lastErrorCode; + private LocalDateTime processedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java new file mode 100644 index 000000000..baeb4f5ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.delivery.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 每趟车唯一安排事实,保留完整客户结果及真实来源。 */ +@Data +public class DispatchDeliveryArrangement { + private Long id; + private Long dispatchOrderId; + private String dispatchNo; + private String arrangementKey; + private String businessHash; + private String cargoHash; + private String loadingCompletionHash; + private String originalCommandKey; + private String originalOperatorId; + private String resultJson; + private String cargoJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java index 3345ff9d3..d70dc6a4f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java @@ -16,6 +16,14 @@ public class OutboundPalletItem { private Long id; private String customerId; private Long outboundOrderId; + /** 排车出库明确关联原作业明细,不从托盘展示字段猜来源。 */ + private Long dispatchItemId; + /** 对应本次真实PDA拣货归属,作为客户和数量核对的不可变来源。 */ + private Long sourceAttributionId; + private Long sourceOrderId; + private Long sourceInventoryPositionId; + private Long sourceReturnReceiptId; + private Long sourceReturnCartonId; private Long warehousePalletId; private String palletNo; private String destinationWarehouseId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java new file mode 100644 index 000000000..5f70d7a76 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 本地签收信号的唯一写入、扫描、确认及失败记录。 */ +@Mapper +public interface DeliveryFactsChangedMapper { + /** 同一派送版本只登记一次,不覆盖首次不可变事实。 */ + @Insert(""" + INSERT INTO ll_delivery_facts_changed + (id,customer_id,dispatch_id,dispatch_no,delivery_id,delivery_no,delivery_version,facts_hash, + occurred_at,state,attempt_count,next_attempt_at) + VALUES (#{id},#{customerId},#{dispatchId},#{dispatchNo},#{deliveryId},#{deliveryNo}, + #{deliveryVersion},#{factsHash},#{occurredAt},'PENDING',0,#{occurredAt}) + ON DUPLICATE KEY UPDATE id=id + """) + int insertOnce(DeliveryFactsChanged fact); + + /** 唯一键等待后读取当前已保存事实,不能用旧快照确认异载荷重放。 */ + @Select(""" + SELECT * FROM ll_delivery_facts_changed + WHERE customer_id=#{customerId} AND delivery_id=#{deliveryId} AND delivery_version=#{version} + """) + DeliveryFactsChanged find(@Param("customerId") String customerId, @Param("deliveryId") long deliveryId, + @Param("version") long version); + + /** 按已扫描信号身份读取,不取得父级之前的子行锁。 */ + @Select("SELECT * FROM ll_delivery_facts_changed WHERE id=#{id}") + DeliveryFactsChanged findById(@Param("id") long id); + + /** 普通读候选,不在申请整车父锁之前锁定信号行。 */ + @Select(""" + SELECT * FROM ll_delivery_facts_changed + WHERE state='PENDING' AND next_attempt_at<=#{now} + ORDER BY next_attempt_at,id LIMIT #{limit} + """) + List pending(@Param("now") LocalDateTime now, @Param("limit") int limit); + + /** 父级汇总同事务仅确认指定的已核对信号,不能吞掉之后产生的版本。 */ + @Update(""" + UPDATE ll_delivery_facts_changed SET state='PROCESSED',processed_at=#{now},last_error_code=NULL + WHERE id=#{id} AND dispatch_id=#{dispatchId} AND state='PENDING' + """) + int acknowledge(@Param("id") long id, @Param("dispatchId") long dispatchId, + @Param("now") LocalDateTime now); + + /** 汇总回滚后以独立短事务记录退避,不改成已完成。 */ + @Update(""" + UPDATE ll_delivery_facts_changed + SET attempt_count=attempt_count+1,last_error_code=#{errorCode},next_attempt_at=GREATEST(next_attempt_at,#{nextAttemptAt}) + WHERE id=#{id} AND state='PENDING' + """) + int failed(@Param("id") long id, @Param("errorCode") String errorCode, + @Param("nextAttemptAt") LocalDateTime nextAttemptAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java index 9b949b501..9caf75f13 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java @@ -9,6 +9,18 @@ import java.util.List; @Mapper public interface DeliveryOrderMapper extends BaseMapper { + /** 整车签收事实使用完整真实客户单据集合。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id") + List findAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 取得全部出库锁后一次取得全部派送锁,禁止逐客户混取来源锁。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id FOR UPDATE") + List lockAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 服务内签收信号按整车及派送号定位真实客户,不接受浏览器客户范围。 */ + @Select("SELECT * FROM ll_delivery_order WHERE dispatch_no=#{dispatchNo} AND delivery_no=#{deliveryNo}") + List findForSignal(@Param("dispatchNo") String dispatchNo, @Param("deliveryNo") String deliveryNo); + @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND outbound_order_id=#{outboundOrderId} ORDER BY id") List findByOutbound(@Param("customerId") String customerId, @Param("outboundOrderId") Long outboundOrderId); @Select("SELECT * FROM ll_delivery_order WHERE customer_id=#{customerId} AND delivery_no=#{deliveryNo} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java index d83da8796..ccfb38ccf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java @@ -18,18 +18,22 @@ public interface DeliverySourceEvidenceMapper { "WHERE customer_id=#{customerId} AND source_region='FIRSTLEG' AND id=#{orderId} FOR UPDATE") Source lockSource(@Param("customerId") String customerId, @Param("orderId") long orderId); + /** 从本客户真实扫码及逐票归属定位共享整车任务,不依赖不存在的任务头客户。 */ @Select("SELECT DISTINCT t.dispatch_order_id FROM ll_dispatch_pda_pick_order a " + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + - "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "JOIN ll_dispatch_pda_task t ON t.id=p.task_id " + "WHERE a.customer_id=#{customerId} AND a.order_id=#{orderId} ORDER BY t.dispatch_order_id") List dispatchIds(@Param("customerId") String customerId, @Param("orderId") long orderId); @Select("SELECT d.id AS delivery_id,d.dispatch_order_id AS dispatch_id,d.delivery_group_key AS group_key," + - "d.package_count AS packages,d.signed_packages,d.signed_at,ds.depart_at AS departed_at FROM ll_delivery_order d " + - "LEFT JOIN ll_dispatch_order ds ON ds.customer_id=d.customer_id AND ds.id=d.dispatch_order_id " + + "d.package_count AS packages,d.signed_packages,d.signed_at,ds.departed_at AS departed_at FROM ll_delivery_order d " + + "LEFT JOIN ll_dispatch_order ds ON ds.id=d.dispatch_order_id " + + "AND EXISTS (SELECT 1 FROM ll_dispatch_item di_member WHERE di_member.dispatch_order_id=ds.id AND di_member.customer_id=d.customer_id) " + "WHERE d.customer_id=#{customerId} AND d.dispatch_order_id=#{dispatchId} ORDER BY d.id") + /** 头部不再代表单客户;由真实货物客户成员证明派送归属,保持单据客户过滤。 */ List deliveries(@Param("customerId") String customerId, @Param("dispatchId") long dispatchId); + /** 共享任务下只读取同一真实客户的扫码、订单和回仓凭据,卸货组沿原权威规则生成。 */ @Select("SELECT a.id AS attribution_id,t.dispatch_order_id AS dispatch_id," + "CASE WHEN di.destination_type='PRIVATE' AND NULLIF(TRIM(di.delivery_address),'') IS NOT NULL " + "THEN CONCAT('PRIVATE:',TRIM(di.delivery_address),':',TRIM(COALESCE(di.postal_code,''))) " + @@ -38,7 +42,7 @@ public interface DeliverySourceEvidenceMapper { "WHEN NULLIF(TRIM(di.destination_warehouse_id),'') IS NOT NULL THEN CONCAT('ID:',TRIM(di.destination_warehouse_id)) ELSE NULL END AS group_key," + "a.order_id,a.packages,a.receipt_id,a.return_receipt_id,r.inventory_ledger_id AS return_ledger_id,r.received_packages AS return_packages FROM ll_dispatch_pda_pick_order a " + "JOIN ll_dispatch_pda_pick p ON p.customer_id=a.customer_id AND p.id=a.pick_id " + - "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "JOIN ll_dispatch_pda_task t ON t.id=p.task_id " + "LEFT JOIN ll_dispatch_item di ON di.customer_id=p.customer_id AND di.id=p.dispatch_item_id AND di.dispatch_order_id=t.dispatch_order_id " + "LEFT JOIN ll_return_stock_receipt r ON r.customer_id=a.customer_id AND r.id=a.return_receipt_id AND r.order_id=a.order_id " + "AND EXISTS (SELECT 1 FROM ll_inventory_ledger rl JOIN ll_exception_order re ON re.customer_id=rl.customer_id " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java new file mode 100644 index 000000000..9e7ed734b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.delivery.mapper; + +import com.baiyue.lastleg.delivery.entity.DispatchDeliveryArrangement; +import org.apache.ibatis.annotations.*; + +/** 整车唯一安排事实的持久化,不以客户出库头替代整车身份。 */ +@Mapper +public interface DispatchDeliveryArrangementMapper { + /** 普通读组合预览中的当前安排事实。 */ + @Select("SELECT * FROM ll_dispatch_delivery_arrangement WHERE dispatch_order_id=#{dispatchId}") + DispatchDeliveryArrangement find(@Param("dispatchId") long dispatchId); + + /** 调用方先持共同父锁,再读取唯一安排。 */ + @Select("SELECT * FROM ll_dispatch_delivery_arrangement WHERE dispatch_order_id=#{dispatchId} FOR UPDATE") + DispatchDeliveryArrangement lock(@Param("dispatchId") long dispatchId); + + /** 单车、安排号及首次请求键均由唯一约束防重复。 */ + @Insert(""" + INSERT INTO ll_dispatch_delivery_arrangement + (id,dispatch_order_id,dispatch_no,arrangement_key,business_hash,cargo_hash,loading_completion_hash, + original_command_key,original_operator_id,result_json,cargo_json,created_at) + VALUES (#{id},#{dispatchOrderId},#{dispatchNo},#{arrangementKey},#{businessHash},#{cargoHash}, + #{loadingCompletionHash},#{originalCommandKey},#{originalOperatorId},#{resultJson},#{cargoJson},#{createdAt}) + """) + int insert(DispatchDeliveryArrangement arrangement); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java index a71e718ce..c560d427c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java @@ -9,6 +9,14 @@ import java.util.List; @Mapper public interface OutboundOrderMapper extends BaseMapper { + /** 整车跨客户读取全部出库单,不用第一张代表全车。 */ + @Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id") + List findAllByDispatch(@Param("dispatchId") long dispatchId); + + /** 父锁后先收齐所有客户出库锁,再锁派送和来源。 */ + @Select("SELECT * FROM ll_outbound_order WHERE dispatch_order_id=#{dispatchId} ORDER BY LENGTH(customer_id),customer_id,id FOR UPDATE") + List lockAllByDispatch(@Param("dispatchId") long dispatchId); + @Select("SELECT * FROM ll_outbound_order WHERE customer_id=#{customerId} AND outbound_no=#{outboundNo} LIMIT 1") OutboundOrder findByNo(@Param("customerId") String customerId, @Param("outboundNo") String outboundNo); @Select("SELECT * FROM ll_outbound_order WHERE outbound_no=#{outboundNo} LIMIT 1") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java new file mode 100644 index 000000000..db94a64a1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Destination; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.security.MessageDigest; +import java.util.*; + +/** 整车按真实客户与目的地拆分,物理托盘和来源行分别统计。 */ +@Component +public class DeliveryCargoGroups { + private final ObjectMapper json; + + /** 使用服务统一序列化器计算稳定业务摘要。 */ + public DeliveryCargoGroups(ObjectMapper json) { this.json=json; } + + /** 本域分组结果不作为另一套公网DTO或跨域Entity。 */ + public record Group(String key,String customerId,String customerName,Destination destination, + String cargoHash,List cargo,long packages,long palletCount) { } + + /** 只接受完整真实拣货来源,不从展示入仓码或计划数补齐事实。 */ + public List groups(Context context) { + if (context==null || context.counts()==null || context.cargo()==null || context.cargo().isEmpty()) + throw conflict("排车单没有真实待派送货物"); + var groups=new TreeMap>(); + var attributions=new HashSet(); + var palletDestinations=new HashMap(); + for (var row:context.cargo()) { + if (row==null || row.dispatchItemId()==null || row.dispatchItemId()<=0 || blank(row.customerId()) || "0".equals(row.customerId()) + || row.orderId()==null || row.orderId()<=0 || row.packages()<=0 || row.pickId()==null || row.pickId()<=0 || row.attributionId()==null || row.attributionId()<=0 + || blank(row.sourceKey()) || row.destination()==null || blank(row.destination().key())) + throw conflict("货物缺少真实客户、拣货或来源归属"); + if ("RETURN_RELABEL".equals(row.sourceStage()) + ? row.returnCartonId()==null || row.returnCartonId()<=0 + : row.inventoryPositionId()==null || row.inventoryPositionId()<=0) + throw conflict("本次拣货缺少实际库存仓位或退货箱来源"); + if (!attributions.add(row.attributionId())) throw conflict("本次派送包含重复拣货来源"); + if (blank(row.destination().type()) || !Set.of("FBA","PRIVATE").contains(row.destination().type())) + throw conflict("货物派送目的地类型不完整"); + if (row.warehousePalletId()!=null) { + var previous=palletDestinations.putIfAbsent(row.warehousePalletId(),row.destination().key()); + if (previous!=null && !previous.equals(row.destination().key())) throw conflict("同一实物托盘不同目的地须先拆托"); + } + if ("FBA".equals(row.destination().type()) && blank(row.destination().warehouseCode()) + || "PRIVATE".equals(row.destination().type()) && blank(row.destination().address())) + throw conflict("货物缺少真实目的仓或派送地址"); + groups.computeIfAbsent(groupKey(row.customerId(),row.destination()),ignored->new ArrayList<>()).add(row); + } + long total=context.cargo().stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact); + if (!context.pickingReconciled() || total!=context.counts().packageCount() + || total!=context.counts().pickedPackageCount() && total!=context.counts().shippedPackageCount()) + throw conflict("实际拣货来源与本车箱数尚未核平"); + var result=new ArrayList(); + for (var entry:groups.entrySet()) { + var rows=entry.getValue().stream().sorted(Comparator.comparing(CargoLine::dispatchItemId) + .thenComparing(CargoLine::attributionId)).toList(); + var first=rows.get(0); + if (rows.stream().anyMatch(row->!Objects.equals(row.destination(),first.destination()))) + throw conflict("同组货物的目的地信息不一致,请核对后安排"); + long packages=rows.stream().mapToLong(CargoLine::packages).reduce(0,Math::addExact); + long pallets=rows.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count(); + result.add(new Group(entry.getKey(),first.customerId(),first.customerName(),first.destination(), + hash(rows),rows,packages,pallets)); + } + return List.copyOf(result); + } + + /** 客户是组身份的一部分,同目的仓的其他客户永不合单。 */ + public String groupKey(String customerId,Destination destination) { + return hash(List.of(customerId,destination.key())); + } + + /** 本地派送来源证明沿用单据分组键,明确映射既有FBA和私人地址语义。 */ + public String documentGroupKey(Destination destination) { + if ("PRIVATE".equals(destination.type()) && !blank(destination.address())) + return "PRIVATE:"+destination.address().trim()+":"+Objects.toString(destination.postalCode(),"").trim(); + if ("FBA".equals(destination.type()) && !blank(destination.warehouseCode())) + return "CODE:"+destination.warehouseCode().trim().toUpperCase(Locale.ROOT); + throw conflict("派送目的地缺少真实业务标识"); + } + + /** 摘要仅使用规范化领域值,不包含操作者或偶然集合遍历顺序。 */ + public String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.writeValueAsBytes(value))); } + catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { + throw new IllegalStateException("派送业务摘要生成失败",error); + } + } + + /** 缺失业务事实直接阻断,不用内部编号回退展示。 */ + private static boolean blank(String value) { return value==null || value.isBlank(); } + /** 业务冲突不产生部分单据。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java new file mode 100644 index 000000000..3d59a40c1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java @@ -0,0 +1,53 @@ +package com.baiyue.lastleg.delivery.model; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import org.springframework.stereotype.Component; +import java.time.Instant; +import java.util.*; + +/** 五阶段只接受原实发全部正常签收,不将真实退回当作签收数量。 */ +@Component +public class NormalDeliveryCompletionPolicy { + private final DeliveryCargoGroups hashes; + + /** 复用本域确定性摘要器,不申请历史来源订单锁。 */ + public NormalDeliveryCompletionPolicy(DeliveryCargoGroups hashes) { this.hashes=hashes; } + + /** 按已安排单据精确集合和原实发数量核对,空集合不算完成。 */ + public NormalDeliveryFacts evaluate(Context context,ArrangementResult arrangement,List actual,Instant now) { + var reasons=new ArrayList(); + var expected=arrangement==null?List.of():arrangement.deliveries(); + var seen=new HashMap(); + if (context.departedAt()==null || now==null || context.departedAt().isAfter(now)) reasons.add("尚无有效实际发车事实"); + if (expected==null || expected.isEmpty()) reasons.add("缺少本车已安排派送单"); + for (var fact:actual) if (fact==null || fact.deliveryNo()==null || seen.putIfAbsent(fact.deliveryNo(),fact)!=null) + reasons.add("派送事实缺失或重复"); + var expectedNos=expected==null?List.of():expected.stream().map(DeliveryReference::deliveryNo).sorted().toList(); + if (new HashSet<>(expectedNos).size()!=expectedNos.size() || !seen.keySet().equals(new HashSet<>(expectedNos))) + reasons.add("派送单未精确覆盖本车安排"); + long total=0; Instant completedAt=null; + if (expected!=null) for (var reference:expected) { + var fact=seen.get(reference.deliveryNo()); + if (fact==null) continue; + if (!Objects.equals(fact.customerId(),reference.customerId()) || !Objects.equals(fact.groupKey(),reference.groupKey()) + || !Objects.equals(fact.outboundNo(),reference.outboundNo()) || fact.originalPackages()!=reference.packageCount()) + reasons.add("派送客户、目的地或原实发来源不一致"); + total=Math.addExact(total,fact.originalPackages()); + // full签收字段是原运输事实;签后另退不重开,但签剩余/拒收/无签收永不冒充原全签。 + if (!fact.outboundCompleted() || fact.status()==null || !Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(fact.status()) || fact.originalPackages()<=0 + || fact.signedPackages()!=fact.originalPackages() || fact.signedAt()==null) + reasons.add("仍有货物未正常全部签收,异常或退回不能代替签收"); + else if (context.departedAt()==null || fact.signedAt().isBefore(context.departedAt()) + || now==null || fact.signedAt().isAfter(now)) reasons.add("签收时间与真实发车事实不一致"); + else if (completedAt==null || fact.signedAt().isAfter(completedAt)) completedAt=fact.signedAt(); + } + if (total<=0 || context.counts()==null || total!=context.counts().shippedPackageCount()) + reasons.add("签收单据未完整覆盖本趟真实实发箱数"); + var sorted=actual.stream().filter(Objects::nonNull).sorted(Comparator.comparing(NormalDeliveryFact::customerId,Comparator.nullsFirst(String::compareTo)) + .thenComparing(NormalDeliveryFact::deliveryNo,Comparator.nullsFirst(String::compareTo))).toList(); + boolean normal=reasons.isEmpty(); + return new NormalDeliveryFacts(context.dispatchNo(),hashes.hash(Arrays.asList(context.dispatchNo(),expectedNos,sorted)), + normal,normal?completedAt:null,expectedNos,sorted,reasons.stream().distinct().toList()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java new file mode 100644 index 000000000..d4f199332 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Objects; + +/** 未执行出库取消的数据访问,业务资格与费用撤销仍由Service编排。 */ +@Repository +public class DeliveryCancellationRepository { + private final OutboundOrderMapper outbounds; + private final OutboundPalletItemMapper items; + private final DeliveryOrderMapper deliveries; + private final OutboundPdaTaskMapper tasks; + private final DeliveryOperationMapper operations; + private final RelabelApplicationMapper relabels; + private final ReturnInventoryCartonMapper cartons; + private final WarehouseTransferOrderMapper transfers; + private final LastlegOrderMapper sources; + private final LastlegOrderStatusHistoryMapper histories; + + /** 装配原领域Mapper,不能将SQL搬到取消Service。 */ + public DeliveryCancellationRepository(OutboundOrderMapper outbounds,OutboundPalletItemMapper items,DeliveryOrderMapper deliveries, + OutboundPdaTaskMapper tasks,DeliveryOperationMapper operations,RelabelApplicationMapper relabels, + ReturnInventoryCartonMapper cartons,WarehouseTransferOrderMapper transfers,LastlegOrderMapper sources,LastlegOrderStatusHistoryMapper histories) { + this.outbounds=outbounds;this.items=items;this.deliveries=deliveries;this.tasks=tasks;this.operations=operations; + this.relabels=relabels;this.cartons=cartons;this.transfers=transfers;this.sources=sources;this.histories=histories; + } + /** 先普通定位真实父身份。 */ + public OutboundOrder find(String no) { return outbounds.findGlobalByNo(no); } + /** 父级协调完成后才申请出库锁。 */ + public OutboundOrder lock(String no) { return outbounds.lockGlobalByNo(no); } + /** 独立换标出库保留其原域确认锁。 */ + public RelabelApplication lockRelabel(OutboundOrder order) { return relabels.lockByOutbound(order.getCustomerId(),order.getOutboundNo()); } + /** 子明细始终按出库客户读取。 */ + public List items(OutboundOrder order) { return items.findByOutbound(order.getCustomerId(),order.getId()); } + /** 一次取得本出库全部派送锁,不边持来源锁边锁下一张派送。 */ + public List deliveries(OutboundOrder order) { return deliveries.lockByOutbound(order.getCustomerId(),order.getId()); } + /** 退货实际箱必须在原域锁内核对。 */ + public ReturnInventoryCarton lockCarton(String mark) { return cartons.lockMark(mark); } + /** 只释放尚未执行的预留箱,不生成退库流水。 */ + public void save(ReturnInventoryCarton value) { cartons.updateById(value); } + /** 更新已锁派送取消状态。 */ + public void save(DeliveryOrder value) { deliveries.updateById(value); } + /** PDA任务与出库同事务取消。 */ + public OutboundPdaTask task(OutboundOrder value) { return tasks.lockByOutbound(value.getCustomerId(),value.getId()); } + /** 保存已锁任务状态。 */ + public void save(OutboundPdaTask value) { tasks.updateById(value); } + /** 保存已锁出库状态。 */ + public void save(OutboundOrder value) { outbounds.updateById(value); } + /** 保存尚未执行的出库明细状态。 */ + public void save(OutboundPalletItem value) { items.updateById(value); } + /** 锁原转仓单以恢复其明确业务状态。 */ + public WarehouseTransferOrder lockTransfer(String customer,String no) { return transfers.lockNo(customer,no); } + /** 保存转仓恢复状态。 */ + public void save(WarehouseTransferOrder value) { transfers.updateById(value); } + /** 相关来源在费用锁前以数字ID顺序锁定。 */ + public LastlegOrder lockSource(String customer,long id) { return sources.lockById(customer,id); } + /** 来源候选一次读齐并固定顺序,不能按展示入仓码挑一票。 */ + public List sources(String customer,String outboundNo) { + return sources.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId,customer) + .eq(LastlegOrder::getOutboundOrderNo,outboundNo).orderByAsc(LastlegOrder::getId)); + } + /** 按真实转换历史恢复,而非统一写某个旧状态。 */ + public LastlegOrderStatusHistory previous(String customer,long id,List events) { + return histories.selectOne(new LambdaQueryWrapper().eq(LastlegOrderStatusHistory::getCustomerId,customer) + .eq(LastlegOrderStatusHistory::getOrderId,id).in(LastlegOrderStatusHistory::getEventId,events) + .orderByAsc(LastlegOrderStatusHistory::getCreatedAt,LastlegOrderStatusHistory::getId).last("LIMIT 1")); + } + /** 显式清空业务关联并增加版本,防止MyBatis跳过null留下旧单号。 */ + public boolean restore(LastlegOrder value,String outboundNo,String status,String subStatus,LocalDateTime now) { + return sources.update(null,new LambdaUpdateWrapper().eq(LastlegOrder::getId,value.getId()) + .eq(LastlegOrder::getCustomerId,value.getCustomerId()).eq(LastlegOrder::getOutboundOrderNo,outboundNo) + .set(LastlegOrder::getCurrentStatus,status).set(LastlegOrder::getSubOrderStatus,subStatus) + .set(LastlegOrder::getOutboundOrderNo,null).set(LastlegOrder::getDeliveryOrderNo,null) + .set(LastlegOrder::getVersion,Objects.requireNonNullElse(value.getVersion(),0)+1).set(LastlegOrder::getUpdatedAt,now))==1; + } + /** 保存不可变取消历史。 */ + public void history(LastlegOrderStatusHistory value) { histories.insert(value); } + /** 完整原因由业务层分段保存,不截断丢失。 */ + public void operation(DeliveryOperation value) { operations.insert(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java new file mode 100644 index 000000000..3dcc6318a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.model.DeliveryReturnReceiptFact; +import com.baiyue.lastleg.delivery.model.SourceDeliveryCompletionPolicy; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; + +/** 派送资料及签收关联的数据访问,业务事务和来源证明由Service编排。 */ +@Repository +public class DeliveryDocumentRepository { + private final DeliveryOrderMapper orders; + private final OutboundOrderMapper outbounds; + private final DeliveryDocumentRevisionMapper revisions; + private final DeliveryOperationMapper operations; + private final LastlegOrderMapper sources; + private final DeliverySourceEvidenceMapper evidence; + + /** 装配各表Mapper,不在资料服务直接访问持久化接口。 */ + public DeliveryDocumentRepository(DeliveryOrderMapper orders, OutboundOrderMapper outbounds, + DeliveryDocumentRevisionMapper revisions, DeliveryOperationMapper operations, + LastlegOrderMapper sources, DeliverySourceEvidenceMapper evidence) { + this.orders=orders; this.outbounds=outbounds; this.revisions=revisions; + this.operations=operations; this.sources=sources; this.evidence=evidence; + } + + /** 客户和派送号共同定位资料单。 */ + public DeliveryOrder find(String customerId, String no) { return orders.findByNo(customerId,no); } + /** 签收先锁自己的派送,随后才能申请来源订单锁。 */ + public DeliveryOrder lock(String customerId, String no) { return orders.lockByNo(customerId,no); } + /** 出库事实普通读,避免在派送锁之后反向申请出库锁。 */ + public OutboundOrder outbound(String customerId, String no) { return outbounds.findByNo(customerId,no); } + /** 读取当前资料命令以保持原请求重放语义。 */ + public DeliveryDocumentRevision command(String customerId,long id,String key) { return revisions.findCommand(customerId,id,key); } + /** 保存已校验的资料和签收字段。 */ + public void save(DeliveryOrder order) { orders.updateById(order); } + /** 同事务保存不可变资料版本。 */ + public void revision(DeliveryDocumentRevision revision) { revisions.insert(revision); } + /** 同事务保存业务历史。 */ + public void operation(DeliveryOperation operation) { operations.insert(operation); } + /** 读取资料版本历史,不暴露其他客户附件。 */ + public List history(String customerId,long id) { return revisions.history(customerId,id); } + /** 签收仍需拒绝未完成的真实退回流程。 */ + public long activeReturns(String customerId,long id) { return operations.activeReturns(customerId,id); } + /** 只读取本派送的真实退回入账事实。 */ + public List returns(String customerId,long id) { return operations.returnReceipts(customerId,id); } + /** 拣货来源按真实客户与整车读取,不拿当前订单指针替代历史。 */ + public List picks(String customerId,long dispatchId) { return evidence.picks(customerId,dispatchId); } + /** 批量读取候选来源,再由Service复核客户归属。 */ + public List sourceOrders(Collection ids) { return ids.isEmpty()?List.of():sources.selectBatchIds(ids); } + /** 非头程本地单据仍读取其本客户的既有当前关系。 */ + public List locallyLinked(String customerId,String deliveryNo) { + return sources.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId,customerId) + .eq(LastlegOrder::getDeliveryOrderNo,deliveryNo)); + } + /** 来源锁由证明服务持有后重新读取当前行,避免旧快照覆盖。 */ + public LastlegOrder source(long id) { return sources.selectById(id); } + /** 只保存Service根据真实来源签收证明得到的子状态。 */ + public void saveSource(LastlegOrder order) { sources.updateById(order); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java new file mode 100644 index 000000000..cfdf3756d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import com.baiyue.lastleg.delivery.mapper.DeliveryFactsChangedMapper; +import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 信号持久化由派送域维护,主流程经正式端口消费。 */ +@Repository +public class DeliveryFactsChangedRepository { + private final DeliveryFactsChangedMapper mapper; + private final DeliveryOrderMapper deliveries; + + /** 装配唯一的MyBatis数据入口。 */ + public DeliveryFactsChangedRepository(DeliveryFactsChangedMapper mapper, DeliveryOrderMapper deliveries) { + this.mapper = mapper; this.deliveries = deliveries; + } + + /** 公共信号端口只认整车和派送号对应的唯一真实单据。 */ + public List deliveries(String dispatchNo, String deliveryNo) { + return deliveries.findForSignal(dispatchNo, deliveryNo); + } + + /** 确认前复核信号原身份,普通读不反向申请父锁。 */ + public DeliveryFactsChanged find(long id) { return mapper.findById(id); } + + /** 保存一次事实并返回当前唯一版本供业务校验。 */ + public DeliveryFactsChanged insertOnce(DeliveryFactsChanged fact) { + mapper.insertOnce(fact); + return mapper.find(fact.getCustomerId(), fact.getDeliveryId(), fact.getDeliveryVersion()); + } + + /** 有界普通扫描,不在此处持有父锁前的子行锁。 */ + public List pending(LocalDateTime now, int limit) { + return mapper.pending(now, limit); + } + + /** 只确认调用方已经核对的指定信号与排车身份。 */ + public int acknowledge(long id, long dispatchId, LocalDateTime now) { + return mapper.acknowledge(id, dispatchId, now); + } + + /** 记录可重试失败且保留PENDING,不能覆盖已成功确认的信号。 */ + public void failed(long id, String code, LocalDateTime nextAttemptAt) { + mapper.failed(id, code, nextAttemptAt); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java new file mode 100644 index 000000000..47c4cd3bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.delivery.repository; + +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 整车派送数据访问;锁顺序明确区分子单和来源,不直写主流程表。 */ +@Repository +public class DispatchDeliveryRepository { + private final DispatchDeliveryArrangementMapper arrangements; + private final OutboundOrderMapper outbounds; + private final OutboundPalletItemMapper items; + private final DeliveryOrderMapper deliveries; + private final DeliveryOperationMapper operations; + private final LastlegOrderMapper sources; + private final LastlegOrderStatusHistoryMapper histories; + + /** 装配派送域唯一持久化入口。 */ + public DispatchDeliveryRepository(DispatchDeliveryArrangementMapper arrangements,OutboundOrderMapper outbounds, + OutboundPalletItemMapper items,DeliveryOrderMapper deliveries,DeliveryOperationMapper operations, + LastlegOrderMapper sources,LastlegOrderStatusHistoryMapper histories) { + this.arrangements=arrangements;this.outbounds=outbounds;this.items=items; + this.deliveries=deliveries;this.operations=operations; + this.sources=sources;this.histories=histories; + } + /** 父锁外预览仅普通读安排事实。 */ + public DispatchDeliveryArrangement arrangement(long id,boolean lock) { return lock?arrangements.lock(id):arrangements.find(id); } + /** 固定顺序取得或读取全部客户出库。 */ + public List outbounds(long id,boolean lock) { return lock?outbounds.lockAllByDispatch(id):outbounds.findAllByDispatch(id); } + /** 仅在取得全体出库锁之后取得全体派送锁。 */ + public List deliveries(long id,boolean lock) { return lock?deliveries.lockAllByDispatch(id):deliveries.findAllByDispatch(id); } + /** 一次安排仅插入一个客户自己的出库头。 */ + public void create(OutboundOrder value) { outbounds.insert(value); } + /** 不可变出库来源明细在同一安排事务保存。 */ + public void create(OutboundPalletItem value) { items.insert(value); } + /** 每客户目的地分别保存派送单。 */ + public void create(DeliveryOrder value) { deliveries.insert(value); } + /** 仅更新已锁定并复验的客户出库执行事实。 */ + public void save(OutboundOrder value) { outbounds.updateById(value); } + /** 仅更新已锁定并复验的客户派送执行事实。 */ + public void save(DeliveryOrder value) { deliveries.updateById(value); } + /** 最后保存单车唯一安排,失败整体回滚不留半车。 */ + public void create(DispatchDeliveryArrangement value) { arrangements.insert(value); } + /** 保存真实业务操作事实,不将零约定冒充费用。 */ + public void operation(DeliveryOperation value) { operations.insert(value); } + /** 正常完成只普通读当前异常数量,不反向锁历史来源。 */ + public long activeReturns(DeliveryOrder value) { return operations.activeReturns(value.getCustomerId(),value.getId()); } + /** 只读取本派送真实退回流水。 */ + public List returns(DeliveryOrder value) { + return operations.returnReceipts(value.getCustomerId(),value.getId()); + } + /** 全部子单锁已收齐后,才按来源数字ID固定顺序申请来源锁。 */ + public LastlegOrder lockSource(String customerId,long id) { return sources.lockById(customerId,id); } + /** 多目的地来源不借一张派送单冒充全部关联,完整归属保留在安排来源快照。 */ + public void bindSource(LastlegOrder source,String outboundNo,String deliveryNo,java.time.LocalDateTime now) { + if (sources.update(null,new LambdaUpdateWrapper().eq(LastlegOrder::getId,source.getId()) + .eq(LastlegOrder::getCustomerId,source.getCustomerId()).set(LastlegOrder::getOutboundOrderNo,outboundNo) + .set(LastlegOrder::getDeliveryOrderNo,deliveryNo) + .set(LastlegOrder::getSubOrderStatus,"OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY") + .set(LastlegOrder::getVersion,java.util.Objects.requireNonNullElse(source.getVersion(),0)+1) + .set(LastlegOrder::getUpdatedAt,now))!=1) throw new IllegalStateException("派送来源绑定已变化"); + } + /** 保存原状态事实供正式未执行取消核对,不把主流程状态改成派送子状态。 */ + public void sourceHistory(LastlegOrderStatusHistory value) { histories.insert(value); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java index dc7b541a0..b75ae4269 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java @@ -5,12 +5,10 @@ import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos; import com.baiyue.lastleg.delivery.dto.DeliveryDocumentDtos.*; import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.gateway.DeliveryDocumentFileGateway; -import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.delivery.repository.DeliveryDocumentRepository; import com.baiyue.lastleg.delivery.model.*; import com.baiyue.lastleg.customervisit.service.CustomerVisitService; import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -28,20 +26,18 @@ import java.util.*; @Service public class DeliveryDocumentService { @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; - @Resource private DeliveryOrderMapper orders; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private DeliveryDocumentRevisionMapper revisions; - @Resource private DeliveryOperationMapper operations; + @Resource private DeliveryDocumentRepository repository; + @Resource private com.baiyue.lastleg.dispatch.port.DispatchSignalPort signals; @Resource private DeliveryDocumentFileGateway files; @Resource private ObjectMapper objectMapper; - @Resource private LastlegOrderMapper lastlegOrders; @Resource private DeliveryCompletedOutboxService deliveryCompletedOutbox; @Resource private CustomerVisitService customerVisits; @Resource private SourceDeliveryCompletionQuery sourceCompletion; - @Resource private DeliverySourceEvidenceMapper sourceEvidence; + /** 客户和派送号共同定位当前资料。 */ public Detail detail(String customerId, String deliveryNo) { return detail(require(customerId, deliveryNo), false); } + /** 客户只查看本客户派送的签收类附件,不返回其他资料。 */ public MerchantReceipts merchantReceipts(String customerId, String deliveryNo) { Detail snapshot = detail(customerId, deliveryNo); return new MerchantReceipts(snapshot.deliveryNo(), snapshot.categories().stream() @@ -49,23 +45,25 @@ public class DeliveryDocumentService { .flatMap(category -> category.files().stream()).toList()); } + /** 下载仍复核业务与类别归属,不放开文件公共访问。 */ public Download merchantReceiptDownload(String customerId, String deliveryNo, long fileId) { return download(customerId, deliveryNo, DeliveryDocumentCategory.RECEIPT, fileId, null); } + /** 上传准备不等于资料保存或真实签收完成。 */ public DeliveryDocumentDtos.File upload(String customerId, String deliveryNo, DeliveryDocumentCategory category, String operatorId, MultipartFile file) { DeliveryOrder order = require(customerId, deliveryNo); return files.upload(customerId, order.getId(), category, operatorId, file); } - /** Lock the same aggregate as status commands, validate the whole set, then commit one snapshot. */ + /** 先锁本派送并校验全部资料,签收及本地待汇总事实在同一事务提交。 */ @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public Detail save(String customerId, String deliveryNo, String operatorId, Save request) { - DeliveryOrder order = orders.lockByNo(customerId, deliveryNo); + DeliveryOrder order = repository.lock(customerId, deliveryNo); if (order == null) throw missing(); String hash = hash(request); - DeliveryDocumentRevision replay = revisions.findCommand(customerId, order.getId(), request.idempotencyKey()); + DeliveryDocumentRevision replay = repository.command(customerId, order.getId(), request.idempotencyKey()); if (replay != null) { if (!hash.equals(replay.getRequestHash())) throw conflict("相同幂等键的派送资料内容不同"); Detail saved = read(replay.getSnapshotJson()); @@ -96,11 +94,11 @@ public class DeliveryDocumentService { if (autoCompleted) { if (!DeliveryStatusPolicy.canTransition(order.getStatus(), "DELIVERED") && !"RETURN_PENDING".equals(order.getStatus())) throw conflict("当前派送状态不允许通过签收文件完成"); - // A read avoids reversing outbound -> delivery lock order. An uncommitted departure remains ineligible. - var outbound = outboundOrders.findByNo(customerId, order.getOutboundNo()); + // 派送锁内只普通读出库,未提交的发车不构成真实出库资格,也不反向申请父锁。 + var outbound = repository.outbound(customerId, order.getOutboundNo()); if (outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) throw conflict("请先完成实际出库,再保存签收资料完成派送"); - if (operations.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收"); - try { signedPackages = DeliveryCargoQuantities.from(order, operations.returnReceipts(customerId, order.getId())).remaining(); } + if (repository.activeReturns(customerId, order.getId()) != 0) throw conflict("请先完成或取消正在处理的退回异常,再确认剩余货物签收"); + try { signedPackages = DeliveryCargoQuantities.from(order, repository.returns(customerId, order.getId())).remaining(); } catch (IllegalArgumentException error) { throw conflict(error.getMessage()); } if (signedPackages <= 0) throw conflict("本次派送已无待签收货物"); } @@ -113,7 +111,7 @@ public class DeliveryDocumentService { order.setSignedAt(now); order.setSignedPackages(signedPackages); } - orders.updateById(order); + repository.save(order); if (autoCompleted) { var completion = completeRelatedOrders(order, operatorId, now); fleetPool.signed(new SignedDelivery( @@ -121,29 +119,34 @@ public class DeliveryDocumentService { completion.sourcePackages(),operatorId,now); deliveryCompletedOutbox.publish(order, completion.orders(), completion.proofs(), now.atZone(executionZone).toInstant()); customerVisits.ensureForCompletedDelivery(order, now); + // 只写持久信号;主流程提交后另事务汇总,不能在此持子锁回调父流程。 + if (order.getDispatchOrderId() != null) signals.enqueue(order.getDispatchNo(), order.getDeliveryNo(), + order.getVersion(), now.atZone(executionZone).toInstant()); } Detail result = detail(order, false); DeliveryDocumentRevision revision = new DeliveryDocumentRevision(); revision.setCustomerId(customerId); revision.setDeliveryOrderId(order.getId()); revision.setVersion(order.getVersion()); revision.setIdempotencyKey(request.idempotencyKey()); revision.setRequestHash(hash); revision.setSnapshotJson(write(result)); - revision.setOperatorId(operatorId); revision.setCreatedAt(now); revisions.insert(revision); + revision.setOperatorId(operatorId); revision.setCreatedAt(now); repository.revision(revision); DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperationNode("SAVE_DOCUMENTS"); operation.setResult("保存派送资料:" + result.uploadedCategoryCount() + "/3类"); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); if (autoCompleted) recordAutomaticCompletion(order, operatorId, now); return result; } + /** 资料历史保持不可变版本并受原客户范围约束。 */ public List history(String customerId, String deliveryNo) { DeliveryOrder order = require(customerId, deliveryNo); - return revisions.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(), + return repository.history(customerId, order.getId()).stream().map(revision -> new History(revision.getVersion(), revision.getOperatorId(), revision.getCreatedAt(), read(revision.getSnapshotJson()))).toList(); } + /** 当前或历史附件必须确实绑定在本派送对应资料版本。 */ public Download download(String customerId, String deliveryNo, DeliveryDocumentCategory category, long fileId, Long revisionVersion) { DeliveryOrder order = require(customerId, deliveryNo); Detail snapshot = detail(order, false); if (revisionVersion != null) { - snapshot = revisions.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion())) + snapshot = repository.history(customerId, order.getId()).stream().filter(r -> revisionVersion.equals(r.getVersion())) .findFirst().map(r -> read(r.getSnapshotJson())).orElseThrow(DeliveryDocumentService::missing); } DeliveryDocumentDtos.File file = snapshot.categories().stream().filter(c -> c.category() == category) @@ -152,36 +155,39 @@ public class DeliveryDocumentService { return new Download(file, files.download(customerId, order.getId(), category, fileId)); } public record Download(DeliveryDocumentDtos.File file, byte[] content) { } + /** 业务编号不属于当前客户时按不存在处理。 */ private DeliveryOrder require(String customerId, String deliveryNo) { - DeliveryOrder order = orders.findByNo(customerId, deliveryNo); + DeliveryOrder order = repository.find(customerId, deliveryNo); if (order == null) throw missing(); return order; } + /** 三类资料只读投影不根据附件数量推断库存或整车阶段。 */ private Detail detail(DeliveryOrder order, boolean replayed) { List categories = DeliveryDocumentSnapshot.categories(order.getDocumentsJson()); return new Detail(order.getDeliveryNo(), order.getCustomerId(), version(order), order.getBolNo(), (int) categories.stream().filter(c -> !c.files().isEmpty()).count(), 3, categories, List.of("MANAGE_DOCUMENTS"), replayed); } + /** 原始单据版本0有效,后续保存按版本增加。 */ private static long version(DeliveryOrder order) { return Objects.requireNonNullElse(order.getVersion(), 0L); } private record Completion(List orders, Map proofs, Map sourcePackages) { } + /** 先根据真实拣货收齐来源,再按跨趟来源锁协议证明正常签收。 */ private Completion completeRelatedOrders(DeliveryOrder delivery, String operatorId, LocalDateTime now) { var related = new TreeMap(); Map sourcePackages = new TreeMap<>(); if (delivery.getDispatchOrderId() != null) { - var actualPicks = sourceEvidence.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() + var actualPicks = repository.picks(delivery.getCustomerId(), delivery.getDispatchOrderId()).stream() .filter(p -> Objects.equals(p.groupKey(), delivery.getDeliveryGroupKey())).toList(); for (var pick : actualPicks) sourcePackages.merge(pick.orderId(), pick.packages(), Math::addExact); var ids = actualPicks.stream() .map(SourceDeliveryCompletionPolicy.Pick::orderId).distinct().toList(); - if (!ids.isEmpty()) for (var tail : lastlegOrders.selectBatchIds(ids)) { + if (!ids.isEmpty()) for (var tail : repository.sourceOrders(ids)) { if (!delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源客户归属不一致"); related.put(tail.getId(), tail); } } - // Non-dispatch local workflows retain their established relation; never use this to prove a domestic source. - for (var tail : lastlegOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, delivery.getCustomerId()).eq(LastlegOrder::getDeliveryOrderNo, delivery.getDeliveryNo()))) { + // 本地非排车单保留既有关系,不能把当前指针当作头程全来源签收证明。 + for (var tail : repository.locallyLinked(delivery.getCustomerId(), delivery.getDeliveryNo())) { if (!DeliveryCompletedOutboxService.domestic(tail)) related.putIfAbsent(tail.getId(),tail); else if (!related.containsKey(tail.getId())) pendingCompletion(delivery, operatorId, now, inboundLabel(tail) + ":缺少历史实际拣货来源,未发布整来源签收事件"); @@ -190,10 +196,10 @@ public class DeliveryDocumentService { .map(LastlegOrder::getSourceInboundId).distinct().toList(); var proofs = sourceIds.isEmpty() ? Map.of() : sourceCompletion.proveAll(delivery.getCustomerId(),sourceIds); - // All source locks are now held. Re-read current rows instead of overwriting with pre-lock snapshots. + // 来源锁已按证明协议持有,重新读取当前行,不能用加锁前快照覆盖并发签收。 var current = new ArrayList(); for (long id : related.keySet()) { - var tail = lastlegOrders.selectById(id); + var tail = repository.source(id); if (tail == null || !delivery.getCustomerId().equals(tail.getCustomerId())) throw conflict("派送来源已变化"); if (DeliveryCompletedOutboxService.domestic(tail)) { var proof = proofs.get(tail.getSourceInboundId()); @@ -205,38 +211,47 @@ public class DeliveryDocumentService { } } else tail.setSubOrderStatus(Objects.equals(delivery.getSignedPackages(),delivery.getPackageCount()) ? "OUTBOUND:OUTBOUNDED;DELIVERY:DELIVERED" : "OUTBOUND:OUTBOUNDED;DELIVERY:PARTIALLY_RETURNED"); - tail.setUpdatedAt(now); lastlegOrders.updateById(tail); current.add(tail); + tail.setUpdatedAt(now); repository.saveSource(tail); current.add(tail); } return new Completion(List.copyOf(current),proofs,Map.copyOf(sourcePackages)); } + /** 入仓业务号缺失明确显示空值标记,不显示内部ID。 */ private static String inboundLabel(LastlegOrder order) { return order.getInboundCode()==null || order.getInboundCode().isBlank() ? "—" : order.getInboundCode(); } + /** 未核齐的来源只保留诊断事实,不伪造全来源完成。 */ private void pendingCompletion(DeliveryOrder delivery, String operatorId, LocalDateTime now, String reason) { var operation = new DeliveryOperation(); operation.setCustomerId(delivery.getCustomerId()); operation.setDeliveryOrderId(delivery.getId()); operation.setOperatorId(operatorId); operation.setOperationNode("FIRSTLEG_COMPLETION_PENDING"); operation.setResult(reason); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); } + /** 保存本次实际签收箱数,真实退回不得混入签收数量。 */ private void recordAutomaticCompletion(DeliveryOrder order, String operatorId, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(order.getCustomerId()); operation.setDeliveryOrderId(order.getId()); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setOperationNode("RECEIPT_AUTO_COMPLETE"); operation.setResult("签收文件保存成功,本次实际签收" + order.getSignedPackages() + "箱;原实发" + order.getPackageCount() + "箱"); - operation.setCreatedAt(now); operations.insert(operation); + operation.setCreatedAt(now); repository.operation(operation); } + /** 资料原请求以严格载荷摘要重放,版本和文件集合不得改写。 */ private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(value).getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } + /** 资料与操作结果必须可持久化,否则同事务回滚。 */ private String write(Object value) { try { return objectMapper.writeValueAsString(value); } catch (JsonProcessingException e) { throw new IllegalStateException("无法保存派送资料", e); } } + /** 损坏历史直接阻断,不回退为当前资料成功。 */ private Detail read(String value) { try { return objectMapper.readValue(value, Detail.class); } catch (JsonProcessingException e) { throw new IllegalStateException("派送资料历史损坏", e); } } + /** 隐藏不存在或不属于本客户的业务资料。 */ private static ResponseStatusException missing() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "派送单或已绑定资料不存在"); } + /** 同版本竞争或业务资格变化要求刷新核对。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + /** 参数范围不合法时不产生任何资料变更。 */ private static ResponseStatusException bad(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java new file mode 100644 index 000000000..bdc630e7c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.DeliveryFactsChanged; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.repository.DeliveryFactsChangedRepository; +import com.baiyue.lastleg.dispatch.port.DispatchSignalPort; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.Signal; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.Instant; +import java.time.ZoneId; +import java.util.HexFormat; +import java.util.List; +import java.util.Objects; + +/** 签收只写持久信号,绝不持派送子锁回调整车完成服务。 */ +@Service +public class DeliveryFactsChangedService implements DispatchSignalPort { + private final DeliveryFactsChangedRepository repository; + private final ObjectMapper json; + + /** 装配事实持久化和确定性序列化。 */ + public DeliveryFactsChangedService(DeliveryFactsChangedRepository repository, ObjectMapper json) { + this.repository = repository; + this.json = json; + } + + /** 直接消费主流程实际骨架,信号仅在原签收事务内持久化。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void enqueue(String dispatchNo, String deliveryNo, long deliveryVersion, Instant occurredAt) { + if (blank(dispatchNo) || blank(deliveryNo) || occurredAt == null) throw new IllegalArgumentException("签收信号身份不完整"); + var rows = repository.deliveries(dispatchNo, deliveryNo); + if (rows.size() != 1 || !Objects.equals(rows.get(0).getVersion(), deliveryVersion)) + throw new IllegalStateException("签收信号与当前真实派送版本不一致"); + signed(rows.get(0), LocalDateTime.ofInstant(occurredAt, ZoneId.systemDefault())); + } + + /** 有界普通读取候选,不在父汇总事务前领取或锁定信号。 */ + @Override + @Transactional(readOnly = true) + public List due(Instant now, int limit) { + if (now == null || limit < 1 || limit > 200) throw new IllegalArgumentException("信号扫描范围不合法"); + return repository.pending(LocalDateTime.ofInstant(now, ZoneId.systemDefault()), limit).stream() + .map(f -> new Signal(f.getId(),f.getDispatchNo(),f.getDeliveryNo(),f.getDeliveryVersion(), + f.getAttemptCount(),f.getNextAttemptAt().atZone(ZoneId.systemDefault()).toInstant())).toList(); + } + + /** 与主阶段CAS共同提交指定信号确认,不能提前确认后来产生的版本。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void acknowledge(Signal signal, Instant acknowledgedAt) { + var stored = requireSignal(signal); + if (acknowledgedAt == null) throw new IllegalArgumentException("信号确认时间不能为空"); + repository.acknowledge(stored.getId(),stored.getDispatchId(),LocalDateTime.ofInstant(acknowledgedAt,ZoneId.systemDefault())); + } + + /** 调用方汇总回滚后独立记录退避,已确认的信号不会退回PENDING。 */ + @Override + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void failed(Signal signal, Instant nextRetryAt, String errorCode) { + var stored = requireSignal(signal); + if (nextRetryAt == null || errorCode == null || !errorCode.matches("[A-Z0-9_]{1,64}")) + throw new IllegalArgumentException("信号重试信息不合法"); + repository.failed(stored.getId(),errorCode,LocalDateTime.ofInstant(nextRetryAt,ZoneId.systemDefault())); + } + + /** 只允许操作扫描时的真实业务身份,不凭一个内部ID修改别的信号。 */ + private DeliveryFactsChanged requireSignal(Signal signal) { + if (signal == null || signal.signalId() == null) throw new IllegalArgumentException("信号不能为空"); + var stored = repository.find(signal.signalId()); + if (stored == null || !Objects.equals(stored.getDispatchNo(),signal.dispatchNo()) + || !Objects.equals(stored.getDeliveryNo(),signal.deliveryNo()) + || !Objects.equals(stored.getDeliveryVersion(),signal.deliveryVersion())) + throw new IllegalStateException("信号原业务身份不一致"); + return stored; + } + + /** 与签收共同提交唯一版本,缺失真实排车身份不得补造信号。 */ + @Transactional(propagation = Propagation.MANDATORY) + public void signed(DeliveryOrder delivery, LocalDateTime now) { + if (delivery.getDispatchOrderId() == null) return; + if (delivery.getId() == null || delivery.getDispatchOrderId() <= 0 || blank(delivery.getCustomerId()) + || blank(delivery.getDispatchNo()) || blank(delivery.getDeliveryNo()) || delivery.getVersion() == null + || delivery.getVersion() < 1 || delivery.getSignedAt() == null || delivery.getSignedPackages() == null + || delivery.getSignedPackages() <= 0 || now == null) throw new IllegalStateException("签收汇总信号缺少真实派送事实"); + var fact = new DeliveryFactsChanged(); + fact.setId(IdWorker.getId()); fact.setCustomerId(delivery.getCustomerId()); + fact.setDispatchId(delivery.getDispatchOrderId()); fact.setDispatchNo(delivery.getDispatchNo()); + fact.setDeliveryId(delivery.getId()); fact.setDeliveryNo(delivery.getDeliveryNo()); + fact.setDeliveryVersion(delivery.getVersion()); fact.setOccurredAt(now); + fact.setFactsHash(hash(List.of(delivery.getCustomerId(), delivery.getDispatchOrderId(), delivery.getDispatchNo(), + delivery.getId(), delivery.getDeliveryNo(), delivery.getVersion(), delivery.getSignedPackages(), delivery.getSignedAt()))); + var saved = repository.insertOnce(fact); + if (saved == null || !Objects.equals(saved.getDispatchId(), fact.getDispatchId()) + || !Objects.equals(saved.getDispatchNo(), fact.getDispatchNo()) + || !Objects.equals(saved.getDeliveryNo(), fact.getDeliveryNo()) + || !Objects.equals(saved.getFactsHash(), fact.getFactsHash())) + throw new IllegalStateException("同一签收版本对应的持久事实不一致"); + } + + /** 以规范化真实字段生成摘要,不保存整车完成的推断结论。 */ + private String hash(Object value) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(json.writeValueAsBytes(value))); } + catch (NoSuchAlgorithmException | JsonProcessingException error) { throw new IllegalStateException("签收信号摘要生成失败", error); } + } + + /** 业务身份缺失直接拒绝,不使用内部ID代替业务编号。 */ + private static boolean blank(String value) { return value == null || value.isBlank(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java deleted file mode 100644 index be764c7ec..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFeeCoverageService.java +++ /dev/null @@ -1,122 +0,0 @@ -package com.baiyue.lastleg.delivery.service; - -import com.baiyue.lastleg.client.FinanceFeeCoverageClient; -import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; -import com.baiyue.lastleg.delivery.dto.DeliveryRequests; -import java.time.Instant; -import com.baiyue.lastleg.delivery.mapper.DispatchFeeCargoMapper; -import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.*; -import java.util.stream.Collectors; - -@Service -public class DeliveryFeeCoverageService { - private final DispatchOrderMapper dispatches; - private final DispatchItemMapper items; - private final DispatchFeeCargoMapper sources; - private final FinanceFeeCoverageClient finance; - private final ObjectMapper json; - private final String token; - public DeliveryFeeCoverageService(DispatchOrderMapper dispatches, DispatchItemMapper items, DispatchFeeCargoMapper sources, - FinanceFeeCoverageClient finance, ObjectMapper json, @Value("${baiyue.security.internal-service-token}") String token) { - this.dispatches=dispatches; this.items=items; this.sources=sources; this.finance=finance; this.json=json; this.token=token; - } - - @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) - public DeliveryFeeCoverageModels.Preview preview(String dispatchNo) { - var dispatch = dispatches.findGlobalByDispatchNo(dispatchNo.trim()); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()); - var cargo = sources.list(dispatch.getCustomerId(), dispatch.getId()); - String reason = incomplete(selected, cargo); - var inboundIds = new TreeSet(); - if (reason == null) for (var row : cargo) if ("FIRSTLEG".equals(row.sourceRegion())) { - try { long id = Long.parseLong(row.sourceInboundId()); if (id < 1) throw new NumberFormatException(); inboundIds.add(id); } - catch (RuntimeException invalid) { reason = "货物缺少原头程入仓单关联,请先核对来源"; break; } - } - List fees = List.of(); - if (reason == null && !inboundIds.isEmpty()) fees = load(dispatch.getCustomerId(), List.copyOf(inboundIds)); - try { - String hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest( - json.writeValueAsString(List.of(dispatch.getDispatchNo(), dispatch.getCustomerId(), cargo, - selected.stream().map(item -> Arrays.asList(item.getId(), item.getPackageCount(), item.getOverseasWarehouseId(), - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), - item.getDeliveryAddress(), item.getPostalCode())).toList())).getBytes(StandardCharsets.UTF_8))); - return new DeliveryFeeCoverageModels.Preview(dispatch.getDispatchNo(), reason == null, reason, hash, cargo, fees); - } catch (java.security.NoSuchAlgorithmException | com.fasterxml.jackson.core.JsonProcessingException error) { throw new IllegalStateException(error); } - } - private List load(String customerId, List inboundIds) { - try { - var result = finance.sources(token, new FinanceFeeCoverageClient.Query(Long.valueOf(customerId), inboundIds)); - if (result == null || !result.successful() || result.data() == null) throw unavailable(); - if (result.data().stream().anyMatch(fee -> fee.financeFeeId() == null || fee.financeFeeId() < 1 || fee.sourceFeeId() == null - || !Objects.equals(fee.customerId(), Long.valueOf(customerId)) || fee.sourceVersion() < 1 - || !inboundIds.contains(fee.sourceInboundId()) || fee.sourceHash() == null || fee.sourceHash().isBlank() - || fee.amount() == null || fee.amount().signum() < 0 || !"CNY".equals(fee.currency()) - || !Set.of("INBOUND_FEE", "CONTAINER_FEE").contains(fee.sourceType()) - || !Set.of("AVAILABLE", "SETTLED").contains(fee.status()))) throw unavailable(); - return result.data(); - } catch (RuntimeException error) { throw unavailable(); } - } - private static String incomplete(List selected, List cargo) { - if (selected.isEmpty()) return "排车单没有待发运货物"; - var totals = cargo.stream().collect(Collectors.groupingBy(DeliveryFeeCoverageModels.Cargo::dispatchItemId, - Collectors.summingLong(DeliveryFeeCoverageModels.Cargo::packages))); - var ids = selected.stream().map(DispatchItem::getId).collect(Collectors.toSet()); - if (!ids.equals(totals.keySet())) return "实际货物来源尚未齐全,请先完成收货并核对实物托盘"; - for (var item : selected) if (item.getPackageCount() == null || totals.get(item.getId()).longValue() != item.getPackageCount()) - return "实物库存及本次已拣数量与排车箱数不一致,请先核对货物范围"; - return null; - } - public record Decision(Set includedServices, DeliveryFeeCoverageModels.Evidence evidence, Set externalInboundCodes) { - public static Decision none() { return new Decision(Set.of(), null, Set.of()); } - public List uncovered(String service) { - if (evidence == null) return List.of(); - return evidence.cargo().stream().filter(row -> !covers(row, service, evidence.references(), externalInboundCodes)).toList(); - } - } - /** 外接覆盖按实际入仓来源判定,不能免除同托普通货的派送费。 */ - public Decision confirm(String dispatchNo, DeliveryRequests.Transfer request, String operator, Set externalInboundCodes) { - if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) return Decision.none(); - var preview = preview(dispatchNo); - if (!preview.sourceComplete()) throw conflict(preview.blockReason()); - if (!Objects.equals(preview.cargoHash(), request.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化,请重新核对覆盖范围"); - var references = new ArrayList(); - var scopes = new HashSet(); - for (var selection : request.feeCoverage()) { - var fee = preview.feeSources().stream().filter(row -> Objects.equals(row.financeFeeId(), selection.financeFeeId())).findFirst() - .orElseThrow(() -> conflict("原费用不属于本次实际货物,或已审核状态/有效性发生变化")); - if (fee.sourceVersion() != selection.sourceVersion() || !Objects.equals(fee.sourceHash(), selection.sourceHash())) - throw conflict("原费用版本已变化,请重新确认服务覆盖范围"); - var services = new TreeSet<>(selection.services()); - if (services.isEmpty() || !Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE").containsAll(services)) - throw conflict("请选择本票原费用明确包含的服务"); - for (var service : services) if (!scopes.add(fee.sourceInboundId() + ":" + service)) - throw conflict("同一票同一服务只能引用一项原费用"); - references.add(new DeliveryFeeCoverageModels.Reference(fee, List.copyOf(services))); - } - var included = new HashSet(); - for (String service : List.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE")) - if (preview.cargo().stream().allMatch(row -> covers(row, service, references, externalInboundCodes))) included.add(service); - return new Decision(Set.copyOf(included), new DeliveryFeeCoverageModels.Evidence(preview.cargoHash(), preview.cargo(), - List.copyOf(references), operator, Instant.now()), Set.copyOf(externalInboundCodes)); - } - private static boolean covers(DeliveryFeeCoverageModels.Cargo cargo, String service, - List references, Set externalInboundCodes) { - if ("DELIVERY_FEE".equals(service) && externalInboundCodes.contains(cargo.inboundCode())) return true; - return "FIRSTLEG".equals(cargo.sourceRegion()) && references.stream().anyMatch(reference -> - reference.source().sourceInboundId().toString().equals(cargo.sourceInboundId()) && reference.services().contains(service)); - } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } - private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "原费用来源暂不可用,请稍后重试"); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 2f54fb267..f86d18bbc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -2,7 +2,6 @@ package com.baiyue.lastleg.delivery.service; import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baomidou.mybatisplus.core.toolkit.IdWorker; @@ -94,7 +93,6 @@ import java.util.stream.Collectors; @Service public class DeliveryTransferService { - @Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities; @Resource private DispatchOrderMapper dispatchOrders; @Resource private DispatchItemMapper dispatchItems; @Resource private OutboundOrderMapper outboundOrders; @@ -131,282 +129,9 @@ public class DeliveryTransferService { @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private ObjectMapper objectMapper; @Resource private FleetDispatchBindingGuard fleetBindings; - @Resource private FleetBookingFreight bookingFreight; - @Resource private DeliveryFeeCoverageService feeCoverage; @Resource private DeliveryExecutionGuard deliveryExecution; - @Resource private DispatchCargoSourceQuery cargoSources; - - @Transactional(readOnly = true, isolation = org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) - public FleetBookingFreight.Preview externalFreight(String dispatchNo) { - DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(trim(dispatchNo)); - if (dispatch == null) throw notFound("Dispatch order not found"); - return externalFreight(dispatch, dispatchItems.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); - } - - /** 外接运费与车次绑定使用相同的实际来源和数量。 */ - private FleetBookingFreight.Preview externalFreight(DispatchOrder dispatch, List items) { - return bookingFreight.preview(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), - cargoSources.sources(dispatch, items)); - } - - @Transactional - public DeliveryResponses.TransferResult platformTransfer(String dispatchNo, String operatorId, - DeliveryRequests.Transfer request) { - DispatchOrder dispatch = dispatchOrders.lockGlobalByDispatchNo(trim(dispatchNo)); - if (dispatch == null) throw notFound("Dispatch order not found"); - return transferResolved(dispatch, operatorId, request); - } - - @Transactional - public DeliveryResponses.TransferResult merchantTransfer(String customerId, String dispatchNo, String operatorId, - DeliveryRequests.Transfer request) { - if (request.feeCoverage() != null && !request.feeCoverage().isEmpty()) - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "原运费服务覆盖由平台确认"); - DispatchOrder found = dispatchOrders.findByDispatchNo(customerId, dispatchNo); - if (found == null) throw notFound("Dispatch order not found"); - DispatchOrder dispatch = dispatchOrders.lockById(customerId, found.getId()); - if (dispatch == null) throw notFound("Dispatch order not found"); - return transferResolved(dispatch, operatorId, request); - } - - /** 排车锁内创建完整目的地派送,历史重放不生成新单或重复发布费用。 */ - private DeliveryResponses.TransferResult transferResolved(DispatchOrder dispatch, String operatorId, - DeliveryRequests.Transfer request) { - String customerId = dispatch.getCustomerId(); - String requestHash = transferRequestHash(request); - OutboundOrder replay = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); - if (replay != null) { - if (!dispatch.getId().equals(replay.getDispatchOrderId())) throw conflict("Idempotency key belongs to another dispatch"); - return replayTransfer(replay, requestHash); - } - OutboundOrder existing = outboundOrders.lockByDispatch(customerId, dispatch.getId()); - if (existing != null) return replayTransfer(existing, requestHash); - if (Set.of("CANCELLED", "DEPARTED", "PARTIAL_DEPARTED", "COMPLETED").contains(dispatch.getStatus())) { - throw conflict("Dispatch cannot be transferred to delivery in its current status"); - } - fleetBindings.requireMatchingAssignment(dispatch.getId(), - new FleetDispatchGateway.Assignment(dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone()), - new FleetDispatchGateway.Assignment(request.vehiclePlate(), request.driverName(), request.driverPhone())); - dispatchItems.bindReceivedPallets(customerId, dispatch.getId()); - List items = dispatchItems.findByDispatchOrder(customerId, dispatch.getId()); - if (items.isEmpty()) throw conflict("Dispatch has no pallets"); - if (items.stream().anyMatch(item -> item.getWarehousePalletId() == null)) { - throw conflict("All dispatch pallets must be received before transfer to delivery"); - } - if (!StringUtils.hasText(dispatch.getOverseasWarehouseId())) throw conflict("Dispatch warehouse is required"); - var freight = externalFreight(dispatch, items); - if ((!freight.quotes().isEmpty() || StringUtils.hasText(request.externalFreightHash())) - && !Objects.equals(freight.snapshotHash(), request.externalFreightHash())) - throw conflict("外接实收或约定运费已变化,请重新打开转派送核对"); - var externalInboundCodes = freight.quotes().stream().map(FleetBookingFreight.Quote::inboundCode).collect(Collectors.toSet()); - var coverage = request.feeCoverage() == null || request.feeCoverage().isEmpty() ? DeliveryFeeCoverageService.Decision.none() - : feeCoverage.confirm(dispatch.getDispatchNo(), request, operatorId, externalInboundCodes); - validateTransferFees(request, freight.requiresDeliveryFee(), coverage.includedServices()); - if (!freight.priorInboundCodes().isEmpty()) feeSources.requireNoActiveDeliveryReceivables(customerId, freight.priorInboundCodes()); - - LocalDateTime now = LocalDateTime.now(); - OutboundOrder outbound = new OutboundOrder(); - outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", dispatch.getOverseasWarehouseId(), outbound.getId())); - outbound.setDispatchOrderId(dispatch.getId()); outbound.setDispatchNo(dispatch.getDispatchNo()); - outbound.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); outbound.setStatus("WAIT_OUTBOUND"); outbound.setOutboundType("DISPATCH_DELIVERY"); - outbound.setLxCodes(resolveLxCodes(customerId, items)); - outbound.setPackageCount(items.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - outbound.setPalletCount(items.size()); - outbound.setGrossWeightKg(sumKnownPhysicalWeight(items, DispatchItem::getGrossWeightKg)); - outbound.setVolumeWeightKg(sum(items, DispatchItem::getVolumeWeightKg)); - outbound.setVolumeCbm(sum(items, DispatchItem::getVolumeCbm)); - outbound.setIdempotencyKey(request.idempotencyKey().trim()); - outbound.setRequestHash(requestHash); - if (coverage.evidence() != null) { - try { outbound.setFeeCoverageJson(objectMapper.writeValueAsString(coverage.evidence())); } - catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法保存", exception); } - } - outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); - try { - outboundOrders.insert(outbound); - } catch (DuplicateKeyException e) { - // The unique keys are the final concurrent guard. A client retry must observe - // the document created by the winning transaction rather than receive a false failure. - OutboundOrder concurrent = outboundOrders.findByIdempotency(customerId, request.idempotencyKey().trim()); - if (concurrent != null && dispatch.getId().equals(concurrent.getDispatchOrderId())) { - return replayTransfer(concurrent, requestHash); - } - concurrent = outboundOrders.lockByDispatch(customerId, dispatch.getId()); - if (concurrent != null) { - return replayTransfer(concurrent, requestHash); - } - throw conflict("Dispatch was concurrently transferred to delivery"); - } - - for (DispatchItem source : items) { - OutboundPalletItem item = new OutboundPalletItem(); - item.setId(IdWorker.getId()); item.setCustomerId(customerId); item.setOutboundOrderId(outbound.getId()); - item.setWarehousePalletId(source.getWarehousePalletId()); item.setPalletNo(source.getPalletNo()); - item.setDestinationWarehouseId(source.getDestinationWarehouseId()); - item.setDestinationWarehouseCode(source.getDestinationWarehouseCode()); - item.setDestinationWarehouseName(source.getDestinationWarehouseName()); - item.setPackageCount(Objects.requireNonNullElse(source.getPackageCount(), 0)); - item.setGrossWeightKg(source.getGrossWeightKg()); item.setVolumeWeightKg(source.getVolumeWeightKg()); - item.setVolumeCbm(source.getVolumeCbm()); item.setPickedPackageCount(0); item.setStatus("WAIT_PICK"); - if (StringUtils.hasText(source.getCartonMarks())) { - item.setCartonMarks(source.getCartonMarks()); - } else if (source.getWarehousePalletId() != null) { - String marks = inboundCartons.listByPallet(customerId, source.getWarehousePalletId()).stream() - .map(c -> c.getCartonMark()).filter(StringUtils::hasText).distinct().collect(java.util.stream.Collectors.joining(",")); - item.setCartonMarks(StringUtils.hasText(marks) ? marks : null); - } - item.setOrderRefs(source.getOrderRefs()); item.setInboundCode(source.getInboundCode()); item.setCreatedAt(now); - outboundPallets.insert(item); - } - - Map> destinationGroups = items.stream().collect(java.util.stream.Collectors.groupingBy( - this::deliveryGroupKey, LinkedHashMap::new, java.util.stream.Collectors.toList())); - List deliveries = new ArrayList<>(); - int groupIndex = 0; - for (Map.Entry> entry : destinationGroups.entrySet()) { - List groupItems = entry.getValue(); - DispatchItem destination = groupItems.get(0); - DeliveryOrder delivery = new DeliveryOrder(); - delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", dispatch.getOverseasWarehouseId(), delivery.getId())); - delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); - delivery.setDispatchOrderId(dispatch.getId()); delivery.setDispatchNo(dispatch.getDispatchNo()); - delivery.setOverseasWarehouseId(dispatch.getOverseasWarehouseId()); delivery.setOverseasWarehouseName(dispatch.getOverseasWarehouseName()); - delivery.setDestinationWarehouseId(trim(destination.getDestinationWarehouseId())); - delivery.setDestinationWarehouseCode(firstText( - firstText(destination.getDestinationWarehouseCode(), destination.getFbaWarehouseCode()), - destination.getDestinationWarehouseId())); - delivery.setDestinationWarehouseName(trim(destination.getDestinationWarehouseName())); - delivery.setDestinationType(destination.getDestinationType()); - delivery.setDeliveryAddress(destination.getDeliveryAddress()); - delivery.setPostalCode(destination.getPostalCode()); - delivery.setContactName(destination.getContactName()); - delivery.setContactPhone(destination.getContactPhone()); - delivery.setDeliveryGroupKey(entry.getKey()); - LastlegOrder firstOrder = firstTailOrder(customerId, groupItems); - delivery.setCustomerName(firstOrder == null ? null : firstOrder.getCustomerName()); delivery.setStatus("WAIT_DELIVERY"); delivery.setVersion(0L); - delivery.setWaybillNo(trim(request.waybillNo())); delivery.setDeliveryMethod(request.deliveryMethod().trim()); - delivery.setCarrierName(firstText(request.carrierName(), dispatch.getCarrierName())); delivery.setPickupMethod(trim(request.pickupMethod())); - delivery.setLoadingType(trim(request.loadingType())); delivery.setVehicleType(trim(request.vehicleType())); delivery.setVehicleSize(trim(request.vehicleSize())); - delivery.setVehiclePlate(firstText(request.vehiclePlate(), dispatch.getVehiclePlate())); delivery.setDriverName(firstText(request.driverName(), dispatch.getDriverName())); - delivery.setDriverPhone(firstText(request.driverPhone(), dispatch.getDriverPhone())); delivery.setAppointmentAccount(trim(request.appointmentAccount())); - delivery.setSellerName(trim(request.sellerName())); delivery.setPlannedDeliveryAt(request.plannedDeliveryAt()); - delivery.setPackageCount(groupItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - delivery.setPalletCount(groupItems.size()); - delivery.setTotalVolumeCbm(sum(groupItems, DispatchItem::getVolumeCbm)); - delivery.setTotalWeightKg(sumKnownPhysicalWeight(groupItems, DispatchItem::getGrossWeightKg)); delivery.setExpectedArrivalAt(dispatch.getEstimatedArrivalAt()); delivery.setRemark(trim(request.remark())); - delivery.setIdempotencyKey(childKey(request.idempotencyKey(), "DEST-" + groupIndex++)); delivery.setCreatedBy(operatorId); - delivery.setCreatedAt(now); delivery.setUpdatedAt(now); deliveryOrders.insert(delivery); - recordOperation(customerId, delivery.getId(), operatorId, "CREATE_DELIVERY", "按目的FBA仓生成派送单", now); - bindTailOrders(customerId, groupItems, outbound.getOutboundNo(), delivery.getDeliveryNo(), request.remark(), now); - deliveries.add(delivery); - } - for (var quote : freight.quotes()) { - var destination = deliveries.stream().filter(item -> Objects.equals(quote.destinationKey(), FleetDispatchGateway.destinationKey( - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseCode(), - item.getDeliveryAddress(), item.getPostalCode()))).findFirst() - .orElseThrow(() -> conflict("外接约定目的地未生成对应派送单")); - recordOperation(customerId, destination.getId(), operatorId, "CONFIRM_EXTERNAL_FREIGHT", - "外接预订 " + quote.bookingNo() + ";实收确认版本 " + quote.recordVersion() + ";约定运费 " - + quote.currency() + " " + quote.amount().toPlainString(), now); - // An explicitly agreed free shipment remains a commercial fact, not a fabricated zero fee. - if (quote.amount().signum() == 0) continue; - feeSources.publishApproved(operatorId, "FLEET:" + quote.bookingNo() + ":" + dispatch.getDispatchNo() + ":FREIGHT", - new OverseasFeeSourceDtos.SaveRequest(customerId, quote.customerName(), - "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", destination.getId(), destination.getDeliveryNo(), - null, null, quote.inboundCode(), null, null, null, parseLong(destination.getOverseasWarehouseId()), - "DELIVERY_FEE", "外接运费(" + quote.bookingNo() + ")", "RECEIVABLE", null, null, null, - null, null, null, null, null, LocalDate.now(), quote.currency(), quote.amount(), quote.quantity(), - quote.quantityUnit(), quote.unitPrice(), null, null)); - } - DeliveryOrder feeDelivery = deliveries.get(0); - List feeInputs = request.fees() == null ? List.of() : request.fees(); - var ordinaryCargo = freight.requiresDeliveryFee() ? cargoSources.sources(dispatch, items).stream() - .filter(source -> !externalInboundCodes.contains(source.inboundCode())).toList() : List.of(); - for (DeliveryRequests.FeeItem input : feeInputs) { - String feeCode = input.feeType().trim().toUpperCase(Locale.ROOT); - var uncovered = coverage.uncovered(feeCode); - var uncoveredItemIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::dispatchItemId).collect(Collectors.toSet()); - var chargeItems = coverage.evidence() != null && "RECEIVABLE".equals(input.direction()) - ? items.stream().filter(item -> uncoveredItemIds.contains(item.getId())).toList() - : "RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && freight.requiresDeliveryFee() - ? items.stream().filter(item -> ordinaryCargo.stream().anyMatch(source -> Objects.equals(source.warehousePalletId(), - item.getWarehousePalletId().toString()) && Objects.equals(source.destinationKey(), FleetDispatchGateway.destinationKey( - item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode())))).toList() : items; - var feeDestination = "DELIVERY_FEE".equalsIgnoreCase(input.feeType()) && !chargeItems.isEmpty() - ? deliveries.stream().filter(item -> deliveryGroupKey(chargeItems.get(0)).equals(item.getDeliveryGroupKey())).findFirst().orElseThrow() - : feeDelivery; - LastlegOrder firstOrder = firstTailOrder(customerId, chargeItems); - // 合托后按真实普通来源归属费用;多票时只绑定派送,不借展示入仓码挂到外接货。 - if ("RECEIVABLE".equals(input.direction()) && "DELIVERY_FEE".equals(feeCode) && !externalInboundCodes.isEmpty()) { - var ordinaryOrders = cargoSources.ordinaryOrderIds(dispatch, externalInboundCodes); - firstOrder = ordinaryOrders.size() == 1 ? tailOrders.selectById(ordinaryOrders.iterator().next()) : null; - if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致"); - } - if (coverage.evidence() != null && "RECEIVABLE".equals(input.direction())) { - var uncoveredOrderIds = uncovered.stream().map(com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels.Cargo::orderId).filter(Objects::nonNull).distinct().toList(); - firstOrder = uncoveredOrderIds.size() == 1 ? tailOrders.selectById(uncoveredOrderIds.get(0)) : null; - if (firstOrder != null && !customerId.equals(firstOrder.getCustomerId())) throw conflict("新增费用来源客户不一致"); - } - feeSources.publishApproved(operatorId, childKey(request.idempotencyKey(), input.direction() + ":" + feeCode), - new OverseasFeeSourceDtos.SaveRequest(customerId, feeDestination.getCustomerName(), - "OVERSEAS_DELIVERY_FEE", "DELIVERY_ORDER", feeDestination.getId(), feeDestination.getDeliveryNo(), - firstOrder == null ? null : firstOrder.getId(), null, - firstOrder == null ? null : firstOrder.getInboundCode(), null, - firstOrder == null ? null : parseLong(firstOrder.getSourceContainerBookingId()), - firstOrder == null ? null : firstOrder.getContainerNo(), parseLong(feeDestination.getOverseasWarehouseId()), - feeCode, input.feeName().trim(), input.direction(), input.supplierId(), - input.supplierName(), input.supplierCategory(), input.settlementMode(), input.paidAt(), - input.paymentMethod(), input.paymentReferenceNo(), input.paymentVoucherFileIds(), LocalDate.now(), - input.currency(), input.amount(), input.chargeableQuantity(), input.quantityUnit(), input.unitPrice(), - input.periodStart(), input.periodEnd())); - } - return result(outbound, deliveries, false); - } - - /** 取消后的同载荷重放仍返回原取消单据,绝不作为新建成功。 */ - private DeliveryResponses.TransferResult replayTransfer(OutboundOrder outbound, String requestHash) { - if (!requestHash.equals(outbound.getRequestHash())) - throw conflict("转派送请求与原已保存内容不一致,请查看原单据;费用调整须通过正式费用更正流程"); - return result(outbound, requireDeliveries(outbound.getCustomerId(), outbound.getId()), true); - } - - String transferRequestHash(DeliveryRequests.Transfer request) { - // A new command key cannot change an already transferred dispatch's business content. - com.fasterxml.jackson.databind.node.ObjectNode content = objectMapper.valueToTree(request); - content.remove("idempotencyKey"); - if (request.feeCoverage() == null || request.feeCoverage().isEmpty()) { - content.remove("feeCoverage"); content.remove("feeCoverageCargoHash"); - } - return hash(content); - } - - private void validateTransferFees(DeliveryRequests.Transfer request, boolean requiresDeliveryFee, Set covered) { - List inputs = request.fees() == null ? List.of() : request.fees(); - Set allowed = Set.of("DELIVERY_FEE", "LOADING_FEE", "APPOINTMENT_FEE"); - Set seen = new HashSet<>(); - for (DeliveryRequests.FeeItem input : inputs) { - String feeType = input.feeType().trim().toUpperCase(Locale.ROOT); - if (!allowed.contains(feeType)) throw conflict("Unsupported delivery fee type: " + feeType); - if (!seen.add(input.direction() + ":" + feeType)) throw conflict("Duplicate delivery fee type: " + feeType); - if ("RECEIVABLE".equals(input.direction()) && covered.contains(feeType)) throw conflict("本次货物的该服务已由原费用覆盖,请勿重复登记应收"); - if (!Set.of("CNY", "USD").contains(input.currency().trim().toUpperCase(Locale.ROOT))) - throw conflict("Delivery fees only support CNY or USD"); - if (input.amount().signum() <= 0) throw conflict("Delivery fee amount must be greater than zero"); - } - if (requiresDeliveryFee && !covered.contains("DELIVERY_FEE") && !seen.contains("RECEIVABLE:DELIVERY_FEE")) throw conflict("尚未有约定运费的货物须填写派送费"); - if (!requiresDeliveryFee && inputs.stream().anyMatch(item -> "DELIVERY_FEE".equalsIgnoreCase(item.feeType().trim()) - && "RECEIVABLE".equals(item.direction()))) throw conflict("本排车全部货物已带入外接约定运费,请勿重复填写派送应收"); - if (Set.of("FLOOR", "MIXED").contains(request.loadingType().trim().toUpperCase(Locale.ROOT)) - && !covered.contains("LOADING_FEE") && !seen.contains("RECEIVABLE:LOADING_FEE")) { - throw conflict("Loading fee is required for floor or mixed loading"); - } - if ("BUY_APPOINTMENT".equalsIgnoreCase(trim(request.appointmentAccount()))) { - if (!StringUtils.hasText(request.sellerName())) throw conflict("Seller name is required when buying an appointment"); - if (!covered.contains("APPOINTMENT_FEE") && !seen.contains("RECEIVABLE:APPOINTMENT_FEE")) throw conflict("Appointment fee is required when buying an appointment"); - } - } + @Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; + @Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations; @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { @@ -1165,38 +890,44 @@ public class DeliveryTransferService { } } - @Transactional + /** 先取得真实整车父锁,再锁出库和派送;已扫码不得以取消出库伪造退库。 */ + @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public void cancelOutbound(String outboundNo, String operatorId, DeliveryRequests.Cancel request) { required(request == null ? null : request.reason(), "取消原因不能为空"); - OutboundOrder observed = outboundOrders.findGlobalByNo(trim(outboundNo)); + OutboundOrder observed = cancellations.find(trim(outboundNo)); if (observed == null) throw notFound("Outbound order not found"); if ("DISCARD_OUTBOUND".equals(observed.getOutboundType())) throw conflict("请通过废弃单取消,废弃出库单随申请同步取消"); - // Match dispatch picking and relabel confirmation lock order before locking outbound. - DispatchOrder dispatch = observed.getDispatchOrderId() == null ? null - : dispatchOrders.lockById(observed.getCustomerId(), observed.getDispatchOrderId()); - RelabelApplication application = "RELABEL_OUTBOUND".equals(observed.getOutboundType()) - ? relabelApplications.lockByOutbound(observed.getCustomerId(), observed.getOutboundNo()) : null; - OutboundOrder outbound = outboundOrders.lockGlobalByNo(trim(outboundNo)); + // 服务端身份与父锁仅使用正式共同端口,不按客户去锁整车头部。 + var dispatch = observed.getDispatchOrderId() == null ? null + : execution.lockForArrangement(new com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor(operatorId, + com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode.PLATFORM,null),observed.getDispatchNo()); + RelabelApplication application = observed.getDispatchOrderId()==null && "RELABEL_OUTBOUND".equals(observed.getOutboundType()) + ? cancellations.lockRelabel(observed) : null; + OutboundOrder outbound = cancellations.lock(trim(outboundNo)); if (outbound == null) throw notFound("Outbound order not found"); if ("CANCELLED".equals(outbound.getStatus())) return; if (outbound.getDispatchOrderId() != null) { - if (dispatch == null || !Objects.equals(dispatch.getId(), outbound.getDispatchOrderId()) - || !"PENDING_DEPARTURE".equals(dispatch.getStatus()) || value(dispatch.getPickedPackageCount()) > 0 - || value(dispatch.getShippedPackageCount()) > 0) throw conflict("排车已开始执行,不能直接取消出库"); - fleetBindings.requireUnbound(dispatch.getId()); + if (dispatch == null || !Objects.equals(dispatch.dispatchId(), outbound.getDispatchOrderId()) + || dispatch.status()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE + || dispatch.stage()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING + || dispatch.counts()==null || dispatch.counts().pickedPackageCount()>0 || dispatch.counts().shippedPackageCount()>0) + throw conflict("排车已开始执行,不能直接取消出库"); + fleetBindings.requireUnbound(dispatch.dispatchId()); } if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) { throw conflict("Only an unexecuted outbound order can be cancelled"); } String customerId = outbound.getCustomerId(); - List outboundItems = outboundPallets.findByOutbound(customerId, outbound.getId()); + List outboundItems = cancellations.items(outbound); if (outboundItems.isEmpty() || outboundItems.stream().anyMatch(i -> value(i.getPickedPackageCount()) > 0 || !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) { throw conflict("Outbound order cannot be cancelled after execution has started"); } - List existingDeliveries = deliveryOrders.lockByOutbound(customerId, outbound.getId()); + List existingDeliveries = cancellations.deliveries(outbound); if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus()))) throw conflict("Outbound order cannot be cancelled while delivery is in progress"); + if (outbound.getDispatchOrderId()!=null && "RELABEL_OUTBOUND".equals(outbound.getOutboundType())) + application=cancellations.lockRelabel(outbound); LocalDateTime now = LocalDateTime.now(); if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { if (application == null || !customerId.equals(application.getCustomerId()) @@ -1206,13 +937,13 @@ public class DeliveryTransferService { for (OutboundPalletItem item : outboundItems) { if (item.getWarehousePalletId() != null) continue; for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) { - ReturnInventoryCarton carton = returnCartons.lockMark(mark); + ReturnInventoryCarton carton = cancellations.lockCarton(mark); if (carton == null || !customerId.equals(carton.getCustomerId()) || !Objects.equals(application.getId(), carton.getRelabelApplicationId()) || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货换标箱预留状态异常,不能取消出库"); carton.setStatus("RELABEL_PENDING"); carton.setUpdatedAt(now); - returnCartons.updateById(carton); + cancellations.save(carton); } } relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId, @@ -1220,12 +951,23 @@ public class DeliveryTransferService { } if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) { - ReturnInventoryCarton carton = returnCartons.lockMark(mark); + ReturnInventoryCarton carton = cancellations.lockCarton(mark); if (carton == null || !customerId.equals(carton.getCustomerId()) || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货箱预留状态异常,不能取消出库"); - carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); returnCartons.updateById(carton); + carton.setStatus("AVAILABLE"); carton.setUpdatedAt(now); cancellations.save(carton); } } + // 已收齐子单后,先锁齐全部相关来源;费用锁之后不得再发现新的来源锁。 + var cancelTransfers=new LinkedHashMap(); + if("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) + for(var no:outboundItems.stream().map(OutboundPalletItem::getOrderRefs).filter(StringUtils::hasText).distinct().sorted().toList()) { + var transfer=cancellations.lockTransfer(customerId,no);if(transfer!=null)cancelTransfers.put(no,transfer); + } + var cancelSources=new TreeMap(); + for(var selected:cancellations.sources(customerId,outboundNo)) { + var current=cancellations.lockSource(customerId,selected.getId()); + if(current!=null && outboundNo.equals(current.getOutboundOrderNo()))cancelSources.put(current.getId(),current); + } for (DeliveryOrder delivery : existingDeliveries) { int voidRequests = 0; for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) { @@ -1237,61 +979,58 @@ public class DeliveryTransferService { voidRequests++; } } - recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND", + recordCancellationOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND", "出库取消;" + voidRequests + "笔未履行派送应收已申请撤销,其他费用保留待核对", now); String reason = request.reason().trim(); for (int offset = 0; offset < reason.length(); offset += 480) - recordOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON", + recordCancellationOperation(customerId, delivery.getId(), operatorId, "CANCEL_OUTBOUND_REASON", "(" + (offset / 480 + 1) + ") " + reason.substring(offset, Math.min(offset + 480, reason.length())), now); delivery.setStatus("CANCELLED"); delivery.setVersion(value(delivery.getVersion()) + 1); - delivery.setUpdatedAt(now); deliveryOrders.updateById(delivery); + delivery.setUpdatedAt(now); cancellations.save(delivery); } - OutboundPdaTask task = pdaTasks.lockByOutbound(customerId, outbound.getId()); - if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); pdaTasks.updateById(task); } - outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); outboundOrders.updateById(outbound); - for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); outboundPallets.updateById(item); } + OutboundPdaTask task = cancellations.task(outbound); + if (task != null) { task.setStatus("CANCELLED"); task.setUpdatedAt(now); cancellations.save(task); } + outbound.setStatus("CANCELLED"); outbound.setUpdatedAt(now); cancellations.save(outbound); + for (OutboundPalletItem item : outboundItems) { item.setStatus("CANCELLED"); cancellations.save(item); } if ("WAREHOUSE_TRANSFER_OUTBOUND".equals(outbound.getOutboundType())) { for (String transferNo : outboundItems.stream().map(OutboundPalletItem::getOrderRefs) .filter(StringUtils::hasText).distinct().toList()) { - WarehouseTransferOrder transfer = warehouseTransfers.lockNo(customerId, transferNo); + WarehouseTransferOrder transfer = cancelTransfers.get(transferNo); if (transfer == null || !"WAIT_OUTBOUND".equals(transfer.getStatus())) continue; String restored = "FBA".equals(transfer.getDestinationType()) || "PRIVATE".equals(transfer.getDestinationType()) ? "READY_FOR_DISPATCH" : "WAIT_FBA_DESTINATION"; - transfer.setStatus(restored); transfer.setUpdatedAt(now); warehouseTransfers.updateById(transfer); - LastlegOrder order = tailOrders.selectById(transfer.getLastlegOrderId()); + transfer.setStatus(restored); transfer.setUpdatedAt(now); cancellations.save(transfer); + LastlegOrder order = cancelSources.get(transfer.getLastlegOrderId()); if (order != null && customerId.equals(order.getCustomerId())) { order.setCurrentStatus(restored); order.setSubOrderStatus("WAREHOUSE_TRANSFER:" + restored); order.setOutboundOrderNo(null); order.setDeliveryOrderNo(null); order.setUpdatedAt(now); - tailOrders.updateById(order); + if(!cancellations.restore(order,outboundNo,restored,"WAREHOUSE_TRANSFER:"+restored,now)) + throw conflict("转仓来源关联已变化,请刷新核对"); } } return; } if (!existingDeliveries.isEmpty() && !"RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { var transferEvents = existingDeliveries.stream().map(delivery -> "TRANSFER_DELIVERY:" + delivery.getDeliveryNo()).toList(); - for (LastlegOrder selected : tailOrders.selectList(new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId) - .eq(LastlegOrder::getOutboundOrderNo, outboundNo).orderByAsc(LastlegOrder::getId))) { - var order = tailOrders.lockById(customerId, selected.getId()); + for (LastlegOrder order : cancelSources.values()) { if (order == null || !outboundNo.equals(order.getOutboundOrderNo())) continue; - var previous = histories.selectOne(new LambdaQueryWrapper() - .eq(LastlegOrderStatusHistory::getCustomerId, customerId).eq(LastlegOrderStatusHistory::getOrderId, order.getId()) - .in(LastlegOrderStatusHistory::getEventId, transferEvents) - .orderByAsc(LastlegOrderStatusHistory::getCreatedAt, LastlegOrderStatusHistory::getId).last("LIMIT 1")); + var previous = cancellations.previous(customerId,order.getId(),transferEvents); if (previous == null || !StringUtils.hasText(previous.getFromStatus())) throw conflict("缺少本次转派送前的订单状态记录,请先核对来源"); String from = order.getCurrentStatus(); - if (tailOrders.update(null, new com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper() - .eq(LastlegOrder::getId, order.getId()).eq(LastlegOrder::getCustomerId, customerId) - .eq(LastlegOrder::getOutboundOrderNo, outboundNo) - .set(LastlegOrder::getCurrentStatus, previous.getFromStatus()) - .set(LastlegOrder::getSubOrderStatus, "OUTBOUND:CANCELLED;DELIVERY:CANCELLED") - .set(LastlegOrder::getOutboundOrderNo, null).set(LastlegOrder::getDeliveryOrderNo, null) - .set(LastlegOrder::getVersion, value(order.getVersion()) + 1).set(LastlegOrder::getUpdatedAt, now)) != 1) + if (!cancellations.restore(order,outboundNo,previous.getFromStatus(),"OUTBOUND:CANCELLED;DELIVERY:CANCELLED",now)) throw conflict("订单关联已变化,请刷新后重试"); - LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); histories.insert(history); + LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); history.setFromStatus(from); history.setToStatus(previous.getFromStatus()); history.setEventId("CANCEL_OUTBOUND:" + outboundNo); history.setRemark(request.reason().trim()); history.setCreatedAt(now); cancellations.history(history); } } } + /** 取消事实按完整原因分段写入,不借通用Service直调Mapper。 */ + private void recordCancellationOperation(String customerId,Long deliveryId,String operator,String node,String result,LocalDateTime now) { + var operation=new DeliveryOperation();operation.setCustomerId(customerId);operation.setDeliveryOrderId(deliveryId); + operation.setOperatorId(operator);operation.setOperatorName(operator);operation.setOperationNode(node);operation.setResult(result);operation.setCreatedAt(now); + cancellations.operation(operation); + } + public List pdaTasks(String warehouseId, String status) { String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); return pdaTasks.listByWarehouse(boundWarehouseId, StringUtils.hasText(status) ? status.trim() : null).stream() @@ -1591,49 +1330,7 @@ public class DeliveryTransferService { } catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); } } - private void bindTailOrders(String customerId, List items, String outboundNo, String deliveryNo, - String remark, LocalDateTime now) { - Set orderIds = new LinkedHashSet<>(); - items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).forEach(orderIds::add); - List palletIds = items.stream().map(DispatchItem::getPlanPalletId).filter(Objects::nonNull).distinct().toList(); - if (!palletIds.isEmpty()) { - List allocations = planAllocations.selectList( - new LambdaQueryWrapper().in(InboundPlanPalletAllocation::getPalletId, palletIds)); - List lineIds = allocations.stream().map(InboundPlanPalletAllocation::getLineId) - .filter(Objects::nonNull).distinct().toList(); - if (!lineIds.isEmpty()) { - planLines.selectBatchIds(lineIds).stream().map(InboundPlanLine::getOrderId) - .filter(Objects::nonNull).forEach(orderIds::add); - } - } - if (orderIds.isEmpty()) return; - for (LastlegOrder order : tailOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId).in(LastlegOrder::getId, orderIds))) { - String from = order.getCurrentStatus(); order.setOutboundOrderNo(outboundNo); - order.setDeliveryOrderNo(deliveryNo); order.setCurrentStatus("WAIT_OUTBOUND"); order.setUpdatedAt(now); - order.setSubOrderStatus("OUTBOUND:WAIT_OUTBOUND;DELIVERY:WAIT_DELIVERY"); - tailOrders.updateById(order); - LastlegOrderStatusHistory history = new LastlegOrderStatusHistory(); history.setCustomerId(customerId); history.setOrderId(order.getId()); - history.setFromStatus(from); history.setToStatus("WAIT_OUTBOUND"); - history.setEventId("TRANSFER_DELIVERY:" + deliveryNo); - history.setRemark(StringUtils.hasText(remark) ? remark.trim() : "Transferred to outbound and delivery documents"); - history.setCreatedAt(now); histories.insert(history); - } - } - - private List requireDeliveries(String customerId, Long outboundId) { - List values = deliveryOrders.lockByOutbound(customerId, outboundId); - if (values.isEmpty()) throw new IllegalStateException("Delivery documents are missing for outbound order"); - return values; - } - private DeliveryResponses.TransferResult result(OutboundOrder o, List deliveries, boolean replayed) { - return new DeliveryResponses.TransferResult(o.getDispatchNo(), o.getOutboundNo(), o.getStatus(), - o.getPalletCount(), o.getPackageCount(), deliveries.stream().map(d -> new DeliveryResponses.TransferDelivery( - d.getDeliveryNo(), d.getStatus(), d.getDestinationWarehouseId(), d.getDestinationWarehouseCode(), - d.getDestinationWarehouseName(), value(d.getPalletCount()), value(d.getPackageCount()))).toList(), replayed); - } - /** 出库列表及详情头部按物理箱数汇总,退货件数不充当箱数。 */ - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), @@ -1655,7 +1352,6 @@ public class DeliveryTransferService { private DeliveryResponses.DeliveryFeeRow feeRow(OverseasFeeSource f) { return new DeliveryResponses.DeliveryFeeRow(f.getId(), f.getDirection(), f.getFeeItemCode(), f.getOriginalCurrency(), f.getOriginalAmount(), f.getReviewReason(), f.getReviewStatus(), f.getSourceStatus(), f.getVoidStatus()); } private DeliveryResponses.DeliveryOperationRow operationRow(DeliveryOperation o) { return new DeliveryResponses.DeliveryOperationRow(o.getId(), o.getCreatedAt(), o.getOperationNode(), firstText(o.getOperatorName(), o.getOperatorId()), o.getResult()); } private void recordOperation(String customerId, Long deliveryId, String operatorId, String node, String result, LocalDateTime now) { DeliveryOperation operation = new DeliveryOperation(); operation.setCustomerId(customerId); operation.setDeliveryOrderId(deliveryId); operation.setOperationNode(node); operation.setOperatorId(operatorId); operation.setOperatorName(operatorId); operation.setResult(result); operation.setCreatedAt(now); deliveryOperations.insert(operation); } - private LastlegOrder firstTailOrder(String customerId, List items) { return items.stream().map(DispatchItem::getOrderId).filter(Objects::nonNull).map(tailOrders::selectById).filter(Objects::nonNull).filter(order -> customerId.equals(order.getCustomerId())).findFirst().orElse(null); } private String deliveryGroupKey(DispatchItem item) { if ("PRIVATE".equals(item.getDestinationType()) && StringUtils.hasText(item.getDeliveryAddress())) { return "PRIVATE:" + item.getDeliveryAddress().trim() + ":" + Objects.toString(item.getPostalCode(), "").trim(); @@ -1665,16 +1361,6 @@ public class DeliveryTransferService { if (StringUtils.hasText(item.getDestinationWarehouseId())) return "ID:" + item.getDestinationWarehouseId().trim(); throw conflict("Dispatch pallet is missing its destination"); } - private String resolveLxCodes(String customerId, List items) { - List inboundCodes = items.stream().map(DispatchItem::getInboundCode) - .filter(StringUtils::hasText).map(String::trim).distinct().toList(); - if (inboundCodes.isEmpty()) return ""; - return tailOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) - .in(LastlegOrder::getInboundCode, inboundCodes)) - .stream().map(LastlegOrder::getLxCode).filter(StringUtils::hasText) - .map(String::trim).distinct().collect(java.util.stream.Collectors.joining(",")); - } private void updateOutboundPickingStatus(String customerId, OutboundOrder outbound) { List items = outboundPallets.findByOutbound(customerId, outbound.getId()); String nextStatus = !items.isEmpty() && items.stream().allMatch(item -> "PICKED".equals(item.getStatus())) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java new file mode 100644 index 000000000..9d598d203 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java @@ -0,0 +1,179 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.client.FinanceFeeCoverageClient; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.entity.DeliveryOrder; +import com.baiyue.lastleg.delivery.entity.DeliveryOperation; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups.Group; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.fleet.trip.FleetBookingFreight; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; + +/** 逐客户逐目的地确认报价与覆盖,费用只归属于该组真实派送。 */ +@Service +public class DispatchDeliveryFees { + private final FleetBookingFreight external; + private final FinanceFeeCoverageClient finance; + private final OverseasFeeSourceService fees; + private final DeliveryCargoGroups groups; + private final DispatchDeliveryRepository repository; + private final String internalToken; + private static final Set SERVICES=Set.of("DELIVERY_FEE","LOADING_FEE","APPOINTMENT_FEE"); + + /** 复用现有报价、费用发布和Finance只读契约,不引入另一套账务。 */ + public DispatchDeliveryFees(FleetBookingFreight external,FinanceFeeCoverageClient finance, + OverseasFeeSourceService fees,DeliveryCargoGroups groups,DispatchDeliveryRepository repository, + @Value("${baiyue.security.internal-service-token}") String internalToken) { + this.external=external;this.finance=finance;this.fees=fees;this.groups=groups; + this.repository=repository;this.internalToken=internalToken; + } + + /** 一客户一次按全部真实来源校验外接实收,避免将一笔完整报价按目的地重复核价。 */ + public Map external(Context context) { + var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(CargoLine::customerId,TreeMap::new,Collectors.toList())); + var result=new LinkedHashMap(); + for (var entry:byCustomer.entrySet()) { + var cargo=entry.getValue().stream().map(c->new FleetDispatchGateway.SourceCargo(c.inboundCode(), + c.warehousePalletId()==null?null:c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key())).toList(); + result.put(entry.getKey(),external.preview(context.dispatchId(),context.dispatchNo(),entry.getKey(),cargo)); + } + return result; + } + + /** 原费候选按客户批量只读,响应越界或不可用直接失败,不能认作已覆盖。 */ + public List original(String customerId,List cargo) { + var ids=new TreeSet(); + for (var row:cargo) if ("FIRSTLEG".equals(row.sourceRegion())) { + try { long id=Long.parseLong(row.sourceInboundId());if(id<=0)throw new NumberFormatException();ids.add(id); } + catch (RuntimeException invalid) { throw conflict("原头程货物缺少真实来源关联"); } + } + if (ids.isEmpty()) return List.of(); + try { + var response=finance.sources(internalToken,new FinanceFeeCoverageClient.Query(Long.valueOf(customerId),List.copyOf(ids))); + if (response==null || !response.successful() || response.data()==null) throw unavailable(); + for (var fee:response.data()) if (fee==null || fee.financeFeeId()==null || fee.financeFeeId()<=0 + || fee.sourceFeeId()==null || !Objects.equals(fee.customerId(),Long.valueOf(customerId)) + || !ids.contains(fee.sourceInboundId()) || fee.sourceVersion()<1 || blank(fee.sourceHash()) + || fee.amount()==null || fee.amount().signum()<0 || !"CNY".equals(fee.currency()) + || !Set.of("INBOUND_FEE","CONTAINER_FEE").contains(fee.sourceType()) + || !Set.of("AVAILABLE","SETTLED").contains(fee.status())) throw unavailable(); + return response.data(); + } catch (RuntimeException unavailable) { throw unavailable(); } + } + + /** 平台按真实组服务范围确认原费,返回不可变证据供客户出库保存。 */ + public DeliveryFeeCoverageModels.Evidence coverage(Group group,ArrangeGroup input,String operatorId, + FleetBookingFreight.Preview freight) { + var selected=input.feeCoverage()==null?List.of():input.feeCoverage(); + if (selected.isEmpty()) return null; + if (!Objects.equals(group.cargoHash(),input.feeCoverageCargoHash())) throw conflict("原费用对应货物已变化"); + var candidates=original(group.customerId(),group.cargo()); + var references=new ArrayList(); var scopes=new HashSet(); + for (var selection:selected) { + if (selection==null || selection.services()==null || selection.services().isEmpty()) throw conflict("请选择明确包含的服务"); + var fee=candidates.stream().filter(f->Objects.equals(f.financeFeeId(),selection.financeFeeId())).findFirst() + .orElseThrow(()->conflict("原费用不属于本客户本次货物或已失效")); + if (fee.sourceVersion()!=selection.sourceVersion() || !Objects.equals(fee.sourceHash(),selection.sourceHash())) + throw conflict("原费用版本已变化,请重新核对"); + var services=new TreeSet<>(selection.services()); + if (services.size()!=selection.services().size() || !SERVICES.containsAll(services)) throw conflict("原费用服务范围重复或无效"); + for (var service:services) if (!scopes.add(fee.sourceInboundId()+":"+service)) throw conflict("同一来源服务不得重复引用原费"); + references.add(new DeliveryFeeCoverageModels.Reference(fee,List.copyOf(services))); + } + var cargo=group.cargo().stream().map(c->new DeliveryFeeCoverageModels.Cargo(c.dispatchItemId(),c.warehousePalletId(), + c.palletNo(),c.orderId(),c.sourceRegion(),c.sourceInboundId(),c.inboundCode(),c.packages(),c.inboundCode())).toList(); + return new DeliveryFeeCoverageModels.Evidence(group.cargoHash(),cargo,List.copyOf(references),operatorId,Instant.now()); + } + + /** 按每条真实来源判断某服务是否已含,外接约定只覆盖派送服务。 */ + private boolean covered(CargoLine cargo,String service,DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight) { + if ("DELIVERY_FEE".equals(service) && freight.quotes().stream().anyMatch(q->Objects.equals(q.inboundCode(),cargo.inboundCode()))) return true; + return evidence!=null && "FIRSTLEG".equals(cargo.sourceRegion()) && evidence.references().stream().anyMatch(r-> + Objects.equals(r.source().sourceInboundId().toString(),cargo.sourceInboundId()) && r.services().contains(service)); + } + + /** 所有收费资格在创建子单前核验,新增应收只能针对未覆盖来源。 */ + public void validate(Group group,ArrangeGroup input,DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight) { + if (input.plannedDeliveryAt()==null || blank(input.deliveryMethod()) || blank(input.pickupMethod()) || blank(input.loadingType())) + throw conflict("请补齐每个客户目的地的派送信息"); + if (!Objects.equals(input.groupCargoHash(),group.cargoHash())) throw conflict("派送货物分组已变化"); + if (!Objects.equals(input.externalFreightHash(),freight.snapshotHash())) throw conflict("外接实收或约定运费已变化"); + var seen=new HashSet(); + for (var fee:input.fees()==null?List.of():input.fees()) { + if (fee==null || blank(fee.feeType()) || !SERVICES.contains(fee.feeType()) || blank(fee.direction()) || !Set.of("RECEIVABLE","PAYABLE").contains(fee.direction()) + || blank(fee.currency()) || !Set.of("CNY","USD").contains(fee.currency()) || blank(fee.feeName()) || fee.amount()==null || fee.amount().signum()<=0) + throw conflict("派送费用类型、方向、币种或金额不合法"); + if (!seen.add(fee.direction()+":"+fee.feeType())) throw conflict("同组同方向同费用不得重复登记"); + if ("RECEIVABLE".equals(fee.direction()) && group.cargo().stream().allMatch(c->covered(c,fee.feeType(),evidence,freight))) + throw conflict("本组该服务已全部覆盖,不得重复登记应收"); + } + var required=new HashSet(); required.add("DELIVERY_FEE"); + if (Set.of("FLOOR","MIXED").contains(input.loadingType())) required.add("LOADING_FEE"); + if ("BUY_APPOINTMENT".equals(input.appointmentAccount())) { + if (blank(input.sellerName())) throw conflict("买约须填写卖家名称");required.add("APPOINTMENT_FEE"); + } + for (var code:required) if (group.cargo().stream().anyMatch(c->!covered(c,code,evidence,freight)) && !seen.contains("RECEIVABLE:"+code)) + throw conflict("本组尚有未覆盖的服务费用,请核对" ); + } + + /** 子单及来源锁已经收齐后才核对原费用撤销状态,不能重复收费。 */ + public void requireOriginalVoided(String customerId,FleetBookingFreight.Preview freight) { + fees.requireNoActiveDeliveryReceivables(customerId,freight.priorInboundCodes()); + } + + /** 费用与子单同事务保存,只向真实客户目的地发布一次正数来源。 */ + public void publish(Context context,Group group,ArrangeGroup input,DeliveryOrder delivery, + DeliveryFeeCoverageModels.Evidence evidence,FleetBookingFreight.Preview freight,String operatorId,String arrangementKey) { + var now=LocalDateTime.now(); + for (var quote:freight.quotes()) if (Objects.equals(quote.destinationKey(),group.destination().key()) + && group.cargo().stream().anyMatch(c->Objects.equals(c.inboundCode(),quote.inboundCode()))) { + var operation=new DeliveryOperation();operation.setCustomerId(group.customerId());operation.setDeliveryOrderId(delivery.getId()); + operation.setOperatorId(operatorId);operation.setOperationNode("CONFIRM_EXTERNAL_FREIGHT"); + operation.setResult("外接预订 "+quote.bookingNo()+";实收确认版本 "+quote.recordVersion()+";约定运费 "+quote.currency()+" "+quote.amount().toPlainString()); + operation.setCreatedAt(now);repository.operation(operation); + if (quote.amount().signum()==0) continue; + fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),quote.bookingNo(),"FREIGHT")), + save(group,delivery,null,"DELIVERY_FEE","外接约定运费", "RECEIVABLE",quote.currency(),quote.amount(), + quote.quantity(),quote.quantityUnit(),quote.unitPrice(),null,quote.inboundCode())); + } + for (var fee:input.fees()==null?List.of():input.fees()) { + var charged=group.cargo().stream().filter(c->!"RECEIVABLE".equals(fee.direction())||!covered(c,fee.feeType(),evidence,freight)).toList(); + var orders=charged.stream().map(CargoLine::orderId).filter(Objects::nonNull).distinct().toList(); + var inbound=charged.stream().map(CargoLine::inboundCode).filter(Objects::nonNull).distinct().toList(); + fees.publishApproved(operatorId,groups.hash(List.of(arrangementKey,group.key(),fee.direction(),fee.feeType())), + save(group,delivery,orders.size()==1?orders.get(0):null,fee.feeType(),fee.feeName(),fee.direction(),fee.currency(), + fee.amount(),fee.chargeableQuantity(),fee.quantityUnit(),fee.unitPrice(),fee,inbound.size()==1?inbound.get(0):null)); + } + } + + /** 多来源不借第一票挂账,供应商应付与客户覆盖独立。 */ + private OverseasFeeSourceDtos.SaveRequest save(Group group,DeliveryOrder delivery,Long orderId,String code,String name, + String direction,String currency,BigDecimal amount,BigDecimal quantity,String unit,BigDecimal unitPrice,FeeItem input,String inboundCode) { + return new OverseasFeeSourceDtos.SaveRequest(group.customerId(),group.customerName(),"OVERSEAS_DELIVERY_FEE","DELIVERY_ORDER", + delivery.getId(),delivery.getDeliveryNo(),orderId,null,inboundCode,null,null,null,Long.valueOf(delivery.getOverseasWarehouseId()), + code,name,direction,input==null?null:input.supplierId(),input==null?null:input.supplierName(),input==null?null:input.supplierCategory(), + input==null?null:input.settlementMode(),input==null||input.paidAt()==null?null:LocalDateTime.ofInstant(input.paidAt(),ZoneId.systemDefault()), + input==null?null:input.paymentMethod(),input==null?null:input.paymentReferenceNo(),input==null?null:input.paymentVoucherFileIds(), + LocalDate.now(),currency,amount,quantity,unit,unitPrice,input==null?null:input.periodStart(),input==null?null:input.periodEnd()); + } + + /** 只有真实业务标识可用于费用归属。 */ + private static boolean blank(String value) { return value==null||value.isBlank(); } + /** 财务只读失败不降级为已覆盖或零费。 */ + private static ResponseStatusException unavailable() { return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"原费用来源暂不可用,请稍后核对"); } + /** 校验失败由外层事务回滚所有客户单据。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java new file mode 100644 index 000000000..46f2689c0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java @@ -0,0 +1,354 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.dto.DeliveryFeeCoverageModels; +import com.baiyue.lastleg.delivery.model.*; +import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups.Group; +import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; +import com.baiyue.lastleg.service.LastlegOrderNoGenerator; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.time.*; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** 整车安排只有一个事务,真实客户单据、费用及主流程命令共同提交。 */ +@Service +public class DispatchDeliveryService implements DispatchDeliveryPort { + private final DispatchExecutionPort execution; + private final DispatchDeliveryRepository repository; + private final DeliveryCargoGroups grouping; + private final DispatchDeliveryFees fees; + private final NormalDeliveryCompletionPolicy completion; + private final LastlegOrderNoGenerator numbers; + private final ObjectMapper json; + + /** 消费主流程实际公共骨架,禁止注入对方Controller或具体Service。 */ + public DispatchDeliveryService(@org.springframework.context.annotation.Lazy DispatchExecutionPort execution,DispatchDeliveryRepository repository, + DeliveryCargoGroups grouping,DispatchDeliveryFees fees,NormalDeliveryCompletionPolicy completion, + LastlegOrderNoGenerator numbers,ObjectMapper json) { + this.execution=execution;this.repository=repository;this.grouping=grouping;this.fees=fees; + this.completion=completion;this.numbers=numbers;this.json=json; + } + + /** 组合GET的入口上下文和本地派送数据加入同一个只读快照。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ArrangementPreview preview(Actor actor,String dispatchNo) { + return preview(execution.preview(actor,dispatchNo)); + } + + /** 外部原费只读带版本证据,不能声称参与Lastleg数据库同刻快照。 */ + @Override + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ArrangementPreview preview(Context context) { + var saved=repository.arrangement(context.dispatchId(),false); + var result=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); + var gate=arrangementGate(context); + if (context.cargo()==null || context.cargo().isEmpty() || !context.pickingReconciled()) + return new ArrangementPreview(context.dispatchNo(),context.recordVersion(),context.status(),context.stage(), + context.cargoHash(),context.loadingCompletionHash(),gate,List.of(),result); + var groups=grouping.groups(context); var external=fees.external(context); + var original=new HashMap>(); + for (var customer:groups.stream().map(Group::customerId).distinct().toList()) + original.put(customer,fees.original(customer,context.cargo().stream().filter(c->customer.equals(c.customerId())).toList())); + var previews=new ArrayList(); + for (var group:groups) { + var freight=external.get(group.customerId()); + var quotes=freight.quotes().stream().filter(q->Objects.equals(q.destinationKey(),group.destination().key()) + && group.cargo().stream().anyMatch(c->Objects.equals(c.inboundCode(),q.inboundCode()))) + .map(q->new ExternalQuote(group.customerId(),group.key(),q.bookingNo(),q.inboundCode(),q.currency(),q.amount(),freight.snapshotHash())).toList(); + var candidates=original.get(group.customerId()).stream().filter(f->group.cargo().stream().anyMatch(c-> + Objects.equals(c.sourceInboundId(),f.sourceInboundId().toString()))) + .map(f->new FeeCandidate(f.financeFeeId(),group.customerId(),f.currency(),f.amount(),f.sourceVersion(),f.sourceHash(), + f.status(),List.of("DELIVERY_FEE","LOADING_FEE","APPOINTMENT_FEE"),group.cargo().stream() + .filter(c->Objects.equals(c.sourceInboundId(),f.sourceInboundId().toString())).map(CargoLine::sourceKey).distinct().toList())).toList(); + var required=group.cargo().stream().anyMatch(c->freight.quotes().stream().noneMatch(q->Objects.equals(q.inboundCode(),c.inboundCode()))) + ?List.of("DELIVERY_FEE"):List.of(); + previews.add(new PreviewGroup(group.key(),group.customerId(),group.customerName(),group.destination(),group.cargoHash(),group.cargo(), + freight.snapshotHash(),quotes,group.cargoHash(),candidates,required,List.of())); + } + return new ArrangementPreview(context.dispatchNo(),context.recordVersion(),context.status(),context.stage(),context.cargoHash(), + context.loadingCompletionHash(),gate,List.copyOf(previews),result); + } + + /** 先共同父锁和严格命令恢复,再按客户与目的地原子创建全车单据。 */ + @Override + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult arrange(Actor actor,String dispatchNo,ArrangeDelivery request) { + if (actor==null || actor.mode()!=ActorMode.PLATFORM) throw new ResponseStatusException(HttpStatus.FORBIDDEN,"整车派送由平台安排"); + if (request==null || request.recordVersion()<0 || blank(request.idempotencyKey()) || request.idempotencyKey().length()>255 + || blank(request.cargoHash()) || blank(request.loadingCompletionHash()) || request.groups()==null || request.groups().isEmpty() + || request.groups().size()>200) throw conflict("安排派送请求不完整"); + var canonical=canonical(request); + var context=execution.lockForArrangement(actor,dispatchNo); + var identity=new CommandIdentity(Action.ARRANGE_DELIVERY,request.idempotencyKey(),request.recordVersion(), + grouping.hash(List.of(actor,Action.ARRANGE_DELIVERY,context.dispatchNo(),canonical))); + var replay=execution.replay(context,actor,identity); + if (replay.isPresent()) return replay.get(); + String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),canonical.cargoHash(),canonical.loadingCompletionHash(),canonical.groups())); + var saved=repository.arrangement(context.dispatchId(),true); + if (saved!=null) { + if (context.recordVersion()!=request.recordVersion() || !Objects.equals(saved.getBusinessHash(),businessHash)) + throw conflict("本车已安排派送,当前版本或业务内容不同"); + var current=departureFacts(context,true); + // 发车后的原安排也可作为既有业务事实读取,但取消/缺项绝不能补建为成功。 + if (context.status()!=Status.ACTIVE || current.outbounds().isEmpty() + || current.outbounds().stream().anyMatch(o->"CANCELLED".equals(o.status())) + || current.deliveries().stream().anyMatch(d->"CANCELLED".equals(d.status())) + || !completeDocuments(context,saved,repository.outbounds(context.dispatchId(),false),repository.deliveries(context.dispatchId(),false))) + throw conflict("原安排单据不完整或已取消,请核对原单据"); + return execution.recordArrangement(context,actor,identity,fact(saved),true); + } + execution.requireFreshArrangement(context,request.recordVersion()); + if (!Objects.equals(context.cargoHash(),request.cargoHash()) || !Objects.equals(context.loadingCompletionHash(),request.loadingCompletionHash())) + throw conflict("装车或实际货物已经变化,请重新核对"); + var groups=grouping.groups(context); var inputs=new HashMap(); + for (var input:canonical.groups()) if (input==null || inputs.putIfAbsent(input.groupKey(),input)!=null) throw conflict("派送分组重复"); + if (!inputs.keySet().equals(groups.stream().map(Group::key).collect(Collectors.toSet()))) throw conflict("必须一次安排本车全部客户目的地"); + // 即使缺少唯一安排记录,也不把已有孤立客户单据当成可重建的半车。 + var existingOutbounds=repository.outbounds(context.dispatchId(),true); + var existingDeliveries=repository.deliveries(context.dispatchId(),true); + if (!existingOutbounds.isEmpty() || !existingDeliveries.isEmpty()) throw conflict("已存在关联单据但缺少完整安排事实,请核对"); + var external=fees.external(context);var coverages=new HashMap(); + for (var group:groups) { + var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId())); + fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage); + } + var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap(); + for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { + var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); + var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer); + order.setOutboundNo(numbers.next("OUTBOUND",context.warehouseId(),order.getId())); + order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); + order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes(""); + order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum())); + order.setPalletCount((int)cargo.stream().map(CargoLine::warehousePalletId).filter(Objects::nonNull).distinct().count()); + order.setGrossWeightKg(metric(cargo,CargoLine::grossWeightKg));order.setVolumeCbm(metric(cargo,CargoLine::volumeCbm)); + order.setIdempotencyKey(grouping.hash(List.of(key,customer,"OUTBOUND")));order.setRequestHash(businessHash); + order.setCreatedBy(actor.operatorId());order.setCreatedAt(local(now));order.setUpdatedAt(local(now)); + var covered=groups.stream().filter(g->g.customerId().equals(customer)).map(g->coverages.get(g.key())).filter(Objects::nonNull).toList(); + if (!covered.isEmpty()) order.setFeeCoverageJson(write(mergeCoverage(covered,actor.operatorId()))); + repository.create(order);outbounds.put(customer,order); + } + var deliveries=new LinkedHashMap(); + for (var group:groups) { + var input=inputs.get(group.key());var outbound=outbounds.get(group.customerId()); + var delivery=delivery(context,group,input,outbound,actor.operatorId(),key,now); + repository.create(delivery);deliveries.put(group.key(),delivery); + for (var row:group.cargo()) { + var item=new OutboundPalletItem();item.setId(IdWorker.getId());item.setCustomerId(row.customerId());item.setOutboundOrderId(outbound.getId()); + item.setDispatchItemId(row.dispatchItemId());item.setSourceAttributionId(row.attributionId());item.setSourceOrderId(row.orderId()); + item.setSourceInventoryPositionId(row.inventoryPositionId());item.setSourceReturnReceiptId(row.returnReceiptId());item.setSourceReturnCartonId(row.returnCartonId()); + item.setWarehousePalletId(row.warehousePalletId());item.setPalletNo(row.palletNo());item.setInboundCode(row.inboundCode()); + item.setDestinationWarehouseId(group.destination().warehouseId());item.setDestinationWarehouseCode(group.destination().warehouseCode()); + item.setDestinationWarehouseName(group.destination().warehouseName());item.setPackageCount(Math.toIntExact(row.packages())); + item.setPickedPackageCount(Math.toIntExact(row.packages()));item.setStatus("PICKED"); + item.setGrossWeightKg(row.grossWeightKg());item.setVolumeCbm(row.volumeCbm());item.setCartonMarks(row.cartonMark()); + item.setOrderRefs(row.orderNo());item.setCreatedAt(local(now));repository.create(item); + } + } + // 所有出库/派送均已插入且持有写锁,此后才申请本次全部来源锁。 + bindSources(context,groups,outbounds,deliveries,now); + for (var customer:external.keySet()) fees.requireOriginalVoided(customer,external.get(customer)); + for (var group:groups) fees.publish(context,group,inputs.get(group.key()),deliveries.get(group.key()),coverages.get(group.key()), + external.get(group.customerId()),actor.operatorId(),key); + var result=new ArrangementResult(context.dispatchNo(),context.recordVersion()+1,key,context.cargoHash(),now, + outboundRefs(outbounds.values(),groups),deliveryRefs(deliveries.values(),groups)); + var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId()); + arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash); + arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash()); + arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId()); + arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement); + return execution.recordArrangement(context,actor,identity,fact(arrangement),false); + } + + /** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public DepartureFacts departureFacts(Context context,boolean lock) { + var arrangement=repository.arrangement(context.dispatchId(),lock); + var outbounds=repository.outbounds(context.dispatchId(),lock);var deliveries=repository.deliveries(context.dispatchId(),lock); + var groups=context.cargo()==null||context.cargo().isEmpty()?List.of():grouping.groups(context); + String reason=arrangement==null?"请先安排本车全部客户派送": + !Objects.equals(arrangement.getCargoHash(),context.cargoHash()) || !Objects.equals(arrangement.getLoadingCompletionHash(),context.loadingCompletionHash()) + ?"派送安排的装车或货物依据已经失效":!completeDocuments(context,arrangement,outbounds,deliveries)?"客户出库及派送未完整覆盖原安排": + outbounds.stream().anyMatch(o->!"WAIT_OUTBOUND".equals(o.getStatus())) || deliveries.stream().anyMatch(d->!"WAIT_DELIVERY".equals(d.getStatus()) + || d.getSignedAt()!=null || d.getDispatchedAt()!=null)?"关联单据已执行或取消":null; + return new DepartureFacts(context.dispatchNo(),arrangement==null?null:arrangement.getArrangementKey(),context.cargoHash(),context.loadingCompletionHash(), + arrangement==null?null:arrangement.getBusinessHash(),outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups), + context.cargo(),new Gate(reason==null,reason==null?null:"DELIVERY_NOT_READY",reason)); + } + + /** 只更新已锁单据,库存扣减及订单归属转换不在此重复执行。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public DepartureResult markDeparted(Context context,Actor actor,Instant departedAt,String commandKey) { + if (departedAt==null) throw conflict("缺少实际发车时间"); + var facts=departureFacts(context,true);if (!facts.depart().allowed()) throw conflict(facts.depart().blockReason()); + var outbounds=repository.outbounds(context.dispatchId(),false);var deliveries=repository.deliveries(context.dispatchId(),false); + for (var outbound:outbounds) { outbound.setStatus("OUTBOUNDED");outbound.setUpdatedAt(local(departedAt));repository.save(outbound); } + for (var delivery:deliveries) { delivery.setStatus("DELIVERING");delivery.setDispatchedAt(local(departedAt)); + delivery.setVersion(Objects.requireNonNullElse(delivery.getVersion(),0L)+1);delivery.setUpdatedAt(local(departedAt));repository.save(delivery); } + var groups=grouping.groups(context); + return new DepartureResult(outboundRefs(outbounds,groups),deliveryRefs(deliveries,groups),outbounds.stream().mapToLong(o->o.getPackageCount()).sum()); + } + + /** 签收汇总仅普通读取全部真实子单,不反向锁来源或再次发布费用。 */ + @Override + @Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED) + public NormalDeliveryFacts normalDeliveryFacts(Context context) { + var saved=repository.arrangement(context.dispatchId(),false); + var expected=saved==null?null:read(saved.getResultJson(),ArrangementResult.class); + var outbounds=repository.outbounds(context.dispatchId(),false); + var actual=new ArrayList(); + for (var delivery:repository.deliveries(context.dispatchId(),false)) { + var outbound=outbounds.stream().filter(o->Objects.equals(o.getId(),delivery.getOutboundOrderId()) + && Objects.equals(o.getCustomerId(),delivery.getCustomerId()) && Objects.equals(o.getOutboundNo(),delivery.getOutboundNo())).findFirst().orElse(null); + var destination=destination(delivery);String groupKey=grouping.groupKey(delivery.getCustomerId(),destination); + // 使用安排时原组身份,避免FBA领域键与单据CODE键的表示差异。 + if (expected!=null) groupKey=expected.deliveries().stream().filter(d->d.deliveryNo().equals(delivery.getDeliveryNo())) + .map(DeliveryReference::groupKey).findFirst().orElse(groupKey); + long returned=repository.returns(delivery).stream().mapToLong(DeliveryReturnReceiptFact::packages).sum(); + actual.add(new NormalDeliveryFact(delivery.getCustomerId(),groupKey,delivery.getOutboundNo(),delivery.getDeliveryNo(), + Objects.requireNonNullElse(delivery.getVersion(),0L),Objects.requireNonNullElse(delivery.getPackageCount(),0), + Objects.requireNonNullElse(delivery.getSignedPackages(),0),returned,repository.activeReturns(delivery),delivery.getStatus(), + DeliveryExecutionGuard.matches(delivery,outbound),delivery.getSignedAt()==null?null:instant(delivery.getSignedAt()))); + } + var result=completion.evaluate(context,expected,actual,Instant.now()); + if (saved!=null && !completeDocuments(context,saved,outbounds,repository.deliveries(context.dispatchId(),false))) { + var reasons=new ArrayList<>(result.blockReasons());reasons.add("本车出库及派送集合与原安排不一致"); + return new NormalDeliveryFacts(result.dispatchNo(),result.factsHash(),false,null,result.expectedDeliveryNos(),result.deliveries(),List.copyOf(reasons)); + } + return result; + } + + /** 集合、客户、父引用和每组原始箱数全部相等,不仅比较全车总数。 */ + private boolean completeDocuments(Context context,DispatchDeliveryArrangement arrangement,List outbounds,List deliveries) { + var expected=read(arrangement.getResultJson(),ArrangementResult.class); + if (outbounds.size()!=expected.outbounds().size() || deliveries.size()!=expected.deliveries().size()) return false; + for (var ref:expected.outbounds()) if (outbounds.stream().filter(o->Objects.equals(o.getCustomerId(),ref.customerId()) + && Objects.equals(o.getOutboundNo(),ref.outboundNo()) && Objects.equals(o.getDispatchOrderId(),context.dispatchId()) + && Objects.equals(o.getOverseasWarehouseId(),context.warehouseId()) && Objects.equals((long)Objects.requireNonNullElse(o.getPackageCount(),0),ref.packageCount())).count()!=1) return false; + for (var ref:expected.deliveries()) if (deliveries.stream().filter(d->Objects.equals(d.getCustomerId(),ref.customerId()) + && Objects.equals(d.getDeliveryNo(),ref.deliveryNo()) && Objects.equals(d.getOutboundNo(),ref.outboundNo()) + && Objects.equals(d.getDispatchOrderId(),context.dispatchId()) && Objects.equals(d.getOverseasWarehouseId(),context.warehouseId()) + && Objects.equals(d.getDeliveryGroupKey(),grouping.documentGroupKey(ref.destination())) + && Objects.equals((long)Objects.requireNonNullElse(d.getPackageCount(),0),ref.packageCount()) + && outbounds.stream().anyMatch(o->Objects.equals(o.getId(),d.getOutboundOrderId()) && Objects.equals(o.getCustomerId(),d.getCustomerId()))).count()!=1) return false; + return expected.cargoHash().equals(context.cargoHash()); + } + + /** 按原实物来源数字ID一次锁齐全部来源,随后绑定状态和历史。 */ + private void bindSources(Context context,List groups,Map outbounds,Map deliveries,Instant now) { + var sources=new TreeMap>(); + for (var row:context.cargo()) if(row.orderId()!=null)sources.computeIfAbsent(row.orderId(),ignored->new ArrayList<>()).add(row); + var locked=new TreeMap(); + for (var entry:sources.entrySet()) { + var customer=entry.getValue().get(0).customerId(); + if(entry.getValue().stream().anyMatch(c->!customer.equals(c.customerId())))throw conflict("同一来源订单出现不同客户"); + var source=repository.lockSource(customer,entry.getKey()); + if(source==null||!Objects.equals(source.getCustomerId(),customer)||Boolean.TRUE.equals(source.getDetained()))throw conflict("派送来源订单不存在、暂扣或归属错误"); + locked.put(entry.getKey(),source); + } + for (var entry:sources.entrySet()) { + var source=locked.get(entry.getKey());var outbound=outbounds.get(source.getCustomerId()); + var refs=groups.stream().filter(g->g.cargo().stream().anyMatch(c->Objects.equals(c.orderId(),entry.getKey()))) + .map(g->deliveries.get(g.key())).toList(); + repository.bindSource(source,outbound.getOutboundNo(),refs.size()==1?refs.get(0).getDeliveryNo():null,local(now)); + for(var delivery:refs) {var history=new LastlegOrderStatusHistory();history.setCustomerId(source.getCustomerId());history.setOrderId(source.getId()); + history.setFromStatus(source.getCurrentStatus());history.setToStatus(source.getCurrentStatus());history.setEventId("TRANSFER_DELIVERY:"+delivery.getDeliveryNo()); + history.setRemark("装车后按真实客户目的地安排派送");history.setCreatedAt(local(now));repository.sourceHistory(history);} + } + } + + /** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */ + private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) { + var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName()); + d.setDeliveryNo(numbers.next("DELIVERY",context.warehouseId(),d.getId()));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo()); + d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName()); + var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type()); + d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName()); + d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone()); + d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt())); + d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType()); + d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark()); + if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate()); + if(context.resources().driver()!=null){d.setDriverName(context.resources().driver().name());d.setDriverPhone(context.resources().driver().phone());}} + d.setPackageCount(Math.toIntExact(group.packages()));d.setPalletCount(Math.toIntExact(group.palletCount())); + d.setTotalWeightKg(metric(group.cargo(),CargoLine::grossWeightKg));d.setTotalVolumeCbm(metric(group.cargo(),CargoLine::volumeCbm)); + d.setIdempotencyKey(grouping.hash(List.of(key,group.key())));d.setCreatedBy(operator);d.setCreatedAt(local(now));d.setUpdatedAt(local(now));return d; + } + + /** 输出全部真实客户出库,不能留单个outboundNo别名。 */ + private List outboundRefs(Collection rows,List groups) { + return rows.stream().map(o->new OutboundReference(o.getCustomerId(),groups.stream().filter(g->g.customerId().equals(o.getCustomerId())) + .map(Group::customerName).filter(Objects::nonNull).findFirst().orElse(null),o.getOutboundNo(),o.getStatus(),Objects.requireNonNullElse(o.getPackageCount(),0),Objects.requireNonNullElse(o.getPalletCount(),0))).toList(); + } + /** 子单组身份由客户和目的地共同确定。 */ + private List deliveryRefs(Collection rows,List groups) { + return rows.stream().map(d->{var group=groups.stream().filter(g->g.customerId().equals(d.getCustomerId()) + && grouping.documentGroupKey(g.destination()).equals(d.getDeliveryGroupKey())).findFirst().orElse(null); + var dest=group==null?destination(d):group.destination();return new DeliveryReference(group==null?grouping.groupKey(d.getCustomerId(),dest):group.key(), + d.getCustomerId(),d.getCustomerName(),d.getOutboundNo(),d.getDeliveryNo(),dest,d.getStatus(),Objects.requireNonNullElse(d.getPackageCount(),0),Objects.requireNonNullElse(d.getPalletCount(),0));}).toList(); + } + /** 读取客户资料时只保留本客户来源与证据,原费用本身不复制为新来源。 */ + private DeliveryFeeCoverageModels.Evidence mergeCoverage(List rows,String operator) { + return new DeliveryFeeCoverageModels.Evidence(grouping.hash(rows.stream().map(DeliveryFeeCoverageModels.Evidence::cargoHash).toList()), + rows.stream().flatMap(e->e.cargo().stream()).toList(),rows.stream().flatMap(e->e.references().stream()).distinct().toList(),operator,Instant.now()); + } + /** 仅依据主流程阶段和真实核平显示资格,写入还需共同端口复验。 */ + private Gate arrangementGate(Context c) { + boolean allowed=c.status()==Status.ACTIVE&&c.stage()==Stage.AFTER_LOADING&&c.pickingReconciled()&&c.loadingCompletedAt()!=null; + return new Gate(allowed,allowed?null:"ARRANGEMENT_NOT_READY",allowed?null:"请先完成真实拣货核平及装车确认"); + } + /** 严格命令和业务摘要共用规范化集合,但原命令不移除版本和主体。 */ + private ArrangeDelivery canonical(ArrangeDelivery request) { + var inputs=new ArrayList(); + for(var g:request.groups()) { if(g==null||blank(g.groupKey()))throw conflict("派送分组身份不能为空"); + if(g.fees()!=null && (g.fees().size()>20||g.fees().stream().anyMatch(Objects::isNull)) + || g.feeCoverage()!=null && (g.feeCoverage().size()>2000||g.feeCoverage().stream().anyMatch(s->s==null||s.services()==null||s.services().stream().anyMatch(Objects::isNull)))) + throw conflict("派送费用或覆盖范围不合法"); + var selections=g.feeCoverage()==null?List.of():g.feeCoverage().stream() + .map(s->new CoverageSelection(s.financeFeeId(),s.sourceVersion(),s.sourceHash(),s.services().stream().sorted().toList())) + .sorted(Comparator.comparing(CoverageSelection::financeFeeId,Comparator.nullsFirst(Long::compareTo))).toList(); + var feeInputs=g.fees()==null?List.of():g.fees().stream().sorted(Comparator.comparing(FeeItem::direction,Comparator.nullsFirst(String::compareTo)) + .thenComparing(FeeItem::feeType,Comparator.nullsFirst(String::compareTo))).toList(); + inputs.add(new ArrangeGroup(g.groupKey(),g.groupCargoHash(),g.plannedDeliveryAt(),g.deliveryMethod(),g.waybillNo(),g.carrierName(),g.pickupMethod(), + g.loadingType(),g.vehicleType(),g.vehicleSize(),g.appointmentAccount(),g.sellerName(),g.remark(),g.externalFreightHash(),g.feeCoverageCargoHash(),selections,feeInputs));} + inputs.sort(Comparator.comparing(ArrangeGroup::groupKey)); + return new ArrangeDelivery(request.recordVersion(),request.idempotencyKey(),request.cargoHash(),request.loadingCompletionHash(),List.copyOf(inputs)); + } + /** 将持久安排还原为共同端口的完整不可变事实。 */ + private ArrangementFact fact(DispatchDeliveryArrangement saved) { return new ArrangementFact(saved.getArrangementKey(),saved.getBusinessHash(),saved.getCargoHash(), + saved.getLoadingCompletionHash(),read(saved.getResultJson(),ArrangementResult.class)); } + /** 始终使用原目的地业务数据,不用内部ID回退。 */ + private Destination destination(DeliveryOrder d) { return new Destination(d.getDeliveryGroupKey(),d.getDestinationType(),d.getDestinationWarehouseId(), + d.getDestinationWarehouseCode(),d.getDestinationWarehouseName(),d.getDeliveryAddress(),d.getPostalCode(),d.getContactName(),d.getContactPhone()); } + /** 未知物理量保持未知,不能补零或拿计费重量替代。 */ + private BigDecimal metric(List cargo,Function value) { return cargo.stream().anyMatch(c->value.apply(c)==null)?null: + cargo.stream().map(value).reduce(BigDecimal.ZERO,BigDecimal::add); } + /** 序列化不可变安排,不接受无法保存的半成品。 */ + private String write(Object value) { try{return json.writeValueAsString(value);}catch(JsonProcessingException e){throw new IllegalStateException("派送事实无法保存",e);} } + /** 损坏的持久事实阻断,不能回退创建另一套单据。 */ + private T read(String value,Class type) { try{return json.readValue(value,type);}catch(JsonProcessingException e){throw new IllegalStateException("派送事实无法读取",e);} } + /** 按既有服务端时区写本地单据时间。 */ + private static LocalDateTime local(Instant value) { return LocalDateTime.ofInstant(value,ZoneId.systemDefault()); } + /** 按既有服务端时区读取实际事件时间。 */ + private static Instant instant(LocalDateTime value) { return value.atZone(ZoneId.systemDefault()).toInstant(); } + /** 必要业务标识不允许空白。 */ + private static boolean blank(String value) { return value==null||value.isBlank(); } + /** 任一客户失败由外层事务回滚全部单据和费用。 */ + private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT,reason); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java new file mode 100644 index 000000000..8f27477dd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; + +/** 只从认证主体构造平台操作身份,不接收请求中的客户/操作者参数。 */ +public final class DispatchActorFactory { + /** 工具类不可实例化。 */ + private DispatchActorFactory() { } + + /** 验证平台技术范围,客户账号不能读取或安排整车。 */ + public static Actor platform(AuthenticatedUser user) { + if(user==null || user.tenantId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"请先登录"); + if(!"PLATFORM".equals(user.appCode()) || user.tenantId()!=0L) throw new BizException(ResultCode.FORBIDDEN,"仅平台可访问整车作业"); + String operator=user.userId()==null?user.username():user.userId().toString(); + if(operator==null || operator.isBlank()) throw new BizException(ResultCode.UNAUTHORIZED,"操作身份无效"); + return new Actor(operator,ActorMode.PLATFORM,null); + } + /** PDA仓和操作者仅由既有仓作业身份校验器取出。 */ + public static Actor pda(AuthenticatedUser user) { + var identity=com.baiyue.lastleg.pda.security.PdaWarehouseIdentity.require(user); + return new Actor(identity.operatorKey(),ActorMode.PDA,Long.toString(identity.overseasWarehouseId())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java new file mode 100644 index 000000000..ad2c82703 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -0,0 +1,144 @@ +package com.baiyue.lastleg.dispatch.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.CommandResult; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +/** 真整车建单/加货入口,接收共同DTO且不保留客户子排车协议。 */ +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches") +public class DispatchAggregateController { + private final DispatchAggregateService service; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure; + + /** 注入本域应用服务。 */ + public DispatchAggregateController(DispatchAggregateService service) { this.service=service; } + + /** 空单与先选货建单使用同一个正式入口。 */ + @PostMapping + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @RequestBody DispatchCommands.Create request) { + return ApiResponse.success(service.create(DispatchActorFactory.platform(user),request)); + } + + /** 主单原版本和来源摘要共同核对后加货。 */ + @PostMapping("/{dispatchNo}/items") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse add(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @RequestBody DispatchCommands.AddCargo request) { + return ApiResponse.success(service.addCargo(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 版本化移货只接受真实明细身份。 */ + @PostMapping("/{dispatchNo}/items/{itemId}/remove") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse remove(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable long itemId,@RequestBody DispatchCommands.ReasonCommand request) { + return ApiResponse.success(service.removeCargo(DispatchActorFactory.platform(user),dispatchNo,itemId,request)); + } + /** 取消与主单版本及原命令键一起核对。 */ + @PostMapping("/{dispatchNo}/cancel") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReasonCommand request) { + return ApiResponse.success(service.cancel(DispatchActorFactory.platform(user),dispatchNo,request)); + } + + /** 移货弹框读取真实明细及当前公共版本。 */ + @GetMapping("/{dispatchNo}/items") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 读取实际起点和卸货顺序。 */ + @GetMapping("/{dispatchNo}/route") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse route(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.route(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 调整既有目的地顺序,服务端保留真实名称地址和外接目的地。 */ + @PutMapping("/{dispatchNo}/route") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse route(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.RouteCommand request) { + return ApiResponse.success(service.reorderRoute(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 实载弹框只读取本车实际拣货和测量证据。 */ + @GetMapping("/{dispatchNo}/loading") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 显式确认装车,不复用最后扫码或实际发车操作。 */ + @PostMapping("/{dispatchNo}/loading-complete") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')") + public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { + return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */ + @PostMapping("/{dispatchNo}/depart") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) { + return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 本车证据先上传,主单引用随后走版本化编辑。 */ + @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) { + return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file)); + } + /** 文件元数据不会暴露存储路径。 */ + @GetMapping("/{dispatchNo}/evidence/{kind}/{fileId}") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@PathVariable long fileId) { + return ApiResponse.success(service.evidenceMetadata(DispatchActorFactory.platform(user),dispatchNo,kind,fileId)); + } + /** 每次内容读取重新核对本车范围,禁止匿名文件URL。 */ + @GetMapping("/{dispatchNo}/evidence/{kind}/{fileId}/content") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public org.springframework.http.ResponseEntity evidenceContent(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@PathVariable long fileId) { + var actor=DispatchActorFactory.platform(user);var metadata=service.evidenceMetadata(actor,dispatchNo,kind,fileId); + return org.springframework.http.ResponseEntity.ok().contentType(org.springframework.http.MediaType.parseMediaType(metadata.contentType())) + .cacheControl(org.springframework.http.CacheControl.noStore()).header(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION, + org.springframework.http.ContentDisposition.inline().filename(metadata.fileName(),java.nio.charset.StandardCharsets.UTF_8).build().toString()) + .body(service.evidenceContent(actor,dispatchNo,kind,fileId)); + } + + /** 提交本轮两级审核。 */ + @PostMapping("/{dispatchNo}/submit-review") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse submit(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.SubmitReview request) { + return ApiResponse.success(service.submitReview(DispatchActorFactory.platform(user),dispatchNo,request)); + } + /** 调度审核先于理货审核。 */ + @PostMapping("/{dispatchNo}/dispatch-review") + @PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')") + public ApiResponse dispatchReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReviewCommand request) { + return ApiResponse.success(service.review(DispatchActorFactory.platform(user),dispatchNo,false,request)); + } + /** 理货审核通过后才允许真实扫码。 */ + @PostMapping("/{dispatchNo}/loading-review") + @PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')") + public ApiResponse loadingReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReviewCommand request) { + return ApiResponse.success(service.review(DispatchActorFactory.platform(user),dispatchNo,true,request)); + } + /** 零扫码退回保留原整车编号。 */ + @PostMapping("/{dispatchNo}/return-to-review") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse returnToReview(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.ReturnToReview request) { + return ApiResponse.success(service.returnToReview(DispatchActorFactory.platform(user),dispatchNo,request)); + } + + /** 原动作权限在服务层按持久动作再次检查,列表权限不能恢复他人命令。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + @PreAuthorize("isAuthenticated()") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(service.command(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java index fe78e7aca..2d05acddd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java @@ -2,70 +2,33 @@ package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import com.baiyue.lastleg.dispatch.service.DispatchService; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; -import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; @RestController @RequestMapping("/api/platform/v1/last-leg/dispatches") @Tag(name = "Tail-leg vehicle dispatch") public class DispatchController { - @Resource private DispatchService service; + @Resource private com.baiyue.lastleg.dispatch.service.DispatchAggregateService aggregate; @PutMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") @Operation(summary = "Edit vehicle, driver and appointment data; route is derived from assigned FBA warehouses") - public ApiResponse update( + public ApiResponse update( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody DispatchRequests.Update request) { - requirePlatform(user); - return ApiResponse.success(service.update(dispatchNo, request)); + @Valid @RequestBody com.baiyue.lastleg.dispatch.port.DispatchCommands.Update request) { + return ApiResponse.success(aggregate.update(DispatchActorFactory.platform(user),dispatchNo,request)); } - @DeleteMapping("/{dispatchNo}/items/{itemId}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_EDIT + "')") - @Operation(summary = "Remove one unpicked pallet before an outbound order is generated") - public ApiResponse removeItem( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @PathVariable Long itemId) { - requirePlatform(user); - return ApiResponse.success(service.removeItem(dispatchNo, itemId)); - } - - @DeleteMapping("/{dispatchNo}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_CANCEL + "')") - @Operation(summary = "Cancel a pending dispatch and release its pallet locks") - public ApiResponse cancel( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo) { - requirePlatform(user); - service.cancel(dispatchNo); - return ApiResponse.success(null); - } - - private void requirePlatform(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - } - if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); - } - } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index feac81de7..536336a12 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -7,6 +7,8 @@ import com.baiyue.lastleg.dispatch.dto.DispatchRequests; import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.service.DispatchService; import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.dispatch.port.DispatchViews; import com.baiyue.lastleg.security.OverseasPermissions; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; @@ -34,27 +36,27 @@ import java.nio.charset.StandardCharsets; @RequestMapping("/api/platform/v1/last-leg/dispatches") @Tag(name = "Tail-leg vehicle dispatch query") public class DispatchQueryController { - @Resource private DispatchService service; + @Resource private DispatchAggregateService aggregateService; @Resource private DispatchBolPrintService printService; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @Operation(summary = "Page dispatch orders") - public ApiResponse> page( + public ApiResponse page( @AuthenticationPrincipal AuthenticatedUser user, - @Valid DispatchRequests.Query query) { + @Valid DispatchViews.Query query) { requirePlatform(user); - return ApiResponse.success(service.platformPage(query)); + return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query)); } @GetMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @Operation(summary = "Get dispatch details and all pallet destinations") - public ApiResponse detail( + public ApiResponse detail( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { requirePlatform(user); - return ApiResponse.success(service.platformDetail(dispatchNo)); + return ApiResponse.success(aggregateService.detail(DispatchActorFactory.platform(user),dispatchNo)); } @GetMapping(value = "/{dispatchNo}/prints/bol", produces = MediaType.APPLICATION_PDF_VALUE) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index b6239d6a4..17af0bea5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -2,154 +2,70 @@ package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningPort; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.security.OverseasPermissions; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; -import org.springframework.http.HttpStatus; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.server.ResponseStatusException; - +import org.springframework.web.bind.annotation.*; import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; +/** 草稿仅走冻结的服务器版本化端口,候选继续由主流程提供真实来源。 */ @RestController -@ConditionalOnProperty(name = "lastleg.smart-dispatch-api.enabled", havingValue = "true") +@ConditionalOnProperty(name="lastleg.smart-dispatch-api.enabled",havingValue="true") @RequestMapping("/api/platform/v1/last-leg/dispatches") -@Tag(name = "Tail-leg smart dispatch") public class SmartDispatchController { - @Resource private DispatchService service; + @Resource private DispatchPlanningPort planning; + @Resource private DispatchAggregateService aggregate; + /** 读取实际候选,不在草稿域复制来源筛选逻辑。 */ @GetMapping("/candidates") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_LIST + "')") - @Operation(summary = "List unlocked planned or received pallets for intelligent pre-dispatch", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_LIST) - public ApiResponse> candidates( - @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) List planIds, - @RequestParam(required = false) String orderNo, - @RequestParam(required = false) String fbaWarehouseCode, - @RequestParam(required = false) String sourceStage, - @RequestParam(required = false) String draftToken) { - requirePlatform(user); - if ("RELABEL".equals(sourceStage) || planIds == null || planIds.isEmpty()) { - return ApiResponse.success(service.platformCandidates(orderNo, fbaWarehouseCode, sourceStage, draftToken)); - } - return ApiResponse.success(service.candidates(service.resolveCustomerScopeForPlans(planIds), planIds, orderNo, - fbaWarehouseCode, sourceStage, draftToken)); + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") + public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId, + @RequestParam(required=false)List planIds,@RequestParam(required=false)String orderNo, + @RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) { + return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken))); } - + /** 只列出当前拥有者未失效草稿。 */ @GetMapping("/drafts") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse> drafts(@AuthenticationPrincipal AuthenticatedUser user) { - requirePlatform(user); return ApiResponse.success(service.activeDrafts(operatorId(user))); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") + public ApiResponse> drafts(@AuthenticationPrincipal AuthenticatedUser user){return ApiResponse.success(planning.drafts(DispatchActorFactory.platform(user)));} + /** 以持久命令键创建服务器token及零版本。 */ + @PostMapping("/drafts") + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse createDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody CreateDraft request){return ApiResponse.success(planning.createDraft(DispatchActorFactory.platform(user),request));} + /** 版本化布局保存只移动既有来源。 */ @PostMapping("/drafts/snapshot") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse saveDraft(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftSnapshot request) { - requirePlatform(user); service.saveDraftSnapshot(operatorId(user), request); return ApiResponse.success(null); - } - - @PostMapping("/pre-arrange") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Create a pending-departure dispatch and lock all selected pallets", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH) - public ApiResponse preArrange( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.PreArrange request) { - requirePlatform(user); - String scopedCustomer = request.relabelNo() == null || request.relabelNo().isBlank() - ? service.resolveCustomerScopeForSources(request.planPalletIds(), request.returnReceiptIds()) - : service.resolveCustomerScopeForRelabel(request.relabelNo()); - String operatorId = user.userId() == null ? user.username() : String.valueOf(user.userId()); - return ApiResponse.success(service.preArrange(scopedCustomer, operatorId, request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse saveDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSnapshot request){return ApiResponse.success(planning.saveSnapshot(DispatchActorFactory.platform(user),request));} + /** 一次转换全部容器,一容器一整车,不按客户拆头。 */ @PostMapping("/drafts/pre-arrange") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Atomically create one dispatch per loading container in an owned draft", - description = "Required authority: " + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH) - public ApiResponse> preArrangeDraft( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.PreArrangeDraft request) { - requirePlatform(user); - return ApiResponse.success(service.preArrangeDraft(operatorId(user), request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse preArrangeDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody ConvertDraft request){return ApiResponse.success(planning.convert(DispatchActorFactory.platform(user),request));} + /** 取得本草稿容器的真实来源排他占用。 */ @PostMapping("/draft-locks/lock") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Immediately lock one pallet in the current intelligent dispatch draft") - public ApiResponse lockDraftPallet( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftPallet request) { - requirePlatform(user); - String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); - service.lockDraftPallet(customerId, operatorId(user), request); - return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse lockDraftPallet(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSelection request){return ApiResponse.success(planning.select(DispatchActorFactory.platform(user),request));} + /** 新建容器返回同一草稿的新版本。 */ @PostMapping("/drafts/containers") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse createContainer(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftContainer request) { - requirePlatform(user); return ApiResponse.success(service.createDraftContainer(operatorId(user), request)); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse createContainer(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody CreateContainer request){return ApiResponse.success(planning.createContainer(DispatchActorFactory.platform(user),request));} + /** DELETE同样带公共版本、原因及命令键,且只允许空容器。 */ @DeleteMapping("/drafts/{draftToken}/containers/{containerId}") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - public ApiResponse deleteContainer(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String draftToken, @PathVariable Long containerId) { - requirePlatform(user); service.deleteDraftContainer(operatorId(user), draftToken, containerId); return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse deleteContainer(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String draftToken,@PathVariable Long containerId, + @Valid @RequestBody DispatchCommands.ReasonCommand request){return ApiResponse.success(planning.removeContainer(DispatchActorFactory.platform(user),draftToken,containerId,request));} + /** 解锁只作用于原草稿原容器及原来源身份。 */ @PostMapping("/draft-locks/unlock") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Unlock one pallet removed from the current intelligent dispatch draft") - public ApiResponse unlockDraftPallet( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftPallet request) { - requirePlatform(user); - String customerId = service.resolveCustomerScopeForSources(request.planPalletId() == null ? List.of() : List.of(request.planPalletId()), request.returnReceiptId() == null ? List.of() : List.of(request.returnReceiptId())); - service.unlockDraftPallet(customerId, operatorId(user), request); - return ApiResponse.success(null); - } - + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse unlockDraftPallet(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftSelection request){return ApiResponse.success(planning.unselect(DispatchActorFactory.platform(user),request));} + /** 释放本拥有者原版本草稿,不释放其他作业占用。 */ @PostMapping("/draft-locks/release") - @PreAuthorize("hasAuthority('" + OverseasPermissions.INBOUND_ORDER_SMART_DISPATCH + "')") - @Operation(summary = "Release every pallet lock held by the current intelligent dispatch draft") - public ApiResponse releaseDraft( - @AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody DispatchRequests.DraftRelease request) { - requirePlatform(user); - service.releaseDraft(operatorId(user), request.draftToken()); - return ApiResponse.success(null); - } - - private String operatorId(AuthenticatedUser user) { - return user.userId() == null ? user.username() : String.valueOf(user.userId()); - } - - private void requirePlatform(AuthenticatedUser user) { - if (user == null || user.tenantId() == null) { - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); - } - if (!"PLATFORM".equals(user.appCode()) || user.tenantId() != 0L) { - throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Platform application required"); - } - } + @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_CREATE+"')") + public ApiResponse releaseDraft(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody DraftRelease request){return ApiResponse.success(planning.release(DispatchActorFactory.platform(user),request));} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java new file mode 100644 index 000000000..0fc306e52 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.dispatch.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 真整车主单,不带客户头部;货物与来源各自保留真实客户。 */ +@Data +public class DispatchAggregate { + private Long id; + private String dispatchNo; + private Long tripId; + private String overseasWarehouseId; + private String overseasWarehouseCode; + private String overseasWarehouseName; + private String status; + private String stage; + private Long recordVersion; + private Long cargoRevision; + private String cargoHash; + private String reviewState; + private Long reviewRound; + private String reviewBasisHash; + private String dispatchReviewJson; + private String loadingReviewJson; + private String resourcesJson; + private String vehiclePlate; + private String route; + private LocalDateTime plannedDepartureAt; + private LocalDateTime estimatedArrivalAt; + private LocalDateTime loadingCompletedAt; + private String loadingCompletionHash; + private String loadingConfirmationJson; + private String arrangementKey; + private String arrangementBusinessHash; + private LocalDateTime departedAt; + private LocalDateTime deliveryCompletedAt; + private Integer itemCount; + private Integer palletCount; + private Integer packageCount; + private Integer pickedPackageCount; + private Integer shippedPackageCount; + private String remark; + private String createdBy; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java new file mode 100644 index 000000000..6e412e3da --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.dispatch.entity; + +import java.time.LocalDateTime; + +/** 全局请求键的不可变成功记录,客户归属不能作为键分片。 */ +public record DispatchCommandRecord(Long id, Long dispatchId, String dispatchNo, String action, + String operatorId, String idempotencyKey, long recordVersion, + String commandHash, String resultJson, LocalDateTime createdAt) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java index bf4ce5818..ffb2daa22 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java @@ -13,8 +13,10 @@ public class DispatchDraft { @TableId(type = IdType.INPUT) private Long id; private String draftToken; - private String customerId; private String operatorId; + private String warehouseId; + private Long recordVersion; + private String sourcesJson; private String planIdsJson; private String containersJson; private String status; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java new file mode 100644 index 000000000..effc6ae6b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.entity; + +import lombok.Data; +import java.time.LocalDateTime; + +/** 草稿命令不可变身份及原始公开结果,与主单命令账本分域持久化。 */ +@Data +public class DispatchDraftCommand { + private Long id; + private String idempotencyKey; + private String operatorId; + private String action; + private String targetToken; + private String requestHash; + private String resultJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java index 74599d6bc..e346b2067 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java @@ -15,6 +15,7 @@ public class DispatchDraftContainer { private String draftToken; private Integer sequenceNo; private Integer capacity; + private String sourceKeysJson; private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java index cb8d25f94..69c6ba311 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java @@ -16,6 +16,10 @@ public class DispatchItem { private Long id; private Long dispatchOrderId; private String customerId; + private String customerName; + private String sourceKey; + private String sourceVersion; + private String sourceSnapshotJson; private Long planId; private Long planPalletId; private Long returnReceiptId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java index 32e2e5b53..a5fd6f89b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java @@ -1,275 +1,151 @@ package com.baiyue.lastleg.dispatch.fleet; import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baiyue.lastleg.delivery.entity.DeliveryOrder; -import com.baiyue.lastleg.delivery.mapper.DeliveryOrderMapper; -import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.model.DeliveryCargoQuantities; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; -import com.baiyue.lastleg.fleet.trip.FleetCompletionModels; -import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import org.springframework.http.HttpStatus; +import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.fleet.exception.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.*; +import org.springframework.http.HttpStatus; import org.springframework.web.server.ResponseStatusException; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; -import java.time.LocalDateTime; -import java.time.Instant; -import java.time.ZoneId; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.util.HexFormat; -import java.util.Locale; -import java.util.Map; -import java.util.HashMap; -import java.util.Objects; - +/** Fleet附加能力读取完整整车,所有客户归属均来自真实明细及客户单据。 */ @Component -@Transactional(propagation = Propagation.MANDATORY) +@Transactional(propagation=Propagation.MANDATORY) public class DispatchFleetAdapter implements FleetDispatchGateway { - @jakarta.annotation.Resource - private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution; - @jakarta.annotation.Resource - private com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery cargoSources; - private final DispatchOrderMapper orders; - private final DispatchPdaTaskMapper tasks; + private final DispatchAggregateRepository aggregates; private final OutboundOrderMapper outbounds; private final DeliveryOrderMapper deliveries; - private final DeliveryOperationMapper deliveryOperations; - private final DispatchItemMapper items; - private final DispatchPdaPickMapper picks; - private final DispatchPdaWorkflowService workflow; - @jakarta.annotation.Resource @org.springframework.context.annotation.Lazy - private com.baiyue.lastleg.dispatch.service.DispatchService dispatchService; - - public DispatchFleetAdapter(DispatchOrderMapper orders, DispatchPdaTaskMapper tasks, - OutboundOrderMapper outbounds, DeliveryOrderMapper deliveries, DispatchItemMapper items, - DispatchPdaPickMapper picks, DispatchPdaWorkflowService workflow, DeliveryOperationMapper deliveryOperations) { - this.orders = orders; this.tasks = tasks; this.outbounds = outbounds; this.deliveries = deliveries; - this.items = items; this.picks = picks; this.workflow = workflow; - this.deliveryOperations = deliveryOperations; + private final DeliveryOperationMapper operations; + private final DispatchCargoSourceRepository sources; + private final DispatchAggregateService workflow; + private final ObjectMapper json; + public DispatchFleetAdapter(DispatchAggregateRepository aggregates,OutboundOrderMapper outbounds, + DeliveryOrderMapper deliveries,DeliveryOperationMapper operations,DispatchCargoSourceRepository sources, + @Lazy DispatchAggregateService workflow,ObjectMapper json) { + this.aggregates=aggregates;this.outbounds=outbounds;this.deliveries=deliveries;this.operations=operations; + this.sources=sources;this.workflow=workflow;this.json=json; } - - @Override - public void removeItem(String dispatchNo,long itemId) { dispatchService.removeItem(dispatchNo,itemId); } - - @Override - public Snapshot lockForPlanning(String dispatchNo) { - return lockBeforeDeparture(dispatchNo, false); + /** 批量只读主单当前审核,绑定表没有审核副本。 */ + @Override public Map workflows(List numbers) { + if(numbers.size()>1000)throw conflict("整车审核批量读取超过上限"); + Map result=new HashMap<>(); + for(var row:aggregates.findMany(numbers))result.put(row.getDispatchNo(),new Workflow(row.getStatus(),row.getStage(),row.getRecordVersion(),review(row.getDispatchReviewJson()),review(row.getLoadingReviewJson()))); + return Map.copyOf(result); } - - @Override - public Snapshot lockForAssignment(String dispatchNo) { - return lockBeforeDeparture(dispatchNo, true); + private FleetTripModels.ReviewState review(String stored) { + try {var value=json.readValue(stored,DispatchContracts.Review.class);return new FleetTripModels.ReviewState(value.status().name(),value.reason(),value.reviewerId(),value.reviewedAt(),null);} + catch(com.fasterxml.jackson.core.JsonProcessingException error){throw new IllegalStateException("整车审核事实读取失败",error);} } - - private Snapshot lockBeforeDeparture(String dispatchNo, boolean allowPicking) { - var dispatch = orders.lockGlobalByDispatchNo(dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var allowedStatuses = allowPicking ? java.util.Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART") : java.util.Set.of("PENDING_DEPARTURE"); - if (dispatch.getStatus() == null || !allowedStatuses.contains(dispatch.getStatus()) - || (!allowPicking && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) - || Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) - throw conflict("排车单 " + dispatchNo + " 已开始执行,不能修改车次关联或改派"); - // Keep the same dispatch -> outbound -> PDA task lock order as departure. - var outbound = outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()); - if (outbound != null && !"WAIT_OUTBOUND".equals(outbound.getStatus())) - throw conflict("排车单 " + dispatchNo + " 的出库状态不允许修改关联"); - var task = tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()); - var allowedTaskStatuses = allowPicking ? java.util.Set.of("PDA_PENDING", "PICKING", "COMPLETED") : java.util.Set.of("PDA_PENDING"); - if (task != null && (task.getStatus() == null || !allowedTaskStatuses.contains(task.getStatus()))) - throw conflict("排车单 " + dispatchNo + " 已开始拣货,不能修改车次关联或改派"); - if (task != null && !Objects.equals(task.getWarehouseId(), dispatch.getOverseasWarehouseId())) - throw conflict("拣货任务所属仓不一致"); - if (task == null && Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0) - throw conflict("排车单缺少已拣货的作业依据"); - if (dispatch.getPackageCount() == null || dispatch.getPackageCount() <= 0) - throw conflict("排车单 " + dispatchNo + " 没有待发运货物"); - return new Snapshot(dispatch.getId(), dispatch.getDispatchNo(), dispatch.getCustomerId(), dispatch.getOverseasWarehouseId(), dispatch.getPackageCount()); + /** 根锁已由上游trip/资源取得,禁止在已有扫码后以旧计划命令调整。 */ + @Override public Snapshot lockForPlanning(String number) { + var row=required(number,true); + if(!"ACTIVE".equals(row.getStatus()) || !"BEFORE_LOADING".equals(row.getStage()) || aggregates.realPicked(row.getId())!=0) + throw conflict("仅本车装车前且未扫码时可安排货物"); + return new Snapshot(row.getId(),row.getDispatchNo(),row.getOverseasWarehouseId(),row.getPackageCount()); } - - @Override - public void assign(Snapshot dispatch, Assignment assignment) { - var now = LocalDateTime.now(); - // Explicit SET is needed when unbinding: MyBatis entity updates omit null fields. - if (orders.update(null, new LambdaUpdateWrapper().eq(DispatchOrder::getId, dispatch.dispatchId()) - .eq(DispatchOrder::getCustomerId, dispatch.customerId()).in(DispatchOrder::getStatus, "PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART") - .set(DispatchOrder::getVehiclePlate, assignment.vehiclePlate()).set(DispatchOrder::getDriverName, assignment.driverName()) - .set(DispatchOrder::getDriverPhone, assignment.driverPhone()).set(DispatchOrder::getUpdatedAt, now)) != 1) - throw conflict("排车单已变化,请刷新后重试"); - var outbound = outbounds.lockByDispatch(dispatch.customerId(), dispatch.dispatchId()); - if (outbound == null) return; - for (var delivery : deliveries.lockByOutbound(dispatch.customerId(), outbound.getId())) { - if (!"WAIT_DELIVERY".equals(delivery.getStatus()) || delivery.getDispatchedAt() != null || delivery.getSignedAt() != null) - throw conflict("派送单 " + delivery.getDeliveryNo() + " 已执行,不能覆盖车辆司机"); - if (deliveries.update(null, new LambdaUpdateWrapper().eq(DeliveryOrder::getId, delivery.getId()) - .eq(DeliveryOrder::getCustomerId, dispatch.customerId()).eq(DeliveryOrder::getVersion, delivery.getVersion()) - .set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName()) - .set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, delivery.getVersion() + 1) - .set(DeliveryOrder::getUpdatedAt, now)) != 1) throw conflict("派送单已变化,请刷新后重试"); + @Override public List customers(String number) { + var row=required(number,false);return aggregates.cargo(row.getId()).stream().map(i->i.getCustomerId()).distinct().sorted(Comparator.comparingLong(Long::parseLong)).toList(); + } + /** 逐客户读取全部来源组成,不用主单或整托展示的一个客户替代。 */ + @Override public List sourceCargo(String number,String customer) { + var row=required(number,false);var items=aggregates.cargo(row.getId()).stream().filter(i->customer.equals(i.getCustomerId())).toList(); + var facts=sources.sources(customer,row.getId());var result=new ArrayList(); + for(var item:items) { + String destination=destination(item.getDestinationType(),item.getDestinationWarehouseCode(),item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()); + if(item.getReturnReceiptId()!=null || Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(),""))) { + result.add(new SourceCargo(item.getInboundCode(),item.getWarehousePalletId()==null?null:item.getWarehousePalletId().toString(),item.getPalletNo(),item.getPackageCount(),destination));continue; + } + var selected=facts.stream().filter(f->Objects.equals(f.dispatchItemId(),item.getId())).toList(); + if(selected.isEmpty() || selected.stream().mapToLong(f->f.packages()).sum()!=item.getPackageCount())throw conflict("本客户实际来源与排车箱数未核平"); + for(var fact:selected) { + if(fact.inboundCode()==null || fact.inboundCode().isBlank() || fact.packages()<=0 || !Objects.equals(fact.warehousePalletId(),item.getWarehousePalletId()))throw conflict("本客户来源缺少真实入仓码或实托"); + result.add(new SourceCargo(fact.inboundCode(),fact.warehousePalletId().toString(),fact.palletNo(),Math.toIntExact(fact.packages()),destination)); + } + } + return List.copyOf(result); + } + @Override public FleetExecutionModels.Manifest manifest(String number,boolean lock) { + var context=workflow.fleetContext(number,lock);var row=required(number,false); + var cargo=aggregates.cargo(row.getId()).stream().map(i->new FleetExecutionModels.Cargo(number,i.getCustomerId(),i.getId().toString(),i.getWarehousePalletId()==null?null:i.getWarehousePalletId().toString(),i.getPalletNo(),i.getPackageCount(), + destination(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),"PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():first(i.getDestinationWarehouseCode(),i.getFbaWarehouseCode()))).toList(); + String reason=!"ACTIVE".equals(row.getStatus())?"排车已取消":!Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(row.getStage())?"请先核平扫码并确认装车完成":!context.pickingReconciled()?"本车逐来源扫码未核平":null; + var vehicle=context.resources().vehicle();var driver=context.resources().driver(); + return new FleetExecutionModels.Manifest(number,row.getOverseasWarehouseId(),vehicle==null?null:vehicle.plate(),driver==null?null:driver.name(),driver==null?null:driver.phone(),cargo,reason); + } + /** 先收齐所有客户出库锁,再锁所有派送;客户和仓归属从每行复验。 */ + private Facts facts(String number,boolean lock) { + var row=required(number,lock); + var outboundRows=(lock?outbounds.lockAllByDispatch(row.getId()):outbounds.findAllByDispatch(row.getId())).stream().filter(o->!"CANCELLED".equals(o.getStatus())).toList(); + var deliveryRows=(lock?deliveries.lockAllByDispatch(row.getId()):deliveries.findAllByDispatch(row.getId())).stream().filter(d->!"CANCELLED".equals(d.getStatus())).toList(); + var expected=aggregates.cargo(row.getId()).stream().collect(Collectors.groupingBy(i->i.getCustomerId(),Collectors.summingLong(i->i.getPackageCount()))); + var byCustomer=new HashMap(); + for(var outbound:outboundRows)if(byCustomer.put(outbound.getCustomerId(),outbound)!=null || !Objects.equals(outbound.getDispatchOrderId(),row.getId()) || !Objects.equals(outbound.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || !"OUTBOUNDED".equals(outbound.getStatus()) || !Objects.equals(expected.get(outbound.getCustomerId()),Long.valueOf(outbound.getPackageCount())))throw conflict("客户出库归属或数量与本车发运不一致"); + if(!byCustomer.keySet().equals(expected.keySet()) || expected.values().stream().mapToLong(Long::longValue).sum()!=row.getShippedPackageCount())throw conflict("客户出库集合未完整覆盖本车实发货物"); + var counts=new HashMap();var numbers=new HashSet(); + for(var delivery:deliveryRows) { + var outbound=byCustomer.get(delivery.getCustomerId()); + if(outbound==null || !numbers.add(delivery.getDeliveryNo()) || !Objects.equals(delivery.getDispatchOrderId(),row.getId()) || !Objects.equals(delivery.getOutboundOrderId(),outbound.getId()) || !Objects.equals(delivery.getOverseasWarehouseId(),row.getOverseasWarehouseId()) || delivery.getPackageCount()==null || delivery.getPackageCount()<=0 || delivery.getVersion()==null)throw conflict("客户派送归属或数量与本车出库不一致"); + counts.merge(delivery.getCustomerId(),delivery.getPackageCount().longValue(),Math::addExact); + } + if(!counts.equals(expected))throw conflict("本车全部客户派送数量未核平"); + return new Facts(row,deliveryRows); + } + private record Facts(DispatchAggregate root,List deliveries) { } + @Override public FleetCompletionModels.DispatchCompletion completion(String number,boolean lock) { + var row=required(number,lock); + if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null)return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.of(),"本车实际发运尚未完成"); + var facts=facts(number,lock);var outcomes=new ArrayList(); + for(var delivery:facts.deliveries()) { + var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts); + Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null; + outcomes.add(new FleetCompletionModels.Outcome(number,delivery.getDeliveryNo(),delivery.getCustomerId(),"PRIVATE".equals(delivery.getDestinationType())?delivery.getDeliveryAddress():first(delivery.getDestinationWarehouseCode(),delivery.getDestinationWarehouseName()),originallySigned(delivery)?"DELIVERED":delivery.getStatus(),delivery.getPackageCount(),terminal,quantities.signed(),quantities.returned(),quantities.remaining())); + } + String reason=outcomes.stream().anyMatch(o->o.remainingPackages()!=0 || o.signedPackages()+o.returnedPackages()!=o.packages() || o.terminalAt()==null || !Set.of("DELIVERED","RETURNED").contains(o.status()))?"本车货物须实际签收或完成真实退回入仓":null; + return new FleetCompletionModels.DispatchCompletion(number,row.getOverseasWarehouseId(),List.copyOf(outcomes),reason); + } + @Override public FleetTransitEvidence transitEvidence(String number,boolean lock) { + var facts=facts(number,lock);var row=facts.root(); + if(!"ACTIVE".equals(row.getStatus()) || !Set.of("DELIVERING","DELIVERED").contains(row.getStage()) || row.getDepartedAt()==null || row.getShippedPackageCount()<=0)throw conflict("交接必须具有本车真实发运依据"); + var result=new ArrayList(); + for(var delivery:facts.deliveries()) { + if(!Set.of("DELIVERING","REJECTED","DELIVERED","RETURN_PENDING","RETURNED").contains(Objects.toString(delivery.getStatus(),"")))throw conflict("本车客户派送状态与实际发运不一致"); + var receipts=operations.returnReceipts(delivery.getCustomerId(),delivery.getId());var quantities=quantities(delivery,receipts); + Instant terminal=originallySigned(delivery)?instant(delivery.getSignedAt()):"RETURNED".equals(delivery.getStatus())?instant(operations.returnedAt(delivery.getCustomerId(),delivery.getId())):null; + if(("DELIVERED".equals(delivery.getStatus()) || "RETURNED".equals(delivery.getStatus())) && (terminal==null || quantities.remaining()!=0))throw conflict("派送终态缺少完整真实签收或退回证据"); + String key=destination(delivery.getDestinationType(),delivery.getDestinationWarehouseCode(),null,delivery.getDeliveryAddress(),delivery.getPostalCode());if(key==null)throw conflict("派送缺少真实卸货目的地"); + var cargo=new FleetHandoverPolicy.Delivery(delivery.getCustomerId(),number,delivery.getDeliveryNo(),key,delivery.getPackageCount(),terminal,originallySigned(delivery)?List.of():receipts.stream().map(r->{if(r.dispatchItemId()==null || r.palletId()==null)throw conflict("实收退回缺少原明细和实托");return new FleetHandoverPolicy.ReturnedCargo(r.dispatchItemId().toString(),r.palletId().toString(),Math.toIntExact(r.packages()),instant(r.receivedAt()));}).toList()); + result.add(new FleetTransitEvidence.Delivery(cargo,delivery.getStatus(),delivery.getVersion(),new FleetTransitEvidence.Transport(delivery.getVehiclePlate(),delivery.getDriverName(),delivery.getDriverPhone()))); + } + return new FleetTransitEvidence(number,row.getOverseasWarehouseId(),row.getShippedPackageCount(),List.copyOf(result)); + } + @Override public void handover(FleetTransitEvidence evidence,Assignment assignment) { + var current=transitEvidence(evidence.dispatchNo(),true);if(!current.equals(evidence))throw conflict("本车交接事实已变化,请重新核对"); + for(var fact:evidence.deliveries()) { + if(fact.cargo().terminalAt()!=null)continue; + var row=deliveries.lockByNo(fact.cargo().customerId(),fact.cargo().deliveryNo()); + if(row==null || !Objects.equals(row.getVersion(),fact.recordVersion()) || row.getSignedAt()!=null)throw conflict("本客户派送已变化"); + if(deliveries.update(null,new LambdaUpdateWrapper().eq(DeliveryOrder::getId,row.getId()).eq(DeliveryOrder::getCustomerId,fact.cargo().customerId()).eq(DeliveryOrder::getVersion,fact.recordVersion()).eq(DeliveryOrder::getStatus,fact.status()).isNull(DeliveryOrder::getSignedAt).set(DeliveryOrder::getVehiclePlate,assignment.vehiclePlate()).set(DeliveryOrder::getDriverName,assignment.driverName()).set(DeliveryOrder::getDriverPhone,assignment.driverPhone()).set(DeliveryOrder::getVersion,fact.recordVersion()+1).set(DeliveryOrder::getUpdatedAt,LocalDateTime.now()))!=1)throw conflict("派送交接保存失败"); } } - - @Override - public FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock) { - var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); - var task = lock ? tasks.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : tasks.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); - var selected = items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()); - Map picked = new HashMap<>(); - if (task != null) for (var pick : picks.listTask(dispatch.getCustomerId(), task.getId())) picked.merge(pick.getDispatchItemId(), pick.getPackages(), Math::addExact); - var cargo = selected.stream().map(item -> new FleetExecutionModels.Cargo(dispatchNo, dispatch.getCustomerId(), item.getId().toString(), - item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), - destination(item.getDestinationType(), item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()), - "PRIVATE".equals(item.getDestinationType()) ? item.getDeliveryAddress() : first(item.getDestinationWarehouseCode(), item.getFbaWarehouseCode()))).toList(); - String reason = null; - if (!"READY_TO_DEPART".equals(dispatch.getStatus()) || task == null || !"COMPLETED".equals(task.getStatus())) reason = "请完成拣货并核平全部箱数"; - else if (!Objects.equals(dispatch.getOverseasWarehouseId(), task.getWarehouseId())) reason = "拣货任务所属仓不一致"; - else if (selected.isEmpty() || selected.stream().anyMatch(item -> item.getPackageCount() == null || item.getPackageCount() <= 0 - || picked.getOrDefault(item.getId(), 0L).longValue() != item.getPackageCount().longValue())) reason = "每条货物须与实际拣货箱数核平"; - else if (!picked.keySet().equals(selected.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchItem::getId).collect(java.util.stream.Collectors.toSet()))) reason = "拣货记录包含不属于当前发运的货物"; - else if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) reason = "请先转派送并生成待出库单"; - else if (cargo.stream().anyMatch(item -> item.warehousePalletId() == null || item.palletNo() == null || item.destinationKey() == null)) reason = "实物托盘或目的地未确认"; - if (reason == null) reason = deliveryExecution.departureBlockReason(outbound, lock); - return new FleetExecutionModels.Manifest(dispatchNo, dispatch.getOverseasWarehouseId(), dispatch.getVehiclePlate(), dispatch.getDriverName(), dispatch.getDriverPhone(), cargo, reason); - } - - @Override - public FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator, - String idempotencyKey, String remark, Instant departedAt) { - var result = workflow.depart(warehouseId, dispatchNo, operator, new DispatchPdaRequests.Depart(idempotencyKey, remark), - LocalDateTime.ofInstant(departedAt, ZoneId.systemDefault())); - return new FleetExecutionModels.DepartedDispatch(result.dispatchNo(), result.shippedPackages()); - } - - private static String destination(String type, String code, String fba, String address, String postalCode) { - return FleetDispatchGateway.destinationKey(type,code,fba,address,postalCode); - } - /** 用逐入仓组成核对外接承诺,不使用整托展示来源。 */ - @Override public java.util.List sourceCargo(String dispatchNo) { - var dispatch=orders.findGlobalByDispatchNo(dispatchNo); - if(dispatch==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在"); - return cargoSources.sources(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(),dispatch.getId())); - } - @Override - public FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock) { - var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); - var rows = outbound == null ? java.util.List.of() : lock - ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId()); - if (outbound != null && (rows.stream().mapToLong(row -> Objects.requireNonNullElse(row.getPackageCount(), 0)).sum() - != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount()))) - return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), java.util.List.of(), "派送单数量未覆盖本次全部实发货物"); - var outcomes = rows.stream().map(row -> { - var quantities = quantities(row, deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId())); - return new FleetCompletionModels.Outcome(dispatchNo, row.getDeliveryNo(), dispatch.getCustomerId(), - "PRIVATE".equals(row.getDestinationType()) ? row.getDeliveryAddress() : first(row.getDestinationWarehouseCode(), row.getDestinationWarehouseName()), - originallySigned(row) ? "DELIVERED" : row.getStatus(), Objects.requireNonNullElse(row.getPackageCount(), 0), - originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) - ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null, - quantities.signed(), quantities.returned(), quantities.remaining()); - }).toList(); - String reason = null; - if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus())) reason = "排车出库尚未完成"; - else if (outcomes.isEmpty()) reason = "缺少对应派送单"; - else if (outcomes.stream().mapToLong(FleetCompletionModels.Outcome::packages).sum() != Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) - || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) reason = "派送单数量未覆盖本次全部实发货物"; - else if (outcomes.stream().anyMatch(row -> row.packages() <= 0 || row.remainingPackages() != 0 - || row.signedPackages() + row.returnedPackages() != row.packages() - || !java.util.Set.of("DELIVERED", "RETURNED").contains(row.status()) || row.terminalAt() == null)) - reason = "所有派送单须已签收,或完成真实退回入仓"; - return new FleetCompletionModels.DispatchCompletion(dispatchNo, dispatch.getOverseasWarehouseId(), outcomes, reason); - } - // Existing delivery commands persist LocalDateTime.now(); convert in that server zone, never the browser zone. - @Override - public com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock) { - var dispatch = lock ? orders.lockGlobalByDispatchNo(dispatchNo) : orders.findGlobalByDispatchNo(dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "排车单不存在"); - var outbound = lock ? outbounds.lockByDispatch(dispatch.getCustomerId(), dispatch.getId()) - : outbounds.findByDispatch(dispatch.getCustomerId(), dispatch.getId()); - if (!"DEPARTED".equals(dispatch.getStatus()) || outbound == null || !"OUTBOUNDED".equals(outbound.getStatus()) - || dispatch.getShippedPackageCount() == null || dispatch.getShippedPackageCount() <= 0 - || !Objects.equals(dispatch.getShippedPackageCount(), outbound.getPackageCount())) - throw conflict("交接必须具有已完成出库的真实发运依据"); - var rows = lock ? deliveries.lockByOutbound(dispatch.getCustomerId(), outbound.getId()) - : deliveries.findByOutbound(dispatch.getCustomerId(), outbound.getId()); - var facts = new java.util.ArrayList(); - long packages = 0; - for (var row : rows) { - if (!Objects.equals(row.getCustomerId(), dispatch.getCustomerId()) - || !Objects.equals(row.getDispatchOrderId(), dispatch.getId()) - || !Objects.equals(row.getOutboundOrderId(), outbound.getId()) - || !Objects.equals(row.getOverseasWarehouseId(), dispatch.getOverseasWarehouseId()) - || row.getPackageCount() == null || row.getPackageCount() <= 0 || row.getVersion() == null - || row.getStatus() == null || !java.util.Set.of("WAIT_DELIVERY", "DELIVERING", "REJECTED", "DELIVERED", "RETURN_PENDING", "RETURNED").contains(row.getStatus())) - throw conflict("派送货物归属或执行状态与原始发运不一致"); - Instant terminal = originallySigned(row) ? instant(row.getSignedAt()) : "RETURNED".equals(row.getStatus()) - ? instant(deliveryOperations.returnedAt(dispatch.getCustomerId(), row.getId())) : null; - if (("DELIVERED".equals(row.getStatus()) || "RETURNED".equals(row.getStatus())) && terminal == null) - throw conflict("派送终态缺少真实签收或退回完成证据"); - var receipts = deliveryOperations.returnReceipts(dispatch.getCustomerId(), row.getId()); - var quantities = quantities(row, receipts); - if (terminal != null && quantities.remaining() != 0) throw conflict("派送终态数量未覆盖原实发货物"); - String key = destination(row.getDestinationType(), row.getDestinationWarehouseCode(), null, - row.getDeliveryAddress(), row.getPostalCode()); - if (key == null) throw conflict("派送单缺少真实卸货目的地"); - var cargo = new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.Delivery(dispatch.getCustomerId(), - dispatchNo, row.getDeliveryNo(), key, row.getPackageCount(), terminal, - originallySigned(row) ? java.util.List.of() : receipts.stream().map(receipt -> { - if (receipt.dispatchItemId() == null || receipt.palletId() == null) throw conflict("实际退回缺少原发运实托明细归属"); - return new com.baiyue.lastleg.fleet.exception.FleetHandoverPolicy.ReturnedCargo( - receipt.dispatchItemId().toString(), receipt.palletId().toString(), Math.toIntExact(receipt.packages()), instant(receipt.receivedAt())); - }).toList()); - facts.add(new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Delivery(cargo, row.getStatus(), row.getVersion(), - new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence.Transport(row.getVehiclePlate(), row.getDriverName(), row.getDriverPhone()))); - packages = Math.addExact(packages, row.getPackageCount()); - } - if (facts.isEmpty() || packages != dispatch.getShippedPackageCount()) - throw conflict("派送明细未核平本次实发货物"); - return new com.baiyue.lastleg.fleet.exception.FleetTransitEvidence(dispatchNo, dispatch.getCustomerId(), - dispatch.getOverseasWarehouseId(), dispatch.getShippedPackageCount(), java.util.List.copyOf(facts)); - } - - @Override - public void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment) { - var current = transitEvidence(evidence.dispatchNo(), true); - if (!current.equals(evidence)) throw conflict("交接货物已变化,请重新核对"); - for (var fact : evidence.deliveries()) { - if (fact.cargo().terminalAt() != null) continue; - var row = deliveries.lockByNo(evidence.customerId(), fact.cargo().deliveryNo()); - if (row == null || !Objects.equals(row.getVersion(), fact.recordVersion()) || row.getSignedAt() != null) - throw conflict("派送货物已变化,请重新核对交接"); - if (deliveries.update(null, new LambdaUpdateWrapper() - .eq(DeliveryOrder::getId, row.getId()).eq(DeliveryOrder::getCustomerId, evidence.customerId()) - .eq(DeliveryOrder::getVersion, fact.recordVersion()).eq(DeliveryOrder::getStatus, fact.status()) - .isNull(DeliveryOrder::getSignedAt) - .set(DeliveryOrder::getVehiclePlate, assignment.vehiclePlate()).set(DeliveryOrder::getDriverName, assignment.driverName()) - .set(DeliveryOrder::getDriverPhone, assignment.driverPhone()).set(DeliveryOrder::getVersion, fact.recordVersion() + 1) - .set(DeliveryOrder::getUpdatedAt, LocalDateTime.now())) != 1) - throw conflict("派送交接保存失败,请刷新核对"); - } - } - - private static Instant instant(LocalDateTime value) { return value == null ? null : value.atZone(ZoneId.systemDefault()).toInstant(); } - private static DeliveryCargoQuantities quantities(DeliveryOrder row, java.util.List receipts) { - try { return DeliveryCargoQuantities.from(row, receipts); } - catch (IllegalArgumentException error) { throw conflict(error.getMessage()); } - } - private static boolean originallySigned(DeliveryOrder order) { - return order.getSignedAt() != null && java.util.Set.of("DELIVERED", "RETURN_PENDING", "RETURNED").contains(order.getStatus()); - } - private static String first(String a, String b) { return a != null && !a.isBlank() ? a : b == null || b.isBlank() ? null : b; } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } + private DispatchAggregate required(String number,boolean lock) {var row=lock?aggregates.lock(number):aggregates.find(number);if(row==null)throw new ResponseStatusException(HttpStatus.NOT_FOUND,"排车单不存在");return row;} + private static String destination(String type,String code,String fba,String address,String postal){return FleetDispatchGateway.destinationKey(type,code,fba,address,postal);} + /** 派送签收/退回生产者保存服务器本地LocalDateTime,按原提供者约定还原真实时刻。 */ + private static Instant instant(LocalDateTime value){return value==null?null:value.atZone(ZoneId.systemDefault()).toInstant();} + private static String first(String a,String b){return a!=null&&!a.isBlank()?a:b==null||b.isBlank()?null:b;} + private static boolean originallySigned(DeliveryOrder row){return row.getSignedAt()!=null && Set.of("DELIVERED","RETURN_PENDING","RETURNED").contains(row.getStatus());} + private static DeliveryCargoQuantities quantities(DeliveryOrder row,List receipts){try{return DeliveryCargoQuantities.from(row,receipts);}catch(IllegalArgumentException error){throw conflict(error.getMessage());}} + private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java index 61dcbe5ed..0d504905e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java @@ -1,58 +1,52 @@ package com.baiyue.lastleg.dispatch.fleet; - -import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; import com.baiyue.lastleg.dispatch.service.DispatchService; -import com.baiyue.lastleg.fleet.trip.FleetBookingIncomingGateway; -import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.trip.*; import org.springframework.context.annotation.Lazy; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.*; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.server.ResponseStatusException; import org.springframework.http.HttpStatus; import java.util.*; - +/** 外接预订绑定真实客户来源,整车身份由当前车次唯一关联解析。 */ @Component @Transactional(propagation=Propagation.MANDATORY) public class FleetDispatchPlanningAdapter implements FleetDispatchPlanningGateway { - private final FleetDispatchPlanningMapper mapper; - private final DispatchService dispatches; - private final FleetDispatchGateway cargo; - public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,@Lazy DispatchService dispatches,FleetDispatchGateway cargo) { - this.mapper=mapper;this.dispatches=dispatches;this.cargo=cargo; + private final FleetDispatchPlanningMapper mapper;private final DispatchSourceRepository sources; + private final DispatchService rules;private final FleetDispatchGateway cargo;private final DispatchAggregateService workflow; + public FleetDispatchPlanningAdapter(FleetDispatchPlanningMapper mapper,DispatchSourceRepository sources,@Lazy DispatchService rules, + FleetDispatchGateway cargo,@Lazy DispatchAggregateService workflow){this.mapper=mapper;this.sources=sources;this.rules=rules;this.cargo=cargo;this.workflow=workflow;} + @Override public String resolveIncoming(String tripNo,String customer,FleetBookingIncomingGateway.Evidence receipt,String operator,String key) { + if(receipt==null || receipt.inboundCode()==null || !"READY_FOR_DISPATCH".equals(receipt.status()) || receipt.pallets().isEmpty())throw conflict("请先完成真实收货和实收核对"); + String number=mapper.dispatchForTrip(tripNo);if(number==null)throw conflict("本车缺少唯一整车主单"); + var authentication=SecurityContextHolder.getContext().getAuthentication(); + var actor=DispatchActorFactory.platform(authentication!=null && authentication.getPrincipal() instanceof AuthenticatedUser user?user:null); + if(!Objects.equals(actor.operatorId(),operator))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"外接安排操作者不一致"); + var context=workflow.lockForArrangement(actor,number);var selected=new HashSet<>(mapper.selectedPlans(context.dispatchId(),customer)); + var additions=new ArrayList(); + for(var id:mapper.receivedPlanPallets(customer,receipt.inboundCode())) { + if(selected.contains(id))continue;var candidates=sources.selectedPlan(customer,id); + if(candidates.size()!=1)throw conflict("外接实收来源已变化,请重新核对");var candidate=candidates.get(0); + if(!Objects.equals(candidate.overseasWarehouseId(),context.warehouseId()))throw conflict("外接来源起运仓不一致"); + additions.add(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,id,null,null,null,rules.wholeSourceVersion(candidate,"PLAN:"+id,false))); + } + if(!additions.isEmpty())workflow.addCargo(actor,number,new DispatchCommands.AddCargo(context.recordVersion(),key,additions,List.of())); + validateIncomingDispatch(number,customer,receipt);return number; } - public PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size) { - var result=mapper.candidates(new com.baomidou.mybatisplus.extension.plugins.pagination.Page<>(page,size),tripNo,warehouseId,keyword); - return new PageResult<>(result.getRecords(),result.getCurrent(),result.getSize(),result.getTotal(),result.getPages()); - } - public String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator) { - if(receipt==null||receipt.inboundCode()==null||!"READY_FOR_DISPATCH".equals(receipt.status())||receipt.pallets().isEmpty()) - throw conflict("请先完成真实收货和实收核对"); - var existing=mapper.sourceDispatches(customerId,receipt.inboundCode()); - if(existing.size()>1)throw conflict("本次来货已有多张排车安排,请调度核查:"+String.join("、",existing)); - String number; - if(existing.isEmpty()) { - var ids=mapper.receivedPlanPallets(customerId,receipt.inboundCode()); - if(ids.isEmpty())throw conflict("实收来源没有可用于排车的真实托盘"); - number=dispatches.preArrange(customerId,operator,new DispatchRequests.PreArrange(ids,null,null,null,null)).dispatchNo(); - } else number=existing.get(0); - validateIncomingDispatch(number,customerId,receipt); - return number; - } - public void validateIncomingDispatch(String number,String customerId,FleetBookingIncomingGateway.Evidence receipt) { - var snapshot=cargo.lockForPlanning(number); - if(!customerId.equals(snapshot.customerId())||!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("排车客户或起运仓与来货不一致"); - var expected=new TreeMap(); - for(var p:receipt.pallets())expected.merge(p.warehousePalletId(),p.packages(),Math::addExact); + @Override public void validateIncomingDispatch(String number,String customer,FleetBookingIncomingGateway.Evidence receipt) { + var snapshot=cargo.lockForPlanning(number);if(!receipt.warehouseId().equals(snapshot.warehouseId()))throw conflict("整车起运仓与外接实收不一致"); + var expected=new TreeMap();for(var pallet:receipt.pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact); var selected=new TreeMap(); - for(var item:cargo.sourceCargo(number)) { - if(!Objects.equals(receipt.inboundCode(),item.inboundCode())||!Objects.equals(receipt.destinationKey(),item.destinationKey())||item.warehousePalletId()==null) - throw conflict("候选排车包含其他来源或目的地,请调度核查 "+number); + for(var item:cargo.sourceCargo(number,customer))if(Objects.equals(receipt.inboundCode(),item.inboundCode())) { + if(item.warehousePalletId()==null || !Objects.equals(receipt.destinationKey(),item.destinationKey()))throw conflict("本客户外接实收托盘或目的地不一致"); selected.merge(item.warehousePalletId(),item.packages(),Math::addExact); } - if(!expected.equals(selected))throw conflict("候选排车未完整覆盖本票全部实收货物,请调度核查 "+number); + if(!expected.equals(selected))throw conflict("本车未完整覆盖本客户本票外接实收来源"); } - private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} + private static ResponseStatusException conflict(String reason){return new ResponseStatusException(HttpStatus.CONFLICT,reason);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java index 60f01d757..28f587908 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java @@ -1,28 +1,13 @@ package com.baiyue.lastleg.dispatch.fleet; - -import com.baomidou.mybatisplus.core.metadata.IPage; -import com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway; import org.apache.ibatis.annotations.*; import java.util.List; - +/** 外接实收仅加入本车真实主单,不生成客户子排车。 */ @Mapper public interface FleetDispatchPlanningMapper { - @Select({""}) - IPage candidates(IPage page,@Param("tripNo") String tripNo,@Param("warehouseId") String warehouseId,@Param("keyword") String keyword); - - @Select("SELECT DISTINCT d.dispatch_no FROM ll_dispatch_order d JOIN ll_dispatch_item i ON i.dispatch_order_id=d.id AND i.customer_id=d.customer_id " + - "WHERE d.customer_id=#{customerId} AND i.inbound_code=#{inboundCode} AND d.status!='CANCELLED' ORDER BY d.dispatch_no") - List sourceDispatches(@Param("customerId") String customerId,@Param("inboundCode") String inboundCode); - + @Select("SELECT dispatch_no FROM ll_fleet_trip_dispatch WHERE trip_no=#{tripNo}") + String dispatchForTrip(@Param("tripNo") String tripNo); + @Select("SELECT DISTINCT plan_pallet_id FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id IS NOT NULL") + List selectedPlans(@Param("dispatch") long dispatch,@Param("customer") String customer); @Select("SELECT DISTINCT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id " + "JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id " + "WHERE ip.customer_id=#{customerId} AND l.inbound_code=#{inboundCode} AND p.warehouse_pallet_id IS NOT NULL ORDER BY p.id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java new file mode 100644 index 000000000..bedd86d03 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.dispatch.gateway; + +import org.springframework.web.multipart.MultipartFile; + +/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */ +public interface DispatchEvidenceGateway { + enum Kind { VEHICLE, DRIVER } + record File(Long fileId,String fileName,String contentType,long sizeBytes) { } + /** 上传只写本车、本类证据。 */ + File upload(long dispatchId,Kind kind,String operator,MultipartFile file); + /** 审核前逐文件复验平台、本车与类型复合归属。 */ + File metadata(long dispatchId,Kind kind,long fileId); + /** 下载仍通过本车读权限入口。 */ + byte[] download(long dispatchId,Kind kind,long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java new file mode 100644 index 000000000..9f2516f8e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java @@ -0,0 +1,96 @@ +package com.baiyue.lastleg.dispatch.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; + +/** 本域只调用File内部通用业务文件入口,不读取File表或返回存储地址。 */ +@Component +public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { + private static final long MAX=10L*1024*1024; + private static final Set TYPES=Set.of("application/pdf","image/jpeg","image/png"); + private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}"; + private final RestClient client; + private final String token; + /** 复用既有内部客户端超时与身份配置,不新增环境地址。 */ + public HttpDispatchEvidenceGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}")String url,@Value("${baiyue.security.internal-service-token:}")String token) { + client=factory.build(builder,url,properties.getCustomerVisitFile());this.token=token; + } + /** 类型由枚举生成,禁止请求指定任意File业务域。 */ + private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; } + /** 本车证据上传完成后仍以File返回元数据核对复合归属。 */ + @Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) { + if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid(); + var body=new MultipartBodyBuilder(); + try { body.part("file",new ByteArrayResource(file.getBytes()) { + /** 保留用户文件名供审阅,不把路径当业务事实。 */ + @Override public String getFilename(){return file.getOriginalFilename();} + }).contentType(MediaType.parseMediaType(file.getContentType())); }catch(java.io.IOException error){throw invalid();} + var result=client.post().uri(PATH,type(kind),dispatch).header("X-Internal-Service-Token",token).header("X-Operator-Id",operator) + .contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().onStatus(HttpStatusCode::isError,(q,r)->{throw unavailable(r.getStatusCode());}).body(Envelope.class); + return verified(result,dispatch,kind,null); + } + /** 不接受其他车、其他证据类型或失效文件。 */ + @Override public File metadata(long dispatch,Kind kind,long id) { + var result=client.get().uri(PATH+"/{file}",type(kind),dispatch,id).header("X-Internal-Service-Token",token).retrieve() + .onStatus(HttpStatusCode::isError,(q,r)->{throw unavailable(r.getStatusCode());}).body(Envelope.class); + return verified(result,dispatch,kind,id); + } + /** 先核对元数据再读取有界内容。 */ + @Override public byte[] download(long dispatch,Kind kind,long id) { + var expected=metadata(dispatch,kind,id); + return client.get().uri(PATH+"/{file}/content",type(kind),dispatch,id).header("X-Internal-Service-Token",token).exchange((request,response)->{ + if(response.getStatusCode().isError())throw unavailable(response.getStatusCode()); + // 只读取上限加一,防止损坏上游响应绕过已核实的10MiB元数据上限。 + var bytes=response.getBody().readNBytes((int)MAX+1); + return verifiedContent(expected,response.getHeaders().getContentType(),response.getHeaders().getContentLength(),bytes); + }); + } + /** HTTP200业务错误JSON不能伪装成PDF或图片;MIME、长度及签名必须与元数据一致。 */ + static byte[] verifiedContent(File expected,MediaType contentType,long contentLength,byte[] bytes) { + if(bytes==null || bytes.length==0 || bytes.length>MAX)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + if(contentType!=null && (MediaType.APPLICATION_JSON.isCompatibleWith(contentType) || contentType.getSubtype().endsWith("+json"))) { + String code=null;try{code=new com.fasterxml.jackson.databind.ObjectMapper().readTree(bytes).path("code").asText(null);}catch(java.io.IOException ignored){/* 无法识别的上游错误响应按故障处理。 */} + throw businessFailure(code); + } + if(bytes.length!=expected.sizeBytes() || contentLength>=0 && contentLength!=bytes.length + || contentType==null || !MediaType.parseMediaType(expected.contentType()).isCompatibleWith(contentType))throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + boolean signature=switch(expected.contentType()) { + case "application/pdf" -> bytes.length>=5 && new String(bytes,0,5,java.nio.charset.StandardCharsets.US_ASCII).equals("%PDF-"); + case "image/png" -> bytes.length>=4 && (bytes[0]&255)==0x89 && bytes[1]==0x50 && bytes[2]==0x4e && bytes[3]==0x47; + case "image/jpeg" -> bytes.length>=2 && (bytes[0]&255)==0xff && (bytes[1]&255)==0xd8; + default -> false; + }; + if(!signature)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE);return bytes; + } + /** File只能证明本车文件事实,审核决策仍属于本域。 */ + static File verified(Envelope envelope,long dispatch,Kind kind,Long id) { + if(envelope==null)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + if(!"200".equals(envelope.code()))throw businessFailure(envelope.code()); + if(envelope.data()==null)throw unavailable(HttpStatus.SERVICE_UNAVAILABLE); + var value=envelope.data(); + if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId()) + || value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank() + || !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid(); + return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes()); + } + /** 无效证据不能作为已核实资料。 */ + private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} + /** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */ + private static ResponseStatusException businessFailure(String code){return code!=null && code.startsWith("A04") + ?new ResponseStatusException("A0404".equals(code)?HttpStatus.NOT_FOUND:HttpStatus.BAD_REQUEST,"证据文件不可用或归属核验失败") + :unavailable(HttpStatus.SERVICE_UNAVAILABLE);} + /** 上游不可用与业务文件不合格分别保留HTTP语义。 */ + private static ResponseStatusException unavailable(HttpStatusCode status){return new ResponseStatusException(status.is4xxClientError()?HttpStatus.BAD_REQUEST:HttpStatus.SERVICE_UNAVAILABLE,"证据文件不可用或归属核验失败");} + record Envelope(String code,Metadata data) { } + record Metadata(Long fileId,Long tenantId,String businessType,String businessId,String fileName,String contentType,Long sizeBytes) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java new file mode 100644 index 000000000..d4c0197ef --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -0,0 +1,105 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 真整车主单SQL,不按单客户头部筛选或创建。 */ +@Mapper +public interface DispatchAggregateMapper { + /** Fleet附加能力批量读取当前整车审核事实,不读取绑定表旧审核。 */ + @Select("") + java.util.List findMany(@Param("numbers") java.util.List numbers); + String BASE_FILTER = """ + AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%') + AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%') + AND d.overseas_warehouse_id=#{q.warehouseId} + AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId}) + AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo}) + """; + String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END"; + String REVIEW_LOADING = "CASE d.review_state WHEN 'LOADING_PENDING' THEN 'PENDING' WHEN 'LOADING_REJECTED' THEN 'REJECTED' WHEN 'APPROVED' THEN 'APPROVED' ELSE 'NOT_SUBMITTED' END"; + String CURRENT_FILTER = """ + AND d.status=#{q.status} + AND d.stage=#{q.stage} + AND ( + """ + REVIEW_DISPATCH + ")=#{q.reviewStatus}" + + " AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}"; + + record Search(String dispatchNo, String vehiclePlate, String warehouseId, String customerId, + String fbaAppointmentNo, String status, String stage, String section, String reviewStatus, + long offset, int limit) { } + record Summary(String status, String stage, String reviewState, long quantity) { } + + /** 首次创建整车所有权及初始版本,不写customer_id。 */ + @Insert(""" + INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code, + overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round, + dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at, + estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count, + remark,created_by,created_at,updated_at) + VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, + #{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash}, + #{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson}, + #{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount}, + #{r.packageCount},#{r.pickedPackageCount},#{r.shippedPackageCount},#{r.remark},#{r.createdBy},#{r.createdAt},#{r.updatedAt}) + """) + int insert(@Param("r") DispatchAggregate row); + + /** 平台按业务号读取真实整车,不借客户ID作为头部归属。 */ + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{number}") + DispatchAggregate find(@Param("number") String number); + + /** 已取得Fleet父锁及资源锁后再锁主单。 */ + @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{number} FOR UPDATE") + DispatchAggregate lock(@Param("number") String number); + + /** SQL完成当前条件下的整车分页,每车只返回一行。 */ + @Select("") + List page(@Param("q") Search query); + + /** 分页总数复用完全相同筛选,不能用当前页长度替代。 */ + @Select("") + long count(@Param("q") Search query); + + /** 基集统计不套当前阶段和审核筛选,客户通过明细EXISTS去重。 */ + @Select("") + List summaries(@Param("q") Search query); + + /** 同一主单版本原子刷新货物摘要、计数并使原审核失效。 */ + @Update(""" + UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items}, + pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT', + review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending}, + record_version=record_version+1,updated_at=#{at} + WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING' + AND picked_package_count=0 AND shipped_package_count=0 + """) + int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash, + @Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages, + @Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at); + + /** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */ + @Update(""" + UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion}, + review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash}, + dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson}, + cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, + estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, + loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, + arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, + delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount}, + shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt} + WHERE id=#{r.id} AND record_version=#{version} + """) + int saveState(@Param("r") DispatchAggregate row,@Param("version") long originalVersion); + + /** 动作及完整当时主单快照追加保存,不覆盖旧审核轮次或原请求历史。 */ + @Insert("INSERT INTO ll_dispatch_history(id,dispatch_id,record_version,action,operator_id,reason,snapshot_json,occurred_at) " + + "VALUES(#{id},#{dispatch},#{version},#{action},#{operator},#{reason},#{snapshot},#{at})") + int history(@Param("id") long id, @Param("dispatch") long dispatchId, @Param("version") long version, + @Param("action") String action, @Param("operator") String operator, @Param("reason") String reason, + @Param("snapshot") String snapshot, @Param("at") java.time.LocalDateTime at); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index 254dbdded..0b7affc87 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -1,15 +1,11 @@ package com.baiyue.lastleg.dispatch.mapper; - -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - +import com.baiyue.lastleg.dispatch.model.DispatchBolData; +import org.apache.ibatis.annotations.*; import java.util.List; - +/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ @Mapper public interface DispatchBolMapper { - @Select("SELECT " + + @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + "COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," + @@ -24,12 +20,11 @@ public interface DispatchBolMapper { "FROM ll_dispatch_item di " + "LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " + "LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " + - "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id " + + "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " + "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " + "LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " + "LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " + - "WHERE di.customer_id=#{customerId} AND di.dispatch_order_id=#{dispatchOrderId} " + + "WHERE di.dispatch_order_id=#{dispatchOrderId} " + "GROUP BY di.id,l.id ORDER BY di.id,l.id") - List findByDispatchOrder(@Param("customerId") String customerId, - @Param("dispatchOrderId") Long dispatchOrderId); + List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 52151dd76..17265daa0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -40,7 +40,7 @@ public interface DispatchCandidateMapper { "") List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); - @Select("") + "ORDER BY p.plan_id,p.sequence_no"; + @Select("") List available(@Param("customerId") String customerId, @Param("planIds") List planIds, @Param("orderNo") String orderNo, @@ -143,7 +143,13 @@ public interface DispatchCandidateMapper { @Param("sourceStage") String sourceStage, @Param("draftToken") String draftToken); - @Select("") + List wholeAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("planIds") List planIds,@Param("orderNo") String orderNo, + @Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken); + + String SELECTED_PLAN_SQL = "") + "ORDER BY p.id"; + + /** 先普通读确定真实来源,再按来源订单→库存顺序取得写锁。 */ + @Select(SELECTED_PLAN_SQL + "") + List readSelected(@Param("customerId") String customerId, + @Param("planPalletIds") List planPalletIds); + + /** 已有调用需要的明确锁定读取,投影与普通读保持同一事实。 */ + @Select(SELECTED_PLAN_SQL + " FOR UPDATE") List lockSelected(@Param("customerId") String customerId, @Param("planPalletIds") List planPalletIds); - @Select("") + "GROUP BY a.id,a.lastleg_order_id,a.customer_id,a.overseas_warehouse_id,a.relabel_no,pos.pallet_id,pos.pallet_no"; + @Select("") List relabelAvailable(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); - @Select("") + List wholeRelabelAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("relabelNo") String relabelNo); + + String NATIVE_RELABEL_BODY = "SELECT a.customer_id AS customerId,a.lastleg_order_id AS orderId,a.overseas_warehouse_id AS overseasWarehouseId," + "MIN(ri.source_warehouse_name) AS overseasWarehouseName,'RETURN_RELABEL' AS orderType,a.relabel_no AS orderNo,a.relabel_no AS orderRefs," + "GROUP_CONCAT(DISTINCT c.source_return_no ORDER BY c.source_return_no SEPARATOR ',') AS inboundCode," + "c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," + @@ -277,11 +295,17 @@ public interface DispatchCandidateMapper { "AND a.relabel_no=#{relabelNo} " + "AND a.relabel_no LIKE CONCAT('%',#{orderNo},'%') " + "AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order d ON d.id=di.dispatch_order_id " + - "AND d.customer_id=di.customer_id WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " + - "GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code") + "WHERE di.customer_id=a.customer_id AND di.source_business_no=a.relabel_no AND d.status!='CANCELLED') " + + "GROUP BY a.id,a.customer_id,a.lastleg_order_id,a.overseas_warehouse_id,a.relabel_no,c.pallet_code"; + @Select("") List nativeReturnRelabelAvailable(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("relabelNo") String relabelNo); + /** 原生退货换标保持原账本,仓过滤与行数限制在数据库执行。 */ + @Select("") + List wholeNativeRelabelAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("relabelNo") String relabelNo); + @org.apache.ibatis.annotations.Update("UPDATE ll_relabel_application SET fulfillment_status='PRE_DISPATCH_CREATED',updated_at=CURRENT_TIMESTAMP(3) " + "WHERE customer_id=#{customerId} AND relabel_no=#{relabelNo} AND fulfillment_status='WAIT_PRE_DISPATCH'") int markRelabelPreDispatched(@Param("customerId") String customerId, @Param("relabelNo") String relabelNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java new file mode 100644 index 000000000..82dfe424f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 持久命令账本只新增和读取,不更新或覆盖原结果。 */ +@Mapper +public interface DispatchCommandMapper { + /** 全局读取请求键,操作者在服务层判断,不能按客户过滤后重复创建。 */ + @Select("SELECT id,dispatch_id AS dispatchId,dispatch_no AS dispatchNo,action,operator_id AS operatorId," + + "idempotency_key AS idempotencyKey,record_version AS recordVersion,command_hash AS commandHash," + + "result_json AS resultJson,created_at AS createdAt FROM ll_dispatch_command WHERE idempotency_key=#{key}") + DispatchCommandRecord find(@Param("key") String key); + + /** 唯一索引仲裁全局请求键并发,与主单变化在调用方同一事务。 */ + @Insert("INSERT INTO ll_dispatch_command(id,dispatch_id,dispatch_no,action,operator_id,idempotency_key," + + "record_version,command_hash,result_json,created_at) VALUES(#{row.id},#{row.dispatchId},#{row.dispatchNo}," + + "#{row.action},#{row.operatorId},#{row.idempotencyKey},#{row.recordVersion},#{row.commandHash}," + + "#{row.resultJson},#{row.createdAt})") + int insert(@Param("row") DispatchCommandRecord row); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java new file mode 100644 index 000000000..f3d633f7f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 复用正式来源/实托唯一占用,仅写本拥有者草稿记录,不修改正式排车占用。 */ +@Mapper +public interface DispatchDraftClaimMapper { + record Claim(Long id,String customerId,String sourceKey,Long palletId,Long containerId,LocalDateTime expiresAt) { } + + /** 来源已加锁后才读取本草稿活动占用;不跨token领取他人记录。 */ + @Select("SELECT id,customer_id AS customerId,source_pallet_key AS sourceKey,warehouse_pallet_id AS palletId,draft_container_id AS containerId,expires_at AS expiresAt " + + "FROM ll_dispatch_pallet_lock WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL " + + "AND status='ACTIVE' AND expires_at>#{at} ORDER BY id FOR UPDATE") + List owned(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at); + + /** 与正式claim共用两项唯一约束,跨客户同实托也不得重复占用。 */ + @Insert("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id,warehouse_pallet_id,active_warehouse_pallet_id," + + "return_receipt_id,dispatch_order_id,draft_container_id,draft_token,operator_id,status,locked_at,expires_at,created_at,updated_at) " + + "VALUES(#{id},#{customer},#{key},#{key},#{plan},#{pallet},#{pallet},#{receipt},NULL,#{container},#{token},#{owner},'ACTIVE',#{at},#{expires},#{at},#{at})") + int insert(@Param("id")long id,@Param("customer")String customer,@Param("key")String key,@Param("plan")Long plan, + @Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("container")long container,@Param("token")String token, + @Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires); + + /** 布局移动仅匹配同草稿原容器,不能迁移他人新取得的占用。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token} " + + "AND operator_id=#{owner} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}") + int move(@Param("id")long id,@Param("token")String token,@Param("owner")String owner,@Param("previous")long previous, + @Param("next")long next,@Param("at")LocalDateTime at); + + /** 按精确原身份释放,保留历史行并同时清空来源键和全局实托键。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} " + + "WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL AND status='ACTIVE' " + + "AND (#{key} IS NULL OR source_pallet_key=#{key}) AND (#{container} IS NULL OR draft_container_id=#{container})") + int release(@Param("token")String token,@Param("owner")String owner,@Param("key")String key,@Param("container")Long container,@Param("at")LocalDateTime at); + + /** 草稿写入续期只延长原草稿活动占用,不复活已被释放或转换的记录。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} AND operator_id=#{owner} " + + "AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}") + int renew(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java new file mode 100644 index 000000000..aaad872bb --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchDraftCommand; +import org.apache.ibatis.annotations.*; + +/** 唯一命令键串行化并保存全载荷结果;未完成记录绝不单独提交。 */ +@Mapper +public interface DispatchDraftCommandMapper { + /** 同键等待原事务,保留首次主体/动作/请求摘要。 */ + @Insert("INSERT INTO ll_dispatch_draft_command(id,idempotency_key,operator_id,action,target_token,request_hash,created_at) " + + "VALUES(#{id},#{idempotencyKey},#{operatorId},#{action},#{targetToken},#{requestHash},#{createdAt}) ON DUPLICATE KEY UPDATE id=id") + int reserve(DispatchDraftCommand command); + + /** 唯一键竞争后采用当前读恢复提交结果,避免旧快照漏见原命令。 */ + @Select("SELECT * FROM ll_dispatch_draft_command WHERE idempotency_key=#{key} FOR UPDATE") + DispatchDraftCommand lock(@Param("key")String key); + + /** 仅完成本事务首次预占的命令,失败必须使所有业务写入回滚。 */ + @Update("UPDATE ll_dispatch_draft_command SET result_json=#{json} WHERE id=#{id} AND result_json IS NULL") + int complete(@Param("id")long id,@Param("json")String json); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java index 43b428aaf..939bafbb9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java @@ -5,10 +5,27 @@ import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import org.apache.ibatis.annotations.Delete; +import java.time.LocalDateTime; import java.util.List; @Mapper public interface DispatchDraftContainerMapper extends BaseMapper { + /** 容器只在其草稿根锁内修改,普通读保持服务端顺序。 */ @Select("SELECT * FROM ll_dispatch_draft_container WHERE draft_token=#{draftToken} ORDER BY sequence_no,id") List findByToken(@Param("draftToken") String draftToken); + + /** 保存已由服务校验的布局,不从任意客户端JSON新增来源。 */ + @Update("UPDATE ll_dispatch_draft_container SET sequence_no=#{sequence},capacity=#{capacity},source_keys_json=#{keys},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token}") + int layout(@Param("id")long id,@Param("token")String token,@Param("sequence")int sequence,@Param("capacity")int capacity, + @Param("keys")String keys,@Param("at")LocalDateTime at); + + /** 在同一草稿根锁中暂存原序号,交换两容器时不短暂冲撞唯一序号。 */ + @Update("UPDATE ll_dispatch_draft_container SET sequence_no=-sequence_no WHERE draft_token=#{token} AND sequence_no>0") + int stageSequences(@Param("token")String token); + + /** 服务已校验为空的容器才可删除,不释放其他容器的来源。 */ + @Delete("DELETE FROM ll_dispatch_draft_container WHERE id=#{id} AND draft_token=#{token}") + int remove(@Param("id")long id,@Param("token")String token); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java index 210f8eca6..65099672e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java @@ -5,20 +5,34 @@ import com.baiyue.lastleg.dispatch.entity.DispatchDraft; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; +import java.time.LocalDateTime; import java.util.List; @Mapper public interface DispatchDraftMapper extends BaseMapper { - @Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " + - "AND (d.expires_at IS NULL OR d.expires_at>CURRENT_TIMESTAMP(3)) AND NOT EXISTS (" + - "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_outbound_order o ON o.customer_id=l.customer_id " + - "AND o.dispatch_order_id=l.dispatch_order_id WHERE l.draft_token=d.draft_token) AND NOT EXISTS (" + - "SELECT 1 FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order x ON x.id=l.dispatch_order_id " + - "AND x.customer_id=l.customer_id WHERE l.draft_token=d.draft_token AND x.status!='PENDING_DEPARTURE') " + - "ORDER BY d.updated_at DESC") - List findActiveByOperator(@Param("operatorId") String operatorId); + /** 草稿头只按拥有者隔离,不再依赖客户子排车或旧主单状态。 */ + default List findActiveByOperator(String operatorId) { + return findActiveAt(operatorId,LocalDateTime.now(java.time.ZoneOffset.UTC)); + } - @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' LIMIT 1 FOR UPDATE") + /** 显式传入UTC时间,数据库会话时区不能使有效草稿被提前视为过期。 */ + @Select("SELECT d.* FROM ll_dispatch_draft d WHERE d.operator_id=#{operatorId} AND d.status='ACTIVE' " + + "AND d.expires_at>#{at} ORDER BY d.updated_at DESC,d.id") + List findActiveAt(@Param("operatorId")String operatorId,@Param("at")LocalDateTime at); + + /** 保留现有提供者的精确拥有者锁查询,过期资格由新服务统一核对。 */ + @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{draftToken} AND operator_id=#{operatorId} AND status='ACTIVE' FOR UPDATE") DispatchDraft lockActive(@Param("draftToken") String draftToken, @Param("operatorId") String operatorId); + + /** 锁定任何生命周期的原草稿,重放在服务层先于有效期校验。 */ + @Select("SELECT * FROM ll_dispatch_draft WHERE draft_token=#{token} AND operator_id=#{owner} FOR UPDATE") + DispatchDraft lockOwned(@Param("token") String token,@Param("owner") String owner); + + /** 唯一公共版本CAS;所有布局、状态和命令结果随同事务提交。 */ + @Update("UPDATE ll_dispatch_draft SET record_version=#{r.recordVersion},sources_json=#{r.sourcesJson}," + + "plan_ids_json=#{r.planIdsJson},status=#{r.status},expires_at=#{r.expiresAt},released_at=#{r.releasedAt},updated_at=#{r.updatedAt} " + + "WHERE id=#{r.id} AND operator_id=#{r.operatorId} AND record_version=#{version}") + int save(@Param("r") DispatchDraft row,@Param("version") long version); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java new file mode 100644 index 000000000..2e29ef14a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; + +/** 本车真实扫码与不可变归属的有界批量读取,不用计划箱数补造执行。 */ +@Mapper +public interface DispatchExecutionReadMapper { + record RescanCandidate(String dispatchNo,String deliveryNo,long deliveryVersion) { } + record PickSource(Long pickId,Long attributionId,Long itemId,String customerId,Long orderId,String orderNo, + String sourceRegion,String sourceInboundId,String inboundCode,Long positionId,Long receiptId,Long returnReceiptId, + Long returnCartonId,String cartonMark,long packages,long productQuantity) { } + record ItemPicked(Long itemId,long packages) { } + record ActualMeasurement(Long palletId,String sourceNo,LocalDateTime observedAt,BigDecimal weightKg,BigDecimal volumeCbm) { } + /** 只取实际存在的订单与归属;缺失源头会使完整性核对失败。 */ + @Select(""" + SELECT p.id AS pickId,a.id AS attributionId,i.id AS itemId,a.customer_id AS customerId,a.order_id AS orderId, + o.order_no AS orderNo,o.source_region AS sourceRegion,o.source_inbound_id AS sourceInboundId, + COALESCE(r.inbound_code,rb.inbound_code,i.inbound_code) AS inboundCode,p.inventory_position_id AS positionId, + a.receipt_id AS receiptId,a.return_receipt_id AS returnReceiptId,a.return_carton_id AS returnCartonId, + c.carton_mark AS cartonMark,a.packages,COALESCE(a.product_quantity,0) AS productQuantity + FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id + JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=p.customer_id + JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id + LEFT JOIN ll_inbound_stock_receipt r ON r.id=a.receipt_id AND r.customer_id=a.customer_id + LEFT JOIN ll_return_stock_receipt rr ON rr.id=a.return_receipt_id AND rr.customer_id=a.customer_id + LEFT JOIN ll_inventory_balance rb ON rb.id=rr.inventory_balance_id AND rb.customer_id=rr.customer_id + LEFT JOIN ll_return_inventory_carton c ON c.id=a.return_carton_id AND c.customer_id=a.customer_id + WHERE i.dispatch_order_id=#{id} ORDER BY i.id,p.id,a.id + """) + List sources(@Param("id")long dispatchId); + /** 每个明细都必须独立核平,不能以整车合计抵消超拣与漏拣。 */ + @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{id} GROUP BY i.id ORDER BY i.id") + List itemPicked(@Param("id")long dispatchId); + /** 每条扫码的不可变归属箱数与商品数必须完整,缺归属不能因总数相等而放行。 */ + @Select(""" + SELECT COUNT(*) FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id + WHERE i.dispatch_order_id=#{id} AND (p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages + OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0)) + """) + long inconsistentPicks(@Param("id")long dispatchId); + /** 已完成实测只有当前整托组成未变时才可自动带入,计费重与订单分摊重不在此表。 */ + @Select(""" + SELECT DISTINCT p.warehouse_pallet_id AS palletId,t.transfer_no AS sourceNo,p.completed_at AS observedAt, + p.gross_weight_kg AS weightKg,p.volume_cbm AS volumeCbm + FROM ll_dispatch_item i JOIN ll_warehouse_transfer_pallet p ON p.warehouse_pallet_id=i.warehouse_pallet_id AND p.customer_id=i.customer_id + JOIN ll_warehouse_transfer_order t ON t.id=p.transfer_order_id AND t.customer_id=p.customer_id + WHERE i.dispatch_order_id=#{id} AND p.status='COMPLETED' AND p.carton_count=i.package_count AND p.completed_at IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger l WHERE l.customer_id=i.customer_id AND l.pallet_id=p.warehouse_pallet_id + AND l.created_at>=p.completed_at AND l.business_type IN('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND')) + ORDER BY p.warehouse_pallet_id,p.completed_at + """) + List actualMeasurements(@Param("id")long dispatchId); + + /** 有界修复性重扫只找当前真实签收且缺本版本信号的在途车,不锁任何子单。 */ + @Select(""" + SELECT d.dispatch_no AS dispatchNo,l.delivery_no AS deliveryNo,l.version AS deliveryVersion + FROM ll_dispatch_order d JOIN ll_delivery_order l ON l.dispatch_order_id=d.id + WHERE d.status='ACTIVE' AND d.stage='DELIVERING' AND l.signed_packages>0 AND l.signed_at IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM ll_delivery_facts_changed s WHERE s.dispatch_id=d.id AND s.delivery_id=l.id AND s.delivery_version=l.version) + ORDER BY d.updated_at,d.id,l.id LIMIT #{limit} + """) + List rescan(@Param("limit")int limit); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java index 8f94757bf..77a0990d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java @@ -11,6 +11,9 @@ import java.util.List; @Mapper public interface DispatchItemMapper extends BaseMapper { + /** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */ + @Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id") + List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); @Select("SELECT * FROM ll_dispatch_item WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} ORDER BY id") List findByDispatchOrder(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java new file mode 100644 index 000000000..200b18f78 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java @@ -0,0 +1,65 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.OutboundReference; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 整车详情的批量只读投影,不修改预约权威模型或客户单据。 */ +@Mapper +public interface DispatchReadMapper { + /** 即使计数漂移也不得把已有真实扫码当成零扫码。 */ + @Select("SELECT COALESCE(SUM(p.packages),0) FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{id}") + long realPicked(@Param("id") long dispatchId); + record DeliveryRow(String groupKey, String customerId, String customerName, String outboundNo, + String deliveryNo, String type, String warehouseId, String warehouseCode, String warehouseName, + String address, String postalCode, String contactName, String contactPhone, String status, + long packageCount, long palletCount) { } + record BatchRow(Long dispatchItemId,Long planPalletId,String batchKey, String batchNo, String customerId, String sourceKey, String destinationKey, + int packageCount, String confirmationNo, String status, String warehouseCode, LocalDateTime startUtc, Long recordVersion) { } + + /** 读取所有客户出库,不因客户筛选把整车单据缩减成一张。 */ + @Select(""" + SELECT o.customer_id AS customerId,c.customerName,o.outbound_no AS outboundNo,o.status, + o.package_count AS packageCount,o.pallet_count AS palletCount + FROM ll_outbound_order o LEFT JOIN(SELECT dispatch_order_id,customer_id,MAX(customer_name) AS customerName + FROM ll_dispatch_item GROUP BY dispatch_order_id,customer_id)c + ON c.dispatch_order_id=o.dispatch_order_id AND c.customer_id=o.customer_id + WHERE o.dispatch_order_id=#{id} ORDER BY o.customer_id,o.id + """) + List outbounds(@Param("id") long dispatchId); + + /** 所有目的地单据一次读取,地址字段只来自真实派送记录。 */ + @Select(""" + SELECT delivery_group_key AS groupKey,customer_id AS customerId,customer_name AS customerName, + outbound_no AS outboundNo,delivery_no AS deliveryNo,destination_type AS type, + destination_warehouse_id AS warehouseId,destination_warehouse_code AS warehouseCode, + destination_warehouse_name AS warehouseName,delivery_address AS address,postal_code AS postalCode, + contact_name AS contactName,contact_phone AS contactPhone,status,package_count AS packageCount,pallet_count AS palletCount + FROM ll_delivery_order WHERE dispatch_order_id=#{id} ORDER BY customer_id,id + """) + List deliveries(@Param("id") long dispatchId); + + /** 关联按真实明细客户读取,UTC时间不从当地时间猜测。 */ + @Select(""" + SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey,CASE WHEN p.id IS NULL THEN d.dispatch_no ELSE p.appointment_no END AS batchNo, + a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey, + a.package_count AS packageCount,p.confirmation_no AS confirmationNo,p.appointment_status AS status, + p.fba_warehouse_code AS warehouseCode,p.appointment_start_utc AS startUtc, + CASE WHEN a.batch_key LIKE 'FBA_APPOINTMENT:%' THEN p.version + WHEN a.batch_key=CONCAT('DISPATCH:',d.id) THEN d.record_version ELSE NULL END AS recordVersion + FROM ll_dispatch_appointment_allocation a JOIN ll_dispatch_order d ON d.id=a.dispatch_id + JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id + LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) + WHERE d.id=#{id} ORDER BY a.batch_key,a.customer_id,a.source_key,a.destination_key,a.dispatch_item_id + """) + List batches(@Param("id") long dispatchId); + + /** 未接通跨客户批次写提供者前必须阻断静默丢批次,不能写无依据运输。 */ + @Select(""" + SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id + JOIN ll_inbound_fba_appointment_item f ON f.order_id=l.order_id AND f.customer_id=l.customer_id AND f.active=1 + WHERE a.pallet_id=#{pallet} AND l.customer_id=#{customer} + """) + long activeAppointments(@Param("customer") String customer, @Param("pallet") Long planPalletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java new file mode 100644 index 000000000..03293fbfd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -0,0 +1,97 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 排车选货的来源身份、真实库存版本及排他资源锁。 */ +@Mapper +public interface DispatchSourceMapper { + record Stock(long id, long balanceId, Long palletId, String warehouseId, long availablePackages, long holdPackages, + long allocatedPackages, long pickedPackages, long version) { } + record Receipt(long id, long orderId, long inventoryPositionId, long packages, String inboundCode) { } + record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { } + record Claim(Long id, Long dispatchId, String draftToken) { } + record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { } + + /** 换标单客户由服务端业务单号查得,不接受客户端指定。 */ + @Select("SELECT customer_id FROM ll_relabel_application WHERE relabel_no=#{number}") + String relabelOwner(@Param("number") String number); + + /** 无独立版本字段的申请以真实更新时间和客户确认轮次参加来源摘要。 */ + @Select("SELECT status,fulfillment_status AS fulfillmentStatus,updated_at AS updatedAt,customer_confirmed_at AS customerConfirmedAt " + + "FROM ll_relabel_application WHERE relabel_no=#{number}") + RelabelBasis relabelBasis(@Param("number") String number); + + /** 同托真实收货和计划组成共同定位来源订单,禁止按展示MIN订单猜归属。 */ + @Select(""" + SELECT DISTINCT x.order_id FROM ( + SELECT r.order_id FROM ll_inbound_stock_receipt r WHERE r.customer_id=#{customer} AND r.warehouse_pallet_id=#{pallet} + UNION SELECT r.order_id FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} + UNION SELECT l.order_id FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=#{customer} + WHERE a.pallet_id=#{planPallet} + ) x ORDER BY x.order_id + """) + List sourceOrders(@Param("customer") String customer, @Param("pallet") Long pallet, + @Param("planPallet") Long planPallet); + + /** 锁来源订单先于库存,并在同一锁内拒绝暂扣。 */ + @Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} AND detained=0 FOR UPDATE") + Long lockOrder(@Param("customer") String customer, @Param("id") Long id); + + /** 计划托盘尚无实物时也有可串行占用的真实来源根。 */ + @Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE") + Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id); + + /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ + @Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE") + Long lockReturn(@Param("customer") String customer, @Param("id") Long id); + + /** sourceVersion包含真实库存数量和版本;写时按仓位ID稳定加锁。 */ + @Select("") + List stock(@Param("customer") String customer, @Param("pallet") Long pallet, @Param("lock") boolean lock); + + /** 版本还包含逐入仓真实组成,后到货不得静默加入原选货。 */ + @Select("SELECT id,order_id AS orderId,inventory_position_id AS inventoryPositionId,received_packages AS packages,inbound_code AS inboundCode " + + "FROM ll_inbound_stock_receipt WHERE customer_id=#{customer} AND warehouse_pallet_id=#{pallet} ORDER BY id") + List receipts(@Param("customer") String customer, @Param("pallet") Long pallet); + + /** 同托真实退回实收也参加来源摘要,不能被原计划展示订单覆盖。 */ + @Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id") + List returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet); + + /** 仅失效的草稿占用可让出唯一键,绝不释放活动正式单。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} " + + "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} " + + "AND ((customer_id=#{customer} AND source_pallet_key=#{key}) OR active_warehouse_pallet_id=#{pallet})") + int expire(@Param("customer") String customer, @Param("key") String key, @Param("pallet") Long pallet, + @Param("at") LocalDateTime at); + + /** 不同来源别名指向同一实托时只能归同一整车,客户不作为实托锁分片。 */ + @Select("SELECT id,dispatch_order_id AS dispatchId,draft_token AS draftToken FROM ll_dispatch_pallet_lock " + + "WHERE status='ACTIVE' AND ((customer_id=#{customer} AND active_key=#{key}) OR active_warehouse_pallet_id=#{pallet}) " + + "AND (expires_at IS NULL OR expires_at>#{at}) ORDER BY id FOR UPDATE") + List claims(@Param("customer") String customer, @Param("key") String key, + @Param("pallet") Long pallet, @Param("at") LocalDateTime at); + + /** 只释放本车本来源原占用,清空实托唯一键,不触碰后续他车占用。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND status='ACTIVE' AND ((#{plan} IS NOT NULL AND plan_pallet_id=#{plan}) OR (#{receipt} IS NOT NULL AND return_receipt_id=#{receipt}) OR (#{pallet} IS NOT NULL AND warehouse_pallet_id=#{pallet}))") + int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("receipt")Long receipt,@Param("pallet")Long pallet,@Param("at")LocalDateTime at); + + /** 原计划自然收货后取得同一实托唯一键,其他车/来源的占用不能被覆盖。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},active_warehouse_pallet_id=#{pallet},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL") + int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("at")LocalDateTime at); + + /** 唯一活动实托键与来源键共同阻止并发跨车重复排货。 */ + @Insert(""" + INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id, + warehouse_pallet_id,active_warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at) + VALUES(#{id},#{customer},#{key},#{key},#{planPallet},#{pallet},#{pallet},#{receipt},#{dispatch},'ACTIVE',#{operator},#{at},#{at},#{at}) + """) + int claim(@Param("id") long id, @Param("customer") String customer, @Param("key") String key, + @Param("planPallet") Long planPallet, @Param("pallet") Long pallet, @Param("receipt") Long receipt, + @Param("dispatch") long dispatch, @Param("operator") String operator, @Param("at") LocalDateTime at); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java index 74bb166d7..060d86376 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java @@ -23,17 +23,28 @@ public interface ReturnDispatchCandidateMapper { "JOIN ll_delivery_order d ON d.customer_id=r.customer_id AND d.id=e.delivery_order_id "; String ELIGIBLE = " AND o.detained=0 AND p.available_packages>0 AND r.received_packages>(SELECT COALESCE(SUM(a.packages),0) " + "FROM ll_dispatch_pda_pick_order a WHERE a.customer_id=r.customer_id AND a.return_receipt_id=r.id) "; - @Select("") + "ORDER BY r.received_at,r.id"; + @Select("") List available(@Param("customerId") String customerId, @Param("orderNo") String orderNo, @Param("fba") String fba, @Param("draftToken") String draftToken); + /** 退回候选按真实库存仓过滤,保持多客户读取。 */ + @Select("") + List wholeAvailable(@Param("warehouseId") String warehouseId, + @Param("customerId") String customerId,@Param("orderNo") String orderNo,@Param("fba") String fba,@Param("draftToken") String draftToken); + + /** 只读解析选定退回来源,避免在来源订单前抢占库存锁。 */ + @Select("") + List readSelected(@Param("customerId") String customerId, @Param("ids") List ids); + @Select("") List lockSelected(@Param("customerId") String customerId, @Param("ids") List ids); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java new file mode 100644 index 000000000..52d2a0136 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.dispatch.model; +import java.time.LocalDateTime; +import java.util.*; +/** 打印服务内部一致快照,不恢复旧排车JSON协议。 */ +public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName, + String vehiclePlate,LocalDateTime estimatedArrivalAt,List bolLines,Map appointmentNumbers) { + public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo, + String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId, + String poReferenceNo,int packageCount) { + public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);} + public String groupKey(){return customerId+"\u0000"+destinationKey();} + } + public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index 397f162e3..defc08736 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -2,12 +2,9 @@ package com.baiyue.lastleg.dispatch.pda.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; -import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; -import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; -import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.*; import jakarta.annotation.Resource; import jakarta.validation.Valid; import org.springframework.security.access.prepost.PreAuthorize; @@ -20,52 +17,56 @@ import java.util.List; @RequestMapping("/api/pda/v1/last-leg/dispatches") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") public class DispatchPdaController { - @Resource private DispatchPdaWorkflowService service; - @Resource private FleetTripExecutionService trips; + @Resource private DispatchPdaQueryService queries; + @Resource private DispatchPickingService picking; + @Resource private DispatchAggregateService workflow; + @Resource private DispatchDepartureService departure; + /** 批量读取当前绑定仓的整车作业。 */ @GetMapping - public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, - @RequestParam(required = false) String status) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - String warehouse = String.valueOf(identity.overseasWarehouseId()); - var tasks = service.list(warehouse, status); - var gates = trips.gates(warehouse, tasks.stream().map(DispatchPdaResponses.Task::dispatchNo).toList()); - return ApiResponse.success(tasks.stream().map(task -> task.withGate(gates.get(task.dispatchNo()))).toList()); + public ApiResponse> list(@AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required=false)DispatchTypes.Stage stage) { + return ApiResponse.success(queries.list(DispatchActorFactory.pda(user),stage)); } + /** 使用排车业务号读取当前仓真实货物和扫码进度。 */ @GetMapping("/{dispatchNo}") - public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse task(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - String warehouse = String.valueOf(identity.overseasWarehouseId()); - return ApiResponse.success(service.task(warehouse, dispatchNo).withGate(trips.gate(warehouse, dispatchNo))); + return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo)); } + /** 共用公共版本和全局请求键执行一次真实库存扫码。 */ @PostMapping("/{dispatchNo}/items/{itemId}/pick") - public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, @PathVariable long itemId, - @Valid @RequestBody DispatchPdaRequests.Pick request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.pick(String.valueOf(identity.overseasWarehouseId()), dispatchNo, itemId, - identity.operatorKey(), identity.operatorName(), request)); + @Valid @RequestBody DispatchCommands.Pick request) { + return ApiResponse.success(picking.pick(DispatchActorFactory.pda(user),dispatchNo,itemId,request)); } - @PostMapping("/{dispatchNo}/complete") - public ApiResponse completePicking( - @AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @Valid @RequestBody DispatchPdaRequests.CompletePicking request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(service.completePicking(String.valueOf(identity.overseasWarehouseId()), - dispatchNo, request)); + /** 与平台共用同一实载证据读取。 */ + @GetMapping("/{dispatchNo}/loading") + public ApiResponse loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo)); } + /** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */ + @PostMapping("/{dispatchNo}/loading-complete") + public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { + return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request)); + } + + /** 与平台共用实际发车服务,不执行第二次库存扣减。 */ @PostMapping("/{dispatchNo}/depart") - public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, + public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody FleetExecutionModels.Depart request) { - PdaWarehouseIdentity identity = PdaWarehouseIdentity.require(user); - return ApiResponse.success(trips.depart(String.valueOf(identity.overseasWarehouseId()), dispatchNo, - identity.operatorKey(), request)); + @Valid @RequestBody DispatchCommands.Depart request) { + return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request)); + } + + /** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(workflow.command(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey)); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java index 81ccee623..12b51e7b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java @@ -11,7 +11,6 @@ import java.time.LocalDateTime; @TableName("ll_dispatch_pda_task") public class DispatchPdaTask { @TableId(type = IdType.INPUT) private Long id; - private String customerId; private Long dispatchOrderId; private String taskNo; private String status; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java index 2b8c0f33c..27aa78364 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java @@ -7,6 +7,9 @@ import java.util.List; @Mapper public interface DispatchPdaPickMapper extends BaseMapper { + /** 按真实明细客户读取全车归属,不要求整车主单存在客户列。 */ + @Select("SELECT p.* FROM ll_dispatch_pda_pick p JOIN ll_dispatch_item i ON i.id=p.dispatch_item_id AND i.customer_id=p.customer_id WHERE i.dispatch_order_id=#{dispatch} AND p.customer_id=#{customer} ORDER BY p.id") + List wholeDispatch(@Param("customer")String customer,@Param("dispatch")long dispatch); @Select("SELECT * FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} ORDER BY id") List listTask(@Param("customerId") String customerId, @Param("taskId") Long taskId); @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick WHERE customer_id=#{customerId} AND task_id=#{taskId} AND dispatch_item_id=#{itemId}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java index 26b78cd40..3099c4da0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java @@ -18,12 +18,14 @@ public interface DispatchPdaPickOrderMapper extends BaseMapper deliveryPickedSkus(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java index c92c6232f..fad6512d2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java @@ -8,11 +8,11 @@ import java.util.List; @Mapper public interface DispatchPdaTaskMapper extends BaseMapper { - @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1 FOR UPDATE") - DispatchPdaTask lockByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + @Select("SELECT * FROM ll_dispatch_pda_task WHERE dispatch_order_id=#{dispatchOrderId} FOR UPDATE") + DispatchPdaTask lockByDispatch(@Param("dispatchOrderId") Long dispatchOrderId); - @Select("SELECT * FROM ll_dispatch_pda_task WHERE customer_id=#{customerId} AND dispatch_order_id=#{dispatchOrderId} LIMIT 1") - DispatchPdaTask findByDispatch(@Param("customerId") String customerId, @Param("dispatchOrderId") Long dispatchOrderId); + @Select("SELECT * FROM ll_dispatch_pda_task WHERE dispatch_order_id=#{dispatchOrderId}") + DispatchPdaTask findByDispatch(@Param("dispatchOrderId") Long dispatchOrderId); @Select("SELECT * FROM ll_dispatch_pda_task WHERE warehouse_id=#{warehouseId} " + "AND (#{status} IS NULL OR status=#{status}) ORDER BY pushed_at DESC LIMIT 200") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java new file mode 100644 index 000000000..f9047428f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java @@ -0,0 +1,37 @@ +package com.baiyue.lastleg.dispatch.pda.mapper; + +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 绑定仓整车PDA批量读取,不读取单客户主单头或逐车查询详情。 */ +@Mapper +public interface DispatchWholePdaReadMapper { + record Picked(Long itemId,long packages) { } + record Invalid(Long dispatchId,long quantity) { } + record Carton(Long itemId,Long cartonId,String cartonMark,String status,long productQuantity) { } + /** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */ + @Select("") + List headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage); + String ROOTS="#{id}"; + /** 一次读取选中整车所有真实客户货物。 */ + @Select("") + List items(@Param("ids")List ids); + /** 实际扫码按真实明细客户合计,不依赖客户任务头的假归属。 */ + @Select("") + List picked(@Param("ids")List ids); + /** 每条扫码的来源订单、箱数与商品数均须真实完整。 */ + @Select("") + List invalid(@Param("ids")List ids); + /** 仓位先按本批明细客户、实托及绑定仓一次读取。 */ + @Select("") + List positions(@Param("ids")List ids,@Param("warehouse")String warehouse); + /** 原生退货箱仍从原账本取真实箱号,精确选中集合由业务层再比对。 */ + @Select("") + List cartons(@Param("ids")List ids,@Param("warehouse")String warehouse); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java index adc8c9907..dbf1f26f3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java @@ -1,405 +1,42 @@ package com.baiyue.lastleg.dispatch.pda.service; -import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.lastleg.delivery.entity.OutboundOrder; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; -import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; -import com.baiyue.lastleg.dispatch.pda.entity.*; -import com.baiyue.lastleg.dispatch.pda.mapper.*; -import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; -import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; -import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; -import com.baiyue.lastleg.inventory.entity.InventoryPosition; -import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.baiyue.lastleg.relabel.dto.RelabelRequests; import com.baiyue.lastleg.relabel.service.RelabelApplicationService; -import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.time.LocalDateTime; +import java.time.*; import java.util.*; import java.util.stream.Collectors; +/** 实际发车推进既有来源状态;扫码与装车只使用公共整车命令。 */ @Service public class DispatchPdaWorkflowService { - @Resource private com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard deliveryExecution; + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository wholePicks; + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceDepartureRepository wholeDeparture; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; - @Resource private FleetDispatchBindingGuard fleetBindings; - @Resource private DispatchOrderMapper orders; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private OutboundPalletItemMapper outboundPalletItems; - @Resource private DispatchItemMapper items; - @Resource private DispatchPdaTaskMapper tasks; - @Resource private DispatchPdaPickMapper picks; - @Resource private DispatchPdaOperationMapper operations; - @Resource private DispatchPalletLockMapper palletLocks; - @Resource private InventoryPositionMapper positions; - @Resource private InventoryCommandService inventory; - @Resource private RelabelApplicationService relabelApplications; - @Resource private WarehouseTransferOrderMapper warehouseTransfers; @Resource private DispatchPickOrderAttributionService pickAttribution; - @Resource private ObjectMapper objectMapper; - @Resource private com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnRelabelStock; - - @Transactional(readOnly = true) - public List list(String warehouseId, String status) { - String boundWarehouseId = warehouse(warehouseId); - List result = new ArrayList<>(); - for (DispatchPdaTask pdaTask : tasks.listByWarehouse(boundWarehouseId, trim(status))) { - DispatchOrder order = orders.selectById(pdaTask.getDispatchOrderId()); - if (order == null || !pdaTask.getCustomerId().equals(order.getCustomerId()) - || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { - throw conflict("Dispatch PDA task scope is inconsistent"); + @Resource private RelabelApplicationService relabelApplications; + /** 全部客户子单锁及来源订单锁已由调用方收齐,此处只推进原来源计数与原业务状态。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void departWholeSources(com.baiyue.lastleg.dispatch.port.DispatchContracts.Context context, + com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor actor, + com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.DepartureResult result,Instant departedAt) { + var at=LocalDateTime.ofInstant(departedAt,java.time.ZoneOffset.UTC); + var byCustomer=context.cargo().stream().collect(Collectors.groupingBy(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::customerId,TreeMap::new,Collectors.toList())); + for(var group:byCustomer.entrySet()) { + String customer=group.getKey();var outbound=result.outbounds().stream().filter(o->customer.equals(o.customerId())).findFirst().orElseThrow(()->conflict("本客户实际出库单缺失")); + var actual=wholePicks.picks(customer,context.dispatchId()); + if(actual.isEmpty())throw conflict("本客户缺少真实扫码来源"); + pickAttribution.depart(customer,outbound.outboundNo(),context.dispatchNo(),actual,at); + for(var order:group.getValue().stream().filter(c->c.relabelNo()==null).map(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::orderId).distinct().sorted().toList())wholeDeparture.transferOrder(customer,order,at); + for(var no:group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine::relabelNo).filter(Objects::nonNull).distinct().sorted().toList()) { + wholeDeparture.relabel(customer,no,at);relabelApplications.changeStatus(customer,no,actor.operatorId(),new RelabelRequests.ChangeStatus("COMPLETED","整车实际发车完成")); } - result.add(task(order.getCustomerId(), order, pdaTask, - items.findByDispatchOrder(order.getCustomerId(), order.getId()))); - } - return result; - } - - @Transactional - public DispatchPdaResponses.Task task(String warehouseId, String dispatchNo) { - DispatchOrder order = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = order.getCustomerId(); - items.bindReceivedPallets(customerId, order.getId()); - DispatchPdaTask task = tasks.findByDispatch(customerId, order.getId()); - if (task == null) throw notFound("Dispatch PDA task not found"); - requireTaskWarehouse(task, warehouseId); - return task(customerId, order, task, items.findByDispatchOrder(customerId, order.getId())); - } - - @Transactional(isolation = org.springframework.transaction.annotation.Isolation.READ_COMMITTED) - public DispatchPdaResponses.Picked pick(String warehouseId, String dispatchNo, long itemId, - String operatorId, String operatorName, - DispatchPdaRequests.Pick request) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - // Transfer and picking serialize on the same dispatch before taking task/inventory locks. - DispatchOrder order = orders.lockById(customerId, found.getId()); - if (order == null) throw notFound("Dispatch order not found"); - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - if (task == null) throw conflict("Dispatch PDA task is not available for picking"); - if (!warehouseId.equals(task.getWarehouseId())) throw conflict("PDA pick warehouse does not match task warehouse"); - DispatchPdaOperation replay = replay(customerId, "PICK", request.idempotencyKey(), request); - if (replay != null) { - DispatchPdaResponses.Picked result = read(replay, DispatchPdaResponses.Picked.class); - if (!task.getId().equals(replay.getTaskId()) || result.dispatchItemId() != itemId) { - throw conflict("Idempotency key was reused with a different dispatch pick request"); - } - return result; - } - requirePdaStatus(order); - requireVehicle(order); - fleetBindings.requirePickingApproved(order.getId()); - items.bindReceivedPallets(customerId, order.getId()); - if (!("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { - throw conflict("Dispatch PDA task is not available for picking"); - } - bindOperator(task, operatorId, operatorName); - DispatchItem item = items.selectById(itemId); - if (item == null || !customerId.equals(item.getCustomerId()) || !order.getId().equals(item.getDispatchOrderId())) { - throw notFound("Dispatch pallet item not found"); - } - if (!warehouseId.equals(item.getOverseasWarehouseId())) throw conflict("Pallet does not belong to selected warehouse"); - boolean nativeReturn = "RETURN_RELABEL".equals(item.getSourceStage()); - if (!nativeReturn && request.positionId() == null) throw conflict("普通入仓拣货必须选择库存仓位"); - if (nativeReturn && request.positionId() != null) throw conflict("退货箱拣货请扫描来源箱唛,不使用普通库存仓位"); - InventoryPosition position = nativeReturn ? null : positions.selectById(request.positionId()); - if (!nativeReturn && (position == null || !customerId.equals(position.getCustomerId()) - || item.getWarehousePalletId() == null || !item.getWarehousePalletId().equals(position.getPalletId()))) { - throw conflict("Inventory position does not belong to this dispatch pallet"); - } - long picked = picks.totalItem(customerId, task.getId(), itemId); - if (picked + request.packages() > item.getPackageCount()) throw conflict("Picked packages exceed dispatch pallet packages"); - long pickId = IdWorker.getId(); - Long returnCartonId = null; - if (nativeReturn) { - pickAttribution.prepareNativeReturn(customerId, item.getOrderId()); - returnCartonId = returnRelabelStock.pick(item, request.cartonMark(), request.packages(), request.productQuantity(), - pickId, order.getDispatchNo()).getId(); - } else { - pickAttribution.preparePicking(customerId, request.positionId(), - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null); - String keyBase = "DISPATCH:" + order.getDispatchNo() + ":" + itemId + ":" + request.idempotencyKey(); - InventoryCommandRequests.Move move = new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":ALLOCATE", request.remark()); - if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) { - inventory.release(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), item.getSourceBusinessNo(), order.getDispatchNo(), null, - keyBase + ":RELABEL_RELEASE", "换标排车拣货前释放冻结库存")); - } - inventory.allocate(customerId, operatorId, move); - inventory.pick(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":PICK", request.remark())); - // A successful PDA scan is the inventory deduction point. Departure only - // changes outbound/dispatch facts and must not deduct the same quantity again. - inventory.ship(customerId, operatorId, new InventoryCommandRequests.Move(request.positionId(), - request.packages(), order.getDispatchNo(), order.getDispatchNo(), null, - keyBase + ":SHIP", request.remark())); - } - DispatchPdaPick pick = new DispatchPdaPick(); - pick.setId(pickId); pick.setCustomerId(customerId); pick.setTaskId(task.getId()); pick.setReturnCartonId(returnCartonId); - pick.setDispatchItemId(itemId); pick.setInventoryPositionId(request.positionId()); pick.setPackages(request.packages()); - pick.setProductQuantity(request.productQuantity()); pick.setIdempotencyKey(request.idempotencyKey()); - pick.setPickedAt(LocalDateTime.now()); pick.setCreatedAt(pick.getPickedAt()); picks.insert(pick); - if (nativeReturn) pickAttribution.attributeNativeReturn(customerId, pick, item.getOrderId()); - else pickAttribution.attribute(customerId, pick, - "RELABEL_ORDER".equals(item.getSourceBusinessType()) ? item.getOrderId() : null, item.getReturnReceiptId()); - long total = picked + request.packages(); - order.setPickedPackageCount((int) (n(order.getPickedPackageCount()) + request.packages())); - task.setStatus("PICKING"); task.setUpdatedAt(LocalDateTime.now()); - if (allPicked(customerId, task.getId(), items.findByDispatchOrder(customerId, order.getId()))) { - task.setStatus("COMPLETED"); task.setCompletedAt(LocalDateTime.now()); order.setStatus("READY_TO_DEPART"); - } - order.setUpdatedAt(LocalDateTime.now()); - orders.updateById(order); - tasks.updateById(task); - DispatchPdaResponses.Picked result = new DispatchPdaResponses.Picked(itemId, request.positionId(), request.packages(), total, false, false); - save(customerId, task.getId(), "PICK", request.idempotencyKey(), request, result); - return result; - } - - @Transactional - public DispatchPdaResponses.PickingCompleted completePicking(String warehouseId, String dispatchNo, - DispatchPdaRequests.CompletePicking request) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - DispatchOrder order = orders.lockById(customerId, found.getId()); - if (order == null) throw notFound("Dispatch order not found"); - DispatchPdaOperation replay = replay(customerId, "COMPLETE_PICKING", request.idempotencyKey(), request); - if (replay != null) return read(replay, DispatchPdaResponses.PickingCompleted.class).withReplay(); - requireVehicle(order); - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - fleetBindings.requirePickingApproved(order.getId()); - if (task != null && "COMPLETED".equals(task.getStatus())) { - List completedItems = items.findByDispatchOrder(customerId, order.getId()); - long planned = completedItems.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) - .mapToLong(Integer::longValue).sum(); - long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); - DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted( - order.getDispatchNo(), order.getStatus(), planned, picked, Math.max(0, planned - picked), false, false); - DispatchPdaOperation completed = operations.findCompleted(customerId, task.getId(), "COMPLETE_PICKING"); - if (completed != null) throw conflict("PDA picking was already completed with another idempotency key"); - save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); - return result; - } - if (task == null || !("PDA_PENDING".equals(task.getStatus()) || "PICKING".equals(task.getStatus()))) { - throw conflict("Dispatch PDA task is not available for completion"); - } - List source = items.findByDispatchOrder(customerId, order.getId()); - long planned = source.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) - .mapToLong(Integer::longValue).sum(); - long picked = picks.listTask(customerId, task.getId()).stream().mapToLong(p -> n(p.getPackages())).sum(); - if (planned <= 0 || picked != planned || !allPicked(customerId, task.getId(), source)) { - throw conflict("Every dispatch pallet and package must be fully picked before completion"); - } - LocalDateTime now = LocalDateTime.now(); - task.setStatus("COMPLETED"); task.setCompletedAt(now); task.setUpdatedAt(now); tasks.updateById(task); - order.setStatus("READY_TO_DEPART"); - order.setPartialCompletionReason(null); - order.setPickedPackageCount((int) picked); - order.setUpdatedAt(now); orders.updateById(order); - DispatchPdaResponses.PickingCompleted result = new DispatchPdaResponses.PickingCompleted(order.getDispatchNo(), - order.getStatus(), planned, picked, 0, false, false); - save(customerId, task.getId(), "COMPLETE_PICKING", request.idempotencyKey(), request, result); - return result; - } - - /** Final warehouse exit: only fully PDA-picked dispatches can consume picked inventory. */ - @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) - public DispatchPdaResponses.Departed depart(String warehouseId, String dispatchNo, String operatorId, - DispatchPdaRequests.Depart request, LocalDateTime departedAt) { - DispatchOrder found = requireWarehouseOrder(warehouseId, dispatchNo); - String customerId = found.getCustomerId(); - DispatchOrder order = orders.lockById(customerId, found.getId()); - if (order == null) throw notFound("Dispatch order not found"); - DispatchPdaOperation replay = replay(customerId, "DEPART", request.idempotencyKey(), request); - if (replay != null) return read(replay, DispatchPdaResponses.Departed.class).withReplay(); - fleetBindings.requireCoordinatedDeparture(order.getId()); - requireVehicle(order); - OutboundOrder outbound = outboundOrders.lockByDispatch(customerId, order.getId()); - if (outbound == null || !"WAIT_OUTBOUND".equals(outbound.getStatus())) { - throw conflict("Dispatch must be transferred to a pending outbound order before departure"); - } - deliveryExecution.requireDeparture(outbound); - DispatchPdaTask task = tasks.lockByDispatch(customerId, order.getId()); - if (task == null || !"COMPLETED".equals(task.getStatus()) - || !"READY_TO_DEPART".equals(order.getStatus())) { - throw conflict("Dispatch must complete PDA picking before departure"); - } - requireTaskWarehouse(task, warehouseId); - - List dispatchItems = items.findByDispatchOrder(customerId, order.getId()); - if (!allPicked(customerId, task.getId(), dispatchItems)) { - throw conflict("Every dispatch pallet must be fully picked before departure"); - } - List dispatchPicks = picks.listTask(customerId, task.getId()); - if (dispatchPicks.isEmpty()) throw conflict("Dispatch has no PDA picks to ship"); - long shipped = dispatchPicks.stream().mapToLong(p -> n(p.getPackages())).sum(); - LocalDateTime now = Objects.requireNonNull(departedAt, "departedAt"); - pickAttribution.depart(customerId, outbound.getOutboundNo(), order.getDispatchNo(), dispatchPicks, now); - if (outboundPalletItems != null) { - for (var outboundItem : outboundPalletItems.findByOutbound(customerId, outbound.getId())) { - boolean selected = dispatchItems.stream().anyMatch(item -> Objects.equals(item.getWarehousePalletId(), outboundItem.getWarehousePalletId()) - && Objects.equals(item.getInboundCode(), outboundItem.getInboundCode())); - if (selected) { - outboundItem.setPickedPackageCount(outboundItem.getPackageCount()); - outboundItem.setStatus("PICKED"); - outboundPalletItems.updateById(outboundItem); - } - } - } - for (DispatchItem item : dispatchItems) { - // A relabel dispatch may consume only part of a warehouse-transfer - // pallet. Do not close the whole transfer by its parent order; the - // remaining cartons still need destination binding/outbound. - if (item.getOrderId() != null && !"RELABEL_ORDER".equals(item.getSourceBusinessType())) { - warehouseTransfers.markOutboundByOrder(customerId, item.getOrderId(), now); - } - if ("RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getSourceBusinessNo() != null) { - warehouseTransfers.markOutboundByRelabel(customerId, item.getSourceBusinessNo(), now); - } - } - dispatchItems.stream().filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) - .map(DispatchItem::getSourceBusinessNo).filter(Objects::nonNull).distinct().forEach(relabelNo -> - relabelApplications.changeStatus(customerId, relabelNo, operatorId, - new RelabelRequests.ChangeStatus("COMPLETED", "换标排车PDA发车完成"))); - outbound.setStatus("OUTBOUNDED"); - outbound.setUpdatedAt(now); - outboundOrders.updateById(outbound); - order.setShippedPackageCount((int) shipped); - order.setStatus("DEPARTED"); - order.setDepartAt(now); - order.setUpdatedAt(now); - orders.updateById(order); - releasePalletLocks(customerId, order.getId(), now); - Map sourcePackages = new TreeMap<>(); - for (var item : dispatchItems) if (item.getOrderId() != null) - sourcePackages.merge(item.getOrderId(), item.getPackageCount(), Math::addExact); - fleetPool.departed(customerId, order.getDispatchNo(), sourcePackages, operatorId, now); - DispatchPdaResponses.Departed result = new DispatchPdaResponses.Departed(order.getDispatchNo(), - order.getStatus(), shipped, false); - save(customerId, task.getId(), "DEPART", request.idempotencyKey(), request, result); - return result; - } - - private DispatchPdaResponses.Task task(String customerId, DispatchOrder order, DispatchPdaTask task, List source) { - List values = new ArrayList<>(); - for (DispatchItem item : source) { - if ("RETURN_RELABEL".equals(item.getSourceStage())) { - values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), - item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), - warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), - picks.totalItem(customerId, task.getId(), item.getId()), List.of(), returnRelabelStock.detail(item))); - continue; - } - List palletPositions = item.getWarehousePalletId() == null ? List.of() - : positions.listPallet(customerId, item.getWarehousePalletId()); - boolean relabel = "RELABEL_ORDER".equals(item.getSourceBusinessType()); - values.add(new DispatchPdaResponses.Item(item.getId(), item.getPalletNo(), item.getDestinationWarehouseCode(), - item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), - warehouseRouteLabel(item.getFbaWarehouseCode(), item.getDestinationWarehouseCode()), item.getPackageCount(), - picks.totalItem(customerId, task.getId(), item.getId()), palletPositions.stream().map(p -> - new DispatchPdaResponses.Position(p.getId(), p.getBalanceId(), n(p.getAvailablePackages()) - + (relabel ? n(p.getHoldPackages()) : 0), - n(p.getAllocatedPackages()), n(p.getPickedPackages()))).toList())); - } - OutboundOrder outbound = outboundOrders.findByDispatch(customerId, order.getId()); - String departBlockReason = departBlockReason(order, task, source, outbound); - return new DispatchPdaResponses.Task(task.getId(), task.getTaskNo(), order.getDispatchNo(), order.getVehiclePlate(), - task.getWarehouseId(), "DEPARTED".equals(order.getStatus()) ? "DEPARTED" : task.getStatus(), - departBlockReason == null, departBlockReason, values, null, null, 0, null, false, "请先关联车次并通过调度审核"); - } - - private String departBlockReason(DispatchOrder order, DispatchPdaTask task, List source, - OutboundOrder outbound) { - if ("DEPARTED".equals(order.getStatus())) return "该排车已发车,不可重复提交。"; - if (trim(order.getVehiclePlate()) == null) return "请先在平台维护车辆信息。"; - if (!"COMPLETED".equals(task.getStatus()) || !"READY_TO_DEPART".equals(order.getStatus()) - || !allPicked(order.getCustomerId(), task.getId(), source)) { - return "请先完成排车拣货并核平全部箱数。"; - } - if (outbound == null) return "请先在平台完成“转派送”,生成待出库单后才能发车。"; - if (!"WAIT_OUTBOUND".equals(outbound.getStatus())) return "关联出库单不是待出库状态,暂不可发车。"; - return null; - } - - private boolean allPicked(String customerId, Long taskId, List source) { - return source.stream().allMatch(item -> picks.totalItem(customerId, taskId, item.getId()) == item.getPackageCount()); - } - private void releasePalletLocks(String customerId, Long dispatchOrderId, LocalDateTime now) { - List activeLocks = palletLocks.selectList(new com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, customerId) - .eq(DispatchPalletLock::getDispatchOrderId, dispatchOrderId) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); - for (DispatchPalletLock lock : activeLocks) { - lock.setStatus("RELEASED"); - lock.setActiveKey(null); - lock.setReleasedAt(now); - lock.setUpdatedAt(now); - palletLocks.updateById(lock); + Map packages=new TreeMap<>();for(var cargo:group.getValue())packages.merge(cargo.orderId(),Math.toIntExact(cargo.packages()),Math::addExact); + fleetPool.departed(customer,context.dispatchNo(),packages,actor.operatorId(),at); } } - private DispatchOrder requireWarehouseOrder(String warehouseId, String dispatchNo) { - String boundWarehouseId = warehouse(warehouseId); - DispatchOrder order = orders.findGlobalByDispatchNo(required(dispatchNo, "Dispatch number is required")); - if (order == null || !boundWarehouseId.equals(trim(order.getOverseasWarehouseId()))) { - throw notFound("Dispatch order not found in the bound warehouse"); - } - return order; - } - private void requirePdaStatus(DispatchOrder order) { - if (!("PENDING_DEPARTURE".equals(order.getStatus()) || "PICKING".equals(order.getStatus()) - || "READY_TO_DEPART".equals(order.getStatus()))) throw conflict("Dispatch has already departed and cannot be changed by PDA"); - } - private void requireVehicle(DispatchOrder order) { if (trim(order.getVehiclePlate()) == null) throw conflict("Vehicle plate is required before PDA push or picking"); } - private static String warehouse(String value) { return required(value, "Warehouse selection is required for PDA work"); } - private static void requireTaskWarehouse(DispatchPdaTask task, String warehouseId) { - if (!warehouse(warehouseId).equals(trim(task.getWarehouseId()))) { - throw notFound("Dispatch PDA task not found in the bound warehouse"); - } - } - private static void bindOperator(DispatchPdaTask task, String operatorId, String operatorName) { - String normalizedOperator = required(operatorId, "PDA operator is required"); - if (trim(task.getOperatorId()) != null && !normalizedOperator.equals(trim(task.getOperatorId()))) { - throw conflict("Dispatch PDA task is already assigned to another operator"); - } - task.setOperatorId(normalizedOperator); - task.setOperatorName(trim(operatorName)); - } - private DispatchPdaOperation replay(String customerId, String type, String key, Object request) { DispatchPdaOperation op = operations.findKey(customerId, required(key, "Idempotency key is required")); if (op == null) return null; if (!type.equals(op.getOperationType()) || !hash(request).equals(op.getRequestHash())) throw conflict("Idempotency key was reused with a different request"); if (!"COMPLETED".equals(op.getStatus())) throw conflict("PDA operation is still processing"); return op; } - private void save(String customerId, Long taskId, String type, String key, Object request, Object result) { DispatchPdaOperation op = new DispatchPdaOperation(); op.setId(IdWorker.getId()); op.setCustomerId(customerId); op.setTaskId(taskId); op.setOperationType(type); op.setIdempotencyKey(required(key,"Idempotency key is required")); op.setRequestHash(hash(request)); op.setResultJson(json(result)); op.setStatus("COMPLETED"); op.setCreatedAt(LocalDateTime.now()); op.setUpdatedAt(op.getCreatedAt()); operations.insert(op); } - private T read(DispatchPdaOperation op, Class type) { try { return objectMapper.readValue(op.getResultJson(), type); } catch (Exception e) { throw new IllegalStateException("Cannot read PDA replay", e); } } - private String hash(Object value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json(value).getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } } - private String json(Object value) { try { return objectMapper.writeValueAsString(value); } catch (Exception e) { throw new IllegalStateException(e); } } - private static long n(Long value) { return value == null ? 0 : value; } - private static long n(Integer value) { return value == null ? 0 : value; } - static String warehouseRouteLabel(String originalFbaWarehouseCode, String destinationWarehouseCode) { - String original = trim(originalFbaWarehouseCode); - String destination = trim(destinationWarehouseCode); - return original != null && destination != null && !original.equalsIgnoreCase(destination) - ? original + " -> " + destination : null; - } - private static String trim(String value) { return value == null || value.trim().isEmpty() ? null : value.trim(); } - private static String required(String value, String message) { String result = trim(value); if (result == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, message); return result; } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } - private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } + private static RuntimeException conflict(String message){return new com.baiyue.common.core.exception.BizException(com.baiyue.common.core.api.ResultCode.CONFLICT,message);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java new file mode 100644 index 000000000..aae359617 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 各端唯一端点常量;骨架本身不表示Controller已经上线。 */ +public final class DispatchApi { + public static final String PLATFORM = "/api/platform/v1/last-leg/dispatches"; + public static final String PDA = "/api/pda/v1/last-leg/dispatches"; + public static final String CANDIDATES = "/candidates"; + public static final String ITEMS = "/{dispatchNo}/items"; + public static final String REMOVE_ITEM = "/{dispatchNo}/items/{itemId}/remove"; + public static final String PICK_ITEM = "/{dispatchNo}/items/{itemId}/pick"; + public static final String CANCEL = "/{dispatchNo}/cancel"; + public static final String LOADING = "/{dispatchNo}/loading"; + public static final String DRAFTS = "/drafts"; + public static final String DRAFT_SNAPSHOT = "/drafts/snapshot"; + public static final String DRAFT_CONTAINERS = "/drafts/containers"; + public static final String DELETE_DRAFT_CONTAINER = "/drafts/{draftToken}/containers/{containerId}"; + public static final String DRAFT_LOCK = "/draft-locks/lock"; + public static final String DRAFT_UNLOCK = "/draft-locks/unlock"; + public static final String DRAFT_RELEASE = "/draft-locks/release"; + public static final String CONVERT_DRAFT = "/drafts/pre-arrange"; + public static final String COMMAND = "/{dispatchNo}/commands/{idempotencyKey}"; + public static final String SUBMIT_REVIEW = "/{dispatchNo}/submit-review"; + public static final String DISPATCH_REVIEW = "/{dispatchNo}/dispatch-review"; + public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review"; + public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review"; + public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete"; + public static final String DEPART = "/{dispatchNo}/depart"; + public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement"; + public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery"; + + /** 端点容器不允许实例化。 */ + private DispatchApi() { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java new file mode 100644 index 000000000..0bff02a42 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 既有预约改量专用内部端口,不注册HTTP,不恢复旧客户排车或手工Fleet绑定。 */ +public interface DispatchAppointmentAmendmentPort { + /** 必须由已锁定原运输scope读取;主流程仍逐项核对DB真实归属及完整实物量。 */ + record PhysicalItem(Long itemId,String customerId,Long planPalletId,String sourceStage,Integer packages) { } + + /** MANDATORY:一次锁全范围trip→全部车辆→司机/资源→全部dispatch,禁止逐车交错。 */ + void lockExistingAppointmentDispatches(Actor actor,List dispatchNos); + + /** + * MANDATORY:调用者先核对完整scope并锁齐原权威批次、来源及业务行。 + * identity.recordVersion是本车当前公共版本;idempotencyKey是原父预约命令键。 + * 实现按父键、可信操作者及dispatchNo派生稳定子键,共用原整车命令账本,重试恢复原快照。 + * expected/next必须来自原权威事实;removePhysicalItems只能由服务端完整实物守卫决定。 + * 只允许ACTIVE、BEFORE_LOADING、零真实扫码、无下游执行;任何失败跟随父事务整体回滚。 + */ + CommandResult applyExistingAppointmentChange(Actor actor,String dispatchNo,DispatchCommands.ReasonCommand identity, + List touchedItems,List expectedAllocations, + List nextAllocations,boolean removePhysicalItems); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java new file mode 100644 index 000000000..9c71dfb6f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 公开整车写请求;所有已有单写入只使用recordVersion。 */ +public final class DispatchCommands { + /** 请求容器不允许实例化。 */ + private DispatchCommands() { } + + public record Vehicle(ResourceMode mode, Long vehicleId, String plate, String carrierName, + Capacity capacity, List evidenceFileIds) { } + + public record Driver(ResourceMode mode, Long driverId, String name, String phone, + DriverEvidence evidence) { } + + public record CargoSelection(SourceType sourceType, Long planPalletId, Long returnReceiptId, + String relabelNo, Long warehousePalletId, String sourceVersion) { } + + /** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */ + public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver, + Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, + List cargo, List batchAllocations) { } + + /** 只关联权威既有预约,确认状态/时间必须解析提供者结果。 */ + public record AppointmentReference(String referenceNo, Long recordVersion) { + /** 拒绝缺引用或缺版本,不提供手工确认状态字段。 */ + public AppointmentReference { + if (referenceNo == null || referenceNo.isBlank()) throw new IllegalArgumentException("预约引用必填"); + recordVersion = requireVersion(recordVersion); + } + } + + public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, + Instant plannedDepartureAt, Instant estimatedArrivalAt, + AppointmentReference vehicleAppointment, String remark) { + /** 缺版本与有效版本零必须区分。 */ + public Update { recordVersion = requireVersion(recordVersion); } + } + + public record AddCargo(Long recordVersion, String idempotencyKey, List cargo, + List batchAllocations) { + /** 缺版本不得退化成零版本。 */ + public AddCargo { recordVersion = requireVersion(recordVersion); } + } + + public record ReasonCommand(Long recordVersion, String idempotencyKey, String reason) { + /** 移货和取消同样必须提交当前版本。 */ + public ReasonCommand { recordVersion = requireVersion(recordVersion); } + } + + public record SubmitReview(Long recordVersion, String idempotencyKey, String remark) { + /** 审核提交不接受隐式零版本。 */ + public SubmitReview { recordVersion = requireVersion(recordVersion); } + } + + public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision, + String reason) { + /** 两级审核都执行相同的必填版本约束。 */ + public ReviewCommand { recordVersion = requireVersion(recordVersion); } + } + + /** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */ + public record ReturnToReview(Long recordVersion, String idempotencyKey, String reason) { + /** 零扫码退回也不能覆盖并发新版本。 */ + public ReturnToReview { recordVersion = requireVersion(recordVersion); } + } + + public record Pick(Long recordVersion, String idempotencyKey, Long positionId, long packages, + long productQuantity, String cartonMark, String remark) { + /** 扫码缺版本必须失败,避免将旧请求解释为首版。 */ + public Pick { recordVersion = requireVersion(recordVersion); } + } + + /** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */ + public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash, + List measurements, String remark) { + /** 后台与PDA共用必填版本约束。 */ + public LoadingComplete { recordVersion = requireVersion(recordVersion); } + } + + public record Depart(Long recordVersion, String idempotencyKey, String remark) { + /** 实际发车不得接受缺省版本。 */ + public Depart { recordVersion = requireVersion(recordVersion); } + } + + /** 装箱类型保留JSON缺字段事实,同时允许显式提交零。 */ + static Long requireVersion(Long recordVersion) { + if (recordVersion == null || recordVersion < 0) throw new IllegalArgumentException("recordVersion必填且不得小于零"); + return recordVersion; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java new file mode 100644 index 000000000..eba03e4bf --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.dispatch.port; + +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 主流程独占实现;阶段CAS与本信号确认在同一新事务提交。 */ +public interface DispatchCompletionPort { + /** 在签收提交后取得trip/dispatch锁,真实全签才推进,失败不确认信号。 */ + CompletionResult reconcile(Signal signal); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java new file mode 100644 index 000000000..b96d9e564 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 主流程与派送域之间的事实快照;不允许传递持久化实体。 */ +public final class DispatchContracts { + /** 契约容器不允许实例化。 */ + private DispatchContracts() { } + + /** 由可信认证上下文构造,不允许从请求体绑定操作身份或PDA仓库。 */ + public record Actor(String operatorId, ActorMode mode, String boundWarehouseId) { } + + /** 未允许时必须携带稳定错误码和可展示原因。 */ + public record Gate(boolean allowed, String blockCode, String blockReason) { } + + public record Counts(long itemCount, long palletCount, long packageCount, + long pickedPackageCount, long shippedPackageCount) { } + + public record Capacity(Integer palletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm) { } + + /** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */ + public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, + List fileIds, Instant expectedTaskEndAt) { } + + public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate, + String carrierName, String vehicleStatus, Capacity capacity, + List evidenceFileIds) { } + + public record DriverSnapshot(ResourceMode mode, Long driverId, String name, String phone, + DriverEvidence evidence) { } + + public record Resources(VehicleSnapshot vehicle, DriverSnapshot driver) { } + + public record Destination(String key, String type, String warehouseId, String warehouseCode, + String warehouseName, String address, String postalCode, + String contactName, String contactPhone) { } + + public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt, + long reviewRound, String basisHash, String invalidatedReason) { } + + public record Reviews(Review dispatchReview, Review loadingReview) { } + + public record BatchAllocation(Long planPalletId, String batchKey, String customerId, + String sourceKey, String destinationKey, int packageCount, Long recordVersion) { + /** 本字段属于batchKey的权威记录,不是整车版本;必须显式提交,零合法。 */ + public BatchAllocation { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } + + public record BatchSource(String sourceKey, String destinationKey, int packageCount) { } + + public record BatchReference(String batchKey, String batchNo, String customerId, + List sources, Long recordVersion) { + /** FBA使用原预约版本,自动DISPATCH引用使用该真实主单版本。 */ + public BatchReference { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } + + /** 一行表示本次不可变真实拣货来源,展示入仓码不能替代此归属。 */ + public record CargoLine(Long dispatchItemId, String customerId, String customerName, + Long orderId, String orderNo, String sourceKey, String sourceStage, + String sourceRegion, String sourceInboundId, String inboundCode, + Long planPalletId, Long warehousePalletId, String palletNo, + Long pickId, Long attributionId, Long inventoryPositionId, + Long receiptId, Long returnReceiptId, Long returnCartonId, + String cartonMark, String relabelNo, Destination destination, + long packages, long productQuantity, BigDecimal grossWeightKg, + BigDecimal volumeCbm, List batchAllocations) { } + + /** 仅用于服务内协作;锁定上下文并不等于通过当前版本或阶段资格。 */ + public record Context(Long dispatchId, String dispatchNo, Long tripId, String tripNo, + String warehouseId, String warehouseCode, String warehouseName, + Status status, Stage stage, long recordVersion, long cargoRevision, + String cargoHash, String loadingCompletionHash, Instant loadingCompletedAt, + Instant departedAt, Resources resources, Counts counts, Reviews reviews, + boolean pickingReconciled, List cargo) { } + + /** 内部命令身份由服务端摘要器产生,公开请求不接受commandHash。 */ + public record CommandIdentity(Action action, String idempotencyKey, long recordVersion, + String commandHash) { } + + public record CommandResult(String dispatchNo, Action action, String idempotencyKey, + long recordVersion, Status status, Stage stage, Instant occurredAt, + Effect effect, Counts counts, DispatchResults.Payload result, + boolean replayed, boolean alreadyArranged) { } + + public record LoadingMeasurement(Long warehousePalletId, int palletSpaces, BigDecimal weightKg, + BigDecimal volumeCbm, int deliveryIndex) { } + + public record ConfirmedLoad(String manifestHash, List pallets, + String operatorId, Instant confirmedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java new file mode 100644 index 000000000..e5632484b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDate; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 派送安排、费用证据与正常签收的共享边界,均保留真实客户。 */ +public final class DispatchDeliveryModels { + /** 派送契约容器不允许实例化。 */ + private DispatchDeliveryModels() { } + + public record CoverageSelection(Long financeFeeId, int sourceVersion, String sourceHash, + List services) { } + + public record FeeItem(String feeType, String feeName, String direction, String currency, + BigDecimal amount, Long supplierId, String supplierName, String supplierCategory, + String settlementMode, Instant paidAt, String paymentMethod, String paymentReferenceNo, + List paymentVoucherFileIds, BigDecimal chargeableQuantity, String quantityUnit, + BigDecimal unitPrice, LocalDate periodStart, LocalDate periodEnd, String remark) { } + + public record ArrangeGroup(String groupKey, String groupCargoHash, Instant plannedDeliveryAt, + String deliveryMethod, String waybillNo, String carrierName, String pickupMethod, + String loadingType, String vehicleType, String vehicleSize, String appointmentAccount, + String sellerName, String remark, String externalFreightHash, String feeCoverageCargoHash, + List feeCoverage, List fees) { } + + public record ArrangeDelivery(Long recordVersion, String idempotencyKey, String cargoHash, + String loadingCompletionHash, List groups) { + /** 安排派送缺版本不得被反序列化为有效零。 */ + public ArrangeDelivery { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record OutboundReference(String customerId, String customerName, String outboundNo, + String status, long packageCount, long palletCount) { } + + public record DeliveryReference(String groupKey, String customerId, String customerName, + String outboundNo, String deliveryNo, Destination destination, + String status, long packageCount, long palletCount) { } + + public record ArrangementResult(String dispatchNo, long recordVersion, String arrangementKey, + String cargoHash, Instant arrangedAt, List outbounds, + List deliveries) implements DispatchResults.Payload { } + + public record ExternalQuote(String customerId, String groupKey, String bookingNo, String inboundCode, + String currency, BigDecimal amount, String sourceHash) { } + + public record FeeCandidate(Long financeFeeId, String customerId, String currency, BigDecimal amount, + int sourceVersion, String sourceHash, String status, List services, + List sourceKeys) { } + + public record PreviewGroup(String groupKey, String customerId, String customerName, Destination destination, + String groupCargoHash, List cargo, String externalFreightHash, + List externalQuotes, String feeCoverageCargoHash, + List feeSources, List requiredServices, + List blockReasons) { } + + public record ArrangementPreview(String dispatchNo, long recordVersion, Status status, Stage stage, + String cargoHash, String loadingCompletionHash, Gate arrange, + List groups, ArrangementResult arrangement) { } + + /** 只认本车全部客户单据完整覆盖,lock模式由调用方父事务提供。 */ + public record DepartureFacts(String dispatchNo, String arrangementKey, String cargoHash, + String loadingCompletionHash, String arrangementBusinessHash, + List outbounds, List deliveries, + List cargo, Gate depart) { } + + public record DepartureResult(List outbounds, List deliveries, + long shippedPackages) { } + + public record NormalDeliveryFact(String customerId, String groupKey, String outboundNo, + String deliveryNo, long deliveryVersion, long originalPackages, + long signedPackages, long returnedPackages, long activeReturnCount, + String status, boolean outboundCompleted, Instant signedAt) { } + + public record NormalDeliveryFacts(String dispatchNo, String factsHash, boolean allNormallySigned, + Instant completedAt, List expectedDeliveryNos, + List deliveries, List blockReasons) { } + + public record ArrangementFact(String arrangementKey, String arrangementBusinessHash, + String cargoHash, String loadingCompletionHash, ArrangementResult result) { } + + public record Signal(Long signalId, String dispatchNo, String deliveryNo, long deliveryVersion, + int attempts, Instant nextRetryAt) { } + + public record CompletionResult(String dispatchNo, Stage stage, long recordVersion, + boolean changed, boolean allNormallySigned, Instant completedAt) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java new file mode 100644 index 000000000..11fea00de --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */ +public interface DispatchDeliveryPort { + /** 一次预览返回同快照下全部客户目的地及费用证据。 */ + ArrangementPreview preview(Context context); + + /** 平台整车安排;使用主流程端口获得父锁和共同命令结果。 */ + CommandResult arrange(Actor actor, String dispatchNo, ArrangeDelivery request); + + /** lock为真时先锁齐本车全部outbound/delivery,再允许来源/费用锁。 */ + DepartureFacts departureFacts(Context context, boolean lock); + + /** 加入发车父事务,仅更新真实客户单据/计数,不执行库存SHIP。 */ + DepartureResult markDeparted(Context context, Actor actor, Instant departedAt, String commandKey); + + /** 普通读取全部本趟签收事实,不锁历史来源,不调用跨趟来源证明。 */ + NormalDeliveryFacts normalDeliveryFacts(Context context); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java new file mode 100644 index 000000000..a1ed11086 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.Optional; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 主流程实现的整车权威端口;写方法必须加入调用者已有事务。 */ +public interface DispatchExecutionPort { + /** 只读返回当前真实货物,不授予写资格或取得子单锁。 */ + Context preview(Actor actor, String dispatchNo); + + /** 双端按当前权限/绑定仓读取真实实载及缺项,不补造PDA数量或预约。 */ + DispatchPlanningModels.LoadingPreview loading(Actor actor, String dispatchNo); + + /** 取得trip→必要资源→dispatch;原命令恢复优先,不提前拒绝旧版本。 */ + Context lockForArrangement(Actor actor, String dispatchNo); + + /** 当前权限/仓核验后查全局请求键;异主体或异摘要必须拒绝。 */ + Optional replay(Context context, Actor actor, CommandIdentity identity); + + /** 新命令复验当前版本、有效状态、装车后和完整真实拣货。 */ + void requireFreshArrangement(Context context, long recordVersion); + + /** 与客户单据/费用同事务记录安排引用及公共命令;既有安排不得再执行。 */ + CommandResult recordArrangement(Context context, Actor actor, CommandIdentity identity, + ArrangementFact fact, boolean alreadyArranged); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java new file mode 100644 index 000000000..647ff9c2d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java @@ -0,0 +1,83 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 候选、草稿和实载读取种子,不生成日历预约或虚构实测。 */ +public final class DispatchPlanningModels { + /** 规划契约容器不允许实例化。 */ + private DispatchPlanningModels() { } + + public record CandidateQuery(String warehouseId, List planIds, String orderNo, + String destinationWarehouseCode, String sourceStage, String draftToken) { } + + /** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */ + public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion, + String customerId, String customerName, String warehouseId, String warehouseCode, + String warehouseName, String orderNo, String inboundCode, String planNo, + String palletNo, String containerNo, Destination destination, long availablePackages, + BigDecimal suggestedWeightKg, BigDecimal suggestedVolumeCbm, String measurementSource, + List appointmentBatches, List blockReasons) { } + + public record CandidateList(List candidates, Instant snapshotAt, boolean complete) { } + + public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity, + List sourceKeys) { } + + public record Draft(String draftToken, long recordVersion, String status, String warehouseId, + List containers, List sources, Instant expiresAt) { } + + public record CreateDraft(String idempotencyKey, String warehouseId) { } + + public record CreateContainer(Long recordVersion, String idempotencyKey, String draftToken, int capacity) { + /** 已建草稿也必须区分缺版本和首版零。 */ + public CreateContainer { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftSelection(Long recordVersion, String idempotencyKey, String draftToken, + Long draftContainerId, DispatchCommands.CargoSelection source) { + /** 锁定或移除来源时不能覆盖并发草稿修订。 */ + public DraftSelection { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftSnapshot(Long recordVersion, String idempotencyKey, String draftToken, + List planIds, List containers) { + /** 草稿布局同样要求显式版本。 */ + public DraftSnapshot { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record DraftRelease(Long recordVersion, String idempotencyKey, String draftToken) { + /** 释放仅作用于原版本/原拥有者,不能覆盖新选择。 */ + public DraftRelease { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + /** 一个服务器草稿容器原子生成一张整车,不按客户再拆子排车。 */ + public record DraftVehicle(Long draftContainerId, DispatchCommands.Create dispatch) { } + + public record ConvertDraft(Long recordVersion, String idempotencyKey, String draftToken, + List groups) { + /** 正式转换按已核对草稿版本执行,不重复生成车单。 */ + public ConvertDraft { recordVersion = DispatchCommands.requireVersion(recordVersion); } + } + + public record ConvertedVehicle(Long draftContainerId, CommandResult dispatch) { } + + public record ConvertedDraft(String draftToken, List vehicles, boolean replayed) { } + + /** 非实测来源只能作为建议展示,不能填入实际测量值。 */ + public record MeasurementEvidence(String field, String sourceType, String sourceBusinessNo, + Instant observedAt, BigDecimal value, boolean actual) { } + + public record LoadingPallet(Long warehousePalletId, String palletNo, long scheduledPackages, + long pickedPackages, Integer palletSpaces, BigDecimal weightKg, + BigDecimal volumeCbm, Integer deliveryIndex, List missingFields, + List evidence) { } + + public record LoadingPreview(String dispatchNo, long recordVersion, DispatchTypes.Status status, + DispatchTypes.Stage stage, String manifestHash, boolean pickingReconciled, + Resources resources, List pallets, ConfirmedLoad confirmedLoad, + Gate loadingComplete, List missingFields) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java new file mode 100644 index 000000000..382abe968 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; + +/** 主流程独占实现候选与草稿,不按客户拆成多个排车头。 */ +public interface DispatchPlanningPort { + /** 有界批量解析真实来源、客户和事实摘要,不能跨仓或静默截断。 */ + CandidateList candidates(Actor actor, CandidateQuery query); + + /** 只查询当前操作者拥有的未失效草稿。 */ + List drafts(Actor actor); + + /** 持久幂等创建草稿,返回服务端token及合法零版本。 */ + Draft createDraft(Actor actor, CreateDraft request); + + /** 在草稿版本和拥有者锁内新增装车容器。 */ + Draft createContainer(Actor actor, CreateContainer request); + + /** 保存布局并按服务端来源占用重核,不信任布局JSON赋予货物资格。 */ + Draft saveSnapshot(Actor actor, DraftSnapshot request); + + /** 比对sourceVersion后取得真实来源/实托排他占用。 */ + Draft select(Actor actor, DraftSelection request); + + /** 仅解除本草稿本容器的原来源,不释放他人新占用。 */ + Draft unselect(Actor actor, DraftSelection request); + + /** 以当前草稿版本移除容器并归还其未执行来源。 */ + Draft removeContainer(Actor actor, String draftToken, Long containerId, DispatchCommands.ReasonCommand request); + + /** 幂等释放当前操作者的原草稿。 */ + Draft release(Actor actor, DraftRelease request); + + /** 一个事务转换全部组,一组一整车,任意客户失败全批回滚。 */ + ConvertedDraft convert(Actor actor, ConvertDraft request); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java new file mode 100644 index 000000000..b78185820 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +/** 不可变命令结果的数据类型,恢复不得改成当前详情。 */ +public final class DispatchResults { + /** 结果容器不允许实例化。 */ + private DispatchResults() { } + + public sealed interface Payload permits Saved, Picked, Loaded, Departed, + DispatchDeliveryModels.ArrangementResult { } + + public record Saved(String dispatchNo, List changedItemIds) implements Payload { } + + public record Picked(Long dispatchItemId, Long positionId, Long returnCartonId, long packages, + long pickedPackages, boolean pickingReconciled) implements Payload { } + + public record Loaded(String loadingCompletionHash, Instant loadingCompletedAt, + DispatchContracts.ConfirmedLoad confirmedLoad) implements Payload { } + + public record Departed(Instant departedAt, long shippedPackages, + List outbounds, + List deliveries) implements Payload { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java new file mode 100644 index 000000000..3b335d48d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.time.Instant; +import java.util.List; + +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; + +/** 派送域实现持久提醒数据访问;调度、退避和重扫由主流程负责。 */ +public interface DispatchSignalPort { + /** 有界普通读到期信号,不持信号或派送锁进入父事务。 */ + List due(Instant now, int limit); + + /** MANDATORY:与主阶段CAS同一汇总事务确认,失败全部回滚。 */ + void acknowledge(Signal signal, Instant acknowledgedAt); + + /** REQUIRES_NEW:仅在父汇总回滚后独立更新失败次数及退避时间。 */ + void failed(Signal signal, Instant nextRetryAt, String errorCode); + + /** 在原签收事务中持久去重写入,不回调父流程或自动推进阶段。 */ + void enqueue(String dispatchNo, String deliveryNo, long deliveryVersion, Instant occurredAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java new file mode 100644 index 000000000..69a90951b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 整车作业各端共用的唯一枚举,不复用客户子单或车次状态。 */ +public final class DispatchTypes { + /** 枚举容器不允许实例化。 */ + private DispatchTypes() { } + + public enum Status { ACTIVE, CANCELLED } + public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED } + public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED } + public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED } + public enum Decision { APPROVED, REJECTED } + public enum ResourceMode { MASTER, EXTERNAL } + public enum ActorMode { PLATFORM, PDA } + public enum SourceType { INBOUND_PLAN, RETURN_RECEIPT, RELABEL } + public enum BeforeLoadingSection { VEHICLE_INFO, DISPATCH_REVIEW, LOADING_REVIEW } + public enum StatusFilter { ACTIVE, CANCELLED, ALL } + public enum Effect { APPLIED, EXISTING } + public enum Action { + CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW, + LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java new file mode 100644 index 000000000..058b41cb2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 列表及四页签展示对象,不将当前页统计冒充全量统计。 */ +public final class DispatchViews { + /** 展示容器不允许实例化。 */ + private DispatchViews() { } + + public record Query(long page, long size, String dispatchNo, String vehiclePlate, + String warehouseId, String customerId, String fbaAppointmentNo, + StatusFilter status, Stage stage, BeforeLoadingSection beforeLoadingSection, + ReviewStatus reviewStatus) { } + + public record ReviewCounts(long notSubmitted, long pending, long approved, long rejected) { } + + public record BeforeLoadingCounts(long vehicleInfoTotal, ReviewCounts dispatchReview, + ReviewCounts loadingReview) { } + + public record Statistics(long matchedTotal, long activeTotal, long cancelledTotal, + Map stageCounts, BeforeLoadingCounts beforeLoadingReviewCounts) { } + + public record Page(List records, long total, long page, long size, long pages, + Statistics statistics, Instant snapshotAt) { } + + public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion, + ReviewState reviewState, Reviews reviews, Resources resources, + String warehouseId, String warehouseCode, String warehouseName, + Instant plannedDepartureAt, Instant departedAt, Counts counts, + boolean pickingReconciled, boolean arranged, + Map actions) { } + + public record VehicleInfo(VehicleSnapshot vehicle, DriverSnapshot driver) { } + + public record FbaAppointment(String batchKey, String batchNo, String appointmentNo, + String destinationKey, String destinationWarehouseCode, + String status, Instant confirmedAt, String blockReason) { } + + /** 仅引用已存在的车辆预约;本批不新增预约权威模型或创建接口。 */ + public record VehicleAppointment(String referenceNo, String status, Instant appointmentAt) { } + + public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt, + Instant estimatedArrivalAt, List fbaAppointments, + VehicleAppointment vehicleAppointment, List appointmentBatches) { } + + public record Documents(String dispatchNo, List outbounds, + List deliveries, + Instant createdAt, Instant updatedAt, String remark) { } + + public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode, + BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse, + BigDecimal volumeCbm, String containerNo, String sourceKey) { } + + /** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */ + public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion, + ReviewState reviewState, Reviews reviews, Counts counts, + boolean pickingReconciled, Map actions, + VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents, + List orderDetails) { } + + public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, + Stage stage, long recordVersion, Reviews reviews, Counts counts, + boolean pickingReconciled, List items, Map actions) { } + + public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo, + Destination destination, long scheduledPackages, long pickedPackages, + List positions, List returnCartons) { } + + public record Position(Long positionId, Long balanceId, long availablePackages, + long allocatedPackages, long pickedPackages) { } + + public record ReturnCarton(Long returnCartonId, String cartonMark, String status, + long productQuantity) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java new file mode 100644 index 000000000..d2f1b708b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 四详情对象之外的作业弹框读取,不以展示字段代替真实明细或卸货顺序。 */ +public final class DispatchWorkModels { + /** DTO容器不实例化。 */ + private DispatchWorkModels(){ } + public record CargoItem(Long dispatchItemId,String customerId,String customerName,String sourceKey,String sourceVersion, + SourceType sourceType,Long planPalletId,Long returnReceiptId,String relabelNo,Long warehousePalletId,String palletNo, + String orderNo,String inboundCode,Destination destination,long scheduledPackages,long pickedPackages,Gate remove) { } + public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List items) { } + public record Stop(int index,String name,String address,String destinationKey) { } + public record Route(String dispatchNo,long recordVersion,List stops,Gate edit) { } + /** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */ + public record RouteCommand(Long recordVersion,String idempotencyKey,List destinationKeys,String reason) { + /** 显式零版本合法,缺版本不能覆盖并发变更。 */ + public RouteCommand { recordVersion=DispatchCommands.requireVersion(recordVersion); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java new file mode 100644 index 000000000..222f415ee --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 整车读写持久化入口;业务流转与事务由服务负责。 */ +@Repository +public class DispatchAggregateRepository { + private final DispatchAggregateMapper mapper; + private final DispatchItemMapper items; + private final DispatchReadMapper reads; + + /** 只注入本域Mapper,服务不直接执行SQL。 */ + public DispatchAggregateRepository(DispatchAggregateMapper mapper, DispatchItemMapper items, DispatchReadMapper reads) { + this.mapper = mapper; this.items = items; this.reads=reads; + } + /** 新建真整车。 */ + public int insert(DispatchAggregate row) { return mapper.insert(row); } + /** 状态、审核、资源和实际执行事实以同一公共版本CAS保存。 */ + public int saveState(DispatchAggregate row,long originalVersion) { return mapper.saveState(row,originalVersion); } + /** 真实扫码流水优先于主单冗余计数。 */ + public long realPicked(long id) { return reads.realPicked(id); } + /** 读取业务号对应整车。 */ + public DispatchAggregate find(String number) { return mapper.find(number); } + /** 同一快照批量读整车,空集合不生成无效IN SQL。 */ + public List findMany(List numbers) { return numbers.isEmpty()?List.of():mapper.findMany(numbers); } + /** 在调用方已取得资源父锁后锁整车。 */ + public DispatchAggregate lock(String number) { return mapper.lock(number); } + /** 整车分页。 */ + public List page(DispatchAggregateMapper.Search query) { return mapper.page(query); } + /** 整车分页总数。 */ + public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); } + /** 全搜索基集的阶段和审核分布。 */ + public List summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); } + /** 按整车读取全部真实客户明细。 */ + public List cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); } + /** 保存已核对真实客户与仓库的来源快照。 */ + public int insertItem(DispatchItem item) { return items.insert(item); } + /** 未执行明细才可移除,资格和根版本由应用层保证。 */ + public int removeItem(long itemId) {return items.deleteById(itemId);} + /** 原子更新货量并作废旧审核依据。 */ + public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages, + String route, String pending, LocalDateTime at) { + return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at); + } + /** 追加不可变业务快照。 */ + public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) { + return mapper.history(id,row.getId(),row.getRecordVersion(),action,operator,reason,snapshot,at); + } + /** 同车全部客户出库摘要。 */ + public List outbounds(long id) { return reads.outbounds(id); } + /** 同车全部客户目的地派送摘要。 */ + public List deliveries(long id) { return reads.deliveries(id); } + /** 已有关联预约批次只读事实。 */ + public List batches(long id) { return reads.batches(id); } + /** 是否必须走受保护提供者核验批次分配。 */ + public long activeAppointments(String customer, Long pallet) { return reads.activeAppointments(customer,pallet); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java new file mode 100644 index 000000000..dfab76006 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import com.baiyue.lastleg.dispatch.mapper.DispatchCommandMapper; +import org.springframework.stereotype.Repository; + +/** 命令账本的数据入口,不承担权限、状态或幂等业务决定。 */ +@Repository +public class DispatchCommandRepository { + private final DispatchCommandMapper mapper; + + /** 注入本服务显式注册的命令Mapper。 */ + public DispatchCommandRepository(DispatchCommandMapper mapper) { this.mapper = mapper; } + + /** 按全局请求键查询原不可变结果。 */ + public DispatchCommandRecord find(String key) { return mapper.find(key); } + + /** 原子新增成功命令,不能更新覆盖既有行。 */ + public int insert(DispatchCommandRecord row) { return mapper.insert(row); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java new file mode 100644 index 000000000..74b7fa05d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.mapper.*; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 草稿命令、根版本、布局和自身占用的数据访问;事务及资格由专用服务编排。 */ +@Repository +public class DispatchDraftRepository { + private final DispatchDraftMapper drafts; + private final DispatchDraftContainerMapper containers; + private final DispatchDraftCommandMapper commands; + private final DispatchDraftClaimMapper claims; + + /** 仅装配本有界包Mapper,来源资格仍取既有提供者。 */ + public DispatchDraftRepository(DispatchDraftMapper drafts,DispatchDraftContainerMapper containers, + DispatchDraftCommandMapper commands,DispatchDraftClaimMapper claims) { + this.drafts=drafts;this.containers=containers;this.commands=commands;this.claims=claims; + } + /** 读取当前拥有者的未失效草稿。 */ + public List active(String owner){return drafts.findActiveByOperator(owner);} + /** 在唯一命令之后取得拥有者根锁。 */ + public DispatchDraft lock(String token,String owner){return drafts.lockOwned(token,owner);} + /** 新建服务器生成身份的草稿。 */ + public void insert(DispatchDraft row){if(drafts.insert(row)!=1)throw new IllegalStateException("草稿未保存");} + /** 公共版本CAS失败必须回滚本次全部写入。 */ + public void save(DispatchDraft row,long version){if(drafts.save(row,version)!=1)throw new IllegalStateException("草稿版本竞争失败");} + /** 布局普通读始终在草稿根事务中使用。 */ + public List containers(String token){return containers.findByToken(token);} + /** 保存真实新容器。 */ + public void insert(DispatchDraftContainer row){if(containers.insert(row)!=1)throw new IllegalStateException("草稿容器未保存");} + /** 布局交换先腾出本草稿原序号,异常由外层事务整体回滚。 */ + public void stageSequences(String token){containers.stageSequences(token);} + /** 写回服务端校验后的完整布局。 */ + public void layout(DispatchDraftContainer row){if(containers.layout(row.getId(),row.getDraftToken(),row.getSequenceNo(),row.getCapacity(),row.getSourceKeysJson(),row.getUpdatedAt())!=1)throw new IllegalStateException("草稿容器归属已变化");} + /** 只删除已核对为空的本容器。 */ + public void remove(String token,long id){if(containers.remove(id,token)!=1)throw new IllegalStateException("草稿容器不存在");} + /** 全局草稿命令键竞争采用数据库唯一约束。 */ + public DispatchDraftCommand reserve(DispatchDraftCommand row){commands.reserve(row);return commands.lock(row.getIdempotencyKey());} + /** 原始结果随业务事务共同完成。 */ + public void complete(long id,String json){if(commands.complete(id,json)!=1)throw new IllegalStateException("草稿命令结果未保存");} + /** 来源提供者已取根锁后,锁本草稿活动占用。 */ + public List claims(DispatchDraft row,LocalDateTime now){return claims.owned(row.getDraftToken(),row.getOperatorId(),now);} + /** 占用来源及实托,两项唯一键共同保证排他。 */ + public void claim(long id,DispatchDraft row,long container,DispatchServiceSource source,LocalDateTime now){ + if(claims.insert(id,source.customer(),source.key(),source.plan(),source.pallet(),source.receipt(),container,row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt())!=1)throw new IllegalStateException("来源占用未保存"); + } + /** 数据访问输入只包含提供者已经解析的真实标识。 */ + public record DispatchServiceSource(String customer,String key,Long plan,Long pallet,Long receipt) { } + /** 移动只针对同草稿原容器记录,零行不能被当作成功。 */ + public void move(DispatchDraft row,DispatchDraftClaimMapper.Claim claim,long next,LocalDateTime now){if(claims.move(claim.id(),row.getDraftToken(),row.getOperatorId(),claim.containerId(),next,now)!=1)throw new IllegalStateException("原草稿占用已变化");} + /** 释放限定原token、owner及尚未转换的草稿记录。 */ + public int release(DispatchDraft row,String key,Long container,LocalDateTime now){return claims.release(row.getDraftToken(),row.getOperatorId(),key,container,now);} + /** 活动草稿修改时同步续期原占用。 */ + public int renew(DispatchDraft row,LocalDateTime now){return claims.renew(row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java new file mode 100644 index 000000000..461a34c01 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 真实执行读取只经本域Mapper,流程判断留给应用服务。 */ +@Repository +public class DispatchExecutionRepository { + private final DispatchExecutionReadMapper reads; + /** 装配批量查询。 */ + public DispatchExecutionRepository(DispatchExecutionReadMapper reads){this.reads=reads;} + /** 全车所有客户的真实扫码来源。 */ + public List sources(long id){return reads.sources(id);} + /** 每项实际已拣数量。 */ + public List itemPicked(long id){return reads.itemPicked(id);} + /** 不完整归属不能核平。 */ + public long inconsistentPicks(long id){return reads.inconsistentPicks(id);} + /** 可自动带入的实测证据。 */ + public List actualMeasurements(long id){return reads.actualMeasurements(id);} + /** 没有持久信号的已签收版本仍可通过真实单据重建通知。 */ + public List rescan(int limit){return reads.rescan(limit);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java new file mode 100644 index 000000000..a3e526e65 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 整车PDA批量读投影,空集合由应用层短路,避免无范围查询。 */ +@Repository +public class DispatchPdaReadRepository { + private final DispatchWholePdaReadMapper mapper; + /** 装配本域读Mapper。 */ + public DispatchPdaReadRepository(DispatchWholePdaReadMapper mapper){this.mapper=mapper;} + /** 绑定仓真实主单。 */ + public List headers(String warehouse,String number,String stage){return mapper.headers(warehouse,number,stage);} + /** 一次取齐当前根集合的所有客户货物。 */ + public List items(List ids){return mapper.items(ids);} + /** 实际扫码计数。 */ + public List picked(List ids){return mapper.picked(ids);} + /** 归属不完整不能核平。 */ + public List invalid(List ids){return mapper.invalid(ids);} + /** 本批真实客户库存仓位。 */ + public List positions(List ids,String warehouse){return mapper.positions(ids,warehouse);} + /** 本批原生退货箱。 */ + public List cartons(List ids,String warehouse){return mapper.cartons(ids,warehouse);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java new file mode 100644 index 000000000..f32b74661 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 每张排车唯一作业,扫码明细保留真实客户与来源归属。 */ +@Repository +public class DispatchPickingRepository { + private final DispatchPdaTaskMapper tasks;private final DispatchPdaPickMapper picks; + private final InventoryPositionMapper positions;private final DispatchItemMapper items; + /** 封装原作业Mapper,库存状态变化仍经库存命令服务。 */ + public DispatchPickingRepository(DispatchPdaTaskMapper tasks,DispatchPdaPickMapper picks,InventoryPositionMapper positions,DispatchItemMapper items){this.tasks=tasks;this.picks=picks;this.positions=positions;this.items=items;} + /** 根锁后锁本车唯一作业。 */ + public DispatchPdaTask task(long dispatch){return tasks.lockByDispatch(dispatch);} + /** 审核后首次实际扫码创建本车作业。 */ + public int createTask(DispatchPdaTask task){return tasks.insert(task);} + /** 保存本车作业进度。 */ + public int saveTask(DispatchPdaTask task){return tasks.updateById(task);} + /** 普通读确定实际仓位身份,来源准备后再锁库存复验。 */ + public InventoryPosition position(long id){return positions.selectById(id);} + /** 真实扫码明细在同一事务保存。 */ + public int pick(DispatchPdaPick pick){return picks.insert(pick);} + /** 本明细实际已扫码数量。 */ + public long itemPicked(String customer,long task,long item){return picks.totalItem(customer,task,item);} + /** 自然收货后保存已经核实的同一计划来源实托身份。 */ + public int materialize(DispatchItem item){return items.updateById(item);} + /** 按真实客户取本车扫码,不借主单客户头。 */ + public List picks(String customer,long dispatch){return picks.wholeDispatch(customer,dispatch);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java new file mode 100644 index 000000000..c41eeec1e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; + +/** 原来源业务状态同步的数据封装,不能把物理库存SHIP再执行一次。 */ +@Repository +public class DispatchSourceDepartureRepository { + private final WarehouseTransferOrderMapper transfers; + /** 保留原转仓完成条件,主流程不重写该提供者SQL。 */ + public DispatchSourceDepartureRepository(WarehouseTransferOrderMapper transfers){this.transfers=transfers;} + /** 普通实际来源同步其转仓履约。 */ + public void transferOrder(String customer,long order,LocalDateTime at){transfers.markOutboundByOrder(customer,order,at);} + /** 换标只在该原转仓剩余可用箱数为零时完成。 */ + public void relabel(String customer,String number,LocalDateTime at){transfers.markOutboundByRelabel(customer,number,at);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java new file mode 100644 index 000000000..161b9119f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -0,0 +1,72 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */ +@Repository +public class DispatchSourceRepository { + private final DispatchCandidateMapper plans; + private final ReturnDispatchCandidateMapper returns; + private final DispatchSourceMapper source; + + /** 复用已有来源查询和本域锁Mapper。 */ + public DispatchSourceRepository(DispatchCandidateMapper plans, ReturnDispatchCandidateMapper returns, DispatchSourceMapper source) { + this.plans=plans; this.returns=returns; this.source=source; + } + /** 普通来源按仓和筛选在SQL取有界集合。 */ + public List availablePlans(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return plans.wholeAvailable(q.warehouseId(),null,q.planIds(),q.orderNo(),q.destinationWarehouseCode(),q.sourceStage(),q.draftToken()); + } + /** 退回来源保持其真实客户和库存仓。 */ + public List availableReturns(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return returns.wholeAvailable(q.warehouseId(),null,q.orderNo(),q.destinationWarehouseCode(),q.draftToken()); + } + /** 普通换标候选先限定真实仓,之后由实际测量提供者补齐证据。 */ + public List availableRelabel(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { + return plans.wholeRelabelAvailable(q.warehouseId(),null,q.orderNo(),null); + } + /** 来源托盘的真实客户。 */ + public List planOwners(Long id) { return plans.findCustomerScopesByPlanPalletIds(List.of(id)); } + /** 退回收据的真实客户。 */ + public List returnOwners(Long id) { return returns.customers(List.of(id)); } + /** 换标单业务客户。 */ + public String relabelOwner(String no) { return source.relabelOwner(no); } + /** 真实申请轮次依据。 */ + public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); } + /** 取得已有普通计划候选,保留原来源资格SQL。 */ + public List selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } + /** 取得已有真实退回候选。 */ + public List selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } + /** 同托真实来源订单组成。 */ + public List sourceOrders(String customer, Long pallet, Long planPallet) { return source.sourceOrders(customer,pallet,planPallet); } + /** 来源订单加锁并校验未暂扣。 */ + public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); } + /** 锁定真实计划托盘根。 */ + public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); } + /** 锁定实际退回收据根。 */ + public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); } + /** 真实库存数量及版本。 */ + public List stock(String customer, Long pallet, boolean lock) { return source.stock(customer,pallet,lock); } + /** 不可变收货来源组成。 */ + public List receipts(String customer, Long pallet) { return source.receipts(customer,pallet); } + /** 同一实托的退回实收不得从来源证明丢失。 */ + public List returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);} + /** 释放已到期草稿键。 */ + public int expire(String customer, String key, Long pallet, LocalDateTime at) { return source.expire(customer,key,pallet,at); } + /** 锁定当前活动资源占用。 */ + public List claims(String customer, String key, Long pallet, LocalDateTime at) { return source.claims(customer,key,pallet,at); } + /** 精确释放本车本来源,不释放他车重新取得的键。 */ + public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getPlanPalletId(),item.getReturnReceiptId(),item.getWarehousePalletId(),at);} + /** 原计划收货的物理身份与其活动键同事务补齐,不复制来源占用。 */ + public int materialize(long dispatch,String customer,long plan,long pallet,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,at);} + /** 保存正式源/实托占用。 */ + public int claim(long id, String customer, String key, Long plan, Long pallet, Long receipt, long dispatch, String operator, LocalDateTime at) { + return source.claim(id,customer,key,plan,pallet,receipt,dispatch,operator,at); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java new file mode 100644 index 000000000..d6ab99161 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -0,0 +1,824 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; + +/** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ +@Service +public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; + /** 仅用于显示的不可变来源字段,数量/归属仍取关系明细和真实拣货。 */ + public record SourceDisplay(String containerNo,String planNo) { } + private static final String PREFIX = "platform:overseas-warehouse:dispatch:"; + private final DispatchAggregateRepository repository; + private final DispatchSourceRepository sources; + private final DispatchService sourceRules; + private final FleetTripExecutionService fleet; + private final DispatchCommandLedger commands; + private final DispatchCommandHasher hashes; + private final ObjectMapper json; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads; + + /** 领域编排只访问Repository和提供者应用入口。 */ + public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources, + DispatchService sourceRules, FleetTripExecutionService fleet, DispatchCommandLedger commands, + DispatchCommandHasher hashes, ObjectMapper json) { + this.repository=repository; this.sources=sources; this.sourceRules=sourceRules; this.fleet=fleet; + this.commands=commands; this.hashes=hashes; this.json=json; + } + + /** 草稿转换统一预锁全批资源;正式create只重入已持身份锁,再逐组持久新根。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDraftCreationResources(Actor actor,List creates) { + requirePlatform(actor);if(creates==null || creates.isEmpty() || creates.size()>1000 || creates.stream().anyMatch(Objects::isNull))throw bad("请选择完整草稿建车分组"); + fleet.lockDispatchCreationResources(creates.stream().map(c->fleetVehicle(c.vehicle())).toList(),creates.stream().map(c->fleetDriver(c.driver())).toList()); + appointments.lockCreates(creates); + } + + /** 全范围先锁父级/资源,再按真实主单ID锁全部dispatch,不交错取下一车父锁。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public void lockExistingAppointmentDispatches(Actor actor,List numbers) { + requirePlatform(actor);fleet.lockExistingDispatchShells(numbers); + var observed=repository.findMany(numbers); + if(observed.size()!=numbers.size())throw conflict("原运输排车集合已变化,请重新读取完整范围"); + for(var row:observed.stream().sorted(Comparator.comparing(DispatchAggregate::getId)).toList()) { + var locked=required(actor,row.getDispatchNo(),true);var identity=fleet.dispatchIdentity(row.getDispatchNo()); + if(!Objects.equals(locked.getTripId(),row.getTripId()) || identity.tripId()!=locked.getTripId())throw conflict("原运输资源归属已变化"); + } + } + + /** 原预约父事务的唯一改量桥接,不调用旧Fleet解绑,不接收用户可选的移货开关。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public CommandResult applyExistingAppointmentChange(Actor actor,String number,DispatchCommands.ReasonCommand request, + List physical,List expected,List next,boolean remove) { + requirePlatform(actor);var row=required(actor,number,true); + if(request==null || trim(request.idempotencyKey())==null || trim(request.reason())==null)throw bad("原预约命令身份及变更原因必填"); + String key="APPOINTMENT:"+hashes.source(Arrays.asList(actor.operatorId(),request.idempotencyKey(),number)); + var identity=identity(actor,number,Action.EDIT,request.recordVersion(),key,Arrays.asList("EXISTING_APPOINTMENT",request,physical,expected,next,remove)); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + requireEditing(row,request.recordVersion()); + if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有实际扫码或有效下游单据,不能普通调整原预约"); + if(physical==null || physical.isEmpty() || physical.size()>500 || physical.stream().anyMatch(Objects::isNull))throw bad("原预约缺少完整真实实物范围"); + var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); + var ids=new HashSet();var touched=new ArrayList(); + for(var input:physical) { + var item=byId.get(input.itemId()); + if(item==null || !ids.add(input.itemId()) || !Objects.equals(input.customerId(),item.getCustomerId()) + || !Objects.equals(input.planPalletId(),item.getPlanPalletId()) || !Objects.equals(input.sourceStage(),item.getSourceStage()) + || !Objects.equals(input.packages(),item.getPackageCount()) || input.packages()==null || input.packages()<=0 + || !Objects.equals(item.getOverseasWarehouseId(),row.getOverseasWarehouseId()))throw conflict("原预约只能调整已核齐真实客户与完整数量的原实物"); + touched.add(item); + } + var current=repository.batches(row.getId()).stream().filter(b->ids.contains(b.dispatchItemId())).toList(); + var actual=current.stream().map(b->new BatchAllocation(b.planPalletId(),b.batchKey(),b.customerId(),b.sourceKey(),b.destinationKey(),b.packageCount(),b.recordVersion())).toList(); + validateAppointmentVersions(expected);validateAppointmentVersions(next); + if(!allocationTotals(actual).equals(allocationTotals(expected)))throw conflict("原实物预约分配集合或数量已变化,请重新读取完整范围"); + if(remove) { + if(next!=null && !next.isEmpty())throw bad("移出完整实物时不能保留其新分配"); + var relabels=touched.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet()); + if(items.stream().anyMatch(i->relabels.contains(i.getSourceBusinessNo()) && !ids.contains(i.getId())))throw conflict("换标必须按原完整申请调整,不能仅移出部分来源"); + appointments.remove(row.getId(),touched);sourceRules.releaseWholeRelabel(touched); + for(var item:touched) { + if(sources.release(row.getId(),item,now())<1 || repository.removeItem(item.getId())!=1)throw conflict("原实物占用或货物明细已变化"); + } + refreshCargo(row,items.stream().filter(i->!ids.contains(i.getId())).toList());row=repository.find(number); + history(row,actor,Action.EDIT,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); + commands.append(row.getId(),actor,identity,result);return result; + } + appointments.bind(row.getId(),touched,next);invalidate(row,"原预约分配已调整,请重新两级审核"); + return saveCommand(row,actor,identity,request.reason(),new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); + } + + /** 原分配比较业务归属和数量;父事务已锁批次并可能正推进权威版本。 */ + private record AllocationIdentity(Long planPalletId,String batchKey,String customerId,String sourceKey,String destinationKey) { } + private static Map allocationTotals(List allocations) { + var result=new HashMap(); + if(allocations!=null)for(var a:allocations) { + if(a==null || trim(a.batchKey())==null || trim(a.customerId())==null || trim(a.sourceKey())==null || trim(a.destinationKey())==null || a.packageCount()<=0)throw bad("原预约分配缺少真实来源或数量"); + result.merge(new AllocationIdentity(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey()),(long)a.packageCount(),Math::addExact); + } + return result; + } + private static void validateAppointmentVersions(List allocations) { + var versions=new HashMap(); + if(allocations!=null)for(var a:allocations) { + if(a==null || a.recordVersion()==null || a.recordVersion()<0)throw bad("原预约批次版本必填且不能为负"); + var old=versions.putIfAbsent(a.batchKey(),a.recordVersion());if(old!=null && !old.equals(a.recordVersion()))throw conflict("同一预约批次包含不同原版本"); + } + } + + /** 候选及来源摘要来自同一只读快照;公开选择可直接用于同仓多客户建单。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchPlanningModels.CandidateList candidates(Actor actor,DispatchPlanningModels.CandidateQuery query) { + requirePlatform(actor); + if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓"); + if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法"); + var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), + trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken())); + var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized); + var batchFacts=appointments.candidates(rows); + var result=new ArrayList(); + for(var row:rows) { + SourceType type="RELABEL".equals(row.sourceStage())?SourceType.RELABEL: + row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN; + String relabel=type==SourceType.RELABEL?row.orderRefs():null; + var preliminary=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),null); + String key=DispatchService.wholeSourceKey(preliminary),version=sourceRules.wholeSourceVersion(row,key,false); + var selection=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),version); + var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(), + row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(), + row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone()); + var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId())); + var references=new ArrayList();var reasons=new LinkedHashSet(); + if(fact!=null) { + reasons.addAll(fact.blockReasons()); + // 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。 + if(fact.occupied() && normalized.draftToken()==null)reasons.add("实物已被其他有效运输安排占用"); + for(var ref:fact.appointmentBatches()) { + if(!Objects.equals(ref.customerId(),row.customerId()))throw conflict("原预约候选客户与实物归属不一致"); + references.add(new BatchReference(ref.batchKey(),ref.batchNo(),ref.customerId(),ref.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),ref.recordVersion())); + } + } + result.add(new DispatchPlanningModels.Candidate(selection,key,version,row.customerId(),row.customerName(),row.overseasWarehouseId(), + row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(), + destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons))); + } + return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true); + } + + /** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchWorkModels.Items items(Actor actor,String number) { + requirePlatform(actor);var row=required(actor,number,false); + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var gate=actions(actor,row).get(Action.REMOVE_CARGO);var result=new ArrayList(); + for(var item:repository.cargo(row.getId())) { + boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType()); + var remove=relabel?new Gate(false,"RELABEL_WHOLE_SOURCE","换标按整单排车,请取消整单后重排"):gate; + if(picked.getOrDefault(item.getId(),0L)>0)remove=new Gate(false,"ALREADY_PICKED","本来源已经真实扫码"); + result.add(new DispatchWorkModels.CargoItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getSourceVersion(), + relabel?SourceType.RELABEL:item.getReturnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN,item.getPlanPalletId(),item.getReturnReceiptId(), + relabel?item.getSourceBusinessNo():null,item.getWarehousePalletId(),item.getPalletNo(),item.getOrderNo(),item.getInboundCode(),destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),remove)); + } + return new DispatchWorkModels.Items(number,row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),List.copyOf(result)); + } + + /** 路线返回真实资源记录中的顺序,索引可直接对应装载卸货站。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchWorkModels.Route route(Actor actor,String number) { + requirePlatform(actor);var row=required(actor,number,false);var actual=fleet.dispatchStops(number);var stops=new ArrayList(); + for(int index=0;index changed=addResolved(row,actor,request.cargo(),request.batchAllocations()); + row=repository.find(number); + history(row,actor,Action.CREATE,request.remark()); + var result=result(row,identity,new DispatchResults.Saved(number,changed)); + commands.append(id,actor,identity,result); + return result; + } + + /** 先trip→资源→dispatch,原命令恢复后才判断当前版本。 */ + @Transactional(isolation = Isolation.READ_COMMITTED) + public CommandResult addCargo(Actor actor, String number, DispatchCommands.AddCargo request) { + requirePlatform(actor); + var shell=fleet.lockDispatchShell(number); + var row=required(actor,number,true); + if (!Objects.equals(row.getTripId(),shell.tripId())) throw conflict("整车资源关系已变化"); + var identity=new CommandIdentity(Action.ADD_CARGO,request.idempotencyKey(),request.recordVersion(), + hashes.command(actor,Action.ADD_CARGO,number,request.recordVersion(),request)); + var replay=commands.replay(actor,number,identity); + if (replay.isPresent()) return replay.get(); + requireEditing(row,request.recordVersion()); + appointments.lock(repository.batches(row.getId()),request.batchAllocations()); + if (request.cargo()==null || request.cargo().isEmpty()) throw bad("请选择需要加入的真实货物"); + var changed=addResolved(row,actor,request.cargo(),request.batchAllocations()); + row=repository.find(number); history(row,actor,Action.ADD_CARGO,null); + var result=result(row,identity,new DispatchResults.Saved(number,changed)); + commands.append(row.getId(),actor,identity,result); + return result; + } + + /** 移除真实未执行明细,来源占用与货量更新一起提交,保留既有整单换标边界。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult removeCargo(Actor actor,String number,long itemId,DispatchCommands.ReasonCommand request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.REMOVE_CARGO,request.recordVersion(),request.idempotencyKey(),Arrays.asList(itemId,request)); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); + if(repository.realPicked(row.getId())!=0)throw conflict("本车已经真实扫码,不能移除货物"); + var all=repository.cargo(row.getId());var selected=all.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"排车货物明细不存在")); + if("RELABEL_ORDER".equals(selected.getSourceBusinessType()))throw conflict("换标按整单排车,请取消整单后重排"); + appointments.lock(repository.batches(row.getId()),List.of()); + appointments.remove(row.getId(),List.of(selected)); + if(sources.release(row.getId(),selected,now())<1)throw conflict("原来源占用已变化,未移除货物"); + if(repository.removeItem(itemId)!=1)throw conflict("货物明细已变化"); + refreshCargo(row,all.stream().filter(i->!Objects.equals(i.getId(),itemId)).toList());row=repository.find(number); + history(row,actor,Action.REMOVE_CARGO,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,List.of(itemId))); + commands.append(row.getId(),actor,identity,result);return result; + } + + /** 零扫码取消协调主单、原来源与资源,取消保留在正交status而不增加第六阶段。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult cancel(Actor actor,String number,DispatchCommands.ReasonCommand request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); + if(trim(request.reason())==null)throw bad("取消原因不能为空"); + if(repository.realPicked(row.getId())!=0 || !repository.outbounds(row.getId()).isEmpty())throw conflict("本车已有真实执行或关联出库,不能取消"); + var items=repository.cargo(row.getId());appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); + for(var item:items)if(sources.release(row.getId(),item,now())<1)throw conflict("原来源占用已变化,未取消排车"); + fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消"); + return saveCommand(row,actor,identity,request.reason()); + } + + /** 旧新资源身份一次锁齐,编辑关键依据后两级审核均失效,普通备注不篡改旧审核。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult update(Actor actor,String number,DispatchCommands.Update request) { + requirePlatform(actor); + fleet.lockDispatchShell(number,request.vehicle()==null?null:request.vehicle().vehicleId(),request.driver()==null?null:request.driver().driverId()); + var row=required(actor,number,true);var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + if(request.vehicleAppointment()!=null)throw conflict("车辆预约关联需按权威预约重新核验"); + String before=basis(row); + var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); + row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); + row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); + if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); + fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark()); + return saveCommand(row,actor,identity,request.remark()); + } + + /** 提交新轮次审核,不把已通过调度审核复用到更改后的货物或资源。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult submitReview(Actor actor,String number,DispatchCommands.SubmitReview request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); + verifyReviewResources(row); + if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); + long round=row.getReviewRound()+1;String basis=basis(row); + row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); + row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null))); + row.setLoadingReviewJson(write(pending(round))); + return saveCommand(row,actor,identity,request.remark()); + } + + /** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) { + var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW; + var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + requireEditing(row,request.recordVersion()); + if(request.decision()==null)throw bad("请选择审核结论"); + String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name(); + if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化"); + if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空"); + if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交"); + if(loading) { + var dispatch=reviews(row).dispatchReview(); + if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效"); + } + if(request.decision()==Decision.APPROVED)verifyReviewResources(row); + var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null); + if(loading) { + row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); + if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name()); + } else { + row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); + row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound()))); + } + return saveCommand(row,actor,identity,request.reason()); + } + + /** 只允许零真实扫码退回原单,保留编号并明确作废两级审核。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult returnToReview(Actor actor,String number,DispatchCommands.ReturnToReview request) { + var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.RETURN_TO_REVIEW,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + if(row.getRecordVersion().longValue()!=request.recordVersion() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()))throw conflict("仅当前版本装车中的有效单可退回重审"); + if(trim(request.reason())==null)throw bad("退回原因不能为空"); + if(n(row.getPickedPackageCount())!=0 || n(row.getShippedPackageCount())!=0 || repository.realPicked(row.getId())!=0)throw conflict("已经真实扫码,不能退回装车前"); + row.setStage(Stage.BEFORE_LOADING.name());invalidate(row,request.reason()); + return saveCommand(row,actor,identity,request.reason()); + } + + /** 派送域只读取本车真实已拣归属;缺失归属不会由计划补齐。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Context preview(Actor actor,String number) { return context(actor,required(actor,number,false)); } + + /** 原命令恢复前仅取根身份锁,不提前拒绝旧版本或已经发车的阶段。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public Context lockForArrangement(Actor actor,String number) { + required(actor,number,false);fleet.lockDispatchShell(number);return context(actor,required(actor,number,true)); + } + + /** 当前范围复验后只恢复原主体与严格摘要相同的命令。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public Optional replay(Context context,Actor actor,CommandIdentity identity) { + required(actor,context.dispatchNo(),false);return commands.replay(actor,context.dispatchNo(),identity); + } + + /** 新安排只能使用装车后的完整真实来源。 */ + @Override public void requireFreshArrangement(Context context,long version) { + if(context.recordVersion()!=version || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING + || !context.pickingReconciled() || context.loadingCompletionHash()==null || context.loadingCompletedAt()==null) + throw conflict("本车尚未完成真实拣货及装车,或当前版本已变化"); + } + + /** 子单及费用保存后,同一事务以主单CAS和全局账本确认原子结果。 */ + @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public CommandResult recordArrangement(Context context,Actor actor,CommandIdentity identity,DispatchDeliveryModels.ArrangementFact fact,boolean existing) { + var row=required(actor,context.dispatchNo(),true); + if(row.getRecordVersion()!=identity.recordVersion() || !Objects.equals(row.getCargoHash(),fact.cargoHash()) + || !Objects.equals(row.getLoadingCompletionHash(),fact.loadingCompletionHash()) || fact.result()==null)throw conflict("本车安排依据已变化"); + if(existing) { + if(!Objects.equals(row.getArrangementKey(),fact.arrangementKey()) || !Objects.equals(row.getArrangementBusinessHash(),fact.arrangementBusinessHash()))throw conflict("原派送安排依据不一致"); + } else { + requireFreshArrangement(context,identity.recordVersion());if(row.getArrangementKey()!=null)throw conflict("本车已经安排派送"); + row.setArrangementKey(fact.arrangementKey());row.setArrangementBusinessHash(fact.arrangementBusinessHash()); + } + long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(now()); + if(repository.saveState(row,version)!=1)throw conflict("排车版本已变化");history(row,actor,Action.ARRANGE_DELIVERY,null); + var result=new CommandResult(row.getDispatchNo(),Action.ARRANGE_DELIVERY,identity.idempotencyKey(),row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()), + Instant.now().truncatedTo(ChronoUnit.MILLIS),existing?Effect.EXISTING:Effect.APPLIED,counts(row),fact.result(),false,existing); + commands.append(row.getId(),actor,identity,result);return result; + } + + /** 两端读取同一实载清单;已知实测自动带出,建议值不能填入实际字段。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchPlanningModels.LoadingPreview loading(Actor actor,String number) { + var row=required(actor,number,false);var context=context(actor,row);var items=repository.cargo(row.getId()); + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var measured=executionReads.actualMeasurements(row.getId()).stream().collect(Collectors.groupingBy(p->p.palletId())); + var stops=fleet.dispatchStops(number);var pallets=new ArrayList();var missing=new ArrayList(); + var groups=items.stream().filter(i->i.getWarehousePalletId()!=null).collect(Collectors.groupingBy(DispatchItem::getWarehousePalletId,TreeMap::new,Collectors.toList())); + for(var entry:groups.entrySet()) { + var group=entry.getValue();var first=group.get(0);String destination=destination(first).key(); + if(group.stream().anyMatch(i->!Objects.equals(destination,destination(i).key())))throw conflict("同一实托的目的地不一致,请先核对来源"); + Integer index=null;for(int n=1;n(); + var facts=measured.getOrDefault(entry.getKey(),List.of()); + if(facts.size()==1) { + var fact=facts.get(0);weight=fact.weightKg();volume=fact.volumeCbm(); + if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true)); + if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true)); + } + var absent=new ArrayList();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex"); + missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList()); + pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(), + group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof))); + } + if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源"); + String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets)); + var gate=actions(actor,row).get(Action.LOADING_COMPLETE); + if(!context.pickingReconciled() || groups.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))gate=new Gate(false,"PICKING_INCOMPLETE","本车真实拣货和逐项来源尚未核平"); + return new DispatchPlanningModels.LoadingPreview(number,row.getRecordVersion(),context.status(),context.stage(),hash,context.pickingReconciled(),context.resources(), + List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing)); + } + + /** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) { + required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true); + var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request); + var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); + if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车"); + var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); + if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); + if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量"); + var supplied=new HashMap(); + for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复"); + for(var p:preview.pallets()) { + var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex() + || p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对"); + } + verifyReviewResources(row);var context=context(actor,row); + var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList(); + var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList(); + fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark()); + var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at); + row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name()); + return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed)); + } + + /** 当前明细、实际扫码、真实归属及批次组合为唯一整车上下文。 */ + /** Fleet附加能力只读执行事实;调用方已按trip→资源→主单顺序持父锁。 */ + @Transactional(propagation=Propagation.MANDATORY) + public Context fleetContext(String number,boolean lock) { + var row=lock?repository.lock(number):repository.find(number); + if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + return context(null,row); + } + private Context context(Actor actor,DispatchAggregate row) { + var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化"); + var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); + var batches=repository.batches(row.getId());var cargo=new ArrayList(); + var remaining=new LinkedHashMap();batches.forEach(b->remaining.put(b,(long)b.packageCount())); + for(var pick:executionReads.sources(row.getId())) { + var item=byId.get(pick.itemId());String source=pick.returnCartonId()!=null?"RETURN_CARTON:"+pick.returnCartonId():pick.returnReceiptId()!=null?"RETURN:"+pick.returnReceiptId():"ORDER:"+pick.orderId(); + var allocations=new ArrayList();long unassigned=pick.packages(); + for(var batch:batches)if(Objects.equals(batch.dispatchItemId(),item.getId()) && Objects.equals(batch.customerId(),item.getCustomerId()) && Objects.equals(batch.sourceKey(),source) && unassigned>0) { + long allocated=Math.min(unassigned,remaining.get(batch));if(allocated<=0)continue; + allocations.add(new BatchAllocation(batch.planPalletId(),batch.batchKey(),batch.customerId(),batch.sourceKey(),batch.destinationKey(),Math.toIntExact(allocated),batch.recordVersion())); + unassigned-=allocated;remaining.put(batch,remaining.get(batch)-allocated); + } + cargo.add(new CargoLine(item.getId(),pick.customerId(),item.getCustomerName(),pick.orderId(),pick.orderNo(),source,item.getSourceStage(),pick.sourceRegion(),pick.sourceInboundId(),pick.inboundCode(), + item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),pick.cartonMark(), + "RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations)); + } + var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())) + && executionReads.inconsistentPicks(row.getId())==0 && cargo.stream().mapToLong(CargoLine::packages).sum()==n(row.getPackageCount()); + return new Context(row.getId(),row.getDispatchNo(),row.getTripId(),identity.tripNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(), + Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),row.getCargoRevision(),row.getCargoHash(),row.getLoadingCompletionHash(),instant(row.getLoadingCompletedAt()),instant(row.getDepartedAt()), + read(row.getResourcesJson(),Resources.class),counts(row),reviews(row),complete,List.copyOf(cargo)); + } + + /** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */ + private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); } + /** 严格请求身份不接收前端摘要或操作者。 */ + private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));} + /** 每次状态变更、历史及不可变命令结果同事务保存。 */ + private CommandResult saveCommand(DispatchAggregate row,Actor actor,CommandIdentity identity,String reason) { + return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(row.getDispatchNo(),List.of())); + } + /** 公开结果类型按动作保存,不把Loaded等恢复成普通保存结果。 */ + private CommandResult saveCommand(DispatchAggregate row,Actor actor,CommandIdentity identity,String reason,DispatchResults.Payload payload) { + long version=row.getRecordVersion();row.setRecordVersion(version+1);row.setUpdatedAt(now()); + if(repository.saveState(row,version)!=1)throw conflict("排车版本已变化,请重新核对"); + history(row,actor,identity.action(),reason);var result=result(row,identity,payload); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */ + private String basis(DispatchAggregate row) { + var facts=new TreeMap();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); + facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts); + } + /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ + private void invalidate(DispatchAggregate row,String reason) { + row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); + var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason); + row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid)); + } + /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ + void verifyReviewResources(DispatchAggregate row) { + var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); + if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话"); + var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积"); + var e=d.evidence();if(e==null || e.expectedTaskEndAt()==null || row.getPlannedDepartureAt()==null || !e.expectedTaskEndAt().isAfter(instant(row.getPlannedDepartureAt())) || !e.expectedTaskEndAt().isAfter(Instant.now()))throw bad("预计任务结束须晚于发车及当前时间"); + if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期"); + if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds()); + if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds()); + var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()), + new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); + if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); + } + /** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */ + private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List ids) { + if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请补齐本车有效的车辆及司机证据"); + for(var id:ids)evidence.metadata(dispatch,kind,id); + } + + /** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.Page page(Actor actor, DispatchViews.Query query) { + requirePlatform(actor); + long page=query.page()==0 ? 1 : query.page(),size=query.size()==0 ? 20 : query.size(); + if(page<1 || size<1 || size>200 || page>1_000_000) throw bad("分页范围不合法"); + StatusFilter status=query.status()==null ? StatusFilter.ACTIVE : query.status(); + if(query.beforeLoadingSection()!=null && (status!=StatusFilter.ACTIVE || query.stage()!=Stage.BEFORE_LOADING)) throw bad("审核分区仅适用于装车前有效单据"); + if(query.reviewStatus()!=null && (query.beforeLoadingSection()==null || query.beforeLoadingSection()==BeforeLoadingSection.VEHICLE_INFO)) throw bad("请先选择对应审核分区"); + var filter=new DispatchAggregateMapper.Search(trim(query.dispatchNo()),trim(query.vehiclePlate()),trim(query.warehouseId()), + trim(query.customerId()),trim(query.fbaAppointmentNo()),status.name(),query.stage()==null?null:query.stage().name(), + query.beforeLoadingSection()==null?null:query.beforeLoadingSection().name(),query.reviewStatus()==null?null:query.reviewStatus().name(),(page-1)*size,(int)size); + var rows=repository.page(filter); long count=repository.count(filter); + return new DispatchViews.Page(rows.stream().map(r -> listRow(actor,r)).toList(),count,page,size,(count+size-1)/size, + statistics(repository.summaries(filter)),Instant.now()); + } + + /** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.Detail detail(Actor actor, String number) { + var row=required(actor,number,false); var cargo=repository.cargo(row.getId()); + var resources=read(row.getResourcesJson(),Resources.class); + var batches=repository.batches(row.getId()); + var references=new LinkedHashMap>(); + batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b)); + var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(), + group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList(); + var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap( + b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream() + .map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList(); + var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(), + d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), + d.status(),d.packageCount(),d.palletCount())).toList(); + var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); + return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), + new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,null,batchRefs), + documents,orderDetails(cargo)); + } + + /** 恢复动作结果前重新核验当前单据范围和原动作权限。 */ + @Transactional(readOnly=true,isolation=Isolation.READ_COMMITTED) + public CommandResult command(Actor actor,String number,String key) { + required(actor,number,false); + var allowed=EnumSet.noneOf(Action.class); + for(var action:Action.values()) if(hasPermission(actor,action)) allowed.add(action); + return commands.readOriginal(actor,number,key,allowed); + } + + /** 全部客户来源和占用同事务,任一失败不留下部分整车。 */ + private List addResolved(DispatchAggregate row,Actor actor,List selections,List batchAllocations) { + if(selections==null || selections.isEmpty()) { + if(batchAllocations!=null && !batchAllocations.isEmpty())throw bad("没有真实货物时不能提交预约分配"); + return List.of(); + } + var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet()); + var physical=current.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toSet()); + var resolved=sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections); + var saved=new ArrayList(); + for(var source:resolved) { + if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入"); + var c=source.candidate(); + if(c.warehousePalletId()!=null && !physical.add(c.warehousePalletId()))throw conflict("同一实托不能作为不同来源重复计入本车,请核对真实来源组成"); + var at=now(); var claims=sources.claims(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at); + if(claims.stream().anyMatch(lock -> !Objects.equals(lock.dispatchId(),row.getId()))) throw conflict("所选托盘已被其他有效运输安排占用"); + if(claims.isEmpty()) { + sources.expire(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at); + try { if(sources.claim(IdWorker.getId(),c.customerId(),c.sourcePalletKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),row.getId(),actor.operatorId(),at)!=1) throw conflict("来源占用未保存"); } + catch(DuplicateKeyException error) { throw conflict("所选实托已被并发排车占用,请重新核对"); } + } + var item=item(row,source); if(repository.insertItem(item)!=1) throw conflict("排车明细未保存"); + saved.add(item); + } + sourceRules.bindWholeRelabel(resolved,saved); + appointments.bind(row.getId(),saved,batchAllocations); + var all=new ArrayList<>(current); all.addAll(saved); + refreshCargo(row,all);return saved.stream().map(DispatchItem::getId).toList(); + } + + /** 加货和移货共享同一货量、摘要、审核失效与Fleet容量刷新。 */ + private void refreshCargo(DispatchAggregate row,List all) { + int packages=0; + try { for(var item:all) packages=Math.addExact(packages,item.getPackageCount()); } + catch(ArithmeticException error) { throw bad("本车箱数超过可支持范围"); } + int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count(); + String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")); + var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList(); + if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物"); + var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key())) + .collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops); + } + + /** 冻结真实来源和客户,候选订单号只用于展示,不替代后续逐来源拣货归属。 */ + private DispatchItem item(DispatchAggregate row,DispatchService.WholeSource source) { + var c=source.candidate(); var i=new DispatchItem(); i.setId(IdWorker.getId()); i.setDispatchOrderId(row.getId()); + i.setCustomerId(c.customerId()); i.setCustomerName(c.customerName()); i.setSourceKey(source.sourceKey()); i.setSourceVersion(source.sourceVersion()); i.setSourceSnapshotJson(write(new SourceDisplay(c.containerNo(),c.planNo()))); + i.setPlanId(c.planId()); i.setPlanPalletId(c.planPalletId()); i.setReturnReceiptId(c.returnReceiptId()); i.setWarehousePalletId(c.warehousePalletId()); + i.setOrderId(c.orderId()); i.setOrderNo(c.orderNo()); i.setOrderRefs(c.orderRefs()); i.setInboundCode(c.inboundCode()); i.setPalletNo(c.palletNo()); + i.setOverseasWarehouseId(c.overseasWarehouseId()); i.setOverseasWarehouseName(c.overseasWarehouseName()); + i.setDestinationWarehouseId(c.destinationWarehouseId()); i.setDestinationWarehouseCode(c.destinationWarehouseCode()); i.setDestinationWarehouseName(c.destinationWarehouseName()); + i.setFbaWarehouseCode(c.fbaWarehouseCode()); i.setDestinationType(c.destinationType()); i.setDeliveryAddress(c.deliveryAddress()); i.setPostalCode(c.postalCode()); i.setContactName(c.contactName()); i.setContactPhone(c.contactPhone()); + i.setPackageCount(c.packageCount()); i.setGrossWeightKg(c.grossWeightKg()); i.setVolumeWeightKg(c.volumeWeightKg()); i.setVolumeCbm(c.volumeCbm()); + i.setSourceStage(source.selection().sourceType()==SourceType.RELABEL ? c.returnCartonSource()?"RETURN_RELABEL":"RELABEL" : c.sourceStage()); + i.setCartonMarks(c.relabelCartonMarks()==null?c.cartonMarks():c.relabelCartonMarks()); + if(source.selection().relabelNo()!=null) { i.setSourceBusinessType("RELABEL_ORDER"); i.setSourceBusinessNo(source.selection().relabelNo()); } + i.setCreatedAt(now()); return i; + } + + + /** 读取后验证PDA绑定仓;平台整车读取不按客户头部裁切。 */ + private DispatchAggregate required(Actor actor,String number,boolean lock) { + if(actor==null || actor.operatorId()==null) throw new BizException(ResultCode.UNAUTHORIZED,"请先登录"); + var row=lock?repository.lock(number):repository.find(number); + if(row==null) throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),row.getOverseasWarehouseId())) throw new BizException(ResultCode.FORBIDDEN,"排车单不属于当前绑定仓"); + return row; + } + + /** 普通变更不允许覆盖已执行的真实库存或旧版本。 */ + private static void requireEditing(DispatchAggregate row,long version) { + if(row.getRecordVersion()!=version) throw conflict("排车版本已变化,请重新核对"); + if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物"); + } + + /** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */ + Map actions(Actor actor,DispatchAggregate row) { + var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); + Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); + for(var action:Action.values()) { + boolean state=switch(action) { + case CREATE -> actor.mode()==ActorMode.PLATFORM; + case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING; + case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING; + case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0; + case PICK -> active && stage==Stage.LOADING && review==ReviewState.APPROVED; + case LOADING_COMPLETE -> active && stage==Stage.LOADING && reconciled(row); + case ARRANGE_DELIVERY -> active && stage==Stage.AFTER_LOADING; + case DEPART -> active && stage==Stage.AFTER_LOADING && row.getArrangementKey()!=null; + }; + boolean permitted=hasPermission(actor,action); + gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件")); + } + return Collections.unmodifiableMap(gates); + } + + /** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */ + private static boolean hasPermission(Actor actor,Action action) { + var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false; + if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action) + && auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority())); + if(action==Action.PICK) return false; + String permission=switch(action) { + case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel"; + case DISPATCH_REVIEW -> "platform:fleet:trip:dispatch-review"; case LOADING_REVIEW -> "platform:fleet:trip:loading-review"; + case LOADING_COMPLETE -> PREFIX+"loading-complete"; case DEPART -> PREFIX+"depart"; + case ARRANGE_DELIVERY -> PREFIX+"transfer-delivery"; default -> PREFIX+"edit"; + }; + return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority())); + } + + /** 统计按真整车去重并补齐全部零项。 */ + private static DispatchViews.Statistics statistics(List groups) { + var stages=new EnumMap(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L); + long active=0,cancelled=0,before=0; long[] dispatch=new long[4],loading=new long[4]; + for(var group:groups) { + if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; } + active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum); + if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState()); + dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity(); + loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); } + } + return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before, + new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3]))); + } + + /** 列表不逐行读取明细、附件或派送。 */ + private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) { + return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row)); + } + + /** 混托多订单无法真实分摊重量体积时保持未知,不能给每票复制整托总量。 */ + private List orderDetails(List cargo) { + var result=new ArrayList(); + for(var item:cargo) { + var source=read(item.getSourceSnapshotJson(),SourceDisplay.class); + var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList(); + if(orders.isEmpty()) orders=Collections.singletonList(null); + for(var order:orders) result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),order,item.getInboundCode(),orders.size()==1?item.getGrossWeightKg():null, + orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey())); + } + return List.copyOf(result); + } + + /** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */ + private static Resources resources(FleetExecutionModels.DispatchShell shell) { + var v=shell.vehicle(); var d=shell.driver(); + return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(), + new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(), + new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt()))); + } + + /** 主档选择不接受请求覆盖其车牌和容量。 */ + private static FleetExecutionModels.DispatchVehicle fleetVehicle(DispatchCommands.Vehicle v) { + if(v==null) return null; if(v.mode()==null) throw bad("请选择车辆来源方式"); + return new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),v.capacity()==null?null:v.capacity().palletSpaces(), + v.capacity()==null?null:v.capacity().maxLoadKg(),v.capacity()==null?null:v.capacity().maxVolumeCbm(),v.evidenceFileIds()==null?List.of():List.copyOf(v.evidenceFileIds())); + } + + /** 司机证据是本趟输入,主档事实仍由Fleet提供者核验。 */ + private static FleetExecutionModels.DispatchDriver fleetDriver(DispatchCommands.Driver d) { + if(d==null) return null; if(d.mode()==null) throw bad("请选择司机来源方式"); var e=d.evidence(); + return new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e==null?null:e.licenseNo(),e==null?null:e.licenseExpiresOn(), + e==null||e.fileIds()==null?List.of():List.copyOf(e.fileIds()),e==null?null:e.expectedTaskEndAt()); + } + + /** 只允许平台发起整车写命令。 */ + private static void requirePlatform(Actor actor) { if(actor==null || actor.mode()!=ActorMode.PLATFORM || actor.operatorId()==null) throw new BizException(ResultCode.FORBIDDEN,"仅平台可安排整车作业"); } + /** 保存动作时不可变结果。 */ + private static CommandResult result(DispatchAggregate row,CommandIdentity identity,DispatchResults.Payload payload) { + return new CommandResult(row.getDispatchNo(),identity.action(),identity.idempotencyKey(),row.getRecordVersion(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),Instant.now().truncatedTo(ChronoUnit.MILLIS),Effect.APPLIED,counts(row),payload,false,false); + } + /** 追加原时刻主单快照和操作证据。 */ + private void history(DispatchAggregate row,Actor actor,Action action,String reason) { + if(repository.history(IdWorker.getId(),row,action.name(),actor.operatorId(),reason,write(row),now())!=1) throw conflict("排车操作历史未保存"); + } + /** 当前审核记录,不以版本相等代替轮次有效性。 */ + Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); } + /** 未提交的审核不伪造待审核。 */ + private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); } + /** 整车真实统计。 */ + private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); } + /** 空单永远不能核平为装车完成。 */ + private static boolean reconciled(DispatchAggregate row) { return n(row.getPackageCount())>0 && Objects.equals(row.getPackageCount(),row.getPickedPackageCount()); } + /** 目的地业务标签仅来源于真实目的仓或实际地址。 */ + private String destinationLabel(DispatchItem i) { return "PRIVATE".equals(i.getDestinationType())?i.getDeliveryAddress():trim(i.getDestinationWarehouseCode())==null?i.getFbaWarehouseCode():i.getDestinationWarehouseCode(); } + /** 目的地稳定业务键复用既有地址规范,不信任用户指定路线。 */ + Destination destination(DispatchItem i) { return new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(i.getDestinationType(),i.getDestinationWarehouseCode(),i.getFbaWarehouseCode(),i.getDeliveryAddress(),i.getPostalCode()),i.getDestinationType(),i.getDestinationWarehouseId(),i.getDestinationWarehouseCode(),i.getDestinationWarehouseName(),i.getDeliveryAddress(),i.getPostalCode(),i.getContactName(),i.getContactPhone()); } + /** 严格写入领域快照,失败回滚而不吞字段。 */ + private String write(Object value) { try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("排车快照保存失败",error);} } + /** 严格读取本版快照,不提供旧字段降级解析。 */ + private T read(String value,Class type) { try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("排车快照读取失败",error);} } + /** 数据库实际时间统一保存UTC毫秒。 */ + private static LocalDateTime now() { return utc(Instant.now().truncatedTo(ChronoUnit.MILLIS)); } + /** 可空计划不填假时间。 */ + private static LocalDateTime utc(Instant time) { return time==null?null:LocalDateTime.ofInstant(time,ZoneOffset.UTC); } + /** 读取UTC字段不使用操作者本地时区。 */ + private static Instant instant(LocalDateTime time) { return time==null?null:time.toInstant(ZoneOffset.UTC); } + /** 空统计只作零处理,不用于业务编号回退。 */ + private static int n(Integer value) { return value==null?0:value; } + /** 查询空字符串收敛为无筛选。 */ + private static String trim(String value) { return value==null||value.isBlank()?null:value.trim(); } + /** 明确参数错误。 */ + private static BizException bad(String message) { return new BizException(ResultCode.BAD_REQUEST,message); } + /** 明确版本/状态/事实竞争失败。 */ + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java new file mode 100644 index 000000000..158c648d6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchReadMapper.BatchRow; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.BatchAllocation; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.util.*; + +/** 预约提供者薄合同适配,客户归属只从已持久化真实明细取得。 */ +@Service +@Transactional(propagation=Propagation.MANDATORY) +public class DispatchAppointmentAllocations { + private final AppointmentWorkDispatchPort provider; + public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;} + + /** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */ + public Map candidates(List rows) { + var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList(); + if(identities.isEmpty())return Map.of(); + var result=provider.candidateBatches(identities); + if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"原预约候选批读范围不完整,请重新核对"); + return result; + } + + /** 当前保留分配与全部请求合并后一次预锁;同批混版本或缺版本不能绕过。 */ + public void lock(List retained,List requested) { + var versions=new TreeMap(); + if(retained!=null)for(var row:retained)if(row.batchKey()!=null && row.batchKey().startsWith("FBA_APPOINTMENT:"))add(versions,row.batchKey(),row.recordVersion()); + if(requested!=null)for(var row:requested) { + if(row==null || row.batchKey()==null || !row.batchKey().matches("FBA_APPOINTMENT:[1-9][0-9]*"))throw bad("只能选择原权威FBA预约批次,客户仓引用由服务器生成"); + add(versions,row.batchKey(),row.recordVersion()); + } + provider.lockBatches(Collections.unmodifiableMap(versions)); + } + /** 草稿在来源锁之前锁齐所有车辆分组的全部预约,不能在第二组才新增批次锁。 */ + public void lockCreates(List requests) { + var allocations=new ArrayList(); + for(var request:requests)if(request.batchAllocations()!=null)allocations.addAll(request.batchAllocations()); + lock(List.of(),allocations); + } + /** Provider只增量替换touched;不将同车其他客户原分配删掉。 */ + public void bind(long dispatch,List touched,List requested) { + var items=touched.stream().map(i->new AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList(); + var allocations=requested==null?List.of():requested.stream().map(a-> + new AppointmentWorkDispatchPort.Allocation(a.planPalletId(),a.batchKey(),a.recordVersion(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount())).toList(); + provider.bindItems(dispatch,items,allocations); + } + /** 调用方已锁齐全车原批次且核完整实物,删除只作用于本车指定明细。 */ + public void remove(long dispatch,List items){for(var item:items)provider.removeItem(dispatch,item.getId());} + private static void add(Map versions,String key,Long version) { + if(version==null || version<0)throw bad("预约批次recordVersion必填且不能小于零"); + var existing=versions.putIfAbsent(key,version); + if(existing!=null && !existing.equals(version))throw new BizException(ResultCode.CONFLICT,"同一预约批次版本不一致,请重新核对原批次"); + } + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index 4ec0823cd..136dcf34a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -4,7 +4,7 @@ import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; import feign.FeignException; import jakarta.annotation.Resource; import java.math.BigDecimal; @@ -27,13 +27,13 @@ public class DispatchBolPrintService { private static final int CONTINUATION_PAGE_ROWS = 26; private static final PrintPdfClient.PageSpec A4 = new PrintPdfClient.PageSpec(new BigDecimal("210"), new BigDecimal("297")); - @Resource private DispatchService dispatches; + @Resource private DispatchBolReadService dispatches; @Resource private PrintPdfClient print; @Resource private DomesticWarehouseDirectoryClient warehouses; @Value("${baiyue.security.internal-service-token:}") private String internalToken; public GeneratedPdf generate(String dispatchNo, String requestId) { - DispatchResponse detail = dispatches.platformDetail(dispatchNo); + DispatchBolData detail = dispatches.read(dispatchNo); if (detail.bolLines() == null || detail.bolLines().isEmpty()) { throw new BizException(ResultCode.VALIDATION_FAILED, "排车单没有可打印的BOL明细"); } @@ -54,16 +54,16 @@ public class DispatchBolPrintService { } } - private List documents(DispatchResponse detail) { + private List documents(DispatchBolData detail) { String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); - Map> groups = new LinkedHashMap<>(); - for (DispatchResponse.BolLine line : detail.bolLines()) groups.computeIfAbsent(text(line.fbaWarehouseCode()), ignored -> new ArrayList<>()).add(line); + Map> groups = new LinkedHashMap<>(); + for (DispatchBolData.Line line : detail.bolLines()) groups.computeIfAbsent(line.groupKey(), ignored -> new ArrayList<>()).add(line); List documents = new ArrayList<>(); - for (Map.Entry> group : groups.entrySet()) { - List lines = group.getValue(); + for (Map.Entry> group : groups.entrySet()) { + List lines = group.getValue(); int pages = pageCount(lines.size()); - int total = lines.stream().mapToInt(DispatchResponse.BolLine::packageCount).sum(); - String destination = lines.stream().map(DispatchResponse.BolLine::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); + int total = lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum(); + String destination = lines.stream().map(DispatchBolData.Line::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); int offset = 0; for (int page = 0; page < pages; page++) { int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS; @@ -72,8 +72,8 @@ public class DispatchBolPrintService { .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), - text(detail.fbaAppointmentNo()), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", - group.getKey(), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), + text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", + text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), total, page + 1, pages, rows)); offset = end; } @@ -128,9 +128,9 @@ public class DispatchBolPrintService { return 1 + (rowCount - FIRST_PAGE_ROWS + CONTINUATION_PAGE_ROWS - 1) / CONTINUATION_PAGE_ROWS; } - private String unique(List lines, boolean shipment) { + private String unique(List lines, boolean shipment) { LinkedHashSet values = new LinkedHashSet<>(); - for (DispatchResponse.BolLine line : lines) { + for (DispatchBolData.Line line : lines) { String raw = shipment ? line.fbaShipmentId() : line.poReferenceNo(); if (raw != null) for (String value : raw.split(",")) if (!value.isBlank()) values.add(value.strip()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java new file mode 100644 index 000000000..00c2c2d28 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.service; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; +import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.util.*; +import java.util.stream.Collectors; +/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */ +@Service +public class DispatchBolReadService { + private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json; + public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;} + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchBolData read(String number) { + var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); + Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);} + var cargo=lines.findWholeDispatch(row.getId()); + if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实"); + var confirmations=new LinkedHashMap>(); + for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo()); + var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue()))); + return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(), + row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java new file mode 100644 index 000000000..29c7db77c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java @@ -0,0 +1,102 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels; +import com.baiyue.lastleg.dispatch.port.DispatchResults; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.JsonNodeFactory; +import org.springframework.stereotype.Component; + +import java.time.Instant; + +/** 严格按已持久化Action恢复结果,不依赖进程内类型缓存或默认多态。 */ +@Component +public final class DispatchCommandCodec { + private final ObjectMapper json; + + /** 保留正式时间/Long配置,并防止中间树将资金或实测小数转为double丢精度。 */ + public DispatchCommandCodec(ObjectMapper json) { + this.json = json.copy().enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) + .setNodeFactory(JsonNodeFactory.withExactBigDecimals(true)); + } + + /** 原动作与结果类型必须匹配后才能写入不可变账本。 */ + public String encode(DispatchContracts.CommandResult result) { + if (result == null || result.action() == null || result.result() == null + || !payloadType(result.action()).isInstance(result.result())) { + throw new IllegalArgumentException("排车动作与命令结果类型不匹配"); + } + try { return json.writeValueAsString(result); } + catch (JsonProcessingException error) { throw new IllegalStateException("排车命令结果保存失败", error); } + } + + /** 从数据库result_json解码,禁止猜测历史类型或回退为空成功结果。 */ + public DispatchContracts.CommandResult decode(String stored) { + try { + JsonNode root = json.readTree(stored); + DispatchTypes.Action action = read(root, "action", DispatchTypes.Action.class); + Long version = read(root, "recordVersion", Long.class); + if (version < 0) throw new IllegalArgumentException("命令结果版本不得为负"); + JsonNode payload = required(root, "result"); + for (String field : mandatoryPayloadFields(action)) required(payload, field); + var result = new DispatchContracts.CommandResult( + read(root, "dispatchNo", String.class), action, + read(root, "idempotencyKey", String.class), version, + read(root, "status", DispatchTypes.Status.class), read(root, "stage", DispatchTypes.Stage.class), + read(root, "occurredAt", Instant.class), read(root, "effect", DispatchTypes.Effect.class), + read(root, "counts", DispatchContracts.Counts.class), + json.treeToValue(payload, payloadType(action)), + read(root, "replayed", Boolean.class), read(root, "alreadyArranged", Boolean.class)); + return result; + } catch (JsonProcessingException | IllegalArgumentException error) { + throw new IllegalStateException("排车命令账本结果损坏,不能恢复为成功", error); + } + } + + /** 原结果仅改变重放标记,原版本、阶段、单据与事实时间均保持。 */ + public DispatchContracts.CommandResult replay(String stored) { + var original = decode(stored); + return new DispatchContracts.CommandResult(original.dispatchNo(), original.action(), original.idempotencyKey(), + original.recordVersion(), original.status(), original.stage(), original.occurredAt(), original.effect(), + original.counts(), original.result(), true, original.alreadyArranged()); + } + + /** 封闭动作集合决定具体类型,不启用任意类名反序列化。 */ + private static Class payloadType(DispatchTypes.Action action) { + return switch (action) { + case PICK -> DispatchResults.Picked.class; + case LOADING_COMPLETE -> DispatchResults.Loaded.class; + case DEPART -> DispatchResults.Departed.class; + case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class; + case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, + DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class; + }; + } + + /** 防止将其他动作JSON缺字段默认为零或null后冒充有效结果。 */ + private static String[] mandatoryPayloadFields(DispatchTypes.Action action) { + return switch (action) { + case PICK -> new String[] { "dispatchItemId", "packages", "pickedPackages", "pickingReconciled" }; + case LOADING_COMPLETE -> new String[] { "loadingCompletionHash", "loadingCompletedAt", "confirmedLoad" }; + case DEPART -> new String[] { "departedAt", "shippedPackages", "outbounds", "deliveries" }; + case ARRANGE_DELIVERY -> new String[] { "dispatchNo", "recordVersion", "arrangementKey", "cargoHash", "arrangedAt", "outbounds", "deliveries" }; + default -> new String[] { "dispatchNo" }; + }; + } + + /** 账本缺少字段即失败,不将缺少布尔或数值解释为默认值。 */ + private static JsonNode required(JsonNode root, String field) { + if (root == null || !root.isObject() || !root.hasNonNull(field)) + throw new IllegalArgumentException("排车命令结果缺少字段:" + field); + return root.get(field); + } + + /** 使用正式ObjectMapper解析对应显式类型。 */ + private T read(JsonNode root, String field, Class type) throws JsonProcessingException { + return json.treeToValue(required(root, field), type); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java new file mode 100644 index 000000000..baa4ab651 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java @@ -0,0 +1,86 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.TreeMap; + +/** 命令身份与派送业务分别取摘要,禁止换主体或换版本冒充原请求。 */ +@Component +public final class DispatchCommandHasher { + private static final Set UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services", + "batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds"); + private final ObjectMapper json; + + /** 复用已注册的时间、数值和公开字段序列化配置。 */ + public DispatchCommandHasher(ObjectMapper json) { this.json = json; } + + /** 严格摘要绑定可信主体、动作、业务号及原版本,渠道不改变命令身份。 */ + public String command(DispatchContracts.Actor actor, DispatchTypes.Action action, String dispatchNo, + Long recordVersion, Object request) { + var root = json.createObjectNode(); + root.put("operatorId", actor.operatorId()); + root.put("action", action.name()); + root.put("dispatchNo", dispatchNo); + if (recordVersion == null) root.putNull("recordVersion"); + else root.put("recordVersion", recordVersion); + JsonNode payload = json.valueToTree(request); + if (payload.isObject()) ((com.fasterxml.jackson.databind.node.ObjectNode) payload).remove("idempotencyKey"); + root.set("payload", payload); + return sha256(canonical(root, "")); + } + + /** 同车安排只按业务证据去重,不受操作者、请求键或纯资格版本影响。 */ + public String arrangement(String dispatchNo, DispatchDeliveryModels.ArrangeDelivery request, + DispatchContracts.Resources resources) { + var root = json.createObjectNode(); + root.put("dispatchNo", dispatchNo); + var payload = (com.fasterxml.jackson.databind.node.ObjectNode) json.valueToTree(request); + payload.remove(List.of("idempotencyKey", "recordVersion")); + root.set("payload", payload); + root.set("resources", json.valueToTree(resources)); + return sha256(canonical(root, "")); + } + + /** 来源版本复用同一规范化算法,调用方必须提供完整权威事实。 */ + public String source(Object authoritativeFacts) { return sha256(canonical(json.valueToTree(authoritativeFacts), "")); } + + /** 无序集合按规范内容排序;路线/站点等有序数组必须保留次序。 */ + private String canonical(JsonNode node, String field) { + if (node == null || node.isNull()) return "null"; + if (node.isObject()) { + var values = new TreeMap(); + node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()))); + var result = new ArrayList(); + values.forEach((key, value) -> result.add(quoted(key) + ":" + value)); + return "{" + String.join(",", result) + "}"; + } + if (node.isArray()) { + var values = new ArrayList(); + node.forEach(value -> values.add(canonical(value, ""))); + if (UNORDERED.contains(field)) values.sort(String::compareTo); + return "[" + String.join(",", values) + "]"; + } + if (node.isNumber()) return node.decimalValue().stripTrailingZeros().toPlainString(); + return node.toString(); + } + + /** 使用Jackson转义字段名,避免自制引号破坏规范JSON。 */ + private String quoted(String value) { return json.getNodeFactory().textNode(value).toString(); } + + /** 标准SHA256输出固定小写十六进制,不使用进程相关hashCode。 */ + private static String sha256(String value) { + try { return java.util.HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException error) { throw new IllegalStateException("运行环境缺少SHA256", error); } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java new file mode 100644 index 000000000..b93fc9ef9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchCommandRecord; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.repository.DispatchCommandRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.Objects; +import java.util.Optional; + +/** 严格命令身份和不可变结果;当前权限/绑定仓必须由领域入口先核验。 */ +@Service +public class DispatchCommandLedger { + private final DispatchCommandRepository repository; + private final DispatchCommandCodec codec; + + /** 数据访问经过Repository,编解码不依赖进程内结果对象。 */ + public DispatchCommandLedger(DispatchCommandRepository repository, DispatchCommandCodec codec) { + this.repository = repository; + this.codec = codec; + } + + /** 原键恢复优先于当前版本判断,但绝不向另一主体披露原结果。 */ + @Transactional(propagation = Propagation.MANDATORY) + public Optional replay(DispatchContracts.Actor actor, String dispatchNo, + DispatchContracts.CommandIdentity identity) { + requireIdentity(actor, identity); + DispatchCommandRecord record = repository.find(identity.idempotencyKey()); + if (record == null) return Optional.empty(); + if (!Objects.equals(record.operatorId(), actor.operatorId())) + throw new BizException(ResultCode.FORBIDDEN, "该请求标识不属于当前操作员"); + boolean creatingUnknownNumber = dispatchNo == null && identity.action() == com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.CREATE; + if ((!creatingUnknownNumber && !Objects.equals(record.dispatchNo(), dispatchNo)) || !Objects.equals(record.action(), identity.action().name()) + || record.recordVersion() != identity.recordVersion() || !Objects.equals(record.commandHash(), identity.commandHash())) + throw new BizException(ResultCode.CONFLICT, "原请求标识不能更换排车、动作、版本或内容"); + var result = codec.replay(record.resultJson()); + if (!Objects.equals(result.dispatchNo(), record.dispatchNo()) || result.action() != identity.action() + || !Objects.equals(result.idempotencyKey(), record.idempotencyKey())) + throw new IllegalStateException("排车命令账本身份与持久结果不一致"); + return Optional.of(result); + } + + /** GET恢复原命令:先检查当前主体/目标/原动作权限,再从持久JSON读取。 */ + @Transactional(propagation = Propagation.MANDATORY) + public DispatchContracts.CommandResult readOriginal(DispatchContracts.Actor actor, String dispatchNo, String key, + java.util.Set allowedActions) { + var record = repository.find(key); + if (record == null) throw new BizException(ResultCode.NOT_FOUND,"未找到该请求的已提交结果,请保持原请求核对"); + if (actor == null || !Objects.equals(record.operatorId(),actor.operatorId()) || !Objects.equals(dispatchNo,record.dispatchNo())) + throw new BizException(ResultCode.FORBIDDEN,"无权读取该请求结果"); + var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.valueOf(record.action()); + if (!allowedActions.contains(action)) throw new BizException(ResultCode.FORBIDDEN,"当前没有原动作权限"); + return replay(actor,dispatchNo,new DispatchContracts.CommandIdentity(action,key,record.recordVersion(),record.commandHash())).orElseThrow(); + } + + /** 调用方已完成状态CAS后在同一事务保存结果,任何唯一键冲突必须整体回滚。 */ + @Transactional(propagation = Propagation.MANDATORY) + public void append(Long dispatchId, DispatchContracts.Actor actor, DispatchContracts.CommandIdentity identity, + DispatchContracts.CommandResult result) { + requireIdentity(actor, identity); + if (result.replayed() || result.action() != identity.action() + || !Objects.equals(result.idempotencyKey(), identity.idempotencyKey())) + throw new IllegalArgumentException("只能保存本次命令的原始结果"); + var row = new DispatchCommandRecord(IdWorker.getId(), dispatchId, result.dispatchNo(), identity.action().name(), + actor.operatorId(), identity.idempotencyKey(), identity.recordVersion(), identity.commandHash(), codec.encode(result), + LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS), ZoneOffset.UTC)); + try { + if (repository.insert(row) != 1) throw new IllegalStateException("排车命令结果未保存"); + } catch (DuplicateKeyException error) { + // 不在已经写过主单/费用的事务中吞掉冲突返回成功,外层必须回滚后原键恢复。 + throw new BizException(ResultCode.CONFLICT, "请求标识已使用,请保持原内容核对原结果"); + } + } + + /** 只接受可信主体和服务端产生的完整摘要,不允许空键或伪摘要。 */ + private static void requireIdentity(DispatchContracts.Actor actor, DispatchContracts.CommandIdentity identity) { + if (actor == null || actor.operatorId() == null || actor.operatorId().isBlank()) + throw new BizException(ResultCode.FORBIDDEN, "缺少可信操作身份"); + if (identity == null || identity.action() == null || identity.idempotencyKey() == null + || identity.idempotencyKey().isBlank() || identity.idempotencyKey().length() > 255 + || identity.recordVersion() < 0 || identity.commandHash() == null || !identity.commandHash().matches("[0-9a-f]{64}")) + throw new BizException(ResultCode.BAD_REQUEST, "命令标识、版本或摘要无效"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java new file mode 100644 index 000000000..918b346ec --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository; +import com.baiyue.lastleg.dispatch.mapper.DispatchExecutionReadMapper.RescanCandidate; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 提交后的正常签收汇总;父CAS、唯一完成历史和指定信号确认必须共同提交。 */ +@Service +public class DispatchCompletionService implements DispatchCompletionPort { + private static final Actor SYSTEM=new Actor("SYSTEM_DELIVERY_RECONCILE",ActorMode.PLATFORM,null); + private final DispatchAggregateRepository repository; + private final DispatchExecutionRepository reads; + private final FleetTripExecutionService fleet; + private final DispatchExecutionPort execution; + private final DispatchDeliveryPort delivery; + private final DispatchSignalPort signals; + private final ObjectMapper json; + /** 仅依赖共同端口,禁止持签收子锁回调父流程。 */ + public DispatchCompletionService(DispatchAggregateRepository repository,DispatchExecutionRepository reads,FleetTripExecutionService fleet, + DispatchExecutionPort execution,DispatchDeliveryPort delivery,DispatchSignalPort signals,ObjectMapper json) { + this.repository=repository;this.reads=reads;this.fleet=fleet;this.execution=execution;this.delivery=delivery;this.signals=signals;this.json=json; + } + /** 新事务先父锁后普通读全部已提交事实,失败回滚后才由worker记录退避。 */ + @Override @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) + public CompletionResult reconcile(Signal signal) { + if(signal==null || signal.signalId()==null || signal.signalId()<=0 || signal.dispatchNo()==null || signal.deliveryNo()==null)throw new IllegalArgumentException("签收信号身份不完整"); + var identity=fleet.lockDispatchCompletion(signal.dispatchNo());var row=repository.lock(signal.dispatchNo()); + if(row==null || !Objects.equals(row.getTripId(),identity.tripId()))throw new IllegalStateException("整车身份已变化"); + var context=execution.preview(SYSTEM,signal.dispatchNo());var facts=delivery.normalDeliveryFacts(context); + if(facts==null || !Objects.equals(facts.dispatchNo(),row.getDispatchNo()) || facts.expectedDeliveryNos()==null || !facts.expectedDeliveryNos().contains(signal.deliveryNo()))throw new IllegalStateException("签收信号不属于本车已安排派送"); + if(!Status.ACTIVE.name().equals(row.getStatus()) || !Set.of(Stage.DELIVERING.name(),Stage.DELIVERED.name()).contains(row.getStage()))throw new IllegalStateException("本车尚无合法在途运输事实"); + boolean changed=false;var now=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC); + if(Stage.DELIVERING.name().equals(row.getStage()) && facts.allNormallySigned()) { + if(facts.completedAt()==null || facts.factsHash()==null || facts.factsHash().isBlank() || row.getDepartedAt()==null)throw new IllegalStateException("缺少正常签收完成依据"); + long version=row.getRecordVersion();row.setStage(Stage.DELIVERED.name());row.setDeliveryCompletedAt(LocalDateTime.ofInstant(facts.completedAt(),ZoneOffset.UTC)); + row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(now); + if(repository.saveState(row,version)!=1)throw new IllegalStateException("整车完成版本竞争失败"); + if(repository.history(IdWorker.getId(),row,"NORMAL_DELIVERY_COMPLETED",SYSTEM.operatorId(),null,write(Map.of("dispatch",row,"normalDeliveryFacts",facts)),now)!=1)throw new IllegalStateException("正常签收完成证据未保存"); + changed=true; + } + // 不完整的合法部分签收也只确认本版本通知,未来签收产生自己的新信号。 + signals.acknowledge(signal,Instant.now()); + return new CompletionResult(row.getDispatchNo(),Stage.valueOf(row.getStage()),row.getRecordVersion(),changed,facts.allNormallySigned(), + row.getDeliveryCompletedAt()==null?null:row.getDeliveryCompletedAt().toInstant(ZoneOffset.UTC)); + } + /** 普通读候选不持子锁跨入汇总或重建事务。 */ + @Transactional(readOnly=true) + public List rescanCandidates(int limit) { + if(limit<1 || limit>200)throw new IllegalArgumentException("重扫范围不合法");return reads.rescan(limit); + } + /** 每个缺失信号独立短事务重核真实派送版本,竞争失败不阻塞下一张车。 */ + @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) + public void rescan(RescanCandidate candidate) {signals.enqueue(candidate.dispatchNo(),candidate.deliveryNo(),candidate.deliveryVersion(),Instant.now());} + /** 证据序列化失败必须随主阶段一起回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("正常签收证据保存失败",error);}} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java new file mode 100644 index 000000000..5e39f67a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.DispatchSignalPort; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import java.time.Instant; + +/** 有界、可重复的持久信号消费者,不在子事务持锁期间调用汇总。 */ +@Component +public class DispatchDeliveryCompletionWorker { + private static final Logger LOG=LoggerFactory.getLogger(DispatchDeliveryCompletionWorker.class); + private final DispatchCompletionService completion; + private final DispatchSignalPort signals; + /** 调用独立代理服务,确保失败事务已回滚再持久记录退避。 */ + public DispatchDeliveryCompletionWorker(DispatchCompletionService completion,DispatchSignalPort signals){this.completion=completion;this.signals=signals;} + /** 每批最多100条;单车失败不终止其他车次,已确认信号不会被失败记录重新激活。 */ + @Scheduled(fixedDelayString="${lastleg.dispatch.delivery-completion-poll-ms:5000}") + public void consume() { + for(var signal:signals.due(Instant.now(),100))try{completion.reconcile(signal);}catch(RuntimeException error){ + long delay=Math.min(900L,5L<();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId()); + for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车"); + var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey()); + if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平"); + sourceWorkflow.departWholeSources(context,actor,departed,at); + for(var item:repository.cargo(row.getId()))if(sources.release(row.getId(),item,LocalDateTime.ofInstant(at,ZoneOffset.UTC))<1)throw conflict("原发运来源占用已变化"); + fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion(); + row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(departed.shippedPackages()));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败"); + if(repository.history(IdWorker.getId(),row,Action.DEPART.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"departure",departed)),row.getUpdatedAt())!=1)throw conflict("发车历史未保存"); + var result=new CommandResult(number,Action.DEPART,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.DELIVERING,at,Effect.APPLIED, + new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Departed(at,departed.shippedPackages(),departed.outbounds(),departed.deliveries()),false,false); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 原装车快照不能缺项回退成当前建议值。 */ + private T read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("装车快照读取失败",error);}} + /** 发车证据失败与单据更新一起回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("发车证据保存失败",error);}} + /** 并发、来源或阶段不满足时整条命令失败。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java new file mode 100644 index 000000000..679ff388c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java @@ -0,0 +1,355 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchDraft; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftCommand; +import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; +import com.baiyue.lastleg.dispatch.mapper.DispatchDraftClaimMapper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchDraftRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Isolation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.*; +import java.util.*; +import java.util.function.Consumer; + +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; + +/** 平台单仓多客户草稿;持久命令、服务器来源、排他占用和整批创建共同提交。 */ +@Service +public class DispatchDraftService implements DispatchPlanningPort { + private static final TypeReference> CANDIDATES = new TypeReference<>() { }; + private static final TypeReference> KEYS = new TypeReference<>() { }; + private final DispatchDraftRepository repository; + private final DispatchSourceRepository sources; + private final DispatchService sourceRules; + private final DispatchAggregateService aggregate; + private final DispatchCommandHasher hashes; + private final DispatchCommandCodec commandCodec; + private final ObjectMapper json; + private final Clock clock; + private final AppointmentWorkDispatchPort appointmentProvider; + + /** 只装配草稿仓储和实际来源/主单提供者,不持久化客户端伪造候选。 */ + @org.springframework.beans.factory.annotation.Autowired + public DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules, + DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider) { + this(repository,sources,sourceRules,aggregate,hashes,json,commandCodec,appointmentProvider,Clock.systemUTC()); + } + + /** 测试可控制锁等待跨越的时间边界,生产始终使用UTC系统时钟。 */ + DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules, + DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,Clock clock) { + this.repository=repository;this.sources=sources;this.sourceRules=sourceRules; + this.aggregate=aggregate;this.hashes=hashes;this.json=json;this.commandCodec=commandCodec;this.appointmentProvider=appointmentProvider;this.clock=Objects.requireNonNull(clock); + } + + /** 候选继续委托主流程已有真实查询和权限范围。 */ + @Override public CandidateList candidates(Actor actor,CandidateQuery query){platform(actor);return aggregate.candidates(actor,query);} + + /** 只读已保存来源快照,不把历史版本推断成最新可转换资格。 */ + @Override @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public List drafts(Actor actor){platform(actor);var rows=repository.active(actor.operatorId());var batches=readCandidateBatches(rows.stream().flatMap(r->selected(r).stream()).toList());return rows.stream().map(r->view(r,batches)).toList();} + + /** 服务器生成token,零版本创建结果可在进程重启后按原命令恢复。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft createDraft(Actor actor,CreateDraft request) { + platform(actor);if(request==null || request.warehouseId()==null || !request.warehouseId().matches("[1-9][0-9]*"))throw bad("请选择海外起运仓"); + var command=command(actor,"CREATE_DRAFT","",request.idempotencyKey(),request); + if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class); + var now=now();var row=new DispatchDraft();row.setId(IdWorker.getId()); + row.setDraftToken("DD-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT)); + row.setOperatorId(actor.operatorId());row.setWarehouseId(request.warehouseId());row.setRecordVersion(0L); + row.setPlanIdsJson("[]");row.setContainersJson("[]");row.setSourcesJson("[]");row.setStatus("ACTIVE"); + row.setCreatedAt(now);row.setUpdatedAt(now);row.setExpiresAt(now.plusMinutes(30));repository.insert(row); + return complete(command,view(row)); + } + + /** 新容器只归本拥有者草稿,容量沿既有15/30托选项。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft createContainer(Actor actor,CreateContainer request) { + Objects.requireNonNull(request);capacity(request.capacity()); + return change(actor,"CREATE_CONTAINER",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var all=repository.containers(row.getDraftToken());if(all.size()>=100)throw bad("每个草稿最多100个容器"); + var container=new DispatchDraftContainer();container.setId(IdWorker.getId());container.setDraftId(row.getId());container.setDraftToken(row.getDraftToken()); + container.setSequenceNo(all.stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0)+1); + container.setCapacity(request.capacity());container.setSourceKeysJson("[]");container.setCreatedAt(now());container.setUpdatedAt(now());repository.insert(container); + }); + } + + /** 来源资格和版本取真实提供者,锁定后才取得共享来源/实托占用。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft select(Actor actor,DraftSelection request) { + Objects.requireNonNull(request); + return change(actor,"SELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source()); + if(selected.stream().anyMatch(c->c.sourceKey().equals(key)))throw conflict("来源已在本草稿中,请勿重复选择"); + var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId()); + var selections=new ArrayList<>(selected.stream().map(Candidate::selection).toList());selections.add(request.source()); + var locked=sourceRules.lockDraftSources(row.getWarehouseId(),selections); + requireLease(row.getExpiresAt()); + verifyClaims(row,selected,containers); + var resolved=locked.stream().filter(s->s.sourceKey().equals(key)).findFirst().orElseThrow(()->conflict("所选来源不存在")); + var candidate=candidate(resolved);var next=new ArrayList<>(selected);next.add(candidate); + var keys=new ArrayList<>(keys(container));keys.add(key);requireCapacity(container.getCapacity(),keys,index(next)); + var at=now();sources.expire(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at); + if(!sources.claims(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at).isEmpty())throw conflict("所选来源或实托已被其他作业占用"); + requireLease(row.getExpiresAt()); + try {repository.claim(IdWorker.getId(),row,container.getId(),new DispatchDraftRepository.DispatchServiceSource(candidate.customerId(),key, + resolved.candidate().planPalletId(),resolved.candidate().warehousePalletId(),resolved.candidate().returnReceiptId()),at);} + catch(DuplicateKeyException error){throw conflict("所选来源或实托已被其他作业占用");} + container.setSourceKeysJson(write(keys));container.setUpdatedAt(at);repository.layout(container);row.setSourcesJson(write(next)); + }); + } + + /** 解除只认原容器与原来源完整身份;来源变旧不阻止拥有者释放自己的占用。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft unselect(Actor actor,DraftSelection request) { + Objects.requireNonNull(request); + return change(actor,"UNSELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source()); + var current=index(selected).get(key);if(current==null || !Objects.equals(current.selection(),request.source()))throw conflict("原来源身份或版本不一致"); + var container=container(repository.containers(row.getDraftToken()),request.draftContainerId());var keys=new ArrayList<>(keys(container)); + if(!keys.remove(key))throw conflict("来源不属于本容器"); + if(repository.release(row,key,container.getId(),now())!=1)throw conflict("原草稿占用已变化"); + container.setSourceKeysJson(write(keys));container.setUpdatedAt(now());repository.layout(container); + row.setSourcesJson(write(selected.stream().filter(c->!c.sourceKey().equals(key)).toList())); + }); + } + + /** 完整布局只能重排当前服务器来源和容器,不能用JSON新增货物或绕过容量。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft saveSnapshot(Actor actor,DraftSnapshot request) { + Objects.requireNonNull(request); + return change(actor,"SNAPSHOT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + var selected=selected(row);var old=repository.containers(row.getDraftToken()); + if(request.containers()==null || request.containers().size()!=old.size())throw bad("布局必须包含全部原容器"); + var remaining=new HashSet<>(old.stream().map(DispatchDraftContainer::getId).toList());var seen=new HashSet();var sequence=new HashSet(); + var mapping=new HashMap();var byKey=index(selected); + for(var c:request.containers()) { + if(c==null || !remaining.remove(c.draftContainerId()) || c.sequenceNo()<1 || !sequence.add(c.sequenceNo()) || c.sourceKeys()==null)throw bad("容器身份或顺序不完整"); + capacity(c.capacity());requireCapacity(c.capacity(),c.sourceKeys(),byKey); + for(var key:c.sourceKeys()){if(!seen.add(key))throw bad("同一来源不能重复放入容器");mapping.put(key,c.draftContainerId());} + } + if(!remaining.isEmpty() || !seen.equals(byKey.keySet()))throw conflict("布局只能完整移动当前已锁来源"); + if(request.planIds()==null || request.planIds().stream().anyMatch(id->id==null || id<=0) || request.planIds().size()>500)throw bad("计划筛选不合法"); + sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList()); + var claims=verifyClaims(row,selected,old);var at=now(); + for(var claim:claims)if(!Objects.equals(claim.containerId(),mapping.get(claim.sourceKey())))repository.move(row,claim,mapping.get(claim.sourceKey()),at); + repository.stageSequences(row.getDraftToken()); + for(var c:request.containers()) {var existing=container(old,c.draftContainerId());existing.setSequenceNo(c.sequenceNo());existing.setCapacity(c.capacity()); + existing.setSourceKeysJson(write(c.sourceKeys()));existing.setUpdatedAt(at);repository.layout(existing);} + row.setPlanIdsJson(write(request.planIds())); + }); + } + + /** 仅移除空容器;来源必须通过显式解除动作处理。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft removeContainer(Actor actor,String token,Long id,DispatchCommands.ReasonCommand request) { + Objects.requireNonNull(request);if(request.reason()==null || request.reason().isBlank())throw bad("移除容器原因必填"); + return change(actor,"REMOVE_CONTAINER",token,request.recordVersion(),request.idempotencyKey(),Arrays.asList(token,id,request),row->{ + var container=container(repository.containers(token),id); + if(!keys(container).isEmpty() || repository.claims(row,now()).stream().anyMatch(c->Objects.equals(c.containerId(),id)))throw conflict("仅空容器可以删除"); + repository.remove(token,container.getId()); + }); + } + + /** 释放原拥有者草稿,绝不按过期token释放后来归属正式单或其他人的记录。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public Draft release(Actor actor,DraftRelease request) { + Objects.requireNonNull(request); + return change(actor,"RELEASE",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ + repository.release(row,null,null,now());row.setStatus("RELEASED");row.setReleasedAt(now());row.setSourcesJson("[]"); + for(var c:repository.containers(row.getDraftToken())){c.setSourceKeysJson("[]");c.setUpdatedAt(now());repository.layout(c);} + }); + } + + /** 全组先资源后来源,随后释放原草稿占用并调用真实整车创建;任一失败全批回滚。 */ + @Override @Transactional(isolation=Isolation.READ_COMMITTED) + public ConvertedDraft convert(Actor actor,ConvertDraft request) { + Objects.requireNonNull(request);platform(actor); + var command=command(actor,"CONVERT",request.draftToken(),request.idempotencyKey(),request); + if(command.getResultJson()!=null)return readConverted(command.getResultJson(),request.draftToken()); + var row=owned(actor,request.draftToken(),request.recordVersion());var deadline=row.getExpiresAt();var selected=selected(row);var containers=repository.containers(row.getDraftToken()); + var nonempty=containers.stream().filter(c->!keys(c).isEmpty()).toList(); + if(request.groups()==null || request.groups().isEmpty() || request.groups().size()!=nonempty.size())throw bad("须一次转换全部非空容器"); + var byId=new TreeMap();var childKeys=new HashSet(); + for(var group:request.groups()) { + if(group==null || group.dispatch()==null || group.draftContainerId()==null || byId.putIfAbsent(group.draftContainerId(),group)!=null)throw bad("转换分组缺失或重复"); + var c=container(nonempty,group.draftContainerId());var create=group.dispatch(); + if(!Objects.equals(create.warehouseId(),row.getWarehouseId()))throw conflict("整批转换必须使用草稿原起运仓"); + if(create.idempotencyKey()==null || create.idempotencyKey().isBlank() || !childKeys.add(create.idempotencyKey()))throw bad("每辆车须有独立创建命令键"); + var original=selected.stream().filter(s->keys(c).contains(s.sourceKey())).map(Candidate::selection).toList(); + if(!selectionIndex(original).equals(selectionIndex(create.cargo())))throw conflict("创建货物必须精确等于原容器来源及版本"); + requireCapacity(c.getCapacity(),keys(c),index(selected)); + } + if(!byId.keySet().equals(new TreeSet<>(nonempty.stream().map(DispatchDraftContainer::getId).toList())))throw bad("转换必须覆盖全部原容器"); + // 该提供者只锁全批资源,不写主单;绝不能先锁来源再锁下一辆车的资源。 + aggregate.lockDraftCreationResources(actor,byId.values().stream().map(DraftVehicle::dispatch).toList()); + sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList()); + requireLease(deadline); + verifyClaims(row,selected,containers); + if(repository.release(row,null,null,now())!=selected.size())throw conflict("草稿来源占用未完整核齐"); + var results=new ArrayList(); + for(var group:byId.values()) {var result=aggregate.create(actor,group.dispatch()); + if(result==null || result.replayed() || result.action()!=DispatchTypes.Action.CREATE)throw conflict("子创建命令已被其他批次使用"); + results.add(new ConvertedVehicle(group.draftContainerId(),result));} + long version=row.getRecordVersion();row.setRecordVersion(Math.addExact(version,1));row.setStatus("CONVERTED");row.setReleasedAt(now());row.setUpdatedAt(now());repository.save(row,version); + var result=complete(command,new ConvertedDraft(row.getDraftToken(),List.copyOf(results),false)); + requireLease(deadline);return result; + } + + /** 草稿写操作统一先恢复原命令,再校验当前拥有者、生命周期与公共版本。 */ + private Draft change(Actor actor,String action,String token,long version,String key,Object request,Consumer mutation) { + platform(actor);var command=command(actor,action,token,key,request);if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class); + var row=owned(actor,token,version);var deadline=row.getExpiresAt();mutation.accept(row);long before=row.getRecordVersion(); + requireLease(deadline); + var at=now();row.setRecordVersion(Math.addExact(before,1));row.setUpdatedAt(at); + if("ACTIVE".equals(row.getStatus())){ + row.setExpiresAt(at.plusMinutes(30));int renewed=repository.renew(row,at); + requireLease(deadline); + var claims=verifyClaims(row,selected(row),repository.containers(token)); + // 部分驱动对未改变值返回0,最终以锁内有效行、精确归属和完整到期时间证明续约;不能只延长根。 + if(renewed<0 || renewed>claims.size())throw conflict("草稿来源续约集合不完整"); + } + repository.save(row,before);var result=complete(command,view(row));requireLease(deadline);return result; + } + + /** 同键完整身份不一致拒绝,结果未知时只读取数据库原结果,不推断未执行。 */ + private DispatchDraftCommand command(Actor actor,String action,String token,String key,Object payload) { + if(key==null || key.isBlank() || key.length()>128)throw bad("命令键必填且最多128字符"); + if(token==null || token.length()>128)throw bad("草稿标识无效"); + var command=new DispatchDraftCommand();command.setId(IdWorker.getId());command.setIdempotencyKey(key);command.setOperatorId(actor.operatorId()); + command.setAction(action);command.setTargetToken(token);command.setRequestHash(hashes.source(Arrays.asList(actor.operatorId(),actor.mode(),action,token,payload)));command.setCreatedAt(now()); + var saved=repository.reserve(command); + if(saved==null || !Objects.equals(saved.getOperatorId(),actor.operatorId()) || !Objects.equals(saved.getAction(),action) + || !Objects.equals(saved.getTargetToken(),token) || !Objects.equals(saved.getRequestHash(),command.getRequestHash()))throw conflict("相同命令键的主体、动作、版本或载荷不同"); + if(!Objects.equals(saved.getId(),command.getId()) && saved.getResultJson()==null)throw new IllegalStateException("已提交草稿命令缺少原结果"); + return saved; + } + + /** 精确拥有者查询避免暴露他人草稿;正常变更不复活过期或已转换状态。 */ + private DispatchDraft owned(Actor actor,String token,long version) { + var row=repository.lock(token,actor.operatorId());if(row==null)throw new BizException(ResultCode.NOT_FOUND,"草稿不存在或不属于当前操作者"); + if(!"ACTIVE".equals(row.getStatus()))throw conflict("草稿已释放或转换");requireLease(row.getExpiresAt()); + if(row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("草稿版本已变化,请刷新");return row; + } + + /** 来源、容器与实际草稿占用逐项相等,不以JSON或总数相等替代归属。 */ + private List verifyClaims(DispatchDraft row,List selected,List containers) { + var claims=repository.claims(row,now());var mapping=new HashMap(); + for(var c:containers)for(var key:keys(c))if(mapping.putIfAbsent(key,c.getId())!=null)throw conflict("草稿来源被重复放入容器"); + var expected=index(selected);if(!mapping.keySet().equals(expected.keySet()) || claims.size()!=expected.size())throw conflict("草稿来源及占用未完整对应"); + var seen=new HashSet();for(var claim:claims){var c=expected.get(claim.sourceKey()); + if(c==null || !seen.add(claim.sourceKey()) || !Objects.equals(c.customerId(),claim.customerId()) + || !Objects.equals(c.selection().warehousePalletId(),claim.palletId()) || !Objects.equals(mapping.get(claim.sourceKey()),claim.containerId()) + || !Objects.equals(row.getExpiresAt(),claim.expiresAt()))throw conflict("草稿原占用身份或有效期已变化");} + return claims; + } + + /** 公开候选快照沿主流程同一字段映射,所有身份和版本来自锁内真实来源。 */ + private Candidate candidate(DispatchService.WholeSource source) { + var r=source.candidate();var d=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode()), + r.destinationType(),r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.deliveryAddress(),r.postalCode(),r.contactName(),r.contactPhone()); + var selection=new DispatchCommands.CargoSelection(source.selection().sourceType(),source.selection().planPalletId(),source.selection().returnReceiptId(),source.selection().relabelNo(),r.warehousePalletId(),source.sourceVersion()); + var candidate=new Candidate(selection,source.sourceKey(),source.sourceVersion(),r.customerId(),r.customerName(),r.overseasWarehouseId(),r.overseasWarehouseCode(),r.overseasWarehouseName(), + r.orderNo(),r.inboundCode(),r.planNo(),r.palletNo(),r.containerNo(),d,r.packageCount(),r.grossWeightKg(),r.volumeCbm(),"SOURCE_SUGGESTION",List.of(),List.of()); + return projectCandidate(candidate,readCandidateBatches(List.of(candidate))); + } + + /** 单容器容量按实际托盘去重;未收货计划托盘按真实来源键计位。 */ + private static void requireCapacity(int capacity,List keys,Map sources) { + var physical=new HashSet();var seen=new HashSet(); + for(var key:keys){var source=sources.get(key);if(source==null || !seen.add(key))throw bad("布局包含未知或重复来源"); + physical.add(source.selection().warehousePalletId()==null?key:"PALLET:"+source.selection().warehousePalletId());} + if(physical.size()>capacity)throw conflict("本容器实托数量超过容量"); + } + /** 只接受既有15/30托容器,不将计划容量当成装载实测。 */ + private static void capacity(int capacity){if(capacity!=15 && capacity!=30)throw bad("容器容量仅支持15托或30托");} + /** 按来源键核对完整原始选择,缺失或重复不能退化为部分转换。 */ + private static Map selectionIndex(List selections){ + if(selections==null)throw bad("创建货物来源必填");var result=new TreeMap(); + for(var s:selections)if(result.putIfAbsent(DispatchService.wholeSourceKey(s),s)!=null)throw bad("来源重复");return result; + } + /** 草稿内来源键唯一,不能以覆盖Map掩盖重复持久数据。 */ + private static Map index(List selected){var result=new TreeMap();for(var c:selected)if(result.putIfAbsent(c.sourceKey(),c)!=null)throw conflict("草稿来源重复");return result;} + /** 容器必须是本草稿中实际已创建的服务器身份。 */ + private static DispatchDraftContainer container(List containers,Long id){if(id==null)throw bad("容器标识必填");return containers.stream().filter(c->Objects.equals(c.getId(),id)).findFirst().orElseThrow(()->conflict("容器不属于本草稿"));} + /** 只读服务器持久来源,不解析客户端自定义候选。 */ + private List selected(DispatchDraft row){return read(row.getSourcesJson(),CANDIDATES);} + /** 容器布局只保存来源键,实际资格取草稿服务器来源。 */ + private List keys(DispatchDraftContainer row){return read(row.getSourceKeysJson(),KEYS);} + /** 返回公开单一版本及来源快照,不泄露内部命令或存储字段。 */ + private Draft view(DispatchDraft row){return view(row,readCandidateBatches(selected(row)));} + /** 整页复用一份权威批读;不改来源摘要、草稿版本、占用或命令结果的重放语义。 */ + private Draft view(DispatchDraft row,Map batches){return new Draft(row.getDraftToken(),row.getRecordVersion(),row.getStatus(),row.getWarehouseId(),repository.containers(row.getDraftToken()).stream() + .map(c->new DraftContainer(c.getId(),c.getSequenceNo(),c.getCapacity(),keys(c))).toList(),selected(row).stream().map(c->projectCandidate(c,batches)).toList(),row.getExpiresAt().toInstant(ZoneOffset.UTC));} + /** 真实客户及计划托盘来自服务器来源,整页一次调用原Provider。 */ + private Map readCandidateBatches(List candidates) { + var identities=candidates.stream().filter(c->c.selection().planPalletId()!=null).map(c->new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())).distinct().toList(); + var result=new LinkedHashMap(); + for(int offset=0;offset batches) { + if(c.selection().planPalletId()==null)return c; + var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())); + var reasons=new LinkedHashSet(); + if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons()); + var refs=result==null?List.of():result.appointmentBatches().stream().map(b->new BatchReference(b.batchKey(),b.batchNo(),b.customerId(), + b.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),b.recordVersion())).toList(); + return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),refs,List.copyOf(reasons)); + } + /** 原结果完整保存,异常时随所有业务写入一起回滚。 */ + private T complete(DispatchDraftCommand command,T result){repository.complete(command.getId(),write(result));return result;} + /** 原批结果的每张车使用公共Action编解码器恢复,不能让抽象Payload触发当前快照或重新创建回退。 */ + private ConvertedDraft readConverted(String stored,String expectedToken) { + try { + var root=json.readTree(stored); + if(root==null || !root.isObject() || !root.hasNonNull("draftToken") || !root.hasNonNull("vehicles") + || !root.get("vehicles").isArray() || root.get("vehicles").isEmpty() || !root.hasNonNull("replayed") + || !root.get("replayed").isBoolean())throw new IllegalArgumentException("原转换结果字段不完整"); + String token=json.treeToValue(root.get("draftToken"),String.class); + if(!Objects.equals(token,expectedToken))throw new IllegalArgumentException("原转换结果不属于本草稿"); + var vehicles=new ArrayList();var ids=new HashSet(); + for(var vehicle:root.get("vehicles")) { + if(!vehicle.isObject() || !vehicle.hasNonNull("draftContainerId") || !vehicle.hasNonNull("dispatch"))throw new IllegalArgumentException("原转换车辆字段不完整"); + Long id=json.treeToValue(vehicle.get("draftContainerId"),Long.class); + if(id==null || id<=0 || !ids.add(id))throw new IllegalArgumentException("原转换容器身份不完整"); + var dispatch=commandCodec.decode(vehicle.get("dispatch").toString()); + if(dispatch.action()!=DispatchTypes.Action.CREATE)throw new IllegalArgumentException("原转换车辆动作不是创建"); + vehicles.add(new ConvertedVehicle(id,dispatch)); + } + // 仅批次返回标记为重放,子车仍是不可变原结果,原版本/时间/载荷逐项保留。 + return new ConvertedDraft(token,List.copyOf(vehicles),true); + }catch(JsonProcessingException | IllegalArgumentException error){throw new IllegalStateException("草稿转换原结果损坏",error);} + } + /** 复用配置好的时间和Long序列化,持久结果不得保留内存对象引用。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("草稿结果保存失败",error);}} + /** 进程重启后从不可变JSON恢复原公开结果。 */ + private T read(String value,Class type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("草稿结果损坏",error);}} + /** 来源和布局使用封闭类型读取,非法存储不能按空草稿降级。 */ + private T read(String value,TypeReference type){try{return json.readValue(value,type);}catch(JsonProcessingException error){throw new IllegalStateException("草稿来源损坏",error);}} + /** 草稿只属于平台操作者,客户和PDA不能复用整车写入口。 */ + private static void platform(Actor actor){if(actor==null || actor.operatorId()==null || actor.operatorId().isBlank() || actor.mode()!=DispatchTypes.ActorMode.PLATFORM)throw new BizException(ResultCode.FORBIDDEN,"仅平台操作者可操作草稿");} + /** 存储时间统一为UTC,本地进程时区不改变过期或幂等身份。 */ + private LocalDateTime now(){return LocalDateTime.ofInstant(clock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS),ZoneOffset.UTC);} + /** 等待锁、续约或写入后仍须在原有效期内,不能在过期后复活占用再返回成功。 */ + private void requireLease(LocalDateTime deadline){if(deadline==null || !deadline.isAfter(now()))throw conflict("草稿已过期,请重新创建并核对来源");} + /** 参数错误采用统一业务结果码。 */ + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} + /** 状态或依据变化不能被解释为新的成功命令。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java new file mode 100644 index 000000000..56df18287 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchPdaReadRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** PDA按绑定仓读取真正整车,列表批量装配全部明细而不逐行请求详情。 */ +@Service +public class DispatchPdaQueryService { + private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow; + /** 共用主流程资格,PDA不维护第二套版本和阶段。 */ + public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;} + /** 列表只展示绑定仓实际作业,阶段使用共同枚举。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public List list(Actor actor,Stage stage){require(actor);return assemble(actor,repository.headers(actor.boundWarehouseId(),null,stage==null?null:stage.name()));} + /** 单车也先在SQL约束绑定仓,其他仓业务编号不可用于读取。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchViews.PdaTask task(Actor actor,String number) { + require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); + var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); + } + /** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */ + private List assemble(Actor actor,List roots) { + if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号"); + var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId)); + var picked=repository.picked(ids).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var invalid=repository.invalid(ids).stream().collect(Collectors.toMap(p->p.dispatchId(),p->p.quantity())); + var positions=repository.positions(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(p->p.getCustomerId()+":"+p.getPalletId())); + var cartons=repository.cartons(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(c->c.itemId()));var result=new ArrayList(); + for(var root:roots) { + var items=cargo.getOrDefault(root.getId(),List.of());var detail=new ArrayList(); + for(var item:items) { + var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet()); + boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType()); + var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream() + .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages())+(relabel?n(p.getHoldPackages()):0),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); + var sourceCartons=cartons.getOrDefault(item.getId(),List.of()).stream().filter(c->marks.contains(c.cartonMark())) + .map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList(); + detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons)); + } + boolean complete=n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0 + && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())); + var actions=new EnumMap(workflow.actions(actor,root)); + if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平")); + else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); + var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); + // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 + result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); + } + return List.copyOf(result); + } + /** 仓和操作者均来自受信身份,不接收请求自报。 */ + private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} + /** 缺失计数不作为业务标识使用。 */ + private static long n(Number value){return value==null?0:value.longValue();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java new file mode 100644 index 000000000..68e058837 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.pda.entity.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.time.temporal.ChronoUnit; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ +@Service +public class DispatchPickingService { + private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; + private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; + private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; + private final DispatchCommandLedger commands;private final DispatchCommandHasher hashes;private final ObjectMapper json; + /** 库存命令与来源归属继续使用既有真实生产者,不用请求数量伪造库存。 */ + public DispatchPickingService(DispatchAggregateService workflow,DispatchAggregateRepository repository,DispatchSourceRepository sources, + DispatchPickingRepository picking,DispatchPickOrderAttributionService attribution,InventoryCommandService inventory, + ReturnRelabelDispatchStock returns,DispatchCommandLedger commands,DispatchCommandHasher hashes,ObjectMapper json) { + this.workflow=workflow;this.repository=repository;this.sources=sources;this.picking=picking;this.attribution=attribution;this.inventory=inventory;this.returns=returns;this.commands=commands;this.hashes=hashes;this.json=json; + } + /** 原键恢复先于当前版本/阶段,全部写入跟随同一根事务回滚。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult pick(Actor actor,String number,long itemId,DispatchCommands.Pick request) { + if(actor==null || actor.mode()!=ActorMode.PDA || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可扫码"); + var context=workflow.lockForArrangement(actor,number);var row=repository.lock(number); + var identity=new CommandIdentity(Action.PICK,request.idempotencyKey(),request.recordVersion(),hashes.command(actor,Action.PICK,number,request.recordVersion(),Arrays.asList(itemId,request))); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING + || context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码"); + if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法"); + try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");} + var item=repository.cargo(row.getId()).stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在")); + if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓"); + materialize(row.getId(),item); + var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC); + if(task==null) { + task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId()); + task.setTaskNo(number); + task.setStatus("PICKING");task.setWarehouseId(actor.boundWarehouseId());task.setPushedAt(at);task.setCreatedAt(at);task.setUpdatedAt(at); + if(picking.createTask(task)!=1)throw conflict("本车作业未保存"); + } + if(!Objects.equals(task.getWarehouseId(),actor.boundWarehouseId()))throw conflict("本车作业仓已变化"); + long before=picking.itemPicked(item.getCustomerId(),task.getId(),itemId); + if(request.packages()>item.getPackageCount()-before)throw conflict("实际扫码超过本来源应装箱数"); + boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage()); + if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); + long pickId=IdWorker.getId();Long returnCartonId=null; + if(nativeReturn) { + attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); + returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); + } else { + var position=picking.position(request.positionId()); + if(position==null || !Objects.equals(item.getCustomerId(),position.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),position.getPalletId()) || !Objects.equals(actor.boundWarehouseId(),position.getOverseasWarehouseId()))throw conflict("实际库存仓位不属于本车本客户实托"); + Long fallback="RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null; + attribution.preparePicking(item.getCustomerId(),request.positionId(),fallback); + var actual=sources.stock(item.getCustomerId(),item.getWarehousePalletId(),true).stream().filter(s->s.id()==request.positionId()).findFirst().orElseThrow(()->conflict("实际库存仓位已移动")); + if(!Objects.equals(actual.warehouseId(),actor.boundWarehouseId()))throw conflict("实际库存起运仓已变化"); + String base="DISPATCH:"+number+":"+itemId+":"+request.idempotencyKey(); + if(fallback!=null)inventory.release(item.getCustomerId(),actor.operatorId(),move(request,number,item.getSourceBusinessNo(),base+":RELABEL_RELEASE")); + inventory.allocate(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":ALLOCATE")); + inventory.pick(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":PICK")); + inventory.ship(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":SHIP")); + } + var pick=new DispatchPdaPick();pick.setId(pickId);pick.setCustomerId(item.getCustomerId());pick.setTaskId(task.getId());pick.setDispatchItemId(itemId); + pick.setInventoryPositionId(request.positionId());pick.setReturnCartonId(returnCartonId);pick.setPackages(request.packages());pick.setProductQuantity(request.productQuantity());pick.setIdempotencyKey(request.idempotencyKey());pick.setPickedAt(at);pick.setCreatedAt(at); + if(picking.pick(pick)!=1)throw conflict("真实扫码明细未保存"); + if(nativeReturn)attribution.attributeNativeReturn(item.getCustomerId(),pick,item.getOrderId()); + else attribution.attribute(item.getCustomerId(),pick,"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null,item.getReturnReceiptId()); + task.setOperatorId(actor.operatorId());task.setStatus("PICKING");task.setUpdatedAt(at);if(picking.saveTask(task)!=1)throw conflict("本车作业进度未保存"); + long version=row.getRecordVersion();row.setPickedPackageCount(Math.toIntExact(repository.realPicked(row.getId())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); + if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败"); + boolean complete=workflow.preview(actor,number).pickingReconciled(); + if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存"); + var result=new CommandResult(number,Action.PICK,request.idempotencyKey(),row.getRecordVersion(),Status.ACTIVE,Stage.LOADING,at.toInstant(ZoneOffset.UTC),Effect.APPLIED, + new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false); + commands.append(row.getId(),actor,identity,result);return result; + } + /** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */ + private void materialize(long dispatch,DispatchItem item) { + if(item.getWarehousePalletId()!=null)return; + if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托"); + var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); + if(orders.isEmpty())throw conflict("原计划缺少真实订单组成"); + for(var id:orders)if(sources.lockOrder(item.getCustomerId(),id)==null)throw conflict("原计划订单已变化或暂扣"); + if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0); + if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) + || !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType()) + || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对"); + if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单"); + try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC))!=1)throw conflict("原计划来源占用已变化");} + catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");} + item.setWarehousePalletId(actual.warehousePalletId());item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); + if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存"); + } + /** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */ + private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());} + /** 不可变扫码证据序列化失败随根命令回滚。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("扫码证据保存失败",error);}} + /** 实际来源或版本冲突不能降级为成功。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} + /** 明确请求值错误。 */ + private static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index fd2359dfe..f8ed8cf0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -10,16 +10,12 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; import com.baiyue.lastleg.dispatch.entity.DispatchDraft; -import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper; -import com.baiyue.lastleg.dispatch.dto.DispatchContainerResponse; -import com.baiyue.lastleg.dispatch.dto.DispatchDraftResponse; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; @@ -52,6 +48,191 @@ import java.util.stream.Collectors; @Service public class DispatchService { + @Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository wholeSources; + @Resource private DispatchCommandHasher wholeHashes; + + /** 整车一次选择的真实客户来源,未持久化客户子排车。 */ + public record WholeSource(DispatchPalletCandidate candidate, + com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { } + + /** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */ + public List wholeCandidates(String operator, + com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) { + validateSourceStage(query.sourceStage()); + if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream() + .noneMatch(d -> query.draftToken().equals(d.getDraftToken()))) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源"); + var result=new java.util.ArrayList(); + if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query)); + if(query.planIds()==null || query.planIds().isEmpty()) { + if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query)); + if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) { + var rows=wholeSources.availableRelabel(query); + if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); + result.addAll(measuredRelabelCandidates(rows)); + result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo())); + } + } + if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); + return result.stream().filter(row -> query.destinationWarehouseCode()==null + || query.destinationWarehouseCode().equals(row.fbaWarehouseCode())) + .sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),"")) + .thenComparing(row -> Objects.toString(row.orderNo(),"")) + .thenComparing(row -> Objects.toString(row.palletNo(),""))).toList(); + } + + /** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public List lockWholeSources(String warehouseId, + List selections) { + return resolveWholeSources(warehouseId,selections,true,500); + } + + /** 草稿可逐托暂存换标选择,正式建单仍要求本申请全部托盘,事实校验和锁序共用。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public List lockDraftSources(String warehouseId,List selections) { + return resolveWholeSources(warehouseId,selections,false,1000); + } + + /** 先锁全部申请/来源订单,再按物理托盘顺序锁来源根和库存,不能按分组交错。 */ + private List resolveWholeSources(String warehouseId,List selections,boolean completeRelabel,int limit) { + if (selections == null || selections.isEmpty()) return List.of(); + if (selections.size() > limit) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"选择来源超过完整核验上限"); + var chosen = new java.util.LinkedHashMap(); + var relabelGroups = selections.stream().filter(s -> s != null && s.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.RELABEL) + .collect(Collectors.groupingBy(s -> Objects.toString(s.relabelNo(), ""), java.util.TreeMap::new, Collectors.toList())); + var relabelRows = new java.util.HashMap>(); + for (var group : relabelGroups.entrySet()) { + String customer = wholeSources.relabelOwner(group.getKey()); + var application = customer == null ? null : mutations().lockRelabel(customer, group.getKey()); + if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus()) || !"TRUCK".equals(application.getDeliveryMethod()) + || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"换标申请尚不具备排车资格"); + relabelBindingGuard.requireBound(application); + relabelMeasurements.requireMeasured(application); + var complete = java.util.stream.Stream.concat(measuredRelabelCandidates(mutations().relabelCandidates(customer, group.getKey())).stream(), + returnRelabelStock.candidates(customer,null,group.getKey()).stream()).toList(); + var requested = group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection::warehousePalletId).collect(Collectors.toSet()); + var available=complete.stream().map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()); + if (requested.contains(null) || requested.size() != group.getValue().size() || complete.isEmpty() || !available.containsAll(requested) + || completeRelabel && !requested.equals(available)) + throw new ResponseStatusException(HttpStatus.CONFLICT,"换标排车须完整选择本申请全部应排托盘"); + relabelRows.put(group.getKey(), complete); + } + for (var selection : selections) { + String key = wholeSourceKey(selection); + if (chosen.containsKey(key)) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"同一来源不能重复选择"); + DispatchPalletCandidate row; + if (selection.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.INBOUND_PLAN) { + String customer = requireSingleCustomerScope(wholeSources.planOwners(selection.planPalletId()), "所选来源"); + row = requireWholeCandidate(wholeSources.selectedPlan(customer, selection.planPalletId())); + } else if (selection.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.RETURN_RECEIPT) { + String customer = requireSingleCustomerScope(wholeSources.returnOwners(selection.returnReceiptId()), "所选退回来源"); + row = requireWholeCandidate(wholeSources.selectedReturn(customer, selection.returnReceiptId())); + } else { + row = relabelRows.getOrDefault(selection.relabelNo(), List.of()).stream() + .filter(c -> Objects.equals(c.warehousePalletId(), selection.warehousePalletId())).findFirst() + .orElseThrow(() -> new ResponseStatusException(HttpStatus.CONFLICT,"换标托盘来源已变化")); + } + if (!Objects.equals(warehouseId, row.overseasWarehouseId()) || row.packageCount() == null || row.packageCount() <= 0) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物必须属于本车起运仓且有真实可排数量"); + if(selection.warehousePalletId()!=null && !Objects.equals(selection.warehousePalletId(),row.warehousePalletId())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选实托身份已变化,请重新核对来源"); + if (row.customerId() == null || !row.customerId().matches("[1-9][0-9]*")) + throw new ResponseStatusException(HttpStatus.CONFLICT,"来源缺少真实客户归属"); + chosen.put(key, new WholeSource(row, selection, key, null)); + } + // 先收齐所有客户/所有实托的来源订单锁,不在拿到库存锁后再回头取另一订单。 + var owners = new java.util.TreeMap(); + for (var source : chosen.values()) { + var row = source.candidate(); + var ids = source.selection().sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.INBOUND_PLAN + ? wholeSources.sourceOrders(row.customerId(),row.warehousePalletId(),row.planPalletId()) + : row.orderId() == null ? List.of() : List.of(row.orderId()); + if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物缺少真实订单组成"); + ids.forEach(id -> owners.put(id,row.customerId())); + } + owners.forEach((id,customer) -> { if (wholeSources.lockOrder(customer,id) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"来源订单缺失或已暂扣,不能排车"); }); + var result = new java.util.ArrayList(); + for (var source : chosen.values().stream().sorted(java.util.Comparator.comparingLong(s -> + s.candidate().warehousePalletId() == null ? Long.MAX_VALUE : s.candidate().warehousePalletId()) + .thenComparingLong(s -> s.candidate().planPalletId() == null ? 0L : s.candidate().planPalletId())).toList()) { + var row = source.candidate(); + if (row.planPalletId() != null && wholeSources.lockPlan(row.customerId(),row.planPalletId()) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"计划托盘来源已变化"); + if (row.returnReceiptId() != null && wholeSources.lockReturn(row.customerId(),row.returnReceiptId()) == null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"退回实收来源已变化"); + String version = wholeSourceVersion(row,source.sourceKey(),true); + if (!Objects.equals(version,source.selection().sourceVersion())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"所选来源数量、组成或版本已变化,请重新核对"); + result.add(new WholeSource(row,source.selection(),source.sourceKey(),version)); + } + return List.copyOf(result); + } + + /** GET候选与写入锁内使用同一摘要字段;不包含客户端显示顺序或锁拥有者。 */ + public String wholeSourceVersion(DispatchPalletCandidate row, String key, boolean lockStock) { + var facts = new java.util.TreeMap(); + facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId()); + facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId()); + facts.put("palletId",row.warehousePalletId()); facts.put("packages",row.packageCount()); facts.put("sourceStage",row.sourceStage()); + facts.put("destinationType",row.destinationType()); facts.put("destinationWarehouseId",row.destinationWarehouseId()); + facts.put("destinationWarehouseCode",row.destinationWarehouseCode()); facts.put("fbaWarehouseCode",row.fbaWarehouseCode()); + facts.put("deliveryAddress",row.deliveryAddress()); facts.put("postalCode",row.postalCode()); + facts.put("contactName",row.contactName()); facts.put("contactPhone",row.contactPhone()); + facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm()); + facts.put("cartonMarks",row.relabelCartonMarks()); + if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':')))); + var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock); + if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId()))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源"); + facts.put("stock",stock); + facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId())); + facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId())); + return wholeHashes.source(facts); + } + + /** 三种可选来源必须互斥,业务键不使用任意客户参数。 */ + public static String wholeSourceKey(com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection source) { + if (source == null || source.sourceType() == null) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"请选择真实货物来源"); + return switch (source.sourceType()) { + case INBOUND_PLAN -> { if (source.planPalletId() == null || source.returnReceiptId() != null || source.relabelNo() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"计划托盘来源字段不匹配"); yield "PLAN:"+source.planPalletId(); } + case RETURN_RECEIPT -> { if (source.returnReceiptId() == null || source.planPalletId() != null || source.relabelNo() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"退回来源字段不匹配"); yield "RETURN:"+source.returnReceiptId(); } + case RELABEL -> { if (source.relabelNo() == null || source.relabelNo().isBlank() || source.warehousePalletId() == null + || source.planPalletId() != null || source.returnReceiptId() != null) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"换标来源字段不匹配"); yield "RELABEL:"+source.relabelNo()+":"+source.warehousePalletId(); } + }; + } + + /** 只接受唯一来源快照,不以缺失候选伪造成功。 */ + private static DispatchPalletCandidate requireWholeCandidate(List rows) { + if (rows.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物不再具备排车资格"); + return rows.get(0); + } + + /** 所有明细保存成功后推进各真实客户换标申请,仍处在主单事务中。 */ + public void bindWholeRelabel(List sources, List saved) { + for (var item : saved) if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.reserve(item); + for (var no : sources.stream().filter(s -> s.selection().relabelNo() != null).map(s -> s.selection().relabelNo()).distinct().sorted().toList()) { + String customer = sources.stream().filter(s -> no.equals(s.selection().relabelNo())).findFirst().orElseThrow().candidate().customerId(); + if (mutations().markRelabel(customer,no) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT,"换标申请已被其他排车占用"); + } + } + + /** 本车零扫码取消后恢复真实换标申请及原生退货箱预占,不生成退库流水。 */ + public void releaseWholeRelabel(List items) { + var groups=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())) + .collect(Collectors.groupingBy(DispatchItem::getSourceBusinessNo,java.util.TreeMap::new,Collectors.toList())); + for(var group:groups.values()) { + var first=group.get(0);var application=mutations().lockRelabel(first.getCustomerId(),first.getSourceBusinessNo()); + if(application==null || !"PRE_DISPATCH_CREATED".equals(application.getFulfillmentStatus()))throw new ResponseStatusException(HttpStatus.CONFLICT,"换标原排车预占状态已变化"); + } + for(var item:items.stream().filter(i->"RETURN_RELABEL".equals(i.getSourceStage())).sorted(java.util.Comparator.comparing(DispatchItem::getId)).toList())returnRelabelStock.release(item); + for(var group:groups.values()){var first=group.get(0);mutations().restoreRelabel(first.getCustomerId(),first.getSourceBusinessNo());} + } @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workChanges; public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; @@ -62,7 +243,6 @@ public class DispatchService { @Resource private DispatchBolMapper bolLines; @Resource private DispatchPalletLockMapper locks; @Resource private DispatchDraftMapper drafts; - @Resource private DispatchDraftContainerMapper draftContainers; @Resource private DispatchCandidateMapper candidates; @Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; @@ -159,489 +339,11 @@ public class DispatchService { return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); } - public List activeDrafts(String operatorId) { - return drafts.findActiveByOperator(trimToNull(operatorId)).stream() - .map(item -> new DispatchDraftResponse(item.getDraftToken(), item.getPlanIdsJson(), item.getContainersJson(), containers(item.getDraftToken()))).toList(); - } - private List containers(String token) { - return draftContainers.findByToken(token).stream().map(c -> new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), - locks.findActiveByDraftContainer(token, c.getId()).stream() - .map(x -> x.getReturnReceiptId() == null ? String.valueOf(x.getPlanPalletId()) : "RETURN:" + x.getReturnReceiptId()).toList())).toList(); - } - @Transactional - public DispatchContainerResponse createDraftContainer(String operatorId, DispatchRequests.DraftContainer request) { - if (!Set.of(15, 30).contains(request.capacity())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托"); - } - DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); - int next = draftContainers.findByToken(request.draftToken()).stream().mapToInt(DispatchDraftContainer::getSequenceNo).max().orElse(0) + 1; - DispatchDraftContainer c = new DispatchDraftContainer(); c.setId(IdWorker.getId()); c.setDraftId(draft.getId()); c.setDraftToken(request.draftToken()); c.setSequenceNo(next); c.setCapacity(request.capacity()); c.setCreatedAt(LocalDateTime.now()); c.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(c); - return new DispatchContainerResponse(String.valueOf(c.getId()), c.getCapacity(), List.of()); - } - private DispatchDraft ensureDraft(String operatorId, String token) { - DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId)); - if (draft != null) return draft; - LocalDateTime now = LocalDateTime.now(); draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(token); draft.setOperatorId(trimToNull(operatorId)); draft.setStatus("ACTIVE"); draft.setCreatedAt(now); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); drafts.insert(draft); return draft; - } - @Transactional - public void saveDraftSnapshot(String operatorId, DispatchRequests.DraftSnapshot request) { - String customerId = resolveCustomerScopeForPlans(request.planIds()); - LocalDateTime now = LocalDateTime.now(); - DispatchDraft draft = drafts.lockActive(request.draftToken(), trimToNull(operatorId)); - if (draft == null) { - draft = new DispatchDraft(); draft.setId(IdWorker.getId()); draft.setDraftToken(request.draftToken()); - draft.setOperatorId(trimToNull(operatorId)); draft.setCustomerId(customerId); draft.setStatus("ACTIVE"); - draft.setCreatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); - } - draft.setPlanIdsJson(request.planIds().stream().map(String::valueOf).collect(Collectors.joining(","))); - draft.setContainersJson(request.containersJson()); draft.setUpdatedAt(now); draft.setExpiresAt(now.plusMinutes(30)); - drafts.insertOrUpdate(draft); - } - @Transactional - public void lockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchDraft draft = ensureDraft(operatorId, request.draftToken()); - DispatchDraftContainer boundContainer = request.draftContainerId() == null ? null : draftContainers.selectById(request.draftContainerId()); - if (boundContainer == null && request.draftContainerId() == null) { - var existing = draftContainers.findByToken(request.draftToken()); - if (existing.size() > 1) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "存在多个装车分组,请明确选择目标分组"); - if (existing.size() == 1) boundContainer = existing.get(0); - else { - boundContainer = new DispatchDraftContainer(); boundContainer.setId(IdWorker.getId()); boundContainer.setDraftId(draft.getId()); boundContainer.setDraftToken(request.draftToken()); boundContainer.setSequenceNo(1); boundContainer.setCapacity(30); boundContainer.setCreatedAt(LocalDateTime.now()); boundContainer.setUpdatedAt(LocalDateTime.now()); draftContainers.insert(boundContainer); - } - } - if (boundContainer == null || !request.draftToken().equals(boundContainer.getDraftToken()) || !Objects.equals(boundContainer.getDraftId(), draft.getId())) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不存在"); - DispatchPalletCandidate candidate = requireDraftSource(customerId, request); - LocalDateTime now = LocalDateTime.now(); - DispatchPalletLock active = activeLock(customerId, candidate); - if (active != null && active.getExpiresAt() != null && !active.getExpiresAt().isAfter(now)) { - releaseLock(active, now); - active = null; - } - if (active != null) { - if (request.draftToken().equals(active.getDraftToken()) - && Objects.equals(trimToNull(operatorId), active.getOperatorId())) { - if (!Objects.equals(boundContainer.getId(), active.getDraftContainerId())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "托盘已锁定到其他装车分组,请先移除后重新选择"); - return; - } - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another dispatch draft: " + candidate.palletNo()); - } - // Older releases did not persist active_key=NULL; clear that inactive unique-key residue before relocking. - locks.clearInactiveActiveKey(customerId, candidate.sourcePalletKey()); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setId(IdWorker.getId()); - lock.setCustomerId(customerId); - lock.setSourcePalletKey(candidate.sourcePalletKey()); - lock.setActiveKey(candidate.sourcePalletKey()); - lock.setPlanId(candidate.planId()); - lock.setPlanPalletId(candidate.planPalletId()); - lock.setReturnReceiptId(candidate.returnReceiptId()); - lock.setWarehousePalletId(candidate.warehousePalletId()); - lock.setDraftToken(request.draftToken()); - lock.setDraftContainerId(boundContainer.getId()); - lock.setOperatorId(trimToNull(operatorId)); - lock.setStatus("ACTIVE"); - lock.setLockedAt(now); - lock.setExpiresAt(now.plusMinutes(30)); - lock.setCreatedAt(now); - lock.setUpdatedAt(now); - try { - locks.insert(lock); - } catch (DuplicateKeyException exception) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another dispatch draft: " + candidate.palletNo(), exception); - } - } - - @Transactional - public void unlockDraftPallet(String customerId, String operatorId, DispatchRequests.DraftPallet request) { - DispatchPalletCandidate candidate = requireDraftSource(customerId, request); - DispatchPalletLock active = activeLock(customerId, candidate); - if (active == null) return; - requireDraftOwner(active, operatorId, request.draftToken()); - if (active.getDispatchOrderId() != null) { - DispatchOrder dispatch = orders.lockById(customerId, active.getDispatchOrderId()); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - requirePalletEditingAllowed(dispatch); - DispatchItem item = request.returnReceiptId() == null ? items.lockByPlanPallet(customerId, dispatch.getId(), request.planPalletId()) - : items.selectOne(new LambdaQueryWrapper().eq(DispatchItem::getCustomerId, customerId) - .eq(DispatchItem::getDispatchOrderId, dispatch.getId()).eq(DispatchItem::getReturnReceiptId, request.returnReceiptId()).last("FOR UPDATE")); - if (item != null) removeLockedItem(dispatch, item, List.of(active)); - else releaseLock(active, LocalDateTime.now()); - return; - } - releaseLock(active, LocalDateTime.now()); - } - - @Transactional - public void releaseDraft(String operatorId, String draftToken) { - List activeLocks = locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getDraftToken, draftToken) - .eq(DispatchPalletLock::getOperatorId, trimToNull(operatorId)) - .eq(DispatchPalletLock::getStatus, "ACTIVE") - .isNull(DispatchPalletLock::getDispatchOrderId)); - LocalDateTime now = LocalDateTime.now(); - activeLocks.forEach(lock -> releaseLock(lock, now)); - draftContainers.delete(new LambdaQueryWrapper().eq(DispatchDraftContainer::getDraftToken, draftToken)); - DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); - if (draft != null) { draft.setStatus("RELEASED"); draft.setReleasedAt(now); draft.setUpdatedAt(now); drafts.updateById(draft); } - } - - @Transactional - public void deleteDraftContainer(String operatorId, String token, Long containerId) { - if (drafts.lockActive(token, trimToNull(operatorId)) == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿不存在或已失效"); - DispatchDraftContainer c = draftContainers.selectById(containerId); - if (c == null || !token.equals(c.getDraftToken())) return; - List bound = locks.selectList(new LambdaQueryWrapper().eq(DispatchPalletLock::getDraftContainerId, containerId).eq(DispatchPalletLock::getStatus, "ACTIVE")); - bound.forEach(lock -> { requireDraftOwner(lock, operatorId, token); releaseLock(lock, LocalDateTime.now()); }); - draftContainers.deleteById(containerId); - } - - @Transactional - public List preArrangeDraft( - String operatorId, DispatchRequests.PreArrangeDraft request) { - String token = trimToNull(request.draftToken()); - if (token == null || request.groups() == null || request.groups().isEmpty() || request.groups().size() > 100) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择需要预排的装车分组"); - DispatchDraft draft = drafts.lockActive(token, trimToNull(operatorId)); - if (draft == null || draft.getExpiresAt() != null && !draft.getExpiresAt().isAfter(LocalDateTime.now())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "排车草稿已失效或不属于当前操作人"); - var containers = draftContainers.findByToken(token); - var groups = new java.util.LinkedHashMap(); - Set allSources = new HashSet<>(); - for (var group : request.groups()) { - if (group == null || group.draftContainerId() == null || groups.putIfAbsent(group.draftContainerId(), group) != null) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车分组不能重复"); - Set selected = requestedGroupSources(group); - if (selected.isEmpty() || !selected.stream().allMatch(allSources::add)) - throw new ResponseStatusException(HttpStatus.CONFLICT, "每个来源只能加入一个非空装车分组"); - } - if (allSources.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); - var prepared = new java.util.ArrayList(); - Set physicalPallets = new HashSet<>(); - Set dispatchIds = new HashSet<>(); - Set actualGroups = new HashSet<>(); - for (var container : containers.stream().sorted(java.util.Comparator.comparing(DispatchDraftContainer::getId)).toList()) { - if (!Objects.equals(container.getDraftId(), draft.getId())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不属于当前草稿"); - List bound = locks.lockActiveByDraftContainer(token, container.getId()); - if (bound.isEmpty()) continue; - actualGroups.add(container.getId()); - var group = groups.get(container.getId()); - if (group == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿分组已变化,请重新加载"); - if (container.getCapacity() == null || !Set.of(15, 30).contains(container.getCapacity())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "车辆大小仅支持15托或30托"); - if (bound.size() > container.getCapacity()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组超过车型托位容量"); - Set actual = new HashSet<>(); - Set customers = new HashSet<>(); - Set groupDispatchIds = new HashSet<>(); - for (var lock : bound) { - requireDraftOwner(lock, operatorId, token); - actual.add(lock.getReturnReceiptId() == null ? "PLAN:" + lock.getPlanPalletId() : "RETURN:" + lock.getReturnReceiptId()); - customers.add(lock.getCustomerId()); - if (lock.getSourcePalletKey() == null || !physicalPallets.add(lock.getSourcePalletKey())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复排车"); - if (lock.getDispatchOrderId() != null) groupDispatchIds.add(lock.getDispatchOrderId()); - } - if (!actual.equals(requestedGroupSources(group)) || actual.size() != bound.size()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "托盘所属装车分组已变化,请重新加载"); - String customer = resolveCustomerScopeForSources(group.planPalletIds(), group.returnReceiptIds()); - if (!customers.equals(Set.of(customer))) throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组客户归属不一致"); - if (groupDispatchIds.size() > 1 || !groupDispatchIds.stream().allMatch(dispatchIds::add)) - throw new ResponseStatusException(HttpStatus.CONFLICT, "草稿与排车分组不一致,请核对原排车单"); - prepared.add(new PreparedDispatchGroup(container.getId(), customer, group)); - } - if (!actualGroups.equals(groups.keySet())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "装车分组不存在、为空或已失效"); - var results = new java.util.ArrayList(); - for (var group : prepared) { - var command = new DispatchRequests.PreArrange(group.request().planPalletIds(), null, null, null, - token, null, group.request().returnReceiptIds(),group.request().batchAllocations()); - var response = preArrangeContainer(group.customerId(), operatorId, command, group.containerId(), true); - results.add(new com.baiyue.lastleg.dispatch.dto.DispatchDraftGroupResult(group.containerId(), response)); - } - return List.copyOf(results); - } - - private Set requestedGroupSources(DispatchRequests.DraftGroup group) { - Set result = new HashSet<>(); - for (Long id : group.planPalletIds()) if (id == null || !result.add("PLAN:" + id)) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "装车托盘不能重复或为空"); - for (Long id : group.returnReceiptIds()) if (id == null || !result.add("RETURN:" + id)) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "退回来源不能重复或为空"); - return result; - } - - private record PreparedDispatchGroup(Long containerId, String customerId, DispatchRequests.DraftGroup request) { } - - @Transactional - public DispatchResponse preArrange(String customerId, String operatorId, DispatchRequests.PreArrange request) { - return preArrangeContainer(customerId, operatorId, request, null, false); - } - - private DispatchResponse preArrangeContainer(String customerId, String operatorId, - DispatchRequests.PreArrange request, Long expectedContainerId, boolean preserveExactReplay) { - if(StringUtils.hasText(request.fbaAppointmentNo()) && request.batchAllocations().isEmpty()) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"预约关联必须提供不可变批次键及逐票实物分配,不能仅填写预约号"); - if (StringUtils.hasText(request.relabelNo())) { - if (!request.returnReceiptIds().isEmpty()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标与退回来源不能混用"); - return preArrangeRelabel(customerId, operatorId, request); - } - if (!StringUtils.hasText(customerId)) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "No customer business data is available for dispatch"); - } - List palletIds = request.planPalletIds().stream().filter(Objects::nonNull).distinct().sorted().toList(); - List receiptIds = request.returnReceiptIds().stream().distinct().sorted().toList(); - if (palletIds.size() + receiptIds.size() > 500) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "每次最多选择500项来源"); - if ((palletIds.isEmpty() && receiptIds.isEmpty()) || palletIds.size() != request.planPalletIds().size() - || receiptIds.size() != request.returnReceiptIds().size()) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Plan pallet IDs must be unique and non-empty"); - } - List selected = new java.util.ArrayList<>(); - if (!palletIds.isEmpty()) selected.addAll(candidates.lockSelected(customerId, palletIds)); - if (!receiptIds.isEmpty()) selected.addAll(returnCandidates.lockSelected(customerId, receiptIds)); - if (selected.size() != palletIds.size() + receiptIds.size()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "One or more selected pallets are not in this tenant"); - } - if (selected.stream().map(DispatchPalletCandidate::sourcePalletKey).distinct().count() != selected.size()) - throw new ResponseStatusException(HttpStatus.CONFLICT, "同一实物托盘不能重复选择,请先拆托核对来源"); - if (selected.stream().anyMatch(candidate -> !hasValidDestination(candidate))) { - boolean legacyMissingFba = selected.stream().allMatch(candidate -> !"PRIVATE".equals(candidate.destinationType())); - throw new ResponseStatusException(HttpStatus.CONFLICT, legacyMissingFba - ? "所选托盘尚未设置 FBA 仓,不能智能排车" : "所选托盘尚未设置有效目的地,不能智能排车"); - } - assertNoDetainedOrders(customerId, selected); - boolean missingWarehouse = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .anyMatch(value -> value == null || value.isBlank()); - if (missingWarehouse) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "所选托盘中存在尚未关联海外仓的托盘"); - } - Set warehouseIds = selected.stream().map(DispatchPalletCandidate::overseasWarehouseId) - .map(String::trim).collect(Collectors.toSet()); - if (warehouseIds.size() != 1) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "一次排车只能包含同一海外仓的托盘"); - } - String draftToken = trimToNull(request.draftToken()); - Set selectedContainers = new HashSet<>(); - for (DispatchPalletCandidate candidate : selected) { - DispatchPalletLock active = activeLock(customerId, candidate); - if (draftToken != null) { - if (active == null) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet draft lock has expired: " + candidate.palletNo()); - } - requireDraftOwner(active, operatorId, draftToken); - selectedContainers.add(active.getDraftContainerId()); - } else if (active != null) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another active dispatch: " + candidate.palletNo()); - } - } - - if (draftToken != null && (selectedContainers.size() != 1 || selectedContainers.contains(null) - || expectedContainerId != null && !selectedContainers.contains(expectedContainerId))) - throw new ResponseStatusException(HttpStatus.CONFLICT, "多个装车分组必须按分组批量预排,不能合并提交"); - - LocalDateTime now = LocalDateTime.now(); - Long existingDispatchId = draftToken == null ? null : locks.findDispatchOrderIdByDraftContainer(draftToken, selectedContainers.iterator().next()); - DispatchOrder dispatch = existingDispatchId == null ? null : orders.lockById(customerId, existingDispatchId); - boolean refreshing = dispatch != null; - if (preserveExactReplay && refreshing) { - var existing = items.findByDispatchOrder(customerId, dispatch.getId()); - if (existing.size() == selected.size() && existing.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()) - .equals(selected.stream().map(DispatchService::selectionKey).collect(Collectors.toSet()))) - { appointmentAllocations.requireReplay(customerId,dispatch.getId(),request.batchAllocations()); return response(dispatch, existing); } - } - if (refreshing) { - requirePalletEditingAllowed(dispatch); - } else { - dispatch = new DispatchOrder(); - dispatch.setId(IdWorker.getId()); - dispatch.setCustomerId(customerId); - dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", selected.get(0).overseasWarehouseId(), dispatch.getId())); - dispatch.setOverseasWarehouseId(selected.get(0).overseasWarehouseId()); - dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setStatus(PENDING_DEPARTURE); - dispatch.setPickedPackageCount(0); - dispatch.setShippedPackageCount(0); - dispatch.setCreatedBy(trimToNull(operatorId)); - dispatch.setCreatedAt(now); - } - dispatch.setRoute(candidateRoute(selected)); - dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); - dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); - dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); - dispatch.setItemCount(selected.size()); - dispatch.setPalletCount(selected.size()); - dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount) - .filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); - dispatch.setUpdatedAt(now); - if (refreshing) orders.updateById(dispatch); else orders.insert(dispatch); - - List previousItems = items.findByDispatchOrder(customerId, dispatch.getId()); - Set selectedPalletIds = selected.stream().map(DispatchService::selectionKey) - .collect(Collectors.toSet()); - if (refreshing) { - List previousLocks = locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, customerId) - .eq(DispatchPalletLock::getDispatchOrderId, dispatch.getId()) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); - previousItems.stream().filter(item -> !selectedPalletIds.contains(selectionKey(item))) - .forEach(item -> { - previousLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId())) - .forEach(lock -> releaseLock(lock, now)); - items.deleteById(item.getId()); - }); - } - Set existingPalletIds = previousItems.stream() - .map(DispatchService::selectionKey).filter(selectedPalletIds::contains).collect(Collectors.toSet()); - - for (DispatchPalletCandidate candidate : selected) { - DispatchPalletLock lock = activeLock(customerId, candidate); - if (draftToken != null) { - requireDraftOwner(lock, operatorId, draftToken); - lock.setDispatchOrderId(dispatch.getId()); - lock.setExpiresAt(null); - lock.setUpdatedAt(now); - locks.updateById(lock); - } else { - lock = new DispatchPalletLock(); - lock.setId(IdWorker.getId()); - lock.setCustomerId(customerId); - lock.setSourcePalletKey(candidate.sourcePalletKey()); - lock.setActiveKey(candidate.sourcePalletKey()); - lock.setPlanId(candidate.planId()); - lock.setPlanPalletId(candidate.planPalletId()); - lock.setReturnReceiptId(candidate.returnReceiptId()); - lock.setWarehousePalletId(candidate.warehousePalletId()); - lock.setDispatchOrderId(dispatch.getId()); - lock.setOperatorId(trimToNull(operatorId)); - lock.setStatus("ACTIVE"); - lock.setLockedAt(now); - lock.setCreatedAt(now); - lock.setUpdatedAt(now); - try { - locks.insert(lock); - } catch (DuplicateKeyException exception) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "Pallet is already locked by another active dispatch: " + candidate.palletNo(), exception); - } - } - if (existingPalletIds.contains(selectionKey(candidate))) continue; - DispatchItem item = new DispatchItem(); - item.setId(IdWorker.getId()); - item.setDispatchOrderId(dispatch.getId()); - item.setCustomerId(customerId); - item.setPlanId(candidate.planId()); - item.setPlanPalletId(candidate.planPalletId()); - item.setReturnReceiptId(candidate.returnReceiptId()); - item.setWarehousePalletId(candidate.warehousePalletId()); - item.setOrderId(candidate.orderId()); - item.setOverseasWarehouseId(candidate.overseasWarehouseId()); - item.setOverseasWarehouseName(candidate.overseasWarehouseName()); - item.setOrderNo(candidate.orderNo()); - item.setOrderRefs(candidate.orderRefs()); - item.setInboundCode(candidate.inboundCode()); - item.setPalletNo(candidate.palletNo()); - item.setDestinationWarehouseId(candidate.destinationWarehouseId()); - item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); - item.setDestinationWarehouseName(candidate.destinationWarehouseName()); - item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); - item.setDestinationType(candidate.destinationType()); - item.setDeliveryAddress(candidate.deliveryAddress()); - item.setPostalCode(candidate.postalCode()); - item.setContactName(candidate.contactName()); - item.setContactPhone(candidate.contactPhone()); - item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); - item.setGrossWeightKg(candidate.grossWeightKg()); - item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); - item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); - item.setSourceStage(candidate.sourceStage()); - item.setCartonMarks(mergeCartonMarks(candidate.cartonMarks(), candidate.relabelCartonMarks())); - item.setCreatedAt(now); - items.insert(item); - } - if (!refreshing) { - DispatchPdaTask pdaTask = new DispatchPdaTask(); - pdaTask.setId(IdWorker.getId()); - pdaTask.setCustomerId(customerId); - pdaTask.setDispatchOrderId(dispatch.getId()); - pdaTask.setTaskNo("DPDA" + pdaTask.getId()); - pdaTask.setStatus("PDA_PENDING"); - pdaTask.setWarehouseId(dispatch.getOverseasWarehouseId()); - pdaTask.setPushedAt(now); - pdaTask.setCreatedAt(now); - pdaTask.setUpdatedAt(now); - pdaTasks.insert(pdaTask); - } - if (draftToken != null) { - DispatchDraft draft = drafts.lockActive(draftToken, trimToNull(operatorId)); - if (draft != null) { - draft.setExpiresAt(null); - draft.setUpdatedAt(now); - drafts.updateById(draft); - } - } - // Exact grouped replay must return the same persisted timestamp precision - // on the initial call as on later reads from DATETIME(3). - DispatchOrder responseOrder = preserveExactReplay - ? Objects.requireNonNull(orders.lockById(customerId, dispatch.getId()), "Saved dispatch is missing") - : dispatch; - var assignedItems=items.findByDispatchOrder(customerId, dispatch.getId()); - appointmentAllocations.bind(customerId,dispatch.getId(),assignedItems.stream().map(i->new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.PhysicalItem(i.getId(),i.getPlanPalletId(),i.getSourceStage(),i.getPackageCount())).toList(),request.batchAllocations()); - return response(responseOrder, assignedItems); - } - - /** 在申请锁内校验全托并原子建立整单排车。 */ - @Transactional - private DispatchResponse preArrangeRelabel(String customerId, String operatorId, DispatchRequests.PreArrange request) { - RelabelApplication application = mutations().lockRelabel(customerId, request.relabelNo()); - if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); - } - if (!"TRUCK".equals(application.getDeliveryMethod()) - || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标单不是待排车状态"); - relabelBindingGuard.requireBound(application); - relabelMeasurements.requireMeasured(application); - List selected = java.util.stream.Stream.concat( - measuredRelabelCandidates(mutations().relabelCandidates(customerId, request.relabelNo())).stream(), - returnRelabelStock.candidates(customerId, null, request.relabelNo()).stream()).toList(); - // 整单履约状态只能由完整托盘集合推进,普通换标与退货换标遵守同一门禁。 - Set requested = new HashSet<>(request.planPalletIds()); - if (requested.contains(null) || requested.size() != request.planPalletIds().size()) - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "换标托盘不得为空或重复选择"); - if (selected.isEmpty() || !requested.equals(selected.stream() - .map(DispatchPalletCandidate::warehousePalletId).collect(Collectors.toSet()))) - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标排车须一次选择本申请全部已绑定托盘,请刷新候选后完整选择"); - selected = selected.stream().filter(c -> requested.contains(c.warehousePalletId())).toList(); - if (selected.isEmpty() || selected.stream().map(DispatchPalletCandidate::warehousePalletId).distinct().count() != requested.size()) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单托盘候选已变化,请刷新后重试"); - String warehouse = selected.get(0).overseasWarehouseId(); - if (selected.stream().anyMatch(c -> !Objects.equals(warehouse, c.overseasWarehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单只能选择同一海外仓"); - LocalDateTime now = LocalDateTime.now(); - DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(IdWorker.getId()); dispatch.setCustomerId(customerId); - dispatch.setDispatchNo(orderNoGenerator.next("DISPATCH", warehouse, dispatch.getId())); dispatch.setOverseasWarehouseId(warehouse); dispatch.setOverseasWarehouseName(selected.get(0).overseasWarehouseName()); - dispatch.setRoute(candidateRoute(selected)); dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); dispatch.setStatus(PENDING_DEPARTURE); dispatch.setItemCount(selected.size()); dispatch.setPalletCount(selected.size()); dispatch.setPackageCount(selected.stream().map(DispatchPalletCandidate::packageCount).filter(Objects::nonNull).mapToInt(Integer::intValue).sum()); dispatch.setPickedPackageCount(0); dispatch.setShippedPackageCount(0); dispatch.setCreatedBy(trimToNull(operatorId)); dispatch.setCreatedAt(now); dispatch.setUpdatedAt(now); mutations().createOrder(dispatch); - for (DispatchPalletCandidate candidate : selected) { DispatchItem item = new DispatchItem(); item.setId(IdWorker.getId()); item.setDispatchOrderId(dispatch.getId()); item.setCustomerId(customerId); item.setWarehousePalletId(candidate.warehousePalletId()); item.setOrderId(candidate.orderId()); item.setOverseasWarehouseId(candidate.overseasWarehouseId()); item.setOverseasWarehouseName(candidate.overseasWarehouseName()); item.setOrderNo(candidate.orderNo()); item.setOrderRefs(candidate.orderRefs()); item.setInboundCode(candidate.inboundCode()); item.setPalletNo(candidate.palletNo()); item.setDestinationWarehouseCode(candidate.destinationWarehouseCode()); item.setDestinationWarehouseName(candidate.destinationWarehouseName()); item.setFbaWarehouseCode(candidate.fbaWarehouseCode()); item.setPackageCount(Objects.requireNonNullElse(candidate.packageCount(), 0)); item.setGrossWeightKg(candidate.grossWeightKg()); item.setVolumeWeightKg(Objects.requireNonNullElse(candidate.volumeWeightKg(), BigDecimal.ZERO)); item.setVolumeCbm(Objects.requireNonNullElse(candidate.volumeCbm(), BigDecimal.ZERO)); item.setSourceStage("RELABEL"); item.setCartonMarks(candidate.relabelCartonMarks()); item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(request.relabelNo()); item.setCreatedAt(now); mutations().createItem(item); } - DispatchPdaTask task = new DispatchPdaTask(); task.setId(IdWorker.getId()); task.setCustomerId(customerId); task.setDispatchOrderId(dispatch.getId()); task.setTaskNo("DPDA" + task.getId()); task.setStatus("PDA_PENDING"); task.setWarehouseId(warehouse); task.setPushedAt(now); task.setCreatedAt(now); task.setUpdatedAt(now); mutations().createTask(task); - for (DispatchItem item : mutations().items(dispatch)) { - if (selected.stream().anyMatch(c -> c.returnCartonSource() && Objects.equals(c.warehousePalletId(), item.getWarehousePalletId()) - && Objects.equals(c.relabelCartonMarks(), item.getCartonMarks()))) { - item.setSourceStage("RETURN_RELABEL"); mutations().saveItem(item); returnRelabelStock.reserve(item); - } - } - if (mutations().markRelabel(customerId, request.relabelNo()) != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "换标订单已被其他排车占用"); - return response(dispatch, mutations().items(dispatch)); - } private void assertNoDetainedOrders(String customerId, List selected) { List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java index 887b5d037..2b7517eab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java @@ -24,8 +24,20 @@ public class ReturnRelabelDispatchStock { @Resource private ReturnInventoryService inventory; public List candidates(String customer, String orderNo, String relabelNo) { + return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo)); + } + + /** 整车候选只解析已经按仓限定且有界的原生退货记录。 */ + public List wholeCandidates(String warehouse,String orderNo) { + var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null); + if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围"); + return resolve(rows); + } + + /** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */ + private List resolve(List rows) { List result = new ArrayList<>(); - for (var candidate : candidates.nativeReturnRelabelAvailable(customer, orderNo, relabelNo)) { + for (var candidate : rows) { var pallet = physical.resolve(candidate.palletNo()); if (pallet == null || !String.valueOf(pallet.warehouseId()).equals(candidate.overseasWarehouseId()) || !Set.of("IDLE", "OCCUPIED").contains(pallet.status())) continue; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java index 0adec6c24..0f829934b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java @@ -9,17 +9,17 @@ import java.util.List; @Mapper public interface ReturnCartonStockReceiptMapper extends BaseMapper { record Shipment(Long pickId, Long dispatchItemId, String dispatchNo,Long productQuantity) { } - /** 原箱必须真实出库且逐箱归属于当前异常、派送、客户及原尾程订单。 */ + /** 整车与任务共享,原箱仍由本客户的明细、扫码及逐票归属证明真实发运。 */ @Select("SELECT p.id pick_id,di.id dispatch_item_id,d.dispatch_no,p.product_quantity FROM ll_exception_order e " + "JOIN ll_exception_return_item frozen ON frozen.exception_order_id=e.id AND frozen.customer_id=e.customer_id " + "AND frozen.outbound_pallet_item_id=#{itemId} AND frozen.return_carton_id=#{cartonId} AND frozen.inventory_position_id IS NULL AND frozen.approved_return_packages=1 AND frozen.received_packages=0 " + "JOIN ll_delivery_order delivery ON delivery.id=e.delivery_order_id AND delivery.customer_id=e.customer_id " + "JOIN ll_outbound_order o ON o.id=delivery.outbound_order_id AND o.customer_id=e.customer_id AND o.outbound_no=e.outbound_no AND o.status='OUTBOUNDED' " + "JOIN ll_outbound_pallet_item oi ON oi.outbound_order_id=o.id AND oi.customer_id=e.customer_id AND oi.id=#{itemId} " + - "JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id AND d.customer_id=e.customer_id " + + "JOIN ll_dispatch_order d ON d.id=o.dispatch_order_id " + "JOIN ll_dispatch_item di ON di.dispatch_order_id=d.id AND di.customer_id=e.customer_id AND di.warehouse_pallet_id=oi.warehouse_pallet_id AND di.source_stage='RETURN_RELABEL' " + "AND di.inbound_code=oi.inbound_code AND COALESCE(di.destination_warehouse_code,'')=COALESCE(oi.destination_warehouse_code,'') " + - "JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id AND t.customer_id=e.customer_id " + + "JOIN ll_dispatch_pda_task t ON t.dispatch_order_id=d.id " + "JOIN ll_dispatch_pda_pick p ON p.task_id=t.id AND p.customer_id=e.customer_id AND p.dispatch_item_id=di.id AND p.return_carton_id=#{cartonId} AND p.inventory_position_id IS NULL AND p.packages=1 " + "JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=e.customer_id AND a.order_id=e.last_mile_order_id AND a.packages=1 " + "WHERE e.id=#{exceptionId} AND e.customer_id=#{customerId} AND e.status='PROCESSING' AND e.return_direction='OVERSEAS_WAREHOUSE'") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 83a79ea80..685804d1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -545,7 +545,7 @@ public class ExceptionOrderService { Map aggregated) { if (outbound.getDispatchOrderId() == null) return; String customerId = delivery.getCustomerId(); - DispatchPdaTask task = dispatchPdaTasks.findByDispatch(customerId, outbound.getDispatchOrderId()); + DispatchPdaTask task = dispatchPdaTasks.findByDispatch(outbound.getDispatchOrderId()); if (task == null) return; Map dispatchItemById = new LinkedHashMap<>(); dispatchItems.findByDispatchOrder(customerId, outbound.getDispatchOrderId()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java index 243a9ffb7..3216dd0b2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java @@ -61,7 +61,6 @@ public class FleetHandoverInspectionService { for (var binding : current) { var originalBinding = departedBindings.get(binding.getDispatchNo()); if (!seen.add(binding.getDispatchNo()) || originalBinding == null - || !Objects.equals(originalBinding.customerId(), binding.getCustomerId()) || !Objects.equals(originalBinding.packageCount(), binding.getPackageCount())) throw conflict("客户排车归属或箱数与原始发车不一致"); } @@ -69,7 +68,6 @@ public class FleetHandoverInspectionService { for (var binding : departedBindings.values()) { var fact = dispatches.transitEvidence(binding.dispatchNo(), lock); if (!Objects.equals(binding.dispatchNo(), fact.dispatchNo()) - || !Objects.equals(binding.customerId(), fact.customerId()) || !trip.getWarehouseId().toString().equals(fact.warehouseId()) || binding.packageCount() != fact.shippedPackages()) throw conflict("实际发运与车次客户、仓库或箱数不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java index 2e0a9966f..7adc9b7b3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -43,7 +43,7 @@ public class FleetHandoverPdaQuery { if (!List.of("IN_TRANSIT", "COMPLETED").contains(trip.getStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT, "尚未发车,请按原拣货发车流程作业"); var evidence = dispatches.transitEvidence(dispatchNo, false); - if (!Objects.equals(evidence.customerId(), binding.getCustomerId()) || !Long.toString(warehouseId).equals(evidence.warehouseId()) + if (!Long.toString(warehouseId).equals(evidence.warehouseId()) || !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound(); var original = history.selectList(new LambdaQueryWrapper().eq(FleetTripHistory::getTripId, trip.getId()) .eq(FleetTripHistory::getAction, "DEPART")); @@ -55,7 +55,7 @@ public class FleetHandoverPdaQuery { var rows = evidence.deliveries().stream().map(row -> new Delivery(row.cargo().deliveryNo(), row.status(), row.cargo().packages(), row.cargo().terminalAt() != null ? 0 : row.cargo().packages() - row.cargo().returnedCargo().stream().mapToInt(FleetHandoverPolicy.ReturnedCargo::packages).sum(), row.cargo().terminalAt(), row.transport())).toList(); - var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("customerId", binding.getCustomerId(), "dispatchNo", dispatchNo))); + var latest = handovers.latestForCargo(trip.getId(), json.writeValueAsString(Map.of("dispatchNo", dispatchNo))); Handover handover = null; if (latest != null) { var snapshot = json.readValue(latest.snapshot(), FleetHandoverModels.Snapshot.class); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java index de5162f38..1d9ce39cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java @@ -2,8 +2,8 @@ package com.baiyue.lastleg.fleet.exception; import java.util.List; -/** Internal port response. Identity always comes from the original customer dispatch. */ -public record FleetTransitEvidence(String dispatchNo, String customerId, String warehouseId, +/** 整车发运事实;真实客户由每条Delivery.cargo携带。 */ +public record FleetTransitEvidence(String dispatchNo, String warehouseId, int shippedPackages, List deliveries) { public record Transport(String vehiclePlate, String driverName, String driverPhone) { } public record Delivery(FleetHandoverPolicy.Delivery cargo, String status, long recordVersion, Transport transport) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index 7c2d1aa87..00835edbf 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -64,12 +64,12 @@ public class FleetBookingReservations { public FleetBookingModels.AcceptedReceipt receipt(FleetBookingEntity row){return row.getReceiptJson()==null?null:read(row.getReceiptJson(),FleetBookingModels.AcceptedReceipt.class);} private static boolean unbound(FleetBookingEntity row){return Set.of("CONFIRMED","RECEIVED").contains(row.getStatus());} /** 按实际入仓来源绑定整票实收,合托不能掩盖外接承诺。 */ - public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,List sources,String operator){ + public void bindIncoming(FleetTripEntity trip,FleetDispatchGateway.Snapshot dispatch,String customerId,List sources,String operator){ var bySource=sources.stream().filter(row->row.inboundCode()!=null).collect(java.util.stream.Collectors.groupingBy(FleetDispatchGateway.SourceCargo::inboundCode)); for(var entry:bySource.entrySet()){ - var row=new FleetBookingCargoRepository(bookings).forInboundSource(dispatch.customerId(),entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue; + var row=new FleetBookingCargoRepository(bookings).forInboundSource(customerId,entry.getKey());if(row==null||"CANCELLED".equals(row.getStatus()))continue; if(!Objects.equals(row.getTripId(),trip.getId()))throw conflict("此外接货已承诺给其他车次,请先调整原预订"); - if(!Objects.equals(row.getCustomerId(),dispatch.customerId())||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); + if(!Objects.equals(row.getCustomerId(),customerId)||!"RECEIVED".equals(row.getStatus()))throw conflict("外接货须先确认实收货量及运费,再关联排车"); var accepted=receipt(row);if(accepted==null)throw conflict("外接货缺少实收确认"); var expected=new TreeMap(); for(var pallet:accepted.source().pallets())expected.merge(pallet.warehousePalletId(),pallet.packages(),Math::addExact); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index b7f6a252d..fdf8ad43d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -38,6 +38,7 @@ public class FleetBookingService { private final FleetQuoteCandidateService quotes; @jakarta.annotation.Resource private FleetPoolOrderBridge poolOrders; @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; + @jakarta.annotation.Resource private FleetDispatchGateway dispatches; @jakarta.annotation.Resource private FleetCapacityOfferCommandMapper commands; public FleetBookingService(FleetTripMapper trips,FleetBookingMapper bookings,FleetBookingReservations reservations, FleetTripService history,FleetTripExecutionService execution,FleetCustomerDirectory customers,ObjectMapper json,FleetBookingIncomingGateway incoming, @@ -201,10 +202,11 @@ public class FleetBookingService { planning.validateIncomingDispatch(request.dispatchNo(),row.getCustomerId(),evidence); } row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); - var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(request.tripRecordVersion(),request.dispatchNo(),"关联外接预订 "+bookingNo)); + bindToWhole(trip,row,request.dispatchNo(),operator); + history.recordExecution(trip,trip.getRecordVersion(),"BOOKING_BIND","关联外接预订 "+bookingNo,operator); var result=booking(trip,bookingNo); if(!"BOUND".equals(result.getStatus())||!Objects.equals(result.getDispatchNo(),request.dispatchNo().trim()))throw conflict("所选排车未包含此外接来货单的实际货物"); - return new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(result),false); + return new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(result),false); } @Transactional public FleetBookingModels.Result arrangeDispatch(String tripNo,String bookingNo,String operator,FleetBookingModels.Command request) { @@ -225,17 +227,27 @@ public class FleetBookingService { var plan=execution.capacityPlan(tripNo); if(!plan.planValid()||plan.blockReason()!=null)throw conflict("本车计划容量待核对,暂不能排入"); reservations.segments(trip,plan.capacityPlan(),row.getId(),row); - String dispatchNo=planning.resolveIncoming(row.getCustomerId(),evidence,operator); - var linked=history.bindDispatch(tripNo,operator,new FleetTripModels.BindingCommand(trip.getRecordVersion(),dispatchNo,"实收核对后排入本车 "+bookingNo)); + String dispatchNo=planning.resolveIncoming(tripNo,row.getCustomerId(),evidence,operator,"FLEET_INCOMING:"+hash(java.util.List.of(bookingNo,request.idempotencyKey()))); + trip=trip(tripNo,true);bindToWhole(trip,row,dispatchNo,operator); + history.recordExecution(trip,trip.getRecordVersion(),"BOOKING_ARRANGE","实收核对后排入本车 "+bookingNo,operator); row=booking(trip,bookingNo); if(!"BOUND".equals(row.getStatus())||!Objects.equals(row.getDispatchNo(),dispatchNo))throw conflict("未完整关联本票实收来源"); row.setBindingKey(request.idempotencyKey());row.setBindingHash(hash);bookings.updateById(row); - var result=new FleetBookingModels.Result(linked.trip().recordVersion(),reservations.response(row),false); + var result=new FleetBookingModels.Result(trip.getRecordVersion(),reservations.response(row),false); var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(trip.getId());command.setOperatorId(operator); command.setAction("BOOKING_ARRANGE");command.setScopeNo(bookingNo);command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash); command.setResultJson(write(result));command.setCreatedAt(now());commands.insert(command); return result; } + /** 外接来源只能绑定该车已有的一对一主单,不能将另一整车挂到当前车次。 */ + private void bindToWhole(FleetTripEntity trip,FleetBookingEntity booking,String number,String operator) { + var identity=history.dispatchIdentity(number); + if(identity.tripId()!=trip.getId())throw conflict("外接实收只能加入本车唯一排车主单"); + var accepted=reservations.receipt(booking);if(accepted==null)throw conflict("此外接预订缺少实际收货依据"); + planning.validateIncomingDispatch(number,booking.getCustomerId(),accepted.source()); + var snapshot=dispatches.lockForPlanning(number); + reservations.bindIncoming(trip,snapshot,booking.getCustomerId(),dispatches.sourceCargo(number,booking.getCustomerId()),operator); + } private static void requireEvidence(FleetTripEntity trip,FleetBookingEntity row,FleetBookingIncomingGateway.Evidence evidence){ if(evidence==null||evidence.incomingNo()==null||!Objects.equals(trip.getWarehouseId().toString(),evidence.warehouseId())||!Objects.equals(row.getDestinationKey(),evidence.destinationKey()))throw conflict("来货单仓库或目的地与外接承运约定不一致"); if(row.getIncomingNo()!=null&&!Objects.equals(row.getIncomingNo(),evidence.incomingNo()))throw conflict("来货单引用不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java index 9b4516de6..b46c15da9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java @@ -9,17 +9,18 @@ import org.springframework.web.server.ResponseStatusException; @Service public class FleetDispatchBindingGuard { private final FleetTripDispatchMapper bindings; - private final com.fasterxml.jackson.databind.ObjectMapper json; - public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, com.fasterxml.jackson.databind.ObjectMapper json) { this.bindings = bindings; this.json = json; } + private final FleetDispatchGateway dispatches; + public FleetDispatchBindingGuard(FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches) { this.bindings = bindings; this.dispatches = dispatches; } /** Dispatch -> task -> binding locks; never acquire the parent trip lock from a PDA pick. */ @Transactional(propagation = Propagation.MANDATORY) public void requirePickingApproved(long dispatchId) { var binding = bindings.lockByDispatch(dispatchId); if (binding == null) throw new ResponseStatusException(HttpStatus.CONFLICT, "请先关联车次并通过调度审核"); - var review = FleetTripReview.read(json, binding.getReviewJson()); - if (!"APPROVED".equals(review.status())) throw new ResponseStatusException(HttpStatus.CONFLICT, - "REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"); + var fact=dispatches.workflows(java.util.List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); + if(fact==null || !"ACTIVE".equals(fact.status()) || !"LOADING".equals(fact.stage()) + || !"APPROVED".equals(fact.dispatchReview().status()) || !"APPROVED".equals(fact.loadingReview().status())) + throw new ResponseStatusException(HttpStatus.CONFLICT,"请先完成本车两级审核再扫码"); } @Transactional(propagation = Propagation.MANDATORY) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java index 5891cafe9..c34618809 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java @@ -4,9 +4,14 @@ import java.time.Instant; /** Customer dispatches remain owned by dispatch; fleet only orchestrates their physical trip. */ public interface FleetDispatchGateway { - record Snapshot(long dispatchId, String dispatchNo, String customerId, String warehouseId, int packageCount) { } + /** 审核唯一事实来自整车主单,Fleet只持有物理资源关联。 */ + record Workflow(String status,String stage,long recordVersion, + FleetTripModels.ReviewState dispatchReview,FleetTripModels.ReviewState loadingReview) { } + java.util.Map workflows(java.util.List dispatchNos); + record Snapshot(long dispatchId, String dispatchNo, String warehouseId, int packageCount) { } record SourceCargo(String inboundCode,String warehousePalletId,String palletNo,int packages,String destinationKey) { } - java.util.List sourceCargo(String dispatchNo); + java.util.List customers(String dispatchNo); + java.util.List sourceCargo(String dispatchNo,String customerId); record Assignment(String vehiclePlate, String driverName, String driverPhone) { } static String destinationKey(String type,String code,String fba,String address,String postalCode) { @@ -19,23 +24,17 @@ public interface FleetDispatchGateway { /** Locks the dispatch and rejects any picking or departure already performed. */ Snapshot lockForPlanning(String dispatchNo); - /** Allows pre-departure resource/time changes without changing cargo or recorded picking. */ - Snapshot lockForAssignment(String dispatchNo); - /** Original dispatch command; caller must first detach the dispatch from its vehicle. */ - void removeItem(String dispatchNo,long itemId); - /** Caller holds the dispatch lock for the entire transaction, including downstream snapshots. */ - void assign(Snapshot dispatch, Assignment assignment); FleetExecutionModels.Manifest manifest(String dispatchNo, boolean lock); FleetCompletionModels.DispatchCompletion completion(String dispatchNo, boolean lock); record ReturnedReceipt(String itemId,String palletId,int packages,Instant receivedAt) { } record TerminalDelivery(String customerId,String deliveryNo,String destinationKey,int packages, Instant terminalAt,String status,long recordVersion,java.util.List receipts) { } - record TerminalEvidence(String dispatchNo,String customerId,String warehouseId,int shippedPackages, + record TerminalEvidence(String dispatchNo,String warehouseId,int shippedPackages, java.util.List deliveries) { } /** Narrow execution facts for appointment eligibility, derived from the original verified evidence. */ default TerminalEvidence terminalEvidence(String dispatchNo,boolean lock) { var evidence=transitEvidence(dispatchNo,lock); - return new TerminalEvidence(evidence.dispatchNo(),evidence.customerId(),evidence.warehouseId(),evidence.shippedPackages(), + return new TerminalEvidence(evidence.dispatchNo(),evidence.warehouseId(),evidence.shippedPackages(), evidence.deliveries().stream().map(d->new TerminalDelivery(d.cargo().customerId(),d.cargo().deliveryNo(), d.cargo().destinationKey(),d.cargo().packages(),d.cargo().terminalAt(),d.status(),d.recordVersion(), d.cargo().returnedCargo().stream().map(r->new ReturnedReceipt(r.itemId(),r.palletId(),r.packages(),r.receivedAt())).toList())).toList()); @@ -44,6 +43,4 @@ public interface FleetDispatchGateway { com.baiyue.lastleg.fleet.exception.FleetTransitEvidence transitEvidence(String dispatchNo, boolean lock); /** Updates only unfinished delivery resource snapshots; departure and financial facts stay immutable. */ void handover(com.baiyue.lastleg.fleet.exception.FleetTransitEvidence evidence, Assignment assignment); - FleetExecutionModels.DepartedDispatch depart(String warehouseId, String dispatchNo, String operator, - String idempotencyKey, String remark, Instant departedAt); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java index 56a5d1fc3..cfb3ffba9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java @@ -4,8 +4,6 @@ import com.baiyue.common.core.api.PageResult; /** Dispatch owns candidate eligibility and creation; fleet orchestrates the trip transaction. */ public interface FleetDispatchPlanningGateway { - record Candidate(String dispatchNo,String customerId,String warehouseId,int packageCount,String route) { } - PageResult candidates(String tripNo,String warehouseId,String keyword,int page,int size); void validateIncomingDispatch(String dispatchNo,String customerId,FleetBookingIncomingGateway.Evidence receipt); - String resolveIncoming(String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator); + String resolveIncoming(String tripNo,String customerId,FleetBookingIncomingGateway.Evidence receipt,String operator,String commandKey); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java index 2c9fef6c2..b084f0443 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java @@ -13,31 +13,28 @@ public class FleetDispatchWorkflowQuery { public record Review(String status, String reason, java.time.Instant reviewedAt) { } public record Workflow(String tripNo, Review dispatchReview, Review loadingReview) { } private final FleetTripDispatchMapper bindings; - private final FleetTripMapper trips; - private final ObjectMapper json; + private final FleetDispatchGateway dispatches; - public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetTripMapper trips, ObjectMapper json) { - this.bindings = bindings; this.trips = trips; this.json = json; + public FleetDispatchWorkflowQuery(FleetTripDispatchMapper bindings, FleetDispatchGateway dispatches) { + this.bindings = bindings; this.dispatches = dispatches; } public Map read(List dispatchNos) { if (dispatchNos.isEmpty()) return Map.of(); var members = bindings.findByDispatchNos(dispatchNos); if (members.isEmpty()) return Map.of(); - var parents = trips.selectBatchIds(members.stream().map(FleetTripDispatch::getTripId).distinct().toList()) - .stream().collect(Collectors.toMap(FleetTripEntity::getId, trip -> trip)); + var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList()); Map result = new HashMap<>(); for (var member : members) { - var trip = parents.get(member.getTripId()); - if (trip == null) throw new IllegalStateException("关联车次不存在"); + var fact = facts.get(member.getDispatchNo()); + if (fact == null) throw new IllegalStateException("关联整车不存在"); result.put(member.getDispatchNo(), new Workflow(member.getTripNo(), - review(member.getReviewJson()), review(trip.getLoadingReviewJson()))); + review(fact.dispatchReview()), review(fact.loadingReview()))); } return result; } - private Review review(String value) { - var state = FleetTripReview.read(json, value); - return new Review(state.status(), state.reason(), state.reviewedAt()); + private Review review(FleetTripModels.ReviewState value) { + return new Review(value.status(), value.reason(), value.reviewedAt()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java index cb9951283..5ac871213 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java @@ -9,6 +9,21 @@ import java.util.List; public final class FleetExecutionModels { private FleetExecutionModels() { } + /** 整车主单自动建立资源记录的原生输入,不引用排车持久化实体。 */ + public record DispatchVehicle(String mode, Long vehicleId, String plate, String carrierName, + Integer palletSpaces, BigDecimal maxLoadKg, BigDecimal maxVolumeCbm, + List evidenceFileIds) { } + public record DispatchDriver(String mode, Long driverId, String name, String phone, String licenseNo, + java.time.LocalDate licenseExpiresOn, List evidenceFileIds, + Instant expectedTaskEndAt) { } + public record DispatchShellRequest(long dispatchId, String dispatchNo, long warehouseId, + String idempotencyKey, String commandHash, Instant plannedDepartureAt, + DispatchVehicle vehicle, DispatchDriver driver, String remark) { } + public record DispatchShell(long tripId, String tripNo, String warehouseId, String warehouseCode, + String warehouseName, DispatchVehicle vehicle, DispatchDriver driver, + String vehicleStatus) { } + public record DispatchShellLock(long tripId, String tripNo, String warehouseId) { } + public record DispatchStop(String name, String address, String destinationKey) { } public record Cargo(String dispatchNo, String customerId, String dispatchItemId, String warehousePalletId, String palletNo, int packages, String destinationKey, String destinationLabel) { } public record Manifest(String dispatchNo, String warehouseId, String vehiclePlate, String driverName, String driverPhone, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java index dccb04145..a1675fc05 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java @@ -12,6 +12,9 @@ public interface FleetResourceMapper { List candidates(@Param("kind") String kind, @Param("ids") List ids); @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id}") Assignment find(@Param("kind") String kind, @Param("id") long id); + /** 草稿批量转换先锁齐资源占用身份,不能在第一个组来源之后再等下一组资源。 */ + @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE resource_kind=#{kind} AND resource_id=#{id} FOR UPDATE") + Assignment lock(@Param("kind")String kind,@Param("id")long id); @Select("SELECT resource_kind,resource_id,trip_id,trip_no FROM ll_fleet_resource_assignment WHERE trip_id=#{tripId} ORDER BY resource_kind,resource_id") List byTrip(@Param("tripId") long tripId); @Insert("INSERT INTO ll_fleet_resource_assignment(resource_kind,resource_id,trip_id,trip_no) VALUES(#{kind},#{id},#{tripId},#{tripNo})") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java index 580f9122a..94bd1ecf9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java @@ -86,11 +86,11 @@ public class FleetTripCompletionService { if (vehicle && (request.vehicleStatus() == null || !Set.of("IN_SERVICE", "MAINTENANCE", "INACTIVE", "RETURNED").contains(request.vehicleStatus()))) throw bad("请确认车辆结束任务后的实际状态"); if (!vehicle && request.vehicleStatus() != null) throw bad("结束司机任务不能修改车辆状态"); - if (vehicle) { + if (vehicle && trip.getVehicleId()!=null) { var current = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在")); requireRelease("VEHICLE", trip.getVehicleId(), trip.getId()); vehicleService.changeStatus(trip.getVehicleId(), operator, new VehicleModels.ChangeStatus(request.vehicleStatus(), current.recordVersion())); - } else { + } else if(!vehicle && trip.getDriverId()!=null) { // A now-expired or disabled driver may end their task; this never re-enables their qualification. requireRelease("DRIVER", trip.getDriverId(), trip.getId()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java index 065e973b1..cca5f30ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java @@ -12,12 +12,9 @@ public class FleetTripDispatch { @TableId(type = IdType.INPUT) private Long dispatchId; private String dispatchNo; - private String customerId; private Long tripId; private String tripNo; private Integer packageCount; private String boundBy; private LocalDateTime boundAt; - @com.baomidou.mybatisplus.annotation.TableField(updateStrategy = com.baomidou.mybatisplus.annotation.FieldStrategy.ALWAYS) - private String reviewJson; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java index 3de088e92..0962f424a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java @@ -6,6 +6,20 @@ import java.util.List; @Mapper public interface FleetTripDispatchMapper extends BaseMapper { + /** 整车主单一对一关联,无客户头部或绑定级审核。 */ + @Insert("INSERT INTO ll_fleet_trip_dispatch(dispatch_id,dispatch_no,trip_id,trip_no,package_count,bound_by,bound_at) " + + "VALUES(#{dispatchId},#{dispatchNo},#{tripId},#{tripNo},0,#{operator},#{at})") + int insertWholeDispatch(@Param("dispatchId") long dispatchId, @Param("dispatchNo") String dispatchNo, + @Param("tripId") long tripId, @Param("tripNo") String tripNo, + @Param("operator") String operator, @Param("at") java.time.LocalDateTime at); + + /** 只锁司机档案身份,历史命令恢复不以当前证照资格重新执行。 */ + @Select("SELECT id FROM ll_fleet_driver WHERE id=#{id} FOR UPDATE") + Long lockDriverIdentity(@Param("id") long id); + + /** 主单货量和唯一资源关系在父锁内同步,不按客户子单累加。 */ + @Update("UPDATE ll_fleet_trip_dispatch SET package_count=#{packages} WHERE dispatch_no=#{number}") + int updateWholeQuantity(@Param("number") String dispatchNo, @Param("packages") int packages); @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE trip_id=#{tripId} ORDER BY dispatch_id") List listByTrip(@Param("tripId") long tripId); @Select("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_no=#{dispatchNo}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index 1c377d3e0..748287597 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -41,6 +41,98 @@ public class FleetTripExecutionService { this.history = history; this.drivers = drivers; this.vehicles = vehicles; this.json = json; this.bookingReservations = bookingReservations; } + /** 对排车域暴露原生资源创建入口,不暴露Fleet实体和Mapper。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) { + return history.createDispatchShell(operator, request); + } + /** 资源批量预锁不得在第一组来源锁之后才执行。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDispatchCreationResources(List vehicles,List drivers){history.lockDispatchCreationResources(vehicles,drivers);} + + /** 建立主单后的唯一关系,不让前端执行旧绑定动作。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void linkDispatchShell(String operator,long dispatchId,String dispatchNo,String tripNo) { + history.linkDispatchShell(operator,dispatchId,dispatchNo,tripNo); + } + + /** 保持排车域只依赖Fleet原生应用入口。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo) { return history.lockDispatchShell(dispatchNo); } + /** 全范围父级和资源预锁,调用方随后一次收齐全部dispatch。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockExistingDispatchShells(List dispatchNos){history.lockExistingDispatchShells(dispatchNos);} + + /** 只读返回唯一资源身份。 */ + public FleetExecutionModels.DispatchShellLock dispatchIdentity(String no){return history.dispatchIdentity(no);} + /** 汇总仅取得资源父身份锁,不申请车辆司机主档锁或释放资源。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchCompletion(String no){return history.lockDispatchCompletion(no);} + /** 装车读数与真实站序共同展示。 */ + public List dispatchStops(String no){return history.dispatchStops(no);} + /** 按排车父事务维护真实站序。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void reorderDispatchStops(String no,String operator,List keys,String reason){history.reorderDispatchStops(no,operator,keys,reason);} + /** 主单取消与资源占用释放同事务协调。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void cancelDispatchShell(String no,String operator,String reason){history.cancelDispatchShell(no,operator,reason);} + + /** 整车主流程已持父锁;容量沿原策略校验,保存实载不会扣库存或发车。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void confirmDispatchLoad(String no,String operator,String manifestHash,List cargo, + List measurements,String reason) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车已开始运输"); + var ownSegments=capacity(trip,cargo,measurements); + var plan=new FleetExecutionModels.PlannedLoad(manifestHash,cargo,measurements,ownSegments,operator,Instant.now()); + var segments=bookingReservations.segments(trip,plan,null,null); + // 同时核对原有外接预订占用;不因新主流程绕过已确认容量。 + var load=new FleetExecutionModels.ConfirmedLoad(manifestHash,cargo,measurements,segments,operator,Instant.now()); + trip.setLoadJson(write(load));history.recordExecution(trip,trip.getRecordVersion(),"DISPATCH_CONFIRM_LOAD",reason,operator); + } + + /** 已锁父级后复验已确认实载及原预约资格,不在此扣库存或锁客户单据。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void requireDispatchDeparture(String no,String manifestHash) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对"); + if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId()); + bookingReservations.segments(trip,new FleetExecutionModels.PlannedLoad(load.manifestHash(),load.cargo(),load.pallets(),load.segments(),load.operatorId(),load.confirmedAt()),null,null); + appointmentAllocations.requireDeparture(no); + } + + /** 客户单据和来源均已核对后记录实际运输状态,资源占用保留到原任务结束入口。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void departDispatchShell(String no,String operator,Instant at,String reason) { + var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车资源任务已开始执行"); + trip.setStatus("IN_TRANSIT");trip.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); + history.recordExecution(trip,trip.getRecordVersion(),"DEPART",reason,operator); + } + + /** 编辑先锁齐全部新旧身份,保持全局资源锁序。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String no,Long vehicle,Long driver) { return history.lockDispatchShell(no,vehicle,driver); } + + /** 当前资格读取不借主单头客户或客户端主档显示字段。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell dispatchResources(String no,FleetExecutionModels.DispatchVehicle vehicle,FleetExecutionModels.DispatchDriver driver) { + return history.dispatchResources(no,vehicle,driver); + } + + /** 同事务保存已核实的新资源与计划。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void saveDispatchResources(String no,String operator,FleetExecutionModels.DispatchShell actual,Instant plannedAt,String remark) { + history.saveDispatchResources(no,operator,actual,plannedAt,remark); + } + + /** 父事务内更新一车货量与真实目的地。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void updateDispatchCargo(String dispatchNo, int packages, List stops) { + history.updateDispatchCargo(dispatchNo,packages,stops); + } + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) public FleetExecutionModels.Planning capacityPlan(String tripNo) { var trip = requireTrip(tripNo, false); @@ -76,7 +168,6 @@ public class FleetTripExecutionService { private String planningBlockReason(FleetTripEntity trip, List cargo) { if (!"PLANNED".equals(trip.getStatus())) return "仅计划中的车次允许核对计划容量"; - if (trip.getVehicleId() == null) return "请先分配车辆,明确可用托位、载重和容积"; String vehicleReason = currentVehicleBlockReason(trip, false); if (vehicleReason != null) return vehicleReason; if (cargo.isEmpty()) return "请先关联本车自有货排车单"; @@ -87,7 +178,10 @@ public class FleetTripExecutionService { /** Caller holds the parent trip lock before requesting a vehicle write lock. */ public String currentVehicleBlockReason(FleetTripEntity trip, boolean lock) { - if (trip.getVehicleId() == null) return "请先分配车辆"; + if (trip.getVehicleId() == null) return trip.getVehiclePlate()!=null && !trip.getVehiclePlate().isBlank() + && trip.getMaxPalletSpaces()!=null && trip.getMaxPalletSpaces()>0 + && trip.getMaxLoadKg()!=null && trip.getMaxLoadKg().signum()>0 + && trip.getMaxVolumeCbm()!=null && trip.getMaxVolumeCbm().signum()>0?null:"请先确认本趟临时车辆资料和实际容量"; var vehicle = (lock ? vehicles.lockById(trip.getVehicleId()) : vehicles.findById(trip.getVehicleId())).orElse(null); if (vehicle == null || !"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) return "车辆当前不可用,请重新分配车辆并核对计划容量"; @@ -107,22 +201,7 @@ public class FleetTripExecutionService { return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, hash(write(cargo)), load(trip), blockReason(trip, manifest)); } - @Transactional - public FleetExecutionModels.Loading confirmLoad(String tripNo, String operator, FleetExecutionModels.Confirm request) { - var trip = requireTrip(tripNo, true); - requireVersionAndPlan(trip, request.recordVersion()); - var manifest = manifests(trip, true); - String block = blockReason(trip, manifest); - if (block != null) throw conflict(block); - var cargo = cargo(manifest); String fingerprint = hash(write(cargo)); - if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对装载"); - var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList(); - var segments = capacity(trip, cargo, pallets); - var confirmed = new FleetExecutionModels.ConfirmedLoad(fingerprint, cargo, pallets, segments, operator, Instant.now()); - trip.setLoadJson(write(confirmed)); - history.recordExecution(trip, request.recordVersion(), "CONFIRM_LOAD", request.reason(), operator); - return new FleetExecutionModels.Loading(tripNo, trip.getRecordVersion(), cargo, fingerprint, confirmed, null); - } + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) { @@ -170,83 +249,22 @@ public class FleetTripExecutionService { } private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) { if (!gate.canPick()) return gate; - var review = FleetTripReview.read(json, binding.getReviewJson()); + var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); + if(fact==null)return gate.withPicking(false,"整车审核事实缺失"); + var review = fact.dispatchReview(); String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"; return gate.withPicking(reason == null, reason); } private String loadingReviewBlockReason(FleetTripEntity trip) { - var review = FleetTripReview.read(json, trip.getLoadingReviewJson()); - return "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) ? "理货审核已打回:" + review.reason() : "请先通过理货审核再发车"; + var members=bindings.listByTrip(trip.getId()); + var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList()); + return !members.isEmpty() && members.stream().allMatch(b->facts.containsKey(b.getDispatchNo()) && "APPROVED".equals(facts.get(b.getDispatchNo()).loadingReview().status()))?null:"请先通过本车理货审核再发车"; } - @Transactional - public FleetTripModels.Detail reviewLoading(String tripNo, String operator, FleetTripModels.Review request) { - var trip = requireTrip(tripNo, true); - requireVersionAndPlan(trip, request.recordVersion()); - String reason = FleetTripReview.reason(request); - if ("APPROVED".equals(request.decision())) { - if (trip.getDriverId() == null) throw conflict("请先分配司机"); - var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); - String driverBlock = driverBlockReason(trip, driver); - if (driverBlock != null) throw conflict(driverBlock); - var manifest = manifests(trip, true); - String block = blockReason(trip, manifest); - if (block == null) block = loadBlockReason(trip, cargo(manifest)); - if (block != null) throw conflict(block); - } - trip.setLoadingReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, Instant.now(), request.recordVersion()))); - history.recordExecution(trip, request.recordVersion(), "LOADING_REVIEW", reason, operator); - return history.detail(tripNo); - } - @Transactional - public FleetExecutionModels.Departed depart(String warehouseId, String dispatchNo, String operator, FleetExecutionModels.Depart request) { - var binding = bindings.findByDispatchNo(dispatchNo); - if (binding == null) throw conflict("请先在平台将排车单关联车次"); - var trip = requireTrip(binding.getTripNo(), true); - requireWarehouse(trip, warehouseId); - // The binding may have changed while waiting for the trip lock. Never ship a stale parent. - var members = bindings.listByTrip(trip.getId()); - if (members.stream().noneMatch(member -> member.getDispatchNo().equals(dispatchNo))) throw conflict("排车单所属车次已变化,请刷新"); - String requestHash = hash(write(request)); - if (trip.getDepartureKey() != null) { - if (!trip.getDepartureKey().equals(request.idempotencyKey()) || !trip.getDepartureHash().equals(requestHash)) - throw conflict("车次已发车,发车请求标识或内容不一致"); - return read(trip.getDepartureResultJson(), FleetExecutionModels.Departed.class).withReplay(); - } - requireVersionAndPlan(trip, request.recordVersion()); - // Resource lock order matches trip assignment: trip -> vehicle -> driver -> customer dispatches. - if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); - var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> conflict("车辆档案不存在")); - if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region()) - || !Objects.equals(vehicle.plateNumber(), trip.getVehiclePlate()) || !Objects.equals(vehicle.maxPalletSpaces(), trip.getMaxPalletSpaces()) - || !equalNumber(vehicle.maxLoadKg(), trip.getMaxLoadKg()) || !equalNumber(vehicle.maxVolumeCbm(), trip.getMaxVolumeCbm())) - throw conflict("车辆状态或容量已变化,请重新确认车次"); - requireResource("VEHICLE", trip.getVehicleId(), trip.getId()); requireResource("DRIVER", trip.getDriverId(), trip.getId()); - var driver = drivers.requireDispatchable(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); - String driverBlock = driverBlockReason(trip, driver); - if (driverBlock != null) throw conflict(driverBlock); - var manifest = manifests(trip, true); - String reason = blockReason(trip, manifest); - if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); - if (reason == null) reason = loadingReviewBlockReason(trip); - if (reason != null) throw conflict(reason); - // Store the same precision in DATETIME(3), the immutable DEPART snapshot and replay result. - for(var member:members) appointmentAllocations.requireDeparture(member.getDispatchNo()); - Instant now = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.MILLIS); - trip.setStatus("DEPARTING"); - if (trips.updateById(trip) != 1) throw conflict("车次状态已变化"); - var results = new ArrayList(); - for (var member : members) results.add(dispatches.depart(warehouseId, member.getDispatchNo(), operator, - trip.getTripNo() + ":" + member.getDispatchNo(), request.remark(), now)); - trip.setStatus("IN_TRANSIT"); trip.setDepartedAt(LocalDateTime.ofInstant(now, ZoneOffset.UTC)); - var result = new FleetExecutionModels.Departed(trip.getTripNo(), "IN_TRANSIT", now, - results.stream().mapToLong(FleetExecutionModels.DepartedDispatch::shippedPackages).sum(), List.copyOf(results), false); - trip.setDepartureKey(request.idempotencyKey()); trip.setDepartureHash(requestHash); trip.setDepartureResultJson(write(result)); - history.recordExecution(trip, request.recordVersion(), "DEPART", request.remark(), operator); - return result; - } + + private List manifests(FleetTripEntity trip, boolean lock) { return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList(); @@ -261,7 +279,8 @@ public class FleetTripExecutionService { if (manifests.isEmpty()) return "车次尚未关联排车单"; if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联"; for (var binding : bindings.listByTrip(trip.getId())) { - if (!FleetTripReview.approved(json, binding.getReviewJson())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核"; + var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); + if (fact==null || !"APPROVED".equals(fact.dispatchReview().status())) return "排车单 " + binding.getDispatchNo() + " 尚未通过调度审核"; } for (var manifest : manifests) { if (!trip.getWarehouseId().toString().equals(manifest.warehouseId())) return "排车单起运仓与车次不一致"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java index 24ae4d6f6..f92b9dd3e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java @@ -36,10 +36,10 @@ public final class FleetTripModels { public record ReviewState(String status, String reason, String reviewerId, Instant reviewedAt, Long recordVersion) { public static ReviewState pending() { return new ReviewState("PENDING", null, null, null, null); } } - public record DispatchBinding(String dispatchNo, String customerId, int packageCount, String boundBy, Instant boundAt, ReviewState review, + public record DispatchBinding(String dispatchNo, int packageCount, String boundBy, Instant boundAt, ReviewState review, List appointmentBatches) { } /** Original bound references only; these quantities never authorize current allocation. */ - public record PreviousDispatch(String dispatchNo,String customerId,long recordVersion,Instant recordedAt, + public record PreviousDispatch(String dispatchNo,long recordVersion,Instant recordedAt, List appointmentBatches) { } public record Response(String tripNo, String status, String warehouseId, String warehouseCode, String warehouseName, String vehicleId, String vehiclePlate, String driverId, String driverName, String driverPhone, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java deleted file mode 100644 index 7e5848ca2..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripPreparationService.java +++ /dev/null @@ -1,81 +0,0 @@ -package com.baiyue.lastleg.fleet.trip; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.fasterxml.jackson.databind.ObjectMapper; -import jakarta.validation.Valid; -import jakarta.validation.constraints.*; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; -import org.springframework.security.core.context.SecurityContextHolder; -import java.util.*; -import java.time.LocalDateTime; -import java.time.ZoneOffset; - -/** Shared by dispatch and appointment consumers; all business writes commit together. */ -@Service -public class FleetTripPreparationService { - public record Prepare(@Size(max=64) String tripNo,@Min(0) Long recordVersion, - @Valid FleetTripModels.Create plan,@Positive Long vehicleId,@Positive Long driverId, - @NotEmpty @Size(max=100) List<@NotBlank @Size(max=64) String> dispatchNos, - @NotBlank @Size(min=8,max=128) String idempotencyKey) { } - private final FleetTripService service; - private final FleetTripMapper trips; - private final FleetCapacityOfferCommandMapper commands; - private final ObjectMapper json; - public FleetTripPreparationService(FleetTripService service,FleetTripMapper trips,FleetCapacityOfferCommandMapper commands,ObjectMapper json) { - this.service=service;this.trips=trips;this.commands=commands;this.json=json; - } - @Transactional - public FleetTripModels.Detail prepare(String operator,Prepare request) { - boolean create=request.tripNo()==null||request.tripNo().isBlank(); - if(create) { permission("platform:fleet:trip:create");permission("platform:fleet:trip:assign"); } - permission("platform:fleet:trip:edit"); - if(new HashSet<>(request.dispatchNos()).size()!=request.dispatchNos().size())throw conflict("排车单不能重复选择"); - if(create && (request.plan()==null||request.vehicleId()==null||request.driverId()==null))throw conflict("新建车次请填写计划、车辆和司机"); - if(!create && (request.plan()!=null||request.vehicleId()!=null||request.driverId()!=null||request.recordVersion()==null))throw conflict("加入已有车次请提交车次版本,不重复修改资源或计划"); - var initial=create?trips.findCreated(operator,request.idempotencyKey()):trips.findByNo(request.tripNo()); - if(!create&&initial==null)throw conflict("所选车次不存在"); - String payload=write(request); - if(initial!=null) { - var locked=trips.lockByNo(initial.getTripNo()); - var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); - if(previous!=null) { - if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); - try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} - } - if(create)throw conflict("该新建请求已被其他操作使用,请核对原车次"); - } - FleetTripModels.Detail detail; - if(create) { - var p=request.plan(); - detail=service.create(operator,new FleetTripModels.Create(p.warehouseId(),p.plannedDepartureAt(),p.expectedDriverEndAt(),p.stops(),p.remark(),request.idempotencyKey())); - // Concurrent same-key create may have committed while waiting for its unique constraint. - var locked=trips.lockByNo(detail.trip().tripNo()); - var previous=commands.find(locked.getId(),operator,"PREPARE_TRIP",locked.getTripNo(),request.idempotencyKey()); - if(previous!=null) { - if(!Objects.equals(previous.getRequestHash(),hash(payload)))throw conflict("同一安排请求不能更换内容"); - try{return json.readValue(previous.getResultJson(),FleetTripModels.Detail.class);}catch(Exception e){throw new IllegalStateException(e);} - } - detail=service.assign(detail.trip().tripNo(),operator,new FleetTripModels.Assign(detail.trip().recordVersion(),request.vehicleId(),request.driverId(),"安排车次时分配资源")); - } else { - detail=service.detail(request.tripNo()); - if(detail.trip().recordVersion()!=request.recordVersion())throw conflict("车次已变化,请重新核对"); - } - for(String number:request.dispatchNos().stream().sorted().toList()) - detail=service.bindDispatch(detail.trip().tripNo(),operator,new FleetTripModels.BindingCommand(detail.trip().recordVersion(),number,"勾选待运货物安排车次")); - var row=trips.findByNo(detail.trip().tripNo()); - var command=new FleetCapacityOfferCommand();command.setId(IdWorker.getId());command.setTripId(row.getId());command.setOperatorId(operator); - command.setAction("PREPARE_TRIP");command.setScopeNo(row.getTripNo());command.setIdempotencyKey(request.idempotencyKey());command.setRequestHash(hash(payload)); - command.setResultJson(write(detail));command.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));commands.insert(command); - return detail; - } - private static void permission(String name) { - var auth=SecurityContextHolder.getContext().getAuthentication(); - if(auth==null||auth.getAuthorities().stream().noneMatch(a->name.equals(a.getAuthority())))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少安排车次所需权限"); - } - private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} - private static String hash(String value){try{return HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(value.getBytes(java.nio.charset.StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} - private static ResponseStatusException conflict(String message){return new ResponseStatusException(HttpStatus.CONFLICT,message);} -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java index cf51f9ffb..1f9bbd79a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java @@ -24,6 +24,16 @@ public class FleetTripRepository { public FleetTripEntity lockCreated(String operator,String key) { return trips.lockCreated(operator,key); } /** 新增车次记录。 */ public int insertTrip(FleetTripEntity row) { return trips.insert(row); } + /** 保存整车的一对一资源关系,不伪造客户或子排车。 */ + public int insertWholeDispatch(long dispatchId, String dispatchNo, FleetTripEntity trip, String operator) { + return bindings.insertWholeDispatch(dispatchId, dispatchNo, trip.getId(), trip.getTripNo(), operator, trip.getCreatedAt()); + } + /** 普通读定位唯一整车关系,父锁后必须重新验证。 */ + public FleetTripDispatch wholeBinding(String number) { return bindings.findByDispatchNo(number); } + /** 只锁司机身份,不因资格过期阻断原命令恢复。 */ + public Long lockDriverIdentity(long id) { return bindings.lockDriverIdentity(id); } + /** 同步整车资源快照货量。 */ + public int wholeQuantity(String number, int packages) { return bindings.updateWholeQuantity(number,packages); } /** 按业务号查询车次。 */ public FleetTripEntity findTrip(String number) { return trips.findByNo(number); } /** 按业务号加锁读取车次。 */ @@ -50,6 +60,8 @@ public class FleetTripRepository { public int insertHistory(FleetTripHistory row) { return histories.insert(row); } /** 查询资源实时占用。 */ public FleetResourceMapper.Assignment resource(String kind,long id) { return resources.find(kind,id); } + /** 批量正式建车的资源占用在取来源锁之前统一锁齐。 */ + public FleetResourceMapper.Assignment lockResource(String kind,long id){return resources.lock(kind,id);} /** 批量读取候选资源占用,避免列表逐项查询。 */ public List resourceOccupancy(String kind,List ids) { return resources.candidates(kind,ids); } /** 唯一键防止车辆或司机同时承接两趟任务。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 5d44b4731..0c58654a6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -93,11 +93,7 @@ public class FleetTripService { }).toList(); } - /** 创建派送用途车次,保留原有请求幂等语义。 */ - @Transactional - public FleetTripModels.Detail create(String operator, FleetTripModels.Create request) { - return createPlan(operator, request, "DELIVERY"); - } + /** 取货车次首次历史即标识取货用途,复用统一的资源和计划规则。 */ @Transactional @@ -105,6 +101,264 @@ public class FleetTripService { return createPlan(operator, request, "PICKUP"); } + /** 主单创建事务内建立空资源记录,允许未知计划/临时资料,审核时再核齐。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) { + var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); + var trip = new FleetTripEntity(); + trip.setId(IdWorker.getId()); + trip.setTripNo("TR-" + new java.math.BigInteger(UUID.randomUUID().toString().replace("-", ""), 16).toString(36).toUpperCase(Locale.ROOT)); + trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(request.commandHash()); + trip.setStatus("PLANNED"); trip.setTripPurpose("DELIVERY"); trip.setWarehouseId(request.warehouseId()); + trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); + trip.setPlannedDepartureAt(request.plannedDepartureAt() == null ? null : utc(request.plannedDepartureAt())); + trip.setStopsJson("[]"); trip.setRemark(request.remark()); trip.setRecordVersion(0L); + trip.setCreatedBy(operator); trip.setUpdatedBy(operator); trip.setCreatedAt(now()); trip.setUpdatedAt(trip.getCreatedAt()); + if (repository.insertTrip(trip) != 1) throw conflict("整车资源记录创建失败"); + + // 创建记录首先取得本trip写锁,随后只按车辆→司机/资源的顺序取锁。 + var vehicle = request.vehicle(); + String vehicleStatus = null; + if (vehicle != null && "MASTER".equals(vehicle.mode())) { + if (vehicle.vehicleId() == null) throw bad("请选择车辆档案"); + var actual = vehicles.lockById(vehicle.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); + if (!"OVERSEAS".equals(actual.region()) || !"IN_SERVICE".equals(actual.status())) throw bad("请选择在役海外车辆"); + vehicle = new FleetExecutionModels.DispatchVehicle("MASTER", actual.id(), actual.plateNumber(), + actual.driverOrDispatchCompany(), actual.maxPalletSpaces(), actual.maxLoadKg(), actual.maxVolumeCbm(), List.of()); + vehicleStatus = actual.status(); + } else if (vehicle != null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId() != null)) { + throw bad("临时车辆不得关联或伪造车辆档案"); + } + var driver = request.driver(); + if (driver != null && "MASTER".equals(driver.mode())) { + if (driver.driverId() == null) throw bad("请选择司机档案"); + var actual = drivers.requireDispatchable(driver.driverId(), driver.expectedTaskEndAt()); + if (!"OVERSEAS".equals(actual.details().region())) throw bad("请选择海外司机"); + driver = new FleetExecutionModels.DispatchDriver("MASTER", Long.valueOf(actual.id()), actual.details().name(), + actual.details().phone(), actual.details().licenseNumber(), actual.details().licenseExpiresOn(), + actual.licenseFileId() == null ? List.of() : List.of(Long.valueOf(actual.licenseFileId())), driver.expectedTaskEndAt()); + } else if (driver != null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId() != null)) { + throw bad("临时司机不得关联或伪造司机档案"); + } + if (vehicle != null) { + if (vehicle.vehicleId() != null) reserve("VEHICLE", vehicle.vehicleId(), trip); + trip.setVehicleId(vehicle.vehicleId()); trip.setVehiclePlate(vehicle.plate()); + trip.setMaxPalletSpaces(vehicle.palletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); + } + if (driver != null) { + if (driver.driverId() != null) reserve("DRIVER", driver.driverId(), trip); + trip.setDriverId(driver.driverId()); trip.setDriverName(driver.name()); trip.setDriverPhone(driver.phone()); + trip.setExpectedDriverEndAt(driver.expectedTaskEndAt() == null ? null : utc(driver.expectedTaskEndAt())); + } + if (repository.updateTrip(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()) + .eq(FleetTripEntity::getRecordVersion, 0L)) != 1) + throw conflict("整车资源关联保存失败"); + return new FleetExecutionModels.DispatchShell(trip.getId(), trip.getTripNo(), warehouse.id(), warehouse.code(), + warehouse.name(), vehicle, driver, vehicleStatus); + } + + /** 批量草稿转换先统一锁车辆、司机和占用,后续新trip为事务内新身份且不可被其他会话引用。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockDispatchCreationResources(List requestedVehicles,List requestedDrivers) { + var vehicleIds=new TreeSet();var driverIds=new TreeSet(); + for(var vehicle:requestedVehicles)if(vehicle!=null && "MASTER".equals(vehicle.mode())) { + if(vehicle.vehicleId()==null || vehicle.vehicleId()<=0)throw bad("请选择真实车辆档案");vehicleIds.add(vehicle.vehicleId()); + } + for(var driver:requestedDrivers)if(driver!=null && "MASTER".equals(driver.mode())) { + if(driver.driverId()==null || driver.driverId()<=0)throw bad("请选择真实司机档案");driverIds.add(driver.driverId()); + } + for(var id:vehicleIds)if(vehicles.lockById(id).isEmpty())throw bad("车辆档案不存在"); + for(var id:driverIds)if(repository.lockDriverIdentity(id)==null)throw bad("司机档案不存在"); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + } + + /** 主单插入后再建立关系,允许数据库同时约束trip和dispatch两个外键。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void linkDispatchShell(String operator,long dispatchId,String dispatchNo,String tripNo) { + var trip=repository.lockTrip(tripNo); + if(trip==null || !"DELIVERY".equals(trip.getTripPurpose()) || !repository.listBindings(trip.getId()).isEmpty()) + throw conflict("整车资源已关联其他主单"); + if(repository.insertWholeDispatch(dispatchId,dispatchNo,trip,operator)!=1) throw conflict("整车唯一资源关系未保存"); + } + + /** 新主流程先锁trip、车辆、司机身份,再由调用方锁dispatch。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo) { + return lockDispatchShell(dispatchNo,null,null); + } + + /** 原预约跨车改量先收齐所有trip,再收齐全部车辆、司机和占用,尚不锁dispatch。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockExistingDispatchShells(List numbers) { + if(numbers==null || numbers.isEmpty() || numbers.size()>1000 || numbers.stream().anyMatch(n->n==null || n.isBlank()) || new HashSet<>(numbers).size()!=numbers.size())throw bad("请选择完整且不重复的原排车范围"); + var observed=new TreeMap(); + for(var number:numbers) { + var binding=repository.wholeBinding(number); + if(binding==null || observed.putIfAbsent(binding.getTripId(),binding)!=null)throw conflict("原排车资源范围缺失或非一对一关联"); + } + var locked=new ArrayList(); + for(var entry:observed.entrySet()) { + var trip=repository.lockTrip(entry.getValue().getTripNo()); + if(trip==null || !entry.getKey().equals(trip.getId()))throw conflict("原排车资源范围已变化"); + locked.add(trip); + } + for(var trip:locked) { + var bindings=repository.listBindings(trip.getId());var original=observed.get(trip.getId()); + if(bindings.size()!=1 || !Objects.equals(bindings.get(0).getDispatchId(),original.getDispatchId()) || !Objects.equals(bindings.get(0).getDispatchNo(),original.getDispatchNo()))throw conflict("原排车一对一关联已变化,请重读完整范围"); + } + var vehicleIds=new TreeSet();var driverIds=new TreeSet(); + for(var trip:locked){if(trip.getVehicleId()!=null)vehicleIds.add(trip.getVehicleId());if(trip.getDriverId()!=null)driverIds.add(trip.getDriverId());} + for(var id:vehicleIds)vehicles.lockById(id); + for(var id:driverIds)repository.lockDriverIdentity(id); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + } + + /** 只读定位本车资源身份,不取得写资格。 */ + @Transactional(readOnly=true) + public FleetExecutionModels.DispatchShellLock dispatchIdentity(String dispatchNo) { + var binding=repository.wholeBinding(dispatchNo);if(binding==null)throw conflict("整车资源关系缺失"); + var trip=repository.findTrip(binding.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 签收汇总只锁资源父记录身份,不变更或释放车辆司机,不申请其主档锁。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchCompletion(String no) { + var observed=repository.wholeBinding(no);if(observed==null)throw conflict("整车资源关系缺失"); + var trip=repository.lockTrip(observed.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + var bindings=repository.listBindings(trip.getId());if(bindings.size()!=1 || !no.equals(bindings.get(0).getDispatchNo()))throw conflict("整车资源关系已变化"); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 实载确认沿现有站序核对卸货位置,不能由客户端自由编造站点。 */ + @Transactional(readOnly=true) + public List dispatchStops(String no) { + var identity=dispatchIdentity(no);var trip=repository.findTrip(identity.tripNo()); + return read(trip.getStopsJson(),new TypeReference>(){}).stream() + .map(stop->new FleetExecutionModels.DispatchStop(stop.name(),stop.address(),stop.destinationKey())).toList(); + } + + /** 仅重排现有真实目的地,保留起点和外接预订;不接受客户端伪造名称或地址。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void reorderDispatchStops(String no,String operator,List destinationKeys,String reason) { + var identity=dispatchIdentity(no);var trip=repository.lockTrip(identity.tripNo()); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅未发车的路线可调整"); + var previous=read(trip.getStopsJson(),new TypeReference>(){}); + if(previous.isEmpty())throw conflict("本车缺少真实起运仓"); + var byKey=new LinkedHashMap(); + for(int index=1;index199 + || destinationKeys.stream().anyMatch(Objects::isNull) || new HashSet<>(destinationKeys).size()!=destinationKeys.size() + || !new HashSet<>(destinationKeys).equals(byKey.keySet()))throw bad("请完整重排本车既有目的地,保留外接卸货点"); + var stops=new ArrayList();stops.add(previous.get(0));destinationKeys.forEach(key->stops.add(byKey.get(key))); + bookingReservations.requireRoute(trip,stops);long version=trip.getRecordVersion(); + if(repository.updateTrip(null,new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version) + .set(FleetTripEntity::getStopsJson,write(stops)).set(FleetTripEntity::getPlannedLoadJson,null).set(FleetTripEntity::getLoadJson,null) + .set(FleetTripEntity::getLoadingReviewJson,null).set(FleetTripEntity::getRecordVersion,version+1).set(FleetTripEntity::getUpdatedBy,operator).set(FleetTripEntity::getUpdatedAt,now()))!=1)throw conflict("资源路线版本已变化"); + record(repository.findTrip(identity.tripNo()),"DISPATCH_ROUTE",reason,operator); + } + + /** 主单零扫码取消时协调本车资源,外接已确认业务沿原取消前置保留。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void cancelDispatchShell(String no,String operator,String reason) { + var identity=dispatchIdentity(no);var trip=repository.lockTrip(identity.tripNo()); + if(!"PLANNED".equals(trip.getStatus()))throw conflict("资源任务已开始执行,不能取消"); + if(bookingReservations.hasConfirmed(trip.getId()))throw conflict("请先取消已确认的外接预订,再取消排车"); + if(trip.getVehicleId()!=null)release("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null)release("DRIVER",trip.getDriverId(),trip.getId()); + trip.setStatus("CANCELLED");persist(trip,trip.getRecordVersion(),"DISPATCH_CANCEL",reason,operator); + } + + /** 编辑资源时先锁齐旧新车辆,再锁齐旧新司机,禁止持司机锁回头抢新车辆。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShellLock lockDispatchShell(String dispatchNo,Long vehicleId,Long driverId) { + var observed = repository.wholeBinding(dispatchNo); + if (observed == null) throw conflict("整车资源关联缺失,请核对原排车单"); + var trip = repository.lockTrip(observed.getTripNo()); + if (trip == null) throw conflict("整车资源记录缺失"); + var bindings = repository.listBindings(trip.getId()); + if (bindings.size() != 1 || !dispatchNo.equals(bindings.get(0).getDispatchNo())) throw conflict("整车资源关联已变化"); + var vehicleIds=new TreeSet(); if(trip.getVehicleId()!=null)vehicleIds.add(trip.getVehicleId());if(vehicleId!=null)vehicleIds.add(vehicleId); + for(var id:vehicleIds) vehicles.lockById(id); + var driverIds=new TreeSet();if(trip.getDriverId()!=null)driverIds.add(trip.getDriverId());if(driverId!=null)driverIds.add(driverId); + for(var id:driverIds) repository.lockDriverIdentity(id); + for(var id:vehicleIds)repository.lockResource("VEHICLE",id); + for(var id:driverIds)repository.lockResource("DRIVER",id); + return new FleetExecutionModels.DispatchShellLock(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString()); + } + + /** 已持父级全部身份锁后,复验资源实际资格;快照不信任请求中的主档名称和容量。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public FleetExecutionModels.DispatchShell dispatchResources(String dispatchNo,FleetExecutionModels.DispatchVehicle vehicle, + FleetExecutionModels.DispatchDriver driver) { + var binding=repository.wholeBinding(dispatchNo);if(binding==null)throw conflict("整车资源关系缺失"); + var trip=repository.lockTrip(binding.getTripNo());if(trip==null)throw conflict("整车资源记录缺失"); + String vehicleStatus=null; + if(vehicle!=null && "MASTER".equals(vehicle.mode())) { + if(vehicle.vehicleId()==null)throw bad("请选择车辆档案"); + var actual=vehicles.findById(vehicle.vehicleId()).orElseThrow(()->bad("车辆档案不存在")); + if(!"OVERSEAS".equals(actual.region()) || !"IN_SERVICE".equals(actual.status()))throw conflict("车辆当前不可执行海外任务"); + var assignment=repository.resource("VEHICLE",actual.id()); + if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("车辆已被其他车次占用"); + vehicle=new FleetExecutionModels.DispatchVehicle("MASTER",actual.id(),actual.plateNumber(),actual.driverOrDispatchCompany(), + actual.maxPalletSpaces(),actual.maxLoadKg(),actual.maxVolumeCbm(),List.of());vehicleStatus=actual.status(); + } else if(vehicle!=null && (!"EXTERNAL".equals(vehicle.mode()) || vehicle.vehicleId()!=null)) throw bad("临时车辆不得关联车辆档案"); + if(driver!=null && "MASTER".equals(driver.mode())) { + if(driver.driverId()==null)throw bad("请选择司机档案"); + var actual=drivers.requireDispatchable(driver.driverId(),driver.expectedTaskEndAt()); + if(!"OVERSEAS".equals(actual.details().region()))throw conflict("司机当前不可执行海外任务"); + var assignment=repository.resource("DRIVER",driver.driverId()); + if(assignment!=null && assignment.tripId()!=trip.getId())throw conflict("司机已被其他车次占用"); + driver=new FleetExecutionModels.DispatchDriver("MASTER",Long.valueOf(actual.id()),actual.details().name(),actual.details().phone(), + actual.details().licenseNumber(),actual.details().licenseExpiresOn(),actual.licenseFileId()==null?List.of():List.of(Long.valueOf(actual.licenseFileId())),driver.expectedTaskEndAt()); + } else if(driver!=null && (!"EXTERNAL".equals(driver.mode()) || driver.driverId()!=null))throw bad("临时司机不得关联司机档案"); + return new FleetExecutionModels.DispatchShell(trip.getId(),trip.getTripNo(),trip.getWarehouseId().toString(),trip.getWarehouseCode(),trip.getWarehouseName(),vehicle,driver,vehicleStatus); + } + + /** 已核对来源的更新原子替换资源占用,保留本趟附加能力和旧资源历史。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void saveDispatchResources(String dispatchNo,String operator,FleetExecutionModels.DispatchShell actual,Instant plannedAt,String remark) { + var trip=repository.lockTrip(actual.tripNo());if(trip==null || !"PLANNED".equals(trip.getStatus()))throw conflict("本车资源任务已开始执行"); + var v=actual.vehicle();var d=actual.driver();Long vehicleId=v==null?null:v.vehicleId(),driverId=d==null?null:d.driverId(); + if(trip.getVehicleId()!=null && !Objects.equals(trip.getVehicleId(),vehicleId))release("VEHICLE",trip.getVehicleId(),trip.getId()); + if(trip.getDriverId()!=null && !Objects.equals(trip.getDriverId(),driverId))release("DRIVER",trip.getDriverId(),trip.getId()); + if(vehicleId!=null)reserve("VEHICLE",vehicleId,trip);if(driverId!=null)reserve("DRIVER",driverId,trip); + long version=trip.getRecordVersion(); + var update=new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version) + .set(FleetTripEntity::getVehicleId,vehicleId).set(FleetTripEntity::getVehiclePlate,v==null?null:v.plate()) + .set(FleetTripEntity::getMaxPalletSpaces,v==null?null:v.palletSpaces()).set(FleetTripEntity::getMaxLoadKg,v==null?null:v.maxLoadKg()) + .set(FleetTripEntity::getMaxVolumeCbm,v==null?null:v.maxVolumeCbm()).set(FleetTripEntity::getDriverId,driverId) + .set(FleetTripEntity::getDriverName,d==null?null:d.name()).set(FleetTripEntity::getDriverPhone,d==null?null:d.phone()) + .set(FleetTripEntity::getExpectedDriverEndAt,d==null||d.expectedTaskEndAt()==null?null:utc(d.expectedTaskEndAt())) + .set(FleetTripEntity::getPlannedDepartureAt,plannedAt==null?null:utc(plannedAt)).set(FleetTripEntity::getRemark,remark) + .set(FleetTripEntity::getPlannedLoadJson,null).set(FleetTripEntity::getLoadJson,null).set(FleetTripEntity::getLoadingReviewJson,null) + .set(FleetTripEntity::getRecordVersion,version+1).set(FleetTripEntity::getUpdatedAt,now()).set(FleetTripEntity::getUpdatedBy,operator); + if(repository.updateTrip(null,update)!=1)throw conflict("整车资源版本已变化"); + record(repository.findTrip(actual.tripNo()),"DISPATCH_EDIT",remark,operator); + } + + /** 父锁内同步真实货量和目的地;已确认外接站点顺序不得重排。 */ + @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) + public void updateDispatchCargo(String dispatchNo, int packages, List destinations) { + var binding = repository.wholeBinding(dispatchNo); + if (binding == null) throw conflict("整车关联缺失"); + var trip = repository.lockTrip(binding.getTripNo()); + if (trip == null || !"PLANNED".equals(trip.getStatus())) throw conflict("仅未发车整车可以调整货物"); + List existing = read(trip.getStopsJson(),new TypeReference>() { }); + var stops = new ArrayList<>(existing); + if (stops.isEmpty()) stops.add(new FleetTripModels.Stop(trip.getWarehouseName(),null,"ORIGIN:"+trip.getWarehouseId())); + var keys = new HashSet(); stops.forEach(stop -> keys.add(stop.destinationKey())); + for (var stop : destinations) if (keys.add(stop.destinationKey())) stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey())); + trip.setStopsJson(write(stops)); trip.setPlannedLoadJson(null); trip.setLoadJson(null); trip.setLoadingReviewJson(null); + trip.setRecordVersion(trip.getRecordVersion()+1); trip.setUpdatedAt(now()); + if (repository.updateTrip(trip,new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId())) != 1 + || repository.wholeQuantity(dispatchNo,packages) != 1) throw conflict("整车货物快照保存失败"); + } + /** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */ private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) { String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request)); @@ -136,6 +390,7 @@ public class FleetTripService { @Transactional public FleetTripModels.Detail assign(String tripNo, String operator, FleetTripModels.Assign request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); + if(!repository.listBindings(trip.getId()).isEmpty())throw conflict("本车资源请通过排车主单调整并重新审核"); requireReason(request.reason()); // 分配始终先锁车辆再锁司机,唯一占用记录作为并发分配的最终约束。 var vehicle = vehicles.lockById(request.vehicleId()).orElseThrow(() -> bad("车辆档案不存在")); @@ -161,21 +416,17 @@ public class FleetTripService { trip.setVehicleId(vehicle.id()); trip.setVehiclePlate(vehicle.plateNumber()); trip.setMaxPalletSpaces(vehicle.maxPalletSpaces()); trip.setMaxLoadKg(vehicle.maxLoadKg()); trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); trip.setDriverId(Long.valueOf(driver.id())); trip.setDriverName(driver.details().name()); trip.setDriverPhone(driver.details().phone()); - for (var binding : repository.listBindings(trip.getId())) { - var dispatch = dispatches.lockForAssignment(binding.getDispatchNo()); - if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw conflict("排车单与车次起运仓不一致"); - dispatches.assign(dispatch, assignment(trip)); - } persist(trip, request.recordVersion(), "ASSIGN", request.reason(), operator); var currentBindings=bindingResponses(trip); var history=previousDispatches(trip,currentBindings); - return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason()); + return new FleetTripModels.Detail(response(trip), driver.qualification(), currentBindings, loadingReview(trip),history.dispatches(),history.unavailableReason()); } /** 修改未发车计划;取货车次保留原单取货点和目的仓。 */ @Transactional public FleetTripModels.Detail updatePlan(String tripNo, String operator, FleetTripModels.Plan request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); + if(!repository.listBindings(trip.getId()).isEmpty())throw conflict("本车计划请通过排车主单调整并重新审核"); requireReason(request.reason()); validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); bookingReservations.requireRoute(trip, request.stops()); @@ -184,37 +435,13 @@ public class FleetTripService { if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线"); boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt())) && trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt())); - for (var binding : repository.listBindings(trip.getId())) { - if (unchangedRoute) dispatches.lockForAssignment(binding.getDispatchNo()); - else dispatches.lockForPlanning(binding.getDispatchNo()); - } trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); trip.setStopsJson(write(request.stops())); trip.setRemark(trim(request.remark())); persist(trip, request.recordVersion(), unchangedPlan ? "REMARK" : "PLAN", request.reason(), operator); return detail(trip); } - /** 逐张审核客户排车单,不以同车其他客户审核代替本单。 */ - @Transactional - public FleetTripModels.Detail reviewDispatch(String tripNo, String dispatchNo, String operator, FleetTripModels.Review request) { - var trip = planned(tripNo, request.recordVersion()); - String reason = FleetTripReview.reason(request); - if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); - if ("APPROVED".equals(request.decision())) { - var vehicle = vehicles.lockById(trip.getVehicleId()).orElseThrow(() -> bad("车辆档案不存在")); - if (!"IN_SERVICE".equals(vehicle.status()) || !"OVERSEAS".equals(vehicle.region())) throw conflict("车辆当前不可派车"); - var driver = drivers.requireDispatchable(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())); - if (!"OVERSEAS".equals(driver.details().region())) throw conflict("请选择海外司机"); - } - var dispatch = dispatches.lockForAssignment(dispatchNo); - var binding = repository.lockBinding(dispatch.dispatchId()); - if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); - binding.setReviewJson(write(new FleetTripModels.ReviewState(request.decision(), reason, operator, clock.instant(), request.recordVersion()))); - if (repository.updateBinding(binding) != 1) throw conflict("排车单审核保存失败"); - trip.setLoadingReviewJson(null); - persist(trip, request.recordVersion(), "DISPATCH_REVIEW", reason, operator); - return detail(trip); - } + /** 取消未执行车次,释放资源并将取货原单退回待安排。 */ @Transactional @@ -238,43 +465,9 @@ public class FleetTripService { return detail(trip); } - @jakarta.annotation.Resource private FleetDispatchPlanningGateway planning; - /** 读取当前车次可关联的客户排车候选。 */ - @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) - public com.baiyue.common.core.api.PageResult dispatchCandidates(String tripNo,String keyword,int page,int size) { - var trip=repository.findTrip(tripNo); - if(trip==null)throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.NOT_FOUND,"车次不存在"); - if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次可以添加待运货物"); - return planning.candidates(tripNo,trip.getWarehouseId().toString(),keyword,page,size); - } - /** 关联同仓客户排车单并同步车辆司机,禁止取货车次混用。 */ - @Transactional - public FleetTripModels.Detail bindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { - var trip = planned(tripNo, request.recordVersion()); - if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次不能关联出库排车单"); - requireReason(request.reason()); - if (trip.getVehicleId() == null || trip.getDriverId() == null) throw conflict("请先分配车辆和司机"); - var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); - appointmentAllocations.requireTripBinding(dispatch.dispatchNo(),trip.getId()); - if (!trip.getWarehouseId().toString().equals(dispatch.warehouseId())) throw bad("排车单与车次必须使用同一起运仓"); - var bound = repository.lockBinding(dispatch.dispatchId()); - if (bound != null) throw conflict("排车单已关联车次 " + bound.getTripNo()); - var binding = new FleetTripDispatch(); - binding.setDispatchId(dispatch.dispatchId()); binding.setDispatchNo(dispatch.dispatchNo()); binding.setCustomerId(dispatch.customerId()); - binding.setTripId(trip.getId()); binding.setTripNo(tripNo); binding.setPackageCount(dispatch.packageCount()); - binding.setBoundBy(operator); binding.setBoundAt(now()); - try { if (repository.insertBinding(binding) != 1) throw conflict("排车单关联失败"); } - catch (DuplicateKeyException duplicate) { throw conflict("排车单已关联其他车次,请刷新后重试"); } - bookingReservations.bindIncoming(trip, dispatch, dispatches.sourceCargo(dispatch.dispatchNo()), operator); - var route = matchedStops(response(trip).stops(), dispatches.manifest(dispatch.dispatchNo(), false).cargo()); - validatePlan(instant(trip.getPlannedDepartureAt()), instant(trip.getExpectedDriverEndAt()), route); - bookingReservations.requireRoute(trip, route); - trip.setStopsJson(write(route)); - dispatches.assign(dispatch, assignment(trip)); - persist(trip, request.recordVersion(), "BIND_DISPATCH", request.reason(), operator); - return detail(trip); - } + + static List matchedStops(List existing, List cargo) { // Keep established stop indexes/order: confirmed bookings may refer to them. @@ -297,20 +490,7 @@ public class FleetTripService { return List.copyOf(result); } - /** 解除尚未执行的排车关联并使原装载审核失效。 */ - @Transactional - public FleetTripModels.Detail unbindDispatch(String tripNo, String operator, FleetTripModels.BindingCommand request) { - var trip = planned(tripNo, request.recordVersion()); - requireReason(request.reason()); - var dispatch = dispatches.lockForPlanning(request.dispatchNo().trim()); - var binding = repository.lockBinding(dispatch.dispatchId()); - if (binding == null || !Objects.equals(binding.getTripId(), trip.getId())) throw conflict("排车单未关联当前车次"); - dispatches.assign(dispatch, new FleetDispatchGateway.Assignment(null, null, null)); - if (repository.deleteBinding(binding.getDispatchId()) != 1) throw conflict("排车单关联已变化,请刷新后重试"); - bookingReservations.unbindIncoming(trip, dispatch.dispatchNo(), operator); - persist(trip, request.recordVersion(), "UNBIND_DISPATCH", request.reason(), operator); - return detail(trip); - } + /** 分页读取不可变车次历史,用于追溯计划和执行变更。 */ public PageResult history(String tripNo, int page, int size) { @@ -362,14 +542,25 @@ public class FleetTripService { var currentBindings=bindingResponses(trip); var history=previousDispatches(trip,currentBindings); return new FleetTripModels.Detail(response(trip), trip.getDriverId() == null ? null - : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, FleetTripReview.read(json, trip.getLoadingReviewJson()),history.dispatches(),history.unavailableReason()); + : drivers.detail(trip.getDriverId(), instant(trip.getExpectedDriverEndAt())).qualification(), currentBindings, loadingReview(trip),history.dispatches(),history.unavailableReason()); } - /** 将客户隔离的排车关联转换为详情展示模型。 */ + /** Fleet只展示主单当前审核事实;没有主单的取货记录没有排车审核。 */ + private FleetTripModels.ReviewState loadingReview(FleetTripEntity trip) { + var members=repository.listBindings(trip.getId()); + if(members.isEmpty())return new FleetTripModels.ReviewState("NOT_SUBMITTED",null,null,null,null); + if(members.size()!=1)throw conflict("车次必须唯一关联整车主单"); + var fact=dispatches.workflows(List.of(members.get(0).getDispatchNo())).get(members.get(0).getDispatchNo()); + if(fact==null)throw conflict("资源关联缺少真实整车审核事实"); + return fact.loadingReview(); + } + /** 将整车关联和逐客户预约引用转换为详情展示模型。 */ private List bindingResponses(FleetTripEntity trip) { var current=repository.listBindings(trip.getId()); var references=appointmentAllocations.references(current.stream().map(FleetTripDispatch::getDispatchId).toList()); + var facts=dispatches.workflows(current.stream().map(FleetTripDispatch::getDispatchNo).toList()); + if(current.stream().anyMatch(b->!facts.containsKey(b.getDispatchNo())))throw conflict("资源关联缺少真实整车审核事实"); return current.stream().map(binding -> new FleetTripModels.DispatchBinding(binding.getDispatchNo(), - binding.getCustomerId(), binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), FleetTripReview.read(json, binding.getReviewJson()),references.getOrDefault(binding.getDispatchId(),List.of()))).toList(); + binding.getPackageCount(), binding.getBoundBy(), instant(binding.getBoundAt()), facts.get(binding.getDispatchNo()).dispatchReview(),references.getOrDefault(binding.getDispatchId(),List.of()))).toList(); } /** 组装当前车次关联单据和预约追溯信息。 */ private record AppointmentHistory(List dispatches,String unavailableReason) { } @@ -389,9 +580,9 @@ public class FleetTripService { var references=new ArrayList(); for(var batch:binding.appointmentBatches()) { var missing=batch.sources().stream().filter(source->seen.add(referenceKey(batch,source))).toList(); - if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.customerId(),missing)); + if(!missing.isEmpty()) references.add(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference(batch.batchKey(),batch.batchNo(),batch.recordVersion(),batch.customerId(),missing)); } - if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),binding.customerId(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references))); + if(!references.isEmpty()) previous.add(new FleetTripModels.PreviousDispatch(binding.dispatchNo(),event.getRecordVersion(),instant(event.getOccurredAt()),List.copyOf(references))); } } return new AppointmentHistory(List.copyOf(previous),unavailableReason); @@ -430,8 +621,6 @@ public class FleetTripService { if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setPlannedLoadJson(null); if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL").contains(action)) trip.setLoadJson(null); if (Set.of("ASSIGN", "PLAN", "BIND_DISPATCH", "UNBIND_DISPATCH", "CANCEL", "CONFIRM_LOAD").contains(action)) trip.setLoadingReviewJson(null); - if (Set.of("ASSIGN", "PLAN").contains(action)) repository.updateBindings(new LambdaUpdateWrapper() - .eq(FleetTripDispatch::getTripId, trip.getId()).set(FleetTripDispatch::getReviewJson, null)); trip.setRecordVersion(version + 1); trip.setUpdatedAt(now()); trip.setUpdatedBy(operator); if (repository.updateTrip(trip, new LambdaUpdateWrapper().eq(FleetTripEntity::getId, trip.getId()).eq(FleetTripEntity::getRecordVersion, version)) != 1) throw conflict("车次已被修改,请刷新后重试"); @@ -444,7 +633,7 @@ public class FleetTripService { event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt()); event.setSnapshotJson(write(new FleetTripModels.AuditSnapshot(response(trip), bindingResponses(trip), trip.getLoadJson() == null ? null : read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class), trip.getCompletionJson() == null ? null - : read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), FleetTripReview.read(json, trip.getLoadingReviewJson()), + : read(trip.getCompletionJson(), FleetCompletionModels.Completed.class), loadingReview(trip), trip.getPlannedLoadJson() == null ? null : read(trip.getPlannedLoadJson(), FleetExecutionModels.PlannedLoad.class), bookingReservations.responses(trip.getId())))); if (repository.insertHistory(event) != 1) throw conflict("车次历史保存失败"); } @@ -463,6 +652,8 @@ public class FleetTripService { } /** 按指定模型解析历史 JSON,损坏记录明确报错。 */ private T read(String value, Class type) { try { return json.readValue(value, type); } catch (JsonProcessingException e) { throw new IllegalStateException("车次历史读取失败", e); } } + /** 按显式集合类型读取资源站点,禁止损坏数据回退为假路线。 */ + private T read(String value, TypeReference type) { try { return json.readValue(value,type); } catch (JsonProcessingException error) { throw new IllegalStateException("车次集合事实读取失败",error); } } /** 为幂等请求生成不可逆内容摘要。 */ private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } } /** 使用统一时钟获取 UTC 业务时间。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java index d7fbcd061..a54777c1d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java @@ -51,7 +51,7 @@ public class PlatformFleetCapacityOfferController { public ApiResponse incoming(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.StartIncoming request){return ApiResponse.success(service.arrangeIncoming(bookingNo,request));} @PostMapping("/inquiries/{bookingNo}/receipt-confirmation") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-confirm')") public ApiResponse receipt(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.AcceptReceipt request){return ApiResponse.success(service.acceptReceipt(bookingNo,request));} - @PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:inbound-order:smart-dispatch')") + @PostMapping("/inquiries/{bookingNo}/arrange-dispatch") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') and hasAuthority('platform:fleet:trip:booking-edit') and hasAuthority('platform:fleet:trip:edit') and hasAuthority('platform:overseas-warehouse:dispatch:create')") public ApiResponse arrange(@PathVariable String bookingNo,@Valid @RequestBody FleetBookingModels.Command request){return ApiResponse.success(service.arrangeDispatch(bookingNo,request));} @PostMapping("/inquiries/{bookingNo}/save-and-confirm") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java index 21cec563c..ff93ee439 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java @@ -27,13 +27,7 @@ public class PlatformFleetTripController { throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台主体可读取车次日历"); return ApiResponse.success(calendar.calendar(from,to)); } - @jakarta.annotation.Resource private FleetTripPreparationService preparation; - @PostMapping("/prepare") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse prepare(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody FleetTripPreparationService.Prepare request) { - return ApiResponse.success(preparation.prepare(operator(user),request)); - } + private final FleetTripService service; private final FleetTripExecutionService execution; private final FleetTripCompletionService completion; @@ -47,11 +41,7 @@ public class PlatformFleetTripController { @GetMapping("/{tripNo}") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") public ApiResponse detail(@PathVariable String tripNo) { return ApiResponse.success(service.detail(tripNo)); } - @PostMapping - @PreAuthorize("hasAuthority('platform:fleet:trip:create')") - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, @Valid @RequestBody FleetTripModels.Create request) { - return ApiResponse.success(service.create(operator(user), request)); - } + @PutMapping("/{tripNo}/plan") @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") public ApiResponse plan(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Plan request) { @@ -78,43 +68,17 @@ public class PlatformFleetTripController { public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, @Valid @RequestBody FleetTripModels.Cancel request) { return ApiResponse.success(service.cancel(tripNo, operator(user), request)); } - @GetMapping("/{tripNo}/dispatch-candidates") - @PreAuthorize("hasAuthority('platform:fleet:trip:list')") - public ApiResponse> dispatchCandidates(@PathVariable String tripNo, - @RequestParam(required=false) String keyword,@RequestParam(defaultValue="1") @Min(1) int page, - @RequestParam(defaultValue="20") @Min(1) @Max(100) int size) { - return ApiResponse.success(service.dispatchCandidates(tripNo,keyword,page,size)); - } - @PostMapping("/{tripNo}/dispatches/bind") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @Valid @RequestBody FleetTripModels.BindingCommand request) { - return ApiResponse.success(service.bindDispatch(tripNo, operator(user), request)); - } - @PostMapping("/{tripNo}/dispatches/unbind") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse unbind(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @Valid @RequestBody FleetTripModels.BindingCommand request) { - return ApiResponse.success(service.unbindDispatch(tripNo, operator(user), request)); - } + + + @GetMapping("/{tripNo}/history") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") public ApiResponse> history(@PathVariable String tripNo, @RequestParam(defaultValue = "1") @Min(1) int page, @RequestParam(defaultValue = "20") @Min(1) @Max(200) int size) { return ApiResponse.success(service.history(tripNo, page, size)); } - @PostMapping("/{tripNo}/dispatches/{dispatchNo}/review") - @PreAuthorize("hasAuthority('platform:fleet:trip:dispatch-review')") - public ApiResponse reviewDispatch(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @PathVariable String dispatchNo, @Valid @RequestBody FleetTripModels.Review request) { - return ApiResponse.success(service.reviewDispatch(tripNo, dispatchNo, operator(user), request)); - } - @PostMapping("/{tripNo}/loading-review") - @PreAuthorize("hasAuthority('platform:fleet:trip:loading-review')") - public ApiResponse reviewLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @Valid @RequestBody FleetTripModels.Review request) { - return ApiResponse.success(execution.reviewLoading(tripNo, operator(user), request)); - } + + @GetMapping("/{tripNo}/loading") @PreAuthorize("hasAuthority('platform:fleet:trip:list')") public ApiResponse loading(@PathVariable String tripNo) { return ApiResponse.success(execution.loading(tripNo)); } @@ -127,12 +91,7 @@ public class PlatformFleetTripController { @Valid @RequestBody FleetExecutionModels.Confirm request) { return ApiResponse.success(execution.confirmCapacityPlan(tripNo, operator(user), request)); } - @PostMapping("/{tripNo}/loading") - @PreAuthorize("hasAuthority('platform:fleet:trip:edit')") - public ApiResponse confirmLoading(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String tripNo, - @Valid @RequestBody FleetExecutionModels.Confirm request) { - return ApiResponse.success(execution.confirmLoad(tripNo, operator(user), request)); - } + private static String operator(AuthenticatedUser user) { if (user == null || user.userId() == null) throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "请重新登录"); return user.userId().toString(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java index 216503c41..49bdc2429 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java @@ -44,7 +44,8 @@ public interface InboundFbaAppointmentQueryMapper { @Select(""" SELECT COUNT(DISTINCT d.id) FROM ll_dispatch_appointment_allocation a - JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.customer_id=a.customer_id + JOIN ll_dispatch_order d ON d.id=a.dispatch_id + JOIN ll_dispatch_item i ON i.id=a.dispatch_item_id AND i.dispatch_order_id=d.id AND i.customer_id=a.customer_id AND i.plan_pallet_id=a.plan_pallet_id WHERE a.batch_key=CONCAT('FBA_APPOINTMENT:',#{appointmentId}) AND d.status<>'CANCELLED' """) long countActiveDispatches(@Param("appointmentId") Long appointmentId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java index 9bf6eae1e..17fd19e7d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java @@ -9,5 +9,5 @@ public interface FbaAppointmentExecutionPort { record Scope(String version,List members) { } Scope inspect(List members); /** Locks original trips and selected customers' dispatch/execution rows before core batch locks. */ - Scope lockAndInspect(List members,String expectedVersion); + Scope lockAndInspect(List members,String expectedVersion,String operator); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java index 17bff899f..9ec4742de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java @@ -11,8 +11,8 @@ public interface FbaAppointmentTransportPort { int originalPackages, int retainedPackages, String laterBatchKey) { } record Result(List vehicles, List nextActions) { } Scope inspect(List batchKeys); - /** Lock order: trip -> dispatch -> outbound/PDA -> appointment; called BEFORE core locks batches. */ - Scope lockAndInspect(List batchKeys, String expectedVersion); + /** 锁全范围trip→车辆/司机资源→dispatch;必须先于核心批次与来源锁。 */ + Scope lockAndInspect(List batchKeys, String expectedVersion,String operator); /** Same transaction as core allocation. Throw on failure so physical/allocation changes roll back together. */ - Result apply(Scope locked, List moves, String operator, String reason); + Result apply(Scope locked, List moves, String operator, String reason,String parentCommandKey); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java index 0edb7b049..5a07887fc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java @@ -72,7 +72,7 @@ public class FbaReceivingService { public Result propose(String operator,Propose request) { return command("RECEIVING_PROPOSE",operator,request.idempotencyKey(),request,()->{ var intent=request.intent();var followup=work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate()); - var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion());requireMutable(scope); + var scope=transport.lockAndInspect(List.of(intent.batchKey()),intent.transportVersion(),operator);requireMutable(scope); var batch=batches.lockReceivingBatch(intent.batchKey());requireVersion(batch,intent.batchVersion()); recheckTransport(List.of(intent.batchKey()),scope); if(store.hasPending(intent.batchKey(),intent.customerId())) throw conflict("本客户已有待确认变化,请继续原记录"); @@ -86,6 +86,7 @@ public class FbaReceivingService { return new Result(pending.changeKey(),"WAITING",List.of(batch),pending.originalAgreements(),List.of(new Step("RECORD_CHANGE","SUCCESS",pending.changeKey(),"原安排及数量保留,待确认变化已阻止相关发车")),List.of("CONTINUE_WAREHOUSE_CONFIRMATION")); }); } + /** 原批次、运输、收货组和跟进在同一父命令事务内处理,跟进guard最后进入。 */ public Result resolve(String operator,String changeKey,Resolve request) { return command("RECEIVING_RESOLVE:"+changeKey,operator,request.idempotencyKey(),request,()->{ var initial=store.change(changeKey,false);var intent=initial.intent(); @@ -93,7 +94,7 @@ public class FbaReceivingService { // picking starts, without editing cargo, the appointment, or vehicle facts. var withdrawing="WITHDRAW".equals(request.decision()); var followup=withdrawing?null:work.resolveFollowup(intent.ownerId(),intent.nextFollowupDate()); - var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion()); + var scope=withdrawing?null:transport.lockAndInspect(List.of(intent.batchKey()),request.transportVersion(),operator); if(!withdrawing) requireMutable(scope); var batch=batches.lockReceivingBatch(intent.batchKey());var pending=store.change(changeKey,true); if(pending.version()!=request.version() || !"WAITING".equals(pending.status())) throw conflict("变化已被处理,请重新读取当前结果"); @@ -112,13 +113,12 @@ public class FbaReceivingService { var currentKey=store.currentAgreement(intent.batchKey(),intent.customerId()); if(!Objects.equals(currentKey==null?null:currentKey.agreementKey(),original==null?null:original.agreementKey())) throw conflict("本客户有效预约关系已变化"); var changed=batches.moveReceivingCustomer(operator,intent);var retained=changed.get(0);var later=changed.get(1); - work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator); var movements=new ArrayList(); for(var source:pending.originalMembers().get(0).sources()) { var q=intent.quantities().stream().filter(v->v.sourceKey().equals(source.sourceKey()) && v.destinationKey().equals(source.destinationKey())).findFirst().orElseThrow(); movements.add(new FbaAppointmentTransportPort.Move(intent.batchKey(),intent.customerId(),source.sourceKey(),source.destinationKey(),source.packageCount(),q.retainedPackages(),later.batchKey())); } - var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason()); + var physical=transport.apply(scope,List.copyOf(movements),operator,intent.reason(),request.idempotencyKey()); var agreements=new ArrayList(); if(original!=null) { var members=new ArrayList<>(original.members());members.removeIf(m->m.batchKey().equals(intent.batchKey()) && m.customerId().equals(intent.customerId())); @@ -130,13 +130,16 @@ public class FbaReceivingService { var steps=List.of(new Step("ALLOCATE_CARGO","SUCCESS",later.batchKey(),"原来源总量不变,后送量已落在新批次"),new Step("ADJUST_TRANSPORT","SUCCESS",intent.batchKey(),"原车辆按实际保留货物调整")); var actions=new ArrayList();actions.add("BOOK_LATER_BATCH");actions.addAll(physical.nextActions()); var result=new Result(changeKey,"APPLIED",List.of(projected,later),List.copyOf(agreements),steps,List.copyOf(actions)); - store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches());return result; + store.finish(pending,"APPLIED",result);batches.receivingChanged(result.batches()); + // 原运输、批次、来源和收货组全部写完后才获取跟进guard。 + work.seedFollowup(new FbaAppointmentWorkPort.Member(intent.batchKey(),intent.customerId()),new FbaAppointmentWorkPort.Member(later.batchKey(),intent.customerId()),followup,operator); + return result; }); } public Result refreshCargo(String operator,RefreshCargo request) { return command("RECEIVING_REFRESH_CARGO",operator,request.idempotencyKey(),request,()->{ var selection=request.member();var selected=List.of(new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId())); - var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked); + var locked=execution.lockAndInspect(selected,request.executionVersion(),operator);requireRebookable(selected,locked); var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion()); var current=execution.inspect(selected); if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); @@ -151,7 +154,7 @@ public class FbaReceivingService { public Result plan(String operator,Plan request) { return command("RECEIVING_PLAN",operator,request.idempotencyKey(),request,()->{ var selection=request.member();var memberKey=new FbaAppointmentExecutionPort.Member(selection.batchKey(),selection.customerId()); - var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion());requireRebookable(selected,locked); + var selected=List.of(memberKey);var locked=execution.lockAndInspect(selected,request.executionVersion(),operator);requireRebookable(selected,locked); var batch=batches.lockReceivingBatch(selection.batchKey());requireVersion(batch,selection.batchVersion()); var current=execution.inspect(selected); if(!Objects.equals(locked.version(),current.version())) throw conflict("客户执行关联已变化,请重新核对当前范围"); @@ -173,7 +176,7 @@ public class FbaReceivingService { var selections=request.bookings().stream().flatMap(b->b.members().stream()).toList(); var keys=selections.stream().map(Selection::batchKey).distinct().sorted().toList(); var executionMembers=selections.stream().map(s->new FbaAppointmentExecutionPort.Member(s.batchKey(),s.customerId())).distinct().toList(); - var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion()); + var executionScope=execution.lockAndInspect(executionMembers,request.executionVersion(),operator); requireRebookable(executionMembers,executionScope); var snapshots=lock(keys); // An unbound customer can acquire a new dispatch before we obtain the batch lock. @@ -209,7 +212,7 @@ public class FbaReceivingService { public Result changeGroup(String operator,String key,GroupChange request) { return command("RECEIVING_GROUP:"+key,operator,request.idempotencyKey(),request,()->{ var initial=store.agreement(key,false);var keys=initial.members().stream().map(Member::batchKey).distinct().sorted().toList(); - var scope=transport.lockAndInspect(keys,request.transportVersion());requireMutable(scope);var snapshots=lock(keys); + var scope=transport.lockAndInspect(keys,request.transportVersion(),operator);requireMutable(scope);var snapshots=lock(keys); recheckTransport(keys,scope); var group=store.agreement(key,true);requireActive(group); if(group.version()!=request.version()) throw conflict("共同预约范围已变化,请重新核对全部客户"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java index 5bc4fbd0a..86288be8b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -5,6 +5,7 @@ import com.baiyue.lastleg.appointmentwork.*; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import org.springframework.stereotype.Component; import org.springframework.transaction.annotation.*; +import org.springframework.transaction.support.*; import java.util.*; import java.util.stream.Collectors; import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; @@ -18,16 +19,16 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(dispatchIds.stream().anyMatch(id->id==null || id<=0)) throw bad("排车引用缺少真实排车身份"); var rows=mapper.references(dispatchIds.stream().distinct().sorted().toList()); if(rows.size()>20000) throw unavailable("排车批次引用超过完整读取上限"); - record Group(long dispatchId,String batchKey,String batchNo,String customerId) { } + record Group(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId) { } var groups=new LinkedHashMap,Integer>>(); for(var row:rows) { - if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0) + if(!dispatchIds.contains(row.dispatchId()) || row.batchKey()==null || row.recordVersion()==null || row.recordVersion()<0 || row.customerId()==null || row.sourceKey()==null || row.destinationKey()==null || row.packages()<=0) throw unavailable("原排车逐票批次引用不完整"); - groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.customerId()),ignored->new LinkedHashMap<>()) + groups.computeIfAbsent(new Group(row.dispatchId(),row.batchKey(),row.batchNo(),row.recordVersion(),row.customerId()),ignored->new LinkedHashMap<>()) .merge(List.of(row.sourceKey(),row.destinationKey()),row.packages(),Math::addExact); } var result=new LinkedHashMap>();dispatchIds.forEach(id->result.put(id,new ArrayList<>())); - groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.customerId(), + groups.forEach((group,sources)->result.get(group.dispatchId()).add(new BatchReference(group.batchKey(),group.batchNo(),group.recordVersion(),group.customerId(), sources.entrySet().stream().map(e->new BatchSource(e.getKey().get(0),e.getKey().get(1),e.getValue())).toList()))); result.replaceAll((id,refs)->List.copyOf(refs));return Collections.unmodifiableMap(result); } @@ -36,53 +37,95 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo private final AppointmentSourceService sources; private final AppointmentWorkChanges changes; private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; + private final AppointmentWorkDispatchCandidates candidates; + private final Object prelockedBatchesKey=new Object(); + private final Object pendingInvalidationKey=new Object(); + /** 注入原预约、真实实物和收货确认权威。 */ public AppointmentWorkDispatchBinding(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentWorkChanges changes, - com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving) { - this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving; + com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving,AppointmentWorkDispatchCandidates candidates) { + this.mapper=mapper; this.appointments=appointments; this.sources=sources; this.changes=changes;this.receiving=receiving;this.candidates=candidates; } - public void bind(String customer,long dispatch,List items,List allocations) { - if(allocations==null) throw bad("批次实物分配清单必填"); - if(allocations.isEmpty()) { - if(mapper.byDispatch(dispatch).stream().anyMatch(a->a.batchKey().startsWith("FBA_APPOINTMENT:"))) throw conflict("已关联预约批次,修改实物时须同时提交完整批次分配"); - for(var item:items) if(item.planPalletId()!=null) requireNoBatch(customer,item.planPalletId()); - var privateRows=new ArrayList(); - for(var item:items) if(item.planPalletId()!=null) privateRows.addAll(privateAllocation(customer,dispatch,item)); - if(!privateRows.isEmpty() || mapper.count(dispatch)>0) { mapper.remove(dispatch);privateRows.forEach(mapper::insert);changes.requirementsChanged(); } - return; + /** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */ + @Transactional(readOnly=true,propagation=Propagation.SUPPORTS) + public Map candidateBatches(List input) {return candidates.batchReferences(input);} + /** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */ + public void lockBatches(Map expectedBatchVersions) { + if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效"); + var expected=new TreeMap(); + for(var entry:expectedBatchVersions.entrySet()) { + batchId(entry.getKey()); + if(entry.getValue()==null || entry.getValue()<0) throw bad("预约批次版本必填且不能小于零"); + expected.put(entry.getKey(),entry.getValue()); } - if(allocations.size()>1000) throw bad("批次实物分配最多1000条"); - var byPallet=items.stream().filter(i->i.planPalletId()!=null).collect(Collectors.toMap(PhysicalItem::planPalletId,i->i)); - var seen=new HashSet(); - for(var row:allocations) { - if(row==null || row.packageCount()<=0 || !customer.equals(row.customerId()) || !byPallet.containsKey(row.planPalletId())) throw conflict("批次分配必须属于本客户已选择的实物托盘"); - if(!seen.add(row.planPalletId()+"/"+row.batchKey()+"/"+row.sourceKey()+"/"+row.destinationKey())) throw bad("批次实物分配重复"); + @SuppressWarnings("unchecked") var locked=(Set)TransactionSynchronizationManager.getResource(prelockedBatchesKey); + if(locked!=null && !locked.containsAll(expected.keySet())) throw conflict("预约批次须在来源锁前一次锁齐"); + for(var entry:expected.entrySet()) { + var row=mapper.lockAppointment(batchId(entry.getKey())); + if(row==null || row.getVersion()==null || row.getVersion().longValue()!=entry.getValue()) throw conflict("预约批次版本已变化,请重新读取实物候选"); } - var batches=new TreeSet<>(allocations.stream().map(Allocation::batchKey).toList()); - for(var batch:batches) lock(batch); - var persisted=new ArrayList(); - for(var item:byPallet.values()) { - var selected=allocations.stream().filter(a->item.planPalletId().equals(a.planPalletId())).toList(); + if(locked==null && TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.bindResource(prelockedBatchesKey,Set.copyOf(expected.keySet())); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务结束后释放本地预锁证明。 */ + public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(prelockedBatchesKey); } + }); + } + } + /** 逐项复核真实明细,只替换本次触及实物;完整有效集合参与同车及跨车余量核对。 */ + public void bindItems(long dispatch,List items,List allocations) { + if(dispatch<=0 || items==null || items.isEmpty() || items.size()>1000 || allocations==null || allocations.size()>1000) throw bad("批次实物分配范围无效"); + var actual=mapper.dispatchPhysicalById(dispatch).stream().collect(Collectors.toMap(AppointmentWorkDispatchMapper.DispatchPhysical::itemId,i->i)); + var byPallet=new HashMap,PhysicalItem>();var touched=new TreeSet(); + for(var item:items) { + if(item==null || item.itemId()==null || !touched.add(item.itemId())) throw bad("受影响实物身份缺失或重复"); + var row=actual.get(item.itemId()); + if(row==null || !Objects.equals(item.customerId(),row.customerId()) || !Objects.equals(item.planPalletId(),row.planPalletId()) + || !Objects.equals(item.sourceStage(),row.sourceStage()) || !Objects.equals(item.packages(),row.packages()) || row.packages()==null || row.packages()<=0) + throw conflict("受影响实物与本车真实客户、托盘或数量不一致"); + if(item.planPalletId()!=null && byPallet.put(List.of(item.customerId(),item.planPalletId().toString()),item)!=null) throw conflict("同客户托盘存在重复明细"); + } + var versions=new TreeMap();var seen=new HashSet>(); + for(var a:allocations) { + if(a==null || a.customerId()==null || a.planPalletId()==null || a.sourceKey()==null || a.destinationKey()==null || a.packageCount()<=0 + || !byPallet.containsKey(List.of(a.customerId(),a.planPalletId().toString()))) throw conflict("批次分配必须属于本次已核实的真实客户实物"); + batchId(a.batchKey()); + if(a.recordVersion()==null || a.recordVersion()<0) throw bad("预约批次版本必填且不能小于零"); + var previous=versions.putIfAbsent(a.batchKey(),a.recordVersion()); + if(previous!=null && !previous.equals(a.recordVersion())) throw conflict("同一预约批次不能提交不同版本"); + if(!seen.add(List.of(a.customerId(),a.planPalletId().toString(),a.batchKey(),a.sourceKey(),a.destinationKey()))) throw bad("批次实物分配重复"); + } + var previous=mapper.byDispatch(dispatch); + var effective=new ArrayList<>(previous.stream().filter(a->!touched.contains(a.itemId())).toList()); + var next=new ArrayList(); + for(var item:items) if(item.planPalletId()!=null) { + var selected=allocations.stream().filter(a->item.customerId().equals(a.customerId()) && item.planPalletId().equals(a.planPalletId())).toList(); if(selected.isEmpty()) { - var privateRows=privateAllocation(customer,dispatch,item); - if(privateRows.isEmpty()) throw conflict("批次排车须明确每个托盘的逐票实物分配"); - persisted.addAll(privateRows);continue; + requireNoBatch(item.customerId(),item.planPalletId()); + next.addAll(privateAllocation(item.customerId(),dispatch,item)); + } else { + validatePhysical(item.customerId(),item,selected,"RECEIVED".equals(item.sourceStage()),false); + for(var a:selected) next.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),item.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount())); } - validatePhysical(customer,item,selected,"RECEIVED".equals(item.sourceStage()),false); - for(var a:selected) persisted.add(new AppointmentWorkDispatchMapper.Bound(dispatch,item.itemId(),item.planPalletId(),a.batchKey(),customer,a.sourceKey(),a.destinationKey(),a.packageCount())); } + effective.addAll(next); + var batches=effective.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).collect(Collectors.toCollection(TreeSet::new)); + @SuppressWarnings("unchecked") var prelocked=(Set)TransactionSynchronizationManager.getResource(prelockedBatchesKey); + if(TransactionSynchronizationManager.isSynchronizationActive() && (prelocked==null || !prelocked.containsAll(batches))) throw conflict("预约批次未在来源锁前完成预锁"); for(var batch:batches) { - var context=appointments.batchContext(batch); var appointment=context.appointment(); - // Pre-arrangement reserves real cargo only. requireReceiving separately gates actual departure per customer. + var appointment=appointments.batchContext(batch).appointment(); + if(appointment.version()==null || appointment.version()<0 || versions.containsKey(batch) && appointment.version().longValue()!=versions.get(batch)) throw conflict("预约批次版本已变化,请重新读取实物候选"); if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次"); - requireCurrentAllocation(appointment,customer,dispatch,allocations.stream().filter(a->batch.equals(a.batchKey())).toList()); + for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) + requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())) + .map(a->allocation(a,appointment.version().longValue())).toList()); } - mapper.remove(dispatch); persisted.forEach(mapper::insert); changes.requirementsChanged(); + // 所有校验结束后才按明确item删除;没有整车清空操作。 + for(var item:touched) mapper.removeItem(dispatch,item); + next.forEach(mapper::insert);invalidateAtBusinessEnd(); } - public void requireReplay(String customer,long dispatch,List allocations) { - if(allocations==null || allocations.stream().anyMatch(a->a==null || !customer.equals(a.customerId()))) throw conflict("重放排车的逐票批次分配不一致"); - var original=mapper.byDispatch(dispatch).stream().filter(a->!a.batchKey().startsWith("DISPATCH:")) - .map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(); - if(allocations.size()!=original.size() || !new HashSet<>(allocations).equals(new HashSet<>(original))) throw conflict("原排车已存在,不能通过重放请求改写批次分配"); + /** 从原分配构造当前权威版本视图,不写入第三套版本账本。 */ + private Allocation allocation(AppointmentWorkDispatchMapper.Bound row,Long version) { + return new Allocation(row.palletId(),row.batchKey(),version,row.customerId(),row.sourceKey(),row.destinationKey(),row.packages()); } private void validatePhysical(String customer,PhysicalItem item,List selected,boolean received,boolean picked) { var physical=picked?mapper.pickedPhysical(item.itemId(),customer):mapper.physical(item.planPalletId(),customer); @@ -108,14 +151,16 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo } private void checkDeparture(String dispatchNo,boolean locked) { var rows=mapper.bindings(dispatchNo); - var linked=rows.stream().map(AppointmentWorkDispatchMapper.Bound::palletId).collect(Collectors.toSet()); - for(var item:mapper.dispatchPhysical(dispatchNo)) if(item.planPalletId()!=null && !linked.contains(item.planPalletId())) { + var physical=mapper.dispatchPhysical(dispatchNo); + for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId()))) + throw conflict("预约分配与本车真实客户实物不一致"); + var batches=rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).filter(b->!b.startsWith("DISPATCH:")).distinct().sorted().toList(); + if(locked) for(var batch:batches) lock(batch); + for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) { requireNoBatch(item.customerId(),item.planPalletId()); if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车"); } - for(var batch:rows.stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { - if(batch.startsWith("DISPATCH:")) continue; - if(locked) lock(batch); + for(var batch:batches) { var a=appointments.batchContext(batch).appointment(); if(!a.replacementBatchKeys().isEmpty()) throw conflict("关联批次已被替代,请重新核对实物批次后发车"); var allocated=rows.stream().filter(row->batch.equals(row.batchKey())).toList(); @@ -124,13 +169,14 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo for(var customer:allocated.stream().map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().toList()) { requireReceiving(batch,customer,allocated.stream().filter(row->customer.equals(row.customerId())).toList()); requireCurrentAllocation(a,customer,dispatchIds.get(0),allocated.stream().filter(row->customer.equals(row.customerId())) - .map(row->new Allocation(row.palletId(),row.batchKey(),row.customerId(),row.sourceKey(),row.destinationKey(),row.packages())).toList()); + .map(row->allocation(row,a.version()==null?null:a.version().longValue())).toList()); } } - for(var group:rows.stream().collect(Collectors.groupingBy(AppointmentWorkDispatchMapper.Bound::palletId)).values()) { + for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) { var first=group.get(0); - validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.palletId(),"RECEIVED",null), - group.stream().map(a->new Allocation(a.palletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packages())).toList(),true,true); + var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow(); + validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()), + group.stream().map(a->allocation(a,null)).toList(),true,true); } } private void requireReceiving(String batch,String customer,List rows) { @@ -168,7 +214,20 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty())) throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); } - public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId); changes.requirementsChanged(); } + /** 调用方已锁主单与原批次,删除只作用于指定实物。 */ + public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); } + /** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */ + private void invalidateAtBusinessEnd() { + if(!TransactionSynchronizationManager.isSynchronizationActive()) {changes.requirementsChanged();return;} + if(TransactionSynchronizationManager.hasResource(pendingInvalidationKey)) return; + TransactionSynchronizationManager.bindResource(pendingInvalidationKey,Boolean.TRUE); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 所有业务写入后获取guard;失败仍使整个原事务回滚。 */ + public void beforeCommit(boolean readOnly) {changes.requirementsChanged();} + /** 清除本事务本地标记,不复用于下一个请求。 */ + public void afterCompletion(int status) {TransactionSynchronizationManager.unbindResourceIfPossible(pendingInvalidationKey);} + }); + } public void requireTripBinding(String dispatchNo,long tripId) { for(var batch:mapper.bindings(dispatchNo).stream().map(AppointmentWorkDispatchMapper.Bound::batchKey).distinct().sorted().toList()) { if(batch.startsWith("DISPATCH:")) continue; @@ -190,8 +249,12 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo return rows; } private void lock(String batch) { - if(batch==null || !batch.startsWith("FBA_APPOINTMENT:")) throw bad("预约批次键无效"); - try { long id=Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); if(!batch.equals("FBA_APPOINTMENT:"+id) || mapper.lockAppointment(id)==null) throw conflict("预约批次不存在"); } - catch(NumberFormatException | IndexOutOfBoundsException error) { throw bad("预约批次键无效"); } + if(mapper.lockAppointment(batchId(batch))==null) throw conflict("预约批次不存在"); + } + /** 批次键只接受权威业务键,拒绝前导零、负数和溢出。 */ + private long batchId(String batch) { + if(batch==null || !batch.matches("FBA_APPOINTMENT:[1-9][0-9]*")) throw bad("预约批次键无效"); + try { return Long.parseLong(batch.substring("FBA_APPOINTMENT:".length())); } + catch(NumberFormatException error) { throw bad("预约批次键无效"); } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java index c0a15bd6d..46fbf5285 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.inboundplan.work; import com.baiyue.lastleg.appointmentplanning.*; import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*; import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -15,8 +16,62 @@ public class AppointmentWorkDispatchCandidates { private final AppointmentWorkDispatchMapper mapper; private final InboundFbaAppointmentService appointments; private final AppointmentSourceService sources; + /** 注入唯一原批次、实物和来源规范化边界。 */ public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; } + /** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */ @Transactional(readOnly=true) + public Map batchReferences(List input) { + if(input==null || input.size()>1000 || input.stream().anyMatch(i->i==null || i.customerId()==null || i.customerId().isBlank() || i.planPalletId()==null || i.planPalletId()<=0)) throw bad("候选批读须提供最多1000个真实客户托盘"); + var identities=input.stream().distinct().sorted(Comparator.comparing(CandidatePhysical::customerId).thenComparing(CandidatePhysical::planPalletId)).toList(); + if(identities.isEmpty()) return Map.of(); + var rows=mapper.candidateSources(identities); + if(rows.size()>20000) throw unavailable("候选逐票实物超过完整读取上限"); + var physical=rows.stream().filter(r->r.orderId()!=null && r.destinationKey()!=null) + .map(r->new AppointmentWorkDispatchMapper.Physical(r.orderId(),r.customerId(),r.destinationKey(),r.plannedPackages(),r.countedPackages())).toList(); + var canonical=canonical(physical); + var refs=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); + if(refs.size()>20000) throw unavailable("候选逐票来源超过完整读取上限"); + var orders=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).distinct().toList(); + var batches=refs.isEmpty()?List.of():mapper.candidateBatches(refs,orders); + if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限"); + var output=new LinkedHashMap(); + for(var identity:identities) { + var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList(); + var reasons=new LinkedHashSet();var references=new LinkedHashMap,List>(); + boolean related=false,unassigned=false;boolean occupied=selected.stream().anyMatch(AppointmentWorkDispatchMapper.CandidateSource::locked); + if(selected.isEmpty()) reasons.add("客户实物托盘不存在或归属已变化"); + for(var row:selected) { + var count="RECEIVED".equals(row.sourceStage())?row.countedPackages():row.plannedPackages(); + if(row.orderId()==null || row.destinationKey()==null || count==null || count<=0) {reasons.add("逐票实物来源或箱数尚未核实");continue;} + if(!row.destinationKey().startsWith("FBA:")) continue; + var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey(); + var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList(); + if(matched.isEmpty()) {unassigned=true;continue;} + related=true;long available=0;var seen=new HashSet(); + for(var b:matched) { + if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;} + if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;} + int remaining=Math.toIntExact(b.packages()-b.allocated());available=Math.addExact(available,remaining); + if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count))); + } + if(availablenew BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList(); + output.put(identity,new CandidateBatches(result,List.copyOf(reasons),occupied)); + } + return Collections.unmodifiableMap(output); + } + /** 保持原来源接口100票边界,按不同订单分块,重复目的地不增加远程调用。 */ + private Map canonical(List physical) { + var unique=new LinkedHashMap(); + for(var row:physical) unique.putIfAbsent(new AppointmentExternalSourceGateway.OrderRef(row.customerId(),row.orderId()),row); + var values=new ArrayList<>(unique.values());var result=new LinkedHashMap(); + for(int offset=0;offsetitem.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托"); - allocations.add(new Allocation(candidate.planPalletId(),batch,part.customerId(),ref.sourceKey(),part.destinationKey(),count)); + if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失"); + allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count)); } if(physical.isEmpty()) { packages=null; reasons.add("缺少逐票实物分配"); } output.add(new Candidate(candidate.planPalletId(),candidate.palletNo(),candidate.planId(),candidate.planNo(),candidate.customerId(),candidate.sourceStage(),packages,List.copyOf(allocations),reasons.stream().distinct().toList())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 72ade2a4f..3a43629fe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -10,17 +10,55 @@ public interface AppointmentWorkDispatchMapper { record Bound(long dispatchId,long itemId,long palletId,String batchKey,String customerId,String sourceKey,String destinationKey,int packages) { } record Candidate(long planPalletId,String palletNo,long planId,String planNo,String customerId,String sourceStage,boolean locked) { } record DispatchPhysical(long itemId,Long planPalletId,String customerId,String sourceStage,Integer packages) { } - record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,Integer pickedPackages,Integer shippedPackages, + record TransportRow(long dispatchId,String dispatchNo,String customerId,String dispatchStatus,String dispatchStage,Long dispatchVersion,Integer pickedPackages,Integer shippedPackages, java.time.LocalDateTime updatedAt,long itemId,long palletId,Integer itemPackages,String sourceStage, String batchKey,String sourceKey,String destinationKey,int packages,String tripNo,Long tripVersion,String tripStatus, Long outboundId,String outboundStatus,String taskStatus) { } record ReceivingSource(String batchKey,String customerId,String sourceKey,String destinationKey,Integer packages) { } - record ReferenceRow(long dispatchId,String batchKey,String batchNo,String customerId,String sourceKey,String destinationKey,int packages) { } + record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { } + record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } + record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { } + /** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */ + @Select(""" + + """) + List candidateSources(@Param("items") List items); + /** 原有效批次版本与跨车占用由同次查询提供,不逐候选读取batchContext。 */ + @Select(""" + + """) + List candidateBatches(@Param("refs") List refs,@Param("orders") List orders); @Select(""" FROM ll_inbound_plan_line l JOIN ll_inbound_plan p ON p.id=l.plan_id JOIN ll_order o ON o.id=l.order_id AND o.customer_id=p.customer_id AND p.customer_id=#{customerId} AND o.customer_id=#{customerId} LEFT JOIN ll_inbound_fba_appointment_item ai ON ai.order_id=l.order_id AND ai.customer_id=p.customer_id AND ai.id=(SELECT ai2.id FROM ll_inbound_fba_appointment_item ai2 JOIN ll_inbound_fba_appointment a2 ON a2.id=ai2.appointment_id WHERE ai2.order_id=l.order_id AND ai2.customer_id=p.customer_id AND ai2.fba_warehouse_code=l.fba_warehouse_code AND a2.replacement_nos_json IS NULL ORDER BY ai2.active DESC,a2.created_at DESC,ai2.id DESC LIMIT 1) LEFT JOIN ll_inbound_fba_appointment a ON a.id=ai.appointment_id WHERE 1=1 AND p.plan_no LIKE CONCAT('%',#{q.planNo},'%') AND l.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%') AND o.lx_code LIKE CONCAT('%',#{q.lxCode},'%') AND p.status=#{q.status} AND p.overseas_warehouse_id=#{q.overseasWarehouseId} AND l.destination_warehouse_code=#{q.destinationWarehouseCode} AND (l.destination_warehouse_id LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_code LIKE CONCAT('%',#{q.warehouse},'%') OR l.destination_warehouse_name LIKE CONCAT('%',#{q.warehouse},'%')) AND l.fba_warehouse_code LIKE CONCAT('%',#{q.fbaWarehouseCode},'%') AND (ai.id IS NULL OR ai.active=0) AND (CASE WHEN (SELECT COUNT(DISTINCT current_item.appointment_id) FROM ll_inbound_fba_appointment_item current_item WHERE current_item.order_id=l.order_id AND current_item.customer_id=p.customer_id AND current_item.fba_warehouse_code=l.fba_warehouse_code AND current_item.active=1)>1 THEN 'MULTIPLE' ELSE a.appointment_status END)=#{q.appointmentStatus} AND EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id AND dispatch.status<>'CANCELLED') AND NOT EXISTS (SELECT 1 FROM ll_dispatch_item di JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id AND dispatch.status<>'CANCELLED') AND p.created_at >=#{q.createdFrom} AND p.created_at <=#{q.createdTo}\"\nto contain:\n [\"di.customer_id=p.customer_id AND di.order_id=l.order_id\",\n \"dispatch.customer_id=di.customer_id\",\n \"dispatch.status<>'CANCELLED'\"]\nbut could not find:\n [\"dispatch.customer_id=di.customer_id\"]\n " + }, + { + "class": "com.baiyue.lastleg.inboundplan.work.AppointmentWorkDomainMapperTest", + "test": "originalDomainJoinsExecuteAgainstTheCurrentSchemaWithoutInventedColumns", + "type": "error", + "message": "Column \"CUSTOMER_ID\" not found; SQL statement:\nINSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(501,'A','D-1','PENDING_DEPARTURE'),(502,'B','D-2','CANCELLED'),(503,'B','D-3','PENDING_DEPARTURE') [42122-224]" + }, + { + "class": "com.baiyue.lastleg.transfer.service.WarehouseTransferServiceTest", + "test": "submittingOldDraftRechecksRelabelCartonOwnership", + "type": "error", + "message": "\nUnnecessary stubbings detected.\nClean & maintainable test code requires zero unnecessary code.\nFollowing stubbings are unnecessary (click to navigate to relevant line of code):\n 1. -> at com.baiyue.lastleg.transfer.service.WarehouseTransferServiceTest.submittingOldDraftRechecksRelabelCartonOwnership(WarehouseTransferServiceTest.java:526)\nPlease remove unnecessary stubbings or use 'lenient' strictness. More info: javadoc for UnnecessaryStubbingException class." + } +] \ No newline at end of file diff --git a/.logs/dispatch-native-cancellation-inputs.json b/.logs/dispatch-native-cancellation-inputs.json new file mode 100644 index 000000000..94cb06222 --- /dev/null +++ b/.logs/dispatch-native-cancellation-inputs.json @@ -0,0 +1,302 @@ +{ + "capturedAt": "2026-10-19T23:12:11.1283856+08:00", + "log": ".logs/dispatch-native-cancellation-affected.maven.log", + "files": [ + { + "path": "../docs/海外仓业务/排车列表接口与数据设计.md", + "sha256": "521169378de4ee51c9af4f8cbe331284f35bf60f137253d9751f2ba3480e95ea" + }, + { + "path": "deploy/sql/海外区域统一增量-MySQL8.sql", + "sha256": "4f31c8731dbb54955555d13f027c24a537aee8a317f744f7bf50b59cb22694b1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java", + "sha256": "52409141461491b48ac5828c3d85a8ee5d578678d2d670c87cc73a707225bcdb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java", + "sha256": "bb036a0cfb01324eba14bb36b623047367bf31c2727be82b6d3728635fb591ce" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java", + "sha256": "02dc868bef1646a02143969f7aad13b4e92cd04d4a8d69c8c71ffc07f7a50360" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java", + "sha256": "2988eee05074b78728878fef6dd45d0df3dba8cc7c219ebfd583bf7650a379b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java", + "sha256": "3044dc6d8780f96b4ff7d6303a2b15ed458c201496788a98c3b28cad7449e0f8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java", + "sha256": "7c0269c3e5179add0ebb772cd7674ddf899039afad179bff06f7524017934573" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java", + "sha256": "32dd25dc737d7c36f3a35f1a97bd2865cf433368aaccce2162585a30d6b07641" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java", + "sha256": "54666dbd5968ebd9d2cb8fc9678a8a385e60591f62c90a850be513f93546906d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java", + "sha256": "8d6b4bf84e27c18b3c8faad15dbf73018485996ca5d4dbb00bdf9ba2ab158d2d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java", + "sha256": "9ae01c8669beb2fcf5e359d2e26e13e07c2a496bbe22036a408f3dd49d2ec2fa" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java", + "sha256": "1bec0687e9d1d3a2404d25a37aad092d18b7134eab605594f352a5432360ac76" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java", + "sha256": "a99080e851a6d22c5ecb1229e761382d846d1f5e4b92a3c69d30716af125746d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java", + "sha256": "3d66df6566b467580c89aa5f9f585aba610b05ca2de07ad28a3d6a6f30ab10e3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java", + "sha256": "4df97c47b2ca481eb5f8f32e7735bb15a80aa4e561bea7ab349f09352ca9db1e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java", + "sha256": "83947030468892ee1d4a02b354e855d7977f55fcc17de440f324cf24940a10d8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java", + "sha256": "5086523d9df7c7d00840d391e9608b1685e4edb71d19154b1608339afe7421e2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java", + "sha256": "47489d22ab81913ab23c319c04561371cbdf1e1c43034fe5b1d24fe94038137e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java", + "sha256": "d34d5a810ea7658ffd2623f53970435ecee116663bd969339de84432e38cd573" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java", + "sha256": "f28b7aff08afdf634387e6abb7919a807fd6a2ff54ff7869b3e1e4d453f2c92b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java", + "sha256": "2bcef5d9952750235ecabcf429e4eab9b9a9c0a3cac0b3e02b083120f85df28e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java", + "sha256": "27f37b5e683b92d82d9cf63a88b2135ada5f597e652474eef52d7c985b870b31" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java", + "sha256": "04b5d5160614b17a31030f23167d9e32aa89e13be7ef2e33c9d91359ef684348" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java", + "sha256": "ec0ce406d4244a0abf34ee9ede97b788675b39234fbb8e6d0ff9579dde360145" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java", + "sha256": "fc9440c6755316c58572f3fed5a576c5721098a3d5d7a036eecd3d6d752f1ea0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java", + "sha256": "a2682e87f8bbf47a799d8d54ae05b8acdef52414016cab116eb9a40fe1364e04" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java", + "sha256": "ee42766e7f86f896df1839e15987382c8aebc3484449b2104312378eaababbba" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java", + "sha256": "0f59acc9c3ed6fd2995c1ed59810146c0056d1462e4e23d85f52198a1ffe0296" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java", + "sha256": "063b557cd488bf2281516df744f12123473269be12f56a5155facad018e6408e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java", + "sha256": "17a461a599cc0702b519d1333c5e136860eff7b638d739dde7e0bd203bcfc4ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java", + "sha256": "c95a0d4075df7628e4443f87be4d2d906aee5cbb95148d5fd3869d6ec7932465" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/PhysicalPalletSerialMapper.java", + "sha256": "237fe76c88d572b0d70eb25d84d191dcc0d2fdbc3a031e728e6bbbf88a8aa25f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/port/PhysicalPalletSerialPort.java", + "sha256": "4aa97a0c9bf0f4a9138ec15ac32e512ff13224e9a6f0e0817c5b0852f9635746" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/repository/PhysicalPalletSerialRepository.java", + "sha256": "0489e8fa5806fd0faa4a61c4a0c7a9bf89d086537752f218dcbcaed08475487e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialService.java", + "sha256": "4876ed0fc6267d9b5986fde0f2e8096ec01db35cd2f0cadbaeb3ebad2e03b76b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java", + "sha256": "960bb99f8432524e5a4bc76a61ccde8273929ad9de5e21ae0b44d941edeb6c9d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java", + "sha256": "55d243f6f419f112d3f89005ce37af0d6446e7b21d3b00f5931b56f3ea556302" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java", + "sha256": "c3a4ceba06b722d5a0bba810eaa948bd364a09ff5599c3176207d5c28b39b7fc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java", + "sha256": "6dd3902b7f64d40f8db5ae1f3dd3640e91a69cadd6c895b1f3a81ac48b93ebf0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java", + "sha256": "af771fd474faf6c411543b077c646479e6368b98ab3cab875c4cd6c0b3b24978" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java", + "sha256": "12b9577391f0908d94e4e2b307f7572e4825f556307145f8a83d0294516a2c46" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java", + "sha256": "949ea59036d710af0cc0b81848a77e17da0da3de049e44124a36be141ee81fe0" + }, + { + "path": "lastleg-service/src/main/resources/db/lastleg.sql", + "sha256": "61946a41d773e856e3d3f9463f7ff5bfe0e904856598e4a64c8cbd0936ef55b5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java", + "sha256": "4fbf6bab69cfaff409d0d228a1169c63db0f092e9c1c6e2f70bb956f1d3ed70f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java", + "sha256": "1dc488f6d614d0038a54ea81ecf1ca15c40887bcf951f5f96df436511a8cec86" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java", + "sha256": "b989d8db720bd10aa4c928297ed387486353a06922d043d298b234bbc3c26b29" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java", + "sha256": "57928866a589890689ff729a234a5a1b1a93e39e2ad6a11dae7c05bb312261e4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java", + "sha256": "cd2b7753a44aeb34f5b2f88bae8349ecb8eeea5c96ecd3796391570a8662b9ca" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java", + "sha256": "6e37ddaf197138e06b6b0d2d3ca908c58e23f8a2e01fb18791cb38b8ff1fd5be" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java", + "sha256": "178d6781adf4c6f08be8569ec869566ea97391ead064ca460b9b3e812ca9e585" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java", + "sha256": "ec3ab8341cec7ae692501ba264178d5d520923b7c2dcf20dfaa620313c9d03d4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java", + "sha256": "8f5907aa180db30a2b56fb6b251f9ae7a94fa550666239947b660cb5431fe978" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java", + "sha256": "0be4d1f344374147149f248bbc2aaac486484ad4a0e9ba1681295aa6fd5ae21d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorkerTest.java", + "sha256": "cf2812520b6c1025b3b8ab8084b9d21064427811dd62f12a734cf90c4c035c12" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java", + "sha256": "35b7c411e452dce2189c22f8cf31d77083f8854062435a9223e306a1a9d44a6a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java", + "sha256": "24e1b3f0dba4d4f9c1f724840c7a263ff0d1561cb5cb509fb0c410be8187afb0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java", + "sha256": "1c36d64a389bf12e55cfb8cbb29b7dd333bc952f66e8bb88d441ddf42f83c653" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java", + "sha256": "9b4402d0dd3f0eefa8b6b9bfe65e20f42f3e60619ac6989b18c9a74cbebad0ad" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimPersistenceTest.java", + "sha256": "02bd34b71fdb237566e99fab0b748c3ed333ace4ae65042aaf315b06695299f6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java", + "sha256": "0971e32ae147a03a730c16f1a75f0576e3a9122f92c5c65608db0cb9a76a3510" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java", + "sha256": "4a4d01424e91fd1b55b0af4721e02c4acad065e958ff4a0b9726cf726691fbdf" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java", + "sha256": "a9a2e25ff22e326925104e48ff1cfed34fec3350dfb569f1e7f19a4e186925c7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java", + "sha256": "0143045a52df440dc8be72706d69cbc45a9d210ddfb4ce77762423533dcc1a55" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java", + "sha256": "4170a35b6f57d9058577941ef665130e0f3a3faeef37c92c9e93a69dd9f0f60a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java", + "sha256": "f2f7174dd6e52f5d9caae36f8fc24aa2dc78145c1c390e8e16857bb066948a86" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java", + "sha256": "b9196d489830c2f4bf24ebcaf3e94adf4e0484f414f373c300b0f4a2a538679b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java", + "sha256": "5fbe3b3be8be194d1f31006e4e05187cd629236c376e01da2d291a2593e70e0a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java", + "sha256": "e146136299db24b1cf618faf2161fa284d9eec15a26a75e8352548cd7dbe7e94" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java", + "sha256": "444bd8d361f4d90fbdaf1e4d1d205906b5452096670f57bd3459906716e36c7e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java", + "sha256": "a8ef7e364e4d0fb97f83515dd1d3b983cdac4f367a9335fe181c39a47719720f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java", + "sha256": "eae67e13f9d1d3e8ff8a76b30a8a72d445e54b3cd6a30394c5ea41f763f48a7c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java", + "sha256": "6eb43b35de92364884e9ffbd36c18c3921ac8c21c959602383f9ca9cc8c4ece5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java", + "sha256": "787e78907d019458e3533325ed2bfe8cc09eb8de7ca1db2c0b98a54239a56fe5" + } + ] +} diff --git a/.logs/dispatch-route-evidence.json b/.logs/dispatch-route-evidence.json new file mode 100644 index 000000000..80e77ea9d --- /dev/null +++ b/.logs/dispatch-route-evidence.json @@ -0,0 +1,167 @@ +{ + "createdAtUtc": "2026-10-19T11:12:23.053116+00:00", + "scope": "C52 route pruning only: actual origin, supported old stops retain relative order, new cargo stops append, active external booking stops required; invalid commitments fail atomically", + "sources": [ + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java", + "sha256": "84906c28ccaf94125a41c03a30c9df392400af74569ed26c2da8abd5c116144c", + "modifiedAtUtc": "2026-10-19T11:06:24.471699+00:00" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java", + "sha256": "28eca09b87337b07a04955039a50c3c55805289955c24db75fe9df6fbf0b3bcc", + "modifiedAtUtc": "2026-10-19T11:06:24.472698+00:00" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java", + "sha256": "687248640255a40456f4197cab28dd32e8a16f97a833858fc868e171dcf2b62c", + "modifiedAtUtc": "2026-10-19T11:08:07.884938+00:00" + } + ], + "aggregateMethod": "normalized path + space + lowercase SHA256, Unicode lexicographic path sort, LF joined, no trailing LF", + "aggregateSha256": "9fab733033d4fffaeb716b4cb2720dd72b26c14ece1b97d6bd6d2e30db502aa2", + "compileCommand": [ + "./scripts/local-check.ps1", + "-WorkingDirectory", + ".", + "-Label", + "Dispatch route test-compile", + "-Command", + [ + "mvn", + "-pl", + "lastleg-service", + "-am", + "-DskipTests", + "test-compile" + ] + ], + "testCommand": [ + "./scripts/local-check.ps1", + "-WorkingDirectory", + ".", + "-Label", + "Dispatch route regression", + "-Command", + [ + "mvn", + "-pl", + "lastleg-service", + "-am", + "-Dtest=FleetDispatchRoutePersistenceTest,DispatchAggregatePersistenceTest,FleetTripPersistenceTest,FleetReceiptCapacityTest,FleetTripMatchedStopsTest", + "-Dsurefire.failIfNoSpecifiedTests=false", + "test" + ] + ], + "compileExit": 0, + "testExit": 0, + "jvmPolicy": "local-check defaults; JAVA_TOOL_OPTIONS ActiveProcessorCount=2 Xmx1536m; no memory override", + "logs": [ + { + "path": ".logs/dispatch-route-test-compile.log", + "sha256": "d52d00bd3b3d16b0ced516f4649367a94b012fb8ed831a1ca933b1d4ef6acb39" + }, + { + "path": ".logs/dispatch-route-test-compile.maven.log", + "sha256": "9071174d9e9248a6655a1ca815043d17e607adb1d4f1ab8b5f914c2e51fc9d4b" + }, + { + "path": ".logs/dispatch-route-regression.log", + "sha256": "1edb3d4afb3b03e35f4192b855ec80c607b4ae5a88e9261a431a79e97dbaa68a" + }, + { + "path": ".logs/dispatch-route-regression.maven-tail.log", + "sha256": "b17e27d2466c4b63d9c4a976bea598b6af45a50f2b53fb387bb8fdbbb48bdfab" + } + ], + "results": [ + { + "class": "com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest", + "tests": 11, + "failures": 0, + "errors": 0, + "skipped": 0, + "xmlSha256": "edb055fa5c2d9ddaa446aec702a00f17db76e8e95c3c5d4ddb411e0df40e2573", + "testCases": [ + "incompleteAttributionCannotPassLoadingThroughMatchingHeaderTotals", + "reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading", + "operationItemsAndRouteUseActualIdsAndPersistedStopOrder", + "emptyCreateHasOneRealTruckAndNoCustomerHeader", + "multiCustomerSelectionCreatesOneTruckAndKeepsAllDocumentsAndBatchReads", + "realPickBlocksReturnEvenWhenHeaderCountersAreZero", + "laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems", + "loadingUsesActualSourcesMeasurementsAndNeverAutoCompletesFromCounters", + "cancelledOutboundHistoryDoesNotBlockZeroPickCancellationButAnActiveDocumentDoes", + "updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField", + "removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims" + ] + }, + { + "class": "com.baiyue.lastleg.fleet.trip.FleetDispatchRoutePersistenceTest", + "tests": 7, + "failures": 0, + "errors": 0, + "skipped": 0, + "xmlSha256": "12404cd4a8e98eecd01e18b7ea12f915032a4f5f175d2d1e0b26daebc4766b8d", + "testCases": [ + "failureAfterTripRouteWriteRollsBackMainClaimsQuantitiesAndLedgerThenTheSameKeyCanSucceed", + "missingRequiredBookingStopRejectsTheWholeRemovalAndDoesNotInventAStop", + "lastPalletRemovalPrunesOnlyItsStopPreservesOrderAndAppendsNewDestinations", + "activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String)[1]", + "activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String)[2]", + "activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String)[3]", + "anotherPalletAtTheSameDestinationKeepsTheStopAndAnEmptyTruckKeepsOnlyItsOrigin" + ] + }, + { + "class": "com.baiyue.lastleg.fleet.trip.FleetReceiptCapacityTest", + "tests": 5, + "failures": 0, + "errors": 0, + "skipped": 0, + "xmlSha256": "a86f409a17999c90c88791bbc64e98c0e6fcd69db6e1f52c5aada0676ab8b53b", + "testCases": [ + "firstReceiptOrMissingPreviousReceiptStillRequiresValidOwnPlan", + "receivedReconfirmationFitsWithoutCreatingAnOwnCargoPlan", + "otherConfirmedReservationsStillConsumeCapacity", + "nonemptyStaleOwnPlanRemainsBlocked", + "remeasuredExternalCargoStillCannotExceedVehicleWeight" + ] + }, + { + "class": "com.baiyue.lastleg.fleet.trip.FleetTripMatchedStopsTest", + "tests": 3, + "failures": 0, + "errors": 0, + "skipped": 0, + "xmlSha256": "0f1d101f1d479c0f8d86fd3ec0d06649addc999bacfb3fa43c39361a42691b4c", + "testCases": [ + "rejectsMissingIdentityWithoutUsingDisplayNameAsKey", + "replacesOnlyInitialPlaceholderAndDeduplicatesByIdentity", + "preservesExistingStopIndexesAndAppendsDifferentIdentityEvenWhenNamesMatch" + ] + }, + { + "class": "com.baiyue.lastleg.fleet.trip.FleetTripPersistenceTest", + "tests": 7, + "failures": 0, + "errors": 0, + "skipped": 0, + "xmlSha256": "2f5eba7a2776a9c19829fba55ed379a84e19d6a5d7095ec7f2ec6f4dfccfba67", + "testCases": [ + "capacityFailureOrStaleFingerprintDoesNotWritePlanOrHistory", + "boundCorePlanAndResourcesMustUseTheCommonMainVersionAndReview", + "externalVehicleCapacityPlanUsesActualWholeTruckStopsAndSnapshots", + "oneWholeTruckProjectsBothReviewsFromTheRealMain", + "laterFullSignatureReturnDoesNotReopenOriginalTransport", + "partialSignatureCannotCloseCargoOrEndExternalTasks", + "externalCargoCompletionAndTwoTaskEndsAreIndependentAndReplayable" + ] + } + ], + "tests": 33, + "failures": 0, + "errors": 0, + "skipped": 0, + "limitations": "H2 service/mapper/transaction regression with controlled catalogue/source providers and seeded persisted bookings; no environment HTTP, production SQL, deployment, UI or real PDA acceptance; no 501 rerun" +} diff --git a/.logs/dispatch-vehicle-appointment-removal-inputs.json b/.logs/dispatch-vehicle-appointment-removal-inputs.json new file mode 100644 index 000000000..e47428bf4 --- /dev/null +++ b/.logs/dispatch-vehicle-appointment-removal-inputs.json @@ -0,0 +1,6104 @@ +{ + "capturedAfterDirectTests": "2026-10-19T10:39:19.584232+00:00", + "method": "Python Unicode lexicographic normalized path sort; path + space + lowercase SHA256, LF joined, no final LF for aggregate", + "aggregateSha256": "1607ea98c3f9a8fadf66541f95c70f33e4ebae55cb3823b3801d52d5196b0685", + "productionJava": 1055, + "testJava": 454, + "changedSincePreviousJointSnapshot": [ + "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java", + "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java", + "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java", + "lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml", + "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java" + ], + "mavenCommand": [ + "mvn", + "-pl", + "lastleg-service", + "-am", + "-Dtest=DispatchPublicContractTest,DispatchAggregatePersistenceTest,DispatchQueryControllerTest,PlatformDispatchOutboundContractTest,DispatchSchemaContractTest", + "-Dsurefire.failIfNoSpecifiedTests=false", + "test" + ], + "entries": [ + { + "path": "deploy/sql/海外区域统一增量-MySQL8.sql", + "sha256": "f91ba4c0f6e189327e7eebf53fd6bfd98d4cd860177ebf3e33d5acaf9d91ca87" + }, + { + "path": "iam/src/main/resources/db/lastleg-permissions.sql", + "sha256": "9deef80d2bcf383918b5ad48a63bea893ced2d9a32528e33268c02e73bbd4f67" + }, + { + "path": "iam/src/main/resources/db/phase5-delivery-permissions.sql", + "sha256": "b886cd198ea110e9eb4caf1c6d0f8e8e0053d973add90ccba013fd0ccfe74d85" + }, + { + "path": "lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs", + "sha256": "f61c77fd2d58e9fd4edc7cf587c8855392c1a136bd45b65eb27e52b04e425f1c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/BaiyueLastlegApplication.java", + "sha256": "91c0d29666e90ce430c0bc02ab615448d30ae1a324a55e61884ff011069df63b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewController.java", + "sha256": "45d087c98a193772b379bb515afdc42df18626c80ff77f91cecff841d829b441" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapper.java", + "sha256": "64c5db9592bdaee36269059bccc24cd24c9567af2f06761bb975e51949bfa300" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewService.java", + "sha256": "604fc36e20fd3227e42eeedc0f1b3e7c3fc98c6aa366cdcad4c65c0f5d9f80f6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargo.java", + "sha256": "4c3b478709e450f82f3681562784288ba21d4a4665c736cc4acbb3cdf1dd2d77" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapper.java", + "sha256": "2b1a3fea6fe685c271c44730637769f30b5042132dbe44b404ef3de35565bd24" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoService.java", + "sha256": "a7b65ec44febc9a80ff4eb7a655dc8cce27a8154a6442c0977d9bc228f3bbf03" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerification.java", + "sha256": "6f9a221d05ac8459f0cf76ebf0b2699bc84d1e13a5db95de3d789cf6e7c6c711" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationController.java", + "sha256": "007849a170b4d8e95df0d7385123b6416786d81101e5acb2497451868cf85283" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationMapper.java", + "sha256": "a97cd1e08d2250f36ccd6871f867a85cd06292657130f9d5cdc50245ea62b6bf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationService.java", + "sha256": "9359438372843934062600d3073a02f5fa093aeabbc0b622d4880df6ddf14c3a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainer.java", + "sha256": "b701c0e140de15be7a74bc942ea17e0680289f2b94a00a4e9f2bfcc77c209c2d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerController.java", + "sha256": "41fe6616f2ab1a1eace610f68d3f1bd620cd4d2455352f2d4fba0c8af169b872" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapper.java", + "sha256": "52155acaa87889c03bffb12280f5fb7c28931523a8486ed649632e1a40126a28" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerService.java", + "sha256": "53e76c39fac1204ef7b88b6f41999e51ee5d1e99df90c2195fe9a217aa31c057" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapper.java", + "sha256": "d529e6e5e6df75fd487655c6e1ce85b5a8ea04b9e7340892f9ce3cdd33ee68b8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageService.java", + "sha256": "8bd68bdcc64b5e7657c6fea4f5cf8c93f7b96fc84cf191e353bfd09fff362098" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateController.java", + "sha256": "d0542a7b5286c1b3b071ab865449d19ef952d2c20755afd631981fca49393fee" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateService.java", + "sha256": "20d4813eac994e796873c036932a03e99b866c1b746abb5471bd49cfeef112e7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalSourceGateway.java", + "sha256": "074c8f8eed144b0be4f3c2fad10dd3b0ac12068a001c09ece4143796ba5a4491" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowup.java", + "sha256": "b080a344fafce9a48dca0445e252f47c69fd218ec4bd7f7794e7433059d25498" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupMapper.java", + "sha256": "0405321899980dab53125c8fb09f11cf68fc6963ea29317b05ca8e710d967a26" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupService.java", + "sha256": "c768670c0b1cbbc9045e54ea96273d83e2d656757c58c8025fd8c1d01252d58f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java", + "sha256": "80f11ae1374b3b8940ed1a3448e52d542d79ca4f0265afce9226d460d9d52260" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpact.java", + "sha256": "c86f718ca6f34d926362bb71779c778634b2809b700e4899c5ea1b61b865a4a8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactController.java", + "sha256": "fa3c5b7b179b57ba989054904ffac34779e0298ade38e060fa66b98100a82ecf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactHistory.java", + "sha256": "a2be267e127df01d602684d4258b5a3da5909901ada9ce1665467d246fbe7ed5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactMapper.java", + "sha256": "31e1aff750451e66e778d5062a0ddec56c9028ba4b2d8e924599e8989774796d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPolicy.java", + "sha256": "10f423fade478a99877d272841dce7a975e5e8938ad872943e6f41690374b6e6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactQueryService.java", + "sha256": "29c82ea172adc569401f4cf75d7cb4e3880aacdbd4468a2e3c8dcee51940025c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java", + "sha256": "56dc51627dd6d7fd7147f0765a346c64ad6b0570bf8847ffa4cef5a2f6adf989" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactService.java", + "sha256": "2c41d452245aa5e8c3fb19accd8e20bf28cfe3411af5d26eeb8809e213f9b926" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactTransportGateway.java", + "sha256": "9703146251dac7b82a2d3cc54536ca5a0afc527ad829331b125146498da07848" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningController.java", + "sha256": "93904e3449d63714f8eeab71c369bf06f5e913c07b1a6790136d726585955094" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicy.java", + "sha256": "80cf301a2c9174d67bea818734c2fa0e8ec8ecaec79f44ee8556db67a461cc41" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceLinker.java", + "sha256": "521229dabc6acc1d3ded9319040a879b290f92f09704fce59a23d230195b223b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceService.java", + "sha256": "a20fbcda73fa56a592db6de5bfd7d727a4024a5e6bedb5723c958fbf92a8d425" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentTimePolicy.java", + "sha256": "d55a34a56da9fe3bb1a447815ebe8382b8654a2f68a45a1036406284dc47ba79" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentWarehouseClient.java", + "sha256": "99335cbcaca92963f0897b0a42a002ec22dc1c0aabe6d187a1f72dac82649c08" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkChanges.java", + "sha256": "0593328adcc2c544aa1589dd696865f8cc5c630610d3c853abb9bc74eb58852f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandService.java", + "sha256": "5315eedfea4227df589e985e1da9a5c6782a456d3dae8ec422bca4f90231baeb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentService.java", + "sha256": "231832a1b2cb7c2c070868b59181533cc518e3d1c2076d6113f05c68aeaae1c4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkController.java", + "sha256": "c7f3f67d2b8209ba36847bfc1c4fd3f1adcbcf51f83f6ea2e2a2632231262ad2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProvider.java", + "sha256": "3085a946281b21b49c3fcdaa5804a6e5310a23738363a24248f89685a5f7ed24" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java", + "sha256": "33f2ec5f8a19eaa814a9f618dc7e91908dfb609174912a98224fb89be47cd653" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapter.java", + "sha256": "4788399e4364b835a2d91fec05287980ed740724bb51446b14ef05eb0193a9c0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactGateway.java", + "sha256": "132d92a2d9e349f05697af35ceab6352f2afc4ffec39b77260dcc99790a7d578" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java", + "sha256": "ea4e7f0c32b62090c4d9e35dc73328fafc07c8cd8ae1f436e698c32bd243a348" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFollowupInheritance.java", + "sha256": "d2e16e62bd9f16756c5b888cb5ddb54136bc4f799732c2b3a0f5db8288a99e3e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkInternalController.java", + "sha256": "511e82ef68736a8eca66b4de4a018e0295490d08851f0f31ba912dc2b2fa30e9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkMapper.java", + "sha256": "307ea1af7e63ac7ea189b7e60bf6149d0443b747cdd7447f4cd59427d7d04a75" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkModels.java", + "sha256": "3c761f8e9c3245d7a394efb5a03ab5c9671c628f1967614370be6236e99e3373" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java", + "sha256": "3c44e077d9a395606ffc3d48ffcff3c1fc0dec46073adb090be0330e6906892d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerDirectory.java", + "sha256": "8d9ef4949b4f5401db0857ee8810de24519c86fa7672a4a5ef08b38705c2355d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicy.java", + "sha256": "012c12e651cc787b146f3d5ada6aa8666f5a18349654f497e6a7e40f17a6c143" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryService.java", + "sha256": "617eabb5b3c81868e82fee222216584d3a2ee604e8f8c8c6a6e0c9be6ecb86c4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteService.java", + "sha256": "34f83676aa72ffc0327522909e8f72450368ba98e7a201658101aa614c86449b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkState.java", + "sha256": "56504188a78a7f3a8eb06c427ffc1ffbf39f9ac74c22b263fde66994b0d51753" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkStore.java", + "sha256": "42c512919cce0679aeb5cc69185f5b2f0fdf4d4ac68a95e6f0671dd3f034e63b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncService.java", + "sha256": "2b02b402d282243e78b45a932d67579cdc562a89549bed50673349d050d47b28" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSynchronizationJob.java", + "sha256": "bd2e7b4afd468942ee6b87689240ba8fe2d74a384bfaf2626bccaf1f28a5df94" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDataSourcePostProcessor.java", + "sha256": "74ef8f3bc6122972e77e4bf6af573a5f00ad67f244a231ad74fe2912ac141489" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilter.java", + "sha256": "3650a91c2ef0bf17e24dfe9e75eadbf3b51acd0ac17483413b26e4812182f12f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnostics.java", + "sha256": "aaeceb96df24c2f9866bfeab76f853b6cd23faff0464dba9caeee9923fc0d6cc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsConfiguration.java", + "sha256": "73688de0de5c9a738a8bb5c22cec6af40ccfee6aa300b7b1db11bc39a6c94f6f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnostics.java", + "sha256": "5efa41280f92f5c78814a80a71ecea6f820739dfbff5c9a0d9da11e9770406a2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarController.java", + "sha256": "17102d4a32640ccfd92a7e7e738a248d4c1f5ae8906b8b965f455eb9be572603" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java", + "sha256": "80f3a96bee0fa7630afff100ddb051977e283902c4e7ef247b8152fb37c2400f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarModels.java", + "sha256": "88824f1a88d24b9bd34c9e7023aaa00d2653849a9131bf619d7df0f5fd3a7a0f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarRepository.java", + "sha256": "4395cce32c574ebee0b4437f6ec8bbe0a5aee62c19a6a451ee0f243eab7be558" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarService.java", + "sha256": "886e9e5460e47962f5701ceeb5e94ba9d2a123c4c83f2791b739178c8bfe874a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/client/DirectEventClient.java", + "sha256": "8ee1cbfd304fd266b58992a45337d11c39ac07d30038835fde443b530009c044" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClient.java", + "sha256": "f4015fb088f944810fe05e3391e25b99ff998367d68bd82696112c22dc7fa1ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceBusinessEventClient.java", + "sha256": "08ef6271d648ffcafe8dc60a6c060b66c3b55da785d363dab52aec8e03d6d316" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java", + "sha256": "3a5cca01acd492aa2af83ba712a8a002792f100e571f87dfc31133d3f3cc4425" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/client/FirstlegEventClient.java", + "sha256": "bfafbcdfe7add0fafb95019eacff106ec5e3846cbd8c7727643e5df1df9e243a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/client/PrintPdfClient.java", + "sha256": "6bf63c1fa7e63bee2246b92d001ea85e6c5718f5c8285b3fae8b65db705c11be" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java", + "sha256": "e0334195b89ae06bbf59febea8da3d6e7c5aac629077d4b256e30440ef8bb661" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientProperties.java", + "sha256": "cb4e53a5f3412e7c0ba5b3c2abd62fb7fe6dd9171a031d5a3479526bbc950add" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandler.java", + "sha256": "9479e43832c7ee27ae2573cc1dfbc4e0c083b437fe41247d43c83a2d827e0e9c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/controller/EventOperationsController.java", + "sha256": "bfc2293c550ef3967f28a17b34e996edd7623b7c01a4568e0ef8b51b9ddee0a5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressController.java", + "sha256": "371e671c5b2e99f72a508d5a4465524b7eee08d8c5a0965911c70ee3d7aac307" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegHealthController.java", + "sha256": "04d5d660f84cb0349455c4253711488e53fb16a62fc9270fdef248c76d905f73" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java", + "sha256": "1fd92f9fbf96fb01d620edb7ee63055fd45530e93fcb33f8933eb8602dced829" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/controller/MerchantLastlegOrderController.java", + "sha256": "06143b0b78a2e42caa5c569be574cd4e8fb5b0916616d7ee54c197f09a22970d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/controller/CustomerVisitController.java", + "sha256": "2fa40156968e12523ed429e466adeb0750d91cf1ad63aa3234cb48e3ada6595a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitRequests.java", + "sha256": "7f960088e565cfa2aa01a24b387ec3410de42bb1c30b1a6c247b561b45a947d2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/dto/CustomerVisitResponses.java", + "sha256": "89b1fe0cde94f6bbf872140817cd6f4ac3075a439fed03ae5399d8e1a9ac5e54" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/entity/CustomerVisit.java", + "sha256": "805e53efb5f600a9c08737601ed78db1264c0689226be839313ac3c6258e485b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/CustomerVisitFileGateway.java", + "sha256": "573042b255da075fc9062c83b7721dfb33fbabd249fe1c836137b8f630de5877" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/gateway/HttpCustomerVisitFileGateway.java", + "sha256": "44238965ffc8d88888c54990c5282116b3832035a9a27572302d528d7ab5c584" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/mapper/CustomerVisitMapper.java", + "sha256": "b49708d5ba18289acbe2c41e78342d1385a9780e5ba229cbd45c18c025cbb11c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/customervisit/service/CustomerVisitService.java", + "sha256": "b3f20e9820aed29a78943a42cfd8cc5180713c7149c1ead19bb4cca1789685d7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentController.java", + "sha256": "ec3958ba004eddc1018a9362804f6cad15ecab8cefe362ff88e44b24e4ca743d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java", + "sha256": "8864d9318967c96e52b9e8cfdc478a67bb64caba723c9aa343b371e3cebac15b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryController.java", + "sha256": "723727ba743e5dd4c24f0889368b689781f7c0af315fab5de659b768c16860ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/OutboundPdaController.java", + "sha256": "8f5d8469d1711e6ff661cbe493b41c64728b2872932982b24d5c4488c88f0725" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/PlatformDeliveryFeeCoverageController.java", + "sha256": "88ffb7e4a043c73c0b8dc0ea1d117ff60a4a996f478ade71f58cbdf123cf78e6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryDocumentDtos.java", + "sha256": "066433101d1b81f51cce60eab7c687662bcd76a3c7a014109babefe80c585fa4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryFeeCoverageModels.java", + "sha256": "3ec8b800b3e250f32b79b86fe8ae7b640b72fca1a5c54b3242895477d18febd1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java", + "sha256": "996734e8aa5070aab4a53776c75b941eebff4676993a099f18a436ae755eeb2f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryResponses.java", + "sha256": "9b698e8cf483f982ec4e9713feef74009ebed631ea586cc985f44e2377320b71" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/WholePieceQuantityDeserializer.java", + "sha256": "4552522f6ff74692e62ceb2f0726ccff3636acd7dd4718eeab3beb868598d403" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryDocumentRevision.java", + "sha256": "077fc351c8a9e364af9516fb7651ccaddb36b9d2acc2f4f7ec0393ace1318d8f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryFactsChanged.java", + "sha256": "ae654f3be98dd94cb28c986c47f61cf813b2c9a8ba957a166c1a6636ed7ebfac" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOperation.java", + "sha256": "b6b653bcfc0d980368e88f482a0bc41a42769d6515169fe1f55baf31b062ec13" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DeliveryOrder.java", + "sha256": "81dfa8c77c676f89520ce366cefdc1980d7621cf4da5ae70e3b974a4aa410ea2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/DispatchDeliveryArrangement.java", + "sha256": "4cb1164027f0c3f3653f530b35877a6720eab4e02683ecaa3a610a510904b550" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundOrder.java", + "sha256": "46292d5c4fa91eca7c89c8c7e9b19deb3b430b844b494d0e462309d420e22a89" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPalletItem.java", + "sha256": "14026c004c0d7138a81f6f0f742cbbecc2e3f0ee0a3f4a3db29b6160213a387e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaPick.java", + "sha256": "63fca8e9f10753979ef7c5f4612a168976a2b86876179d06ec70cb22e22ed76e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/entity/OutboundPdaTask.java", + "sha256": "63b3af85c710f69f244f6da2f724f9c592cdafa2e5a7954c7e5b5285f9721cca" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapter.java", + "sha256": "c49ac92841f21d0bbddc2d5739743b05ff2330193e461bb89d83d1db3973eeba" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/DeliveryDocumentFileGateway.java", + "sha256": "9db39e3dd4c032d768362e50592e9160a9ce9cfc01e5b03dd452ada528670a2c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGateway.java", + "sha256": "86bb9b4da6cd4f09c5ab83efb535d3d92d559dd8b1deae5eb4648ad6a4611fee" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryDocumentRevisionMapper.java", + "sha256": "c92959f2d1cf5d50c556a88fedb57b79a8465bf81bec9213d5d8f3441d579205" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryFactsChangedMapper.java", + "sha256": "c66da7e1842b82814afac1843796bf2ee649595e7b8668b5b37556c33d03d7f6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java", + "sha256": "9fcb9932d98efab4147cbb349d3fe0a49036a4982ed593ad6369d20ea4b67e82" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOrderMapper.java", + "sha256": "fb8a65ea9eba73ec3bd8079947bba94c1d404ecdc7b24fe0c2c67ede8a5ad67d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliverySourceEvidenceMapper.java", + "sha256": "6c6b8770594d4559dee5274ca2efcf960967d40997503e97c0acab68a1b3ba2b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchDeliveryArrangementMapper.java", + "sha256": "f732a8147f7e644bf4b65ab0bbfc43b9c5ff7663ab2087fa251d6e1b26fee4d5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java", + "sha256": "226a249efd3f36aea53ed6691be0455385c88ded5b542a1a54fd571c8b9f42b0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundOrderMapper.java", + "sha256": "ef613c153b4081bf18dfea7aaf657ef018ef4004bea51bec7093e9321a1f03ce" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPalletItemMapper.java", + "sha256": "fa7f2182d0b5fb73214a3375cad868881ed48d22f26da33140e7eb5d7160b74a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaPickMapper.java", + "sha256": "45a00e30e3ad7ed55cd82fb2af60148ff6ed70dd031d051790b254722ba244cb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/OutboundPdaTaskMapper.java", + "sha256": "1a66de644b7d2df842beea8984c9f0b899b3ec6e875ca3f8b25f93aafc6f1c68" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoGroups.java", + "sha256": "edd64b93be89983757124d2878efd908e6893ff41e915e10e36e609ce2d2e3f6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantities.java", + "sha256": "84e2415427639f13e924357b9e3a04576da8a15009e6ef2d794aef642c40917d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryDocumentCategory.java", + "sha256": "19c81be13f2f93ca5e4826ba9a5c23560e0d1a86611fc5150fef65d1291816ea" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryReturnReceiptFact.java", + "sha256": "60aa910b52a5472be32cb337f595c6e74da0542926f905b26d1652a176e0cbd6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicy.java", + "sha256": "f4309c948c9dfdf1754322da3133014261cd6d06937cabe579cd60341e96c4e1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/NormalDeliveryCompletionPolicy.java", + "sha256": "9f17b123a614d2e75b0d11cc15698ddcdaf239895af0e526abf2a850e8e2f7ad" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/model/SourceDeliveryCompletionPolicy.java", + "sha256": "27c2f6af212c51001f0218aa56b3cbf4ea0eb092bae43674c73d533bedb7a6b6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java", + "sha256": "4e4ee62aafd7e082fcb837c8a1669659d36dc4c801ad3463e8f0d681b53bcbd9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryDocumentRepository.java", + "sha256": "95f8988ee93fc9f9afe9afc4c6f8b3103c6699579526e4b6f32fe1e3786ad86e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryExecutionRepository.java", + "sha256": "056ca82a1384e52b845ae8d76b53f93c08805b7b30976a167b56bc4f91d22fde" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryFactsChangedRepository.java", + "sha256": "c6373f111564cff745d1cdf9fa324169697381861ad36f03f29adba1d093af98" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchCargoSourceRepository.java", + "sha256": "c3c07d788757bc17ffe60fee96ce91071d595655839c448d02b09b6ea6e1c960" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DispatchDeliveryRepository.java", + "sha256": "12bcffe057a1f3ce846d5eb4dc027a3862fb94143e11329d827c2d5fc51912f7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java", + "sha256": "37c101772c3230356b56d2a68049dfca50d80a71ee3a65c10c91deeb9f7cdcd6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxService.java", + "sha256": "24eb17409863924aaa0272132efde2f102f32133adb2b78e30bfa7b285573495" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentService.java", + "sha256": "1b2d8978cbc07a6a276b02b4516132c68a6c5399688a086af7393350eb0c2f8b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentSnapshot.java", + "sha256": "b02ffacafaf02db620bc78dbdc741643c3ba3ff6e923376ebdf93c04bf22a025" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuard.java", + "sha256": "a0bed256e2de5aecafff3bec2c39629cb4adcec197412097a17461af1cfcaf86" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryFactsChangedService.java", + "sha256": "f973472873b520276d4caf44992054384e2fc36f6df45a0c3ad5bab3fe7fb205" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java", + "sha256": "126e3852f663f3dc6cb820309bba5f45bc9ee371e0fb3816ee9cb42874f57b4a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryFees.java", + "sha256": "43472c82155c5f5ff2ef7d2107f9bf61c69ecfef6ecc4d2fef75628f9e08eb66" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java", + "sha256": "2793078f6eb49f122aaa687d3e143f59b6f20b40596a3ff85f2c70fe8626c245" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/SourceDeliveryCompletionQuery.java", + "sha256": "8ac0fc64e45027f90aebd942769ff2370231bb0eec3eb526a7bcdbd3797f7a6b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java", + "sha256": "a8c7d56da34fe429ceabc8877422cf1ce482335a5649c54ca020547481e2f898" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java", + "sha256": "c322fb54148abb3216cd459275aa47996d2c5641eab1f3caf1668a8b2d7eb9f6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java", + "sha256": "cb30725d19b2f3fc93fc45e429adc05de522546a324c7c9a9ef6a0ce4c6c163c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java", + "sha256": "e0c883e8d1a6ef46925f9188832c20cb75132bdb26ad26e39ff06a6863ca3e63" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardApplication.java", + "sha256": "1ee9b3cba14699a6fbe8ec6b9add168e3aaa59c7d81001aba000788b680301d7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardCarton.java", + "sha256": "b7792785131a30f082c02cc79cbb1425bffc33fc3c4658b88f3bc66f1e3d8d25" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardItem.java", + "sha256": "a2ae0384b27c156830883940faca7da907aa835497dfc8cc62fb9497f1d08ab9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardPhoto.java", + "sha256": "fba20d41a1edb9af045d55c443a1993aabf21f1eed3cad44a35627e713fbe720" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/entity/CargoDiscardStatusHistory.java", + "sha256": "01b238f2f0680fd9a04f6951fc23eff9bb918db0cdb952c0de82b9bd3cf79c7a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/CargoDiscardPhotoFileGateway.java", + "sha256": "629d82544f69fccb76efa86af88408957597dade7270bb6a38ff9ddeec6a36fc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java", + "sha256": "c60a7fc8a60e2e1e921864b8fcfe376fad3a45432f58d05349685859789bebf1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardApplicationMapper.java", + "sha256": "7fac8e4d90de7fd063dbf08bfbe5f9646e514917147d9821387d1a3e04e4c4a8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardCartonMapper.java", + "sha256": "a19e0516aff2d249876aebef441b2739d6d752f68b8c11d4f4f3ad7fcdb1e29f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java", + "sha256": "9d28de74687dfe971f44acdebff28b663275b045fa32fa252f893ac2b3e15373" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardItemMapper.java", + "sha256": "eddc738bbd8d2084a91cdaa8e01e14ec9762d63e54f141a3351fe23b931da619" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardPhotoMapper.java", + "sha256": "0fac76b8cf91e561de40ee59118094352788348eaa8dede23417595281da6857" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardStatusHistoryMapper.java", + "sha256": "fdf16f2fcc6d9705d2dffd72b43cb41e93ca7cc90e246533b750447180e66f01" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardCandidateRow.java", + "sha256": "9787729a4f209e1efc5e89bc58c5717c8bbc5edbe0b595064ca6fa05d8820ddf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java", + "sha256": "2fb6ac4ee28c63ce3a134eeb951bbeb99a51cbf86843d16f816f6840d1f03cc4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java", + "sha256": "4297b331aa9851ec689bb68a57d0660a65bf698bbb37bb8f65966c3297b5f6a0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/controller/PdaCargoDiscardController.java", + "sha256": "ee104d5e5606c0818d56d7d8c61c421e2e088a243642dbb4aff5e2bcc36c20ed" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java", + "sha256": "b85780f7bb1f64e1ffdad37103fc6e742081f2f990391f470a59a8668b66afa5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java", + "sha256": "d1ae0a5fbff33147338461c25a0c56eb10483914a22cd1c8e4f8c9172786becf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java", + "sha256": "d8217cb0d35e286942b9a8428dd0bb754528901e2836227e65d7d7f20c9da998" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentService.java", + "sha256": "650a24fe2c856a3c4cc8c9fbcb0a63585259754b800e68f80d129082bb8815d0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java", + "sha256": "d67ed2700dbc731e95819f47aff4b4f584c152ac5276da2302d475042c1d92ae" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoService.java", + "sha256": "bd281b6b2fa9f23d18b3e55a18184a0671bd7d08b4950ec3e7659b515a3308b1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java", + "sha256": "a54f2256ebb491835d6140ace035ddad85126f36848a38c53aee494f834b7582" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java", + "sha256": "7bd500659c340cb047df5966e36e139acc996754848729502c27608d99893b08" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchActorFactory.java", + "sha256": "74706b68e4240585280e49eb8105b84b5cfa9a9215dfa03beb553da8ebf23ff8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java", + "sha256": "2bb62f5d7ede8291615bcba313619c74b0884dad9f51704cec9f82da03ca8b1d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchController.java", + "sha256": "1a2191f78b257771928c440cd1c4599fd77180ea55b2acb533ab6ff047d72756" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java", + "sha256": "711f8cf30fb16c2ea0e1440f3da84e9af98cfcf7759829fd3307b6c060e9f9e7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java", + "sha256": "c39dcd115a984d105f744b93f92d74db24b4337589b2494262dbf2de133284e9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchContainerResponse.java", + "sha256": "1db815061b9741036cb9f5a687dd27b8b7bea45aca843b25156287936af41597" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftResponse.java", + "sha256": "91690e80fcaf72917de4b6a8ecaf4d990e39c1deb1d7c2777cdc6fdc529a76f8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java", + "sha256": "5905a47f949562814a08a0631331ac00f29b64982c11f4c7b678f9191e7bd8ba" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchCommandRecord.java", + "sha256": "2a49c323f17d47d2b3475057983e01e088f1d18022c3068335d1586c753f17d9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraft.java", + "sha256": "4f1509204cfff1a6993f75cfc7dd2ab62b8a56c3217c58e2e00326958db76a12" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftCommand.java", + "sha256": "38366ac363927ada62a25e1ea1cd3cb39bd76df10242e3fa5ff8dd4d3745c14f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchDraftContainer.java", + "sha256": "deded482497de649ae66600f372c4ed2d9450f4cfe08da249feaacaa3008d619" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItem.java", + "sha256": "e1009806ec367e282f79db542c5d1fb01f76a8e1123e1aff541a2eb673c3b4fc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java", + "sha256": "617d294baae1cf2debca429d34203b5e97e19bf69440d188ad420674471e5269" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchFleetAdapter.java", + "sha256": "44988ef1d60636d74cfbe22c5bed92a45fc9f6997f7452f5a7c46f8adfb1e0c8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapter.java", + "sha256": "67a04d6583f0f3b24443469a3f97596e84512f19ab19b63d84b66388fb3c7652" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningMapper.java", + "sha256": "db2dae475986f0d320dc6cbfd5b69b80e605e5d0ce79bbd1b43f3ae87a16b1f5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/PdaFleetHandoverController.java", + "sha256": "4b43024f9b4492647890a0663c591530221616decf1fd03b44a8d587c3b9e99b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java", + "sha256": "18927afa3a3d254a7ddd7bbfd0d25b7271569265fb34b39dc5fea79d8360e222" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java", + "sha256": "2403c1188dd5bf594018fa658ae5ed666c776953aeb3485a4e12748bb26501a4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java", + "sha256": "0f2405ff1eaaa72054f4ecd6251f75ab8a83ba9b8f04ae9021e67b71d42724bc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java", + "sha256": "df865b4981e25a43690fae7fedf79b218bbe3cd0d89a13f12f36f2d4db5fdd79" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java", + "sha256": "c247f84215fbc6c5d6b92813daaf4467306580c69183f1bf4eea4c75edcdd84f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCommandMapper.java", + "sha256": "96cfb82112b63b6660277bd5a4d79767048ad8e26ea9b14be87b72ad66121478" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java", + "sha256": "f23aa194955b7058da25992e43ee786ce3cde0c3ba9025a9ae13440c36aafc66" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftCommandMapper.java", + "sha256": "e63f642901fbb0a0f1b3895e871c3c93539cea9b7928a86899073bee05a46920" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftContainerMapper.java", + "sha256": "4a752ec506f3dbd7f86ad41b5f9fc1751b166e0b25f5d394b8d9c588e1b2c142" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftMapper.java", + "sha256": "141df6769ed0c9399a59a550204998c8d0b0c23e935e108f96602d1729c319dd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java", + "sha256": "3eaddf618be64d407a40646d856d8eec9622b6e4013da318b0652b6a75795f7c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemMapper.java", + "sha256": "1f002fcceceb1a2a94c27cd75456ba471edcd3a0c4b1b5a2477a341ac66d90c3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java", + "sha256": "5458fd08aabe70ebdb49984c73845e73318a1dc6da5cf7dfd677cbb882ce8a9d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchReadMapper.java", + "sha256": "5e695aca2b884fc50432556ceac623fffdbfa96408f7c8e9dd1042ef51673a03" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java", + "sha256": "fc07eee36014ee76229a1a3ca715c54222dc7275c7e3e094a738e78dedde12b4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java", + "sha256": "9bb57631cbbca10324e3c53f46c58bb13be42ab81e090a8f5ba04cb8f5d08b8c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java", + "sha256": "d4177e03676db89516040402ca832f4424414b604d6382b1eca62adc869fa1c1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchPalletCandidate.java", + "sha256": "f111995269be078bdcee07d387da8db2492cf3f84410c2d25073b1871d55d622" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java", + "sha256": "6e7388cf0ca0028ed5700153e0673251b4083e857d5b022bfc5c89f185153cec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java", + "sha256": "ab1790b5de8a47eb013902c141f01b1ec71a66af77ad8f42505c047d6ed372b8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaOperation.java", + "sha256": "8a62600b44a9549b80301623dfb2e9ffce98bb91853ef7978748600bb5dc5a3a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPick.java", + "sha256": "9fffe03fd899cf50f1938f359867d4b301f8d78151da5e1fcf1df28d9ef1d392" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java", + "sha256": "38a51989056c238a257314776b698d24f864c31be10e856b9f7497e23cbb729e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaTask.java", + "sha256": "b311d99854e77d3244a74365bac072919be64cdf85d1dbb25be7547785069586" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaOperationMapper.java", + "sha256": "766cc8d38fad213a903c73402099f0da3fb0c94e9f2454c47fb8dcee3299382d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickMapper.java", + "sha256": "4513d916d33d107e67ca7c28a767fd9bc78de75582ee320d6930b3617cfb3d85" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaPickOrderMapper.java", + "sha256": "968dad1e90f05d7b571bc8f422c7d6de7c39d7c0e7cc78c99175bba6bc5bb1a2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchPdaTaskMapper.java", + "sha256": "74b47cf79645692cf2f2d189e6f1ba469d4a452315a8f101e25164e8c3af7aeb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java", + "sha256": "98323955644b8124d7f7c54406a4ce20420816be03a4d109d30074afaf5c230f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowService.java", + "sha256": "ebf71260abdc2efd66d1d2915c644bbc9ed1228ddb58e84eca63afd3b4be8575" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java", + "sha256": "2adb9bc6f38dd3603b6c9652245bfc198d71c76c84b2ee19270c25eccaa298dd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java", + "sha256": "584a973627365c4987e4fad05bb26ca8dbea538c779472f0df83b9ee81302c25" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchAppointmentAmendmentPort.java", + "sha256": "45aca62fa8a52f3b97a3e666c87b85d2ae0ca5372ca46e1c29ef76f27887b238" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java", + "sha256": "0553f0af5dbe31bb9d63701872540261398536f1925edfc3d7f309202aeeea56" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCompletionPort.java", + "sha256": "4a222f0d3b3a34267f7469aa0242cf62486f70496c15481838ac889b5e64a76d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java", + "sha256": "73b5b83fcd0155bf09b18c18b1bd6a5bbb0175221102a13101248f92ca84f641" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java", + "sha256": "a8452477225b71400996992896f0a8a3ed385118cf988d5c4ed7b014c4e85896" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java", + "sha256": "1963618f71d831482a1a8527da68467773721db980c84b5c970832b1c3dafbd6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchExecutionPort.java", + "sha256": "06ab14af3879ba90a7f4e5d6b2c56a8142bbdc3068911c8eaa9336807c8c8457" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java", + "sha256": "33e3512544ec49aa6d9dd332cfc2e271303519058f8aac978125d91fa3c72f8d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningPort.java", + "sha256": "2ecc408b6d5adb31fab23fa677916608e4434013716bade9a88dbd2655f64b88" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchResults.java", + "sha256": "2355f33a878c8aba599122b4d94fb97f88db1b27622f541f3082dadc27dc1e94" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSignalPort.java", + "sha256": "85b26c63d98f87abbc9ddbbcd5b18961ac32c4bb60e5cb74832997a4ff233c07" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java", + "sha256": "ecd710d123747fa28f1c39c6fbafcd58f7bbe8656717135b81c80ff0736177d2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java", + "sha256": "2c027f2516ee7a1a0a544e52aaa4a8be70b1439a0b94008223335d499ebf2ae9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java", + "sha256": "1e91da656e38aebbc554b4b4275b080d387e6bdb2eefaa14d5ae1ebd5b1aec64" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java", + "sha256": "481cf90679685b3c61c2bf5d17e143ea6c3f7b20c30e6f1a0968fd5d97c9cbf8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchCommandRepository.java", + "sha256": "98b911a33861fe58843b3b19eb6a75174193453e1c47b9bdae100a3f8c4fdff1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java", + "sha256": "6ee39efc395352f43ce36003545956c917990e2f2913dd2be5ca7c19dea9675d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchExecutionRepository.java", + "sha256": "f9f3e537a768a15e092f08ea4aca91817d776a24aac5785b90c9da4c35ba09b9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java", + "sha256": "efe94d2c32fad627d8d1decaa8709d09f469ed48e05c2fc1f63e874c5792ad0a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPdaReadRepository.java", + "sha256": "8d542aa7b44d2685783300ca32e2aa18022782079237567cb9544734bc55f88e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPickingRepository.java", + "sha256": "19c24e3a179db17fba9a07669df52433dc41411d27bfeb0f042a3d9733b9d92b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceDepartureRepository.java", + "sha256": "fbde6280d15da8edd4df6e23ec5ddcf3efd8cc595b4868f2f4dbc0a64d3f8cdf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java", + "sha256": "77bd6346e97d224ee06a761f90a0eed1805b38ad9fdd81b0fb58dc8862a4e2db" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java", + "sha256": "885009b9d74bdce6463bc49cb438ce46ccc7888f5e54e5e2dd816679ec39c522" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java", + "sha256": "abf0dbe096c1c695473a9de840b2de87bda472907f606c8a11c9af9097ff0f3c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java", + "sha256": "d0c02d8475618782cd9805392620dc02ab6632c372eb883e6bdd2f7894ef794d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java", + "sha256": "60a1750e33bb7cbd777487b8682bcb4bdde7025ca0131b6cabf3997ec4c7c8a6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java", + "sha256": "0e61307658495fb05e6dfed3b7029e89d1f92ab100bae777be8f4f7ef35871f4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java", + "sha256": "445cad5b3ee7001a925c1c97f58be496c5afc0666ef10c75260bb16bf004ada9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedger.java", + "sha256": "362d36783c59775c7518b6f3ab288fe45d5bce5c66164669ceddbeb9e00dff35" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionService.java", + "sha256": "b48cef897279201613f4c9fc6e3d541d347b2db3465a0902b0f5c525786a2a3d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorker.java", + "sha256": "1051e2c3b842fb8e45a8f55be4aaec50cf0f004a6e8aba1b4af448f0a549e598" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java", + "sha256": "3123fe0e2beb47caa30c8ecc262efc084b42fd96c1f04eb7e08ca7f54a0c0878" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java", + "sha256": "0330fe2f78484a0a744b4d5fbdb5471dd1ad1fbe5360af190040377419114d46" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java", + "sha256": "fefca13a1a22a025b6930adf3760eb5f70c046a590fd5ac6f0389ec5d3e1f7e0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java", + "sha256": "d1018f17762375b29e72e73eac8c5ed0ac3363208469229012d399cbd00718da" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java", + "sha256": "9bf0572f019de6b7bf1fd958aca7d536c684174951ffc22f8700158c3be092bc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java", + "sha256": "b8fb5b56f9ec10aa148a4684f44554d9d2da17ae37ac8dca5b10ba32721590ef" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/controller/DropshipOrderController.java", + "sha256": "6e673d27015b3d1c298e47cb0b58bceba0552fb64e4778d187f69fd1cf88acd3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/dto/DropshipRequests.java", + "sha256": "2701df47ddd1f39f2920bd7655b1b0aacaf182b5c81dbfd64a171547b040b568" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCarton.java", + "sha256": "4c50e8e2f92e3bc5799a725ebd0e2f8b8276fc599d29bc3a335fd7d269bb520e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonLedger.java", + "sha256": "70c1394b5b8304c3b6400fc5a8e6c35a8c371fbdd2cc78540f7243405e3d900f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipCartonSku.java", + "sha256": "ad3766ff72f2746dc2aa1bff7b6c06f4bf53c87a01d6321466e2b04f21fa898d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipOrderProfile.java", + "sha256": "2fbf9cca0a24f896ec9df4d8982e1203df45065b61900e22967941bb470f709a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/entity/DropshipSkuInventory.java", + "sha256": "104d42ef52f3a0e1af3f7ec5ed6f88d787f9c16576db246e37ac0940eec1166a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonLedgerMapper.java", + "sha256": "c3c0063bdebf64244a0cdbbb0dd5fa9d4e47cbc8dc41293f44b9000099a87e85" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonMapper.java", + "sha256": "d9a7c9bc13508c8b2409e3a31d9fbf3dd639882c5f414992c5703974bba00a68" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipCartonSkuMapper.java", + "sha256": "b2c49c81ed752997dae2a50d18872685380d146431604ad096c6312ed8a1e851" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipOrderProfileMapper.java", + "sha256": "5260bcf57661deeb4dda4cae1a1d67016a33541671a2e09f96371724d2ad4609" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/mapper/DropshipSkuInventoryMapper.java", + "sha256": "c8058da78ad2c4181c3299702b9f0e3dc40cea3a894569db9feb3b34cd6a80c2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipOutboundCandidate.java", + "sha256": "8da2535ad538b2e83c88a2d04819718be1ce18a7e7e766d96c10516e90d62bbe" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/model/DropshipPalletSnapshot.java", + "sha256": "fe50d19d51175889613124ce41f39fbf092d44aabdaa56e7556860c85db090b1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dropship/service/DropshipOrderService.java", + "sha256": "cb83162bc156e5df69c64bcaea49a0ca9dea4402dc147e562313b9ca122d230e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java", + "sha256": "bf2e00bca26e2f15f74c47c42f668ce7b89bae1606491d9c24d27c932d1ec1b0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressDetail.java", + "sha256": "1778b84b58243b075a402bc0d38e2a5dd797583a25506117befaf971344a5ad6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerPlanProgressSummary.java", + "sha256": "46f2bcaece49b0349e244a8d93ef18ac20683e6bac91624a9a46e35fd34002c8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/CustomerOrderProgress.java", + "sha256": "45861a83b23fffceb913df283da3712e5b229b21c6b09c0af460b877a6b4f6ce" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderCommandDtos.java", + "sha256": "0e0f7c0bafcc5fb977496dbe5265f5b5a1f1bc3a2cc5e226e3b071b410c6d82a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderDetailResponse.java", + "sha256": "b9535a89be97b39def1d6d0abeff327d29dfb0728573fdb373ca1658402ae7e6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderRequests.java", + "sha256": "402d27819903d9260387b95f220864f631b0c0627de7255cfced0ff012e9a29e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java", + "sha256": "2f6e25fefac6642d9eed0cf851bfe7d9988ec7bc68e7bef8980dfe4a3f497615" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/RelabelSubOrder.java", + "sha256": "ccdbda09acd33ad0da8eb5626314cbae1565484c7607fb877e224123291fdbfa" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/dto/package-info.java", + "sha256": "55812e63a86e9aaf994a4f143d530c9b58336ee9184b21392f468bc5f98c9088" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/ContainerArrivalTimeOperation.java", + "sha256": "4c76be47221234a4b047db31e77330b1f1ad82b2521eed789d73cf2d99846e50" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventInbox.java", + "sha256": "ef8b86dc6bc52b9ece5842997ae72137e619b8b1bb10504aa4dfc917b6f53f2c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegEventOutbox.java", + "sha256": "966909acce03a8dfc57933c39f2db74a176f19f2627c9509d918d6d083f65baa" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegInboundOrder.java", + "sha256": "cedf35fc01894372b15eeaa39af9d11afec44944dd3a735b84fc0309ca0e278d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrder.java", + "sha256": "e25beb30f697d6342ae0407eed8c38fdb2fc83a428e88dce83d23ffe81da827d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderCommandRecord.java", + "sha256": "7eb3d4c3eaa86b5d179f5991705d22162dbedaa0795d222bf489ff5637862e70" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderDetail.java", + "sha256": "c48d3ff7d26e9802b9a372ba16a76ff4bd9b287cbf0d519b51db07646a0d2b2f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderHoldHistory.java", + "sha256": "e113ff808e610c321319e0b5513ab1788b5700757cd81a78e8819cfbd62b5276" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNode.java", + "sha256": "90c6a6cb95d1caeaf9b9cfc3e2881e66c07542642243ecf6c0cecbdbd4017085" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderManualNodeHistory.java", + "sha256": "cb2e9e65960aff08ed66ebb1dcf8452661cfd639c27daf374e4f204f54000065" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderOperationLog.java", + "sha256": "fac0a0b94a55be47afc38c27264c5947693f61abd42a68458e3508f86a4391db" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/LastlegOrderStatusHistory.java", + "sha256": "939d59117deadc4c8d9bd1ffdf37ed2ea14ea1a408207f7fae2a82226e6bd083" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/ReceivingWarehouseOperation.java", + "sha256": "af42e7320a8b1099e9dcd3b8514167e1c68085c44e3e5b55862860c795fb2445" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/entity/package-info.java", + "sha256": "a022392e2ec354006e5fdef6c18fc9c100ab682c285aa84112ffd44270ea2ff5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/controller/ExceptionOrderController.java", + "sha256": "2a4251802fadee5911bc8fee15b589ab070d91778115fcb6c9a14943d836dd3e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderRequests.java", + "sha256": "552ad724889d236b4eaf8392d358e9d5abd900afd7ee02022c17c3cac2c718ff" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/dto/ExceptionOrderResponses.java", + "sha256": "5f218d42796f3e604d76fe8f495361d58536c7619078b7e6e4ecaf2f74672053" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrder.java", + "sha256": "60d72647100af4f803f12a0360a5a90bbe5862b4a70201099266e66040bcdf81" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionOrderCommand.java", + "sha256": "f0da64b12f1406ee76660740d5504d123a0725207cf4a0a3149b2327fb05faa4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ExceptionReturnItem.java", + "sha256": "0539157140a5ced2301f3df8099d5f8bd4a0c5bf3d65bbf48a52936a5488cafd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/entity/ReturnCartonStockReceipt.java", + "sha256": "d1d696a43271a11308f62ebfd32cb0c6433d0a6b835571a9162c43391244c763" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/ExceptionEvidenceFileGateway.java", + "sha256": "9d43b06652e899c9fc0d4e929c9f01cf075838e7e39c12714357f9d45ac7e6da" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/gateway/HttpExceptionEvidenceFileGateway.java", + "sha256": "17b0a285b83ab98fa9e56a67b1cbc8c1af599f32af790fc984586e4c892efeb2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderCommandMapper.java", + "sha256": "016581e8d178736579568e19f772e534741643b426037bdf4388b83d1696b2b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionOrderMapper.java", + "sha256": "c19123d5e29fb4cd3a5392ac53f60e6f89094f6aa42610aa55f28ab7f9ad4c87" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ExceptionReturnItemMapper.java", + "sha256": "d31f8d2edbc70d7f0ef4649e2bfb38e67ef02fce6f9c7bbea35fb7e80a659345" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/mapper/ReturnCartonStockReceiptMapper.java", + "sha256": "87246f09d1f9ea71779a71c8aa792a394b91d86c5027251453e5815d43d6b5cc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonReturnRepository.java", + "sha256": "3dfd734a46ff30987c82a54a731669ecb86dc326c8a058a54532549f8ee50471" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/repository/ReturnCartonSourceRepository.java", + "sha256": "c71f6427f900f1de7b00ac7dc61dabeab1ec8cf412de1bc3e312468949631ef1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java", + "sha256": "2811cc54c37688f137ae9e8b0244625758078721c69ff7ad7eae72698c8b11b1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java", + "sha256": "c01e5164aaf1e3e469f31eff6be3d56ece4a9c8449f94e965d71092436ba012c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicy.java", + "sha256": "0efc759a4ab4cc1e51aa6dbfb5b1de8df04425859daf3a28d636a52b53caba8a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportColumns.java", + "sha256": "71ecc24020cdb6e00aca386546eca09b556df440d637145785903dd124a2b52d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderController.java", + "sha256": "7b3cd29e24d7aa68f7dfc8aac8a409a293ad320c01fb2b4471e743ab92fae9de" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/export/LastlegExportProviderService.java", + "sha256": "5cfc88305f9fc6c8f2847ebd09a67c78dc0e921c7207aa5f6c5e41b313a589fb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/controller/PlatformOverseasFeeSourceController.java", + "sha256": "c9ed0a6aa6b191f7e942fcd5de9377f3f7d5a243d95e78dae1188ce3b8104a38" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/dto/OverseasFeeSourceDtos.java", + "sha256": "ac4e98039f027f3545db80852a863dbd57b91c2d86ed9d3c67fe41a342783a10" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeCommand.java", + "sha256": "0e25990a80e386022a3b91e0ec76f74e29d5776febf703d2d86593d02139df74" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/entity/OverseasFeeSource.java", + "sha256": "7cdfb9e40033ff1bff441ea0bb7a64a74286ddb3b8165dbcdbc17ff013e14f3b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapter.java", + "sha256": "9a58b07b8687d7580c609b7718ab38ce0ebf0c1cddcd19e06ead049357c6345b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeCommandMapper.java", + "sha256": "45d8dc2f142610dac00ba682cd1991fa257e63b7477edf6c1189d509e6490a80" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/mapper/OverseasFeeSourceMapper.java", + "sha256": "3a6152baf76c6508a223a7293aaa74f6f2f7cb486d94f0aea15ec174d4804979" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/repository/OverseasFeeOutboxRepository.java", + "sha256": "0467c49a27004d513fe1379fd26320d0b3d0db8d5ce3f1a44b1ac10447f52371" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuard.java", + "sha256": "b8f574d57deaabe0ff7babeceb251bc266842a16c781335bdbaec1fe1bf41d6a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverter.java", + "sha256": "c1ff7b35b8beb135f77009b65ae5b7e0dfd8ecbb211596f2658ff053c8216888" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxRelay.java", + "sha256": "b1885c66cfb4f4709ba7c27ab6dc695d0220bf4b5fe4c38bc39d03b5bfb8d450" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxService.java", + "sha256": "281c5c0237708b5382c63bf5fd9559d94b6831016a3ddf1034923b6583082b19" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/service/OverseasFeeSourceService.java", + "sha256": "23e1729ca43ffff1c69edf85ea725d1130900f79401ffdceb6e10715e2df3646" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/financefee/state/OverseasFeeCancellation.java", + "sha256": "ad21c39b5927cd722f7d19b21dabeffd5f862ef4eb3fef0799a773c7875044d7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentCandidateMapper.java", + "sha256": "058d7291e0666db190a1e9043e4a93498acd96604326bb5af21623f6ef164f83" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactAdapter.java", + "sha256": "9058f107b3fddcfcf3f6b737b70e44c9c1b6ad5661b68a2f92d548de53ce9a4e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java", + "sha256": "36ae1dccaff96dcc49a8bc1a43ebaba18950b641440cf6bc5682ffa0e6911381" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentSourceAdapter.java", + "sha256": "2eeddf116d4a89c11796cd7ff13bfd60527ed7e7ad616f418a21334af3b35a7e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolArrangementRow.java", + "sha256": "049d528afe92198462fc33d86bdb5606e132057638a5ff0aefbc41962a7a473e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolDeliveryEvidence.java", + "sha256": "cd6af31c392df7f0b0b158bba56ea703d674a10acdd358f7c541e97abc0cd4bf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java", + "sha256": "e72b83314154f780fb17566e65c296d09eb7b4ef5603058e43e7637304ef5456" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderLink.java", + "sha256": "3c0d6bed541916bd1487eb9894223f0be781c60716de8cb5c6a12bdb2eef3d90" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java", + "sha256": "64b13b751b9255927b6a2ed1d48f5da2771f620d52ca500de2d6f83c2fbd09bc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderModels.java", + "sha256": "b3704c05b1d81cfb0a63023e0c48c96b681b62e9d5bb98f5112af7f7a76e23c7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java", + "sha256": "1e7c80ef1b83fa8610862fe0210bca5b17c2a11674cae780c2ee40ecaf7a9c0d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderQueries.java", + "sha256": "f1746a46bac36ea3c802ff2de4e6a6e5c0c4f8db828cf65524f77326f14ad732" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderRepository.java", + "sha256": "8201abd15e00c6f33e6c90121eb4aa85075820d960f6db21200d3255920c8d54" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolSourceResolver.java", + "sha256": "404f695632e6a91174761d09d01216214cc3d66d7139ffd698778475795ffb18" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerClient.java", + "sha256": "5a6cd17efdc88fccb8d9ebd36f7772862bed25d7a457eb162a9c35b29e4764ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectory.java", + "sha256": "968803c9040709ea04be30ae95ce34dec2c82518abf5ebfe297eea116e7826c7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicy.java", + "sha256": "146d46134880dcca805c788aa0760e5aef0d5c6f147b7f661f69696e76c622a1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicy.java", + "sha256": "11f7d827ca9d436614316b12d221b2860dca2b6d71ee692b0fb26e08008a73e4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicy.java", + "sha256": "2f054a7d0a6b8f0ad4d60897b65469dd1e0e00caa7bb9005c85f059601110a5c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverEntity.java", + "sha256": "248e40c5ab9ee454051f6db4053eb5d2a9dc93fd9945046de3011e001509b41c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverFileGateway.java", + "sha256": "07389aeb7c5f02418212675af917ba34feebbbd17a246b7e73e892c5209a8fd4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverHistoryEntity.java", + "sha256": "037a2a0d364cdefac5324aee552a323887b84a0311166de961c3d0fba32b9891" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverHistoryMapper.java", + "sha256": "792aaf69afc5a96981062c6f06cc0f4a6ec7cfe4e62112cfef2f8d7c7e9825ad" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverMapper.java", + "sha256": "f9dd394ff105dfb33dd6c6615d4cbb3bff6c7d45461be20c638dbf4806442d97" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverModels.java", + "sha256": "e80190b34a652587e3d74e6aae7808ee6aa2133c7802af632c42be3f52ab4d82" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/DriverService.java", + "sha256": "96b4863579c6f62b4be9f023396a6484593b70cd9e4720f33cbb84184747b167" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseClient.java", + "sha256": "2aeeeb821163842bb0555f23a405b2e5c6d62d5406277fce64ff7705a899bc01" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectory.java", + "sha256": "03be75bd9b06b98741053b0f71e7bae280feeb0602bce655f7c3e2f29a6476f4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGateway.java", + "sha256": "6834a295737da7ea3636a2abaeccfe045e298401cfbc287159f6791f5c8ec7fe" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/driver/PlatformDriverController.java", + "sha256": "274c8583763c264780c124fd18a5f689773088fc872b81f670bfb540818fc69f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverInspectionService.java", + "sha256": "00958401a8520917140e00db902e06da3b37ae9d5a288f1f3eedc3944598dbb0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverModels.java", + "sha256": "ac91d309243475e52ada3fa4019cb2e169a6bfd2a592c7142a8c62dafaeccc85" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java", + "sha256": "8528699cca2d298fde79f6d8968e841253ef792bfb3a21544a496dcf8b982cc6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java", + "sha256": "a26c9722f0f0c8809c690f71e4ece92d6ba9bec6992fbb1bdb236220c5dde043" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverRepository.java", + "sha256": "91cbb9cc008611b778dfc1379e6e1d50cad48a91d86724dc581ac005b8b91eec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverService.java", + "sha256": "c3d218369945e343112df50101f18cb52aa34382ef2302060da83ef24c313056" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidence.java", + "sha256": "e6eefffe9ad924e5cf7d1be17a91a0ee9fb47c33717ebff72b9ad297056191d3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/PlatformFleetHandoverController.java", + "sha256": "e3a748382552fa7f7fad74bf0e4844e08c549f6df758cab59d2cc004eca7d47a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGateway.java", + "sha256": "52c9af2972f01868beb7d2a94837f2f0206112406fa4a2760f23276de1a927ac" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareController.java", + "sha256": "0f1f2273c9ef633e25af9a05bbf75f2b551e418a025ebcfb6fa94a396f24d0f8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/PlatformVehicleCareFinanceController.java", + "sha256": "7f153d63872b8bea599e6e6a4a6f988ab9221e71c1abdce3a597f1488dc481d5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareEntity.java", + "sha256": "faeb80b34fadf98dd83f4680f99664d3639e8e76f9e9f53662eb13960c4f3630" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFileGateway.java", + "sha256": "c4040e55ffb7a5a72e7b52f153e26f28fb0d883a5e6d9f37b84c8fcc326fe2fb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceClient.java", + "sha256": "a903dc0b36ea385e4d2005f296101a1557b633f5144c3c432dfc393212ef2f48" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceGateway.java", + "sha256": "46990fa7957a346fbc5f9430343ec5e5b66d542ac4d9ba7ab67e9a3537176861" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceModels.java", + "sha256": "65b87f1dc3037dab5cda68dfd95eabccafc3465879cc2cf3baa3d33e86d5a89c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinanceService.java", + "sha256": "967784b2f64b34855df7fa7cd1e9cf04f3708f4c71bf8501218dad258a8249ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareHistory.java", + "sha256": "68df8a75f1b1af54971627b8e9d41d45c8bc14a913c1d3617c95135060ae26ef" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareHistoryMapper.java", + "sha256": "de6c3c96450204abf9752023e23476d257bb3ddfb3fbac7ebe1972fac373f53a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareMapper.java", + "sha256": "023c44cc9ddb7a580ce58dc46392b921ca31ef385cbb1b8518c1489c2e8826bd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareModels.java", + "sha256": "f038931a9dc1c8b090c2073d76633e5ab32381fe026cb436a00e2d3864b8e455" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java", + "sha256": "d3329ef3f498c4c4ffbca1dd22ab94636962a95fd45cbc84a04e91a74335ed30" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareSupplierClient.java", + "sha256": "c2c2ad4e0d0b274c443525b9c8e14e0352c6dfd6a0ad55d215aa1847c4c270d6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java", + "sha256": "ffd992efaa09fbcf516c008f6c76bbc8e832f5757a69cdf9664b4a537195ddbc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java", + "sha256": "247b03e17f522d52b7f107e831bb51097a5a031dfb3dce693fae68a1c0e73544" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java", + "sha256": "e26252bab60bee969d99adb159bd5403d8402a1a06ac0eca541e42c2b1c94e76" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java", + "sha256": "2e8adb7a11c1d3b49772b3e114a83c92b352b070bb9bcb839e04c1f16fae8278" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupTask.java", + "sha256": "63372bd1897ae823138fcdc9d590038df806d46b00ff8220d1e971bc4898ca61" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PlatformFleetPickupController.java", + "sha256": "4f7829fc6bca2b55ce4e72d429022897d59e435b28a166a89cdad55b268f470b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/TransferPickupProgressController.java", + "sha256": "6342e42f6aabd0f5eeb5a50af7ac76f1c6c5c805b988c260b6f09e6c60b56004" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateModels.java", + "sha256": "7dc35bf8a5fe9d281f6858380b5c113c44e652aaf1adf2a7f851d45d01360c25" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateRepository.java", + "sha256": "87f0de2970fdd835d63f15eb54707a474a8465d2ad9c13517d120be2e3b505d4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateService.java", + "sha256": "132b671e3a35c8784a0fd47970843f51b5761a3278d62177280c58fa83ac83ce" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectory.java", + "sha256": "aecf64b97c63452c1d4c70787525e770ee4c97d60463c143b6e5700f227ba0e1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteModels.java", + "sha256": "d86b6a1ce605aa832b7f300ae018b3531e8754d57adda77ac9c8892a3626cfbb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteRepository.java", + "sha256": "2291b452f77a0305927d43f422c52d039659ee422f0fca498de76b044607668f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteService.java", + "sha256": "4204c409793a2a85e5184066febc769940b68240d6933a27a17ddb4129e49c2d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/FleetQuoteSupplierClient.java", + "sha256": "ab6b67f0b9eac949beb95f1cb4a6f6606cb15cdee5da23ffc6d07482dd7b213a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteCandidateController.java", + "sha256": "a1ec9c1b817bde98d2b9b25a4340477a1507cc08a6303517ed22d96d67073f3e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/quote/PlatformFleetQuoteController.java", + "sha256": "ed19ccbc76c21a8ec909cea8364ebb5001d68f871cb6f62226032541a0cf5d13" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java", + "sha256": "865eb9a98acfd0d8170563932eefa5275d32fe9dc1774cb52f0d3cc390d78206" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsController.java", + "sha256": "fcd2b07a8283a480826da366c48f10101ca6f0c60f2cde67bdb6c9358e978254" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/PlatformTripOperationsRecordController.java", + "sha256": "6d084c4e98c09e21eec0046f726a02cb955c60ff982bf5f5952d5d838e61ff1a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripFinanceClient.java", + "sha256": "feda696f75a08f4b492e3f4bb281d67cb506852b97b0d6d8e74ed7e3ea84e727" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsCharts.java", + "sha256": "e787839bde9c819b0717cbbc3746732a3fbaf2447086d8fcce77b219c1bbccf3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsEvidenceGateway.java", + "sha256": "d03bde0c13fa08fa58a6fe8526c01b23bf8584202f99b8fc18b1192a7a5b9633" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceService.java", + "sha256": "f417143faaca39036b02eedb7a63c9ec63efe5d5a98dd5e61f431c135d97fdb3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsModels.java", + "sha256": "237edfff36be8efa3c40b30f8406a318dc30833fa0c2f86e93cc394d583eb233" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordModels.java", + "sha256": "add3aad0df886fafce85b1dea4ba7116bc9759723f8baa46e52c8c168b92259f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordRepository.java", + "sha256": "f6a990996f5a154f7be62510c4679fd35ca2ba4e8e6f2a03af77dc3dd2a15a76" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordService.java", + "sha256": "2a5a086fc7c1f06cb739f0501b607206208dfee9cda82b8cb0b9856385fefbd0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRepository.java", + "sha256": "73c7bdea6e2544d66b69e41b5d6ec8cf14ed87e8fafed923e4b44510bef3ab64" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/TripOperationsService.java", + "sha256": "f704b00c5c41c57afc4250e32f4e74570cf9afd93b0db63bc0ab26b179e4662b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingCargoRepository.java", + "sha256": "a6ed52c4aef9e60dc8cdbd0e18751d2f4fd9679a036085db07fc85cfdeb599f4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingEntity.java", + "sha256": "853d56f59c551685b7a8e87b81bb9023ec7451fb68fc8c5e165d8ae335c6726f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreight.java", + "sha256": "b113419c4c6504ebc231600d134834379671256db07842eaeb86739e1f5da1be" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingIncomingGateway.java", + "sha256": "10e2444415a530b19ec9d1404065c3ac3989a6606c442ca307b3ffe67b662a51" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingMapper.java", + "sha256": "5f07aa4cae7cc3575919748a1aeccdbe56c105035401fb3437583f1af0315024" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingModels.java", + "sha256": "11592fefbf03a70b47758ed526d1ff0afc002822e034a03c38d1861e95b106c1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContext.java", + "sha256": "08c78ba4d5f52b0a48f89b281c201abb54018f2aa5cb20a04384e2228d15f6b4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java", + "sha256": "1e443b73f1d99fb8c20066012e72cb34bf700aa46742d9245fef2a8edbcb36b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java", + "sha256": "4600df3bd15bdd95ac4d8de13a2f11116d1b934f114c33a310a95c293b47adbb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferCommand.java", + "sha256": "d47b4e362f21e040b4508bbef8ae3af58401952116488c5ac6cab88a8b33bc47" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferCommandMapper.java", + "sha256": "3fe423c87391ad4b9a276160d4d4fd68fd43396edf01d13cf4ae7e6f2cdc81f1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferEntity.java", + "sha256": "061e4c754397e18d6bc2ecb542d5d2b6d941b44751df2e3295c13e4a8f3704d4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferMapper.java", + "sha256": "3ca9093534c1e9a93e0834aac99638521d1f55592aa4190f0260b34d2f54efef" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java", + "sha256": "0397c3073b34fbb9e099eb64389d51782271c4bf64ed0328836dbb608e002c3e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java", + "sha256": "2af2b8a7e033606b2934085edabfebb2a8591196429454a614206c376b874cfb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCompletionModels.java", + "sha256": "ebbe1e37aeea99cdc10e49052f8dcdc5594a19757e9738efda6ab36769ac968b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchBindingGuard.java", + "sha256": "48e031ef4644da62dc319901c47255a97f9de15ad20d8125dc5530422309d211" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchGateway.java", + "sha256": "8c44f6659bf9f12c013e496b2f836e83d38ca95383b263897849f1c9a4f53871" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchPlanningGateway.java", + "sha256": "9d0cd66ec8b0c5fb43f6667490d126e6c1ae2e155e3ca9b80a388c2e09db4a06" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQuery.java", + "sha256": "a22b81d04aef7b80d3fe42b80bae07a67861483957b05e34e1a5e16059c3c532" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetExecutionModels.java", + "sha256": "ae5fa907917476829cb374b17995071594228fc3848671367c420521399c073f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgress.java", + "sha256": "cfecd23cf0c3d536eee127c98908f1d3e6df94308acbf932a51cfe4b962dcc24" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetResourceMapper.java", + "sha256": "766f95af512d7d114f2e040c30b15fbcf34c85e3acad034d7745e69fa11cea67" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java", + "sha256": "226e91676713bbc722419b28989819e8b499e57c3ab5c37c30d5f73ef0e85b04" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCompletionService.java", + "sha256": "49f37bbbb70782d9e556d893ee65f60524d83e1430bedbb44857be600250b445" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostBasis.java", + "sha256": "cfcfe9fce3e2675db54872add6fe6c45782ea6bde8f0db85a88a107d0b84eb39" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostModels.java", + "sha256": "f6a5bf87769bc59bdc3d3479c48035e612447cb730ea580d8543498697e822d6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostRepository.java", + "sha256": "62aa22b2ecf5ad53f6bda372f777fa5ecaa6087788ed2e7889d32213d940ef3e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCostService.java", + "sha256": "c3b433f38699e55483264bff85e53f3721ddffdb01198bfe54da35c66cf041fe" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatch.java", + "sha256": "9d254e5989f3d2c048fc9439ad46c2838af91bcdc08b19570e2613778443bcdf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripDispatchMapper.java", + "sha256": "8c15e1dd616195dfaeb46e6dca0db4ffc816303689061ff8f9045235a402f57e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripEntity.java", + "sha256": "55977f17b628e802193812784bc3d4a8531dc1908e0500afde745bd9d5139de6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java", + "sha256": "9a6556bd0851598c91edc521fe5ca8f0a70ab5573e86f8f8eaba7f2767088d83" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistory.java", + "sha256": "27a7baa92d43255df53946978620f8fc001398a8cd8adf65d9116f830e5066da" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripHistoryMapper.java", + "sha256": "898b566c1f43e4e5e7a248d5ea01852eae15c0d2731f8628faf1c60d928571a7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java", + "sha256": "9d45c29aec1bfb69353d817311757dfd990138e19af9a8f5cabee87a204ea768" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripModels.java", + "sha256": "d3419d0cb473b9cd688d8eab2bced47fcc4b073dfb23c30ac930948c09dbdaeb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripRepository.java", + "sha256": "7ef797b0dea638f55dffd19a0da8d3bdba6a21fed29f6b8f70963db81b6e73fc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripReview.java", + "sha256": "23759e54b6d03453b943aeaef170328afec264732913d6c27a074d052eccddf9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java", + "sha256": "9f964be042b463ed015c68897d86cc5895108a0d0aa81d29778761baf984263c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetVehicleAssignmentGuard.java", + "sha256": "462edcd4ff7d38d1738f0f65776b2f6d2598d97b254659cf2c7acf75261b1825" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetBookingController.java", + "sha256": "fc9d77ceac6ad1f0a7f529213cffbe153a80515d1341806dc0ad3c3ef6a189f6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java", + "sha256": "cc4d56567bf8498004c2fc2ce924b38e045d50c5f59d43355cbdc5a51c1946f6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripController.java", + "sha256": "d47759cb47b706aad9160726b8138b56a0cde5a26649c5ba71103b28923d47f4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetTripCostController.java", + "sha256": "0a1ff31047960a6310f63356839c6ff0d1faea392303228f5f3b7c07b3114662" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformVehicleFulfillmentController.java", + "sha256": "eae5604644a97f76e6d825542ca2d8816de5af95b5b11298615c0a190ef85c97" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java", + "sha256": "79ec8f7a818c412931e81f7e2b6a5299cb4c8bf38939ca5f645a5a5d43d70761" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentModels.java", + "sha256": "3eee7166812925d0f06534b510da1de6654ebda427a2c0c4082ec4e665480798" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentService.java", + "sha256": "87e261748992aa5db366018153d98cd9c968afe3ab990f45100fe59f5edb0c2c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkSupplierDirectory.java", + "sha256": "4527de17ba79f5badbd24de2c956755189d046ba4801893314d1ee309cfe2e8b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTransportProvider.java", + "sha256": "6879504718ee8cae72794218f29be1c51927f345422e90631a01bc23ad0d6c96" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripChanges.java", + "sha256": "56f983b80245086ac1aa3751350dc63f73cd68d1f97603c87a875187761c7212" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripMapper.java", + "sha256": "b96a8dd3994f0d4e751b99a2dc02b7dbaf4218756b4f5c3c69eeb963a8e6b4ac" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/AppointmentSourceCreateController.java", + "sha256": "4d01ce7d3bcd518468601672427eb5bb31e39c115c9063878dba14a66cca7b41" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/FbaAppointmentBatchController.java", + "sha256": "217c4e65a94e1a570022e8943b02527b55d2794c2c7b1a01f5685b2c4c1621cd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanController.java", + "sha256": "b4ac268411abb5d2b1ead8ae13676126105339b60fde09d5341050a0c65e25e7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/controller/MerchantInboundPlanController.java", + "sha256": "c70500bcdf1b8c2dfbe20ded2c82e3b882208da68c8411eee1d7243b7077931a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/BatchFbaAppointmentRequest.java", + "sha256": "bd4a179b15d0aabe1e58c347bb6b9c40083e96171bbc0aff21fcf26b2d012970" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/CancelFbaAppointmentRequest.java", + "sha256": "c8cbb38b6a23a051f06d8505f4391f90da9f605e1f7f9976e3cbaf2381678860" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ContainerInboundPlanPreviewResponse.java", + "sha256": "fbc055c438118ccdcf03178b85e7a864012ca57e63e5d22b394cd7853a8fec3a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ConvertContainerInboundPlanRequest.java", + "sha256": "00391b2ba693994012f4ece58dc775cf0b4f674c32c49f6133647b317f966e4e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentBatchContext.java", + "sha256": "7420e0590468152a2d3e35b7cbcac7f091ee584b0344b881b0baf59d54a712dc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentCancellationHistory.java", + "sha256": "2b28031929b82d36475c3c35edcb2b4430015ec4323a93a639009765fb424252" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentCancellationSummary.java", + "sha256": "1fcaea9dab4a3213eeee3ee8be6e3a439584547b45358b55be1de51526e99bc4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentDetails.java", + "sha256": "659139d92617cfdfabbf1d675362c258c207c6c2e1397a8347b8bdf95008ff5e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/FbaAppointmentResponse.java", + "sha256": "e3623a1175ca9981de68aefeb4af5d68671e20c7fa0318bff1fde260b77b42ba" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanListResponse.java", + "sha256": "2078f78b59533baa0721b3e8c6d76e45924c8a1c01e931bfafdfe598e1af023f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanRequests.java", + "sha256": "e655f5f44176044c7597efa1801d7cffe54695db5dcb940394459d947f9cd5b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanResponse.java", + "sha256": "8f0adbd1b415e446049696aabde60df1d82c64e73f01cb5ef37c6ad01fc47963" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/InboundPlanSummary.java", + "sha256": "598b876fbb761e85fafffff1fd6b7483ed0792bb9e7d89730418b6b989c431d1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/ManualInboundPlanRequest.java", + "sha256": "0b351e763409d476aedb7a54f9feafe9bb27c10f9106699ceaf9c62139521c2d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/RepartitionFbaAppointmentsRequest.java", + "sha256": "956a6838891fedce24c8b5aa0c6f526ff44ca4d47e36cd71e39b00693beb216f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/RepartitionFbaAppointmentsResponse.java", + "sha256": "737024f2b622a0c8ae5bb830affd0e8a50b79fff1773422f98a468089bfaa00d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SourceFbaAppointmentRequest.java", + "sha256": "9251a8c262b9e0f2bb911fc231d1884d752553e38fe518f37cea6d568d53bed2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/SupplementPalletRequest.java", + "sha256": "cde14478e4931087d261f039c588e7860af253aeb3a7b525a6c5b97e98b9f5b1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateFbaAppointmentRequest.java", + "sha256": "3df029e3ac3492355b08433ab0f18196ac313c4907c3cc4c3945d496dc6e10ef" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/dto/UpdateInboundPlanRequest.java", + "sha256": "46bf6c81da10b0e4fdba2120dc6c6b4b210c28863017e730dae105bf53ae0669" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/ContainerPlanConversionCommand.java", + "sha256": "90a6f91e7952ce216f7592cd3b82f8ca3aa9c2f9095029415c290bc6d7e99d5d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointment.java", + "sha256": "cd8be8fc28eb7c89fb799c3d36c4ddf73998de196ed1eac4837fe526bed55013" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundFbaAppointmentItem.java", + "sha256": "a2d832c43273124d17e6cbacc4782e2fe17a5db0bea9d39546db1ff715808666" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlan.java", + "sha256": "c859289b364919a673f686d3267270496c9ba57c4a61007e462eaad9a2aedfd3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanCarton.java", + "sha256": "0a642cc1a21b1eda1db410f9336cf129d0439afae95cb0b00bb12cce895e6c5d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanLine.java", + "sha256": "eb7d90e91af8ab200eaa87b461e13c11b60bbf1648476ffb40fd90f77edddb6d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPallet.java", + "sha256": "cbc6bec6eab93507e2b5b447cfc96da3536f78ee131be175b210c858f8a1460d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/entity/InboundPlanPalletAllocation.java", + "sha256": "e9077c672e5725bc4f098718e2578f6480cc7fdfa7aa81210c8ddfe43371bc4b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/ContainerPlanConversionCommandMapper.java", + "sha256": "44b7010914d30599593df52e872872402af00b79a4fbc98f7e3bcf4cbb5918ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/FbaAppointmentCommandMapper.java", + "sha256": "764a8cce0f70767641b484444204e646ef2937035007010a30eaa43be0323c02" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentItemMapper.java", + "sha256": "2794ec47afe278d2cd3672e4385d5cf44d0e43c26ed78c8cb24040877c85dfe8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentMapper.java", + "sha256": "dc7a4acbbdbf6ccfeaa8e169f95cd39977d8d03bbf97c42c680c1fe06c2ca951" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundFbaAppointmentQueryMapper.java", + "sha256": "018193279a361234401dee825d255e2c72e557b08a0fd4edb5a1c58cdf38e280" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanCartonMapper.java", + "sha256": "1c8ec6ed09fffee915c29ece869789934e0e70a04758dfd8365d4bbfd6410b50" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanLineMapper.java", + "sha256": "d9dbcf0af9b612f210a55074f63c115aa95409806d5f8e1da3a06ada797e9665" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanMapper.java", + "sha256": "f16965bf5869d92d1adec8944c7d61f23106b72e891553f13fe87fe1e4ff2c3b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapper.java", + "sha256": "09590ef87b8bb06b842102d7d7326099451745be433498738647e49e99a72833" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletAllocationMapper.java", + "sha256": "4d6fb7ce62fd534942ea1fff2ae8076483177dabec05260861dc487fb787d4b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanPalletMapper.java", + "sha256": "c714c3e3d01d8425bc451c93cd322f66d73d8f8d08f5b124e4147228c23e5b41" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java", + "sha256": "f2d7203ec27846e7549377d02f4c308b2c6960a272002e62dbbb1216a1c0d194" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentLineSnapshot.java", + "sha256": "ccb7132cb802945d096833448a724fa9d16fd4fc7024a499db0188022164806f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/FbaAppointmentStatus.java", + "sha256": "3b04c03e140b38fab7886901e969317d4155f8ab4ff3223ed0c3da74394e33f0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/model/SourceOrderSnapshot.java", + "sha256": "20c6a7f2bf22fc5bf9dcd2e6b4237c093c8e3349cc97a96eaec9f29e75ee60c2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerController.java", + "sha256": "f4c42b1a2aba29c40ad237870226aec3fd4c2afe7b05737ed2b61228f98c4920" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaRequests.java", + "sha256": "143e9bf9afd4908063e0672978a8983f7c4e692908cfb12713941bef756c608d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/PdaResponses.java", + "sha256": "f640eb8234485714e5ae92f7ae993a92f576a4787aff6443fe22baea77c1135c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerRequests.java", + "sha256": "73c29a5064e4c17e7b7748a21e0c4f527e47cfc7c4c4b6233f683ef996b129ad" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingContainerResponses.java", + "sha256": "0ef745d01e8e11649a0273e8c514e02b41c9b0357e742809ae82f04016ee3b01" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/dto/ReceivingPhotoRole.java", + "sha256": "55b6730b25cdcd27cea8c9d26319edc8b783eee777cea93f28f74c5ae4faf9ad" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ContainerLoadingEvidence.java", + "sha256": "927a1c1fc6e54a42943bf9f4406a6d54f0b028fe04ec55fe03ac5da7a9e547e3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPalletMerge.java", + "sha256": "18a3edb66d9baba7e22717f9c5fdbefe7ca2694afe1ef958b7fc96d8fb9ec33e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaOperation.java", + "sha256": "a02a93a86f6adb127c309dc54f767f545ab5d445a2fcd50c77b2ca16840ef634" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundPdaTask.java", + "sha256": "f9ffb948b55022b15eddd8dcdcb239be8d6e4d14fee2210a815d5d824c24abb5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/InboundStockReceipt.java", + "sha256": "5b886b30fe182bb404d954c899eae32920ad8be404a2fcafcd349eb68c0b9ab4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerOperation.java", + "sha256": "c6494bfd8070d872e7286070cba5c7038d9cbd117cf2d70cd361280a0d3d6a5c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingContainerSession.java", + "sha256": "64e63b51e5d37c9053379462ec7f33228bd786160c889c574d21e6fe16385314" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingFbaPalletCount.java", + "sha256": "3d83daaa189de8ecb32768075dc6f00612d66240dc15898d45a2ac5f45cab4f0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/entity/ReceivingSkuReceipt.java", + "sha256": "a2cf6b774fa4ccd6941db2bd9e0f164bc641fcd5b37c41346faeb056b4f9e73c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/DomesticLoadingEvidenceGateway.java", + "sha256": "3b66704f69793e9d9415dc1a79c0177ab7826819bcf4a86bab97342e1e6c8f7c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGateway.java", + "sha256": "87249f92f261f89f121e41781ceb57212430273412c675a523e97a7e9ec255e0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java", + "sha256": "deac01e9223bf7ccafc6fbaaad5c83fb0632381279aa149f7e51d4321962b54a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java", + "sha256": "e4d041456ffee9ca35f21be80365bc8cf1378cc61202e302575d0f2c734c0a9e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/UnloadPhotoFileGateway.java", + "sha256": "f96cee6676e149a364be27adb28f04dbab2ca7e70b889c394c68edbd47791b3a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/WarehouseResourceGateway.java", + "sha256": "80e6690fd2131e6d7e5c5a77c0265cc0bcae7f7544700bf4f6066771d91dec14" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ContainerLoadingEvidenceMapper.java", + "sha256": "c9a4dd42329bdea7b72d63b6df030362753b4f053f92489cdd530b04ff08180d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPalletMergeMapper.java", + "sha256": "5637687f91654042f9327b536f9e1cbda32e892c0d15210a0eac91a05d30ee04" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaOperationMapper.java", + "sha256": "d50e1af906a42e27c4b26eaaac59411def5b28e5adc2be37f8abaf2f2749a307" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundPdaTaskMapper.java", + "sha256": "bbb7a5bcc5589da29a54f97d7d825635c26d1b1ae38c6af1f57be73e09333857" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/InboundStockReceiptMapper.java", + "sha256": "1f7c4b2c09442e06bbf7ac0426a4e36b0d6f79c474e6a233015278496f6e8c90" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingCandidateMapper.java", + "sha256": "3dbd834994647737c6ee5783e4df89f23e32518560c11ed64c18a9cdde5db6bd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerOperationMapper.java", + "sha256": "45e66233ff88c1dc30af7dd96f524bf9fa0750e36dd042e3a61c7077f0aa1b5e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingContainerSessionMapper.java", + "sha256": "81076df11ba7b3bbfcb2c8424b87b922063ba03cbca73dc354c23a2aceb8c47c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingFbaPalletCountMapper.java", + "sha256": "6dd0c8cc6b33db56ded3629660cde13d1543a9b63a90ec5ba5b72f65742203ad" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/mapper/ReceivingSkuReceiptMapper.java", + "sha256": "49fa2a66889cf5d675c6a3e39eae5b35d9c407fabea5709b4586d5916a8b8e22" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java", + "sha256": "cadf6fc1bdd870df71a5bf75037af766adfd1c323512a085e49d89fa29cd4f79" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/ReceivingCandidateRepository.java", + "sha256": "d537f07837301ce87494e6be0a3e84cdd20c101cc63cf5d4a764ac2404db38a0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java", + "sha256": "173fc811bb20ba5fb5c88b432b2355b598acea2cc130cbd10cc37d353248dff6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptService.java", + "sha256": "412f4ff43ecbc48cbf8ba8385ddb26ede3d2d87aabe9456b4034b85ad2974ef9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelay.java", + "sha256": "f26964d6c2d9078527d184d180bed7ee9c14aaef0c76b5bb3665c169130111b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxService.java", + "sha256": "2216798b3dfa193925274047e98ee07b3a29cfc87cb857496f729e5a4d16df27" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java", + "sha256": "6b6911713bba9b2861381aa4943e19abfd765aef60a6f22198edb8a544a390f7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentExecutionPort.java", + "sha256": "64b0dab0e5a8ad74dc2b1412cd2d628b9f7f5850da72c121bb265e654b288efd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentTransportPort.java", + "sha256": "c9cba7c2688fc0621d127b83ba0dca1693201bdff711f962e2eddc34a30b8083" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaAppointmentWorkPort.java", + "sha256": "f6c6f4809d046a71a0a4d7e94f6a39e8db6d6908636a93eaa98705f13561b246" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingContracts.java", + "sha256": "5ae6b23ef2f77ef355c18b03ec0b8bd9db1d7b4b94d6c091fd288dfdd938faea" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingController.java", + "sha256": "1ad846a64ac1b02387f98386f797b5bec53f48b890ddb89e12c4a66138ccea07" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingMapper.java", + "sha256": "e5b8780746d2e24a44acba52224923145372bbeeb133ddf64084b31300f5e19f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingService.java", + "sha256": "17cba0f20cb3ff2c13c8c6ad9cd38843e05aab8954f885a1e08685f36b541310" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingStore.java", + "sha256": "df9aad5eda4e3d1395d25975740beca093f12c9166c95f44350c2f6f56d7fd0c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java", + "sha256": "ac2904d86f75fb202b50defb2a01e859de01044165c8df2ec6cf209890bbf84a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostJob.java", + "sha256": "1766143c195ac27de31f5824ff8e9e087bf48fce9de5da2027c6cce36028e042" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostService.java", + "sha256": "4224aff5900fe658b513adb699884d08fbfd593d16effd59c4066a8d363285e0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundAppointmentSourceLinker.java", + "sha256": "b66924a3745254f45e8a96027fb93bddc0f04b45db9af2a6e0fe0443a46ee822" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java", + "sha256": "e917cced0f40b7475bb3310754a4b3dc37bc26117f6c99f697d6db3b5941ddb0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java", + "sha256": "2da770a8010fe2f203071226cfc1d692dcc62a114fbe6b1c3cebfa6c65beb96c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/PalletAllocator.java", + "sha256": "af2ef2c6d908f06b12166950473d2db3bfe1c3da9cf7ed702f9f132db014c52b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkAppointmentChanges.java", + "sha256": "9b52b2ec764877a9d09e1e7cd0a2dd7b32a75905914386cab5472dcd6545eb28" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryMapper.java", + "sha256": "b826283da905cd44a4dcfe5a196d4958222f2f15745bf8d1bbf97e32b35eb91c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProvider.java", + "sha256": "e53737075537cff112a83d1197cfea08103c0ac6fb7fab98b7ad33b7039130dd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java", + "sha256": "a9b6c9004960f9ad0b763b156a68dc4ca59f9cbdc2646fe612a6d7a9f78be3a1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidateController.java", + "sha256": "55adcb1511b405063493947966588174ce825a982ddccb4da76666501626f48d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java", + "sha256": "b43ced699717dfd8eafb429e67b95109d0b5189281ec37902cc70c318f1e84bc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java", + "sha256": "1d0dc2a6d27eead02730fbe90263dc78435dfc258f1760e65cd5e6263375d015" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkPhysicalSources.java", + "sha256": "6f9969cf9aaf1eefc94473fff47800332de411e960d634416f7595d9c5cc1cf8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuard.java", + "sha256": "fca0647fc9cee82cbd844a554d5cf08e6e90ad460e13ec5128b26eb23d81ea7b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingMapper.java", + "sha256": "e9a8dc5944eb29a47943b497fc56429eb6a2616a937b1304cc49bcae78c8f0b2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProvider.java", + "sha256": "b34e9babd589aeec7121adc85236ec7513383a3cec5fdb9aab0302e310989270" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProvider.java", + "sha256": "f8c150a375df1525bc2073bf1daaadcd03ea630888fab9eb285169e314dc1cb4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceAvailability.java", + "sha256": "b5bd124cb7cc1bff75d9503271e7516a574be11eec534b0ede11cadff128706b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkSourceMapper.java", + "sha256": "583e7c051f38934391f6eaae9af76ef3fde27310296129242cf28fc063ae3722" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java", + "sha256": "eb7b3069c8557043d573cfaa96eec068047bda93a1701f9e4f0886ab7372fd43" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapter.java", + "sha256": "4124e454ea63d6803cf1d82f5376a009806088ce8213f2d736759edf369f2c9c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentWorkAdapter.java", + "sha256": "4a23c3b864827b2123ef859393511da8868bef7b5aff6c440185b6305d8a3d3d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandController.java", + "sha256": "893bc9f49f16735353f3c0e779cc139d5adfe3db942359ad0e20cba154f930f4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandRequests.java", + "sha256": "c4a3ae0da57f19d80c9de45b8c19444201523ed55be73a6b2d32a21ec4b7dfb1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/InventoryCommandResponse.java", + "sha256": "7d0344c86b1d1ae907ab0fed4ceefa0dad9b95ff92549d233d668bbce32f4545" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreRequest.java", + "sha256": "6ae23921aa63589b171017a339382c06bc8b33563e90084c9d77258e911eceeb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ReturnedInventoryRestoreResponse.java", + "sha256": "29dd824a51d086bffebc01d8cbd30d9baeef8c0416cc6b82337bf4b29a349a56" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/dto/ShipmentSource.java", + "sha256": "eb71973c30f831c3a0a34c610bfeddd3a506535129c0c9db6f7b1899e30a27a1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java", + "sha256": "416651351598dee436454015946bcbf11cb06060dd377746bfc4ca979fab383a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java", + "sha256": "2e76ed8134b593e8b54d37d39061e2f97eef8c8327901b592d594387682b6550" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryBalance.java", + "sha256": "71f2dd6136fd6e0522f955fd01eed3a6dcdca7906b1157f696eaa36c976ad656" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryLedger.java", + "sha256": "385aab6bb746459727e7b996ea8827e74a69a35a7c4b1040faa194f2b8efa80f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryOperation.java", + "sha256": "676f68293c0dc16fdc69a10961cee0ec54d786cc0f1c340df53578f9cce3f1e5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/InventoryPosition.java", + "sha256": "c9a3a5d56ad8f13036f99c58f1b14749ed66166d2c53162a94848a1f4c1aad04" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/entity/ReturnStockReceipt.java", + "sha256": "85b02162cb2f4093714ba1595f10427d98e4656065dc787f6dab4a35e07f97d5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryBalanceMapper.java", + "sha256": "d4cc6843e326a687f7e455428213c324b7b410d0e10f28508eb9d4cd691554dc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryLedgerMapper.java", + "sha256": "38b554560444511fefee2efadf21e80b61711cf131aa1f680f53d76a94b60ec2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryOperationMapper.java", + "sha256": "f87a322f6e219173fec4a1a526147bf2472acd20f6f5570d5beba6b77709fa4e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/InventoryPositionMapper.java", + "sha256": "b012c4cdf8cc66ffe1c9af6f00b95baf7b9f69519d0dc3ef5fa08af2d3d5a7ac" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/ReturnStockReceiptMapper.java", + "sha256": "bbe6afd1d953a0bded67937ea7e8169be97e7ab6039a02578d28be44f35c94a2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/model/InventorySummary.java", + "sha256": "cac5d28bde464b79483484dd0b3d42eb38148eec38eeab034ac9885d7963cdd0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipController.java", + "sha256": "8a2fd7a58793d1938cee91d9d1a26a6ea27d900e0b2bbe15372419fc05cb0614" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java", + "sha256": "eeffa9ed5c730b4cb495c239ba8a85df7400526798835229751e177c7f19f3fe" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InboundCodeRelationshipResponse.java", + "sha256": "8318b65236c1a91adc855acb4605e1d806cad3384eeb4829238cdb451011c1ce" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryBalanceView.java", + "sha256": "22974a7a118097f070403883c1cc26d0355980cb7a2c1ad388a29f86bd745308" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryDashboardResponse.java", + "sha256": "bb7f5e28b6774e25b2bf93eb585483f371cf9f2bc13e82eee40d1568e9966b7e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryLedgerView.java", + "sha256": "fc198bb502e9f7242c07f03a0441da0a9252dba773b304f8bd0fdd7c11a3d13b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPositionView.java", + "sha256": "66804ad1d236d328fc67be517a9c992a9372e289a963ccd9e72f14cf0547edea" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryQueryRequests.java", + "sha256": "256999853fa3d482d2fd6f55cc74bf19591f6e48badebe5708f5c35b4d3ce966" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapper.java", + "sha256": "e77a4a9073153d7e0232b969f09bf4e3f7777ca34c974e21d8e9a9601ffcb874" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java", + "sha256": "166d7c241e0bbfa8a7a087d96aa5e5006f783079c512de2ed0f5ff9dfa3030eb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InboundCodeRelationshipRows.java", + "sha256": "fe7fd1087093292124c4fd9e65a6e3750e6f72aa46a21eb75d2213b874ef812c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/model/InventoryAggregateProjection.java", + "sha256": "49c0144a427cd27301358acc8cec042df36eb98361507ec0e17d4f1267fab3d2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipService.java", + "sha256": "59117598952d318c4973a4321ae3d7ba05e6dadf780c85882683583883c4dd68" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryService.java", + "sha256": "70c56867c8353e4bc6b18c8e52c30a5dcebd6feff079c44b79b21876f5613665" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/MerchantLocalLooseboardController.java", + "sha256": "ba15d300324b4cfc7f7fd962c755d8de96a7e9f53a0ee4ee5e7d497a107d3f0f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardController.java", + "sha256": "ac0e542abe92fb6362c3bd9bce8aafb3d93ee319e9b2e67477295b5b332fd66f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardRequests.java", + "sha256": "f9547cd64776394c290d3be315e2a574f0b0b25fd8f5876430e07adec48c4ed3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/dto/LocalLooseboardResponses.java", + "sha256": "120c868f3e2a9775c25aae465402dd187c68140f2a4d6cc97707f6ee8fa331af" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardHistory.java", + "sha256": "368065dd5cfa0b8a799e1f896a428e3b49781bce74131b4adec9b774a0b12ba8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOperation.java", + "sha256": "2e7006638d2d70dac3cc5a89d7222357e5f90c89b181c14841e98ee76576e714" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/entity/LocalLooseboardOrder.java", + "sha256": "578abec91866a211db7c57da6ca45de42a36d87d0d2f2c87e6f2ddfad36cae1a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardHistoryMapper.java", + "sha256": "95b92fd09b7b6b241cf6b0d1b8bf6d7d52d12042d1bfbc7b9cc942a56d6a7344" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOperationMapper.java", + "sha256": "cb0f87c432ba27e8a16fa751ec036954b960cec7187d9c21001590e80c91c429" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/mapper/LocalLooseboardOrderMapper.java", + "sha256": "017929ecb9d5e784780e1c487f45c18097d78bbb02fcd097ff65e99b2bf6fb27" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/repository/LocalLooseboardRepository.java", + "sha256": "031505d1d623b391f481c88fc4f850a1bcacd2ab713eebf5c91ec64bdb6e17bf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java", + "sha256": "3d131c3d4aec7f0a3de8e3c6e23021a73437df9f4e19ddf2be8ffcfb4be3a9af" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ContainerArrivalTimeOperationMapper.java", + "sha256": "74a66ae0d0fa7343b62d0b8aab9b583387ad78d56b86731f51d29253b0d1ca42" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java", + "sha256": "6de2b412172d3807f9c38fb366e9ffce698352507edaf31a5e05e288914bdf28" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventInboxMapper.java", + "sha256": "d1964a888d851c3fc418fada235dc4248a77124fffdfffcabc5db0d6e57be3e5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegEventOutboxMapper.java", + "sha256": "96f0211e076a207a965dee3992e09119d7dc096e0a2d41f8a98a6e9fbe88b291" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegInboundOrderMapper.java", + "sha256": "70d2586d1ae6f118defbbeda2ebae61b410a9ef948be2a8d4bb9ae311f10fce4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderCommandRecordMapper.java", + "sha256": "a3e21612a8ae3f304774fef972cb489cc7f254774a2b2067bd54966699637c9e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderDetailMapper.java", + "sha256": "f53038ebb6041b23a1359af9a7233bef5f18816552607419843b4750b2880e35" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderHoldHistoryMapper.java", + "sha256": "78181e99d939193cc07c16b3cd87a19972d9e83d87e9b37731bd461f1c554f40" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeHistoryMapper.java", + "sha256": "66ba3535ab0fd9ab7ca477a30f70520a7617e7c8f77c1a6ed1d2aaed74561193" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderManualNodeMapper.java", + "sha256": "4a7dbb82526cd9356d3955769faaaf35ed63afc92add33afe1f76003113ad3c3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java", + "sha256": "3082900f06736ce539fecb5f768acadbf1c84f928e9dd9c18ebfa152ed438f4a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java", + "sha256": "b0d9f618ad4f5efd209366af69a546aaeef4740d3360e1d3744e464abca05316" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderOperationLogMapper.java", + "sha256": "d3ebf9683651246d2624cbfcf72eb9b647c6e794f67a52473f2095962df052c0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderStatusHistoryMapper.java", + "sha256": "3f8ea9311185cf3c6109eb89327d5d7498b98dd1a09f54084b95ce7e1839b253" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegSourcePalletMapper.java", + "sha256": "291f8ae1a8db14a5cf95e9fb564fcbf491cc6e3780587b76b612f6403947b94d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/ReceivingWarehouseOperationMapper.java", + "sha256": "c909eef0d0524ab15c17ab476f9f6f34894c4451c1f13aaeb44dff15d583a2e7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/mapper/package-info.java", + "sha256": "cc750a43b10402ba8815c8f8edc8a851186aede11f830de46158bcdc345f9a4c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateController.java", + "sha256": "b2ca6ae4f17d9045ace674a4146a14ce42e3101713034e2b64ae137d6d61da20" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/dto/MonthlyExchangeRateDtos.java", + "sha256": "e966f4906fb7061aa5e552c92c45beb98cf3655f23331969e45db308a1c4d721" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRate.java", + "sha256": "ed037a07d4128e6fcd8d3a5428637824072792c9b7e4d8bace4b12439901769b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/entity/MonthlyExchangeRateHistory.java", + "sha256": "aa23afcf08534d3d715e9dbe29fe4f1efb6a7a76f0d3df9550a0fa74b915fda7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateHistoryMapper.java", + "sha256": "c481f25b0015154321854ccf63a3a236f6db3242bfc07a58e2be38f2f9300882" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/mapper/MonthlyExchangeRateMapper.java", + "sha256": "dbf78134d4dba54c57cb0b92511d615cb0cac182600aab8419a0fb7a268bf911" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateService.java", + "sha256": "f00d03932fad1b15e7216aa2dafe89739e80d2eac78e87bb61394412fcdee22f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java", + "sha256": "29088e3a109618ae5535ae00ea9cfccdac8ba1ccfd0a1a94f4fc35028716dc21" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java", + "sha256": "090f616e907b4fe2c7885bf7783254c7c24c2a451fffe1451019d3b38c114d28" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteRequests.java", + "sha256": "c872a860bf048e48400d56d12cc8db9a8b9b92ee87b3a345dcb531013a8d2bca" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/dto/OverseasQuoteResponses.java", + "sha256": "28d1ce9963c5ba7a409a704336e3ef8c2b75393c189333fcc34d453fb001a2a2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuote.java", + "sha256": "dcb57f60e78c3addc98426aa22f314ff607bbbb9d65b404010026ff62b04d903" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteFee.java", + "sha256": "b168f13a81b0340e33dba0574819e171c184b95c116db739a986349cbbfff972" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/entity/OverseasQuoteTransitPrice.java", + "sha256": "3e95a1ae3efd68cd6c7fe19053e3ee6a8e46d8d18084653acf5f7ed1a3249ee3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteFeeMapper.java", + "sha256": "8110348ca353e56592a3cfc6da46d43b9b3ebac9ea982680bcfbff6bf251ef68" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteMapper.java", + "sha256": "8e56299eb084086edabea421af0b103b032c06fa0f587f61a1432722efc1c098" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/mapper/OverseasQuoteTransitPriceMapper.java", + "sha256": "1e81824ba427bb25679b6c0b26beb00b13ce315b2879f3d9a36d4cddc84550ac" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java", + "sha256": "3bd625ec590df4e264cb9a309251a17cdfdfeabc6bb14e3e54280d8d7cec2163" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java", + "sha256": "fec5570b9193978676f5abd3358a2144577c15d10c657f9a2b991b2c9a548775" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java", + "sha256": "0159a41bda38dbaeeb291e81c702345833842d06d0f5a44af537d5724586ecb0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java", + "sha256": "4d79803945b546a7aeaeb872d265bf3b9a8be732657b174a9e25116e8c0fd826" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java", + "sha256": "5805ba6d8ad2ada9083d7d80dd33e70e877a8cf8278dd5d92584a9e4ddbd68c9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", + "sha256": "f5396db2b0a918401c7de012749ce35495ef541a2d1e6280b63cdb71a4dd85e6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", + "sha256": "ebc75ca8cbd39b3ba8f3f8532c6320388b2f9001d66cc7a6b904be070827a501" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", + "sha256": "8060af5d43ad4563a53209ebf16b952a546908b966c704439d40a703ce536591" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleAssignmentGuard.java", + "sha256": "feaf8fac7c3c5c6a318dfd6f65d52d536330a33e1c489ba72104c168c7361fc4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java", + "sha256": "67635d1383e764c70efdb9460309fb817ae1423aff690786680e9862cde14e96" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderStatus.java", + "sha256": "6530e9f55da73c1bd18a6766454f7217a21a4c3beb60beff80eeb5c731d7c30b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/model/LastlegOrderType.java", + "sha256": "b942164637177a07b94b13f4ce7c8c2cf4a19580d3ee49e90fe7d31d755fd90b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationController.java", + "sha256": "b50e223cbbbb524cbba22af810d9623dd23f98c10f6d5edcdd594ad75f196cc9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationRequests.java", + "sha256": "230fbf5ae3958a3cfe66d060b05f7508f26201cb2524271ddf3bb5b795f693f8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/dto/PalletOperationResponses.java", + "sha256": "c87091ad5445d42ac3fdb243d1bec4f75ed2c19447054b9f46b62c980b3e7d46" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/entity/PdaPalletOperation.java", + "sha256": "8b6e5c13814fe32761a567a1fcdcfda41966bde907a264bb428f155d4f664cb5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java", + "sha256": "dc232ebe3685907e0abce0df5f8be6a304d6fade599f40b2eedc4e478171251e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/PalletPhysicalGateway.java", + "sha256": "016b92eabf4582431ec6e13a84949d1a936d0641d8e6105c455cca6e59ce0463" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java", + "sha256": "aaaaf33b9ae9f5fd40739b151b3a0df61db398ccb0c5b3cf0b269ad364105eb7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/model/PalletInventoryRow.java", + "sha256": "12e0b84353a566672f9bf459d1d6b5e1f4fecd5c9a97be9c1d38dd788a68130f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java", + "sha256": "78244786e5038f3ce168c8c20131c66cfa0071e3ae9698f42b2f06c60436e559" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PdaInventoryMoveController.java", + "sha256": "7731304e353e05336693e6513a12cd8df28aa87af9cfa249634c5ee210c05f64" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/controller/PlatformInventoryMoveController.java", + "sha256": "222d5062f2786518250ddc562574f270ce08f9ac73ae9690704ddd947d231d1a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveRequest.java", + "sha256": "f575ba354bff1554897fc1bf6a729226e1400cfd92169527ea36490c4609202e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PdaInventoryMoveResponse.java", + "sha256": "7a7e8a396fbcf1ff5ec90ae8b3a40d427c1027b979108ed3a6d57e96f06c36f4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/dto/PlatformInventoryMoveRequest.java", + "sha256": "11895653cf2e3b143302f5cc6eca7138929718e1b274645a66b731355760de68" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/entity/PdaInventoryMove.java", + "sha256": "9c1c0735bd732e96ca47996b44b9664c1935adf8cbf37f341c7d7caf1c0e3229" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/mapper/PdaInventoryMoveMapper.java", + "sha256": "1ea8d63a9fbf200df17a6559ca08aa30de2a9c5cebd2963d8415b0072f96de9d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveService.java", + "sha256": "55483b3de64a31c1ac8f00b08f6870fe1d96eca8cf729d780c0484cd8da5586f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/security/PdaWarehouseIdentity.java", + "sha256": "1a4d32bad10f2910b4a35acbebdefdb8ccdb85868143412adea0e7b9c5cf6843" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/controller/PdaStocktakeController.java", + "sha256": "3dac77ec8367ca7f10cb6f45c73c8e168bfde094a9dc72e0c7652a289690133f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeRequests.java", + "sha256": "0294d1bf7be45d2319c13cb60d599a5db12d97455de980b44ea522ae665479b8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/dto/PdaStocktakeResponses.java", + "sha256": "0e4cf069743a964de1c3f8d16a09dfb2ad26d2fccf5bf5f73b0e3f10e4772adb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeAction.java", + "sha256": "8e47eeef681e10481fcf64f5fead95468054ede0f9863902b87feabfec393347" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeItem.java", + "sha256": "20adf8ca6f7ad9c35a03045ac4bc0805143a40468bb144f62cfcf733925a5c18" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/entity/PdaStocktakeTask.java", + "sha256": "00eb94a4675d8f4d476c3075245b80025a5524e7f20e2637d72e69ad24f0f070" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeActionMapper.java", + "sha256": "00e189338f5d331e76c015f38cfd3b4271192a102abc935f60088dc234ec0554" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeInventoryMapper.java", + "sha256": "3d94d49414da41d9724480abaf33cdf914e41a39b3fb8642c1372a197978a676" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeItemMapper.java", + "sha256": "a091e8990f06b899ab4168dbceef42462fa525bb829cf90f618b8d32e003b024" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/mapper/PdaStocktakeTaskMapper.java", + "sha256": "59f8f33dee3898203037017fb03cd46cfdf5afda1fc773434c9743457798f5da" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/model/StocktakeCandidate.java", + "sha256": "a902805098b8d6268368c7664d14e84e8b034b294a2368c4e53554634d92025e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeService.java", + "sha256": "4730ef792334d5651a90f7df84196365b902a4411f63ece55f1c24fc77efe150" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseController.java", + "sha256": "915407cff964e479704324fc28c9f4198265b3900da5e4cb39653a92205d6cef" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseRequests.java", + "sha256": "ee2a1445f71f05834c0ba99d4f6f3d0c256b94d12427606345aa7f66d8c69048" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/dto/PdaWarehouseResponses.java", + "sha256": "23efee989c48f74786f34996f85f4d696760467082e80fec6166406a7d34d45e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/entity/PdaOperationHistory.java", + "sha256": "c4028b332e9d40b69a0ba5282b65982677e9630e0d2b8d1bffaecd26f9566763" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/mapper/PdaWarehouseMapper.java", + "sha256": "b06df571b687a87f63612a66752b96149b1dcd4b02e988c75bb5c48223655659" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/model/PdaInventoryProjection.java", + "sha256": "c9fd3f9fd665d5c00951a1a8d1b9210605a5dcc89cab3230fdf65ff6f1641e89" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseService.java", + "sha256": "5e4000fdc4f9d752caa2bc022ead0587f5345dda9c76712a1c4a482eb6fb303b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/MerchantOverseasProcurementController.java", + "sha256": "8e67f2b216bbba6a38466b34d7fc4d78474a04c4b870ca604590fdd67a77d1cc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/PlatformOverseasProcurementController.java", + "sha256": "3d60033e19158698e556593d860818dc296aa1ec3bf5bba8ebfd8f4ed3f56c08" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementChargeController.java", + "sha256": "98387d77b3305b2d604eb7ba39488d6f1d2aa46132eb79a9800f3e2f2d40b5c6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementControlController.java", + "sha256": "bdda3e6224189d9be1ad1617024570b65b9445e63bd52b368f0c88925ccd5a06" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementFileController.java", + "sha256": "23ea5563d7cf082f0f17f3abeff5558850b3ecb154a6cfcf3b8ccd55d27a18dc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleController.java", + "sha256": "d3553b607ba8e7588706983c0153fe3967a916990b95571f4b9ca35cd6e7f0ad" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementPhysicalController.java", + "sha256": "6b5a5be2bcfeb459ef00ae0dfa75e76a94b2a93a1e392dd4cf1db07fcc07d707" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/controller/ProcurementTransportController.java", + "sha256": "f084aa1b62c1dc674d15de26daeb77960b3a203db45cb9db9138bba328d6ee00" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java", + "sha256": "3be22efed048aa6b82e4edd02f13704bb452ae4ecb40dd2ae36c3e76773c41a2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementResponses.java", + "sha256": "77561a9ad52d1347f7df1787058ccc03f3acdea9025601be6c8a6f9107157310" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementChargeRequests.java", + "sha256": "97e250477e059a3ba3973081a78c1c22ddfdef11fb1dccdfd11f32f0e2924025" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementControlRequests.java", + "sha256": "abf127b0f59339662fdd7b8e58b56c06fa96133a0ca6bf2d1335aba9fbf715ad" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementLifecycleRequests.java", + "sha256": "4681b6a6d6fdff3f9d8ade8a6b0c2bc45a01ad8ef6863d15e7322590015b8ba3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementPhysicalRequests.java", + "sha256": "ec684458d43d57ca43f70a89f2536415f7e4d62eb147775e25786aff38721b7f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/ProcurementTransportRequests.java", + "sha256": "9c5ea51d4c6dd9f0de4b8123b3e75a939e265b18d352b63bb46d298ad3c9ce5d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java", + "sha256": "152df774d69ee2ddcf02518f16eabc1caf67898127ed095faf5e97fd38e226bf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFile.java", + "sha256": "1d5db2df6f8cdc17bcc8bb57cc3d4c48934e68cd3e5e3368e9b0472ff148008d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementOrder.java", + "sha256": "1a5eade07725efa197a5cba362b5eaaf4985ebb0a24bab5917930c9c2bc1cf3c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementStatusHistory.java", + "sha256": "e250681c437d6b8a58c47b066a3ceaafcec0a8368245dfed7b168e0cd3aa97bc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/HttpProcurementFileGateway.java", + "sha256": "d98adeb4ed486ce87fc3a37259a4fb22b4f47feac3bc56f215aca87ba834794d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileCategory.java", + "sha256": "1210b7a9cf84a8af973a09955c71103d35d4f73127b9cfdc86f9d9d2e0f87cb9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/gateway/ProcurementFileGateway.java", + "sha256": "3a9b1eb5bb6e71c0f4f1fa0c4c87d4294e167bfa0a9b1adfc15a1f28fbac0c01" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java", + "sha256": "a5295653be5f5f17f8df6e096f93400424f8d0da109673eedaaf733ff4683cd3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFileMapper.java", + "sha256": "17b4970557432e95055f08ab6aee86e6cafbfc8c7babceba02bd7c81a43a55e9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementOrderMapper.java", + "sha256": "5cd7829d9ee0ba8f2f58709fa943a259a5085e454e9d0aa729c963de4a055900" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementStatusHistoryMapper.java", + "sha256": "36a7398b56670b926d2806af92d3e8caa669a1c579f63522dadb297b92092923" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java", + "sha256": "af94fd1bd34ec7104a5139c0bf79b8bd0cab638d6ce226e5c2cdf7436fb8bc03" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java", + "sha256": "a1ac769b1a5b9931d27d7b0d5ceb5e1ff513931997b8037dcdada05fad78930d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementChargeService.java", + "sha256": "11d47d5c9a16103c6d8b1311589c856b5e8f027c3b8ad54bc29be28a3214e170" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java", + "sha256": "b483e2df05da6f7f4f6c19a0787db1c9d50a48f43f7a8c6256d2669f1546e32c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFeePurpose.java", + "sha256": "19c3147b8fb9b4c4b459a39c091aa90b38ccc6f9e4bdf244b896e29d050eb479" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java", + "sha256": "96988e9c6ab990b59dd722b0111717b5797ab0676cd0fef1ce772404a747fa77" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementLifecycleService.java", + "sha256": "aaf8e456be880a3b3069154e3cc932bfe51b4b2a5933891de9f82c052be8b19a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java", + "sha256": "9943929bfb902a604ce4734a1331be5170355365ec36182a4814bbf131fec3f7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java", + "sha256": "96c49c6bcfe3418e2fa9e7d6248007dbea44a8aa6672a0e86df3f90364854745" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementQueryService.java", + "sha256": "4f5d60eadf73dd411dfc3dbe9ce5907aa28ec3a398aa7932171570f195847c0b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java", + "sha256": "ab4509dc82d9d8f80948a8e6e29216ecaa8aa2f8072028fc3e5a29c3a06d99cc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectory.java", + "sha256": "0e35e5cf31052b809318994c7860f28afdbb8fc66f02c15a94706748471bc90f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java", + "sha256": "51f7f7a35a189c1729a8427244c02b43fae5328277d00e6a246abfff59b406ab" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java", + "sha256": "dec58956ca963f41b6fd91ab0317749498a18168481df79d9f928afa1f77f692" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java", + "sha256": "d18eec430e676f7ef7b37a64f6e017407b6ba356f873202e44ab6184f333abf8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelResponses.java", + "sha256": "2ed1a0b2f2c10a91f2a8c515368dbb79ead0f2c9cd99634f16ed6479cc14eccb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelApplication.java", + "sha256": "9c1ec3220aa2dca83d86b1db9fac3e231d38a60bd6c997ebbb7620b331d10ee8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelItem.java", + "sha256": "183e68a04d1523ca3f4f6f81ffc95aa549ad20d9a6179fabe72a885d925ec281" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/entity/RelabelStatusHistory.java", + "sha256": "8dce8dfe9be859b7a6848e9d238634a0635b9b55c4ac48104ffc1693cb9ea6d6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/HttpRelabelDocumentFileGateway.java", + "sha256": "df3127c83b09c99a40a1ad5a32bc8bca5b1f120b27d9ea0f8f38cc3b39a8e759" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/gateway/RelabelDocumentFileGateway.java", + "sha256": "f2fdc7c1e733166d28f5befe3ae940bf8856fc8b0784b8089f00cd29e5ecfdcc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelApplicationMapper.java", + "sha256": "67f9c2826fea46dad6aa6a8c11cd17e87c3d80e8e2bbf883b12357bff17a66d0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelItemMapper.java", + "sha256": "b06984db0f7cc27c49816c6d1bfe6ca18da281b1b84f7441e0f369d41f2dfd57" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelPalletMeasurementMapper.java", + "sha256": "c2d9157fd92f0e89f9062ffd7928392ce2be78b00e5639864935f8abd03b4d17" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelStatusHistoryMapper.java", + "sha256": "7fcac462760ff0fe66c339349375f76045bc733e43992581134f3ae6d776520b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelApplicationStatus.java", + "sha256": "97f28f92842514a3a24961c3383755b48765f53b93b6696fc0e04b1644465fd3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelController.java", + "sha256": "6f4b1cb8a198fe3d9362bbea1d9b63c9a72c82e25f5f04f007453cd8f44a0691" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelRequests.java", + "sha256": "4dc3191e2c2365ac2d1cf92d9d9844db495625ae20ca36f7dbd3a137b8d4f7a1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/dto/PdaRelabelResponses.java", + "sha256": "762017b7adaea0af862c2a4c9efe3772aa2d06cf6e340d5ba46be74a7cc010c2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelHistory.java", + "sha256": "8e6e5789c22512515115695bb013f9327b185860db201534876a5316d98f7c72" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelOperation.java", + "sha256": "4f1f0c84f46c50bd55c050e51fe065f2692f8bf219b199705c0d3b3a1f3bd4af" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelTask.java", + "sha256": "04fb8ec6dbed612ba786f1ff6e2e11920e8097b6eb6fd9916d6e39e9adafe9b9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/entity/PdaRelabelUnitFact.java", + "sha256": "f82c1f02d72a650b26cb51a82fe9aad6d2f7898abffd361ec4989154d2c811ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java", + "sha256": "f6ba7815bd24461267ce711e5800815c2980e6a707ef9d90fd04abd1a0b571e9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/RelabelPhotoFileGateway.java", + "sha256": "8d3b8b9e249e849f4222ed1cb2362c1e5f02c407010498d53cc141fa9a25dc65" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelHistoryMapper.java", + "sha256": "32212f3da264b282f98834f54852de9cf79e74b1a956bcd9518885d8bcac4c56" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelOperationMapper.java", + "sha256": "e0f78475e6e36e9f62241778bb28a379080050c955731682173c8a80d3afbf97" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelTaskMapper.java", + "sha256": "0599abd9f60bc6f086f750998af9e1364e08409d15388f8e6b63072008078708" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/mapper/PdaRelabelUnitFactMapper.java", + "sha256": "1ab21f14d73c55449896562a40a5f761cf24b828181e69decf1af0795e27b531" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelService.java", + "sha256": "b5c4738a58f380ac4c7c2a233b220b8af54a68a97e4f5e52a7b4ccdd6719a2a7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelStatusRepository.java", + "sha256": "c91a6dbc99b8f93a916bc87456c46308342882590b064577f4642e366e3b8030" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java", + "sha256": "f2211861746cc12abf3f4c1a55825a5af4d7f02bdea73850f91d8a30e1d36492" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuard.java", + "sha256": "114169b32a1e3d5980c8982178ed6488d5b00b045bbb06578e5f908318895858" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelCartonMeasurementService.java", + "sha256": "4110016a4522a16b0e38f79710444878709470f4e0dbfcfa9865d0cd0e26c36b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementService.java", + "sha256": "4c6d2416456ea3dc20d20989403e3fb13ebd9b793b81544bb025f673540d1a36" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationService.java", + "sha256": "4985af9368ea2dcf59e2a01802749c6632830407483dffd90536d043262d20c6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingService.java", + "sha256": "979168652eb474ba976313de3a5fe4e24ca16c255ae06ecb8cc45aefaec1143a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/ReturnSkuConversionService.java", + "sha256": "c57015be25fee5c5686f0361d056910f0a77e65109ee8fa2c6eba403117fdbda" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java", + "sha256": "3c60fe4a1387d0a15c491768cda68b5fb10a9955535013ff4ac2b4bf66d1de11" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/repository/package-info.java", + "sha256": "310e4c652c7c048609525a8ed864475a36a898c6cdc961b9a2896bac27f86261" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnInventoryController.java", + "sha256": "951d65649aa549a54fb1e72f8b2d3b8fee153ff2e3852c3f2662af4f3dbec08b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderController.java", + "sha256": "30ba3e0b96753ce713d333c9ca30e5ec21d8a06d3c692198e39f4f08f67b41f9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnPackingPlanController.java", + "sha256": "8c49bc9708277f6956c4a57601b43151556c6d01ff2a7b92cb2088fbcb8955a8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnInventoryController.java", + "sha256": "9ef8898443689efafdb010c70bb2a5a081fe6d5d2b1b928f4294022008961540" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PdaReturnReceiptController.java", + "sha256": "81ca457acb36599eba1ceda7fd036c70442178d677c472e91727db0c4b6abd05" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnInventoryController.java", + "sha256": "cd7f5f5eea6984b89758adb2c63ea090c3f83113ee1965e82ad4f4153a425059" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderController.java", + "sha256": "6edad6a574e6a8306c4309d27e1cab100905e0fe126979ffe61fc37c45dddcfb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnPackingPlanController.java", + "sha256": "e1ebf957820f72373e13f06e6ecb1fac7200e9a6eff35747084a84f54f3c1156" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptRequests.java", + "sha256": "09078a0b9a9976f549d6b9fefaefd6d2525eba0399306ae168f7a808e0babd60" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/PdaReturnReceiptResponses.java", + "sha256": "f6d645198a57bb81964bb0a14b4d18f8f0b6f26984576ebc019cd33ec43029e2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnCartonCountDtos.java", + "sha256": "d6afff8454bbbde1ce614556096129f9509e208f4df743cc1e25d397df4790f4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryRequests.java", + "sha256": "c9701b87bb4b532309fa6aea1de7628b1c5b3a4c228d9e0a8c4ff549d560d46d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnInventoryResponses.java", + "sha256": "7701818b01bdfa6d1d19fecad8d3d238b84e3604d65541ce0e06132564e6a0b9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderRequests.java", + "sha256": "48dedfcdfad7e72d453a1b3210042775f67b018ec379788480207b74900625ce" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderResponses.java", + "sha256": "98a6773f18356a198cb4af77239e3bab9691266eac862530b8868a62773ae579" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnQuantityDeserializer.java", + "sha256": "519c6513bbb5fff6d41db25b1f0436055022cee312813dd4a801171efabcc509" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryBalance.java", + "sha256": "76067bf1aed65838ab4358f4574fe1001f4619be886a6b2b91abef44d6193e84" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCarton.java", + "sha256": "f820e038965e741e775d4add7c8e5acf00d9a0c677abe5cede8e44080c92afd3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryCartonItem.java", + "sha256": "064c30b9baf2c5949eed2ceabca9d8e428953f25c1bf13845f50feda800f5d34" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOperation.java", + "sha256": "82318f48cef872ed45f411c38cc4c451441fb2e03cd5e87f2e305157e43ccb45" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnInventoryOutboundMovement.java", + "sha256": "4658e138a0cdbd484ef919ea0189983ada4471700f2482c766ef451d55ca8536" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrder.java", + "sha256": "d32005a2f424b5b0b5dbed02e4c0a28be2d46cf0808fe42c740f4eefd29a35f3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnOrderItem.java", + "sha256": "a1189557dd83619ae99ab713b88e3048cd96f92062a8d3f7e22aac50f8163dbe" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnPackingPlan.java", + "sha256": "1e917b4be8171c30f1d752d0163413cf90b4a248a4315822dd7c95501adc864c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceipt.java", + "sha256": "f6dbf2eeab513b337e7a1e7882613c6648ebcd615857ad2ab8a9f72693faf308" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceiptBatch.java", + "sha256": "59b6be9c59e5a52628f60f7c15fe5ea711104c3de19fb2ac772ed3565de2a84b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnReceiptItem.java", + "sha256": "ecd1b97d599a46d284d3679900505e5f9efb4e61a2186186c66900bcde47e3e5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/entity/ReturnSkuConversion.java", + "sha256": "3378eec14850db7df57d8e3b4d36898d41f156d980a285ac127be1bc80c826a4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnCartonCountSessionMapper.java", + "sha256": "bfa8887ecb0c782ff57d5051951c3c1d3b571db9d073609b5388a829a766faf3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryBalanceMapper.java", + "sha256": "f71a5b4268f822b28fe6043144a9efa352ff984356d74df451410b01a54b1ccb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonItemMapper.java", + "sha256": "fd3cb1dadf5b45126611b4840fb0c162c2393f8ea3cb1fcf1f3f20f252c8d387" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryCartonMapper.java", + "sha256": "740bdfb712ca527a17a1038790c627231d94479a580b4b085d24d9397d932989" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryMovementMapper.java", + "sha256": "6bf9cb7e460c6b360679763faa99965bc382ec518a8637a2bf565d9197177409" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryOperationMapper.java", + "sha256": "b33cdce718b091030adf25502e6c8a7ded4e2ad69ea197ea62349a47c74515bb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnInventoryQueryMapper.java", + "sha256": "388dd93cd35e0208c14084aa494edc410124728759de52d4316b55a93775ce76" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderItemMapper.java", + "sha256": "d85ec6986cb73318510da0ea72974243d0bcc355cdddb4d2bbd70ffec814ae34" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderMapper.java", + "sha256": "4cf1f0616eebe4ad34774540388afb64fbbe98b1dde54d047c9c1e5b7dc3cb0a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingPlanMapper.java", + "sha256": "226e1267252e02203a99ff7167c07bac1a289230e3e53c7e8cccb890b9b677fa" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnPackingTraceMapper.java", + "sha256": "cf0893ad0cc10e2a5807a81b53253deb1521f604d18684492ef29a7741ee5b67" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptBatchMapper.java", + "sha256": "dfa38f2661afa0f707acfa66392988792ba02ca45aac10fd2ec94989eae81382" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptItemMapper.java", + "sha256": "781e5e92bbd17de635174f22ff2a6f23b3180a014eaebbe026e5b1af8416fe74" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnReceiptMapper.java", + "sha256": "acc03793f0002778f519b93523049ffc9742852f7ad56a5aca7110eecce7b454" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnRelabelCompletionLedgerMapper.java", + "sha256": "c546903e91e0390d7782e727dc246d284fc37c5f34e085de8d1d9dbb9def3ffc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnSkuConversionMapper.java", + "sha256": "aec9d587593dc90b15d1d0b867c32029349f882868311f486b46b5123fbcd955" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/model/ReturnInventoryRow.java", + "sha256": "25a4a45e07c4b04c795c642d85cc4cd0ca9f7447ed173811a2c045b5a0c64caf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcode.java", + "sha256": "398ce0158076aa53426b13257a94ab2e6cd483e5936f09d12a0d4772b539b553" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountGuard.java", + "sha256": "38a385bf4a97893eb29a15e222c12d76218fb1901275f1fbb1fcb08ef7febd7f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountService.java", + "sha256": "55846edc47ef513d8c309b9ce88c8a32f7b8acd84c3c25ebb11228cd3de2cc45" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java", + "sha256": "54ee87ce0243339ace541892e6ab96fe17e03ea5443bbc98cec9633509dae0e6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java", + "sha256": "92b183402d190e6b85b87810ff7809e4c7ffb176ae2bfeadceac730152ac25f3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnRelabelProgress.java", + "sha256": "84c2656c809959daea32d4859a7b5ce955e5c9a5f0375c5eb007cc350929f54a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/security/InternalServiceTokenGuard.java", + "sha256": "0bc60e23e184409914e880157030b7df3159bdbf77938da9618b70be25e6043f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/security/OverseasPermissions.java", + "sha256": "b28165039d3d44e375e3c32cb10afab050d397df8172809f32fa18572326113c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/AnalyticsClientException.java", + "sha256": "b7ac3bd34e0ad97e07999d67f8e4d93c5b0a806744f6715ade79bb1aaa4a3cd4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/LingxingAnalyticsClient.java", + "sha256": "8394610fea57dfb6f78ffd5fca26ae648f38456866106411ce02c20f2070be4e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClient.java", + "sha256": "83e1b24238176fc5030349cb1d0083f16e344819ba9a70a9a6609580312a448d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsController.java", + "sha256": "421e5d70f253fe5d9f5ae8b2692bff9894577cdc54ae41fc180fb766b2e85fa4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeController.java", + "sha256": "97b19ac780353c8f7f76894fca302a2ca111894b0bf249a2d7affd76466a9944" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverController.java", + "sha256": "3faed78adc4119de78e6e79ffe24f75527016ec614bb87310e9a2a4485dff9ac" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticConfiguration.java", + "sha256": "c45f7d05ab7d5d94611b183eea2609964b5f10eb39b8c3b4ce221ded7dc3a25a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticContract.java", + "sha256": "32c689f6aab963dfe6d2d254da33fdb7f8ab75263cd3c439491db36720726807" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticController.java", + "sha256": "9eb4ad9d49d2f4607610703b36c1ce9a537749e5c9f05bdc96c77e2621597c4b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticPolicy.java", + "sha256": "1c7acaecda397b91656fee820f603878ede1b904778b7f887078e38972843006" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticProperties.java", + "sha256": "2dea571838745a28e55688b2d1726f79188f1e474815f6df63a5ae46082682f0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticService.java", + "sha256": "0b3bbac745e3457769398cbe4580ad22b122e6b149b21d5a826fddef936b573f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowCollector.java", + "sha256": "e1b6d87b3bbf6b2d819fe3ddb903ffcbdeb6076caa3dce5753c5308f608a553d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowFacts.java", + "sha256": "0bc61ed3bfe26ceef831adb7a224a2d18307faf29b98628e82f0966cdd682025" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSlice.java", + "sha256": "190b95f225791bb12ee93be8b85ea95f69a432eb9eab5e2cc77ef30badee256b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryBuilder.java", + "sha256": "129177d8fabfd3246e21e3e24c95e45aded339e8bade110b82c6678602fe6c54" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculator.java", + "sha256": "2b16c28d169a7fd7b84530495a0eadda513b9523d80cbaf51d9f34a5ba03c96a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersions.java", + "sha256": "8d1872a30a347ac1a08ea594a04ef9cc503ce17a00d3c2953743019aac19d3cd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryWorker.java", + "sha256": "ffa24fcb4106090fb826d850de80ab5d7a4f3a3ba527afc5403e2a28c2d37bec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssembler.java", + "sha256": "e63e27e2c91f8cbaa1949ba7e3565c20a646f61c7459c1cc49581d56eb0566cd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotService.java", + "sha256": "6f2227065bd6e53be8015750fc99e8c6e8942d1de7eded4cc0f12428908b96bf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopCoordinationMapper.java", + "sha256": "439c418b8c404a7f1f9a40e639f16f6edcb586a7ca207212025106d47c83912e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopInventorySnapshotMapper.java", + "sha256": "a432f00b75bd17a3e055f6a2a078590f2993b2b3ea0d4506dfa3ba1bf4f5be53" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopMasterDataMapper.java", + "sha256": "3150d9ed69557c86af9c20c125b55bbca8bf7b6c01aacb6157345dc0234348a1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundDiscoveryScopeMapper.java", + "sha256": "a0e1dbdab24e0fdeedd137216a39ce6830fa2698bf35d33bb10a98e5c43d5b48" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundItemMapper.java", + "sha256": "d2de43ea1751cce159bf503a9631ff04ccdd65c612914d40b77df44253714b00" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopOutboundOrderMapper.java", + "sha256": "73811bf06ffb6f11b65266db853520ddedc1196eed832f8527a2dae010c770af" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuDailySalesMapper.java", + "sha256": "ae102628b53fa9c49256aee8dcbac25879faa9bf50e56314986346b12f50a363" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSkuProfileMapper.java", + "sha256": "b53bc1d0248d89bce3c877f75b4d07e80e06af178a7f35ef24378d895b9363c1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncRunMapper.java", + "sha256": "607c7436466a4bb9523f11451f9d592fbe978c3043903d1927ca2f18d5a09d9a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncScopeMapper.java", + "sha256": "67eb659ebb638eb34a876977528f03385e0de32ea5aee78af72ec8c5971a65dc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncStageMapper.java", + "sha256": "6fd92004aff4cdca551f047bbbc677762814d26f6d1f3025a7c28b7ffa807860" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopSyncTaskMapper.java", + "sha256": "c8b38efcab247abb472629ffa95d5750600ab3f3cdf42f43363543d1a69cd0a5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsProperties.java", + "sha256": "fd8ca94e5e519902df2be427735a31fbe7fa072c3b579c5fa05238bdef2f748d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopCollectionPolicy.java", + "sha256": "1fa4ca1de067f31ac665be8da930811f0e3b00c38f8690f26aa9f1a8128f22ab" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopFailure.java", + "sha256": "7d5b138ff80963f844032d9736eb881243d6af428b61cb9034a8556908cce27d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopInventorySnapshot.java", + "sha256": "ede18edb33c675dfa0dac28cc83c2c381987129f981ee2be352afc0c20cb972d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundDiscoveryScope.java", + "sha256": "29a206cf3c87f735f0af470d32b40c9372ed712fa55766e098d3068105d17092" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundFingerprint.java", + "sha256": "9e934973f228e94200143633c3e347f7a96565fefa946e3ac7f0de1a1ccf2086" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundItem.java", + "sha256": "d1f023b94babdda921a5c12aad5af72ffdf1be2d0df9ddf8da48723621e3e6e4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundOrder.java", + "sha256": "cfa2ac34ae81ab75fdf7dd57b93640ed8bd7e40f7c015c4ba779ec2ce4409a12" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopPayloads.java", + "sha256": "1ff2c14bb0b4c3d78828b817d3062cc57583a70f973c6cd9ceb5a393e059eb23" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopProgressProjection.java", + "sha256": "605de000ff515018c42c2badce75b2e4e13345bb898cf190fbbd169f6ccd6750" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRequestContext.java", + "sha256": "1277100708fc7d8a32c9a6534d3e3b90ebd02afb76b154640ee05c162c51e11a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopRoutingManifest.java", + "sha256": "7ca0994ac4cab69f0cf654e40855370d1da08966ce7f97f6b587337b6f038bfd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSalesCoverage.java", + "sha256": "5bf2b2c7a75c09872ea6fed9a4038c4cffaae51ea05f3586ad3ce65e98c3f517" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuDailySales.java", + "sha256": "6c94001069c999c6c040eb54455823e48da4fbc5ffbc74eb40326b71190c8482" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSkuProfile.java", + "sha256": "eced9626b3dc3522725adec25e03aaf3223e9823c72e91b8fd1acbca02c47c10" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimate.java", + "sha256": "6d7bca1006cf04186c686efb10999a9629f6606f1c15f9d40ae5706cf524ba68" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStockFingerprint.java", + "sha256": "8e1fe0f0d4ffb932e1a8b5c26b5c25b3a1f324af02ca5529003f28e2c4e554e0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSupport.java", + "sha256": "11edc4c395feb1c198486844f6500499b9e79565084833e804c587e7e337bee9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncRun.java", + "sha256": "970761fd968a0f25aa4fab43cc6cf8a7f3321c55fb5fe8d1618c44483d17a677" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncScope.java", + "sha256": "52790a02aa6a88a3b3d7c83f452602c438bc2290aab7fe54a837cf14112c2806" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncStage.java", + "sha256": "e1ad4ce9674a919b6e21e66a0b19afc232aaf4e1f764198622f75a9aea92e978" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopSyncTask.java", + "sha256": "f980e3a0ec2d7f6c346f8b0085ba2e1be0c75e1844a4657488626af8a63e8a2b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapper.java", + "sha256": "fc483c02ae5abe09ef648e2e4f179a7a59f581fdde894ad33e77e17739263703" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilder.java", + "sha256": "385825b77dbf619410c071756ccaf08ba0547b0093e194966417ae24326a7958" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateTransaction.java", + "sha256": "b22a2da2e8f3826e70b66f9a6f14c6ca3c476d343b5d385570cc12bee6f10b15" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactService.java", + "sha256": "dfc0e62d2830387ed234b029184150d06da3093f7f11d3f08320d2612251eea1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundLockMapper.java", + "sha256": "30f1c379d82481f6c16d324874ba2150643c96d2245baf940c9fe47472011c30" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicy.java", + "sha256": "16316860d17cbb5b01a9d587f893b5a04c253d01eb46204d016107fe35aaed49" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventoryOptions.java", + "sha256": "370cb185e5aea2628559aac96bb000eafe27b5b9c73687529b739e8eb61ebc5a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsInventorySql.java", + "sha256": "b070626c203b925bc3f11829dcb73cbc60c8f036583e787f0a22a7727b8809d4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQuery.java", + "sha256": "8fe136609d848f93de7adff94559dc8093ced0b351437b833bfdc10a12a212a5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryService.java", + "sha256": "28baaaa5124b281bf8a0941a647ce10721a8771c28ec1438449f9ab3831ace7f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapper.java", + "sha256": "195ee2bc6442a6cfe0e754760081008b803289108496d8adfa70006840295744" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadRows.java", + "sha256": "69bd06223b2293d82b5044718c6b8a0c2d772650f37489de8f85f91c330856ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadSql.java", + "sha256": "a4f546689f9daecae6a74f64281e962424c860850ecd6c54e3d6e7550dea5240" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsResponses.java", + "sha256": "6216fd5baf5cf5fbb493c8c9069deaa29e3a380e2e80ecba108dd1f0bd4ce8b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlRequest.java", + "sha256": "c61bd79d9d3d5af0c9c759c1759961b1c25da5a951c1fb7c010e6f882681cb75" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQuery.java", + "sha256": "99a159c57f81200cd2621f96ef3f09a3ce0bf43ce164d33d01553005881f6efe" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryService.java", + "sha256": "d63581cbf310e2f57e765452d54e86deba9a2ec453a721051b187a03c9bb03c3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadMapper.java", + "sha256": "634ea87b507dd3bfa713b4912e65bae7480b810a9742f15a044e4e9dc815cbe2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionReadRows.java", + "sha256": "520ca194256c32ae93059be7c1e83c9af1e6daf6def8ff137881946f595e0d96" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionResponses.java", + "sha256": "16761aad4841f91e04035ba5b1ec224ae97feab385305b050db4b99f5474927e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionPolicy.java", + "sha256": "f5779bd73bd18a02dabf07ad76cf7e194c742351d9f2bf4b0848b8cac5c6692a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/CoverageDistributionSql.java", + "sha256": "ee1977a03970fa7ab3b337d9d3fd114f6924a222fd93220999ebda6a2e7abb7c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryService.java", + "sha256": "b4a5b2d122a12f0dd924ed359e9ddd99fcd0a40b5e6dd115ba9b69174e1d66eb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadMapper.java", + "sha256": "64569d8f0293b183c9e20119f23c57e65a842691e223f9042418bb506502a312" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardReadSql.java", + "sha256": "ae762ce97336247d904b7568047f193d6d0c74b9519547f8c6e6d7c8612fa55c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardRequest.java", + "sha256": "a6614a946295732ddf88bcbb364c2ed1954ccd5fb1986d74ef024079c9a56883" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardResponses.java", + "sha256": "465e805cce843666729b3708aa8447d0187cc2c2be51675369581dc9ea1a555a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSalesPolicy.java", + "sha256": "e2f8e0503640f53dd6c2fce7686d17e452033e8c3c7645101cf27c6bc8246f7f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContext.java", + "sha256": "302bf5b61a50ffe443018febfb6fa5062c869addea2ec96dc308211c723fe66a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExport.java", + "sha256": "32e9d320a5f09ff4cd6df094f2080b305d6873f4dd4e2d727f15ed851382d990" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicy.java", + "sha256": "60d2f65a76db259e60843e70a2e760e8e380664fde3bc3b493f8631825da4c95" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryService.java", + "sha256": "e10860d1875609430ed3d8ecc5cca1efa61b647b7599d52be46e9f775ee5c96a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverReadMapper.java", + "sha256": "ea06baaa84969b5f8dbd435a6b43704836e31a6f5391ba86446220fe44210a44" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequest.java", + "sha256": "f73d154d1a3955cf054875f1d7afe24e787442658a6e94abe9990e838a348477" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverResponses.java", + "sha256": "0adcede1ffa54eb220ab289b26c18262d29258dcb73c819fde87351545753dc5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSql.java", + "sha256": "5f62b543eb08f94d46403c3a43ad9df93c79d57da38ebeee3e024fe5fd9c6580" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryVolumeSql.java", + "sha256": "ce263ceed9b7afe8842a48d1395a18357ce786fd32d7acc809a2eb068859005f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicy.java", + "sha256": "ba578c3a13af621a954a5f1eba5d0b74094a978fe974c62aa9475f70f3355c0d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesQueryCoverage.java", + "sha256": "4ac230fd6c482d841f0032cf2c7a32b4fc182be4253a2574ff7bffa29c8286fe" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SalesSort.java", + "sha256": "dfc72f796c5d758321bae10d36343eb8b3f14b77afe359db9fc7b44f34a5035a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/SelfopOwnerDirectory.java", + "sha256": "79bea2618005f53c2c53e92f313268808c2650f44d3b482f1a733dd74c41e638" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/TurnoverExportReasons.java", + "sha256": "d239a488cd8b2b56279041f7ed6cf1cd15426fb06f9d58a581f26da3c79a2f6b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnostics.java", + "sha256": "716155eb3875fec3084bd31ab70a97daf53d4d12f6b927661011e1853183df81" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsConfiguration.java", + "sha256": "d70ce5964957ef1295dabfeb7243f3c27951b865eefd30a75311b273b13d73e5" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsFilter.java", + "sha256": "bb0150552f3d7dacc9db75c3a09a310f67c84f2883e32b5e47baa1aa11ac624c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsInterceptor.java", + "sha256": "ced1a77c6958d4113d8c050bcb077e8a5cd84627348dd115ccd633ddec2c96c1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsResponseAdvice.java", + "sha256": "8bb0fabca4791c520c8de5a028a45ac5af3ab72ffee1d83c8885192a05133480" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/EgressRejectedException.java", + "sha256": "8a2e2112417d8da5d012e0450098817549630e4cc6ea777f3c788935dd05b58c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopApiBudget.java", + "sha256": "b20f7483066ddf80671ef455b4563b1d7a345acc9cf7b473c1284b9dd3304312" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudget.java", + "sha256": "96b7d016ae111f0bf2222a74b5a4cd22e6ee6b45b07924320909bf7ccfd74be3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressController.java", + "sha256": "251dd3faf974c88358b2658ca64930e10ce5740aa92ce02f35a715411e5b32d0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressDtos.java", + "sha256": "1def074291a02b66501d1e5dad95757779cd144f59fdfc32ce41a5bc7974683f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressMapper.java", + "sha256": "f03f9a98848e54f4da2716ba62e5adc6ef3b2bc681904ac1cedb2108aff41f38" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressService.java", + "sha256": "ab4554ff2bd5dd4fe6fc40cf0adfcdf510b449a08cdd4d1b705673f659bf56b4" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopValidationBudget.java", + "sha256": "3197c66f9a224a98f38aa8efa4cd3e8604d197d71649bc6cc611eb0c89d87536" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionMapper.java", + "sha256": "a5fce43123d770b042c95f450a2a54107da8babcdf4af496a8be4b2322d4eb60" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionService.java", + "sha256": "5d9db33951f3f00a8ad7eac923c1e9abeed29e680a2df925a67dc9d320eaad0e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/InventoryCoveragePolicy.java", + "sha256": "59c0f1ef22b8a2ba7241e8114d431fd2a79a4ef28fa8c65199fb69beefc53485" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicy.java", + "sha256": "edd0dd67dff2d0708b87a37e57bc681c368ccef023e849283f86377f345c5867" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicy.java", + "sha256": "ef77489cd86423ca944c1c8b50a010237c74be2349559c2bf1a8809ce14aea60" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeService.java", + "sha256": "93fcb24e0477d09c632c3ed6df0eced8e6bcbdfb298bbbd6ce0ba6f6b930ef8d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopBudget.java", + "sha256": "a763d8ef1ab74f4ca7dba94614ec9a1aafec2ad1e4ae59a7fb74c95185923dd9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandService.java", + "sha256": "59de361989c9fd28d5c61f08bf75b6b7f2b2cfa4074513eca49a3c72933d6c60" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryService.java", + "sha256": "2a482e6679a9f776bad3ae07b2f1b34590dd03f22e62136a476c8a85df527bd2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionService.java", + "sha256": "f6a2b7f73215865ff56e653418796b5a0d7fc72d0b9a748e51c56be0471dd8fd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollector.java", + "sha256": "baad3fcef8f02b746ede6c7a8174de32be0b5b9abd696da0e2a07a1a0fef293f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopLeaseService.java", + "sha256": "6a81a96950fbacf49dbe047252a0933dd140c640310ee2922ad885db6b5acb0d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopMasterDataCollector.java", + "sha256": "93cdf8d3af50bedcd23f8ceb17ed4a14130a61c6ab962f6435dd31f8a4c26fbd" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressService.java", + "sha256": "a2fbc7e3bce18d6458cd0874e5aa36e680e5e1250429ea6831287456526e090f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecovery.java", + "sha256": "4169a406b421cf2c1ce3180a5c467d146f1c1b38737be4fa32a054c5c545800a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisher.java", + "sha256": "72f61164464601fbeac5d739e11852dd99bb15f88ccb058bc09f45180870abef" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlanner.java", + "sha256": "732ce0c33a4813b998aa18bc97c0ad817b6a6fb3c3b314a4951fd3bb71d50f0d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskService.java", + "sha256": "8d34925ad209a9729039b3a0fcb20d308848da6538908935abc5042fe86d5e62" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunner.java", + "sha256": "bb17da49707145a797917d3a25ee70d4920fd954f684585f38b69401143e4656" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncController.java", + "sha256": "ad7383612175cebb8ef0701f0a8c763a2aea130d7bc73ce8270bd2e81565d36e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java", + "sha256": "5e72b67f58dbe6feb0758fe68dcdc6eb752eb0fee1a9cfbc7126deb7f004b96d" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/CustomerOrderProgressService.java", + "sha256": "e1d2012157b4f7c0cfbb967d9affbcd1a1895ec59d695200a6de242a9937c3b1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/FbaWarehouseValidator.java", + "sha256": "eed22b3a69016d2d6e4480024f94ff896368e79b331911b5e8ac1e47b0de3765" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxService.java", + "sha256": "6fadfcba71666606bd08f562568286dfff7fa5a2f8f0fbfbbc2ec3c29dc80568" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderCommandService.java", + "sha256": "d261bf12a1906e5203e69f631871355bc7cfd95c204c9928bd8c42ee902ad8aa" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java", + "sha256": "732d0481f0faa621fbc3338ecdaf5be533a80a34c7f5ef95e333f30a996d04d3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java", + "sha256": "250a980424948b52c20002d4d8c1415ee3e4367296fcb07fd69ae32b13295f78" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java", + "sha256": "994de72374f75e76a9f6ba688dc6c4034c553943f417c632121c9a8b64feb871" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolver.java", + "sha256": "e9780114f172659b4dc5d58ba0cb7dfa20fb1520bde5f5f50896070cd063c57c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/package-info.java", + "sha256": "f3ca38fada6851a2de3abad4615808a9844de2ae82d7c9d22efadada50b5a2da" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/service/reference/ReturnCustomerReferenceClient.java", + "sha256": "dcb3c81d276ff7f057ed303bcff939529f6ed8e4e02084a52cd88fb4282ad16f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/shipping/entity/ContainerShippingGate.java", + "sha256": "ccf8253bb208cfa8cdfee0857546c79354459ac9ad70488de57295b5a912929e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/shipping/mapper/ContainerShippingGateMapper.java", + "sha256": "15cf1f16381890bc22aa659bc1ae05bb91a6561b99e0d603b45ee6804c2a35b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java", + "sha256": "8abb9aed56c7fd6fdc72a0b404224fad81de17a845d41ee7bbd233641f618fcf" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java", + "sha256": "6b7be69f7c0413a224854b6ec12280de135d9a9ede1d756687cdf35df23dc590" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferController.java", + "sha256": "95f8acfd47ed07d40ba7f11bcc300d7c390a033f57cf934cede462a9496b8f59" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferController.java", + "sha256": "8f8f3acec7293de20112cd22ef2f9ca27c9e37ef284ecf12995326bec7b82f3f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java", + "sha256": "46d0b9d22072f888cfc992243fe907378261ecf675263eaaae7631c43119aa6e" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferResponses.java", + "sha256": "c53fa21d7fbb2000de4d815a5d14c2faa9ca28d6de4d581f3d9816d705f12a9c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferCarton.java", + "sha256": "6d8c224b8b902bf0d834cb45a4a327e7cf6c476cc7f363f5bcd0ed6b2c0efda7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferInventoryAllocation.java", + "sha256": "77c67fd0a5efaa8c40899d2cedf382be3bf2981a4aea9221f556db087b741877" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferItem.java", + "sha256": "0281fbdedd56499a1f505159f3a287a424b2c54c8b1d9cb6fd913d13db28690f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferMeasurementPhoto.java", + "sha256": "0378c66d9d11cbc9f5ea5688357b57a14bbc9ecad31a34496cb0dab8fefadace" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferOrder.java", + "sha256": "ac8cecb5995e9c25d656ba00d60f4176154a2f05c34fc4b09e03e092281faa20" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPallet.java", + "sha256": "5eb75183eb4f97aebdbb4ebfadcbb8dfdf1a0d71ca9b4c7301dfd786e2c94ebe" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferPalletItem.java", + "sha256": "36830d62ea6af9ca864963ef62589dd2d363b8320c034a627fbaf8a3bf25b32a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceipt.java", + "sha256": "2706ddeeb76e669544255995a8c3d040c3ce15f88f674dfdf891f0bb56a83bf2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferReceiptCommand.java", + "sha256": "821cd630ab900e4a59aeb24126f0412db3adf3a9525f85ce078b708ad1ccc5f0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferRelabelBinding.java", + "sha256": "0385616f12bb1464fd4c7f2800f63dce4c80f301cc46ff2a8f96d0d46fe93531" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/entity/WarehouseTransferSourceOrder.java", + "sha256": "98656a9442f0f9672db9b107356b9469d7ae5303975ad8ab275b96eb62949572" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapter.java", + "sha256": "db95f8139d9c94a83287f4a8288b02977bd8419a9f178c33df834b1558a8e4ff" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGateway.java", + "sha256": "62336656eb0e48726b674a3f312af7ce72c8645a2e3c13de3276c3abd1725b1f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferMeasurementPhotoFileGateway.java", + "sha256": "274d9fb7f93e1252130d1906cf284d77ca36c3e1cfba1a59e6fae30f6abf6ea1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/gateway/TransferWarehouseCatalogClient.java", + "sha256": "b5dcb0b82ee38dca69d5e2f0e1c0a78391d2daf0dd1ac5a7ba45cabfcc6eb256" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/FleetIncomingActualSourceMapper.java", + "sha256": "5e0268b5be81a8a15dc2e0de5f1c939ceab6a87bf1989467ae1f9b0cd770e6a8" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java", + "sha256": "b7bfa7ff5b750907fee7dcac2d609cc6e3f9dc9f357a62795a8d1dab3e5fd4ec" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java", + "sha256": "ca023eed292396ddd229215b349070c2030fc890f5ed76c289b6ec704ae6ad8f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferCartonMapper.java", + "sha256": "18ff9189ae42dc74eb6f1e1dfe242cb983758b7307580fbe46adeb9a42131c53" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferInventoryAllocationMapper.java", + "sha256": "492b98c0a778da414d6b5fcba51c7069e192e6eb0575cd895585344e407e911b" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferItemMapper.java", + "sha256": "22733b5555b76dbda9a6f34888c39a5513171e4624ed3f76aad5fada26b5b8bc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferMeasurementPhotoMapper.java", + "sha256": "3815ba9bfe9a1fabfe549c3b17b486961c2348216a06dfa36c6249320af1927a" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferOrderMapper.java", + "sha256": "355788ac99de662e3f81dfee8a45b96da31ddcc9b270393bb6a67fd36ca8aba2" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletItemMapper.java", + "sha256": "83975841281ec93f5c17c2d8e00ed2986b39c59acc45541c1a62b70b3ad52bfc" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferPalletMapper.java", + "sha256": "3e8f48f8b3dc4a635ee5ce54144ee22149cda89afcacd3e960da69db81cd8eff" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptCommandMapper.java", + "sha256": "2ee67199d3b466aa74798b91a36b86ccd3812ae2f451a76fb99bd5917c7d65db" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferReceiptMapper.java", + "sha256": "8444c130e0345f897dab0cbd30528618519274fc40090fb057e20a02a4dced18" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferRelabelBindingMapper.java", + "sha256": "620fdf0aa8b8c31ec63258fc1fc7a861a4b3f909329f301e6e13c573bce43432" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/WarehouseTransferSourceOrderMapper.java", + "sha256": "3cb7f44f7cb17b6865a68f97ef958c112a381e57a41b6dea6fd9d5ca8e520fda" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/model/WarehouseTransferStatus.java", + "sha256": "118209b965b16c39b2d85e0953e16833aaa8be1e6c8a11182346841e8532aa45" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/FleetIncomingActualSourceRepository.java", + "sha256": "3fd7d5153332972d2b14ee257f3a75604817d60266d481853e85a631ce15c143" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferForecastRepository.java", + "sha256": "25048bcc0c41035fd97c84dfcb13d85254eee11229d698a7377c35c1e61485b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java", + "sha256": "d7e68a39a9c7cd9ea090d3eca7c0e234eca980e730f09d99abea2e7ad5f02548" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferMeasurementRepository.java", + "sha256": "1c4e6b15aadf03577192cc361f4ec9e72b129ff9c411cdf55f691ce03f7599d3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferReturnSourceRepository.java", + "sha256": "c2388945f86543d23de4e141480d9d6394d9b4b7716a18befc226af3a16634cb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastService.java", + "sha256": "3647c69c774dc591ac87d6066c6572e0976142a466125c0b4dd540143dea8421" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinator.java", + "sha256": "56ba406b815ff15989b3547885f4a2aa284c491424d654aff29ac153a4259187" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelCompletionService.java", + "sha256": "9cd8fef46312263fc020d0b637179baad57e8c1edb3cdd7517f073421f1db954" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java", + "sha256": "92415ae036dbb0e015f01be978b771aa280dc0c0ceb3365737d5bebc26d47f6f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/MerchantUsTruckOrderController.java", + "sha256": "591be6557892a672026a93b6658665298c62d7aeda2f07a2a8cec725a09d8ed7" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/controller/PlatformUsTruckOrderController.java", + "sha256": "89f0f9485a8e56ab916ae6ebfc0b222e78e6d4484b575fb2d46ad64d89821c33" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderRequests.java", + "sha256": "83552eb17c91854b160b779ff551a1ed2127a05ab027271fb87725a914d3556f" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/dto/UsTruckOrderResponses.java", + "sha256": "3cfc36998f879dc0abfef945b18f37b90f21b8182715e257b7263e019a32e7e9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrder.java", + "sha256": "e2d407f0ebb21c5c138fc64f51e7e6e18e8619a43618d5217040f37180c26ffb" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderOperation.java", + "sha256": "318e31bc6ac478b8419905b47bc5fa74f618fa609a4736880de762eef152cbb0" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderPallet.java", + "sha256": "e2c6fbff9755bc9d9141e00f4daea947b504dc64fb3f035ab83bd217e278e7b3" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/entity/UsTruckOrderStatusHistory.java", + "sha256": "0a4eea97f9c1bb3ad4c8acbb11262e27f605e2c786b132c340627619eb7e5b1c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java", + "sha256": "12c5da15f94363d877f48bf5fed9d2f388b7775fe82f5f33a2e13c122683cb77" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderOperationMapper.java", + "sha256": "3356e83b18224db10c86676cfe1a7aa3c684dc464114e73d4034218e78aec8f9" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderPalletMapper.java", + "sha256": "cb134cde12e967220cadd990c390bdbf117304fd4bc9696d2489221b57732e67" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderStatusHistoryMapper.java", + "sha256": "5356a108640f66cb1b3b1be5666756d1e7c5f210a7ac8d1e3ca24349d8537971" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java", + "sha256": "aa4ecf9511fccb992e7e2085e20844e9d785a4fa2b4ae8a443d0ed0ff289a8f1" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java", + "sha256": "1c6fd699ae14be29477d0f831a4e82ce01b0598541d850de9526beaa014afba6" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandCompletedConsumer.java", + "sha256": "e2f692ba27643371a06078f9e1d14b066ee8b1821d5a3b28bd8e2a5454e2c58c" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletCommandOutboxService.java", + "sha256": "2ab877d9acfd039b0f89f789cb3f8aa0802ec1072a887314d3ceac4393073132" + }, + { + "path": "lastleg-service/src/main/java/com/baiyue/lastleg/wallet/WalletResultHandler.java", + "sha256": "a88e087cd7ee853628d52f77e73b3a62d9e70a5c41d53ee9d71345e702d7128d" + }, + { + "path": "lastleg-service/src/main/resources/db/lastleg.sql", + "sha256": "7d24bc1266e0cc253d5740a5dad4ec1953ccb8210a6c099a8bd0d2536e8c3800" + }, + { + "path": "lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml", + "sha256": "42bd301b6de7d56e6fba24d9b98531669341dabe2ac8f13182c109634f2479c2" + }, + { + "path": "lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml", + "sha256": "26d0a2f2972cdcc7860851aaef9cbdf9d9a56e41936a7f9abd3123dfc07584c4" + }, + { + "path": "lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml", + "sha256": "b0a9792f9592470845321074a0596847adac2ee4674c06e585ac1b1f80f82bf3" + }, + { + "path": "lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml", + "sha256": "2c6c5c7dac381c2dccc229432a34ba52e93788ee0611f1811c4b1dfebbe3413e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/MapperRegistrationArchitectureTest.java", + "sha256": "c2a8debff1bcb6afbef36e4b8717a750c916f3c7015ac350c062c1bb81042216" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/QueryBindingContractTest.java", + "sha256": "e100084a1a3498c73cd9d945d409110dc59e0695c33845315b2296b14d5194e6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewMapperTest.java", + "sha256": "ab45469d1b90596364b1ebac4ef59c07c35646a72c1868b31307925f3fdfc87b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentBatchPreviewServiceTest.java", + "sha256": "ea69fc9a4e33efd60f4cc6e922d90b1fe9df0483af30ce89c8402f6488ea5137" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoMapperTest.java", + "sha256": "91c555c001c009b122460fa46a5ec5582a85f41e307274e2f4ae27a675ed508c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoServiceTest.java", + "sha256": "4010272a47b8a0e2149248e1ff830e9d5ebffc0bf842ce4cdcb75983eb44d0be" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCargoVerificationBatchTest.java", + "sha256": "46ca2f3eaaaf1acc528ee93e59b7b0c2324574c456d95b0bec27b948bf4f41f3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerMapperTest.java", + "sha256": "0e8c02fa1037705a88212e29230beb9d6699905176ff0d8d9ff54c59cb2202ce" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentContainerServiceTest.java", + "sha256": "a45a602475481b502b977cccefc51cd4c7dddba00e404e805b63ed509f69a9cb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageMapperTest.java", + "sha256": "4c6adc0bb4e06274e3c1e827401f65abb43733e946a42d0805d778f749083e4c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentCoverageServiceTest.java", + "sha256": "fcb860b62318c9d4c4a768c71144959b2638804b3749cba72a58faee0e0b6ee0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateAuthorizationTest.java", + "sha256": "26478c861d8987b639ee5a7d71930bbd0b0992d9ba54495ff95901b64e244f4b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentExternalCandidateServiceTest.java", + "sha256": "6fa4ed9ae70ea273135924f324047f3c1b9944a7ede9dd1ea130466b6cffe3a2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupPersistenceTest.java", + "sha256": "c850f9f4106b49fe4e349839b06f870de19de9fdb4658f5b8f9c59992c590a9a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentFollowupServiceTest.java", + "sha256": "9b8664a1107de493d59fca65b1011722d787f887ca6cc1806be4cb3d2a46e3f3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactAuthorizationTest.java", + "sha256": "f40f26145b77a01f7046ef3e22d31743222e16b7aec696c89e8db071c0feb9d4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java", + "sha256": "2182f7e6f95238f177cf61cf039bef18db8f1664e2755e6398a366947cec3e4e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java", + "sha256": "0ca76cdd133b354895ac5530ea3f7fd1c7d558b1d551c669ef87f721726798dd" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningAuthorizationTest.java", + "sha256": "a024180cb9d5b78a257b75f7ae6cc7c068cd62772e89afce5fc341b66be251d2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentPlanningPolicyTest.java", + "sha256": "3b1f657a78fca972096110b2c93f01c20735a5a4cfe3b4697b92a63829aeb3be" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentSourceServiceTest.java", + "sha256": "4ab5bbba1def7edd1abd56f7db99cd189dc6b2c4cc831ae2eb33643828579e69" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommandServiceTest.java", + "sha256": "2ba1cd5c59c2dcc02479bd2f100c04f5626676dba58d4001792427efa91e1f02" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkCommitmentServiceTest.java", + "sha256": "6865eeacec91087a18f630ed05bec049317cfb81d8f3c73278ceb4ec68283e75" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkContractTest.java", + "sha256": "3817ed43cffc994f327edbccfb472df4f33e68890739700c62fabf9f53156b8e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDirectTransportProviderTest.java", + "sha256": "51e7fbf0cb726e8213a0689b403e80cb6279d23fd1eb953d4c48091e8b8eee32" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFactAdapterTest.java", + "sha256": "e544571425acec8798e8a9502d9ecc13d3f68930f19c7035117d80100662fcb6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFollowupInheritanceTest.java", + "sha256": "1e659cc22b55bee80e2a677b5dd10d300aab5d3fcb290b599c8c8c3c6eaec929" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPersistenceTest.java", + "sha256": "1684dc950690d472d22801f3d8a12afc00366052e014b4b438e228ab3843e6bb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkPolicyTest.java", + "sha256": "edc214d276fbfca6f79ba080408be253b1b478c5dfba8976d6842d5d5e344b31" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkQueryServiceTest.java", + "sha256": "20871af343606ff7d4e92ed533966c0cbe3d5a2ce25493111d5d7e5d5f5a2414" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkRouteServiceTest.java", + "sha256": "cd7729fbc27be10b8c1cda0e0e645f798b23f09e32b1d5205085b6ed07ba7ed5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSyncServiceTest.java", + "sha256": "24f05f931ffc71e80dc72414af0d51cfd417328c8be81e8e80aa9568d92457d6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkSynchronizationJobTest.java", + "sha256": "efbee72349bfeb9ab5bd2083b3222fc630b941b3db836e2b7a0b8bab85db4273" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkV13WorkflowTest.java", + "sha256": "d54dcd71bbefdc64c8617dfc138343c888e241b52f07da746076c7d553e54684" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/WorkTestFixture.java", + "sha256": "dc92c16b1c54b3b85eb28e4fb0dfbf377de06f7fc7d9b3e59e5fed13d98e7a90" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticFilterTest.java", + "sha256": "ff0d6d05548f1745f6bb9c25e36fc7dd5706d0dffbadd3dcefb2826bfb5eb91c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsInfrastructureTest.java", + "sha256": "2fe18e422f0724336daab4443fa2bc325f89008b8756ebb5e74355d92ca792cf" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkDiagnosticsTest.java", + "sha256": "c49cee785099092b928997e18856fa4ab39ded2239b8caa43e6b9cc25767fe60" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/diagnostics/AppointmentWorkMyBatisDiagnosticsTest.java", + "sha256": "3bcb8e147d26152932b82125a23578279a109d0cab9da5dce986a722c5c02dc9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java", + "sha256": "a17e359b39000036fd9bd80d6b0744018541fc7bbe846a0585ece629ff09dc6c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java", + "sha256": "9ed34bea7163166d0f79f882f12b23033dae13ad13bd4eedc7e202aed5defb8f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/client/DirectEventClientTest.java", + "sha256": "c92037f0a874119a3e7f3b99a483882f1ece65ea7771caeeb2897613ef18b1d9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/client/DomesticWarehouseDirectoryClientContractTest.java", + "sha256": "5c06008de3859feae502abcdb21b33bb3e384be788d46c71365d2920ad98a14c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java", + "sha256": "3b94b333a68dc9ad7b97973e9293b7aa8c66601748222c620ec76ba62a02c542" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/contract/AppointmentWorkSchemaContractTest.java", + "sha256": "2b145bab50a8596bf0947463b833811ad707a45379383970f4d5975c860f35f9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventConsumptionExceptionHandlerTest.java", + "sha256": "37c6d555d1caf0bfa65c39dd526637d6f672871ba56c501760d80eca08f74263" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/EventOperationsControllerTest.java", + "sha256": "f2d99715a70a668a89dbd16ae0ffd515ff931978575a04b5844488cc679f0d79" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/InternalCustomerOrderProgressControllerTest.java", + "sha256": "4d93e645851a9d9150f7da7e86ed9eb83efa1bf35a0d0b397e43618ef4edd411" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java", + "sha256": "53b04d75d6f5dd58b257afc54f27f5e64d9e556e1f74b567cb2394dbfbc40dd7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/MerchantLastlegOrderControllerTest.java", + "sha256": "0fb0ff59655cbb30b9eb8821f52a2a94c7040bbb31bda016fe99dd0511604952" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java", + "sha256": "b4e6da0ffd0468b372472688a9e476ac5a4f3f3a8baa59f7d491343035d33926" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/customervisit/service/CustomerVisitServiceTest.java", + "sha256": "fb1edfd23288c2332f621e90dfa74eeb0a7feef1e63b6d5e27d004321f3bd47c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryCargoGroupsTest.java", + "sha256": "c01094e2af06ef211bc3b55cdb48a9d00f30a509ccca76be44b10206794f5ecc" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java", + "sha256": "441d21c15e577eec6fd5d6f7ea3d4d834893fd262f4ac116a4e787cf51cb0e4d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDocumentContractTest.java", + "sha256": "a807231cf5a30fc141d6317883f6587267f77ccbcbe408a16e12020bb1cc3278" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryFactsChangedPersistenceTest.java", + "sha256": "cd94821fbdfc0beabb44a4da17783612486d0ed2b046e5e782cfb4be63bed9dc" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryLongIdJsonContractTest.java", + "sha256": "040915a39b0b7ca770da0199b4348fb732e5b483474596e9f9125354048bf916" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySchemaContractTest.java", + "sha256": "65edaa99a378a13d3f2f662a89372ca53187e0f7269ca1e291e04a73af328f86" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourceEvidenceMapperTest.java", + "sha256": "525d9f06022fbd3f4086b05a0dc76bca400e629e15d32ce3d5a23203764cded4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java", + "sha256": "ea598527fbc359a178d14dcaa3ccf3cdf5ec4bb5e018d8522d027ed74f4ebaee" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java", + "sha256": "f802316f6bb091f4278ae242333a219d9170dbbdd837281fc51c2cf260488d70" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java", + "sha256": "e9478b95f6820512f5278a3a00604ca52f89fc0f458d4899ce251a1fd19ca855" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java", + "sha256": "cf26e7a710bea1a74454b8b289bfc31e96b91ca3c8b5fa24783934c3dd6e5463" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java", + "sha256": "094470744d7baf80a846a76f929f4e0b3c87a6592e2a1229ce31c7abc662da94" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DropshipOutboundCandidateMapperContractTest.java", + "sha256": "ae551c7c24623916e2fd93d5d3d9e99fa4be3946bc5ac942e65bcf5294e77030" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/NormalDeliveryCompletionPolicyTest.java", + "sha256": "2f70a3ecba5222b8b43816cf94d1d091f2fa55888d1cc55212289d3dbb13efd0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/Phase5PdaDeploymentSqlContractTest.java", + "sha256": "6df019684f6ed53e51cf69befc5ed53f5a5e1a71c4d304a26254f9026ab29eb6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java", + "sha256": "479ad8c55303bc7b30c8955473687d4210c3f0e518e3e7bae846602873fa8d2d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java", + "sha256": "7b2b9783a8d57c5d0b4412aab8b3929abdadea882ea82ea62f7e6a65000c5bdd" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionPolicyTest.java", + "sha256": "7e92c481cddf372caa81e20214d729c5a6c9f87948ce1316074746254da5a6c2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionQueryTest.java", + "sha256": "dd59f42a944bc181d24a14aa5533a03e01473f3e79adf0d38d9ae7a594065455" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/DeliveryDocumentControllerTest.java", + "sha256": "0e5633c63286c953925711da40ced6980d6d4ab23af883527d46d4eb026164e0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/controller/MerchantDeliveryControllerTest.java", + "sha256": "c45c68d01b54a67dab7f1fc7e3c55b4511530eb0aad716561ae84d6cda94dcb6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/fleet/FleetPoolDeliveryAdapterTest.java", + "sha256": "0359e8ee7c13c0f9f6c5397d04681fbf492e78199de04b7bf8524d90c521ad54" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/gateway/HttpDeliveryDocumentFileGatewayTest.java", + "sha256": "bfc7f95d84a952a618074fb14260af93ec89d4d8f3ab7e3848f1458699a36801" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryCargoQuantitiesTest.java", + "sha256": "6d5f7053267aa6797de5aa353841b254ef456dd2d0baa32752be9560970fe3e5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/model/DeliveryStatusPolicyTest.java", + "sha256": "8aa31d4f9ea8c49515dfd21c8ca44135dc29d19b77039fab891ff919062d340b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryCompletedOutboxServiceTest.java", + "sha256": "ad470b3e7e1d4573497b6e1aec94c078c6081344a9de67c29238bb4f6e5e7fcd" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryDocumentServiceTest.java", + "sha256": "83124b5e74b6892a6e6b5d22911763672ab97d19c5080b691376a59bce3b9b29" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryExecutionGuardTest.java", + "sha256": "86bf0b00c04c47feeec971c05de6c6896d528c6ea04fdcb42ff656e031c7fc12" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryReturnEvidencePersistenceTest.java", + "sha256": "fbe4707274b1fbae60bf00712358fc8a81c0f28371ca2bc19f456f9cfe0bd402" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java", + "sha256": "6e37ddaf197138e06b6b0d2d3ca908c58e23f8a2e01fb18791cb38b8ff1fd5be" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java", + "sha256": "8a22a8d95ca5ab5164e4bb74c90177d3ef7bc487155e5d8d975c64ce25842136" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java", + "sha256": "64a0eba35d738ec96a5cf18c0b3201f7217ed69e9d2ef7e0cfbd082d09ba7d88" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardAvailableStockMapperTest.java", + "sha256": "9af7930189b27e832e583ec642662117634d9d9adb11576c8fe90e9b4b9a7e2d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardMysqlAcceptanceTest.java", + "sha256": "06e0d71460fab27bb034ecee475cdb51b5257319470cb25641ac8bdb1242bc16" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardPhysicalCartonMapperContractTest.java", + "sha256": "88e0721dd6047296d6fb9b1463a3552a76d304b19c1a8c3957f4acd6a1e352c2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardSchemaContractTest.java", + "sha256": "e1bd377fb3ed9bd1e5734b54ad6024f1e9ec929f73834d3bdc860d5782a73d6c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardControllerTest.java", + "sha256": "07a965d293fe4184622ca800e06626ff6c8fc7b7e5777124ab2877dbd6b071c4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java", + "sha256": "2ca95d48d8e199ffa1b2a522eb2f5ebbae6363408ec101957a89a61d5b196564" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java", + "sha256": "a1797a1177cf3081822773e0dc2f6682c9a4417f13d54e0595c2f8f61b7c439d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardInventoryAdjustmentServiceTest.java", + "sha256": "c2b4dfeeecd4719efbf9141b40abcf467730d4a42f91b5cbe93e25a7cdbec664" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java", + "sha256": "cfb2faf05945a0de72cafcc9baa22050c90099a74f437b8eaf8f24b6119a736d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardPhotoServiceTest.java", + "sha256": "666875ddea8ddc13671c39a2b85f22258e6c6bbf7795c3ffe2df68c3b7c957b4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java", + "sha256": "b056e9cfe186d65bc1c628885328c556658d51d03dbe3facc0e0d4217d436665" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java", + "sha256": "68184c9190071da34253786e993653204db9c3c80f5f3ad9150f036ffffae955" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java", + "sha256": "26893507dc6c28bdfb2bef507817df9cb20ad8a141161bffa3b7f3bc9851c085" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPalletLockPersistenceContractTest.java", + "sha256": "b6d863beff2c17cd5a775796939194d06e19b4c39d72c4ea0e09a60d17bf68ed" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java", + "sha256": "f0e939cbae0014f8ab17e593e7d539cf61085a82ee251e80759d8e71c3d17091" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java", + "sha256": "6162e1b38e3b37910da136cd63dae915cdfff70997155e0d7cf145d0c9fa86f2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java", + "sha256": "035e333a85b40953e42968a97c472212919c5a5608430cb39747a722971101a9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java", + "sha256": "c8882395213c4402f42b150cdf6e8e3b3123b8b3dbcacd4dceb801e2e8d7edf6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java", + "sha256": "c7daefb6fa74bf2d57f6083fc3cd019567b0c03bf7a71354c0279d54b5d60ccf" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java", + "sha256": "29547b757922548e188585e237624c46b0e226d8e901a1bad7af26415ffbbef1" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java", + "sha256": "91337a6fd24f29e8be7fcb1033b4229937ec3b0cdda00193e0a957d40796a15a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java", + "sha256": "fa036108efe186da947e26174f23dad415dc33d1742e67194ec6945b39bdabe9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java", + "sha256": "15723d5a13a75d37ed9d92dffc2f5effa0bb4f2a45e4ac9019ec250f632ab72b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java", + "sha256": "4eb3e1c8138dd62386f470581645ad2dbe97ee6e71ce3d790e409c5ddd87d692" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/ReturnedDispatchAllowancePersistenceTest.java", + "sha256": "840eeca7f099f1c9f594780052ae0a28efc6502cb7bf6ffaebff461a2ac8aae7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java", + "sha256": "d4ce0286e997d3e91db90384791fbca991a3580a323740c412a6c5994cb8baa1" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicContractTest.java", + "sha256": "9bb982c1be17fa206d8098133719a6c41d3350d326b01e278339a7ee79a96a6a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java", + "sha256": "dfcb6884447ac5862161794a4a0334d395e09dd073a532764d4a17ac9ecf0e23" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java", + "sha256": "763da15a8924c9f587f9a5b9ef8518ae34267bb9bd6d6184d03e01303f462464" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java", + "sha256": "efff2f7b9c1e3c4a0a5ba1dbaa67301523177a61ddff6f25143a744eb51df35e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java", + "sha256": "da0971a42f94dd6c9cb48e1d076d94bf2a4fe2a2f2443b0b9ed49967e778c3fb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java", + "sha256": "fbc36065d2ab8d8d0ab2062b511065631f20e5227e49929d095fcd120d0c410e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java", + "sha256": "93dfd2de5286f248f05a544790b5dbc19f57c73bfeee16398290b7cdd6d3cb85" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCommandLedgerPersistenceTest.java", + "sha256": "6a29f32c3eb42b46db921c4bb75040b1ff758e474db27a915a35c61a1d954e4b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCompletionPersistenceTest.java", + "sha256": "13f7b69b4cddb617318834fe476fa814c532ae841df4b73eaa8a2176bbe79330" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeliveryCompletionWorkerTest.java", + "sha256": "cf2812520b6c1025b3b8ab8084b9d21064427811dd62f12a734cf90c4c035c12" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java", + "sha256": "80c1901b443e301dbd51dd2680114baf8e89af45c299192a2dd51ff0815d1598" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java", + "sha256": "846aa50bb8af260a16f806cb92686f155bf5c81d2dc607f94216965e66220e96" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java", + "sha256": "1ada414c5c554042b72ac507109c71ed4c3dea7d2d732e385d0a0751f4c00060" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java", + "sha256": "9b4402d0dd3f0eefa8b6b9bfe65e20f42f3e60619ac6989b18c9a74cbebad0ad" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java", + "sha256": "1609b67d37be81e5f124341bd41e6c4492e1692d18e86f4247a02c786e6eebbb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java", + "sha256": "e859bdfa85d14151ff16466662062697810ee2d9a6b10896ba76801a6d4f7a39" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java", + "sha256": "99b0753c8e1310b231f54fdde23b192df8282780e41ddf88e8242c71bd40ecfb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java", + "sha256": "5dc2b2a4a3382e2649a1d49c9600638050fba8255dc271615f455d0d9a57928f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java", + "sha256": "7235ebca987b5900f2c85d534a13d56dc9de91be73171caf6576cd7b0b2900e5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStockTest.java", + "sha256": "0a8a93e0186368fda33f90688c8d39fd6752a45ee43ddda81402f5098f0244db" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dropship/DropshipSchemaContractTest.java", + "sha256": "db6064cc65efa686bdc52ab0b267307bdb5e247479f115bac8f948bdaabb31c7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dropship/controller/DropshipOrderControllerContractTest.java", + "sha256": "bddedcb70cb4b33f3ac1383ac216cf4ab2afda607972317e76ee8699ec4a6b2e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/dropship/service/DropshipOrderServiceTest.java", + "sha256": "4edba9a22d805d89734c93965355326a72d9f1df46302fd9eb1f121762ca750c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderDispatchReturnSourceTest.java", + "sha256": "f82f9057a809683ffb8e2e711e8f05a85824bb7555e44d0337b4fda71b2ceb9c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderLifecycleTest.java", + "sha256": "965732ea6ef1237f8dad079e60b714126fe01bb572dd039e7e97b46b59b6cd8d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderServiceTest.java", + "sha256": "e7ce8f73bd1310795cccec351e77973b66d9a9133c5f479c449073b50e46f43b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnConcurrencyTest.java", + "sha256": "a29205881da91123958970e615b6d434bcd68a8f1b01a4f4dde140a9d47defb7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ExceptionReturnHistoryTest.java", + "sha256": "5fafca54e2d17f93e3d831828544e574ace96c443d786787cf4cfaefe6dbd56c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java", + "sha256": "d7e37c994804cc1038fa6b84d1c4b3d2ea0b87311505fea36b1061e267d8cdce" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnReceiptPolicyTest.java", + "sha256": "8b5be5f22f3c8c963e5a5131cc50fe3a0088a38fc77384f23afcb7f81c58e8b4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/export/LastlegExportProviderServiceTest.java", + "sha256": "bab1a9eabae5eb565a1d194c3cf2af5c111894302e17c2c32d297a60c07bbf3d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationContractTest.java", + "sha256": "280f4373c46af4f80650b01b874a141afcd385adcab49515231237ae20610764" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeCancellationPersistenceTest.java", + "sha256": "424f88eae74218406fd2eea102db146aad8cadc3f4380ddad05c4ce84557e0eb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/OverseasFeeOutboxRetryTest.java", + "sha256": "1751ed49407f2b3593894242486971aef14509b419936aa153fda6d6c0bb71cc" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/fleet/VehicleCareFinanceAdapterTest.java", + "sha256": "358c6f029a90dfbf63e6241f221ab531c002bcbe6903af01f0041422f341507a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasContainerFeeSourceServiceTest.java", + "sha256": "b0567a4349880259cf063c15c837bf10cf8414ccf3cf3a52fa6c699cb1f82529" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeBusinessGuardTest.java", + "sha256": "2d596a18dc4e95b1b7f99816af851a0429dae2a99d952c1582d93cbd8e6c4e85" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCancellationRequestTest.java", + "sha256": "f481a7b82324b015c5e6d0cf7ff9bdb9b45f6ff59740f6041d5bd562a273c914" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeCurrencyConverterTest.java", + "sha256": "3e97aad66d7cce45eaf079f02cef40a3895408a07c5b1b7fa54ab90e4807ff8c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/OverseasFeeOutboxServiceTest.java", + "sha256": "99bb738c0ed6af0070525f0acb8afc637cb76e1925ef5960743bcc938655996c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/financefee/service/ProcurementSettledFeeSourceTest.java", + "sha256": "ace1f8e504caad035e7158ef266b4595b66af66892234ac240c760c0ea3cc178" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetAppointmentCandidateMapperTest.java", + "sha256": "27d5516c7d95a9b1bc08f285b1534480ceaea5bf2fb86c363f7d6bef9c2a6444" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java", + "sha256": "5f3f53e5ebc83efe0af0e2ad6c2f23dbe72e48b1e183eb5a56263d3d9acd2acb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java", + "sha256": "124f7803298080ded538e953a4ae8563510999de997e8d2ae46be16895e744d8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderQueriesTest.java", + "sha256": "653bfa536f77ea862a48dfca0f0ae91be472742703f3956a3fb439cd0a3bb29c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/customer/FleetCustomerDirectoryTest.java", + "sha256": "8afbfb3a2d4c2b95b83d8e9aff97e430187b1a2e203a5af9ed3211776aaf6cfe" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/DriverQualificationPolicyTest.java", + "sha256": "e22b3a3f4da85ec36b5ca8c643ae383856381a0c736174bf40771a189b6b246c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java", + "sha256": "86e44a471be75e1d29402858e5d71adc360f07ffe08eb144c4485802b18a5618" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCostAllocationPolicyTest.java", + "sha256": "b6a306b3aa883b557624c1566fbb90114ec908864caa249ee27494dc0c36293c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverApiContractTest.java", + "sha256": "9fd4bc18a95e9233e77f9a064340e5baddf9c3311bc8917a52d7623a8d8f851f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverAuthorizationTest.java", + "sha256": "b4b6387a09eed8bf3c5af1d4c84c55da6770c6ac79740f3d768a795fa1462c18" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverPersistenceTest.java", + "sha256": "e4413a55c6ca953af9ad11f89a9d369023e6258e4acfbb938e5113985d9b3b10" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/DriverServiceTest.java", + "sha256": "7902ea9ce939e244d7e97b94a5134f8ad484f91861fe78bbc8186e22f421d555" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/FleetWarehouseDirectoryTest.java", + "sha256": "2afe1e58d34a89b16ad3e5bdfee7aa54161ea9acf30806be00cb7f0a20d9de31" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/driver/HttpDriverFileGatewayTest.java", + "sha256": "ccb49ac64037d6220fc53a83051d3dfffc0dd6e79884880bf87d2f67bc2bc455" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverContractTest.java", + "sha256": "1e20a1d6a89511ceabe6ec5e453db1a3231f09f3dc9398a4cddfb54d253b3c63" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java", + "sha256": "cd410887e74c7580017626e18102f12e472b9e0f05b4cfeff3fa5ee491a57730" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicyTest.java", + "sha256": "ed2393158e31e075407a44814706105b8fad0832f7180f82d34d8b6c29aaacb9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java", + "sha256": "503b1967aab7947cf59d011befced56dcead2b34aeea16d354e944eff71205fa" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetTransitEvidenceTest.java", + "sha256": "aa6227407880771cd56a2c2aa362ddd207de06c2570da50fd7738a14493bffeb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/HttpVehicleCareFileGatewayTest.java", + "sha256": "b7039f8ca1dfb0e0ba969ec5320174c69e80e7e428d1b85615140a8ed41069e8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareApiContractTest.java", + "sha256": "f0b365103903baf3a98f8d9db5ac7fbe0320cdb9669c1fca573775b9b0d66930" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java", + "sha256": "fc64f65fb64fadcff75f9a1e93a510d66e6062faab60a91d9dc024e92e5bde8e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareFinancePersistenceTest.java", + "sha256": "27b5221ec550e661737131831e1854996d9ce832704df020738b7a55622c305f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java", + "sha256": "b4bcac08f87f77461ea2a701e1c43585da533748acf46dacfab69f3ba60aeda5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java", + "sha256": "8e0a369d9d40deb44190e0d0b8a75312f2cccbc363367094cecd4c4b524e48ec" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java", + "sha256": "1d12f436ca1117c0b8a737a7943cfb4ace4997a7777fcc06cca324143e6e942e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidatePersistenceTest.java", + "sha256": "05506a4ae98b0b87c5652e6ad60e1be8a405cb1a15d6694c427d880fec7f3005" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteCandidateServiceTest.java", + "sha256": "f20b0fa65477cb6598e4e8f711d0a969223d7ae7ed8166ae59bd363c4148ea13" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuoteDirectoryTest.java", + "sha256": "301a16e3f39f78723c3a0bbb361dc9f33f26991f5930699a0b4eb81b208f78e4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/quote/FleetQuotePersistenceTest.java", + "sha256": "a7d8911c27508bbcb545757c581e657b6612aeb88e2f667860f9693f22053410" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGatewayTest.java", + "sha256": "667e43e286309f927123bf9ba9e1be518e33fa7b9b256ecb94c684ec820729a9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsFinanceServiceTest.java", + "sha256": "f2b807c65a500c64b2cc44c75ff4c6d332c491d79dce6f6345befaa526cf8709" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordAuthorizationTest.java", + "sha256": "9623063c15869a5d3dfde0bab00fb755b538d3f90ff0c590abe9d629c05ce344" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsRecordPersistenceTest.java", + "sha256": "46721971787c0f608fea7d5866bc500d2f812ca0cc447f32f04afc47747e5be3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/reporting/TripOperationsServiceTest.java", + "sha256": "fd910eac589d4c8ce798aeeffa1972a53bb50f1ad987fdb70ea342b191b2a3f2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingFreightTest.java", + "sha256": "fc296b008097813f547482b8238aedf86b968a6cf4aceb44665cbd3eb9e05970" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingQuoteContextTest.java", + "sha256": "6ec6e01724edaeb4fe84853852a433ec7ea5a644883409ce2f0b7460bee895c5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java", + "sha256": "8118d071a625e7a8483aa4528a5718cb0a7a51dd172b3def9923966ed48c73de" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchWorkflowQueryTest.java", + "sha256": "1ef929bf3c7fd7745b6dd0acbddb6747c3e4b223fb99f548a9e321f87113377a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetInquiryProgressTest.java", + "sha256": "957f4191c7c549d35108aaf5a9ea96bc82d5c8374c173e06e9692a0fda7c8f62" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetReceiptCapacityTest.java", + "sha256": "479686f7f6dad52bf31fd5ab102ef7a59c2f9383f977fce75a7b254282cfba19" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java", + "sha256": "35a080e4b4c5bb6c27208d1ea4b63a70e504825b5eca501fd9029eb1e89fec0a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java", + "sha256": "d92ce0d8b5c02697418ad1964989dcd332e3a17d3b407b551a9426fd5d456204" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java", + "sha256": "f820bfaf0c8b0675ecbfc3cc9d1e0c0e84b2232934d3465595ac78224a198be2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java", + "sha256": "e3128ee73b52e1d61a83926497993f6cd9e36f4f787112622b0d59990b7140a5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostContractTest.java", + "sha256": "1ac98a5168a6353e3d3c45f2c41be9ad1a512c916a211f307f6defb8198adaba" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCostPersistenceTest.java", + "sha256": "0398efeeb0cff9a53973b979eb8ebb9803e0dfb7638d9fb8b9fe035d5e055f36" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripMatchedStopsTest.java", + "sha256": "e06d60e2c4e2396fcb8913d30f375dccaef6dce50000923c8a03f54e69bcde15" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java", + "sha256": "ebdef00c4851de565b123f1cd4a8ad6cc9557927edd871ff645292052c882a4d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentContractTest.java", + "sha256": "8407ce0d3ecfd1c911249023c84d6193345bcecc624bacd7d710c2dcf92aa08c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java", + "sha256": "3342a791ca6e58ac24467443a7109cf8f783cc3e437a2d4661cd174fb5ff1f70" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTransportProviderTest.java", + "sha256": "949021b2000f803af7b873cc7160c94419281d286fa7db4242fb38fd40678c0a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/work/AppointmentWorkTripChangesTest.java", + "sha256": "b4500c7d0e7c59dec2be7baff73c4f89f222b8e28b990adc16845b82cdfcb00d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/InboundPlanSchemaContractTest.java", + "sha256": "ee64cc637701f0289334a714784d177a0648a48450ef6f3e73a6f6f71e7c3336" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/TailPlanCommandOpenApiContractTest.java", + "sha256": "1af56fb63c9e71927758e597d27a963f1cfdc0d0b4031a208f4f55dfcd105c31" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanControllerTest.java", + "sha256": "07e12aeb326f43db6189689ad7116307a4b43f6e76a4df5df373eb193b9e74e9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/controller/InboundPlanPermissionContractTest.java", + "sha256": "20dea33ba9f25d19c3be005ae5953d6ec46eece942b1446aa1b8a3eb200d34d5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java", + "sha256": "bc8fdcce85d94a20dccd49ccaa20f934dcc814158f3cdb4234a2e5dcc4eb45ea" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanOrderMapperContractTest.java", + "sha256": "7291a667bb742c4a17a63e03a9767e9b6086547ab9ce0286951986e907f4ab54" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java", + "sha256": "4e7fe42ce922b7e14eac1d745119488648c8a63cf6233ef6653fd5906805a7f4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/UnconfirmedContainerResourcesContractTest.java", + "sha256": "4f1f0802ffe60b3e9cef9eb04185ebb689eeb74eb3e86b169e0af7268192f201" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingContainerContractTest.java", + "sha256": "5787e41d5d7250f410fcf34a727de23cb276edd37797afb9d6bb5089272bf30b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/ReceivingPhotoUploadConfigurationContractTest.java", + "sha256": "14b84fca447e5b058c4e58447331903e95ae3c87beec5c66a0e4620c1b7b7271" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/controller/ReceivingContainerControllerTest.java", + "sha256": "172c2cccf340dafbfeb2b8a7d227d5620fb1f0d5ba2123239e790f884ba898ec" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpDomesticLoadingEvidenceGatewayTest.java", + "sha256": "4571fde66a872762678c18dfd487e5a2af22c25ee2722cec4994418777b9b461" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGatewayTest.java", + "sha256": "237ce7cd5fd2681b71f1d135dfa311a4ce268f7b776fd36bbbd57b5a90256a58" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java", + "sha256": "73e72fe0d038e9f54077ddcb5ad108cced82dd5f2ee76c8ae0f37cd81122f921" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java", + "sha256": "4aa80212765f0f08c47da26ceabfa25b424809c3579c999c642041fe6a871846" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java", + "sha256": "1c94b6bbd940b431355e557b08fc580aaace9bf01c8028f5b68c3d6db1eb948c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundStockReceiptServiceTest.java", + "sha256": "0ec655594d0200c3edb4568f3fbde907dc170623bf0841e0bfc4012c39247001" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxRelayTest.java", + "sha256": "358fdac359d84f892f519c9471f6ef989708b4698857f58490fd51be786fddd2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundUnloadCompletedOutboxServiceTest.java", + "sha256": "213280890ec0d4429da7fc48fa84df972a6ed2fe271887768e4d66e5c5334534" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingCandidateRepositoryTest.java", + "sha256": "d3e751145eda3b92fa80d3512b765f99ea086dc11cc641b29503dc138cfe220e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java", + "sha256": "65d79b556e1c82c99348382fd924de1f6dc914a7c752db1e6dfc8bb4b101ab8d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/receiving/FbaReceivingAuthorizationTest.java", + "sha256": "c518eca613e35d3f917d6e1d37d82d3964f663afbf35a9a356f1b004d83acb8b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentCargoVerificationPersistenceTest.java", + "sha256": "52acfdb4ac1745b6b75ef91d06ad1fe4caadfaa1d08677c95d16d0aefff09fe8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/AppointmentWorkPreArrangementPersistenceTest.java", + "sha256": "8300008cda492a6be58542e1ec5e22409ebb2b22caec315b797a2c5dd83643a2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java", + "sha256": "0944bf2220d0413ae86f47d003ada3c382a6fb04c6266ac67801b2730c0f5d2a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanPreviewTest.java", + "sha256": "eed5f9e57f9d0016629a6326058637debe98d9a2da059432e69443075b4ad623" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/DestuffingPalletCostServiceTest.java", + "sha256": "f7e7e30cbe1b4367348bbfee44904e614a5a42af926ee7a5815a70d618d86f03" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java", + "sha256": "222cc71e3b24f7bbe25cc743fc964891f6d2c385e276744af59b45d5abe72d7c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java", + "sha256": "2239a4a2b1dfddd397157d71e8f7d197cad89356d86cd86d31ba4b4bf7623bac" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java", + "sha256": "504c79ab02cac49cbb5c4698959ace76d2c3970532bf9e3c372e64930f449407" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java", + "sha256": "cb6f148c2831b66849f0f6a277e94a9c0d9b4fbd3f6bb46908e6e76402e21d4a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java", + "sha256": "492c9d7294aa75a2586b88f4900180cd68e40d835a41d30751ee081190e0f7a0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java", + "sha256": "eefe737164ba3ca463a86e8aa8e03e55c767fa750167c337bb4606e42c6bcfa6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/PalletAllocatorTest.java", + "sha256": "a69a6e5abab5e767679425e4fc29839e2a74f07703376f1c2c0f23e92e9ad31b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDeliveryProviderTest.java", + "sha256": "bbe1f518264454f247af5c851fa84352389a2e3078066c05c11c66e0f59e451c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java", + "sha256": "df2b0e6a7f86f2678ae8c857a20016e1ce9a7ed2adf6870571e0577974212e61" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java", + "sha256": "06b3fb5da55e373f874e6f700a699f60f5edd1c8d1a33498be2da3e154853f27" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java", + "sha256": "272367f8cb1a3fe92506f7c5664e4998d13518e5ad13c9394096ba176a00fe12" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingGuardTest.java", + "sha256": "fc4e54e0dbde03e7dfa2b1f9b1ad081d77c719c68b8516e190d1b0d1e1968348" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkReceivingProviderTest.java", + "sha256": "2df281b0b17807be6e326a8ca227d87ea7be299b899e2b2f3eaa7c1a6844ce6a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkRequirementProviderTest.java", + "sha256": "ec4054e3d97e7c27b80c0aaed69596bb4f5f84f4df95a799619cbff41c4ed1d2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java", + "sha256": "076198846409ee929853807d3e822c9cbf106bf26fb09a69def7bbe2d715774f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentTransportAdapterTest.java", + "sha256": "f84644e00665b1579d960ae9a6378490b1f325f862c6beccbbef9291f5b7d533" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/InventorySchemaContractTest.java", + "sha256": "06e7d9d03fb47d8793c67e4f846c091d1d8bd28a5909bf3938ab31853734f9c3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java", + "sha256": "6c2f88cd69d665591998d7bbcdb3d1916ff825c12f76cb03b84a7284f6e92be5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryCommandServiceTest.java", + "sha256": "3c3f91d68d5f0020bb33090da7abb8838fa3115940b231538c1ae55ee2e178ce" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java", + "sha256": "b38f7ac9f0d7dd97d7fa867ec06589a917b147b546723c32e820b1815781a5b8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/controller/InventoryCommandControllerTest.java", + "sha256": "d5585413b6cadc992e1b2230e529b8cdd5d860d2fc6c81bc7694744c7742cf37" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/mapper/InventoryCartonOwnershipTest.java", + "sha256": "f8b06d707e5097b7df0350086f1b188b461492749267803eb53516b53916c450" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InboundCodeRelationshipOpenApiContractTest.java", + "sha256": "c11aa7bea02fdb49b2cb847951c4943e12b1c7269b46833502726a2fe27b9ff5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryBalanceDetailContractTest.java", + "sha256": "7ce51610a0bade469db6a5a9cc2856adab7322cc957368660490979350e04779" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InboundCodeRelationshipControllerTest.java", + "sha256": "500b606aeef1452e8018173f86e38c9d896088ffdbaf0978b3ede109bdd1a360" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryControllerTest.java", + "sha256": "5bafe4444332cd23f2e6766f53d7c5c43289049ebcc2b1ca4379bde2ea60285d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/mapper/InboundCodeRelationshipMapperContractTest.java", + "sha256": "9ae6c396bb7085c21ca22d21bfac1c8ba9bbb9d3f942988403bd6d1e27efa388" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InboundCodeRelationshipServiceTest.java", + "sha256": "848a209e75355cebbd6069bc4439b330a2c25f96de99b9b733a81e6543eef0a7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/service/InventoryQueryServiceTest.java", + "sha256": "ca29236d15b73357439f78d39bf7056b4f31b71055a6581f845f1bf8ee8b5374" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardPersistenceTest.java", + "sha256": "3799f6a3e5002d69b23e743d740d30608405bcf972e3c8015218432da8959ef8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardRequestsTest.java", + "sha256": "6289cb9638600e2c20096557b078ea005e71053af56b7017e9dd11ea5ed319dd" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/LocalLooseboardSchemaContractTest.java", + "sha256": "175bd91409cd0686984f340e930762eda2d93512c081898ce3d8e1d0b20fac86" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/controller/PlatformLocalLooseboardControllerTest.java", + "sha256": "e67512c3b49cf94e801de6ef351c9362b0153c5929b630284937ab87d758abf8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java", + "sha256": "c0a8fa40c2c8bca0fec5f5285bc85a6ee08fdf1796587d76debff1fbebde0a3d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapperTest.java", + "sha256": "0b6e74c1bd0b3926ef8244d0fd18cf932e09a405f0481b21cd51b4f6df1eeece" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/mapper/LastlegOrderWarehouseQueryContractTest.java", + "sha256": "2c572a784054297de20c8eab785ef3ed67c239241e6bab8424f7ebb4966fa21a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/mapper/SharedDispatchReturnQueryTest.java", + "sha256": "941b9547a878703b20a3bb18c534639bad64d2b26f3e20fad03625ed3c868da7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java", + "sha256": "0b7f256404ace8e4969fefdef05ff02bb15776d06524f035cd95be107299e054" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/MonthlyExchangeRateSqlContractTest.java", + "sha256": "e5d5ca68aca9124e762182a33365abd8a3fec769e68a87ebbefbf9d467665f95" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/controller/PlatformMonthlyExchangeRateControllerContractTest.java", + "sha256": "b8939b63c8ff477e0b6e5def0f0ab630f4d367cdd155776b9bd816a0ba52a444" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/exchange/service/MonthlyExchangeRateServiceTest.java", + "sha256": "93ae6974ad6207df041b5c1a9e3815e930cc4020e7adfba38b00117e60feda7f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java", + "sha256": "139cd55042b290c2e683fde13ed1adefc98a07b20ebaafc623ad2d8734e1bf81" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java", + "sha256": "69b825930c9e14e7215d683300b49545a632bdd7e522a2716ebde158b9c2a1a9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteServiceTest.java", + "sha256": "5ca9af0d36e14b4a2de21bd0378ddff5bff008d24fa10a34ad2ffdc86e0d7808" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java", + "sha256": "fe0d49428f99288b24c3fe7696b5f196865f0b990f02111823e33d416a939275" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java", + "sha256": "2b3967bcd7076e9543d54daedf39c3e6781a8e5d7888809e238660fd769563c0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java", + "sha256": "28597b2b3664282588a5b9ae065f845d97173d58be504058726a0524e70c8de2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java", + "sha256": "87180dfdc7966e8c4ad943ebff51191cf9ec1ba72b2517f8280259a5a16a53a9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasCustomerApiContractTest.java", + "sha256": "1466cceefe7f217c6dd049014dd3c885ebf4ce254e32d0937f6817b7a3303aaf" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/merchant/MerchantOverseasOpenApiContractTest.java", + "sha256": "599899db0831bde0e9946de6cec0fc7120fc249435728fb76beef18884ab6cdd" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/model/LastlegOrderTypeTest.java", + "sha256": "5e9f8be98d766f5cfa7ba5c5a23d9cb9908cca045c66e14f35d51deb3cad62c6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/controller/PdaPalletOperationControllerContractTest.java", + "sha256": "ed9ea0cd62d680927f3f1c738fce5f84b4bbe812970d4055668d0617a20b8be1" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGatewayTest.java", + "sha256": "9104bca582fbb0574cc54fead983a8805e6c9165b5277c882bd67286293cdcef" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java", + "sha256": "43cad4683ec5954aca4c26fdf9f6bba70036daa26c2eaa690246649d6c607b0b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaInventoryOperationContractTest.java", + "sha256": "7e92d7cb1f1b31f0e1cfe949f1a7703146aebe2f0f8163b0792302af79617b5a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java", + "sha256": "942f75b836a815d284f14691af55cee2e94d48866f86bb6cb64af92a720de02f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java", + "sha256": "20ca09d5c10a5b013cec1804ee03170af0f5beb5f52ee8ce5e9f1404bd9bd7a0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/pda/move/service/PdaInventoryMoveServiceTest.java", + "sha256": "86ca7e679fcead8871fa4bfc81268b92c9b151f4dbbd36bbb731a8e29463d3c4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/pda/stocktake/service/PdaStocktakeServiceTest.java", + "sha256": "366edce3cf34856bb5e3825a77631d4a31e9bcf4abe051df6c3fb0289daf30c1" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/PdaWarehouseContractTest.java", + "sha256": "63fe8decc7afa67e97df9a3e02add9348b634c0c3dc1a2889b7e1a52fee7f8e2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/controller/PdaWarehouseControllerTest.java", + "sha256": "f479306a5a369952ac6be72c05751bd242c08aeab8a78532d5cff2aea1c9f67e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/pda/warehouse/service/PdaWarehouseServiceTest.java", + "sha256": "f3be7cc7d2e42d46d7ce16befaf064b627d619c06c3ef1247b15b00fbb5c5093" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementInventoryBoundaryTest.java", + "sha256": "d53ca8f704a056d5e7103d0bf167e3169f8a2997d13d303d23fd9b512a5b65b8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementOpenApiContractTest.java", + "sha256": "e64d445aa46526990034c128d081a05a7ac4769df780de2b930aef0dffecd96c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/OverseasProcurementSchemaContractTest.java", + "sha256": "0fe9238ab6a4561603acc378ea1b689beaf38a0d143cf20b145d4064dc636287" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java", + "sha256": "51538597d31d4b6f65a1cddb6819e4d2e9335e9486eefa553bd73ba510803fb8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/OverseasProcurementControllerTest.java", + "sha256": "66f693096a5264b4fbde5dda10cb3bcbc6995406a4736a7a9be042854790e360" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java", + "sha256": "2b71c41f785a8ba5230c787a2ab35574af5ac1e3421bd3cfcd9643a9e15bdd48" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java", + "sha256": "1a81cba5681271f44074e840980351dafbd0a025e3357aa530701c8216a301ad" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java", + "sha256": "fed13fe682a20c7847834d37adb141f3aaab185a7ba25a861533023ead27e089" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java", + "sha256": "a11b1446a873cba850c4a4a031e8e0a013ff48f78438dda2294491bfbfbe51e3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java", + "sha256": "f61c2b0365ae660c7262a8e8ac855851138b64be0885b3dbe750a0f891b373e6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java", + "sha256": "56b1389b1a50b368f1ed7315762a4525bbcce08b4adf5ce1f9cc32c3dfd244d2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java", + "sha256": "5760cf2d000c25b2d3b61688c0d4b22a5e3c16f4f09397ca77a1c35c2b564948" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPurchasePersistenceTest.java", + "sha256": "5c9213c8b53a9ed14816346677cf139057a46620956dcf2b1495c009bba7ef86" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java", + "sha256": "ff4753ae9f6fccd85799baf2a6151bf36aed4e8063a960634fd99b7a1a1f9af2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java", + "sha256": "980f4e283c489941adf1c8e9b46d1d3f5aa203cbe4688ef6b920d4eaa847e81f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementWarehouseDirectoryTest.java", + "sha256": "5c07c911167a1dcdfd9b9315eacd1ba1a8989fef4cf4a75f9903140c761370d6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelContractTest.java", + "sha256": "9260831e7ad2b36282012804d77d1ea79d2a3e35fcab0f14d6c899a903c69272" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/RelabelMilestonesPersistenceTest.java", + "sha256": "48736bb74804f4954d9d5b4addeccc5ba8eb92c9beefc6a36750d4b0376499ff" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationControllerTest.java", + "sha256": "47ca115376bac15da73776f116b3e18e9cbd682c0afc278bf6c1f3c8201b059a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationControllerTest.java", + "sha256": "7a16b7f8d104fb96a42381b31c9d7855c8dd2052c4e65e906d3df8b8d3c40f05" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/controller/PdaRelabelContractTest.java", + "sha256": "cb048aa089065f301558fee3979e2c1e215fc5d120af60c1cd9e12d9a4d692cd" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java", + "sha256": "a7a94f349f6832c16125b9a9a3a606d1ed084be278f011a74963edfbd79a9d01" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java", + "sha256": "bdcba41ea080bc46a43328efcb4ac435a794ba0307de50c081dc0ce03371f635" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelBindingGuardTest.java", + "sha256": "3ed4935e170b73e74966130f209d0bc6db99313782cd959ce5227bbfbfda6956" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelCartonMeasurementServiceTest.java", + "sha256": "0af04ea138d968a093ee4d9cc9de27579771a5f8b276472025738e2c2fa82056" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDocumentServiceTest.java", + "sha256": "828a438dafcb475db000421558de3035d3256a932c63dacfb5eab04e3eed14d8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementMapperTest.java", + "sha256": "79d456fe4347a8b0abf9b96444d2c0e80104884e674f847d29f8eec21f8baa6b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMeasurementServiceTest.java", + "sha256": "8719ea4f1470a4ce7b4a2cad7da778be39f88a5611f73976a1f873d849f85b50" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelPalletMutationServiceTest.java", + "sha256": "0dd9e6b1f3e521b4ccc566fb6efedd437275c212ddcc0bd5aed93e4a05a6c45c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelReturnCartonCandidateTest.java", + "sha256": "2c41bd77b2b030974255dca2b1e2cb34456458f6475df88b95b673470213485e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java", + "sha256": "1ea1e2fd33731867b4d85078b176d169213e6b011ac8b99d94917e5b4fc46a12" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelPackingServiceTest.java", + "sha256": "435ec4d08f36dd1294fba971dce64923f80f68dd5f1d4a4fd92ca7e89cdff428" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/repository/OutboxLeaseDatabaseTest.java", + "sha256": "61be094e21db40f31106cc3a11819a313ebdda8d1f11dda21d7c6d3fd5f95908" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java", + "sha256": "93bc15ce1f2453977fe7fad7b5f7fff25d8682523e6d1500d75a9e3ff08b9b8e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/controller/MerchantReturnOrderControllerTest.java", + "sha256": "69940ad27c065a0487ba330fe65bd69a2af7959f31cd94a2a3673d95627b39e7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/dto/ReturnQuantityDeserializerTest.java", + "sha256": "91a8df7a206297e7b0be26b95cc8d8c6023658e268a471ba62ec3d3d4553c237" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonBarcodeTest.java", + "sha256": "2ae8611d7db6f7bfafeb9cb7afe646710863d04fdcc9eee70d53b30f6fb95aa3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonCountServiceTest.java", + "sha256": "a532587f4fab60f814b012ea7258070e0058267ddeeeabcc484f8b774ff51395" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCartonQueryTest.java", + "sha256": "44c3ede84c338c7f54fbbd2c553ab4815117f46bc737181a5167bc560e5931d8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnCurrentReadContractTest.java", + "sha256": "26ec29fc8e4a095c676dfa2097fb8366c7bda7884ef3d4eb6accee936074592d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java", + "sha256": "b09370221cffc3f1a5f1dc3072d9750db92b9c2295e3ee6d04124ebcd871c445" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java", + "sha256": "571d6117d4142ee0f6e5c25915b03a83cf3567bf7c50670380248e28c342ca0c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnPackingBalancePersistenceTest.java", + "sha256": "61c4ef52999c1b365c5d6e75a0175393c322cee9151ca6c365621b3e141c18fe" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnPackingTracePersistenceTest.java", + "sha256": "0c91bc7f098ba52cd42aacad1b0be5d0e4ec0df8f2b87a7b6492931cf06718cd" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java", + "sha256": "046270ded5e884beb15f13c45738aed06f485ed8ed87c3f2013026dc7c6726c3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuConversionPersistenceTest.java", + "sha256": "664e8b949553924046aa1419feced920681d8aa16f5aaa3be5aa7fa19163f07e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnSkuMySqlTestDatabase.java", + "sha256": "21cabf795b90cff69788c7a1a0c7d56891158a0e92e3f084f4e1a63b57377601" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/schema/LastlegOrderDetailSchemaContractTest.java", + "sha256": "9149fabc03974028d88b7325f8031c7b687bfb6eb0ddc72122d3766f06fbd0c5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5ExceptionVisitSchemaContractTest.java", + "sha256": "45ba0ae6085c74620bb74f4d793fdaadd9d4ffb9321a1f31cc8e0f6ccf380f60" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/schema/Phase5OverseasUpgradeSqlContractTest.java", + "sha256": "695cd2f4baae9ece768cb4bc9dd4f46e0329d244b9f3fe361085980e6545f7de" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/schema/SchemaChineseCommentContractTest.java", + "sha256": "d91cb13c48414e47e31dc0d14caa96600f696300a153800f079a36e5ce00a1c6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/schema/SelfoperatedAnalyticsSchemaContractTest.java", + "sha256": "983d95516f60a610b6256ccd8f1e3fe6d20384f853ab0d037373c3cda45af4fb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/security/InternalServiceTokenGuardTest.java", + "sha256": "eb015553b0f2a9c55bdbff9685ba7f7e7dff3e950949587b19f0e341110d4fd3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/security/Phase5AdminPermissionContractTest.java", + "sha256": "f4b1a81b0dd85e88ea3444af27153a4f29297c1e9d5f3c9d8f2798642897c4ee" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/client/RestLingxingAnalyticsClientTest.java", + "sha256": "3d9424f24d3c9f6ed54b2b8fd197f4a53bc8b69064b7b70e0eaf2c359ce3be8f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java", + "sha256": "aafa80d3b0c7c6407490a9dab4dc1099d7acb41b4afada235561cfb70a670d55" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryFlowProbeControllerTest.java", + "sha256": "ae7050e5b1e276e3a860c09d1a57b86a289bdcc837f13b24563a93943a005a47" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformInventoryTurnoverControllerTest.java", + "sha256": "4422d3f3e7ee23f9a949fc95985b4bdc29d08acdb470422385478608d544ee27" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticControllerTest.java", + "sha256": "bc83006be6d242c9b45afad30f793d6c19623a3a75e6e3fe80489c83d7479c70" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/diagnostic/QuantityDiagnosticServiceTest.java", + "sha256": "d9127084d9454e2ce81ba85d03958d916996dcc8a45c9c0e6c49fb3eff268fc7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryFlowSliceTest.java", + "sha256": "800862db47a32203089339f831a677bb06129f9fa3e7ba25dbb628f765160eaf" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryCalculatorTest.java", + "sha256": "7811a5d5da2ee3598c0df49b06dcd2274a4c7bab2b3d9c136233915b35729051" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryPipelineTest.java", + "sha256": "d900f5374d3bc8789596443db980d86c58a1baba9c81db90637232ef1d5f2343" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/history/InventoryHistoryVersionsTest.java", + "sha256": "b3df7da35f5f4203ca795ec2ebc1db13f2256e63f0148090943c597dd5844e24" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventoryPublicationMySqlAcceptanceTest.java", + "sha256": "1557f860d42eb61cd8d4e4b215827a29ed17a4f3c4de165a66d253e74a0cb771" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotAssemblerTest.java", + "sha256": "f0aeb369b004667d82a60ba1d00c60795e29876ec9e739bf116ea6cb0ae98371" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/inventory/InventorySnapshotServiceTest.java", + "sha256": "ed3da8901c3b8d9257032b467bd39cb8106899a033280d768cff60d9ac7cbd2b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/mapper/SelfopMasterDataMapperTest.java", + "sha256": "a098b271c9f4f1a1616914da4a281ca06812bcc337b76d1e5401656aa26162a8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopAnalyticsPropertiesBindingTest.java", + "sha256": "90e9090bd03e9fc81819ef7cc3d1df1f9c93b15e3b5fe2188e154e0ba4a6fb6b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopFailureTest.java", + "sha256": "6de16e65c0235d4d2f880d727a6d617205945e23856539cf9f1a512597a84b5b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopOutboundFingerprintTest.java", + "sha256": "0fa50416b5c7d2a83479fe1904bae262fad0a0b7468c7b4ce47d4b1a61ec9c00" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStageEstimateTest.java", + "sha256": "ee2672bee55b4da5f8a55de218aba095342da457d71d276c44f4650f56dbe369" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/model/SelfopStockFingerprintTest.java", + "sha256": "6a5ebe8b419a0bba079a59de0f98e80ce97a631dddfd88c7c664f0f3d2bee3c3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundAggregateMapperTest.java", + "sha256": "53020210fe6099e136150e1469cc51ea756bd92dbbc6fe8b76adb8dccb6493a7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuildMySqlAcceptanceTest.java", + "sha256": "166a2c3a73efe780ad203ddc85f81bb09cc1316f2dede3f2ba48cd13c5e17ec2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundCandidateBuilderTest.java", + "sha256": "b8491489d41a254c27a42324d16dcc75e7c7bab15e6d83ffac136bbfa289c682" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundFactServiceTest.java", + "sha256": "8cc0b830e6a44fa8f9778876e97211a17d88332ea28ba687780a6e02e809c9e2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/outbound/OutboundPolicyTest.java", + "sha256": "3d0ef62f56aad4c24510709057a0cbc1217629e4577e4d20d3b8b5c6d845d26b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsQueryServiceTest.java", + "sha256": "aa2be910475c07c4e26203ea2a9c923ed769b3eb291e7317cee199debd3eec6e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsReadMapperTest.java", + "sha256": "784a8d2a9ff718eca9a1700103a36d97e7db266da1f2cb43398d7bc808946238" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/AnalyticsSqlSemanticTest.java", + "sha256": "71e75c7efdb17af7b287673ac5f2bdeee2b78910fdb31f76b9ff59d02b40586d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionDailySamplesSqlTest.java", + "sha256": "05c4105b668d248ffede37146206eef02f7c7ff2ad2e12baf4c47ef47d92f96f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/CollectionQueryServiceTest.java", + "sha256": "ffe77618fb2f50e9ad628befcf863acb8c6dac3fb35af8b804dbce7415cd2f2f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardPolicyTest.java", + "sha256": "5cef3cb40b8d3283bf6dab9edd4aeb81326cf385792db1ed300b5ce40af4b7b0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardQueryServiceTest.java", + "sha256": "0b22be8c0635d8564fb2e49d66a7ee569057214683c5d966019f7b0f4bd876a3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java", + "sha256": "59cf4b91f285fa77604266c9d01ca67b7faf3355a582b4133ca178e023468758" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverContextTest.java", + "sha256": "2d0a52dd89dd32d33520f109af6e9f77e366f9eba2a62ae706049350a5d92c2a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverExportTest.java", + "sha256": "d940e9edd4a6ba9f7186c8ef19f4ffdcb1124be462dbafec1165a587e51759eb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverPolicyTest.java", + "sha256": "6d777689175bbc54a6a8ee8989337b6ed6bf9bed1b4b42c1ecf0c9cb472645ce" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverQueryServiceTest.java", + "sha256": "871331902de51bf9411115968e15e2b251f8b990690fc29f28dce0919c5d2ec1" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverRequestTest.java", + "sha256": "5a3e7f33baa10d43cd3b4f13208c4bbcd3b6ecd3c3700f973710e71b745148eb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryTurnoverSqlTest.java", + "sha256": "16a74cb96a00d72c122afafa53eaa7c55219ce2332acbd67b9db273c233d32f6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/InventoryWindowPolicyTest.java", + "sha256": "f41cb3d7cb9dd62e87880940750a7fc90c4a2efa29acfb1cbffc094dd69e6619" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/SelfopOwnerDirectoryTest.java", + "sha256": "fe8f6492c00ce73505bf2fe47daaac3e3c81c7c5c37542680c18a1a466fa4a1d" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/diagnostics/QueryDiagnosticsTest.java", + "sha256": "cbf0dc568d4951ee37119fc5e71f09d5899ff997b4b503065409321dfb1528b6" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetPersistenceTest.java", + "sha256": "d0d835008e448e5ba73f06499dc992203589b67c54ce8ab6bc693b3d2eb50b24" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopBusinessBudgetTest.java", + "sha256": "5308eb6a6f5822af3312c1a0a6578314aad2a01fa08827f7c923f3303b2c0025" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressControllerTest.java", + "sha256": "95d810f0dbcf4b9c28fbdc54e7dcc31977382a47808e7ff6b917ddf0e40bc28e" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/request/SelfopEgressServiceTest.java", + "sha256": "b1633b7636605c053fc13297d367a74012076776c9bb44dc12226c346f2800b2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/retention/SalesRetentionServiceTest.java", + "sha256": "2710e37f348e7e8263cc4735085a23f115df490304e78284587606a4f1d6afec" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesBucketPolicyTest.java", + "sha256": "1b3a13022ef316e8da5d4fa1834fdf8c76028b22f973866ab8287449e891f40c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/statistics/SalesRangePolicyTest.java", + "sha256": "4d0efa7492003695795da9efa2334a1ddfcb1214ec1c41bd1d9f67d34e816004" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/InventoryFlowProbeServiceTest.java", + "sha256": "d1a48fc8170fdf636ffec18800256d793113b684b4b0049e0848085e821dc1d0" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopCommandServiceTest.java", + "sha256": "8e38dc7b3eaa06f895099e0029393820eef8ec1fd9aa164498a96e1f871bad8b" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopContinuousDueSqlTest.java", + "sha256": "028ad6a612037d03b909437c08bc1cdd252e1ed03d1e896bb0518241b6033229" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopDiscoveryServiceTest.java", + "sha256": "6583bd63615b5d766ad7ad08ac5f225be0115c8eabf5acd21f51c12ba9d315d4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopExecutionServiceTest.java", + "sha256": "62f3e0e9acee498effd36b3bc9da57ac41c906e1bb6a13171eaa36c8c406b419" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryCollectorTest.java", + "sha256": "ec83b8a1ad8a1562b53dc8bc9e71351fe3f200bf2fe50322d5756d454f69360a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopInventoryRecoveryTest.java", + "sha256": "5ee6ede1c71765361ce2939ca49f4d1c8c3b75197f60a8ffbf620db798aa86e5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopMasterDataCollectorTest.java", + "sha256": "94af9073a5dec2852e156fb8629c91125f0f8b8701a6b215961d399cd97cf89a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopObservationReservationTest.java", + "sha256": "3e7a31db9f0b1b8be7d704ee0b0185346b4851d9219c9a6c77a7b47927f84d85" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopProgressServiceTest.java", + "sha256": "754bd4bed8186c19e6613ef30390420bbff4bd973dedf0ee5839a2ace1da9066" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReferenceRecoveryTest.java", + "sha256": "1e633ab1bb6cca2d654cf33252d4df474d42530d8eed30b1d262ce1d82740329" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopReusedRunScopeSqlTest.java", + "sha256": "8e7c44ba1e6cef6a3c96d3f292319653546888419dcf373f98e3f5767df002f8" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSalesPublisherTest.java", + "sha256": "ef687e25f8dbf5826d64d5d18272c0e0b9279053df414a5e1545e31f6e983ce9" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopSchedulePlannerTest.java", + "sha256": "e1c892ce354a077b6102ac1328149a05bf841a10387d527cdd1094c54bbfa5a5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopTaskServiceTest.java", + "sha256": "a22cbf088fa1830f235fc0fe8d631e340a5217824076cc2a13c0ea881f63ceeb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SelfopValidationRunnerTest.java", + "sha256": "71dfcbce9ca7da4032652e55455e42bab295ca3203e9dd2209d65de9ff416a23" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/sync/SyncControllerTest.java", + "sha256": "bb66ce6d473d94ac6f944bd867e990bac62bd51f9a833e697d24a844511f13c5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java", + "sha256": "ad173e8f142788c4194df471c248f2ccfcb07245ae1db6fcea94ca493a440ee7" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerArrivalTimeContractTest.java", + "sha256": "4de8c07d090890533b7f09bd204506bdaa5c7c9372b27637f3701353e9c21ae3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerPlanProgressContractTest.java", + "sha256": "eb5fb3d363082caaa759ea9377c266c370b985837ad8799a8d6bb6341740af0f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/CustomerOrderProgressServiceTest.java", + "sha256": "399de5ae7208d23f01c6d9a95a70fc3d489f80ea1996a27487fac7b126427de4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/FbaWarehouseValidatorTest.java", + "sha256": "a75aeab9934db8ebd641a08ee94bb244afb820c759def08e6653939aef6f0a6c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/InboundOrderDetainedOutboxServiceTest.java", + "sha256": "07720cc9d4b5d95567ab9c410b87fd56a9d8291f5fe64e1d7b492d899d7f2a48" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderBusinessSourceTest.java", + "sha256": "5cb78aa655bc3f0cbce2fa54e9418cbe6b3d029a5a73a6d5fd61f3c1904b7c83" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderCommandServiceTest.java", + "sha256": "7bb9e5d1a1a12be90986e964704c6fc32f8e1aa73dde3a16210cdab5d03884ba" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java", + "sha256": "68c64539de5bd874095a1c772720021f89404cda0b21f0e46bd1e540eea90e18" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderReturnVolumeTest.java", + "sha256": "685c52357415d0ff536b3fde653e842c87da20e775cffa6b843d6c209112406c" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderSailingStageTest.java", + "sha256": "141de46abe1e8fbd4a606ddadc9141c9ec7cb069ef7d9a270308a39bf2ea6181" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java", + "sha256": "a1c2557dafedae5e1eeed43ab3f2970f6bdf6a5d5438819cc69919b8d0b35b80" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java", + "sha256": "8f5c05ef98c7bd872228778bb804cc108bf1ee79be9d8e3134941e2898422705" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java", + "sha256": "8fb4c03e6e72db40de95d897bac8774dacf7131afba4bff935d4733b1d8e0bc2" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnOrderReferenceResolverTest.java", + "sha256": "d3e556694f83c0bfc6c0fb29b243321e87d9f9e4ba159d6f6c7d3cdc2edc36f4" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/service/TaillegRealConsumerAcceptanceTest.java", + "sha256": "fa008bcf5647a7ace05a5d930c98de8dd4b41d62be6b20efed459a4c3d225422" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java", + "sha256": "0b6896d4480e87f24fd9d9dd39984c7985cc534d68f0c40de9ef90e046efcb1f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferReceiptOpenApiContractTest.java", + "sha256": "bdc3d1079fa8ebd534963383683d576282399fb642710260a5669622c7915288" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/WarehouseTransferSchemaContractTest.java", + "sha256": "f71fd310446c5f3421576496ab4943a19c71e0d458106ec434e4890c0ecb3646" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java", + "sha256": "767c2ccf41923e20de196bccfe9f3a3c985f5a784058e6a4ab26bfba791fc57f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/PdaWarehouseTransferControllerContractTest.java", + "sha256": "dc83e78e65b1f2ee3026214905663f2a5f484dace27dcedca993ceef7be90956" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferControllerPermissionTest.java", + "sha256": "8434dae3e6bbeeb26248ee899ed25db367ecd7f6eb428960e493789fb1a755d5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/WarehouseTransferForecastControllerTest.java", + "sha256": "1861f0b1fabd0da9be19980e9630d78a7107f077b4bb52a059594667e2657210" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferCargoValidationTest.java", + "sha256": "98562ba296127801670d442399d9e422ad2adc3d02597cdd66641c60734ee936" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferPalletValidationTest.java", + "sha256": "e1aa68f4811679fdd9ecc67378df08da42d2c826bc59c92382afb536bb7f6904" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/fleet/FleetIncomingAdapterTest.java", + "sha256": "fab553e9e961cee1c4319435d276cbc5b9e53cf72fe618f6c452768d699e3728" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/gateway/HttpTransferMeasurementPhotoFileGatewayContractTest.java", + "sha256": "251f9867ec11a9980eb7e9ac12b18a126a805702b7ab90c48c287d667365eb99" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java", + "sha256": "cb3699b7a3e6ff1d6e6bd3c657c3a3a5f7fc9d3fcef28cee147f3c9ecce087b5" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferReturnSourceTest.java", + "sha256": "e8d07e1c5a8441c8a10c20ce9076811a334d32f29586fb1bae616eae0b88d4f3" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java", + "sha256": "743f1e354446180c5bec9295706e7514bfb8521b511523544df4cd9752e271b1" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferForecastServiceTest.java", + "sha256": "c08851fc1e530012958b73ad5c5484a4bed1b0d8286654feb9508b5cb7740827" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferPalletAllocationTest.java", + "sha256": "8ec38d4f730a008ccd06decad5f278fcccd849e700967ad67a20fc700ec1a1ea" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferReceiptCoordinatorTest.java", + "sha256": "4cbb6a584be789e2607d73fbf7ba8b4e28a2672bdc56c700741bc7096aabbc20" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelPersistenceTest.java", + "sha256": "125df49f88688c1c65aca135193b5c68932b35fb203bd9ee92d85558cbb428e1" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferRelabelSchemaContractTest.java", + "sha256": "a2feead1710c5de515b615b097c60a03eaab55ed69491e4a8910938e13165d88" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java", + "sha256": "9549f2b964909ad072384b0e5117e2bd9c5c1514940d6fb0e5288936aa443b3f" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderPersistenceTest.java", + "sha256": "0eb7ade86246fb0727ec0d10b69b5c9c996de62f6051491a2c015f2b35df685a" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderRequestsTest.java", + "sha256": "afc4c2c4cd899111f54de137750e08a1a11748ad22ca690160e479094b62d585" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/trucking/UsTruckOrderSchemaContractTest.java", + "sha256": "e53c6bb156b1e3ffee8f25285efed6c14af098dbc3c24f8d6e50745110471092" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/trucking/controller/UsTruckOrderControllerTest.java", + "sha256": "fa300e51888af6e56de0b862e0f84c2f5c61ffbe21d7efec9e158ee566c6bd40" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java", + "sha256": "5104b7ef62e8d6da313abd2e7a7b01ad80caeb24b085b68b9b9fdac2e136f3bb" + }, + { + "path": "lastleg-service/src/test/java/com/baiyue/lastleg/wallet/WalletCommandOutboxServiceTest.java", + "sha256": "fa13d9ba6bb6f31579857dd87b9b182b13e76c89fb3fbdafa8cb6f88bb7e5429" + }, + { + "path": "lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json", + "sha256": "6b43999a956f4459ad83b9d9f6c953c179b487753573aeb8ecf6c8c589c1f90e" + } + ] +} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 6c452094b..46052ade5 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -2,7 +2,9 @@ 费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 -排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;活动来源键与真实物理托盘分别唯一,防止跨车重复占用。智能草稿按拥有者、起运仓和版本维护,容器保存真实来源键,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。 +排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;`ll_dispatch_physical_pallet_lock` 独立保存无代表客户的实托排他占用,`ll_dispatch_pallet_lock.physical_lock_id` 关联逐客户逻辑来源,同车可共享、跨主单或草稿容器仍排他。`ll_dispatch_item` 按主单和完整来源键唯一,`ll_dispatch_item_source` 冻结换标明细的实际位置、绑定箱数、真实件数(未知为空)及原货源事实;拣货归属新增 `dispatch_item_source_id`,与实收、退回实收、原生退货箱三类凭据四者互斥,不伪造普通收货凭据。智能草稿按拥有者、起运仓和版本维护,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。 + +`ll_physical_pallet_guard` 以真实全局实托ID和首次创建时间提供排车冻结与仓内改托的共同串行点,独立于运输占用,不保存客户、状态、有效期或第三种拥有者。`ll_dispatch_item_source.relabel_round_key`保存真实作业轮次,不能把扫码引起的库存和申请状态变化误当作换标轮次变化。排车、草稿和仓内调用必须使用匹配的公共锁实现;本条只说明空库基线结构,不表示已执行或已部署。 本土散板简化下单的当前结构同步于 Lastleg 源基线及海外统一空库基线:移除散板柜型与旧要求日期,增加商品名称、申报预计托数及当地取货/送达时间,移除运输方式,增加起运地及目的地各自的联系人和联系电话;四项联系信息、取货及送货时间均必填;打盘与运输安排允许未知。预计托数不代表实收。字段语义唯一见工作区 `docs/海外仓业务/README.md`,不回填测试存量,不提供自动重置或启动时 SQL;需与匹配 Lastleg、Admin 和 Client 一起发布。本说明不表示 SQL 已执行。 @@ -51,7 +53,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| | 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 225 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -61,7 +63,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving - 外接预订的`quote_source_json`保存可选销售报价来源版本快照,实际成交价仍在货物快照及`agreed_amount`中;手填成交价时无报价来源。该字段随Lastleg模块基线与海外统一入口维护,执行状态只见正式部署契约。 - 异常退回的`return_context_json`保存取消时所需的原始业务状态;逐来源`received_packages`累计实际入仓量且不得超过批准退回量。同一派送可以保留多张已取消/已结束的历史退回单,活动退回仍由派送行锁内校验;普通`idx_ll_exception_delivery_return`替代跨全部历史的唯一索引。数量和状态不能通过回填旧单据伪造。 - 派送`ll_delivery_order.signed_packages`保存签收资料确认时冻结的实际签收箱数;NULL表示未确认,不当作全量签收,数量须在0到本次实发箱数之间。后续退回保留原签收历史,不用当前退回量倒改实签。 -- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;拣货`receipt_id`与`return_receipt_id`恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。 +- `ll_return_stock_receipt`与真实退回库存入账同事务保存,按客户/入账流水唯一,保留原订单和出库位置以及实际入仓位置。重派候选、草稿锁及拣货显式引用该凭据;`receipt_id`、`return_receipt_id`、`return_carton_id`、`dispatch_item_source_id` 四类拣货归属凭据恰有一个非空。退回不增加原订单箱数,不回退历史出库流水;可重新发运量由实际退回及已消耗凭据核平。 - 国内 Warehouse 管理包括海外仓在内的公共仓库主档;海外 Warehouse 只保存资源与主档 ID 引用。 - Gateway、Print、Integration 不新增数据库。Compose 继续使用 `SPRING_SQL_INIT_MODE=never`,服务启动不执行这些文件。 @@ -74,7 +76,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/215`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 39047fe4f..b821e6b70 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -373,40 +373,41 @@ CREATE TABLE ll_dispatch_item ( source_key VARCHAR(255) NOT NULL COMMENT '本条货物稳定权威来源键', source_version CHAR(64) NOT NULL COMMENT '选货时核对的真实来源版本摘要', source_snapshot_json JSON NOT NULL COMMENT '本条货物不可变来源及关联事实快照', - id bigint NOT NULL COMMENT '主键ID', - dispatch_order_id bigint NOT NULL COMMENT '派车单ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - plan_id bigint DEFAULT NULL COMMENT '入仓计划ID', - plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', - warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', - order_id bigint DEFAULT NULL COMMENT '订单ID', - order_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本条真实来源订单业务号快照', - order_refs varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联订单信息', - inbound_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓单号快照', - pallet_no varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘号', - overseas_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓ID', - overseas_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', - destination_warehouse_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID', - destination_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', - destination_warehouse_name varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', - fba_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', - destination_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', - delivery_address varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', - postal_code varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', - contact_name varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', - contact_phone varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', - package_count int NOT NULL DEFAULT '0' COMMENT '箱数', + id BIGINT NOT NULL COMMENT '主键ID', + dispatch_order_id BIGINT NOT NULL COMMENT '派车单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + order_id BIGINT NULL COMMENT '订单ID', + order_no VARCHAR(128) NULL COMMENT '本条真实来源订单业务号快照', + order_refs VARCHAR(2000) NULL COMMENT '关联订单信息', + inbound_code VARCHAR(128) NULL COMMENT '入仓单号快照', + pallet_no VARCHAR(128) NOT NULL COMMENT '托盘号', + overseas_warehouse_id VARCHAR(128) NULL COMMENT '海外仓ID', + overseas_warehouse_name VARCHAR(255) NULL COMMENT '海外仓名称快照', + destination_warehouse_id VARCHAR(128) NULL COMMENT '目的仓ID', + destination_warehouse_code VARCHAR(128) NULL COMMENT '目的仓编码快照', + destination_warehouse_name VARCHAR(255) NULL COMMENT '目的仓名称快照', + fba_warehouse_code VARCHAR(128) NULL COMMENT 'FBA仓库编码', + destination_type VARCHAR(32) NULL COMMENT '目的类型:FBA或PRIVATE', + delivery_address VARCHAR(1000) NULL COMMENT '私人仓目的地址快照', + postal_code VARCHAR(32) NULL COMMENT '私人仓邮编快照', + contact_name VARCHAR(128) NULL COMMENT '私人仓联系人快照', + contact_phone VARCHAR(64) NULL COMMENT '私人仓联系电话快照', + package_count INT NOT NULL DEFAULT 0 COMMENT '箱数', gross_weight_kg DECIMAL(18,3) NULL COMMENT '毛重(千克),未知为NULL,不以材积重代替', - volume_weight_kg decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', - volume_cbm decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '体积(立方米)', - source_stage varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '数据来源阶段', - carton_marks varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标箱唛映射快照', - source_business_type varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型', - source_business_no varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT '材积重(千克)', + volume_cbm DECIMAL(18,4) NOT NULL DEFAULT 0 COMMENT '体积(立方米)', + source_stage VARCHAR(32) NOT NULL COMMENT '数据来源阶段', + carton_marks VARCHAR(4000) NULL COMMENT '换标箱唛映射快照', + source_business_type VARCHAR(32) NULL COMMENT '来源业务类型', + source_business_no VARCHAR(128) NULL COMMENT '来源业务单号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), - UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,source_key), KEY idx_ll_dispatch_item_order (customer_id,order_id), KEY idx_ll_dispatch_item_inbound (customer_id,inbound_code), KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id) @@ -470,32 +471,91 @@ CREATE TABLE ll_dispatch_history ( KEY idx_dispatch_history_version (dispatch_id,record_version,id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车版本与审核轮次的追加历史'; +CREATE TABLE ll_dispatch_item_source ( + id BIGINT NOT NULL COMMENT '逻辑运输来源贡献快照主键', + dispatch_order_id BIGINT NOT NULL COMMENT '所属整车排车主单ID', + dispatch_item_id BIGINT NOT NULL COMMENT '所属客户逻辑排车明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '本条真实客户ID', + relabel_application_id BIGINT NOT NULL COMMENT '换标申请ID', + relabel_item_id BIGINT NOT NULL COMMENT '换标申请原明细ID', + relabel_round_key VARCHAR(128) NOT NULL COMMENT '冻结的真实换标作业轮次,扫码数量变化不改变轮次', + warehouse_pallet_id BIGINT NOT NULL COMMENT '当前真实物理托盘ID', + inventory_position_id BIGINT NOT NULL COMMENT '绑定后实际库存位置ID', + inventory_balance_id BIGINT NOT NULL COMMENT '绑定后实际库存余额ID', + source_inventory_balance_id BIGINT NULL COMMENT '原货源库存余额快照,未知为空', + source_inbound_order_id BIGINT NULL COMMENT '逐换标明细原货源订单ID,未知为空', + source_inbound_order_no VARCHAR(128) NULL COMMENT '逐换标明细原货源订单业务号快照', + source_inbound_code VARCHAR(128) NULL COMMENT '逐换标明细原入仓业务号快照', + notified_package_count BIGINT NOT NULL COMMENT '本来源通知箱数,不代表全部换标成功', + package_count BIGINT NOT NULL COMMENT '本来源实际绑定的可拣箱数', + product_quantity BIGINT NULL COMMENT '有真实依据的来源产品件数,未知为空', + binding_basis_hash CHAR(64) NOT NULL COMMENT '申请轮次、位置及逐来源贡献的冻结依据摘要', + unit_facts_json JSON NOT NULL COMMENT '逐件新旧箱唛及成功或异常工作事实快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_item_source_contribution (dispatch_item_id,relabel_item_id,inventory_position_id), + KEY idx_dispatch_item_source_dispatch (dispatch_order_id,dispatch_item_id), + KEY idx_dispatch_item_source_relabel (customer_id,relabel_application_id,relabel_item_id), + CONSTRAINT chk_dispatch_item_source_packages CHECK(notified_package_count>=0 AND package_count>0), + CONSTRAINT chk_dispatch_item_source_quantity CHECK(product_quantity IS NULL OR product_quantity>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='换标逻辑来源实际贡献不可变快照'; + +CREATE TABLE ll_physical_pallet_guard ( + warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间', + PRIMARY KEY (warehouse_pallet_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车冻结与仓内实托组成修改的共同串行点'; + +CREATE TABLE ll_dispatch_physical_pallet_lock ( + id BIGINT NOT NULL COMMENT '物理实托占用主键', + warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID', + active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空', + dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥', + draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥', + draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID', + operator_id VARCHAR(128) NULL COMMENT '占用操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态', + expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) NULL COMMENT '释放时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_physical_active_pallet (active_warehouse_pallet_id), + KEY idx_dispatch_physical_order (dispatch_order_id,status), + KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status), + CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)), + CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='同车共享且跨车排他的物理实托占用'; + CREATE TABLE ll_dispatch_pallet_lock ( - id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - source_pallet_key varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源托盘业务键', - active_key varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '生效中的资源排他键', - active_warehouse_pallet_id bigint DEFAULT NULL COMMENT '活动占用的真实仓库托盘,释放后为空,跨客户及来源别名唯一', - plan_id bigint DEFAULT NULL COMMENT '入仓计划ID', - plan_pallet_id bigint DEFAULT NULL COMMENT '计划托盘ID', - warehouse_pallet_id bigint DEFAULT NULL COMMENT '仓库实物托盘ID', - dispatch_order_id bigint DEFAULT NULL COMMENT '正式派车单ID,草稿锁阶段为空', - draft_container_id bigint DEFAULT NULL COMMENT '智能排车草稿装车托盘ID', - draft_token varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '智能排车草稿随机标识', - operator_id varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿锁操作人ID', - status varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', - locked_at datetime(3) NOT NULL COMMENT '锁定时间', - expires_at datetime(3) DEFAULT NULL COMMENT '草稿锁失效时间,正式派车锁为空', - released_at datetime(3) DEFAULT NULL COMMENT '释放时间', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', - updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + id BIGINT NOT NULL COMMENT '主键ID', + physical_lock_id BIGINT NULL COMMENT '实际物理实托占用ID,非物理来源为空', + customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', + source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', + active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', + plan_id BIGINT NULL COMMENT '入仓计划ID', + plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', + warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', + dispatch_order_id BIGINT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + draft_container_id BIGINT NULL COMMENT '智能排车草稿装车托盘ID', + draft_token VARCHAR(64) NULL COMMENT '智能排车草稿随机标识', + operator_id VARCHAR(128) NULL COMMENT '草稿锁操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '业务状态', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + expires_at DATETIME(3) NULL COMMENT '草稿锁失效时间,正式派车锁为空', + released_at DATETIME(3) NULL COMMENT '释放时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), - UNIQUE KEY uk_ll_dispatch_active_warehouse_pallet (active_warehouse_pallet_id), + KEY idx_dispatch_logical_physical (physical_lock_id,status), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派车托盘排他锁'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户逻辑运输来源占用'; CREATE TABLE ll_dispatch_pda_operation ( id bigint NOT NULL COMMENT '主键ID', @@ -554,20 +614,18 @@ CREATE TABLE ll_return_stock_receipt ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; CREATE TABLE ll_dispatch_pda_pick_order ( - id bigint NOT NULL COMMENT '主键ID', - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', - pick_id bigint NOT NULL COMMENT 'PDA拣货记录ID', - receipt_id BIGINT NULL COMMENT '入仓实收记录ID', - order_id bigint NOT NULL COMMENT '订单ID', - packages bigint NOT NULL COMMENT '本次操作箱数', - product_quantity bigint NOT NULL DEFAULT '0' COMMENT '产品数量', - created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + id BIGINT NOT NULL COMMENT '主键ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', pick_id BIGINT NOT NULL COMMENT 'PDA拣货记录ID', + receipt_id BIGINT NULL COMMENT '入仓实收记录ID', order_id BIGINT NOT NULL COMMENT '订单ID', packages BIGINT NOT NULL COMMENT '本次操作箱数', + product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥', + dispatch_item_source_id BIGINT NULL COMMENT '换标实际来源贡献快照ID,与其他三类真实凭据互斥', KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), - CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1), - PRIMARY KEY (id), - KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), + KEY idx_dispatch_pick_item_source(customer_id,dispatch_item_source_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) + (dispatch_item_source_id IS NOT NULL) = 1), + + + PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), KEY idx_ll_dispatch_pick_order_receipt (customer_id,receipt_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA拣货订单归属记录'; @@ -4824,4 +4882,4 @@ CREATE TABLE ll_fleet_pickup_history ( KEY idx_pickup_history_trip(trip_no,id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 220 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 5964dc0a0..b74e1142f 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -15,7 +15,7 @@ 业务包相对 `src/main/java/com/baiyue/lastleg/`;`src/`、`docs/`、`scripts/`、`../` 相对服务目录。裸类名在指定包内检索。 -拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;车次准备查同包 `FleetTripPreparationService.java`,本土业务接线查 `fleet/business/`;合同 `fleet-preparation-v1.yaml`,流程变更再查 `../../docs/海外仓业务/内部拼车需求池首版开发契约.md`。 +拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 68b4e6ef4..dc68a0cbe 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -20,6 +20,10 @@ - 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。 - 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。 +### 整车排车的既有预约接线边界(2026-10-19) + +既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。 + ### 平台自营统计边界 `selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。 diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index 95eb6013e..b8ccfdaa2 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -34,7 +34,7 @@ default-test - **/*SqlSemanticTest.java + false**/*SqlSemanticTest.java diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index 404ea68be..44371e5bc 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -73,6 +73,40 @@ function analyze(sourceSet = 'main') { })); } +test('original appointment adapters use only the reviewed dispatch port types', () => { + const report = analyze(); + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const prefix = 'com.baiyue.lastleg.inboundplan.work.'; + const port = 'com.baiyue.lastleg.dispatch.port.'; + const expected = [ + ['FbaAppointmentExecutionAdapter', 'DispatchAppointmentAmendmentPort'], + ['FbaAppointmentExecutionAdapter', 'DispatchContracts'], + ['FbaAppointmentExecutionAdapter', 'DispatchTypes'], + ['FbaAppointmentTransportAdapter', 'DispatchAppointmentAmendmentPort'], + ['FbaAppointmentTransportAdapter', 'DispatchCommands'], + ['FbaAppointmentTransportAdapter', 'DispatchContracts'], + ['FbaAppointmentTransportAdapter', 'DispatchTypes'], + ].map(([source, target]) => `${prefix}${source}->${port}${target}`).sort(); + const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.dispatch.')); + assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), expected); + assert.equal(baseline.allowedSliceEdges.includes('inbound-pda->dispatch-delivery'), false); + const violations = contractViolations(report, baseline); + for (const edge of expected) assert.equal(violations.some(message => message.includes(edge)), false); + + // 精确端口边不允许原适配器直连整车实现,也不授权其他预约消费者借用此许可。 + const forbidden = [ + { source: prefix + 'FbaAppointmentExecutionAdapter', target: 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' }, + { source: prefix + 'AppointmentWorkDispatchBinding', target: port + 'DispatchAppointmentAmendmentPort' }, + ]; + for (const edge of forbidden) { + assert.ok(report.classes.some(entry => entry.fqcn === edge.source)); + assert.ok(report.classes.some(entry => entry.fqcn === edge.target)); + report.classDependencies.push(edge); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`))); + } +}); + test('current source does not add slice dependencies beyond the Lastleg baseline', () => { execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { cwd: moduleRoot, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java index 1072fb67c..95068c989 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationMapper.java @@ -19,11 +19,11 @@ public interface AppointmentImpactRelationMapper { """) List appointments(String id); @Select(""" - SELECT DISTINCT d.customer_id,d.dispatch_no FROM ll_order o + SELECT DISTINCT i.customer_id,d.dispatch_no FROM ll_order o JOIN ll_dispatch_item i ON i.order_id=o.id AND i.customer_id=o.customer_id - JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id AND d.customer_id=i.customer_id + JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE o.source_container_booking_id=#{id} AND o.order_type='FIRST_LEG' AND o.current_status NOT IN ('CANCELLED','COMPLETED') - AND d.status NOT IN ('CANCELLED','COMPLETED') ORDER BY d.dispatch_no LIMIT 1001 + AND d.status='ACTIVE' AND d.stage<>'DELIVERED' ORDER BY d.dispatch_no LIMIT 1001 """) List dispatches(String id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java index e722a2460..b71495f50 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DeliveryOperationMapper.java @@ -12,7 +12,7 @@ import java.util.List; @Mapper public interface DeliveryOperationMapper extends BaseMapper { - /** 普通库存流水与原箱回仓凭据共同证明实际退回,每个原箱只计一次箱数。 */ + /** 普通流水与原箱凭据证明退回,共享任务按真实扫码及逐票客户归属核验。 */ @Select("SELECT l.id AS ledger_id, di.id AS dispatch_item_id, oi.warehouse_pallet_id AS pallet_id, " + "l.delta_packages AS packages, l.occurred_at AS received_at FROM ll_delivery_order d " + "JOIN ll_exception_order e ON e.customer_id=d.customer_id AND e.delivery_order_id=d.id " + @@ -25,7 +25,7 @@ public interface DeliveryOperationMapper extends BaseMapper { "AND di.warehouse_pallet_id=oi.warehouse_pallet_id " + "AND EXISTS (SELECT 1 FROM ll_dispatch_pda_pick p JOIN ll_dispatch_pda_pick_order a " + "ON a.customer_id=p.customer_id AND a.pick_id=p.id " + - "JOIN ll_dispatch_pda_task t ON t.customer_id=p.customer_id AND t.id=p.task_id " + + "JOIN ll_dispatch_pda_task t ON t.id=p.task_id " + "WHERE p.customer_id=d.customer_id AND t.dispatch_order_id=d.dispatch_order_id " + "AND p.dispatch_item_id=di.id AND p.inventory_position_id=i.inventory_position_id AND a.order_id=e.last_mile_order_id) " + "WHERE d.customer_id=#{customerId} AND d.id=#{deliveryOrderId} UNION ALL SELECT MIN(r.id),r.source_dispatch_item_id,oi.warehouse_pallet_id,SUM(r.packages),r.received_at " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java index 1e1690c19..14d07321e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/mapper/DispatchFeeCargoMapper.java @@ -17,7 +17,7 @@ public interface DispatchFeeCargoMapper { JOIN ll_inventory_balance b ON b.id=p.balance_id AND b.customer_id=di.customer_id LEFT JOIN ll_order o ON o.id=b.inbound_order_id AND o.customer_id=di.customer_id LEFT JOIN (SELECT pk.dispatch_item_id,pk.inventory_position_id,SUM(pk.packages) AS packages - FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id AND task.customer_id=pk.customer_id + FROM ll_dispatch_pda_pick pk JOIN ll_dispatch_pda_task task ON task.id=pk.task_id WHERE task.dispatch_order_id=#{dispatchId} AND pk.customer_id=#{customerId} GROUP BY pk.dispatch_item_id,pk.inventory_position_id) picked ON picked.dispatch_item_id=di.id AND picked.inventory_position_id=p.id diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java index d4f199332..0cd12d693 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/DeliveryCancellationRepository.java @@ -45,6 +45,14 @@ public class DeliveryCancellationRepository { public OutboundOrder lock(String no) { return outbounds.lockGlobalByNo(no); } /** 独立换标出库保留其原域确认锁。 */ public RelabelApplication lockRelabel(OutboundOrder order) { return relabels.lockByOutbound(order.getCustomerId(),order.getOutboundNo()); } + /** 仅撤销本次出库关联,原生箱和普通仓托均保留客户已确认成果;旧号必须显式清空。 */ + public boolean restoreRelabelArrangement(RelabelApplication application,String outboundNo,String fulfillment,LocalDateTime now) { + return relabels.update(null,new LambdaUpdateWrapper() + .eq(RelabelApplication::getId,application.getId()).eq(RelabelApplication::getCustomerId,application.getCustomerId()) + .eq(RelabelApplication::getOutboundOrderNo,outboundNo).eq(RelabelApplication::getStatus,"READY_FOR_OUTBOUND") + .set(RelabelApplication::getOutboundOrderNo,null).set(RelabelApplication::getFulfillmentStatus,fulfillment) + .set(RelabelApplication::getUpdatedAt,now))==1; + } /** 子明细始终按出库客户读取。 */ public List items(OutboundOrder order) { return items.findByOutbound(order.getCustomerId(),order.getId()); } /** 一次取得本出库全部派送锁,不边持来源锁边锁下一张派送。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index f86d18bbc..ecd857967 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -1,6 +1,5 @@ package com.baiyue.lastleg.delivery.service; -import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; @@ -22,9 +21,9 @@ import com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper; import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.entity.LastlegOrder; import com.baiyue.lastleg.entity.LastlegOrderStatusHistory; import com.baiyue.lastleg.entity.LastlegOrderDetail; @@ -93,7 +92,7 @@ import java.util.stream.Collectors; @Service public class DeliveryTransferService { - @Resource private DispatchOrderMapper dispatchOrders; + @Resource private DispatchAggregateRepository dispatches; @Resource private DispatchItemMapper dispatchItems; @Resource private OutboundOrderMapper outboundOrders; @Resource private OutboundPalletItemMapper outboundPallets; @@ -128,7 +127,6 @@ public class DeliveryTransferService { @Resource private RelabelApplicationService relabelService; @Resource private LastlegOrderNoGenerator orderNoGenerator; @Resource private ObjectMapper objectMapper; - @Resource private FleetDispatchBindingGuard fleetBindings; @Resource private DeliveryExecutionGuard deliveryExecution; @Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; @Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations; @@ -912,7 +910,7 @@ public class DeliveryTransferService { || dispatch.stage()!=com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING || dispatch.counts()==null || dispatch.counts().pickedPackageCount()>0 || dispatch.counts().shippedPackageCount()>0) throw conflict("排车已开始执行,不能直接取消出库"); - fleetBindings.requireUnbound(dispatch.dispatchId()); + // 唯一Fleet资源记录随整车自动创建,不能把本车正常关系当作额外取消阻断。 } if (!Set.of("WAIT_OUTBOUND", "WAIT_PICK").contains(outbound.getStatus())) { throw conflict("Only an unexecuted outbound order can be cancelled"); @@ -923,6 +921,8 @@ public class DeliveryTransferService { || !Set.of("WAIT_PICK", "WAIT_OUTBOUND").contains(i.getStatus()))) { throw conflict("Outbound order cannot be cancelled after execution has started"); } + boolean relabelArrangement = "RELABEL_OUTBOUND".equals(outbound.getOutboundType()); + String pendingRelabelFulfillment = null; List existingDeliveries = cancellations.deliveries(outbound); if (existingDeliveries.stream().anyMatch(delivery -> !"WAIT_DELIVERY".equals(delivery.getStatus()))) throw conflict("Outbound order cannot be cancelled while delivery is in progress"); @@ -932,6 +932,7 @@ public class DeliveryTransferService { if ("RELABEL_OUTBOUND".equals(outbound.getOutboundType())) { if (application == null || !customerId.equals(application.getCustomerId()) || !outbound.getOutboundNo().equals(application.getOutboundOrderNo()) + || !Objects.equals(outbound.getOverseasWarehouseId(),application.getOverseasWarehouseId()) || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) throw conflict("换标出库关联状态已变化,不能取消"); for (OutboundPalletItem item : outboundItems) { @@ -939,6 +940,7 @@ public class DeliveryTransferService { for (String mark : Objects.toString(item.getCartonMarks(), "").split(",")) { ReturnInventoryCarton carton = cancellations.lockCarton(mark); if (carton == null || !customerId.equals(carton.getCustomerId()) + || !Objects.equals(outbound.getOverseasWarehouseId(),carton.getOverseasWarehouseId()) || !Objects.equals(application.getId(), carton.getRelabelApplicationId()) || !"OUTBOUND_PENDING".equals(carton.getStatus())) throw conflict("退货换标箱预留状态异常,不能取消出库"); @@ -946,8 +948,10 @@ public class DeliveryTransferService { cancellations.save(carton); } } - relabelService.changeStatus(customerId, application.getRelabelNo(), operatorId, - new RelabelRequests.ChangeStatus("CANCELLED", request.reason().trim())); + // 原生箱和普通仓托均只撤销本次安排;整车分支仍须先通过父锁、阶段和零扫码门禁。 + pendingRelabelFulfillment = "TRUCK".equals(application.getDeliveryMethod()) ? "WAIT_PRE_DISPATCH" : "WAIT_OUTBOUND_CREATION"; + if (!cancellations.restoreRelabelArrangement(application,outbound.getOutboundNo(),pendingRelabelFulfillment,now)) + throw conflict("原换标出库关联已变化,未取消本次安排"); } if ("RETURN_OUTBOUND".equals(outbound.getOutboundType())) { for (String mark : outboundItems.stream().map(OutboundPalletItem::getCartonMarks).sorted().toList()) { @@ -968,6 +972,13 @@ public class DeliveryTransferService { var current=cancellations.lockSource(customerId,selected.getId()); if(current!=null && outboundNo.equals(current.getOutboundOrderNo()))cancelSources.put(current.getId(),current); } + if (relabelArrangement) { + // 仅清本次出库/派送关联,来源主状态和换标完成事实保持原值,不能退回未换标库存。 + for (var order : cancelSources.values()) { + if (!cancellations.restore(order,outboundNo,order.getCurrentStatus(),"RELABEL:"+pendingRelabelFulfillment,now)) + throw conflict("原换标来源出库关联已变化,请刷新核对"); + } + } for (DeliveryOrder delivery : existingDeliveries) { int voidRequests = 0; for (var fee : feeSources.listForBusiness(customerId, "DELIVERY_ORDER", delivery.getId())) { @@ -1066,7 +1077,7 @@ public class DeliveryTransferService { OutboundOrder outbound = outboundOrders.findGlobalByNo(code); if (outbound != null && boundWarehouseId.equals(outbound.getOverseasWarehouseId())) { if ("DISPATCH_DELIVERY".equals(outbound.getOutboundType()) && StringUtils.hasText(outbound.getDispatchNo())) { - DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(outbound.getDispatchNo()); + DispatchAggregate dispatch = dispatches.find(outbound.getDispatchNo()); if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, outbound.getOutboundNo(), dispatch.getDispatchNo()); @@ -1076,7 +1087,7 @@ public class DeliveryTransferService { if (task != null) return outboundResolution(outbound, task); } - DispatchOrder dispatch = dispatchOrders.findGlobalByDispatchNo(code); + DispatchAggregate dispatch = dispatches.find(code); if (dispatch != null && boundWarehouseId.equals(dispatch.getOverseasWarehouseId())) { return new DeliveryResponses.PdaTaskResolution("DISPATCH", null, null, dispatch.getDispatchNo()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java deleted file mode 100644 index b4b94c8f8..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchCargoSourceQuery.java +++ /dev/null @@ -1,57 +0,0 @@ -package com.baiyue.lastleg.delivery.service; - -import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; -import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; -import org.springframework.stereotype.Service; -import org.springframework.http.HttpStatus; -import org.springframework.web.server.ResponseStatusException; -import java.util.*; - -/** 外接绑定和约定运费共同消费同一份逐来源货物。 */ -@Service -public class DispatchCargoSourceQuery { - private final DispatchCargoSourceRepository repository; - /** 装配当前库存与本次拣货的真实来源入口。 */ - public DispatchCargoSourceQuery(DispatchCargoSourceRepository repository) { this.repository = repository; } - - /** 新增普通派送应收只绑定真实未约定来源;混合多票时保留派送归属。 */ - public Set ordinaryOrderIds(DispatchOrder dispatch, Set externalInboundCodes) { - return repository.sources(dispatch.getCustomerId(), dispatch.getId()).stream() - .filter(row -> !externalInboundCodes.contains(row.inboundCode())) - .map(row -> row.orderId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet()); - } - - /** 每条普通排车明细核平实际组成;箱级换标和退货保留其专用来源快照。 */ - public List sources(DispatchOrder dispatch, List items) { - if (items.isEmpty()) return List.of(); - var rows = repository.sources(dispatch.getCustomerId(), dispatch.getId()); - var result = new ArrayList(); - for (var item : items) { - String destination = FleetDispatchGateway.destinationKey(item.getDestinationType(), - item.getDestinationWarehouseCode(), item.getFbaWarehouseCode(), item.getDeliveryAddress(), item.getPostalCode()); - // 实收重派按不可变实收记录选货,箱级换标按箱来源选货;都不是普通整托候选。 - if (item.getReturnReceiptId() != null || Set.of("RELABEL", "RETURN_RELABEL").contains(Objects.toString(item.getSourceStage(), ""))) { - result.add(new FleetDispatchGateway.SourceCargo(item.getInboundCode(), - item.getWarehousePalletId() == null ? null : item.getWarehousePalletId().toString(), - item.getPalletNo(), Objects.requireNonNullElse(item.getPackageCount(), 0), destination)); - continue; - } - var selected = rows.stream().filter(row -> Objects.equals(row.dispatchItemId(), item.getId())).toList(); - if (selected.isEmpty() || item.getPackageCount() == null || selected.stream().mapToLong(row -> row.packages()).sum() != item.getPackageCount()) - throw conflict("实际货物组成与排车箱数不一致,请核对收货、托盘及本次拣货来源"); - for (var row : selected) { - if (row.inboundCode() == null || row.inboundCode().isBlank() || row.packages() <= 0 - || !Objects.equals(row.warehousePalletId(), item.getWarehousePalletId())) - throw conflict("货物缺少真实入仓来源或实物托盘,不能识别为普通货跳过外接核对"); - result.add(new FleetDispatchGateway.SourceCargo(row.inboundCode(), row.warehousePalletId().toString(), - row.palletNo(), Math.toIntExact(row.packages()), destination)); - } - } - return List.copyOf(result); - } - - /** 来源不足直接阻断,不回退展示字段。 */ - private static ResponseStatusException conflict(String reason) { return new ResponseStatusException(HttpStatus.CONFLICT, reason); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index 536336a12..fbaa18cec 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -3,8 +3,6 @@ package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.service.DispatchService; import com.baiyue.lastleg.dispatch.service.DispatchBolPrintService; import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java deleted file mode 100644 index 49b66e7d4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchDraftGroupResult.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.baiyue.lastleg.dispatch.dto; - -/** One independent dispatch for one server-owned loading container. */ -public record DispatchDraftGroupResult(Long draftContainerId, DispatchResponse dispatch) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java deleted file mode 100644 index 2088889b7..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchRequests.java +++ /dev/null @@ -1,109 +0,0 @@ -package com.baiyue.lastleg.dispatch.dto; - -import jakarta.validation.constraints.Max; -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Size; - -import java.time.LocalDateTime; -import java.util.List; -import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.Allocation; - -public final class DispatchRequests { - private DispatchRequests() { - } - - public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String dispatchNo, - String vehiclePlate, String status, String fbaAppointmentNo, Stage stage) { - public Query { if (page == 0) page = 1; if (size == 0) size = 20; } - } - - public enum Stage { - BEFORE_LOADING, LOADING, AFTER_LOADING, CANCELLED; - public List statuses() { - return switch (this) { - case BEFORE_LOADING -> List.of("PENDING_DEPARTURE"); - case LOADING -> List.of("PICKING", "READY_TO_DEPART", "PARTIAL_READY_TO_DEPART"); - case AFTER_LOADING -> List.of("DEPARTED", "PARTIAL_DEPARTED"); - case CANCELLED -> List.of("CANCELLED"); - }; - } - } - - public record PreArrange(@Size(max = 500) List<@NotNull Long> planPalletIds, - @Size(max = 64) String fbaAppointmentNo, - @Size(max = 32) String fbaAppointmentStatus, - @Size(max = 32) String vehicleAppointmentStatus, - @Size(max = 64) String draftToken, - @Size(max = 64) String relabelNo, - @Size(max = 500) List<@NotNull Long> returnReceiptIds, - @Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) { - public PreArrange { - planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds); - returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds); - batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations); - } - public PreArrange(List planPalletIds,String fbaAppointmentNo,String fbaAppointmentStatus,String vehicleAppointmentStatus,String draftToken,String relabelNo,List returnReceiptIds) { - this(planPalletIds,fbaAppointmentNo,fbaAppointmentStatus,vehicleAppointmentStatus,draftToken,relabelNo,returnReceiptIds,List.of()); - } - public PreArrange(List planPalletIds, String fbaAppointmentNo, String fbaAppointmentStatus, - String vehicleAppointmentStatus, String draftToken, String relabelNo) { - this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, relabelNo, List.of()); - } - public PreArrange(List planPalletIds, String fbaAppointmentNo, - String fbaAppointmentStatus, String vehicleAppointmentStatus) { - this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, null, null); - } - public PreArrange(List planPalletIds, String fbaAppointmentNo, - String fbaAppointmentStatus, String vehicleAppointmentStatus, String draftToken) { - this(planPalletIds, fbaAppointmentNo, fbaAppointmentStatus, vehicleAppointmentStatus, draftToken, null); - } - } - - public record DraftPallet(Long planPalletId, @NotBlank @Size(max = 64) String draftToken, Long draftContainerId, - Long returnReceiptId, String sourceType) { - public DraftPallet(Long planPalletId, String draftToken, Long draftContainerId) { this(planPalletId, draftToken, draftContainerId, null, "INBOUND_PLAN"); } - public DraftPallet(Long planPalletId, String draftToken) { this(planPalletId, draftToken, null); } - @jakarta.validation.constraints.AssertTrue(message = "请选择唯一且匹配类型的实物来源") - public boolean isValidSource() { - return returnReceiptId != null ? planPalletId == null && "RETURN_RECEIPT".equals(sourceType) - : planPalletId != null && (sourceType == null || "INBOUND_PLAN".equals(sourceType)); - } - } - - public record DraftContainer(@NotBlank @Size(max = 64) String draftToken, @NotNull Integer capacity) {} - - public record PreArrangeDraft(@NotBlank @Size(max = 64) String draftToken, - @NotEmpty @Size(max = 100) List<@NotNull @jakarta.validation.Valid DraftGroup> groups) {} - - public record DraftGroup(@NotNull Long draftContainerId, - @Size(max = 500) List<@NotNull Long> planPalletIds, - @Size(max = 500) List<@NotNull Long> returnReceiptIds, - @Size(max = 1000) List<@jakarta.validation.Valid Allocation> batchAllocations) { - public DraftGroup { - planPalletIds = planPalletIds == null ? List.of() : List.copyOf(planPalletIds); - returnReceiptIds = returnReceiptIds == null ? List.of() : List.copyOf(returnReceiptIds); - batchAllocations = batchAllocations == null ? List.of() : List.copyOf(batchAllocations); - } - public DraftGroup(Long id,List pallets,List receipts) { this(id,pallets,receipts,List.of()); } - } - - public record DraftRelease(@NotBlank @Size(max = 64) String draftToken) { - } - - public record DraftSnapshot(@NotBlank @Size(max = 64) String draftToken, - @NotEmpty @Size(max = 500) List<@NotNull Long> planIds, - @NotBlank @Size(max = 20000) String containersJson) { - } - - public record Update(@Size(max = 64) String vehiclePlate, @Size(max = 128) String driverName, - @Size(max = 64) String driverPhone, @Size(max = 255) String carrierName, - LocalDateTime departAt, - @Size(max = 64) String fbaAppointmentNo, - @Size(max = 32) String fbaAppointmentStatus, - @Size(max = 32) String vehicleAppointmentStatus, - LocalDateTime estimatedArrivalAt) { - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java deleted file mode 100644 index df6187c20..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/dto/DispatchResponse.java +++ /dev/null @@ -1,35 +0,0 @@ -package com.baiyue.lastleg.dispatch.dto; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.util.List; - -public record DispatchResponse(Long id, String dispatchNo, String vehiclePlate, String driverName, - String driverPhone, String carrierName, String orderNos, String route, - String overseasWarehouseId, String overseasWarehouseName, LocalDateTime departAt, - String fbaAppointmentNo, String fbaAppointmentStatus, - String vehicleAppointmentStatus, LocalDateTime estimatedArrivalAt, String status, - int itemCount, int palletCount, int packageCount, int pickedPackageCount, - int shippedPackageCount, int remainingPackageCount, String partialCompletionReason, - LocalDateTime createdAt, - List items, List bolLines, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow, - List appointmentBatches, - Actions actions) { - public record Actions(boolean canEdit, String editBlockReason, - boolean canCancel, String cancelBlockReason, - boolean canRemovePallet, String removePalletBlockReason, - boolean canTransfer, String transferBlockReason, - String outboundNo, String outboundStatus) { } - public record Item(Long id, Long planPalletId, Long warehousePalletId, String palletNo, - String overseasWarehouseId, String overseasWarehouseName, - String orderNo, String orderRefs, String inboundCode, String destinationWarehouseCode, - String destinationWarehouseName, String fbaWarehouseCode, int packageCount, - BigDecimal grossWeightKg, BigDecimal volumeWeightKg, - BigDecimal volumeCbm, String sourceStage) { - } - - public record BolLine(String containerNo, String palletNo, String fbaWarehouseCode, - String destinationWarehouseName, String warehouseNo, String fbaShipmentId, - String poReferenceNo, int packageCount) { - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java new file mode 100644 index 000000000..76d84b96d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchItemSource.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** 普通换标逐item/实际仓位的冻结贡献,原货源与换标履约单分开保留。 */ +@Data +@TableName("ll_dispatch_item_source") +public class DispatchItemSource { + @TableId(type=IdType.INPUT) private Long id; + private Long dispatchOrderId; + private Long dispatchItemId; + private String customerId; + private Long relabelApplicationId; + private Long relabelItemId; + private String relabelRoundKey; + private Long warehousePalletId; + private Long inventoryPositionId; + private Long inventoryBalanceId; + private Long sourceInventoryBalanceId; + private Long sourceInboundOrderId; + private String sourceInboundOrderNo; + private String sourceInboundCode; + private Long notifiedPackageCount; + private Long packageCount; + private Long productQuantity; + private String bindingBasisHash; + private String unitFactsJson; + private LocalDateTime createdAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java deleted file mode 100644 index 78caeb77d..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchOrder.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.baiyue.lastleg.dispatch.entity; - -import com.baomidou.mybatisplus.annotation.IdType; -import com.baomidou.mybatisplus.annotation.TableId; -import com.baomidou.mybatisplus.annotation.TableName; -import lombok.Data; - -import java.time.LocalDateTime; - -/** Header for a pre-dispatched vehicle load. Vehicle information remains editable before departure. */ -@Data -@TableName("ll_dispatch_order") -public class DispatchOrder { - @TableId(type = IdType.INPUT) - private Long id; - private String customerId; - private String dispatchNo; - private String overseasWarehouseId; - private String overseasWarehouseName; - private String vehiclePlate; - private String driverName; - private String driverPhone; - private String carrierName; - private String route; - private LocalDateTime departAt; - private String fbaAppointmentNo; - private String fbaAppointmentStatus; - private String vehicleAppointmentStatus; - private LocalDateTime estimatedArrivalAt; - private String status; - private Integer itemCount; - private Integer palletCount; - private Integer packageCount; - private Integer pickedPackageCount; - private Integer shippedPackageCount; - private String partialCompletionReason; - private String createdBy; - private LocalDateTime createdAt; - private LocalDateTime updatedAt; -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java index eb6728f0b..d3cf5fb90 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPalletLock.java @@ -9,15 +9,16 @@ import lombok.Data; import java.time.LocalDateTime; -/** Prevents a source pallet from being added to more than one active dispatch order. */ +/** 真实客户逐逻辑来源的活动占用,实际实托排他归独立物理占用行。 */ @Data @TableName("ll_dispatch_pallet_lock") public class DispatchPalletLock { @TableId(type = IdType.INPUT) private Long id; + private Long physicalLockId; private String customerId; private String sourcePalletKey; - /** Equals sourcePalletKey while ACTIVE and is null after release; unique index enforces exclusivity. */ + /** 活动时为完整逻辑来源键,释放后为空;不同换标来源不再共用一个来源键。 */ @TableField(updateStrategy = FieldStrategy.ALWAYS) private String activeKey; private Long planId; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java new file mode 100644 index 000000000..d7f5f9d4e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchPhysicalPalletLock.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.dispatch.entity; + +import com.baomidou.mybatisplus.annotation.*; +import lombok.Data; +import java.time.LocalDateTime; + +/** 实托排他占用归主单或草稿容器,不以任何逻辑来源或客户代表整托。 */ +@Data +@TableName("ll_dispatch_physical_pallet_lock") +public class DispatchPhysicalPalletLock { + @TableId(type=IdType.INPUT) private Long id; + private String warehouseId; + private Long warehousePalletId; + @TableField(updateStrategy=FieldStrategy.ALWAYS) private Long activeWarehousePalletId; + private Long dispatchOrderId; + private String draftToken; + private Long draftContainerId; + private String operatorId; + private String status; + private LocalDateTime expiresAt; + private LocalDateTime lockedAt; + private LocalDateTime releasedAt; + private LocalDateTime createdAt; + private LocalDateTime updatedAt; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index 0b7affc87..a150fdfd0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -25,6 +25,6 @@ public interface DispatchBolMapper { "LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " + "LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " + "WHERE di.dispatch_order_id=#{dispatchOrderId} " + - "GROUP BY di.id,l.id ORDER BY di.id,l.id") + "GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id") List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 17265daa0..434f24fc9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -52,11 +52,12 @@ public interface DispatchCandidateMapper { " / NULLIF((SELECT COUNT(*) FROM ll_inbound_plan_pallet_allocation a2 WHERE a2.line_id=a.line_id),0) END),0) ELSE NULL END AS grossWeightKg," + "MIN(ip.container_no) AS containerNo," + CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + // 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。 + "CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + - "AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')," + + "AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," + "(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " + - "WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS overseasWarehouseId," + + "WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," + "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + @@ -116,9 +117,8 @@ public interface DispatchCandidateMapper { "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + "WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " + - "AND (d.expires_at IS NULL OR d.expires_at > CURRENT_TIMESTAMP(3)) AND (" + - "d.source_pallet_key=CONCAT('PLAN:',p.id) OR (p.warehouse_pallet_id IS NOT NULL AND " + - "d.source_pallet_key=CONCAT('RECEIVED:',p.warehouse_pallet_id))) " + + "AND (d.expires_at IS NULL OR d.expires_at > CURRENT_TIMESTAMP(3)) AND " + + "d.source_pallet_key=CONCAT('PLAN:',p.id) " + "AND (#{draftToken} IS NULL OR d.draft_token IS NULL OR d.draft_token != #{draftToken})) " + // Incoming creation mode never grants ownership of a later transfer reservation. "AND NOT EXISTS (SELECT 1 FROM ll_warehouse_transfer_inventory_allocation ta " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java index f3d633f7f..e4342a232 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchDraftClaimMapper.java @@ -7,21 +7,21 @@ import java.util.List; /** 复用正式来源/实托唯一占用,仅写本拥有者草稿记录,不修改正式排车占用。 */ @Mapper public interface DispatchDraftClaimMapper { - record Claim(Long id,String customerId,String sourceKey,Long palletId,Long containerId,LocalDateTime expiresAt) { } + record Claim(Long id,String customerId,String sourceKey,Long palletId,Long containerId,LocalDateTime expiresAt,Long physicalLockId) { } /** 来源已加锁后才读取本草稿活动占用;不跨token领取他人记录。 */ - @Select("SELECT id,customer_id AS customerId,source_pallet_key AS sourceKey,warehouse_pallet_id AS palletId,draft_container_id AS containerId,expires_at AS expiresAt " + @Select("SELECT id,customer_id AS customerId,source_pallet_key AS sourceKey,warehouse_pallet_id AS palletId,draft_container_id AS containerId,expires_at AS expiresAt,physical_lock_id AS physicalLockId " + "FROM ll_dispatch_pallet_lock WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL " + "AND status='ACTIVE' AND expires_at>#{at} ORDER BY id FOR UPDATE") List owned(@Param("token")String token,@Param("owner")String owner,@Param("at")LocalDateTime at); - /** 与正式claim共用两项唯一约束,跨客户同实托也不得重复占用。 */ - @Insert("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id,warehouse_pallet_id,active_warehouse_pallet_id," + /** 每个逻辑来源独立保留客户和来源键,同容器同实托共享已取得的物理占用。 */ + @Insert("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id,warehouse_pallet_id," + "return_receipt_id,dispatch_order_id,draft_container_id,draft_token,operator_id,status,locked_at,expires_at,created_at,updated_at) " - + "VALUES(#{id},#{customer},#{key},#{key},#{plan},#{pallet},#{pallet},#{receipt},NULL,#{container},#{token},#{owner},'ACTIVE',#{at},#{expires},#{at},#{at})") + + "VALUES(#{id},#{physical},#{customer},#{key},#{key},#{plan},#{pallet},#{receipt},NULL,#{container},#{token},#{owner},'ACTIVE',#{at},#{expires},#{at},#{at})") int insert(@Param("id")long id,@Param("customer")String customer,@Param("key")String key,@Param("plan")Long plan, @Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("container")long container,@Param("token")String token, - @Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires); + @Param("owner")String owner,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires,@Param("physical")Long physical); /** 布局移动仅匹配同草稿原容器,不能迁移他人新取得的占用。 */ @Update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} AND draft_token=#{token} " @@ -29,8 +29,8 @@ public interface DispatchDraftClaimMapper { int move(@Param("id")long id,@Param("token")String token,@Param("owner")String owner,@Param("previous")long previous, @Param("next")long next,@Param("at")LocalDateTime at); - /** 按精确原身份释放,保留历史行并同时清空来源键和全局实托键。 */ - @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} " + /** 按精确原身份释放逻辑来源;物理占用由最后成员结束后经共同提供者释放。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} " + "WHERE draft_token=#{token} AND operator_id=#{owner} AND dispatch_order_id IS NULL AND status='ACTIVE' " + "AND (#{key} IS NULL OR source_pallet_key=#{key}) AND (#{container} IS NULL OR draft_container_id=#{container})") int release(@Param("token")String token,@Param("owner")String owner,@Param("key")String key,@Param("container")Long container,@Param("at")LocalDateTime at); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java index 2e29ef14a..2d24562b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchExecutionReadMapper.java @@ -11,16 +11,16 @@ public interface DispatchExecutionReadMapper { record RescanCandidate(String dispatchNo,String deliveryNo,long deliveryVersion) { } record PickSource(Long pickId,Long attributionId,Long itemId,String customerId,Long orderId,String orderNo, String sourceRegion,String sourceInboundId,String inboundCode,Long positionId,Long receiptId,Long returnReceiptId, - Long returnCartonId,String cartonMark,long packages,long productQuantity) { } + Long returnCartonId,String cartonMark,long packages,long productQuantity,Long dispatchItemSourceId) { } record ItemPicked(Long itemId,long packages) { } record ActualMeasurement(Long palletId,String sourceNo,LocalDateTime observedAt,BigDecimal weightKg,BigDecimal volumeCbm) { } /** 只取实际存在的订单与归属;缺失源头会使完整性核对失败。 */ @Select(""" SELECT p.id AS pickId,a.id AS attributionId,i.id AS itemId,a.customer_id AS customerId,a.order_id AS orderId, o.order_no AS orderNo,o.source_region AS sourceRegion,o.source_inbound_id AS sourceInboundId, - COALESCE(r.inbound_code,rb.inbound_code,i.inbound_code) AS inboundCode,p.inventory_position_id AS positionId, + COALESCE(s.source_inbound_code,r.inbound_code,rb.inbound_code,i.inbound_code) AS inboundCode,p.inventory_position_id AS positionId, a.receipt_id AS receiptId,a.return_receipt_id AS returnReceiptId,a.return_carton_id AS returnCartonId, - c.carton_mark AS cartonMark,a.packages,COALESCE(a.product_quantity,0) AS productQuantity + c.carton_mark AS cartonMark,a.packages,COALESCE(a.product_quantity,0) AS productQuantity,a.dispatch_item_source_id AS dispatchItemSourceId FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id JOIN ll_dispatch_pda_pick_order a ON a.pick_id=p.id AND a.customer_id=p.customer_id JOIN ll_order o ON o.id=a.order_id AND o.customer_id=a.customer_id @@ -28,18 +28,32 @@ public interface DispatchExecutionReadMapper { LEFT JOIN ll_return_stock_receipt rr ON rr.id=a.return_receipt_id AND rr.customer_id=a.customer_id LEFT JOIN ll_inventory_balance rb ON rb.id=rr.inventory_balance_id AND rb.customer_id=rr.customer_id LEFT JOIN ll_return_inventory_carton c ON c.id=a.return_carton_id AND c.customer_id=a.customer_id + LEFT JOIN ll_dispatch_item_source s ON s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id AND s.dispatch_item_id=i.id WHERE i.dispatch_order_id=#{id} ORDER BY i.id,p.id,a.id """) List sources(@Param("id")long dispatchId); /** 每个明细都必须独立核平,不能以整车合计抵消超拣与漏拣。 */ @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{id} GROUP BY i.id ORDER BY i.id") List itemPicked(@Param("id")long dispatchId); + /** 两端共同校验四种来源和逐贡献额度,避免同托来源互相补数量而放行。 */ + String INVALID_PICK=""" + (p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages + OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0) + OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id + AND NOT EXISTS(SELECT 1 FROM ll_order o WHERE o.id=a.order_id AND o.customer_id=a.customer_id)) + OR EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM ll_dispatch_item_source s WHERE s.id=a.dispatch_item_source_id AND s.customer_id=a.customer_id + AND s.dispatch_order_id=i.dispatch_order_id AND s.dispatch_item_id=i.id AND s.inventory_position_id=p.inventory_position_id + AND s.warehouse_pallet_id=i.warehouse_pallet_id AND i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER' + AND (SELECT COALESCE(SUM(x.packages),0) FROM ll_dispatch_pda_pick_order x WHERE x.dispatch_item_source_id=s.id AND x.customer_id=s.customer_id)<=s.package_count)) + OR (i.source_stage='RELABEL' AND i.source_business_type='RELABEL_ORDER' AND EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a + WHERE a.pick_id=p.id AND a.customer_id=p.customer_id AND a.dispatch_item_source_id IS NULL))) + """; /** 每条扫码的不可变归属箱数与商品数必须完整,缺归属不能因总数相等而放行。 */ @Select(""" SELECT COUNT(*) FROM ll_dispatch_item i JOIN ll_dispatch_pda_pick p ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id - WHERE i.dispatch_order_id=#{id} AND (p.packages<=0 OR COALESCE((SELECT SUM(a.packages) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>p.packages - OR COALESCE((SELECT SUM(a.product_quantity) FROM ll_dispatch_pda_pick_order a WHERE a.pick_id=p.id AND a.customer_id=p.customer_id),0)<>COALESCE(p.product_quantity,0)) - """) + WHERE i.dispatch_order_id=#{id} AND + """+INVALID_PICK) long inconsistentPicks(@Param("id")long dispatchId); /** 已完成实测只有当前整托组成未变时才可自动带入,计费重与订单分摊重不在此表。 */ @Select(""" @@ -47,7 +61,8 @@ public interface DispatchExecutionReadMapper { p.gross_weight_kg AS weightKg,p.volume_cbm AS volumeCbm FROM ll_dispatch_item i JOIN ll_warehouse_transfer_pallet p ON p.warehouse_pallet_id=i.warehouse_pallet_id AND p.customer_id=i.customer_id JOIN ll_warehouse_transfer_order t ON t.id=p.transfer_order_id AND t.customer_id=p.customer_id - WHERE i.dispatch_order_id=#{id} AND p.status='COMPLETED' AND p.carton_count=i.package_count AND p.completed_at IS NOT NULL + WHERE i.dispatch_order_id=#{id} AND p.status='COMPLETED' AND p.carton_count=(SELECT SUM(x.package_count) FROM ll_dispatch_item x + WHERE x.dispatch_order_id=i.dispatch_order_id AND x.warehouse_pallet_id=i.warehouse_pallet_id) AND p.completed_at IS NOT NULL AND NOT EXISTS(SELECT 1 FROM ll_inventory_ledger l WHERE l.customer_id=i.customer_id AND l.pallet_id=p.warehouse_pallet_id AND l.created_at>=p.completed_at AND l.business_type IN('RELABEL_PALLET_DISASSEMBLE','PALLET_ASSEMBLE','PALLET_DISASSEMBLE','PALLET_SWAP','PALLET_UNBIND')) ORDER BY p.warehouse_pallet_id,p.completed_at diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java new file mode 100644 index 000000000..fd6dce681 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchItemSourceMapper.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchItemSource; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 不可变来源快照只追加或随零扫码移货删除,不用聚合库存回推历史贡献。 */ +@Mapper +public interface DispatchItemSourceMapper extends BaseMapper { + record PickedSource(Long sourceId,long packages,long productQuantity) { } + /** 读取整车完整贡献,客户和逻辑明细身份始终随行保留。 */ + @Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} ORDER BY customer_id,dispatch_item_id,inventory_position_id,relabel_item_id,id") + List all(@Param("dispatch")long dispatch); + + /** PDA列表一次读取所有本批主单贡献,不逐车逐来源查询。 */ + @Select("") + List batchAll(@Param("ids")List ids); + + /** 一次核对冻结贡献的实际已消费箱件,不用同position其他申请的拣货抵消额度。 */ + @Select("") + List pickedAll(@Param("ids")List ids); + + /** 扫码位置只能消费所选真实客户和逻辑明细的贡献。 */ + @Select("SELECT * FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} AND customer_id=#{customer} " + + "AND inventory_position_id=#{position} ORDER BY relabel_item_id,id") + List position(@Param("dispatch")long dispatch,@Param("item")long item, + @Param("customer")String customer,@Param("position")long position); + + /** 额度按不可变真实拣货归属累计,不能以整仓位已拣数代替本来源已拣数。 */ + @Select("SELECT COALESCE(SUM(packages),0) FROM ll_dispatch_pda_pick_order WHERE customer_id=#{customer} AND dispatch_item_source_id=#{source}") + long picked(@Param("customer")String customer,@Param("source")long source); + + /** 零扫码移货才允许删除;已有实际归属时由SQL再次阻断。 */ + @Delete("DELETE FROM ll_dispatch_item_source WHERE dispatch_order_id=#{dispatch} AND dispatch_item_id=#{item} " + + "AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pda_pick_order a WHERE a.dispatch_item_source_id=ll_dispatch_item_source.id)") + int removeUnpicked(@Param("dispatch")long dispatch,@Param("item")long item); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java deleted file mode 100644 index 97250cbc5..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchOrderMapper.java +++ /dev/null @@ -1,22 +0,0 @@ -package com.baiyue.lastleg.dispatch.mapper; - -import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -@Mapper -public interface DispatchOrderMapper extends BaseMapper { - @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND dispatch_no=#{dispatchNo} LIMIT 1") - DispatchOrder findByDispatchNo(@Param("customerId") String customerId, @Param("dispatchNo") String dispatchNo); - - @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1") - DispatchOrder findGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo); - - @Select("SELECT * FROM ll_dispatch_order WHERE dispatch_no=#{dispatchNo} LIMIT 1 FOR UPDATE") - DispatchOrder lockGlobalByDispatchNo(@Param("dispatchNo") String dispatchNo); - - @Select("SELECT * FROM ll_dispatch_order WHERE customer_id=#{customerId} AND id=#{id} LIMIT 1 FOR UPDATE") - DispatchOrder lockById(@Param("customerId") String customerId, @Param("id") Long id); -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java index ddb022d7e..bfc0c463f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPalletLockMapper.java @@ -23,13 +23,6 @@ public interface DispatchPalletLockMapper extends BaseMapper List lockActiveByDraftContainer(@Param("draftToken") String draftToken, @Param("draftContainerId") Long draftContainerId); - @Select("SELECT l.dispatch_order_id FROM ll_dispatch_pallet_lock l JOIN ll_dispatch_order d " + - "ON d.id=l.dispatch_order_id AND d.customer_id=l.customer_id " + - "WHERE l.draft_token=#{draftToken} AND l.draft_container_id=#{draftContainerId} AND l.status='ACTIVE' " + - "ORDER BY l.updated_at DESC,l.id DESC LIMIT 1") - Long findDispatchOrderIdByDraftContainer(@Param("draftToken") String draftToken, - @Param("draftContainerId") Long draftContainerId); - @Select("SELECT * FROM ll_dispatch_pallet_lock WHERE customer_id=#{customerId} AND source_pallet_key=#{sourcePalletKey} " + "AND status='ACTIVE' LIMIT 1 FOR UPDATE") DispatchPalletLock lockActive(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java new file mode 100644 index 000000000..7878205ae --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchPhysicalClaimMapper.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; +import java.util.List; + +/** 物理层只保存实托与真正拥有者,逻辑来源份额仍由各自客户成员保存。 */ +@Mapper +public interface DispatchPhysicalClaimMapper extends BaseMapper { + /** 到期但尚未清键的原行也必须先锁定,不能越过原拥有者领取。 */ + @Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=#{pallet} FOR UPDATE") + DispatchPhysicalPalletLock lockActive(@Param("pallet")long pallet); + + /** 释放按原物理占用主键取得,不能按实托号误释放新拥有者的后续占用。 */ + @Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE id=#{id} FOR UPDATE") + DispatchPhysicalPalletLock lockOriginal(@Param("id")long id); + + /** 只读原实托身份供服务排序,再逐主键取锁,避免filesort之前按主键先锁的执行计划。 */ + @Select("") + List originals(@Param("ids")List ids); + + /** 批量释放、转换与续约前按物理托盘顺序收齐本主单的原行。 */ + @Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE dispatch_order_id=#{dispatch} AND status='ACTIVE' ORDER BY warehouse_pallet_id,id") + List lockDispatch(@Param("dispatch")long dispatch); + + /** 草稿归属同时包含token与操作人,容器身份由每条原行保留。 */ + @Select("SELECT * FROM ll_dispatch_physical_pallet_lock WHERE draft_token=#{token} AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' ORDER BY warehouse_pallet_id,id") + List lockDraft(@Param("token")String token,@Param("operator")String operator); + + /** 仅使该原物理占用下已到期的草稿成员失效,不释放正式来源或已经续期的成员。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,released_at=#{at},updated_at=#{at} " + + "WHERE physical_lock_id=#{id} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} " + + "AND EXISTS(SELECT 1 FROM ll_dispatch_physical_pallet_lock p WHERE p.id=#{id} AND p.status='ACTIVE' AND p.dispatch_order_id IS NULL " + + "AND p.draft_token=ll_dispatch_pallet_lock.draft_token AND p.draft_container_id=ll_dispatch_pallet_lock.draft_container_id " + + "AND p.operator_id=ll_dispatch_pallet_lock.operator_id AND p.expires_at<=#{at})") + int expireMembers(@Param("id")long id,@Param("at")LocalDateTime at); + + /** 最后活动成员结束后才能清物理键;原id保证不会触碰同托后续的新占用。 */ + @Update("UPDATE ll_dispatch_physical_pallet_lock SET status=#{status},active_warehouse_pallet_id=NULL,released_at=#{at},updated_at=#{at} " + + "WHERE id=#{id} AND status='ACTIVE' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} AND l.status='ACTIVE')") + int releaseUnused(@Param("id")long id,@Param("status")String status,@Param("at")LocalDateTime at); + + /** 草稿成员已经在同事务全部移入目标容器后,才允许迁移物理拥有者。 */ + @Update("UPDATE ll_dispatch_physical_pallet_lock SET draft_container_id=#{next},updated_at=#{at} WHERE id=#{id} " + + "AND draft_token=#{token} AND operator_id=#{operator} AND draft_container_id=#{previous} AND dispatch_order_id IS NULL " + + "AND status='ACTIVE' AND expires_at>#{at} AND NOT EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock l WHERE l.physical_lock_id=#{id} " + + "AND l.status='ACTIVE' AND (l.dispatch_order_id IS NOT NULL OR l.draft_token IS NULL OR l.draft_token<>#{token} " + + "OR l.draft_container_id IS NULL OR l.draft_container_id<>#{next} OR l.operator_id IS NULL OR l.operator_id<>#{operator}))") + int moveDraft(@Param("id")long id,@Param("token")String token,@Param("operator")String operator, + @Param("previous")long previous,@Param("next")long next,@Param("at")LocalDateTime at); + + /** 活动续约不复活已到期记录,期限与逻辑来源及草稿根一起提交。 */ + @Update("UPDATE ll_dispatch_physical_pallet_lock SET expires_at=#{expires},updated_at=#{at} WHERE draft_token=#{token} " + + "AND operator_id=#{operator} AND dispatch_order_id IS NULL AND status='ACTIVE' AND expires_at>#{at}") + int renewDraft(@Param("token")String token,@Param("operator")String operator,@Param("at")LocalDateTime at,@Param("expires")LocalDateTime expires); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index 03293fbfd..a3be9c524 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -13,6 +13,54 @@ public interface DispatchSourceMapper { record ReturnedReceipt(long id,long orderId,long inventoryPositionId,long packages) { } record Claim(Long id, Long dispatchId, String draftToken) { } record RelabelBasis(String status,String fulfillmentStatus,LocalDateTime updatedAt,LocalDateTime customerConfirmedAt) { } + record NativeApplication(String customerId,String relabelNo) { } + record PlanReference(Long planPalletId,String customerId) { } + record ReturnReference(Long receiptId,String customerId) { } + record OrderReference(Long orderId,String customerId) { } + record ItemPicked(Long itemId,long packages) { } + + /** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */ + @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p " + + "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List pickedItems(@Param("dispatch")long dispatch); + + /** 组成查询保留完整客户成员;仓ID两侧同转有界字符,避免列与连接字符集排序规则冲突。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List physicalPlans(@Param("warehouse")String warehouse,@Param("ids")List ids); + + /** 退回实收的事实身份不因当前草稿占用而消失。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List physicalReturns(@Param("warehouse")String warehouse,@Param("ids")List ids); + + /** 当前余额对应的真实实收订单参加共同锁序,不拿展示MIN或原货源快照ID猜订单表身份。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List physicalOrders(@Param("warehouse")String warehouse,@Param("ids")List ids); + + /** 共同串行只补齐真实身份锁,不给额外原货源或仓内操作新增暂扣资格。 */ + @Select("SELECT id FROM ll_order WHERE customer_id=#{customer} AND id=#{id} FOR UPDATE") + Long lockOrderIdentity(@Param("customer")String customer,@Param("id")long id); + + /** 原生退货实托仍由真实箱唛与申请号关联,不借普通库存仓位推断。 */ + @Select("") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List nativeApplications(@Param("warehouse")String warehouse,@Param("codes")List codes); /** 换标单客户由服务端业务单号查得,不接受客户端指定。 */ @Select("SELECT customer_id FROM ll_relabel_application WHERE relabel_no=#{number}") @@ -52,6 +100,7 @@ public interface DispatchSourceMapper { + "hold_packages AS holdPackages,allocated_packages AS allocatedPackages,picked_packages AS pickedPackages,version " + "FROM ll_inventory_position WHERE customer_id=#{customer} AND pallet_id=#{pallet} ORDER BY id" + " FOR UPDATE") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) List stock(@Param("customer") String customer, @Param("pallet") Long pallet, @Param("lock") boolean lock); /** 版本还包含逐入仓真实组成,后到货不得静默加入原选货。 */ @@ -63,35 +112,32 @@ public interface DispatchSourceMapper { @Select("SELECT r.id,r.order_id AS orderId,r.inventory_position_id AS inventoryPositionId,r.received_packages AS packages FROM ll_return_stock_receipt r JOIN ll_inventory_position p ON p.id=r.inventory_position_id AND p.customer_id=r.customer_id WHERE r.customer_id=#{customer} AND p.pallet_id=#{pallet} ORDER BY r.id") List returnedReceipts(@Param("customer")String customer,@Param("pallet")Long pallet); - /** 仅失效的草稿占用可让出唯一键,绝不释放活动正式单。 */ - @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} " - + "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} " - + "AND ((customer_id=#{customer} AND source_pallet_key=#{key}) OR active_warehouse_pallet_id=#{pallet})") - int expire(@Param("customer") String customer, @Param("key") String key, @Param("pallet") Long pallet, - @Param("at") LocalDateTime at); + /** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} " + + "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}") + int expire(@Param("customer") String customer,@Param("key") String key,@Param("at") LocalDateTime at); - /** 不同来源别名指向同一实托时只能归同一整车,客户不作为实托锁分片。 */ + /** 逻辑键只代表本客户的这条来源,不能因同托已有其他来源就跳过插入。 */ @Select("SELECT id,dispatch_order_id AS dispatchId,draft_token AS draftToken FROM ll_dispatch_pallet_lock " - + "WHERE status='ACTIVE' AND ((customer_id=#{customer} AND active_key=#{key}) OR active_warehouse_pallet_id=#{pallet}) " + + "WHERE status='ACTIVE' AND customer_id=#{customer} AND active_key=#{key} " + "AND (expires_at IS NULL OR expires_at>#{at}) ORDER BY id FOR UPDATE") - List claims(@Param("customer") String customer, @Param("key") String key, - @Param("pallet") Long pallet, @Param("at") LocalDateTime at); + List claims(@Param("customer") String customer,@Param("key") String key,@Param("at") LocalDateTime at); - /** 只释放本车本来源原占用,清空实托唯一键,不触碰后续他车占用。 */ - @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,active_warehouse_pallet_id=NULL,updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND status='ACTIVE' AND ((#{plan} IS NOT NULL AND plan_pallet_id=#{plan}) OR (#{receipt} IS NOT NULL AND return_receipt_id=#{receipt}) OR (#{pallet} IS NOT NULL AND warehouse_pallet_id=#{pallet}))") - int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("receipt")Long receipt,@Param("pallet")Long pallet,@Param("at")LocalDateTime at); + /** 精确释放本车本客户完整来源键,不能按共享实托一起释放另一申请。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL,released_at=#{at},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND source_pallet_key=#{key} AND status='ACTIVE'") + int release(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("key")String key,@Param("at")LocalDateTime at); - /** 原计划自然收货后取得同一实托唯一键,其他车/来源的占用不能被覆盖。 */ - @Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},active_warehouse_pallet_id=#{pallet},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL") - int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("at")LocalDateTime at); + /** 原计划收货只补齐本逻辑成员的实际实托和已取得的物理占用引用。 */ + @Update("UPDATE ll_dispatch_pallet_lock SET warehouse_pallet_id=#{pallet},physical_lock_id=#{physical},updated_at=#{at} WHERE dispatch_order_id=#{dispatch} AND customer_id=#{customer} AND plan_pallet_id=#{plan} AND status='ACTIVE' AND warehouse_pallet_id IS NULL AND physical_lock_id IS NULL") + int materialize(@Param("dispatch")long dispatch,@Param("customer")String customer,@Param("plan")Long plan,@Param("pallet")Long pallet,@Param("physical")long physical,@Param("at")LocalDateTime at); - /** 唯一活动实托键与来源键共同阻止并发跨车重复排货。 */ + /** 本来源活动唯一键独立保存,实际实托必须关联本拥有者的物理占用。 */ @Insert(""" - INSERT INTO ll_dispatch_pallet_lock(id,customer_id,source_pallet_key,active_key,plan_pallet_id, - warehouse_pallet_id,active_warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at) - VALUES(#{id},#{customer},#{key},#{key},#{planPallet},#{pallet},#{pallet},#{receipt},#{dispatch},'ACTIVE',#{operator},#{at},#{at},#{at}) + INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,plan_pallet_id, + warehouse_pallet_id,return_receipt_id,dispatch_order_id,status,operator_id,locked_at,created_at,updated_at) + VALUES(#{id},#{physical},#{customer},#{key},#{key},#{planPallet},#{pallet},#{receipt},#{dispatch},'ACTIVE',#{operator},#{at},#{at},#{at}) """) - int claim(@Param("id") long id, @Param("customer") String customer, @Param("key") String key, - @Param("planPallet") Long planPallet, @Param("pallet") Long pallet, @Param("receipt") Long receipt, - @Param("dispatch") long dispatch, @Param("operator") String operator, @Param("at") LocalDateTime at); + int claim(@Param("id")long id,@Param("customer")String customer,@Param("key")String key, + @Param("planPallet")Long planPallet,@Param("pallet")Long pallet,@Param("receipt")Long receipt,@Param("physical")Long physical, + @Param("dispatch")long dispatch,@Param("operator")String operator,@Param("at")LocalDateTime at); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java index 060d86376..82e8e75d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/ReturnDispatchCandidateMapper.java @@ -28,7 +28,7 @@ public interface ReturnDispatchCandidateMapper { "AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR b.inbound_code LIKE CONCAT('%',#{orderNo},'%') OR r.receipt_no LIKE CONCAT('%',#{orderNo},'%')) " + "AND d.destination_warehouse_code=#{fba} " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock k WHERE k.customer_id=r.customer_id " + - "AND k.source_pallet_key=CONCAT('RECEIVED:',p.pallet_id) AND k.status='ACTIVE' " + + "AND k.source_pallet_key=CONCAT('RETURN:',r.id) AND k.status='ACTIVE' " + "AND (k.expires_at IS NULL OR k.expires_at>CURRENT_TIMESTAMP) AND (#{draftToken} IS NULL OR k.draft_token IS NULL OR k.draft_token != #{draftToken})) " + "ORDER BY r.received_at,r.id"; @Select("") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java index 52d2a0136..869c23a97 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java @@ -8,6 +8,7 @@ public record DispatchBolData(String dispatchNo,String overseasWarehouseId,Strin String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId, String poReferenceNo,int packageCount) { public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);} + public String destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;} public String groupKey(){return customerId+"\u0000"+destinationKey();} } public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java deleted file mode 100644 index f8591d251..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaRequests.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.lastleg.dispatch.pda.dto; - -import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Positive; -import jakarta.validation.constraints.PositiveOrZero; -import jakarta.validation.constraints.Size; - -public final class DispatchPdaRequests { - private DispatchPdaRequests() {} - public record Pick(Long positionId, @Positive long packages, @PositiveOrZero long productQuantity, - @NotBlank String idempotencyKey, String remark, @Size(max = 255) String cartonMark) { - public Pick(Long positionId, long packages, long productQuantity, String idempotencyKey, String remark) { - this(positionId, packages, productQuantity, idempotencyKey, remark, null); - } - } - public record Depart(@NotBlank String idempotencyKey, String remark) {} - public record CompletePicking(@NotBlank String idempotencyKey) {} -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java index 845cc9847..01dcf8530 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/dto/DispatchPdaResponses.java @@ -1,43 +1,6 @@ package com.baiyue.lastleg.dispatch.pda.dto; - -import java.util.List; -import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; - +/** 原生退货换标箱的内部读取投影。 */ public final class DispatchPdaResponses { - private DispatchPdaResponses() {} - public record Task(Long id, String taskNo, String dispatchNo, String vehiclePlate, String warehouseId, String status, - boolean canDepart, String departBlockReason, List items, String tripNo, Long tripRecordVersion, int tripDispatchCount, String driverWarning, - boolean canPick, String pickBlockReason) { - public Task withGate(FleetExecutionModels.Gate gate) { - return new Task(id, taskNo, dispatchNo, vehiclePlate, warehouseId, status, gate.canDepart(), gate.blockReason(), items, - gate.tripNo(), gate.recordVersion(), gate.dispatchCount(), gate.driverWarning(), gate.canPick(), gate.pickBlockReason()); - } - } - public record Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, - String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, - int scheduledPackages, - long pickedPackages, List positions, List returnCartons) { - public Item(Long dispatchItemId, String palletNo, String destinationWarehouseCode, - String destinationWarehouseName, String fbaWarehouseCode, String warehouseRouteLabel, - int scheduledPackages, long pickedPackages, List positions) { - this(dispatchItemId, palletNo, destinationWarehouseCode, destinationWarehouseName, fbaWarehouseCode, - warehouseRouteLabel, scheduledPackages, pickedPackages, positions, List.of()); - } - } - public record ReturnCarton(Long id, String cartonMark, String status, long productQuantity) {} - public record Position(Long id, Long balanceId, long availablePackages, long allocatedPackages, - long pickedPackages) {} - public record Picked(Long dispatchItemId, Long positionId, long packages, long pickedPackages, - boolean palletScrapped, boolean replayed) {} - public record Departed(String dispatchNo, String status, long shippedPackages, boolean replayed) { - public Departed withReplay() { return new Departed(dispatchNo, status, shippedPackages, true); } - } - public record PickingCompleted(String dispatchNo, String status, long plannedPackages, - long pickedPackages, long remainingPackages, boolean partial, - boolean replayed) { - public PickingCompleted withReplay() { - return new PickingCompleted(dispatchNo, status, plannedPackages, pickedPackages, - remainingPackages, partial, true); - } - } + private DispatchPdaResponses() { } + public record ReturnCarton(Long id,String cartonMark,String status,long productQuantity) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java index 225cb9aa5..95dde7510 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/entity/DispatchPdaPickOrder.java @@ -17,6 +17,7 @@ public class DispatchPdaPickOrder { private Long receiptId; private Long returnReceiptId; private Long returnCartonId; + private Long dispatchItemSourceId; private Long orderId; private Long packages; private Long productQuantity; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java index f9047428f..47fed9de8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java @@ -25,8 +25,7 @@ public interface DispatchWholePdaReadMapper { List picked(@Param("ids")List ids); /** 每条扫码的来源订单、箱数与商品数均须真实完整。 */ @Select("") + +" AND GROUP BY i.dispatch_order_id") List invalid(@Param("ids")List ids); /** 仓位先按本批明细客户、实托及绑定仓一次读取。 */ @Select("") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index 9d1f51a3d..45b9a0687 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -18,6 +18,9 @@ import java.time.LocalDateTime; import java.util.TreeMap; import java.util.List; import java.util.Map; +import java.util.Objects; +import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService; @Service public class DispatchPickOrderAttributionService { @@ -25,14 +28,41 @@ public class DispatchPickOrderAttributionService { @Resource private LastlegOrderMapper orders; @Resource private LastlegOrderStatusHistoryMapper history; @Resource private com.baiyue.lastleg.inventory.mapper.ReturnStockReceiptMapper returnReceipts; + @Resource private DispatchItemSourceRepository relabelSources; - /** Acquire source order locks before any inventory lock, matching order hold/release. */ + /** 普通换标只锁本申请真实履约单,不读取其他来源receipt做FIFO猜测。 */ + @org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void prepareRelabel(String customerId,Long orderId) { + var order=orderId==null?null:orders.lockById(customerId,orderId); + if(order==null || Boolean.TRUE.equals(order.getDetained()))throw conflict("换标履约订单缺失或暂扣"); + } + + /** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */ + @org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List allocations) { + long packages=0,quantity=0;var seen=new java.util.HashSet(); + for(var allocation:allocations) { + var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId()); + if(source==null || !seen.add(source.getId()) || !Objects.equals(source.getCustomerId(),customerId) + || !Objects.equals(source.getDispatchItemId(),pick.getDispatchItemId()) || !Objects.equals(source.getInventoryPositionId(),pick.getInventoryPositionId()) + || allocation.packages()<=0 || allocation.productQuantity()!=null && allocation.productQuantity()<0)throw conflict("换标扫码冻结贡献身份不一致"); + packages=Math.addExact(packages,allocation.packages());quantity=Math.addExact(quantity,allocation.productQuantity()==null?0:allocation.productQuantity()); + } + if(allocations.isEmpty() || packages!=value(pick.getPackages()) || quantity!=value(pick.getProductQuantity()))throw conflict("换标扫码箱件与真实贡献未核平"); + for(var allocation:allocations) { + var row=new DispatchPdaPickOrder();row.setId(IdWorker.getId());row.setCustomerId(customerId);row.setPickId(pick.getId());row.setOrderId(orderId); + row.setDispatchItemSourceId(allocation.dispatchItemSourceId());row.setPackages(allocation.packages());row.setProductQuantity(allocation.productQuantity()==null?0:allocation.productQuantity());row.setCreatedAt(pick.getPickedAt()); + if(attributions.insert(row)!=1)throw conflict("换标实际来源归属未保存"); + } + if(orderId==null || orders.increasePicked(customerId,orderId,packages)!=1)throw conflict("换标履约实际拣货箱数不一致"); + } + + /** 来源订单先于库存锁,普通实收不补造换标履约归属。 */ @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) - public void preparePicking(String customerId, long positionId, Long fallbackOrderId) { + public void preparePicking(String customerId, long positionId) { var sourceIds = new java.util.TreeSet(); attributions.availableReceipts(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); returnReceipts.available(customerId, positionId).forEach(receipt -> sourceIds.add(receipt.getOrderId())); - if (fallbackOrderId != null && fallbackOrderId > 0) sourceIds.add(fallbackOrderId); if (sourceIds.isEmpty()) throw conflict("PDA pick has no attributable source order"); for (Long orderId : sourceIds) { LastlegOrder order = orders.lockById(customerId, orderId); @@ -50,20 +80,8 @@ public class DispatchPickOrderAttributionService { } } - /** Call after the inventory position has been locked/moved and the PDA pick row has been inserted. */ - public List attribute(String customerId, DispatchPdaPick pick) { - return attribute(customerId, pick, null); - } - - /** - * Relabel inventory created before receipt-detail backfill may have no - * ll_inbound_stock_receipt row. Its dispatch item still carries the - * authoritative tail-leg order, so use that order only for relabel picks. - */ - public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId) { - return attribute(customerId, pick, fallbackOrderId, null); - } - public List attribute(String customerId, DispatchPdaPick pick, Long fallbackOrderId, Long selectedReturnReceiptId) { + /** 普通实收与退回实收只按真实receipt归属;换标另走冻结贡献分支。 */ + public List attribute(String customerId, DispatchPdaPick pick, Long selectedReturnReceiptId) { long remainingPackages = value(pick.getPackages()); long totalPackages = remainingPackages; long totalQuantity = value(pick.getProductQuantity()); @@ -114,10 +132,6 @@ public class DispatchPickOrderAttributionService { pendingAttributions.add(row); pickedByOrder.merge(locked.getOrderId(), packages, Long::sum); remainingPackages -= packages; allocatedPackages = nextPackages; allocatedQuantity = nextQuantity; } - if (remainingPackages != 0 && selectedReturnReceiptId == null && fallbackOrderId != null && fallbackOrderId > 0) { - pickedByOrder.merge(fallbackOrderId, remainingPackages, Long::sum); - remainingPackages = 0; - } if (remainingPackages != 0) throw conflict("PDA pick cannot be attributed to inbound receipt orders"); pendingAttributions.forEach(attributions::insert); for (Map.Entry entry : pickedByOrder.entrySet()) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java index 9c71dfb6f..8b1ca3dee 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -25,18 +25,9 @@ public final class DispatchCommands { Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, List cargo, List batchAllocations) { } - /** 只关联权威既有预约,确认状态/时间必须解析提供者结果。 */ - public record AppointmentReference(String referenceNo, Long recordVersion) { - /** 拒绝缺引用或缺版本,不提供手工确认状态字段。 */ - public AppointmentReference { - if (referenceNo == null || referenceNo.isBlank()) throw new IllegalArgumentException("预约引用必填"); - recordVersion = requireVersion(recordVersion); - } - } - public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, Instant plannedDepartureAt, Instant estimatedArrivalAt, - AppointmentReference vehicleAppointment, String remark) { + String remark) { /** 缺版本与有效版本零必须区分。 */ public Update { recordVersion = requireVersion(recordVersion); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java index b96d9e564..b3940213d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -59,6 +59,11 @@ public final class DispatchContracts { public BatchReference { recordVersion=DispatchCommands.requireVersion(recordVersion); } } + /** 普通换标本次归属的原货源快照,不以换标履约单或整托货量冒充。 */ + public record SourceOrigin(Long dispatchItemSourceId, Long relabelApplicationId, Long relabelItemId, + Long sourceInventoryBalanceId, Long sourceInboundOrderId, + String sourceInboundOrderNo, String sourceInboundCode, Long productQuantity) { } + /** 一行表示本次不可变真实拣货来源,展示入仓码不能替代此归属。 */ public record CargoLine(Long dispatchItemId, String customerId, String customerName, Long orderId, String orderNo, String sourceKey, String sourceStage, @@ -68,7 +73,8 @@ public final class DispatchContracts { Long receiptId, Long returnReceiptId, Long returnCartonId, String cartonMark, String relabelNo, Destination destination, long packages, long productQuantity, BigDecimal grossWeightKg, - BigDecimal volumeCbm, List batchAllocations) { } + BigDecimal volumeCbm, List batchAllocations, + SourceOrigin sourceOrigin) { } /** 仅用于服务内协作;锁定上下文并不等于通过当前版本或阶段资格。 */ public record Context(Long dispatchId, String dispatchNo, Long tripId, String tripNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java index 647ff9c2d..0ee629c41 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java @@ -20,7 +20,21 @@ public final class DispatchPlanningModels { String warehouseName, String orderNo, String inboundCode, String planNo, String palletNo, String containerNo, Destination destination, long availablePackages, BigDecimal suggestedWeightKg, BigDecimal suggestedVolumeCbm, String measurementSource, - List appointmentBatches, List blockReasons) { } + List appointmentBatches, List blockReasons, + SelectionGroup selectionGroup) { } + + /** 服务端核齐实托与整申请闭包;缺组、缺版本或complete=false均不能作为完整选货证明。 */ + public record SelectionGroup(String groupKey, String groupVersion, List sources, + List pallets, boolean complete, List blockReasons) { } + + /** 每个逻辑来源保留真实客户及业务号,selection携带该来源的权威版本。 */ + public record SelectionGroupSource(DispatchCommands.CargoSelection selection, String sourceKey, + String customerId, String customerName, String sourceBusinessType, + String sourceBusinessNo, long packages) { } + + /** 实托只列一次;sourceKeys完整列出其逻辑贡献,packageCount为这些贡献的真实合计。 */ + public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List sourceKeys, + long packageCount) { } public record CandidateList(List candidates, Instant snapshotAt, boolean complete) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 058b41cb2..75f767fdd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -42,12 +42,9 @@ public final class DispatchViews { String destinationKey, String destinationWarehouseCode, String status, Instant confirmedAt, String blockReason) { } - /** 仅引用已存在的车辆预约;本批不新增预约权威模型或创建接口。 */ - public record VehicleAppointment(String referenceNo, String status, Instant appointmentAt) { } - public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt, Instant estimatedArrivalAt, List fbaAppointments, - VehicleAppointment vehicleAppointment, List appointmentBatches) { } + List appointmentBatches) { } public record Documents(String dispatchNo, List outbounds, List deliveries, @@ -55,7 +52,8 @@ public final class DispatchViews { public record OrderDetail(String customerId, String customerName, String orderNo, String inboundCode, BigDecimal grossWeightKg, Integer palletCount, String destinationFbaWarehouse, - BigDecimal volumeCbm, String containerNo, String sourceKey) { } + BigDecimal volumeCbm, String containerNo, String sourceKey, + String sourceBusinessType, String sourceBusinessNo) { } /** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */ public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion, @@ -70,7 +68,8 @@ public final class DispatchViews { public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo, Destination destination, long scheduledPackages, long pickedPackages, - List positions, List returnCartons) { } + List positions, List returnCartons, + String sourceKey, String sourceBusinessType, String sourceBusinessNo) { } public record Position(Long positionId, Long balanceId, long availablePackages, long allocatedPackages, long pickedPackages) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java index 74b7fa05d..a0783c6d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchDraftRepository.java @@ -43,12 +43,12 @@ public class DispatchDraftRepository { public void complete(long id,String json){if(commands.complete(id,json)!=1)throw new IllegalStateException("草稿命令结果未保存");} /** 来源提供者已取根锁后,锁本草稿活动占用。 */ public List claims(DispatchDraft row,LocalDateTime now){return claims.owned(row.getDraftToken(),row.getOperatorId(),now);} - /** 占用来源及实托,两项唯一键共同保证排他。 */ + /** 逻辑来源引用物理提供者已取得的共享占用,保留本客户独立来源身份。 */ public void claim(long id,DispatchDraft row,long container,DispatchServiceSource source,LocalDateTime now){ - if(claims.insert(id,source.customer(),source.key(),source.plan(),source.pallet(),source.receipt(),container,row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt())!=1)throw new IllegalStateException("来源占用未保存"); + if(claims.insert(id,source.customer(),source.key(),source.plan(),source.pallet(),source.receipt(),container,row.getDraftToken(),row.getOperatorId(),now,row.getExpiresAt(),source.physicalLockId())!=1)throw new IllegalStateException("来源占用未保存"); } /** 数据访问输入只包含提供者已经解析的真实标识。 */ - public record DispatchServiceSource(String customer,String key,Long plan,Long pallet,Long receipt) { } + public record DispatchServiceSource(String customer,String key,Long plan,Long pallet,Long receipt,Long physicalLockId) { } /** 移动只针对同草稿原容器记录,零行不能被当作成功。 */ public void move(DispatchDraft row,DispatchDraftClaimMapper.Claim claim,long next,LocalDateTime now){if(claims.move(claim.id(),row.getDraftToken(),row.getOperatorId(),claim.containerId(),next,now)!=1)throw new IllegalStateException("原草稿占用已变化");} /** 释放限定原token、owner及尚未转换的草稿记录。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java new file mode 100644 index 000000000..34792a750 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchItemSourceRepository.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchItemSource; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 换标来源冻结快照的持久化边界,不执行物理库存或履约状态命令。 */ +@Repository +public class DispatchItemSourceRepository { + private final DispatchItemSourceMapper mapper; + /** 只装配本域真实来源快照Mapper。 */ + public DispatchItemSourceRepository(DispatchItemSourceMapper mapper){this.mapper=mapper;} + /** 唯一贡献键阻止同一item/位置被重复冻结。 */ + public int insert(DispatchItemSource source){return mapper.insert(source);} + /** 批量读取整车真实贡献。 */ + public List all(long dispatch){return mapper.all(dispatch);} + /** 本批主单的真实贡献一次读取。 */ + public List batchAll(List ids){return ids.isEmpty()?List.of():mapper.batchAll(ids);} + /** 本批全部贡献的实际已消费额度一次读取。 */ + public List pickedAll(List ids){return ids.isEmpty()?List.of():mapper.pickedAll(ids);} + /** 新归属分支必须关联真实冻结贡献。 */ + public DispatchItemSource find(long id){return mapper.selectById(id);} + /** 所选位置仍受本客户及本逻辑来源约束。 */ + public List position(long dispatch,long item,String customer,long position){return mapper.position(dispatch,item,customer,position);} + /** 已消费额度只取本来源不可变归属。 */ + public long picked(String customer,long source){return mapper.picked(customer,source);} + /** 仅删除原主单原明细且尚无扫码的贡献。 */ + public int removeUnpicked(long dispatch,long item){return mapper.removeUnpicked(dispatch,item);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java index b52211565..5347b9a33 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchMutationRepository.java @@ -1,60 +1,16 @@ package com.baiyue.lastleg.dispatch.repository; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baiyue.lastleg.dispatch.entity.*; -import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.delivery.entity.OutboundOrder; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import java.util.List; -/** 排车变更的数据操作,事务及状态规则由排车服务维护。 */ +/** 整车来源规则对原换标申请的事务内数据操作。 */ public class DispatchMutationRepository { - private final DispatchOrderMapper orders; - private final DispatchItemMapper items; - private final DispatchPalletLockMapper locks; - private final OutboundOrderMapper outbounds; private final DispatchCandidateMapper candidates; private final RelabelApplicationMapper relabels; - private final DispatchPdaTaskMapper tasks; - - /** 复用现有 Mapper 装配,供原排车服务分步收口变更路径。 */ - public DispatchMutationRepository(DispatchOrderMapper orders, DispatchItemMapper items, DispatchPalletLockMapper locks, - OutboundOrderMapper outbounds, DispatchCandidateMapper candidates, RelabelApplicationMapper relabels, DispatchPdaTaskMapper tasks) { - this.orders=orders; this.items=items; this.locks=locks; this.outbounds=outbounds; - this.candidates=candidates; this.relabels=relabels; this.tasks=tasks; - } - /** 按业务号锁定唯一排车。 */ - public DispatchOrder lockOrder(String dispatchNo) { return orders.lockGlobalByDispatchNo(dispatchNo); } - /** 保存排车的业务变更。 */ - public void saveOrder(DispatchOrder order) { orders.updateById(order); } - /** 创建已校验客户和完整货量的新排车。 */ - public void createOrder(DispatchOrder order) { orders.insert(order); } - /** 读取当前客户排车的原始明细。 */ - public List items(DispatchOrder order) { return items.findByDispatchOrder(order.getCustomerId(), order.getId()); } - /** 锁定本排车的指定托盘明细。 */ - public DispatchItem lockItem(DispatchOrder order, Long itemId) { return items.lockById(order.getCustomerId(), order.getId(), itemId); } - /** 保存新换标排车明细。 */ - public void createItem(DispatchItem item) { items.insert(item); } - /** 保存退货换标来源快照。 */ - public void saveItem(DispatchItem item) { items.updateById(item); } - /** 删除尚未执行的排车托盘,历史单据由取消命令保留。 */ - public void removeItem(Long itemId) { items.deleteById(itemId); } - /** 读取该排车自己的活动占用,不触碰后续新排车。 */ - public List activeLocks(DispatchOrder order) { - return locks.selectList(new LambdaQueryWrapper() - .eq(DispatchPalletLock::getCustomerId, order.getCustomerId()).eq(DispatchPalletLock::getDispatchOrderId, order.getId()) - .eq(DispatchPalletLock::getStatus, "ACTIVE")); - } - /** 保存单条原排车占用的释放事实。 */ - public void saveLock(DispatchPalletLock lock) { locks.updateById(lock); } - /** 读取或锁定历史关联出库;业务层区分活动与取消。 */ - public OutboundOrder outbound(DispatchOrder order, boolean lock) { - return lock ? outbounds.lockByDispatch(order.getCustomerId(), order.getId()) : outbounds.findByDispatch(order.getCustomerId(), order.getId()); + public DispatchMutationRepository(DispatchCandidateMapper candidates,RelabelApplicationMapper relabels) { + this.candidates=candidates; this.relabels=relabels; } /** 锁定换标申请,串行完整候选检查与整单状态推进。 */ public RelabelApplication lockRelabel(String customer, String number) { return relabels.lockNo(customer, number); } @@ -64,6 +20,4 @@ public class DispatchMutationRepository { public int markRelabel(String customer, String number) { return candidates.markRelabelPreDispatched(customer, number); } /** 取消未执行排车后恢复原申请候选。 */ public void restoreRelabel(String customer, String number) { candidates.restoreRelabelPreDispatch(customer, number); } - /** 创建与新排车同事务的 PDA 任务。 */ - public void createTask(DispatchPdaTask task) { tasks.insert(task); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java new file mode 100644 index 000000000..1305a7241 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchPhysicalClaimRepository.java @@ -0,0 +1,35 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock; +import com.baiyue.lastleg.dispatch.mapper.DispatchPhysicalClaimMapper; +import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; + +/** 物理占用的持久化适配,拥有者和成员生命周期由服务编排。 */ +@Repository +public class DispatchPhysicalClaimRepository { + private final DispatchPhysicalClaimMapper mapper; + /** 只经本域Mapper访问物理及其逻辑成员。 */ + public DispatchPhysicalClaimRepository(DispatchPhysicalClaimMapper mapper){this.mapper=mapper;} + /** 锁定唯一活动实托占用。 */ + public DispatchPhysicalPalletLock lockActive(long pallet){return mapper.lockActive(pallet);} + /** 锁定原物理占用记录。 */ + public DispatchPhysicalPalletLock lockOriginal(long id){return mapper.lockOriginal(id);} + /** 原引用集合只读物理身份供服务排序,空集合不生成无效IN。 */ + public List originals(List ids){return ids.isEmpty()?List.of():mapper.originals(ids);} + /** 按全局实托顺序锁本主单全部物理占用。 */ + public List lockDispatch(long dispatch){return mapper.lockDispatch(dispatch);} + /** 按全局实托顺序锁本草稿全部物理占用。 */ + public List lockDraft(String token,String operator){return mapper.lockDraft(token,operator);} + /** 活动实托唯一键承担并发领取的最终约束。 */ + public int insert(DispatchPhysicalPalletLock row){return mapper.insert(row);} + /** 到期只影响原物理行的草稿成员。 */ + public int expireMembers(long id,LocalDateTime at){return mapper.expireMembers(id,at);} + /** 只有没有活动成员时才结束原物理占用。 */ + public int releaseUnused(long id,String status,LocalDateTime at){return mapper.releaseUnused(id,status,at);} + /** 草稿整托迁移不修改其他拥有者。 */ + public int moveDraft(long id,String token,String operator,long previous,long next,LocalDateTime at){return mapper.moveDraft(id,token,operator,previous,next,at);} + /** 原草稿全部物理占用一起续期。 */ + public int renewDraft(String token,String operator,LocalDateTime at,LocalDateTime expires){return mapper.renewDraft(token,operator,at,expires);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 161b9119f..08d3261f6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -31,6 +31,24 @@ public class DispatchSourceRepository { public List availableRelabel(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery q) { return plans.wholeRelabelAvailable(q.warehouseId(),null,q.orderNo(),null); } + /** 完整组只扩大到已定位实托的可用普通及真实退回组成。 */ + public List physicalAvailable(String warehouse,List pallets) { + if(pallets.isEmpty())return List.of(); + var result=new java.util.ArrayList(); + var selected=source.physicalPlans(warehouse,pallets).stream().collect(java.util.stream.Collectors.groupingBy(r->r.customerId())); + for(var group:selected.entrySet())result.addAll(plans.readSelected(group.getKey(),group.getValue().stream().map(r->r.planPalletId()).toList())); + var returned=source.physicalReturns(warehouse,pallets).stream().collect(java.util.stream.Collectors.groupingBy(r->r.customerId())); + for(var group:returned.entrySet())result.addAll(returns.readSelected(group.getKey(),group.getValue().stream().map(r->r.receiptId()).toList())); + return result.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).toList(); + } + /** 原生退货箱的真实申请归属只在指定托盘码范围内读取。 */ + public List nativeApplications(String warehouse,List codes){return codes.isEmpty()?List.of():source.nativeApplications(warehouse,codes);} + /** 指定实托当前余额来源的真实订单集合。 */ + public List physicalOrders(String warehouse,List pallets){return pallets.isEmpty()?List.of():source.physicalOrders(warehouse,pallets);} + /** 同一全车各逻辑来源已经实际消费的箱数。 */ + public List pickedItems(long dispatch){return source.pickedItems(dispatch);} + /** 额外身份锁不隐式增加原本没有的资格条件。 */ + public Long lockOrderIdentity(String customer,long id){return source.lockOrderIdentity(customer,id);} /** 来源托盘的真实客户。 */ public List planOwners(Long id) { return plans.findCustomerScopesByPlanPalletIds(List.of(id)); } /** 退回收据的真实客户。 */ @@ -58,15 +76,15 @@ public class DispatchSourceRepository { /** 同一实托的退回实收不得从来源证明丢失。 */ public List returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);} /** 释放已到期草稿键。 */ - public int expire(String customer, String key, Long pallet, LocalDateTime at) { return source.expire(customer,key,pallet,at); } + public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); } /** 锁定当前活动资源占用。 */ - public List claims(String customer, String key, Long pallet, LocalDateTime at) { return source.claims(customer,key,pallet,at); } + public List claims(String customer, String key, LocalDateTime at) { return source.claims(customer,key,at); } /** 精确释放本车本来源,不释放他车重新取得的键。 */ - public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getPlanPalletId(),item.getReturnReceiptId(),item.getWarehousePalletId(),at);} + public int release(long dispatch,com.baiyue.lastleg.dispatch.entity.DispatchItem item,LocalDateTime at) {return source.release(dispatch,item.getCustomerId(),item.getSourceKey(),at);} /** 原计划收货的物理身份与其活动键同事务补齐,不复制来源占用。 */ - public int materialize(long dispatch,String customer,long plan,long pallet,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,at);} + public int materialize(long dispatch,String customer,long plan,long pallet,long physical,LocalDateTime at){return source.materialize(dispatch,customer,plan,pallet,physical,at);} /** 保存正式源/实托占用。 */ - public int claim(long id, String customer, String key, Long plan, Long pallet, Long receipt, long dispatch, String operator, LocalDateTime at) { - return source.claim(id,customer,key,plan,pallet,receipt,dispatch,operator,at); + public int claim(long id, String customer, String key, Long plan, Long pallet, Long receipt, Long physical, long dispatch, String operator, LocalDateTime at) { + return source.claim(id,customer,key,plan,pallet,receipt,physical,dispatch,operator,at); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index d6ab99161..b283e69fb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -32,6 +32,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; @Service public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; + @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; + @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins; /** 仅用于显示的不可变来源字段,数量/归属仍取关系明细和真实拣货。 */ public record SourceDisplay(String containerNo,String planNo) { } private static final String PREFIX = "platform:overseas-warehouse:dispatch:"; @@ -103,10 +106,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(next!=null && !next.isEmpty())throw bad("移出完整实物时不能保留其新分配"); var relabels=touched.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet()); if(items.stream().anyMatch(i->relabels.contains(i.getSourceBusinessNo()) && !ids.contains(i.getId())))throw conflict("换标必须按原完整申请调整,不能仅移出部分来源"); + var pallets=touched.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toSet()); + if(items.stream().anyMatch(i->pallets.contains(i.getWarehousePalletId()) && !ids.contains(i.getId())))throw conflict("同实托完整来源须一起调整,不能仅移出部分成员"); + lockExecutionSources(row,items); appointments.remove(row.getId(),touched);sourceRules.releaseWholeRelabel(touched); - for(var item:touched) { - if(sources.release(row.getId(),item,now())<1 || repository.removeItem(item.getId())!=1)throw conflict("原实物占用或货物明细已变化"); - } + releaseSources(row,actor,touched,now()); + for(var item:touched){relabelSources.removeUnpicked(row.getId(),item.getId());if(repository.removeItem(item.getId())!=1)throw conflict("原货物明细已变化");} refreshCargo(row,items.stream().filter(i->!ids.contains(i.getId())).toList());row=repository.find(number); history(row,actor,Action.EDIT,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); commands.append(row.getId(),actor,identity,result);return result; @@ -142,20 +147,24 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken())); var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized); + var groups=sourceRules.selectionGroups(rows); var batchFacts=appointments.candidates(rows); var result=new ArrayList(); for(var row:rows) { - SourceType type="RELABEL".equals(row.sourceStage())?SourceType.RELABEL: + SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL: row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN; String relabel=type==SourceType.RELABEL?row.orderRefs():null; var preliminary=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),null); - String key=DispatchService.wholeSourceKey(preliminary),version=sourceRules.wholeSourceVersion(row,key,false); + String key=DispatchService.wholeSourceKey(preliminary);var group=groups.get(key); + if(group==null)throw conflict("完整来源组缺失,请重新读取"); + String version=group.sources().stream().filter(s->s.sourceKey().equals(key)).findFirst().orElseThrow(()->conflict("本来源不在完整组中")).selection().sourceVersion(); var selection=new DispatchCommands.CargoSelection(type,row.planPalletId(),row.returnReceiptId(),relabel,row.warehousePalletId(),version); var destination=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(row.destinationType(),row.destinationWarehouseCode(), row.fbaWarehouseCode(),row.deliveryAddress(),row.postalCode()),row.destinationType(),row.destinationWarehouseId(),row.destinationWarehouseCode(), row.destinationWarehouseName(),row.deliveryAddress(),row.postalCode(),row.contactName(),row.contactPhone()); var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId())); var references=new ArrayList();var reasons=new LinkedHashSet(); + reasons.addAll(group.blockReasons()); if(fact!=null) { reasons.addAll(fact.blockReasons()); // 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。 @@ -167,7 +176,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA } result.add(new DispatchPlanningModels.Candidate(selection,key,version,row.customerId(),row.customerName(),row.overseasWarehouseId(), row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(), - destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons))); + destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group)); } return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true); } @@ -214,14 +223,37 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion()); return evidence.upload(row.getId(),kind,actor.operatorId(),file); } - /** 列表权限含详情只读,但文件仍限当前整车复合归属。 */ + /** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */ public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File evidenceMetadata(Actor actor,String number, com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) { - requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.metadata(row.getId(),kind,fileId); + requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效"); + var driverId=masterEvidenceDriverId(row,kind,fileId); + if(driverId!=null)return driverEvidenceMetadata(fleet.dispatchDriverLicense(driverId,fileId).metadata(),fileId); + return evidence.metadata(row.getId(),kind,fileId); } - /** 内容仅经服务端鉴权代理,不返回匿名存储URL。 */ + /** 内容沿同一次确定的归属代理读取,不尝试类别回退,不返回匿名存储URL。 */ public byte[] evidenceContent(Actor actor,String number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) { - requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效");return evidence.download(row.getId(),kind,fileId); + requirePlatform(actor);var row=required(actor,number,false);if(fileId<=0)throw bad("文件引用无效"); + var driverId=masterEvidenceDriverId(row,kind,fileId); + if(driverId!=null) { + var file=fleet.dispatchDriverLicense(driverId,fileId);driverEvidenceMetadata(file.metadata(),fileId);return file.content(); + } + return evidence.download(row.getId(),kind,fileId); + } + /** 未命中冻结MASTER引用的文件仅可按原排车归属读取,保留编辑中尚未保存的本车外配上传。 */ + private Long masterEvidenceDriverId(DispatchAggregate row,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,long fileId) { + if(kind!=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER)return null; + var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver(); + if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null + || !driver.evidence().fileIds().contains(fileId))return null; + if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照"); + return driver.driverId(); + } + /** 司机域已执行当前/历史关联及File四元组校验,转换元数据时仍保留请求文件身份。 */ + private static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File driverEvidenceMetadata( + com.baiyue.lastleg.fleet.driver.DriverModels.File file,long fileId) { + if(file==null || !Long.toString(fileId).equals(file.fileId()))throw bad("司机证照回执文件身份不一致"); + return new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(fileId,file.fileName(),file.contentType(),file.sizeBytes()); } /** 空单或同仓多客户选货一次建车,与资源关系、来源锁和账本同事务。 */ @@ -290,9 +322,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(repository.realPicked(row.getId())!=0)throw conflict("本车已经真实扫码,不能移除货物"); var all=repository.cargo(row.getId());var selected=all.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"排车货物明细不存在")); if("RELABEL_ORDER".equals(selected.getSourceBusinessType()))throw conflict("换标按整单排车,请取消整单后重排"); + if(selected.getWarehousePalletId()!=null && all.stream().anyMatch(i->!Objects.equals(i.getId(),selected.getId()) && Objects.equals(i.getWarehousePalletId(),selected.getWarehousePalletId())))throw conflict("本实托含多个真实来源,请按完整实托调整或取消后重排"); + lockExecutionSources(row,all); appointments.lock(repository.batches(row.getId()),List.of()); appointments.remove(row.getId(),List.of(selected)); - if(sources.release(row.getId(),selected,now())<1)throw conflict("原来源占用已变化,未移除货物"); + releaseSources(row,actor,List.of(selected),now()); if(repository.removeItem(itemId)!=1)throw conflict("货物明细已变化"); refreshCargo(row,all.stream().filter(i->!Objects.equals(i.getId(),itemId)).toList());row=repository.find(number); history(row,actor,Action.REMOVE_CARGO,request.reason());var result=result(row,identity,new DispatchResults.Saved(number,List.of(itemId))); @@ -305,9 +339,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.CANCEL,request.recordVersion(),request.idempotencyKey(),request); var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); if(trim(request.reason())==null)throw bad("取消原因不能为空"); - if(repository.realPicked(row.getId())!=0 || !repository.outbounds(row.getId()).isEmpty())throw conflict("本车已有真实执行或关联出库,不能取消"); - var items=repository.cargo(row.getId());appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); - for(var item:items)if(sources.release(row.getId(),item,now())<1)throw conflict("原来源占用已变化,未取消排车"); + if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消"); + var items=repository.cargo(row.getId());lockExecutionSources(row,items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); + releaseSources(row,actor,items,now()); fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消"); return saveCommand(row,actor,identity,request.reason()); } @@ -320,7 +354,6 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var row=required(actor,number,true);var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); requireEditing(row,request.recordVersion()); - if(request.vehicleAppointment()!=null)throw conflict("车辆预约关联需按权威预约重新核验"); String before=basis(row); var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); @@ -396,6 +429,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA required(actor,number,false);fleet.lockDispatchShell(number);return context(actor,required(actor,number,true)); } + /** 新执行命令在原根锁和重放检查之后,锁齐全车真实组成并核对冻结来源。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void lockExecutionSources(DispatchAggregate row,List items) { + relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); + } + /** 当前范围复验后只恢复原主体与严格摘要相同的命令。 */ @Override @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public Optional replay(Context context,Actor actor,CommandIdentity identity) { @@ -467,6 +506,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车"); + lockExecutionSources(row,repository.cargo(row.getId())); var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量"); @@ -496,9 +536,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA private Context context(Actor actor,DispatchAggregate row) { var identity=fleet.dispatchIdentity(row.getDispatchNo());if(identity.tripId()!=row.getTripId())throw conflict("整车资源关系已变化"); var items=repository.cargo(row.getId());var byId=items.stream().collect(Collectors.toMap(DispatchItem::getId,i->i)); - var batches=repository.batches(row.getId());var cargo=new ArrayList(); + var batches=repository.batches(row.getId());var cargo=new ArrayList();var actualSources=executionReads.sources(row.getId()); + var origins=actualSources.stream().anyMatch(p->p.dispatchItemSourceId()!=null)?sourceOrigins.all(row.getId()).stream().collect(Collectors.toMap(s->s.getId(),s->s)):Map.of(); var remaining=new LinkedHashMap();batches.forEach(b->remaining.put(b,(long)b.packageCount())); - for(var pick:executionReads.sources(row.getId())) { + for(var pick:actualSources) { var item=byId.get(pick.itemId());String source=pick.returnCartonId()!=null?"RETURN_CARTON:"+pick.returnCartonId():pick.returnReceiptId()!=null?"RETURN:"+pick.returnReceiptId():"ORDER:"+pick.orderId(); var allocations=new ArrayList();long unassigned=pick.packages(); for(var batch:batches)if(Objects.equals(batch.dispatchItemId(),item.getId()) && Objects.equals(batch.customerId(),item.getCustomerId()) && Objects.equals(batch.sourceKey(),source) && unassigned>0) { @@ -506,9 +547,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA allocations.add(new BatchAllocation(batch.planPalletId(),batch.batchKey(),batch.customerId(),batch.sourceKey(),batch.destinationKey(),Math.toIntExact(allocated),batch.recordVersion())); unassigned-=allocated;remaining.put(batch,remaining.get(batch)-allocated); } + var origin=pick.dispatchItemSourceId()==null?null:origins.get(pick.dispatchItemSourceId()); + var sourceOrigin=origin==null?null:new SourceOrigin(origin.getId(),origin.getRelabelApplicationId(),origin.getRelabelItemId(),origin.getSourceInventoryBalanceId(),origin.getSourceInboundOrderId(),origin.getSourceInboundOrderNo(),origin.getSourceInboundCode(),origin.getProductQuantity()!=null || pick.productQuantity()>0?pick.productQuantity():null); cargo.add(new CargoLine(item.getId(),pick.customerId(),item.getCustomerName(),pick.orderId(),pick.orderNo(),source,item.getSourceStage(),pick.sourceRegion(),pick.sourceInboundId(),pick.inboundCode(), item.getPlanPalletId(),item.getWarehousePalletId(),item.getPalletNo(),pick.pickId(),pick.attributionId(),pick.positionId(),pick.receiptId(),pick.returnReceiptId(),pick.returnCartonId(),pick.cartonMark(), - "RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations)); + "RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getSourceBusinessNo():null,destination(item),pick.packages(),pick.productQuantity(),null,null,allocations,sourceOrigin)); } var picked=executionReads.itemPicked(row.getId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); boolean complete=reconciled(row) && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())) @@ -599,7 +642,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), - new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,null,batchRefs), + new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs), documents,orderDetails(cargo)); } @@ -619,29 +662,37 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return List.of(); } var current=repository.cargo(row.getId()); var keys=current.stream().map(DispatchItem::getSourceKey).collect(Collectors.toSet()); - var physical=current.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toSet()); - var resolved=sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections); + var resolved=current.isEmpty()?sourceRules.lockWholeSources(row.getOverseasWarehouseId(),selections):sourceRules.lockAdditionalSources(row.getOverseasWarehouseId(),selections,current); + if(!current.isEmpty())lockExecutionSources(row,current); + var at=now();var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null); + var physical=physicalClaims.reserve(owner,java.util.stream.Stream.concat(current.stream().map(DispatchItem::getWarehousePalletId),resolved.stream().map(s->s.candidate().warehousePalletId())).filter(Objects::nonNull).toList(),at); var saved=new ArrayList(); for(var source:resolved) { if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入"); var c=source.candidate(); - if(c.warehousePalletId()!=null && !physical.add(c.warehousePalletId()))throw conflict("同一实托不能作为不同来源重复计入本车,请核对真实来源组成"); - var at=now(); var claims=sources.claims(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at); - if(claims.stream().anyMatch(lock -> !Objects.equals(lock.dispatchId(),row.getId()))) throw conflict("所选托盘已被其他有效运输安排占用"); - if(claims.isEmpty()) { - sources.expire(c.customerId(),c.sourcePalletKey(),c.warehousePalletId(),at); - try { if(sources.claim(IdWorker.getId(),c.customerId(),c.sourcePalletKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),row.getId(),actor.operatorId(),at)!=1) throw conflict("来源占用未保存"); } - catch(DuplicateKeyException error) { throw conflict("所选实托已被并发排车占用,请重新核对"); } - } + sources.expire(c.customerId(),source.sourceKey(),at); + if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用"); + try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");} + catch(DuplicateKeyException error){throw conflict("所选逻辑来源已被并发排车占用,请重新核对");} var item=item(row,source); if(repository.insertItem(item)!=1) throw conflict("排车明细未保存"); saved.add(item); } + var all=new ArrayList<>(current);all.addAll(saved); + if(saved.stream().anyMatch(i->"RELABEL".equals(i.getSourceStage()) && "RELABEL_ORDER".equals(i.getSourceBusinessType())))relabelSources.capture(row,all,saved,at); sourceRules.bindWholeRelabel(resolved,saved); appointments.bind(row.getId(),saved,batchAllocations); - var all=new ArrayList<>(current); all.addAll(saved); refreshCargo(row,all);return saved.stream().map(DispatchItem::getId).toList(); } + /** 先锁原物理拥有者,再逐逻辑键释放;同托仍有活动成员时物理排他继续保留。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void releaseSources(DispatchAggregate row,Actor actor,List items,LocalDateTime at) { + var owner=new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,actor.operatorId(),null); + var original=physicalClaims.lockOwner(owner); + for(var item:items)if(sources.release(row.getId(),item,at)!=1)throw conflict("原逻辑来源占用已变化"); + physicalClaims.releaseIfUnused(owner,original.stream().map(com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock::getId).toList(),at); + } + /** 加货和移货共享同一货量、摘要、审核失效与Fleet容量刷新。 */ private void refreshCargo(DispatchAggregate row,List all) { int packages=0; @@ -754,7 +805,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var orders=Arrays.stream(Objects.requireNonNullElse(item.getOrderRefs(),Objects.requireNonNullElse(item.getOrderNo(),"")).split(",")).map(String::trim).filter(s->!s.isBlank()).distinct().toList(); if(orders.isEmpty()) orders=Collections.singletonList(null); for(var order:orders) result.add(new DispatchViews.OrderDetail(item.getCustomerId(),item.getCustomerName(),order,item.getInboundCode(),orders.size()==1?item.getGrossWeightKg():null, - orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey())); + orders.size()==1?1:null,item.getFbaWarehouseCode(),orders.size()==1?item.getVolumeCbm():null,source.containerNo(),item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo())); } return List.copyOf(result); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index 136dcf34a..ff5af7f79 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -63,7 +63,7 @@ public class DispatchBolPrintService { List lines = group.getValue(); int pages = pageCount(lines.size()); int total = lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum(); - String destination = lines.stream().map(DispatchBolData.Line::destinationWarehouseName).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); + String destination = lines.stream().map(DispatchBolData.Line::destinationLabel).filter(v -> v != null && !v.isBlank()).findFirst().orElse("—"); int offset = 0; for (int page = 0; page < pages; page++) { int capacity = page == 0 ? FIRST_PAGE_ROWS : CONTINUATION_PAGE_ROWS; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java index 3c8c4ae0f..572b62d86 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java @@ -42,12 +42,13 @@ public class DispatchDepartureService { if(!Objects.equals(facts.arrangementKey(),row.getArrangementKey()) || !Objects.equals(facts.arrangementBusinessHash(),row.getArrangementBusinessHash()) || !Objects.equals(facts.cargoHash(),context.cargoHash()) || !Objects.equals(facts.loadingCompletionHash(),context.loadingCompletionHash()))throw conflict("本车实际货物或派送安排已变化"); fleet.requireDispatchDeparture(number,confirmed.manifestHash()); + workflow.lockExecutionSources(row,repository.cargo(row.getId())); var orderOwners=new TreeMap();for(var cargo:context.cargo())orderOwners.put(cargo.orderId(),cargo.customerId()); for(var source:orderOwners.entrySet())if(sources.lockOrder(source.getValue(),source.getKey())==null)throw conflict("来源订单缺失或已暂扣,不能发车"); var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var departed=delivery.markDeparted(context,actor,at,request.idempotencyKey()); if(departed.shippedPackages()!=context.counts().packageCount())throw conflict("实际发运单据与本车箱数未核平"); sourceWorkflow.departWholeSources(context,actor,departed,at); - for(var item:repository.cargo(row.getId()))if(sources.release(row.getId(),item,LocalDateTime.ofInstant(at,ZoneOffset.UTC))<1)throw conflict("原发运来源占用已变化"); + workflow.releaseSources(row,actor,repository.cargo(row.getId()),LocalDateTime.ofInstant(at,ZoneOffset.UTC)); fleet.departDispatchShell(number,actor.operatorId(),at,request.remark());long version=row.getRecordVersion(); row.setStage(Stage.DELIVERING.name());row.setDepartedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC));row.setShippedPackageCount(Math.toIntExact(departed.shippedPackages()));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(LocalDateTime.ofInstant(at,ZoneOffset.UTC)); if(repository.saveState(row,version)!=1)throw conflict("本车发车版本竞争失败"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java index 679ff388c..a6c4667e6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java @@ -11,6 +11,7 @@ import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.repository.DispatchDraftRepository; import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; @@ -40,19 +41,21 @@ public class DispatchDraftService implements DispatchPlanningPort { private final ObjectMapper json; private final Clock clock; private final AppointmentWorkDispatchPort appointmentProvider; + private final DispatchPhysicalClaimService physicalClaims; + private final PhysicalPalletSerialPort palletSerial; /** 只装配草稿仓储和实际来源/主单提供者,不持久化客户端伪造候选。 */ @org.springframework.beans.factory.annotation.Autowired public DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules, - DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider) { - this(repository,sources,sourceRules,aggregate,hashes,json,commandCodec,appointmentProvider,Clock.systemUTC()); + DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,DispatchPhysicalClaimService physicalClaims,PhysicalPalletSerialPort palletSerial) { + this(repository,sources,sourceRules,aggregate,hashes,json,commandCodec,appointmentProvider,physicalClaims,palletSerial,Clock.systemUTC()); } /** 测试可控制锁等待跨越的时间边界,生产始终使用UTC系统时钟。 */ DispatchDraftService(DispatchDraftRepository repository,DispatchSourceRepository sources,DispatchService sourceRules, - DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,Clock clock) { + DispatchAggregateService aggregate,DispatchCommandHasher hashes,ObjectMapper json,DispatchCommandCodec commandCodec,AppointmentWorkDispatchPort appointmentProvider,DispatchPhysicalClaimService physicalClaims,PhysicalPalletSerialPort palletSerial,Clock clock) { this.repository=repository;this.sources=sources;this.sourceRules=sourceRules; - this.aggregate=aggregate;this.hashes=hashes;this.json=json;this.commandCodec=commandCodec;this.appointmentProvider=appointmentProvider;this.clock=Objects.requireNonNull(clock); + this.aggregate=aggregate;this.hashes=hashes;this.json=json;this.commandCodec=commandCodec;this.appointmentProvider=appointmentProvider;this.physicalClaims=physicalClaims;this.palletSerial=palletSerial;this.clock=Objects.requireNonNull(clock); } /** 候选继续委托主流程已有真实查询和权限范围。 */ @@ -88,43 +91,62 @@ public class DispatchDraftService implements DispatchPlanningPort { }); } - /** 来源资格和版本取真实提供者,锁定后才取得共享来源/实托占用。 */ + /** 单来源请求由服务端扩展完整组,一次命令锁定并加入全部成员及共享物理占用。 */ @Override @Transactional(isolation=Isolation.READ_COMMITTED) public Draft select(Actor actor,DraftSelection request) { Objects.requireNonNull(request); return change(actor,"SELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source()); - if(selected.stream().anyMatch(c->c.sourceKey().equals(key)))throw conflict("来源已在本草稿中,请勿重复选择"); + var group=sourceRules.selectionGroup(row.getWarehouseId(),request.source()); + var members=groupMembers(group);var requested=members.get(key); + if(requested==null || !Objects.equals(requested.selection(),request.source()))throw conflict("来源身份、版本或完整组成已变化"); + if(selected.stream().anyMatch(c->members.containsKey(c.sourceKey())))throw conflict("完整来源组已全部或部分在本草稿,请重新核对组成"); var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId()); - var selections=new ArrayList<>(selected.stream().map(Candidate::selection).toList());selections.add(request.source()); + var selections=new ArrayList<>(selected.stream().map(Candidate::selection).toList());members.values().stream().map(SelectionGroupSource::selection).forEach(selections::add); var locked=sourceRules.lockDraftSources(row.getWarehouseId(),selections); + var lockedIndex=new TreeMap();for(var source:locked)if(lockedIndex.putIfAbsent(source.sourceKey(),source)!=null)throw conflict("锁内来源组成重复"); + if(!lockedIndex.keySet().equals(selectionIndex(selections).keySet()))throw conflict("锁内来源组成员已变化"); requireLease(row.getExpiresAt()); verifyClaims(row,selected,containers); - var resolved=locked.stream().filter(s->s.sourceKey().equals(key)).findFirst().orElseThrow(()->conflict("所选来源不存在")); - var candidate=candidate(resolved);var next=new ArrayList<>(selected);next.add(candidate); - var keys=new ArrayList<>(keys(container));keys.add(key);requireCapacity(container.getCapacity(),keys,index(next)); - var at=now();sources.expire(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at); - if(!sources.claims(candidate.customerId(),key,resolved.candidate().warehousePalletId(),at).isEmpty())throw conflict("所选来源或实托已被其他作业占用"); + var next=new ArrayList<>(selected);var keys=new ArrayList<>(keys(container)); + for(var member:members.values()) { + var resolved=lockedIndex.get(member.sourceKey()); + if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId()) + || resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化"); + next.add(candidate(resolved,group));keys.add(member.sourceKey()); + } + requireCapacity(container.getCapacity(),keys,index(next)); + var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream() + .map(member->member.selection().warehousePalletId()).filter(Objects::nonNull).distinct().toList(),at); requireLease(row.getExpiresAt()); - try {repository.claim(IdWorker.getId(),row,container.getId(),new DispatchDraftRepository.DispatchServiceSource(candidate.customerId(),key, - resolved.candidate().planPalletId(),resolved.candidate().warehousePalletId(),resolved.candidate().returnReceiptId()),at);} - catch(DuplicateKeyException error){throw conflict("所选来源或实托已被其他作业占用");} + for(var member:members.values()) { + var resolved=lockedIndex.get(member.sourceKey());sources.expire(member.customerId(),member.sourceKey(),at); + if(!sources.claims(member.customerId(),member.sourceKey(),at).isEmpty())throw conflict("所选来源已被其他作业占用"); + try {repository.claim(IdWorker.getId(),row,container.getId(),new DispatchDraftRepository.DispatchServiceSource(member.customerId(),member.sourceKey(), + resolved.candidate().planPalletId(),resolved.candidate().warehousePalletId(),resolved.candidate().returnReceiptId(),physical.get(resolved.candidate().warehousePalletId())),at);} + catch(DuplicateKeyException error){throw conflict("完整组中的来源已被其他作业占用");} + } container.setSourceKeysJson(write(keys));container.setUpdatedAt(at);repository.layout(container);row.setSourcesJson(write(next)); }); } - /** 解除只认原容器与原来源完整身份;来源变旧不阻止拥有者释放自己的占用。 */ + /** 按原冻结完整组原子解除;真实来源变旧不阻止拥有者释放原占用。 */ @Override @Transactional(isolation=Isolation.READ_COMMITTED) public Draft unselect(Actor actor,DraftSelection request) { Objects.requireNonNull(request); return change(actor,"UNSELECT",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ var selected=selected(row);String key=DispatchService.wholeSourceKey(request.source()); var current=index(selected).get(key);if(current==null || !Objects.equals(current.selection(),request.source()))throw conflict("原来源身份或版本不一致"); - var container=container(repository.containers(row.getDraftToken()),request.draftContainerId());var keys=new ArrayList<>(keys(container)); - if(!keys.remove(key))throw conflict("来源不属于本容器"); - if(repository.release(row,key,container.getId(),now())!=1)throw conflict("原草稿占用已变化"); + var containers=repository.containers(row.getDraftToken());var container=container(containers,request.draftContainerId());var keys=new ArrayList<>(keys(container)); + var members=groupMembers(current.selectionGroup()).keySet(); + var claims=verifyClaims(row,selected,containers); + if(!keys.containsAll(members))throw conflict("完整来源组不属于本容器"); + var originalPhysical=claims.stream().filter(c->members.contains(c.sourceKey())).map(DispatchDraftClaimMapper.Claim::physicalLockId).filter(Objects::nonNull).distinct().toList(); + for(var member:members)if(repository.release(row,member,container.getId(),now())!=1)throw conflict("原完整来源组占用已变化"); + physicalClaims.releaseIfUnused(physicalOwner(row,container.getId()),originalPhysical,now()); + keys.removeAll(members); container.setSourceKeysJson(write(keys));container.setUpdatedAt(now());repository.layout(container); - row.setSourcesJson(write(selected.stream().filter(c->!c.sourceKey().equals(key)).toList())); + row.setSourcesJson(write(selected.stream().filter(c->!members.contains(c.sourceKey())).toList())); }); } @@ -143,10 +165,18 @@ public class DispatchDraftService implements DispatchPlanningPort { for(var key:c.sourceKeys()){if(!seen.add(key))throw bad("同一来源不能重复放入容器");mapping.put(key,c.draftContainerId());} } if(!remaining.isEmpty() || !seen.equals(byKey.keySet()))throw conflict("布局只能完整移动当前已锁来源"); + requireWholeLayout(selected,mapping); if(request.planIds()==null || request.planIds().stream().anyMatch(id->id==null || id<=0) || request.planIds().size()>500)throw bad("计划筛选不合法"); sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList()); var claims=verifyClaims(row,selected,old);var at=now(); for(var claim:claims)if(!Objects.equals(claim.containerId(),mapping.get(claim.sourceKey())))repository.move(row,claim,mapping.get(claim.sourceKey()),at); + var physicalMoves=new TreeMap>>(); + for(var claim:claims)if(claim.physicalLockId()!=null && !Objects.equals(claim.containerId(),mapping.get(claim.sourceKey()))) + physicalMoves.computeIfAbsent(claim.containerId(),ignored->new TreeMap<>()) + .computeIfAbsent(mapping.get(claim.sourceKey()),ignored->new TreeSet<>()).add(claim.physicalLockId()); + // 全部逻辑成员先完成布局,再由物理提供者按实际成员目标迁移整托,不能把原容器其他组带走。 + for(var previous:physicalMoves.entrySet())for(var next:previous.getValue().entrySet()) + physicalClaims.moveDraft(physicalOwner(row,previous.getKey()),List.copyOf(next.getValue()),next.getKey(),at); repository.stageSequences(row.getDraftToken()); for(var c:request.containers()) {var existing=container(old,c.draftContainerId());existing.setSequenceNo(c.sequenceNo());existing.setCapacity(c.capacity()); existing.setSourceKeysJson(write(c.sourceKeys()));existing.setUpdatedAt(at);repository.layout(existing);} @@ -170,7 +200,10 @@ public class DispatchDraftService implements DispatchPlanningPort { public Draft release(Actor actor,DraftRelease request) { Objects.requireNonNull(request); return change(actor,"RELEASE",request.draftToken(),request.recordVersion(),request.idempotencyKey(),request,row->{ - repository.release(row,null,null,now());row.setStatus("RELEASED");row.setReleasedAt(now());row.setSourcesJson("[]"); + var containers=repository.containers(row.getDraftToken());var claims=verifyClaims(row,selected(row),containers); + if(repository.release(row,null,null,now())!=claims.size())throw conflict("草稿来源未完整释放"); + releasePhysical(row,containers,claims); + row.setStatus("RELEASED");row.setReleasedAt(now());row.setSourcesJson("[]"); for(var c:repository.containers(row.getDraftToken())){c.setSourceKeysJson("[]");c.setUpdatedAt(now());repository.layout(c);} }); } @@ -195,12 +228,14 @@ public class DispatchDraftService implements DispatchPlanningPort { requireCapacity(c.getCapacity(),keys(c),index(selected)); } if(!byId.keySet().equals(new TreeSet<>(nonempty.stream().map(DispatchDraftContainer::getId).toList())))throw bad("转换必须覆盖全部原容器"); + var placement=new HashMap();for(var c:containers)for(var key:keys(c))placement.put(key,c.getId());requireWholeLayout(selected,placement); // 该提供者只锁全批资源,不写主单;绝不能先锁来源再锁下一辆车的资源。 aggregate.lockDraftCreationResources(actor,byId.values().stream().map(DraftVehicle::dispatch).toList()); sourceRules.lockDraftSources(row.getWarehouseId(),selected.stream().map(Candidate::selection).toList()); requireLease(deadline); - verifyClaims(row,selected,containers); + var claims=verifyClaims(row,selected,containers); if(repository.release(row,null,null,now())!=selected.size())throw conflict("草稿来源占用未完整核齐"); + releasePhysical(row,containers,claims); var results=new ArrayList(); for(var group:byId.values()) {var result=aggregate.create(actor,group.dispatch()); if(result==null || result.replayed() || result.action()!=DispatchTypes.Action.CREATE)throw conflict("子创建命令已被其他批次使用"); @@ -213,11 +248,21 @@ public class DispatchDraftService implements DispatchPlanningPort { /** 草稿写操作统一先恢复原命令,再校验当前拥有者、生命周期与公共版本。 */ private Draft change(Actor actor,String action,String token,long version,String key,Object request,Consumer mutation) { platform(actor);var command=command(actor,action,token,key,request);if(command.getResultJson()!=null)return read(command.getResultJson(),Draft.class); - var row=owned(actor,token,version);var deadline=row.getExpiresAt();mutation.accept(row);long before=row.getRecordVersion(); + var row=owned(actor,token,version);var deadline=row.getExpiresAt(); + if(Set.of("UNSELECT","RELEASE","CREATE_CONTAINER","REMOVE_CONTAINER").contains(action)) { + // 纯释放、租约操作使用原冻结身份先取共同串行点,再读逻辑/物理占用;不刷新来源资格。 + // SELECT/SNAPSHOT 仍由来源提供者按申请、订单→Serial取锁,不能在此提前取得其guard。 + palletSerial.lock(selected(row).stream().map(c->c.selection().warehousePalletId()).filter(Objects::nonNull).distinct().sorted().toList()); + requireLease(deadline); + } + mutation.accept(row);long before=row.getRecordVersion(); requireLease(deadline); var at=now();row.setRecordVersion(Math.addExact(before,1));row.setUpdatedAt(at); if("ACTIVE".equals(row.getStatus())){ - row.setExpiresAt(at.plusMinutes(30));int renewed=repository.renew(row,at); + var previousExpiresAt=row.getExpiresAt();row.setExpiresAt(at.plusMinutes(30)); + // 先核对并延长原物理租约,再同步逻辑来源与根;同刻操作允许原期限不变。 + physicalClaims.renewDraft(row.getWarehouseId(),row.getDraftToken(),row.getOperatorId(),at,previousExpiresAt,row.getExpiresAt()); + int renewed=repository.renew(row,at); requireLease(deadline); var claims=verifyClaims(row,selected(row),repository.containers(token)); // 部分驱动对未改变值返回0,最终以锁内有效行、精确归属和完整到期时间证明续约;不能只延长根。 @@ -251,20 +296,99 @@ public class DispatchDraftService implements DispatchPlanningPort { var claims=repository.claims(row,now());var mapping=new HashMap(); for(var c:containers)for(var key:keys(c))if(mapping.putIfAbsent(key,c.getId())!=null)throw conflict("草稿来源被重复放入容器"); var expected=index(selected);if(!mapping.keySet().equals(expected.keySet()) || claims.size()!=expected.size())throw conflict("草稿来源及占用未完整对应"); + requireWholeLayout(selected,mapping); + var physical=new HashMap(); + for(var container:containers)for(var lock:physicalClaims.lockOwner(physicalOwner(row,container.getId()))) { + if(!Objects.equals(lock.getExpiresAt(),row.getExpiresAt()) || physical.putIfAbsent(lock.getWarehousePalletId(),lock)!=null) + throw conflict("草稿物理占用重复或有效期已变化"); + } var seen=new HashSet();for(var claim:claims){var c=expected.get(claim.sourceKey()); if(c==null || !seen.add(claim.sourceKey()) || !Objects.equals(c.customerId(),claim.customerId()) || !Objects.equals(c.selection().warehousePalletId(),claim.palletId()) || !Objects.equals(mapping.get(claim.sourceKey()),claim.containerId()) - || !Objects.equals(row.getExpiresAt(),claim.expiresAt()))throw conflict("草稿原占用身份或有效期已变化");} + || !Objects.equals(row.getExpiresAt(),claim.expiresAt()))throw conflict("草稿原占用身份或有效期已变化"); + var lock=physical.get(claim.palletId()); + if(claim.palletId()==null?claim.physicalLockId()!=null:lock==null || !Objects.equals(lock.getId(),claim.physicalLockId()) + || !Objects.equals(lock.getDraftContainerId(),claim.containerId()))throw conflict("草稿逻辑来源未关联原容器的真实物理占用"); + } + var expectedPallets=selected.stream().map(c->c.selection().warehousePalletId()).filter(Objects::nonNull).collect(java.util.stream.Collectors.toSet()); + if(!physical.keySet().equals(expectedPallets))throw conflict("草稿物理占用与完整来源集合不一致"); return claims; } + /** 草稿真实物理拥有者同时由仓、token、容器及操作者限定。 */ + private DispatchPhysicalClaimService.Owner physicalOwner(DispatchDraft row,long container) { + return new DispatchPhysicalClaimService.Owner(row.getWarehouseId(),null,row.getDraftToken(),container,row.getOperatorId(),row.getExpiresAt()); + } + + /** 逻辑来源全部结束后再按原物理主键释放,不按实托号触碰后续新占用。 */ + private void releasePhysical(DispatchDraft row,List containers,List claims) { + for(var container:containers) { + var ids=claims.stream().filter(c->Objects.equals(c.containerId(),container.getId())).map(DispatchDraftClaimMapper.Claim::physicalLockId) + .filter(Objects::nonNull).distinct().toList(); + if(physicalClaims.releaseIfUnused(physicalOwner(row,container.getId()),ids,now())!=ids.size())throw conflict("草稿实托还有未结束的逻辑来源"); + } + } + + /** 完整组的身份、版本、逻辑成员和实托贡献均由服务端事实明确给出。 */ + private static Map groupMembers(SelectionGroup group) { + if(group==null || !group.complete() || group.groupKey()==null || group.groupKey().isBlank() + || group.groupVersion()==null || group.groupVersion().isBlank() || group.sources()==null || group.sources().isEmpty() + || group.pallets()==null)throw conflict("完整来源组尚未核齐,请重新读取候选"); + var members=new TreeMap(); + for(var member:group.sources()) { + if(member==null || member.selection()==null || member.customerId()==null || member.customerId().isBlank() + || member.selection().sourceVersion()==null || member.selection().sourceVersion().isBlank() || member.packages()<=0 + || !Objects.equals(member.sourceKey(),DispatchService.wholeSourceKey(member.selection())) + || members.putIfAbsent(member.sourceKey(),member)!=null)throw conflict("完整来源组成员身份或版本不完整"); + } + var pallets=new HashSet();var physicalKeys=new HashSet(); + for(var pallet:group.pallets()) { + if(pallet==null || pallet.warehousePalletId()!=null && (pallet.warehousePalletId()<=0 || !pallets.add(pallet.warehousePalletId())) + || pallet.sourceKeys()==null || pallet.sourceKeys().isEmpty())throw conflict("完整来源组实托身份不完整"); + if(pallet.warehousePalletId()==null && pallet.sourceKeys().size()!=1)throw conflict("未关联实托的计划必须保留独立真实来源"); + long packages=0; + for(var key:pallet.sourceKeys()) { + var member=members.get(key); + if(member==null || !physicalKeys.add(key) || !Objects.equals(member.selection().warehousePalletId(),pallet.warehousePalletId())) + throw conflict("完整来源组实托与逻辑成员不一致"); + if(pallet.warehousePalletId()==null && (member.selection().sourceType()!=DispatchTypes.SourceType.INBOUND_PLAN + || member.selection().planPalletId()==null || member.selection().planPalletId()<=0))throw conflict("未关联实托的来源缺少真实计划身份"); + packages=Math.addExact(packages,member.packages()); + } + if(packages!=pallet.packageCount())throw conflict("完整来源组实托箱数未核平"); + } + if(!members.keySet().equals(physicalKeys))throw conflict("完整来源组缺少实际实托或计划成员"); + return members; + } + + /** 任一完整组及同一真实实托只能位于同一容器,不能以布局拆成半托或半申请。 */ + private static void requireWholeLayout(List selected,Map placement) { + var byKey=index(selected);var palletContainers=new HashMap(); + for(var candidate:selected) { + Long container=placement.get(candidate.sourceKey());if(container==null)throw conflict("完整来源缺少容器"); + var members=groupMembers(candidate.selectionGroup()); + if(!members.containsKey(candidate.sourceKey()))throw conflict("草稿来源不在原冻结完整组内"); + for(var member:members.values()) { + var actual=byKey.get(member.sourceKey()); + if(actual==null || !Objects.equals(actual.selection(),member.selection()) || !Objects.equals(actual.customerId(),member.customerId()) + || actual.availablePackages()!=member.packages() || !Objects.equals(actual.sourceVersion(),member.selection().sourceVersion()) + || !Objects.equals(container,placement.get(member.sourceKey())))throw conflict("完整来源组不能拆分到不同容器或改变成员版本"); + } + Long pallet=candidate.selection().warehousePalletId(); + if(pallet!=null) { + var previous=palletContainers.putIfAbsent(pallet,container); + if(previous!=null && !previous.equals(container))throw conflict("同一真实实托不能拆分到不同容器"); + } + } + } + /** 公开候选快照沿主流程同一字段映射,所有身份和版本来自锁内真实来源。 */ - private Candidate candidate(DispatchService.WholeSource source) { + private Candidate candidate(DispatchService.WholeSource source,SelectionGroup group) { var r=source.candidate();var d=new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode()), r.destinationType(),r.destinationWarehouseId(),r.destinationWarehouseCode(),r.destinationWarehouseName(),r.deliveryAddress(),r.postalCode(),r.contactName(),r.contactPhone()); var selection=new DispatchCommands.CargoSelection(source.selection().sourceType(),source.selection().planPalletId(),source.selection().returnReceiptId(),source.selection().relabelNo(),r.warehousePalletId(),source.sourceVersion()); var candidate=new Candidate(selection,source.sourceKey(),source.sourceVersion(),r.customerId(),r.customerName(),r.overseasWarehouseId(),r.overseasWarehouseCode(),r.overseasWarehouseName(), - r.orderNo(),r.inboundCode(),r.planNo(),r.palletNo(),r.containerNo(),d,r.packageCount(),r.grossWeightKg(),r.volumeCbm(),"SOURCE_SUGGESTION",List.of(),List.of()); + r.orderNo(),r.inboundCode(),r.planNo(),r.palletNo(),r.containerNo(),d,r.packageCount(),r.grossWeightKg(),r.volumeCbm(),"SOURCE_SUGGESTION",List.of(),List.of(),group); return projectCandidate(candidate,readCandidateBatches(List.of(candidate))); } @@ -310,7 +434,7 @@ public class DispatchDraftService implements DispatchPlanningPort { if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons()); var refs=result==null?List.of():result.appointmentBatches().stream().map(b->new BatchReference(b.batchKey(),b.batchNo(),b.customerId(), b.sources().stream().map(s->new BatchSource(s.sourceKey(),s.destinationKey(),s.packageCount())).toList(),b.recordVersion())).toList(); - return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),refs,List.copyOf(reasons)); + return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),refs,List.copyOf(reasons),c.selectionGroup()); } /** 原结果完整保存,异常时随所有业务写入一起回滚。 */ private T complete(DispatchDraftCommand command,T result){repository.complete(command.getId(),write(result));return result;} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java new file mode 100644 index 000000000..9aeb1e526 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java @@ -0,0 +1,158 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.*; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +/** 锁内冻结明确组托贡献,后续扫码只消费这些真实额度,不对混合仓位做FIFO猜归属。 */ +@Service +public class DispatchItemSourceService { + private final DispatchItemSourceRepository repository; + private final RelabelDispatchSourcePort relabel; + private final ObjectMapper json; + /** 来源事实经提供者读取,冻结贡献及已消费数经本域仓储读取。 */ + public DispatchItemSourceService(DispatchItemSourceRepository repository,RelabelDispatchSourcePort relabel,ObjectMapper json){this.repository=repository;this.relabel=relabel;this.json=json;} + + /** packages是本次实际消费量,件数未知保留空;不是请求可伪造的来源身份。 */ + public record Allocation(Long dispatchItemSourceId,long packages,Long productQuantity) { } + + /** 主单原事务已锁齐全部来源与库存;所有当前成员一起核对,仅为新增明细追加不可变快照。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void capture(DispatchAggregate root,List all,List added,LocalDateTime at) { + var normal=all.stream().filter(this::normalRelabel).toList(); + if(normal.isEmpty())return; + var snapshot=relabel.lockedSnapshot(root.getOverseasWarehouseId(),normal.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList()); + if(snapshot==null || !snapshot.complete())throw conflict("换标实托真实来源组成尚未核齐"); + var selected=new HashMap(); + for(var item:normal)if(selected.put(key(item.getSourceBusinessNo(),item.getWarehousePalletId()),item)!=null)throw conflict("本车同一换标来源重复"); + var applications=snapshot.applications().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Application::applicationId,a->a)); + var positions=snapshot.positions().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Position::positionId,p->p)); + var totals=new HashMap();var held=new HashMap();var identities=new HashSet(); + var additions=added.stream().map(DispatchItem::getId).collect(Collectors.toSet()); + for(var source:snapshot.sources()) { + var item=selected.get(key(source.relabelNo(),source.warehousePalletId()));var app=applications.get(source.applicationId());var position=positions.get(source.inventoryPositionId()); + if(item==null)throw conflict("实托或完整换标申请还有未选入本车的实际贡献"); + if(app==null || position==null || !Objects.equals(app.customerId(),source.customerId()) || !Objects.equals(app.fulfillmentOrderId(),item.getOrderId()) + || !Objects.equals(item.getCustomerId(),source.customerId()) || !Objects.equals(root.getOverseasWarehouseId(),source.warehouseId()) + || !Objects.equals(position.customerId(),source.customerId()) || !Objects.equals(position.warehousePalletId(),source.warehousePalletId()) + || !Objects.equals(position.inventoryBalanceId(),source.inventoryBalanceId()) || source.boundPackageCount()<=0 || source.notifiedPackageCount()<0 + || source.productQuantity()!=null && source.productQuantity()<0 || source.bindingBasisHash()==null || !source.bindingBasisHash().matches("[a-f0-9]{64}") + || source.unitFacts()==null)throw conflict("换标原货源、实际位置或绑定依据不完整"); + if(!identities.add(source.applicationId()+":"+source.itemId()+":"+source.inventoryPositionId()))throw conflict("换标实际贡献重复,不能重复计算冻结箱数"); + totals.merge(item.getId(),source.boundPackageCount(),Math::addExact);held.merge(source.inventoryPositionId(),source.boundPackageCount(),Math::addExact); + if(!additions.contains(item.getId()))continue; + var row=new DispatchItemSource();row.setId(IdWorker.getId());row.setDispatchOrderId(root.getId());row.setDispatchItemId(item.getId());row.setCustomerId(source.customerId()); + if(app.roundKey()==null || app.roundKey().isBlank())throw conflict("换标实际作业轮次缺失"); + row.setRelabelApplicationId(source.applicationId());row.setRelabelItemId(source.itemId());row.setRelabelRoundKey(app.roundKey());row.setWarehousePalletId(source.warehousePalletId()); + row.setInventoryPositionId(source.inventoryPositionId());row.setInventoryBalanceId(source.inventoryBalanceId());row.setSourceInventoryBalanceId(source.sourceInventoryBalanceId()); + row.setSourceInboundOrderId(source.sourceInboundOrderId());row.setSourceInboundOrderNo(source.sourceInboundOrderNo());row.setSourceInboundCode(source.sourceInboundCode()); + row.setNotifiedPackageCount(source.notifiedPackageCount());row.setPackageCount(source.boundPackageCount());row.setProductQuantity(source.productQuantity()); + row.setBindingBasisHash(source.bindingBasisHash());row.setUnitFactsJson(write(source.unitFacts()));row.setCreatedAt(at); + if(repository.insert(row)!=1)throw conflict("换标实际贡献未保存"); + } + for(var item:normal)if(totals.getOrDefault(item.getId(),0L)!=item.getPackageCount().longValue())throw conflict("本换标来源箱数与逐项实际贡献不一致"); + for(var position:snapshot.positions())if(held.getOrDefault(position.positionId(),0L)!=position.holdPackages())throw conflict("实托累计换标贡献与真实冻结箱数不一致"); + } + + /** 正常扫码可改变状态和库存版本,但不得改变轮次、原货源、位置或逐件作业事实。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void validateFrozen(DispatchAggregate root,List items,RelabelDispatchSourcePort.Snapshot current) { + var normal=items.stream().filter(this::normalRelabel).collect(Collectors.toMap(DispatchItem::getId,i->i)); + var nativeNos=items.stream().filter(i->"RETURN_RELABEL".equals(i.getSourceStage())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet()); + var frozen=repository.all(root.getId());var expected=new HashMap(); + for(var row:frozen) { + var item=normal.get(row.getDispatchItemId()); + if(item==null || !Objects.equals(item.getCustomerId(),row.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),row.getWarehousePalletId()))throw conflict("本车冻结贡献与逻辑来源不一致"); + if(expected.put(row.getRelabelApplicationId()+":"+row.getRelabelItemId()+":"+row.getInventoryPositionId(),row)!=null)throw conflict("本车冻结贡献身份重复"); + } + var apps=current.applications().stream().collect(Collectors.toMap(RelabelDispatchSourcePort.Application::applicationId,a->a)); + for(var source:current.sources()) { + if(nativeNos.contains(source.relabelNo()))continue; + var row=expected.remove(source.applicationId()+":"+source.itemId()+":"+source.inventoryPositionId()); + if(row==null)throw conflict("实托出现未经本车审核的换标贡献"); + var app=apps.get(source.applicationId());var item=normal.get(row.getDispatchItemId()); + if(app==null || !Objects.equals(app.fulfillmentOrderId(),item.getOrderId()) || !Objects.equals(app.relabelNo(),item.getSourceBusinessNo()) + || !Objects.equals(app.roundKey(),row.getRelabelRoundKey()) || !"READY_FOR_OUTBOUND".equals(app.status()) + || !"PRE_DISPATCH_CREATED".equals(app.fulfillmentStatus()) || !"TRUCK".equals(app.deliveryMethod()) + || !Objects.equals(source.customerId(),row.getCustomerId()) || !Objects.equals(source.warehouseId(),root.getOverseasWarehouseId()) + || !Objects.equals(source.warehousePalletId(),row.getWarehousePalletId()) || !Objects.equals(source.inventoryBalanceId(),row.getInventoryBalanceId()) + || !Objects.equals(source.sourceInventoryBalanceId(),row.getSourceInventoryBalanceId()) || !Objects.equals(source.sourceInboundOrderId(),row.getSourceInboundOrderId()) + || !Objects.equals(source.sourceInboundOrderNo(),row.getSourceInboundOrderNo()) || !Objects.equals(source.sourceInboundCode(),row.getSourceInboundCode()) + || source.notifiedPackageCount()!=row.getNotifiedPackageCount() || !Objects.equals(source.productQuantity(),row.getProductQuantity()) + || !sameFacts(source.unitFacts(),row.getUnitFactsJson()))throw conflict("换标轮次、实际位置或原货源依据已变化,请回平台核对"); + } + if(!expected.isEmpty() || !normal.isEmpty() && frozen.isEmpty())throw conflict("本车原冻结换标贡献缺失"); + var totals=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId,Collectors.summingLong(DispatchItemSource::getPackageCount))); + for(var item:normal.values())if(totals.getOrDefault(item.getId(),0L)!=item.getPackageCount().longValue())throw conflict("换标逐项冻结箱数尚未核平"); + var used=repository.pickedAll(List.of(root.getId())).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())); + var remaining=new HashMap(); + for(var row:frozen) { + long left=row.getPackageCount()-used.getOrDefault(row.getId(),0L); + if(left<0)throw conflict("换标实际消费已超过冻结贡献");remaining.merge(row.getInventoryPositionId(),left,Math::addExact); + } + for(var position:current.positions())if(position.holdPackages()!=remaining.getOrDefault(position.positionId(),0L))throw conflict("实托全部换标剩余贡献与实际冻结箱数不一致"); + } + + /** 比较原逐件事实,不把JSON排版当作来源变化。 */ + private boolean sameFacts(Object facts,String frozen){try{return json.readTree(write(facts)).equals(json.readTree(frozen));}catch(Exception error){throw new IllegalStateException("冻结逐件事实无法读取",error);}} + + /** 本客户本实托的全部剩余贡献必须累计核对HOLD,逐item单独足够不能证明共享position总量足够。 */ + @Transactional(propagation=Propagation.MANDATORY) + public List allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List actualStock) { + var all=repository.all(dispatch);var consumed=repository.pickedAll(List.of(dispatch)).stream().collect(Collectors.toMap(p->p.sourceId(),p->p)); + var expected=new HashMap(); + for(var source:all)if(Objects.equals(item.getCustomerId(),source.getCustomerId()) && Objects.equals(item.getWarehousePalletId(),source.getWarehousePalletId())) { + long picked=consumed.containsKey(source.getId())?consumed.get(source.getId()).packages():0; + if(picked<0 || picked>source.getPackageCount())throw conflict("换标实际消费额度已超过冻结贡献"); + expected.merge(source.getInventoryPositionId(),source.getPackageCount()-picked,Math::addExact); + } + var stock=actualStock.stream().collect(Collectors.toMap(DispatchSourceMapper.Stock::id,s->s)); + for(var actual:actualStock)if(actual.holdPackages()!=expected.getOrDefault(actual.id(),0L))throw conflict("同实托累计换标剩余份额与实际冻结箱数不一致"); + for(var entry:expected.entrySet())if(entry.getValue()>0 && !stock.containsKey(entry.getKey()))throw conflict("换标实际来源位置已变化"); + var rows=all.stream().filter(s->Objects.equals(item.getId(),s.getDispatchItemId()) && Objects.equals(item.getCustomerId(),s.getCustomerId()) + && Objects.equals(positionId,s.getInventoryPositionId())).filter(s->s.getPackageCount()>(consumed.containsKey(s.getId())?consumed.get(s.getId()).packages():0)) + .filter(s->cartonMark==null || cartonMark.isBlank() || matches(s,cartonMark.trim())).toList(); + if(rows.isEmpty() || !stock.containsKey(positionId) || rows.stream().anyMatch(s->!Objects.equals(stock.get(positionId).balanceId(),s.getInventoryBalanceId())))throw conflict("所选位置或箱唛不属于本换标来源的剩余贡献"); + var remaining=new HashMap();long total=0; + for(var row:rows){long left=row.getPackageCount()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).packages():0);remaining.put(row.getId(),left);total=Math.addExact(total,left);} + if(packages<=0 || packages>total)throw conflict("实际扫码超过本换标来源本位置的剩余箱数"); + if(rows.size()>1 && packages!=total)throw conflict("本位置含多条换标贡献,请扫描具体箱唛后确认部分箱数"); + var result=new ArrayList();long knownQuantity=0; + for(var row:rows)if(row.getProductQuantity()!=null){long used=consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0;long left=row.getProductQuantity()-used;if(left<0)throw conflict("换标实际件数超过来源贡献");knownQuantity=Math.addExact(knownQuantity,left);} + if(rows.size()>1) { + if(productQuantity!=knownQuantity)throw conflict("多来源件数不能推算,请扫描明确箱唛并确认实际件数"); + for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new Allocation(row.getId(),remaining.get(row.getId()),quantity));} + } else { + var row=rows.get(0);if(row.getProductQuantity()!=null && (productQuantity>knownQuantity || packages==total && productQuantity!=knownQuantity))throw conflict("实际扫码件数与本来源剩余件数不一致"); + result.add(new Allocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity)); + } + return List.copyOf(result); + } + + /** 移货只删除未产生实际拣货的原贡献;取消保留快照作历史。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void removeUnpicked(long dispatch,long item){repository.removeUnpicked(dispatch,item);} + /** 普通换标和原生退货箱始终使用不同真实来源分支。 */ + private boolean normalRelabel(DispatchItem item){return "RELABEL_ORDER".equals(item.getSourceBusinessType()) && "RELABEL".equals(item.getSourceStage());} + /** 组内身份由真实换标业务号与全局实托共同确定。 */ + private String key(String number,Long pallet){return number+":"+pallet;} + /** 箱唛只在所选应用/位置范围内解析,不用另一个应用的同托记录冒充。 */ + private boolean matches(DispatchItemSource source,String mark){ + try {for(var fact:json.readTree(source.getUnitFactsJson()))if(mark.equalsIgnoreCase(fact.path("oldLabel").asText()) || mark.equalsIgnoreCase(fact.path("newLabel").asText()))return true;return false;} + catch(Exception failure){throw new IllegalStateException("冻结换标工作事实无法读取",failure);} + } + /** 原工作事实完整持久化,不把成功数或SKU件数改写成通知箱数。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception failure){throw new IllegalStateException("换标实际贡献无法保存",failure);}} + /** 任一来源不一致均由原主单事务整体回滚。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 56df18287..add8a5eae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -15,6 +15,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** PDA按绑定仓读取真正整车,列表批量装配全部明细而不逐行请求详情。 */ @Service public class DispatchPdaQueryService { + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins; private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow; /** 共用主流程资格,PDA不维护第二套版本和阶段。 */ public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;} @@ -35,20 +36,51 @@ public class DispatchPdaQueryService { var invalid=repository.invalid(ids).stream().collect(Collectors.toMap(p->p.dispatchId(),p->p.quantity())); var positions=repository.positions(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(p->p.getCustomerId()+":"+p.getPalletId())); var cartons=repository.cartons(ids,actor.boundWarehouseId()).stream().collect(Collectors.groupingBy(c->c.itemId()));var result=new ArrayList(); + boolean hasRelabel=cargo.values().stream().flatMap(Collection::stream).anyMatch(DispatchPdaQueryService::normalRelabel); + var frozen=hasRelabel?sourceOrigins.batchAll(ids):List.of(); + var byItem=frozen.stream().collect(Collectors.groupingBy(DispatchItemSource::getDispatchItemId)); + var consumed=hasRelabel?sourceOrigins.pickedAll(ids).stream().collect(Collectors.toMap(p->p.sourceId(),p->p.packages())):Map.of(); for(var root:roots) { var items=cargo.getOrDefault(root.getId(),List.of());var detail=new ArrayList(); + boolean sourceValid=true;var expectedHold=new HashMap(); + for(var source:frozen)if(Objects.equals(source.getDispatchOrderId(),root.getId())) { + long used=consumed.getOrDefault(source.getId(),0L),left=n(source.getPackageCount())-used; + if(used<0 || left<0)sourceValid=false; + expectedHold.merge(source.getCustomerId()+":"+source.getWarehousePalletId()+":"+source.getInventoryPositionId(),left,Math::addExact); + } + if(Stage.LOADING.name().equals(root.getStage())) { + for(var group:positions.values())for(var p:group) { + String key=p.getCustomerId()+":"+p.getPalletId()+":"+p.getId(); + if(items.stream().anyMatch(i->normalRelabel(i) && Objects.equals(i.getCustomerId(),p.getCustomerId()) && Objects.equals(i.getWarehousePalletId(),p.getPalletId())) + && n(p.getHoldPackages())!=expectedHold.getOrDefault(key,0L))sourceValid=false; + } + } for(var item:items) { var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet()); - boolean relabel="RELABEL_ORDER".equals(item.getSourceBusinessType()); var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream() - .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages())+(relabel?n(p.getHoldPackages()):0),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); + .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); + if(normalRelabel(item)) { + var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList(); + if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false; + for(var group:contributions.stream().collect(Collectors.groupingBy(DispatchItemSource::getInventoryPositionId,TreeMap::new,Collectors.toList())).entrySet()) { + long used=group.getValue().stream().mapToLong(s->consumed.getOrDefault(s.getId(),0L)).sum(); + long total=group.getValue().stream().mapToLong(s->n(s.getPackageCount())).sum(); + var actual=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream().filter(p->Objects.equals(p.getId(),group.getKey())).findFirst().orElse(null); + if(actual==null || group.getValue().stream().anyMatch(s->!Objects.equals(s.getCustomerId(),item.getCustomerId()) || !Objects.equals(s.getWarehousePalletId(),item.getWarehousePalletId()) || !Objects.equals(s.getInventoryBalanceId(),actual.getBalanceId()))) { + if(total>used)sourceValid=false;continue; + } + ownPositions.add(new DispatchViews.Position(actual.getId(),actual.getBalanceId(),Math.max(0,total-used),0,used)); + } + sourcePositions=List.copyOf(ownPositions); + } var sourceCartons=cartons.getOrDefault(item.getId(),List.of()).stream().filter(c->marks.contains(c.cartonMark())) .map(c->new DispatchViews.ReturnCarton(c.cartonId(),c.cartonMark(),c.status(),c.productQuantity())).toList(); - detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons)); + detail.add(new DispatchViews.PdaItem(item.getId(),item.getCustomerId(),item.getCustomerName(),item.getPalletNo(),workflow.destination(item),n(item.getPackageCount()),picked.getOrDefault(item.getId(),0L),sourcePositions,sourceCartons,item.getSourceKey(),item.getSourceBusinessType(),item.getSourceBusinessNo())); } - boolean complete=n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0 + boolean complete=sourceValid && n(root.getPackageCount())>0 && n(root.getPickedPackageCount())==n(root.getPackageCount()) && invalid.getOrDefault(root.getId(),0L)==0 && !items.isEmpty() && items.stream().allMatch(i->picked.getOrDefault(i.getId(),0L)==n(i.getPackageCount())); var actions=new EnumMap(workflow.actions(actor,root)); + if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对")); if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平")); else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); @@ -61,4 +93,6 @@ public class DispatchPdaQueryService { private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} /** 缺失计数不作为业务标识使用。 */ private static long n(Number value){return value==null?0:value.longValue();} + /** 原生退货箱沿真实箱账本,不把它当普通仓位贡献。 */ + private static boolean normalRelabel(DispatchItem item){return "RELABEL".equals(item.getSourceStage()) && "RELABEL_ORDER".equals(item.getSourceBusinessType());} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java new file mode 100644 index 000000000..f2c473127 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPhysicalClaimService.java @@ -0,0 +1,140 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchPhysicalPalletLock; +import com.baiyue.lastleg.dispatch.repository.DispatchPhysicalClaimRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.LocalDateTime; +import java.util.*; + +/** 同托成员共享一个真实拥有者;所有方法跟随主单或草稿原事务,不独立提交。 */ +@Service +public class DispatchPhysicalClaimService { + private final DispatchPhysicalClaimRepository repository; + /** 物理占用不依赖任何客户代表行。 */ + public DispatchPhysicalClaimService(DispatchPhysicalClaimRepository repository){this.repository=repository;} + + /** 正式拥有者和草稿拥有者互斥,草稿身份还必须包含原容器和原操作人。 */ + public record Owner(String warehouseId,Long dispatchId,String draftToken,Long draftContainerId,String operatorId,LocalDateTime expiresAt) { + /** 不允许混合或缺失拥有者字段,空计划来源不会申请物理占用。 */ + public Owner { + if(warehouseId==null || warehouseId.isBlank() || operatorId==null || operatorId.isBlank() + || !(dispatchId!=null && dispatchId>0 && draftToken==null && draftContainerId==null && expiresAt==null + || dispatchId==null && draftToken!=null && !draftToken.isBlank() && draftContainerId!=null && draftContainerId>0 && expiresAt!=null)) + throw new IllegalArgumentException("物理实托拥有者身份不完整"); + } + } + + /** 调用方先锁齐申请、来源及库存,再按实托全局顺序取得排他占用。 */ + @Transactional(propagation=Propagation.MANDATORY) + public Map reserve(Owner owner,Collection pallets,LocalDateTime at) { + if(at==null || owner.expiresAt()!=null && !owner.expiresAt().isAfter(at))throw conflict("草稿占用已到期,请重新核对"); + var result=new LinkedHashMap(); + for(var pallet:ordered(pallets)) { + var row=repository.lockActive(pallet); + if(row!=null && !Objects.equals(row.getWarehouseId(),owner.warehouseId()))throw conflict("实托占用起运仓不一致,请核对来源"); + if(row!=null && row.getDispatchOrderId()==null && row.getExpiresAt()!=null && !row.getExpiresAt().isAfter(at)) { + repository.expireMembers(row.getId(),at); + if(repository.releaseUnused(row.getId(),"EXPIRED",at)!=1)throw conflict("原实托成员尚未完整到期"); + row=null; + } + if(row!=null) { + if(!owns(row,owner))throw conflict("所选实托已被其他有效运输安排占用"); + } else { + row=new DispatchPhysicalPalletLock();row.setId(IdWorker.getId());row.setWarehouseId(owner.warehouseId()); + row.setWarehousePalletId(pallet);row.setActiveWarehousePalletId(pallet);row.setDispatchOrderId(owner.dispatchId()); + row.setDraftToken(owner.draftToken());row.setDraftContainerId(owner.draftContainerId());row.setOperatorId(owner.operatorId()); + row.setStatus("ACTIVE");row.setExpiresAt(owner.expiresAt());row.setLockedAt(at);row.setCreatedAt(at);row.setUpdatedAt(at); + try{if(repository.insert(row)!=1)throw conflict("实托占用未保存");} + catch(DuplicateKeyException error){throw conflict("所选实托已被并发运输安排占用,请重新核对");} + } + result.put(pallet,row.getId()); + } + return Collections.unmodifiableMap(result); + } + + /** 只结束原主键、本拥有者且没有活动成员的占用;其他来源仍在时保留物理排他。 */ + @Transactional(propagation=Propagation.MANDATORY) + public int releaseIfUnused(Owner owner,Collection originalIds,LocalDateTime at) { + Objects.requireNonNull(at,"释放时间缺失"); + var original=new ArrayList(); + // 先普通读取不可变实托身份,再按实托逐原主键取锁,不依赖ORDER BY之前的数据库扫描锁序。 + var references=repository.originals(ordered(originalIds)).stream() + .sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList(); + for(var ref:references) { + var row=repository.lockOriginal(ref.getId()); + if(row==null || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原物理占用身份已变化"); + if("ACTIVE".equals(row.getStatus()) && owns(row,owner))original.add(row); + } + int released=0; + for(var row:original)released+=repository.releaseUnused(row.getId(),"RELEASED",at); + return released; + } + + /** 按拥有者普通读原身份,再按实际实托逐主键取锁,不依赖批量扫描计划的加锁先后。 */ + @Transactional(propagation=Propagation.MANDATORY) + public List lockOwner(Owner owner) { + var references=owner.dispatchId()!=null?repository.lockDispatch(owner.dispatchId()):repository.lockDraft(owner.draftToken(),owner.operatorId()); + var result=new ArrayList(); + for(var ref:references.stream().filter(r->owner.dispatchId()!=null || Objects.equals(r.getDraftContainerId(),owner.draftContainerId())) + .sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList()) { + var row=repository.lockOriginal(ref.getId()); + if(row==null || !"ACTIVE".equals(row.getStatus()) || !owns(row,owner) || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原实托占用归属已变化"); + result.add(row); + } + return List.copyOf(result); + } + + /** 逻辑成员须已全部移入目标容器;物理拥有者仍按原容器及原租约核对。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void moveDraft(Owner previous,Collection originalPhysicalLockIds,long nextContainerId,LocalDateTime at) { + if(previous.dispatchId()!=null || nextContainerId<=0 || at==null || !previous.expiresAt().isAfter(at))throw conflict("草稿整托迁移身份或租约无效"); + var ids=ordered(originalPhysicalLockIds);var references=repository.originals(ids).stream().sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList(); + if(ids.isEmpty() || references.size()!=ids.size())throw conflict("原草稿容器没有完整可迁移的实托占用"); + var rows=new ArrayList(); + for(var ref:references) { + var row=repository.lockOriginal(ref.getId()); + if(row==null || !"ACTIVE".equals(row.getStatus()) || !owns(row,previous) || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()))throw conflict("原草稿实托拥有者已变化"); + rows.add(row); + } + for(var row:rows) { + if(!Objects.equals(row.getExpiresAt(),previous.expiresAt()))throw conflict("原草稿实托租约已变化"); + if(repository.moveDraft(row.getId(),previous.draftToken(),previous.operatorId(),previous.draftContainerId(),nextContainerId,at)!=1)throw conflict("同实托全部逻辑来源尚未移入同一目标容器"); + } + } + + /** 调用方先持草稿根和完整共同实托锁,物理续期后再同事务续期逻辑成员及根。 */ + @Transactional(propagation=Propagation.MANDATORY) + public void renewDraft(String warehouseId,String draftToken,String operatorId,LocalDateTime at,LocalDateTime previousExpiresAt,LocalDateTime nextExpiresAt) { + if(warehouseId==null || warehouseId.isBlank() || draftToken==null || draftToken.isBlank() || operatorId==null || operatorId.isBlank() + || at==null || previousExpiresAt==null || nextExpiresAt==null || !previousExpiresAt.isAfter(at) || nextExpiresAt.isBefore(previousExpiresAt))throw conflict("草稿续期身份或租约无效"); + var references=repository.lockDraft(draftToken,operatorId).stream().sorted(Comparator.comparing(DispatchPhysicalPalletLock::getWarehousePalletId).thenComparing(DispatchPhysicalPalletLock::getId)).toList(); + for(var ref:references) { + var row=repository.lockOriginal(ref.getId()); + if(row==null || !"ACTIVE".equals(row.getStatus()) || row.getDispatchOrderId()!=null || row.getDraftContainerId()==null + || !Objects.equals(row.getWarehouseId(),warehouseId) || !Objects.equals(row.getDraftToken(),draftToken) || !Objects.equals(row.getOperatorId(),operatorId) + || !Objects.equals(row.getWarehousePalletId(),ref.getWarehousePalletId()) || !Objects.equals(row.getExpiresAt(),previousExpiresAt))throw conflict("草稿实托归属或原租约已变化"); + } + if(nextExpiresAt.equals(previousExpiresAt))return; + if(repository.renewDraft(draftToken,operatorId,at,nextExpiresAt)!=references.size())throw conflict("草稿实托续期未完整保存"); + } + + /** 身份核对不把正式排车创建操作人当拥有者,其他有权限人员仍可维护同一主单。 */ + private boolean owns(DispatchPhysicalPalletLock row,Owner owner) { + return Objects.equals(row.getWarehouseId(),owner.warehouseId()) && Objects.equals(row.getDispatchOrderId(),owner.dispatchId()) + && Objects.equals(row.getDraftToken(),owner.draftToken()) && Objects.equals(row.getDraftContainerId(),owner.draftContainerId()) + && (owner.dispatchId()!=null || Objects.equals(row.getOperatorId(),owner.operatorId())); + } + + /** 实托和原占用引用必须真实且去重,固定顺序避免逐客户交错取锁。 */ + private List ordered(Collection ids) { + if(ids==null || ids.stream().anyMatch(id->id==null || id<=0))throw new IllegalArgumentException("真实实托或占用引用缺失"); + return List.copyOf(new TreeSet<>(ids)); + } + /** 任一物理争用失败都由外层事务回滚整组逻辑来源。 */ + private static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 68e058837..4f5fc7da3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -24,6 +24,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ @Service public class DispatchPickingService { + @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; + @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; + @jakarta.annotation.Resource private DispatchService sourceRules; private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; @@ -45,9 +48,10 @@ public class DispatchPickingService { || context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码"); if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法"); try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");} - var item=repository.cargo(row.getId()).stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在")); + var currentItems=repository.cargo(row.getId()); + var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在")); if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓"); - materialize(row.getId(),item); + materialize(row,item,actor,currentItems); var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC); if(task==null) { task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId()); @@ -60,19 +64,22 @@ public class DispatchPickingService { if(request.packages()>item.getPackageCount()-before)throw conflict("实际扫码超过本来源应装箱数"); boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage()); if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); - long pickId=IdWorker.getId();Long returnCartonId=null; + long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType()); + List relabelAllocations=List.of(); if(nativeReturn) { attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); } else { var position=picking.position(request.positionId()); if(position==null || !Objects.equals(item.getCustomerId(),position.getCustomerId()) || !Objects.equals(item.getWarehousePalletId(),position.getPalletId()) || !Objects.equals(actor.boundWarehouseId(),position.getOverseasWarehouseId()))throw conflict("实际库存仓位不属于本车本客户实托"); - Long fallback="RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null; - attribution.preparePicking(item.getCustomerId(),request.positionId(),fallback); - var actual=sources.stock(item.getCustomerId(),item.getWarehousePalletId(),true).stream().filter(s->s.id()==request.positionId()).findFirst().orElseThrow(()->conflict("实际库存仓位已移动")); + if(ordinaryRelabel)attribution.prepareRelabel(item.getCustomerId(),item.getOrderId()); + else attribution.preparePicking(item.getCustomerId(),request.positionId()); + var stock=sources.stock(item.getCustomerId(),item.getWarehousePalletId(),true); + var actual=stock.stream().filter(s->s.id()==request.positionId()).findFirst().orElseThrow(()->conflict("实际库存仓位已移动")); if(!Objects.equals(actual.warehouseId(),actor.boundWarehouseId()))throw conflict("实际库存起运仓已变化"); + if(ordinaryRelabel)relabelAllocations=relabelSources.allocate(row.getId(),item,request.positionId(),request.packages(),request.productQuantity(),request.cartonMark(),stock); String base="DISPATCH:"+number+":"+itemId+":"+request.idempotencyKey(); - if(fallback!=null)inventory.release(item.getCustomerId(),actor.operatorId(),move(request,number,item.getSourceBusinessNo(),base+":RELABEL_RELEASE")); + if(ordinaryRelabel)inventory.release(item.getCustomerId(),actor.operatorId(),move(request,number,item.getSourceBusinessNo(),base+":RELABEL_RELEASE")); inventory.allocate(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":ALLOCATE")); inventory.pick(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":PICK")); inventory.ship(item.getCustomerId(),actor.operatorId(),move(request,number,number,base+":SHIP")); @@ -81,9 +88,12 @@ public class DispatchPickingService { pick.setInventoryPositionId(request.positionId());pick.setReturnCartonId(returnCartonId);pick.setPackages(request.packages());pick.setProductQuantity(request.productQuantity());pick.setIdempotencyKey(request.idempotencyKey());pick.setPickedAt(at);pick.setCreatedAt(at); if(picking.pick(pick)!=1)throw conflict("真实扫码明细未保存"); if(nativeReturn)attribution.attributeNativeReturn(item.getCustomerId(),pick,item.getOrderId()); - else attribution.attribute(item.getCustomerId(),pick,"RELABEL_ORDER".equals(item.getSourceBusinessType())?item.getOrderId():null,item.getReturnReceiptId()); + else if(ordinaryRelabel)attribution.attributeRelabel(item.getCustomerId(),pick,item.getOrderId(),relabelAllocations); + else attribution.attribute(item.getCustomerId(),pick,item.getReturnReceiptId()); task.setOperatorId(actor.operatorId());task.setStatus("PICKING");task.setUpdatedAt(at);if(picking.saveTask(task)!=1)throw conflict("本车作业进度未保存"); - long version=row.getRecordVersion();row.setPickedPackageCount(Math.toIntExact(repository.realPicked(row.getId())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); + long version=row.getRecordVersion();row.setPickedPackageCount(Math.toIntExact(repository.realPicked(row.getId()))); + row.setPalletCount(Math.toIntExact(currentItems.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); + row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); if(repository.saveState(row,version)!=1)throw conflict("本车扫码版本竞争失败"); boolean complete=workflow.preview(actor,number).pickingReconciled(); if(repository.history(IdWorker.getId(),row,Action.PICK.name(),actor.operatorId(),request.remark(),write(Map.of("dispatch",row,"pick",pick)),at)!=1)throw conflict("实际扫码证据未保存"); @@ -92,22 +102,31 @@ public class DispatchPickingService { commands.append(row.getId(),actor,identity,result);return result; } /** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */ - private void materialize(long dispatch,DispatchItem item) { - if(item.getWarehousePalletId()!=null)return; + private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List items) { + long dispatch=row.getId(); + if(item.getWarehousePalletId()!=null) { + relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return; + } if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托"); var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); if(orders.isEmpty())throw conflict("原计划缺少真实订单组成"); - for(var id:orders)if(sources.lockOrder(item.getCustomerId(),id)==null)throw conflict("原计划订单已变化或暂扣"); - if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0); if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) || !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType()) || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对"); if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单"); - try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC))!=1)throw conflict("原计划来源占用已变化");} + item.setWarehousePalletId(actual.warehousePalletId()); + relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); + if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对"); + var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true); + var pallets=new ArrayList();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId()); + var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId()); + try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");} catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");} - item.setWarehousePalletId(actual.warehousePalletId());item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); + item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存"); } /** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index f8ed8cf0b..c2b706f2c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -1,55 +1,154 @@ package com.baiyue.lastleg.dispatch.service; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; -import com.baiyue.lastleg.dispatch.entity.DispatchDraft; import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard; -import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; -import com.baiyue.lastleg.relabel.service.RelabelApplicationService; import jakarta.annotation.Resource; -import org.springframework.dao.DuplicateKeyException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; - -import java.math.BigDecimal; -import java.time.LocalDateTime; -import java.time.format.DateTimeFormatter; -import com.baiyue.lastleg.service.LastlegOrderNoGenerator; -import java.util.Arrays; -import java.util.HashSet; -import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.stream.Collectors; +import java.util.*; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; @Service public class DispatchService { @Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository wholeSources; @Resource private DispatchCommandHasher wholeHashes; + @Resource private RelabelDispatchSourcePort relabelSources; + @Resource private com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort palletSerial; + private final Object relabelLockScope=new Object(); + + /** 候选与写入共用完整来源集合,展示筛选不参与组成推导。 */ + private record SourceGroup(SelectionGroup view,Map rows,Map versions) { } + + /** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */ + public Map selectionGroups(List rows) { + var result=new LinkedHashMap(); + for(var row:rows) { + String key=wholeSourceKey(selection(row,null)); + if(result.containsKey(key))continue; + var group=sourceGroup(row,false); + for(var source:group.view().sources())result.put(source.sourceKey(),group.view()); + } + return Collections.unmodifiableMap(result); + } + + /** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */ + public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) { + var row=readSelection(warehouse,requested);return sourceGroup(row,false).view(); + } + + /** 业务来源和物理托盘分别标识;来源版本由同一完整组事实派生。 */ + private DispatchCommands.CargoSelection selection(DispatchPalletCandidate row,String version) { + SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL:row.returnReceiptId()!=null?SourceType.RETURN_RECEIPT:SourceType.INBOUND_PLAN; + return new DispatchCommands.CargoSelection(type,type==SourceType.INBOUND_PLAN?row.planPalletId():null,type==SourceType.RETURN_RECEIPT?row.returnReceiptId():null, + type==SourceType.RELABEL?row.orderRefs():null,row.warehousePalletId(),version); + } + + /** 只读定位请求真实来源,不在收齐申请/订单之前抢占任何库存锁。 */ + private DispatchPalletCandidate readSelection(String warehouse,DispatchCommands.CargoSelection request) { + wholeSourceKey(request);DispatchPalletCandidate row; + if(request.sourceType()==SourceType.INBOUND_PLAN)row=requireWholeCandidate(wholeSources.selectedPlan(requireSingleCustomerScope(wholeSources.planOwners(request.planPalletId()),"所选来源"),request.planPalletId())); + else if(request.sourceType()==SourceType.RETURN_RECEIPT)row=requireWholeCandidate(wholeSources.selectedReturn(requireSingleCustomerScope(wholeSources.returnOwners(request.returnReceiptId()),"所选退回来源"),request.returnReceiptId())); + else { + String customer=wholeSources.relabelOwner(request.relabelNo()); + if(customer==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"换标来源不存在"); + row=relabelRows(customer,request.relabelNo()).stream().filter(r->Objects.equals(request.warehousePalletId(),r.warehousePalletId())).findFirst() + .orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"换标托盘来源已变化")); + } + if(!Objects.equals(warehouse,row.overseasWarehouseId()))throw new ResponseStatusException(HttpStatus.CONFLICT,"所选来源起运仓不一致"); + return row; + } + + /** 普通和原生退货换标仍各走实际生产者,保留整申请全部应排托盘。 */ + private List relabelRows(String customer,String no) { + return java.util.stream.Stream.concat(measuredRelabelCandidates(mutations().relabelCandidates(customer,no)).stream(),returnRelabelStock.candidates(customer,null,no).stream()).toList(); + } + + /** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */ + private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) { + var rows=new TreeMap();rows.put(wholeSourceKey(selection(seed,null)),seed); + var pallets=new TreeSet();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId()); + var applications=new TreeMap();var loaded=new HashSet();var reasons=new LinkedHashSet(); + if(selection(seed,null).sourceType()==SourceType.RELABEL)applications.put(seed.orderRefs(),seed.customerId()); + RelabelDispatchSourcePort.Snapshot facts=null;var previous=new TreeSet(); + do { + previous.clear();previous.addAll(pallets); + if(!pallets.isEmpty()) { + facts=locked?relabelSources.lockedSnapshot(seed.overseasWarehouseId(),List.copyOf(pallets)):relabelSources.snapshot(seed.overseasWarehouseId(),List.copyOf(pallets)); + if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失"); + reasons.addAll(facts.blockReasons()); + if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐"); + facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add); + for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId()); + var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList(); + for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId()); + } + for(var app:new TreeMap<>(applications).entrySet())if(loaded.add(app.getKey())) { + var complete=relabelRows(app.getValue(),app.getKey()); + if(complete.isEmpty())reasons.add(app.getKey()+":申请尚无完整可排实托来源"); + for(var row:complete) { + if(!Objects.equals(seed.overseasWarehouseId(),row.overseasWarehouseId())){reasons.add("完整换标申请包含其他起运仓");continue;} + rows.put(wholeSourceKey(selection(row,null)),row);if(row.warehousePalletId()!=null)pallets.add(row.warehousePalletId()); + } + } + if(rows.size()>1000 || pallets.size()>1000)throw new ResponseStatusException(HttpStatus.CONFLICT,"完整来源组超过完整核验上限"); + } while(!pallets.equals(previous)); + var nativeNos=rows.values().stream().filter(r->"RETURN_RELABEL".equals(r.sourceStage())).map(DispatchPalletCandidate::orderRefs).collect(Collectors.toSet()); + // 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。 + reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":"))); + if(!pallets.isEmpty()) { + var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)); + if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限"); + var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList())); + for(var entry:buckets.entrySet()) { + var choices=entry.getValue().stream().sorted(Comparator.comparing(r->wholeSourceKey(selection(r,null)))).toList(); + var preferred=selection(seed,null).sourceType()!=SourceType.RELABEL && entry.getKey().equals(seed.customerId()+":"+seed.warehousePalletId())?seed: + choices.stream().filter(r->r.planPalletId()!=null).findFirst().orElse(null); + // PLAN是该客户此实托全部可用库存的一个选择别名,不能把多个别名重复计箱。 + if(preferred!=null && preferred.planPalletId()!=null)rows.put(wholeSourceKey(selection(preferred,null)),preferred); + else for(var row:choices)if(row.returnReceiptId()!=null)rows.put(wholeSourceKey(selection(row,null)),row); + } + if(facts!=null) { + var bound=facts.sources().stream().filter(s->!nativeNos.contains(s.relabelNo()) && s.inventoryPositionId()!=null).collect(Collectors.groupingBy(RelabelDispatchSourcePort.BoundSource::inventoryPositionId,Collectors.summingLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount))); + if(facts.positions().stream().anyMatch(p->p.holdPackages()!=bound.getOrDefault(p.positionId(),0L)))reasons.add("实托还有未核齐的实际冻结来源"); + var ordinary=rows.values().stream().filter(r->selection(r,null).sourceType()!=SourceType.RELABEL && r.warehousePalletId()!=null) + .collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId())); + var actual=facts.positions().stream().collect(Collectors.groupingBy(p->p.customerId()+":"+p.warehousePalletId(),Collectors.summingLong(RelabelDispatchSourcePort.Position::availablePackages))); + for(var entry:actual.entrySet())if(entry.getValue()>0 && ordinary.getOrDefault(entry.getKey(),List.of()).stream().mapToLong(r->r.packageCount()==null?0:r.packageCount()).sum()!=entry.getValue()) + reasons.add("实托还有未完整选择或重复计入的真实可用库存来源"); + } + } + var bases=new TreeMap();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false)); + String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets)); + var versions=new TreeMap();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version)))); + var sources=new ArrayList(); + for(var entry:rows.entrySet()) { + var row=entry.getValue();var selected=selection(row,versions.get(entry.getKey()));String type=selected.sourceType()==SourceType.RELABEL?"RELABEL_ORDER":selected.sourceType().name(); + String no=selected.sourceType()==SourceType.RELABEL?selected.relabelNo():selected.sourceType()==SourceType.RETURN_RECEIPT?row.returnReceiptNo():row.planNo(); + sources.add(new SelectionGroupSource(selected,entry.getKey(),row.customerId(),row.customerName(),type,no,row.packageCount()==null?0:row.packageCount())); + } + var physical=new ArrayList(); + var physicalRows=rows.values().stream().collect(Collectors.groupingBy(r->r.warehousePalletId()==null?"PLAN:"+r.planPalletId():"PALLET:"+r.warehousePalletId(),TreeMap::new,Collectors.toList())); + for(var group:physicalRows.values()) { + var first=group.get(0);var destinations=group.stream().map(r->com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(r.destinationType(),r.destinationWarehouseCode(),r.fbaWarehouseCode(),r.deliveryAddress(),r.postalCode())).distinct().count(); + if(destinations!=1)reasons.add("同一实托目的地不一致,请核对实际组托"); + physical.add(new SelectionGroupPallet(first.warehousePalletId(),first.palletNo(),group.stream().map(r->wholeSourceKey(selection(r,null))).sorted().toList(),group.stream().mapToLong(r->r.packageCount()==null?0:r.packageCount()).sum())); + } + String groupKey="SG-"+wholeHashes.source(Arrays.asList(rows.keySet(),pallets)); + return new SourceGroup(new SelectionGroup(groupKey,version,List.copyOf(sources),List.copyOf(physical),reasons.isEmpty(),List.copyOf(reasons)),Collections.unmodifiableMap(rows),Collections.unmodifiableMap(versions)); + } /** 整车一次选择的真实客户来源,未持久化客户子排车。 */ public record WholeSource(DispatchPalletCandidate candidate, @@ -85,19 +184,39 @@ public class DispatchService { @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public List lockWholeSources(String warehouseId, List selections) { - return resolveWholeSources(warehouseId,selections,true,500); + return resolveWholeSources(warehouseId,selections,true,500,List.of()); } - /** 草稿可逐托暂存换标选择,正式建单仍要求本申请全部托盘,事实校验和锁序共用。 */ + /** 加货先并入本车原实托范围,避免持有新托库存后才反向取得旧托占用锁。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public List lockAdditionalSources(String warehouseId,List selections,List current) { + return resolveWholeSources(warehouseId,selections,true,500,current); + } + + /** 草稿与正式建单都按完整实托来源组锁定,不能拆开同托申请。 */ @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public List lockDraftSources(String warehouseId,List selections) { - return resolveWholeSources(warehouseId,selections,false,1000); + return resolveWholeSources(warehouseId,selections,true,1000,List.of()); } /** 先锁全部申请/来源订单,再按物理托盘顺序锁来源根和库存,不能按分组交错。 */ - private List resolveWholeSources(String warehouseId,List selections,boolean completeRelabel,int limit) { + private List resolveWholeSources(String warehouseId,List selections,boolean completeRelabel,int limit,List currentItems) { if (selections == null || selections.isEmpty()) return List.of(); if (selections.size() > limit) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"选择来源超过完整核验上限"); + var selectedKeys=selections.stream().map(DispatchService::wholeSourceKey).collect(Collectors.toCollection(TreeSet::new)); + if(selectedKeys.size()!=selections.size())throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"同一来源不能重复选择"); + var scopePallets=new TreeSet();var scopeApplications=new TreeSet(); + currentItems.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(scopePallets::add); + if(!scopePallets.isEmpty())relabelSources.snapshot(warehouseId,List.copyOf(scopePallets)).applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).forEach(scopeApplications::add); + currentItems.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).forEach(scopeApplications::add); + for(var selection:selections) { + var group=sourceGroup(readSelection(warehouseId,selection),false); + if(!group.view().complete())throw new ResponseStatusException(HttpStatus.CONFLICT,"完整实托来源尚未核齐:"+String.join(";",group.view().blockReasons())); + if(!selectedKeys.containsAll(group.rows().keySet()))throw new ResponseStatusException(HttpStatus.CONFLICT,"须一次完整选择同实托全部来源及整换标申请全部应排托盘"); + group.view().pallets().stream().map(SelectionGroupPallet::warehousePalletId).filter(Objects::nonNull).forEach(scopePallets::add); + group.view().sources().stream().map(SelectionGroupSource::selection).filter(s->s.sourceType()==SourceType.RELABEL).map(DispatchCommands.CargoSelection::relabelNo).forEach(scopeApplications::add); + } + if(!scopePallets.isEmpty())lockRelabelApplications(scopeApplications); var chosen = new java.util.LinkedHashMap(); var relabelGroups = selections.stream().filter(s -> s != null && s.sourceType() == com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType.RELABEL) .collect(Collectors.groupingBy(s -> Objects.toString(s.relabelNo(), ""), java.util.TreeMap::new, Collectors.toList())); @@ -108,8 +227,6 @@ public class DispatchService { if (application == null || !"READY_FOR_OUTBOUND".equals(application.getStatus()) || !"TRUCK".equals(application.getDeliveryMethod()) || !"WAIT_PRE_DISPATCH".equals(application.getFulfillmentStatus())) throw new ResponseStatusException(HttpStatus.CONFLICT,"换标申请尚不具备排车资格"); - relabelBindingGuard.requireBound(application); - relabelMeasurements.requireMeasured(application); var complete = java.util.stream.Stream.concat(measuredRelabelCandidates(mutations().relabelCandidates(customer, group.getKey())).stream(), returnRelabelStock.candidates(customer,null,group.getKey()).stream()).toList(); var requested = group.getValue().stream().map(com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection::warehousePalletId).collect(Collectors.toSet()); @@ -152,8 +269,31 @@ public class DispatchService { if (ids.isEmpty()) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选货物缺少真实订单组成"); ids.forEach(id -> owners.put(id,row.customerId())); } - owners.forEach((id,customer) -> { if (wholeSources.lockOrder(customer,id) == null) + var allOrders=new TreeMap();for(var ref:wholeSources.physicalOrders(warehouseId,List.copyOf(scopePallets)))allOrders.put(ref.orderId(),ref.customerId()); + allOrders.putAll(executionOrders(warehouseId,currentItems,scopePallets)); + allOrders.putAll(owners); + allOrders.forEach((id,customer) -> { if ((owners.containsKey(id)?wholeSources.lockOrder(customer,id):wholeSources.lockOrderIdentity(customer,id)) == null) throw new ResponseStatusException(HttpStatus.CONFLICT,"来源订单缺失或已暂扣,不能排车"); }); + palletSerial.lock(List.copyOf(scopePallets)); + // 在共同串行点后重读;新申请/订单/实托必须整笔重试,不能临时追加乱序锁。 + var currentOrders=new TreeMap();for(var ref:wholeSources.physicalOrders(warehouseId,List.copyOf(scopePallets)))currentOrders.put(ref.orderId(),ref.customerId());currentOrders.putAll(owners); + currentOrders.putAll(executionOrders(warehouseId,currentItems,scopePallets)); + if(!currentOrders.equals(allOrders))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托真实来源订单组成已变化,请重新读取"); + for(var source:chosen.values()) { + var fresh=readSelection(warehouseId,source.selection());var group=sourceGroup(fresh,!scopePallets.isEmpty()); + if(!group.view().complete() || !selectedKeys.containsAll(group.rows().keySet()) || group.view().pallets().stream().map(SelectionGroupPallet::warehousePalletId).filter(Objects::nonNull).anyMatch(p->!scopePallets.contains(p))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源组成已变化,请重新读取"); + } + for(var no:relabelGroups.keySet()) { + String customer=wholeSources.relabelOwner(no);var application=mutations().lockRelabel(customer,no); + relabelBindingGuard.requireBound(application);relabelMeasurements.requireMeasured(application); + } + var availableBuckets=new HashMap(); + for(var source:chosen.values())if(source.selection().sourceType()==SourceType.INBOUND_PLAN && source.candidate().warehousePalletId()!=null) { + String bucket=source.candidate().customerId()+":"+source.candidate().warehousePalletId(); + if(availableBuckets.put(bucket,source.candidate().planPalletId())!=null || chosen.values().stream().anyMatch(s->s.selection().sourceType()==SourceType.RETURN_RECEIPT && bucket.equals(s.candidate().customerId()+":"+s.candidate().warehousePalletId()))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"同一可用库存组成不能按多个来源别名重复计入"); + } var result = new java.util.ArrayList(); for (var source : chosen.values().stream().sorted(java.util.Comparator.comparingLong(s -> s.candidate().warehousePalletId() == null ? Long.MAX_VALUE : s.candidate().warehousePalletId()) @@ -163,7 +303,11 @@ public class DispatchService { throw new ResponseStatusException(HttpStatus.CONFLICT,"计划托盘来源已变化"); if (row.returnReceiptId() != null && wholeSources.lockReturn(row.customerId(),row.returnReceiptId()) == null) throw new ResponseStatusException(HttpStatus.CONFLICT,"退回实收来源已变化"); - String version = wholeSourceVersion(row,source.sourceKey(),true); + wholeSources.stock(row.customerId(),row.warehousePalletId(),true); + } + for(var source:chosen.values()) { + var row=readSelection(warehouseId,source.selection());var group=sourceGroup(row,!scopePallets.isEmpty());String version=group.versions().get(source.sourceKey()); + if(!group.view().complete() || !selectedKeys.containsAll(group.rows().keySet()))throw new ResponseStatusException(HttpStatus.CONFLICT,"锁内完整实托组成未核齐"); if (!Objects.equals(version,source.selection().sourceVersion())) throw new ResponseStatusException(HttpStatus.CONFLICT,"所选来源数量、组成或版本已变化,请重新核对"); result.add(new WholeSource(row,source.selection(),source.sourceKey(),version)); @@ -171,8 +315,89 @@ public class DispatchService { return List.copyOf(result); } + /** 外层草稿整批已锁全集时只重入子集,不让嵌套正式创建替换或追加申请锁。 */ + private void lockRelabelApplications(Set requested) { + Object existing=org.springframework.transaction.support.TransactionSynchronizationManager.getResource(relabelLockScope); + if(existing instanceof Set prior) { + if(!prior.containsAll(requested))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托申请锁范围已变化,请重新读取完整组成"); + return; + } + relabelSources.lockApplications(List.copyOf(requested));var locked=Set.copyOf(requested); + org.springframework.transaction.support.TransactionSynchronizationManager.bindResource(relabelLockScope,locked); + org.springframework.transaction.support.TransactionSynchronizationManager.registerSynchronization(new org.springframework.transaction.support.TransactionSynchronization() { + /** 新事务不能借用被挂起事务的申请锁证明。 */ + @Override public void suspend(){org.springframework.transaction.support.TransactionSynchronizationManager.unbindResourceIfPossible(relabelLockScope);} + /** 恢复原事务时恢复其同一锁集合。 */ + @Override public void resume(){org.springframework.transaction.support.TransactionSynchronizationManager.bindResource(relabelLockScope,locked);} + /** 事务结束后清除本次锁集合。 */ + @Override public void afterCompletion(int status){org.springframework.transaction.support.TransactionSynchronizationManager.unbindResourceIfPossible(relabelLockScope);} + }); + } + + /** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List items) { + // 合法空车没有来源或实托可锁;非空明细即使尚未关联实托,仍必须经过原严格校验。 + if(items.isEmpty()) { + if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("空排车仍须明确起运仓库"); + return new RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(), + wholeHashes.source(Map.of("warehouseId",warehouse,"items",List.of())),true,List.of()); + } + var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new)); + var initial=relabelSources.snapshot(warehouse,List.copyOf(pallets));var applications=new TreeSet(); + initial.applications().forEach(a->applications.add(a.relabelNo())); + items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).forEach(applications::add); + var codes=items.stream().map(DispatchItem::getPalletNo).filter(Objects::nonNull).distinct().toList(); + var nativeBefore=wholeSources.nativeApplications(warehouse,codes);nativeBefore.forEach(a->applications.add(a.relabelNo())); + lockRelabelApplications(applications); + var orders=executionOrders(warehouse,items,pallets); + orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");}); + palletSerial.lock(List.copyOf(pallets)); + var current=relabelSources.lockedSnapshot(warehouse,List.copyOf(pallets)); + var selectedApplications=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())).map(DispatchItem::getSourceBusinessNo).collect(Collectors.toSet()); + if(wholeSources.nativeApplications(warehouse,codes).stream().anyMatch(a->!selectedApplications.contains(a.relabelNo())))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托出现未完整选入本车的原生退货换标来源"); + if(!orders.equals(executionOrders(warehouse,items,pallets)) || current.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).anyMatch(p->!pallets.contains(p))) + throw new ResponseStatusException(HttpStatus.CONFLICT,"本车实托来源组成已变化,请回平台核对"); + var positionIds=current.positions().stream().map(RelabelDispatchSourcePort.Position::positionId).collect(Collectors.toSet()); + var stockScopes=new TreeMap>(); + for(var position:current.positions())if(position.warehousePalletId()!=null)stockScopes.computeIfAbsent(position.warehousePalletId(),p->new TreeSet<>()).add(position.customerId()); + for(var entry:stockScopes.entrySet())for(var customer:entry.getValue())wholeSources.stock(customer,entry.getKey(),true); + current=relabelSources.lockedSnapshot(warehouse,List.copyOf(pallets)); + if(!positionIds.equals(current.positions().stream().map(RelabelDispatchSourcePort.Position::positionId).collect(Collectors.toSet())) + || !orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托实际库存位置或真实订单组成已变化,请回平台核对"); + if(!items.isEmpty()) { + var picked=wholeSources.pickedItems(items.get(0).getDispatchOrderId()).stream().collect(Collectors.toMap(p->p.itemId(),p->p.packages())); + var expected=new HashMap(); + for(var item:items)if(!"RELABEL_ORDER".equals(item.getSourceBusinessType()) && item.getWarehousePalletId()!=null) { + long left=item.getPackageCount()-picked.getOrDefault(item.getId(),0L); + if(left<0)throw new ResponseStatusException(HttpStatus.CONFLICT,"普通来源实际拣货超过本来源箱数"); + expected.merge(item.getCustomerId()+":"+item.getWarehousePalletId(),left,Math::addExact); + } + var actual=current.positions().stream().collect(Collectors.groupingBy(p->p.customerId()+":"+p.warehousePalletId(),Collectors.summingLong(RelabelDispatchSourcePort.Position::availablePackages))); + for(var entry:expected.entrySet())if(!Objects.equals(actual.getOrDefault(entry.getKey(),0L),entry.getValue()))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托普通来源剩余箱数已变化,请回平台核对"); + for(var entry:actual.entrySet())if(!Objects.equals(expected.getOrDefault(entry.getKey(),0L),entry.getValue()))throw new ResponseStatusException(HttpStatus.CONFLICT,"实托出现未完整选入本车的可用库存来源"); + } + return current; + } + + /** 原订单与换标履约单分别锁定,各身份保持真实所属客户。 */ + private TreeMap executionOrders(String warehouse,List items,Set pallets) { + var orders=new TreeMap();for(var ref:wholeSources.physicalOrders(warehouse,List.copyOf(pallets)))orders.put(ref.orderId(),ref.customerId()); + for(var item:items) { + if(item.getOrderId()!=null)orders.put(item.getOrderId(),item.getCustomerId()); + if(item.getPlanPalletId()!=null)for(var id:wholeSources.sourceOrders(item.getCustomerId(),item.getWarehousePalletId(),item.getPlanPalletId()))orders.put(id,item.getCustomerId()); + } + return orders; + } + /** GET候选与写入锁内使用同一摘要字段;不包含客户端显示顺序或锁拥有者。 */ public String wholeSourceVersion(DispatchPalletCandidate row, String key, boolean lockStock) { + if(lockStock)wholeSources.stock(row.customerId(),row.warehousePalletId(),true); + return sourceGroup(row,false).versions().get(key); + } + + /** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */ + private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) { var facts = new java.util.TreeMap(); facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId()); facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId()); @@ -233,36 +458,17 @@ public class DispatchService { for(var item:items.stream().filter(i->"RETURN_RELABEL".equals(i.getSourceStage())).sorted(java.util.Comparator.comparing(DispatchItem::getId)).toList())returnRelabelStock.release(item); for(var group:groups.values()){var first=group.get(0);mutations().restoreRelabel(first.getCustomerId(),first.getSourceBusinessNo());} } - @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; - @Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges workChanges; - public static final String PENDING_DEPARTURE = "PENDING_DEPARTURE"; - public static final String CANCELLED = "CANCELLED"; - @Resource private DispatchOrderMapper orders; - @Resource private DispatchItemMapper items; - @Resource private DispatchBolMapper bolLines; - @Resource private DispatchPalletLockMapper locks; @Resource private DispatchDraftMapper drafts; @Resource private DispatchCandidateMapper candidates; - @Resource private com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; - - public String resolveCustomerScopeForSources(List planPalletIds, List returnReceiptIds) { - var scopes = new java.util.ArrayList(); - if (planPalletIds != null && !planPalletIds.isEmpty()) scopes.addAll(candidates.findCustomerScopesByPlanPalletIds(planPalletIds)); - if (returnReceiptIds != null && !returnReceiptIds.isEmpty()) scopes.addAll(returnCandidates.customers(returnReceiptIds)); - return requireSingleCustomerScope(scopes.stream().distinct().toList(), "Selected dispatch sources"); - } - @Resource private DispatchPdaTaskMapper pdaTasks; - @Resource private LastlegOrderNoGenerator orderNoGenerator; - @Resource private LastlegOrderMapper tailOrders; - @Resource private OutboundOrderMapper outboundOrders; - @Resource private FleetDispatchBindingGuard fleetBindings; - @Resource private com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; @Resource private RelabelApplicationMapper relabelApplications; @Resource private com.baiyue.lastleg.relabel.service.RelabelBindingGuard relabelBindingGuard; @Resource private ReturnRelabelDispatchStock returnRelabelStock; @Resource private com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements; - + private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() { + return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository(candidates,relabelApplications); + } + private boolean planIdsForReturns(String stage) { return stage == null || stage.isBlank() || "RECEIVED".equals(stage); } private List measuredRelabelCandidates(List rows) { var cache = new java.util.HashMap>(); for (var row : rows) { @@ -277,440 +483,12 @@ public class DispatchService { } return rows; } - - public String resolveCustomerScopeForPlans(List planIds) { - return requireSingleCustomerScope(candidates.findCustomerScopesByPlanIds(normalizeIds(planIds, "Plan IDs")), - "Selected inbound plans"); - } - - public String resolveCustomerScopeForPallets(List planPalletIds) { - return requireSingleCustomerScope( - candidates.findCustomerScopesByPlanPalletIds(normalizeIds(planPalletIds, "Plan pallet IDs")), - "Selected pallets"); - } - - public String resolveCustomerScopeForRelabel(String relabelNo) { - String normalized = trimToNull(relabelNo); - RelabelApplication application = normalized == null ? null : relabelApplications.findAnyByRelabelNo(normalized); - if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "换标单不存在,请检查换标单号"); - if (!RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标尚未完成或客户尚未确认,无法进行排车"); - } - if (!"TRUCK".equals(application.getDeliveryMethod())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "只有平台卡车派送的换标单可以排车"); - } - return application.getCustomerId(); - } - - public List candidates(String customerId, List planIds, String orderNo, - String fbaWarehouseCode, String sourceStage, String draftToken) { - validateSourceStage(sourceStage); - if (!StringUtils.hasText(customerId)) return List.of(); - List scopedPlanIds = normalizeIds(planIds, "Plan IDs"); - return candidates.available(customerId, scopedPlanIds, trimToNull(orderNo), trimToNull(fbaWarehouseCode), - trimToNull(sourceStage), trimToNull(draftToken)); - } - public List candidates(String customerId, List planIds, String orderNo, - String fbaWarehouseCode, String sourceStage) { - return candidates(customerId, planIds, orderNo, fbaWarehouseCode, sourceStage, null); - } - - public List platformCandidates(String orderNo, String fbaWarehouseCode, - String sourceStage, String draftToken) { - validateSourceStage(sourceStage); - List result = "RELABEL".equals(sourceStage) ? List.of() : candidates.available(null, null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), - trimToNull(sourceStage), trimToNull(draftToken)); - if (!StringUtils.hasText(sourceStage) || Set.of("RECEIVED", "RELABEL").contains(sourceStage)) { - List relabel = java.util.stream.Stream.concat( - measuredRelabelCandidates(candidates.relabelAvailable(null, trimToNull(orderNo), null)).stream(), - returnRelabelStock.candidates(null, trimToNull(orderNo), null).stream()).toList(); - if (relabel != null && !relabel.isEmpty()) { - result = java.util.stream.Stream.concat(result.stream(), relabel.stream().filter(item -> - !StringUtils.hasText(fbaWarehouseCode) || Objects.toString(item.fbaWarehouseCode(), "") - .toUpperCase(java.util.Locale.ROOT).contains(fbaWarehouseCode.trim().toUpperCase(java.util.Locale.ROOT)))).toList(); - } - } - if (planIdsForReturns(sourceStage)) result = java.util.stream.Stream.concat(result.stream(), - returnCandidates.available(null, trimToNull(orderNo), trimToNull(fbaWarehouseCode), trimToNull(draftToken)).stream()).toList(); - return result; - } - private boolean planIdsForReturns(String sourceStage) { return sourceStage == null || sourceStage.isBlank() || "RECEIVED".equals(sourceStage); } - public List platformCandidates(String orderNo, String fbaWarehouseCode, String sourceStage) { - return platformCandidates(orderNo, fbaWarehouseCode, sourceStage, null); - } - - - - - - - - private void assertNoDetainedOrders(String customerId, List selected) { - List orderIds = selected.stream().map(DispatchPalletCandidate::orderId) - .filter(Objects::nonNull).distinct().sorted().toList(); - if (orderIds.isEmpty()) return; - List scoped = tailOrders.selectList(new LambdaQueryWrapper() - .eq(LastlegOrder::getCustomerId, customerId) - .in(LastlegOrder::getId, orderIds) - .orderByAsc(LastlegOrder::getId) - .last("FOR UPDATE")); - List detainedNos = scoped.stream().filter(order -> Boolean.TRUE.equals(order.getDetained())) - .map(LastlegOrder::getOrderNo).filter(StringUtils::hasText).distinct().toList(); - if (!detainedNos.isEmpty()) { - throw new ResponseStatusException(HttpStatus.CONFLICT, - "暂扣订单不可创建排车或出库计划:" + String.join("、", detainedNos)); - } - } - - public PageResult page(String customerId, DispatchRequests.Query query) { - if (!StringUtils.hasText(customerId)) { - return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); - } - return page(customerId, query, false); - } - - public PageResult platformPage(DispatchRequests.Query query) { - return page(null, query, true); - } - - private PageResult page(String customerId, DispatchRequests.Query query, - boolean acrossCustomers) { - Page page = orders.selectPage(new Page<>(query.page(), query.size()), - new LambdaQueryWrapper() - .eq(!acrossCustomers, DispatchOrder::getCustomerId, customerId) - .like(StringUtils.hasText(query.dispatchNo()), DispatchOrder::getDispatchNo, query.dispatchNo()) - .like(StringUtils.hasText(query.vehiclePlate()), DispatchOrder::getVehiclePlate, query.vehiclePlate()) - .eq(StringUtils.hasText(query.status()), DispatchOrder::getStatus, query.status()) - .in(query.stage() != null, DispatchOrder::getStatus, - query.stage() == null ? List.of() : query.stage().statuses()) - .like(StringUtils.hasText(query.fbaAppointmentNo()), DispatchOrder::getFbaAppointmentNo, - query.fbaAppointmentNo()) - .orderByDesc(DispatchOrder::getCreatedAt)); - var workflows = acrossCustomers ? fleetWorkflow.read(page.getRecords().stream() - .map(DispatchOrder::getDispatchNo).toList()) : java.util.Map.of(); - var appointmentReferences=appointmentAllocations.references(page.getRecords().stream().map(DispatchOrder::getId).toList()); - List records = page.getRecords().stream() - .map(order -> response(order, items.findByDispatchOrder(order.getCustomerId(), order.getId()), - List.of(), workflows.get(order.getDispatchNo()),appointmentReferences.getOrDefault(order.getId(),List.of()))).toList(); - return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); - } - - public DispatchResponse detail(String customerId, String dispatchNo) { - DispatchOrder dispatch = find(customerId, dispatchNo); - return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()), - bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); - } - - public DispatchResponse platformDetail(String dispatchNo) { - DispatchOrder dispatch = orders.findGlobalByDispatchNo(trimToNull(dispatchNo)); - if (dispatch == null) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - } - return response(dispatch, items.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId()), - bolLines.findByDispatchOrder(dispatch.getCustomerId(), dispatch.getId())); - } - - /** 仅编辑未执行且无有效出库或车次占用的排车。 */ - @Transactional - public DispatchResponse update(String dispatchNo, DispatchRequests.Update request) { - DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - requirePalletEditingAllowed(dispatch); - String customerId = dispatch.getCustomerId(); - List assignedItems = mutations().items(dispatch); - dispatch.setVehiclePlate(trimToNull(request.vehiclePlate())); - dispatch.setDriverName(trimToNull(request.driverName())); - dispatch.setDriverPhone(trimToNull(request.driverPhone())); - dispatch.setCarrierName(trimToNull(request.carrierName())); - dispatch.setDepartAt(request.departAt()); - dispatch.setRoute(itemRoute(assignedItems)); - dispatch.setFbaAppointmentNo(trimToNull(request.fbaAppointmentNo())); - dispatch.setFbaAppointmentStatus(defaultStatus(request.fbaAppointmentStatus())); - dispatch.setVehicleAppointmentStatus(defaultStatus(request.vehicleAppointmentStatus())); - dispatch.setEstimatedArrivalAt(request.estimatedArrivalAt()); - dispatch.setUpdatedAt(LocalDateTime.now()); - mutations().saveOrder(dispatch); - return response(dispatch, assignedItems); - } - - /** 移除普通来源托盘,整单换标只允许整单取消重排。 */ - @Transactional - public DispatchResponse removeItem(String dispatchNo, Long itemId) { - DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - requirePalletEditingAllowed(dispatch); - DispatchItem item = mutations().lockItem(dispatch, itemId); - if (item == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch pallet not found"); - if ("RELABEL_ORDER".equals(item.getSourceBusinessType())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "换标按整单排车,请取消整张排车单后重新排车"); - - List activeLocks = mutations().activeLocks(dispatch); - List matchingLocks = activeLocks.stream().filter(lock -> Objects.equals(lock.getPlanPalletId(), item.getPlanPalletId()) && Objects.equals(lock.getReturnReceiptId(), item.getReturnReceiptId()) - || (item.getWarehousePalletId() != null && Objects.equals(lock.getWarehousePalletId(), item.getWarehousePalletId()))) - .toList(); - return removeLockedItem(dispatch, item, matchingLocks); - } - - /** 同事务释放当前托盘锁、预约分配并重算剩余排车货量。 */ - private DispatchResponse removeLockedItem(DispatchOrder dispatch, DispatchItem item, - List matchingLocks) { - LocalDateTime now = LocalDateTime.now(); - matchingLocks.forEach(lock -> releaseLock(lock, now)); - mutations().removeItem(item.getId()); - appointmentAllocations.removeItem(dispatch.getId(),item.getId()); - - List remaining = mutations().items(dispatch); - dispatch.setItemCount(remaining.size()); - dispatch.setPalletCount(remaining.size()); - dispatch.setPackageCount(remaining.stream().map(DispatchItem::getPackageCount).filter(Objects::nonNull) - .mapToInt(Integer::intValue).sum()); - dispatch.setRoute(itemRoute(remaining)); - dispatch.setUpdatedAt(now); - mutations().saveOrder(dispatch); - return response(dispatch, remaining); - } - - /** 取消历史不占活动出库,已拣货或车次绑定仍禁止修改。 */ - private void requirePalletEditingAllowed(DispatchOrder dispatch) { - fleetBindings.requireUnbound(dispatch.getId()); - if (!PENDING_DEPARTURE.equals(dispatch.getStatus()) - || Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0 - || Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Only an unpicked pending dispatch can edit pallets"); - } - var outbound = mutations().outbound(dispatch, false); - if (outbound != null && !CANCELLED.equals(outbound.getStatus())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,不能再修改排车托盘"); - } - } - - /** 只取消原未执行排车,取消出库历史保留且重复取消无副作用。 */ - @Transactional - public void cancel(String dispatchNo) { - DispatchOrder dispatch = mutations().lockOrder(trimToNull(dispatchNo)); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - fleetBindings.requireUnbound(dispatch.getId()); - if (CANCELLED.equals(dispatch.getStatus())) return; - if (!(PENDING_DEPARTURE.equals(dispatch.getStatus()))) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Only a pending dispatch can be cancelled"); - } - String customerId = dispatch.getCustomerId(); - if (Objects.requireNonNullElse(dispatch.getPickedPackageCount(), 0) > 0 - || Objects.requireNonNullElse(dispatch.getShippedPackageCount(), 0) > 0) - throw new ResponseStatusException(HttpStatus.CONFLICT, "排车已开始拣货,不能取消并释放已出库箱"); - var outbound = mutations().outbound(dispatch, true); - if (outbound != null && !CANCELLED.equals(outbound.getStatus())) - throw new ResponseStatusException(HttpStatus.CONFLICT, "已生成出库单,请先取消对应出库单再取消排车"); - LocalDateTime now = LocalDateTime.now(); - dispatch.setStatus(CANCELLED); - workChanges.requirementsChanged(); - dispatch.setUpdatedAt(now); - mutations().saveOrder(dispatch); - for (DispatchItem item : mutations().items(dispatch)) { - // 预约分配的有效占用按排车 CANCELLED 状态排除,原分配保留作历史证据。 - if ("RETURN_RELABEL".equals(item.getSourceStage())) returnRelabelStock.release(item); - } - mutations().items(dispatch).stream() - .filter(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) - .map(DispatchItem::getSourceBusinessNo).filter(StringUtils::hasText).distinct() - .forEach(relabelNo -> mutations().restoreRelabel(customerId, relabelNo)); - List activeLocks = mutations().activeLocks(dispatch); - for (DispatchPalletLock lock : activeLocks) { - lock.setStatus("RELEASED"); - lock.setActiveKey(null); - lock.setReleasedAt(now); - lock.setUpdatedAt(now); - mutations().saveLock(lock); - } - } - - private DispatchOrder find(String customerId, String dispatchNo) { - if (!StringUtils.hasText(customerId)) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - } - DispatchOrder dispatch = orders.findByDispatchNo(customerId, dispatchNo); - if (dispatch == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Dispatch order not found"); - return dispatch; - } - - private DispatchResponse response(DispatchOrder order, List itemList) { - return response(order, itemList, List.of()); - } - - private DispatchResponse response(DispatchOrder order, List itemList, - List bolLineList) { - return response(order, itemList, bolLineList, null); - } - - private DispatchResponse response(DispatchOrder order, List itemList, - List bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { - return response(order,itemList,bolLineList,workflow,appointmentAllocations.references(List.of(order.getId())).getOrDefault(order.getId(),List.of())); - } - private DispatchResponse response(DispatchOrder order, List itemList, - List bolLineList, com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow, - List appointmentBatches) { - String orderNos = itemList.stream().flatMap(item -> splitOrderRefs(item.getOrderRefs(), item.getOrderNo()).stream()) - .distinct().collect(Collectors.joining(",")); - return new DispatchResponse(order.getId(), order.getDispatchNo(), order.getVehiclePlate(), order.getDriverName(), - order.getDriverPhone(), order.getCarrierName(), orderNos, itemRoute(itemList), - order.getOverseasWarehouseId(), order.getOverseasWarehouseName(), order.getDepartAt(), - order.getFbaAppointmentNo(), order.getFbaAppointmentStatus(), order.getVehicleAppointmentStatus(), - order.getEstimatedArrivalAt(), order.getStatus(), Objects.requireNonNullElse(order.getItemCount(), 0), - Objects.requireNonNullElse(order.getPalletCount(), 0), Objects.requireNonNullElse(order.getPackageCount(), 0), - Objects.requireNonNullElse(order.getPickedPackageCount(), 0), - Objects.requireNonNullElse(order.getShippedPackageCount(), 0), - Math.max(0, Objects.requireNonNullElse(order.getPackageCount(), 0) - - Objects.requireNonNullElse(order.getShippedPackageCount(), 0)), order.getPartialCompletionReason(), - order.getCreatedAt(), itemList.stream().map(item -> new DispatchResponse.Item(item.getId(), item.getPlanPalletId(), - item.getWarehousePalletId(), item.getPalletNo(), item.getOverseasWarehouseId(), item.getOverseasWarehouseName(), - item.getOrderNo(), item.getOrderRefs(), item.getInboundCode(), - item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), item.getFbaWarehouseCode(), - Objects.requireNonNullElse(item.getPackageCount(), 0), item.getGrossWeightKg(), - item.getVolumeWeightKg(), item.getVolumeCbm(), item.getSourceStage())).toList(), - bolLineList == null ? List.of() : bolLineList, workflow,appointmentBatches, actions(order, itemList, workflow)); - } - - /** 返回与写命令相同的操作资格;权限仍由入口独立校验。 */ - private DispatchResponse.Actions actions(DispatchOrder order, List itemList, - com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery.Workflow workflow) { - if (workflow == null) workflow = fleetWorkflow.read(List.of(order.getDispatchNo())).get(order.getDispatchNo()); - var outbound = mutations().outbound(order, false); - boolean activeOutbound = outbound != null && !CANCELLED.equals(outbound.getStatus()); - String pendingReason = !PENDING_DEPARTURE.equals(order.getStatus()) ? "仅未执行的待发车排车可操作" - : Objects.requireNonNullElse(order.getPickedPackageCount(), 0) > 0 - || Objects.requireNonNullElse(order.getShippedPackageCount(), 0) > 0 ? "排车已开始拣货或发车" : null; - String mutationReason = workflow != null ? "请先在车次 " + workflow.tripNo() + " 中解除关联" : pendingReason; - String editReason = mutationReason != null ? mutationReason : activeOutbound ? "请先取消对应出库单" : null; - String removeReason = editReason != null ? editReason : itemList.stream() - .anyMatch(item -> "RELABEL_ORDER".equals(item.getSourceBusinessType())) ? "换标按整单排车,请取消整张排车后重排" : null; - String transferReason = outbound != null ? (CANCELLED.equals(outbound.getStatus()) - ? "原出库已取消,请取消原排车后通过新预排重新安排" : "已转派送,请查看原出库及派送单") - : !Set.of("PENDING_DEPARTURE", "PICKING", "READY_TO_DEPART").contains(Objects.toString(order.getStatus(), "")) - ? "当前状态不能转派送" : itemList.isEmpty() || itemList.stream().anyMatch(item -> item.getWarehousePalletId() == null) - ? "请先完成收货并核对实物托盘" : null; - return new DispatchResponse.Actions(editReason == null, editReason, editReason == null, editReason, - removeReason == null, removeReason, transferReason == null, transferReason, - outbound == null ? null : outbound.getOutboundNo(), outbound == null ? null : outbound.getStatus()); - } - - /** 为本次变更路径集中组装数据访问,保持既有 Mapper 注入与事务连接。 */ - private com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository mutations() { - return new com.baiyue.lastleg.dispatch.repository.DispatchMutationRepository( - orders, items, locks, outboundOrders, candidates, relabelApplications, pdaTasks); - } - - private static String candidateRoute(List selected) { - return selected.stream().map(DispatchService::candidateFbaCode).filter(StringUtils::hasText) - .distinct().collect(Collectors.joining(",")); - } - - private static String itemRoute(List assignedItems) { - return assignedItems.stream().map(DispatchService::itemFbaCode).filter(StringUtils::hasText) - .distinct().collect(Collectors.joining(",")); - } - - private static String candidateFbaCode(DispatchPalletCandidate candidate) { - if ("PRIVATE".equals(candidate.destinationType())) { - return java.util.stream.Stream.of(candidate.deliveryAddress(), candidate.postalCode()) - .filter(StringUtils::hasText).map(String::trim).collect(Collectors.joining(" ")); - } - return StringUtils.hasText(candidate.fbaWarehouseCode()) - ? candidate.fbaWarehouseCode().trim() : trimToNull(candidate.destinationWarehouseCode()); - } - - private static boolean hasValidDestination(DispatchPalletCandidate candidate) { - if ("PRIVATE".equals(candidate.destinationType())) { - return StringUtils.hasText(candidate.deliveryAddress()) && StringUtils.hasText(candidate.postalCode()) - && StringUtils.hasText(candidate.contactName()) && StringUtils.hasText(candidate.contactPhone()); - } - return StringUtils.hasText(candidate.fbaWarehouseCode()) - || StringUtils.hasText(candidate.destinationWarehouseCode()); - } - - private static String itemFbaCode(DispatchItem item) { - return StringUtils.hasText(item.getFbaWarehouseCode()) - ? item.getFbaWarehouseCode().trim() : trimToNull(item.getDestinationWarehouseCode()); - } - - private DispatchPalletCandidate requireDraftCandidate(String customerId, Long planPalletId) { - List found = candidates.lockSelected(customerId, List.of(planPalletId)); - if (found.size() != 1) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Pallet is not available for dispatch"); - } - return found.get(0); - } - private static String selectionKey(DispatchPalletCandidate value) { return value.returnReceiptId() == null ? "PLAN:" + value.planPalletId() : "RETURN:" + value.returnReceiptId(); } - private static String selectionKey(DispatchItem value) { return value.getReturnReceiptId() == null ? "PLAN:" + value.getPlanPalletId() : "RETURN:" + value.getReturnReceiptId(); } - private DispatchPalletCandidate requireDraftSource(String customerId, DispatchRequests.DraftPallet request) { - if (!request.isValidSource()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "请选择唯一实际来源"); - if (request.returnReceiptId() == null) return requireDraftCandidate(customerId, request.planPalletId()); - var found = returnCandidates.lockSelected(customerId, List.of(request.returnReceiptId())); - if (found.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "退回实收来源当前不可预排"); - return found.get(0); - } - - private DispatchPalletLock activeLock(String customerId, DispatchPalletCandidate candidate) { - DispatchPalletLock active = locks.lockActive(customerId, candidate.sourcePalletKey()); - if (active == null && candidate.warehousePalletId() != null && candidate.planPalletId() != null) { - // Read legacy plan-level locks created before physical-pallet locking was introduced. - active = locks.lockActive(customerId, "PLAN:" + candidate.planPalletId()); - } - return active; - } - - private void requireDraftOwner(DispatchPalletLock lock, String operatorId, String draftToken) { - if (lock == null || !draftToken.equals(lock.getDraftToken()) - || !Objects.equals(trimToNull(operatorId), lock.getOperatorId())) { - throw new ResponseStatusException(HttpStatus.CONFLICT, "Pallet is locked by another dispatch draft"); - } - } - - private void releaseLock(DispatchPalletLock lock, LocalDateTime now) { - lock.setStatus("RELEASED"); - lock.setActiveKey(null); - lock.setReleasedAt(now); - lock.setUpdatedAt(now); - locks.updateById(lock); - } - - private static List splitOrderRefs(String refs, String fallback) { - String value = StringUtils.hasText(refs) ? refs : fallback; - return StringUtils.hasText(value) ? List.of(value.split(",")) : List.of(); - } - - private static String defaultStatus(String status) { - return StringUtils.hasText(status) ? status.trim().toUpperCase() : "PENDING"; - } - - private static String trimToNull(String value) { - return StringUtils.hasText(value) ? value.trim() : null; - } - - private static String mergeCartonMarks(String regularMarks, String relabelMarks) { - LinkedHashSet values = new LinkedHashSet<>(); - for (String source : List.of(Objects.toString(regularMarks, ""), Objects.toString(relabelMarks, ""))) { - Arrays.stream(source.split(",")).map(String::trim).filter(StringUtils::hasText).forEach(values::add); - } - return values.isEmpty() ? null : String.join(",", values); - } - private static void validateSourceStage(String sourceStage) { if (StringUtils.hasText(sourceStage) && !Set.of("PLAN", "RECEIVED", "RELABEL").contains(sourceStage)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "sourceStage must be PLAN, RECEIVED or RELABEL"); } } - private static List normalizeIds(List ids, String label) { - if (ids == null || ids.isEmpty() || ids.stream().anyMatch(Objects::isNull)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " must be non-empty"); - } - List normalized = ids.stream().distinct().sorted().toList(); - if (normalized.size() > 500) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, label + " cannot exceed 500 items"); - } - return normalized; - } - private static String requireSingleCustomerScope(List customerIds, String source) { if (customerIds == null || customerIds.isEmpty()) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, source + " were not found"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java index fe2092ae3..6100f14f8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetAppointmentImpactMapper.java @@ -9,7 +9,7 @@ public interface FleetAppointmentImpactMapper { @Select(""" """) List trips(@Param("rows") List rows); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java index 7adc9b7b3..5452976d7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQuery.java @@ -14,7 +14,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; -/** Warehouse-scoped projection; exposes only the selected customer dispatch, never a whole-trip snapshot. */ +/** 由PDA绑定仓读取本车交接状态;实际派送归属逐行核验。 */ @Service @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) public class FleetHandoverPdaQuery { @@ -45,6 +45,12 @@ public class FleetHandoverPdaQuery { var evidence = dispatches.transitEvidence(dispatchNo, false); if (!Long.toString(warehouseId).equals(evidence.warehouseId()) || !dispatchNo.equals(evidence.dispatchNo()) || !Objects.equals(binding.getPackageCount(), evidence.shippedPackages())) throw notFound(); + var deliveryNumbers=new java.util.HashSet(); + if(evidence.deliveries()==null || evidence.deliveries().isEmpty() || evidence.deliveries().stream().anyMatch(row -> + row==null || row.cargo()==null || row.cargo().customerId()==null || row.cargo().customerId().isBlank() + || !dispatchNo.equals(row.cargo().dispatchNo()) || row.cargo().deliveryNo()==null + || !deliveryNumbers.add(row.cargo().deliveryNo()) || row.cargo().packages()<=0) + || evidence.deliveries().stream().mapToLong(row->row.cargo().packages()).sum()!=evidence.shippedPackages())throw notFound(); var original = history.selectList(new LambdaQueryWrapper().eq(FleetTripHistory::getTripId, trip.getId()) .eq(FleetTripHistory::getAction, "DEPART")); if (original.size() != 1) throw new ResponseStatusException(HttpStatus.CONFLICT, "缺少唯一原始发车记录"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index 00835edbf..46128f80c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -90,8 +90,17 @@ public class FleetBookingReservations { } } private void touch(FleetBookingEntity row,String operator){row.setRecordVersion(row.getRecordVersion()+1);row.setUpdatedBy(operator);row.setUpdatedAt(java.time.LocalDateTime.now(ZoneOffset.UTC));if(bookings.updateById(row)!=1)throw conflict("外接关联已变化");} + /** 父车次锁下读取有效外接占位支持的卸货目的地,不用展示响应推导。 */ + public Set requiredRouteDestinations(long tripId) { + var keys=new LinkedHashSet(); + for(var row:bookings.list(tripId)) if(unbound(row)||"BOUND".equals(row.getStatus())) { + if(row.getDestinationKey()==null || row.getDestinationKey().isBlank())throw conflict("已确认外接预订缺少真实卸货目的地"); + keys.add(row.getDestinationKey()); + } + return Collections.unmodifiableSet(keys); + } public void requireRoute(FleetTripEntity trip,List stops) { - for(var row:bookings.list(trip.getId())) if((unbound(row)||"BOUND".equals(row.getStatus())) && index(stops,row.getDestinationKey())<1) + for(var key:requiredRouteDestinations(trip.getId())) if(index(stops,key)<1) throw conflict("路线仍须包含已确认外接预订的卸货站"); } private void add(List loads,List stops,FleetBookingEntity row){ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index 748287597..9f8e22d1e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -34,6 +34,11 @@ public class FleetTripExecutionService { private final ObjectMapper json; private final FleetBookingReservations bookingReservations; + /** 排车域先核对冻结司机及附件引用;本域继续校验该司机当前或历史证照归属。 */ + public DriverModels.Download dispatchDriverLicense(long driverId,long fileId) { + return drivers.download(driverId,fileId); + } + public FleetTripExecutionService(FleetTripMapper trips, FleetTripDispatchMapper bindings, FleetResourceMapper resources, FleetDispatchGateway dispatches, FleetTripService history, DriverService drivers, VehicleRepository vehicles, ObjectMapper json, FleetBookingReservations bookingReservations) { @@ -203,69 +208,6 @@ public class FleetTripExecutionService { - @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) - public FleetExecutionModels.Gate gate(String warehouseId, String dispatchNo) { - var binding = bindings.findByDispatchNo(dispatchNo); - if (binding == null) return missingGate(); - return pickingGate(gateForTrip(warehouseId, binding.getTripNo()), binding); - } - - @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) - public Map gates(String warehouseId, List numbers) { - if (numbers.isEmpty()) return Map.of(); - var result = new HashMap(); - numbers.forEach(number -> result.put(number, missingGate())); - var byTrip = new HashMap(); - for (var binding : bindings.findByDispatchNos(numbers)) result.put(binding.getDispatchNo(), pickingGate( - byTrip.computeIfAbsent(binding.getTripNo(), number -> gateForTrip(warehouseId, number)), binding)); - return result; - } - - private FleetExecutionModels.Gate gateForTrip(String warehouseId, String tripNo) { - var trip = requireTrip(tripNo, false); - requireWarehouse(trip, warehouseId); - var manifest = manifests(trip, false); - String reason = blockReason(trip, manifest); - String warning = null; - if (reason == null) reason = loadBlockReason(trip, cargo(manifest)); - if (reason == null) reason = loadingReviewBlockReason(trip); - if (reason == null) for (var member : bindings.listByTrip(trip.getId())) { - reason = appointmentAllocations.departureBlockReason(member.getDispatchNo()); - if (reason != null) break; - } - if (reason == null) { - var driver = drivers.detail(trip.getDriverId(), trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); - if (!driver.qualification().canDispatch()) reason = "司机资格不满足本次任务,请在平台核对"; - else reason = driverBlockReason(trip, driver); - if (reason == null && driver.qualification().expiringSoon()) warning = "司机驾驶证将于 " + driver.qualification().licenseExpiresOn() + " 到期;有效期覆盖本次预计任务,请留意延期风险"; - } - boolean canPick = "PLANNED".equals(trip.getStatus()) && trip.getVehicleId() != null && trip.getDriverId() != null; - return new FleetExecutionModels.Gate(trip.getTripNo(), trip.getRecordVersion(), manifest.size(), reason == null, reason, warning, - canPick, canPick ? null : "当前车次不允许继续拣货"); - } - - private static FleetExecutionModels.Gate missingGate() { - return new FleetExecutionModels.Gate(null, null, 0, false, "请先在平台将排车单关联车次", null, false, "请先关联车次并通过调度审核"); - } - private FleetExecutionModels.Gate pickingGate(FleetExecutionModels.Gate gate, FleetTripDispatch binding) { - if (!gate.canPick()) return gate; - var fact=dispatches.workflows(List.of(binding.getDispatchNo())).get(binding.getDispatchNo()); - if(fact==null)return gate.withPicking(false,"整车审核事实缺失"); - var review = fact.dispatchReview(); - String reason = "APPROVED".equals(review.status()) ? null : "REJECTED".equals(review.status()) - ? "调度审核已打回:" + review.reason() : "请先通过调度审核再拣货"; - return gate.withPicking(reason == null, reason); - } - private String loadingReviewBlockReason(FleetTripEntity trip) { - var members=bindings.listByTrip(trip.getId()); - var facts=dispatches.workflows(members.stream().map(FleetTripDispatch::getDispatchNo).toList()); - return !members.isEmpty() && members.stream().allMatch(b->facts.containsKey(b.getDispatchNo()) && "APPROVED".equals(facts.get(b.getDispatchNo()).loadingReview().status()))?null:"请先通过本车理货审核再发车"; - } - - - - - private List manifests(FleetTripEntity trip, boolean lock) { return bindings.listByTrip(trip.getId()).stream().map(binding -> dispatches.manifest(binding.getDispatchNo(), lock)).toList(); } @@ -275,7 +217,8 @@ public class FleetTripExecutionService { } private String blockReason(FleetTripEntity trip, List manifests) { if (!"PLANNED".equals(trip.getStatus())) return "当前车次不是待发车计划"; - if (trip.getVehicleId() == null || trip.getDriverId() == null) return "请先分配车辆和司机"; + String vehicleReason=currentVehicleBlockReason(trip,false);if(vehicleReason!=null)return vehicleReason; + if(trip.getDriverName()==null || trip.getDriverName().isBlank())return "请先确认本趟司机资料"; if (manifests.isEmpty()) return "车次尚未关联排车单"; if (bookingReservations.hasConfirmed(trip.getId())) return "已确认外接预订尚未关联实际发运货物,请先完成来货与排车关联"; for (var binding : bindings.listByTrip(trip.getId())) { @@ -290,13 +233,6 @@ public class FleetTripExecutionService { } return null; } - private String loadBlockReason(FleetTripEntity trip, List cargo) { - var load = load(trip); - if (load == null) return "请在平台确认本车实际装载"; - if (!load.manifestHash().equals(hash(write(cargo)))) return "装载确认后货物发生变化,请重新确认"; - try { capacity(trip, cargo, load.pallets()); return null; } - catch (ResponseStatusException error) { return error.getReason(); } - } private List capacity(FleetTripEntity trip, List cargo, List pallets) { List stops; @@ -333,19 +269,11 @@ public class FleetTripExecutionService { var claim = resources.find(kind, id); if (claim == null || claim.tripId() != tripId) throw conflict("车次车辆或司机占用已变化"); } - private static String driverBlockReason(FleetTripEntity trip, DriverModels.Response driver) { - if (!"OVERSEAS".equals(driver.details().region())) return "本车次须使用海外司机,请核对当前司机档案"; - if (!Objects.equals(driver.details().name(), trip.getDriverName())) return "司机档案已变化,请核对车次司机快照"; - return null; - } private FleetTripEntity requireTrip(String tripNo, boolean lock) { var trip = lock ? trips.lockByNo(tripNo) : trips.findByNo(tripNo); if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); return trip; } - private static void requireWarehouse(FleetTripEntity trip, String warehouseId) { - if (!trip.getWarehouseId().toString().equals(warehouseId)) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "当前绑定仓无此车次"); - } /** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */ private static void requireVersionAndPlan(FleetTripEntity trip, long version) { if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 0c58654a6..5f9bcb3e6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -349,13 +349,28 @@ public class FleetTripService { var trip = repository.lockTrip(binding.getTripNo()); if (trip == null || !"PLANNED".equals(trip.getStatus())) throw conflict("仅未发车整车可以调整货物"); List existing = read(trip.getStopsJson(),new TypeReference>() { }); - var stops = new ArrayList<>(existing); - if (stops.isEmpty()) stops.add(new FleetTripModels.Stop(trip.getWarehouseName(),null,"ORIGIN:"+trip.getWarehouseId())); - var keys = new HashSet(); stops.forEach(stop -> keys.add(stop.destinationKey())); - for (var stop : destinations) if (keys.add(stop.destinationKey())) stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey())); + String originKey="ORIGIN:"+trip.getWarehouseId(); + var origin=existing.isEmpty()?new FleetTripModels.Stop(trip.getWarehouseName(),null,originKey):existing.get(0); + if(!originKey.equals(origin.destinationKey()))throw conflict("本车缺少与起运仓一致的路线起点"); + var requested=new LinkedHashMap(); + for(var stop:destinations) { + if(stop==null || stop.destinationKey()==null || stop.destinationKey().isBlank() || originKey.equals(stop.destinationKey()))throw conflict("实际货物缺少有效卸货目的地"); + requested.putIfAbsent(stop.destinationKey(),stop); + } + var supported=new HashSet<>(bookingReservations.requiredRouteDestinations(trip.getId())); + supported.addAll(requested.keySet()); + var stops=new ArrayList();stops.add(origin); + var keys=new HashSet();keys.add(originKey); + // 仅删除失去货物和有效预订支持的站;保留其余站的原相对顺序与事实快照。 + for(var stop:existing.subList(Math.min(1,existing.size()),existing.size())) + if(supported.contains(stop.destinationKey()) && keys.add(stop.destinationKey()))stops.add(stop); + for(var stop:requested.values()) if(keys.add(stop.destinationKey()))stops.add(new FleetTripModels.Stop(stop.name(),stop.address(),stop.destinationKey())); + // 不能为缺失的外接站编造名称/地址;异常承诺必须阻断整笔主单事务。 + bookingReservations.requireRoute(trip,stops); + long version=trip.getRecordVersion(); trip.setStopsJson(write(stops)); trip.setPlannedLoadJson(null); trip.setLoadJson(null); trip.setLoadingReviewJson(null); - trip.setRecordVersion(trip.getRecordVersion()+1); trip.setUpdatedAt(now()); - if (repository.updateTrip(trip,new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId())) != 1 + trip.setRecordVersion(Math.addExact(version,1)); trip.setUpdatedAt(now()); + if (repository.updateTrip(trip,new LambdaUpdateWrapper().eq(FleetTripEntity::getId,trip.getId()).eq(FleetTripEntity::getRecordVersion,version)) != 1 || repository.wholeQuantity(dispatchNo,packages) != 1) throw conflict("整车货物快照保存失败"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java index 720cd2b0f..eac3a86e5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentMapper.java @@ -15,7 +15,7 @@ public interface VehicleFulfillmentMapper { + "AND JSON_CONTAINS(ho.snapshot_json,CONCAT('{\"customerId\":',JSON_QUOTE(d.customer_id),',\"deliveryNo\":',JSON_QUOTE(d.delivery_no),'}'),'$.carriedDeliveries')=1)"; String DEPARTED = "(" + ORIGINAL_DEPARTURE + " OR " + HANDOVER + ")"; String DELIVERIES = " FROM ll_delivery_order d JOIN ll_fleet_trip_dispatch b " - + "ON b.dispatch_id=d.dispatch_order_id AND CAST(b.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128)) " + + "ON b.dispatch_id=d.dispatch_order_id AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=b.dispatch_id AND CAST(i.customer_id AS BINARY(128))=CAST(d.customer_id AS BINARY(128))) " + "JOIN ll_fleet_trip t ON t.id=b.trip_id WHERE ((t.status='PLANNED' AND t.vehicle_id=#{vehicleId}) OR " + DEPARTED + ")"; String PREVIOUS_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(p.snapshot_json,'$.trip.vehicleId'))"; String NEXT_VEHICLE = "JSON_UNQUOTE(JSON_EXTRACT(h.snapshot_json,'$.trip.vehicleId'))"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java index 2e24d2207..38357c4bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapper.java @@ -14,7 +14,6 @@ public interface InboundPlanQueryMapper { String APPOINTMENT_STATUS="CASE WHEN "+ACTIVE_APPOINTMENT_COUNT+">1 THEN 'MULTIPLE' ELSE a.appointment_status END"; String SCHEDULED_DISPATCH = "EXISTS (SELECT 1 FROM ll_dispatch_item di" + " JOIN ll_dispatch_order dispatch ON dispatch.id=di.dispatch_order_id" + - " AND dispatch.customer_id=di.customer_id" + " WHERE di.customer_id=p.customer_id AND di.order_id=l.order_id" + " AND dispatch.status<>'CANCELLED')"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java index ffe56f392..02392b8d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/mapper/InventoryCommandMapper.java @@ -65,11 +65,11 @@ public interface InventoryCommandMapper { "AND ((o.outbound_type='DISPATCH_DELIVERY' AND l.business_order_no=o.dispatch_no " + "AND l.outbound_order_no=o.dispatch_no AND EXISTS (" + "SELECT 1 FROM ll_dispatch_pda_task t JOIN ll_dispatch_pda_pick p " + - "ON p.customer_id=t.customer_id AND p.task_id=t.id " + + "ON p.task_id=t.id " + "JOIN ll_inventory_operation op ON op.customer_id=p.customer_id " + "AND op.operation_type='SHIP' AND op.status='COMPLETED' " + "AND op.idempotency_key=CONCAT('DISPATCH:',o.dispatch_no,':',p.dispatch_item_id,':',p.idempotency_key,':SHIP') " + - "WHERE t.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " + + "WHERE p.customer_id=o.customer_id AND t.dispatch_order_id=o.dispatch_order_id " + "AND t.warehouse_id=o.overseas_warehouse_id AND p.inventory_position_id=l.position_id " + "AND p.packages=-l.delta_packages AND l.idempotency_key=CONCAT(op.idempotency_key,':',op.id))) " + "OR (o.outbound_type<>'DISPATCH_DELIVERY' AND l.outbound_order_no=o.outbound_no)) " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/PhysicalPalletSerialMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/PhysicalPalletSerialMapper.java new file mode 100644 index 000000000..99d94c7f0 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/mapper/PhysicalPalletSerialMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.inventory.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 共同串行行只按全局真实实托主键建行和取锁,不承载业务占用状态。 */ +@Mapper +public interface PhysicalPalletSerialMapper { + /** 首次使用建立空实托的串行行,重复使用保留首次创建时间。 */ + @Insert("INSERT INTO ll_physical_pallet_guard(warehouse_pallet_id) VALUES(#{warehousePalletId}) " + + "ON DUPLICATE KEY UPDATE warehouse_pallet_id=VALUES(warehouse_pallet_id)") + int ensure(@Param("warehousePalletId") long warehousePalletId); + + /** 每次都取得实际主键行锁,不以一级或二级缓存替代数据库锁。 */ + @Select("SELECT warehouse_pallet_id FROM ll_physical_pallet_guard " + + "WHERE warehouse_pallet_id=#{warehousePalletId} FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + Long lock(@Param("warehousePalletId") long warehousePalletId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/port/PhysicalPalletSerialPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/port/PhysicalPalletSerialPort.java new file mode 100644 index 000000000..b2fbb2168 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/port/PhysicalPalletSerialPort.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.inventory.port; + +import java.util.List; + +/** 全局真实实托组成变更的共同事务串行点,不承载业务占用或客户归属。 */ +public interface PhysicalPalletSerialPort { + /** 加入调用方原事务,对真实实托去重升序建行并逐主键锁定;空目标不造身份。 */ + void lock(List warehousePalletIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/repository/PhysicalPalletSerialRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/repository/PhysicalPalletSerialRepository.java new file mode 100644 index 000000000..ab4251d85 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/repository/PhysicalPalletSerialRepository.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.inventory.repository; + +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import org.springframework.stereotype.Repository; + +import java.util.List; + +/** 真实实托共同串行行的持久化入口,所有 SQL 由 Mapper 执行。 */ +@Repository +public class PhysicalPalletSerialRepository { + private final PhysicalPalletSerialMapper mapper; + + /** 注入真实实托串行映射器。 */ + public PhysicalPalletSerialRepository(PhysicalPalletSerialMapper mapper) { + this.mapper = mapper; + } + + /** 调用方已完成去重升序;先按该顺序建齐行,再按同序逐主键锁定。 */ + public void lockInOrder(List warehousePalletIds) { + // upsert 本身也会持行锁,因此建行和后续 FOR UPDATE 必须使用同一稳定顺序。 + for (Long id : warehousePalletIds) mapper.ensure(id); + for (Long id : warehousePalletIds) { + if (!id.equals(mapper.lock(id))) throw new IllegalStateException("实托共同串行行不存在"); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialService.java new file mode 100644 index 000000000..ee8261281 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialService.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.inventory.service; + +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.util.List; +import java.util.TreeSet; + +/** 调用方原事务中的实托共同串行点,不生成实托身份或另建业务占用。 */ +@Service +public class PhysicalPalletSerialService implements PhysicalPalletSerialPort { + private final PhysicalPalletSerialRepository repository; + + /** 注入实托串行行的唯一持久化入口。 */ + public PhysicalPalletSerialService(PhysicalPalletSerialRepository repository) { + this.repository = repository; + } + + /** 校验完整目标集合后去重升序锁齐;没有真实实托时不插入任何替代行。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void lock(List warehousePalletIds) { + // 直接调用同样不能绕过事务要求,锁一直持有到外层业务提交或回滚。 + if (!TransactionSynchronizationManager.isActualTransactionActive() + || TransactionSynchronizationManager.isCurrentTransactionReadOnly()) + throw new IllegalStateException("实托共同串行锁必须加入调用方现存写事务"); + if (warehousePalletIds == null || warehousePalletIds.stream().anyMatch(id -> id == null || id <= 0)) + throw new IllegalArgumentException("实托集合只接受合法真实实托ID"); + if (warehousePalletIds.isEmpty()) return; + repository.lockInOrder(List.copyOf(new TreeSet<>(warehousePalletIds))); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 519aee690..7d12730b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -66,7 +66,7 @@ public interface LastlegOrderMapper extends BaseMapper { "OR LOWER(TRIM(overseas_warehouse_name)) LIKE CONCAT('%',LOWER({0}),'%'))"; @Select("") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index d53b0c2fd..72d0d164f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -7,11 +7,105 @@ import com.baiyue.lastleg.palletops.model.PalletInventoryRow; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Options; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; import java.util.List; @Mapper public interface PdaPalletOperationMapper extends BaseMapper { + String MUTATION_IDS = "#{id}"; + String MUTATION_CODES = "#{code}"; + String MUTATION_APPS = "" + + "#{no}" + + "(NULL)"; + + /** 真实订单身份不携带暂扣资格,仓内作业只与运输取得相同的身份锁。 */ + record MutationOrder(String customerId, Long orderId) { } + /** 原生退货只有托盘码时保持ID为空,调用方必须通过权威资源提供者解析。 */ + record MutationPallet(Long palletId, String palletCode) { } + /** 组成重读比较逐位置、箱记录与换标明细,不以客户或首行代表整托。 */ + record MutationFact(String kind, Long id, String customerId, Long applicationId, Long referenceId, + Long palletId, String palletCode, Long quantity, Long version) { } + + /** 从全客户库存与原生退货扩展相关申请,不按排车资格过滤。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List mutationApplications(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds, + @Param("palletCodes") List palletCodes, @Param("applicationNos") List applicationNos); + + /** 原申请全部绑定实托参与共同串行集合;无托退货不伪造资源ID。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List mutationPallets(@Param("warehouseId") String warehouseId, @Param("applicationNos") List applicationNos); + + /** 收齐原入仓、退回实收、计划及原生退货业务订单,不使用MIN或其他客户代表行。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List mutationOrders(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds, + @Param("palletCodes") List palletCodes, @Param("applicationNos") List applicationNos); + + /** 逐申请业务号取锁,完整顺序由调用方在首次锁前确定。 */ + @Select("SELECT id FROM ll_relabel_application WHERE relabel_no=#{number} FOR UPDATE") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List lockMutationApplication(@Param("number") String number); + + /** 只锁真实订单身份,不为仓内作业附加暂扣或运输状态资格。 */ + @Select("SELECT id FROM ll_order WHERE customer_id=#{customerId} AND id=#{orderId} FOR UPDATE") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long lockMutationOrder(@Param("customerId") String customerId, @Param("orderId") long orderId); + + /** 重读全部位置和逐申请绑定,发现同锁集合内的新成员也必须重试。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + List mutationFacts(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds, + @Param("palletCodes") List palletCodes, @Param("applicationNos") List applicationNos); + + /** 共同实托锁持有期间检查正式和未过期草稿,过期判断使用调用方统一UTC业务时钟。 */ + @Select("") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + long mutationTransportClaims(@Param("palletIds") List palletIds, @Param("at") java.time.LocalDateTime at); String CONTENT_SELECT = "SELECT b.customer_id,b.customer_name,b.id balance_id,p.id position_id," + "b.inbound_code,b.sku_code,b.product_name_zh,b.product_name_en,b.destination_warehouse_code," + "p.pallet_id,p.pallet_no,p.batch_no,p.warehouse_location_id,p.warehouse_location_code," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java new file mode 100644 index 000000000..88d5e852c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PdaPalletOperationRepository.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.palletops.repository; + +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationFact; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationOrder; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper.MutationPallet; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.*; + +/** 仓内实托组成及身份锁的数据访问,不承担换标或运输资格判断。 */ +@Repository +public class PdaPalletOperationRepository { + private final PdaPalletOperationMapper mapper; + + /** 仅复用本域Mapper,不依赖排车内部服务或跨域Mapper。 */ + public PdaPalletOperationRepository(PdaPalletOperationMapper mapper) { this.mapper = mapper; } + + /** 未解析的原生退货托盘码由Service交给真实资源提供者解析,不伪造ID。 */ + public record Composition(List applications, List orders, + List pallets, List facts) { } + + /** 从全部客户的源目标实托扩展申请及其其他实托,保留完整原始组成。 */ + public Composition composition(String warehouseId, Map seedPallets, RelabelApplication source) { + var ids = new TreeSet<>(seedPallets.keySet()); + var codes = new TreeSet<>(seedPallets.values()); + var numbers = new TreeSet(); + if (source != null) numbers.add(source.getRelabelNo()); + List applications; + List related; + boolean changed; + do { + applications = mapper.mutationApplications(warehouseId, List.copyOf(ids), List.copyOf(codes), List.copyOf(numbers)); + changed = applications.stream().map(RelabelApplication::getRelabelNo).map(numbers::add).reduce(false, Boolean::logicalOr); + related = mapper.mutationPallets(warehouseId, List.copyOf(numbers)); + for (var pallet : related) { + if (pallet.palletId() != null && pallet.palletId() > 0) changed |= ids.add(pallet.palletId()); + if (pallet.palletCode() != null && !pallet.palletCode().isBlank()) changed |= codes.add(pallet.palletCode()); + } + } while (changed); + var pallets = new ArrayList(); + seedPallets.forEach((id,code) -> pallets.add(new MutationPallet(id,code))); + pallets.addAll(related); + return new Composition(List.copyOf(applications), + List.copyOf(mapper.mutationOrders(warehouseId,List.copyOf(ids),List.copyOf(codes),List.copyOf(numbers))), + List.copyOf(pallets), List.copyOf(mapper.mutationFacts(warehouseId,List.copyOf(ids),List.copyOf(codes),List.copyOf(numbers)))); + } + + /** 申请按调用方已收齐的业务号顺序逐行加锁。 */ + public boolean lockApplication(String number) { return mapper.lockMutationApplication(number).size() == 1; } + + /** 订单只锁身份,不添加暂扣或履约状态筛选。 */ + public boolean lockOrder(MutationOrder order) { return mapper.lockMutationOrder(order.customerId(),order.orderId()) != null; } + + /** 已持有共同串行点后读取正式及当前业务时钟下尚未过期的草稿占用。 */ + public long transportClaims(List pallets, LocalDateTime at) { return mapper.mutationTransportClaims(pallets,at); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index 94419194d..fb8030484 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -12,6 +12,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationResponses; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; import com.baiyue.lastleg.palletops.model.PalletInventoryRow; import com.baiyue.lastleg.relabel.entity.RelabelApplication; import com.baiyue.lastleg.relabel.entity.RelabelItem; @@ -42,6 +44,8 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; +import java.time.Clock; +import java.time.ZoneOffset; import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; @@ -58,6 +62,9 @@ import java.util.stream.Collectors; @Service public class PdaPalletOperationService { @Resource private PdaPalletOperationMapper mapper; + @Resource private PdaPalletOperationRepository mutationRepository; + @Resource private PhysicalPalletSerialPort physicalPalletSerial; + private Clock mutationClock = Clock.systemUTC(); @Resource private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Resource private InventoryPositionMapper positions; @Resource private InventoryBalanceMapper balances; @@ -75,11 +82,15 @@ public class PdaPalletOperationService { private TransactionTemplate transaction; private TransactionTemplate newTransaction; + /** 仓内组成预读和串行点后的复读使用RC,意图记录仍保留独立事务。 */ @Resource void configureTransactions(PlatformTransactionManager manager) { transaction = new TransactionTemplate(manager); + // 共同串行点之后必须看到其他事务的新成员,不能沿用MySQL默认RR的预读快照。 + transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); DefaultTransactionDefinition definition = new DefaultTransactionDefinition(); definition.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + definition.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); newTransaction = new TransactionTemplate(manager, definition); } @@ -218,6 +229,7 @@ public class PdaPalletOperationService { false)); } + /** 换标组托沿真实申请及箱贡献改绑,三个分支均先冻结完整实托组成。 */ public PalletOperationResponses.Operation relabelDisassemble(PdaWarehouseIdentity identity, PalletOperationRequests.RelabelDisassemble request) { PalletOperationResponses.Operation replay = replayIfPresent(identity, "RELABEL_PALLET_DISASSEMBLE", @@ -288,6 +300,8 @@ public class PdaPalletOperationService { } if (source.palletId() == target.palletId()) { return transaction.execute(status -> { + lockMutationComposition(identity, application, pallets); + requireCurrentRelabelItems(application,selected); var lockedApp = relabelApplications.lockAnyByRelabelNo(application.getRelabelNo()); requireRelabelWork(identity, lockedApp); long total = mapper.palletPackages(String.valueOf(identity.overseasWarehouseId()), source.palletId()); @@ -359,6 +373,7 @@ public class PdaPalletOperationService { reason, relabelPlan); } + /** 原生退货及无来源托盘的箱绑定共用冻结门禁,远端失败在原事务持锁期间补偿。 */ private PalletOperationResponses.Operation bindReturnRelabelPallet(PdaWarehouseIdentity identity, RelabelApplication application, List selected, PalletPhysicalGateway.PalletReference source, PalletPhysicalGateway.PalletReference target, @@ -371,7 +386,7 @@ public class PdaPalletOperationService { long quantity = 0; List cartons = new ArrayList<>(); for (RelabelItem item : selected) { - ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); + ReturnInventoryCarton carton = returnCartons.selectById(item.getReturnCartonId()); if (carton == null || !Objects.equals(carton.getCustomerId(), application.getCustomerId()) || (unboundSource ? (org.springframework.util.StringUtils.hasText(carton.getPalletCode()) && !"UNBOUND".equalsIgnoreCase(carton.getPalletCode())) : !Objects.equals(carton.getPalletCode(), source.palletCode())) @@ -388,9 +403,19 @@ public class PdaPalletOperationService { String requestHash = hash(request); PdaPalletOperation operation = begin(identity, "RELABEL_PALLET_DISASSEMBLE", key, requestHash); if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); - var transition = physical.apply(identity.overseasWarehouseId(), target, "OCCUPIED", target.locationId(), key, "退货换标组托"); + PhysicalJournal journal = new PhysicalJournal(); + Boolean[] compensationResult = { null }; try { return transaction.execute(status -> { + Map pallets = new LinkedHashMap<>(); + if (source != null) pallets.put(source.palletCode(), source); + pallets.put(target.palletCode(), target); + lockMutationComposition(identity, application, pallets); + requireCurrentRelabelItems(application,selected); + requireRelabelDestination(identity.overseasWarehouseId(), selected, target); + // 先取得共同串行锁再触碰远端;本地失败补偿也必须在该锁释放前结束。 + try { + applyPhysical(operation,identity.overseasWarehouseId(),target,"OCCUPIED",target.locationId(),key,"退货换标组托",journal); for (int index = 0; index < cartons.size(); index++) { RelabelItem item = selected.get(index); ReturnInventoryCarton carton = returnCartons.lockId(item.getReturnCartonId()); @@ -423,10 +448,16 @@ public class PdaPalletOperationService { operation.setStatus("COMPLETED"); operation.setResultJson(write(result)); operation.setUpdatedAt(result.completedAt()); mapper.updateById(operation); return result; + } catch (RuntimeException failure) { + boolean restored = compensate(identity.overseasWarehouseId(),journal.confirmed); + compensationResult[0] = restored && !journal.awaitingReceipt; + if (journal.awaitingReceipt) failure.addSuppressed(new IllegalStateException("本次实托请求尚未取得有效回执,不能确认已补偿")); + throw failure; + } }); } catch (RuntimeException failure) { - try { physical.compensate(identity.overseasWarehouseId(), transition); } - catch (RuntimeException compensationFailure) { failure.addSuppressed(compensationFailure); } + boolean compensated = compensationResult[0] != null ? compensationResult[0] : journal.attempts.isEmpty(); + fail(operation.getId(),failure,compensated); throw failure; } } @@ -519,6 +550,7 @@ public class PdaPalletOperationService { plan(identity.overseasWarehouseId(), pallets, moves, Set.of(), true)); } + /** 所有托盘变更在库存锁之前冻结源目标完整组成,保留幂等与原事务持锁补偿。 */ private PalletOperationResponses.Operation execute(PdaWarehouseIdentity identity, String type, String key, Object request, String reason, Plan plan) { String normalizedKey = required(key, "幂等键不能为空"); @@ -526,9 +558,18 @@ public class PdaPalletOperationService { PdaPalletOperation operation = begin(identity, type, normalizedKey, requestHash); if ("COMPLETED".equals(operation.getStatus())) return read(operation).asReplay(); - List transitions = new ArrayList<>(); + PhysicalJournal journal = new PhysicalJournal(); + List transitions = journal.confirmed; + Boolean[] compensationResult = { null }; try { PalletOperationResponses.Operation result = transaction.execute(status -> { + lockMutationComposition(identity, plan.relabelMutation() == null ? null : plan.relabelMutation().application(), plan.pallets()); + if (plan.relabelMutation() != null) { + var selected = plan.relabelMutation().itemsByPosition().values().stream().flatMap(List::stream).toList(); + requireCurrentRelabelItems(plan.relabelMutation().application(),selected); + for (var move : plan.moves()) requireRelabelDestination(identity.overseasWarehouseId(), selected, + plan.pallets().get(move.targetPalletCode())); + } if (plan.relabelMutation() != null) lockRelabelMutation(plan.relabelMutation(), identity.overseasWarehouseId()); Long relabelOwner = plan.relabelMutation() == null ? null : inventoryCommands.relabelTransfer( plan.relabelMutation().application().getCustomerId(), plan.relabelMutation().application().getId()); @@ -547,18 +588,27 @@ public class PdaPalletOperationService { requirePalletReservation(anchor.getCustomerId(), anchorId, relabelOwner); } } + try { for (PalletChange change : plan.changes()) { - transitions.add(physical.apply(identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), + applyPhysical(operation,identity.overseasWarehouseId(), change.pallet(), change.targetStatus(), change.targetLocationId(), transitionKey(identity.operatorKey(), normalizedKey, - change.pallet().palletId()), reason)); + change.pallet().palletId()), reason,journal); } - saveTransitions(operation.getId(), transitions); return mutate(identity, operation, plan, normalizedKey, requestHash, reason); + } catch (RuntimeException failure) { + // 普通及换标操作都持有共同串行点,不能先解锁再补偿旧的物理组成。 + boolean compensated = compensate(identity.overseasWarehouseId(), transitions); + compensationResult[0] = compensated && !journal.awaitingReceipt; + if (journal.awaitingReceipt) failure.addSuppressed(new IllegalStateException("本次实托请求尚未取得有效回执,局部补偿不代表整体恢复")); + if (!compensated) failure.addSuppressed(new IllegalStateException("实托流转补偿未完成")); + throw failure; + } }); if (result == null) throw new IllegalStateException("托盘作业事务没有返回结果"); return result; } catch (RuntimeException failure) { - boolean compensated = compensate(identity.overseasWarehouseId(), transitions); + // 未进入物理阶段时没有远端变更;提交阶段异常不能解锁后自动回放旧补偿。 + boolean compensated = compensationResult[0] != null ? compensationResult[0] : journal.attempts.isEmpty(); fail(operation.getId(), failure, compensated); throw failure; } @@ -620,6 +670,97 @@ public class PdaPalletOperationService { return result; } + /** 首次申请锁前收齐闭包;申请、订单、实托共同串行点之后才允许读取库存并改绑。 */ + private void lockMutationComposition(PdaWarehouseIdentity identity, RelabelApplication source, + Map pallets) { + var seeds = new java.util.TreeMap(); + pallets.values().forEach(pallet -> seeds.put(pallet.palletId(),pallet.palletCode())); + var before = readMutationComposition(identity,source,seeds); + String expected = mutationCompositionIdentity(before); + var numbers = new java.util.TreeSet(); + before.composition().applications().forEach(app -> numbers.add(app.getRelabelNo())); + if (source != null && !numbers.contains(source.getRelabelNo())) throw conflict("换标申请的真实组成未完整返回"); + for (String number : numbers) if (!mutationRepository.lockApplication(number)) + throw conflict("换标申请身份已变化,请重新读取完整组成"); + var orders = new java.util.TreeMap(); + for (var order : before.composition().orders()) { + if (order.orderId() == null || order.orderId() <= 0 || order.customerId() == null || order.customerId().isBlank()) + throw conflict("实托来源缺少真实原始订单身份,不能改绑"); + var previous = orders.putIfAbsent(order.orderId(),order); + if (previous != null && !previous.customerId().equals(order.customerId())) + throw conflict("实托来源订单客户归属不一致"); + } + for (var order : orders.values()) if (!mutationRepository.lockOrder(order)) + throw conflict("实托来源订单不存在或归属已变化"); + physicalPalletSerial.lock(List.copyOf(before.pallets().keySet())); + var current = readMutationComposition(identity,source,seeds); + // 共同串行点之后发现新申请、订单、实托或贡献即整笔拒绝,不追加乱序锁。 + if (!expected.equals(mutationCompositionIdentity(current))) throw conflict("实托组成已变化,请重新读取后组托"); + for (var application : current.composition().applications()) requireUnallocatedRelabel(application); + if (source != null) { + var currentSource = current.composition().applications().stream() + .filter(app -> source.getRelabelNo().equals(app.getRelabelNo())).findFirst() + .orElseThrow(() -> conflict("换标申请身份已变化")); + requireRelabelWork(identity,currentSource); + } + var now = LocalDateTime.ofInstant(mutationClock.instant().truncatedTo(java.time.temporal.ChronoUnit.MILLIS),ZoneOffset.UTC); + if (mutationRepository.transportClaims(List.copyOf(current.pallets().keySet()),now) != 0) + throw conflict("实托已进入排车或有效草稿,请先解除运输占用后再组托"); + } + + /** 解析原生退货关联的真实托盘码,再重新展开全客户组成,绝不猜测资源ID。 */ + private MutationComposition readMutationComposition(PdaWarehouseIdentity identity, RelabelApplication source, + Map seeds) { + var resolved = new java.util.TreeMap<>(seeds); + PdaPalletOperationRepository.Composition composition; + boolean changed; + do { + composition = mutationRepository.composition(String.valueOf(identity.overseasWarehouseId()),resolved,source); + changed = false; + for (var pallet : composition.pallets()) { + if (pallet.palletCode() == null || pallet.palletCode().isBlank()) throw conflict("关联实托缺少真实托盘码"); + Long id = pallet.palletId(); + if (id == null) { + var known = resolved.entrySet().stream().filter(entry -> entry.getValue().equals(pallet.palletCode())).findFirst(); + id = known.isPresent() ? known.get().getKey() : requirePallet(identity.overseasWarehouseId(),pallet.palletCode()).palletId(); + } + if (id <= 0) throw conflict("关联实托缺少真实资源身份"); + var previous = resolved.putIfAbsent(id,pallet.palletCode()); + if (previous != null && !previous.equals(pallet.palletCode())) throw conflict("关联实托身份与托盘码不一致"); + changed |= previous == null; + } + } while (changed); + return new MutationComposition(composition,java.util.Collections.unmodifiableSortedMap(resolved)); + } + + /** 比较组成身份与贡献,不把预读对象或展示首行当作已锁定事实。 */ + private String mutationCompositionIdentity(MutationComposition value) { + var applications = value.composition().applications().stream().map(app -> java.util.Arrays.asList( + app.getId(),app.getRelabelNo(),app.getCustomerId(),app.getOverseasWarehouseId(),app.getLastlegOrderId(), + app.getStatus(),app.getFulfillmentStatus(),app.getOutboundOrderNo())).map(Object::toString).sorted().toList(); + return write(Map.of("applications",applications,"orders",value.composition().orders().stream().map(Object::toString).sorted().toList(), + "pallets",value.pallets(),"facts",value.composition().facts().stream().map(Object::toString).sorted().toList())); + } + + /** 本轮完整组成和已解析的全局实托集合。 */ + private record MutationComposition(PdaPalletOperationRepository.Composition composition, java.util.SortedMap pallets) { } + + /** 申请锁内复验请求预读的逐箱指针,不能用改绑前的旧明细覆盖当前贡献。 */ + private void requireCurrentRelabelItems(RelabelApplication application,List selected) { + for (var item : selected) { + var current = item.getId() == null ? null : relabelItems.selectById(item.getId()); + if (current == null || !Objects.equals(current.getApplicationId(),application.getId()) + || !Objects.equals(current.getCustomerId(),application.getCustomerId()) + || !Objects.equals(current.getSourceInventoryPositionId(),item.getSourceInventoryPositionId()) + || !Objects.equals(current.getReturnCartonId(),item.getReturnCartonId()) + || !Objects.equals(current.getCartonMark(),item.getCartonMark()) + || !Objects.equals(current.getNotifiedPackageCount(),item.getNotifiedPackageCount()) + || !Objects.equals(current.getDestinationFbaWarehouseCode(),item.getDestinationFbaWarehouseCode())) + throw conflict("换标箱的真实贡献或绑定已变化,请重新读取"); + } + } + + /** 已收齐全部申请锁后重核本次申请,重复取得同一申请不改变全局锁序。 */ private void lockRelabelMutation(RelabelMutation mutation, long warehouseId) { RelabelApplication locked = relabelApplications.lockAnyByRelabelNo(mutation.application().getRelabelNo()); if (locked != null) requireUnallocatedRelabel(locked); @@ -1016,10 +1157,49 @@ public class PdaPalletOperationService { return read(existing).asReplay(); } - private void saveTransitions(long operationId, List transitions) { + /** 每次远端调用先保存完整意图,只有匹配的APPLIED回执才能清除当前未决事实。 */ + private void applyPhysical(PdaPalletOperation operation,long warehouseId,PalletPhysicalGateway.PalletReference pallet, + String targetStatus,Long targetLocationId,String key,String reason,PhysicalJournal journal) { + var attempt = new PhysicalAttempt(key,warehouseId,pallet.palletId(),pallet.palletCode(),pallet.version(), + pallet.status(),pallet.locationId(),targetStatus,targetLocationId,null); + journal.attempts.add(attempt); + savePhysicalAttempts(operation.getId(),journal.attempts); + // 从进入POST调用到有效回执之间均可能已在远端提交,不能按异常类型或HTTP码推定未写。 + journal.awaitingReceipt = true; + var receipt = physical.apply(warehouseId,pallet,targetStatus,targetLocationId,key,reason); + Long effectiveLocation = targetLocationId == null ? pallet.locationId() : targetLocationId; + if (receipt == null || !key.equals(receipt.operationKey()) || receipt.warehouseId()!=warehouseId + || receipt.palletId()!=pallet.palletId() || !Objects.equals(receipt.palletCode(),pallet.palletCode()) + || !Objects.equals(receipt.beforeStatus(),pallet.status()) || receipt.beforeVersion()!=pallet.version() + || !Objects.equals(receipt.beforeLocationId(),pallet.locationId()) + || !Objects.equals(receipt.afterStatus(),targetStatus) || !Objects.equals(receipt.afterLocationId(),effectiveLocation) + || (long)receipt.afterVersion()!=(long)pallet.version()+1 || !"APPLIED".equals(receipt.operationStatus())) + throw new IllegalStateException("实托流转未返回匹配本次意图的APPLIED回执,请保留原请求核对"); + journal.confirmed.add(receipt); + journal.awaitingReceipt = false; + journal.attempts.set(journal.attempts.size()-1,new PhysicalAttempt(key,warehouseId,pallet.palletId(),pallet.palletCode(),pallet.version(), + pallet.status(),pallet.locationId(),targetStatus,targetLocationId,receipt)); + savePhysicalAttempts(operation.getId(),journal.attempts); + } + + /** 已知回执与尚未确认的原请求分别保留,不用占位Transition冒充成功证据。 */ + private record PhysicalAttempt(String operationKey,long warehouseId,long palletId,String palletCode,int expectedVersion, + String beforeStatus,Long beforeLocationId,String targetStatus,Long targetLocationId, + PalletPhysicalGateway.Transition receipt) { } + + /** 本次作业所有意图和已确认回执;未决当前调用不属于可补偿回执集合。 */ + private static final class PhysicalJournal { + private final List attempts = new ArrayList<>(); + private final List confirmed = new ArrayList<>(); + private boolean awaitingReceipt; + } + + /** 意图证据独立保存,业务事务回滚或进程中断不能抹去已发远端请求。 */ + private void savePhysicalAttempts(long operationId, List attempts) { newTransaction.executeWithoutResult(status -> { PdaPalletOperation operation = mapper.selectById(operationId); - operation.setRemoteTransitionsJson(write(transitions)); operation.setUpdatedAt(LocalDateTime.now()); + if (operation == null) throw new IllegalStateException("实托作业意图记录不存在,不能发出远端请求"); + operation.setRemoteTransitionsJson(write(attempts)); operation.setUpdatedAt(LocalDateTime.now()); mapper.updateById(operation); }); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java new file mode 100644 index 000000000..068903272 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/mapper/RelabelDispatchSourceMapper.java @@ -0,0 +1,69 @@ +package com.baiyue.lastleg.relabel.mapper; + +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +import java.util.List; + +/** 只读取换标及库存事实;不连接排车账本,不取得库存位置锁。 */ +@Mapper +public interface RelabelDispatchSourceMapper { + String PALLETS = "#{id}"; + + /** 找出实托当前所有明细关联的申请,不按状态或页面资格筛选。 */ + @Select("") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List applicationIds(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds); + + /** 读取托盘全部库存位置,保留未被某申请引用的位置。 */ + @Select("") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List palletPositions(@Param("warehouseId") String warehouseId, @Param("palletIds") List palletIds); + + /** 读取原申请身份和履约状态。 */ + @Select("SELECT * FROM ll_relabel_application WHERE id=#{id}") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + RelabelApplication application(@Param("id") Long id); + + /** 读取申请全部明细,防止只选择同申请的一部分实托。 */ + @Select("SELECT * FROM ll_relabel_item WHERE application_id=#{id} ORDER BY id") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List items(@Param("id") Long id); + + /** 读取当前任务及复核轮次,不按任务状态筛选。 */ + @Select("SELECT * FROM ll_relabel_pda_task WHERE application_id=#{id} ORDER BY id") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List tasks(@Param("id") Long id); + + /** 独立读取作业历史,避免与逐箱事实连接后放大贡献。 */ + @Select("SELECT * FROM ll_relabel_pda_history WHERE application_id=#{id} ORDER BY created_at,id") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List histories(@Param("id") Long id); + + /** 独立读取全部逐箱事实,由业务层识别当前轮次与归档记录。 */ + @Select("SELECT * FROM ll_relabel_pda_unit_fact WHERE application_id=#{id} ORDER BY id") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List facts(@Param("id") Long id); + + /** 读取明细当前位置,不在此处取得正式库存锁。 */ + @Select("SELECT * FROM ll_inventory_position WHERE id=#{id}") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + InventoryPosition position(@Param("id") Long id); + + /** 仅按申请业务号取得原申请行锁,顺序由业务层统一排列。 */ + @Select("SELECT id FROM ll_relabel_application WHERE relabel_no=#{relabelNo} ORDER BY id FOR UPDATE") + @Options(useCache = false, flushCache = Options.FlushCachePolicy.TRUE) + List lockApplication(@Param("relabelNo") String relabelNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java new file mode 100644 index 000000000..e3a8ccbc8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/port/RelabelDispatchSourcePort.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.relabel.port; + +import java.time.LocalDateTime; +import java.util.List; + +/** 换标域向排车提供原始组成事实,不读取排车账本或代替排车锁定库存。 */ +public interface RelabelDispatchSourcePort { + /** 当前作业轮次的逐件成功或异常事实;未知件数保持空值。 */ + record UnitFact(Long id, String oldLabel, String newLabel, String result, Integer quantity) { } + + /** 原申请身份、内部作业状态、履约订单及客户确认轮次。 */ + record Application(Long applicationId, String relabelNo, String customerId, + Long fulfillmentOrderId, String status, String fulfillmentStatus, + String deliveryMethod, String roundKey, LocalDateTime updatedAt, + LocalDateTime customerConfirmedAt) { } + + /** 按申请与任务明细聚合的实际绑定贡献,原货源始终取明细冻结字段。 */ + record BoundSource(Long applicationId, String relabelNo, Long itemId, String customerId, + String warehouseId, Long warehousePalletId, Long inventoryPositionId, + Long inventoryBalanceId, Long sourceInventoryBalanceId, + Long sourceInboundOrderId, String sourceInboundOrderNo, + String sourceInboundCode, long notifiedPackageCount, + long boundPackageCount, Long productQuantity, String bindingBasisHash, + List unitFacts) { } + + /** 库存位置的原始箱数和版本,不在提供者内取得库存正式锁。 */ + record Position(Long positionId, String customerId, Long inventoryBalanceId, + Long warehousePalletId, long availablePackages, long holdPackages, + long allocatedPackages, long pickedPackages, long receivingPackages, + long recordVersion) { } + + /** 从实托扩展关联申请及全部绑定明细的组成快照,资格判断仍由消费方负责。 */ + record Snapshot(List applications, List sources, + List positions, String compositionVersion, + boolean complete, List blockReasons) { } + + /** 只读取得完整原始组成,不按当前页面筛选申请或业务状态。 */ + Snapshot snapshot(String warehouseId, List warehousePalletIds); + + /** 在调用方现存事务内按业务号稳定顺序一次锁齐原申请。 */ + void lockApplications(List relabelNos); + + /** 调用方持有申请锁后重新读取组成;库存位置仍由排车域统一锁定。 */ + Snapshot lockedSnapshot(String warehouseId, List warehousePalletIds); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java new file mode 100644 index 000000000..2adcf5885 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.relabel.repository; + +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelDispatchSourceMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import org.springframework.stereotype.Repository; + +import java.util.ArrayList; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** 从请求实托递归扩展申请与全部关联实托,始终通过实际 Mapper 读写。 */ +@Repository +public class RelabelDispatchSourceRepository { + private final RelabelDispatchSourceMapper mapper; + + /** 注入换标事实查询映射器。 */ + public RelabelDispatchSourceRepository(RelabelDispatchSourceMapper mapper) { + this.mapper = mapper; + } + + /** 单个申请独立读取的原始持久化事实,尚未进行业务资格判断。 */ + public record ApplicationRows(RelabelApplication application, List items, + List tasks, List histories, + List facts) { } + + /** 闭包中全部申请与库存位置;缺失数据作为不完整原因返回。 */ + public record Closure(List applications, List positions, + List blockReasons) { } + + /** 扩展到固定点:实托上的全部申请、申请的全部明细、明细所在实托上的其他申请。 */ + public Closure closure(String warehouseId, List requestedPallets) { + Set pending = new TreeSet<>(requestedPallets); + Set visitedPallets = new LinkedHashSet<>(); + Set visitedApplications = new LinkedHashSet<>(); + Map positions = new TreeMap<>(); + List applications = new ArrayList<>(); + List reasons = new ArrayList<>(); + while (!pending.isEmpty()) { + List batch = List.copyOf(pending); + pending.clear(); + visitedPallets.addAll(batch); + mapper.palletPositions(warehouseId, batch).forEach(position -> positions.put(position.getId(), position)); + for (Long applicationId : mapper.applicationIds(warehouseId, batch)) { + if (!visitedApplications.add(applicationId)) continue; + RelabelApplication application = mapper.application(applicationId); + if (application == null) { + reasons.add("换标明细缺少所属申请"); + continue; + } + // 正式取消已释放 HOLD,旧 item 指针仅供追溯;不把它重新解释为当前贡献或沿其他托盘扩组。 + // 请求实托的全部真实位置已在上方保留,残余 HOLD 仍交现有消费方完整核对。 + if ("CANCELLED".equals(application.getStatus())) continue; + List items = mapper.items(applicationId); + applications.add(new ApplicationRows(application, items, mapper.tasks(applicationId), + mapper.histories(applicationId), mapper.facts(applicationId))); + for (RelabelItem item : items) { + if (item.getSourceInventoryPositionId() == null) continue; + InventoryPosition position = positions.get(item.getSourceInventoryPositionId()); + if (position == null) position = mapper.position(item.getSourceInventoryPositionId()); + if (position == null) continue; + positions.put(position.getId(), position); + if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null + && !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId()); + } + } + } + applications.sort(java.util.Comparator.comparing(value -> value.application().getRelabelNo())); + return new Closure(List.copyOf(applications), List.copyOf(positions.values()), List.copyOf(reasons)); + } + + /** 按单一业务号锁定原申请,返回匹配行数以识别缺失或重复身份。 */ + public int lockApplication(String relabelNo) { + return mapper.lockApplication(relabelNo).size(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java new file mode 100644 index 000000000..28a643181 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java @@ -0,0 +1,345 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelUnitFact; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository.ApplicationRows; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.format.DateTimeParseException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HexFormat; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.TreeSet; + +/** 普通换标的原申请与实托组成提供者;业务资格和正式库存锁由排车消费方负责。 */ +@Service +public class RelabelDispatchSourceService implements RelabelDispatchSourcePort { + private final RelabelDispatchSourceRepository repository; + private final ObjectMapper json; + private final Object applicationLocks = new Object(); + + /** 注入持久化事实入口和历史解析器。 */ + public RelabelDispatchSourceService(RelabelDispatchSourceRepository repository, ObjectMapper json) { + this.repository = repository; + this.json = json.copy().findAndRegisterModules(); + } + + /** 读取所有关联申请和库存组成,保留未确认、异常及其他不可用状态。 */ + @Override + @Transactional(readOnly = true) + public Snapshot snapshot(String warehouseId, List warehousePalletIds) { + return readSnapshot(warehouseId, normalizePallets(warehouseId, warehousePalletIds)); + } + + /** 一次锁齐按业务号排序的原申请,禁止在事务中追加另一批锁。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void lockApplications(List relabelNos) { + requireTransaction(); + if (relabelNos == null || relabelNos.stream().anyMatch(no -> no == null || no.isBlank())) + throw new IllegalArgumentException("换标申请业务号不能为空"); + Set ordered = new TreeSet<>(relabelNos); + Object previous = TransactionSynchronizationManager.getResource(applicationLocks); + if (previous != null) { + if (!previous.equals(ordered)) throw new IllegalStateException("本事务已锁定换标申请,不能追加或替换锁集合"); + return; + } + for (String relabelNo : ordered) { + if (repository.lockApplication(relabelNo) != 1) + throw new IllegalStateException("换标申请不存在或业务号不唯一:" + relabelNo); + } + TransactionSynchronizationManager.bindResource(applicationLocks, Set.copyOf(ordered)); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 挂起调用方事务时同步挂起锁凭证,避免新事务借用旧事务的锁。 */ + @Override + public void suspend() { + TransactionSynchronizationManager.unbindResourceIfPossible(applicationLocks); + } + + /** 恢复调用方事务时恢复该事务自己的锁凭证。 */ + @Override + public void resume() { + TransactionSynchronizationManager.bindResource(applicationLocks, Set.copyOf(ordered)); + } + + /** 事务结束后清除锁凭证,防止下一事务复用。 */ + @Override + public void afterCompletion(int status) { + TransactionSynchronizationManager.unbindResourceIfPossible(applicationLocks); + } + }); + } + + /** 重读完整闭包,并拒绝申请锁集合以外的新组成,不抢先锁库存位置。 */ + @Override + @Transactional(propagation = Propagation.MANDATORY) + public Snapshot lockedSnapshot(String warehouseId, List warehousePalletIds) { + requireTransaction(); + Object resource = TransactionSynchronizationManager.getResource(applicationLocks); + if (!(resource instanceof Set locked)) throw new IllegalStateException("须先一次锁齐换标原申请"); + Snapshot current = readSnapshot(warehouseId, normalizePallets(warehouseId, warehousePalletIds)); + if (current.applications().stream().anyMatch(app -> !locked.contains(app.relabelNo()))) + throw new IllegalStateException("换标实托组成已变化,请重新读取并锁定全部申请"); + return current; + } + + /** 从闭包生成按申请、明细聚合的事实,不连接逐箱和历史表放大数量。 */ + private Snapshot readSnapshot(String warehouseId, List palletIds) { + var closure = repository.closure(warehouseId, palletIds); + Set reasons = new LinkedHashSet<>(closure.blockReasons()); + Map positions = new HashMap<>(); + closure.positions().forEach(position -> positions.put(position.getId(), position)); + List applications = new ArrayList<>(); + List sources = new ArrayList<>(); + for (ApplicationRows rows : closure.applications()) { + RelabelApplication app = rows.application(); + PdaRelabelTask task = rows.tasks().size() == 1 ? rows.tasks().get(0) : null; + if (task == null || !Objects.equals(task.getCustomerId(), app.getCustomerId()) + || !Objects.equals(task.getRelabelNo(), app.getRelabelNo()) + || !Objects.equals(Objects.toString(task.getOverseasWarehouseId(), null), app.getOverseasWarehouseId())) + reasons.add(app.getRelabelNo() + ":作业任务缺失或归属不一致"); + if (!Objects.equals(warehouseId, app.getOverseasWarehouseId())) + reasons.add(app.getRelabelNo() + ":申请与实托不属于同一仓库"); + List histories = currentHistories(rows, task, reasons); + LocalDateTime archivedAt = rows.histories().stream().filter(h -> "REWORK_ARCHIVE".equals(h.getAction())) + .filter(h -> task != null && Objects.equals(h.getTaskId(), task.getId()) + && Objects.equals(h.getCustomerId(), app.getCustomerId())) + .map(PdaRelabelHistory::getCreatedAt).filter(Objects::nonNull).max(LocalDateTime::compareTo).orElse(null); + // 客户确认会重写任务 reviewedAt;作业轮次只能取最后返工边界后的生产者历史。 + String round = currentRound(histories, archivedAt, reasons, app.getRelabelNo()); + applications.add(new Application(app.getId(), app.getRelabelNo(), app.getCustomerId(), + app.getLastlegOrderId(), app.getStatus(), app.getFulfillmentStatus(), app.getDeliveryMethod(), + round, app.getUpdatedAt(), app.getCustomerConfirmedAt())); + List facts = rows.facts().stream() + .filter(f -> !"REWORK_ARCHIVED".equals(f.getResult())) + .filter(f -> task != null && Objects.equals(f.getTaskId(), task.getId()) + && Objects.equals(f.getCustomerId(), app.getCustomerId())) + .filter(f -> archivedAt == null || (f.getOccurredAt() != null && f.getOccurredAt().isAfter(archivedAt))) + .toList(); + if (rows.facts().stream().filter(f -> !"REWORK_ARCHIVED".equals(f.getResult())).count() != facts.size()) + reasons.add(app.getRelabelNo() + ":存在归属不一致或旧轮次未归档的作业事实"); + // 独立的申请、明细聚合保留成功与异常事实;不得用成功数冒充绑定数。 + for (RelabelItem item : rows.items()) { + InventoryPosition position = positions.get(item.getSourceInventoryPositionId()); + List itemFacts = facts.stream() + .filter(f -> Objects.equals(f.getItemId(), item.getId())).toList(); + boolean bound = bindingMatches(item, position, histories, round, reasons, app.getRelabelNo()); + long notified = number(item.getNotifiedPackageCount()); + if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null + || !Objects.equals(position.getCustomerId(), app.getCustomerId()) + || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) { + reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致"); + bound = false; + } + if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + reasons.add(app.getRelabelNo() + ":退货原生换标须使用退货组成提供者"); + bound = false; + } + if (notified <= 0) reasons.add(app.getRelabelNo() + ":通知箱数无效"); + if (!bound) reasons.add(app.getRelabelNo() + ":明细缺少当前轮次的有效实托绑定"); + String basis = hash(java.util.Arrays.asList(app, task, item, position, histories, itemFacts)); + sources.add(new BoundSource(app.getId(), app.getRelabelNo(), item.getId(), item.getCustomerId(), + position == null ? warehouseId : position.getOverseasWarehouseId(), + position == null ? null : position.getPalletId(), item.getSourceInventoryPositionId(), + position == null ? null : position.getBalanceId(), item.getSourceInventoryBalanceId(), + item.getSourceInboundOrderId(), item.getSourceInboundOrderNo(), item.getSourceInboundCode(), + notified, bound ? notified : 0, productQuantity(item, itemFacts, histories, round), basis, + itemFacts.stream().map(f -> new UnitFact(f.getId(), f.getOldLabel(), f.getNewLabel(), + f.getResult(), f.getQuantity())).toList())); + } + if (rows.items().isEmpty()) reasons.add(app.getRelabelNo() + ":申请明细为空"); + if (facts.stream().anyMatch(f -> rows.items().stream().noneMatch(i -> Objects.equals(i.getId(), f.getItemId())))) + reasons.add(app.getRelabelNo() + ":作业事实缺少所属明细"); + } + Map contributions = new HashMap<>(); + sources.forEach(source -> { + if (source.inventoryPositionId() != null) + contributions.merge(source.inventoryPositionId(), source.boundPackageCount(), Math::addExact); + }); + // 多申请共用位置必须合计比较 HOLD,不能让每张申请分别通过后重复消费。 + contributions.forEach((id, total) -> { + InventoryPosition position = positions.get(id); + if (position != null && total > number(position.getHoldPackages())) + reasons.add("实托绑定总箱数超过该位置实际 HOLD 箱数"); + }); + List positionFacts = closure.positions().stream().map(p -> new Position(p.getId(), p.getCustomerId(), + p.getBalanceId(), p.getPalletId(), number(p.getAvailablePackages()), number(p.getHoldPackages()), + number(p.getAllocatedPackages()), number(p.getPickedPackages()), number(p.getReceivingPackages()), + number(p.getVersion()))).toList(); + String version = hash(List.of(warehouseId, palletIds, closure, applications, sources, positionFacts)); + return new Snapshot(List.copyOf(applications), List.copyOf(sources), positionFacts, version, + reasons.isEmpty(), List.copyOf(reasons)); + } + + /** 清除返工归档前历史,只保留同客户、同任务的原始当前历史。 */ + private List currentHistories(ApplicationRows rows, PdaRelabelTask task, Set reasons) { + List current = new ArrayList<>(); + for (PdaRelabelHistory history : rows.histories()) { + if (task == null || !Objects.equals(history.getCustomerId(), rows.application().getCustomerId()) + || !Objects.equals(history.getTaskId(), task.getId())) { + reasons.add(rows.application().getRelabelNo() + ":作业历史归属不一致"); + continue; + } + if ("REWORK_ARCHIVE".equals(history.getAction())) current.clear(); + else current.add(history); + } + return List.copyOf(current); + } + + /** 从实际完工、绑定及扫码历史识别轮次;返工前轮次即使出现在边界后也不能复用。 */ + private String currentRound(List histories, LocalDateTime archivedAt, + Set reasons, String relabelNo) { + Set recorded = new LinkedHashSet<>(); + String completed = null; + for (PdaRelabelHistory history : histories) { + if (!Set.of("COMPLETE", "PALLET_BIND", "SKU_SCAN", "MIXED_CARTON", "MIXED_SKU", "RETURN_PACK") + .contains(Objects.toString(history.getAction(), ""))) continue; + JsonNode data = detail(history, reasons, relabelNo); + String round = data.path("COMPLETE".equals(history.getAction()) ? "roundKey" : "round").asText(null); + if (round == null || round.isBlank()) { + reasons.add(relabelNo + ":作业历史缺少轮次"); + continue; + } + if (!afterReworkBoundary(round, archivedAt)) { + reasons.add(relabelNo + ":返工后存在旧轮次作业历史"); + continue; + } + recorded.add(round); + if ("COMPLETE".equals(history.getAction())) completed = round; + } + if (recorded.size() != 1) { + reasons.add(relabelNo + (recorded.isEmpty() ? ":未找到当前作业轮次" : ":当前作业历史轮次不一致")); + return null; + } + return completed == null ? recorded.iterator().next() : completed; + } + + /** 返工轮次由生产者写入复核时间文本,允许数据库毫秒精度下与归档边界同刻。 */ + private boolean afterReworkBoundary(String round, LocalDateTime archivedAt) { + if (archivedAt == null) return true; + try { + return !LocalDateTime.parse(round).isBefore(archivedAt); + } catch (DateTimeParseException invalid) { + return false; + } + } + + /** 专用绑定必须同时命中当前轮次、箱唛与当前位置实托码。 */ + private boolean bindingMatches(RelabelItem item, InventoryPosition position, List histories, + String round, Set reasons, String relabelNo) { + if (round == null) return false; + boolean bound = false; + for (PdaRelabelHistory history : histories) { + if (!"PALLET_BIND".equals(history.getAction())) continue; + JsonNode detail = detail(history, reasons, relabelNo); + if (!Objects.equals(round, detail.path("round").asText(null))) continue; + if (contains(detail.path("cartonMarks"), item.getCartonMark())) + bound = position != null && contains(detail.path("targetPallets"), position.getPalletNo()); + } + return bound; + } + + /** 仅以本轮实际 SKU 扫描确认件数;纯箱标及缺少逐 SKU 事实保持未知。 */ + private Long productQuantity(RelabelItem item, List facts, + List histories, String round) { + if (round == null || !Set.of("SKU_LABEL", "CARTON_AND_SKU").contains(Objects.toString(item.getOperationType(), "")) + || facts.stream().noneMatch(f -> "RELABELLED".equals(f.getResult()))) return null; + try { + JsonNode expected = json.readTree(Objects.toString(item.getSkuRelabelItemsJson(), "[]")); + if (!expected.isArray() || expected.isEmpty()) return null; + Map scanned = new HashMap<>(); + for (PdaRelabelHistory history : histories) { + if (!"SKU_SCAN".equals(history.getAction())) continue; + JsonNode data = json.readTree(history.getDetailJson()); + if (!Objects.equals(round, data.path("round").asText(null)) + || !Objects.equals(item.getId().toString(), data.path("itemId").asText())) continue; + int index = data.path("skuIndex").asInt(-1); + long quantity = data.path("quantity").asLong(-1); + if (index < 0 || index >= expected.size() || quantity <= 0 || scanned.putIfAbsent(index, quantity) != null) + return null; + } + if (scanned.size() != expected.size()) return null; + long total = 0; + for (int index = 0; index < expected.size(); index++) { + if (expected.get(index).path("quantity").asLong(-1) != scanned.get(index)) return null; + total = Math.addExact(total, scanned.get(index)); + } + return total; + } catch (JsonProcessingException | ArithmeticException invalid) { + return null; + } + } + + /** 解析绑定历史;损坏记录显式阻止宣称快照完整。 */ + private JsonNode detail(PdaRelabelHistory history, Set reasons, String relabelNo) { + try { + JsonNode parsed = json.readTree(Objects.toString(history.getDetailJson(), "")); + if (parsed != null && parsed.isObject()) return parsed; + } catch (JsonProcessingException invalid) { + reasons.add(relabelNo + ":实托绑定历史无法读取"); + } + reasons.add(relabelNo + ":实托绑定历史缺少对象结构"); + return json.createObjectNode(); + } + + /** 严格按原业务码判断历史中的箱唛或实托码。 */ + private boolean contains(JsonNode values, String expected) { + if (expected == null || !values.isArray()) return false; + for (JsonNode value : values) if (expected.equals(value.asText())) return true; + return false; + } + + /** 统一组成和绑定摘要,保留原始位置版本、申请更新及返工历史变化。 */ + private String hash(Object value) { + try { + byte[] encoded = json.writeValueAsString(value).getBytes(StandardCharsets.UTF_8); + return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(encoded)); + } catch (JsonProcessingException | NoSuchAlgorithmException failure) { + throw new IllegalStateException("无法生成换标组成版本", failure); + } + } + + /** 实托集合必须明确且仅含有效身份;排序去重使相同选择拥有稳定摘要。 */ + private List normalizePallets(String warehouseId, List ids) { + if (warehouseId == null || warehouseId.isBlank() || ids == null || ids.isEmpty() + || ids.stream().anyMatch(id -> id == null || id <= 0)) + throw new IllegalArgumentException("仓库和实托集合不能为空"); + return List.copyOf(new TreeSet<>(ids)); + } + + /** 空数量只作为零事实输出,不推导商品件数。 */ + private long number(Number value) { + return value == null ? 0 : value.longValue(); + } + + /** 直接调用同样要求调用方事务,避免绕过 Spring 代理后无事务取锁。 */ + private void requireTransaction() { + if (!TransactionSynchronizationManager.isActualTransactionActive() + || !TransactionSynchronizationManager.isSynchronizationActive()) + throw new IllegalStateException("换标申请锁与锁后重读必须加入调用方现存事务"); + if (TransactionSynchronizationManager.isCurrentTransactionReadOnly()) + throw new IllegalStateException("换标申请锁必须加入调用方写事务"); + } +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index cce874ebe..a0ba74737 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1986,7 +1986,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_item ( return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', PRIMARY KEY (id), - UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,plan_pallet_id,warehouse_pallet_id), + UNIQUE KEY uk_ll_dispatch_item_source (dispatch_order_id,source_key), KEY idx_ll_dispatch_item_order (customer_id,order_id), KEY idx_ll_dispatch_item_inbound (customer_id,inbound_code), KEY idx_ll_dispatch_item_pallet (customer_id,plan_pallet_id,warehouse_pallet_id) @@ -1998,12 +1998,70 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='gross_weight_kg'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN gross_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''毛重(千克)'' AFTER package_count, ADD COLUMN volume_weight_kg DECIMAL(18,3) NOT NULL DEFAULT 0 COMMENT ''材积重(千克)'' AFTER gross_weight_kg')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_item' AND column_name='destination_type'), 'SELECT 1', 'ALTER TABLE ll_dispatch_item ADD COLUMN destination_type VARCHAR(32) NULL COMMENT ''目的类型:FBA或PRIVATE'' AFTER fba_warehouse_code, ADD COLUMN delivery_address VARCHAR(1000) NULL COMMENT ''私人仓目的地址快照'' AFTER destination_type, ADD COLUMN postal_code VARCHAR(32) NULL COMMENT ''私人仓邮编快照'' AFTER delivery_address, ADD COLUMN contact_name VARCHAR(128) NULL COMMENT ''私人仓联系人快照'' AFTER postal_code, ADD COLUMN contact_phone VARCHAR(64) NULL COMMENT ''私人仓联系电话快照'' AFTER contact_name')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; +CREATE TABLE IF NOT EXISTS ll_dispatch_item_source ( + id BIGINT NOT NULL COMMENT '逻辑运输来源贡献快照主键', + dispatch_order_id BIGINT NOT NULL COMMENT '所属整车排车主单ID', + dispatch_item_id BIGINT NOT NULL COMMENT '所属客户逻辑排车明细ID', + customer_id VARCHAR(128) NOT NULL COMMENT '本条真实客户ID', + relabel_application_id BIGINT NOT NULL COMMENT '换标申请ID', + relabel_item_id BIGINT NOT NULL COMMENT '换标申请原明细ID', + relabel_round_key VARCHAR(128) NOT NULL COMMENT '冻结的真实换标作业轮次,扫码数量变化不改变轮次', + warehouse_pallet_id BIGINT NOT NULL COMMENT '当前真实物理托盘ID', + inventory_position_id BIGINT NOT NULL COMMENT '绑定后实际库存位置ID', + inventory_balance_id BIGINT NOT NULL COMMENT '绑定后实际库存余额ID', + source_inventory_balance_id BIGINT NULL COMMENT '原货源库存余额快照,未知为空', + source_inbound_order_id BIGINT NULL COMMENT '逐换标明细原货源订单ID,未知为空', + source_inbound_order_no VARCHAR(128) NULL COMMENT '逐换标明细原货源订单业务号快照', + source_inbound_code VARCHAR(128) NULL COMMENT '逐换标明细原入仓业务号快照', + notified_package_count BIGINT NOT NULL COMMENT '本来源通知箱数,不代表全部换标成功', + package_count BIGINT NOT NULL COMMENT '本来源实际绑定的可拣箱数', + product_quantity BIGINT NULL COMMENT '有真实依据的来源产品件数,未知为空', + binding_basis_hash CHAR(64) NOT NULL COMMENT '申请轮次、位置及逐来源贡献的冻结依据摘要', + unit_facts_json JSON NOT NULL COMMENT '逐件新旧箱唛及成功或异常工作事实快照', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_item_source_contribution (dispatch_item_id,relabel_item_id,inventory_position_id), + KEY idx_dispatch_item_source_dispatch (dispatch_order_id,dispatch_item_id), + KEY idx_dispatch_item_source_relabel (customer_id,relabel_application_id,relabel_item_id), + CONSTRAINT chk_dispatch_item_source_packages CHECK(notified_package_count>=0 AND package_count>0), + CONSTRAINT chk_dispatch_item_source_quantity CHECK(product_quantity IS NULL OR product_quantity>=0) +) ENGINE=InnoDB COMMENT='换标逻辑来源实际贡献不可变快照'; + +CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard ( + warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间', + PRIMARY KEY (warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='排车冻结与仓内实托组成修改的共同串行点'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_physical_pallet_lock ( + id BIGINT NOT NULL COMMENT '物理实托占用主键', + warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID', + active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空', + dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥', + draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥', + draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID', + operator_id VARCHAR(128) NULL COMMENT '占用操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态', + expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) NULL COMMENT '释放时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_physical_active_pallet (active_warehouse_pallet_id), + KEY idx_dispatch_physical_order (dispatch_order_id,status), + KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status), + CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)), + CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL)) +) ENGINE=InnoDB COMMENT='同车共享且跨车排他的物理实托占用'; + CREATE TABLE IF NOT EXISTS ll_dispatch_pallet_lock ( id BIGINT NOT NULL COMMENT '主键ID', + physical_lock_id BIGINT NULL COMMENT '实际物理实托占用ID,非物理来源为空', customer_id VARCHAR(128) NOT NULL COMMENT '客户租户ID', source_pallet_key VARCHAR(160) NOT NULL COMMENT '来源托盘业务键', active_key VARCHAR(160) NULL COMMENT '生效中的资源排他键', - active_warehouse_pallet_id BIGINT NULL COMMENT '活动占用的真实仓库托盘,释放后为空,跨客户及来源别名唯一', plan_id BIGINT NULL COMMENT '入仓计划ID', plan_pallet_id BIGINT NULL COMMENT '计划托盘ID', warehouse_pallet_id BIGINT NULL COMMENT '仓库实物托盘ID', @@ -2021,10 +2079,10 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PRIMARY KEY (id), UNIQUE KEY uk_ll_dispatch_active_pallet (customer_id,active_key), - UNIQUE KEY uk_ll_dispatch_active_warehouse_pallet (active_warehouse_pallet_id), + KEY idx_dispatch_logical_physical (physical_lock_id,status), KEY idx_ll_dispatch_pallet_lock_order (customer_id,dispatch_order_id,status), KEY idx_ll_dispatch_pallet_lock_source (customer_id,source_pallet_key,status) - ) ENGINE=InnoDB COMMENT='派车托盘排他锁'; +) ENGINE=InnoDB COMMENT='客户逻辑运输来源占用'; CREATE TABLE IF NOT EXISTS ll_dispatch_draft ( id BIGINT NOT NULL COMMENT '主键ID', draft_token VARCHAR(64) NOT NULL COMMENT '智能排车草稿随机标识', @@ -2137,8 +2195,10 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_pda_pick_order ( product_quantity BIGINT NOT NULL DEFAULT 0 COMMENT '产品数量', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', return_receipt_id BIGINT NULL COMMENT '明确选用的真实退回入仓来源', return_carton_id BIGINT NULL COMMENT '原生退货箱来源ID,与实收来源互斥', + dispatch_item_source_id BIGINT NULL COMMENT '换标实际来源贡献快照ID,与其他三类真实凭据互斥', KEY idx_dispatch_pick_return_receipt(customer_id,return_receipt_id), - CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) = 1), + KEY idx_dispatch_pick_item_source(customer_id,dispatch_item_source_id), + CONSTRAINT chk_dispatch_pick_receipt_source CHECK((receipt_id IS NOT NULL) + (return_receipt_id IS NOT NULL) + (return_carton_id IS NOT NULL) + (dispatch_item_source_id IS NOT NULL) = 1), PRIMARY KEY (id), KEY idx_ll_dispatch_pick_order_pick (customer_id,pick_id), diff --git a/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml index 040a5d241..12ee67e7b 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml @@ -1,6 +1,6 @@ openapi: 3.0.3 info: - title: 共用车次准备与拼车连续处理 + title: 外接登记与整车履约衔接 version: 1.0.0 security: - bearerAuth: [] @@ -63,19 +63,3 @@ paths: components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} - schemas: - Prepare: - type: object - required: [dispatchNos, idempotencyKey] - properties: - tripNo: {type: string, nullable: true, maxLength: 64} - recordVersion: {type: integer, format: int64, nullable: true, minimum: 0} - plan: - allOf: - - {$ref: './fleet-trips-v1.yaml#/components/schemas/Create'} - nullable: true - description: 仅新建提交,实际创建幂等键使用外层idempotencyKey。 - vehicleId: {type: string, nullable: true} - driverId: {type: string, nullable: true} - dispatchNos: {type: array, minItems: 1, maxItems: 100, uniqueItems: true, items: {type: string, maxLength: 64}} - idempotencyKey: {type: string, minLength: 8, maxLength: 128} diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index fc042e85b..e693121eb 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -3,27 +3,16 @@ info: title: 平台运输车次与资源分配 version: 1.0.0 description: >- - 车次是一次实际运输的稳定业务编号,客户排车、库存和派送单继续保留各自客户归属。 - 本文件定义计划、资源分配及客户排车单关联命令;分配或关联不会扣库存或标记发车。 - 创建可暂不指定车与司机,幂等键按创建人隔离;相同键相同内容返回同一车次,不同内容拒绝。 - 正式分配要求在役海外车辆及已确认的托位/载重/容积,以及海外司机;司机按预计任务结束时间重新核验且验证证照文件。 - 发车预览与实际发车重新核对司机当前海外区域和身份;分配后档案变更不能沿用原分配时的资格结果。 - 一个车辆或司机同时只能占用一个未释放车次;当前不提供多个未来时段的并行预约。 - 改派先取得新资源,成功后释放旧资源;整次写入与历史处于同一事务。 - 司机预计结束包含承担的返程/转场;修改预计结束须重新核验司机,所有写命令使用recordVersion。 - 普通计划接口不提供修改发车或签收状态的入口;PDA统一发车,结案检查所有客户派送终态及实发数量。 - 货物结案不释放车辆或司机;结束各资源任务独立提交实际结束时间、地点和原因,车辆另确认可用/维修/停用/已归还状态。 - 结案和结束任务命令携带版本及幂等键;结束司机任务不要求其证照仍有效,也不自动恢复其派车资格。 - 关联要求已分配车和司机,客户排车单同起运仓且未开始拣货;可跨客户关联但不改变客户数据归属。 - 同一排车单唯一关联一个车次;关联、改派、解除及尚未执行的派送单车/司机快照同步使用同一事务。 - 关联后子排车不能独立编辑、取消或单独发车;取消车次前须解除关联,已开工不允许直接解除或变更路线。 - 发车前允许在车次统一换车、换司机或调整时间,保留全部拣货事实与客户归属;已确认装载须能容纳于替换车辆。 - 改派或调整计划使原装载确认失效,发车前须重新确认;不支持用计划接口处理在途换车或撤销已执行货物。 - 关联只建立执行单据关系,不代表实际装载量、容量核验或审核已完成。 - 每张客户排车单调度审核通过后允许新拣货;整车确认实际装载并通过理货审核后允许发车。 - 审核仅通过或打回,通过意见选填、打回原因必填;无需额外提交或不同审核人。 - 换车换司机、修改路线或时间使调度与理货审核待审;仅修改备注保留审核及装载。 - 关联变更、重新确认装载或重新审核客户排车单使理货审核待审;保留全部拣货事实及历史审核快照。 + 排车列表是一趟整车作业的唯一主入口,一张排车单对应唯一车次资源记录;同一起运仓允许多个客户和目的地。 + 整车主单与任务不属于单一客户,货物明细、扫码归属、出库和派送单分别保留真实客户边界。 + 本文件保留资源占用查询、历史详情、计划容量、外接预订、结案及车辆/司机任务结束等附加能力。 + 不提供独立派送车次创建、多排车单手工绑定、旧调度/理货审核或装车写入口;审核和装发使用排车主单接口。 + 已绑定排车主单的资源与路线调整由排车主单执行;零真实扫码时显式退回装车前,修改后重新两级审核。 + 车辆和司机资格、容量与实际占用由服务端复验;候选可用不表示已预留,临时外配证据依排车主单规则核验。 + 调度审核后经理货审核才可真实扫码;PDA拣货核平后,平台和PDA均可确认装车完成及实际发车。 + 发车前整车派送安排须完整,正常签收汇总推进排车派送完成;既有异常业务和附加资源能力保留。 + 货物结案不自动释放车辆或司机;各资源任务分别提交实际结束时间、地点和原因,车辆另确认结束状态。 + 版本、幂等、客户隔离及事务要求以各实际端点及排车公共契约为准;历史数量不授权当前分配。 security: - bearerAuth: [] paths: @@ -350,7 +339,7 @@ components: dispatches: {type: array, items: {$ref: '#/components/schemas/DispatchBinding'}} previousDispatches: type: array - description: 原绑定历史中已不在当前绑定的批次、客户及来源引用,按最新有效证据去重;历史数量不作为当前装载或可分配数量 + description: 原绑定历史中已不在当前绑定的批次及其客户、来源引用,按最新有效证据去重;历史数量不作为当前装载或可分配数量 items: {$ref: '#/components/schemas/PreviousDispatch'} appointmentHistoryUnavailableReason: type: string @@ -656,20 +645,6 @@ components: manifestHash: {type: string, pattern: '^[0-9a-f]{64}$'} pallets: {type: array, minItems: 1, maxItems: 1000, items: {$ref: '#/components/schemas/Measurement'}} reason: {type: string, minLength: 1, maxLength: 1000} - BindingCommand: - type: object - required: [recordVersion, dispatchNo, reason] - properties: - recordVersion: {type: integer, format: int64, minimum: 0} - dispatchNo: {type: string, minLength: 1, maxLength: 64} - reason: {type: string, minLength: 1, maxLength: 1000} - Review: - type: object - required: [recordVersion, decision] - properties: - recordVersion: {type: integer, format: int64, minimum: 0} - decision: {type: string, enum: [APPROVED, REJECTED]} - reason: {type: string, nullable: true, maxLength: 1000, description: 通过选填;打回必填} ReviewState: type: object required: [status] @@ -685,17 +660,15 @@ components: properties: appointmentBatches: {type: array, items: {$ref: 'overseas-business-v1.yaml#/components/schemas/DispatchBatchReference'}} dispatchNo: {type: string} - customerId: {type: string, description: 排车单自身客户归属,由服务端解析} packageCount: {type: integer, description: 关联时待发运箱数,不是实物托位数} boundBy: {type: string} boundAt: {type: string, format: date-time} review: {$ref: '#/components/schemas/ReviewState'} PreviousDispatch: type: object - required: [dispatchNo, customerId, recordVersion, recordedAt, appointmentBatches] + required: [dispatchNo, recordVersion, recordedAt, appointmentBatches] properties: dispatchNo: {type: string} - customerId: {type: string} recordVersion: {type: integer, format: int64, description: 原绑定历史的车次版本} recordedAt: {type: string, format: date-time} appointmentBatches: {type: array, items: {$ref: 'overseas-business-v1.yaml#/components/schemas/DispatchBatchReference'}} @@ -712,16 +685,6 @@ components: name: {type: string, minLength: 1, maxLength: 128} address: {type: string, nullable: true, maxLength: 500} destinationKey: {type: string, nullable: true, maxLength: 160, description: 卸货站匹配Loading货物的destinationKey;未配置时可以保存计划但不能确认装载} - Create: - type: object - required: [warehouseId, plannedDepartureAt, expectedDriverEndAt, stops, idempotencyKey] - properties: - warehouseId: {type: string, pattern: '^[1-9][0-9]*$'} - plannedDepartureAt: {type: string, format: date-time} - expectedDriverEndAt: {type: string, format: date-time} - stops: {type: array, minItems: 2, maxItems: 200, items: {$ref: '#/components/schemas/Stop'}} - remark: {type: string, nullable: true, maxLength: 1000} - idempotencyKey: {type: string, minLength: 8, maxLength: 128} Plan: type: object required: [recordVersion, plannedDepartureAt, expectedDriverEndAt, stops, reason] diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index a1891997a..76d732dd7 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -4038,10 +4038,6 @@ components: type: string format: date-time nullable: true - vehicleAppointment: - allOf: - - $ref: '#/components/schemas/DispatchAppointmentReference' - nullable: true remark: type: string nullable: true @@ -5602,6 +5598,10 @@ components: items: $ref: '#/components/schemas/DispatchBatchAllocation' nullable: true + sourceOrigin: + allOf: + - $ref: '#/components/schemas/DispatchSourceOrigin' + nullable: true required: - dispatchItemId - customerId @@ -5630,6 +5630,7 @@ components: - grossWeightKg - volumeCbm - batchAllocations + - sourceOrigin DispatchCommandResult: type: object x-java-record: DispatchContracts.CommandResult @@ -5797,7 +5798,6 @@ components: sourceType: allOf: - $ref: '#/components/schemas/DispatchSourceType' - nullable: true planPalletId: type: string pattern: ^[0-9]+$ @@ -5815,7 +5815,9 @@ components: nullable: true sourceVersion: type: string - nullable: true + required: + - sourceType + - sourceVersion DispatchCreate: type: object x-java-record: DispatchCommands.Create @@ -5857,22 +5859,6 @@ components: - idempotencyKey - warehouseId description: 同一起运仓,允许空单或多客户、多目的仓。客户和来源归属由服务端解析,临时资源不生成假档案。 - DispatchAppointmentReference: - type: object - x-java-record: DispatchCommands.AppointmentReference - properties: - referenceNo: - type: string - nullable: true - recordVersion: - oneOf: - - type: string - pattern: ^[0-9]+$ - - type: integer - minimum: 0 - description: 已有单写入必填;0合法。响应Long按十进制字符串输出。 - required: - - recordVersion DispatchAddCargo: type: object x-java-record: DispatchCommands.AddCargo @@ -6351,24 +6337,6 @@ components: - status - confirmedAt - blockReason - DispatchVehicleAppointment: - type: object - x-java-record: DispatchViews.VehicleAppointment - properties: - referenceNo: - type: string - nullable: true - status: - type: string - nullable: true - appointmentAt: - type: string - format: date-time - nullable: true - required: - - referenceNo - - status - - appointmentAt DispatchDispatchInfo: type: object x-java-record: DispatchViews.DispatchInfo @@ -6393,10 +6361,6 @@ components: items: $ref: '#/components/schemas/DispatchFbaAppointment' nullable: true - vehicleAppointment: - allOf: - - $ref: '#/components/schemas/DispatchVehicleAppointment' - nullable: true appointmentBatches: type: array items: @@ -6408,7 +6372,6 @@ components: - departedAt - estimatedArrivalAt - fbaAppointments - - vehicleAppointment - appointmentBatches DispatchDocuments: type: object @@ -6479,6 +6442,12 @@ components: sourceKey: type: string nullable: true + sourceBusinessType: + type: string + nullable: true + sourceBusinessNo: + type: string + nullable: true required: - customerId - customerName @@ -6490,6 +6459,8 @@ components: - volumeCbm - containerNo - sourceKey + - sourceBusinessType + - sourceBusinessNo DispatchDetail: type: object x-java-record: DispatchViews.Detail @@ -6660,6 +6631,14 @@ components: items: $ref: '#/components/schemas/DispatchReturnCarton' nullable: true + sourceKey: + type: string + sourceBusinessType: + type: string + nullable: true + sourceBusinessNo: + type: string + nullable: true required: - dispatchItemId - customerId @@ -6670,6 +6649,9 @@ components: - pickedPackages - positions - returnCartons + - sourceKey + - sourceBusinessType + - sourceBusinessNo DispatchPosition: type: object x-java-record: DispatchViews.Position @@ -6970,6 +6952,11 @@ components: items: type: string nullable: true + selectionGroup: + allOf: + - $ref: '#/components/schemas/DispatchSelectionGroup' + required: + - selectionGroup DispatchCandidateList: type: object x-java-record: DispatchPlanningModels.CandidateList @@ -7860,5 +7847,132 @@ components: sizeBytes: type: string pattern: ^[0-9]+$ + DispatchSelectionGroup: + type: object + x-java-record: DispatchPlanningModels.SelectionGroup + properties: + groupKey: + type: string + groupVersion: + type: string + sources: + type: array + items: + $ref: '#/components/schemas/DispatchSelectionGroupSource' + pallets: + type: array + items: + $ref: '#/components/schemas/DispatchSelectionGroupPallet' + complete: + type: boolean + blockReasons: + type: array + items: + type: string + required: + - groupKey + - groupVersion + - sources + - pallets + - complete + - blockReasons + description: 服务端按完整实托及整换标申请计算未被页面筛选截断的来源集合;版本绑定组成、完工轮次、实际位置和各来源版本。complete=false或blockReasons非空禁止选入。 + DispatchSelectionGroupSource: + type: object + x-java-record: DispatchPlanningModels.SelectionGroupSource + properties: + selection: + allOf: + - $ref: '#/components/schemas/DispatchCargoSelection' + sourceKey: + type: string + customerId: + type: string + customerName: + type: string + nullable: true + sourceBusinessType: + type: string + nullable: true + sourceBusinessNo: + type: string + nullable: true + packages: + type: string + pattern: ^[0-9]+$ + required: + - selection + - sourceKey + - customerId + - customerName + - sourceBusinessType + - sourceBusinessNo + - packages + description: 客户和换标业务号按逻辑来源保留;selection携带本来源权威版本,正式建单和加货显式提交完整组,不从展示sourceKey推业务号。 + DispatchSelectionGroupPallet: + type: object + x-java-record: DispatchPlanningModels.SelectionGroupPallet + properties: + warehousePalletId: + type: string + pattern: ^[0-9]+$ + palletNo: + type: string + sourceKeys: + type: array + items: + type: string + packageCount: + type: string + pattern: ^[0-9]+$ + required: + - warehousePalletId + - palletNo + - sourceKeys + - packageCount + description: 每实托只列一次,sourceKeys列出全部逻辑贡献;实际装载按实托测量,不能把整托容量重复计到各来源。 + DispatchSourceOrigin: + type: object + x-java-record: DispatchContracts.SourceOrigin + properties: + dispatchItemSourceId: + type: string + pattern: ^[0-9]+$ + nullable: true + relabelApplicationId: + type: string + pattern: ^[0-9]+$ + nullable: true + relabelItemId: + type: string + pattern: ^[0-9]+$ + nullable: true + sourceInventoryBalanceId: + type: string + pattern: ^[0-9]+$ + nullable: true + sourceInboundOrderId: + type: string + pattern: ^[0-9]+$ + nullable: true + sourceInboundOrderNo: + type: string + nullable: true + sourceInboundCode: + type: string + nullable: true + productQuantity: + type: string + pattern: ^[0-9]+$ + nullable: true + required: + - dispatchItemSourceId + - relabelApplicationId + - relabelItemId + - sourceInventoryBalanceId + - sourceInboundOrderId + - sourceInboundOrderNo + - sourceInboundCode + - productQuantity securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java index ad8e23f83..840e4c869 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactRelationTest.java @@ -17,17 +17,17 @@ class AppointmentImpactRelationTest { s.execute("CREATE TABLE ll_inbound_fba_appointment_item(order_id BIGINT,customer_id VARCHAR,active INT,appointment_id BIGINT)"); s.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR,appointment_status VARCHAR,fba_warehouse_code VARCHAR,appointment_at TIMESTAMP,timezone VARCHAR)"); s.execute("CREATE TABLE ll_dispatch_item(order_id BIGINT,customer_id VARCHAR,dispatch_order_id BIGINT)"); - s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR,dispatch_no VARCHAR,status VARCHAR)"); - s.execute("CREATE TABLE ll_fleet_trip_dispatch(customer_id VARCHAR,dispatch_no VARCHAR,trip_id BIGINT)"); + s.execute("CREATE TABLE ll_dispatch_order(id BIGINT,dispatch_no VARCHAR,status VARCHAR,stage VARCHAR)"); + s.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT UNIQUE,dispatch_no VARCHAR,trip_id BIGINT UNIQUE)"); s.execute("CREATE TABLE ll_fleet_trip(id BIGINT,trip_no VARCHAR,status VARCHAR,planned_departure_at TIMESTAMP)"); s.execute("CREATE TABLE ll_fleet_external_booking(trip_no VARCHAR,booking_no VARCHAR,customer_name VARCHAR,destination_label VARCHAR,status VARCHAR)"); s.execute("INSERT INTO ll_order VALUES(1,'A','10','FIRST_LEG','PENDING','IN-A','Alpha',10,'ONT8'),(2,'B','20','FIRST_LEG','PENDING','IN-B','Beta',20,'ONT8')"); s.execute("INSERT INTO ll_inbound_fba_appointment VALUES(1,'AP-1','SUCCESS','ONT8','2026-10-15','America/Los_Angeles'),(2,'AP-WRONG','SUCCESS','ONT8','2026-10-15','America/Los_Angeles')"); s.execute("INSERT INTO ll_inbound_fba_appointment_item VALUES(1,'A',1,1),(1,'B',1,2)"); - s.execute("INSERT INTO ll_dispatch_order VALUES(1,'A','DS-1','PENDING'),(2,'B','DS-WRONG','PENDING')"); - s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2)"); + s.execute("INSERT INTO ll_dispatch_order VALUES(1,'DS-1','ACTIVE','BEFORE_LOADING'),(2,'DS-WRONG','ACTIVE','BEFORE_LOADING')"); + s.execute("INSERT INTO ll_dispatch_item VALUES(1,'A',1),(1,'B',2),(2,'B',1)"); s.execute("INSERT INTO ll_fleet_trip VALUES(1,'TR-1','PLANNED','2026-10-15'),(2,'TR-WRONG','PLANNED','2026-10-15')"); - s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES('A','DS-1',1),('B','DS-1',2)"); + s.execute("INSERT INTO ll_fleet_trip_dispatch VALUES(1,'DS-1',1),(2,'DS-WRONG',2)"); s.execute("INSERT INTO ll_fleet_external_booking VALUES('TR-1','FB-1','Other customer','LAX9','CONFIRMED'),('TR-1','FB-CANCEL','Other','ONT8','CANCELLED'),('TR-WRONG','FB-WRONG','Other','ONT8','CONFIRMED')"); } var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); @@ -40,6 +40,9 @@ class AppointmentImpactRelationTest { assertThat(links.trips()).extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1"); assertThat(links.sameTripBookings()).extracting(AppointmentImpactTransportGateway.Booking::bookingNo).containsExactly("FB-1"); assertThat(relations.cargo("20")).extracting(AppointmentImpactRelationMapper.Cargo::inboundCode).containsExactly("IN-B"); + assertThat(relations.dispatches("20")).extracting(AppointmentImpactTransportGateway.Dispatch::dispatchNo).containsExactly("DS-1"); + assertThat(new FleetAppointmentImpactAdapter(session.getMapper(FleetAppointmentImpactMapper.class)).related(relations.dispatches("20")).trips()) + .extracting(AppointmentImpactTransportGateway.Trip::tripNo).containsExactly("TR-1"); } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java index fd9129a73..24a14d742 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/Phase4BrowserApiBoundaryTest.java @@ -100,7 +100,7 @@ class Phase4BrowserApiBoundaryTest { .filter(method -> method.isAnnotationPresent(PostMapping.class)) .flatMap(method -> Arrays.stream(method.getAnnotation(PostMapping.class).value())) .collect(Collectors.toSet()); - assertEquals(Set.of("/drafts/snapshot", "/pre-arrange", "/drafts/pre-arrange", "/drafts/containers", + assertEquals(Set.of("/drafts/snapshot", "/drafts", "/drafts/pre-arrange", "/drafts/containers", "/draft-locks/lock", "/draft-locks/unlock", "/draft-locks/release"), postPaths); assertTrue(Arrays.stream(SmartDispatchController.class.getDeclaredMethods()) .filter(method -> method.isAnnotationPresent(DeleteMapping.class)).count() == 1); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java index a41c62a4d..df67ddf59 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliveryDispatchFixtures.java @@ -15,7 +15,16 @@ public final class DeliveryDispatchFixtures { return new CargoLine(id,customer,"客户"+customer,id,"LM-"+id,"SOURCE-"+id,"RECEIVED","LOCAL",null,"IN-"+id, null,pallet,"PALLET-"+pallet,id,id,id,id,null,null,null,null, new Destination("FBA:"+destination,"FBA",null,destination,destination,null,null,null,null), - packages,0,null,null,List.of()); + packages,0,null,null,List.of(),null); + } + /** 换标沿用真实履约订单及逐次拣货归属,原货源单独进入冻结快照。 */ + public static CargoLine relabelCargo(long id,String customer,String destination,long pallet,long packages,SourceOrigin origin) { + var row=cargo(id,customer,destination,pallet,packages); + return new CargoLine(row.dispatchItemId(),row.customerId(),row.customerName(),row.orderId(),row.orderNo(), + row.sourceKey(),"RELABEL",row.sourceRegion(),row.sourceInboundId(),row.inboundCode(),row.planPalletId(), + row.warehousePalletId(),row.palletNo(),row.pickId(),row.attributionId(),row.inventoryPositionId(),null,null,null, + row.cartonMark(),"RL-"+origin.relabelApplicationId(),row.destination(),row.packages(),row.productQuantity(), + row.grossWeightKg(),row.volumeCbm(),row.batchAllocations(),origin); } /** 构造同仓完整拣货的整车上下文,发车后只改变业务计数。 */ public static Context context(List cargo,boolean departed) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java index 674e773b6..ddeb99242 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DeliverySourcePublicationPersistenceTest.java @@ -39,12 +39,13 @@ class DeliverySourcePublicationPersistenceTest { DeliverySourceEvidenceMapper mapper; volatile Runnable beforeFirstSourceRead = () -> {}; final Map> sourceLocks=new ConcurrentHashMap<>(); + /** 原生整车和唯一任务没有客户头,发布证据沿真实明细、扫码与来源行读取。 */ Fixture() { org.springframework.test.util.ReflectionTestUtils.setField(service,"fleetPool",mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); - db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,status VARCHAR(20),stage VARCHAR(30),departed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); - db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)"); db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); @@ -131,10 +132,11 @@ class DeliverySourcePublicationPersistenceTest { Save request(String key) { return new Save(0L,key,null,List.of(new Binding(DeliveryDocumentCategory.RECEIPT,List.of(1L)))); } void sign(long id,String key) { service.save("C",String.valueOf(id),"TEST",request(key)); } void source(long id,int packages) { db.update("INSERT INTO ll_order(id,customer_id,source_region,source_inbound_id,package_count) VALUES(?,'C','FIRSTLEG',?,?)",id,"S-"+id,packages); } + /** 创建真实客户明细成员及原生departed_at,保留签收与重派的确定时间顺序。 */ void shipment(long id,int packages) { - db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-01-01 01:00:00')",id); + db.update("INSERT INTO ll_dispatch_order(id,status,stage,departed_at) VALUES(?,'ACTIVE','DELIVERING','2026-01-01 01:00:00')",id); db.update("INSERT INTO ll_delivery_order(id,customer_id,dispatch_order_id,delivery_group_key,package_count,outbound_no) VALUES(?,'C',?,'CODE:A',?,?)",id,id,packages,"OUT-"+id); - db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",id,id); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id) VALUES(?,?)",id,id); db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",id,id); db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",id,id,id); } @@ -156,17 +158,17 @@ class DeliverySourcePublicationPersistenceTest { f.sign(1,"same-key");assertThat(f.published()).containsExactly("S-102"); assertThat(f.db.queryForObject("SELECT COUNT(*) FROM proof_revision",Integer.class)).isEqualTo(1); // Re-dispatch only the two previously unsigned boxes. Original current delivery pointers are absent. - f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1)); + f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=2",LocalDateTime.now().plusSeconds(1)); f.pick(3,2,101,2,30L); // Keep deterministic chronology without sleeping or depending on wall clock granularity. - f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2"); + f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-03 00:00:00' WHERE id=2"); f.sign(2,"redelivery");assertThat(f.published()).containsExactly("S-101","S-102"); } @Test void postSignatureRedeliveryDoesNotFillNeverShippedFourBoxes() { var f=new Fixture();f.source(101,10);f.shipment(1,6);f.pick(1,1,101,6,null);f.sign(1,"first"); f.db.update("UPDATE ll_delivery_order SET signed_at='2026-01-02 00:00:00' WHERE id=1"); f.returned(30,1,101,4,LocalDateTime.of(2026,1,3,0,0)); - f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-04 00:00:00' WHERE id=2"); + f.shipment(2,4);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-04 00:00:00' WHERE id=2"); f.pick(2,2,101,4,30L);f.sign(2,"repeat-four"); assertThat(f.published()).isEmpty();assertThat(f.tail(101).getSubOrderStatus()).contains("WAIT_DELIVERY"); } @@ -211,9 +213,9 @@ class DeliverySourcePublicationPersistenceTest { @Test void multiHopPreSignatureRedeliveryPublishesWhenLastUnsignedBoxIsSigned() { var f=new Fixture();f.source(101,4);f.shipment(1,4);f.pick(1,1,101,4,null); f.returned(30,1,101,2,LocalDateTime.of(2026,1,2,0,0));f.sign(1,"first");assertThat(f.published()).isEmpty(); - f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L); + f.shipment(2,2);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-03 00:00:00' WHERE id=2");f.pick(2,2,101,2,30L); f.returned(40,2,101,1,LocalDateTime.of(2026,1,4,0,0));f.sign(2,"second");assertThat(f.published()).isEmpty(); - f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET depart_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L); + f.shipment(3,1);f.db.update("UPDATE ll_dispatch_order SET departed_at='2026-01-05 00:00:00' WHERE id=3");f.pick(3,3,101,1,40L); f.sign(3,"third");assertThat(f.published()).containsExactly("S-101"); } @Test void unrelatedUnattributedDestinationDoesNotBlockSignedSource() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java index 4cf800742..74a79d9f2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java @@ -42,7 +42,7 @@ class DispatchDeliveryArrangementPersistenceTest { private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class); private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); private final DeliveryCargoGroups grouping=new DeliveryCargoGroups(json); - private final Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false); + private Context context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"102","ONT8",52,3)),false); private final Actor actor=new Actor("TEST",ActorMode.PLATFORM,null); /** 隔离测试数据库只用于生产Mapper验证,不连接环境或启动服务。 */ @@ -79,7 +79,7 @@ class DispatchDeliveryArrangementPersistenceTest { var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json); var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource())); service=(DispatchDeliveryService)proxy.getProxy(); - when(execution.lockForArrangement(any(),eq("DP-10"))).thenReturn(context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty()); + when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context);when(execution.replay(any(),any(),any())).thenReturn(Optional.empty()); when(fees.external(any())).thenReturn(Map.of("101",new FleetBookingFreight.Preview("freight",true,List.of(),List.of()), "102",new FleetBookingFreight.Preview("freight",true,List.of(),List.of()))); when(execution.recordArrangement(any(),any(),any(),any(),anyBoolean())).thenThrow(new IllegalStateException("主单CAS失败")); @@ -102,6 +102,77 @@ class DispatchDeliveryArrangementPersistenceTest { verify(execution,never()).recordArrangement(any(),any(),any(),any(),anyBoolean()); } + /** 真实 Mapper 保存安排和逐次归属后,出库及派送均可独立定位原货源,不依赖可变履约单回填。 */ + @Test void persistedAttributionAndDeliveryReferencesResolveImmutableRelabelOrigins() throws Exception { + var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); + var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null); + var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null); + var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L); + context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second), + relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false); + db.update("INSERT INTO ll_order(id,customer_id,current_status,version) VALUES(3,'101','INBOUND_COMPLETED',0),(4,'101','INBOUND_COMPLETED',0)"); + // 成功用例覆盖默认 CAS 失败桩时不能调用旧桩;保留失败用例原有回滚语义。 + doAnswer(a->{ + ArrangementFact fact=a.getArgument(3);CommandIdentity identity=a.getArgument(2); + return new CommandResult("DP-10",Action.ARRANGE_DELIVERY,identity.idempotencyKey(),4,Status.ACTIVE,Stage.AFTER_LOADING, + DEPARTED,Effect.APPLIED,context.counts(),fact.result(),false,false); + }).when(execution).recordArrangement(any(),any(),any(),any(),anyBoolean()); + var result=service.arrange(actor,"DP-10",request()); + assertNotNull(result); + assertEquals(4,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_pallet_item",Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class)); + assertEquals(3,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class)); + assertEquals(5,db.queryForObject("SELECT package_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'",Integer.class)); + assertEquals(1,db.queryForObject("SELECT pallet_count FROM ll_delivery_order WHERE customer_id='101' AND destination_warehouse_code='ONT8'",Integer.class)); + assertEquals(2,db.queryForObject("SELECT pallet_count FROM ll_outbound_order WHERE customer_id='101'",Integer.class)); + + // 修改履约单展示事实后,仍只通过原安排快照和已保存的真实归属定位来源。 + db.update("UPDATE ll_order SET order_no=CONCAT('CHANGED-',id)"); + var outboundRows=db.queryForList(""" + SELECT i.customer_id,i.source_attribution_id,i.source_order_id,i.dispatch_item_id,i.package_count, + i.gross_weight_kg,i.volume_cbm,CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json + FROM ll_outbound_pallet_item i + JOIN ll_outbound_order o ON o.id=i.outbound_order_id AND o.customer_id=i.customer_id + JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=o.dispatch_order_id + ORDER BY i.source_attribution_id + """); + for (var item:outboundRows) { + List frozen=json.readValue(item.get("CARGO_JSON").toString(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); + long attribution=((Number)item.get("SOURCE_ATTRIBUTION_ID")).longValue(); + var row=frozen.stream().filter(c->c.customerId().equals(item.get("CUSTOMER_ID"))&&c.attributionId()==attribution) + .findFirst().orElseThrow(); + assertEquals(context.cargo().stream().filter(c->c.attributionId()==attribution).findFirst().orElseThrow().sourceOrigin(),row.sourceOrigin()); + assertEquals(row.orderId().longValue(),((Number)item.get("SOURCE_ORDER_ID")).longValue()); + assertNotEquals(row.sourceOrigin().sourceInboundOrderId().longValue(),((Number)item.get("SOURCE_ORDER_ID")).longValue()); + assertEquals(row.packages(),((Number)item.get("PACKAGE_COUNT")).longValue()); + assertNull(item.get("GROSS_WEIGHT_KG"));assertNull(item.get("VOLUME_CBM")); + } + // 费用的 DELIVERY_ORDER.businessId 指向此真实派送行,可按客户及目的地取回其全部冻结来源。 + var deliveryRows=db.queryForList(""" + SELECT d.id,d.customer_id,d.delivery_group_key,d.package_count,d.pallet_count, + CAST(a.cargo_json AS VARCHAR(20000)) AS cargo_json + FROM ll_delivery_order d + JOIN ll_dispatch_delivery_arrangement a ON a.dispatch_order_id=d.dispatch_order_id + ORDER BY d.customer_id,d.delivery_group_key + """); + int referencedSources=0; + for (var delivery:deliveryRows) { + List frozen=json.readValue(delivery.get("CARGO_JSON").toString(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); + var group=frozen.stream().filter(c->c.customerId().equals(delivery.get("CUSTOMER_ID")) + && grouping.documentGroupKey(c.destination()).equals(delivery.get("DELIVERY_GROUP_KEY"))).toList(); + referencedSources+=group.size(); + assertFalse(group.isEmpty());assertTrue(group.stream().allMatch(c->c.sourceOrigin()!=null)); + assertEquals(((Number)delivery.get("PACKAGE_COUNT")).longValue(),group.stream().mapToLong(CargoLine::packages).sum()); + assertEquals(((Number)delivery.get("PALLET_COUNT")).longValue(),group.stream().map(CargoLine::warehousePalletId).distinct().count()); + if (group.size()==2) assertEquals(List.of(first,second),group.stream().map(CargoLine::sourceOrigin).toList()); + } + assertEquals(4,referencedSources); + List frozen=json.readValue(db.queryForObject("SELECT cargo_json FROM ll_dispatch_delivery_arrangement",String.class), + json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); + assertEquals(3,frozen.stream().filter(c->c.sourceOrigin().productQuantity()==null).count()); + assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity()); + } + /** 按真实共享记录构造完整客户目的地请求。 */ private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g-> new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java index 5c21aa048..6b806fbe6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryFeesTest.java @@ -7,6 +7,7 @@ import com.baiyue.lastleg.delivery.model.DeliveryCargoGroups; import com.baiyue.lastleg.delivery.repository.DispatchDeliveryRepository; import com.baiyue.lastleg.delivery.service.DispatchDeliveryFees; import com.baiyue.lastleg.dispatch.port.DispatchContracts.CargoLine; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.SourceOrigin; import com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; import com.baiyue.lastleg.financefee.dto.OverseasFeeSourceDtos; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; @@ -80,7 +81,7 @@ class DispatchDeliveryFeesTest { var first=firstleg(cargo(1,"101","ONT8",51,2));var raw=firstleg(cargo(2,"101","ONT8",51,3)); var second=new CargoLine(raw.dispatchItemId(),raw.customerId(),raw.customerName(),raw.orderId(),raw.orderNo(),raw.sourceKey(),raw.sourceStage(), raw.sourceRegion(),"502",raw.inboundCode(),raw.planPalletId(),raw.warehousePalletId(),raw.palletNo(),raw.pickId(),raw.attributionId(),raw.inventoryPositionId(), - raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations()); + raw.receiptId(),raw.returnReceiptId(),raw.returnCartonId(),raw.cartonMark(),raw.relabelNo(),raw.destination(),raw.packages(),raw.productQuantity(),raw.grossWeightKg(),raw.volumeCbm(),raw.batchAllocations(),raw.sourceOrigin()); var group=grouping.groups(context(List.of(first,second),false)).get(0); when(finance.sources(anyString(),any())).thenReturn(ApiResponse.success(List.of(original(101)))); var input=input(group,List.of(new CoverageSelection(71L,1,"FEE-HASH",List.of("DELIVERY_FEE"))),List.of()); @@ -92,6 +93,27 @@ class DispatchDeliveryFeesTest { input(group,List.of(input.feeCoverage().get(0),input.feeCoverage().get(0)),List.of()),"OP",ordinary())); } + /** 多个换标原货源共组费用只归真实派送,不借第一票原货源或履约单伪造单一归属。 */ + @Test void relabelFeeRetainsDeliveryReferenceWithoutInventingOneOrigin() { + var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); + var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null); + var context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first), + relabelCargo(3,"101","ONT8",51,3,second)),false); + var group=grouping.groups(context).get(0); + var delivery=new DeliveryOrder();delivery.setId(81L);delivery.setDeliveryNo("DL-101"); + delivery.setCustomerId("101");delivery.setOverseasWarehouseId("1001"); + service.publish(context,group,input(group,List.of(),List.of(fee("RECEIVABLE"))),delivery,null,ordinary(),"OP","ARR-RELABEL"); + var saved=ArgumentCaptor.forClass(OverseasFeeSourceDtos.SaveRequest.class); + verify(sources).publishApproved(eq("OP"),anyString(),saved.capture()); + assertEquals("DELIVERY_ORDER",saved.getValue().businessType()); + assertEquals(81L,saved.getValue().businessId());assertEquals("DL-101",saved.getValue().businessNo()); + assertEquals("101",saved.getValue().customerId()); + assertNull(saved.getValue().orderId());assertNull(saved.getValue().inboundId());assertNull(saved.getValue().inboundCode()); + assertEquals(List.of(first,second),group.cargo().stream().map(CargoLine::sourceOrigin).toList()); + assertTrue(group.cargo().stream().allMatch(c->c.sourceOrigin().productQuantity()==null&&c.grossWeightKg()==null&&c.volumeCbm()==null)); + assertEquals(5,group.packages());assertEquals(1,group.palletCount()); + } + /** 只读平台候选接口仍拒绝客户身份,不能借组结果跨客户访问。 */ @Test void readOnlyCoverageControllerRejectsMerchantBeforeQuery() throws Exception { var delivery=mock(com.baiyue.lastleg.delivery.service.DispatchDeliveryService.class); @@ -109,7 +131,7 @@ class DispatchDeliveryFeesTest { /** 真实头程关联只在来源行声明,不从展示编号推断。 */ private CargoLine firstleg(CargoLine c) { return new CargoLine(c.dispatchItemId(),c.customerId(),c.customerName(),c.orderId(),c.orderNo(),c.sourceKey(),c.sourceStage(), "FIRSTLEG","501",c.inboundCode(),c.planPalletId(),c.warehousePalletId(),c.palletNo(),c.pickId(),c.attributionId(),c.inventoryPositionId(),c.receiptId(), - c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations()); } + c.returnReceiptId(),c.returnCartonId(),c.cartonMark(),c.relabelNo(),c.destination(),c.packages(),c.productQuantity(),c.grossWeightKg(),c.volumeCbm(),c.batchAllocations(),c.sourceOrigin()); } /** 输入仅使用共同ArrangeGroup,不保留旧单客户协议别名。 */ private ArrangeGroup input(DeliveryCargoGroups.Group group,List coverage,List fees) { return new ArrangeGroup(group.key(),group.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null,"freight",group.cargoHash(),coverage,fees); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java index e13ff83ff..f87732f13 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java @@ -72,6 +72,43 @@ class DispatchDeliveryServiceTest { verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString()); } + /** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */ + @Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception { + var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); + var second=new SourceOrigin(10002L,202L,302L,402L,702L,"IN-702","CODE-702",null); + var third=new SourceOrigin(10003L,203L,303L,403L,703L,"IN-703","CODE-703",null); + var fourth=new SourceOrigin(10004L,204L,304L,404L,704L,"IN-704","CODE-704",6L); + context=context(List.of(relabelCargo(1,"101","ONT8",51,2,first),relabelCargo(3,"101","ONT8",51,3,second), + relabelCargo(2,"102","ONT8",51,4,third),relabelCargo(4,"101","LAX9",52,1,fourth)),false); + var result=(ArrangementResult)service.arrange(actor,"DP-10",request("RELABEL-ARRANGE")).result(); + assertEquals(2,result.outbounds().size()); + assertEquals(3,result.deliveries().size()); + var ont8=result.deliveries().stream().filter(d->d.customerId().equals("101")&&d.destination().warehouseCode().equals("ONT8")) + .findFirst().orElseThrow(); + assertEquals(5,ont8.packageCount());assertEquals(1,ont8.palletCount()); + assertEquals(2,result.deliveries().stream().filter(d->d.destination().warehouseCode().equals("ONT8")) + .map(DeliveryReference::customerId).distinct().count()); + List cargo=json.readValue(arrangement.getCargoJson(),json.getTypeFactory().constructCollectionType(List.class,CargoLine.class)); + assertEquals(context.cargo(),cargo); + var created=org.mockito.ArgumentCaptor.forClass(OutboundPalletItem.class); + verify(repository,times(4)).create(created.capture()); + for (var item:created.getAllValues()) { + var source=cargo.stream().filter(c->c.customerId().equals(item.getCustomerId()) + && c.attributionId().equals(item.getSourceAttributionId())).findFirst().orElseThrow(); + assertEquals(source.orderId(),item.getSourceOrderId()); + assertNotEquals(source.sourceOrigin().sourceInboundOrderId(),item.getSourceOrderId()); + assertEquals(source.dispatchItemId(),item.getDispatchItemId()); + assertEquals(source.packages(),item.getPackageCount().longValue()); + assertNull(item.getGrossWeightKg());assertNull(item.getVolumeCbm()); + } + assertEquals(3,cargo.stream().filter(c->c.sourceOrigin().productQuantity()==null).count()); + assertTrue(outbounds.stream().allMatch(o->o.getGrossWeightKg()==null&&o.getVolumeCbm()==null)); + assertTrue(deliveries.stream().allMatch(d->d.getTotalWeightKg()==null&&d.getTotalVolumeCbm()==null)); + var orders=org.mockito.ArgumentCaptor.forClass(Long.class); + verify(repository,times(4)).lockSource(anyString(),orders.capture()); + assertEquals(List.of(1L,2L,3L,4L),orders.getAllValues()); + } + /** 原命令恢复先于新阶段和版本判断,不能再次写任何客户单据。 */ @Test void strictReplayDelegatesToAuthoritativeLedgerBeforeCreatingDocuments() { var snapshot=new CommandResult("DP-10",Action.ARRANGE_DELIVERY,"KEY-1",4,Status.ACTIVE,Stage.AFTER_LOADING, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java index 35cd2559a..ce4b1c444 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchFeeCargoMapperTest.java @@ -14,13 +14,11 @@ import static org.assertj.core.api.Assertions.*; class DispatchFeeCargoMapperTest { /** 原退回实收只重派其已冻结货量,不借同托其他货物放大来源。 */ @Test void returnedReceiptRetainsItsExactSourceInsteadOfBecomingAWholePallet() { - var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L); item.setReturnReceiptId(701L); item.setSourceStage("RECEIVED"); item.setInboundCode("IN-A"); item.setPackageCount(2); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8"); - var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( - new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper)); - assertThat(query.sources(dispatch, java.util.List.of(item))).singleElement().satisfies(source -> { + var query = nativeGateway(item); + assertThat(query.sourceCargo("DP-1", "101")).singleElement().satisfies(source -> { assertThat(source.inboundCode()).isEqualTo("IN-A"); assertThat(source.packages()).isEqualTo(2); }); } @@ -29,17 +27,28 @@ class DispatchFeeCargoMapperTest { @org.junit.jupiter.params.provider.ValueSource(strings={"IN-A", "IN-B"}) void actualSourceQueryDoesNotDependOnWhichInboundWasChosenForDisplay(String displayInbound) { jdbc.update("UPDATE ll_dispatch_item SET inbound_code=?,plan_pallet_id=801", displayInbound); - var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("101"); var item = new com.baiyue.lastleg.dispatch.entity.DispatchItem(); item.setId(201L); item.setWarehousePalletId(501L); item.setInboundCode(displayInbound); item.setPackageCount(20); item.setDestinationType("FBA"); item.setDestinationWarehouseCode("ONT8"); - var query = new com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery( - new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper)); - var sources = query.sources(dispatch, java.util.List.of(item)); + var query = nativeGateway(item); + var sources = query.sourceCargo("DP-1", "101"); assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::inboundCode).containsExactly("IN-A", "IN-B"); assertThat(sources).extracting(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.SourceCargo::packages).containsExactly(10, 10); item.setPackageCount(19); - assertThatThrownBy(() -> query.sources(dispatch, java.util.List.of(item))).hasMessageContaining("箱数不一致"); + assertThatThrownBy(() -> query.sourceCargo("DP-1", "101")).hasMessageContaining("箱数未核平"); } + /** 使用当前Fleet整车来源入口,隔离无关资源写链,不保留旧客户主单查询类。 */ + private com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter nativeGateway(com.baiyue.lastleg.dispatch.entity.DispatchItem item) { + var aggregates = org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository.class); + var dispatch = new com.baiyue.lastleg.dispatch.entity.DispatchAggregate(); + dispatch.setId(1L); dispatch.setDispatchNo("DP-1"); + item.setCustomerId("101"); item.setDispatchOrderId(1L); + org.mockito.Mockito.when(aggregates.find("DP-1")).thenReturn(dispatch); + org.mockito.Mockito.when(aggregates.cargo(1L)).thenReturn(java.util.List.of(item)); + return new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(aggregates,null,null,null, + new com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository(mapper),null, + new com.fasterxml.jackson.databind.ObjectMapper()); + } + JdbcTemplate jdbc; DispatchFeeCargoMapper mapper; @BeforeEach void setup() throws Exception { var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); @@ -48,7 +57,7 @@ class DispatchFeeCargoMapperTest { jdbc.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),balance_id BIGINT,available_packages BIGINT)"); jdbc.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(64),inbound_order_id BIGINT,inbound_code VARCHAR(64))"); jdbc.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),source_region VARCHAR(32),source_inbound_id VARCHAR(64))"); - jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(64),dispatch_order_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,dispatch_order_id BIGINT UNIQUE)"); jdbc.execute("CREATE TABLE ll_dispatch_pda_pick(task_id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT)"); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)"); @@ -56,7 +65,7 @@ class DispatchFeeCargoMapperTest { jdbc.update("INSERT INTO ll_inventory_position VALUES(101,'101',501,'WH-1',301,8),(102,'101',501,'WH-1',302,6),(103,'202',501,'WH-1',303,50),(104,'101',501,'WH-2',301,50)"); jdbc.update("INSERT INTO ll_inventory_balance VALUES(301,'101',401,'IN-A'),(302,'101',402,'IN-B'),(303,'202',403,'IN-OTHER')"); jdbc.update("INSERT INTO ll_order VALUES(401,'101','FIRSTLEG','11'),(402,'101','FIRSTLEG','12'),(403,'202','FIRSTLEG','13')"); - jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,'101',1),(602,'101',2)"); + jdbc.update("INSERT INTO ll_dispatch_pda_task VALUES(601,1),(602,2)"); jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'101',201,101,2),(601,'101',201,102,4),(602,'101',201,101,50)"); var config = new MybatisConfiguration(); config.addMapper(DispatchFeeCargoMapper.class); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(dataSource); factory.setConfiguration(config); factory.afterPropertiesSet(); @@ -69,6 +78,17 @@ class DispatchFeeCargoMapperTest { assertThat(rows).extracting(row -> row.packages()).containsExactly(10L, 10L); assertThat(mapper.list("202", 1)).isEmpty(); } + /** 同一整车与任务内按真实明细和拣货客户分别统计,不借其他客户货量。 */ + @Test void sharedDispatchAndTaskKeepEachCustomersSourcesSeparate() { + jdbc.update("INSERT INTO ll_dispatch_item VALUES(202,1,'202',501,'PLT-501','WH-1',NULL,'IN-OTHER')"); + jdbc.update("INSERT INTO ll_dispatch_pda_pick VALUES(601,'202',202,103,7)"); + assertThat(mapper.list("101",1)).extracting(row -> row.packages()).containsExactly(10L,10L); + assertThat(mapper.list("202",1)).singleElement().satisfies(row -> { + assertThat(row.inboundCode()).isEqualTo("IN-OTHER"); + assertThat(row.packages()).isEqualTo(57L); + }); + assertThat(mapper.list("303",1)).isEmpty(); + } @Test void aPhysicalSharedPalletIncludesEveryActualSourceRegardlessOfItsDisplayPlan() { jdbc.update("UPDATE ll_dispatch_item SET plan_pallet_id=801"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(801,901)"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index 5e35ffba7..fb1243dd1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -9,7 +9,7 @@ import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; import com.baiyue.lastleg.dispatch.controller.DispatchController; import com.baiyue.lastleg.dispatch.controller.DispatchAggregateController; import com.baiyue.lastleg.dispatch.port.DispatchCommands; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchAggregateMapper; import com.baiyue.lastleg.security.OverseasPermissions; import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; @@ -124,7 +124,7 @@ class PlatformDispatchOutboundContractTest { assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) .noneMatch(method -> Set.of("depart", "cancel").contains(method.getName()))); assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver", - "plannedDepartureAt", "estimatedArrivalAt", "vehicleAppointment", "remark"), + "plannedDepartureAt", "estimatedArrivalAt", "remark"), Arrays.stream(DispatchCommands.Update.class.getRecordComponents()) .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"), @@ -184,8 +184,8 @@ class PlatformDispatchOutboundContractTest { @Test void globalBusinessNumberMappersAndSchemaDoNotUseCustomerForIdentity() throws Exception { - String dispatchFind = select(DispatchOrderMapper.class, "findGlobalByDispatchNo", String.class); - String dispatchLock = select(DispatchOrderMapper.class, "lockGlobalByDispatchNo", String.class); + String dispatchFind = select(DispatchAggregateMapper.class, "find", String.class); + String dispatchLock = select(DispatchAggregateMapper.class, "lock", String.class); String outboundFind = select(OutboundOrderMapper.class, "findGlobalByNo", String.class); String outboundLock = select(OutboundOrderMapper.class, "lockGlobalByNo", String.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java index 37ff4394f..9087f3f21 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/SourceDeliveryCompletionConcurrencyTest.java @@ -18,13 +18,14 @@ import static org.assertj.core.api.Assertions.*; /** Two real database transactions execute the production mapper SQL, with bounded waits. */ class SourceDeliveryCompletionConcurrencyTest { + /** 两个真实签收事务共享来源锁,最后持锁者必须读到前一签收提交。 */ @Test void lastSignerSeesFirstCommitEvenAfterAnEarlyReadWithoutLockingOtherDelivery() throws Exception { var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=4000","sa",""); var db = new JdbcTemplate(ds); var named = new NamedParameterJdbcTemplate(ds); db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),source_region VARCHAR(20),source_inbound_id VARCHAR(30),package_count BIGINT,UNIQUE(customer_id,source_region,source_inbound_id))"); - db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),depart_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,status VARCHAR(20),stage VARCHAR(30),departed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(20),dispatch_order_id BIGINT,delivery_group_key VARCHAR(100),package_count BIGINT,signed_packages BIGINT,signed_at TIMESTAMP,outbound_no VARCHAR(30))"); - db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_task(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT UNIQUE)"); db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(20),task_id BIGINT,dispatch_item_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(20),pick_id BIGINT,order_id BIGINT,packages BIGINT,receipt_id BIGINT,return_receipt_id BIGINT)"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20),destination_warehouse_code VARCHAR(30),fba_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30))"); @@ -33,9 +34,9 @@ class SourceDeliveryCompletionConcurrencyTest { db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,inventory_ledger_id BIGINT,received_packages BIGINT,exception_order_id BIGINT,source_outbound_no VARCHAR(30),source_outbound_pallet_item_id BIGINT,source_inventory_position_id BIGINT)"); db.update("INSERT INTO ll_order VALUES(101,'C','FIRSTLEG','S',10)"); for (int i=1;i<=2;i++) { - db.update("INSERT INTO ll_dispatch_order VALUES(?,'C','2026-10-03 01:00:00')",i); + db.update("INSERT INTO ll_dispatch_order(id,status,stage,departed_at) VALUES(?,'ACTIVE','DELIVERING','2026-10-03 01:00:00')",i); db.update("INSERT INTO ll_delivery_order VALUES(?,'C',?,'CODE:A',5,NULL,NULL,?)",i,i,"OUT-"+i); - db.update("INSERT INTO ll_dispatch_pda_task VALUES(?,'C',?)",i,i); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id) VALUES(?,?)",i,i); db.update("INSERT INTO ll_dispatch_item VALUES(?,'C',?,'FBA',NULL,NULL,'A',NULL,NULL)",i,i); db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,'C',?,?)",i,i,i); db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,'C',?,101,5,?,NULL)",i,i,i); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index b7faf9d0b..b7a5b9034 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -15,9 +15,9 @@ import com.baiyue.lastleg.delivery.mapper.OutboundPdaTaskMapper; import com.baiyue.lastleg.delivery.mapper.OutboundPdaPickMapper; import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanLineMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanPalletAllocationMapper; @@ -99,7 +99,7 @@ class DeliveryTransferServiceTest { .valueToTree(detail).path("task").path("outboundType").asText()); } - @Mock DispatchOrderMapper dispatchOrders; + @Mock DispatchAggregateRepository dispatches; @Mock DispatchItemMapper dispatchItems; @Mock OutboundOrderMapper outboundOrders; @Mock OutboundPalletItemMapper outboundPallets; @@ -128,21 +128,19 @@ class DeliveryTransferServiceTest { @Mock com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService returnStock; private DeliveryTransferService service; - @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; @Mock com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; @BeforeEach void setUp() { service = new DeliveryTransferService(); ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders))); - ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); ReflectionTestUtils.setField(service, "execution", execution); ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository( outboundOrders,outboundPallets,deliveryOrders,pdaTasks,deliveryOperations, mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class),returnCartons, mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class),tailOrders,histories)); ReflectionTestUtils.setField(service, "objectMapper", new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules()); - ReflectionTestUtils.setField(service, "dispatchOrders", dispatchOrders); + ReflectionTestUtils.setField(service, "dispatches", dispatches); ReflectionTestUtils.setField(service, "dispatchItems", dispatchItems); ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); ReflectionTestUtils.setField(service, "outboundPallets", outboundPallets); @@ -665,7 +663,11 @@ class DeliveryTransferServiceTest { .getMethod("deliveryPickedSkus", String.class, Long.class); String sql = String.join(" ", method.getAnnotation(org.apache.ibatis.annotations.Select.class).value()); assertTrue(sql.contains("t.dispatch_order_id=#{dispatchId}")); - assertTrue(sql.contains("BINARY t.customer_id=BINARY #{customerId}")); + assertTrue(sql.contains("BINARY p.customer_id=BINARY #{customerId}")); + assertTrue(sql.contains("di.dispatch_order_id=t.dispatch_order_id")); + assertTrue(sql.contains("BINARY di.customer_id=BINARY p.customer_id")); + assertTrue(sql.contains("BINARY a.customer_id=BINARY p.customer_id")); + assertTrue(!sql.contains("t.customer_id")); assertTrue(sql.contains("CASE WHEN a.receipt_id IS NOT NULL THEN i.sku_code ELSE b.sku_code END")); assertTrue(sql.contains("COALESCE(a.packages,p.packages)")); assertTrue(!sql.contains("ll_order_detail")); @@ -748,9 +750,9 @@ class DeliveryTransferServiceTest { .map(row -> row.getResult().replaceFirst("^\\(\\d+\\) ", "")).collect(java.util.stream.Collectors.joining())); verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(702L), any(), any()); verify(feeSources, never()).voidSource(org.mockito.ArgumentMatchers.eq(703L), any(), any()); - var ordered = org.mockito.Mockito.inOrder(outboundOrders, execution, fleetBindings); + var ordered = org.mockito.Mockito.inOrder(outboundOrders, execution); ordered.verify(outboundOrders).findGlobalByNo("OUT-301"); ordered.verify(execution).lockForArrangement(any(),eq("DSP-101")); - ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); ordered.verify(fleetBindings).requireUnbound(101L); + ordered.verify(outboundOrders).lockGlobalByNo("OUT-301"); service.cancelOutbound("OUT-301", "OP-2", new DeliveryRequests.Cancel("重试")); verify(feeSources, org.mockito.Mockito.times(1)).voidSource(org.mockito.ArgumentMatchers.anyLong(), any(), any()); } @@ -966,10 +968,10 @@ class DeliveryTransferServiceTest { OutboundOrder outbound = outbound(301L, 401L, "DSP-401", "OUT-301", "transfer-key"); outbound.setOverseasWarehouseId("WH-1"); outbound.setOutboundType("DISPATCH_DELIVERY"); - DispatchOrder dispatch = new DispatchOrder(); + DispatchAggregate dispatch = new DispatchAggregate(); dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1"); when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); - when(dispatchOrders.findGlobalByDispatchNo("DSP-401")).thenReturn(dispatch); + when(dispatches.find("DSP-401")).thenReturn(dispatch); DeliveryResponses.PdaTaskResolution result = service.resolvePdaTask("WH-1", "OUT-301"); @@ -978,6 +980,18 @@ class DeliveryTransferServiceTest { assertEquals("OUT-301", result.outboundNo()); } + /** 直接扫排车号使用无客户头整车,并保持PDA绑定仓隔离。 */ + @Test + void directDispatchNumberResolvesOnlyInItsWarehouse() { + DispatchAggregate dispatch = new DispatchAggregate(); + dispatch.setId(401L); dispatch.setDispatchNo("DSP-401"); dispatch.setOverseasWarehouseId("WH-1"); + when(dispatches.find("DSP-401")).thenReturn(dispatch); + assertEquals("DSP-401", service.resolvePdaTask("WH-1", "DSP-401").dispatchNo()); + ResponseStatusException denied = assertThrows(ResponseStatusException.class, + () -> service.resolvePdaTask("WH-2", "DSP-401")); + assertEquals(404, denied.getStatusCode().value()); + } + @Test void outboundNumberResolvesOneClickTaskInBoundWarehouse() { OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "one-click-key"); @@ -1264,17 +1278,6 @@ class DeliveryTransferServiceTest { - private static DispatchOrder dispatch(Long id, String dispatchNo, String customerId) { - DispatchOrder value = new DispatchOrder(); - value.setId(id); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); - value.setOverseasWarehouseId("WH-US-1"); value.setStatus("READY_TO_DEPART"); - return value; - } - - - - - private static DispatchItem item(Long id, Long warehousePalletId, String palletNo, String destinationCode, String destinationName, String inboundCode, int packages) { DispatchItem value = new DispatchItem(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index d6a5b1436..b26dead8f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -8,6 +8,31 @@ import com.baiyue.lastleg.returnreceipt.mapper.*; import com.baiyue.lastleg.returnreceipt.service.ReturnInventoryService; import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.fasterxml.jackson.databind.ObjectMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository; +import com.baiyue.lastleg.dispatch.port.DispatchExecutionPort; +import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import java.lang.reflect.Proxy; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.Map; +import java.util.Objects; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; @@ -104,40 +129,215 @@ class ReturnOutboundClosureTest { verify(stock, never()).shipCarton(anyString(), anyString(), anyString(), anyString(), anyString(), anyLong()); } - @Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() { - OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1"); - outbound.setOutboundNo("OUT1"); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); - when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound); - when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound); - var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication(); - application.setId(50L); application.setCustomerId("C1"); application.setRelabelNo("REL1"); - application.setOutboundOrderNo("OUT1"); application.setStatus("READY_FOR_OUTBOUND"); - when(relabelApplications.lockByOutbound("C1", "OUT1")).thenReturn(application); - OutboundPalletItem item = new OutboundPalletItem(); item.setCartonMarks("RC#1-1"); item.setStatus("WAIT_PICK"); - item.setPickedPackageCount(0); when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item)); - carton.setStatus("OUTBOUND_PENDING"); carton.setRelabelApplicationId(50L); - service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel")); - assertThat(carton.getStatus()).isEqualTo("RELABEL_PENDING"); - assertThat(outbound.getStatus()).isEqualTo("CANCELLED"); - verify(relabelService).changeStatus(eq("C1"), eq("REL1"), eq("OP1"), - argThat(request -> "CANCELLED".equals(request.status()))); - var sequence = inOrder(relabelApplications, outbounds, cartons, relabelService); - sequence.verify(relabelApplications).lockByOutbound("C1", "OUT1"); - sequence.verify(outbounds).lockGlobalByNo("OUT1"); - sequence.verify(cartons).lockMark("RC#1-1"); - sequence.verify(relabelService).changeStatus(anyString(), anyString(), anyString(), any()); + /** 实际取消只撤销安排,已确认申请、换标照片/逐件事实及原库存保持不变。 */ + @Test void cancelsUntouchedNativeRelabelWithItsOwningApplication() throws Exception { + var f = new CancellationFixture(); var proof = f.proof(); + f.cancel(); + assertThat(f.text("ll_return_inventory_carton", "status", 10)).isEqualTo("RELABEL_PENDING"); + assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_outbound_pallet_item", "status", 40)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_outbound_pda_task", "status", 20)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_delivery_order", "status", 60)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_relabel_application", "status", 50)).isEqualTo("READY_FOR_OUTBOUND"); + assertThat(f.text("ll_relabel_application", "fulfillment_status", 50)).isEqualTo("WAIT_OUTBOUND_CREATION"); + assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull(); + assertThat(f.text("ll_order", "outbound_order_no", 70)).isNull(); + assertThat(f.text("ll_order", "delivery_order_no", 70)).isNull(); + assertThat(f.text("ll_order", "current_status", 70)).isEqualTo("WAIT_OUTBOUND"); + assertThat(f.proof()).isEqualTo(proof); + assertThat(f.locks.subList(0,3)).containsExactly("relabel", "outbound", "carton"); + verifyNoInteractions(f.relabelLifecycle, f.execution); + var after = f.state(); f.cancel(); assertThat(f.state()).isEqualTo(after); + assertThatThrownBy(() -> f.service.pdaPick("1", "picker", "TASK1", + new DeliveryRequests.PdaPick(40L,null,1L,"old-outbound-pick",List.of("RC#1-1")))) + .isInstanceOf(ResponseStatusException.class).hasMessageContaining("not available for picking"); + assertThat(f.proof()).isEqualTo(proof); } - @Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() { - OutboundOrder outbound = new OutboundOrder(); outbound.setId(30L); outbound.setCustomerId("C1"); - outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); - when(outbounds.findGlobalByNo("OUT1")).thenReturn(outbound); - when(outbounds.lockGlobalByNo("OUT1")).thenReturn(outbound); - OutboundPalletItem item = new OutboundPalletItem(); item.setStatus("WAIT_PICK"); item.setPickedPackageCount(1); - when(items.findByOutbound("C1", 30L)).thenReturn(List.of(item)); - assertThatThrownBy(() -> service.cancelOutbound("OUT1", "OP1", new DeliveryRequests.Cancel("cancel"))) + @Test void cancellationRejectsPickedNativeBoxWithoutReleasingItsReservation() throws Exception { + var f = new CancellationFixture(); + f.db.update("UPDATE ll_outbound_pallet_item SET picked_package_count=1 WHERE id=40"); + f.insert("ll_outbound_pda_pick", "id", 90, "customer_id", "C1", "task_id", 20, + "outbound_pallet_item_id", 40, "packages", 1, "idempotency_key", "actual-pick"); + var original = f.state(); + assertThatThrownBy(f::cancel) .isInstanceOf(ResponseStatusException.class).hasMessageContaining("execution has started"); - verifyNoInteractions(relabelService); - verify(cartons, never()).updateById(any(ReturnInventoryCarton.class)); + assertThat(f.state()).isEqualTo(original); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_pda_pick", Long.class)).isEqualTo(1); + verifyNoInteractions(f.relabelLifecycle, f.execution, f.fees); + } + + /** 普通仓托换标同样只取消出库安排,不释放真实原入仓位置的HOLD或重做换标。 */ + @Test void cancelsUntouchedWarehousePalletRelabelWithoutReleasingItsHold() throws Exception { + var f = new CancellationFixture(); f.warehousePalletSource(); var proof = f.proof(); + f.cancel(); + assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_outbound_pallet_item", "status", 40)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_relabel_application", "status", 50)).isEqualTo("READY_FOR_OUTBOUND"); + assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull(); + assertThat(f.text("ll_relabel_application", "fulfillment_status", 50)).isEqualTo("WAIT_OUTBOUND_CREATION"); + assertThat(f.db.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)).isEqualTo(1); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + assertThat(f.proof()).isEqualTo(proof); + assertThat(f.text("ll_order", "outbound_order_no", 70)).isNull(); + assertThat(f.text("ll_order", "delivery_order_no", 70)).isNull(); + verifyNoInteractions(f.relabelLifecycle,f.execution); + var cancelled = f.state(); f.cancel(); assertThat(f.state()).isEqualTo(cancelled); + } + + /** 原出库号不匹配不能通过申请ID或其他客户的同类记录恢复关联。 */ + @Test void cancellationRejectsChangedRelabelAssociationWithoutAnyWrite() throws Exception { + for (String column : List.of("outbound_order_no", "customer_id", "overseas_warehouse_id")) { + var f = new CancellationFixture(); + f.db.update("UPDATE ll_relabel_application SET " + column + "=? WHERE id=50", "other"); + var original = f.state(); + assertThatThrownBy(f::cancel).isInstanceOf(ResponseStatusException.class).hasMessageContaining("关联状态已变化"); + assertThat(f.state()).isEqualTo(original); + } + } + + /** 相同箱号也必须属于原出库的客户和仓,不能解除另一个客户的库存预留。 */ + @Test void cancellationRejectsForeignCustomerOrWarehouseCarton() throws Exception { + for (String column : List.of("customer_id", "overseas_warehouse_id")) { + var f = new CancellationFixture(); + f.db.update("UPDATE ll_return_inventory_carton SET " + column + "=? WHERE id=10", "other"); + var original = f.state(); + assertThatThrownBy(f::cancel).isInstanceOf(ResponseStatusException.class).hasMessageContaining("预留状态异常"); + assertThat(f.state()).isEqualTo(original); + } + } + + /** 在申请和箱预留已实际恢复后注入下游失败,所有关联、任务及派送状态必须整体回滚。 */ + @Test void laterCancellationFailureRollsBackTheWholeArrangementAndAllowsRetry() throws Exception { + var f = new CancellationFixture(); var original = f.state(); + when(f.fees.listForBusiness("C1", "DELIVERY_ORDER", 60L)).thenAnswer(call -> { + assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull(); + assertThat(f.text("ll_return_inventory_carton", "status", 10)).isEqualTo("RELABEL_PENDING"); + throw new IllegalStateException("取消后续核对失败"); + }); + assertThatThrownBy(f::cancel).isInstanceOf(IllegalStateException.class).hasMessage("取消后续核对失败"); + assertThat(f.state()).isEqualTo(original); + doReturn(List.of()).when(f.fees).listForBusiness("C1", "DELIVERY_ORDER", 60L); + f.cancel(); + assertThat(f.text("ll_outbound_order", "status", 30)).isEqualTo("CANCELLED"); + assertThat(f.text("ll_relabel_application", "outbound_order_no", 50)).isNull(); + } + + /** 真实数据库装配取消仓储与全部Mapper;仅隔离费用查询等外部端口,不伪造取消成功。 */ + private static final class CancellationFixture { + final JdbcTemplate db; + final DeliveryTransferService service; + final OverseasFeeSourceService fees = mock(OverseasFeeSourceService.class); + final DispatchExecutionPort execution = mock(DispatchExecutionPort.class); + final com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelLifecycle = mock(com.baiyue.lastleg.relabel.service.RelabelApplicationService.class); + final List locks = new ArrayList<>(); + + /** 按统一初始化实际建表,保留列、默认值及约束,不以Java字段猜测正式结构。 */ + CancellationFixture() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:cancel-native-" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + db = new JdbcTemplate(ds); + String schema = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for (String table : List.of("ll_outbound_order", "ll_outbound_pallet_item", "ll_outbound_pda_task", "ll_outbound_pda_pick", + "ll_delivery_order", "ll_delivery_operation", "ll_relabel_application", "ll_return_inventory_carton", + "ll_return_inventory_carton_item", "ll_warehouse_transfer_order", "ll_order", "ll_order_status_history", + "ll_relabel_item", "ll_relabel_pda_task", "ll_relabel_pda_unit_fact", "ll_relabel_pda_history", + "ll_inventory_balance", "ll_inventory_position", "ll_inventory_ledger")) { + int start = schema.indexOf("CREATE TABLE " + table + " ("); int end = schema.indexOf(") ENGINE=", start); + if (start < 0 || end < 0) throw new IllegalStateException("统一初始化缺少取消测试表:" + table); + db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b", "CLOB") + .replaceAll("(?i)\\s+CHARACTER SET\\s+\\w+", "").replaceAll("(?i)\\s+COLLATE\\s+\\w+", "") + .replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)", "$1 " + table + "_$2")); + } + var config = new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); + for (var type : List.of(OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaTaskMapper.class,OutboundPdaPickMapper.class, + DeliveryOrderMapper.class,DeliveryOperationMapper.class,RelabelApplicationMapper.class,ReturnInventoryCartonMapper.class, + WarehouseTransferOrderMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class)) config.addMapper(type); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(config); + var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); + var outboundMapper = traced(sessions.getMapper(OutboundOrderMapper.class), OutboundOrderMapper.class, "lockGlobalByNo", "outbound"); + var relabelMapper = traced(sessions.getMapper(RelabelApplicationMapper.class), RelabelApplicationMapper.class, "lockByOutbound", "relabel"); + var cartonMapper = traced(sessions.getMapper(ReturnInventoryCartonMapper.class), ReturnInventoryCartonMapper.class, "lockMark", "carton"); + var target = new DeliveryTransferService(); + ReflectionTestUtils.setField(target,"cancellations",new DeliveryCancellationRepository(outboundMapper,sessions.getMapper(OutboundPalletItemMapper.class), + sessions.getMapper(DeliveryOrderMapper.class),sessions.getMapper(OutboundPdaTaskMapper.class),sessions.getMapper(DeliveryOperationMapper.class),relabelMapper, + cartonMapper,sessions.getMapper(WarehouseTransferOrderMapper.class),sessions.getMapper(LastlegOrderMapper.class),sessions.getMapper(LastlegOrderStatusHistoryMapper.class))); + ReflectionTestUtils.setField(target,"outboundOrders",outboundMapper); ReflectionTestUtils.setField(target,"outboundPallets",sessions.getMapper(OutboundPalletItemMapper.class)); + ReflectionTestUtils.setField(target,"pdaTasks",sessions.getMapper(OutboundPdaTaskMapper.class)); ReflectionTestUtils.setField(target,"pdaPicks",sessions.getMapper(OutboundPdaPickMapper.class)); + ReflectionTestUtils.setField(target,"feeSources",fees); ReflectionTestUtils.setField(target,"execution",execution); + ReflectionTestUtils.setField(target,"relabelService",relabelLifecycle); + var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource())); + service = (DeliveryTransferService)proxy.getProxy(); + when(fees.listForBusiness(anyString(),anyString(),anyLong())).thenReturn(List.of()); + seed(); + } + + /** 记录真实Mapper的取锁次序,调用及SQL均委托原MyBatis实现。 */ + private T traced(T actual,Class type,String method,String label) { + return type.cast(Proxy.newProxyInstance(type.getClassLoader(),new Class[]{type},(proxy,called,args) -> { + if (called.getName().equals(method)) locks.add(label); + try { return called.invoke(actual,args); } + catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); } + })); + } + + /** 已确认原生换标、未扫码独立出库及其真实派送/来源关联。 */ + private void seed() { + var at = LocalDateTime.of(2026,10,19,12,0); + insert("ll_outbound_order","id",30,"customer_id","C1","outbound_no","OUT1","overseas_warehouse_id","1","outbound_type","RELABEL_OUTBOUND","status","WAIT_PICK","idempotency_key","OUT1-CREATE"); + insert("ll_outbound_pallet_item","id",40,"customer_id","C1","outbound_order_id",30,"carton_marks","RC#1-1","package_count",1,"status","WAIT_PICK"); + insert("ll_outbound_pda_task","id",20,"customer_id","C1","outbound_order_id",30,"task_no","TASK1","status","PENDING"); + insert("ll_delivery_order","id",60,"customer_id","C1","delivery_no","DEL1","outbound_order_id",30,"outbound_no","OUT1","delivery_group_key","NATIVE","status","WAIT_DELIVERY","idempotency_key","DEL1-CREATE"); + insert("ll_relabel_application","id",50,"customer_id","C1","relabel_no","REL1","lastleg_order_id",70,"customer_order_no","CUSTOMER-1","overseas_warehouse_id","1","label_deadline",at.plusDays(1),"contact_name","测试","contact_phone","10000000000","delivery_method","EXPRESS","outbound_order_no","OUT1","status","READY_FOR_OUTBOUND","fulfillment_status","OUTBOUND_CREATED","customer_confirmed_at",at,"customer_confirmation_key","CONFIRMED-1","wallet_status","SETTLED","idempotency_key","REL1-CREATE","request_hash","a".repeat(64)); + insert("ll_return_inventory_carton","id",10,"customer_id","C1","overseas_warehouse_id","1","carton_mark","RC#1-1","status","OUTBOUND_PENDING","relabel_application_id",50,"relabel_no","REL1","relabel_status","COMPLETED","relabel_completed_at",at,"sku_types",1,"total_quantity",6,"created_at",at,"updated_at",at); + insert("ll_return_inventory_carton_item","id",11,"customer_id","C1","carton_id",10,"sku_code","NEW-SKU","quantity",6); + insert("ll_order","id",70,"customer_id","C1","order_no","RELABEL-ORDER","order_type","RELABEL_APPLICATION","source_region","OVERSEAS","source_inbound_id","REL1","source_event_id","REL1","source_loading_plan_id","REL1","source_payload_hash","a".repeat(64),"relabel_order_no","REL1","relabel_status","COMPLETED","current_status","WAIT_OUTBOUND","outbound_order_no","OUT1","delivery_order_no","DEL1"); + insert("ll_relabel_item","id",71,"application_id",50,"customer_id","C1","product_name_zh","测试","total_package_count",1,"notified_package_count",1,"return_carton_id",10,"operation_type","CARTON_LABEL","carton_mark","NEW-BOX"); + insert("ll_relabel_pda_task","id",80,"application_id",50,"customer_id","C1","relabel_no","REL1","overseas_warehouse_id",1,"status","COMPLETED","operator_key","picker","operator_name","测试","expected_packages",1,"relabelled_packages",1,"claimed_at",at,"completion_photo_file_ids","[501]","reviewed_at",at); + insert("ll_relabel_pda_unit_fact","id",81,"task_id",80,"application_id",50,"item_id",71,"customer_id","C1","old_label","RC#1-1","new_label","NEW-BOX","result","RELABELLED","quantity",1,"operator_key","picker","operator_name","测试","occurred_at",at); + insert("ll_relabel_pda_history","id",82,"task_id",80,"application_id",50,"customer_id","C1","action","COMPLETE","to_status","WAIT_REVIEW","operator_key","picker","operator_name","测试","detail_json","{\"roundKey\":\"INITIAL\",\"photoFileIds\":[501]}"); + } + + /** 使用真实原入仓余额和仓位表示普通已换标仓托;原生退货箱记录不参与此场景。 */ + void warehousePalletSource() { + db.update("DELETE FROM ll_return_inventory_carton_item"); db.update("DELETE FROM ll_return_inventory_carton"); + db.update("UPDATE ll_outbound_pallet_item SET warehouse_pallet_id=501,pallet_no='PL-501',carton_marks='BOX-ORD' WHERE id=40"); + db.update("UPDATE ll_relabel_item SET return_carton_id=NULL,source_inventory_position_id=101,source_inventory_balance_id=601,source_inbound_order_id=701,source_inbound_order_no='IN-701',source_inbound_code='IN-701' WHERE id=71"); + db.update("UPDATE ll_relabel_pda_unit_fact SET old_label='BOX-ORD' WHERE id=81"); + insert("ll_order","id",701,"customer_id","C1","order_no","ORIGINAL-INBOUND","source_region","OVERSEAS","source_inbound_id","IN-701","source_event_id","IN-701","source_loading_plan_id","IN-701","source_payload_hash","b".repeat(64),"current_status","INBOUND_COMPLETED"); + insert("ll_inventory_balance","id",601,"customer_id","C1","customer_name","测试客户","overseas_warehouse_id","1","inbound_order_id",701,"inbound_code","IN-701","hold_packages",1); + insert("ll_inventory_position","id",101,"customer_id","C1","balance_id",601,"overseas_warehouse_id","1","pallet_id",501,"pallet_no","PL-501","warehouse_location_code","LOC-1","hold_packages",1); + } + + /** 每次取消经同一个实际Spring事务入口。 */ + void cancel() { service.cancelOutbound("OUT1","OP1",new DeliveryRequests.Cancel("cancel")); } + /** 合成数据只用于测试准备,生产取消全部通过真实仓储和Mapper。 */ + void insert(String table,Object... fields) { + var columns = new ArrayList(); var values = new ArrayList(); + for(int i=0;i>> proof() { + return List.of(db.queryForList("SELECT status,delivery_method,customer_confirmed_at,customer_confirmation_key,wallet_status FROM ll_relabel_application"), + db.queryForList("SELECT relabel_application_id,relabel_no,relabel_status,relabel_completed_at,total_quantity,sku_types FROM ll_return_inventory_carton"), + db.queryForList("SELECT * FROM ll_return_inventory_carton_item"), + db.queryForList("SELECT status,reviewed_at,CAST(completion_photo_file_ids AS VARCHAR) photos FROM ll_relabel_pda_task"), + db.queryForList("SELECT * FROM ll_relabel_pda_unit_fact"), + db.queryForList("SELECT id,action,CAST(detail_json AS VARCHAR) detail FROM ll_relabel_pda_history"), + db.queryForList("SELECT * FROM ll_inventory_balance"),db.queryForList("SELECT * FROM ll_inventory_position"), + db.queryForList("SELECT * FROM ll_inventory_ledger")); + } + /** 全部可变安排记录在失败和重放时必须与原提交状态一致。 */ + List>> state() { + return List.of(db.queryForList("SELECT status,outbound_order_no,fulfillment_status,updated_at FROM ll_relabel_application"), + db.queryForList("SELECT status,updated_at FROM ll_return_inventory_carton"), + db.queryForList("SELECT status,updated_at FROM ll_outbound_order"),db.queryForList("SELECT status FROM ll_outbound_pallet_item"), + db.queryForList("SELECT status,updated_at FROM ll_outbound_pda_task"),db.queryForList("SELECT status,version,updated_at FROM ll_delivery_order"), + db.queryForList("SELECT current_status,outbound_order_no,delivery_order_no,version FROM ll_order"),db.queryForList("SELECT * FROM ll_delivery_operation"), + List.of(Map.of("proof",proof()))); + } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index f4b40f5d8..daf2c0e44 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -16,6 +16,57 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { + /** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */ + @Test + void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception { + String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class, + String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value()); + int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)"); + int end = sql.indexOf(" AS overseasWarehouseId", start); + assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影"); + String expression = sql.substring(start, end).replace(">", ">").replace("<", "<"); + assertTrue(expression.endsWith("END AS CHAR(128))")); + assertTrue(expression.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')")); + String warehouseFilter = sql.substring(sql.lastIndexOf(") selected WHERE ") + 1) + .replace("#{warehouseId}", "?").replace("", ""); + String query = "SELECT selected.planPalletId FROM (SELECT p.id AS planPalletId," + expression + + " AS overseasWarehouseId FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id)" + + warehouseFilter; + try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) { + var statement = connection.createStatement(); + statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)"); + statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)"); + statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))"); + statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))"); + statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL)," + + "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL)," + + "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21)," + + "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)"); + statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL)," + + "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)"); + statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001')," + + "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001')," + + "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')"); + statement.execute("INSERT INTO ll_order VALUES (20,'A','193000000000200002')," + + "(20,'B','193000000000100001'),(21,'B','193000000000100001')"); + try (var prepared = connection.prepareStatement(query)) { + assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L)); + assertWarehouseRows(prepared, "193000000000200002", List.of(8L)); + for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong")) + assertWarehouseRows(prepared, invalid, List.of()); + } + } + } + + /** 每次实际执行同一参数化仓过滤,禁止用前缀、数字强转或其他客户库存替代身份。 */ + private void assertWarehouseRows(java.sql.PreparedStatement prepared, String warehouse, List expected) throws Exception { + prepared.setString(1, warehouse); + var actual = new ArrayList(); + try (var rows = prepared.executeQuery()) { while (rows.next()) actual.add(rows.getLong(1)); } + actual.sort(Long::compareTo); + assertEquals(expected, actual, warehouse); + } + @Test void ordinaryAndReturnRelabelCandidatesExcludeDirectOutboundDeliveryModes() throws Exception { for (String method : List.of("relabelAvailable", "nativeReturnRelabelAvailable")) { @@ -200,7 +251,9 @@ class DispatchCandidateMapperContractTest { String sql = String.join(" ", method.getAnnotation(Select.class).value()); assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL")); assertTrue(sql.contains("pos.overseas_warehouse_id")); - assertTrue(sql.contains("NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')")); + assertTrue(sql.contains(method.getName().equals("available") + ? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')" + : "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR),'')")); assertTrue(sql.contains("source_order.id=ip.source_order_id")); assertTrue(sql.contains("source_order.customer_id=ip.customer_id")); assertTrue(sql.contains("source_order.overseas_warehouse_id")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java index eb21f2262..a63efb2d8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchPhysicalWeightTest.java @@ -16,7 +16,7 @@ class DispatchPhysicalWeightTest { jdbc.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(64),gross_weight_kg DECIMAL(12,3))"); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(line_id BIGINT)"); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1)"); - int tested = 0; + var tested = new java.util.HashSet(); for (var method : DispatchCandidateMapper.class.getDeclaredMethods()) { var annotation = method.getAnnotation(Select.class); if (annotation == null) continue; @@ -43,8 +43,8 @@ class DispatchPhysicalWeightTest { jdbc.update("INSERT INTO ll_order_detail VALUES('10',2,40)"); assertThat(jdbc.queryForObject(mixed, BigDecimal.class)).as(method.getName() + " complete aggregate").isEqualByComparingTo("40"); jdbc.update("DELETE FROM ll_order_detail"); - tested++; + tested.add(method.getName()); } - assertThat(tested).isEqualTo(2); + assertThat(tested).containsExactlyInAnyOrder("available","wholeAvailable","readSelected","lockSelected"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java index 3034312e9..a9a5012fa 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetDispatchPlanningAdapterTest.java @@ -1,32 +1,39 @@ package com.baiyue.lastleg.dispatch.fleet; - -import com.baiyue.lastleg.dispatch.service.DispatchService; +import com.baiyue.lastleg.dispatch.service.*; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.fleet.trip.*; -import org.junit.jupiter.api.Test; +import com.baiyue.common.security.AuthenticatedUser; +import org.junit.jupiter.api.*; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; import java.math.BigDecimal; -import java.util.List; +import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; - class FleetDispatchPlanningAdapterTest { - private final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class); - private final DispatchService service=mock(DispatchService.class); - private final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class); - private final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,service,gateway); + final FleetDispatchPlanningMapper mapper=mock(FleetDispatchPlanningMapper.class); + final DispatchSourceRepository sources=mock(DispatchSourceRepository.class); + final DispatchService rules=mock(DispatchService.class); + final DispatchAggregateService main=mock(DispatchAggregateService.class); + final FleetDispatchGateway gateway=mock(FleetDispatchGateway.class); + final FleetDispatchPlanningAdapter adapter=new FleetDispatchPlanningAdapter(mapper,sources,rules,gateway,main); + @AfterEach void clear(){SecurityContextHolder.clearContext();} private FleetBookingIncomingGateway.Evidence receipt(){return new FleetBookingIncomingGateway.Evidence("WT-1","IN-1","92","FBA:ACY2","READY_FOR_DISPATCH","hash",List.of(new FleetBookingIncomingGateway.Pallet("1","P-1",10,BigDecimal.TEN,BigDecimal.ONE)),10);} - @Test void refusesAmbiguousSourcesWithoutCreatingAnotherDispatch() { - when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1","DS-2")); - assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("多张排车"); - verifyNoInteractions(service); + @Test void keepsOtherCustomerAndOtherInboundCargoWhileVerifyingTheExactReceiptSubset(){ + when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","92",50)); + when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"),new FleetDispatchGateway.SourceCargo("OTHER-IN","2","P-2",7,"FBA:ONT8"))); + adapter.validateIncomingDispatch("DS-1","9",receipt());verify(gateway).sourceCargo("DS-1","9");verifyNoInteractions(main); + when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2"))); + assertThatThrownBy(()->adapter.validateIncomingDispatch("DS-1","9",receipt())).hasMessageContaining("未完整覆盖"); } - @Test void reusesOnlyAnExactExistingShipment() { - when(mapper.sourceDispatches("9","IN-1")).thenReturn(List.of("DS-1")); - when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","9","92",10)); - when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"))); - assertThat(adapter.resolveIncoming("9",receipt(),"1")).isEqualTo("DS-1");verifyNoInteractions(service); - when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",9,"FBA:ACY2"))); - assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("全部实收"); - when(gateway.sourceCargo("DS-1")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-other","1","P-1",10,"FBA:ACY2"))); - assertThatThrownBy(()->adapter.resolveIncoming("9",receipt(),"1")).hasMessageContaining("其他来源"); + @Test void joinsTheTripExistingMainAndDoesNotCreateASecondCustomerDispatch(){ + var user=new AuthenticatedUser(1L,0L,0L,1L,"operator","PLATFORM","WEB",Set.of());SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,null,List.of())); + when(mapper.dispatchForTrip("TR-1")).thenReturn("DS-1");var context=mock(DispatchContracts.Context.class);when(context.dispatchId()).thenReturn(1L);when(main.lockForArrangement(any(),eq("DS-1"))).thenReturn(context); + when(mapper.selectedPlans(1,"9")).thenReturn(List.of(10L));when(mapper.receivedPlanPallets("9","IN-1")).thenReturn(List.of(10L)); + when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","92",10));when(gateway.sourceCargo("DS-1","9")).thenReturn(List.of(new FleetDispatchGateway.SourceCargo("IN-1","1","P-1",10,"FBA:ACY2"))); + assertThat(adapter.resolveIncoming("TR-1","9",receipt(),"1","booking-original")).isEqualTo("DS-1");verify(main,never()).create(any(),any());verify(main,never()).addCargo(any(),any(),any());verifyNoInteractions(sources,rules); + assertThatThrownBy(()->adapter.resolveIncoming("TR-1","9",receipt(),"forged","booking-original")).hasMessageContaining("操作者"); } + @Test void wrongWarehouseCannotBeAcceptedByMatchingReceiptTotals(){when(gateway.lockForPlanning("DS-1")).thenReturn(new FleetDispatchGateway.Snapshot(1,"DS-1","OTHER",10));assertThatThrownBy(()->adapter.validateIncomingDispatch("DS-1","9",receipt())).hasMessageContaining("起运仓");verify(gateway,never()).sourceCargo(any(),any());} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java index 47ef4aa44..14ec6e0fb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/FleetPlanningReadPersistenceTest.java @@ -16,7 +16,7 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.*; class FleetPlanningReadPersistenceTest { - @Test void candidatesPaginateOnlyEligibleSameWarehouseUnboundCargoAndSourcesPreserveUnknowns() throws Exception { + @Test void externalSourcesKeepActualCustomerAndUnknownQuantities() 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_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(64),customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),package_count INT,status VARCHAR(32),picked_package_count INT,shipped_package_count INT,updated_at TIMESTAMP,route VARCHAR(500))"); @@ -38,9 +38,6 @@ class FleetPlanningReadPersistenceTest { var interceptor=new MybatisPlusInterceptor();interceptor.addInnerInterceptor(new PaginationInnerInterceptor(com.baomidou.mybatisplus.annotation.DbType.H2));configuration.addInterceptor(interceptor); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(configuration); var session=new SqlSessionTemplate(factory.getObject());var mapper=session.getMapper(FleetDispatchPlanningMapper.class); - var page=mapper.candidates(new Page<>(1,1),"TR-1","92",null); - assertThat(page.getTotal()).isEqualTo(2);assertThat(page.getRecords()).hasSize(1); - assertThat(mapper.candidates(new Page<>(1,100),"TR-1","92","DS-1").getRecords()).extracting(r->r.dispatchNo()).containsExactly("DS-1"); var resolver=new FleetPoolSourceResolver(session.getMapper(FleetPoolOrderMapper.class),new ObjectMapper()); var sources=resolver.resolve(List.of(new FleetPoolSourceResolver.SourceRef("9","FB-5"),new FleetPoolSourceResolver.SourceRef("9","FB-6"),new FleetPoolSourceResolver.SourceRef("OTHER","FB-6"))); assertThat(sources).hasSize(2); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapperWarehouseBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapperWarehouseBindingTest.java new file mode 100644 index 000000000..3a233f672 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapperWarehouseBindingTest.java @@ -0,0 +1,110 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.util.List; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 实际Mapper查询仓/客户/实托交集;MySQL不同排序规则另由只读真实库探针验证。 */ +class DispatchSourceMapperWarehouseBindingTest { + private static final long WAREHOUSE = 193000000000200003L; + private static final long PHYSICAL_PALLET = 2112349761578209281L; + private static final long PLAN = 2112349764502786049L; + private static final long PLAN_PALLET = 2112349764502786050L; + private static final String CUSTOMER = "2112349740878327809"; + private static final String SECOND_CUSTOMER = "2112349740878327810"; + private JdbcTemplate db; + private DispatchSourceMapper mapper; + + @BeforeEach + void setUp() throws Exception { + var data = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + db = new JdbcTemplate(data); + // 仅保留本查询所需列,类型/长度与lastleg.sql一致:计划仓BIGINT,库存仓VARCHAR(128)。 + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),warehouse_pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128),pallet_id BIGINT)"); + plan(PLAN, PLAN_PALLET, CUSTOMER, CUSTOMER, WAREHOUSE, PHYSICAL_PALLET); + position(1, CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET); + var config = new MybatisConfiguration(); + config.addMapper(DispatchSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(data); + factory.setConfiguration(config); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class); + } + + @Test + void longStringStockWarehouseMatchesTheSameNumericPlanWarehouseWithoutLosingIdentity() { + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))) + .containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER)); + } + + @Test + void requestedWarehouseMustMatchTheRealStockPosition() { + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE + 1), List.of(PHYSICAL_PALLET))).isEmpty(); + } + + @Test + void matchingRequestedStockWarehouseDoesNotAuthorizeAPlanInAnotherWarehouse() { + db.update("UPDATE ll_inbound_plan SET overseas_warehouse_id=? WHERE id=?", WAREHOUSE + 1, PLAN); + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty(); + } + + @Test + void samePhysicalPalletCannotBorrowAnotherCustomersInventoryPosition() { + db.update("UPDATE ll_inventory_position SET customer_id=? WHERE id=1", SECOND_CUSTOMER); + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty(); + } + + @Test + void matchingHeaderAndPositionCannotBypassThePlanPalletCustomer() { + db.update("UPDATE ll_inbound_plan_pallet SET customer_id=? WHERE id=?", SECOND_CUSTOMER, PLAN_PALLET); + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))).isEmpty(); + } + + @Test + void completeSharedPalletCompositionRetainsBothSeparatelyOwnedCustomerPlans() { + plan(PLAN + 10, PLAN_PALLET + 10, SECOND_CUSTOMER, SECOND_CUSTOMER, WAREHOUSE, PHYSICAL_PALLET); + position(2, SECOND_CUSTOMER, Long.toString(WAREHOUSE), PHYSICAL_PALLET); + // 无本仓库存的第三份计划不能混入当前组成。 + plan(PLAN + 20, PLAN_PALLET + 20, "2112349740878327811", "2112349740878327811", WAREHOUSE, PHYSICAL_PALLET); + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET))) + .containsExactly(new DispatchSourceMapper.PlanReference(PLAN_PALLET, CUSTOMER), + new DispatchSourceMapper.PlanReference(PLAN_PALLET + 10, SECOND_CUSTOMER)); + } + + @Test + void aMatchingWarehouseAndCustomerCannotBypassTheRequestedPhysicalPalletSet() { + assertThat(mapper.physicalPlans(Long.toString(WAREHOUSE), List.of(PHYSICAL_PALLET + 1))).isEmpty(); + } + + @ParameterizedTest + @ValueSource(strings = {"0193000000000200003", "193000000000200003x", "193000000000200003.0"}) + void pseudoWarehouseIdentifiersAreNotAcceptedThroughNumericCoercion(String pseudoWarehouse) { + db.update("UPDATE ll_inventory_position SET overseas_warehouse_id=? WHERE id=1", pseudoWarehouse); + // 请求与库存字符串确实相同;必须仍因不等于计划仓的完整字符身份而拒绝。 + assertThat(mapper.physicalPlans(pseudoWarehouse, List.of(PHYSICAL_PALLET))).isEmpty(); + } + + private void plan(long planId, long planPalletId, String headerCustomer, String palletCustomer, + long warehouse, long physicalPallet) { + db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)", planId, headerCustomer, warehouse); + db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?)", planPalletId, planId, palletCustomer, physicalPallet); + } + + private void position(long positionId, String customer, String warehouse, long pallet) { + db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?)", positionId, customer, warehouse, pallet); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java index 18e7ae307..05632112f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPdaWorkflowServiceTest.java @@ -1,380 +1,40 @@ package com.baiyue.lastleg.dispatch.pda.service; - -import com.baiyue.lastleg.delivery.entity.OutboundOrder; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; -import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; -import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaRequests; -import com.baiyue.lastleg.dispatch.pda.dto.DispatchPdaResponses; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaOperation; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaOperationMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaPickMapper; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; -import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; -import com.fasterxml.jackson.databind.ObjectMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge; +import com.baiyue.lastleg.relabel.service.RelabelApplicationService; +import org.junit.jupiter.api.*; import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.util.HexFormat; -import java.util.List; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertFalse; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class DispatchPdaWorkflowServiceTest { - @Mock DispatchOrderMapper orders; - @Mock OutboundOrderMapper outboundOrders; - @Mock OutboundPalletItemMapper outboundPalletItems; - @Mock DispatchItemMapper items; - @Mock DispatchPdaTaskMapper tasks; - @Mock DispatchPdaPickMapper picks; - @Mock DispatchPdaOperationMapper operations; - @Mock DispatchPalletLockMapper palletLocks; - @Mock InventoryPositionMapper positions; - @Mock InventoryCommandService inventory; - @Mock DispatchPickOrderAttributionService attribution; - @Mock com.baiyue.lastleg.dispatch.service.ReturnRelabelDispatchStock returnStock; - @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; - private DispatchPdaWorkflowService service; - private ObjectMapper objectMapper; - - @BeforeEach - void setUp() { - service = new DispatchPdaWorkflowService(); - ReflectionTestUtils.setField(service, "deliveryExecution", org.mockito.Mockito.mock(com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard.class)); - org.springframework.test.util.ReflectionTestUtils.setField(service, "fleetPool", org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge.class)); - ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); - objectMapper = new ObjectMapper(); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); - ReflectionTestUtils.setField(service, "outboundPalletItems", outboundPalletItems); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "tasks", tasks); - ReflectionTestUtils.setField(service, "picks", picks); - ReflectionTestUtils.setField(service, "operations", operations); - ReflectionTestUtils.setField(service, "palletLocks", palletLocks); - ReflectionTestUtils.setField(service, "positions", positions); - ReflectionTestUtils.setField(service, "inventory", inventory); - ReflectionTestUtils.setField(service, "pickAttribution", attribution); - ReflectionTestUtils.setField(service, "returnRelabelStock", returnStock); - ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + final DispatchPdaWorkflowService service=new DispatchPdaWorkflowService(); + final DispatchPickingRepository picks=mock(DispatchPickingRepository.class); + final DispatchSourceDepartureRepository source=mock(DispatchSourceDepartureRepository.class); + final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class); + final FleetPoolOrderBridge fleet=mock(FleetPoolOrderBridge.class); + final RelabelApplicationService relabels=mock(RelabelApplicationService.class); + final DispatchContracts.Actor actor=new DispatchContracts.Actor("operator",DispatchTypes.ActorMode.PLATFORM,null); + @BeforeEach void setup(){ReflectionTestUtils.setField(service,"wholePicks",picks);ReflectionTestUtils.setField(service,"wholeDeparture",source);ReflectionTestUtils.setField(service,"pickAttribution",attribution);ReflectionTestUtils.setField(service,"fleetPool",fleet);ReflectionTestUtils.setField(service,"relabelApplications",relabels);} + @Test void departureAdvancesEachRealCustomerAndUsesItsOwnImmutablePicks(){ + var context=context(List.of(cargo("102",22L,5,null),cargo("101",11L,3,null))); + var a=List.of(new DispatchPdaPick());var b=List.of(new DispatchPdaPick());when(picks.picks("101",1)).thenReturn(a);when(picks.picks("102",1)).thenReturn(b); + var at=Instant.parse("2026-10-19T10:00:00Z");service.departWholeSources(context,actor,result("101","102"),at); + var local=LocalDateTime.ofInstant(at,ZoneOffset.UTC);verify(attribution).depart("101","OUT-101","DP-ONE",a,local);verify(attribution).depart("102","OUT-102","DP-ONE",b,local); + verify(source).transferOrder("101",11L,local);verify(source).transferOrder("102",22L,local); + verify(fleet).departed("101","DP-ONE",Map.of(11L,3),"operator",local);verify(fleet).departed("102","DP-ONE",Map.of(22L,5),"operator",local); + verifyNoInteractions(relabels); } - - @Test - void labelsTransferredPalletsWithOriginalAndCurrentFbaWarehouses() { - assertEquals("ONT8 -> ONT", DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ONT")); - assertNull(DispatchPdaWorkflowService.warehouseRouteLabel("ONT8", "ont8")); - assertNull(DispatchPdaWorkflowService.warehouseRouteLabel(null, "ONT")); + @Test void missingActualPicksCannotAdvanceAnySourceOrFleetOrder(){ + assertThatThrownBy(()->service.departWholeSources(context(List.of(cargo("101",11L,3,null))),actor,result("101"),Instant.now())).hasMessageContaining("真实扫码");verifyNoInteractions(source,attribution,fleet,relabels); } - - @Test - void taskListUsesOnlyTokenBoundWarehouseAndResolvedCustomer() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); - DispatchPdaTask task = task(11L, "100", "WH-1", "PDA_PENDING"); - when(tasks.listByWarehouse("WH-1", null)).thenReturn(List.of(task)); - when(orders.selectById(1L)).thenReturn(order); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of()); - - List result = service.list("WH-1", null); - - assertEquals(1, result.size()); - assertEquals("DSP-1", result.get(0).dispatchNo()); - verify(tasks).listByWarehouse("WH-1", null); - } - - @Test - void detailRejectsDispatchFromAnotherWarehouseBeforeCustomerLookup() { - when(orders.findGlobalByDispatchNo("DSP-1")) - .thenReturn(order("100", "DSP-1", "WH-2", "PENDING_DEPARTURE")); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.task("WH-1", "DSP-1")); - - assertEquals(404, exception.getStatusCode().value()); - verify(tasks, never()).findByDispatch(any()); - } - - @Test - void completedTaskExposesTransferDeliveryGateBeforeDeparture() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - DispatchItem item = item(21L, "100", 6); - item.setInboundCode("IN-1"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(tasks.findByDispatch(1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); - - DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1"); - - assertFalse(result.canDepart()); - assertEquals("请先在平台完成“转派送”,生成待出库单后才能发车。", result.departBlockReason()); - } - - @Test - void completedTaskExposesDepartureWhenPendingOutboundExists() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - DispatchItem item = item(21L, "100", 6); - OutboundOrder outbound = new OutboundOrder(); - outbound.setStatus("WAIT_OUTBOUND"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(tasks.findByDispatch(1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); - when(outboundOrders.findByDispatch("100", 1L)).thenReturn(outbound); - - DispatchPdaResponses.Task result = service.task("WH-1", "DSP-1"); - - assertTrue(result.canDepart()); - assertNull(result.departBlockReason()); - } - - @Test - void exactPickRetryReturnsPersistedResultAfterTaskAutomaticallyCompleted() throws Exception { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - task.setOperatorId("7"); - DispatchPdaRequests.Pick request = new DispatchPdaRequests.Pick(31L, 2, 2, "pick-1", null); - DispatchPdaResponses.Picked persisted = new DispatchPdaResponses.Picked(21L, 31L, 2, 2, false, false); - DispatchPdaOperation replay = operation("PICK", request, persisted); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(tasks.lockByDispatch(1L)).thenReturn(task); - when(operations.findKey("100", "pick-1")).thenReturn(replay); - when(orders.lockById("100", 1L)).thenReturn(order); - - DispatchPdaResponses.Picked result = service.pick("WH-1", "DSP-1", 21L, "7", "Picker", request); - - assertEquals(2, result.packages()); - verify(fleetBindings, never()).requirePickingApproved(anyLong()); - verify(inventory, never()).allocate(any(), any(), any()); - verify(picks, never()).insert((DispatchPdaPick) any()); - verify(items, never()).bindReceivedPallets(any(), any()); - var locks = org.mockito.Mockito.inOrder(orders, tasks); - locks.verify(orders).findGlobalByDispatchNo("DSP-1"); - locks.verify(orders).lockById("100", 1L); - locks.verify(tasks).lockByDispatch("100", 1L); - } - - @Test - void completionRejectsPartialPickingWithoutStatusDrift() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "PICKING"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "PICKING"); - DispatchItem item = item(21L, "100", 6); - DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(3L); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(tasks.lockByDispatch(1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("done-1"))); - - assertEquals(409, exception.getStatusCode().value()); - assertEquals("PICKING", order.getStatus()); - verify(orders, never()).updateById((DispatchOrder) any()); - } - - @Test - void automaticallyCompletedTaskPersistsFirstCompleteKeyThenReplaysIt() throws Exception { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - DispatchItem item = item(21L, "100", 6); - DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L); - DispatchPdaRequests.CompletePicking request = new DispatchPdaRequests.CompletePicking("done-1"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(tasks.lockByDispatch(1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); - - DispatchPdaResponses.PickingCompleted first = service.completePicking("WH-1", "DSP-1", request); - assertFalse(first.replayed()); - verify(operations).insert((DispatchPdaOperation) any()); - - DispatchPdaOperation replay = operation("COMPLETE_PICKING", request, first); - when(operations.findKey("100", "done-1")).thenReturn(replay); - DispatchPdaResponses.PickingCompleted second = service.completePicking("WH-1", "DSP-1", request); - assertTrue(second.replayed()); - } - - @Test - void departRequiresFullyCompletedPickingAndPendingOutbound() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "PARTIAL_READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - OutboundOrder outbound = new OutboundOrder(); outbound.setStatus("WAIT_OUTBOUND"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now())); - - assertEquals(409, exception.getStatusCode().value()); - verify(outboundOrders, never()).updateById((OutboundOrder) any()); - } - - @Test void pendingDispatchReviewBlocksPickingAndCompletionBeforeInventoryChanges() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); order.setVehiclePlate("CA-100"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(tasks.lockByDispatch(1L)).thenReturn(task(11L, "100", "WH-1", "PDA_PENDING")); - org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先通过调度审核再拣货")) - .when(fleetBindings).requirePickingApproved(1L); - assertThrows(ResponseStatusException.class, () -> service.pick("WH-1", "DSP-1", 21L, "7", "作业员", new DispatchPdaRequests.Pick(31L, 2, 0, "pending-pick", null))); - assertThrows(ResponseStatusException.class, () -> service.completePicking("WH-1", "DSP-1", new DispatchPdaRequests.CompletePicking("pending-complete"))); - org.mockito.Mockito.verifyNoInteractions(inventory, picks, attribution); - verify(orders, never()).updateById((DispatchOrder) any()); - } - - @Test void boundDispatchCannotDepartBeforeTripCoordination() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); order.setVehiclePlate("CA-100"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "按车次统一发车")) - .when(fleetBindings).requireCoordinatedDeparture(1L); - assertThrows(ResponseStatusException.class, () -> service.depart("WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now())); - org.mockito.Mockito.verifyNoInteractions(attribution, inventory, outboundOrders); - verify(orders, never()).updateById((DispatchOrder) any()); - } - - @Test - void successfulDeparturePersistsBusinessDepartureTime() { - DispatchOrder order = order("100", "DSP-1", "WH-1", "READY_TO_DEPART"); - order.setVehiclePlate("CA-100"); - DispatchPdaTask task = task(11L, "100", "WH-1", "COMPLETED"); - DispatchItem item = item(21L, "100", 6); - item.setInboundCode("IN-1"); - DispatchPdaPick pick = new DispatchPdaPick(); pick.setPackages(6L); - OutboundOrder outbound = new OutboundOrder(); - outbound.setId(1L); - outbound.setOutboundNo("OB-1"); outbound.setStatus("WAIT_OUTBOUND"); - OutboundPalletItem outboundItem = new OutboundPalletItem(); - outboundItem.setWarehousePalletId(41L); outboundItem.setInboundCode("IN-1"); - outboundItem.setPackageCount(6); outboundItem.setPickedPackageCount(0); outboundItem.setStatus("WAIT_PICK"); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); - when(tasks.lockByDispatch(1L)).thenReturn(task); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - when(picks.totalItem("100", 11L, 21L)).thenReturn(6L); - when(picks.listTask("100", 11L)).thenReturn(List.of(pick)); - when(outboundPalletItems.findByOutbound("100", 1L)).thenReturn(List.of(outboundItem)); - when(palletLocks.selectList(any())).thenReturn(List.of()); - - DispatchPdaResponses.Departed result = service.depart( - "WH-1", "DSP-1", "7", new DispatchPdaRequests.Depart("depart-1", null), java.time.LocalDateTime.now()); - - assertEquals("DEPARTED", result.status()); - assertNotNull(order.getDepartAt()); - assertEquals(order.getDepartAt(), order.getUpdatedAt()); - verify(orders).updateById(order); - assertEquals("DEPARTED", order.getStatus()); - assertEquals("OUTBOUNDED", outbound.getStatus()); - assertEquals(6, outboundItem.getPickedPackageCount()); - assertEquals("PICKED", outboundItem.getStatus()); - verify(outboundPalletItems).updateById(outboundItem); - } - - @Test - void exactDepartRetryReturnsPersistedResultAfterStatusChanged() throws Exception { - DispatchOrder order = order("100", "DSP-1", "WH-1", "DEPARTED"); - DispatchPdaRequests.Depart request = new DispatchPdaRequests.Depart("depart-1", null); - DispatchPdaResponses.Departed persisted = new DispatchPdaResponses.Departed("DSP-1", "DEPARTED", 12, false); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(operations.findKey("100", "depart-1")).thenReturn(operation("DEPART", request, persisted)); - - DispatchPdaResponses.Departed result = service.depart("WH-1", "DSP-1", "7", request, java.time.LocalDateTime.now()); - - assertEquals("DEPARTED", result.status()); - assertTrue(result.replayed()); - } - - private DispatchPdaOperation operation(String type, Object request, Object result) throws Exception { - DispatchPdaOperation value = new DispatchPdaOperation(); - value.setTaskId(11L); value.setOperationType(type); value.setStatus("COMPLETED"); - value.setRequestHash(HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") - .digest(objectMapper.writeValueAsString(request).getBytes(StandardCharsets.UTF_8)))); - value.setResultJson(objectMapper.writeValueAsString(result)); - return value; - } - - @Test void nativeBoxPickLocksOrderBeforeStockAndNeverUsesOrdinaryInventory() { - var order = order("100", "DSP-1", "WH-1", "PENDING_DEPARTURE"); - order.setVehiclePlate("CA-100"); - var task = task(11L, "100", "WH-1", "PDA_PENDING"); - var item = item(21L, "100", 2); - item.setSourceStage("RETURN_RELABEL"); item.setSourceBusinessType("RELABEL_ORDER"); item.setOrderId(101L); - when(orders.findGlobalByDispatchNo("DSP-1")).thenReturn(order); - when(orders.lockById("100", 1L)).thenReturn(order); - when(tasks.lockByDispatch(1L)).thenReturn(task); - when(items.selectById(21L)).thenReturn(item); - var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); carton.setId(71L); - when(returnStock.pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"), - org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1"))).thenReturn(carton); - var result = service.pick("WH-1", "DSP-1", 21L, "7", "作业员", - new DispatchPdaRequests.Pick(null, 1, 3, "native-pick", null, "RC1#2-1")); - assertEquals(1L, result.pickedPackages()); - var sequence = org.mockito.Mockito.inOrder(attribution, returnStock, picks); - sequence.verify(attribution).prepareNativeReturn("100", 101L); - sequence.verify(returnStock).pick(org.mockito.ArgumentMatchers.eq(item), org.mockito.ArgumentMatchers.eq("RC1#2-1"), - org.mockito.ArgumentMatchers.eq(1L), org.mockito.ArgumentMatchers.eq(3L), anyLong(), org.mockito.ArgumentMatchers.eq("DSP-1")); - sequence.verify(picks).insert(org.mockito.ArgumentMatchers.argThat((DispatchPdaPick p) -> p.getReturnCartonId().equals(71L) && p.getInventoryPositionId() == null)); - sequence.verify(attribution).attributeNativeReturn(org.mockito.ArgumentMatchers.eq("100"), any(), org.mockito.ArgumentMatchers.eq(101L)); - org.mockito.Mockito.verifyNoInteractions(inventory, positions); - } - - private static DispatchOrder order(String customerId, String dispatchNo, String warehouseId, String status) { - DispatchOrder value = new DispatchOrder(); - value.setId(1L); value.setCustomerId(customerId); value.setDispatchNo(dispatchNo); - value.setOverseasWarehouseId(warehouseId); value.setStatus(status); - return value; - } - - private static DispatchPdaTask task(Long id, String customerId, String warehouseId, String status) { - DispatchPdaTask value = new DispatchPdaTask(); - value.setId(id); value.setDispatchOrderId(1L); - value.setTaskNo("DPDA-1"); value.setWarehouseId(warehouseId); value.setStatus(status); - return value; - } - - private static DispatchItem item(Long id, String customerId, int packages) { - DispatchItem value = new DispatchItem(); - value.setId(id); value.setCustomerId(customerId); value.setDispatchOrderId(1L); - value.setOverseasWarehouseId("WH-1"); value.setWarehousePalletId(41L); value.setPackageCount(packages); - return value; + @Test void missingCustomerOutboundCannotBorrowAnotherCustomersDocument(){ + assertThatThrownBy(()->service.departWholeSources(context(List.of(cargo("101",11L,3,null))),actor,result("102"),Instant.now())).hasMessageContaining("本客户实际出库单");verifyNoInteractions(picks,source,attribution,fleet,relabels); } + private static DispatchContracts.CargoLine cargo(String customer,long order,long packages,String relabel){var c=mock(DispatchContracts.CargoLine.class);when(c.customerId()).thenReturn(customer);when(c.orderId()).thenReturn(order);when(c.packages()).thenReturn(packages);when(c.relabelNo()).thenReturn(relabel);return c;} + private static DispatchContracts.Context context(List cargo){var c=mock(DispatchContracts.Context.class);when(c.cargo()).thenReturn(cargo);when(c.dispatchId()).thenReturn(1L);when(c.dispatchNo()).thenReturn("DP-ONE");return c;} + private static DispatchDeliveryModels.DepartureResult result(String... customers){return new DispatchDeliveryModels.DepartureResult(Arrays.stream(customers).map(c->new DispatchDeliveryModels.OutboundReference(c,null,"OUT-"+c,"OUTBOUNDED",0,0)).toList(),List.of(),0);} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java index 9b3f1ec74..663b46821 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionServiceTest.java @@ -62,7 +62,7 @@ class DispatchPickOrderAttributionServiceTest { when(orders.increasePicked(eq("t1"), anyLong(), anyLong())).thenReturn(1); DispatchPdaPick pick = pick(81L, 91L, 5L, 50L); - List result = service.attribute("t1", pick); + List result = service.attribute("t1", pick, null); assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 3L), new DispatchPickOrderAttributionService.OrderPackages(102L, 2L)), result); @@ -94,7 +94,7 @@ class DispatchPickOrderAttributionServiceTest { when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(2L,102L,1L,1L), receipt(1L,101L,1L,1L),receipt(3L,102L,1L,1L))); when(orders.lockById(eq("t1"),anyLong())).thenAnswer(call -> new LastlegOrder()); - service().preparePicking("t1",91L,null); + service().preparePicking("t1",91L); var sequence=inOrder(attributions,orders); sequence.verify(attributions).availableReceipts("t1",91L); sequence.verify(orders).lockById("t1",101L);sequence.verify(orders).lockById("t1",102L); @@ -105,15 +105,15 @@ class DispatchPickOrderAttributionServiceTest { when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L))); var held=new LastlegOrder();held.setDetained(true); when(orders.lockById("t1",101L)).thenReturn(held).thenReturn(null); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L)); verifyNoInteractions(history); } @Test void changedSourceSetAfterLockingFailsClosed() { when(attributions.availableReceipts("t1",91L)).thenReturn(List.of(receipt(1L,101L,1L,1L))) .thenReturn(List.of(receipt(2L,102L,1L,1L))); when(orders.lockById("t1",101L)).thenReturn(new LastlegOrder()); - assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L,null)); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service().preparePicking("t1",91L)); verify(orders,never()).increasePicked(anyString(),anyLong(),anyLong()); } @@ -125,22 +125,22 @@ class DispatchPickOrderAttributionServiceTest { when(orders.increasePicked("t1", 101L, 2L)).thenReturn(1); var service = service(); assertEquals(List.of(new DispatchPickOrderAttributionService.OrderPackages(101L, 2L)), - service.attribute("t1", pick(81L, 91L, 2L, 0L), null, 201L)); + service.attribute("t1", pick(81L, 91L, 2L, 0L), 201L)); var capture = ArgumentCaptor.forClass(DispatchPdaPickOrder.class); verify(attributions).insert(capture.capture()); assertEquals(201L, capture.getValue().getReturnReceiptId()); assertEquals(null, capture.getValue().getReceiptId()); when(returnReceipts.picked("t1", 201L)).thenReturn(2L); assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> service.attribute("t1", pick(82L, 91L, 1L, 0L), null, 201L)); + () -> service.attribute("t1", pick(82L, 91L, 1L, 0L), 201L)); } - @Test void selectedReturnCannotUseFallbackOrLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() { + @Test void selectedReturnCannotLeavePartialAttributionWhenOnlyTwoOfThreeAreAvailable() { var receipt = new com.baiyue.lastleg.inventory.entity.ReturnStockReceipt(); receipt.setId(201L); receipt.setOrderId(101L); receipt.setReceivedPackages(2L); when(returnReceipts.available("t1", 91L)).thenReturn(List.of(receipt)); when(returnReceipts.lock("t1", 201L)).thenReturn(receipt); assertThrows(org.springframework.web.server.ResponseStatusException.class, - () -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 101L, 201L)); + () -> service().attribute("t1", pick(81L, 91L, 3L, 0L), 201L)); verify(attributions, never()).insert(any(DispatchPdaPickOrder.class)); verify(orders, never()).increasePicked(anyString(), anyLong(), anyLong()); verify(attributions, never()).availableReceipts(anyString(), anyLong()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java index 7157a438c..b70efee54 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPortCompileContract.java @@ -36,6 +36,9 @@ public final class DispatchPortCompileContract { } catch (IllegalArgumentException expected) { require(expected.getMessage().contains("recordVersion"), "明确说明缺失版本"); } + require(!fields(DispatchCommands.Update.class).contains("vehicleAppointment"), "更新不保留车辆预约字段或空占位"); + require(!fields(DispatchViews.DispatchInfo.class).contains("vehicleAppointment"), "排车信息不保留车辆预约字段或空占位"); + require(fields(DispatchViews.DispatchInfo.class).containsAll(Set.of("fbaAppointments","appointmentBatches")), "原FBA预约及批次仍保留"); require(!fields(DispatchCommands.Create.class).contains("customerId"), "整车创建无单客户头部"); require(!fields(DispatchContracts.Context.class).contains("customerId"), "整车上下文无伪客户"); require(fields(DispatchContracts.CargoLine.class).contains("customerId"), "真实来源保留客户"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java index d05d8ca5a..0928101c6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java @@ -22,7 +22,7 @@ public final class DispatchPublicJsonContract { /** 测试执行器不允许实例化。 */ private DispatchPublicJsonContract() { } - /** 覆盖缺版本、显式零、预约只写引用及所有动作结果持久JSON恢复。 */ + /** 覆盖缺版本、显式零、移除车辆预约字段及所有动作结果持久JSON恢复。 */ public static void main(String[] args) throws Exception { ObjectMapper requests = mapper(); for (Class request : new Class[] { @@ -42,11 +42,8 @@ public final class DispatchPublicJsonContract { Object zero = requests.readValue("{\"recordVersion\":0}", request); require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法"); } - var reference = requests.readValue("{\"referenceNo\":\"EXISTING-AP\",\"recordVersion\":0}", - DispatchCommands.AppointmentReference.class); - require("EXISTING-AP".equals(reference.referenceNo()), "预约只保留权威引用"); - rejectsMissing(requests, DispatchCommands.AppointmentReference.class, - "{\"referenceNo\":\"EXISTING-AP\",\"recordVersion\":0,\"status\":\"CONFIRMED\",\"appointmentAt\":\"2026-10-19T00:00:00Z\"}"); + rejectsMissing(requests, DispatchCommands.Update.class, + "{\"recordVersion\":0,\"vehicleAppointment\":{\"referenceNo\":\"REMOVED\",\"recordVersion\":0}}"); Instant now = Instant.parse("2026-10-19T00:00:00Z"); var outbound = new OutboundReference("101", "客户甲", "OB-CONTRACT", "OUTBOUNDED", 10, 1); @@ -80,7 +77,7 @@ public final class DispatchPublicJsonContract { codec.decode("{\"action\":\"PICK\",\"recordVersion\":0,\"result\":{\"dispatchNo\":\"DP-CONTRACT\"}}"); throw new AssertionError("损坏或错误动作结果不得恢复为成功"); } catch (IllegalStateException expected) { require(expected.getCause() != null, "保留损坏原因"); } - System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; appointment reference; all action JSON restart/replay)"); + System.out.println("DispatchPublicJsonContract: PASS (16 requests and 2 batch records missing/null/negative/zero; vehicle appointment removed; FBA batch references retained; all action JSON restart/replay)"); } /** 与正式Long按字符串输出约定共同验证账本恢复。 */ @@ -97,7 +94,7 @@ public final class DispatchPublicJsonContract { private static void rejectsMissing(ObjectMapper mapper, Class type, String payload) throws Exception { try { mapper.readValue(payload, type); } catch (JsonProcessingException expected) { return; } - throw new AssertionError("请求必须拒绝缺失/非法版本或伪造预约字段:" + type.getSimpleName()); + throw new AssertionError("请求必须拒绝缺失/非法版本或已移除字段:" + type.getSimpleName()); } /** 离线合同失败时终止,不模拟接口成功。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 020dec6fb..3f4290dcf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -35,6 +35,166 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 主单/资源/占用/账本真实SQL回归;外部目录与选货权威提供者使用受控输入。 */ public class DispatchAggregatePersistenceTest { + /** 正式司机附件从排车冻结引用选出真实司机,再复用司机域及File的读取边界。 */ + @Test void masterDriverEvidenceUsesFrozenDriverOwnerForMetadataAndContent() throws Exception { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L)); + e.allowFile(81L);var before=e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number); + assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L); + assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3); + verify(e.files,times(2)).metadata(71L,81L);verify(e.files,times(2)).download(71L,81L);verifyNoInteractions(e.dispatchFiles); + assertThat(e.f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?",number)).isEqualTo(before); + } + + /** 主档后来换证不改写本车冻结证照;旧附件必须仍满足该司机的历史关联规则。 */ + @Test void frozenHistoricalDriverLicenseRemainsReadableAfterMasterReplacement() throws Exception { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L)); + e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(1L);e.allowFile(81L); + assertThat(e.f.service.evidenceMetadata(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L).fileId()).isEqualTo(81L); + assertThat(e.f.service.evidenceContent(e.f.actor,number,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,81L)).containsExactly(1,2,3); + verify(e.history,times(2)).selectCount(any());verify(e.files,times(2)).metadata(71L,81L);verifyNoInteractions(e.dispatchFiles); + } + + /** 外配附件仍限本车证据;不能因为共用DRIVER展示类型而改走正式司机档案。 */ + @Test void externalDriverEvidenceRetainsExactDispatchOwnerForBothReads() throws Exception { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.EXTERNAL,null,List.of(81L)); + var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; + var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(81L,"external.pdf","application/pdf",3L); + when(e.dispatchFiles.metadata(dispatch,kind,81L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,81L)).thenReturn(new byte[]{4,5,6}); + assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).isEqualTo(metadata); + assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,81L)).containsExactly(4,5,6); + verify(e.dispatchFiles).metadata(dispatch,kind,81L);verify(e.dispatchFiles).download(dispatch,kind,81L); + verifyNoInteractions(e.driverRows,e.history,e.files); + } + + /** 冻结引用不绕过司机当前或历史关联,提供者明确拒绝后不得尝试排车类别。 */ + @Test void unlinkedFrozenMasterFileCannotFallBackToDispatchEvidence() throws Exception { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L)); + e.driver.setLicenseFileId(82L);when(e.history.selectCount(any())).thenReturn(0L); + var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; + assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("未关联"); + assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("未关联"); + verifyNoInteractions(e.files,e.dispatchFiles); + } + + /** File拒绝主档四元组时直接失败,不以另一业务类别或其他司机重新读取。 */ + @Test void masterFileProviderRejectionCannotFallBackToAnotherCategory() throws Exception { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L)); + when(e.files.metadata(71L,81L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配")); + var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; + assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配"); + assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("归属不匹配"); + verify(e.files,never()).download(anyLong(),anyLong());verifyNoInteractions(e.dispatchFiles); + } + + /** MASTER改外配的编辑尚未保存时,新上传文件只走本车归属,不误借旧司机白名单。 */ + @Test void unsavedExternalUploadOnMasterDispatchRetainsOriginalDispatchFileReads() throws Exception { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L)); + var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; + var metadata=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"new-external.pdf","application/pdf",3L); + when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenReturn(metadata);when(e.dispatchFiles.download(dispatch,kind,91L)).thenReturn(new byte[]{4,5,6}); + assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).isEqualTo(metadata); + assertThat(e.f.service.evidenceContent(e.f.actor,number,kind,91L)).containsExactly(4,5,6); + verifyNoInteractions(e.driverRows,e.history,e.files); + assertThat(e.f.db.queryForObject("SELECT resources_json FROM ll_dispatch_order WHERE dispatch_no=?",String.class,number)).contains("MASTER").doesNotContain("91"); + } + + /** 未列入MASTER冻结集合或声明为外配的档案文件只能接受本车四元组核验,失败后不转司机域。 */ + @Test void foreignOrWrongModeMasterFilesNeverGainDriverDomainAccess() throws Exception { + for(var mode:List.of(ResourceMode.MASTER,ResourceMode.EXTERNAL)) { + var e=new DriverEvidenceFixture();var number=e.dispatch(mode,71L,mode==ResourceMode.MASTER?List.of(81L):List.of(91L)); + var dispatch=e.f.repository.find(number).getId();var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; + when(e.dispatchFiles.metadata(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配")); + when(e.dispatchFiles.download(dispatch,kind,91L)).thenThrow(new BizException(com.baiyue.common.core.api.ResultCode.NOT_FOUND,"业务附件不存在或归属不匹配")); + assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配"); + assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,91L)).hasMessageContaining("归属不匹配"); + verifyNoInteractions(e.driverRows,e.history,e.files); + } + } + + /** 命中冻结文件集合仍须有本车真实正数司机身份,不能拿车号或其他司机替代。 */ + @Test void masterFileWithMissingOrInvalidDriverIdentityFailsBeforeAnyFileProvider() throws Exception { + for(Long driverId:Arrays.asList(null,0L,-1L)) { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,driverId,List.of(81L)); + var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; + assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效"); + assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("身份无效"); + verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles); + } + } + + /** 无法解释的资源模式不选择任何文件域,防止异常快照放宽读取。 */ + @Test void unknownDriverModeFailsBeforeEitherFileProvider() throws Exception { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L)); + e.f.db.update("UPDATE ll_dispatch_order SET resources_json=REPLACE(resources_json,'MASTER','UNKNOWN') WHERE dispatch_no=?",number); + var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; + assertThatThrownBy(()->e.f.service.evidenceMetadata(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败"); + assertThatThrownBy(()->e.f.service.evidenceContent(e.f.actor,number,kind,81L)).hasMessageContaining("快照读取失败"); + verifyNoInteractions(e.driverRows,e.history,e.files,e.dispatchFiles); + } + + /** 使用真实排车Mapper/资源JSON及司机Service,只替代外部文件内容和主档持久提供者。 */ + private static final class DriverEvidenceFixture { + final Fixture f;final DriverMapper driverRows=mock(DriverMapper.class);final DriverHistoryMapper history=mock(DriverHistoryMapper.class); + final DriverFileGateway files=mock(DriverFileGateway.class);final DriverEntity driver=new DriverEntity(); + final com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway dispatchFiles; + /** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */ + DriverEvidenceFixture() throws Exception { + f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver); + var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class)); + org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers); + dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence"); + } + /** 在真实主单保存服务端资源快照,HTTP请求本身不能指定司机、模式或允许文件集合。 */ + String dispatch(ResourceMode mode,Long driverId,List ids) throws Exception { + var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of()))); + var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789", + new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z")))); + f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo(); + } + /** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */ + void allowFile(long fileId) { + var metadata=new DriverModels.File(Long.toString(fileId),"driver.pdf","application/pdf",3L); + when(files.metadata(71L,fileId)).thenReturn(metadata);when(files.download(71L,fileId)).thenReturn(new byte[]{1,2,3}); + } + } + /** 空单零版本取消使用真实来源规则,成功及原键重放均不制造来源、库存或重复命令。 */ + @Test void emptyVersionZeroCancellationUsesRealSourceRuleAndPreservesOriginalReplay() throws Exception { + var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-real-cancel",List.of()))); + var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial); + when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空")); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules); + assertThat(created.recordVersion()).isZero(); + var request=new DispatchCommands.ReasonCommand(0L,"empty-cancel-original","取消尚未添加货物的空车"); + var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)); + assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING); + assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-cancel-original'",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("CANCELLED"); + verifyNoInteractions(facts,sources,serial); + } + + /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ + @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { + var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); + var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注"); + var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)); + assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero(); + assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); + assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='empty-edit-original'",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + } + /** 移货、取消只释放本车原占用;重排后旧键恢复不得释放新车的活动实托键。 */ @Test void removalCancellationAndOldCommandRecoveryNeverReleaseNewTruckClaims() throws Exception { var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); @@ -42,10 +202,10 @@ public class DispatchAggregatePersistenceTest { long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class); var removeRequest=new DispatchCommands.ReasonCommand(created.recordVersion(),"remove-original","重新分车"); var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest)); - assertThat(removed.counts().packageCount()).isEqualTo(5);assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE warehouse_pallet_id=501 AND active_warehouse_pallet_id IS NOT NULL",Long.class)).isZero(); + assertThat(removed.counts().packageCount()).isEqualTo(5);assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isZero(); var newOwner=f.tx.execute(s->f.service.create(f.actor,f.create("new-owner",List.of(a)))); assertThat(f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,removeRequest)).replayed()).isTrue(); - assertThat(f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(f.repository.find(newOwner.dispatchNo()).getId()); + assertThat(f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(f.repository.find(newOwner.dispatchNo()).getId()); var cancelRequest=new DispatchCommands.ReasonCommand(removed.recordVersion(),"cancel-original","本趟取消"); var cancelled=f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest)); assertThat(cancelled.status()).isEqualTo(Status.CANCELLED);assertThat(cancelled.stage()).isEqualTo(Stage.BEFORE_LOADING); @@ -54,6 +214,23 @@ public class DispatchAggregatePersistenceTest { assertThat(f.tx.execute(s->f.service.cancel(f.actor,created.dispatchNo(),cancelRequest)).replayed()).isTrue(); assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2); } + @Test void cancelledOutboundHistoryDoesNotBlockZeroPickCancellationButAnActiveDocumentDoes() throws Exception { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3);var created=f.tx.execute(t->f.service.create(f.actor,f.create("cancel-history",List.of(source))));var root=f.repository.find(created.dispatchNo()); + var outbound=new com.baiyue.lastleg.delivery.entity.OutboundOrder();outbound.setId(123L);outbound.setCustomerId("101");outbound.setOutboundNo("OUT-HISTORY");outbound.setDispatchOrderId(root.getId());outbound.setStatus("WAIT_OUTBOUND");outbound.setPackageCount(3);outbound.setPalletCount(1);f.session.getMapper(OutboundOrderMapper.class).insert(outbound); + var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-history-original","取消未执行排车"); + assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request))).hasMessageContaining("关联出库"); + f.db.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=123"); + var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request));assertThat(cancelled.status()).isEqualTo(Status.CANCELLED); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isOne();assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero(); + } + @Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception { + var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input)); + var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划"); + var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); + var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划"); + var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue(); + assertThat(f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + } /** 作业读取返回真实明细身份,路线保存重排实际站序且保留起运仓。 */ @Test void operationItemsAndRouteUseActualIdsAndPersistedStopOrder() throws Exception { var f=new Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); @@ -223,7 +400,7 @@ public class DispatchAggregatePersistenceTest { var config=new MybatisConfiguration(); config.setMapUnderscoreToCamelCase(true); var mapperTypes=List.of(DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchExecutionReadMapper.class,DispatchCommandMapper.class,DispatchSourceMapper.class, DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,FleetResourceMapper.class, - OutboundOrderMapper.class,DeliveryOrderMapper.class,DispatchCandidateMapper.class,ReturnDispatchCandidateMapper.class); + OutboundOrderMapper.class,DeliveryOrderMapper.class,DispatchCandidateMapper.class,ReturnDispatchCandidateMapper.class,DispatchPhysicalClaimMapper.class,DispatchItemSourceMapper.class); mapperTypes.forEach(config::addMapper); var factory=new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); factory.afterPropertiesSet(); session=new SqlSessionTemplate(factory.getObject()); @@ -233,19 +410,19 @@ public class DispatchAggregatePersistenceTest { heads.add(column+" "+type(field.getType())+(column.equals("id")?" PRIMARY KEY":"")); } db.execute("CREATE TABLE ll_dispatch_order("+String.join(",",heads)+",UNIQUE(dispatch_no),UNIQUE(trip_id))"); - for(var mapper:List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,OutboundOrderMapper.class,DeliveryOrderMapper.class)) + for(var mapper:List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,DispatchPhysicalClaimMapper.class,DispatchItemSourceMapper.class,FleetTripMapper.class,FleetTripDispatchMapper.class,FleetTripHistoryMapper.class,OutboundOrderMapper.class,DeliveryOrderMapper.class)) for(var parent:mapper.getGenericInterfaces()) if(parent instanceof java.lang.reflect.ParameterizedType generic && generic.getRawType()==com.baomidou.mybatisplus.core.mapper.BaseMapper.class) { var table=TableInfoHelper.getTableInfo((Class)generic.getActualTypeArguments()[0]); var columns=new ArrayList(); columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); for(var field:table.getFieldList()) if(!table.getTableName().equals("ll_fleet_trip_dispatch") || !Set.of("customer_id","review_json").contains(field.getColumn())) columns.add(field.getColumn()+" "+type(field.getPropertyType())); db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")"); } db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)"); - db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD active_warehouse_pallet_id BIGINT UNIQUE"); + db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)"); db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)"); db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))"); db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))"); db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)"); - db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,packages BIGINT,product_quantity BIGINT)"); + db.execute("CREATE TABLE ll_dispatch_pda_pick_order(id BIGINT,customer_id VARCHAR(64),pick_id BIGINT,order_id BIGINT,receipt_id BIGINT,return_receipt_id BIGINT,return_carton_id BIGINT,dispatch_item_source_id BIGINT,packages BIGINT,product_quantity BIGINT)"); db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),order_no VARCHAR(128),source_region VARCHAR(64),source_inbound_id VARCHAR(128))"); db.execute("CREATE TABLE ll_inbound_stock_receipt(id BIGINT,customer_id VARCHAR(64),inbound_code VARCHAR(128))"); db.execute("CREATE TABLE ll_return_stock_receipt(id BIGINT,customer_id VARCHAR(64),inventory_balance_id BIGINT,order_id BIGINT,inventory_position_id BIGINT,received_packages BIGINT)"); @@ -273,10 +450,16 @@ public class DispatchAggregatePersistenceTest { fleet=new FleetTripExecutionService(session.getMapper(FleetTripMapper.class),session.getMapper(FleetTripDispatchMapper.class),session.getMapper(FleetResourceMapper.class),gateway,fleetService,drivers,vehicles,json,reservations); org.springframework.test.util.ReflectionTestUtils.setField(fleet,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); var rules=mock(DispatchService.class); + var emptySnapshot=new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"EMPTY",true,List.of()); + when(rules.lockExecutionSources(anyString(),anyList())).thenReturn(emptySnapshot); when(rules.lockWholeSources(eq("9"),anyList())).thenAnswer(invocation -> { List request=invocation.getArgument(1); return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList(); }); + when(rules.lockAdditionalSources(eq("9"),anyList(),anyList())).thenAnswer(invocation -> { + List request=invocation.getArgument(1); + return request.stream().map(s->new DispatchService.WholeSource(candidates.get(s.planPalletId()),s,"PLAN:"+s.planPalletId(),s.sourceVersion())).toList(); + }); service=new DispatchAggregateService(repository, new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json); @@ -285,6 +468,10 @@ public class DispatchAggregatePersistenceTest { org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class))); executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(service,"executionReads",executionReads); + var origins=new DispatchItemSourceRepository(session.getMapper(DispatchItemSourceMapper.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"sourceOrigins",origins); + org.springframework.test.util.ReflectionTestUtils.setField(service,"relabelSources",new DispatchItemSourceService(origins,mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class),json)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"physicalClaims",new DispatchPhysicalClaimService(new DispatchPhysicalClaimRepository(session.getMapper(DispatchPhysicalClaimMapper.class)))); } /** 提供者固定事实代表真实客户来源,调用请求自身没有客户字段。 */ @@ -322,7 +509,7 @@ public class DispatchAggregatePersistenceTest { db.update("INSERT INTO ll_order VALUES(?,?,?,'OVERSEAS',?)",9000+source,customer,"ORDER-"+source,"SOURCE-"+source); db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?)",source,customer,"IN-"+source); db.update("INSERT INTO ll_dispatch_pda_pick VALUES(?,?,?,?,?,?)",source,customer,item,pallet*10,packages,packages*2); - db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES(?,?,?,?,?,NULL,NULL,?,?)",source,customer,source,9000+source,source,packages,packages*2); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,?,?,?,?,?,?)",source,customer,source,9000+source,source,packages,packages*2); } /** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java index d8daae1ca..bd34fb202 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java @@ -29,6 +29,16 @@ class DispatchAppointmentAllocationsTest { verify(provider).lockBatches(new TreeMap<>(Map.of("FBA_APPOINTMENT:9",4L,"FBA_APPOINTMENT:2",0L))); verifyNoMoreInteractions(provider); } + @Test void bulkCandidatesUseEveryRealOwnerOnceAndRequireACompleteAuthorityResponse() { + var provider=mock(AppointmentWorkDispatchPort.class);var service=new DispatchAppointmentAllocations(provider); + var a=new com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate().customerId("101").planPalletId(11L); + var b=new com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate().customerId("102").planPalletId(12L); + var identities=List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",11L),new AppointmentWorkDispatchPort.CandidatePhysical("102",12L)); + var references=Map.of(identities.get(0),new AppointmentWorkDispatchPort.CandidateBatches(List.of(new AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:7","AP-7",0L,"101",List.of(new AppointmentWorkDispatchPort.BatchSource("ORDER:1","FBA:ONT8",3)))),List.of(),false),identities.get(1),new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of("原批次待确认"),false)); + when(provider.candidateBatches(identities)).thenReturn(references); + assertThat(service.candidates(List.of(a,b,a))).isEqualTo(references);verify(provider,times(1)).candidateBatches(identities); + when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整"); + } private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List allocations) { return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java index 8390f535f..495aa39e0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentCancellationPersistenceTest.java @@ -1,57 +1,47 @@ package com.baiyue.lastleg.dispatch.service; - -import com.baomidou.mybatisplus.core.MybatisConfiguration; -import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; -import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; -import com.baiyue.lastleg.dispatch.mapper.*; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import com.baiyue.lastleg.fleet.trip.*; -import com.baiyue.lastleg.inboundplan.work.AppointmentWorkDispatchMapper; -import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; import org.junit.jupiter.api.Test; -import org.mybatis.spring.SqlSessionTemplate; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.datasource.*; import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.transaction.support.TransactionTemplate; import java.util.*; -import java.time.LocalDateTime; -import java.math.BigDecimal; import static org.assertj.core.api.Assertions.*; -import static org.mockito.Mockito.mock; - +import static org.mockito.Mockito.*; +/** 主单内部桥接使用实际主单、明细、资源、占用、命令和历史事务;Provider SQL由其直接测试覆盖。 */ class DispatchAppointmentCancellationPersistenceTest { - /** 实际取消只解除有效预约运输占用,历史引用和后来重排的占用都保留。 */ - @Test void cancellationReleasesEffectiveBatchAllocationAndReplayCannotReleaseTheNewDispatch() throws Exception { - var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); - var db=new JdbcTemplate(data);var tx=new TransactionTemplate(new DataSourceTransactionManager(data)); - var config=new MybatisConfiguration();var mappers=List.of(DispatchOrderMapper.class,DispatchItemMapper.class,DispatchPalletLockMapper.class,OutboundOrderMapper.class,FleetTripDispatchMapper.class,AppointmentWorkDispatchMapper.class); - mappers.forEach(config::addMapper);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet();var session=new SqlSessionTemplate(factory.getObject()); - for(var mapper:mappers)for(var parent:mapper.getGenericInterfaces()) { - if(!(parent instanceof java.lang.reflect.ParameterizedType p)||p.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue; - var table=TableInfoHelper.getTableInfo((Class)p.getActualTypeArguments()[0]);var columns=new ArrayList();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); - table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType())));db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")"); + @Test void removingOneCustomersOriginalCargoKeepsTruckAndSiblingAndRecoversTheOriginalCommand() throws Exception { + var s=new Scenario();var request=new DispatchCommands.ReasonCommand(s.created.recordVersion(),"appointment-remove-original","原预约整票移出"); + var removed=s.f.tx.execute(tx->{s.f.service.lockExistingAppointmentDispatches(s.f.actor,List.of(s.created.dispatchNo()));return s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),s.expected(),List.of(),true);}); + assertThat(removed.status()).isEqualTo(DispatchTypes.Status.ACTIVE);assertThat(removed.counts().packageCount()).isEqualTo(5); + assertThat(s.f.db.queryForObject("SELECT customer_id FROM ll_dispatch_item",String.class)).isEqualTo("102"); + assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch",Long.class)).isOne();assertThat(s.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isZero();assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=502",Long.class)).isOne(); + long history=s.count("ll_dispatch_history");assertThat(s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),s.expected(),List.of(),true)).replayed()).isTrue();assertThat(s.count("ll_dispatch_history")).isEqualTo(history); + verify(s.provider,times(1)).removeItem(s.root,s.item); + } + @Test void parentFailureRollsBackCargoClaimsReviewsHistoryAndChildLedger() throws Exception { + var s=new Scenario();long commands=s.count("ll_dispatch_command"),history=s.count("ll_dispatch_history"); + assertThatThrownBy(()->s.f.tx.execute(tx->{s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),new DispatchCommands.ReasonCommand(s.created.recordVersion(),"parent-rollback","原预约回滚"),s.physical(),s.expected(),List.of(),true);throw new IllegalStateException("parent rejected");})).hasMessageContaining("parent rejected"); + assertThat(s.count("ll_dispatch_item")).isEqualTo(2);assertThat(s.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Long.class)).isEqualTo(8);assertThat(s.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(s.created.recordVersion());assertThat(s.count("ll_dispatch_command")).isEqualTo(commands);assertThat(s.count("ll_dispatch_history")).isEqualTo(history); + assertThat(s.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(2); + } + @Test void wrongOwnerPartialQuantityOrChangedOriginalAllocationCannotMutateAnyCargo() throws Exception { + var s=new Scenario();var request=new DispatchCommands.ReasonCommand(s.created.recordVersion(),"reject-wrong-original","范围核验"); + for(var physical:List.of(List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(s.item,"102",1L,"RECEIVED",3)),List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(s.item,"101",1L,"RECEIVED",2))))assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,physical,s.expected(),List.of(),true))).hasMessageContaining("完整数量"); + assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),request,s.physical(),List.of(),List.of(),true))).hasMessageContaining("分配集合");assertThat(s.count("ll_dispatch_item")).isEqualTo(2);verifyNoInteractions(s.provider); + } + @Test void realPickBlocksAppointmentRemovalEvenWhenMainCountersAreZero() throws Exception { + var s=new Scenario();s.f.picked(1,"101",501,1); + assertThatThrownBy(()->s.f.tx.execute(tx->s.f.service.applyExistingAppointmentChange(s.f.actor,s.created.dispatchNo(),new DispatchCommands.ReasonCommand(s.created.recordVersion(),"reject-picked","真实扫码"),s.physical(),s.expected(),List.of(),true))).hasMessageContaining("实际扫码");assertThat(s.count("ll_dispatch_item")).isEqualTo(2);verifyNoInteractions(s.provider); + } + private static final class Scenario { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture();final DispatchContracts.CommandResult created;final long root,item;final AppointmentWorkDispatchPort provider=mock(AppointmentWorkDispatchPort.class); + Scenario() throws Exception { + created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-main",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5)))));root=f.repository.find(created.dispatchNo()).getId();item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class); + f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(900,0,'AP-ORIGINAL')");f.db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",root,item); + ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); } - db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,plan_pallet_id BIGINT,batch_key VARCHAR(64),customer_id VARCHAR(64),source_key VARCHAR(64),destination_key VARCHAR(64),package_count INT)"); - db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(10,'C','DP-OLD','PENDING_DEPARTURE')"); - db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id,package_count) VALUES(30,'C',10,1,4)"); - db.update("INSERT INTO ll_outbound_order(id,customer_id,dispatch_order_id,status) VALUES(40,'C',10,'CANCELLED')"); - db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(50,'C',10,'ACTIVE','RECEIVED:501')"); - db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,30,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)"); - var allocations=session.getMapper(AppointmentWorkDispatchMapper.class); - assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4); - var service=new DispatchService();ReflectionTestUtils.setField(service,"orders",session.getMapper(DispatchOrderMapper.class));ReflectionTestUtils.setField(service,"items",session.getMapper(DispatchItemMapper.class));ReflectionTestUtils.setField(service,"locks",session.getMapper(DispatchPalletLockMapper.class));ReflectionTestUtils.setField(service,"outboundOrders",session.getMapper(OutboundOrderMapper.class));ReflectionTestUtils.setField(service,"fleetBindings",new FleetDispatchBindingGuard(session.getMapper(FleetTripDispatchMapper.class),new ObjectMapper()));ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class)); - tx.executeWithoutResult(s->service.cancel("DP-OLD")); - assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isZero(); - assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_appointment_allocation WHERE dispatch_id=10",Long.class)).isEqualTo(1); - db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(11,'C','DP-NEW','PENDING_DEPARTURE')"); - db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,status,active_key) VALUES(51,'C',11,'ACTIVE','RECEIVED:501')"); - db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(11,31,1,'FBA_APPOINTMENT:1','C','ORDER:1','FBA:ONT8',4)"); - tx.executeWithoutResult(s->service.cancel("DP-OLD")); - assertThat(allocations.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",999)).isEqualTo(4); - assertThat(db.queryForObject("SELECT status FROM ll_dispatch_pallet_lock WHERE id=51",String.class)).isEqualTo("ACTIVE"); - assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=50",String.class)).isNull(); + List physical(){return List.of(new DispatchAppointmentAmendmentPort.PhysicalItem(item,"101",1L,"RECEIVED",3));} + List expected(){return List.of(new DispatchContracts.BatchAllocation(1L,"FBA_APPOINTMENT:900","101","ORDER:9001","FBA:ONT8",3,0L));} + long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);} } - /** 测试表保留生产Mapper列映射,正式DDL由独立Schema合同验证。 */ - private static String sqlType(Class type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(4000)";} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index 6db7cd363..488b56e2f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -9,7 +9,8 @@ import static org.mockito.Mockito.when; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; +import java.util.Map; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; @@ -26,7 +27,7 @@ import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) class DispatchBolPrintServiceTest { - @Mock private DispatchService dispatches; + @Mock private DispatchBolReadService dispatches; @Mock private PrintPdfClient print; @Mock private DomesticWarehouseDirectoryClient warehouses; @InjectMocks private DispatchBolPrintService service; @@ -50,17 +51,13 @@ class DispatchBolPrintServiceTest { @Test void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() { String warehouseId = "193000000000200003"; - DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", - null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, - null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", - 1, 1, 1, 0, 0, 1, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), - List.of(new DispatchResponse.BolLine("TSTU3539519", "DP-260911-0027", "ABE2", "ABE2", - "NYEXX2609110214", null, null, 1)), null, List.of(), null); + DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, + LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1)),Map.of()); var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); - when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(dispatches.read(detail.dispatchNo())).thenReturn(detail); when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of(address)))); when(print.generateDispatchBol(eq("internal-test-token"), eq("request-1"), any())) @@ -79,18 +76,11 @@ class DispatchBolPrintServiceTest { @Test void createsOneBolDocumentPerFbaWarehouse() { String warehouseId = "193000000000200003"; - DispatchResponse detail = new DispatchResponse(1L, "DS-WH-20260911-624449", "DEV-23653906", - null, null, "五月开发验收车队", null, null, warehouseId, "洛杉矶仓", null, - null, null, null, LocalDateTime.of(2026, 9, 12, 2, 40, 20), "PENDING_DEPARTURE", - 2, 2, 2, 0, 0, 2, null, LocalDateTime.of(2026, 9, 11, 10, 0), List.of(), - List.of( - new DispatchResponse.BolLine("TSTU3539519", "DP-001", "ABE2", "ABE2 Warehouse", - "NYEXX2609110214", null, null, 1), - new DispatchResponse.BolLine("TSTU3539519", "DP-002", "ONT8", "ONT8 Warehouse", - "NYEXX2609110215", null, null, 1)), null, List.of(), null); + DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, + LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1)),Map.of()); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); - when(dispatches.platformDetail(detail.dispatchNo())).thenReturn(detail); + when(dispatches.read(detail.dispatchNo())).thenReturn(detail); when(warehouses.activeWarehouse(Long.parseLong(warehouseId), "internal-test-token")) .thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse, List.of()))); when(print.generateDispatchBol(eq("internal-test-token"), eq("request-2"), any())) @@ -105,4 +95,22 @@ class DispatchBolPrintServiceTest { .map(PrintPdfClient.Document::fbaWarehouseCode).toList()) .containsExactly("ABE2", "ONT8"); } + @Test + void keepsTwoCustomersAtTheSameDestinationInSeparateBolDocuments() { + var first=line("101","ABE2","ABE2 Warehouse",3);var second=line("102","ABE2","ABE2 Warehouse",5); + var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,List.of(first,second), + Map.of(first.groupKey(),"CONF-A",second.groupKey(),"CONF-B")); + when(dispatches.read(detail.dispatchNo())).thenReturn(detail); + var warehouse=new DomesticWarehouseDirectoryClient.Warehouse(9L,0L,"WH9","Operating Warehouse","OVERSEAS","US","ACTIVE",null,null); + when(warehouses.activeWarehouse(9L,"internal-test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse,List.of()))); + when(print.generateDispatchBol(eq("internal-test-token"),eq("two-customers"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + service.generate(detail.dispatchNo(),"two-customers"); + var request=ArgumentCaptor.forClass(PrintPdfClient.Request.class); + verify(print).generateDispatchBol(eq("internal-test-token"),eq("two-customers"),request.capture()); + assertThat(request.getValue().documents()).hasSize(2); + assertThat(request.getValue().documents().stream().map(PrintPdfClient.Document::isaNumber).toList()).containsExactly("CONF-A","CONF-B"); + } + private static DispatchBolData.Line line(String customer,String code,String name,int packages) { + return new DispatchBolData.Line(customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java new file mode 100644 index 000000000..098768f5a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.dispatch.service; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.fasterxml.jackson.databind.ObjectMapper; +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.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +class DispatchBolReadPersistenceTest { + @Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception { + var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10),(2,20)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A'),(20,'102','CN-B')"); + f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','IN-A'),(200,'102',2000,'ONT8','ONT8','IN-B')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,0,3),(2,200,0,5)"); + f.db.update("INSERT INTO ll_order VALUES(1000,'101','CN-A'),(2000,'102','CN-B')");f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')"); + f.item(11,"101",1,null,"FBA",null,3);f.item(12,"102",2,null,"FBA",null,5); + var result=f.service.read("DP-ONE");assertThat(result.bolLines()).extracting(l->l.customerId()).containsExactly("101","102");assertThat(result.bolLines()).extracting(l->l.packageCount()).containsExactly(3,5);assertThat(result.bolLines()).extracting(l->l.fbaShipmentId()).containsExactly("SHIP-A","SHIP-B"); + assertThat(result.bolLines().get(0).groupKey()).isNotEqualTo(result.bolLines().get(1).groupKey()); + } + @Test void partialReturnReceiptPrintsOnlySelectedQuantityAndPrivateActualAddress() throws Exception { + var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A')");f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','ORIGINAL-IN')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,8,8)"); + f.item(11,"101",1,99L,"PRIVATE","Actual receiving street",2); + var result=f.service.read("DP-ONE");assertThat(result.bolLines()).singleElement().satisfies(l->{assertThat(l.packageCount()).isEqualTo(2);assertThat(l.destinationLabel()).isEqualTo("Actual receiving street");assertThat(l.warehouseNo()).isEqualTo("RETURN-IN");}); + } + private static final class Fixture { + final JdbcTemplate db;final DispatchBolReadService service; + Fixture() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),destination_type VARCHAR(32),delivery_address VARCHAR(128),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(64),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64),package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT)");db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT,destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64))");db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(64),shipment_no VARCHAR(64),fba_tracking_no VARCHAR(64))"); + var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class); + var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row);when(roots.batches(1)).thenReturn(List.of());service=new DispatchBolReadService(roots,mapper,new ObjectMapper()); + } + void item(long id,String customer,long plan,Long returned,String type,String address,int packages){db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,?,NULL,?,?,NULL,?,'ONT8','ONT8','ONT8',?,?)",id,customer,plan,returned,type,address,"P-"+id,returned==null?"IN-"+customer:"RETURN-IN",packages);} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java index e6c8ea075..83efefece 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java @@ -10,10 +10,16 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.repository.*; import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort; +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.aop.framework.ProxyFactory; import org.springframework.jdbc.core.JdbcTemplate; @@ -21,6 +27,7 @@ import org.springframework.jdbc.datasource.DataSourceTransactionManager; import org.springframework.jdbc.datasource.DriverManagerDataSource; import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; import java.time.*; import java.util.*; @@ -45,11 +52,17 @@ class DispatchDraftPersistenceTest { private final AppointmentWorkDispatchPort appointmentProvider=mock(AppointmentWorkDispatchPort.class); private final Map versions=new ConcurrentHashMap<>(); private final Map physical=new ConcurrentHashMap<>(); + private final Map> completeGroups=new ConcurrentHashMap<>(); + private final Map choices=new ConcurrentHashMap<>(); private final List calls=Collections.synchronizedList(new ArrayList<>()); private DriverManagerDataSource dataSource; private JdbcTemplate db; private DispatchDraftRepository repository; private DispatchDraftService service; + private DispatchPhysicalClaimService physicalClaims; + private PhysicalPalletSerialPort palletSerial; + private DispatchSourceMapper actualClaimMapper; + private TransactionTemplate transactions; private Clock clock=Clock.systemUTC(); private Runnable beforeSourceLocks=()->{ }; private final AtomicInteger commands=new AtomicInteger(); @@ -61,35 +74,45 @@ class DispatchDraftPersistenceTest { db.execute("CREATE TABLE ll_dispatch_draft(id BIGINT PRIMARY KEY,draft_token VARCHAR(128) UNIQUE,operator_id VARCHAR(128),warehouse_id VARCHAR(64),record_version BIGINT,sources_json CLOB,plan_ids_json CLOB,containers_json CLOB,status VARCHAR(32),expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); db.execute("CREATE TABLE ll_dispatch_draft_container(id BIGINT PRIMARY KEY,draft_id BIGINT,draft_token VARCHAR(128),sequence_no INT,capacity INT,source_keys_json CLOB,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(draft_id,sequence_no))"); db.execute("CREATE TABLE ll_dispatch_draft_command(id BIGINT PRIMARY KEY,idempotency_key VARCHAR(128) UNIQUE,operator_id VARCHAR(128),action VARCHAR(32),target_token VARCHAR(128),request_hash CHAR(64),result_json CLOB,created_at TIMESTAMP)"); - db.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_pallet_key VARCHAR(128),active_key VARCHAR(128),plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,active_warehouse_pallet_id BIGINT UNIQUE,return_receipt_id BIGINT,dispatch_order_id BIGINT,draft_container_id BIGINT,draft_token VARCHAR(128),operator_id VARCHAR(128),status VARCHAR(32),locked_at TIMESTAMP,expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(customer_id,active_key))"); + db.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,physical_lock_id BIGINT,customer_id VARCHAR(64),source_pallet_key VARCHAR(128),active_key VARCHAR(128),plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,return_receipt_id BIGINT,dispatch_order_id BIGINT,draft_container_id BIGINT,draft_token VARCHAR(128),operator_id VARCHAR(128),status VARCHAR(32),locked_at TIMESTAMP,expires_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP,UNIQUE(customer_id,active_key))"); + db.execute("CREATE TABLE ll_dispatch_physical_pallet_lock(id BIGINT PRIMARY KEY,warehouse_id VARCHAR(128),warehouse_pallet_id BIGINT NOT NULL,active_warehouse_pallet_id BIGINT UNIQUE,dispatch_order_id BIGINT,draft_token VARCHAR(128),draft_container_id BIGINT,operator_id VARCHAR(128),status VARCHAR(32),expires_at TIMESTAMP,locked_at TIMESTAMP,released_at TIMESTAMP,created_at TIMESTAMP,updated_at TIMESTAMP)"); db.execute("CREATE TABLE draft_created_dispatch(dispatch_no VARCHAR(128) PRIMARY KEY,cargo_count INT)"); db.execute("CREATE TABLE draft_source_gate(id BIGINT PRIMARY KEY)"); + db.execute("CREATE TABLE ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))"); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); - for(var mapper:List.of(DispatchDraftMapper.class,DispatchDraftContainerMapper.class,DispatchDraftCommandMapper.class,DispatchDraftClaimMapper.class))config.addMapper(mapper); + for(var mapper:List.of(DispatchDraftMapper.class,DispatchDraftContainerMapper.class,DispatchDraftCommandMapper.class,DispatchDraftClaimMapper.class,DispatchPhysicalClaimMapper.class,PhysicalPalletSerialMapper.class,DispatchSourceMapper.class))config.addMapper(mapper); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(dataSource);factory.setConfiguration(config); var session=new SqlSessionTemplate(factory.getObject()); repository=new DispatchDraftRepository(session.getMapper(DispatchDraftMapper.class),session.getMapper(DispatchDraftContainerMapper.class), session.getMapper(DispatchDraftCommandMapper.class),session.getMapper(DispatchDraftClaimMapper.class)); + var physicalTarget=new DispatchPhysicalClaimService(new DispatchPhysicalClaimRepository(session.getMapper(DispatchPhysicalClaimMapper.class))); + var physicalProxy=new ProxyFactory(physicalTarget);physicalProxy.setProxyTargetClass(true); + physicalProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),new AnnotationTransactionAttributeSource())); + physicalClaims=(DispatchPhysicalClaimService)physicalProxy.getProxy(); + var serialTarget=new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(session.getMapper(PhysicalPalletSerialMapper.class))); + var serialProxy=new ProxyFactory(serialTarget);serialProxy.setProxyTargetClass(true); + serialProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource),new AnnotationTransactionAttributeSource())); + palletSerial=(PhysicalPalletSerialPort)serialProxy.getProxy(); + actualClaimMapper=session.getMapper(DispatchSourceMapper.class); + transactions=new TransactionTemplate(new DataSourceTransactionManager(dataSource)); service=proxy(); for(long id=1;id<=40;id++){versions.put(id,"version-"+id);physical.put(id,1000+id);db.update("INSERT INTO draft_source_gate VALUES(?)",id);} + when(sources.selectionGroup(anyString(),any())).thenAnswer(call->group(call.getArgument(1))); when(sources.lockDraftSources(anyString(),anyList())).thenAnswer(call->{ calls.add("sources");String warehouse=call.getArgument(0);List selections=call.getArgument(1); beforeSourceLocks.run(); - var result=new ArrayList(); - for(var s:selections){long sourceId=s.planPalletId()!=null?s.planPalletId():s.returnReceiptId()!=null?s.returnReceiptId():s.warehousePalletId()-1000; + var result=new ArrayList();var selectedKeys=selections.stream().map(DispatchService::wholeSourceKey).collect(java.util.stream.Collectors.toSet()); + for(var s:selections){long sourceId=sourceId(s); + if(!selectedKeys.containsAll(group(s).sources().stream().map(SelectionGroupSource::sourceKey).toList()))throw new IllegalStateException("真实完整组缺少成员"); db.queryForObject("SELECT id FROM draft_source_gate WHERE id=? FOR UPDATE",Long.class,sourceId); if(!Objects.equals(s.sourceVersion(),versions.get(sourceId)))throw new IllegalStateException("真实来源版本已变化"); if(!"92".equals(warehouse))throw new IllegalStateException("真实来源仓不一致"); - var row=new DispatchPalletCandidate().planPalletId(sourceId).warehousePalletId(physical.get(sourceId)) - .returnReceiptId(s.sourceType()==SourceType.RETURN_RECEIPT?sourceId:null) - .customerId(sourceId%2==0?"102":"101").customerName("客户"+sourceId).overseasWarehouseId("92") - .overseasWarehouseCode("WH").overseasWarehouseName("海外仓").packageCount(10).palletNo("P-"+sourceId) - .sourceStage(s.sourceType()==SourceType.RELABEL?"RELABEL":"RECEIVED").destinationType("FBA").destinationWarehouseCode(sourceId%2==0?"LAX9":"ONT8") - .fbaWarehouseCode(sourceId%2==0?"LAX9":"ONT8"); - result.add(new DispatchService.WholeSource(row,s,DispatchService.wholeSourceKey(s),s.sourceVersion()));} + result.add(whole(s));} + // 受控来源入口仍遵循真实生产顺序:先完整来源身份锁,再共同实托串行点。 + palletSerial.lock(selections.stream().map(DispatchCommands.CargoSelection::warehousePalletId).filter(Objects::nonNull).distinct().sorted().toList()); return result; }); - when(sourceClaims.claims(anyString(),anyString(),any(),any())).thenReturn(List.of()); + when(sourceClaims.claims(anyString(),anyString(),any())).thenReturn(List.of()); when(appointmentProvider.candidateBatches(anyList())).thenAnswer(call->{List items=call.getArgument(0);var result=new LinkedHashMap();for(var item:items)result.put(item,new AppointmentWorkDispatchPort.CandidateBatches(List.of(),List.of(),false));return result;}); doAnswer(call->{calls.add("resources");return null;}).when(aggregate).lockDraftCreationResources(any(),anyList()); when(aggregate.create(any(),any())).thenAnswer(call->{calls.add("create");DispatchCommands.Create request=call.getArgument(1);String no="DS-"+request.idempotencyKey(); @@ -139,6 +162,184 @@ class DispatchDraftPersistenceTest { assertThat(state.containers().get(0).sourceKeys()).containsExactly("RETURN:1","RELABEL:RL-2:1002"); } + /** 单来源请求一次加入同托完整两申请,客户与逻辑箱数独立但共享唯一物理占用。 */ + @Test void oneSelectionAtomicallyClaimsWholeRelabelGroupForMultipleCustomers(){ + relabelPair();var before=empty(1);String commandKey=key(); + var request=new DraftSelection(before.recordVersion(),commandKey,before.draftToken(),before.containers().get(0).draftContainerId(),choices.get(1L)); + var state=service.select(owner,request); + assertThat(state.recordVersion()).isEqualTo(before.recordVersion()+1); + assertThat(state.sources()).extracting(Candidate::customerId).containsExactly("101","102"); + assertThat(state.sources()).extracting(Candidate::sourceKey).containsExactly("RELABEL:RL-1:1001","RELABEL:RL-2:1001"); + assertThat(state.sources().stream().mapToLong(Candidate::availablePackages).sum()).isEqualTo(20); + assertThat(state.sources()).allSatisfy(candidate->assertThat(candidate.selectionGroup().sources()).hasSize(2)); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(DISTINCT physical_lock_id) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + assertThat(proxy().select(owner,request)).isEqualTo(state); + verify(sources).lockDraftSources("92",List.of(choices.get(1L),choices.get(2L))); + } + + /** 从组内任一成员解除时,同一命令结束全组逻辑来源并在最后成员后释放原物理占用。 */ + @Test void oneUnselectReleasesEveryFrozenMemberAndItsLastPhysicalClaim(){ + relabelPair();var state=select(empty(1),0,1);var before=state; + var request=new DraftSelection(state.recordVersion(),key(),state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(2L)); + versions.put(1L,"changed-after-freeze"); + var released=service.unselect(owner,request); + assertThat(released.recordVersion()).isEqualTo(before.recordVersion()+1); + assertThat(released.sources()).isEmpty();assertThat(released.containers().get(0).sourceKeys()).isEmpty(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='RELEASED' AND active_warehouse_pallet_id IS NULL",Integer.class)).isEqualTo(1); + assertThat(proxy().unselect(owner,request)).isEqualTo(released); + } + + /** 完整组缺少锁内成员或版本改变时不能留下部分来源、物理占用、命令结果或根版本。 */ + @Test void missingLockedMemberAndStaleGroupVersionRollBackAtomicSelection(){ + relabelPair();var state=empty(1);String failedKey=key(); + doReturn(List.of(whole(choices.get(1L)))).when(sources).lockDraftSources(eq("92"),anyList()); + assertThatThrownBy(()->service.select(owner,new DraftSelection(state.recordVersion(),failedKey,state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(1L)))) + .isInstanceOf(BizException.class); + assertThat(service.drafts(owner).get(0)).isEqualTo(state); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,failedKey)).isZero(); + versions.put(1L,"new-version"); + assertThatThrownBy(()->service.select(owner,new DraftSelection(state.recordVersion(),key(),state.draftToken(),state.containers().get(0).draftContainerId(),choices.get(1L)))) + .isInstanceOf(BizException.class); + assertThat(service.drafts(owner).get(0)).isEqualTo(state); + } + + /** 完整组即使跨两个实托也不能布局拆开;整组移动同步逻辑成员及全部物理拥有者。 */ + @Test void wholeGroupCannotSplitButCanMoveAllPhysicalPalletsToAnotherContainer(){ + completeGroups.put(1L,List.of(1L,2L));completeGroups.put(2L,List.of(1L,2L)); + var state=select(empty(2),0,1);var original=state;var a=state.containers().get(0);var b=state.containers().get(1); + assertThatThrownBy(()->service.saveSnapshot(owner,new DraftSnapshot(original.recordVersion(),key(),original.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),1,15,List.of("PLAN:1")),new DraftContainer(b.draftContainerId(),2,15,List.of("PLAN:2")))))) + .isInstanceOf(BizException.class); + assertThat(service.drafts(owner).get(0)).isEqualTo(original); + state=service.saveSnapshot(owner,new DraftSnapshot(state.recordVersion(),key(),state.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),1,15,List.of()),new DraftContainer(b.draftContainerId(),2,15,a.sourceKeys())))); + assertThat(state.recordVersion()).isEqualTo(original.recordVersion()+1); + assertThat(db.queryForList("SELECT draft_container_id FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)) + .containsExactlyInAnyOrder(b.draftContainerId(),b.draftContainerId()); + assertThat(db.queryForList("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)) + .containsExactlyInAnyOrder(b.draftContainerId(),b.draftContainerId()); + } + + /** 一个原容器内独立完整组可以部分移走,原地实托及其客户来源不应被桥接带走。 */ + @Test void movingOneCompleteGroupKeepsOtherPhysicalPalletInOriginalContainer(){ + var state=select(empty(2),0,1);state=select(state,0,2);var a=state.containers().get(0);var b=state.containers().get(1); + state=service.saveSnapshot(owner,new DraftSnapshot(state.recordVersion(),key(),state.draftToken(),List.of(),List.of( + new DraftContainer(a.draftContainerId(),1,15,List.of("PLAN:2")),new DraftContainer(b.draftContainerId(),2,15,List.of("PLAN:1"))))); + assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE' AND warehouse_pallet_id=1001",Long.class)).isEqualTo(b.draftContainerId()); + assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE' AND warehouse_pallet_id=1002",Long.class)).isEqualTo(a.draftContainerId()); + assertThat(db.queryForObject("SELECT draft_container_id FROM ll_dispatch_pallet_lock WHERE active_key='PLAN:2'",Long.class)).isEqualTo(a.draftContainerId()); + assertThat(state.sources()).hasSize(2); + } + + /** 还没有真实实托的计划独立计位,不制造物理占用记录。 */ + @Test void unmaterializedPlanKeepsLogicalIdentityWithoutSyntheticPhysicalClaim(){ + physical.remove(1L);var state=select(empty(1),0,1); + assertThat(state.sources()).hasSize(1);assertThat(state.sources().get(0).selection().warehousePalletId()).isNull(); + assertThat(state.sources().get(0).selectionGroup().pallets().get(0).warehousePalletId()).isNull(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key='PLAN:1' AND physical_lock_id IS NULL",Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock",Integer.class)).isZero(); + } + + /** 十六条逻辑来源共享一个实托仍只占一个容量位,箱数按每条真实贡献累计。 */ + @Test void capacityCountsOnePhysicalPalletAndAllLogicalPackages(){ + var members=java.util.stream.LongStream.rangeClosed(1,16).boxed().toList(); + for(var id:members){physical.put(id,1001L);completeGroups.put(id,members);} + var state=select(empty(1),0,1); + assertThat(state.containers().get(0).capacity()).isEqualTo(15); + assertThat(state.containers().get(0).sourceKeys()).hasSize(16); + assertThat(state.sources().stream().mapToLong(Candidate::availablePackages).sum()).isEqualTo(160); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(16); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(1); + } + + /** 到期实托由共同提供者结束旧组成员后才能由新草稿完整领取,不复活原拥有者。 */ + @Test void expiryReclaimsWholePhysicalGroupWithoutTouchingNewOwner(){ + relabelPair();var started=Instant.parse("2026-10-19T03:00:00Z");var controlled=new MutableClock(started);clock=controlled;service=proxy(); + var original=select(empty(1),0,1);controlled.set(started.plus(Duration.ofMinutes(31))); + var next=service.createDraft(other,new CreateDraft(key(),"92"));next=service.createContainer(other,new CreateContainer(0L,key(),next.draftToken(),15)); + next=service.select(other,new DraftSelection(next.recordVersion(),key(),next.draftToken(),next.containers().get(0).draftContainerId(),choices.get(1L))); + assertThat(next.sources()).hasSize(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,original.draftToken())).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,original.draftToken())).isEqualTo(1); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE draft_token=? AND status='ACTIVE'",Integer.class,next.draftToken())).isEqualTo(1); + assertThatThrownBy(()->service.release(owner,new DraftRelease(original.recordVersion(),key(),original.draftToken()))).isInstanceOf(BizException.class); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(2); + } + + /** 纯释放或续约先在真实 Serial SQL 等待,接管事务可结束到期成员;旧拥有者不能复活租约或释放新拥有者。 */ + @ParameterizedTest + @ValueSource(strings={"UNSELECT","RELEASE","CREATE_CONTAINER","REMOVE_CONTAINER"}) + @Timeout(30) + void pureDraftActionsWaitAtSerialBeforeLogicalClaimsDuringExpiredTakeover(String action)throws Exception { + relabelPair();var started=Instant.parse("2026-10-19T03:00:00Z");var controlled=new MutableClock(started);clock=controlled;service=proxy(); + var draft=select(empty(2),0,1);var oldOwner=new DispatchPhysicalClaimService.Owner("92",null,draft.draftToken(), + draft.containers().get(0).draftContainerId(),owner.operatorId(),LocalDateTime.ofInstant(draft.expiresAt(),ZoneOffset.UTC)); + long oldPhysical=db.queryForObject("SELECT id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class); + controlled.set(started.plus(Duration.ofMinutes(30)).minusMillis(1)); + versions.put(1L,"changed-source-must-not-be-refreshed");clearInvocations(sources); + var holderReady=new CountDownLatch(1);var allowTakeover=new CountDownLatch(1);var waiterReady=new CountDownLatch(1); + var holderSession=new AtomicInteger();var waiterSession=new AtomicInteger();String failedKey="guard-crossing-"+action; + var pool=Executors.newFixedThreadPool(2); + try { + Future takeover=pool.submit(()->transactions.execute(status->{ + palletSerial.lock(List.of(1001L)); + physicalClaims.lockOwner(oldOwner); + holderSession.set(sessionId());holderReady.countDown();awaitLatch(allowTakeover); + var at=LocalDateTime.ofInstant(controlled.instant(),ZoneOffset.UTC); + var acquired=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner("92",9000L,null,null,"new-owner",null),List.of(1001L),at); + long physicalId=acquired.get(1001L);long id=9001; + for(var source:draft.sources())assertThat(actualClaimMapper.claim(id++,source.customerId(),source.sourceKey(), + source.selection().planPalletId(),source.selection().warehousePalletId(),source.selection().returnReceiptId(),physicalId,9000,"new-owner",at)).isEqualTo(1); + return physicalId; + })); + awaitLatch(holderReady); + Future pending=pool.submit(()->transactions.execute(status->{ + waiterSession.set(sessionId());waiterReady.countDown();return pureChange(action,draft,failedKey); + })); + awaitLatch(waiterReady); + awaitActualGuardWait(waiterSession.get(),holderSession.get(),pending); + controlled.set(started.plus(Duration.ofMinutes(30)).plusMillis(1));allowTakeover.countDown(); + long newPhysical=takeover.get(10,TimeUnit.SECONDS); + assertThatThrownBy(()->pending.get(10,TimeUnit.SECONDS)).isInstanceOf(ExecutionException.class).hasCauseInstanceOf(BizException.class); + assertThat(newPhysical).isNotEqualTo(oldPhysical); + assertThat(db.queryForObject("SELECT status FROM ll_dispatch_physical_pallet_lock WHERE id=?",String.class,oldPhysical)).isEqualTo("EXPIRED"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE draft_token=? AND status='EXPIRED'",Integer.class,draft.draftToken())).isEqualTo(2); + assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=1001",Long.class)).isEqualTo(9000L); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE dispatch_order_id=9000 AND physical_lock_id=? AND status='ACTIVE'",Integer.class,newPhysical)).isEqualTo(2); + assertThat(db.queryForObject("SELECT record_version FROM ll_dispatch_draft WHERE draft_token=?",Long.class,draft.draftToken())).isEqualTo(draft.recordVersion()); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_container WHERE draft_token=?",Integer.class,draft.draftToken())).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key=?",Integer.class,failedKey)).isZero(); + assertThatThrownBy(()->service.release(owner,new DraftRelease(draft.recordVersion(),"late-owner-release-"+action,draft.draftToken()))).isInstanceOf(BizException.class); + assertThat(db.queryForObject("SELECT id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=1001",Long.class)).isEqualTo(newPhysical); + verifyNoInteractions(sources); + } finally { + allowTakeover.countDown();pool.shutdownNow();assertThat(pool.awaitTermination(10,TimeUnit.SECONDS)).isTrue(); + } + } + + /** 共享实托完整组不能部分转换,成功转换和原键恢复保留全部客户及来源版本。 */ + @Test void conversionKeepsWholeSharedGroupAndOriginalCommandIdentity(){ + relabelPair();var state=select(empty(1),0,1);var full=conversion(state,"convert-whole");var vehicle=full.groups().get(0);var create=vehicle.dispatch(); + var partial=new DispatchCommands.Create("partial-child",create.warehouseId(),create.vehicle(),create.driver(),create.plannedDepartureAt(), + create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations()); + assertThatThrownBy(()->service.convert(owner,new ConvertDraft(state.recordVersion(),"partial-convert",state.draftToken(),List.of(new DraftVehicle(vehicle.draftContainerId(),partial))))) + .isInstanceOf(BizException.class); + assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero(); + var converted=service.convert(owner,full);assertThat(converted.vehicles()).hasSize(1); + var captured=org.mockito.ArgumentCaptor.forClass(DispatchCommands.Create.class);verify(aggregate).create(eq(owner),captured.capture()); + assertThat(captured.getValue().cargo()).containsExactlyElementsOf(state.sources().stream().map(Candidate::selection).toList()); + assertThat(db.queryForObject("SELECT cargo_count FROM draft_created_dispatch",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(2); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='RELEASED'",Integer.class)).isEqualTo(1); + clearInvocations(aggregate,sources); + assertThat(proxy().convert(owner,full).vehicles()).isEqualTo(converted.vehicles());verifyNoInteractions(aggregate,sources); + } + /** 第二辆车失败必须撤销首车、草稿状态、命令及所有占用释放;原键重试成功后只恢复结果。 */ @Test void laterVehicleFailureRollsBackWholeBatchAndUnknownSuccessReplaysAfterRestart(){ var state=empty(2);state=select(state,0,1);state=select(state,0,2);state=select(state,1,3); @@ -148,6 +349,7 @@ class DispatchDraftPersistenceTest { assertThatThrownBy(()->service.convert(owner,request)).isInstanceOf(IllegalStateException.class); assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero(); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(3); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Integer.class)).isEqualTo(3); assertThat(service.drafts(owner).get(0)).isEqualTo(original); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_draft_command WHERE idempotency_key='convert'",Integer.class)).isZero(); calls.clear();doAnswer(call->{calls.add("create");DispatchCommands.Create r=call.getArgument(1);String no="DS-"+r.idempotencyKey(); @@ -184,7 +386,8 @@ class DispatchDraftPersistenceTest { var release=new DraftRelease(state.recordVersion(),"release",state.draftToken());var released=service.release(owner,release); assertThat(released.status()).isEqualTo("RELEASED");assertThat(released.sources()).isEmpty(); assertThat(proxy().release(owner,release)).isEqualTo(released); - assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key IS NOT NULL OR active_warehouse_pallet_id IS NOT NULL",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE active_key IS NOT NULL",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id IS NOT NULL",Integer.class)).isZero(); } /** 过期和旧版本不能变更;布局容量按完整物理来源集合核对。 */ @@ -267,16 +470,88 @@ class DispatchDraftPersistenceTest { start.await();try{service.select(actor,new DraftSelection(draft.recordVersion(),UUID.randomUUID().toString(),draft.draftToken(),draft.containers().get(0).draftContainerId(),selection(id)));return true;} catch(BizException conflict){return false;} } + /** 四种纯释放或租约入口只消费草稿原冻结来源,不请求当前来源资格。 */ + private Draft pureChange(String action,Draft draft,String commandKey) { + return switch(action) { + case "UNSELECT"->service.unselect(owner,new DraftSelection(draft.recordVersion(),commandKey,draft.draftToken(),draft.containers().get(0).draftContainerId(),draft.sources().get(0).selection())); + case "RELEASE"->service.release(owner,new DraftRelease(draft.recordVersion(),commandKey,draft.draftToken())); + case "CREATE_CONTAINER"->service.createContainer(owner,new CreateContainer(draft.recordVersion(),commandKey,draft.draftToken(),15)); + case "REMOVE_CONTAINER"->service.removeContainer(owner,draft.draftToken(),draft.containers().get(1).draftContainerId(),new DispatchCommands.ReasonCommand(draft.recordVersion(),commandKey,"移除空容器")); + default->throw new IllegalArgumentException("未支持的测试动作"); + }; + } + /** 数据库会话取自调用方当前事务连接,不以线程启动标记冒充 SQL 执行。 */ + private int sessionId(){return db.queryForObject("SELECT SESSION_ID()",Integer.class);} + /** 有界协调真实事务阶段,异常时由调用方 finally 放开持锁者。 */ + private void awaitLatch(CountDownLatch latch) { + try {if(!latch.await(10,TimeUnit.SECONDS))throw new IllegalStateException("等待真实事务阶段超时");} + catch(InterruptedException failure){Thread.currentThread().interrupt();throw new IllegalStateException("测试事务已中断",failure);} + } + /** 必须实际看到 guard 写语句被指定物理拥有者会话阻塞,不能用 Future 超时判断数据库已等待。 */ + private void awaitActualGuardWait(int waiter,int holder,Future pending)throws Exception { + assertThat(waiter).isPositive().isNotEqualTo(holder);assertThat(holder).isPositive(); + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(3);List> last=List.of(); + while(System.nanoTime()(); + for(long id:completeGroups.getOrDefault(seed,List.of(seed))) { + var input=choices.getOrDefault(id,id==seed?requested:selection(id)); + var choice=new DispatchCommands.CargoSelection(input.sourceType(),input.planPalletId(),input.returnReceiptId(),input.relabelNo(),physical.get(id),versions.get(id)); + String key=DispatchService.wholeSourceKey(choice); + members.add(new SelectionGroupSource(choice,key,id%2==0?"102":"101","客户"+id, + choice.sourceType()==SourceType.RELABEL?"RELABEL_ORDER":choice.sourceType().name(),choice.relabelNo()==null?"PLAN-"+id:choice.relabelNo(),10)); + } + var byPallet=new TreeMap>(); + for(var member:members)if(member.selection().warehousePalletId()!=null)byPallet.computeIfAbsent(member.selection().warehousePalletId(),ignored->new ArrayList<>()).add(member); + var pallets=new ArrayList<>(byPallet.entrySet().stream().map(entry->new SelectionGroupPallet(entry.getKey(),"P-"+entry.getKey(), + entry.getValue().stream().map(SelectionGroupSource::sourceKey).sorted().toList(),10L*entry.getValue().size())).toList()); + for(var member:members)if(member.selection().warehousePalletId()==null)pallets.add(new SelectionGroupPallet(null,null,List.of(member.sourceKey()),member.packages())); + return new SelectionGroup("GROUP-"+members.stream().map(SelectionGroupSource::sourceKey).sorted().toList(), + "GROUP-VERSION-"+members.stream().map(member->member.selection().sourceVersion()).toList(),List.copyOf(members),List.copyOf(pallets),true,List.of()); + } + /** 合成来源业务号用于测试身份定位,同实托时也不能借物理号猜成其他申请。 */ + private long sourceId(DispatchCommands.CargoSelection source) { + if(source.planPalletId()!=null)return source.planPalletId(); + if(source.returnReceiptId()!=null)return source.returnReceiptId(); + return Long.parseLong(source.relabelNo().substring("RL-".length())); + } /** 每个非空服务器容器生成一组完整创建请求。 */ private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null, draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of())));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java new file mode 100644 index 000000000..515f73550 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java @@ -0,0 +1,430 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.entity.DispatchItemSource; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.Stock; +import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.*; +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.aop.framework.ProxyFactory; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 受控来源 port 配合真实 Mapper、仓储、服务及事务;不代替真实换标 provider 的组合验收。 */ +class DispatchItemSourcePersistenceTest { + private static final long DISPATCH = 77L; + private static final long PALLET = 500L; + private static final LocalDateTime AT = LocalDateTime.of(2026, 10, 19, 12, 0); + private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); + private final RelabelDispatchSourcePort provider = mock(RelabelDispatchSourcePort.class); + private JdbcTemplate jdbc; + private TransactionTemplate transaction; + private DispatchItemSourceRepository repository; + private DispatchItemSourceService service; + private DispatchAggregate root; + private DispatchItem first; + private DispatchItem second; + private DispatchItem third; + private List items; + private Snapshot supplied; + + /** 建立与正式贡献和拣货约束对应的隔离表,通过实际 Spring 代理加入调用方事务。 */ + @BeforeEach + void prepare() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:item-source-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute(""" + CREATE TABLE ll_dispatch_item_source ( + id BIGINT PRIMARY KEY, dispatch_order_id BIGINT NOT NULL, dispatch_item_id BIGINT NOT NULL, + customer_id VARCHAR(128) NOT NULL, relabel_application_id BIGINT NOT NULL, + relabel_item_id BIGINT NOT NULL, relabel_round_key VARCHAR(128) NOT NULL, warehouse_pallet_id BIGINT NOT NULL, + inventory_position_id BIGINT NOT NULL, inventory_balance_id BIGINT NOT NULL, + source_inventory_balance_id BIGINT, source_inbound_order_id BIGINT, + source_inbound_order_no VARCHAR(128), source_inbound_code VARCHAR(128), + notified_package_count BIGINT NOT NULL, package_count BIGINT NOT NULL, product_quantity BIGINT, + binding_basis_hash CHAR(64) NOT NULL, unit_facts_json CLOB NOT NULL, created_at TIMESTAMP NOT NULL, + UNIQUE(dispatch_item_id,relabel_item_id,inventory_position_id), + CHECK(notified_package_count>=0 AND package_count>0), CHECK(product_quantity IS NULL OR product_quantity>=0)) + """); + jdbc.execute(""" + CREATE TABLE ll_dispatch_pda_pick_order ( + id BIGINT PRIMARY KEY, customer_id VARCHAR(128) NOT NULL, pick_id BIGINT NOT NULL, + receipt_id BIGINT, return_receipt_id BIGINT, return_carton_id BIGINT, dispatch_item_source_id BIGINT, + order_id BIGINT NOT NULL, packages BIGINT NOT NULL, product_quantity BIGINT NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + CHECK((CASE WHEN receipt_id IS NOT NULL THEN 1 ELSE 0 END) + +(CASE WHEN return_receipt_id IS NOT NULL THEN 1 ELSE 0 END) + +(CASE WHEN return_carton_id IS NOT NULL THEN 1 ELSE 0 END) + +(CASE WHEN dispatch_item_source_id IS NOT NULL THEN 1 ELSE 0 END)=1)) + """); + jdbc.execute("CREATE TABLE test_caller_transaction_marker(id INT PRIMARY KEY)"); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(DispatchItemSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchItemSourceMapper.class); + repository = new DispatchItemSourceRepository(mapper); + var manager = new DataSourceTransactionManager(ds); + transaction = new TransactionTemplate(manager); + var target = new DispatchItemSourceService(repository, provider, json); + var proxy = new ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource())); + service = (DispatchItemSourceService) proxy.getProxy(); + root = new DispatchAggregate(); + root.setId(DISPATCH); root.setOverseasWarehouseId("1001"); + first = item(11, "101", "RL-201", 1001, PALLET, 2); + second = item(12, "101", "RL-202", 1002, PALLET, 3); + third = item(13, "102", "RL-203", 1003, PALLET, 4); + items = List.of(first, second, third); + supplied = new Snapshot(List.of(application(201, "RL-201", "101", 1001), + application(202, "RL-202", "101", 1002), application(203, "RL-203", "102", 1003)), + List.of(source(201, "RL-201", 301, "101", PALLET, 101, 601, 501, 701, 2, 10L, "SHARED"), + source(202, "RL-202", 302, "101", PALLET, 101, 601, 502, 702, 3, null, "SHARED"), + source(203, "RL-203", 303, "102", PALLET, 102, 602, 503, 703, 4, 8L, "SHARED")), + List.of(position(101, "101", 601, PALLET, 5), position(102, "102", 602, PALLET, 4)), + "c".repeat(64), true, List.of()); + when(provider.lockedSnapshot(anyString(), anyList())).thenAnswer(invocation -> supplied); + } + + /** 同托两个申请和另一客户各存独立贡献,原入仓链及未知件数原样冻结。 */ + @Test + void captureFreezesEveryApplicationCustomerAndOriginalSource() throws Exception { + capture(); + var rows = repository.all(DISPATCH); + assertEquals(3, rows.size()); + assertEquals(List.of(201L, 202L, 203L), rows.stream().map(DispatchItemSource::getRelabelApplicationId).toList()); + assertEquals(List.of("101", "101", "102"), rows.stream().map(DispatchItemSource::getCustomerId).toList()); + assertEquals(List.of(701L, 702L, 703L), rows.stream().map(DispatchItemSource::getSourceInboundOrderId).toList()); + assertEquals(List.of(501L, 502L, 503L), rows.stream().map(DispatchItemSource::getSourceInventoryBalanceId).toList()); + assertEquals(List.of("INITIAL", "INITIAL", "INITIAL"), rows.stream().map(DispatchItemSource::getRelabelRoundKey).toList()); + assertEquals(List.of("IN-701", "IN-702", "IN-703"), rows.stream().map(DispatchItemSource::getSourceInboundOrderNo).toList()); + assertEquals(List.of("CODE-701", "CODE-702", "CODE-703"), rows.stream().map(DispatchItemSource::getSourceInboundCode).toList()); + assertNull(rows.get(1).getProductQuantity()); + assertEquals(10L, rows.get(0).getProductQuantity()); + assertEquals(5, rows.stream().filter(s -> s.getInventoryPositionId() == 101).mapToLong(DispatchItemSource::getPackageCount).sum()); + for (var row : rows) { + var original = supplied.sources().stream().filter(s -> s.itemId().equals(row.getRelabelItemId())).findFirst().orElseThrow(); + assertEquals(original.boundPackageCount(), row.getPackageCount().longValue()); + assertEquals(original.bindingBasisHash(), row.getBindingBasisHash()); + assertEquals(json.readTree(json.writeValueAsString(original.unitFacts())), json.readTree(row.getUnitFactsJson())); + assertNotEquals(supplied.applications().stream().filter(a -> a.applicationId().equals(original.applicationId())) + .findFirst().orElseThrow().fulfillmentOrderId(), row.getSourceInboundOrderId()); + } + verify(provider).lockedSnapshot("1001", List.of(PALLET)); + verify(provider, never()).snapshot(anyString(), anyList()); + verify(provider, never()).lockApplications(anyList()); + } + + /** 来源冻结和分配必须加入真实调用方事务,不能在事务外独立执行。 */ + @Test + void captureAndAllocateRequireCallerTransaction() { + assertThrows(IllegalTransactionStateException.class, () -> service.capture(root, items, items, AT)); + assertThrows(IllegalTransactionStateException.class, + () -> service.allocate(DISPATCH, first, 101, 1, 0, null, List.of(stock(101, 601, 5)))); + assertEquals(0, countSources()); + } + + /** 同实托遗漏另一申请时,已尝试保存的首申请和调用方其他写入必须一起回滚。 */ + @Test + void missingApplicationOnPhysicalPalletRollsBackWholeCallerTransaction() { + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> { + jdbc.update("INSERT INTO test_caller_transaction_marker VALUES(1)"); + service.capture(root, List.of(first, third), List.of(first, third), AT); + })); + assertEquals(0, countSources()); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM test_caller_transaction_marker", Integer.class)); + } + + /** 同申请还有另一实托时,不能只冻结请求托盘上的部分明细。 */ + @Test + void missingOtherPalletOfSameApplicationRollsBackAllContributions() { + var sources = new ArrayList<>(supplied.sources()); + sources.add(source(201, "RL-201", 304, "101", 501, 103, 603, 504, 704, 1, null, "OTHER-PALLET")); + var positions = new ArrayList<>(supplied.positions()); + positions.add(position(103, "101", 603, 501, 1)); + supplied = snapshot(sources, positions); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + } + + /** 每张申请各自小于 HOLD 仍不能掩盖同位置合计超量。 */ + @Test + void combinedApplicationsCannotExceedPositionHold() { + supplied = snapshot(supplied.sources(), List.of(position(101, "101", 601, PALLET, 4), + position(102, "102", 602, PALLET, 4))); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + } + + /** 额外 HOLD 也说明实托成员未完整核齐,不能悄悄忽略未归属冻结箱数。 */ + @Test + void unexplainedHoldCannotBeOmittedFromCompletePhysicalSelection() { + supplied = snapshot(supplied.sources(), List.of(position(101, "101", 601, PALLET, 6), + position(102, "102", 602, PALLET, 4))); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + } + + /** 客户与履约订单必须匹配真实申请,不接受借原入仓订单替换履约归属。 */ + @Test + void captureRejectsWrongCustomerAndWrongFulfillmentOwner() { + second.setCustomerId("102"); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + second.setCustomerId("101"); + first.setOrderId(701L); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT))); + assertEquals(0, countSources()); + } + + /** 相同贡献唯一约束阻止重复冻结,失败事务不能覆盖或删除已提交来源。 */ + @Test + void duplicateCaptureLeavesOriginalImmutableRowsIntact() { + capture(); + var original = repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList(); + assertThrows(DataIntegrityViolationException.class, + () -> transaction.executeWithoutResult(status -> service.capture(root, items, items, AT.plusSeconds(1)))); + assertEquals(original, repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList()); + assertEquals(3, countSources()); + } + + /** 不新增明细时仍复核完整成员,但不能重写已冻结的原货源。 */ + @Test + void recheckingExistingMembersDoesNotAppendOrReplaceSources() { + capture(); + var original = repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList(); + transaction.executeWithoutResult(status -> service.capture(root, items, List.of(), AT.plusSeconds(1))); + assertEquals(original, repository.all(DISPATCH).stream().map(DispatchItemSource::getId).toList()); + } + + /** 原轮次写入真实持久列后允许正常扫码状态,返工新轮次不能复用旧冻结来源。 */ + @Test + void frozenRoundSurvivesNormalStateChangesAndRejectsReworkRound() { + capture(); + var scheduled = supplied.applications().stream().map(app -> new Application(app.applicationId(), app.relabelNo(), + app.customerId(), app.fulfillmentOrderId(), "READY_FOR_OUTBOUND", "PRE_DISPATCH_CREATED", "TRUCK", + app.roundKey(), AT.plusSeconds(1), app.customerConfirmedAt())).toList(); + var current = new Snapshot(scheduled, supplied.sources(), supplied.positions(), supplied.compositionVersion(), true, List.of()); + transaction.executeWithoutResult(status -> service.validateFrozen(root, items, current)); + var reworked = scheduled.stream().map(app -> new Application(app.applicationId(), app.relabelNo(), app.customerId(), + app.fulfillmentOrderId(), app.status(), app.fulfillmentStatus(), app.deliveryMethod(), + app.applicationId() == 201 ? "2026-10-20T12:00" : app.roundKey(), AT.plusDays(1), app.customerConfirmedAt())).toList(); + var changed = new Snapshot(reworked, supplied.sources(), supplied.positions(), supplied.compositionVersion(), true, List.of()); + assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> service.validateFrozen(root, items, changed))); + assertEquals(List.of("INITIAL", "INITIAL", "INITIAL"), jdbc.queryForList( + "SELECT relabel_round_key FROM ll_dispatch_item_source ORDER BY dispatch_item_id", String.class)); + } + + /** 同箱唛跨申请及客户仍按本逻辑明细精确归属,不把整贡献件数复制给本次部分扫描。 */ + @Test + void sameCartonMarkAllocatesOnlySelectedApplicationAndCustomer() { + capture(); + var known = allocate(first, 101, 1, 3, "SHARED", 5); + assertEquals(1, known.size()); assertEquals(row(11).getId(), known.get(0).dispatchItemSourceId()); + assertEquals(1, known.get(0).packages()); assertEquals(3L, known.get(0).productQuantity()); + var unknown = allocate(second, 101, 1, 0, "SHARED", 5); + assertEquals(row(12).getId(), unknown.get(0).dispatchItemSourceId()); + assertNull(unknown.get(0).productQuantity()); + var otherCustomer = allocate(third, 102, 1, 1, "SHARED", 4); + assertEquals(row(13).getId(), otherCustomer.get(0).dispatchItemSourceId()); + assertEquals(1L, otherCustomer.get(0).productQuantity()); + } + + /** 分配只减本来源真实已消费额度,耗尽后不能向同位置另一申请 FIFO 借箱。 */ + @Test + void consumedAllowanceCannotBorrowFromAnotherApplicationOnSamePosition() { + capture(); + var firstPart = allocate(first, 101, 1, 3, "SHARED", 5).get(0); + pick(1, "101", 1001, firstPart); + assertThrows(BizException.class, () -> allocate(first, 101, 2, 7, "SHARED", 4)); + var lastPart = allocate(first, 101, 1, 7, "SHARED", 4).get(0); + assertEquals(firstPart.dispatchItemSourceId(), lastPart.dispatchItemSourceId()); + assertEquals(7L, lastPart.productQuantity()); + pick(2, "101", 1001, lastPart); + assertThrows(BizException.class, () -> allocate(first, 101, 1, 0, "SHARED", 3)); + var separate = allocate(second, 101, 3, 0, "SHARED", 3).get(0); + assertEquals(row(12).getId(), separate.dispatchItemSourceId()); + assertEquals(3, separate.packages()); assertNull(separate.productQuantity()); + assertEquals(2, repository.picked("101", row(11).getId())); + assertEquals(0, repository.picked("101", row(12).getId())); + } + + /** 排车、客户、逻辑明细和实际位置任一不符都不能消费已有真实贡献。 */ + @Test + void allocateRejectsWrongDispatchCustomerItemAndPosition() { + capture(); + assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH + 1, first, + 101, 1, 0, "SHARED", List.of(stock(101, 601, 5))))); + var wrongCustomer = item(11, "102", "RL-201", 1001, PALLET, 2); + assertThrows(BizException.class, () -> allocate(wrongCustomer, 102, 1, 0, "SHARED", 4)); + var wrongItem = item(999, "101", "RL-201", 1001, PALLET, 2); + assertThrows(BizException.class, () -> allocate(wrongItem, 101, 1, 0, "SHARED", 5)); + assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first, + 102, 1, 0, "SHARED", List.of(stock(101, 601, 5))))); + assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first, + 101, 1, 0, "SHARED", List.of()))); + } + + /** 聚合库存可用数、其他申请额度及变更后的余额都不能补足本来源超量扫描。 */ + @Test + void allocateRejectsExcessPackagesWrongBalanceAndChangedHold() { + capture(); + assertThrows(BizException.class, () -> allocate(first, 101, 3, 10, null, 5)); + assertThrows(BizException.class, () -> transaction.execute(status -> service.allocate(DISPATCH, first, + 101, 1, 3, null, List.of(stock(101, 999, 5))))); + assertThrows(BizException.class, () -> allocate(first, 101, 1, 3, null, 6)); + assertThrows(BizException.class, () -> allocate(first, 101, 2, 9, null, 5)); + assertThrows(BizException.class, () -> allocate(first, 101, 1, 11, null, 5)); + } + + /** 同申请同位置有多明细时,部分箱数必须有明确箱唛,不能按记录顺序拆分。 */ + @Test + void partialMultiContributionPickRequiresExactCartonMark() { + addAnotherItemInFirstApplication(); + capture(); + assertThrows(BizException.class, () -> allocate(first, 101, 1, 0, null, 6)); + var extra = allocate(first, 101, 1, 0, "EXTRA", 6); + assertEquals(1, extra.size()); assertNull(extra.get(0).productQuantity()); + assertEquals(304L, repository.find(extra.get(0).dispatchItemSourceId()).getRelabelItemId()); + var original = allocate(first, 101, 1, 3, "SHARED", 6); + assertEquals(301L, repository.find(original.get(0).dispatchItemSourceId()).getRelabelItemId()); + assertEquals(3L, original.get(0).productQuantity()); + } + + /** 全量多明细扫描分别保留已知件数与未知空值,不复制整托或整个贡献的件数。 */ + @Test + void fullMultiContributionPickKeepsKnownAndUnknownPieceFactsSeparate() { + addAnotherItemInFirstApplication(); + capture(); + var allocated = allocate(first, 101, 3, 10, null, 6); + assertEquals(2, allocated.size()); + assertEquals(3, allocated.stream().mapToLong(DispatchItemSourceService.Allocation::packages).sum()); + assertEquals(10, allocated.stream().filter(a -> a.productQuantity() != null).mapToLong(a -> a.productQuantity()).sum()); + assertEquals(1, allocated.stream().filter(a -> a.productQuantity() == null).count()); + assertThrows(BizException.class, () -> allocate(first, 101, 3, 20, null, 6)); + } + + /** SQL 只能删除未被真实拣货引用的来源,不能连带删除同实托已消费申请及其他客户。 */ + @Test + void removeUnpickedCannotDeleteAnySourceWithActualPick() { + capture(); + var kept = row(11).getId(); + pick(1, "101", 1001, allocate(first, 101, 1, 3, "SHARED", 5).get(0)); + transaction.executeWithoutResult(status -> { + service.removeUnpicked(DISPATCH, 11); + service.removeUnpicked(DISPATCH, 12); + service.removeUnpicked(DISPATCH + 1, 13); + }); + assertNotNull(repository.find(kept)); + assertEquals(List.of(11L, 13L), repository.all(DISPATCH).stream().map(DispatchItemSource::getDispatchItemId).toList()); + assertEquals(1, jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick_order WHERE dispatch_item_source_id=?", Integer.class, kept)); + assertEquals(2, countSources()); + } + + /** 创建本次完整受控来源输入,不暗示真实 provider 已参与本组件测试。 */ + private Snapshot snapshot(List sources, List positions) { + return new Snapshot(supplied.applications(), List.copyOf(sources), List.copyOf(positions), supplied.compositionVersion(), true, List.of()); + } + + /** 在第一申请同位置增加一条未知件数明细,用于验证多贡献精确分配。 */ + private void addAnotherItemInFirstApplication() { + first.setPackageCount(3); + var sources = new ArrayList<>(supplied.sources()); + sources.add(source(201, "RL-201", 304, "101", PALLET, 101, 601, 504, 704, 1, null, "EXTRA")); + supplied = snapshot(sources, List.of(position(101, "101", 601, PALLET, 6), position(102, "102", 602, PALLET, 4))); + } + + /** 真实服务事务只冻结受控输入,数据写入仍经过实际仓储和 Mapper。 */ + private void capture() { + transaction.executeWithoutResult(status -> service.capture(root, items, items, AT)); + } + + /** 本次输入件数明确传入,库存数量取调用方锁后的受控真实位置快照。 */ + private List allocate(DispatchItem item, long position, long packages, + long quantity, String mark, long hold) { + long balance = position == 102 ? 602 : 601; + return transaction.execute(status -> service.allocate(DISPATCH, item, position, packages, quantity, mark, + List.of(stock(position, balance, hold)))); + } + + /** 模拟已提交的不可变真实拣货账本供实际 Mapper 读取消费额度,不冒充 PDA 全链。 */ + private void pick(long id, String customer, long fulfillment, DispatchItemSourceService.Allocation allocation) { + jdbc.update(""" + INSERT INTO ll_dispatch_pda_pick_order + (id,customer_id,pick_id,dispatch_item_source_id,order_id,packages,product_quantity,created_at) + VALUES(?,?,?,?,?,?,?,?) + """, id, customer, 9000 + id, allocation.dispatchItemSourceId(), fulfillment, allocation.packages(), + allocation.productQuantity() == null ? 0 : allocation.productQuantity(), AT.plusSeconds(id)); + } + + /** 读取一条逻辑明细的原冻结来源。 */ + private DispatchItemSource row(long itemId) { + return repository.all(DISPATCH).stream().filter(s -> s.getDispatchItemId() == itemId).findFirst().orElseThrow(); + } + + /** 从真实数据库核对事务提交后的贡献数量。 */ + private int countSources() { + return jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item_source", Integer.class); + } + + /** 构造本次逻辑运输来源,真实履约订单独立于原入仓链。 */ + private DispatchItem item(long id, String customer, String no, long order, long pallet, int packages) { + var item = new DispatchItem(); + item.setId(id); item.setDispatchOrderId(DISPATCH); item.setCustomerId(customer); item.setOrderId(order); + item.setOverseasWarehouseId("1001"); item.setWarehousePalletId(pallet); item.setPackageCount(packages); + item.setSourceBusinessType("RELABEL_ORDER"); item.setSourceBusinessNo(no); item.setSourceStage("RELABEL"); + return item; + } + + /** 构造已确认的普通卡车换标申请事实。 */ + private Application application(long id, String no, String customer, long fulfillment) { + return new Application(id, no, customer, fulfillment, "READY_FOR_OUTBOUND", "WAIT_PRE_DISPATCH", "TRUCK", "INITIAL", AT, AT); + } + + /** 构造独立冻结货源与箱级作业事实,通知和成功均不能替代实际绑定贡献。 */ + private BoundSource source(long app, String no, long item, String customer, long pallet, long position, + long balance, long sourceBalance, long inbound, long packages, Long pieces, String mark) { + return new BoundSource(app, no, item, customer, "1001", pallet, position, balance, sourceBalance, inbound, + "IN-" + inbound, "CODE-" + inbound, packages, packages, pieces, "b".repeat(64), + List.of(new UnitFact(item, mark, "NEW-" + mark, "RELABELLED", 1), + new UnitFact(item + 1000, "EXCEPTION-" + item, null, "EXCEPTION", 1))); + } + + /** 同一实托可以含不同客户位置,每个位置的 HOLD 独立守恒。 */ + private Position position(long id, String customer, long balance, long pallet, long hold) { + return new Position(id, customer, balance, pallet, 0, hold, 0, 0, 0, 1); + } + + /** 保留大额 AVAILABLE 以证明它不能补足本来源的冻结额度。 */ + private Stock stock(long position, long balance, long hold) { + return new Stock(position, balance, PALLET, "1001", 1000, hold, 0, 0, 1); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java new file mode 100644 index 000000000..d4c32ae67 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -0,0 +1,221 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.mapper.*; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService; +import com.baiyue.lastleg.mapper.*; +import com.baiyue.lastleg.relabel.mapper.*; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.baiyue.lastleg.relabel.service.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.math.BigDecimal; +import java.nio.file.*; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */ +class DispatchMergedRelabelPersistenceTest { + /** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */ + @ParameterizedTest @ValueSource(booleans={false,true}) + void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception { + var f=new Merged(crossCustomer);var group=f.group(); + assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();assertThat(group.sources()).hasSize(2);assertThat(group.pallets()).hasSize(1); + var created=f.create("merged",group.sources().stream().map(s->s.selection()).toList()); + assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().itemCount()).isEqualTo(2);assertThat(created.counts().packageCount()).isEqualTo(5); + assertThat(f.count("ll_dispatch_physical_pallet_lock")).isEqualTo(1);assertThat(f.count("ll_dispatch_pallet_lock")).isEqualTo(2); + assertThat(f.f.db.queryForObject("SELECT COUNT(DISTINCT physical_lock_id) FROM ll_dispatch_pallet_lock",Long.class)).isEqualTo(1); + var approved=f.f.approve(created);var before=f.pda.task(f.picker,created.dispatchNo()); + assertThat(before.items()).extracting(i->i.positions().stream().mapToLong(p->p.availablePackages()).sum()).containsExactlyInAnyOrder(2L,3L); + var first=f.item("RL-A");var second=f.item("RL-B"); + var request=new DispatchCommands.Pick(approved.recordVersion(),"pick-A",101L,2L,0L,null,null); + var picked=f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),first,request)); + assertThat(f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),first,request)).replayed()).isTrue(); + assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(1); + var halfway=f.pda.task(f.picker,created.dispatchNo()); + assertThat(halfway.items().stream().filter(i->i.sourceBusinessNo().equals("RL-B")).findFirst().orElseThrow().positions().get(0).availablePackages()).isEqualTo(3); + var last=f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),second,new DispatchCommands.Pick(picked.recordVersion(),"pick-B",crossCustomer?102L:101L,3L,0L,null,null))); + assertThat(f.pda.task(f.picker,created.dispatchNo()).pickingReconciled()).isTrue(); + assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2); + assertThat(f.f.db.queryForObject("SELECT COALESCE(SUM(hold_packages+available_packages+allocated_packages+picked_packages),0) FROM ll_inventory_position",Long.class)).isZero(); + assertThat(f.count("ll_dispatch_pda_pick_order WHERE dispatch_item_source_id IS NOT NULL AND receipt_id IS NULL AND return_receipt_id IS NULL AND return_carton_id IS NULL")).isEqualTo(2); + var context=f.f.service.preview(f.f.actor,created.dispatchNo());assertThat(context.cargo()).hasSize(2); + assertThat(context.cargo()).extracting(c->c.sourceOrigin().sourceInboundOrderNo()).containsExactlyInAnyOrder("ORIGIN-A","ORIGIN-B"); + assertThat(context.cargo()).extracting(c->c.orderId()).containsExactlyInAnyOrder(9001L,9002L); + var preview=f.f.service.loading(f.f.actor,created.dispatchNo());assertThat(preview.pallets()).hasSize(1);assertThat(preview.pallets().get(0).scheduledPackages()).isEqualTo(5); + var pallet=preview.pallets().get(0); + var loaded=f.f.tx.execute(s->f.f.service.loadingComplete(f.picker,created.dispatchNo(),new DispatchCommands.LoadingComplete(last.recordVersion(),"load",preview.manifestHash(), + List.of(new DispatchContracts.LoadingMeasurement(501L,1,new BigDecimal("10"),BigDecimal.ONE,pallet.deliveryIndex()==null?1:pallet.deliveryIndex())),null))); + assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING); + assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2); + } + + /** 缺一个申请、同量换原货源、轮次或逐件事实变化,都不能用旧选择冻结新事实。 */ + @Test void incompleteOrChangedSelectionRollsBackEveryClaimAndRoot() throws Exception { + var f=new Merged(false);var selections=f.group().sources().stream().map(s->s.selection()).toList(); + assertThatThrownBy(()->f.create("partial",List.of(selections.get(0)))).hasMessageContaining("完整选择"); + assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero(); + f.f.db.update("UPDATE ll_relabel_item SET source_inbound_order_no='CHANGED' WHERE id=302"); + assertThatThrownBy(()->f.create("stale",selections)).hasMessageContaining("版本已变化"); + assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_item_source")).isZero(); + } + + /** 建单后轮次或原货源同量变化仍失败,前面任务/库存命令/归属/版本均不能留下。 */ + @ParameterizedTest @ValueSource(strings={"origin","round","fact"}) + void changedFrozenOriginPreventsPickingBeforeAnyInventoryWrite(String changed) throws Exception { + var f=new Merged(false);var created=f.create("origin-drift",f.group().sources().stream().map(s->s.selection()).toList());var approved=f.f.approve(created); + if("origin".equals(changed))f.f.db.update("UPDATE ll_relabel_item SET source_inbound_code='OTHER' WHERE id=302"); + else if("round".equals(changed))f.f.db.update("UPDATE ll_relabel_pda_history SET detail_json=? WHERE application_id=202 AND action='COMPLETE'",f.f.json.writeValueAsString(Map.of("roundKey","OTHER"))); + else f.f.db.update("UPDATE ll_relabel_pda_unit_fact SET new_label='OTHER' WHERE application_id=202"); + assertThatThrownBy(()->f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),new DispatchCommands.Pick(approved.recordVersion(),"bad-origin",101L,2L,0L,null,null)))) + .hasMessageContaining("原货源依据已变化"); + assertThat(f.count("ll_inventory_operation")).isZero();assertThat(f.count("ll_dispatch_pda_pick")).isZero(); + assertThat(f.f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion()); + } + + /** 第二个申请归属失败时真实四个库存动作与首层任务一起回滚。 */ + @Test void lateAttributionFailureRollsBackInventoryAndAllowsSameCommandRetry() throws Exception { + var f=new Merged(false);var created=f.create("rollback",f.group().sources().stream().map(s->s.selection()).toList());var approved=f.f.approve(created); + var attribution=spy(f.attribution);doThrow(new IllegalStateException("归属故障")).when(attribution).attributeRelabel(anyString(),any(),anyLong(),anyList()); + ReflectionTestUtils.setField(f.picking,"attribution",attribution); + var request=new DispatchCommands.Pick(approved.recordVersion(),"retry",101L,2L,0L,null,null); + assertThatThrownBy(()->f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),request))).hasMessageContaining("归属故障"); + assertThat(f.count("ll_inventory_operation")).isZero();assertThat(f.count("ll_inventory_ledger")).isZero();assertThat(f.count("ll_dispatch_pda_task")).isZero(); + ReflectionTestUtils.setField(f.picking,"attribution",f.attribution); + assertThat(f.f.tx.execute(s->f.picking.pick(f.picker,created.dispatchNo(),f.item("RL-A"),request)).recordVersion()).isEqualTo(approved.recordVersion()+1); + } + + /** 取消按整车释放两份逻辑成员和一个物理拥有者,原业务号恢复不误释放新单。 */ + @Test void zeroScanCancellationRestoresBothApplicationsAndReleasesOnePallet() throws Exception { + var f=new Merged(false);var created=f.create("cancel",f.group().sources().stream().map(s->s.selection()).toList()); + var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-merged","重排"); + f.f.tx.execute(s->f.f.service.cancel(f.f.actor,created.dispatchNo(),request)); + assertThat(f.count("ll_dispatch_pallet_lock WHERE status='ACTIVE'")).isZero();assertThat(f.count("ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'")).isZero(); + assertThat(f.count("ll_relabel_application WHERE fulfillment_status='WAIT_PRE_DISPATCH'")).isEqualTo(2); + var next=f.create("next",f.group().sources().stream().map(s->s.selection()).toList()); + assertThat(f.f.tx.execute(s->f.f.service.cancel(f.f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThat(f.f.db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)).isEqualTo(f.f.repository.find(next.dispatchNo()).getId()); + } + + /** 两条真实建单事务竞争同一完整组,只能提交一车、两逻辑来源及一个物理拥有者。 */ + @Test void concurrentCreatesCannotSplitOrDoubleClaimTheMergedPallet() throws Exception { + var f=new Merged(false);var selections=f.group().sources().stream().map(s->s.selection()).toList(); + var pool=java.util.concurrent.Executors.newFixedThreadPool(2);var start=new java.util.concurrent.CountDownLatch(1); + try { + var results=new ArrayList>(); + for(int n=0;n<2;n++){final int own=n;results.add(pool.submit(()->{start.await();try{f.create("race-"+own,selections);return true;}catch(org.springframework.web.server.ResponseStatusException|com.baiyue.common.core.exception.BizException conflict){return false;}}));} + start.countDown();int applied=0;for(var result:results)if(result.get(20,java.util.concurrent.TimeUnit.SECONDS))applied++; + assertThat(applied).isEqualTo(1);assertThat(f.count("ll_dispatch_order")).isEqualTo(1);assertThat(f.count("ll_dispatch_item")).isEqualTo(2); + assertThat(f.count("ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'")).isEqualTo(1);assertThat(f.count("ll_dispatch_pallet_lock WHERE status='ACTIVE'")).isEqualTo(2); + } finally {pool.shutdownNow();assertThat(pool.awaitTermination(5,java.util.concurrent.TimeUnit.SECONDS)).isTrue();} + } + + /** H2方言适配仍保留四类来源恰好一个,缺凭据或伪造双凭据必须由数据库拒绝。 */ + @Test void actualAttributionConstraintRejectsMissingAndAmbiguousSources() throws Exception { + var f=new Merged(false); + assertThatThrownBy(()->f.insert("ll_dispatch_pda_pick_order","id",1,"customer_id","101","pick_id",1,"order_id",9001,"packages",1)).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThatThrownBy(()->f.insert("ll_dispatch_pda_pick_order","id",2,"customer_id","101","pick_id",1,"order_id",9001,"packages",1,"receipt_id",1,"dispatch_item_source_id",1)).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(f.count("ll_dispatch_pda_pick_order")).isZero(); + } + + /** 正式初始化的计划实托绑定参与真实候选SQL,同码另一仓位置不能混入。 */ + @Test void actualPlanSchemaUsesRealPhysicalBindingAndRejectsAnotherWarehouse() throws Exception { + var f=new Merged(false); + f.insert("ll_order","id",9100,"order_no","PLAN-ORDER","customer_id","101","source_region","OVERSEAS","source_inbound_id","PLAN-SOURCE","source_event_id","PLAN-SOURCE","source_loading_plan_id","PLAN-SOURCE","source_payload_hash","a".repeat(64)); + f.insert("ll_inbound_plan","id",9011,"customer_id","101","source_order_id",9100,"container_no","PLAN-CN","plan_no","PLAN-NO","overseas_warehouse_id",9,"creation_mode","WAREHOUSE_TRANSFER"); + f.insert("ll_inbound_plan_line","id",9010,"customer_id","101","plan_id",9011,"order_id",9100,"inbound_code","PLAN-IN","destination_type","FBA","fba_warehouse_code","ONT8","volume_cbm",BigDecimal.ONE); + f.insert("ll_inbound_plan_pallet","id",9012,"customer_id","101","plan_id",9011,"pallet_no","PLAN-PALLET","warehouse_pallet_id",502,"destination_type","FBA","destination_warehouse_code","ONT8","sequence_no",1,"allocated_volume_cbm",BigDecimal.ONE); + f.insert("ll_inbound_plan_pallet_allocation","id",9013,"customer_id","101","plan_id",9011,"line_id",9010,"pallet_id",9012,"allocated_volume_cbm",BigDecimal.ONE,"planned_package_count",4); + f.insert("ll_inventory_balance","id",603,"customer_id","101","customer_name","测试","overseas_warehouse_id","9","inbound_order_id",7002,"inbound_code","PLAN-IN","sku_code","SKU","available_packages",4); + f.insert("ll_inventory_position","id",103,"customer_id","101","balance_id",603,"overseas_warehouse_id","9","pallet_id",502,"pallet_no","PLAN-PALLET","warehouse_location_code","LOC-2","available_packages",4); + // 相同码在另一仓另一个physical,不能成为父计划的来源。 + f.insert("ll_inventory_position","id",104,"customer_id","101","balance_id",603,"overseas_warehouse_id","99","pallet_id",999,"pallet_no","PLAN-PALLET","warehouse_location_code","LOC-OTHER","available_packages",8); + var group=f.rules.selectionGroup("9",new DispatchCommands.CargoSelection(SourceType.INBOUND_PLAN,9012L,null,null,null,null)); + assertThat(group.complete()).as(group.blockReasons().toString()).isTrue();assertThat(group.sources()).hasSize(1);assertThat(group.pallets()).hasSize(1); + assertThat(group.pallets().get(0).warehousePalletId()).isEqualTo(502L);assertThat(group.sources().get(0).packages()).isEqualTo(4); + var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4); + } + + /** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ + private static final class Merged { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchService rules=new DispatchService();final DispatchPickingService picking;final DispatchPdaQueryService pda; + final DispatchPickOrderAttributionService attribution=new DispatchPickOrderAttributionService(); + final DispatchContracts.Actor picker=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + Merged(boolean crossCustomer) throws Exception { + var configuration=f.session.getConfiguration(); + for(var mapper:List.of(RelabelApplicationMapper.class,RelabelDispatchSourceMapper.class,PhysicalPalletSerialMapper.class,DispatchWholePdaReadMapper.class, + DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,LastlegOrderMapper.class,LastlegOrderStatusHistoryMapper.class, + InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryOperationMapper.class,InventoryCommandMapper.class,ReturnStockReceiptMapper.class))configuration.addMapper(mapper); + var schema=Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", + "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", + "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", + "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { + f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); + if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table); + // H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。 + f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","") + .replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2") + .replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)")); + } + var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json); + var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json); + var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); + ReflectionTestUtils.setField(rules,"relabelSources",provider);ReflectionTestUtils.setField(rules,"palletSerial",new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(f.session.getMapper(PhysicalPalletSerialMapper.class)))); + ReflectionTestUtils.setField(rules,"candidates",f.session.getMapper(DispatchCandidateMapper.class));ReflectionTestUtils.setField(rules,"relabelApplications",f.session.getMapper(RelabelApplicationMapper.class)); + ReflectionTestUtils.setField(rules,"relabelMeasurements",mock(RelabelPalletMeasurementService.class));ReflectionTestUtils.setField(rules,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(rules,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class)); + ReflectionTestUtils.setField(f.service,"sourceRules",rules);ReflectionTestUtils.setField(f.service,"relabelSources",snapshots); + var inventory=new InventoryCommandService(); + ReflectionTestUtils.setField(inventory,"positions",f.session.getMapper(InventoryPositionMapper.class));ReflectionTestUtils.setField(inventory,"balances",f.session.getMapper(InventoryBalanceMapper.class)); + ReflectionTestUtils.setField(inventory,"ledgers",f.session.getMapper(InventoryLedgerMapper.class));ReflectionTestUtils.setField(inventory,"operations",f.session.getMapper(InventoryOperationMapper.class));ReflectionTestUtils.setField(inventory,"commands",f.session.getMapper(InventoryCommandMapper.class));ReflectionTestUtils.setField(inventory,"objectMapper",f.json); + ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway.class)); + ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins); + picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json); + ReflectionTestUtils.setField(picking,"physicalClaims",ReflectionTestUtils.getField(f.service,"physicalClaims"));ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots); + pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins); + balance(601,"101",5);position(101,601,"101",crossCustomer?2:5); + if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);} + application(201,"RL-A","101",9001,301,101,2,"A");application(202,"RL-B",crossCustomer?"102":"101",9002,302,crossCustomer?102:101,3,"B"); + } + /** 真实业务来源入口获取组,不构造客户端版本。 */ + DispatchPlanningModels.SelectionGroup group(){return rules.selectionGroup("9",new DispatchCommands.CargoSelection(SourceType.RELABEL,null,null,"RL-A",501L,null));} + /** 同一事务执行正式创建,真实选货来源替换中央夹具的受控普通计划。 */ + DispatchContracts.CommandResult create(String key,List selections){return f.tx.execute(s->f.service.create(f.actor,f.configured(key,selections)));} + /** 按业务来源定位逻辑明细,不将表ID作为业务单号。 */ + long item(String no){return f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE source_business_no=?",Long.class,no);} + /** 实际隔离库断言使用固定测试表和谓词。 */ + long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);} + /** 测试输入使用实际模块必填字段,不创建假结构列。 */ + void insert(String table,Object... fields){var columns=new ArrayList();var values=new ArrayList();for(int n=0;n{long id=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,id,"101","RELABEL:A:501",10L,null,null,null);return id;}); + f.tx.executeWithoutResult(s->{assertThat(f.service.reserve(new DispatchPhysicalClaimService.Owner("9",10L,null,null,"another",null),List.of(501L),AT).get(501L)).isEqualTo(physical);f.member(2,physical,"202","RELABEL:B:501",10L,null,null,null);}); + assertThat(f.count("ll_dispatch_physical_pallet_lock","status='ACTIVE'")).isOne(); + assertThat(f.count("ll_dispatch_pallet_lock","physical_lock_id="+physical+" AND status='ACTIVE'")).isEqualTo(2); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT))).isInstanceOf(BizException.class).hasMessageContaining("其他有效运输"); + } + + /** 第一逻辑来源移除后仍保持物理排他,最后成员结束才可交给下一主单。 */ + @Test void firstMemberReleaseCannotExposeRemainingCustomerPallet() throws Exception { + var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L);f.member(1,id,"101","A",10L,null,null,null);f.member(2,id,"202","B",10L,null,null,null);return id;}); + f.tx.executeWithoutResult(s->{f.endMember(1);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isZero();}); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT))).isInstanceOf(BizException.class); + f.tx.executeWithoutResult(s->{f.endMember(2);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isOne();}); + long next=f.tx.execute(s->f.service.reserve(f.dispatch(20),List.of(501L),AT).get(501L)); + assertThat(next).isNotEqualTo(physical); + f.tx.executeWithoutResult(s->assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isZero()); + assertThat(f.mapper.lockOriginal(next).getStatus()).isEqualTo("ACTIVE"); + } + + /** 最后一行释放后的后续失败必须恢复逻辑状态及原物理活动键。 */ + @Test void laterFailureRollsBackMemberAndPhysicalReleaseTogether() throws Exception { + var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L);f.member(1,id,"101","A",10L,null,null,null);return id;}); + assertThatThrownBy(()->f.tx.execute(s->{f.endMember(1);assertThat(f.service.releaseIfUnused(f.dispatch(10),List.of(physical),AT)).isOne();throw new IllegalStateException("后续业务失败");})).hasMessageContaining("后续业务失败"); + assertThat(f.mapper.lockOriginal(physical).getActiveWarehousePalletId()).isEqualTo(501L); + assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE' AND active_key='A'")).isOne(); + } + + /** 草稿与容器共同拥有实托,同一token的另一容器不能把同托拆走。 */ + @Test void draftContainerIdentityIsPartOfExclusiveOwner() throws Exception { + var f=new Fixture();var first=f.draft("DRAFT",1,AT.plusMinutes(30)); + long physical=f.tx.execute(s->f.service.reserve(first,List.of(501L),AT).get(501L)); + Long replay=f.tx.execute(s->f.service.reserve(first,List.of(501L),AT).get(501L));assertThat(replay).isEqualTo(physical); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.draft("DRAFT",2,AT.plusMinutes(30)),List.of(501L),AT))).isInstanceOf(BizException.class); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(new DispatchPhysicalClaimService.Owner("88",10L,null,null,"operator",null),List.of(501L),AT))).hasMessageContaining("起运仓"); + } + + /** 过期原草稿全部成员失效后才能重领,迟到的旧释放不能清新主单活动键。 */ + @Test void expiryIsOwnedAndOldReleaseCannotAffectNewOwner() throws Exception { + var f=new Fixture();var draft=f.draft("OLD",1,AT.minusSeconds(1)); + long old=f.tx.execute(s->{long id=f.service.reserve(draft,List.of(501L),AT.minusMinutes(1)).get(501L);f.member(1,id,"101","A",null,"OLD",1L,AT.minusSeconds(1));f.member(2,id,"202","B",null,"OLD",1L,AT.minusSeconds(1));return id;}); + long next=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(501L),AT).get(501L)); + assertThat(f.mapper.lockOriginal(old).getStatus()).isEqualTo("EXPIRED");assertThat(f.count("ll_dispatch_pallet_lock","status='EXPIRED' AND active_key IS NULL")).isEqualTo(2); + f.tx.executeWithoutResult(s->assertThat(f.service.releaseIfUnused(draft,List.of(old),AT)).isZero()); + assertThat(f.mapper.lockOriginal(next).getActiveWarehousePalletId()).isEqualTo(501L); + } + + /** 到期时间不一致不能偷走仍有效成员,前面已失效的行也随本次失败恢复。 */ + @Test void nonexpiredMemberPreventsExpiredPhysicalHeadFromBeingStolen() throws Exception { + var f=new Fixture();var draft=f.draft("OLD",1,AT.minusSeconds(1)); + long old=f.tx.execute(s->{long id=f.service.reserve(draft,List.of(501L),AT.minusMinutes(1)).get(501L);f.member(1,id,"101","A",null,"OLD",1L,AT.minusSeconds(1));f.member(2,id,"202","B",null,"OLD",1L,AT.plusMinutes(1));return id;}); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(501L),AT))).hasMessageContaining("尚未完整到期"); + assertThat(f.mapper.lockOriginal(old).getStatus()).isEqualTo("ACTIVE");assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE'")).isEqualTo(2); + } + + /** 部分成员先迁移时物理拥有者不能变,整组迁移后一次更新成功。 */ + @Test void draftMoveRequiresAllPhysicalMembersInTheSameNextContainer() throws Exception { + var f=new Fixture();long physical=f.tx.execute(s->{long id=f.service.reserve(f.draft("DRAFT",1,AT.plusMinutes(30)),List.of(501L),AT).get(501L);f.member(1,id,"101","A",null,"DRAFT",1L,AT.plusMinutes(30));f.member(2,id,"202","B",null,"DRAFT",1L,AT.plusMinutes(30));return id;}); + f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");assertThat(f.repository.moveDraft(physical,"DRAFT","operator",1,2,AT)).isZero();f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=2");assertThat(f.repository.moveDraft(physical,"DRAFT","operator",1,2,AT)).isOne();}); + assertThat(f.mapper.lockOriginal(physical).getDraftContainerId()).isEqualTo(2L); + } + + /** 服务只在全部成员已迁移时更新原物理拥有者,失败会回滚已移的逻辑成员。 */ + @Test void moveServiceRollsBackPartialMembersAndThenMovesWholePallet() throws Exception { + var f=new Fixture();var owner=f.draft("MOVE",1,AT.plusMinutes(30)); + long id=f.tx.execute(s->{long head=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,head,"101","A",null,"MOVE",1L,owner.expiresAt());f.member(2,head,"102","B",null,"MOVE",1L,owner.expiresAt());return head;}); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(id),2,AT);})).hasMessageContaining("全部逻辑来源"); + assertThat(f.count("ll_dispatch_pallet_lock","draft_container_id=1")).isEqualTo(2);assertThat(f.mapper.lockOriginal(id).getDraftContainerId()).isEqualTo(1L); + f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2");f.service.moveDraft(owner,List.of(id),2,AT);}); + assertThat(f.mapper.lockOriginal(id).getDraftContainerId()).isEqualTo(2L); + } + + /** 物理续期核对所有原租约,错误仓/过期记录/不同租约均不能部分续期。 */ + @Test void renewServiceRequiresExactOriginalLeaseAndCannotReviveExpiredClaims() throws Exception { + var f=new Fixture();var owner=f.draft("LEASE",1,AT.plusMinutes(10)); + long id=f.tx.execute(s->{long head=f.service.reserve(owner,List.of(501L),AT).get(501L);f.member(1,head,"101","A",null,"LEASE",1L,owner.expiresAt());return head;}); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("99","LEASE","operator",AT,owner.expiresAt(),AT.plusMinutes(30)))).hasMessageContaining("归属或原租约"); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,AT.plusMinutes(11),AT.plusMinutes(30)))).hasMessageContaining("归属或原租约"); + f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,owner.expiresAt(),AT.plusMinutes(30))); + assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30)); + f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT,AT.plusMinutes(30),AT.plusMinutes(30))); + assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30)); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->f.service.renewDraft("9","LEASE","operator",AT.plusMinutes(31),AT.plusMinutes(30),AT.plusMinutes(50)))).hasMessageContaining("租约无效"); + assertThat(f.mapper.lockOriginal(id).getExpiresAt()).isEqualTo(AT.plusMinutes(30)); + } + + /** 容器中只迁移所选整托,另一托不动;混入他人占用须连已移动成员一起回滚。 */ + @Test void movingOneOfTwoPalletsKeepsOtherPalletAndRejectsForeignClaim() throws Exception { + var f=new Fixture();var owner=f.draft("SUBSET",1,AT.plusMinutes(30)); + var heads=f.tx.execute(s->{var all=f.service.reserve(owner,List.of(501L,502L),AT);f.member(1,all.get(501L),"101","A",null,"SUBSET",1L,owner.expiresAt());f.member(2,all.get(502L),"102","B",null,"SUBSET",1L,owner.expiresAt());return all;}); + long foreign=f.tx.execute(s->f.service.reserve(f.draft("OTHER",3,AT.plusMinutes(30)),List.of(503L),AT).get(503L)); + assertThatThrownBy(()->f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(heads.get(501L),foreign),2,AT);})).hasMessageContaining("拥有者已变化"); + assertThat(f.count("ll_dispatch_pallet_lock","draft_container_id=1")).isEqualTo(2); + f.tx.executeWithoutResult(s->{f.db.update("UPDATE ll_dispatch_pallet_lock SET draft_container_id=2 WHERE id=1");f.service.moveDraft(owner,List.of(heads.get(501L)),2,AT);}); + assertThat(f.mapper.lockOriginal(heads.get(501L)).getDraftContainerId()).isEqualTo(2L);assertThat(f.mapper.lockOriginal(heads.get(502L)).getDraftContainerId()).isEqualTo(1L); + assertThat(f.mapper.lockOriginal(foreign).getDraftContainerId()).isEqualTo(3L); + } + + /** 两个事务都读到未占用后竞争插入,活动实托唯一键仍只允许一个主单提交。 */ + @Test void concurrentDifferentDispatchesCannotAcquireTheSamePallet() throws Exception { + var f=new Fixture();var real=f.repository;var intercepted=spy(real);var bothRead=new CyclicBarrier(2); + doAnswer(call->{var found=real.lockActive(call.getArgument(0));if(found==null)bothRead.await(5,TimeUnit.SECONDS);return found;}).when(intercepted).lockActive(501L); + var service=new DispatchPhysicalClaimService(intercepted);var pool=Executors.newFixedThreadPool(2); + try { + var tasks=new ArrayList>(); + for(int index=1;index<=2;index++) {final int own=index;tasks.add(pool.submit(()->{ + try {f.tx.executeWithoutResult(s->{long id=service.reserve(f.dispatch(own*10),List.of(501L),AT).get(501L);f.member(own,id,String.valueOf(own),"SOURCE-"+own,(long)own*10,null,null,null);});return "APPLIED";} + catch(BizException conflict){return "CONFLICT";} + }));} + assertThat(List.of(tasks.get(0).get(10,TimeUnit.SECONDS),tasks.get(1).get(10,TimeUnit.SECONDS))).containsExactlyInAnyOrder("APPLIED","CONFLICT"); + } finally {pool.shutdownNow();assertThat(pool.awaitTermination(5,TimeUnit.SECONDS)).isTrue();} + assertThat(f.count("ll_dispatch_physical_pallet_lock","status='ACTIVE'")).isOne();assertThat(f.count("ll_dispatch_pallet_lock","status='ACTIVE'")).isOne(); + } + + /** 原主键释放查询使用物理顺序,即使创建时间与托盘编号顺序相反。 */ + @Test void releaseLocksOriginalReferencesByPhysicalPalletOrder() throws Exception { + var f=new Fixture();long large=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(900L),AT).get(900L)); + long small=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(100L),AT).get(100L)); + var traced=spy(f.repository);var service=new DispatchPhysicalClaimService(traced); + f.tx.executeWithoutResult(s->assertThat(service.releaseIfUnused(f.dispatch(10),List.of(large,small),AT)).isEqualTo(2)); + var locks=inOrder(traced);locks.verify(traced).lockOriginal(small);locks.verify(traced).lockOriginal(large); + } + + /** 空物理集合不伪造托盘,缺失或非法真实引用会在访问数据库前拒绝。 */ + @Test void nonphysicalSourcesDoNotCreatePlaceholderClaims() throws Exception { + var f=new Fixture();Map empty=f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(),AT));assertThat(empty).isEmpty(); + assertThatThrownBy(()->f.tx.execute(s->f.service.reserve(f.dispatch(10),List.of(0L),AT))).isInstanceOf(IllegalArgumentException.class); + assertThat(f.count("ll_dispatch_physical_pallet_lock","1=1")).isZero(); + } + + /** 测试仅用本机隔离H2;表定义直接截取模块基线,不连接共享环境。 */ + static class Fixture { + final JdbcTemplate db;final TransactionTemplate tx;final DispatchPhysicalClaimMapper mapper; + final DispatchPhysicalClaimRepository repository;final DispatchPhysicalClaimService service; + /** 实际Mapper与Spring事务共用数据源,唯一键及失败回滚均由数据库执行。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DATABASE_TO_LOWER=TRUE;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); + db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data));tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchPhysicalClaimMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchPhysicalClaimMapper.class);repository=new DispatchPhysicalClaimRepository(mapper);service=new DispatchPhysicalClaimService(repository); + String schema;try(var in=getClass().getResourceAsStream("/db/lastleg.sql")){schema=new String(Objects.requireNonNull(in).readAllBytes(),StandardCharsets.UTF_8);} + for(var table:List.of("ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock")) { + int start=schema.indexOf("CREATE TABLE IF NOT EXISTS "+table+" (");int end=schema.indexOf(") ENGINE=",start); + if(start<0 || end<0)throw new IllegalStateException("模块物理占用表缺失");db.execute(schema.substring(start,end+1)); + } + } + /** 正式主单拥有者不绑定客户。 */ + DispatchPhysicalClaimService.Owner dispatch(long id){return new DispatchPhysicalClaimService.Owner("9",id,null,null,"operator",null);} + /** 草稿拥有者使用真实token、容器、操作人及期限。 */ + DispatchPhysicalClaimService.Owner draft(String token,long container,LocalDateTime expires){return new DispatchPhysicalClaimService.Owner("9",null,token,container,"operator",expires);} + /** 逻辑成员只是本切片准备输入,物理占用和释放始终走被测生产Mapper。 */ + void member(long id,long physical,String customer,String key,Long dispatch,String token,Long container,LocalDateTime expires){ + db.update("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,source_pallet_key,active_key,warehouse_pallet_id,dispatch_order_id,draft_token,draft_container_id,operator_id,status,locked_at,expires_at) VALUES(?,?,?,?,?,?,?,?,?,?,'ACTIVE',?,?)",id,physical,customer,key,key,mapper.selectById(physical).getWarehousePalletId(),dispatch,token,container,"operator",AT.minusMinutes(1),expires); + } + /** 模拟已校验的逻辑来源结束,为最后成员物理保护提供真实数据库输入。 */ + void endMember(long id){db.update("UPDATE ll_dispatch_pallet_lock SET status='RELEASED',active_key=NULL WHERE id=?",id);} + /** 核对隔离测试库实际状态。 */ + long count(String table,String predicate){return Objects.requireNonNull(db.queryForObject("SELECT COUNT(*) FROM "+table+" WHERE "+predicate,Long.class));} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 06167fd78..05963953b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -55,7 +55,7 @@ class DispatchPickingPersistenceTest { } /** 归属失败时连模拟库存SQL也回滚,不能留下仅库存已扣而没有扫码/账本的部分结果。 */ @Test void attributionFailureRollsBackInventoryPickTaskHeaderAndLedger() throws Exception { - var fixture=new Picking();doThrow(new IllegalStateException("模拟归属失败")).when(fixture.attribution).attribute(eq("101"),any(DispatchPdaPick.class),isNull(),isNull()); + var fixture=new Picking();doThrow(new IllegalStateException("模拟归属失败")).when(fixture.attribution).attribute(eq("101"),any(DispatchPdaPick.class),isNull()); assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,new DispatchCommands.Pick(fixture.version,"rollback-pick",11L,3,6,null,null)))).isInstanceOf(IllegalStateException.class); assertThat(fixture.f.db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=11",Long.class)).isEqualTo(3); assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero();assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_task",Long.class)).isZero(); @@ -93,10 +93,11 @@ class DispatchPickingPersistenceTest { if(multipleCustomers)f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version)VALUES(12,'102',22,502,'PL-502','9',2,0,0,0,1)"); doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any()); doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));}) - .when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull(),isNull()); + .when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull()); var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json); + for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); } /** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */ private static String sqlType(Class type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java index b8e261541..2d4e9bc52 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -10,15 +10,13 @@ import com.baiyue.lastleg.delivery.dto.DeliveryRequests; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.delivery.service.DeliveryTransferService; import com.baiyue.lastleg.delivery.service.DeliveryExecutionGuard; -import com.baiyue.lastleg.delivery.service.DispatchCargoSourceQuery; import com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository; import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; +import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter; import com.baiyue.lastleg.dispatch.mapper.*; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.pda.mapper.*; -import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionReturnItemMapper; import com.baiyue.lastleg.exceptionorder.mapper.ExceptionOrderMapper; import com.baiyue.lastleg.exceptionorder.service.ExceptionOrderService; @@ -26,9 +24,18 @@ import com.baiyue.lastleg.financefee.service.OverseasFeeSourceService; import com.baiyue.lastleg.fleet.trip.*; import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.relabel.mapper.*; import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelUnitFactMapper; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService; import com.baiyue.lastleg.relabel.service.RelabelBindingGuard; import com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; @@ -36,15 +43,12 @@ import com.baiyue.lastleg.service.LastlegOrderNoGenerator; import com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; -import org.apache.ibatis.session.LocalCacheScope; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.ValueSource; 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.test.util.ReflectionTestUtils; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.web.server.ResponseStatusException; @@ -70,153 +74,116 @@ class DispatchRepairWorkflowTest { private SqlSessionTemplate session; private final Map, Object> dependencies = new LinkedHashMap<>(); private final ObjectMapper json = new ObjectMapper().findAndRegisterModules(); - private DispatchService dispatch; + private DispatchAggregateService dispatch; + private DispatchService sourceRules; + private DispatchAggregatePersistenceTest.Fixture aggregateFixture; private DeliveryTransferService delivery; private CustomerVisitService visits; private ExceptionOrderService exceptions; private DispatchFleetAdapter fleet; private DispatchCandidateMapper candidates; private OverseasFeeSourceService fees; + private final Map cancellationCommands=new LinkedHashMap<>(); - /** 每个用例使用独立H2库,不连接运行环境;真实Mapper元数据生成最小测试表。 */ + /** 复用公开整车夹具的真实资源/占用/账本,扩展只发生在本测试的独立数据库。 */ @BeforeEach void setUp() throws Exception { - var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() - + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); - db = new JdbcTemplate(source); - transactions = new TransactionTemplate(new DataSourceTransactionManager(source)); - var configuration = new MybatisConfiguration(); - configuration.setMapUnderscoreToCamelCase(true); - configuration.setLocalCacheScope(LocalCacheScope.STATEMENT); - List> mappers = List.of(DispatchOrderMapper.class, DispatchItemMapper.class, - DispatchPalletLockMapper.class, DispatchBolMapper.class, DispatchPdaTaskMapper.class, - DispatchPdaPickMapper.class, DispatchPdaPickOrderMapper.class, OutboundOrderMapper.class, - OutboundPalletItemMapper.class, OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, - DeliveryOrderMapper.class, DeliveryOperationMapper.class, CustomerVisitMapper.class, - LastlegOrderMapper.class, LastlegInboundOrderMapper.class, LastlegOrderStatusHistoryMapper.class, - FleetTripDispatchMapper.class, FleetTripMapper.class, FleetBookingMapper.class, InventoryPositionMapper.class, - InventoryBalanceMapper.class, DispatchFeeCargoMapper.class, ExceptionReturnItemMapper.class, ExceptionOrderMapper.class, - RelabelApplicationMapper.class, RelabelItemMapper.class, PdaRelabelHistoryMapper.class, - RelabelPalletMeasurementMapper.class, ReturnInventoryCartonMapper.class, DispatchCandidateMapper.class, - WarehouseTransferOrderMapper.class); - mappers.forEach(configuration::addMapper); - var factory = new MybatisSqlSessionFactoryBean(); - factory.setDataSource(source); - factory.setConfiguration(configuration); - factory.afterPropertiesSet(); - session = new SqlSessionTemplate(factory.getObject()); - for (Class mapper : mappers) dependencies.put(mapper, session.getMapper(mapper)); - // 不复制正式SQL,也不声称验证生产DDL;只构造本文件使用的实体表。 - for (Class mapper : mappers) for (var parent : mapper.getGenericInterfaces()) { - if (!(parent instanceof java.lang.reflect.ParameterizedType generic) - || generic.getRawType() != com.baomidou.mybatisplus.core.mapper.BaseMapper.class) continue; - var table = TableInfoHelper.getTableInfo((Class) generic.getActualTypeArguments()[0]); - var columns = new ArrayList(); - if (table.havePK()) columns.add(table.getKeyColumn() + " " + sqlType(table.getKeyType()) - + (table.getIdType() == com.baomidou.mybatisplus.annotation.IdType.AUTO ? " AUTO_INCREMENT" : "") + " PRIMARY KEY"); - table.getFieldList().forEach(field -> columns.add(field.getColumn() + " " + sqlType(field.getPropertyType()))); - db.execute("CREATE TABLE IF NOT EXISTS " + table.getTableName() + " (" + String.join(",", columns) + ")"); + aggregateFixture=new DispatchAggregatePersistenceTest.Fixture();db=aggregateFixture.db;transactions=aggregateFixture.tx;session=aggregateFixture.session; + List> mappers=List.of(DispatchItemMapper.class,DispatchPalletLockMapper.class,DispatchBolMapper.class,DispatchPdaTaskMapper.class, + DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaTaskMapper.class,OutboundPdaPickMapper.class, + DeliveryOrderMapper.class,DeliveryOperationMapper.class,CustomerVisitMapper.class,LastlegOrderMapper.class,LastlegInboundOrderMapper.class,LastlegOrderStatusHistoryMapper.class, + FleetTripDispatchMapper.class,FleetTripMapper.class,FleetBookingMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,DispatchFeeCargoMapper.class, + ExceptionReturnItemMapper.class,ExceptionOrderMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,PdaRelabelHistoryMapper.class,RelabelPalletMeasurementMapper.class, + ReturnInventoryCartonMapper.class,DispatchCandidateMapper.class,WarehouseTransferOrderMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class, + RelabelDispatchSourceMapper.class,PdaRelabelTaskMapper.class,PdaRelabelUnitFactMapper.class,PhysicalPalletSerialMapper.class); + for(var mapper:mappers) { + if(!session.getConfiguration().hasMapper(mapper))session.getConfiguration().addMapper(mapper); + dependencies.put(mapper,session.getMapper(mapper)); + for(var parent:mapper.getGenericInterfaces()) { + if(!(parent instanceof java.lang.reflect.ParameterizedType generic)||generic.getRawType()!=com.baomidou.mybatisplus.core.mapper.BaseMapper.class)continue; + var table=TableInfoHelper.getTableInfo((Class)generic.getActualTypeArguments()[0]); + db.execute("CREATE TABLE IF NOT EXISTS "+table.getTableName()+"("+table.getKeyColumn()+" "+sqlType(table.getKeyType())+" PRIMARY KEY)"); + for(var field:table.getFieldList())if(!table.getTableName().equals("ll_fleet_trip_dispatch") || !List.of("customer_id","review_json").contains(field.getColumn()))db.execute("ALTER TABLE "+table.getTableName()+" ADD COLUMN IF NOT EXISTS "+field.getColumn()+" "+sqlType(field.getPropertyType())); + } } - db.execute("CREATE UNIQUE INDEX test_dispatch_active_lock ON ll_dispatch_pallet_lock(customer_id,active_key)"); - // 普通换标与退货换标候选查询含MySQL专用聚合;固定输入不替代完整集合校验或状态写入。 - candidates = spy(session.getMapper(DispatchCandidateMapper.class)); - doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(), any(), any()); - dependencies.put(DispatchCandidateMapper.class, candidates); - dependencies.put(ObjectMapper.class, json); - dependencies.put(LastlegOrderNoGenerator.class, new LastlegOrderNoGenerator()); - dependencies.put(FleetDispatchBindingGuard.class, - new FleetDispatchBindingGuard(mapper(FleetTripDispatchMapper.class), json)); - dependencies.put(FleetDispatchWorkflowQuery.class, - new FleetDispatchWorkflowQuery(mapper(FleetTripDispatchMapper.class), mapper(FleetTripMapper.class), json)); - var execution = new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class)); - dependencies.put(DeliveryExecutionRepository.class, execution); - dependencies.put(DeliveryExecutionGuard.class, new DeliveryExecutionGuard(execution)); - dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class, - new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class), - mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class), - mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class), - mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class))); - // 本旧流程夹具只隔离新整车端口;父身份及已拣数量仍从本事务实际锁定的记录读取。 - var wholeExecution=mock(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class); - when(wholeExecution.lockForArrangement(any(),anyString())).thenAnswer(call -> { - var parent=mapper(DispatchOrderMapper.class).lockGlobalByDispatchNo(call.getArgument(1)); - var context=mock(com.baiyue.lastleg.dispatch.port.DispatchContracts.Context.class); - when(context.dispatchId()).thenReturn(parent.getId()); - when(context.status()).thenReturn(com.baiyue.lastleg.dispatch.port.DispatchTypes.Status.ACTIVE); - when(context.stage()).thenReturn(com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage.BEFORE_LOADING); - when(context.counts()).thenReturn(new com.baiyue.lastleg.dispatch.port.DispatchContracts.Counts(0,0,0, - parent.getPickedPackageCount()==null?0:parent.getPickedPackageCount(),0)); - return context; - }); - dependencies.put(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class,wholeExecution); - dependencies.put(DispatchCargoSourceQuery.class, - new DispatchCargoSourceQuery(new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class)))); - dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class, - mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); - dependencies.put(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class, - mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class)); - fees = mock(OverseasFeeSourceService.class); - dependencies.put(OverseasFeeSourceService.class, fees); - var binding = wire(new RelabelBindingGuard()); - var measurements = wire(new RelabelPalletMeasurementService()); - var returnStock = wire(new ReturnRelabelDispatchStock()); - dependencies.put(RelabelBindingGuard.class, binding); - dependencies.put(RelabelPalletMeasurementService.class, measurements); - dependencies.put(ReturnRelabelDispatchStock.class, returnStock); - dispatch = wire(new DispatchService()); - delivery = wire(new DeliveryTransferService()); - visits = wire(new CustomerVisitService()); - exceptions = wire(new ExceptionOrderService()); - fleet = new DispatchFleetAdapter(mapper(DispatchOrderMapper.class), mapper(DispatchPdaTaskMapper.class), - mapper(OutboundOrderMapper.class), mapper(DeliveryOrderMapper.class), mapper(DispatchItemMapper.class), - mapper(DispatchPdaPickMapper.class), new DispatchPdaWorkflowService(), mapper(DeliveryOperationMapper.class)); - wire(fleet); + db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)"); + db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet(id BIGINT,customer_id VARCHAR(128) NOT NULL,plan_id BIGINT,pallet_no VARCHAR(128),warehouse_pallet_id BIGINT)"); + db.execute("CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))"); + for(var column:List.of("order_id BIGINT","warehouse_pallet_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT"))db.execute("ALTER TABLE ll_inbound_stock_receipt ADD COLUMN IF NOT EXISTS "+column); + candidates=spy(session.getMapper(DispatchCandidateMapper.class));doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(),any(),any());dependencies.put(DispatchCandidateMapper.class,candidates); + dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,new LastlegOrderNoGenerator()); + var sourceRepository=new com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository(candidates,session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)); + dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.class,sourceRepository);dependencies.put(DispatchCommandHasher.class,new DispatchCommandHasher(json)); + var relabelSources=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(mapper(RelabelDispatchSourceMapper.class)),json); + dependencies.put(RelabelDispatchSourcePort.class,relabelSources); + dependencies.put(PhysicalPalletSerialPort.class,new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(mapper(PhysicalPalletSerialMapper.class)))); + dependencies.put(RelabelBindingGuard.class,wire(new RelabelBindingGuard()));dependencies.put(RelabelPalletMeasurementService.class,wire(new RelabelPalletMeasurementService())); + dependencies.put(ReturnRelabelDispatchStock.class,wire(new ReturnRelabelDispatchStock()));sourceRules=wire(new DispatchService()); + dispatch=aggregateFixture.service;ReflectionTestUtils.setField(dispatch,"sourceRules",sourceRules); + ReflectionTestUtils.setField(dispatch,"relabelSources",new DispatchItemSourceService( + new com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository(session.getMapper(DispatchItemSourceMapper.class)),relabelSources,json)); + dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository.class,aggregateFixture.repository); + dependencies.put(com.baiyue.lastleg.dispatch.port.DispatchExecutionPort.class,dispatch); + var cargo=new DispatchCargoSourceRepository(mapper(DispatchFeeCargoMapper.class)); + fleet=new DispatchFleetAdapter(aggregateFixture.repository,mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class),mapper(DeliveryOperationMapper.class),cargo,dispatch,json); + dependencies.put(FleetDispatchBindingGuard.class,new FleetDispatchBindingGuard(mapper(FleetTripDispatchMapper.class),fleet)); + dependencies.put(FleetDispatchWorkflowQuery.class,new FleetDispatchWorkflowQuery(mapper(FleetTripDispatchMapper.class),fleet)); + var execution=new DeliveryExecutionRepository(mapper(OutboundOrderMapper.class),mapper(DeliveryOrderMapper.class));dependencies.put(DeliveryExecutionRepository.class,execution);dependencies.put(DeliveryExecutionGuard.class,new DeliveryExecutionGuard(execution)); + dependencies.put(com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository.class,new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository(mapper(OutboundOrderMapper.class),mapper(OutboundPalletItemMapper.class),mapper(DeliveryOrderMapper.class),mapper(OutboundPdaTaskMapper.class),mapper(DeliveryOperationMapper.class),mapper(RelabelApplicationMapper.class),mapper(ReturnInventoryCartonMapper.class),mapper(WarehouseTransferOrderMapper.class),mapper(LastlegOrderMapper.class),mapper(LastlegOrderStatusHistoryMapper.class))); + fees=mock(OverseasFeeSourceService.class);dependencies.put(OverseasFeeSourceService.class,fees); + delivery=wire(new DeliveryTransferService());visits=wire(new CustomerVisitService());exceptions=wire(new ExceptionOrderService()); + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("OP","", "platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:cancel","platform:overseas-warehouse:dispatch:transfer-delivery")); } - + /** 真实权限上下文不泄漏到其他测试。 */ + @org.junit.jupiter.api.AfterEach void clearSecurity(){org.springframework.security.core.context.SecurityContextHolder.clearContext();} /** 历史取消出库仍可追溯;取消旧排车后同货重排,重复取消不得释放新锁。 */ @Test void cancelledOutboundThenDispatchCancellationAllowsNewArrangementWithoutReleasingItsLock() { seedPendingOutbound(); - db.update("INSERT INTO ll_dispatch_pallet_lock(id,customer_id,dispatch_order_id,plan_pallet_id,warehouse_pallet_id,source_pallet_key,active_key,status) VALUES(40,'C',1,900,501,'PLAN:900','PLAN:900','ACTIVE')"); + var source=seedPlanSource(); + db.update("UPDATE ll_dispatch_item SET source_key='PLAN:900',plan_pallet_id=900,order_id=9900,source_stage='RECEIVED',source_business_type='INBOUND_PLAN' WHERE id=4"); + db.update("INSERT INTO ll_dispatch_physical_pallet_lock(id,warehouse_id,warehouse_pallet_id,active_warehouse_pallet_id,dispatch_order_id,operator_id,status) VALUES(41,'9',501,501,1,'OP','ACTIVE')"); + db.update("INSERT INTO ll_dispatch_pallet_lock(id,physical_lock_id,customer_id,dispatch_order_id,plan_pallet_id,warehouse_pallet_id,source_pallet_key,active_key,status) VALUES(40,41,'101',1,900,501,'PLAN:900','PLAN:900','ACTIVE')"); run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重新安排"))); assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("CANCELLED"); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("CANCELLED"); long operations = count("ll_delivery_operation"); - run(() -> dispatch.cancel("DP-1")); + run(() -> cancel("DP-1")); assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("CANCELLED"); assertThat(text("ll_dispatch_pallet_lock", "status", 40)).isEqualTo("RELEASED"); assertThat(db.queryForObject("SELECT active_key FROM ll_dispatch_pallet_lock WHERE id=40", String.class)).isNull(); - doReturn(List.of(candidate(501).planPalletId(900L))).when(candidates).lockSelected("C", List.of(900L)); - var created = inTransaction(() -> dispatch.preArrange("C", "OP", - new DispatchRequests.PreArrange(List.of(900L), null, null, null))); + assertThat(text("ll_dispatch_physical_pallet_lock","status",41)).isEqualTo("RELEASED"); + var created = inTransaction(() -> dispatch.create(aggregateFixture.actor,create(List.of(source)))); assertThat(created.dispatchNo()).isNotEqualTo("DP-1"); - assertThat(created.actions().canCancel()).isTrue(); - assertThat(created.actions().canTransfer()).isTrue(); + var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo()); + assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue(); + assertThat(current.actions().get(DispatchTypes.Action.ARRANGE_DELIVERY).allowed()).isFalse(); + assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId()); run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("重复取消"))); - run(() -> dispatch.cancel("DP-1")); + run(() -> cancel("DP-1")); assertThat(count("ll_outbound_order")).isEqualTo(1); assertThat(count("ll_delivery_operation")).isEqualTo(operations); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); - verify(fees, times(1)).listForBusiness("C", "DELIVERY_ORDER", 3L); + verify(fees, times(1)).listForBusiness("101", "DELIVERY_ORDER", 3L); verify(fees, never()).voidSource(anyLong(), any(), any()); } - /** 真实车次关联和已拣事实分别阻止取消,失败事务不修改单据及占用。 */ + /** 唯一自动车次关系保持;真实已拣阻止取消,失败事务不修改单据及关系。 */ @Test - void bindingThenPickingIndependentlyPreventOutboundCancellation() { + void automaticTripRelationshipRemainsWhilePickingPreventsOutboundCancellation() { seedPendingOutbound(); - db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(51,'TR-1',1,'DP-1','C')"); - assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消")))) - .isInstanceOf(ResponseStatusException.class).hasMessageContaining("解除关联"); - assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); - db.update("DELETE FROM ll_fleet_trip_dispatch WHERE dispatch_id=1"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_fleet_trip_dispatch WHERE dispatch_id=1",Long.class)).isEqualTo(1); + var binding=db.queryForMap("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=1"); db.update("UPDATE ll_dispatch_order SET picked_package_count=1 WHERE id=1"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,dispatch_item_id,packages) VALUES(103,'101',4,1)"); assertThatThrownBy(() -> run(() -> delivery.cancelOutbound("OUT-1", "OP", new DeliveryRequests.Cancel("取消")))) .isInstanceOf(ResponseStatusException.class); assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); assertThat(count("ll_delivery_operation")).isZero(); + assertThat(db.queryForMap("SELECT * FROM ll_fleet_trip_dispatch WHERE dispatch_id=1")).isEqualTo(binding); } /** 未出库开始派送/拒签均拒绝;真实出库后允许开始,但普通状态编辑始终不能取消。 */ @@ -224,23 +191,23 @@ class DispatchRepairWorkflowTest { @ValueSource(strings = {"DELIVERING", "REJECTED"}) void deliveryActionsFollowActualOutboundAndNeverOfferAnIndependentCancellation(String target) { seedPendingOutbound(); - assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1", new CustomerVisitRequests.DeliveryStatus(target, 0L, "提前操作")))) .isInstanceOf(ResponseStatusException.class); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); assertThat(count("ll_delivery_operation")).isZero(); - db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=2 WHERE id=1"); + db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',shipped_package_count=2,departed_at=CURRENT_TIMESTAMP WHERE id=1"); db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2"); - assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + assertThatThrownBy(() -> run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1", new CustomerVisitRequests.DeliveryStatus("CANCELLED", 0L, "已发车取消")))) .isInstanceOf(ResponseStatusException.class); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo("WAIT_DELIVERY"); - run(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + run(() -> visits.updateDeliveryStatus("101", "OP", "DL-1", new CustomerVisitRequests.DeliveryStatus(target, 0L, "合法操作"))); assertThat(text("ll_delivery_order", "status", 3)).isEqualTo(target); assertThat(db.queryForObject("SELECT version FROM ll_delivery_order WHERE id=3", Long.class)).isEqualTo(1); long operations = count("ll_delivery_operation"); - var replay = inTransaction(() -> visits.updateDeliveryStatus("C", "OP", "DL-1", + var replay = inTransaction(() -> visits.updateDeliveryStatus("101", "OP", "DL-1", new CustomerVisitRequests.DeliveryStatus(target, 0L, "同状态重放"))); assertThat(replay.replayed()).isTrue(); assertThat(count("ll_delivery_operation")).isEqualTo(operations); @@ -250,52 +217,68 @@ class DispatchRepairWorkflowTest { @Test void departureManifestRejectsCancelledMissingAndIncompleteDeliveryCoverage() { seedPendingOutbound(); - db.update("UPDATE ll_dispatch_order SET status='READY_TO_DEPART',picked_package_count=2 WHERE id=1"); - db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')"); - db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages) VALUES(103,'C',102,4,104,2)"); + db.update("UPDATE ll_dispatch_order SET stage='AFTER_LOADING',picked_package_count=2 WHERE id=1"); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,status,warehouse_id) VALUES(102,1,'COMPLETED','9')"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,inventory_position_id,packages) VALUES(103,'101',102,4,104,2)"); + db.update("INSERT INTO ll_order(id,customer_id,order_no) VALUES(100,'101','LM-100')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(104,'101',501,'9',2)"); + db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,order_id,warehouse_pallet_id,inventory_position_id,received_packages,inbound_code) VALUES(106,'101',100,501,104,2,'IN-100')"); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,receipt_id,order_id,packages) VALUES(105,'101',103,106,100,2)"); assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNull(); - db.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=3"); - assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); var guard = (DeliveryExecutionGuard) dependencies.get(DeliveryExecutionGuard.class); - assertThatThrownBy(() -> run(() -> guard.requireDeparture(mapper(OutboundOrderMapper.class).findByNo("C", "OUT-1")))) + assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNull(); + db.update("UPDATE ll_delivery_order SET status='CANCELLED' WHERE id=3"); + assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank(); + assertThatThrownBy(() -> run(() -> guard.requireDeparture(mapper(OutboundOrderMapper.class).findByNo("101", "OUT-1")))) .isInstanceOf(ResponseStatusException.class); db.update("UPDATE ll_delivery_order SET status='WAIT_DELIVERY',package_count=1 WHERE id=3"); - assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); + assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank(); db.update("DELETE FROM ll_delivery_order WHERE id=3"); - assertThat(inTransaction(() -> fleet.manifest("DP-1", true)).blockReason()).isNotBlank(); - assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("READY_TO_DEPART"); + assertThat(inTransaction(()->guard.departureBlockReason(mapper(OutboundOrderMapper.class).findByNo("101","OUT-1"),true))).isNotBlank(); + assertThat(text("ll_dispatch_order", "status", 1)).isEqualTo("ACTIVE"); + assertThat(text("ll_dispatch_order", "stage", 1)).isEqualTo("AFTER_LOADING"); assertThat(text("ll_outbound_order", "status", 2)).isEqualTo("WAIT_OUTBOUND"); } /** 两托部分选取和重复ID都必须零副作用,完整预排取消后仍可整单重新预排。 */ @Test void ordinaryRelabelRequiresAllPalletsAndCanBeRearrangedAfterCancellation() { - db.update("INSERT INTO ll_relabel_application(id,customer_id,relabel_no,overseas_warehouse_id,status,fulfillment_status,delivery_method) VALUES(70,'C','RL-1','WH-1','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH','TRUCK')"); + var confirmedAt=LocalDateTime.of(2026,10,19,12,5); + db.update("INSERT INTO ll_relabel_application(id,customer_id,relabel_no,lastleg_order_id,overseas_warehouse_id,status,fulfillment_status,delivery_method,total_package_count,notified_package_count,item_count,updated_at,customer_confirmed_at) VALUES(70,'101','RL-1',9501,'9','READY_FOR_OUTBOUND','WAIT_PRE_DISPATCH','TRUCK',2,2,2,?,?)",confirmedAt,confirmedAt); + db.update("INSERT INTO ll_relabel_pda_task(id,application_id,customer_id,relabel_no,overseas_warehouse_id,status,reviewed_at,version) VALUES(72,70,'101','RL-1',9,'COMPLETED',?,1)",confirmedAt); + db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,detained,package_count,overseas_warehouse_id) VALUES(9501,'101','LM-RL','RL-1',0,2,'9')"); for (long pallet : List.of(501L, 502L)) { - db.update("INSERT INTO ll_inventory_position(id,customer_id,pallet_id,pallet_no,overseas_warehouse_id,hold_packages) VALUES(?,'C',?,?,'WH-1',1)", pallet, pallet, "P-" + pallet); - db.update("INSERT INTO ll_relabel_item(id,customer_id,application_id,operation_type,source_inventory_position_id,carton_mark,notified_package_count) VALUES(?,'C',70,'CARTON_LABEL',?,?,1)", pallet, pallet, "BOX-" + pallet); + db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(?,'101',?,1)",7000+pallet,"IN-"+pallet); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(?,'101',?,?)",pallet,7000+pallet,"IN-"+pallet); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version) VALUES(?,'101',?,?,?,'9',0,1,0,0,0)", pallet,pallet,pallet, "P-" + pallet); + db.update("INSERT INTO ll_relabel_item(id,customer_id,application_id,operation_type,source_inventory_position_id,source_inventory_balance_id,source_inbound_order_id,source_inbound_order_no,source_inbound_code,carton_mark,notified_package_count,total_package_count) VALUES(?,'101',70,'CARTON_LABEL',?,?,?,?,?,?,1,1)", + pallet,pallet,pallet,7000+pallet,"IN-"+pallet,"IN-"+pallet,"BOX-"+pallet); + db.update("INSERT INTO ll_relabel_pda_unit_fact(id,task_id,application_id,item_id,customer_id,old_label,new_label,result,quantity,occurred_at) VALUES(?,72,70,?,'101',?,?,'RELABELLED',1,?)", + 8000+pallet,pallet,"BOX-"+pallet,"NEW-BOX-"+pallet,confirmedAt.minusMinutes(3)); } - db.update("INSERT INTO ll_relabel_pda_history(id,customer_id,application_id,action,detail_json) VALUES(71,'C',70,'PALLET_BIND',?)", - "{\"cartonMarks\":[\"BOX-501\",\"BOX-502\"],\"targetPallets\":[\"P-501\",\"P-502\"]}"); - doReturn(List.of(candidate(501).orderRefs("RL-1").packageCount(1), candidate(502).orderRefs("RL-1").packageCount(1))) - .when(candidates).relabelAvailable("C", null, "RL-1"); + db.update("INSERT INTO ll_relabel_pda_history(id,task_id,customer_id,application_id,action,detail_json,created_at) VALUES(71,72,'101',70,'PALLET_BIND',?,?)", + "{\"round\":\"INITIAL\",\"cartonMarks\":[\"BOX-501\",\"BOX-502\"],\"targetPallets\":[\"P-501\",\"P-502\"]}",confirmedAt.minusMinutes(4)); + db.update("INSERT INTO ll_relabel_pda_history(id,task_id,customer_id,application_id,action,detail_json,created_at) VALUES(73,72,'101',70,'COMPLETE',?,?)", + "{\"roundKey\":\"INITIAL\"}",confirmedAt.minusMinutes(2)); + doReturn(List.of(relabelCandidate(501),relabelCandidate(502))) + .when(candidates).relabelAvailable("101", null, "RL-1"); for (var selection : List.of(List.of(501L), List.of(501L, 501L, 502L))) { - assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(selection)))) + assertThatThrownBy(() -> inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(selection)))) .isInstanceOf(ResponseStatusException.class); assertThat(count("ll_dispatch_order")).isZero(); assertThat(count("ll_dispatch_item")).isZero(); assertThat(count("ll_dispatch_pda_task")).isZero(); assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH"); } - var first = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L)))); - assertThat(first.actions().canRemovePallet()).isFalse(); + var first = inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L)))); + assertThat(dispatch.items(aggregateFixture.actor,first.dispatchNo()).items()).allSatisfy(i->assertThat(i.remove().allowed()).isFalse()); assertThat(count("ll_dispatch_item")).isEqualTo(2); assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("PRE_DISPATCH_CREATED"); - assertThatThrownBy(() -> inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L))))) + assertThatThrownBy(() -> inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L))))) .isInstanceOf(ResponseStatusException.class); - run(() -> dispatch.cancel(first.dispatchNo())); + run(() -> cancel(first.dispatchNo())); assertThat(text("ll_relabel_application", "fulfillment_status", 70)).isEqualTo("WAIT_PRE_DISPATCH"); - var second = inTransaction(() -> dispatch.preArrange("C", "OP", relabel(List.of(501L, 502L)))); + var second = inTransaction(() -> dispatch.create(aggregateFixture.actor,relabel(List.of(501L, 502L)))); assertThat(second.dispatchNo()).isNotEqualTo(first.dispatchNo()); assertThat(count("ll_dispatch_order")).isEqualTo(2); } @@ -306,30 +289,31 @@ class DispatchRepairWorkflowTest { void mixedPalletUsesBothActualSourcesForBindingAndFreight(String displayInbound) throws Exception { seedPendingOutbound(); db.update("UPDATE ll_dispatch_item SET inbound_code=?,package_count=5 WHERE id=4", displayInbound); - db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(80,'C',81,'IN-NORMAL'),(82,'C',83,'IN-EXT')"); - db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages) VALUES(84,'C',80,501,'P-501','WH-1',3),(85,'C',82,501,'P-501','WH-1',2)"); - db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code) VALUES(81,'C','LM-NORMAL','IN-NORMAL'),(83,'C','LM-EXT','IN-EXT')"); + db.update("UPDATE ll_dispatch_order SET package_count=5 WHERE id=1");db.update("UPDATE ll_fleet_trip_dispatch SET package_count=5 WHERE dispatch_id=1"); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(80,'101',81,'IN-NORMAL'),(82,'101',83,'IN-EXT')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages) VALUES(84,'101',80,501,'P-501','9',3),(85,'101',82,501,'P-501','9',2)"); + db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code) VALUES(81,'101','LM-NORMAL','IN-NORMAL'),(83,'101','LM-EXT','IN-EXT')"); // 该测试同时校验客户隔离,不能把同物理托盘编号的他客库存算进来。 - db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(86,'OTHER',87,'FOREIGN')"); - db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(88,'OTHER',86,501,'WH-1',99)"); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,inbound_code) VALUES(86,'202',87,'FOREIGN')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages) VALUES(88,'202',86,501,'9',99)"); db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); db.execute("CREATE TABLE IF NOT EXISTS ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT)"); - var realCargo = mapper(DispatchFeeCargoMapper.class).list("C", 1); + var realCargo = mapper(DispatchFeeCargoMapper.class).list("101", 1); assertThat(realCargo).extracting(row -> row.inboundCode()).containsExactly("IN-NORMAL", "IN-EXT"); assertThat(realCargo).extracting(row -> row.packages()).containsExactly(3L, 2L); - var sources = inTransaction(() -> fleet.sourceCargo("DP-1")); + var sources = inTransaction(() -> fleet.sourceCargo("DP-1","101")); assertThat(sources).extracting(FleetDispatchGateway.SourceCargo::inboundCode) .containsExactlyInAnyOrder("IN-NORMAL", "IN-EXT"); assertThat(sources.stream().mapToInt(FleetDispatchGateway.SourceCargo::packages).sum()).isEqualTo(5); seedExternalBooking(); var reservations = new FleetBookingReservations(mapper(FleetBookingMapper.class), json); - var trip = new FleetTripEntity(); trip.setId(91L); trip.setTripNo("TR-1"); + var trip=mapper(FleetTripMapper.class).selectById(aggregateFixture.repository.find("DP-1").getTripId()); run(() -> reservations.bindIncoming(trip, - new FleetDispatchGateway.Snapshot(1L, "DP-1", "C", "WH-1", 5), sources, "OP")); + new FleetDispatchGateway.Snapshot(1L, "DP-1", "9", 5), "101", sources, "OP")); assertThat(text("ll_fleet_external_booking", "status", 90)).isEqualTo("BOUND"); - db.update("INSERT INTO ll_fleet_trip_dispatch(trip_id,trip_no,dispatch_id,dispatch_no,customer_id) VALUES(91,'TR-1',1,'DP-1','C')"); + assertThat(mapper(FleetTripDispatchMapper.class).findByDispatchNo("DP-1").getTripId()).isEqualTo(trip.getId()); var freight = new FleetBookingFreight(mapper(FleetBookingMapper.class), mapper(FleetTripDispatchMapper.class), reservations, json); - var preview = inTransaction(() -> freight.preview(1L, "DP-1", "C", sources)); + var preview = inTransaction(() -> freight.preview(1L, "DP-1", "101", sources)); assertThat(preview.requiresDeliveryFee()).isTrue(); assertThat(preview.quotes()).singleElement().satisfies(quote -> { assertThat(quote.bookingNo()).isEqualTo("FB-EXT"); @@ -348,13 +332,13 @@ class DispatchRepairWorkflowTest { db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=2"); db.update("UPDATE ll_dispatch_item SET source_stage='RETURN_RELABEL',source_business_type='RELABEL_ORDER',source_business_no='RL-1',inbound_code='RL-1' WHERE id=4"); db.update("UPDATE ll_outbound_pallet_item SET inbound_code='RL-1' WHERE id=5"); - db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,delivery_order_no,outbound_order_no,package_count,overseas_warehouse_id) VALUES(100,'C','LM-RL','RL-1','DL-1','OUT-1',2,'WH-1')"); - db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(104,'C','RL-1',2)"); - db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,carton_mark,pallet_code,overseas_warehouse_id,status,relabel_no,total_quantity) VALUES(101,'C','BOX-RL','P-501','WH-1','OUTBOUNDED','RL-1',6)"); - db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id,status,warehouse_id) VALUES(102,'C',1,'COMPLETED','WH-1')"); - db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,6)"); - db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages,product_quantity) VALUES(105,'C',103,100,1,6)"); - var relations = inTransaction(() -> exceptions.returnCandidates("C", "DL-1")); + db.update("INSERT INTO ll_order(id,customer_id,order_no,inbound_code,delivery_order_no,outbound_order_no,package_count,overseas_warehouse_id) VALUES(100,'101','LM-RL','RL-1','DL-1','OUT-1',2,'9')"); + db.update("INSERT INTO ll_inbound_order(id,customer_id,inbound_code,package_count) VALUES(104,'101','RL-1',2)"); + db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,carton_mark,pallet_code,overseas_warehouse_id,status,relabel_no,total_quantity) VALUES(101,'101','BOX-RL','P-501','9','OUTBOUNDED','RL-1',6)"); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,status,warehouse_id) VALUES(102,1,'COMPLETED','9')"); + db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'101',102,4,101,1,6)"); + db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,return_carton_id,order_id,packages,product_quantity) VALUES(105,'101',103,101,100,1,6)"); + var relations = inTransaction(() -> exceptions.returnCandidates("101", "DL-1")); assertThat(relations).hasSize(1); assertThat(relations.get(0).returnSources()).singleElement().satisfies(source -> { var actual = json.valueToTree(source); @@ -367,11 +351,14 @@ class DispatchRepairWorkflowTest { /** 准备统一未拣货排车、出库及派送关系,避免用桩伪造取消资格。 */ private void seedPendingOutbound() { - db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,overseas_warehouse_id,status,package_count,picked_package_count,shipped_package_count) VALUES(1,'C','DP-1','WH-1','PENDING_DEPARTURE',2,0,0)"); - db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,dispatch_no,outbound_type,status,package_count) VALUES(2,'C','OUT-1',1,'DP-1','DISPATCH_OUTBOUND','WAIT_OUTBOUND',2)"); - db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count) VALUES(3,'C','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2)"); - db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'C',1,501,'P-501','WH-1',2,'FBA','ONT8')"); - db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'C',2,501,'P-501',2,0,'WAIT_PICK','ONT8')"); + var shell=inTransaction(()->aggregateFixture.fleet.createDispatchShell("OP",new FleetExecutionModels.DispatchShellRequest(1,"DP-1",9,"seed-physical-trip","b".repeat(64),null,null,null,null))); + db.update("INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round,dispatch_review_json,loading_review_json,resources_json,item_count,pallet_count,package_count,picked_package_count,shipped_package_count) VALUES(1,'DP-1',?,'9','ACTIVE','BEFORE_LOADING',0,0,?,'DRAFT',0,?,?,?,1,1,2,0,0)",shell.tripId(),"c".repeat(64),"{\"status\":\"NOT_SUBMITTED\",\"reviewRound\":0}","{\"status\":\"NOT_SUBMITTED\",\"reviewRound\":0}","{\"vehicle\":null,\"driver\":null}"); + run(()->aggregateFixture.fleet.linkDispatchShell("OP",1,"DP-1",shell.tripNo())); + db.update("UPDATE ll_fleet_trip_dispatch SET package_count=2 WHERE dispatch_id=1"); + db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,dispatch_no,outbound_type,status,package_count,pallet_count) VALUES(2,'101','OUT-1',1,'DP-1','DISPATCH_OUTBOUND','WAIT_OUTBOUND',2,1)"); + db.update("INSERT INTO ll_delivery_order(id,customer_id,delivery_no,outbound_no,outbound_order_id,dispatch_order_id,status,version,package_count,pallet_count) VALUES(3,'101','DL-1','OUT-1',2,1,'WAIT_DELIVERY',0,2,1)"); + db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,pallet_no,overseas_warehouse_id,package_count,destination_type,destination_warehouse_code) VALUES(4,'101',1,501,'P-501','9',2,'FBA','ONT8')"); + db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,pallet_no,package_count,picked_package_count,status,destination_warehouse_code) VALUES(5,'101',2,501,'P-501',2,0,'WAIT_PICK','ONT8')"); } /** 固定真实外接实收两箱及约定金额,后续绑定和报价核对均运行生产逻辑。 */ @@ -379,26 +366,55 @@ class DispatchRepairWorkflowTest { var cargo = new FleetBookingModels.Cargo("FBA:ONT8", "外接货", 2, 1, BigDecimal.TEN, BigDecimal.ONE, "PALLET", new BigDecimal("60.00"), "CNY", null, null, null); var receipt = new FleetBookingModels.AcceptedReceipt(new FleetBookingIncomingGateway.Evidence( - "WT-EXT", "IN-EXT", "WH-1", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-v1", + "WT-EXT", "IN-EXT", "9", "FBA:ONT8", "READY_FOR_DISPATCH", "receipt-v1", List.of(new FleetBookingIncomingGateway.Pallet("501", "P-501", 2, BigDecimal.TEN, BigDecimal.ONE)), 2), List.of(new FleetBookingModels.PalletSpaces("501", 1, null, null)), "OP", Instant.parse("2026-10-01T00:00:00Z")); // 来货业务编号和库存入仓码是两个标识,必须经真实来货登记关联,不能混用。 - db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no,inbound_code,status) VALUES(93,'C','WT-EXT','IN-EXT','READY_FOR_DISPATCH')"); - db.update("INSERT INTO ll_fleet_external_booking(id,trip_id,trip_no,booking_no,incoming_no,customer_id,destination_key,status,agreed_amount,record_version,cargo_json,receipt_json) VALUES(90,91,'TR-1','FB-EXT','WT-EXT','C','FBA:ONT8','RECEIVED',60,1,?,?)", - json.writeValueAsString(cargo), json.writeValueAsString(receipt)); + db.update("INSERT INTO ll_warehouse_transfer_order(id,customer_id,transfer_no,inbound_code,status) VALUES(93,'101','WT-EXT','IN-EXT','READY_FOR_DISPATCH')"); + var trip=mapper(FleetTripMapper.class).selectById(aggregateFixture.repository.find("DP-1").getTripId()); + db.update("INSERT INTO ll_fleet_external_booking(id,trip_id,trip_no,booking_no,incoming_no,customer_id,destination_key,status,agreed_amount,record_version,cargo_json,receipt_json) VALUES(90,?,?,'FB-EXT','WT-EXT','101','FBA:ONT8','RECEIVED',60,1,?,?)", + trip.getId(),trip.getTripNo(),json.writeValueAsString(cargo), json.writeValueAsString(receipt)); } /** 构造来源输入;真实门禁仍由DispatchService及数据库锁决定。 */ private DispatchPalletCandidate candidate(long pallet) { - return new DispatchPalletCandidate().warehousePalletId(pallet).palletNo("P-" + pallet) - .customerId("C").overseasWarehouseId("WH-1").sourceStage("RECEIVED") + return new DispatchPalletCandidate().warehousePalletId(pallet).orderId(9000+pallet).palletNo("P-" + pallet) + .customerId("101").overseasWarehouseId("9").sourceStage("RECEIVED") .destinationType("FBA").destinationWarehouseCode("ONT8").fbaWarehouseCode("ONT8") .packageCount(2).grossWeightKg(BigDecimal.TEN).volumeCbm(BigDecimal.ONE); } + /** 同一申请两托共享真实履约订单,原入仓链保留在各自明细的冻结字段。 */ + private DispatchPalletCandidate relabelCandidate(long pallet) { + return candidate(pallet).orderId(9501L).orderNo("LM-RL").orderRefs("RL-1").inboundCode("RL-1").packageCount(1).sourceStage("RELABEL"); + } + /** 保留整单换标申请入口,不直接调用私有校验。 */ - private DispatchRequests.PreArrange relabel(List pallets) { - return new DispatchRequests.PreArrange(pallets, null, null, null, null, "RL-1"); + private DispatchCommands.Create relabel(List pallets) { + var cargo=pallets.stream().map(pallet->{var candidate=relabelCandidate(pallet); + return new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",pallet,sourceRules.wholeSourceVersion(candidate,"RELABEL:RL-1:"+pallet,false));}).toList(); + return create(cargo); + } + /** 使用当前公开整车创建DTO,不恢复旧客户预排接口。 */ + private DispatchCommands.Create create(List cargo) { + return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of()); + } + /** 重放固定原版本及原键,取消旧单不会取得或释放新单占用。 */ + private void cancel(String number) { + var request=cancellationCommands.computeIfAbsent(number,key->new DispatchCommands.ReasonCommand(aggregateFixture.repository.find(number).getRecordVersion(),"cancel-"+number,"取消原运输安排")); + dispatch.cancel(aggregateFixture.actor,number,request); + } + /** 候选聚合在边界固定,真实订单/计划根/库存和摘要仍由当前提供者锁定验证。 */ + private DispatchCommands.CargoSelection seedPlanSource() { + db.update("INSERT INTO ll_order(id,customer_id,order_no,detained) VALUES(9900,'101','LM-PLAN',0)"); + db.update("INSERT INTO ll_inbound_plan(id,customer_id,overseas_warehouse_id,source_order_id) VALUES(900,'101','9',9900)"); + db.update("INSERT INTO ll_inbound_plan_pallet(id,customer_id,plan_id,pallet_no,warehouse_pallet_id) VALUES(900,'101',900,'P-501',501)"); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_code) VALUES(9500,'101','IN-PLAN')"); + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,pallet_no,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages,version) VALUES(9500,'101',9500,501,'P-501','9',2,0,0,0,0)"); + db.update("INSERT INTO ll_inbound_stock_receipt(id,customer_id,order_id,warehouse_pallet_id,inventory_position_id,received_packages,inbound_code) VALUES(9500,'101',9900,501,9500,2,'IN-PLAN')"); + var row=candidate(501).planId(900L).planPalletId(900L).orderId(9900L).inboundCode("IN-PLAN"); + doReturn(List.of("101")).when(candidates).findCustomerScopesByPlanPalletIds(List.of(900L));doReturn(List.of(row)).when(candidates).readSelected("101",List.of(900L)); + return new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,900L,null,null,501L,sourceRules.wholeSourceVersion(row,"PLAN:900",false)); } /** 按类型注入实际依赖;未用到的外部能力保持未装配,调用时显式失败。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index e18376215..10478e7c8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -1,623 +1,90 @@ package com.baiyue.lastleg.dispatch.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.lastleg.dispatch.dto.DispatchRequests; -import com.baiyue.lastleg.dispatch.dto.DispatchResponse; -import com.baiyue.lastleg.dispatch.entity.DispatchDraft; -import com.baiyue.lastleg.dispatch.entity.DispatchDraftContainer; -import com.baiyue.lastleg.dispatch.entity.DispatchItem; -import com.baiyue.lastleg.dispatch.entity.DispatchOrder; -import com.baiyue.lastleg.dispatch.entity.DispatchPalletLock; -import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchDraftContainerMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchItemMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchOrderMapper; -import com.baiyue.lastleg.dispatch.mapper.DispatchPalletLockMapper; +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask; -import com.baiyue.lastleg.dispatch.pda.mapper.DispatchPdaTaskMapper; -import com.baiyue.lastleg.entity.LastlegOrder; -import com.baiyue.lastleg.mapper.LastlegOrderMapper; -import com.baiyue.lastleg.delivery.mapper.OutboundOrderMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.service.*; +import org.junit.jupiter.api.*; import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.server.ResponseStatusException; - -import java.math.BigDecimal; -import java.util.List; -import java.time.LocalDateTime; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class DispatchServiceTest { - /** 历史取消出库不阻止取消原排车,重放不影响后续占用。 */ - @Test void cancelledOutboundAllowsOriginalDispatchCancellationAndPreservesAppointmentHistory() { - var dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setCustomerId("100"); - dispatch.setDispatchNo("DP-OLD"); dispatch.setStatus("PENDING_DEPARTURE"); - var outbound = new com.baiyue.lastleg.delivery.entity.OutboundOrder(); outbound.setStatus("CANCELLED"); - var item = item(11L, "ONT8"); - when(orders.lockGlobalByDispatchNo("DP-OLD")).thenReturn(dispatch); - when(outboundOrders.lockByDispatch("100", 1L)).thenReturn(outbound); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(item)); - var allocations = mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); - ReflectionTestUtils.setField(service, "appointmentAllocations", allocations); - service.cancel("DP-OLD"); service.cancel("DP-OLD"); - assertEquals("CANCELLED", dispatch.getStatus()); - verify(allocations, never()).removeItem(org.mockito.ArgumentMatchers.anyLong(), org.mockito.ArgumentMatchers.anyLong()); - verify(orders).updateById(dispatch); + final DispatchService service=new DispatchService(); + final DispatchSourceRepository sources=mock(DispatchSourceRepository.class); + final DispatchCandidateMapper candidates=mock(DispatchCandidateMapper.class); + final DispatchDraftMapper drafts=mock(DispatchDraftMapper.class); + final RelabelApplicationMapper relabels=mock(RelabelApplicationMapper.class); + final RelabelPalletMeasurementService measurements=mock(RelabelPalletMeasurementService.class); + final com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + final com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort palletSerial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class); + @BeforeEach void setup(){ + ReflectionTestUtils.setField(service,"wholeSources",sources);ReflectionTestUtils.setField(service,"wholeHashes",new DispatchCommandHasher(new com.fasterxml.jackson.databind.ObjectMapper())); + ReflectionTestUtils.setField(service,"candidates",candidates);ReflectionTestUtils.setField(service,"drafts",drafts); + ReflectionTestUtils.setField(service,"relabelApplications",relabels);ReflectionTestUtils.setField(service,"relabelMeasurements",measurements); + ReflectionTestUtils.setField(service,"relabelBindingGuard",mock(RelabelBindingGuard.class));ReflectionTestUtils.setField(service,"returnRelabelStock",mock(ReturnRelabelDispatchStock.class)); + // 本用例只验证完整选择的早期拒绝;实际贡献/HOLD由持久化主链覆盖。 + when(facts.snapshot(anyString(),anyList())).thenReturn(new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),List.of(),"CONTROLLED",true,List.of())); + ReflectionTestUtils.setField(service,"relabelSources",facts); + ReflectionTestUtils.setField(service,"palletSerial",palletSerial); } - - /** 普通换标部分选择与重复选择均在创建排车前拒绝。 */ - @Test void ordinaryRelabelRequiresTheWholeLockedApplicationBeforeAnyMutation() { - var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class); - ReflectionTestUtils.setField(service, "relabelApplications", relabels); - ReflectionTestUtils.setField(service, "relabelBindingGuard", mock(com.baiyue.lastleg.relabel.service.RelabelBindingGuard.class)); - var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication(); - application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); - application.setDeliveryMethod("TRUCK"); application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); - when(relabels.lockNo("100", "RL-1")).thenReturn(application); - when(candidates.relabelAvailable("100", null, "RL-1")).thenReturn(List.of(candidate(10L, 900L, "ONT8"), candidate(11L, 901L, "ONT8"))); - assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP", - new DispatchRequests.PreArrange(List.of(900L), null, null, null, null, "RL-1"))); - assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP", - new DispatchRequests.PreArrange(List.of(900L, 900L), null, null, null, null, "RL-1"))); - verify(orders, never()).insert(any(DispatchOrder.class)); - verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); - verify(candidates, never()).markRelabelPreDispatched(any(), any()); + /** 空车返回仓范围内的确定空快照,不对不存在的申请、来源或实托取得锁。 */ + @Test void emptyExecutionSourcesHaveWarehouseScopedSnapshotWithoutAnySourceLocks(){ + var empty=service.lockExecutionSources("9",List.of()); + assertThat(empty.complete()).isTrue();assertThat(empty.applications()).isEmpty();assertThat(empty.sources()).isEmpty(); + assertThat(empty.positions()).isEmpty();assertThat(empty.blockReasons()).isEmpty(); + assertThat(empty.compositionVersion()).matches("[0-9a-f]{64}"); + assertThat(service.lockExecutionSources("9",List.of()).compositionVersion()).isEqualTo(empty.compositionVersion()); + assertThat(service.lockExecutionSources("10",List.of()).compositionVersion()).isNotEqualTo(empty.compositionVersion()); + verifyNoInteractions(facts,sources,palletSerial,relabels,measurements); } - - @Test void detailReturnsEveryOriginalBatchSourceWithoutLegacyAppointmentNumber() { - var dispatch=new DispatchOrder();dispatch.setId(1L);dispatch.setCustomerId("C");dispatch.setDispatchNo("DP-1"); - when(orders.findGlobalByDispatchNo("DP-1")).thenReturn(dispatch); - var allocations=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class);ReflectionTestUtils.setField(service,"appointmentAllocations",allocations); - var references=List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:1","AP-1","C", - List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:10","FBA:ONT8",40))), - new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchReference("FBA_APPOINTMENT:2","AP-2","C", - List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.BatchSource("ORDER:11","FBA:ONT8",20)))); - when(allocations.references(List.of(1L))).thenReturn(java.util.Map.of(1L,references)); - var result=service.platformDetail("DP-1");assertNull(result.fbaAppointmentNo());assertEquals(references,result.appointmentBatches()); - verify(allocations).references(List.of(1L)); + /** 空车也不能缺失起运仓,不能把无范围请求解释成合法空来源。 */ + @Test void emptyExecutionSourcesStillRequireWarehouse(){ + for(String warehouse:Arrays.asList(null,""," "))assertThatThrownBy(()->service.lockExecutionSources(warehouse,List.of())) + .isInstanceOf(IllegalArgumentException.class).hasMessageContaining("起运仓库"); + verifyNoInteractions(facts,sources,palletSerial); } - @org.junit.jupiter.params.ParameterizedTest - @org.junit.jupiter.params.provider.NullAndEmptySource - @org.junit.jupiter.params.provider.ValueSource(strings = {"PICKUP", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "UNKNOWN"}) - void directRelabelCannotResolveDispatchScopeOrBypassCandidateFiltering(String deliveryMethod) { - var relabels = mock(com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper.class); - ReflectionTestUtils.setField(service, "relabelApplications", relabels); - var application = new com.baiyue.lastleg.relabel.entity.RelabelApplication(); - application.setCustomerId("100"); application.setStatus("READY_FOR_OUTBOUND"); - application.setFulfillmentStatus("WAIT_PRE_DISPATCH"); application.setDeliveryMethod(deliveryMethod); - when(relabels.findAnyByRelabelNo("RL-1")).thenReturn(application); - when(relabels.lockNo("100", "RL-1")).thenReturn(application); - assertThrows(ResponseStatusException.class, () -> service.resolveCustomerScopeForRelabel("RL-1")); - assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "OP", - new DispatchRequests.PreArrange(List.of(1L), null, null, null, null, "RL-1"))); - verify(orders, never()).insert(any(DispatchOrder.class)); - verify(candidates, never()).relabelAvailable(any(), any(), any()); - verify(relabelMeasurements, never()).requireMeasured(any()); + /** 非空货物缺实托或提供者拒绝时保留原错误,不能借空车分支绕过闭包。 */ + @Test void nonemptyExecutionSourcesRetainStrictProviderValidation(){ + var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem(); + when(facts.snapshot(eq("9"),eq(List.of()))).thenThrow(new IllegalArgumentException("仓库和实托集合不能为空")); + assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("实托集合不能为空"); + verify(facts).snapshot("9",List.of()); + item.setWarehousePalletId(501L); + when(facts.snapshot("9",List.of(501L))).thenThrow(new IllegalStateException("完整实托来源已变化")); + assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("完整实托来源已变化"); + verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial); } - - @Mock DispatchOrderMapper orders; - @Mock DispatchItemMapper items; - @Mock DispatchBolMapper bolLines; - @Mock DispatchPalletLockMapper locks; - @Mock DispatchCandidateMapper candidates; - @Mock com.baiyue.lastleg.dispatch.mapper.ReturnDispatchCandidateMapper returnCandidates; - @Mock DispatchDraftMapper drafts; - @Mock DispatchPdaTaskMapper pdaTasks; - @Mock LastlegOrderMapper tailOrders; - @Mock OutboundOrderMapper outboundOrders; - @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchBindingGuard fleetBindings; - @Mock com.baiyue.lastleg.fleet.trip.FleetDispatchWorkflowQuery fleetWorkflow; - @Mock ReturnRelabelDispatchStock returnRelabelStock; - @Mock com.baiyue.lastleg.relabel.service.RelabelPalletMeasurementService relabelMeasurements; - private DispatchService service; - - @BeforeEach - void setUp() { - service = new DispatchService(); - ReflectionTestUtils.setField(service,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); - ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkChanges.class)); - ReflectionTestUtils.setField(service, "relabelMeasurements", relabelMeasurements); - ReflectionTestUtils.setField(service, "fleetWorkflow", fleetWorkflow); - ReflectionTestUtils.setField(service, "fleetBindings", fleetBindings); - ReflectionTestUtils.setField(service, "returnRelabelStock", returnRelabelStock); - ReflectionTestUtils.setField(service, "orders", orders); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "bolLines", bolLines); - ReflectionTestUtils.setField(service, "locks", locks); - ReflectionTestUtils.setField(service, "candidates", candidates); - ReflectionTestUtils.setField(service, "returnCandidates", returnCandidates); - ReflectionTestUtils.setField(service, "drafts", drafts); - ReflectionTestUtils.setField(service, "pdaTasks", pdaTasks); - ReflectionTestUtils.setField(service, "tailOrders", tailOrders); - ReflectionTestUtils.setField(service, "outboundOrders", outboundOrders); - ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); - lenient().when(items.findByDispatchOrder(any(), any())).thenReturn(List.of()); - lenient().when(bolLines.findByDispatchOrder(any(), any())).thenReturn(List.of()); - lenient().when(tailOrders.selectList(any())).thenReturn(List.of()); + @Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){ + assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources); } - - @Test - void preArrangeRejectsAHeldTailOrderBeforeCreatingDispatchDocuments() { - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - LastlegOrder held = new LastlegOrder(); - held.setId(2L); held.setCustomerId("100"); held.setOrderNo("LMO-HOLD-1"); held.setDetained(true); - when(tailOrders.selectList(any())).thenReturn(List.of(held)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, - null, null, null))); - - assertEquals(409, exception.getStatusCode().value()); - assertEquals("暂扣订单不可创建排车或出库计划:LMO-HOLD-1", exception.getReason()); - verify(orders, never()).insert(any(DispatchOrder.class)); + @Test void wholeCandidatesPreserveActualCustomersAndDoNotChooseAHeaderOwner(){ + var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null); + when(sources.availablePlans(query)).thenReturn(List.of(row(2,502,"102"),row(1,501,"101"))); + assertThat(service.wholeCandidates("operator",query)).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102"); } - - @Test - void preArrangeLocksPalletWithoutVehicleCapacityValidation() throws Exception { - assertNotNull(DispatchService.class.getMethod("preArrange", String.class, String.class, - DispatchRequests.PreArrange.class).getAnnotation(Transactional.class)); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of(item(11L, "ONT8"))); - - var result = service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, - null, null, null)); - - assertEquals(DispatchService.PENDING_DEPARTURE, result.status()); - assertEquals("ONT8", result.route()); - ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); - verify(orders).insert(dispatch.capture()); - ArgumentCaptor pdaTask = ArgumentCaptor.forClass(DispatchPdaTask.class); - verify(pdaTasks).insert(pdaTask.capture()); - assertEquals(dispatch.getValue().getId(), pdaTask.getValue().getDispatchOrderId()); - assertEquals("WH-1", pdaTask.getValue().getWarehouseId()); - assertEquals("PDA_PENDING", pdaTask.getValue().getStatus()); - assertEquals(1, dispatch.getValue().getPalletCount()); - verify(locks).insert(any(DispatchPalletLock.class)); - verify(items).insert(any(DispatchItem.class)); + @Test void duplicateAndPartialRelabelSelectionsAreRejectedBeforeMutation(){ + var app=new RelabelApplication();app.setCustomerId("101");app.setRelabelNo("RL-1");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("TRUCK");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + when(sources.relabelOwner("RL-1")).thenReturn("101");when(relabels.lockNo("101","RL-1")).thenReturn(app); + when(candidates.relabelAvailable("101",null,"RL-1")).thenReturn(List.of(relabelRow(501),relabelRow(502))); + var selection=new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",501L,"version"); + assertThatThrownBy(()->service.lockWholeSources("9",List.of(selection))).hasMessageContaining("完整选择"); + assertThatThrownBy(()->service.lockWholeSources("9",List.of(selection,selection))).hasMessageContaining("重复选择"); + verify(candidates,never()).markRelabelPreDispatched(any(),any()); } - - @Test - void preArrangeBuildsCommaSeparatedDistinctFbaRouteFromAssignedPallets() { - when(candidates.lockSelected("100", List.of(10L, 11L, 12L))).thenReturn(List.of( - candidate(10L, null, "ONT8"), candidate(11L, null, "ABE4"), candidate(12L, null, "ONT8"))); - when(items.findByDispatchOrder(eq("100"), any())).thenReturn(List.of( - item(21L, "ONT8"), item(22L, "ABE4"), item(23L, "ONT8"))); - - DispatchResponse result = service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L, 11L, 12L), null, null, null)); - - assertEquals("ONT8,ABE4", result.route()); - ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); - verify(orders).insert(dispatch.capture()); - assertEquals("ONT8,ABE4", dispatch.getValue().getRoute()); + @Test void nonTruckRelabelCannotReachMeasurementsOrCargoMutation(){ + var app=new RelabelApplication();app.setCustomerId("101");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("EXPRESS");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); + when(sources.relabelOwner("RL-1")).thenReturn("101");when(relabels.lockNo("101","RL-1")).thenReturn(app); + // 当前生产者SQL已过滤非TRUCK;消费方读不到来源时在加锁和测量前拒绝。 + assertThatThrownBy(()->service.lockWholeSources("9",List.of(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.RELABEL,null,null,"RL-1",501L,"v")))).hasMessageContaining("来源已变化"); + verifyNoInteractions(measurements);verify(candidates).relabelAvailable("101",null,"RL-1");verify(candidates,never()).markRelabelPreDispatched(any(),any()); } - - - - - - private DispatchDraftContainer existingDraftGroup() { - DispatchDraft draft = new DispatchDraft(); - draft.setId(20L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); - when(drafts.lockActive("draft-1", "7")).thenReturn(draft); - DispatchDraftContainer group = new DispatchDraftContainer(); - group.setId(21L); group.setDraftId(20L); group.setDraftToken("draft-1"); - return group; - } - - - - - - - - - - @Test - void preArrangeAcceptsVolumeOnlyPlanPallet() { - when(candidates.lockSelected("100", List.of(10L))) - .thenReturn(List.of(candidate(10L, null, "ONT8", 0))); - - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, - null, null, null)); - - ArgumentCaptor dispatch = ArgumentCaptor.forClass(DispatchOrder.class); - verify(orders).insert(dispatch.capture()); - assertEquals(0, dispatch.getValue().getPackageCount()); - verify(items).insert(any(DispatchItem.class)); - } - - @Test - void preArrangeRejectsPalletWithoutResolvedOverseasWarehouseWithPreciseReason() { - DispatchPalletCandidate unresolved = new DispatchPalletCandidate(1L, 10L, 20L, 2L, - null, null, "LMO1", "LMO1", "RK1", "LIP1-P001", "ONT8", "ONT8", "ONT8", "ONT8", - 12, new BigDecimal("1.2000"), "RECEIVED"); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(unresolved)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); - - assertEquals("所选托盘中存在尚未关联海外仓的托盘", exception.getReason()); - verify(orders, never()).insert(any(DispatchOrder.class)); - } - - @Test - void preArrangeRejectsPalletWithoutFbaWarehouse() { - DispatchPalletCandidate missingFba = new DispatchPalletCandidate(1L, 10L, 20L, 2L, - "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", "LIP1-P001", - null, null, null, null, 12, new BigDecimal("1.2000"), "RECEIVED"); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(missingFba)); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); - - assertEquals(409, exception.getStatusCode().value()); - assertEquals("所选托盘尚未设置 FBA 仓,不能智能排车", exception.getReason()); - verify(orders, never()).insert(any(DispatchOrder.class)); - } - - - - @Test - void preArrangeKeepsVirtualVehicleAndItsLocksEditableUntilOutboundExists() { - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setDraftToken("draft-1"); lock.setDraftContainerId(21L); lock.setOperatorId("7"); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); - DispatchDraft draft = new DispatchDraft(); - draft.setId(31L); draft.setDraftToken("draft-1"); draft.setOperatorId("7"); draft.setStatus("ACTIVE"); - when(locks.lockActive("100", "PLAN:10")).thenReturn(lock); - when(drafts.lockActive("draft-1", "7")).thenReturn(draft); - - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, "draft-1")); - - assertNotNull(lock.getDispatchOrderId()); - assertEquals("draft-1", lock.getDraftToken()); - assertEquals(21L, lock.getDraftContainerId()); - assertEquals("ACTIVE", draft.getStatus()); - assertNull(draft.getExpiresAt()); - verify(locks).updateById(lock); - verify(locks, never()).insert(any(DispatchPalletLock.class)); - verify(drafts).updateById(draft); - } - - @Test - void removingUnpickedPalletReleasesLockAndRefreshesDispatchTotals() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); - dispatch.setStatus(DispatchService.PENDING_DEPARTURE); dispatch.setPickedPackageCount(0); - DispatchItem removed = new DispatchItem(); - removed.setId(11L); removed.setPlanPalletId(10L); removed.setWarehousePalletId(20L); - DispatchItem remaining = new DispatchItem(); - remaining.setId(12L); remaining.setPlanPalletId(12L); remaining.setPackageCount(7); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setPlanPalletId(10L); lock.setStatus("ACTIVE"); lock.setActiveKey("PLAN:10"); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - when(items.lockById("100", 1L, 11L)).thenReturn(removed); - when(locks.selectList(any())).thenReturn(List.of(lock)); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(remaining)); - - DispatchResponse result = service.removeItem("PC1", 11L); - - assertEquals(1, result.palletCount()); - assertEquals(7, result.packageCount()); - assertEquals("RELEASED", lock.getStatus()); - verify(items).deleteById(11L); - verify(orders).updateById(dispatch); - } - - - - - - @Test - void rejectsPalletAlreadyLockedByAnotherDispatch() { - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(candidate(10L, null, "ONT8"))); - DispatchPalletLock locked = new DispatchPalletLock(); - when(locks.lockActive(eq("100"), eq("PLAN:10"))).thenReturn(locked); - - assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); - verify(orders, never()).insert(any(DispatchOrder.class)); - } - - @Test - void invalidCandidateStageIsRejected() { - assertThrows(ResponseStatusException.class, - () -> service.candidates("100", List.of(1L), null, null, "INVALID")); - verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); - } - - @Test - void platformCandidatesQueryAcrossCustomersWithoutPlanScope() { - when(candidates.available(null, null, null, null, "RECEIVED", "draft-1")).thenReturn(List.of()); - - service.platformCandidates(null, null, "RECEIVED", "draft-1"); - - verify(candidates).available(null, null, null, null, "RECEIVED", "draft-1"); - } - - @Test - void relabelSourceDoesNotRequireOrQueryAnInboundPlan() { - when(candidates.relabelAvailable(null, "RL-1", null)).thenReturn(List.of()); - org.junit.jupiter.api.Assertions.assertTrue(service.platformCandidates("RL-1", null, "RELABEL", "draft-1").isEmpty()); - verify(candidates).relabelAvailable(null, "RL-1", null); - verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); - } - - @Test - void resolvesCustomerFromSelectedPlansAndPalletsInMultiCustomerDeployment() { - when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100")); - when(candidates.findCustomerScopesByPlanPalletIds(List.of(10L, 11L))).thenReturn(List.of("100")); - - assertEquals("100", service.resolveCustomerScopeForPlans(List.of(2L, 1L, 1L))); - assertEquals("100", service.resolveCustomerScopeForPallets(List.of(11L, 10L))); - } - - @Test - void rejectsSelectedPlansThatSpanCustomers() { - when(candidates.findCustomerScopesByPlanIds(List.of(1L, 2L))).thenReturn(List.of("100", "200")); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.resolveCustomerScopeForPlans(List.of(1L, 2L))); - - assertEquals(409, exception.getStatusCode().value()); - } - - @Test - void emptyCustomerScopeProducesEmptyCandidatesAndRejectsPreArrange() { - assertEquals(List.of(), service.candidates(null, null, null, null, null, "draft-1")); - verify(candidates, never()).available(any(), any(), any(), any(), any(), any()); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.preArrange(null, "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null))); - assertEquals(409, exception.getStatusCode().value()); - } - - @Test - void platformEditResolvesCustomerFromGlobalDispatchNumberAndPreservesStatus() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); - dispatch.setStatus(DispatchService.PENDING_DEPARTURE); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - DispatchItem ont8 = item(11L, "ONT8"); - DispatchItem abe4 = item(12L, "ABE4"); - when(items.findByDispatchOrder("100", 1L)).thenReturn(List.of(ont8, abe4, item(13L, "ONT8"))); - LocalDateTime departAt = LocalDateTime.of(2026, 9, 18, 10, 30); - DispatchRequests.Update request = new DispatchRequests.Update("ABC-1", "Driver", null, null, - departAt, null, null, null, LocalDateTime.now()); - - DispatchResponse result = service.update("PC1", request); - - assertEquals(DispatchService.PENDING_DEPARTURE, dispatch.getStatus()); - assertEquals("ABC-1", result.vehiclePlate()); - assertEquals(departAt, result.departAt()); - assertEquals(departAt, dispatch.getDepartAt()); - assertEquals("ONT8,ABE4", result.route()); - assertEquals("ONT8,ABE4", dispatch.getRoute()); - verify(orders).updateById(dispatch); - verify(fleetBindings).requireUnbound(1L); - verify(items).findByDispatchOrder("100", 1L); - } - - @Test void boundDispatchCannotEditRemoveOrCancelIndependently() { - DispatchOrder dispatch = new DispatchOrder(); dispatch.setId(1L); dispatch.setStatus("PENDING_DEPARTURE"); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - org.mockito.Mockito.doThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT, "请先解除车次关联")) - .when(fleetBindings).requireUnbound(1L); - assertThrows(ResponseStatusException.class, () -> service.update("PC1", null)); - assertThrows(ResponseStatusException.class, () -> service.removeItem("PC1", 2L)); - assertThrows(ResponseStatusException.class, () -> service.cancel("PC1")); - verify(orders, never()).updateById((DispatchOrder) any()); - org.mockito.Mockito.verifyNoInteractions(items, locks, outboundOrders); - } - - @Test - void platformEditRejectsAnExecutedDispatchWithoutWriting() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("100"); dispatch.setDispatchNo("PC1"); - dispatch.setStatus("DEPARTED"); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - DispatchRequests.Update request = new DispatchRequests.Update(null, null, null, null, - null, null, null, null, null); - - ResponseStatusException exception = assertThrows(ResponseStatusException.class, - () -> service.update("PC1", request)); - - assertEquals(409, exception.getStatusCode().value()); - verify(orders, never()).updateById(any(DispatchOrder.class)); - } - - @Test - void detailUsesResolvedCustomerScopeAndLoadsItems() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); - dispatch.setCustomerId("200"); - dispatch.setDispatchNo("PC1"); - when(orders.findByDispatchNo("200", "PC1")).thenReturn(dispatch); - - service.detail("200", "PC1"); - - verify(items).findByDispatchOrder("200", 1L); - verify(bolLines).findByDispatchOrder("200", 1L); - } - - @Test - void platformPageQueriesAllCustomersAndLoadsItemsInEachCustomerScope() { - DispatchOrder first = new DispatchOrder(); - first.setId(1L); first.setCustomerId("100"); first.setDispatchNo("PC1"); - DispatchOrder second = new DispatchOrder(); - second.setId(2L); second.setCustomerId("200"); second.setDispatchNo("PC2"); - Page resultPage = new Page<>(1, 20, 2); - resultPage.setRecords(List.of(first, second)); - when(orders.selectPage(any(Page.class), any(LambdaQueryWrapper.class))).thenReturn(resultPage); - - var result = service.platformPage(new DispatchRequests.Query(1, 20, null, null, null, null, null)); - - assertEquals(2, result.total()); - verify(items).findByDispatchOrder("100", 1L); - verify(items).findByDispatchOrder("200", 2L); - verify(bolLines, never()).findByDispatchOrder(any(), any()); - } - - @Test - void platformDetailUsesCustomerFromMatchedDispatch() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); - when(orders.findGlobalByDispatchNo("PC1")).thenReturn(dispatch); - - var expected = new DispatchResponse.BolLine("D2144", "D2144-112-1", "LBE1", "Amazon LBE1", - "NPHK2607030206", "FBA19HFHQCHB", "7UH3F1DW", 138); - when(bolLines.findByDispatchOrder("200", 1L)).thenReturn(List.of(expected)); - - var result = service.platformDetail("PC1"); - - verify(items).findByDispatchOrder("200", 1L); - verify(bolLines).findByDispatchOrder("200", 1L); - assertEquals(List.of(expected), result.bolLines()); - } - - @Test - void globalCancelIsIdempotentAndReleasesOnlyTheResolvedCustomerLocks() { - DispatchOrder dispatch = new DispatchOrder(); - dispatch.setId(1L); dispatch.setCustomerId("200"); dispatch.setDispatchNo("PC1"); - dispatch.setStatus(DispatchService.PENDING_DEPARTURE); - DispatchPalletLock lock = new DispatchPalletLock(); - lock.setStatus("ACTIVE"); lock.setActiveKey("PALLET:1"); - when(orders.lockGlobalByDispatchNo("PC1")).thenReturn(dispatch); - when(locks.selectList(any())).thenReturn(List.of(lock)); - - service.cancel("PC1"); - - assertEquals(DispatchService.CANCELLED, dispatch.getStatus()); - assertEquals("RELEASED", lock.getStatus()); - assertEquals(null, lock.getActiveKey()); - verify(locks).updateById(lock); - - service.cancel("PC1"); - verify(locks, org.mockito.Mockito.times(1)).selectList(any()); - } - - @Test - void receivedPalletUsesPhysicalPalletAsDispatchLockIdentity() { - DispatchPalletCandidate mergedInboundOrder = candidate(10L, 900L, "ONT8"); - DispatchPalletCandidate plannedOnly = candidate(11L, null, "ONT8"); - - assertEquals("RECEIVED:900", mergedInboundOrder.sourcePalletKey()); - assertEquals("PLAN:11", plannedOnly.sourcePalletKey()); - } - - @Test void returnedReceiptPreArrangementUsesItsOwnReferenceAndTheRealTargetPalletLock() throws Exception { - var candidate = candidate(10L, 900L, "ONT8", 2); - candidate.planId(null); candidate.planPalletId(null); candidate.returnReceiptId(701L); candidate.returnReceiptNo("RSR-701"); - when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(candidate)); - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(), null, null, null, null, null, List.of(701L))); - var item = ArgumentCaptor.forClass(DispatchItem.class); verify(items).insert(item.capture()); - assertEquals(null, item.getValue().getPlanPalletId()); assertEquals(701L, item.getValue().getReturnReceiptId()); - assertEquals(2, item.getValue().getPackageCount()); - var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); verify(locks).insert(lock.capture()); - assertEquals("RECEIVED:900", lock.getValue().getActiveKey()); assertEquals(701L, lock.getValue().getReturnReceiptId()); - verify(candidates, never()).lockSelected(any(), any()); - var json = new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(candidate); - assertEquals("RETURN_RECEIPT", json.path("sourceType").asText()); - assertEquals("RSR-701", json.path("returnReceiptNo").asText()); assertTrue(json.path("planPalletId").isNull()); - } - - @Test void plannedAndReturnedSourcesCreateOneDispatchWithBothReferencesAndTheirExactQuantities() { - var planned = candidate(10L, 900L, "ONT8", 3); - var returned = candidate(11L, 901L, "ONT8", 2); - returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701"); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned)); - when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned)); - service.preArrange("100", "7", new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L))); - var dispatch = ArgumentCaptor.forClass(DispatchOrder.class); verify(orders).insert(dispatch.capture()); - assertEquals(5, dispatch.getValue().getPackageCount()); assertEquals(2, dispatch.getValue().getPalletCount()); - var inserted = ArgumentCaptor.forClass(DispatchItem.class); - verify(items, org.mockito.Mockito.times(2)).insert(inserted.capture()); - var normalItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() == null).findFirst().orElseThrow(); - var returnItem = inserted.getAllValues().stream().filter(row -> row.getReturnReceiptId() != null).findFirst().orElseThrow(); - assertEquals(10L, normalItem.getPlanPalletId()); assertEquals(3, normalItem.getPackageCount()); - assertEquals(null, returnItem.getPlanPalletId()); assertEquals(701L, returnItem.getReturnReceiptId()); assertEquals(2, returnItem.getPackageCount()); - assertEquals(normalItem.getDispatchOrderId(), returnItem.getDispatchOrderId()); - var lock = ArgumentCaptor.forClass(DispatchPalletLock.class); - verify(locks, org.mockito.Mockito.times(2)).insert(lock.capture()); - assertEquals(java.util.Set.of("RECEIVED:900", "RECEIVED:901"), lock.getAllValues().stream() - .map(DispatchPalletLock::getActiveKey).collect(java.util.stream.Collectors.toSet())); - } - - @Test void mixedSourcesOnTheSamePhysicalPalletAreRejectedBeforeAnyDispatchOrLockWrite() { - var planned = candidate(10L, 900L, "ONT8", 3); - var returned = candidate(11L, 900L, "ONT8", 2); - returned.planId(null); returned.planPalletId(null); returned.returnReceiptId(701L); returned.returnReceiptNo("RSR-701"); - when(candidates.lockSelected("100", List.of(10L))).thenReturn(List.of(planned)); - when(returnCandidates.lockSelected("100", List.of(701L))).thenReturn(List.of(returned)); - var failure = assertThrows(ResponseStatusException.class, () -> service.preArrange("100", "7", - new DispatchRequests.PreArrange(List.of(10L), null, null, null, null, null, List.of(701L)))); - assertEquals(409, failure.getStatusCode().value()); assertTrue(failure.getReason().contains("同一实物托盘")); - verify(orders, never()).insert(any(DispatchOrder.class)); verify(items, never()).insert(any(DispatchItem.class)); - verify(locks, never()).insert(any(DispatchPalletLock.class)); verify(pdaTasks, never()).insert(any(DispatchPdaTask.class)); - } - - - - - - - - - - - - - - - - - - - - - - private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination) { - return candidate(planPalletId, warehousePalletId, destination, 12); - } - - private DispatchPalletCandidate candidate(long planPalletId, Long warehousePalletId, String destination, - int packageCount) { - return new DispatchPalletCandidate(1L, planPalletId, warehousePalletId, 2L, "WH-1", "Overseas warehouse", "LMO1", "LMO1", "RK1", - "LIP1-P001", destination, destination, destination, destination, packageCount, - new BigDecimal("1.2000"), warehousePalletId == null ? "PLAN" : "RECEIVED"); - } - - private DispatchItem item(long id, String fbaWarehouseCode) { - DispatchItem item = new DispatchItem(); - item.setId(id); - item.setFbaWarehouseCode(fbaWarehouseCode); - return item; + @Test void sourceIdentityCannotCombinePlanAndReturnReceipt(){ + assertThatThrownBy(()->DispatchService.wholeSourceKey(new DispatchCommands.CargoSelection(DispatchTypes.SourceType.INBOUND_PLAN,1L,2L,null,501L,"v"))).hasMessageContaining("来源字段"); } + private static DispatchPalletCandidate row(long plan,long pallet,String customer){return new DispatchPalletCandidate().planPalletId(plan).warehousePalletId(pallet).customerId(customer).overseasWarehouseId("9").sourceStage("RECEIVED").packageCount(3).orderNo("ORDER-"+plan).palletNo("PALLET-"+pallet);} + /** 换标生产者没有计划托盘别名,始终提供真实申请业务号与实托。 */ + private static DispatchPalletCandidate relabelRow(long pallet){return row(0,pallet,"101").planPalletId(null).sourceStage("RELABEL").orderRefs("RL-1");} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java index 004b12a5e..446d3e332 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java @@ -26,7 +26,7 @@ class DispatchWholeSourcePersistenceTest { @Test void rejectsChangedFactsHeldSourcesForeignPalletAndWarehouse() throws Exception { var fixture=new Sources();var selected=fixture.selections(); fixture.f.db.update("UPDATE ll_inventory_position SET version=version+1,available_packages=available_packages+1 WHERE id=1"); - assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("版本已变化"); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("真实可用库存来源"); fixture.f.db.update("UPDATE ll_inventory_position SET version=1,available_packages=3 WHERE id=1"); fixture.f.db.update("UPDATE ll_order SET detained=1 WHERE id=9002"); assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.rules.lockWholeSources("9",selected))).isInstanceOf(ResponseStatusException.class).hasMessageContaining("暂扣"); @@ -48,15 +48,16 @@ class DispatchWholeSourcePersistenceTest { Sources() throws Exception { f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5); f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0"); - f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64))"); - f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT)"); - f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT,version BIGINT)"); + f.db.execute("ALTER TABLE ll_order ADD overseas_warehouse_id VARCHAR(64)"); + f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)"); + f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT,pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,customer_id VARCHAR(64))"); + f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,pallet_no VARCHAR(128),overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT,version BIGINT)"); for(String column:List.of("order_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT","warehouse_pallet_id BIGINT"))f.db.execute("ALTER TABLE ll_inbound_stock_receipt ADD "+column); for(long id:List.of(1L,2L)) { var row=f.candidates.get(id);f.db.update("INSERT INTO ll_order(id,customer_id,order_no)VALUES(?,?,?)",row.orderId(),row.customerId(),row.orderNo()); - f.db.update("INSERT INTO ll_inbound_plan VALUES(?,?)",id,row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?)",id,id); + f.db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?,?)",id,row.customerId(),"9",row.orderId());f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,?,?,?)",id,id,row.palletNo(),row.warehousePalletId(),row.customerId()); f.db.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?)",id,row.orderId(),row.customerId());f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?)",id,id); - f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),"9",row.packageCount()); + f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount()); f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId()); } var plans=mock(DispatchCandidateMapper.class); @@ -65,6 +66,14 @@ class DispatchWholeSourcePersistenceTest { sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class)); var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper); ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); + var relabel=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + org.mockito.stubbing.Answer snapshot=call->{ + List pallets=call.getArgument(1); + var positions=f.db.query("SELECT * FROM ll_inventory_position ORDER BY id",(rs,n)->new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Position(rs.getLong("id"),rs.getString("customer_id"),rs.getLong("balance_id"),rs.getLong("pallet_id"),rs.getLong("available_packages"),0,0,0,0,rs.getLong("version"))).stream().filter(p->pallets.contains(p.warehousePalletId())).toList(); + return new com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot(List.of(),List.of(),positions,"ORDINARY",true,List.of()); + }; + when(relabel.snapshot(anyString(),anyList())).thenAnswer(snapshot);when(relabel.lockedSnapshot(anyString(),anyList())).thenAnswer(snapshot); + ReflectionTestUtils.setField(rules,"relabelSources",relabel);ReflectionTestUtils.setField(rules,"palletSerial",mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class)); } /** GET与写锁内复用同一真实事实摘要。 */ List selections(){return f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(entry->new DispatchCommands.CargoSelection( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java index fbfd62f09..5eb5fcda0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/ReturnDispatchCandidatePersistenceTest.java @@ -39,7 +39,7 @@ class ReturnDispatchCandidatePersistenceTest { assertThat(row.warehousePalletId()).isEqualTo(900); assertThat(row.grossWeightKg()).isNull(); assertThat(mapper.lockSelected("A", List.of(6L))).hasSize(1); assertThat(mapper.lockSelected("B", List.of(6L))).isEmpty(); - db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RECEIVED:900','ACTIVE',NULL,'another-draft')"); + db.update("INSERT INTO ll_dispatch_pallet_lock VALUES('A','RETURN:6','ACTIVE',NULL,'another-draft')"); assertThat(mapper.available("A", null, null, "this-draft")).isEmpty(); db.update("DELETE FROM ll_dispatch_pallet_lock"); db.update("INSERT INTO ll_dispatch_pda_pick_order VALUES('A',6,1)"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java index 818a8ca1e..a7181eb07 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java @@ -10,6 +10,9 @@ import com.baiyue.lastleg.exceptionorder.repository.ReturnCartonReturnRepository import com.baiyue.lastleg.returnreceipt.mapper.*; import com.baiyue.lastleg.delivery.mapper.*; import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository; import com.baiyue.lastleg.dispatch.pda.mapper.*; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; @@ -32,10 +35,10 @@ class ReturnCartonReturnPersistenceTest { @BeforeEach void setup() throws Exception { var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); db=new JdbcTemplate(data);tx=new TransactionTemplate(new DataSourceTransactionManager(data)); - var config=new MybatisConfiguration(); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); var mappers=List.of(ExceptionOrderMapper.class,ExceptionOrderCommandMapper.class,com.baiyue.lastleg.mapper.LastlegOrderMapper.class,com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class,ExceptionReturnItemMapper.class,ReturnCartonStockReceiptMapper.class, ReturnInventoryCartonMapper.class,ReturnInventoryCartonItemMapper.class,ReturnInventoryBalanceMapper.class,ReturnInventoryMovementMapper.class, - DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchOrderMapper.class,DispatchItemMapper.class, + DeliveryOrderMapper.class,OutboundOrderMapper.class,OutboundPalletItemMapper.class,OutboundPdaPickMapper.class,OutboundPdaTaskMapper.class,DispatchAggregateMapper.class,DispatchReadMapper.class,DispatchItemMapper.class,DispatchFeeCargoMapper.class, DispatchPdaTaskMapper.class,DispatchPdaPickMapper.class,DispatchPdaPickOrderMapper.class,DeliveryOperationMapper.class); mappers.forEach(config::addMapper); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(config);factory.afterPropertiesSet(); @@ -47,6 +50,7 @@ class ReturnCartonReturnPersistenceTest { table.getFieldList().forEach(f->columns.add(f.getColumn()+" "+sqlType(f.getPropertyType()))); db.execute("CREATE TABLE IF NOT EXISTS "+table.getTableName()+"("+String.join(",",columns)+")"); } + createAggregateTable(); db.execute("CREATE UNIQUE INDEX balance_scope ON ll_return_inventory_balance(customer_id,overseas_warehouse_id,sku_code)"); db.execute("CREATE UNIQUE INDEX receipt_source ON ll_return_carton_stock_receipt(customer_id,source_pick_id,sku_code)"); db.execute("CREATE TABLE ll_inventory_ledger(id BIGINT,customer_id VARCHAR(128),business_order_no VARCHAR(128),outbound_order_no VARCHAR(128),business_type VARCHAR(128),delta_packages BIGINT,source_event_id VARCHAR(128),occurred_at TIMESTAMP)"); @@ -59,9 +63,9 @@ class ReturnCartonReturnPersistenceTest { db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)"); db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')"); db.update("INSERT INTO ll_outbound_pallet_item(id,customer_id,outbound_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code) VALUES(5,'C',2,500,'RL-1','ONT8')"); - db.update("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no) VALUES(10,'C','DP-1')"); - db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code,source_stage,overseas_warehouse_id) VALUES(4,'C',10,500,'RL-1','ONT8','RETURN_RELABEL','92')"); - db.update("INSERT INTO ll_dispatch_pda_task(id,customer_id,dispatch_order_id) VALUES(102,'C',10)"); + db.update("INSERT INTO ll_dispatch_order(id,dispatch_no,status,stage,record_version,cargo_revision,item_count,pallet_count,package_count,picked_package_count,shipped_package_count,overseas_warehouse_id,departed_at) VALUES(10,'DP-1','ACTIVE','DELIVERING',0,0,1,1,1,1,1,'92',?)",LocalDateTime.now().minusHours(1)); + db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,warehouse_pallet_id,inbound_code,destination_warehouse_code,destination_type,package_count,source_stage,overseas_warehouse_id) VALUES(4,'C',10,500,'RL-1','ONT8','FBA',1,'RETURN_RELABEL','92')"); + db.update("INSERT INTO ll_dispatch_pda_task(id,dispatch_order_id,task_no,status,warehouse_id) VALUES(102,10,'PDA-DP-1','COMPLETED','92')"); db.update("INSERT INTO ll_dispatch_pda_pick(id,customer_id,task_id,dispatch_item_id,return_carton_id,packages,product_quantity) VALUES(103,'C',102,4,101,1,8)"); db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,packages) VALUES(105,'C',103,100,1)"); db.update("INSERT INTO ll_exception_return_item(id,customer_id,exception_order_id,outbound_pallet_item_id,return_carton_id,approved_return_packages,received_packages) VALUES(9,'C',1,5,101,1,0)"); @@ -98,9 +102,8 @@ class ReturnCartonReturnPersistenceTest { /** 完成命令真实落账后才推进派送并供车次结案消费,同键重放不重复恢复库存。 */ @Test void realExceptionCompletionAndTripCompletionConsumeTheNativeReceipt() { db.update("UPDATE ll_exception_order SET exception_type='RETURN',delivery_no='DL-1',version=0 WHERE id=1"); - db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',status='RETURN_PENDING',package_count=1,version=0 WHERE id=3"); - db.update("UPDATE ll_dispatch_order SET status='DEPARTED',shipped_package_count=1,overseas_warehouse_id='92' WHERE id=10"); - db.update("UPDATE ll_outbound_order SET package_count=1 WHERE id=2"); + db.update("UPDATE ll_delivery_order SET delivery_no='DL-1',dispatch_order_id=10,outbound_no='OUT-1',overseas_warehouse_id='92',destination_type='FBA',destination_warehouse_code='ONT8',status='RETURN_PENDING',package_count=1,version=0 WHERE id=3"); + db.update("UPDATE ll_outbound_order SET package_count=1,overseas_warehouse_id='92' WHERE id=2"); db.update("INSERT INTO ll_order(id,customer_id,order_no,delivery_order_no) VALUES(100,'C','LM-1','DL-1')"); var business=business(); var request=new ExceptionOrderRequests.Complete(0,"native-complete","实收原箱",List.of(999L),List.of(receipt())); @@ -109,9 +112,14 @@ class ReturnCartonReturnPersistenceTest { assertThat(db.queryForObject("SELECT status FROM ll_delivery_order WHERE id=3",String.class)).isEqualTo("RETURNED"); assertThat(tx.execute(s->business.complete("C","OP","EX-1",request)).replayed()).isTrue(); assertThat(db.queryForObject("SELECT SUM(packages) FROM ll_return_carton_stock_receipt",Long.class)).isEqualTo(1); - var fleet=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter(map(DispatchOrderMapper.class),map(DispatchPdaTaskMapper.class),map(OutboundOrderMapper.class),map(DeliveryOrderMapper.class),map(DispatchItemMapper.class),map(DispatchPdaPickMapper.class),mock(com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService.class),map(DeliveryOperationMapper.class)); + var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class); + var fleet=new com.baiyue.lastleg.dispatch.fleet.DispatchFleetAdapter( + new DispatchAggregateRepository(map(DispatchAggregateMapper.class),map(DispatchItemMapper.class),map(DispatchReadMapper.class)), + map(OutboundOrderMapper.class),map(DeliveryOrderMapper.class),map(DeliveryOperationMapper.class), + new DispatchCargoSourceRepository(map(DispatchFeeCargoMapper.class)),workflow,new ObjectMapper().findAndRegisterModules()); var outcome=tx.execute(s->fleet.completion("DP-1",true)); assertThat(outcome.blockReason()).isNull();assertThat(outcome.outcomes()).singleElement().satisfies(row->{assertThat(row.returnedPackages()).isEqualTo(1);assertThat(row.remainingPackages()).isZero();}); + verifyNoInteractions(workflow); } /** 不制造入仓单,从真实换标主单经过请求校验、标记、受理、回仓直至派送结案。 */ @Test void nativeMasterWithoutInboundCanMarkAndCompleteAfterBeanValidation() throws Exception { @@ -191,6 +199,12 @@ class ReturnCartonReturnPersistenceTest { private ExceptionOrderRequests.ReturnReceipt receipt(){return new ExceptionOrderRequests.ReturnReceipt(5L,null,1,501L,"P-NEW",301L,"LOC-301",101L);} /** 根据实体类型建立测试表;正式DDL另由Schema合同验证。 */ private static String sqlType(Class type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(8000)";} + /** 当前整车使用独立聚合模型,测试表不引入旧客户头部或旧主单实体。 */ + private void createAggregateTable() { + var columns=Arrays.stream(DispatchAggregate.class.getDeclaredFields()).filter(f->!f.isSynthetic()&&!java.lang.reflect.Modifier.isStatic(f.getModifiers())) + .map(f->f.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT)+" "+sqlType(f.getType())+(f.getName().equals("id")?" PRIMARY KEY":"")).toList(); + db.execute("CREATE TABLE ll_dispatch_order("+String.join(",",columns)+")"); + } /** 获取隔离库的真实Mapper代理。 */ private T map(Class type){return session.getMapper(type);} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java index 9bc03905b..0673904d1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPdaQueryTest.java @@ -57,6 +57,10 @@ class FleetHandoverPdaQueryTest { assertThrows(org.springframework.web.server.ResponseStatusException.class, () -> service.status(92, "PC-A")); verifyNoInteractions(history, handovers); } + @Test void sameWarehouseEmptyEvidenceCannotBorrowTheTruckHeaderQuantity() { + when(dispatches.transitEvidence("PC-A",false)).thenReturn(new FleetTransitEvidence("PC-A","92",30,List.of())); + assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.status(92,"PC-A"));verifyNoInteractions(history,handovers); + } @Test void controllerHasOnlyReadRouteAndRequiresThePdaIdentityPolicy() { var policy = PdaFleetHandoverController.class.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value(); assertTrue(policy.contains("lastleg:pda:operate")); assertTrue(policy.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java index 9dc1b9f06..90216b54c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/exception/FleetHandoverTransactionTest.java @@ -98,7 +98,6 @@ class FleetHandoverTransactionTest { assertEquals("11", result.snapshot().previous().vehicleId()); assertEquals("12", result.snapshot().replacement().vehicleId()); assertEquals(result.handoverNo(), service.confirm("TR-ONE", "another-operator", command).handoverNo()); verify(dispatches, times(1)).handover(any(), any()); - verify(dispatches, never()).depart(anyString(), anyString(), anyString(), anyString(), any(), any()); assertEquals(1, repository.count(1)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java index 04c6342e5..f48e8ff79 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java @@ -46,13 +46,13 @@ class FleetPickupServiceTest { source.setDeliveryMethod("WAREHOUSE_PICKUP");when(repository.task(1L)).thenReturn(null);when(repository.lockTask(1L)).thenReturn(null); var other=source(2L,"C2","WT-2","200");when(repository.source("C2","WT-2",true)).thenReturn(other); var mixed=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1"),new FleetPickupModels.Source("C2","WT-2")),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-2"); - assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).create(anyString(),any()); + assertThatThrownBy(()->service.create("OP",mixed)).hasMessageContaining("同一海外仓");verify(trips,never()).createPickup(anyString(),any()); var duplicate=new FleetPickupModels.Create(List.of(plan.sources().get(0),plan.sources().get(0)),plan.plannedDepartureAt(),plan.expectedDriverEndAt(),null,"create-key-3"); assertThatThrownBy(()->service.create("OP",duplicate)).hasMessageContaining("重复选择"); } @Test void alreadyScheduledSourceIsNotScheduledAgain() { var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1"); - assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).create(anyString(),any()); + assertThatThrownBy(()->service.create("OP",plan)).hasMessageContaining("已安排车次");verify(trips,never()).createPickup(anyString(),any()); } @Test void pickupQuantityDoesNotOverwriteForecastAndReplayDoesNotWriteTwice() { when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java new file mode 100644 index 000000000..7be302e22 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetDispatchRoutePersistenceTest.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.Fixture; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.*; +import static org.assertj.core.api.Assertions.*; + +/** 真实主单/Fleet/预订Mapper及事务;沿GET route所用应用读取检验站序,不调用环境HTTP。 */ +class FleetDispatchRoutePersistenceTest { + @Test void lastPalletRemovalPrunesOnlyItsStopPreservesOrderAndAppendsNewDestinations() throws Exception { + var s=new Scenario();var f=s.f; + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-prune",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5),f.source(3,"103",503,"SEA1",4))))); + var origin=s.route(created.dispatchNo()).stops().get(0); + var ordered=f.tx.execute(t->f.service.reorderRoute(f.actor,created.dispatchNo(),new DispatchWorkModels.RouteCommand(created.recordVersion(),"route-order",List.of("FBA:SEA1","FBA:ONT8","FBA:LAX9"),"确认卸货顺序"))); + long item=s.item(1);var request=new DispatchCommands.ReasonCommand(ordered.recordVersion(),"remove-last-ont8","移出本目的地最后一托"); + var removed=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request)); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:SEA1","FBA:LAX9"); + var added=f.tx.execute(t->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(removed.recordVersion(),"append-new-destination",List.of(f.source(4,"104",504,"ABE2",2)),List.of()))); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:SEA1","FBA:LAX9","FBA:ABE2"); + assertThat(s.route(created.dispatchNo()).stops().get(0)).isEqualTo(origin); + var beforeReplay=s.state(created.dispatchNo());var replay=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request)); + assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(removed.recordVersion());assertThat(s.state(created.dispatchNo())).isEqualTo(beforeReplay);assertThat(s.route(created.dispatchNo()).recordVersion()).isEqualTo(added.recordVersion()); + } + + @Test void anotherPalletAtTheSameDestinationKeepsTheStopAndAnEmptyTruckKeepsOnlyItsOrigin() throws Exception { + var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-same-destination",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"ONT8",5))))); + var origin=s.route(created.dispatchNo()).stops().get(0);String trip=s.trip(); + var preview=f.fleet.capacityPlan(trip);var measurements=preview.cargo().stream().map(c->new FleetExecutionModels.Measurement(c.warehousePalletId(),1,BigDecimal.TEN,BigDecimal.ONE,1)).toList(); + f.tx.execute(t->f.fleet.confirmCapacityPlan(trip,f.actor.operatorId(),new FleetExecutionModels.Confirm(preview.recordVersion(),preview.manifestHash(),measurements,"实际计划容量"))); + assertThat(f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class)).isNotNull(); + var first=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(created.recordVersion(),"remove-first-same-destination","移出一托"))); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:ONT8");assertThat(f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class)).isNull(); + var empty=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(2),new DispatchCommands.ReasonCommand(first.recordVersion(),"remove-all-cargo","移出最后一托"))); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9");assertThat(s.route(created.dispatchNo()).stops().get(0)).isEqualTo(origin);assertThat(empty.counts().packageCount()).isZero();assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isZero(); + } + + @ParameterizedTest @ValueSource(strings={"CONFIRMED","RECEIVED","BOUND"}) + void activeBookingKeepsItsOriginalStopAndCancelledBookingsDoNotLeaveGhostStops(String status) throws Exception { + var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-booking-"+status,List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5))))); + var ordered=f.tx.execute(t->f.service.reorderRoute(f.actor,created.dispatchNo(),new DispatchWorkModels.RouteCommand(created.recordVersion(),"booking-stop-order",List.of("FBA:ONT8","FBA:LAX9"),"原承诺站序"))); + s.booking(status,"FBA:ONT8");var original=s.route(created.dispatchNo()).stops(); + var removed=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(ordered.recordVersion(),"remove-booked-destination","保留有效外接卸货点"))); + assertThat(s.route(created.dispatchNo()).stops()).isEqualTo(original); + f.db.update("UPDATE ll_fleet_external_booking SET status='CANCELLED'"); + f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(2),new DispatchCommands.ReasonCommand(removed.recordVersion(),"remove-after-booking-cancel","外接已取消后移出剩余货物"))); + assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9"); + } + + @Test void missingRequiredBookingStopRejectsTheWholeRemovalAndDoesNotInventAStop() throws Exception { + var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-missing-booking",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5))))); + s.booking("CONFIRMED","FBA:UNSUPPORTED");var before=s.state(created.dispatchNo()); + assertThatThrownBy(()->f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),s.item(1),new DispatchCommands.ReasonCommand(created.recordVersion(),"reject-missing-booking-stop","外接目的地不完整")))).hasMessageContaining("已确认外接预订"); + assertThat(s.state(created.dispatchNo())).isEqualTo(before);assertThat(s.keys(created.dispatchNo())).doesNotContain("FBA:UNSUPPORTED"); + } + + @Test void failureAfterTripRouteWriteRollsBackMainClaimsQuantitiesAndLedgerThenTheSameKeyCanSucceed() throws Exception { + var s=new Scenario();var f=s.f;var created=f.tx.execute(t->f.service.create(f.actor,f.configured("route-write-rollback",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"102",502,"LAX9",5))))); + long item=s.item(2);var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"route-failed-original","实际事务回滚");var before=s.state(created.dispatchNo()); + f.db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD CONSTRAINT reject_quantity_three CHECK(package_count<>3)"); + assertThatThrownBy(()->f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request))).isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class); + assertThat(s.state(created.dispatchNo())).isEqualTo(before); + f.db.execute("ALTER TABLE ll_fleet_trip_dispatch DROP CONSTRAINT reject_quantity_three"); + var saved=f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request));assertThat(saved.replayed()).isFalse();assertThat(s.keys(created.dispatchNo())).containsExactly("ORIGIN:9","FBA:ONT8"); + var after=s.state(created.dispatchNo());assertThat(f.tx.execute(t->f.service.removeCargo(f.actor,created.dispatchNo(),item,request)).replayed()).isTrue();assertThat(s.state(created.dispatchNo())).isEqualTo(after); + } + + private record State(long version,int packages,String cargoHash,String review,long fleetVersion,String stops,String plan,int bindingPackages,long items,long activeClaims,long commands,long history,long fleetHistory) { } + private static final class Scenario { + final Fixture f=new Fixture();final FleetBookingMapper bookings; + Scenario() throws Exception { + f.session.getConfiguration().addMapper(FleetBookingMapper.class);bookings=f.session.getMapper(FleetBookingMapper.class); + var table=TableInfoHelper.getTableInfo(FleetBookingEntity.class);var columns=new ArrayList();columns.add(table.getKeyColumn()+" BIGINT PRIMARY KEY"); + for(var field:table.getFieldList())columns.add(field.getColumn()+" "+sqlType(field.getPropertyType())); + f.db.execute("CREATE TABLE "+table.getTableName()+"("+String.join(",",columns)+")"); + var reservations=new FleetBookingReservations(bookings,f.json);ReflectionTestUtils.setField(f.nativeFleetService,"bookingReservations",reservations);ReflectionTestUtils.setField(f.fleet,"bookingReservations",reservations); + } + long item(long plan){return f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=?",Long.class,plan);} + String trip(){return f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip",String.class);} + DispatchWorkModels.Route route(String number){return f.tx.execute(t->f.service.route(f.actor,number));} + List keys(String number){return route(number).stops().stream().map(DispatchWorkModels.Stop::destinationKey).toList();} + /** 预订是本切片的持久化输入;未声称执行原外接确认流程。 */ + void booking(String status,String destination) throws Exception { + var row=new FleetBookingEntity();row.setId(901L);row.setTripId(f.db.queryForObject("SELECT id FROM ll_fleet_trip",Long.class));row.setTripNo(trip());row.setBookingNo("BOOKING-ROUTE");row.setCustomerId("201");row.setStatus(status);row.setDestinationKey(destination);row.setDestinationLabel(destination);row.setRecordVersion(0L);row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt()); + row.setCargoJson(f.json.writeValueAsString(new FleetBookingModels.Cargo(destination,"测试外接货",1,1,BigDecimal.ONE,BigDecimal.ONE,null,null,null,null,null,null)));bookings.insert(row); + } + State state(String number){var root=f.repository.find(number);return new State(root.getRecordVersion(),root.getPackageCount(),root.getCargoHash(),root.getDispatchReviewJson(),f.db.queryForObject("SELECT record_version FROM ll_fleet_trip",Long.class),f.db.queryForObject("SELECT stops_json FROM ll_fleet_trip",String.class),f.db.queryForObject("SELECT planned_load_json FROM ll_fleet_trip",String.class),f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class),count("ll_dispatch_item"),f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class),count("ll_dispatch_command"),count("ll_dispatch_history"),count("ll_fleet_trip_history"));} + long count(String table){return f.db.queryForObject("SELECT COUNT(*) FROM "+table,Long.class);} + static String sqlType(Class type){return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==BigDecimal.class?"DECIMAL(18,6)":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(8000)";} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index 6d26a90cd..61d092e6c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -19,6 +19,7 @@ class FleetTripApiContractTest { for(var spec:java.util.List.of(document,preparation,pool))paths.putAll((Map)spec.get("paths")); String module = Files.readString(Path.of("../iam/src/main/resources/db/lastleg-permissions.sql")); module += Files.readString(Path.of("../iam/src/main/resources/db/fleet-capacity-offer-permissions.sql")); + module += Files.readString(Path.of("../iam/src/main/resources/db/phase5-delivery-permissions.sql")); String deployed = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); int count = 0; for (var controller : java.util.List.of(PlatformFleetTripController.class, PlatformFleetBookingController.class, PlatformFleetCapacityOfferController.class)) { @@ -46,7 +47,7 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(54); + assertThat(count).isEqualTo(46); verifyRefs(document, document); verifyRefs(preparation, preparation); verifyRefs(pool, pool); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java index 154c9ad4e..0d0198ac6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripPersistenceTest.java @@ -4,7 +4,8 @@ import com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.Fixt import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.delivery.entity.*; import com.baiyue.lastleg.delivery.mapper.*; -import com.baiyue.lastleg.masterdata.vehicle.application.service.VehicleService; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; import org.junit.jupiter.api.Test; import java.math.BigDecimal; import java.time.*; @@ -51,7 +52,7 @@ class FleetTripPersistenceTest { var scenario=new CompletedCargo();var f=scenario.f;var inspection=scenario.completion.inspection(trip(f));assertThat(inspection.canComplete()).isTrue(); var request=new FleetCompletionModels.Complete(inspection.recordVersion(),"complete-original","全部货物实际履约"); var complete=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(complete.trip().status()).isEqualTo("COMPLETED"); - assertThat(f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request))).isEqualTo(complete); + FleetTripModels.Detail recovered=f.tx.execute(s->scenario.completion.complete(trip(f),"operator",request));assertThat(recovered).isEqualTo(complete); assertThat(complete.trip().vehicleRelease()).isNull();assertThat(complete.trip().driverRelease()).isNull(); var end=Instant.now().minusSeconds(10);var vehicle=new FleetCompletionModels.Release(complete.trip().recordVersion(),"VEHICLE",end,"实际交车点","RETURNED","vehicle-end-original","外配车实际结束"); var released=f.tx.execute(s->scenario.completion.release(trip(f),"operator",vehicle));assertThat(released.trip().vehicleRelease()).isNotNull();assertThat(released.trip().driverRelease()).isNull(); @@ -75,14 +76,14 @@ class FleetTripPersistenceTest { private static String trip(Fixture f){return f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip",String.class);} /** 已发运起始事实作为本切片fixture;客户单据实际Mapper读取,未模拟仓储查询成功。 */ private static final class CompletedCargo { - final Fixture f;final VehicleService vehicleService=mock(VehicleService.class);final FleetTripCompletionService completion; + final Fixture f;final VehicleApplicationService vehicleService=mock(VehicleApplicationService.class);final FleetTripCompletionService completion; CompletedCargo() throws Exception { f=new Fixture();var created=f.tx.execute(s->f.service.create(f.actor,f.configured("fleet-completion",List.of(f.source(1,"101",501,"ONT8",3)))));f.approve(created);f.picked(1,"101",501,3); f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',picked_package_count=3,shipped_package_count=3,departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10)); f.db.update("UPDATE ll_fleet_trip SET status='IN_TRANSIT',departed_at=?",LocalDateTime.now(ZoneOffset.UTC).minusMinutes(10)); var root=f.repository.find(created.dispatchNo());var outbound=new OutboundOrder();outbound.setId(600L);outbound.setCustomerId("101");outbound.setOutboundNo("OB-REAL");outbound.setDispatchOrderId(root.getId());outbound.setOverseasWarehouseId("9");outbound.setStatus("OUTBOUNDED");outbound.setPackageCount(3);f.session.getMapper(OutboundOrderMapper.class).insert(outbound); var delivery=new DeliveryOrder();delivery.setId(700L);delivery.setCustomerId("101");delivery.setDeliveryNo("DL-REAL");delivery.setDispatchOrderId(root.getId());delivery.setOutboundOrderId(600L);delivery.setOverseasWarehouseId("9");delivery.setStatus("DELIVERED");delivery.setPackageCount(3);delivery.setSignedPackages(3);delivery.setSignedAt(LocalDateTime.now().minusMinutes(5));delivery.setVersion(0L);delivery.setDestinationType("FBA");delivery.setDestinationWarehouseCode("ONT8");f.session.getMapper(DeliveryOrderMapper.class).insert(delivery); - completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,vehicleService,f.json); + completion=new FleetTripCompletionService(f.session.getMapper(FleetTripMapper.class),f.session.getMapper(FleetTripDispatchMapper.class),f.session.getMapper(FleetResourceMapper.class),(FleetDispatchGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.nativeFleetService,"dispatches"),f.nativeFleetService,mock(VehicleRepository.class),vehicleService,f.json); } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java index baad0af40..bb6089ba9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/VehicleFulfillmentQueryTest.java @@ -50,7 +50,8 @@ public class VehicleFulfillmentQueryTest { jdbc.execute("CREATE ALIAS JSON_CONTAINS FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonContains'"); jdbc.execute("CREATE ALIAS JSON_SET FOR 'com.baiyue.lastleg.fleet.trip.VehicleFulfillmentQueryTest.jsonSet'"); jdbc.execute("CREATE TABLE ll_fleet_trip(id BIGINT PRIMARY KEY,trip_no VARCHAR(64),vehicle_id BIGINT,status VARCHAR(32))"); - jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT,customer_id VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_trip_dispatch(dispatch_id BIGINT PRIMARY KEY,trip_id BIGINT UNIQUE)"); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(32))"); jdbc.execute("CREATE TABLE ll_fleet_trip_history(id BIGINT PRIMARY KEY,trip_id BIGINT,record_version BIGINT,occurred_at TIMESTAMP,action VARCHAR(64),reason VARCHAR(255),snapshot_json CLOB,UNIQUE(trip_id,record_version))"); jdbc.execute("CREATE TABLE ll_fleet_trip_handover(id BIGINT PRIMARY KEY,trip_id BIGINT,trip_version BIGINT,snapshot_json CLOB,UNIQUE(trip_id,trip_version))"); jdbc.execute("CREATE TABLE ll_delivery_order(id BIGINT PRIMARY KEY,delivery_no VARCHAR(64),dispatch_no VARCHAR(64),dispatch_order_id BIGINT,customer_id VARCHAR(32),customer_name VARCHAR(100),bol_no VARCHAR(64),destination_type VARCHAR(32),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(100),delivery_address VARCHAR(255),planned_delivery_at TIMESTAMP,status VARCHAR(32),created_at TIMESTAMP)"); @@ -61,8 +62,9 @@ public class VehicleFulfillmentQueryTest { when(vehicles.findById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class))); service = new VehicleFulfillmentService(vehicles, mapper, JSON); jdbc.update("INSERT INTO ll_fleet_trip VALUES(1,'TR-1',8,'PLANNED')"); - jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1,'CUSTOMER-A'),(12,1,'CUSTOMER-B')"); - delivery(21,11,"CUSTOMER-A"); delivery(22,12,"CUSTOMER-B"); + jdbc.update("INSERT INTO ll_fleet_trip_dispatch VALUES(11,1)"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(1,11,'CUSTOMER-A'),(2,11,'CUSTOMER-B')"); + delivery(21,11,"CUSTOMER-A"); delivery(22,11,"CUSTOMER-B"); } private void delivery(long deliveryId, long dispatchId, String customer) { jdbc.update("INSERT INTO ll_delivery_order(id,delivery_no,dispatch_no,dispatch_order_id,customer_id,customer_name,destination_type,destination_warehouse_code,status,created_at) VALUES(?,?,?,?,?,?,'FBA','ONT8','WAIT_DELIVERY',TIMESTAMP '2026-10-03 00:00:00')", @@ -72,7 +74,7 @@ public class VehicleFulfillmentQueryTest { var trip = new HashMap(); if (vehicle != null) { trip.put("vehicleId",vehicle); trip.put("vehiclePlate","PLATE-"+vehicle); } if (driver != null) { trip.put("driverId",driver); trip.put("driverName","司机"+driver); } - String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11"),Map.of("dispatchNo","DP-12")))); + String snapshot = JSON.writeValueAsString(Map.of("trip",trip,"dispatches",List.of(Map.of("dispatchNo","DP-11")))); jdbc.update("INSERT INTO ll_fleet_trip_history VALUES(?,1,?,TIMESTAMP '2026-10-03 00:00:00',?,'更换车辆',?)",version+1,version,action,snapshot); } @Test void currentPlanUsesStableVehicleIdentityAndCustomerScopedDispatchJoin() { @@ -108,7 +110,7 @@ public class VehicleFulfillmentQueryTest { assertThat(in.records().get(0).relationship()).isEqualTo("REPLACED_IN"); assertThat(out.records().get(0).previousVehiclePlate()).isEqualTo("PLATE-7"); assertThat(out.records().get(0).vehiclePlate()).isEqualTo("PLATE-8"); - assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11","DP-12"); + assertThat(out.records().get(0).dispatchNos()).containsExactly("DP-11"); assertThat(out.records().get(0).occurredAt().toString()).isEqualTo("2026-10-03T00:00:00Z"); assertThat(service.changes(77,new VehicleFulfillmentModels.Query(1,10)).total()).isZero(); } @@ -127,14 +129,14 @@ public class VehicleFulfillmentQueryTest { jdbc.update("UPDATE ll_fleet_trip SET vehicle_id=9,status='IN_TRANSIT'"); String snapshot = JSON.writeValueAsString(Map.of("replacement", Map.of("vehicleId", "9"), "carriedDeliveries", List.of(Map.of("customerId", "CUSTOMER-B", "deliveryNo", "DL-22")), - "cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-12"), Map.of("dispatchNo", "DP-12"))))); + "cargo", Map.of("remainingCargo", List.of(Map.of("dispatchNo", "DP-11"), Map.of("dispatchNo", "DP-11"))))); jdbc.update("INSERT INTO ll_fleet_trip_handover VALUES(1,1,1,?)", snapshot); assertThat(service.deliveries(8,new VehicleFulfillmentModels.Query(1,10)).records()).hasSize(2); assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).records()) .extracting(VehicleFulfillmentModels.Delivery::deliveryNo).containsExactly("DL-22"); assertThat(service.changes(9,new VehicleFulfillmentModels.Query(1,10)).records()).singleElement().satisfies(change -> { assertThat(change.relationship()).isEqualTo("REPLACED_IN"); - assertThat(change.dispatchNos()).containsExactly("DP-12"); + assertThat(change.dispatchNos()).containsExactly("DP-11"); }); jdbc.update("UPDATE ll_delivery_order SET status='DELIVERED' WHERE id=22"); assertThat(service.deliveries(9,new VehicleFulfillmentModels.Query(1,10)).total()).isEqualTo(1); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java index a1952e1e3..401c9e65d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanDispatchStatusPersistenceTest.java @@ -17,6 +17,7 @@ import static org.assertj.core.api.Assertions.assertThat; class InboundPlanDispatchStatusPersistenceTest { private JdbcTemplate db; private InboundPlanQueryMapper mapper; + private com.baiyue.lastleg.mapper.LastlegOrderMapper orders; @BeforeEach void setUp() throws Exception { @@ -31,30 +32,33 @@ class InboundPlanDispatchStatusPersistenceTest { db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR(20),gross_weight_kg DECIMAL(18,3),net_weight_kg DECIMAL(18,3))"); db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(20),dispatch_order_id BIGINT,order_id BIGINT)"); - db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,customer_id VARCHAR(20),status VARCHAR(30))"); + db.execute("CREATE TABLE ll_dispatch_order(id BIGINT,status VARCHAR(30),stage VARCHAR(30))"); db.update("INSERT INTO ll_inbound_plan VALUES(1,'LIP-1','A',90,TRUE,FALSE,FALSE,'RECEIVED',CURRENT_TIMESTAMP,0)"); db.update("INSERT INTO ll_inbound_plan_line VALUES(2,1,3,'IN-1','91','ABE4','ABE4','ABE4',1,1,1)"); db.update("INSERT INTO ll_order VALUES(3,'A','LX-1','FIRST_LEG','LA','洛杉矶仓','客户A')"); var config = new MybatisConfiguration(); config.addMapper(InboundPlanQueryMapper.class); + config.addMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(data); factory.setConfiguration(config); - mapper = new SqlSessionTemplate(factory.getObject()).getMapper(InboundPlanQueryMapper.class); + var session = new SqlSessionTemplate(factory.getObject()); + mapper = session.getMapper(InboundPlanQueryMapper.class); + orders = session.getMapper(com.baiyue.lastleg.mapper.LastlegOrderMapper.class); } @Test void cancelledDispatchRetainsHistoryButDoesNotMarkOrFilterTheInboundAsScheduled() { - dispatch(10, "A", "CANCELLED", "A"); + dispatch(10, "CANCELLED", "BEFORE_LOADING", "A"); assertStatus("UNSCHEDULED"); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item", Integer.class)).isEqualTo(1); } @Test void replacementDispatchCountsOnceAndCancellingItRestoresUnscheduled() { - dispatch(10, "A", "CANCELLED", "A"); - dispatch(11, "A", "PENDING_DEPARTURE", "A"); - dispatch(12, "A", "PICKING", "A"); + dispatch(10, "CANCELLED", "BEFORE_LOADING", "A"); + dispatch(11, "ACTIVE", "BEFORE_LOADING", "A"); + dispatch(12, "ACTIVE", "LOADING", "A"); assertStatus("SCHEDULED"); db.update("UPDATE ll_dispatch_order SET status='CANCELLED'"); assertStatus("UNSCHEDULED"); @@ -62,16 +66,15 @@ class InboundPlanDispatchStatusPersistenceTest { @Test void departedAndCompletedDispatchesRemainScheduled() { - dispatch(10, "A", "DEPARTED", "A"); + dispatch(10, "ACTIVE", "DELIVERING", "A"); assertStatus("SCHEDULED"); - db.update("UPDATE ll_dispatch_order SET status='COMPLETED'"); + db.update("UPDATE ll_dispatch_order SET stage='DELIVERED'"); assertStatus("SCHEDULED"); } @Test void otherCustomerOrMissingDispatchCannotMarkThisInboundScheduled() { - dispatch(10, "B", "PENDING_DEPARTURE", "B"); - dispatch(11, "B", "PENDING_DEPARTURE", "A"); + dispatch(10, "ACTIVE", "BEFORE_LOADING", "B"); db.update("INSERT INTO ll_dispatch_item VALUES(12,'A',999,3)"); assertStatus("UNSCHEDULED"); assertThat(mapper.rows("B", query(null), 0, 20)).isEmpty(); @@ -106,12 +109,14 @@ class InboundPlanDispatchStatusPersistenceTest { assertThat(remaining.appointmentNo()).isEqualTo("AP-FIRST");assertThat(remaining.appointmentStatus()).isEqualTo("SUCCESS"); } - private void dispatch(long id, String owner, String status, String itemOwner) { - db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, owner, status); + private void dispatch(long id, String status, String stage, String itemOwner) { + db.update("INSERT INTO ll_dispatch_order VALUES(?,?,?)", id, status, stage); db.update("INSERT INTO ll_dispatch_item VALUES(?,?,?,3)", id, itemOwner, id); } private void assertStatus(String status) { + assertThat(orders.findDispatchStatuses(java.util.List.of(3L))) + .extracting(com.baiyue.lastleg.mapper.LastlegOrderMapper.DispatchStatusRow::dispatchStatus).containsExactly(status); for (String customer : new String[]{null, "A"}) { assertThat(mapper.rows(customer, query(null), 0, 20)) .extracting(InboundPlanListResponse::dispatchStatus).containsExactly(status); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java index 4adc78fa9..d151bd1a4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/mapper/InboundPlanQueryMapperContractTest.java @@ -4,6 +4,7 @@ import org.apache.ibatis.annotations.Select; import org.junit.jupiter.api.Test; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assertions.assertFalse; class InboundPlanQueryMapperContractTest { @Test @@ -20,7 +21,7 @@ class InboundPlanQueryMapperContractTest { assertTrue(sql.contains("p.overseas_warehouse_id overseasWarehouseId")); assertTrue(sql.contains("p.overseas_warehouse_id=#{q.overseasWarehouseId}")); assertTrue(sql.contains("di.customer_id=p.customer_id AND di.order_id=l.order_id")); - assertTrue(sql.contains("dispatch.customer_id=di.customer_id")); + assertFalse(sql.contains("dispatch.customer_id")); assertTrue(sql.contains("dispatch.status<>'CANCELLED'")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index 433bbf0ab..bac6bc6e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -240,7 +240,7 @@ class FbaAppointmentPersistenceTest { @Bean FbaAppointmentTransportPort receivingTransport(){ var port=mock(FbaAppointmentTransportPort.class);var scope=new FbaAppointmentTransportPort.Scope("transport-v1",List.of(),List.of()); when(port.inspect(anyList())).thenReturn(scope);when(port.lockAndInspect(anyList(),anyString(),anyString())).thenReturn(scope); - when(port.apply(any(),anyList(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port; + when(port.apply(any(),anyList(),anyString(),anyString(),anyString())).thenReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of()));return port; } @Bean FbaAppointmentWorkPort receivingWork(){ var port=mock(FbaAppointmentWorkPort.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java index 7a9ce6b62..947a80973 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentRepartitionPersistenceTest.java @@ -165,7 +165,7 @@ class FbaAppointmentRepartitionPersistenceTest { try(var context=new AnnotationConfigApplicationContext(FbaAppointmentPersistenceTest.Config.class)) { var service=context.getBean(InboundFbaAppointmentService.class);var db=new JdbcTemplate(context.getBean(DataSource.class)); var original=service.createFromSources("operator",create("create")); - db.update("INSERT INTO ll_dispatch_order VALUES(10,'A','DRAFT')");db.update("INSERT INTO ll_dispatch_item VALUES(1,'A',10)"); + db.update("INSERT INTO ll_dispatch_order(id,status) VALUES(10,'ACTIVE')");db.update("INSERT INTO ll_dispatch_item(id,customer_id,dispatch_order_id,plan_pallet_id) VALUES(11,'A',10,12)"); db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(10,11,12,?,'A','ORDER:1','FBA:ONT8',1)",original.batchKey()); assertThrows(ResponseStatusException.class,()->service.repartition("operator",split(original,"blocked"))); db.update("UPDATE ll_dispatch_order SET status='CANCELLED' WHERE id=10"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java index ad077dd1a..ee88828e4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaReceivingPersistenceTest.java @@ -33,7 +33,7 @@ class FbaReceivingPersistenceTest { assertFalse(f.store.hasPending(original.batchKey(),"A")); assertEquals(1,f.receiving.context(original.batchKey()).pendingChanges().size()); f.assertAllocations(80,160,40); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); var withdrawn=f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw",0,"WITHDRAW",null,TRANSPORT)); assertEquals("WITHDRAWN",withdrawn.status()); assertFalse(f.store.hasPending(original.batchKey(),"B")); @@ -65,7 +65,7 @@ class FbaReceivingPersistenceTest { assertEquals(List.of(20,40,100),destinations.stream().flatMap(b->b.items().stream()).filter(i->i.customerId().equals("B")).map(FbaAppointmentResponse.Item::packageCount).sorted().toList()); f.assertAllocations(80,160,40); assertEquals(0,f.db.queryForObject("SELECT SUM(version) FROM ll_order",Integer.class)); - verify(f.transport).apply(any(),eq(List.of(new FbaAppointmentTransportPort.Move(original.batchKey(),"B","ORDER:2",DESTINATION,160,100,later.batchKey()))),eq(OPERATOR),anyString()); + verify(f.transport).apply(any(),eq(List.of(new FbaAppointmentTransportPort.Move(original.batchKey(),"B","ORDER:2",DESTINATION,160,100,later.batchKey()))),eq(OPERATOR),anyString(),eq("partial-confirm")); } } @@ -102,7 +102,7 @@ class FbaReceivingPersistenceTest { conflict(()->f.receiving.resolve(OPERATOR,first.changeKey(),new Resolve("same-resolve",0,"WITHDRAW",null,TRANSPORT))); assertEquals(2,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class)); assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_receiving_change",Integer.class)); - verify(f.transport,times(1)).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,times(1)).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -119,7 +119,7 @@ class FbaReceivingPersistenceTest { assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class)); assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='retry-resolve'",Integer.class)); f.assertAllocations(80,160,40); - doReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of())).when(f.transport).apply(any(),anyList(),anyString(),anyString()); + doReturn(new FbaAppointmentTransportPort.Result(List.of(),List.of())).when(f.transport).apply(any(),anyList(),anyString(),anyString(),anyString()); assertEquals("APPLIED",f.confirm(pending.changeKey(),"retry-resolve").status()); f.assertAllocations(80,160,40); } @@ -171,7 +171,7 @@ class FbaReceivingPersistenceTest { conflict(()->f.confirm(pending.changeKey(),"stale-batch")); assertEquals("WAITING",f.store.change(pending.changeKey(),false).status()); assertEquals(280,total(f.batches.detail(original.appointmentNo())));f.assertAllocations(80,160,40); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); } } @@ -185,7 +185,7 @@ class FbaReceivingPersistenceTest { conflict(()->f.confirm(pending.changeKey(),"stale-agreement")); assertEquals("WAITING",f.store.change(pending.changeKey(),false).status()); assertEquals(original,f.batches.detail(original.appointmentNo()));f.assertAllocations(80,160,40); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); } } @@ -198,7 +198,7 @@ class FbaReceivingPersistenceTest { assertEquals(original,f.batches.detail(original.appointmentNo())); assertEquals("WAITING",f.store.change(pending.changeKey(),false).status()); f.assertAllocations(80,160,40); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); } } @@ -286,7 +286,7 @@ class FbaReceivingPersistenceTest { assertEquals(result,f.receiving.resolve(OPERATOR,pending.changeKey(),request)); conflict(()->f.receiving.resolve(OPERATOR,pending.changeKey(),new Resolve("withdraw-after-picking",0,"CONFIRM","修改请求内容",TRANSPORT))); verify(f.transport,times(2)).lockAndInspect(anyList(),eq(TRANSPORT),anyString()); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -315,7 +315,7 @@ class FbaReceivingPersistenceTest { assertEquals(List.of("B","D"),rebooked.agreements().get(0).members().stream().map(Member::customerId).sorted().toList()); verify(f.execution).lockAndInspect(eq(List.of(new FbaAppointmentExecutionPort.Member(batch.batchKey(),"B"),new FbaAppointmentExecutionPort.Member(batch.batchKey(),"D"))),eq("execution-v1"),anyString()); verify(f.transport,never()).lockAndInspect(anyList(),anyString(),anyString()); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -367,7 +367,7 @@ class FbaReceivingPersistenceTest { assertEquals(1,f.db.queryForObject("SELECT COUNT(*) FROM ll_inbound_fba_appointment",Integer.class)); assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='inactive-confirm'",Integer.class)); verify(f.work,never()).seedFollowup(any(),any(),any(),anyString()); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -439,7 +439,7 @@ class FbaReceivingPersistenceTest { assertEquals(0,f.db.queryForObject("SELECT COUNT(*) FROM ll_fba_appointment_command WHERE command_key='raced-proposal'",Integer.class)); assertEquals(original,f.batches.detail(original.appointmentNo())); assertEquals(agreement,f.store.currentAgreement(original.batchKey(),"B")); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); f.assertAllocations(80,160,40); } } @@ -462,7 +462,7 @@ class FbaReceivingPersistenceTest { f.assertAllocations(80,160,40); } verify(f.transport,times(2)).lockAndInspect(eq(List.of(original.batchKey())),eq(TRANSPORT),anyString()); - verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString()); + verify(f.transport,never()).apply(any(),anyList(),anyString(),anyString(),anyString()); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java index 31b7d210f..223e6140f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanPaginationTest.java @@ -87,7 +87,8 @@ class InboundPlanPaginationTest { assertThat(sql).contains("p.customer_id=#{customerId}", "o.customer_id=p.customer_id"); assertThat(sql).doesNotContain("a2.customer_id=#{customerId}"); assertThat(sql).contains("di.customer_id=p.customer_id AND di.order_id=l.order_id", - "dispatch.customer_id=di.customer_id", "dispatch.status<>'CANCELLED'"); + "dispatch.id=di.dispatch_order_id", "dispatch.status<>'CANCELLED'"); + assertThat(sql).doesNotContain("dispatch.customer_id"); assertThat(sql).contains("p.overseas_warehouse_id overseasWarehouseId", "o.overseas_warehouse_code overseasWarehouseCode", "o.overseas_warehouse_name overseasWarehouseName", "p.overseas_warehouse_id=#{q.overseasWarehouseId}"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java index bc761a7d5..8de2daafd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDomainMapperTest.java @@ -61,17 +61,28 @@ class AppointmentWorkDomainMapperTest { var sessions=receiving.sessions(List.of("1","2"));assertEquals(2,sessions.size()); assertEquals(403L,sessions.stream().filter(s->"1".equals(s.getSourceContainerBookingId())).findFirst().orElseThrow().getId()); try(var sql=session.getConnection().createStatement()) { - sql.execute("INSERT INTO ll_dispatch_order(id,customer_id,dispatch_no,status) VALUES(501,'A','D-1','PENDING_DEPARTURE'),(502,'B','D-2','CANCELLED'),(503,'B','D-3','PENDING_DEPARTURE')"); + sql.execute("INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,status,stage,cargo_hash) VALUES(501,'D-1',801,'W','ACTIVE','BEFORE_LOADING','HASH'),(502,'D-2',802,'W','CANCELLED','BEFORE_LOADING','HASH'),(503,'D-3',803,'W','ACTIVE','BEFORE_LOADING','HASH')"); + sql.execute("INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,plan_pallet_id,order_id,pallet_no,source_key,source_version,source_snapshot_json,source_stage,package_count) VALUES(601,501,'A',701,101,'P-701','PLAN:701','HASH','{}','RECEIVED',4),(602,501,'B',702,102,'P-702','PLAN:702','HASH','{}','RECEIVED',6),(603,502,'B',703,102,'P-703','PLAN:703','HASH','{}','RECEIVED',2),(604,503,'B',704,102,'P-704','PLAN:704','HASH','{}','RECEIVED',3)"); sql.execute("INSERT INTO ll_inbound_fba_appointment(id,appointment_no,origin_warehouse_id,fba_warehouse_code,created_by) VALUES(1,'AP-1','W','ONT8','operator'),(2,'AP-2','W','ONT8','operator')"); sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','A','ORDER:101','FBA:ONT8',4),(501,602,702,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(502,603,703,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',2),(503,604,704,'FBA_APPOINTMENT:2','B','ORDER:102','FBA:ONT8',3)"); } session.clearCache(); var references=dispatch.references(List.of(501L,503L)); - assertEquals(2,references.size());assertEquals("A",references.get(0).customerId());assertEquals("AP-1",references.get(0).batchNo());assertEquals(4,references.get(0).packages()); - assertEquals("FBA_APPOINTMENT:2",references.get(1).batchKey());assertEquals("AP-2",references.get(1).batchNo()); // Wrong-customer rows on dispatch 501 must not leak into current references. + // 同车两客户均由真实明细关联;主单没有单一客户归属。 + assertEquals(3,references.size());assertEquals("A",references.get(0).customerId());assertEquals("AP-1",references.get(0).batchNo());assertEquals(4,references.get(0).packages()); + assertEquals(501L,references.get(1).dispatchId());assertEquals("B",references.get(1).customerId());assertEquals("AP-1",references.get(1).batchNo());assertEquals(6,references.get(1).packages()); + assertEquals("FBA_APPOINTMENT:2",references.get(2).batchKey());assertEquals("AP-2",references.get(2).batchNo()); + assertEquals(4,dispatch.allocated("FBA_APPOINTMENT:1","A","ORDER:101","FBA:ONT8",503)); + assertEquals(6,dispatch.allocated("FBA_APPOINTMENT:1","B","ORDER:102","FBA:ONT8",503)); var batchDeliveries=deliveries.batches(List.of("FBA_APPOINTMENT:1","FBA_APPOINTMENT:2"));assertEquals(3,batchDeliveries.size()); assertEquals(deliveries.batch("FBA_APPOINTMENT:1"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:1")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList()); assertEquals(deliveries.batch("FBA_APPOINTMENT:2"),batchDeliveries.stream().filter(d->d.batchKey().equals("FBA_APPOINTMENT:2")).map(AppointmentWorkDeliveryMapper.BatchDelivery::delivery).toList()); + // 错客户或错托盘的分配不能借同一主单混入当前明细引用。 + try(var sql=session.getConnection().createStatement()) { + sql.execute("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(501,601,701,'FBA_APPOINTMENT:1','B','ORDER:102','FBA:ONT8',6),(501,602,999,'FBA_APPOINTMENT:1','B','ORDER:FOREIGN','FBA:ONT8',7)"); + } + session.clearCache(); + assertEquals(references,dispatch.references(List.of(501L,503L))); var trips=session.getMapper(AppointmentWorkTripMapper.class);assertTrue(trips.batchTrips("FBA_APPOINTMENT:1").isEmpty());assertTrue(trips.batches(1).isEmpty());assertNull(trips.trip(1)); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java index 50e59fb95..adf3fd34f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/DispatchShipmentSourceQueryTest.java @@ -19,10 +19,10 @@ class DispatchShipmentSourceQueryTest { Connection connection = DriverManager.getConnection("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL"); execute(connection, "CREATE TABLE ll_inventory_ledger (id BIGINT PRIMARY KEY, customer_id VARCHAR, position_id BIGINT, overseas_warehouse_id VARCHAR, sku_code VARCHAR, business_type VARCHAR, delta_packages BIGINT, business_order_no VARCHAR, outbound_order_no VARCHAR, idempotency_key VARCHAR)"); execute(connection, "CREATE TABLE ll_outbound_order (id BIGINT PRIMARY KEY, customer_id VARCHAR, outbound_no VARCHAR, overseas_warehouse_id VARCHAR, status VARCHAR, outbound_type VARCHAR, dispatch_no VARCHAR, dispatch_order_id BIGINT)"); - execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, customer_id VARCHAR, warehouse_id VARCHAR, dispatch_order_id BIGINT)"); + execute(connection, "CREATE TABLE ll_dispatch_pda_task (id BIGINT PRIMARY KEY, warehouse_id VARCHAR, dispatch_order_id BIGINT UNIQUE)"); execute(connection, "CREATE TABLE ll_dispatch_pda_pick (id BIGINT PRIMARY KEY, customer_id VARCHAR, task_id BIGINT, dispatch_item_id BIGINT, inventory_position_id BIGINT, packages BIGINT, idempotency_key VARCHAR)"); execute(connection, "CREATE TABLE ll_inventory_operation (id BIGINT PRIMARY KEY, customer_id VARCHAR, operation_type VARCHAR, status VARCHAR, idempotency_key VARCHAR)"); - execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'100','WH-1',1)"); + execute(connection, "INSERT INTO ll_dispatch_pda_task VALUES (11,'WH-1',1)"); execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (21,'100',11,31,41,8,'pick-1')"); return connection; } @@ -88,9 +88,10 @@ class DispatchShipmentSourceQueryTest { execute(connection, "UPDATE ll_dispatch_pda_pick SET idempotency_key='pick-1', packages=9"); assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); execute(connection, "UPDATE ll_dispatch_pda_pick SET packages=8"); - execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='200'"); + execute(connection, "UPDATE ll_dispatch_pda_pick SET customer_id='200'"); assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); - execute(connection, "UPDATE ll_dispatch_pda_task SET customer_id='100',warehouse_id='WH-2'"); + execute(connection, "UPDATE ll_dispatch_pda_pick SET customer_id='100'"); + execute(connection, "UPDATE ll_dispatch_pda_task SET warehouse_id='WH-2'"); assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); execute(connection, "UPDATE ll_dispatch_pda_task SET warehouse_id='WH-1',dispatch_order_id=2"); assertEquals(0, shipped(connection, "100", 41, "OB-1", "SKU-1")); @@ -103,6 +104,22 @@ class DispatchShipmentSourceQueryTest { } } + /** 一车一任务承载两客户,发运恢复只认各自实际扫码和完成的库存命令。 */ + @Test + void sharedTaskPreservesIndependentCustomerShipmentSources() throws Exception { + try (Connection connection = database()) { + scan(connection); transfer(connection); + execute(connection, "INSERT INTO ll_dispatch_pda_pick VALUES (22,'200',11,32,42,5,'pick-2')"); + execute(connection, "INSERT INTO ll_inventory_operation VALUES (72,'200','SHIP','COMPLETED','DISPATCH:DS-1:32:pick-2:SHIP')"); + execute(connection, "INSERT INTO ll_inventory_ledger VALUES (52,'200',42,'WH-1','SKU-2','SHIP',-5,'DS-1','DS-1','DISPATCH:DS-1:32:pick-2:SHIP:72')"); + execute(connection, "INSERT INTO ll_outbound_order VALUES (62,'200','OB-2','WH-1','OUTBOUNDED','DISPATCH_DELIVERY','DS-1',1)"); + assertEquals(8, shipped(connection,"100",41,"OB-1","SKU-1")); + assertEquals(5, shipped(connection,"200",42,"OB-2","SKU-2")); + assertEquals(0, shipped(connection,"100",42,"OB-2","SKU-2")); + assertEquals(0, shipped(connection,"200",41,"OB-1","SKU-1")); + } + } + @Test void independentOutboundUsesItsOwnNumberAndDoesNotAcceptADispatchAlias() throws Exception { try (Connection connection = database()) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java index b08b236c2..08b33c9cb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/command/InventoryHoldConcurrencyPersistenceTest.java @@ -122,7 +122,7 @@ class InventoryHoldConcurrencyPersistenceTest { } private void scan(Runnable afterStock) { tx.executeWithoutResult(ignored -> { - attribution.preparePicking("C1",21,null); + attribution.preparePicking("C1",21); inventory.allocate("C1","picker",move(21,5,"scan-allocate")); inventory.pick("C1","picker",move(21,5,"scan-pick")); inventory.ship("C1","picker",move(21,5,"scan-ship")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java new file mode 100644 index 000000000..7de29d251 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/service/PhysicalPalletSerialPersistenceTest.java @@ -0,0 +1,382 @@ +package com.baiyue.lastleg.inventory.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import org.h2.api.Trigger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.Timeout; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; + +import java.sql.Connection; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.junit.jupiter.api.Assertions.*; + +/** 实际 Mapper、H2 行锁及 Spring 事务验证共同串行点,不以 mock 调用顺序代替并发证据。 */ +public class PhysicalPalletSerialPersistenceTest { + private static final Map> INSERT_ORDER = new ConcurrentHashMap<>(); + private JdbcTemplate jdbc; + private TransactionTemplate transaction; + private PhysicalPalletSerialRepository repository; + private PhysicalPalletSerialService service; + private ExecutorService workers; + private String observationKey; + + /** 隔离表严格只有实托主键与首次创建时间,触发器仅记录实际数据库插入顺序。 */ + @BeforeEach + void prepare() throws Exception { + var ds = new DriverManagerDataSource("jdbc:h2:mem:physical-serial-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000", "sa", ""); + jdbc = new JdbcTemplate(ds); + jdbc.execute(""" + CREATE TABLE ll_physical_pallet_guard ( + warehouse_pallet_id BIGINT PRIMARY KEY, + created_at TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3)) + """); + observationKey = "PHYSICAL_ORDER_" + UUID.randomUUID().toString().replace("-", "").toUpperCase(Locale.ROOT); + INSERT_ORDER.put(observationKey, Collections.synchronizedList(new ArrayList<>())); + jdbc.execute("CREATE TRIGGER " + observationKey + " BEFORE INSERT ON ll_physical_pallet_guard " + + "FOR EACH ROW CALL '" + GuardInsertOrderTrigger.class.getName() + "'"); + var config = new MybatisConfiguration(); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(PhysicalPalletSerialMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(ds); + factory.setConfiguration(config); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(PhysicalPalletSerialMapper.class); + repository = new PhysicalPalletSerialRepository(mapper); + var manager = new DataSourceTransactionManager(ds); + transaction = new TransactionTemplate(manager); + var target = new PhysicalPalletSerialService(repository); + var proxy = new ProxyFactory(target); + proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager, new AnnotationTransactionAttributeSource())); + service = (PhysicalPalletSerialService) proxy.getProxy(); + workers = Executors.newFixedThreadPool(2); + } + + /** 释放失败测试中的等待任务并移除本数据库的观测记录,避免跨用例共享状态。 */ + @AfterEach + void cleanup() throws InterruptedException { + if (workers != null) { + workers.shutdownNow(); + assertTrue(workers.awaitTermination(10, TimeUnit.SECONDS), "并发测试线程未退出"); + } + if (observationKey != null) INSERT_ORDER.remove(observationKey); + } + + /** 尚无库存或运输占用的真实空实托首次使用也能建立共同串行行。 */ + @Test + void firstUseOfEmptyPhysicalPalletCreatesGuard() { + assertEquals(0, count()); + transaction.executeWithoutResult(status -> service.lock(List.of(901L))); + assertEquals(List.of(901L), ids()); + assertNotNull(createdAt(901)); + assertEquals(2, jdbc.queryForObject("SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS " + + "WHERE TABLE_NAME='LL_PHYSICAL_PALLET_GUARD'", Integer.class)); + } + + /** 没有关联真实实托的计划传空集合时不生成替代主键。 */ + @Test + void emptyTargetSetDoesNotCreateSyntheticGuard() { + transaction.executeWithoutResult(status -> { + service.lock(List.of()); + service.lock(List.of()); + }); + assertEquals(0, count()); + assertTrue(INSERT_ORDER.get(observationKey).isEmpty()); + } + + /** 重复 upsert 和重复取锁保留原首次创建时间,不刷新租期或附加归属。 */ + @Test + void repeatedUsePreservesOriginalCreatedAt() { + LocalDateTime original = LocalDateTime.of(2020, 1, 2, 3, 4, 5); + jdbc.update("INSERT INTO ll_physical_pallet_guard(warehouse_pallet_id,created_at) VALUES(?,?)", 901, original); + transaction.executeWithoutResult(status -> { + service.lock(List.of(901L, 901L)); + service.lock(List.of(901L)); + }); + transaction.executeWithoutResult(status -> service.lock(List.of(901L))); + assertEquals(1, count()); + assertEquals(original, createdAt(901)); + } + + /** 触发器记录真实插入顺序,重复输入必须在 SQL 前去重并按数值升序执行。 */ + @Test + void databaseObservesUniqueAscendingCreationOrder() { + transaction.executeWithoutResult(status -> service.lock(List.of(303L, 101L, 202L, 101L, 303L))); + assertEquals(List.of(101L, 202L, 303L), List.copyOf(INSERT_ORDER.get(observationKey))); + assertEquals(List.of(101L, 202L, 303L), ids()); + } + + /** 一次校验完整目标,非法及空身份不应让前面的合法身份先产生 SQL。 */ + @Test + void invalidIdsAreRejectedBeforeAnyDatabaseInsert() { + assertThrows(IllegalArgumentException.class, + () -> transaction.executeWithoutResult(status -> service.lock(null))); + for (List invalid : List.of(List.of(901L, 0L), List.of(901L, -1L), Arrays.asList(901L, null))) { + assertThrows(IllegalArgumentException.class, + () -> transaction.executeWithoutResult(status -> service.lock(invalid))); + } + assertEquals(0, count()); + assertTrue(INSERT_ORDER.get(observationKey).isEmpty()); + } + + /** MANDATORY 对空集合也生效,不能在无外层事务时建立或短暂持有串行锁。 */ + @Test + void missingOuterTransactionIsRejected() { + assertThrows(IllegalTransactionStateException.class, () -> service.lock(List.of(901L))); + assertThrows(IllegalTransactionStateException.class, () -> service.lock(List.of())); + assertEquals(0, count()); + } + + /** 即使绕过 Spring 代理直接调用实现,也不能在事务外取得瞬时自动提交锁。 */ + @Test + void directInvocationCannotBypassTransactionRequirement() { + var direct = new PhysicalPalletSerialService(repository); + assertThrows(IllegalStateException.class, () -> direct.lock(List.of(901L))); + assertEquals(0, count()); + } + + /** 只读事务不能承担建行及持有业务串行锁的职责。 */ + @Test + void readOnlyCallerCannotAcquireGuard() { + var readOnly = new TransactionTemplate(transaction.getTransactionManager()); + readOnly.setReadOnly(true); + assertThrows(IllegalStateException.class, + () -> readOnly.executeWithoutResult(status -> service.lock(List.of(901L)))); + assertEquals(0, count()); + } + + /** 第二连接对同一真实实托必须等待第一外层事务提交后才能进入。 */ + @Test + @Timeout(30) + void samePhysicalPalletSerializesConcurrentTransactions() throws Exception { + jdbc.update("INSERT INTO ll_physical_pallet_guard(warehouse_pallet_id) VALUES(901)"); + var firstLocked = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var firstSession = new AtomicInteger(); + Future first = hold(List.of(901L), firstSession, firstLocked, releaseFirst); + try { + await(firstLocked); + var secondSession = new AtomicInteger(); + var secondSessionReady = new CountDownLatch(1); + Future second = workers.submit(() -> transaction.executeWithoutResult(status -> { + secondSession.set(sessionId()); + secondSessionReady.countDown(); + service.lock(List.of(901L)); + })); + await(secondSessionReady); + awaitGuardLockWait(secondSession.get(), firstSession.get(), second); + assertFalse(first.isDone()); + releaseFirst.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertEquals(1, count()); + } finally { + releaseFirst.countDown(); + } + } + + /** 不相交真实实托可以在另一事务仍持锁时完成,不形成全表业务互斥。 */ + @Test + @Timeout(30) + void disjointPhysicalPalletsProceedWhileAnotherTransactionHoldsItsGuard() throws Exception { + var firstLocked = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var firstSession = new AtomicInteger(); + Future first = hold(List.of(901L), firstSession, firstLocked, releaseFirst); + try { + await(firstLocked); + Future second = workers.submit(() -> transaction.executeWithoutResult(status -> { + assertNotEquals(firstSession.get(), sessionId()); + service.lock(List.of(902L)); + })); + second.get(5, TimeUnit.SECONDS); + assertFalse(first.isDone()); + assertEquals(1, releaseFirst.getCount()); + assertEquals(List.of(902L), ids()); + releaseFirst.countDown(); + first.get(5, TimeUnit.SECONDS); + assertEquals(List.of(901L, 902L), ids()); + } finally { + releaseFirst.countDown(); + } + } + + /** 新行随第一事务回滚并释放阻塞,等待者随后为同实托重新建行取锁。 */ + @Test + @Timeout(30) + void rollbackReleasesWaitingTransactionAndDoesNotKeepRolledBackCreationTime() throws Exception { + var firstLocked = new CountDownLatch(1); + var releaseFirst = new CountDownLatch(1); + var firstSession = new AtomicInteger(); + LocalDateTime rolledBackTime = LocalDateTime.of(2010, 1, 2, 3, 4, 5); + Future first = workers.submit(() -> transaction.executeWithoutResult(status -> { + service.lock(List.of(903L)); + jdbc.update("UPDATE ll_physical_pallet_guard SET created_at=? WHERE warehouse_pallet_id=903", rolledBackTime); + firstSession.set(sessionId()); + firstLocked.countDown(); + await(releaseFirst); + status.setRollbackOnly(); + })); + try { + await(firstLocked); + var secondSession = new AtomicInteger(); + var secondSessionReady = new CountDownLatch(1); + Future second = workers.submit(() -> transaction.executeWithoutResult(status -> { + secondSession.set(sessionId()); + secondSessionReady.countDown(); + service.lock(List.of(903L)); + })); + await(secondSessionReady); + awaitGuardLockWait(secondSession.get(), firstSession.get(), second); + releaseFirst.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertEquals(List.of(903L), ids()); + assertNotEquals(rolledBackTime, createdAt(903)); + } finally { + releaseFirst.countDown(); + } + } + + /** 两个相反且重复的请求通过真实数据库完成,交集按同一数值顺序串行。 */ + @Test + @Timeout(30) + void overlappingReverseRequestsFinishWithOneGuardPerPhysicalPallet() throws Exception { + var start = new CountDownLatch(1); + Future first = workers.submit(() -> { + await(start); + transaction.executeWithoutResult(status -> service.lock(List.of(905L, 904L, 906L, 904L))); + }); + Future second = workers.submit(() -> { + await(start); + transaction.executeWithoutResult(status -> service.lock(List.of(906L, 905L, 904L, 906L))); + }); + start.countDown(); + first.get(5, TimeUnit.SECONDS); + second.get(5, TimeUnit.SECONDS); + assertEquals(List.of(904L, 905L, 906L), ids()); + } + + /** 第一调用方原事务持锁等待,不提交中间建行或借用独立事务。 */ + private Future hold(List ids, AtomicInteger session, CountDownLatch locked, CountDownLatch release) { + return workers.submit(() -> transaction.executeWithoutResult(status -> { + service.lock(ids); + session.set(sessionId()); + locked.countDown(); + await(release); + })); + } + + /** 在各调用方事务实际使用的连接上取得会话身份,不把线程启动标记视为数据库等待证据。 */ + private int sessionId() { + return jdbc.queryForObject("SELECT SESSION_ID()", Integer.class); + } + + /** 观察者连接须看到等待者执行串行表 SQL,且实际 BLOCKER_ID 正是持锁事务,才能释放持锁者。 */ + private void awaitGuardLockWait(int waiter, int holder, Future waiting) throws Exception { + assertTrue(waiter > 0 && holder > 0); + assertNotEquals(holder, waiter); + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(3); + List> lastObserved = List.of(); + while (System.nanoTime() < deadline) { + if (waiting.isDone()) { + waiting.get(); + fail("持锁事务尚未释放,但等待事务未进入数据库锁等待便已完成"); + } + lastObserved = jdbc.queryForList("SELECT SESSION_ID,EXECUTING_STATEMENT,SESSION_STATE,BLOCKER_ID " + + "FROM INFORMATION_SCHEMA.SESSIONS WHERE SESSION_ID=?", waiter); + if (lastObserved.size() != 1) continue; + var observed = lastObserved.get(0); + Object blocker = observed.get("BLOCKER_ID"); + String sql = String.valueOf(observed.get("EXECUTING_STATEMENT")).toUpperCase(Locale.ROOT); + if (blocker instanceof Number number && number.intValue() == holder + && sql.contains("LL_PHYSICAL_PALLET_GUARD") && (sql.contains("INSERT") || sql.contains("UPDATE"))) { + assertFalse(waiting.isDone()); + return; + } + } + fail("未观察到真实实托数据库锁等待,waiter=" + waiter + ",holder=" + holder + ",sessions=" + lastObserved); + } + + /** 有界协调并发测试,失败或中断时及时退出以释放事务锁。 */ + private void await(CountDownLatch latch) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("等待并发测试事务超时"); + } catch (InterruptedException interrupted) { + Thread.currentThread().interrupt(); + throw new IllegalStateException("并发测试事务已中断", interrupted); + } + } + + /** 普通连接只读取已提交的真实实托主键。 */ + private List ids() { + return jdbc.queryForList("SELECT warehouse_pallet_id FROM ll_physical_pallet_guard ORDER BY warehouse_pallet_id", Long.class); + } + + /** 核对共同串行行数量,不访问其他业务表或环境。 */ + private int count() { + return jdbc.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard", Integer.class); + } + + /** 读取数据库实际保留的首次创建时间。 */ + private LocalDateTime createdAt(long id) { + return jdbc.queryForObject("SELECT created_at FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=?", LocalDateTime.class, id); + } + + /** H2 调用的真实插入观测器,仅用于证明去重与数据库执行顺序。 */ + public static class GuardInsertOrderTrigger implements Trigger { + private String observationKey; + + /** 允许 H2 反射创建本测试的数据库触发器。 */ + public GuardInsertOrderTrigger() { } + + /** 将触发器记录隔离到其真实测试数据库。 */ + @Override + public void init(Connection connection, String schema, String name, String table, boolean before, int type) { + observationKey = name; + } + + /** 记录数据库实际执行的插入尝试,不记录 Java Mapper 的调用顺序。 */ + @Override + public void fire(Connection connection, Object[] oldRow, Object[] newRow) { + INSERT_ORDER.get(observationKey).add(((Number) newRow[0]).longValue()); + } + + /** 观测器不持有数据库外资源。 */ + @Override + public void close() { } + + /** 用例结束统一清理观测记录,不在删除触发器时改写业务表。 */ + @Override + public void remove() { } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java new file mode 100644 index 000000000..2e50caf6a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java @@ -0,0 +1,539 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baomidou.mybatisplus.annotation.TableField; +import com.baomidou.mybatisplus.annotation.TableName; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.inventory.entity.InventoryBalance; +import com.baiyue.lastleg.inventory.entity.InventoryPosition; +import com.baiyue.lastleg.inventory.entity.InventoryLedger; +import com.baiyue.lastleg.inventory.mapper.InventoryBalanceMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryPositionMapper; +import com.baiyue.lastleg.inventory.mapper.InventoryLedgerMapper; +import com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper; +import com.baiyue.lastleg.inventory.mapper.PhysicalPalletSerialMapper; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; +import com.baiyue.lastleg.inventory.repository.PhysicalPalletSerialRepository; +import com.baiyue.lastleg.inventory.service.PhysicalPalletSerialService; +import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; +import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; +import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; +import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; +import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory; +import com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelHistoryMapper; +import com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +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.jdbc.datasource.DataSourceUtils; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.transaction.support.TransactionTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.lang.reflect.Modifier; +import java.lang.reflect.Proxy; +import java.time.*; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Consumer; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 实际MyBatis、H2和Spring事务证明仓内调用端与运输使用同一组成冻结边界。 */ +class PdaPalletDispatchFreezePersistenceTest { + private static final LocalDateTime AT = LocalDateTime.of(2026,10,19,12,0); + private static final PdaWarehouseIdentity ACTOR = new PdaWarehouseIdentity("7","picker",88L); + + /** 已排申请A与待组托申请B同目的仓,也不能向A的实托追加。 */ + @Test void dispatchedTargetApplicationBlocksAnotherCustomerBeforePhysicalWrite() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.application(1,"RL-A","100",2001,"PRE_DISPATCH_CREATED"); + f.position(101,"100",501,40,3001,1); f.item(11,1,"100",101L,null,"A-BOX"); + assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","freeze-target")); + assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + + /** 普通入仓/退回目标没有换标申请,同样受正式和有效草稿运输占用保护。 */ + @Test void ordinaryAndReturnedStockClaimsBlockWithoutBorrowingARelabelApplication() throws Exception { + for (boolean returned : List.of(false,true)) { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.position(101,"100",501,40,3001,0); + if (returned) f.db.update("INSERT INTO ll_return_stock_receipt VALUES(1,'100',3001,101)"); + else f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(1,'100',3001,40,101)"); + f.db.update("INSERT INTO ll_dispatch_physical_pallet_lock VALUES(1,40,40,'ACTIVE',99,NULL)"); + assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","ordinary-"+returned)); + assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + } + + /** 统一UTC时钟只忽略已到期草稿;独立逻辑claim也不能漏检。 */ + @Test void draftExpiryUsesBusinessClockAndLogicalClaimsAreChecked() throws Exception { + for (boolean active : List.of(true,false)) { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.db.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,40,'ACTIVE',NULL,?)",active?AT.plusNanos(1_000_000):AT); + if (active) assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","draft-active")); + else { assertEquals("COMPLETED",f.bind("RL-B","draft-expired").status()); assertEquals("TARGET",f.cartonPallet(802)); } + } + } + + /** 原生退货完整执行绑定;原始退货订单即使暂扣仍只锁身份,不新增仓内资格。 */ + @Test void unboundNativeReturnLocksBothActualOrdersAndCompletesBinding() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.db.update("UPDATE ll_order SET detained=1 WHERE id=3002"); + assertEquals("COMPLETED",f.bind("RL-B","native-return").status()); + assertEquals("TARGET",f.cartonPallet(802)); + assertEquals(List.of(2002L,3002L),f.orderLocks.get(Thread.currentThread().getName())); + assertEquals(1L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=40",Long.class)); + } + + /** 合箱后的每张原始退货单均取真实订单锁,不能只锁sourceReceiptId代表的一票。 */ + @Test void mergedNativeCartonKeepsEveryOriginalReturnOrder() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.db.update("INSERT INTO ll_order(id,customer_id) VALUES(3003,'200')"); + f.db.update("INSERT INTO ll_return_order VALUES(3,'200','RT-3',3003)"); + f.db.update("UPDATE ll_return_inventory_carton SET source_return_no='RT-2,RT-3' WHERE id=802"); + assertEquals("COMPLETED",f.bind("RL-B","merged-native").status()); + assertEquals(List.of(2002L,3002L,3003L),f.orderLocks.get(Thread.currentThread().getName())); + } + + /** 已绑定计划按真实物理ID取源,排除同码其他实托、异仓及尚未绑定的计划订单。 */ + @Test void plannedOrdersUsePhysicalBindingAndParentWarehouse() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.planned(101,"100",88,5001,40L,"TARGET"); + f.planned(102,"300",88,5002,41L,"TARGET"); + f.planned(103,"400",99,5003,40L,"TARGET"); + f.planned(104,"500",88,5004,null,"TARGET"); + assertEquals("COMPLETED",f.bind("RL-B","planned-warehouse").status()); + assertEquals(List.of(2002L,3002L,5001L),f.orderLocks.get(Thread.currentThread().getName())); + } + + /** 展示码不同也不能漏掉实际绑定本实托的真实来源订单,身份以最终物理绑定列为准。 */ + @Test void actualPlanBindingIsNotLostWhenPalletCodeDiffers() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.planned(101,"100",88,5001,40L,"ANOTHER-CODE"); + assertEquals("COMPLETED",f.bind("RL-B","planned-physical-id").status()); + assertEquals(List.of(2002L,3002L,5001L),f.orderLocks.get(Thread.currentThread().getName())); + } + + /** 原始订单身份缺失时明确拒绝,不能仅借申请自身的履约订单通过门禁。 */ + @Test void missingNativeOriginalOrderCannotBeSilentlyOmitted() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); + f.db.update("DELETE FROM ll_return_order WHERE id=2"); + assertTrue(assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","missing-original")).getReason().contains("原始订单身份")); + assertEquals("UNBOUND",f.cartonPallet(802)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + + /** 两个不相交申请都预读空托,第二个等待共同锁后必须发现新成员并整笔拒绝。 */ + @Test void concurrentEmptyTargetCannotAcceptAStaleComposition() throws Exception { + var f = new Fixture(); f.nativeSource(1,"RL-A","100",801,3001); f.nativeSource(2,"RL-B","200",802,3002); + var firstInside = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1); var secondAtSerial = new CountDownLatch(1); + f.serialObserver = ids -> { if (Thread.currentThread().getName().equals("second")) secondAtSerial.countDown(); }; + doAnswer(call -> { if ("empty-B".equals(call.getArgument(4))) { firstInside.countDown(); assertTrue(releaseFirst.await(5,TimeUnit.SECONDS)); } return f.transition(call.getArgument(4)); }) + .when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> { Thread.currentThread().setName("first"); return f.bind("RL-B","empty-B"); }); + assertTrue(firstInside.await(5,TimeUnit.SECONDS)); + var second = pool.submit(() -> { Thread.currentThread().setName("second"); return assertThrows(ResponseStatusException.class,() -> f.bind("RL-A","empty-A")); }); + assertTrue(secondAtSerial.await(5,TimeUnit.SECONDS)); assertFalse(second.isDone()); releaseFirst.countDown(); + assertEquals("COMPLETED",first.get(5,TimeUnit.SECONDS).status()); assertTrue(second.get(5,TimeUnit.SECONDS).getReason().contains("组成已变化")); + assertEquals("TARGET",f.cartonPallet(802)); assertEquals("UNBOUND",f.cartonPallet(801)); + } finally { releaseFirst.countDown(); pool.shutdownNow(); } + } + + /** 同一闭包从B或A进入均实际按A、B取锁,不出现B→A反序。 */ + @Test void oppositeSourceApplicationsTakeTheSameRealLockOrder() throws Exception { + var f = new Fixture(); f.application(1,"RL-A","100",2001,"WAIT_PRE_DISPATCH"); f.application(2,"RL-B","200",2002,"WAIT_PRE_DISPATCH"); + f.position(101,"100",501,40,3001,1); f.position(102,"200",502,40,3002,1); + f.item(11,1,"100",101L,null,"A-BOX"); f.item(12,2,"200",102L,null,"B-BOX"); + var start = new CountDownLatch(1); var pool = Executors.newFixedThreadPool(2); + try { + var a = pool.submit(() -> { Thread.currentThread().setName("source-A"); start.await(); f.guard("RL-A"); return true; }); + var b = pool.submit(() -> { Thread.currentThread().setName("source-B"); start.await(); f.guard("RL-B"); return true; }); + start.countDown(); assertTrue(a.get(5,TimeUnit.SECONDS)); assertTrue(b.get(5,TimeUnit.SECONDS)); + assertEquals(List.of("RL-A","RL-B"),f.applicationLocks.get("source-A")); + assertEquals(List.of("RL-A","RL-B"),f.applicationLocks.get("source-B")); + } finally { pool.shutdownNow(); } + } + + /** 箱记录已真实更新后注入失败,业务回滚且远端补偿发生在共同锁仍持有的事务内。 */ + @Test void laterFailureRollsBackNativeBindingAndCompensatesBeforeUnlock() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); var inside = new AtomicBoolean(); + doAnswer(call -> { assertEquals("TARGET",f.cartonPallet(802)); throw new IllegalStateException("绑定历史写入失败"); }) + .when(f.histories).insert(any(PdaRelabelHistory.class)); + doAnswer(call -> { inside.set(TransactionSynchronizationManager.isActualTransactionActive()); assertEquals(1L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard WHERE warehouse_pallet_id=40",Long.class)); return null; }) + .when(f.physical).compensate(eq(88L),any()); + assertThrows(IllegalStateException.class,() -> f.bind("RL-B","rollback")); + assertTrue(inside.get()); assertEquals("UNBOUND",f.cartonPallet(802)); + assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_relabel_pda_history",Long.class)); + assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard",Long.class)); + assertEquals("FAILED_COMPENSATED",f.operationStatus("rollback")); + assertEquals("APPLIED",f.attempts("rollback").get(0).path("receipt").path("operationStatus").asText()); + verify(f.physical).compensate(eq(88L),any()); + } + + /** 普通AVAILABLE库存也不能通过assemble追加到已排车的其他客户实托。 */ + @Test void genericAvailableSourceCannotEnterDispatchedTarget() throws Exception { + for (boolean relabelTarget : List.of(true,false)) { + var f = new Fixture(); + if (relabelTarget) f.application(1,"RL-A","100",2001,"PRE_DISPATCH_CREATED"); + f.position(101,"100",501,40,3001,relabelTarget?1:0); + if (relabelTarget) f.item(11,1,"100",101L,null,"A-BOX"); + f.position(102,"200",502,30,3002,0); + f.db.update("INSERT INTO ll_dispatch_physical_pallet_lock VALUES(1,40,40,'ACTIVE',99,NULL)"); + assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"generic-target")); + assertEquals(1L,f.available(102)); + assertEquals(relabelTarget?1L:0L,f.db.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101",Long.class)); + assertEquals(relabelTarget?0L:1L,f.available(101)); + verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + } + + /** 来源实托已占用时普通移动同样被挡住,空申请闭包不代表可以越过运输claim。 */ + @Test void genericClaimedSourceCannotMoveWithAnEmptyApplicationClosure() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); + f.db.update("INSERT INTO ll_dispatch_pallet_lock VALUES(1,30,'ACTIVE',99,NULL)"); + assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"generic-source")); + assertEquals(1L,f.available(102)); verify(f.physical,never()).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } + + /** 没有关联申请、没有运输claim的普通组托继续执行真实库存和流水,不增加暂扣资格。 */ + @Test void genericEmptyApplicationClosureCompletesOrdinaryAssemble() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); + f.db.update("UPDATE ll_order SET detained=1 WHERE id=3002"); + assertEquals("COMPLETED",f.assemble("SOURCE",102,"generic-success").status()); + assertEquals(0L,f.available(102)); assertEquals(1L,f.targetAvailable()); + assertEquals(2L,f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)); + assertFalse(f.applicationLocks.containsKey(Thread.currentThread().getName())); + assertEquals(List.of(3002L),f.orderLocks.get(Thread.currentThread().getName())); + } + + /** 两个空申请闭包竞争空目标,第二个在共同锁后看到新普通位置和订单必须重试。 */ + @Test void concurrentGenericEmptyTargetRejectsNewOrdinaryMember() throws Exception { + var f = new Fixture(); f.position(101,"100",501,30,3001,0); f.position(102,"200",502,50,3002,0); + var firstInside = new CountDownLatch(1); var releaseFirst = new CountDownLatch(1); var secondAtSerial = new CountDownLatch(1); + f.serialObserver = ids -> { if (Thread.currentThread().getName().equals("generic-second")) secondAtSerial.countDown(); }; + doAnswer(call -> { + if (Thread.currentThread().getName().equals("generic-first") && firstInside.getCount()>0) { firstInside.countDown(); assertTrue(releaseFirst.await(5,TimeUnit.SECONDS)); } + return f.transition(call.getArgument(1),call.getArgument(2),call.getArgument(4)); + }).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + var pool = Executors.newFixedThreadPool(2); + try { + var first = pool.submit(() -> { Thread.currentThread().setName("generic-first"); return f.assemble("SOURCE",101,"generic-empty-A"); }); + assertTrue(firstInside.await(5,TimeUnit.SECONDS)); + var second = pool.submit(() -> { Thread.currentThread().setName("generic-second"); return assertThrows(ResponseStatusException.class,() -> f.assemble("OTHER",102,"generic-empty-B")); }); + assertTrue(secondAtSerial.await(5,TimeUnit.SECONDS)); assertFalse(second.isDone()); releaseFirst.countDown(); + assertEquals("COMPLETED",first.get(5,TimeUnit.SECONDS).status()); assertTrue(second.get(5,TimeUnit.SECONDS).getReason().contains("组成已变化")); + assertEquals(0L,f.available(101)); assertEquals(1L,f.available(102)); assertEquals(1L,f.targetAvailable()); + verify(f.physical,times(2)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + } finally { releaseFirst.countDown(); pool.shutdownNow(); } + } + + /** 普通库存已真实移入目标后失败,两次远端补偿都在共同锁事务内,库存和流水随后回滚。 */ + @Test void genericFailureCompensatesBeforeUnlockAndRollsBackInventory() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); var inside = new AtomicBoolean(); + doAnswer(call -> { assertEquals(0L,f.available(102)); assertEquals(1L,f.targetAvailable()); throw new IllegalStateException("普通组托历史失败"); }) + .when(f.operationHistory).insert(any(PdaOperationHistory.class)); + doAnswer(call -> { inside.set(TransactionSynchronizationManager.isActualTransactionActive()); assertEquals(2L,f.db.queryForObject("SELECT COUNT(*) FROM ll_physical_pallet_guard",Long.class)); return null; }) + .when(f.physical).compensate(eq(88L),any()); + assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"generic-rollback")); + assertTrue(inside.get()); assertEquals(1L,f.available(102)); assertEquals(0L,f.targetAvailable()); + assertEquals(0L,f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)); + assertEquals("FAILED_COMPENSATED",f.operationStatus("generic-rollback")); + assertEquals(2,f.attempts("generic-rollback").size()); + verify(f.physical,times(2)).compensate(eq(88L),any()); + } + + /** 首次POST已产生远端效果但响应丢失时,没有已知回执绝不等于全部已补偿。 */ + @Test void firstApplyCommittedWithoutReceiptRemainsUnresolvedAndOriginalKeyCannotReplay() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); var effects = new ConcurrentHashMap(); + doAnswer(call -> { PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); effects.put(pallet.palletId(),call.getArgument(2)); throw new IllegalStateException("远端已提交但响应丢失"); }) + .when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"unknown-first")); + assertEquals("IDLE",effects.get(30L)); assertEquals("COMPENSATION_REQUIRED",f.operationStatus("unknown-first")); + var attempts=f.attempts("unknown-first"); assertEquals(1,attempts.size()); assertTrue(attempts.get(0).path("receipt").isNull()); + assertEquals(30L,attempts.get(0).path("palletId").asLong()); assertFalse(attempts.get(0).path("operationKey").asText().isBlank()); + assertEquals(1L,f.available(102)); assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"unknown-first")); + verify(f.physical,times(1)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,never()).compensate(anyLong(),any()); + } + + /** 已知第一项补偿成功,不能抹去第二次已提交但未回执的实托调用。 */ + @Test void laterUnknownApplyCannotBeClassifiedCompensatedByRestoringEarlierReceipt() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); var effects = new ConcurrentHashMap(); var calls = new AtomicInteger(); + doAnswer(call -> { + PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); String status=call.getArgument(2); effects.put(pallet.palletId(),status); + if (calls.incrementAndGet()==2) throw new IllegalStateException("第二个实托已提交但回执解析失败"); + return f.transition(pallet,status,call.getArgument(4)); + }).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + doAnswer(call -> { assertTrue(TransactionSynchronizationManager.isActualTransactionActive()); PalletPhysicalGateway.Transition receipt=call.getArgument(1); effects.put(receipt.palletId(),receipt.beforeStatus()); return null; }) + .when(f.physical).compensate(anyLong(),any()); + assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"unknown-later")); + assertEquals("OCCUPIED",effects.get(30L)); assertEquals("OCCUPIED",effects.get(40L)); + assertEquals("COMPENSATION_REQUIRED",f.operationStatus("unknown-later")); + var attempts=f.attempts("unknown-later"); assertEquals(2,attempts.size()); + assertEquals("APPLIED",attempts.get(0).path("receipt").path("operationStatus").asText()); assertTrue(attempts.get(1).path("receipt").isNull()); + assertEquals(40L,attempts.get(1).path("palletId").asLong()); assertEquals(1L,f.available(102)); + assertThrows(ResponseStatusException.class,() -> f.assemble("SOURCE",102,"unknown-later")); + verify(f.physical,times(2)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,times(1)).compensate(eq(88L),any()); + } + + /** 原生无来源托盘分支同样持久保留已发未确认事实,原箱指针回滚且原键不再发写。 */ + @Test void nativeReturnUnknownApplyPersistsPendingAttemptAndBlocksOriginalKey() throws Exception { + var f = new Fixture(); f.nativeSource(2,"RL-B","200",802,3002); var committed = new AtomicBoolean(); + doAnswer(call -> { committed.set(true); throw new IllegalStateException("退货实托已提交但响应丢失"); }) + .when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,() -> f.bind("RL-B","native-unknown")); + assertTrue(committed.get()); assertEquals("UNBOUND",f.cartonPallet(802)); assertEquals("COMPENSATION_REQUIRED",f.operationStatus("native-unknown")); + var attempt=f.attempts("native-unknown").get(0); assertEquals("native-unknown",attempt.path("operationKey").asText()); assertTrue(attempt.path("receipt").isNull()); + assertThrows(ResponseStatusException.class,() -> f.bind("RL-B","native-unknown")); + verify(f.physical,times(1)).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); verify(f.physical,never()).compensate(anyLong(),any()); + } + + /** 空、身份不符以及已补偿的旧回执都不能冒充本次有效APPLIED成功。 */ + @Test void invalidOrCompensatedReceiptNeverAdvancesAndRemainsPending() throws Exception { + for (String kind : List.of("NULL","OTHER_KEY","COMPENSATED")) { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); + doAnswer(call -> { + if (kind.equals("NULL")) return null; + PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); + return new PalletPhysicalGateway.Transition(kind.equals("OTHER_KEY")?"different-key":call.getArgument(4),pallet.palletId(),pallet.palletCode(),88, + pallet.status(),call.getArgument(2),pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,kind.equals("COMPENSATED")?"COMPENSATED":"APPLIED",true); + }).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,() -> f.assemble("SOURCE",102,"invalid-"+kind)); + assertEquals("COMPENSATION_REQUIRED",f.operationStatus("invalid-"+kind)); assertTrue(f.attempts("invalid-"+kind).get(0).path("receipt").isNull()); + assertEquals(1L,f.available(102)); assertEquals(0L,f.targetAvailable()); verify(f.physical,never()).compensate(anyLong(),any()); + } + } + + /** Warehouse的APPLIED精确重放仍是有效回执,不能凭replayed标记拒绝合法成功。 */ + @Test void matchingAppliedReplayReceiptRemainsValid() throws Exception { + var f = new Fixture(); f.position(102,"200",502,30,3002,0); + doAnswer(call -> { + PalletPhysicalGateway.PalletReference pallet=call.getArgument(1); + return new PalletPhysicalGateway.Transition(call.getArgument(4),pallet.palletId(),pallet.palletCode(),88,pallet.status(),call.getArgument(2), + pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,"APPLIED",true); + }).when(f.physical).apply(anyLong(),any(),anyString(),any(),anyString(),anyString()); + assertEquals("COMPLETED",f.assemble("SOURCE",102,"applied-replay").status()); + assertEquals("COMPLETED",f.operationStatus("applied-replay")); assertEquals(1L,f.targetAvailable()); + assertTrue(f.attempts("applied-replay").get(0).path("receipt").path("replayed").asBoolean()); + } + + /** 隔离数据库保留实际Mapper、事务和共享串行提供者,仅远端资源使用受控替身。 */ + static class Fixture { + final JdbcTemplate db; + final PdaPalletOperationService service = new PdaPalletOperationService(); + final PalletPhysicalGateway physical = mock(PalletPhysicalGateway.class); + final PdaRelabelHistoryMapper histories; + final PdaWarehouseMapper operationHistory; + final RelabelApplicationMapper applications; + final TransactionTemplate tx; + final String baseline; + final Map> applicationLocks = new ConcurrentHashMap<>(); + final Map> orderLocks = new ConcurrentHashMap<>(); + volatile Consumer> serialObserver = ids -> { }; + + /** 构建实际数据映射与Spring事务;测试表只服务本次隔离用例。 */ + Fixture() throws Exception { + // 底层连接故意默认RR;完整作业入口必须显式切到RC,不能借H2默认RC通过。 + var ds = new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa","") { + /** 每条新连接模拟目标MySQL默认RR。 */ + @Override public java.sql.Connection getConnection() throws java.sql.SQLException { + var connection = super.getConnection(); connection.setTransactionIsolation(java.sql.Connection.TRANSACTION_REPEATABLE_READ); return connection; + } + }; + db = new JdbcTemplate(ds); + baseline = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); + for (var type : List.of(RelabelApplication.class,RelabelItem.class,ReturnInventoryCarton.class,InventoryPosition.class,InventoryBalance.class,InventoryLedger.class,PdaPalletOperation.class,PdaRelabelTask.class,PdaRelabelHistory.class,PdaOperationHistory.class)) createEntity(type); + createColumns("ll_order",List.of("id","customer_id","detained")); + createColumns("ll_inbound_stock_receipt",List.of("id","customer_id","order_id","warehouse_pallet_id","inventory_position_id")); + createColumns("ll_return_stock_receipt",List.of("id","customer_id","order_id","inventory_position_id")); + createColumns("ll_inbound_plan",List.of("id","customer_id","overseas_warehouse_id")); + createColumns("ll_inbound_plan_pallet",List.of("id","customer_id","plan_id","pallet_no","warehouse_pallet_id")); + createColumns("ll_inbound_plan_pallet_allocation",List.of("pallet_id","line_id","customer_id","plan_id")); + createColumns("ll_inbound_plan_line",List.of("id","customer_id","order_id","plan_id")); + createColumns("ll_return_order",List.of("id","customer_id","return_no","lastleg_order_id")); + createColumns("ll_dispatch_physical_pallet_lock",List.of("id","warehouse_pallet_id","active_warehouse_pallet_id","status","dispatch_order_id","expires_at")); + createColumns("ll_dispatch_pallet_lock",List.of("id","warehouse_pallet_id","status","dispatch_order_id","expires_at")); + createColumns("ll_physical_pallet_guard",List.of("warehouse_pallet_id","created_at")); + createColumns("ll_warehouse_transfer_inventory_allocation",List.of("customer_id","inventory_position_id","transfer_order_id","package_count","status")); + var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + for (var type : List.of(PdaPalletOperationMapper.class,PhysicalPalletSerialMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,ReturnInventoryCartonMapper.class,PdaRelabelTaskMapper.class,PdaRelabelHistoryMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryCommandMapper.class,PdaWarehouseMapper.class)) configuration.addMapper(type); + var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); + var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); + PdaPalletOperationMapper actual = sessions.getMapper(PdaPalletOperationMapper.class); + PdaPalletOperationMapper traced = (PdaPalletOperationMapper)Proxy.newProxyInstance(getClass().getClassLoader(),new Class[]{PdaPalletOperationMapper.class},(proxy,method,args) -> { + if (method.getName().equals("lockMutationApplication")) applicationLocks.computeIfAbsent(Thread.currentThread().getName(),key -> new CopyOnWriteArrayList<>()).add((String)args[0]); + if (method.getName().equals("lockMutationOrder")) orderLocks.computeIfAbsent(Thread.currentThread().getName(),key -> new CopyOnWriteArrayList<>()).add((Long)args[1]); + try { return method.invoke(actual,args); } catch (java.lang.reflect.InvocationTargetException failure) { throw failure.getCause(); } + }); + var manager = new DataSourceTransactionManager(ds); tx = new TransactionTemplate(manager); tx.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + var serial = new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(sessions.getMapper(PhysicalPalletSerialMapper.class))); + PhysicalPalletSerialPort observedSerial = ids -> { + try { assertEquals(java.sql.Connection.TRANSACTION_READ_COMMITTED,DataSourceUtils.getConnection(ds).getTransactionIsolation()); } + catch (java.sql.SQLException failure) { throw new IllegalStateException(failure); } + serialObserver.accept(ids); serial.lock(ids); + }; + applications = sessions.getMapper(RelabelApplicationMapper.class); histories = spy(sessions.getMapper(PdaRelabelHistoryMapper.class)); + operationHistory = spy(sessions.getMapper(PdaWarehouseMapper.class)); + var historyService = new PdaWarehouseService(); ReflectionTestUtils.setField(historyService,"mapper",operationHistory); + ReflectionTestUtils.setField(service,"mapper",traced); ReflectionTestUtils.setField(service,"mutationRepository",new PdaPalletOperationRepository(traced)); + ReflectionTestUtils.setField(service,"physicalPalletSerial",observedSerial); ReflectionTestUtils.setField(service,"physical",physical); + ReflectionTestUtils.setField(service,"relabelApplications",applications); ReflectionTestUtils.setField(service,"relabelItems",sessions.getMapper(RelabelItemMapper.class)); + ReflectionTestUtils.setField(service,"returnCartons",sessions.getMapper(ReturnInventoryCartonMapper.class)); + ReflectionTestUtils.setField(service,"positions",sessions.getMapper(InventoryPositionMapper.class)); ReflectionTestUtils.setField(service,"balances",sessions.getMapper(InventoryBalanceMapper.class)); + ReflectionTestUtils.setField(service,"ledgers",sessions.getMapper(InventoryLedgerMapper.class)); ReflectionTestUtils.setField(service,"inventoryCommands",sessions.getMapper(InventoryCommandMapper.class)); + ReflectionTestUtils.setField(service,"history",historyService); + ReflectionTestUtils.setField(service,"relabelWorkTasks",sessions.getMapper(PdaRelabelTaskMapper.class)); ReflectionTestUtils.setField(service,"relabelWorkHistory",histories); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper().findAndRegisterModules()); ReflectionTestUtils.setField(service,"mutationClock",Clock.fixed(AT.toInstant(ZoneOffset.UTC),ZoneOffset.UTC)); + service.configureTransactions(manager); + when(physical.resolve("TARGET")).thenReturn(target()); + when(physical.resolve("SOURCE")).thenReturn(new PalletPhysicalGateway.PalletReference(30,"SOURCE",88,null,"OCCUPIED","SCK8",0)); + when(physical.resolve("OTHER")).thenReturn(new PalletPhysicalGateway.PalletReference(50,"OTHER",88,null,"OCCUPIED","SCK8",0)); + when(physical.apply(anyLong(),any(),anyString(),any(),anyString(),anyString())).thenAnswer(call -> transition(call.getArgument(1),call.getArgument(2),call.getArgument(4))); + } + + /** 实体仅决定需映射的列集合,列名和类型必须实际存在于当前统一DDL。 */ + private void createEntity(Class type) { + var columns = new ArrayList(); + for (var field : type.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) continue; + var mapping = field.getAnnotation(TableField.class); if (mapping != null && !mapping.exist()) continue; + String name = mapping != null && !mapping.value().isBlank() ? mapping.value() : field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT); + columns.add(name); + } + createColumns(type.getAnnotation(TableName.class).value(),columns); + } + + /** 从真实DDL提取所需普通列,支持同一行多列;隔离表只省略与本专项无关的约束。 */ + private void createColumns(String table,List requested) { + var block = Pattern.compile("(?is)CREATE\\s+TABLE\\s+(?:IF\\s+NOT\\s+EXISTS\\s+)?`?"+Pattern.quote(table)+"`?\\s*\\((.*?)\\)\\s*ENGINE").matcher(baseline); + assertTrue(block.find(),"当前基线缺表:"+table); + String body = block.group(1).replaceAll("'(?:''|[^'])*'"," "); + var columns = new LinkedHashMap(); + var matcher = Pattern.compile("(?i)(?:\\A|,)\\s*`?([a-z_][a-z0-9_]*)`?\\s+(BIGINT|TINYINT|SMALLINT|INTEGER|INT|VARCHAR|CHAR|DECIMAL|NUMERIC|DATETIME|TIMESTAMP|DATE|LONGTEXT|TEXT|JSON|BOOLEAN|DOUBLE|FLOAT|BIT)(\\s*\\(\\s*\\d+(?:\\s*,\\s*\\d+)?\\s*\\))?").matcher(body); + while (matcher.find()) { + String type = matcher.group(2).toUpperCase(Locale.ROOT)+Objects.toString(matcher.group(3),""); + // H2隔离表保留JSON列名,以文本承载既有Jackson字符串,不验证MySQL JSON存储行为。 + if (type.equals("JSON")) type="LONGTEXT"; + columns.put(matcher.group(1).toLowerCase(Locale.ROOT),type); + } + var definitions = new ArrayList(); + for (String column : requested) { + assertTrue(columns.containsKey(column),"测试列不存在于当前真实DDL:"+table+"."+column); + String definition = column+" "+columns.get(column); + if (column.equals("id") || table.equals("ll_physical_pallet_guard") && column.equals("warehouse_pallet_id")) definition += " PRIMARY KEY"; + if (column.equals("created_at") && table.equals("ll_physical_pallet_guard")) definition += " DEFAULT CURRENT_TIMESTAMP(3) NOT NULL"; + if (column.equals("detained") && table.equals("ll_order")) definition += " DEFAULT 0"; + definitions.add(definition); + } + db.execute("CREATE TABLE "+table+" ("+String.join(",",definitions)+")"); + } + + /** 建立真实申请及其独立履约订单身份。 */ + void application(long id,String no,String customer,long order,String fulfillment) { + db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); + db.update("INSERT INTO ll_relabel_application(id,relabel_no,customer_id,overseas_warehouse_id,lastleg_order_id,status,fulfillment_status) VALUES(?,?,?,'88',?,'READY_FOR_OUTBOUND',?)",id,no,customer,order,fulfillment); + } + + /** 构造原始退货订单、实收箱和无来源托盘的换标明细。 */ + void nativeSource(long id,String no,String customer,long carton,long originalOrder) { + application(id,no,customer,2000+id,"WAIT_PRE_DISPATCH"); + db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",originalOrder,customer); + db.update("INSERT INTO ll_return_order VALUES(?,?,?,?)",id,customer,"RT-"+id,originalOrder); + db.update("INSERT INTO ll_return_inventory_carton(id,customer_id,overseas_warehouse_id,source_receipt_id,source_return_no,pallet_code,carton_mark,relabel_application_id,status,total_quantity,destination_warehouse_code) VALUES(?,?,'88',?,?, 'UNBOUND',?,?,'RELABEL_PENDING',2,'SCK8')",carton,customer,500+id,"RT-"+id,"BOX-"+id,id); + item(10+id,id,customer,null,carton,"BOX-"+id); + db.update("INSERT INTO ll_relabel_pda_task(id,application_id,customer_id,status) VALUES(?,?,?,'COMPLETED')",900+id,id,customer); + } + + /** 使用最终基线物理绑定列准备计划,分配链同时保留plan_id和customer_id。 */ + void planned(long plan,String customer,long warehouse,long order,Long physicalPalletId,String palletCode) { + db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); + db.update("INSERT INTO ll_inbound_plan(id,customer_id,overseas_warehouse_id) VALUES(?,?,?)",plan,customer,warehouse); + db.update("INSERT INTO ll_inbound_plan_pallet(id,customer_id,plan_id,pallet_no,warehouse_pallet_id) VALUES(?,?,?,?,?)",plan+1000,customer,plan,palletCode,physicalPalletId); + db.update("INSERT INTO ll_inbound_plan_line(id,customer_id,order_id,plan_id) VALUES(?,?,?,?)",plan+2000,customer,order,plan); + db.update("INSERT INTO ll_inbound_plan_pallet_allocation(pallet_id,line_id,customer_id,plan_id) VALUES(?,?,?,?)",plan+1000,plan+2000,customer,plan); + } + + /** 真实库存余额保留其原始订单,跨客户位置各有独立行。 */ + void position(long id,String customer,long balance,long pallet,long order,long held) { + db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,order); + String code = pallet==40?"TARGET":pallet==30?"SOURCE":"OTHER"; + db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,pallet_id,pallet_no,batch_no,destination_warehouse_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,?,?,'88',?,?,'TEST','SCK8',?,?,?,?,?,0)",id,customer,balance,pallet,code,held==0?1:0,held,0,0,0); + } + + /** 明细原始订单从真实余额身份取得,原生退货保持其独立箱身份。 */ + void item(long id,long app,String customer,Long position,Long carton,String mark) { + Long order = position == null ? null : db.queryForObject("SELECT b.inbound_order_id FROM ll_inventory_balance b JOIN ll_inventory_position p ON p.balance_id=b.id WHERE p.id=?",Long.class,position); + db.update("INSERT INTO ll_relabel_item(id,application_id,customer_id,source_inventory_position_id,return_carton_id,carton_mark,notified_package_count,source_inbound_order_id,destination_fba_warehouse_code) VALUES(?,?,?,?,?,?,1,?,'SCK8')",id,app,customer,position,carton,mark,order); + } + + /** 执行现有原生退货入口,不绕开三个分支共用的生产门禁。 */ + com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation bind(String no,String key) { + var app = applications.findAnyByRelabelNo(no); + String mark = db.queryForObject("SELECT carton_mark FROM ll_relabel_item WHERE application_id=?",String.class,app.getId()); + return service.relabelDisassemble(ACTOR,new PalletOperationRequests.RelabelDisassemble(key,no,null,new PalletOperationRequests.PalletExpectation("TARGET",0),List.of(mark),"TEST合成资料")); + } + + /** 在真实事务内单独观察生产门禁的全局锁序。 */ + void guard(String no) { tx.executeWithoutResult(status -> ReflectionTestUtils.invokeMethod(service,"lockMutationComposition",ACTOR,applications.findAnyByRelabelNo(no),Map.of("TARGET",target()))); } + + /** 普通组托通过公开既有服务入口执行,不伪造换标申请。 */ + com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation assemble(String source,long position,String key) { + return service.assemble(ACTOR,new PalletOperationRequests.Assemble(key,new PalletOperationRequests.PalletExpectation("TARGET",0), + List.of(new PalletOperationRequests.PalletExpectation(source,0)),List.of(new PalletOperationRequests.MoveLine(position,1,0)),"TEST合成资料")); + } + + /** 读取真实位置当前可用量。 */ + long available(long id) { return Objects.requireNonNull(db.queryForObject("SELECT available_packages FROM ll_inventory_position WHERE id=?",Long.class,id)); } + + /** 目标托全部客户贡献合计只用于测试核对。 */ + long targetAvailable() { return Objects.requireNonNull(db.queryForObject("SELECT COALESCE(SUM(available_packages),0) FROM ll_inventory_position WHERE pallet_id=40",Long.class)); } + + /** 读取真实独立意图行的最终分类,不从抛出的异常或Mock次数推定恢复成功。 */ + String operationStatus(String key) { return db.queryForObject("SELECT status FROM ll_pda_pallet_operation WHERE idempotency_key=?",String.class,key); } + + /** 解析实际落盘的唯一attempt结构,确认未回执事实没有随业务回滚消失。 */ + com.fasterxml.jackson.databind.JsonNode attempts(String key) throws Exception { + return new ObjectMapper().readTree(db.queryForObject("SELECT remote_transitions_json FROM ll_pda_pallet_operation WHERE idempotency_key=?",String.class,key)); + } + + /** 读取提交后的箱绑定,亦可在当前事务内验证回滚注入点。 */ + String cartonPallet(long id) { return db.queryForObject("SELECT pallet_code FROM ll_return_inventory_carton WHERE id=?",String.class,id); } + + /** 目标来自受控的真实资源接口回执,未伪造来源仓位。 */ + PalletPhysicalGateway.PalletReference target() { return new PalletPhysicalGateway.PalletReference(40,"TARGET",88,null,"OCCUPIED","SCK8",0); } + + /** 仅模拟跨服务响应,库存、明细、历史及本地锁仍走实际Mapper。 */ + PalletPhysicalGateway.Transition transition(String key) { return new PalletPhysicalGateway.Transition(key,40,"TARGET",88,"OCCUPIED","OCCUPIED",null,null,0,1,"APPLIED",false); } + + /** 按本次实际调用实托返回受控远端回执,不把目标托回执借给来源托。 */ + PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet,String status,String key) { + return new PalletPhysicalGateway.Transition(key,pallet.palletId(),pallet.palletCode(),pallet.warehouseId(),pallet.status(),status,pallet.locationId(),pallet.locationId(),pallet.version(),pallet.version()+1,"APPLIED",false); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index 9fbfc5254..cdc8e8b34 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -10,6 +10,8 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; +import com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort; import com.baiyue.lastleg.palletops.model.PalletInventoryRow; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.warehouse.service.PdaWarehouseService; @@ -59,6 +61,8 @@ class PdaPalletOperationServiceTest { assertThrows(ResponseStatusException.class, () -> ReflectionTestUtils.invokeMethod(service, "requireUnallocatedRelabel", app)); } @Mock private PdaPalletOperationMapper mapper; + @Mock private PdaPalletOperationRepository mutationRepository; + @Mock private PhysicalPalletSerialPort physicalPalletSerial; @Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Mock private InventoryPositionMapper positions; @Mock private InventoryBalanceMapper balances; @@ -77,6 +81,19 @@ class PdaPalletOperationServiceTest { service = new PdaPalletOperationService(); ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class)); ReflectionTestUtils.setField(service, "mapper", mapper); + ReflectionTestUtils.setField(service, "mutationRepository", mutationRepository); + ReflectionTestUtils.setField(service, "physicalPalletSerial", physicalPalletSerial); + // 既有单元测试只模拟本申请;真实全客户组成、SQL锁及竞争由持久化专项证明。 + lenient().when(mutationRepository.composition(anyString(), anyMap(), any())).thenAnswer(call -> { + java.util.Map pallets = call.getArgument(1); + RelabelApplication application = call.getArgument(2); + return new PdaPalletOperationRepository.Composition(application == null ? List.of() : List.of(application),List.of(),pallets.entrySet().stream() + .map(entry -> new PdaPalletOperationMapper.MutationPallet(entry.getKey(),entry.getValue())).toList(),List.of()); + }); + lenient().when(mutationRepository.lockApplication(anyString())).thenReturn(true); + lenient().when(relabelItems.selectById(anyLong())).thenAnswer(call -> relabelItems.selectList( + new com.baomidou.mybatisplus.core.conditions.query.QueryWrapper()).stream() + .filter(item -> java.util.Objects.equals(item.getId(),call.getArgument(0))).findFirst().orElse(null)); ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "positions", positions); ReflectionTestUtils.setField(service, "balances", balances); @@ -118,9 +135,12 @@ class PdaPalletOperationServiceTest { ReflectionTestUtils.setField(service, "relabelWorkTasks", workTasks); ReflectionTestUtils.setField(service, "relabelWorkHistory", workHistory); var app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); + app.setRelabelNo("RL-1"); app.setStatus("READY_FOR_OUTBOUND"); app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); app.setOverseasWarehouseId("88"); var task = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelTask(); task.setId(700L); when(workTasks.find("100", 600L)).thenReturn(task); var item = new RelabelItem(); item.setReturnCartonId(801L); item.setCartonMark("BOX-1"); + item.setId(701L); item.setApplicationId(600L); item.setCustomerId("100"); + doReturn(item).when(relabelItems).selectById(701L); item.setDestinationFbaWarehouseCode("SCK8"); item.setNotifiedPackageCount(1); var box = new ReturnInventoryCarton(); box.setId(801L); box.setCustomerId("100"); box.setPalletCode("SOURCE"); box.setRelabelApplicationId(600L); box.setStatus("RELABEL_PENDING"); @@ -133,6 +153,7 @@ class PdaPalletOperationServiceTest { var source = new PalletPhysicalGateway.PalletReference(20L, "SOURCE", 88L, 1020L, "OCCUPIED", "SCK8", 1); var target = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, 1010L, "OCCUPIED", "SCK8", 2); when(returnCartons.lockId(801L)).thenReturn(box); + when(returnCartons.selectById(801L)).thenReturn(box); when(returnCartons.palletCartons("88", "TARGET")).thenAnswer(call -> "TARGET".equals(box.getPalletCode()) ? List.of(existing, box) : List.of(existing)); var request = new PalletOperationRequests.RelabelDisassemble("rebind-current", "RL-1", @@ -154,6 +175,11 @@ class PdaPalletOperationServiceTest { new PalletPhysicalGateway.LocationReference(1010L, "TARGET-LOC", "STORAGE", "ACTIVE")); when(physical.resolve("SOURCE")).thenReturn(source); when(physical.resolve("TARGET")).thenReturn(target); + AtomicReference operation = new AtomicReference<>(); + doAnswer(call -> { operation.set(call.getArgument(0)); return 1; }).when(mapper).insert(any(PdaPalletOperation.class)); + when(mapper.selectById(anyLong())).thenAnswer(call -> operation.get()); + when(physical.apply(eq(88L),eq(target),eq("OCCUPIED"),eq(1010L),eq("rebind-current"),anyString())) + .thenReturn(transition(target,"OCCUPIED","rebind-current")); var result = (com.baiyue.lastleg.palletops.dto.PalletOperationResponses.Operation) ReflectionTestUtils.invokeMethod(service, "bindReturnRelabelPallet", identity(), app, List.of(item), source, target, request, false); @@ -324,7 +350,7 @@ class PdaPalletOperationServiceTest { .when(mapper).insert(any(PdaPalletOperation.class)); when(mapper.selectById(anyLong())).thenAnswer(invocation -> operation.get()); when(physical.apply(eq(88L), any(), anyString(), anyLong(), anyString(), any())) - .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4))); var request = new PalletOperationRequests.Assemble("idem-1", new PalletOperationRequests.PalletExpectation("TARGET", 2), @@ -399,7 +425,7 @@ class PdaPalletOperationServiceTest { when(physical.resolve("SOURCE")).thenReturn(sourcePallet); when(physical.resolve("TARGET")).thenReturn(targetPallet); when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any())) - .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4))); RelabelApplication application = new RelabelApplication(); application.setId(600L); application.setCustomerId("100"); application.setRelabelNo("RL-1"); application.setStatus("READY_FOR_OUTBOUND"); @@ -489,7 +515,7 @@ class PdaPalletOperationServiceTest { when(relabelApplications.lockAnyByRelabelNo("RL-1")).thenReturn(app); RelabelItem item = relabelItem(701L, "CARTON-A", 101L); when(relabelItems.selectList(any())).thenReturn(List.of(item)); - when(relabelItems.selectById(701L)).thenReturn(item); + doReturn(item).when(relabelItems).selectById(701L); InventoryPosition anchor = position(101L, 30L, "ACCOUNTING", 2L, 3L); InventoryPosition actual = position(100L, 20L, "SOURCE", 0L, 4L); actual.setAvailablePackages(5L); AtomicReference target = new AtomicReference<>(); @@ -506,7 +532,7 @@ class PdaPalletOperationServiceTest { var dst = new PalletPhysicalGateway.PalletReference(10L, "TARGET", 88L, null, "IDLE", null, 2); when(physical.resolve("SOURCE")).thenReturn(src); when(physical.resolve("TARGET")).thenReturn(dst); when(physical.apply(eq(88L), any(), anyString(), isNull(), anyString(), any())) - .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2))); + .thenAnswer(invocation -> transition(invocation.getArgument(1), invocation.getArgument(2),invocation.getArgument(4))); when(mapper.content("88", 10L)).thenReturn(List.of()); when(mapper.palletPackages("88", 20L)).thenAnswer(invocation -> actual.getAvailablePackages() + actual.getHoldPackages()); when(mapper.palletPackages("88", 10L)).thenAnswer(invocation -> target.get() == null ? 0L : target.get().getHoldPackages()); @@ -620,8 +646,8 @@ class PdaPalletOperationServiceTest { id == 20L ? "IDLE" : "OCCUPIED", null, version); } private static PalletPhysicalGateway.Transition transition(PalletPhysicalGateway.PalletReference pallet, - String targetStatus) { - return new PalletPhysicalGateway.Transition("op-" + pallet.palletId(), pallet.palletId(), pallet.palletCode(), + String targetStatus,String key) { + return new PalletPhysicalGateway.Transition(key, pallet.palletId(), pallet.palletCode(), pallet.warehouseId(), pallet.status(), targetStatus, pallet.locationId(), pallet.locationId(), pallet.version(), pallet.version() + 1, "APPLIED", false); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java new file mode 100644 index 000000000..1d7f46504 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourcePersistenceTest.java @@ -0,0 +1,553 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.mapper.DispatchItemSourceMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService; +import com.baiyue.lastleg.relabel.mapper.RelabelDispatchSourceMapper; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; +import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.Snapshot; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +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.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; + +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Proxy; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** 隔离 H2 中执行真实 Mapper,验证闭包、箱数、轮次和调用方事务锁。 */ +class RelabelDispatchSourcePersistenceTest { + private JdbcTemplate jdbc; + private RelabelDispatchSourceService service; + private DataSourceTransactionManager transactionManager; + private TransactionTemplate transaction; + private final List lockCalls = new ArrayList<>(); + + /** 创建最小业务表并装配实际 MyBatis Mapper 与事务管理器。 */ + @BeforeEach + void prepare() throws Exception { + var dataSource = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute(""" + CREATE TABLE ll_relabel_application ( + id BIGINT PRIMARY KEY, customer_id VARCHAR(40), relabel_no VARCHAR(60) UNIQUE, + lastleg_order_id BIGINT, overseas_warehouse_id VARCHAR(40), delivery_method VARCHAR(40), + status VARCHAR(40), fulfillment_status VARCHAR(40), updated_at TIMESTAMP, + customer_confirmed_at TIMESTAMP) + """); + jdbc.execute(""" + CREATE TABLE ll_relabel_item ( + id BIGINT PRIMARY KEY, application_id BIGINT, customer_id VARCHAR(40), + notified_package_count INT, total_package_count INT, carton_mark VARCHAR(80), + operation_type VARCHAR(40), sku_relabel_items_json VARCHAR(4000), return_carton_id BIGINT, + source_inventory_balance_id BIGINT, source_inventory_position_id BIGINT, + source_inbound_order_id BIGINT, source_inbound_order_no VARCHAR(80), source_inbound_code VARCHAR(80)) + """); + jdbc.execute(""" + CREATE TABLE ll_inventory_position ( + id BIGINT PRIMARY KEY, customer_id VARCHAR(40), balance_id BIGINT, + overseas_warehouse_id VARCHAR(40), pallet_id BIGINT, pallet_no VARCHAR(80), + available_packages BIGINT, hold_packages BIGINT, allocated_packages BIGINT, + picked_packages BIGINT, receiving_packages BIGINT, version BIGINT) + """); + jdbc.execute(""" + CREATE TABLE ll_relabel_pda_task ( + id BIGINT PRIMARY KEY, application_id BIGINT, customer_id VARCHAR(40), + relabel_no VARCHAR(60), overseas_warehouse_id BIGINT, status VARCHAR(40), + reviewed_at TIMESTAMP, version INT) + """); + jdbc.execute(""" + CREATE TABLE ll_relabel_pda_history ( + id BIGINT PRIMARY KEY, task_id BIGINT, application_id BIGINT, customer_id VARCHAR(40), + action VARCHAR(40), detail_json VARCHAR(4000), created_at TIMESTAMP) + """); + jdbc.execute(""" + CREATE TABLE ll_relabel_pda_unit_fact ( + id BIGINT PRIMARY KEY, task_id BIGINT, application_id BIGINT, item_id BIGINT, + customer_id VARCHAR(40), old_label VARCHAR(80), new_label VARCHAR(80), + result VARCHAR(40), quantity INT, occurred_at TIMESTAMP) + """); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(RelabelDispatchSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setConfiguration(configuration); + RelabelDispatchSourceMapper actual = new SqlSessionTemplate(factory.getObject()) + .getMapper(RelabelDispatchSourceMapper.class); + // 只记录真实 Mapper 的锁调用次序,所有查询及 FOR UPDATE 仍交给实际数据库执行。 + RelabelDispatchSourceMapper observed = (RelabelDispatchSourceMapper) Proxy.newProxyInstance( + getClass().getClassLoader(), new Class[]{RelabelDispatchSourceMapper.class}, (proxy, method, args) -> { + if (method.getName().equals("lockApplication")) lockCalls.add((String) args[0]); + try { return method.invoke(actual, args); } + catch (InvocationTargetException failure) { throw failure.getCause(); } + }); + service = new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(observed), new ObjectMapper()); + transactionManager = new DataSourceTransactionManager(dataSource); + transaction = new TransactionTemplate(transactionManager); + transaction.setIsolationLevel(TransactionDefinition.ISOLATION_READ_COMMITTED); + position(101, 1001, "PALLET-1", 10); + application(1, "RL-B", 9001); + application(2, "RL-A", 9002); + item(11, 1, 101, "BOX-1", 3, 501, 7001); + item(21, 2, 101, "BOX-2", 4, 502, 7002); + binding(10, 1, "BOX-1", "PALLET-1", "INITIAL"); + binding(20, 2, "BOX-2", "PALLET-1", "INITIAL"); + fact(11, 1, 11, "RELABELLED"); + fact(12, 1, 11, "EXCEPTION"); + fact(21, 2, 21, "RELABELLED"); + } + + /** 两个申请共用同一位置时合计绑定数,不让每张申请单独通过 HOLD 校验。 */ + @Test + void combinedContributionsCannotExceedActualHold() { + jdbc.update("UPDATE ll_inventory_position SET hold_packages=6 WHERE id=101"); + Snapshot snapshot = snapshot(); + assertEquals(2, snapshot.applications().size()); + assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum()); + assertEquals(6, snapshot.positions().get(0).holdPackages()); + assertFalse(snapshot.complete()); + assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("HOLD"))); + } + + /** 逐明细冻结原入仓链保留独立身份,履约订单及当前余额不能替代原货源。 */ + @Test + void sourceProvenanceComesFromEachItem() { + Snapshot snapshot = snapshot(); + assertTrue(snapshot.complete(), snapshot.blockReasons().toString()); + var first = snapshot.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + var second = snapshot.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow(); + assertEquals(7001L, first.sourceInboundOrderId()); + assertEquals("IN-7001", first.sourceInboundOrderNo()); + assertEquals("CODE-7001", first.sourceInboundCode()); + assertEquals(501L, first.sourceInventoryBalanceId()); + assertEquals(7002L, second.sourceInboundOrderId()); + assertEquals(502L, second.sourceInventoryBalanceId()); + assertEquals(600L, first.inventoryBalanceId()); + assertEquals(9001L, snapshot.applications().stream().filter(a -> a.applicationId() == 1) + .findFirst().orElseThrow().fulfillmentOrderId()); + } + + /** 绑定、通知、成功及异常各自保留,历史数量不放大箱数,纯箱标件数未知。 */ + @Test + void workFactsAreGroupedBeforeCombiningAndCartonPiecesStayUnknown() { + binding(22, 2, "BOX-2", "PALLET-1", "INITIAL"); + Snapshot snapshot = snapshot(); + assertTrue(snapshot.complete(), snapshot.blockReasons().toString()); + assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::notifiedPackageCount).sum()); + assertEquals(7, snapshot.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum()); + var facts = snapshot.sources().stream().flatMap(source -> source.unitFacts().stream()).toList(); + assertEquals(3, facts.size()); + assertEquals(2, facts.stream().filter(f -> f.result().equals("RELABELLED")).count()); + assertEquals(1, facts.stream().filter(f -> f.result().equals("EXCEPTION")).count()); + assertTrue(snapshot.sources().stream().allMatch(source -> source.productQuantity() == null)); + } + + /** 商品件数只来自当前轮次完整的实际 SKU 扫描,不使用逐箱事实 quantity=1 推算。 */ + @Test + void skuPiecesRequireCurrentCompleteScanFacts() { + jdbc.update("UPDATE ll_relabel_item SET operation_type='SKU_LABEL', sku_relabel_items_json=? WHERE id=11", + "[{\"quantity\":5},{\"quantity\":7}]"); + skuScan(30, 0, 5, "INITIAL"); + assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + skuScan(31, 1, 7, "INITIAL"); + assertEquals(12L, snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + jdbc.update("UPDATE ll_relabel_pda_history SET detail_json=? WHERE id=31", + "{\"round\":\"INITIAL\",\"itemId\":\"11\",\"skuIndex\":1,\"quantity\":99}"); + assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + } + + /** 初轮 PDA 完工后客户确认改写复核时间,仍须保留 INITIAL 绑定与实际 SKU 件数。 */ + @Test + void customerConfirmationMustNotReplaceInitialWorkRound() { + jdbc.update("UPDATE ll_relabel_application SET status='CUSTOMER_CONFIRM_PENDING',customer_confirmed_at=NULL WHERE id=1"); + jdbc.update("UPDATE ll_relabel_item SET operation_type='CARTON_AND_SKU',sku_relabel_items_json=? WHERE id=11", + "[{\"quantity\":5},{\"quantity\":7}]"); + skuScan(30, 0, 5, "INITIAL"); + skuScan(31, 1, 7, "INITIAL"); + complete(50, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0)); + Snapshot before = snapshot(); + assertTrue(before.complete(), before.blockReasons().toString()); + assertEquals("INITIAL", before.applications().stream().filter(a -> a.applicationId() == 1) + .findFirst().orElseThrow().roundKey()); + var beforeSource = before.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + assertEquals(3, beforeSource.boundPackageCount()); + assertEquals(12L, beforeSource.productQuantity()); + + LocalDateTime confirmedAt = LocalDateTime.of(2026, 1, 5, 10, 30); + customerConfirm(1, confirmedAt); + Snapshot after = snapshot(); + assertTrue(after.complete(), after.blockReasons().toString()); + var application = after.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow(); + var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + assertEquals(confirmedAt, jdbc.queryForObject("SELECT reviewed_at FROM ll_relabel_pda_task WHERE application_id=1", LocalDateTime.class)); + assertEquals("INITIAL", application.roundKey()); + assertEquals(confirmedAt, application.customerConfirmedAt()); + assertEquals(3, source.boundPackageCount()); + assertEquals(12L, source.productQuantity()); + assertEquals(beforeSource.unitFacts(), source.unitFacts()); + assertNotEquals(before.compositionVersion(), after.compositionVersion()); + } + + /** 返工新轮次取实际绑定及完工历史,客户再次确认的时间不能替换返工作业轮次。 */ + @Test + void customerConfirmationPreservesReworkRoundAndRejectsPriorScans() { + jdbc.update("UPDATE ll_relabel_item SET operation_type='CARTON_AND_SKU',sku_relabel_items_json=? WHERE id=11", + "[{\"quantity\":5},{\"quantity\":7}]"); + skuScan(30, 0, 5, "INITIAL"); + skuScan(31, 1, 7, "INITIAL"); + complete(35, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0)); + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(40,1001,1,'C1','REWORK_ARCHIVE','{}','2026-01-05 00:00:00')"); + LocalDateTime reworkAt = LocalDateTime.of(2026, 1, 5, 0, 0, 1); + String round = reworkAt.toString(); + jdbc.update("UPDATE ll_relabel_pda_task SET reviewed_at=?,version=2 WHERE application_id=1", reworkAt); + jdbc.update("UPDATE ll_relabel_pda_unit_fact SET result='REWORK_ARCHIVED',old_label=NULL WHERE application_id=1"); + jdbc.update("UPDATE ll_relabel_application SET status='CUSTOMER_CONFIRM_PENDING',customer_confirmed_at=NULL WHERE id=1"); + binding(41, 1, "BOX-1", "PALLET-1", round); + jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:00' WHERE id=41"); + fact(42, 1, 11, "RELABELLED"); + jdbc.update("UPDATE ll_relabel_pda_unit_fact SET occurred_at='2026-01-06 00:00:00' WHERE id=42"); + complete(50, 1, round, LocalDateTime.of(2026, 1, 7, 0, 0)); + // 旧轮次两个 SKU 均已扫描,也不能作为返工新轮次的商品件数证据。 + assertNull(snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + skuScan(51, 0, 5, round); + skuScan(52, 1, 7, round); + jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:01' WHERE id IN (51,52)"); + Snapshot before = snapshot(); + assertTrue(before.complete(), before.blockReasons().toString()); + assertEquals(round, before.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow().roundKey()); + assertEquals(12L, before.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().productQuantity()); + + LocalDateTime confirmedAt = LocalDateTime.of(2026, 1, 8, 10, 30); + customerConfirm(1, confirmedAt); + Snapshot after = snapshot(); + assertTrue(after.complete(), after.blockReasons().toString()); + assertEquals(confirmedAt, jdbc.queryForObject("SELECT reviewed_at FROM ll_relabel_pda_task WHERE application_id=1", LocalDateTime.class)); + assertEquals(round, after.applications().stream().filter(a -> a.applicationId() == 1).findFirst().orElseThrow().roundKey()); + var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + assertEquals(3, source.boundPackageCount()); + assertEquals(12L, source.productQuantity()); + assertEquals(List.of(42L), source.unitFacts().stream().map(RelabelDispatchSourcePort.UnitFact::id).toList()); + assertNotEquals(before.compositionVersion(), after.compositionVersion()); + } + + /** 返工后不能借用旧绑定和旧作业事实;其他申请的本轮事实完整保留。 */ + @Test + void reworkInvalidatesOldRoundBindingAndFacts() { + Snapshot before = snapshot(); + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(40,1001,1,'C1','REWORK_ARCHIVE','{}','2026-01-05 00:00:00')"); + jdbc.update("UPDATE ll_relabel_pda_task SET reviewed_at='2026-01-05 00:00:01', version=2 WHERE application_id=1"); + jdbc.update("UPDATE ll_relabel_pda_unit_fact SET result='REWORK_ARCHIVED',old_label=NULL WHERE application_id=1"); + Snapshot after = snapshot(); + var source = after.sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow(); + assertEquals(0, source.boundPackageCount()); + assertTrue(source.unitFacts().isEmpty()); + assertFalse(after.complete()); + assertNotEquals(before.compositionVersion(), after.compositionVersion()); + assertEquals(4, after.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow().boundPackageCount()); + binding(41, 1, "BOX-1", "PALLET-1", "INITIAL"); + jdbc.update("UPDATE ll_relabel_pda_history SET created_at='2026-01-06 00:00:00' WHERE id=41"); + assertEquals(0, snapshot().sources().stream().filter(s -> s.itemId() == 11).findFirst().orElseThrow().boundPackageCount()); + } + + /** 闭包递归扩展到新实托上的其他申请及它们的其他实托,不能只返回首申请。 */ + @Test + void physicalApplicationClosureExpandsToFixedPoint() { + position(102, 1002, "PALLET-2", 50); + position(103, 1003, "PALLET-3", 50); + item(12, 1, 102, "BOX-12", 1, 503, 7003); + binding(30, 1, "BOX-12", "PALLET-2", "INITIAL"); + application(3, "RL-C", 9003); + item(31, 3, 102, "BOX-31", 1, 504, 7004); + item(32, 3, 103, "BOX-32", 1, 505, 7005); + binding(31, 3, "BOX-31", "PALLET-2", "INITIAL"); + binding(32, 3, "BOX-32", "PALLET-3", "INITIAL"); + application(4, "RL-D", 9004); + item(41, 4, 103, "BOX-41", 1, 506, 7006); + binding(41, 4, "BOX-41", "PALLET-3", "INITIAL"); + Snapshot snapshot = snapshot(); + assertTrue(snapshot.complete(), snapshot.blockReasons().toString()); + assertEquals(List.of("RL-A", "RL-B", "RL-C", "RL-D"), snapshot.applications().stream() + .map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertEquals(6, snapshot.sources().size()); + assertEquals(List.of(1001L, 1002L, 1003L), snapshot.positions().stream() + .map(RelabelDispatchSourcePort.Position::warehousePalletId).toList()); + } + + /** 不按已完成状态、确认时间或运输方式删减原始关联申请。 */ + @Test + void snapshotKeepsAllApplicationStatuses() { + for (String status : List.of("PENDING_REVIEW", "PENDING_PROCESSING", "PROCESSING", "CUSTOMER_CONFIRM_PENDING", + "READY_FOR_OUTBOUND", "REJECTED", "COMPLETED")) { + jdbc.update("UPDATE ll_relabel_application SET status=?,delivery_method='OTHER',customer_confirmed_at=NULL WHERE id=2", status); + Snapshot snapshot = snapshot(); + assertEquals(2, snapshot.applications().size()); + var app = snapshot.applications().stream().filter(a -> a.applicationId() == 2).findFirst().orElseThrow(); + assertEquals(status, app.status()); + assertEquals("OTHER", app.deliveryMethod()); + assertNull(app.customerConfirmedAt()); + assertEquals(2, snapshot.sources().size()); + } + } + + /** 仅验证取消后持久状态:旧 SKU 指针保留,但不再作为新贡献或连接另一托上的活跃申请。 */ + @Test + void cancelledSkuHistoryDoesNotContributeOrExpandThroughItsOtherPallet() { + cancelledSkuHistoryWithReleasedHold(); + Snapshot current = snapshot(); + assertTrue(current.complete(), current.blockReasons().toString()); + assertEquals(List.of("RL-A"), current.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertEquals(List.of(21L), current.sources().stream().map(RelabelDispatchSourcePort.BoundSource::itemId).toList()); + assertEquals(List.of(1001L), current.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).toList()); + assertEquals(4, current.positions().get(0).holdPackages()); + assertEquals(1, current.positions().get(0).availablePackages()); + Snapshot other = service.snapshot("1", List.of(1002L)); + assertTrue(other.complete(), other.blockReasons().toString()); + assertEquals(List.of("RL-C"), other.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertEquals(List.of(31L), other.sources().stream().map(RelabelDispatchSourcePort.BoundSource::itemId).toList()); + assertEquals(List.of(1002L), other.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).toList()); + assertEquals(5, other.positions().get(0).holdPackages()); + assertEquals(1, other.positions().get(0).availablePackages()); + assertEquals("CANCELLED", jdbc.queryForObject("SELECT status FROM ll_relabel_application WHERE id=1", String.class)); + assertEquals(List.of(101L, 102L), jdbc.queryForList( + "SELECT source_inventory_position_id FROM ll_relabel_item WHERE application_id=1 ORDER BY id", Long.class)); + } + + /** 取消历史退出闭包不能隐去残余 HOLD,真实运输冻结服务仍须拒绝并回滚已经插入的贡献。 */ + @Test + void cancelledHistoryCannotHideUnexplainedHoldFromActualTransportConsumer() throws Exception { + cancelledSkuHistoryWithReleasedHold(); + jdbc.update("UPDATE ll_inventory_position SET hold_packages=5,available_packages=0 WHERE id=101"); + Snapshot current = snapshot(); + assertTrue(current.complete(), current.blockReasons().toString()); + assertEquals(List.of("RL-A"), current.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertEquals(5, current.positions().get(0).holdPackages()); + assertEquals(4, current.sources().stream().mapToLong(RelabelDispatchSourcePort.BoundSource::boundPackageCount).sum()); + // provider 负责完整事实,实际运输消费者继续执行逐位置 HOLD 等量核对;这里不修改其规则。 + DispatchItemSourceService consumer = actualTransportConsumer(); + var root = new DispatchAggregate(); root.setId(77L); root.setOverseasWarehouseId("1"); + var selected = new DispatchItem(); selected.setId(88L); selected.setDispatchOrderId(77L); + selected.setCustomerId("C1"); selected.setOrderId(9002L); selected.setWarehousePalletId(1001L); + selected.setSourceBusinessType("RELABEL_ORDER"); selected.setSourceBusinessNo("RL-A"); + selected.setSourceStage("RELABEL"); selected.setPackageCount(4); + BizException blocked = assertThrows(BizException.class, () -> transaction.executeWithoutResult(status -> { + service.lockApplications(List.of("RL-A")); + consumer.capture(root, List.of(selected), List.of(selected), LocalDateTime.of(2026, 1, 6, 0, 0)); + })); + assertTrue(blocked.getMessage().contains("冻结箱数")); + assertEquals(0, jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item_source", Integer.class)); + assertEquals(5L, jdbc.queryForObject("SELECT hold_packages FROM ll_inventory_position WHERE id=101", Long.class)); + } + + /** 位置版本、逐箱事实及位置移动都会改变组成摘要,不能复用旧绑定依据。 */ + @Test + void versionsReflectStockAndCompositionChanges() { + Snapshot original = snapshot(); + assertEquals(original.compositionVersion(), snapshot().compositionVersion()); + jdbc.update("UPDATE ll_inventory_position SET version=version+1 WHERE id=101"); + Snapshot stockChanged = snapshot(); + assertNotEquals(original.compositionVersion(), stockChanged.compositionVersion()); + assertNotEquals(original.sources().get(0).bindingBasisHash(), stockChanged.sources().get(0).bindingBasisHash()); + jdbc.update("UPDATE ll_relabel_pda_unit_fact SET new_label='CHANGED' WHERE id=21"); + Snapshot workChanged = snapshot(); + assertNotEquals(stockChanged.compositionVersion(), workChanged.compositionVersion()); + position(102, 1002, "PALLET-2", 50); + jdbc.update("UPDATE ll_relabel_item SET source_inventory_position_id=102 WHERE id=21"); + Snapshot moved = snapshot(); + assertNotEquals(workChanged.compositionVersion(), moved.compositionVersion()); + // 明细已经移出原托盘闭包;原托盘保留的申请仍有完整绑定,不能把已移走明细强塞回来。 + assertTrue(moved.complete(), moved.blockReasons().toString()); + assertEquals(List.of("RL-B"), moved.applications().stream().map(RelabelDispatchSourcePort.Application::relabelNo).toList()); + assertTrue(moved.sources().stream().noneMatch(s -> s.itemId() == 21)); + // 显式读取新托盘后,实际位置与旧绑定目标不符,必须保留该明细并拒绝旧绑定。 + Snapshot includingMovedPallet = service.snapshot("1", List.of(1001L, 1002L)); + assertFalse(includingMovedPallet.complete()); + assertEquals(0, includingMovedPallet.sources().stream().filter(s -> s.itemId() == 21).findFirst().orElseThrow().boundPackageCount()); + } + + /** 无事务或未锁申请时必须拒绝锁后读取;锁顺序稳定且每事务只锁一次。 */ + @Test + void callerTransactionOwnsOneOrderedApplicationLockSet() { + assertThrows(IllegalStateException.class, () -> service.lockApplications(List.of("RL-A"))); + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))); + transaction.executeWithoutResult(status -> { + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))); + service.lockApplications(List.of("RL-B", "RL-A", "RL-B")); + assertEquals(List.of("RL-A", "RL-B"), lockCalls); + service.lockApplications(List.of("RL-A", "RL-B")); + assertEquals(2, lockCalls.size()); + assertEquals(2, service.lockedSnapshot("1", List.of(1001L)).applications().size()); + assertThrows(IllegalStateException.class, () -> service.lockApplications(List.of("RL-A"))); + }); + transaction.executeWithoutResult(status -> + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L)))); + } + + /** 锁后重读跳过 MyBatis 缓存,并拒绝锁集合以外新出现的申请。 */ + @Test + void lockedReadRefreshesRowsAndRejectsNewApplication() { + transaction.executeWithoutResult(status -> { + Snapshot before = snapshot(); + service.lockApplications(List.of("RL-A", "RL-B")); + jdbc.update("UPDATE ll_inventory_position SET version=2 WHERE id=101"); + Snapshot after = service.lockedSnapshot("1", List.of(1001L)); + assertEquals(2, after.positions().get(0).recordVersion()); + assertNotEquals(before.compositionVersion(), after.compositionVersion()); + application(3, "RL-C", 9003); + item(31, 3, 101, "BOX-31", 1, 503, 7003); + binding(31, 3, "BOX-31", "PALLET-1", "INITIAL"); + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))); + }); + } + + /** 被挂起事务的锁凭证不能被新事务借用,恢复和回滚后凭证按事务清除。 */ + @Test + void suspendedOrCompletedTransactionCannotLendItsLockProof() { + transaction.executeWithoutResult(status -> { + service.lockApplications(List.of("RL-A", "RL-B")); + var nested = new TransactionTemplate(transactionManager); + nested.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); + nested.executeWithoutResult(inner -> + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L)))); + assertTrue(service.lockedSnapshot("1", List.of(1001L)).complete()); + status.setRollbackOnly(); + }); + transaction.executeWithoutResult(status -> { + assertThrows(IllegalStateException.class, () -> service.lockedSnapshot("1", List.of(1001L))); + service.lockApplications(List.of("RL-A", "RL-B")); + assertTrue(service.lockedSnapshot("1", List.of(1001L)).complete()); + }); + } + + /** 客户隔离或绑定历史损坏时输出事实并显式阻止完整性声明。 */ + @Test + void invalidOwnershipOrBindingHistoryBlocksCompleteness() { + jdbc.update("UPDATE ll_relabel_item SET customer_id='C2' WHERE id=21"); + jdbc.update("UPDATE ll_relabel_pda_history SET detail_json='broken' WHERE id=10"); + Snapshot snapshot = snapshot(); + assertEquals(2, snapshot.sources().size()); + assertFalse(snapshot.complete()); + assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("归属"))); + assertTrue(snapshot.blockReasons().stream().anyMatch(reason -> reason.contains("历史"))); + } + + /** 仅用隔离 SQL 准备正式取消后的状态,不调用 changeStatus 或声称完整取消、财务链已经验收。 */ + private void cancelledSkuHistoryWithReleasedHold() { + position(102, 1002, "PALLET-2", 6); + item(12, 1, 102, "BOX-12", 1, 503, 7003); + binding(30, 1, "BOX-12", "PALLET-2", "INITIAL"); + application(3, "RL-C", 9003); + item(31, 3, 102, "BOX-31", 5, 504, 7004); + binding(31, 3, "BOX-31", "PALLET-2", "INITIAL"); + jdbc.update("UPDATE ll_relabel_item SET operation_type='SKU_LABEL',notified_package_count=1,total_package_count=1," + + "sku_relabel_items_json='[{\"quantity\":5}]' WHERE application_id=1"); + jdbc.update("DELETE FROM ll_relabel_pda_unit_fact WHERE id=12"); + fact(13, 1, 12, "RELABELLED"); + skuScan(90, 0, 5, "INITIAL"); + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(91,1001,1,'C1','SKU_SCAN',?,'2026-01-03 00:00:00')", + "{\"round\":\"INITIAL\",\"itemId\":\"12\",\"skuIndex\":0,\"quantity\":5}"); + complete(92, 1, "INITIAL", LocalDateTime.of(2026, 1, 4, 0, 0)); + jdbc.update("UPDATE ll_inventory_position SET hold_packages=5,available_packages=0 WHERE id=101"); + jdbc.update("UPDATE ll_relabel_application SET status='CANCELLED',fulfillment_status='WAIT_CUSTOMER_CONFIRMATION'," + + "delivery_method=NULL,customer_confirmed_at=NULL WHERE id=1"); + jdbc.update("UPDATE ll_inventory_position SET hold_packages=hold_packages-1,available_packages=available_packages+1," + + "version=version+1 WHERE id IN (101,102)"); + } + + /** 新反例复用真实运输 Mapper、Repository、Service 和同一 H2 事务,不使用模拟拒绝门禁。 */ + private DispatchItemSourceService actualTransportConsumer() throws Exception { + jdbc.execute(""" + CREATE TABLE ll_dispatch_item_source ( + id BIGINT PRIMARY KEY,dispatch_order_id BIGINT NOT NULL,dispatch_item_id BIGINT NOT NULL, + customer_id VARCHAR(128) NOT NULL,relabel_application_id BIGINT NOT NULL,relabel_item_id BIGINT NOT NULL, + relabel_round_key VARCHAR(128) NOT NULL,warehouse_pallet_id BIGINT NOT NULL, + inventory_position_id BIGINT NOT NULL,inventory_balance_id BIGINT NOT NULL, + source_inventory_balance_id BIGINT,source_inbound_order_id BIGINT,source_inbound_order_no VARCHAR(128), + source_inbound_code VARCHAR(128),notified_package_count BIGINT NOT NULL,package_count BIGINT NOT NULL, + product_quantity BIGINT,binding_basis_hash CHAR(64) NOT NULL,unit_facts_json CLOB NOT NULL,created_at TIMESTAMP NOT NULL, + UNIQUE(dispatch_item_id,relabel_item_id,inventory_position_id)) + """); + var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(DispatchItemSourceMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(transactionManager.getDataSource()); factory.setConfiguration(configuration); + var mapper = new SqlSessionTemplate(factory.getObject()).getMapper(DispatchItemSourceMapper.class); + return new DispatchItemSourceService(new DispatchItemSourceRepository(mapper), service, new ObjectMapper().findAndRegisterModules()); + } + + /** 创建同客户申请及独立作业任务,不借用履约订单作为原货源。 */ + private void application(long id, String no, long fulfillmentOrderId) { + jdbc.update("INSERT INTO ll_relabel_application VALUES(?,'C1',?,?,'1','TRUCK','READY_FOR_OUTBOUND'," + + "'WAIT_PRE_DISPATCH','2026-01-04 00:00:00','2026-01-04 00:00:00')", id, no, fulfillmentOrderId); + jdbc.update("INSERT INTO ll_relabel_pda_task VALUES(?,?,'C1',?,1,'WAIT_REVIEW',NULL,1)", 1000 + id, id, no); + } + + /** 创建真实库存位置,保留可用、HOLD 和版本的独立事实。 */ + private void position(long id, long palletId, String palletNo, long hold) { + jdbc.update("INSERT INTO ll_inventory_position VALUES(?,'C1',600,'1',?,?,0,?,0,0,0,1)", + id, palletId, palletNo, hold); + } + + /** 创建逐明细独立冻结来源的普通箱标任务。 */ + private void item(long id, long appId, long positionId, String mark, int packages, long sourceBalance, long inbound) { + jdbc.update("INSERT INTO ll_relabel_item VALUES(?,?,'C1',?,?,?,'CARTON_LABEL',NULL,NULL,?,?,?,?,?)", + id, appId, packages, packages, mark, sourceBalance, positionId, inbound, "IN-" + inbound, "CODE-" + inbound); + } + + /** 写入专用组托的当前轮次历史,箱唛与实托码严格配对。 */ + private void binding(long id, long appId, String mark, String palletNo, String round) { + String detail = "{\"round\":\"" + round + "\",\"cartonMarks\":[\"" + mark + + "\"],\"targetPallets\":[\"" + palletNo + "\"]}"; + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,?,?,'C1','PALLET_BIND',?,'2026-01-02 00:00:00')", + id, 1000 + appId, appId, detail); + } + + /** 写入箱级成功或异常事实,quantity=1 明确表示箱作业而非商品件数。 */ + private void fact(long id, long appId, long itemId, String result) { + jdbc.update("INSERT INTO ll_relabel_pda_unit_fact VALUES(?,?,?,?,'C1',?,?,?,1,'2026-01-03 00:00:00')", + id, 1000 + appId, appId, itemId, "OLD-" + id, "NEW-" + id, result); + } + + /** 写入商品件数的实际逐 SKU 扫描证据。 */ + private void skuScan(long id, int index, int quantity, String round) { + String detail = "{\"round\":\"" + round + "\",\"itemId\":\"11\",\"skuIndex\":" + index + + ",\"quantity\":" + quantity + "}"; + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,1001,1,'C1','SKU_SCAN',?,'2026-01-03 00:00:00')", id, detail); + } + + /** 写入生产者 COMPLETE 的 roundKey,不以客户确认时更新的任务字段替代。 */ + private void complete(long id, long appId, String round, LocalDateTime occurredAt) { + jdbc.update("INSERT INTO ll_relabel_pda_history VALUES(?,?,?,'C1','COMPLETE',?,?)", id, 1000 + appId, + appId, "{\"roundKey\":\"" + round + "\"}", occurredAt); + } + + /** 按正式客户确认路径重写 reviewedAt、任务状态及申请确认字段。 */ + private void customerConfirm(long appId, LocalDateTime confirmedAt) { + jdbc.update("UPDATE ll_relabel_pda_task SET status='COMPLETED',reviewed_at=?,version=version+1 WHERE application_id=?", + confirmedAt, appId); + jdbc.update("UPDATE ll_relabel_application SET status='READY_FOR_OUTBOUND',fulfillment_status='WAIT_PRE_DISPATCH'," + + "customer_confirmed_at=?,updated_at=? WHERE id=?", confirmedAt, confirmedAt, appId); + } + + /** 从一个实托调用实际生产事实提供者。 */ + private Snapshot snapshot() { + return service.snapshot("1", List.of(1001L)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index d68230850..abab245b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -513,7 +513,7 @@ class WarehouseTransferServiceTest { verifyNoInteractions(transfers); } - /** 来源箱快照先通过占用校验,再单独拒绝草稿中的外部换标箱。 */ + /** 先按原订单来源快照拒绝外部换标箱,不进入后续选箱占用。 */ @Test void submittingOldDraftRechecksRelabelCartonOwnership() { var transfer = transfer(WarehouseTransferService.DRAFT); @@ -521,9 +521,6 @@ class WarehouseTransferServiceTest { transfer.setInboundCode("IN-1"); transfer.setNeedRelabel(true); transfer.setPackageCount(1); - var selected = new WarehouseTransferCarton(); - selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX"); - when(transferCartons.listOrder("TENANT-A", transfer.getId())).thenReturn(List.of(selected)); when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")) .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null))); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 56d09c370..fc1243a00 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -295,6 +295,41 @@ "source": "com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticController", "target": "com.baiyue.lastleg.security.InternalServiceTokenGuard", "reason": "内部只读数量诊断复用服务间身份守卫,不依赖客户库存与出库业务。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentExecutionAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentExecutionAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchContracts", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentExecutionAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchTypes", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchAppointmentAmendmentPort", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchCommands", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchContracts", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", + "target": "com.baiyue.lastleg.dispatch.port.DispatchTypes", + "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" } ], "applicationDependencyRule": { diff --git a/scripts/README.md b/scripts/README.md index d7b0d2594..08ccd5cd8 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -59,7 +59,7 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`: `fba-remaining-nodes-acceptance-pda-operator.mjs` 是 F33 海外 PDA 的一次性 TEST 账号准备助手,导入不运行。显式调用 `createF33PdaOperator({env, credentialFile, reportFile})`,凭据来自已授权 TEST 环境;仅通过正式 IAM API 创建独立洛杉矶仓作业员、授予已存在的海外 PDA 角色并验证登录/仓绑定,不改角色权限或旧账号,不操作库存。随机凭据仅写入调用方指定的仓库外环境文件,报告建议保存 `reports/fba-remaining-nodes-20261004/pda-operator/`,不包含密码或 Token;已有文件、请求未知结果或首次强制改密均停止,禁止自动重造。退出自有管理员/PDA会话,账号保留供页面验收,报告给出精确停用接口,清理由总控在验收结束后执行。 -`fleet-same-route-cost-test-env.mjs` 是同路线自有/外接实运成本分摊的串行TEST入口,默认`--plan`零网络;执行参数为`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据仅通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用两新客户普通来货/PDA实收/客户确认,各一托一箱,再复用真实外接预订、来货、PDA实收与关联,三托统一目的站;逐单拣货、实际装载和审核后整车发车。登记USD1.00合成成本,以不可变DEPART实测托位、三票来源及业务号顺序独立核对最大余数分币、原币守恒、创建重放与唯一历史,再逐票签收、正式结案并分别释放车辆/司机。自有两票分别分摊,不能先合并自有托位后假设汇总一定为0.67;余分按现行逐业务号规则分配。不写业务SQL、不部署、不操作财务付款。报告沿`reports/fleet-cross-<新批次>/`,增加`same-route-source.json`和`createdResources.fleetSameRouteCost`;入口要求验收函数显式返回本轮车次VERIFIED才能结案,不借另一证据对象的状态。失败保留原请求/样本、停止且不自动重跑,finally停用自有PDA并退出会话。`fleet-same-route-cost-test-env.test.mjs`验证错误来源/目的地/冻结依据/分币及零网络计划;真实HTTP与页面结论分别记录。 +`fleet-same-route-cost-test-env.mjs` 是同路线自有/外接实运成本分摊的串行TEST入口,默认`--plan`零网络;执行参数为`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据仅通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。两新客户普通来货/PDA实收/客户确认各一托一箱,共用一张原生排车;真实外接预订、来货和运营确认实收后加到原主单,三托统一目的站,零扫码退回后重新两审、按各客户实拣、实际装载、平台逐组安排,再以当前版本整车发车。登记USD1.00合成成本,以不可变DEPART实测托位核对两条业务分摊:OWN按本张排车单汇总两实托,EXTERNAL按真实booking业务号计外接实托,两行共用同一dispatchNo。实际容量2:1时为0.67/0.33,非单位容量按实际比例及业务号顺序分配最大余数,不按托盘个数或预设比例替代。保留每客户来源、库存/SHIP、原币分币守恒、成本创建重放与唯一历史,逐派送单去重签收、正式结案并分别释放车辆/司机。不写业务SQL、不部署、不操作财务付款。报告沿`reports/fleet-cross-<新批次>/`,增加`same-route-source.json`和`createdResources.fleetSameRouteCost`;显式SAME_ROUTE_COST校验域要求本轮marker、排车、车次及原proof一致,不能借跨客户或容量池结果结案。失败保留原请求/样本、停止且不自动重跑,缺实测先阻断,finally停用自有PDA并退出会话。`fleet-same-route-cost-test-env.test.mjs`验证错误来源/目的地/冻结依据/分币及零网络计划;真实HTTP与页面结论分别记录。 `fleet-booking-quotes-test-env.mjs` 是外接预订选用销售报价的独立 TEST HTTP 验收入口,使用显式 `node --env-file=<用户指定目录的test-acceptance.env>` 和 `--ready-commit=a6fd0ba3`,执行前须由总控核对实际部署版本与健康。只读公共仓城市/时区,创建自身全新隔离客户、销售报价及未分配资源的车次/询价草稿;验证候选日期/币种/计价、零价与缺价、手填价格、陈旧版本拒绝、刷新重选及来源快照,不创建库存、不发车、不证明承运成功或实收完成。请求前持久留证、敏感字段脱敏、未知结果停止且禁止自动重跑,独立登录并退出;保留自己的计划/报价/草稿,不删除资源。当前固定R2标识,独占报告 `reports/fleet-booking-quotes-live-20261003-r2/`。离线合同 `fleet-booking-quotes-test-env-contract.test.mjs`,运行前仍须总控放行对应健康版本。 @@ -91,16 +91,18 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`: F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_CANCELLED` 只运行显式指定的一种顺序,省略仍串行执行三种;报告和成功范围仅覆盖所列模式。`--verify-delivery-event-recovery` 必须同时显式指定一种顺序,且环境为TEST;仅在该模式第一张真实FBA派送签收前开启定向ACK丢失,不能使用普通海外转运的签收替代国内回写。故障窗口会重建同镜像Lastleg并临时调整事件目标和退避配置,禁止与其他部署或共享环境写入并行;原事件DEAD恢复后必须验证原镜像/配置/网络身份、健康及代理退出。入口事先留存精确故障scope,finally兜底恢复,缺恢复证据或恢复校验失败一律失败。完整库存取证接入既有只读器,报告保留于该批次 `evidence/`,不能把某一模式或事件通过解读为全部反向事件完成。 -`fleet-cross-customer-test-env.mjs` 是最小两客户共享车次的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,分别预排并关联同车;一单未拣齐时验证全车拒绝及完整库存快照不变,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 +`fleet-cross-customer-test-env.mjs` 是最小两客户同一排车主单的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,真实来源一次Create到同一主单,自动唯一Fleet记录、配置资源并完成两审;一客户未核平时验证整车装车/发车拒绝及完整库存快照不变,全量实拣后按真实测量显式装车、平台逐客户目的地安排,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,缺实测不补计划值,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。单票验收的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 ## 验收证据校验 -`fleet-trip-rollback-test-env.mjs` 是跨客户整车发车中途回滚的串行TEST入口,默认`--plan`零网络;实际参数沿用`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据只通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用正式两客户一托一箱来货/实收/排车链,全部合法拣货和理货审核后,在第二客户原托盘锁行建立65秒SELECT FOR UPDATE租约,以默认50秒锁等待超时使原整车发车事务失败;不修改业务行、Schema、共享参数或镜像。严格证明原等待图及第一客户尚未提交的DEPARTED/OUTBOUNDED/重放记录,再按一致性完整快照证明全部回滚;READ UNCOMMITTED仅用于明确标识的中途观察,不作为最终库存事实。租约自动ROLLBACK并核对无等待,再用同一原发车键/载荷完成正式发车、重放、逐客户签收和资源释放。报告沿`reports/fleet-cross-<新批次>/`,额外`rollback.json`、源码SHA与镜像身份保留;失败停止、原现场和请求保留、不自动重造或续跑,finally仅清理自有PDA/会话。共享TEST窗口禁止并行发布或操作相同库存。对应`fleet-trip-rollback-test-env.test.mjs`验证证据反例和范围;通用跨客户入口不默认故障注入。 +`fleet-trip-rollback-test-env.mjs` 是跨客户同一原生主单发车中途回滚的串行TEST入口,默认`--plan`零网络;实际参数沿用`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据只通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用正式两客户一托一箱普通转仓来货/实收、同主单两审/实拣/实测装车及平台安排链;按Java客户字符串顺序,通过原业务号只读解析当前主单、客户子单及真实转仓来源,第二客户转仓行建立65秒SELECT FOR UPDATE租约,等待事务自身的50秒会话锁超时使原整车发车失败。不修改业务行、Schema、共享参数或镜像;GLOBAL超时或HTTP超时不能代替会话与事务证据。等待图须精确对应原发车事务、目标转仓主键及实际UPDATE,同时独立READ UNCOMMITTED观察已写的第一客户子单、来源计数及历史;此时主单仍AFTER_LOADING、Fleet为PLANNED、尚无DEPART命令结果,不能沿用旧DEPARTED/重放表断言。最终新已提交读按实际Schema全字段比较相关主/子单、来源历史、占用、命令账本及已提交扫码/SHIP事实,脏读仅作中途证明。租约自动ROLLBACK,核对原事务和等待均已消失,再用同一原发车键/载荷完成正式发车、重放、逐客户签收和资源释放。报告沿`reports/fleet-cross-<新批次>/`,额外`rollback.json`、源码SHA与镜像身份保留;权限、字段、会话超时或任一证据缺失明确阻断,失败保留原现场,不自动重造或续跑,finally仅清理自有PDA/会话。执行需要对应TEST数据库取证、持锁及真实HTTP的明确授权,共享窗口禁止并行发布或操作相同库存。对应`fleet-trip-rollback-test-env.test.mjs`验证证据反例和范围;通用跨客户入口不默认故障注入。 `fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 +整车回滚专项的完整快照覆盖当前23表,包括本批真实实托的`ll_physical_pallet_guard`永久串行行;按实际Schema全列及COUNT取证,中途和回滚后均核对原主键与首次创建时间不变,不把删除重建串行点视作成功回滚。此取证范围仅限该回滚专项,不扩展通用车辆入口的故障注入。 + 该入口用 `--scenario handover` 验证整车在途交接及经营成本;`--scenario return-redispatch` 验证实际退回入库后的重派和逐来源完成,默认要求全新批次;仅下述精确候选失败检查点允许显式恢复原样本。`--output reports/<新目录>` 指定独占报告目录,已有目录禁止覆盖。所有写请求发送前保存业务载荷、版本和幂等依据;网络失败、5xx、缺少关联信息或不符合正式响应契约时立即停止,保留原请求,不自动重发。 经审阅的已发车样本可显式提供 `--resume-report reports/<原目录>/report.json --resume-stage <阶段> --resume-sha256 <原报告SHA256>`,同时使用新的 `--run-id` 和输出目录。`DEPARTED_BEFORE_COSTS` 只接受尚未登记成本、交接和签收的已知只读成本断言失败;现场再次核对原车次版本、唯一 DEPART、实货及空成本/交接和未签收状态后,新建受限成本/PDA会话,继续成本、交接、签收与资源释放。`DEPARTED_AFTER_COSTS_BEFORE_HANDOVER` 只接受成本已验证、交接仍仅只读的已知断言失败;重新核对已登记成本完整原快照及原车次事实,只新建 PDA 会话,保留成本并从交接继续。两阶段均保留原 DEPART,不再拣货、发车或重复先前已确认的业务命令;未知结果和已发生交接/签收的报告不属于此恢复范围。多目的地不能使用同路线自动分摊时,整趟成本按原币核对,来源分摊明确记录为 `UNAVAILABLE`。恢复必须另获当前执行放行,不由脚本自动启动。 @@ -159,7 +161,7 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-acceptance.mjs --apply --confirm-production-ddl ``` -`mixed-container-outbound-test-env.mjs` 是经明确授权的测试环境留存混柜出库续跑入口:参数为 `source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`,凭据由显式 `node --env-file` 注入。FBA 分支实时校验既有收货数据,创建排车、出库、派送及临时 PDA 账号,执行拣货、发车、测试签收文件保存和国内状态回查;一件代发分支只走专用候选、两托四箱出库、PDA 幂等拣货和完成回查,并明确记录派送未覆盖,不返回全流程成功退出码。finally 停用临时账号,不删除业务数据。输出报告作为检查点,失败后须先核对其中业务单号和实时状态,再用同一输出文件续跑;不得删除报告后盲目重试。它不代表普通海外仓目的地转换或所有分支签收验收通过。 +`mixed-container-outbound-test-env.mjs` 是经明确授权的TEST留存混柜出库续跑入口,参数为`source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report`;显式环境文件使用统一Gateway/Auth,拒绝其他环境。FBA分支仅从本次报告中真实收货的客户、仓、来源和marker创建原生排车,顺序为两级审核、PDA实拣、真实测量装车、平台全车派送安排、当前主单版本发车、POD和资源结案。续跑须保留来源报告SHA、本轮marker、原Create载荷及原生CommandResult/Detail,旧报告或裸排车号不能证明来源;没有实测托位时明确阻断,不使用计划常量。一件代发仍为专用两托四箱出库及PDA幂等拣货/完成,明确派送未覆盖且不返回全流程成功退出码。写前原子落盘,结果未知停止,不换键或删除报告重跑;finally停用临时账号,保留业务资源。离线合同`native-retained-outbound-contract.test.mjs`覆盖两个留存入口,导入无网络;不能代替真实HTTP或PDA验收。 全量验收只认测试环境 HTTP 脚本。它们通过 Gateway 调用已部署服务,按业务模块执行真实登录、权限、状态、幂等、跨服务和数据链路检查;脚本通过后,剩余工作是 Admin/Client 的真实接口调用与浏览器样式验收,以及 PDA 页面/设备验收。 @@ -189,6 +191,7 @@ node --env-file="<外部生产数据库.env>" scripts/prod-database-schema-accep - 完整正常链:`full-flow <外部订单输入.json> <独立全流程检查点.json> --confirm-single-full-flow [--resume]`。仅支持 `BUY_DECLARATION` 买单报关、单 SKU、1~2 箱、义乌起运及洛杉矶接柜的 FBA 正常链。先用现有客户下单审核,再测量/费用确认、订舱/分柜、国内 PDA 装柜、买单报关、人工船节点、目的港提柜预约、海外 PDA 收货/还空柜、排车/派送、车次调度/拣货/发车、签收回传及车次结案释放资源。任何HTTP或归属/数量门禁失败都停止,保留同票检查点,不新建订单代替失败。 - 全流程内部模块:`prod-release-full-flow.mjs` 负责单票阶段编排和独立子检查点;`prod-release-http.mjs` 负责统一 Gateway、写前请求摘要落盘、失败诊断、合成文件上传及独立登录会话退出;`prod-release-domestic-stage.mjs` 负责国内正常链;`prod-release-overseas-stage.mjs` 负责海外正常链和签收后车次结案。这些文件没有独立CLI,不运行全量fixture,不创建账号、客户、车辆、司机、供应商等主档。正常业务会创建本票货柜、订舱、作业托盘、排车、车次、派送及费用记录。 - 完整输入还需 `domestic: {palletId, assigneeId, bookingAgent:{id,name}, carrier:{id,name,carrierCode}, terminalFleet:{id,name}, estimatedSailingAt, estimatedArrivalAt}` 和 `resources:{vehicleId,driverId,carrierName}`,均引用既有启用且有资格的资源。订单级及货物行都必须明确提供 `shipmentId/referenceId`;不能只填货物行。报告/输入/检查点使用仓库外私有路径;公开交付仅保留脱敏结果、业务号及诊断编号。 +- 实际装载缺少托位等实测值时,在首次输入中预先指定 `loadingMeasurementFile` 私有绝对路径;该路径随完整输入指纹冻结,文件可在本票扫码核平后补齐,再以同一输入和检查点 `--resume`。文件为 JSON:`{environment:"TEST"|"GRAY",batch,dispatchNo,pallets:[{warehousePalletId,palletSpaces,weightKg,volumeCbm,deliveryIndex,evidence:[{field,actual:true,sourceType,sourceBusinessNo,observedAt,value}]}]}`,环境须与本次入口一致,三项测量各有实际来源、时间和值,实托和路线须匹配当前装载预览。缺失、格式错误或归属不符停止装车,不从计划或计费常量补值。装车意图前保存原文件 SHA、回执和最终测量;成功命令恢复原请求,结果未知停写,不用修改文件触发重发。内部直接调用的 `loadingMeasurements` 与文件输入互斥。 - 全流程报告只有全部正常链及签收回传、资源释放核对成功才输出 `SINGLE_ORDER_HTTP_COMPLETED` 与 `fullFlowVerified:true`,其范围仅为 `ONE_ORDER_NORMAL_HTTP_PATH`。测量、资质、装柜、卸柜和签收文件均明确为合成验收材料,不代表真实物流证据;不覆盖退税报关、多客户/拆单/异常、故障注入、真实支付、第三方下单、邮件、浏览器及设备验收。阶段检查点已完成命令复用原结果,未知写入意图必须人工核对,不能自动重发。 - 离线验证:`node --test scripts/prod-release-acceptance.test.mjs scripts/prod-release-order-stage.test.mjs scripts/prod-release-delivery-stage.test.mjs scripts/prod-release-measurement-plan.test.mjs scripts/prod-release-http.test.mjs scripts/prod-release-domestic-stage.test.mjs scripts/prod-release-overseas-stage.test.mjs scripts/script-documentation-contract.test.mjs`。一票主链不覆盖多客户混柜、退货异常、真实付款、外部下单、邮件发送或页面交互。最终输出单独报告 `sessionCleanup`,会话退出不受业务请求次数上限影响。 @@ -491,10 +494,12 @@ Dry-run 只校验两个统一 SQL 的入口标记,打印当前 SHA-256、行 - `db/seed-local-relabel-ready-orders.sql`:仅供明确授权的本机测试库存准备。调用者在同一 MySQL 会话提供客户、共享海外仓、有效虚拟库位、FBA目的仓、唯一批次及ID基数;凭据由本机进程环境提供。以事务插入三单共54箱、六托及原入仓快照、计划、明细、余额、仓位和明确标记 LOCAL_TEST_FIXTURE 的库存流水;不伪造PDA照片、签收操作或HTTP验收通过,不修改原数据。重复批次失败并回滚,不用upsert重置已消耗库存;成功数据保留,结果表输出到调用者日志,不自动清理。 - `scripts/current-session-overseas-relabel-acceptance.mjs`:本地开发环境定向入口,从当前 Codex 内置浏览器本地存储读取最新平台/客户 refresh token,在内存中刷新双区域会话,自动选择当前客户具备足量库存的海外仓并临时创建海外 PDA 操作员;随后串行运行换标拆/合托、智能预排、PDA 出库与派送完成。报告写入 `reports/current-session-overseas-relabel/`,不记录 Token/密码;会消耗当前浏览器 refresh token 并保留业务验收单据,临时 PDA 在 `finally` 中停用。履约阶段中断后可显式设置 `OVERSEAS_RESUME_FULFILLMENT=true`,复用目录内已通过的 `relabel.json` 且只续跑出库派送。只允许本机开发环境且须有当前轮真实写入授权。 -- `scripts/overseas-relabel-real-acceptance.mjs`:定向真实 HTTP 换标作业,会创建换标、库存及排车记录;从显式环境文件读取平台/PDA账号、网关及客户参数,输出脚本配置的报告,不自动清理。失败保留证据与已创建资源,重跑前核对报告,必须另获指定环境授权。 +- `scripts/overseas-relabel-real-acceptance.mjs`:仅显式 TEST 的统一 Gateway/Auth 换标生产者,会创建换标、库存及原生排车记录;平台/PDA及可选客户登录凭据由显式环境文件提供,缺客户凭据时通过正式临时登录票据,票据和 Token 只留内存。运行前必须指定 `OVERSEAS_TEST_CUSTOMER_ID`、`OVERSEAS_TEST_WAREHOUSE_ID`、`OVERSEAS_RELABEL_SOURCE_BALANCE_ID`、`OVERSEAS_RELABEL_SOURCE_POSITION_ID`,可用 `OVERSEAS_RELABEL_SOURCE_INBOUND_CODE` 进一步限定本轮来源;不任意借历史库存。`OVERSEAS_RELABEL_MARKER` 可固定批次,报告默认 `reports/overseas-relabel-real-acceptance.json`(`OVERSEAS_ACCEPTANCE_OUTPUT` 覆盖),已有报告禁止覆盖或重造 marker,失败保留原请求和资源,不自动清理、重跑或续跑,执行另需对应环境授权。保留四种换标作业、新旧箱唛、实物归属与扫码/打印重放;原生排车前一次读取本仓完整RELABEL候选,核对服务端selectionGroup、组成版本、全部逻辑来源与实托。NEW-PALLET和MERGE-EXISTING共托时共用一次Create及原键重放,并分别保留换标来源贡献;SPLIT-TWO-TARGETS仍按整申请闭包独立建单。未知成员、缺组、版本漂移或测量冲突必须在首个Create前阻断,不能退回只拒绝所有合托的旧行为。 +- 可选 `OVERSEAS_RELABEL_LOADING_EVIDENCE` 指向按场景名索引的私有 JSON 回执,每项明确 `{environment:"TEST",marker,dispatchNo,pallets:[{warehousePalletId,palletSpaces,weightKg,volumeCbm,deliveryIndex,evidence:[{field,actual:true,sourceType,sourceBusinessNo,observedAt,value}]}]}`;当前排车、实托及三个实际测量来源须完全匹配,缺项标记装载证据阻断,不虚构托位。共享组各场景若同时提供回执,必须指向同一排车并具有相同实托测量;整托重量、体积及容量只计一次。`scripts/overseas-relabel-real-contract.test.mjs` 仅离线验证显式来源、四场景证据、完整组共车Create、原键重放、冲突前置阻断及未知写停写,不代表真实作业验收。该入口产出原生排车证明,既有留存消费者仍只继续SPLIT场景,不据此宣称共享组审核、扫码、装车、发车及签收全链已验收。 - 该脚本现按“扫码→组托→完工→客户确认”执行,`OVERSEAS_RELABEL_CHARGE_AMOUNT=0` 表示省略费用字段、只验仓库链路(不代表财务通过);默认 0.01 验钱包冻结。照片为有效的合成测试 PNG,不是真实现场照片。 - `scripts/relabel-pdf-local-acceptance.mjs`:仅本机 Print→File 真实 HTTP 验收,显式提供 `INTERNAL_SERVICE_TOKEN`、`OVERSEAS_TEST_CUSTOMER_ID`,可配置 `PRINT_SERVICE_URL`、`FILE_SERVICE_URL`。创建三类测试 PDF 文件元数据,验证下载哈希及跨客户拒绝,报告 `reports/relabel-closure-20260916/pdf.json`;保留文件,不清理,失败非零退出,可重跑生成新 businessId。不代表换标下单/PDA全流程通过。 -- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:读取 `OVERSEAS_ACCEPTANCE_SOURCE` 或指定排车/换标编号,执行 PDA 拣货、出库、派送后续真实 HTTP,会推进业务状态;凭据来自显式环境文件,报告默认 `reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT` 可覆盖)。失败保留资源,重跑前核对业务状态,不自动清理。两个定向脚本不替代 Admin 全量入口,本次合并仅做离线合同。 +- `scripts/overseas-relabel-fulfillment-acceptance.mjs`:显式TEST环境读取`OVERSEAS_ACCEPTANCE_SOURCE`中SPLIT-TWO-TARGETS的本轮原生Create命令、响应/详情及新旧箱唛证明,裸排车号或旧预排报告被拒绝。使用统一Gateway/Auth及与混柜相同的原生审核、扫码、真实装车、平台安排、发车、POD和资源链;缺物理测量不造值,未知写入保留原键停写。报告默认`reports/overseas-relabel-fulfillment-acceptance.json`(`OVERSEAS_FULFILLMENT_OUTPUT`覆盖),来源SHA和原命令必须保留,失败不自动清理。它不接管仅开发环境的current-session包装,亦不替代Admin全量验收。 +- `scripts/native-retained-outbound-contract.test.mjs`:仅离线校验上述混柜/换标入口的来源身份、原生命令、写前落盘与未知停写、实载/PDA顺序、SHIP数量、POD和箱唛护栏;不读取环境凭据、不发HTTP、不执行SQL或部署。命令为`node --test scripts/native-retained-outbound-contract.test.mjs`。 - `scripts/warehouse-transfer-remaining-acceptance.mjs`:本地开发环境仓储转运真实 HTTP 验收入口,创建并推进转运、换标和库存业务数据,凭据来自环境变量或 `local-env.ps1`,报告写入 `reports/warehouse-transfer-remaining-acceptance.json`;失败数据保留,不自动清理。 - `scripts/warehouse-transfer-resume-acceptance.mjs`:续跑既有仓储转运排车、出库和派送流程,会推进真实业务状态;通过脚本内既有单据或环境变量定位待续跑数据,报告写入 `reports/warehouse-transfer-resume-acceptance.json`,不自动清理。 - `scripts/warehouse-transfer-receipt-acceptance.mjs`:本地开发环境目的仓收货真实 HTTP 验收,创建或通过 `WAREHOUSE_TRANSFER_RECEIPT_NO` 续跑库存转运并写入目的仓库存;凭据来自环境变量或 `local-env.ps1`,报告默认写入 `reports/warehouse-transfer-receipt-acceptance.json`。失败资源保留,续跑会复用同一报告中已创建的目的仓托盘,不自动清理。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 9efc81f42..e8d0c5e26 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -32,9 +32,9 @@ `fleet-vehicle-care.mjs` 是可选车辆运维专项,通过正式入口 `admin-test-env-acceptance.mjs --modules fleet-vehicle-care --output <报告路径>` 执行,不单独运行。仅用于获授权 TEST,凭据由调用者显式 `node --env-file=<指定环境文件>` 提供;复用总入口 gateway/username/password 参数。经正式 API 创建本轮专用车辆,再复用 `vehicle-care.mjs` 验证六类记录、版本、更正历史、照片幂等及跨车隔离;不依赖订单、库存、报价或历史样本。写入仅涉及测试车辆、运维记录和图片;成功/失败均保留报告 `createdResources.fleetVehicleCare` 和样本,finally 读取最新版本停用本轮主车辆,内部助手停用隔离车辆,不删除记录或文件。失败重跑创建新样本,不修改原失败证据;不执行 SQL 或部署。该场景不证明费用已经入账或付款。 -`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收、客户确认及预排接口生成两套独立本轮货物,再共用一个真实车次并各自转派送,停在尚未拣货。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 +`fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物,再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 -`fleet-cross-customer-acceptance.mjs` 是跨客户共享车次的内部后半链验收函数,不是独立入口。默认调用只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收并分别预排/转派送、关联同一合格车次且未拣货,以及调用方提供的完整库存只读取证器。逐客户审核和拣货,验证第二单未就绪时整车拒绝且全部快照不变,再验证统一发车、原键重放和分别签收。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 +`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量显式装车、平台逐客户目的地安排,再以当前主单版本统一发车、原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 `TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。 @@ -54,11 +54,11 @@ 头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。 -`fleet-trip-fulfillment.mjs` 是内部车次验收助手,不是独立入口。五期与F40模块通过正式API建立本轮车辆容量、测试司机及PNG证照上传/审核、车次关联、目的站匹配及本单调度审核通过;拣货后确认明确标记为测试的实物测量,通过整车理货审核后携带最新车次版本统一发车。资源及文件ID保留到报告,不自动删除、不以SQL修改状态;失败停止,不能跳过资格或装载。`mixed-container-outbound-test-env.mjs` 与 `overseas-relabel-fulfillment-acceptance.mjs` 的留存场景须在拣货前已有正式车次关联和通过的调度审核,不能对已经开工的旧单补造关联;它们确认受控测试装载、理货审核并继续履约。改派检查保留拣货事实,但重新通过每张单的调度审核后再确认装载。`fleet-trip-fulfillment-contract.test.mjs` 离线验证重复实托、目的地及准备顺序,不代表HTTP验收。 +`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;五期与F40通过正式API维护本轮车辆、司机及证照,提交并通过调度/理货两级审核,再读取该排车唯一自动生成的Fleet资源记录。PDA拣货核平后,显式确认装车完成,再由平台安排全车客户目的地派送,最后使用当前主单版本发车;不再独立创建、绑定或审核Fleet车次。实载托位、重量、体积及卸货站须有本轮真实来源证据,不能使用计费量或固定值;ID和版本无损保留,Long箱数字符串仅在安全数量边界归一化。资源及文件ID留在报告,失败保留原命令。直接调用者也须同步消费原生DTO及该顺序;合同通过不代表HTTP或PDA硬件验收。 -`prepareFleetTrip` 创建的本轮车次必须完成发车前改派验证:`confirmFleetLoading` 先确认装载,再创建等容量替换车辆和经文件上传/审核的替班司机,经正式分配接口改派。回查客户关联、原货物、拣货数量、路线和到达时间未改变,原装载确认已清除;旧版本确认须被拒绝且不产生写入,最后按新版本重新确认。返回最终版本供既有 PDA 就绪检查和整车发车使用。报告 `createdResources.fleetTrips[].reassignment` 留存前后快照、替换资源、原请求和结果,只有全部通过才标记 `VERIFIED`;失败保留 `CHECKING`,同一证明对象禁止自动重试或重造。顶层资源引用随后更新为替换资源供结案/释放验证,原资源仍保留在快照中。留存场景不执行这段新建资源与改派流程。 +`prepareFleetTrip` 对零扫码的装车中排车,须先显式退回装车前再维护资源并重走两级审核;已扫码时不能重排。`confirmFleetLoading` 验证已扫码编辑被拒绝且主单与实际货物零漂移,然后按真实装载预览/实测回执发送装车完成命令;PDA成功后,平台使用同一原键原载荷重放,必须保持原结果和版本。报告`createdResources.fleetTrips[].reassignment`保留原请求、前后快照及拒绝证据;未知写结果停止,不自动换键重造。装车返回版本不替代发车前重新读取的公共版本。 -五期和F40签收后继续调用 `finishFleetTrip`:正式结案并同键重放,通过两个无货计划及一辆隔离用测试车辆验证“结案仍占用车辆、只结束车辆仍占用司机、两项结束后可重新分配、旧结束请求重放不能清除新占用”。仅409不足以通过,必须是相应资源占用冲突;全部完成后以正式取消命令取消无货计划并保留历史。测试车辆/司机在末站结束,不含返程任务;地点、时间、原请求及计划号保存于 `createdResources.fleetTrips[].closure`。失败保留CHECKING及原资源,不自动重造或修复旧fixture;离线模拟通过不等于这些真实HTTP场景已执行。 +五期和F40签收后继续调用`finishFleetTrip`:货物结案、车辆任务结束、司机任务结束分别执行并重放,按真实资源占用所有者核对“结案仍占用车辆、只结束车辆仍占用司机”。两项结束后,通过原生Create创建本轮空排车取得下一趟资源占用,再重放旧结束请求,证明不能清除新占用;不调用退役Fleet空计划创建接口。只有当前空排车无来源/无箱/无扫码且创建结果明确时,才能按最新版本取消探针;结果未知不清理。地点、时间、原请求及业务号留在`createdResources.fleetTrips[].closure`,不含返程任务,离线模拟不能替代真实环境验收。 `phase5-overseas-fulfillment.mjs` 的 `pickAll`、`saveReceiptAndComplete` 也供显式授权的 `../mixed-container-outbound-test-env.mjs` 留存混柜续跑入口复用;仍使用正式 PDA 拣货和签收文件绑定接口,不代替全模块验收。 @@ -72,7 +72,7 @@ `phase4-overseas-pda.mjs` 的收货入口对本轮 `containerBookingId + containerNo` 做有界候选收敛等待(最多 30 次、间隔 1 秒),只有唯一且可操作的原柜才继续;鉴权失败、畸形响应和重复候选立即阻断。它用于等待异步船节点投影,不构成事件重放证据。F40 在成功和失败时均保留 `receivingCandidateEvidence` 的逐次查询身份、结果和诊断信息,不覆盖已有收货会话,不借其他柜或历史柜继续。 -多目的地排车 C52:`phase4-overseas-dispatch.mjs` 在本轮实际收货产生的同仓同客户托盘上验证多 FBA 目的地及重复目的地托盘的增加、移除、重放和拒绝后零漂移。路线期望从实际排车明细及本轮冻结候选独立推导,记录 `routeLifecycle`,最终恢复原主链排车并释放额外测试托盘;缺少至少两个目的地及同目的地双托前置时明确阻断,不借历史数据或用单目的地代替。复用 candidates、draft-locks/lock、pre-arrange、详情和 DELETE items 正式 API;定向入口仍为 `--modules phase4-overseas-dispatch`,会写 TEST,使用新报告路径,失败保留样本,不自动删除重跑。合同为 `phase4-overseas-dispatch-contract.test.mjs`;离线成功不代表实际 HTTP 生命周期通过。 +多目的地排车C52:`phase4-overseas-dispatch.mjs`在本轮实际收货产生的同仓同客户托盘上验证多FBA目的地及重复目的地托盘的增加、移除、原键重放和拒绝后零漂移。路线期望从真实Detail、Items、Route与冻结候选独立推导,记录`routeLifecycle`;缺少两个目的地及同目的地双托时明确阻断。使用版本化服务器草稿/容器/来源占用、`drafts/pre-arrange`转换、原生Create、`POST /{dispatchNo}/items/{dispatchItemId}/remove`和`PUT /{dispatchNo}/route`,不使用旧独立pre-arrange或DELETE items。正式交接保留原生三个读取对象及实际sourceKeys,停在ACTIVE/BEFORE_LOADING,未装车不得标记完成;实测缺失另行记录。写前原子落盘,未知结果停写,同marker不能重新创建。定向入口仍为`--modules phase4-overseas-dispatch`;真实运行会写指定TEST,仍须环境授权,失败保留原样本。两份对应合同验证脚本编排,不能证明服务端路线已正确同步或真实HTTP生命周期通过。 船节点异常 C66:`phase3-carrier-schedule-queries.mjs` 的 `captureCarrierExceptionEvidence` 与 `verifyCarrierExceptionTransition` 对本轮原船任务采集只读 checkpoint,核对 CUSTOMS_HOLD/TERMINAL_HOLD/DOCUMENT_HOLD/UNKNOWN_HOLD 的追加、解除、重放及冲突前后正常实际/预计节点守恒。`phase3-carrier-exceptions-contract.test.mjs` 只验证离线比对逻辑;真实生命周期还需要 Integration 供应商结果经调度器消费的独立投递证据。默认 HTTP 链只采基线并保留该覆盖缺口;空异常数组、重复 GET、报关异常和合成快照均不能计为供应商异常生命周期通过。 diff --git a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs index d96efd803..cf8534bc8 100644 --- a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance-contract.test.mjs @@ -1,6 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { verifyFleetBookingReservation } from './fleet-booking-reservation-acceptance.mjs' +import { verifyFleetBookingReservation, testPlannedCapacity } from './fleet-booking-reservation-acceptance.mjs' +import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs' function fixture(fault = '') { const baseline = [ @@ -71,16 +72,12 @@ function fixture(fault = '') { return result(booking) }, } - const measurements = (actual, stops) => { - assert.deepEqual(actual, cargo); assert.deepEqual(stops, tripProof.stops) - return cargo.map((row, index) => ({ warehousePalletId: row.warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: index + 1 })) - } - return { context, tripProof, measurements, calls, bookings, assertions } + return { context, tripProof, cargo, calls, bookings, assertions } } test('reservation HTTP helper verifies actual segments, original requests and cancellation without receiving or charging', async () => { const f = fixture() - const proof = await verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements) + const proof = await verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof) assert.equal(proof.status, 'VERIFIED') assert.equal(proof.bookings.length, 2) assert.ok(f.bookings.every(row => row.status === 'CANCELLED')) @@ -89,13 +86,14 @@ test('reservation HTTP helper verifies actual segments, original requests and ca assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) assert.ok(f.calls.every(row => !/incoming|receipt-confirmation|transfer-delivery|departure/.test(row.suffix))) assert.equal(proof.createRequest.customerId, '900719925474099301') - await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements), /本轮新建/) + assert.equal(proof.planningBasis.kind, 'CONTROLLED_TEST_PLAN'); assert.equal(proof.planningBasis.actual, false) + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof), /本轮新建/) }) for (const fault of ['wrong-segment', 'replay-reoccupies', 'cancel-does-not-release', 'fake-loaded', 'missing-trace', 'wrong-conflict']) { test(`reservation helper fails closed on ${fault} even when HTTP reports success`, async () => { const f = fixture(fault) - await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof, f.measurements)) + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', f.tripProof)) assert.equal(f.tripProof.reservation.status, 'CHECKING') assert.ok(f.assertions.some(row => !row.passed && row.options.deploymentBlocker)) if (['wrong-segment', 'replay-reoccupies'].includes(fault)) assert.equal(f.calls.some(row => row.suffix.endsWith('/cancel')), false) @@ -104,6 +102,16 @@ for (const fault of ['wrong-segment', 'replay-reoccupies', 'cancel-does-not-rele test('reservation helper rejects foreign fixture before making any request', async () => { const f = fixture() - await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', { ...f.tripProof }, f.measurements), /本轮新建/) + await assert.rejects(() => verifyFleetBookingReservation(f.context, 'fleet-test', { ...f.tripProof }), /本轮新建/) assert.equal(f.calls.length, 0) }) + +test('planned capacity counts a cross-customer physical pallet once and cannot be treated as actual measurement', () => { + const f = fixture(), first = f.cargo[0] + const planned = testPlannedCapacity([first, { ...first, customerId: 'OTHER-CUSTOMER' }], f.tripProof.stops) + assert.equal(planned.length, 1); assert.equal(planned[0].warehousePalletId, first.warehousePalletId) + assert.throws(() => controlledPalletMeasurements(planned, f.tripProof.stops), /实际预览|实际托盘/) + assert.throws(() => testPlannedCapacity([first, { ...first, destinationKey: 'FBA:B' }], f.tripProof.stops), /不同计划卸货站/) + assert.throws(() => testPlannedCapacity([{ ...first, warehousePalletId: null }], f.tripProof.stops), /缺少真实/) + assert.throws(() => testPlannedCapacity([first], [{ name: 'Origin' }]), /唯一路线/) +}) diff --git a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs index ff8651c1e..5a21a83e4 100644 --- a/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-booking-reservation-acceptance.mjs @@ -10,8 +10,23 @@ const version = value => /^(0|[1-9]\d*)$/.test(text(value)) const trace = response => ({ http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) +/** 仅生成明确TEST计划占用;这些预计值不提供真实装载测量证据。 */ +export function testPlannedCapacity(cargo, stops) { + const pallets = new Map() + if (!Array.isArray(cargo) || !cargo.length || !Array.isArray(stops)) throw new Error('计划容量缺少本轮真实货物或路线') + for (const row of cargo) { + if (!row.warehousePalletId || !row.palletNo || !row.destinationKey) throw new Error('计划货物缺少真实实托和目的地') + const matches = stops.map((stop, index) => stop.destinationKey === row.destinationKey ? index : -1).filter(index => index > 0) + if (matches.length !== 1) throw new Error('计划货物目的地不在唯一路线卸货站') + const palletId = text(row.warehousePalletId), deliveryIndex = matches[0] + if (pallets.has(palletId) && pallets.get(palletId).deliveryIndex !== deliveryIndex) throw new Error('同一实托存在不同计划卸货站') + pallets.set(palletId, { warehousePalletId: palletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex }) + } + return [...pallets.values()] +} + /** Internal HTTP helper. Operates only on the current run's trip before picking; no SQL or cleanup on failure. */ -export async function verifyFleetBookingReservation(context, module, tripProof, measurements) { +export async function verifyFleetBookingReservation(context, module, tripProof) { if (!context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof.tripNo || tripProof.reservation) throw new Error('外接占位验收仅接受本轮新建且尚未开始验收的车次;失败后保留原证据') const proof = tripProof.reservation = { status: 'CHECKING', marker: context.marker, requests: [], bookings: [] } @@ -37,7 +52,9 @@ export async function verifyFleetBookingReservation(context, module, tripProof, await check('自有货容量来源完整', initial?.cargo?.length > 0 && !initial.blockReason && initial.cargo.every(row => row.customerId && row.warehousePalletId && row.destinationKey) && version(initial.recordVersion) && /^[0-9a-f]{64}$/.test(initial.manifestHash || '')) - const pallets = measurements(initial.cargo, tripProof.stops) + const pallets = testPlannedCapacity(initial.cargo, tripProof.stops) + proof.planningBasis = { kind: 'CONTROLLED_TEST_PLAN', actual: false, pallets, + remark: '仅预计每实托1托位/100kg/1m³,用于计划余位测试;不得作为装车完成实测回执' } const plan = await request('POST', '/capacity-plan', { recordVersion: initial.recordVersion, manifestHash: initial.manifestHash, pallets, reason: '受控测试计划:按本轮真实实托确认预计占用,非生产测量', diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index 4361d3982..6942383e1 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -1,71 +1,125 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' -import { verifyFleetCrossCustomer, fixtureContract } from './fleet-cross-customer-acceptance.mjs' +import { createHash } from 'node:crypto' +import { verifyFleetCrossCustomer, fixtureContract, continueFleetCrossCustomerAfterLoading, afterLoadingContinuationContract } from './fleet-cross-customer-acceptance.mjs' -function model(fault='') { - const copy=value=>structuredClone(value),calls=[],keys=new Set() - const members=[0,1].map(i=>({marker:'RUN',customerId:`C${i}`,incomingNo:`IN${i}`,dispatchNo:`DS${i}`,outboundNo:`OUT${i}`,deliveryNo:`DL${i}`, - balanceId:`B${i}`,positionId:`P${i}`,palletId:`PAL${i}`,palletNo:`PALLET${i}`,receiptVerified:true})) - const fixture={marker:'RUN',stage:fixtureContract.stage,tripNo:'TRIP',warehouseId:'WH',pdaToken:'not-a-real-token',members} - const trip={trip:{tripNo:'TRIP',warehouseId:'WH',status:'PLANNED',recordVersion:'0',vehicleId:'V',driverId:'D',stops:[{name:'Origin'}, - {name:'A',destinationKey:'FBA:A'},{name:'B',destinationKey:'FBA:B'}]},dispatches:members.map(m=>({dispatchNo:m.dispatchNo,customerId:m.customerId,review:{status:'PENDING'}}))} - const state=members.map(m=>({dispatch:{dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'PENDING_DEPARTURE',packageCount:1,pickedPackageCount:0, - items:[{id:`ITEM${m.customerId}`,warehousePalletId:m.palletId}]},delivery:{order:{deliveryNo:m.deliveryNo,customerId:m.customerId,dispatchNo:m.dispatchNo,outboundNo:m.outboundNo,status:'WAIT_DELIVERY',version:'0'}}, - outbound:{outboundNo:m.outboundNo,dispatchNo:m.dispatchNo,overseasWarehouseId:'WH',status:'WAIT_OUTBOUND'}, - stock:{scope:{marker:'RUN',customerId:m.customerId,warehouseId:'WH',coverage:'CUSTOMER_WAREHOUSE_COMPLETE'}, - balances:[{id:m.balanceId,customerId:m.customerId,overseasWarehouseId:'WH',availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0',inTransitPackages:'0',procurementPackages:'0'}], - positions:[{id:m.positionId,customerId:m.customerId,overseasWarehouseId:'WH',balanceId:m.balanceId,palletId:m.palletId,palletNo:m.palletNo,availablePackages:'1',holdPackages:'0',allocatedPackages:'0',pickedPackages:'0',receivingPackages:'0'}],ledger:[]}})) - const cargo=members.map((m,i)=>({dispatchNo:m.dispatchNo,customerId:m.customerId,warehousePalletId:m.palletId,palletNo:m.palletNo,destinationKey:i?'FBA:B':'FBA:A',packages:1})) - const response=data=>({http:200,body:{code:'200',data:copy(data)},requestId:'request',traceId:'trace'}) - const context={marker:'RUN',token:'admin',createdResources:{},assert(_m,label,_r,condition){assert.ok(condition,label)}, - async request(method,raw,body,token){ - calls.push({method,raw,body:copy(body),token});const path=new URL(raw,'https://offline.invalid').pathname - let index=members.findIndex(m=>path.includes(m.dispatchNo)||path.includes(m.outboundNo)||path.includes(m.deliveryNo)) - const row=state[index],member=members[index] - if(path.endsWith('/review')){trip.dispatches[index].review.status='APPROVED';trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)} - if(path.endsWith('/loading-review')){trip.loadingReview={status:'APPROVED'};trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response(trip)} - if(path.endsWith('/loading')){if(method==='POST')trip.trip.recordVersion=String(Number(trip.trip.recordVersion)+1);return response({recordVersion:trip.trip.recordVersion,manifestHash:'HASH',cargo,blockReason:null})} - if(path.endsWith('/TRIP'))return response(trip) - if(path.endsWith('/pick')){ - if(keys.has(body.idempotencyKey))return response({replayed:true}) - keys.add(body.idempotencyKey);row.dispatch.pickedPackageCount=1 - row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0' - row.stock.ledger=['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:member.customerId,overseasWarehouseId:'WH',positionId:member.positionId,businessOrderNo:member.dispatchNo,businessType:type,deltaPackages:type==='SHIP'?'-1':'1'})) - if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger[2],id:'EXTRA'}) - return response({replayed:false}) - } - if(path.endsWith('/complete')){row.dispatch.status='READY_TO_DEPART';return response({status:'READY_TO_DEPART'})} - if(path.endsWith('/depart')){ - if(state.some(s=>s.dispatch.pickedPackageCount!==1)){ - if(fault==='gate reorders route') [trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] - if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'} - return {http:fault==='outage disguised as rejection'?503:200,body:{code:fault==='outage disguised as rejection'?'S0503':'A0409',message:fault==='unrelated gate rejection'?'车次版本已变化':'DS1:请完成拣货并核平全部箱数'},requestId:'r',traceId:'t'} +function model(fault = '') { + const copy = value => value == null ? value : structuredClone(value), calls = [], keys = new Map() + const members = [0,1].map(i => ({ marker: 'RUN', customerId: `C${i}`, incomingNo: `IN${i}`, dispatchNo: 'DS-ONE', dispatchItemId: `ITEM${i}`, + balanceId: `B${i}`, positionId: `P${i}`, palletId: `PAL${i}`, palletNo: `PALLET${i}`, receiptVerified: true })) + const stops = [{ name: 'Origin' }, { name: 'A', destinationKey: 'FBA:A' }, { name: 'B', destinationKey: 'FBA:B' }] + const tripProof = { marker: 'FT-CURRENT', dispatchNo: 'DS-ONE', tripNo: 'TRIP', warehouseId: 'WH', vehicleId: 'V', driverId: 'D', stops, + reassignment: { status: 'PENDING' }, updateRequest: { plannedDepartureAt: new Date(Date.now()+3600000).toISOString() } } + const actualEvidence = (field,value,i) => ({ field, value, actual: true, sourceType: 'WAREHOUSE_TRANSFER', sourceBusinessNo: `IN${i}`, observedAt: '2026-10-19T00:00:00Z' }) + const fixture = { marker: 'RUN', stage: fixtureContract.stage, dispatchNo: 'DS-ONE', tripNo: 'TRIP', tripProof, warehouseId: 'WH', pdaToken: 'not-a-real-token', members, + loadingMeasurements: { marker: 'RUN', environment: 'TEST', dispatchNo: 'DS-ONE', pallets: members.map((m,i) => ({ warehousePalletId: m.palletId, + palletSpaces: 1, deliveryIndex: i+1, evidence: [actualEvidence('palletSpaces',1,i)] })) } } + const trip = { trip: { tripNo: 'TRIP', warehouseId: 'WH', status: 'PLANNED', recordVersion: '0', vehicleId: 'V', driverId: 'D', stops }, dispatches: [{ dispatchNo: 'DS-ONE' }] } + let nativeVersion = 5, stage = 'LOADING', arranged = false, confirmedLoad = null, stopAtArrangement = false, aggregationReads = 4 + const state = members.map(m => ({ picked: 0, delivery: null, outbound: null, + stock: { scope: { marker: 'RUN', customerId: m.customerId, warehouseId: 'WH', coverage: 'CUSTOMER_WAREHOUSE_COMPLETE' }, + balances: [{ id: m.balanceId, customerId: m.customerId, overseasWarehouseId: 'WH', availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0', inTransitPackages: '0', procurementPackages: '0' }], + positions: [{ id: m.positionId, customerId: m.customerId, overseasWarehouseId: 'WH', balanceId: m.balanceId, palletId: m.palletId, palletNo: m.palletNo, availablePackages: '1', holdPackages: '0', allocatedPackages: '0', pickedPackages: '0', receivingPackages: '0' }], + ledger: [{ id: `STOCK-${m.customerId}`, customerId: m.customerId, overseasWarehouseId: 'WH', positionId: m.positionId, businessType: 'STOCK_IN', businessOrderNo: m.incomingNo, deltaPackages: '1' }] } })) + const counts = () => ({ itemCount: 2, palletCount: 2, packageCount: 2, pickedPackageCount: state.reduce((n,s) => n+s.picked,0), shippedPackageCount: ['DELIVERING','DELIVERED'].includes(stage) ? 2 : 0 }) + const items = () => members.map((m,i) => ({ dispatchItemId: m.dispatchItemId, customerId: m.customerId, warehousePalletId: m.palletId, + palletNo: m.palletNo, destination: { key: i ? 'FBA:B' : 'FBA:A' }, scheduledPackages: 1, pickedPackages: state[i].picked })) + const deliveryRefs = () => state.filter(s => s.delivery).map((s,i) => ({ customerId: members[i].customerId, deliveryNo: s.delivery.order.deliveryNo, + outboundNo: s.outbound.outboundNo, packageCount: 1, status: s.delivery.order.status, destination: { key: i ? 'FBA:B' : 'FBA:A' } })) + const detail = () => ({ dispatchNo: 'DS-ONE', status: 'ACTIVE', stage, recordVersion: nativeVersion, reviewState: 'APPROVED', + reviews: { dispatchReview: { status: 'APPROVED' }, loadingReview: { status: 'APPROVED' } }, counts: counts(), + documents: { deliveries: deliveryRefs(), outbounds: state.filter(s=>s.outbound).map(s=>s.outbound) } }) + const result = (body,payload,replayed = false) => ({ dispatchNo: 'DS-ONE', status: 'ACTIVE', stage, recordVersion: nativeVersion, + counts: counts(), idempotencyKey: body.idempotencyKey, result: payload, replayed }) + const response = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' }) + const reject = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' }) + const forbidden = () => ({ http: 200, body: { code: 'A0403', message: '该请求标识不属于当前操作员', requestId: 'request', traceId: 'trace' }, requestId: 'request', traceId: 'trace' }) + const remember = (body,payload,actor,action) => { const value = {...result(body,payload),...(action?{action}:{})}; keys.set(body.idempotencyKey,{body:copy(body),result:copy(value),actor});return response(value) } + const context = { marker: 'RUN', token: 'admin', createdResources: { fleetTrips: [tripProof] }, assert(_m,label,_r,condition) { assert.ok(condition,label) }, + apiSuccess(_m,_l,r) { return r.body.data }, + async request(method,raw,body,token) { + calls.push({method,raw,body:copy(body),token}); const url = new URL(raw,'https://offline.invalid'), path = url.pathname + if (method === 'POST' && keys.has(body?.idempotencyKey)) { + const prior = keys.get(body.idempotencyKey); assert.deepEqual(body,prior.body) + if(token!==prior.actor) { + if(fault==='foreign departure actor accepted'&&path.endsWith('/depart'))return response({...prior.result,replayed:true}) + if(fault==='foreign departure actor mutates'&&path.endsWith('/depart'))state[0].stock.ledger.push({...state[0].stock.ledger[0],id:'FOREIGN-ACTOR-MUTATION'}) + return forbidden() } - const replayed=keys.has(body.idempotencyKey);keys.add(body.idempotencyKey);trip.trip.status='IN_TRANSIT' - for(const [i,s] of state.entries())if(!(fault==='partial departure'&&i===1)){s.dispatch.status='DEPARTED';s.outbound.status='OUTBOUNDED'} - if(fault==='depart deducts again'){row.stock.balances[0].availablePackages='-1';row.stock.positions[0].availablePackages='-1'} - return response({tripNo:'TRIP',status:'IN_TRANSIT',shippedPackages:'2',dispatches:members.map(m=>({dispatchNo:m.dispatchNo})),replayed}) + return response({...prior.result,replayed:true}) } - if(path.includes('/api/pda/'))return response({dispatchNo:member.dispatchNo,warehouseId:'WH',tripNo:'TRIP',status:row.dispatch.pickedPackageCount?'COMPLETED':'PDA_PENDING', - items:[{dispatchItemId:`ITEM${member.customerId}`,palletNo:member.palletNo,scheduledPackages:1,pickedPackages:String(row.dispatch.pickedPackageCount), - positions:fault==='missing PDA position'?[]:[{id:member.positionId,balanceId:member.balanceId,availablePackages:row.stock.positions[0].availablePackages}]}]}) - if(path.includes('/dispatches/'))return response(row.dispatch) + if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) + if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()}) + if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') { + if(method==='PUT')return reject('已经真实扫码,不能编辑资源') + if(fault==='aggregation delayed'&&state.every(s=>s.delivery?.order.status==='DELIVERED')) { + if(aggregationReads===0)stage='DELIVERED';else aggregationReads-- + } + return response(detail()) + } + if (path.endsWith('/loading') && method === 'GET') return response({dispatchNo:'DS-ONE',status:'ACTIVE',stage,recordVersion:nativeVersion,manifestHash:'REAL-MANIFEST', + pickingReconciled:state.every(s=>s.picked===1),confirmedLoad,loadingComplete:{allowed:state.every(s=>s.picked===1)},pallets:members.map((m,i)=>({warehousePalletId:m.palletId,palletNo:m.palletNo, + scheduledPackages:1,pickedPackages:state[i].picked,palletSpaces:null,weightKg:100,volumeCbm:1,deliveryIndex:i+1, + evidence:[actualEvidence('weightKg',100,i),actualEvidence('volumeCbm',1,i)]}))}) + if (path.endsWith('/loading-complete')) { + if (!state.every(s=>s.picked===1)) { + if(fault==='outage disguised as rejection')return {http:503,body:{code:'S0503'},requestId:'r',traceId:'t'} + return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平') + } + assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2) + stage='AFTER_LOADING';nativeVersion++ + confirmedLoad={manifestHash:body.manifestHash,pallets:copy(body.measurements),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'} + return remember(body,{confirmedLoad,loadingCompletionHash:'LOADED'},token,'LOADING_COMPLETE') + } + if (path.endsWith('/pick')) { + const index=members.findIndex(m=>path.includes(`/items/${m.dispatchItemId}/`)),m=members[index],row=state[index] + assert.equal(body.recordVersion,nativeVersion);assert.equal(body.positionId,m.positionId);row.picked=1;nativeVersion++ + row.stock.positions[0].availablePackages='0';row.stock.balances[0].availablePackages='0' + row.stock.ledger.push(...['ALLOCATE','PICK','SHIP'].map(type=>({id:`${type}${index}`,customerId:m.customerId,overseasWarehouseId:'WH',positionId:m.positionId,businessOrderNo:'DS-ONE',businessType:type,deltaPackages:type==='SHIP'?'-1':'1'}))) + if(fault==='duplicate ship')row.stock.ledger.push({...row.stock.ledger.at(-1),id:'EXTRA'}) + return remember(body,{dispatchItemId:m.dispatchItemId,pickedPackages:1},token) + } + if (path === '/api/pda/v1/last-leg/dispatches/DS-ONE') return response({dispatchNo:'DS-ONE',warehouseId:'WH',status:'ACTIVE',stage,recordVersion:nativeVersion,counts:counts(),pickingReconciled:state.every(s=>s.picked===1), + items:items().map((item,i)=>({...item,positions:fault==='missing PDA position'?[]:[{positionId:members[i].positionId,balanceId:members[i].balanceId,availablePackages:state[i].stock.positions[0].availablePackages}]}))}) + if (path.endsWith('/delivery-arrangement')) { + if(stopAtArrangement)throw Error('Explicit test checkpoint after confirmed loading') + return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,status:'ACTIVE',stage,cargoHash:'CARGO',loadingCompletionHash:'LOADED',arrange:{allowed:true},arrangement:arranged?{status:'ARRANGED'}:null, + groups:members.map((m,i)=>({groupKey:`GROUP${i}`,customerId:m.customerId,groupCargoHash:`GROUP-HASH${i}`,destination:{key:i?'FBA:B':'FBA:A'},cargo:[{customerId:m.customerId,warehousePalletId:m.palletId}],blockReasons:[],requiredServices:['DELIVERY_FEE']}))}) + } + if (path.endsWith('/transfer-delivery')) { + assert.equal(stage,'AFTER_LOADING');assert.equal(body.recordVersion,nativeVersion);assert.equal(body.groups.length,2) + assert.ok(body.groups.every(g=>g.fees.length===1&&g.fees[0].amount===30)) + for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'WAIT_OUTBOUND'} + s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:'WAIT_DELIVERY',version:0}} } + arranged=true;nativeVersion++;return remember(body,{dispatchNo:'DS-ONE',outbounds:state.map((s,i)=>({...s.outbound,packageCount:1})),deliveries:deliveryRefs()},token) + } + if (path.endsWith('/depart')) { + if(!arranged) { + if(fault==='gate reorders route')[trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] + if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'} + return reject('请先完成装车并一次安排全部客户派送') + } + assert.equal(body.recordVersion,nativeVersion);stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT' + for(const [i,s]of state.entries())if(!(fault==='partial departure'&&i===1)){s.outbound.status='OUTBOUNDED';s.delivery.order.status='DELIVERING'} + if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'} + return remember(body,{shippedPackages:2,departedAt:'2026-10-19T01:00:00Z',deliveries:deliveryRefs(),outbounds:state.map(s=>s.outbound)},token) + } + const index=members.findIndex((_m,i)=>path.includes(`/OUT${i}`)||path.includes(`/DL${i}`)),row=state[index] if(path.includes('/outbound-orders/'))return response({outbound:row.outbound,deliveries:[row.delivery.order]}) - if(path.endsWith('/status')){row.delivery.order.status='DELIVERING';row.delivery.order.version=String(Number(row.delivery.order.version)+1);return response({deliveryStatus:'DELIVERING'})} - if(path.endsWith('/documents')){ + if(path.endsWith('/documents')) { if(method==='GET')return response({version:row.delivery.order.version,categories:[]}) - row.delivery.order.status='DELIVERED';row.delivery.order.version=String(Number(row.delivery.order.version)+1) + row.delivery.order.status='DELIVERED';row.delivery.order.version++ if(fault==='signs both')state[1-index].delivery.order.status='DELIVERED' + if(state.every(s=>s.delivery.order.status==='DELIVERED')&&!['aggregation delayed','aggregation never completes'].includes(fault))stage='DELIVERED' return response({version:row.delivery.order.version}) } - if(path.includes('/delivery-orders/'))return response(row.delivery) + if(path.includes('/delivery-orders/')) {assert.equal(url.searchParams.get('customerId'),members[index].customerId);return response(row.delivery)} throw new Error(`Unexpected ${method} ${path}`) - },async requestMultipart(method,path,body){calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})}} + },async requestMultipart(method,path) {calls.push({method,raw:path,multipart:true});return response({fileId:`FILE-${path.includes('DL0')?0:1}`})} } const readInventory=async scope=>{const value=copy(state[members.findIndex(m=>m.customerId===scope.customerId)].stock);if(fault==='wrong stock scope')value.positions[0].customerId='OTHER';return value} - return {context,fixture,readInventory,calls,state} + return {context,fixture,readInventory,calls,state,stopAfterLoading(){stopAtArrangement=true},resumeAfterLoading(){stopAtArrangement=false}} } - test('default call and import do not request, create or activate anything',async()=>{ let called=false;const result=await verifyFleetCrossCustomer({request(){called=true}},{}) assert.equal(result,fixtureContract);assert.equal(called,false) @@ -73,17 +127,29 @@ test('default call and import do not request, create or activate anything',async test('current DTO-shaped two-customer trip rejects unready gate, departs once and signs independently',async()=>{ const m=model();const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}) assert.equal(proof.status,'VERIFIED');assert.equal(proof.gateProof,'PREREQUISITE_REJECTION_VERIFIED');assert.equal(proof.midTransactionRollback,'NOT_TESTED') - assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,3) + assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,4) assert.deepEqual(m.state.map(s=>s.delivery.order.status),['DELIVERED','DELIVERED']) assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken)) assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken)) + assert.equal(new Set(proof.members.map(row=>row.dispatchNo)).size,1) + assert.equal(new Set(proof.members.map(row=>row.deliveryNo)).size,2) + assert.equal(new Set(proof.members.map(row=>row.outboundNo)).size,2) + assert.ok(!m.calls.some(c=>c.raw.endsWith('/complete')||c.raw.includes('/dispatches/bind'))) +}) + +test('missing physical pallet-space evidence blocks after both real picks without fabricating loading or delivery',async()=>{ + const m=model();delete m.fixture.loadingMeasurements + await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/palletSpaces/) + assert.ok(m.state.every(row=>row.picked===1&&row.delivery===null)) + assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1,'only the earlier expected unready rejection was sent') + assert.equal(m.calls.some(call=>call.raw.endsWith('/transfer-delivery')),false) }) test('explicit rollback verifier runs after both picks and before successful departure',async()=>{ const m=model();let calls=0 const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory, verifyDepartureRollback:async({ready,departPath,departBody})=>{ - calls++;assert.equal(ready.trip.trip.status,'PLANNED') - assert.ok(ready.members.every(row=>row.dispatch.status==='READY_TO_DEPART')) + calls++;assert.equal(ready.trip.trip.status,'PLANNED');assert.equal(ready.dispatch.stage,'AFTER_LOADING') + assert.ok(ready.members.every(row=>row.outbound.outbound.status==='WAIT_OUTBOUND')) assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.equal(departBody.idempotencyKey,'RUN-CROSS-DEPART') return {verified:true,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'} @@ -101,7 +167,7 @@ for(const failure of ['unverified','throws','mutates'])test(`rollback verificati assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) assert.ok(m.state.every(row=>row.delivery.order.status==='WAIT_DELIVERY')) }) -for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both']) { +for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both','foreign delivery','foreign departure actor accepted','foreign departure actor mutates']) { test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{ const m=model(fault);await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}), fault==='gate reorders route'?/Rejected departure leaves both complete inventories\/outbounds\/deliveries\/trip unchanged/:undefined) @@ -118,5 +184,152 @@ test('missing reader, reused customer and stale marker reject before any API req test('module contains no autonomous runner or SQL/environment/credential plumbing',async()=>{ const source=await readFile(new URL('./fleet-cross-customer-acceptance.mjs',import.meta.url),'utf8') assert.doesNotMatch(source,/process\.env|fetch\(|execSync|INSERT INTO|UPDATE ll_|createPicker|createMerchant/) - assert.match(source,/controlledPalletMeasurements/) + assert.match(source,/confirmFleetLoading/) +}) + +async function afterLoadingModel(fault = '') { + const m = model(fault) + m.stopAfterLoading() + await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/Explicit test checkpoint after confirmed loading/) + const priorProof = structuredClone(m.context.createdResources.fleetCrossCustomer) + assert.equal(priorProof.status,'FAILED') + assert.equal(priorProof.gateProof,'PREREQUISITE_REJECTION_VERIFIED') + assert.equal(m.fixture.tripProof.loadingCommand.result.stage,'AFTER_LOADING') + delete m.context.createdResources.fleetCrossCustomer + m.fixture.stage = afterLoadingContinuationContract.stage + m.resumeAfterLoading() + m.calls.length = 0 + return {...m,priorProof} +} + +const continueLoaded = (m,extra = {}) => continueFleetCrossCustomerAfterLoading(m.context,m.fixture, + {execute:true,readInventory:m.readInventory,priorProof:m.priorProof,completionPolling:{maxAttempts:8,intervalMs:1,deadlineMs:1000},...extra}) +const writes = m => m.calls.filter(call=>!['GET','HEAD','OPTIONS'].includes(call.method)) +function alterRead(m,match,alter) { + const original = m.context.request + m.context.request = async function(...args) { + const response = await original.apply(this,args) + if(args[0]==='GET'&&match(args[1]))alter(response.body.data) + return response + } +} + +test('explicit after-loading import/default stays offline and is not a fallback of the fresh entry',async()=>{ + let called=false + assert.equal(await continueFleetCrossCustomerAfterLoading({request(){called=true}},{}),afterLoadingContinuationContract) + assert.equal(called,false) + const m=await afterLoadingModel() + await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory})) + assert.equal(m.calls.length,0) +}) + +test('explicit after-loading continuation reconciles original proof and never repeats picking or loading',async()=>{ + const m=await afterLoadingModel(),original=structuredClone(m.priorProof),loading=structuredClone(m.fixture.tripProof.loadingCommand) + const proof=await continueLoaded(m) + assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true) + assert.equal(proof.continuation.mode,'EXPLICIT_AFTER_LOADING') + assert.equal(proof.continuation.gateProofSource,'PRIOR_PROOF') + assert.match(proof.continuation.priorProofSHA256,/^[a-f0-9]{64}$/) + const normalize=value=>Array.isArray(value)?value.map(normalize):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])):value + assert.equal(proof.continuation.priorProofSHA256,createHash('sha256').update(JSON.stringify(normalize(original))).digest('hex')) + assert.deepEqual(m.priorProof,original);assert.deepEqual(m.fixture.tripProof.loadingCommand,loading) + assert.equal(proof.snapshots.AFTER_PICK_1,undefined,'no historical loading snapshot is fabricated in the new proof') + assert.ok(proof.snapshots.CONTINUATION_INITIAL);assert.deepEqual(proof.continuation.originalAfterPickSnapshot,original.snapshots.AFTER_PICK_1) + assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false) + assert.equal(writes(m).filter(call=>call.raw.endsWith('/transfer-delivery')).length,2,'one arrangement plus the exact original-key replay') + const departures=writes(m).filter(call=>call.raw.endsWith('/depart')) + assert.deepEqual(departures.map(call=>call.token),[m.fixture.pdaToken,m.fixture.pdaToken,m.context.token]) + assert.deepEqual(m.state.map(row=>row.delivery.order.status),['DELIVERED','DELIVERED']) + assert.equal(JSON.stringify(proof).includes(m.fixture.pdaToken),false) +}) + +test('after-loading accepts the official NON_NULL preview when the unarranged field is omitted',async()=>{ + const m=await afterLoadingModel() + let omitted=false + alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{ + delete value.arrangement + omitted=!Object.hasOwn(value,'arrangement') + }) + const proof=await continueLoaded(m) + assert.equal(omitted,true);assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true) + assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false) +}) + +test('after-loading rejects even an empty existing arrangement object before any business write',async()=>{ + const m=await afterLoadingModel() + alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{value.arrangement={}}) + await assert.rejects(continueLoaded(m),/Continuation has no prior arrangement/) + assert.equal(writes(m).length,0) +}) + +for(const failure of ['missing prior','marker','gate','source identity','prior picking','unknown prior write','loading result','loading manifest','loading pallet','loading version']) { + test(`after-loading refuses incomplete or foreign source proof before any request: ${failure}`,async()=>{ + const m=await afterLoadingModel() + if(failure==='missing prior')m.priorProof=undefined + if(failure==='marker')m.priorProof.marker='OTHER-RUN' + if(failure==='gate')m.priorProof.gateProof='NOT_RUN' + if(failure==='source identity')m.priorProof.members[0].positionId='FOREIGN-POSITION' + if(failure==='prior picking')m.priorProof.snapshots.AFTER_PICK_1.dispatch.counts.pickedPackageCount=1 + if(failure==='unknown prior write')m.priorProof.requests.push({method:'POST',path:'/unconfirmed-command',status:'UNKNOWN_RESULT'}) + if(failure==='loading result')delete m.fixture.tripProof.loadingCommand.result + if(failure==='loading manifest')m.fixture.tripProof.loadingCommand.body.manifestHash='OTHER-MANIFEST' + if(failure==='loading pallet')m.fixture.tripProof.loadingCommand.result.result.confirmedLoad.pallets[0].warehousePalletId='OTHER-PALLET' + if(failure==='loading version')m.fixture.tripProof.loadingCommand.body.recordVersion='1' + await assert.rejects(continueLoaded(m));assert.equal(m.calls.length,0) + }) +} + +for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity','manifest drift','confirmed load drift','existing arrangement']) { + test(`after-loading refuses changed current facts before arranging or departing: ${failure}`,async()=>{ + const m=await afterLoadingModel() + if(failure==='inventory drift')m.state[0].stock.ledger.push({...m.state[0].stock.ledger[0],id:'UNEXPECTED-MOVEMENT'}) + if(failure==='current main version')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{value.recordVersion=999}) + if(failure==='current shipped')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{value.counts.shippedPackageCount=1}) + if(failure==='existing outbound')alterRead(m,path=>path.endsWith('/DS-ONE'),value=>{if(value.documents)value.documents.outbounds.push({outboundNo:'EXISTING'})}) + if(failure==='current source identity')alterRead(m,path=>path.endsWith('/items'),value=>{value.items[0].warehousePalletId='FOREIGN'}) + if(failure==='manifest drift')alterRead(m,path=>path.endsWith('/loading'),value=>{value.manifestHash='OTHER'}) + if(failure==='confirmed load drift')alterRead(m,path=>path.endsWith('/loading'),value=>{value.confirmedLoad.pallets[0].weightKg=999}) + if(failure==='existing arrangement')alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{value.arrangement={status:'ARRANGED'}}) + await assert.rejects(continueLoaded(m));assert.equal(writes(m).length,0) + }) +} + +test('read-only aggregation polling accepts delayed completion without repeating receipt uploads or signatures',async()=>{ + const m=await afterLoadingModel('aggregation delayed') + const proof=await continueLoaded(m) + assert.equal(proof.completionPolling.status,'COMPLETED') + assert.ok(proof.completionPolling.attempts.length>1&&proof.completionPolling.attempts.length<=8) + assert.ok(proof.completionPolling.attempts.every(entry=>entry.status==='RESPONDED')) + assert.equal(m.calls.filter(call=>call.multipart).length,2) + assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2) +}) + +test('read-only aggregation polling times out as a real failure without changing the dispatch stage',async()=>{ + const m=await afterLoadingModel('aggregation never completes') + await assert.rejects(continueLoaded(m,{completionPolling:{maxAttempts:3,intervalMs:1,deadlineMs:1000}}),/Automatic delivery completion/) + const proof=m.context.createdResources.fleetCrossCustomer + assert.equal(proof.status,'FAILED');assert.equal(proof.completionPolling.status,'TIMED_OUT') + assert.equal(proof.completionPolling.attempts.length,3) + assert.ok(proof.completionPolling.attempts.every(entry=>entry.stage==='DELIVERING')) + assert.equal(m.calls.filter(call=>call.multipart).length,2) + assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2) +}) + +for(const failure of ['permission','structure'])test(`completion polling terminates immediately on ${failure} errors`,async()=>{ + const m=await afterLoadingModel(),original=m.context.request + let reads=0 + m.context.request=async function(...args){ + const response=await original.apply(this,args) + if(args[0]==='GET'&&args[1]==='/api/platform/v1/last-leg/dispatches/DS-ONE' + &&m.context.createdResources.fleetCrossCustomer?.completionPolling?.status==='WAITING') { + reads++ + if(failure==='permission')return {http:403,body:{code:'A0403'},requestId:'request',traceId:'trace'} + response.body.data.documents={outbounds:[],deliveries:[]} + } + return response + } + await assert.rejects(continueLoaded(m)) + assert.equal(reads,1);assert.equal(m.context.createdResources.fleetCrossCustomer.completionPolling.status,'FAILED') + assert.equal(m.calls.filter(call=>call.multipart).length,2) + assert.equal(m.calls.filter(call=>call.method==='PUT'&&call.raw.includes('/documents?')).length,2) }) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs index 9d7bc0a79..ed825617e 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs @@ -1,4 +1,5 @@ -import { controlledPalletMeasurements } from './fleet-trip-fulfillment.mjs' +import { createHash } from 'node:crypto' +import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs' const MODULE = 'fleet-cross-customer-acceptance' const ROOT = '/api/platform/v1/last-leg' @@ -10,57 +11,82 @@ const encode = encodeURIComponent const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}` const canonical = value => JSON.stringify(normalize(value)) function normalize(value) { - // Route stops and other business arrays are ordered. Raw readers return stable ID order. if (Array.isArray(value)) return value.map(normalize) - return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])) : value + return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value } function integer(value) { - if (!['number','string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer') + if (!['number', 'string'].includes(typeof value) || !/^(0|[1-9][0-9]*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) throw new Error('Invalid inventory integer') return Number(value) } -const buckets = ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages'] -const balanceBuckets = [...buckets,'inTransitPackages','procurementPackages'] - +const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] +const balanceBuckets = [...buckets, 'inTransitPackages', 'procurementPackages'] export const fixtureContract = Object.freeze({ - stage: 'TWO_FRESH_RECEIVED_DISPATCHES_BOUND_AND_TRANSFERRED_NOT_PICKED', - fixture: 'marker, warehouseId, tripNo, pdaToken, members[2]: {marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified:true}', - preparation: 'Two independently qualified current-run customers; each formally received one physical pallet/box in the same warehouse; draft lock/pre-arrange separately; bind both to one assigned qualified vehicle/driver trip; transfer each to its own outbound and delivery. No picking yet.', - reader: 'readInventory(scope) => {scope:{marker,customerId,warehouseId,coverage:"CUSTOMER_WAREHOUSE_COMPLETE"}, balances:all raw rows including zero, positions:all raw rows including zero, ledger:all immutable movement rows}. Retain camelCase IDs and every quantity bucket; never substitute a grouped public SKU page.', - helperLimit: 'Existing external-receipt helper selects plan.cargo[0].customerId and requires an existing own-cargo capacity plan; external-dispatch helper has one mutable externalReceipt slot. Do not run either twice pretending to create two customers.', - boundary: 'This exercises a prerequisite gate rejection, not a mid-transaction rollback. No fault injection, account activation, SQL writes, deployment, cleanup deletion or implicit fixture creation.', + stage: 'TWO_FRESH_CUSTOMERS_ONE_REVIEWED_DISPATCH_LOADING_NOT_PICKED', + fixture: 'marker, warehouseId, dispatchNo, tripNo, tripProof, pdaToken, optional current-run loadingMeasurements; members[2]: marker/customerId/incomingNo/shared dispatchNo/dispatchItemId/balanceId/positionId/palletId/palletNo/receiptVerified', + preparation: 'Two independently qualified customers formally received one pallet/box each in the same warehouse. One native Create with both actual source selections; automatic unique Fleet record, resources and both native reviews. No picking or delivery arrangement.', + reader: 'readInventory(scope) returns all raw balances/positions/immutable movements including zero, exact marker/customer/warehouse and CUSTOMER_WAREHOUSE_COMPLETE coverage.', + boundary: 'Prerequisite rejection is separate from an explicitly injected real mid-transaction rollback. Actual loading needs actual evidence; absent measurement fields block. No environment loader, SQL or implicit account lifecycle.', }) -/** Import/default call is network-free. Explicit execute:true requires separately authorized TEST execution. - * Tokens stay in the caller, never in the persisted proof. Failure retains fixture and exact request keys. - */ -export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory, verifyDepartureRollback } = {}) { +export const afterLoadingContinuationContract = Object.freeze({ + ...fixtureContract, + stage: 'TWO_OWNED_CUSTOMERS_ONE_DISPATCH_AFTER_LOADING_NOT_ARRANGED', + preparation: 'Explicit continuation only: prior failed proof with verified gate and complete AFTER_PICK_1; original successful loadingCommand/confirmedLoad; current AFTER_LOADING with no arrangement or execution documents.', + boundary: 'Reconcile original identities, complete picked inventory and original loading receipt through current reads before any new write. Never repeat picking/loading or rewrite the prior proof.', +}) + +export async function verifyFleetCrossCustomer(context, fixture, { execute = false, readInventory, verifyDepartureRollback, completionPolling } = {}) { if (!execute) return fixtureContract - if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== fixtureContract.stage - || !fixture.tripNo || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function' - || fixture.members?.length !== 2 || new Set(fixture.members.map(row=>row.customerId)).size !== 2 - || fixture.members.some(row=>row.marker!==context.marker || row.receiptVerified!==true - || ['customerId','incomingNo','dispatchNo','outboundNo','deliveryNo','balanceId','positionId','palletId','palletNo'].some(key=>!id(row[key])))) { - throw new Error('Requires two distinct current-run customers, immutable receipt references and a complete raw inventory reader') - } - for (const key of ['dispatchNo','outboundNo','deliveryNo','positionId','palletId']) { - if (new Set(fixture.members.map(row=>id(row[key]))).size!==2) throw new Error(`Cross-customer fixture reuses ${key}`) - } - context.createdResources ||= {} + return runCrossCustomerCore(context, fixture, { readInventory, verifyDepartureRollback, completionPolling }) +} + +export async function continueFleetCrossCustomerAfterLoading(context, fixture, { execute = false, readInventory, verifyDepartureRollback, priorProof, completionPolling } = {}) { + if (!execute) return afterLoadingContinuationContract + if (!priorProof) throw new Error('Explicit prior cross-customer proof is required for AFTER_LOADING continuation') + return runCrossCustomerCore(context, { ...fixture, members: structuredClone(fixture?.members) }, + { readInventory, verifyDepartureRollback, priorProof, completionPolling }) +} + +async function runCrossCustomerCore(context, fixture, { readInventory, verifyDepartureRollback, priorProof, completionPolling }) { + const continuation = priorProof !== undefined + const contract = continuation ? afterLoadingContinuationContract : fixtureContract + if (!context?.marker || fixture?.marker !== context.marker || fixture.stage !== contract.stage + || !fixture.dispatchNo || !fixture.tripNo || fixture.tripProof?.dispatchNo !== fixture.dispatchNo || fixture.tripProof.tripNo !== fixture.tripNo + || !context.createdResources?.fleetTrips?.includes(fixture.tripProof) + || !fixture.warehouseId || !fixture.pdaToken || typeof readInventory !== 'function' || fixture.members?.length !== 2 + || new Set(fixture.members.map(row => row.customerId)).size !== 2 + || fixture.members.some(row => row.marker !== context.marker || row.receiptVerified !== true || row.dispatchNo !== fixture.dispatchNo + || ['customerId', 'incomingNo', 'dispatchItemId', 'balanceId', 'positionId', 'palletId', 'palletNo'].some(field => !id(row[field])))) + throw new Error('Requires two distinct current-run customers and one reviewed native dispatch with complete raw inventory evidence') + for (const field of ['incomingNo', 'dispatchItemId', 'positionId', 'palletId', 'balanceId']) + if (new Set(fixture.members.map(row => id(row[field]))).size !== 2) throw new Error(`Cross-customer fixture reuses ${field}`) + const polling = { maxAttempts: 41, intervalMs: 500, deadlineMs: 20000, ...completionPolling } + if (!Number.isSafeInteger(polling.maxAttempts) || polling.maxAttempts < 1 || polling.maxAttempts > 121 + || !Number.isSafeInteger(polling.intervalMs) || polling.intervalMs < 1 || polling.intervalMs > 2000 + || !Number.isSafeInteger(polling.deadlineMs) || polling.deadlineMs < 1 || polling.deadlineMs > 60000) + throw new Error('Finite completion polling bounds are required') + context.createdResources ??= {} if (context.createdResources.fleetCrossCustomer) throw new Error('Proof already exists; retain failed run and reconcile explicitly') - const proof = context.createdResources.fleetCrossCustomer = { marker:context.marker,tripNo:fixture.tripNo, - warehouseId:fixture.warehouseId,members:fixture.members.map(({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})=>({marker,customerId,incomingNo,dispatchNo,outboundNo,deliveryNo,balanceId,positionId,palletId,palletNo})), - status:'CHECKING',requests:[],snapshots:{},gateProof:'NOT_RUN',midTransactionRollback:'NOT_TESTED',cleanup:'Caller owns account/session lifecycle; retain all business fixtures' } - const check = (name, passed) => { - context.assert(MODULE,name,null,Boolean(passed),'',OPTIONS) - if (!passed) throw new Error(name) - } + const proof = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo, tripNo: fixture.tripNo, + warehouseId: fixture.warehouseId, members: fixture.members.map(({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo }) => + ({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo })), + status: 'CHECKING', requests: [], snapshots: {}, gateProof: 'NOT_RUN', midTransactionRollback: 'NOT_TESTED', cleanup: 'Caller owns account/session lifecycle; retain all business fixtures' } + const check = (name, passed) => { context.assert(MODULE, name, null, Boolean(passed), '', OPTIONS); if (!passed) throw new Error(name) } + const equal = (a, b, name) => check(name, canonical(a) === canonical(b)) const key = suffix => `${context.marker}-CROSS-${suffix}` + const path = `${ROOT}/dispatches/${encode(fixture.dispatchNo)}`, pdaPath = `${PDA}/${encode(fixture.dispatchNo)}` const tripPath = `${ROOT}/fleet/trips/${encode(fixture.tripNo)}` - const request = async (method,path,body,label,pda=false,rejection=false) => { - const response = await context.request(method,path,body,pda?fixture.pdaToken:context.token,{}) - proof.requests.push({method,path,body,label,http:response?.http,code:response?.body?.code,requestId:response?.requestId,traceId:response?.traceId}) - const denied = response?.http===409 || (response?.http===200 && id(response.body?.code)==='A0409') - check(label,Boolean(response?.requestId && response?.traceId) && (rejection?denied && rejection(id(response.body?.message)):ok(response))) + const request = async (method, target, body, label, pda = false, rejection) => { + const entry = { method, path: target, body, label, status: 'SENDING' }; proof.requests.push(entry); await context.checkpoint?.() + let response + try { response = await context.request(method, target, body, pda ? fixture.pdaToken : context.token, {}) } + catch (error) { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint?.(); throw error } + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) + await context.checkpoint?.() + const denied = response?.http === 409 || (response?.http === 200 && id(response.body?.code) === 'A0409') + const actorDenied = [200,403].includes(response?.http) && id(response.body?.code) === 'A0403' + check(label, Boolean(response?.requestId && response?.traceId) + && (rejection === 'ACTOR_FORBIDDEN' ? actorDenied : rejection ? denied && rejection(id(response.body?.message)) : ok(response))) return response.body?.data } const stock = async member => { @@ -81,120 +107,273 @@ export async function verifyFleetCrossCustomer(context, fixture, { execute = fal return raw } const snapshot = async label => { - const trip=await request('GET',tripPath,undefined,'Read shared trip') - check('Trip members are exactly the two independent customers',trip.trip?.tripNo===fixture.tripNo && id(trip.trip.warehouseId)===id(fixture.warehouseId) - && trip.dispatches?.length===2 && fixture.members.every(member=>trip.dispatches.some(row=>row.dispatchNo===member.dispatchNo && id(row.customerId)===id(member.customerId)))) - const members=[] - for(const member of fixture.members) { - const dispatch=await request('GET',`${ROOT}/dispatches/${encode(member.dispatchNo)}`,undefined,'Read customer dispatch') - const outbound=await request('GET',scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`,member.customerId),undefined,'Read customer outbound') - const delivery=await request('GET',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId),undefined,'Read customer delivery') - const task=await request('GET',`${PDA}/${encode(member.dispatchNo)}`,undefined,'Read PDA task',true) - check('Execution records retain exact customer, warehouse and dispatch ownership',dispatch.dispatchNo===member.dispatchNo - && id(dispatch.overseasWarehouseId)===id(fixture.warehouseId) && dispatch.items?.length===1 - && id(dispatch.items[0].warehousePalletId)===id(member.palletId) - && outbound.outbound?.outboundNo===member.outboundNo && outbound.outbound.dispatchNo===member.dispatchNo - && id(outbound.outbound.overseasWarehouseId)===id(fixture.warehouseId) && outbound.deliveries?.length===1 - && outbound.deliveries[0].deliveryNo===member.deliveryNo && id(outbound.deliveries[0].customerId)===id(member.customerId) - && delivery.order?.deliveryNo===member.deliveryNo && id(delivery.order.customerId)===id(member.customerId) - && delivery.order.dispatchNo===member.dispatchNo && delivery.order.outboundNo===member.outboundNo - && task.dispatchNo===member.dispatchNo && id(task.warehouseId)===id(fixture.warehouseId) && task.tripNo===fixture.tripNo) - members.push({dispatch,outbound,delivery,task,stock:await stock(member)}) + const trip = await request('GET', tripPath, undefined, 'Read automatic unique Fleet resource record') + check('One trip binds exactly this one native cross-customer dispatch', trip.trip?.tripNo === fixture.tripNo + && id(trip.trip.warehouseId) === id(fixture.warehouseId) && trip.dispatches?.length === 1 && trip.dispatches[0].dispatchNo === fixture.dispatchNo) + const dispatch = await request('GET', path, undefined, 'Read native whole dispatch') + const items = await request('GET', `${path}/items`, undefined, 'Read actual per-customer cargo') + const task = await request('GET', pdaPath, undefined, 'Read bound warehouse native PDA task', true) + check('Native main/items/PDA retain both true customers and physical pallets', dispatch.dispatchNo === fixture.dispatchNo + && items.dispatchNo === fixture.dispatchNo && task.dispatchNo === fixture.dispatchNo && id(task.warehouseId) === id(fixture.warehouseId) + && id(dispatch.recordVersion) === id(items.recordVersion) && id(task.recordVersion) === id(dispatch.recordVersion) + && items.items?.length === 2 && task.items?.length === 2 && fixture.members.every(member => + items.items.some(i => id(i.dispatchItemId) === member.dispatchItemId && id(i.customerId) === member.customerId + && id(i.warehousePalletId) === member.palletId && i.palletNo === member.palletNo && integer(i.scheduledPackages) === 1) + && task.items.some(i => id(i.dispatchItemId) === member.dispatchItemId && id(i.customerId) === member.customerId && i.palletNo === member.palletNo))) + const members = [] + for (const member of proof.members) { + let outbound = null, delivery = null + if (member.outboundNo || member.deliveryNo) { + check('Customer execution documents are both explicit', member.outboundNo && member.deliveryNo) + outbound = await request('GET', scoped(`${ROOT}/outbound-orders/${encode(member.outboundNo)}`, member.customerId), undefined, 'Read customer outbound') + delivery = await request('GET', scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`, member.customerId), undefined, 'Read customer delivery') + check('Execution documents keep customer, warehouse and shared main ownership', outbound.outbound?.outboundNo === member.outboundNo + && outbound.outbound.dispatchNo === fixture.dispatchNo && id(outbound.outbound.overseasWarehouseId) === id(fixture.warehouseId) + && outbound.deliveries?.length === 1 && outbound.deliveries[0].deliveryNo === member.deliveryNo + && id(outbound.deliveries[0].customerId) === member.customerId && delivery.order?.deliveryNo === member.deliveryNo + && id(delivery.order.customerId) === member.customerId && delivery.order.dispatchNo === fixture.dispatchNo && delivery.order.outboundNo === member.outboundNo) + } + members.push({ outbound, delivery, stock: await stock(member) }) } - const value={trip,members};proof.snapshots[label]=value;return value + const value = { trip, dispatch, items, task, members }; proof.snapshots[label] = value; return value } - const equal = (a,b,name)=>check(name,canonical(a)===canonical(b)) - const pick = async (member,index) => { - const before=await snapshot(`BEFORE_PICK_${index}`),task=before.members[index].task - check('PDA exposes the single real current-run pallet and position',task.items?.length===1 && integer(task.items[0].scheduledPackages)===1 - && integer(task.items[0].pickedPackages)===0 && task.items[0].palletNo===member.palletNo && task.items[0].positions?.length===1 - && id(task.items[0].positions[0].id)===id(member.positionId) && integer(task.items[0].positions[0].availablePackages)===1) - const body={positionId:member.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker} - const path=`${PDA}/${encode(member.dispatchNo)}/items/${encode(task.items[0].dispatchItemId)}/pick` - await request('POST',path,body,'Pick this customer only',true) - await request('POST',`${PDA}/${encode(member.dispatchNo)}/complete`,{idempotencyKey:key(`COMPLETE-${index}`)},'Complete this customer picking',true) - const after=await snapshot(`AFTER_PICK_${index}`) - equal(before.members[1-index].stock,after.members[1-index].stock,'Other customer inventory unchanged by picking') - const expected=structuredClone(before.members[index].stock) - for(const collection of ['balances','positions']) { - const row=expected[collection].find(row=>id(row.id)===id(collection==='balances'?member.balanceId:member.positionId)) - row.availablePackages=integer(row.availablePackages)-1 - } - const counts=raw=>Object.fromEntries(['balances','positions'].map(collection=>[collection,raw[collection].map(row=>({id:id(row.id),...Object.fromEntries((collection==='balances'?balanceBuckets:buckets).map(field=>[field,integer(row[field])]))}))])) - equal(counts(expected),counts(after.members[index].stock),'Only selected customer position and balance lose one box') - const old=before.members[index].stock.ledger,newRows=after.members[index].stock.ledger.filter(row=>!old.some(prior=>id(prior.id)===id(row.id))) - equal(old,after.members[index].stock.ledger.filter(row=>old.some(prior=>id(prior.id)===id(row.id))),'Immutable old movements preserved') - check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity',newRows.length===3 && ['ALLOCATE','PICK','SHIP'].every(type=>newRows.filter(row=>row.businessType===type - && id(row.positionId)===id(member.positionId) && row.businessOrderNo===member.dispatchNo && Number(row.deltaPackages)===(type==='SHIP'?-1:1)).length===1)) - await request('POST',path,body,'Replay exact customer pick',true) - equal(after,await snapshot(`PICK_REPLAY_${index}`),'Pick replay changes neither customer nor any execution record') + const pick = async (member, index) => { + const before = await snapshot(`BEFORE_PICK_${index}`), item = before.task.items.find(i => id(i.dispatchItemId) === member.dispatchItemId) + check('PDA exposes exactly this customer real current-run pallet and position', integer(item.scheduledPackages) === 1 && integer(item.pickedPackages) === 0 + && item.palletNo === member.palletNo && item.positions?.length === 1 && id(item.positions[0].positionId) === member.positionId + && integer(item.positions[0].availablePackages) === 1) + const body = { recordVersion: before.task.recordVersion, positionId: member.positionId, packages: 1, productQuantity: 1, + idempotencyKey: key(`PICK-${index}`), remark: context.marker } + const pickPath = `${pdaPath}/items/${encode(member.dispatchItemId)}/pick` + await request('POST', pickPath, body, 'Pick this customer only', true) + const after = await snapshot(`AFTER_PICK_${index}`) + equal(before.members[1-index].stock, after.members[1-index].stock, 'Other customer inventory unchanged by picking') + const expected = structuredClone(before.members[index].stock) + for (const collection of ['balances', 'positions']) expected[collection].find(row => id(row.id) === (collection === 'balances' ? member.balanceId : member.positionId)).availablePackages = 0 + const counts = raw => Object.fromEntries(['balances','positions'].map(collection => [collection, raw[collection].map(row => ({ id: id(row.id), + ...Object.fromEntries((collection === 'balances' ? balanceBuckets : buckets).map(field => [field, integer(row[field])])) }))])) + equal(counts(expected), counts(after.members[index].stock), 'Only selected customer position and balance lose one box') + const old = before.members[index].stock.ledger, newRows = after.members[index].stock.ledger.filter(row => !old.some(prior => id(prior.id) === id(row.id))) + equal(old, after.members[index].stock.ledger.filter(row => old.some(prior => id(prior.id) === id(row.id))), 'Immutable old movements preserved') + check('Single ALLOCATE/PICK/SHIP sequence has exact source and quantity', newRows.length === 3 && ['ALLOCATE','PICK','SHIP'].every(type => newRows.filter(row => + row.businessType === type && id(row.positionId) === member.positionId && row.businessOrderNo === fixture.dispatchNo && Number(row.deltaPackages) === (type === 'SHIP' ? -1 : 1)).length === 1)) + await request('POST', pickPath, body, 'Replay exact customer pick', true) + equal(after, await snapshot(`PICK_REPLAY_${index}`), 'Pick replay changes neither customer nor any execution record') } try { - let initial=await snapshot('INITIAL') - check('Minimal fresh fixture has one available box per customer and no prior execution',initial.trip.trip.status==='PLANNED' - && initial.members.every((row,index)=>row.dispatch.status==='PENDING_DEPARTURE' && integer(row.dispatch.packageCount)===1 - && integer(row.dispatch.pickedPackageCount)===0 && row.outbound.outbound.status==='WAIT_OUTBOUND' && row.delivery.order.status==='WAIT_DELIVERY' - && integer(row.stock.positions.find(p=>id(p.id)===id(fixture.members[index].positionId)).availablePackages)===1 - && row.stock.balances.every(balance=>balanceBuckets.filter(field=>field!=='availablePackages').every(field=>integer(balance[field])===0)) - && row.stock.balances.reduce((sum,balance)=>sum+integer(balance.availablePackages),0)===1)) - let detail=initial.trip - for(const member of fixture.members)detail=await request('POST',`${tripPath}/dispatches/${encode(member.dispatchNo)}/review`, - {recordVersion:detail.trip.recordVersion,decision:'APPROVED'},'Review each customer dispatch separately') - check('Both customer dispatches approved',detail.dispatches.every(row=>row.review?.status==='APPROVED')) - await pick(fixture.members[0],0) - const rejectedBefore=await snapshot('GATE_BEFORE') - check('Precisely one customer is ready',rejectedBefore.members[0].dispatch.status==='READY_TO_DEPART' && integer(rejectedBefore.members[1].dispatch.pickedPackageCount)===0) - await request('POST',`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart`, - {recordVersion:rejectedBefore.trip.trip.recordVersion,idempotencyKey:key('UNREADY-DEPART'),remark:context.marker},'Unready second customer blocks whole trip before execution',true, - message=>message.includes(fixture.members[1].dispatchNo) && message.includes('请完成拣货并核平全部箱数')) - equal(rejectedBefore,await snapshot('GATE_AFTER'),'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged') - proof.gateProof='PREREQUISITE_REJECTION_VERIFIED' - await pick(fixture.members[1],1) - const loading=await request('GET',`${tripPath}/loading`,undefined,'Read ready shared loading') - check('Both real pallets ready for loading',!loading.blockReason && loading.cargo?.length===2) - detail=await request('GET',tripPath,undefined,'Read current trip route') - const measured=controlledPalletMeasurements(loading.cargo,detail.trip.stops) - const loaded=await request('POST',`${tripPath}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:measured,reason:context.marker},'Confirm controlled TEST measurements') - const reviewed=await request('POST',`${tripPath}/loading-review`,{recordVersion:loaded.recordVersion,decision:'APPROVED'},'Approve whole-trip loading') - const ready=await snapshot('READY') - const departBody={recordVersion:reviewed.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker} - const departPath=`${PDA}/${encode(fixture.members[0].dispatchNo)}/depart` - if(verifyDepartureRollback!==undefined) { - check('Explicit rollback verifier is callable',typeof verifyDepartureRollback==='function') - const rollback=await verifyDepartureRollback({context,fixture,ready,departPath,departBody,snapshot}) - check('Mid-transaction rollback has exact owned verification',rollback?.verified===true && rollback.scope==='OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') - equal(ready,await snapshot('ROLLBACK_RESTORED'),'Mid-transaction rollback restores both complete customer execution and inventory snapshots') - proof.rollbackEvidence=rollback;proof.midTransactionRollback='VERIFIED' + let cargoBasis, originalLoadingHash + if (continuation) { + const prior = priorProof.snapshots?.AFTER_PICK_1, command = fixture.tripProof.loadingCommand + const confirmed = command?.result?.result?.confirmedLoad + check('Continuation has the original failed owned proof and verified prerequisite gate', priorProof.status === 'FAILED' + && priorProof.marker === context.marker && priorProof.dispatchNo === fixture.dispatchNo && priorProof.tripNo === fixture.tripNo + && id(priorProof.warehouseId) === id(fixture.warehouseId) && priorProof.gateProof === 'PREREQUISITE_REJECTION_VERIFIED' + && priorProof.members?.length === 2 && !priorProof.arrangeRequest && !priorProof.departure + && !priorProof.members.some(member => member.outboundNo || member.deliveryNo) + && Array.isArray(priorProof.requests) && priorProof.requests.every(entry => ['GET','HEAD','OPTIONS'].includes(entry.method) || entry.status === 'RESPONDED')) + const identityFields = ['marker','customerId','incomingNo','dispatchNo','dispatchItemId','balanceId','positionId','palletId','palletNo'] + check('Continuation preserves each original customer, source, item, balance, position and physical pallet', fixture.members.every((member,index) => + identityFields.every(field => id(member[field]) === id(priorProof.members[index]?.[field])))) + check('Prior AFTER_PICK_1 is a complete reviewed picked but not shipped native snapshot', prior?.dispatch?.dispatchNo === fixture.dispatchNo + && prior.dispatch.status === 'ACTIVE' && prior.dispatch.stage === 'LOADING' && prior.dispatch.reviewState === 'APPROVED' + && prior.dispatch.reviews?.dispatchReview?.status === 'APPROVED' && prior.dispatch.reviews?.loadingReview?.status === 'APPROVED' + && integer(prior.dispatch.counts?.packageCount) === 2 && integer(prior.dispatch.counts?.pickedPackageCount) === 2 + && integer(prior.dispatch.counts?.shippedPackageCount) === 0 && prior.task?.pickingReconciled === true + && prior.task.dispatchNo === fixture.dispatchNo && id(prior.task.warehouseId) === id(fixture.warehouseId) + && prior.task.stage === 'LOADING' && id(prior.task.recordVersion) === id(prior.dispatch.recordVersion) + && prior.items?.dispatchNo === fixture.dispatchNo && id(prior.items.recordVersion) === id(prior.dispatch.recordVersion) + && prior.items.items?.length === 2 && prior.task.items?.length === 2 + && prior.members?.length === 2 && prior.members.every(row => row.stock && !row.outbound && !row.delivery) + && prior.dispatch.documents?.outbounds?.length === 0 && prior.dispatch.documents?.deliveries?.length === 0) + check('Original loading command has a successful explicit receipt for this main and both exact physical pallets', command?.path === `${pdaPath}/loading-complete` + && command.body?.idempotencyKey && command.body.manifestHash && command.result?.dispatchNo === fixture.dispatchNo + && command.result.action === 'LOADING_COMPLETE' && command.result.idempotencyKey === command.body.idempotencyKey + && command.result.status === 'ACTIVE' && command.result.stage === 'AFTER_LOADING' && command.result.result?.loadingCompletionHash + && id(command.body.recordVersion) === id(prior.dispatch.recordVersion) + && /^(0|[1-9]\d*)$/.test(id(command.result.recordVersion)) + && /^(0|[1-9]\d*)$/.test(id(command.body.recordVersion)) + && BigInt(id(command.result.recordVersion)) > BigInt(id(command.body.recordVersion)) + && integer(command.result.counts?.packageCount) === 2 && integer(command.result.counts?.pickedPackageCount) === 2 + && integer(command.result.counts?.shippedPackageCount) === 0 + && confirmed?.manifestHash === command.body.manifestHash && confirmed.operatorId && Number.isFinite(Date.parse(confirmed.confirmedAt)) + && canonical(confirmed) === canonical(fixture.tripProof.confirmedLoad) + && Array.isArray(command.body.measurements) && command.body.measurements.length === 2 + && canonical(command.body.measurements) === canonical(confirmed.pallets) + && new Set(confirmed.pallets.map(row => id(row.warehousePalletId))).size === 2 + && fixture.members.every(member => confirmed.pallets.some(row => id(row.warehousePalletId) === member.palletId + && Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0 + && ['weightKg','volumeCbm'].every(field => ['number','string'].includes(typeof row[field]) + && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number.isFinite(Number(row[field])) && Number(row[field]) > 0) + && Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0 && fixture.tripProof.stops?.[row.deliveryIndex]?.destinationKey))) + proof.continued = true + proof.continuation = { mode: 'EXPLICIT_AFTER_LOADING', priorProofSHA256: createHash('sha256').update(canonical(priorProof)).digest('hex'), + priorStatus: priorProof.status, gateProofSource: 'PRIOR_PROOF', originalAfterPickSnapshot: structuredClone(prior), + originalLoadingCommand: structuredClone(command), originalConfirmedLoad: structuredClone(confirmed) } + proof.gateProof = priorProof.gateProof + await context.checkpoint?.() + const initial = await snapshot('CONTINUATION_INITIAL') + check('Current main is exactly the unarranged confirmed loading version with two picked and zero shipped packages', initial.dispatch.status === 'ACTIVE' + && initial.dispatch.stage === 'AFTER_LOADING' && initial.task.stage === 'AFTER_LOADING' && initial.trip.trip.status === 'PLANNED' + && id(initial.dispatch.recordVersion) === id(command.result.recordVersion) && initial.task.pickingReconciled === true + && initial.dispatch.reviewState === 'APPROVED' && initial.dispatch.reviews?.dispatchReview?.status === 'APPROVED' + && initial.dispatch.reviews?.loadingReview?.status === 'APPROVED' && integer(initial.dispatch.counts.packageCount) === 2 + && integer(initial.dispatch.counts.pickedPackageCount) === 2 && integer(initial.dispatch.counts.shippedPackageCount) === 0 + && initial.dispatch.documents?.outbounds?.length === 0 && initial.dispatch.documents?.deliveries?.length === 0 + && id(initial.trip.trip.vehicleId) === id(fixture.tripProof.vehicleId) && id(initial.trip.trip.driverId) === id(fixture.tripProof.driverId) + && id(prior.trip?.trip?.vehicleId) === id(fixture.tripProof.vehicleId) && id(prior.trip?.trip?.driverId) === id(fixture.tripProof.driverId) + && canonical(initial.trip.trip.stops) === canonical(fixture.tripProof.stops) + && initial.items.items.every(item => integer(item.pickedPackages) === 1) && initial.task.items.every(item => integer(item.pickedPackages) === 1)) + equal(prior.members.map(row => row.stock), initial.members.map(row => row.stock), 'Continuation current complete inventory equals original AFTER_PICK_1 inventory') + const sourceItems = items => items.map(item => Object.fromEntries(['dispatchItemId','customerId','sourceKey','sourceVersion','sourceType', + 'planPalletId','returnReceiptId','relabelNo','warehousePalletId','palletNo','orderNo','inboundCode','destination','scheduledPackages','pickedPackages'] + .map(field => [field,item[field]]))).sort((a,b) => id(a.dispatchItemId).localeCompare(id(b.dispatchItemId))) + equal(sourceItems(prior.items.items), sourceItems(initial.items.items), 'Continuation preserves both picked source items and destinations') + const loading = await request('GET', `${pdaPath}/loading`, undefined, 'Reconcile current native manifest against original successful loading receipt', true) + check('Current loading preview preserves the original manifest, confirmed receipt and two reconciled physical pallets', loading.dispatchNo === fixture.dispatchNo + && loading.status === 'ACTIVE' && loading.stage === 'AFTER_LOADING' && id(loading.recordVersion) === id(command.result.recordVersion) + && loading.pickingReconciled === true && loading.manifestHash === confirmed.manifestHash + && canonical(loading.confirmedLoad) === canonical(confirmed) && loading.pallets?.length === 2 + && new Set(loading.pallets.map(row => id(row.warehousePalletId))).size === 2 + && fixture.members.every(member => loading.pallets.some(row => id(row.warehousePalletId) === member.palletId + && row.palletNo === member.palletNo && integer(row.scheduledPackages) === 1 && integer(row.pickedPackages) === 1))) + proof.continuation.currentLoadingPreview = loading + cargoBasis = initial + originalLoadingHash = command.result.result.loadingCompletionHash + } else { + const initial = await snapshot('INITIAL') + check('Fresh native fixture is reviewed LOADING with both customer receipts untouched', initial.trip.trip.status === 'PLANNED' + && initial.dispatch.status === 'ACTIVE' && initial.dispatch.stage === 'LOADING' && initial.dispatch.reviewState === 'APPROVED' + && initial.dispatch.reviews?.dispatchReview?.status === 'APPROVED' && initial.dispatch.reviews?.loadingReview?.status === 'APPROVED' + && integer(initial.dispatch.counts.packageCount) === 2 && integer(initial.dispatch.counts.pickedPackageCount) === 0 + && initial.dispatch.documents?.deliveries?.length === 0 && initial.members.every((row,index) => + integer(row.stock.positions.find(p => id(p.id) === fixture.members[index].positionId).availablePackages) === 1 + && row.stock.balances.every(balance => balanceBuckets.filter(field => field !== 'availablePackages').every(field => integer(balance[field]) === 0)) + && row.stock.balances.reduce((sum,balance) => sum + integer(balance.availablePackages), 0) === 1)) + await pick(proof.members[0], 0) + const rejectedBefore = await snapshot('GATE_BEFORE') + check('Precisely one customer picked while main remains LOADING', integer(rejectedBefore.dispatch.counts.pickedPackageCount) === 1 + && rejectedBefore.dispatch.stage === 'LOADING' && rejectedBefore.task.pickingReconciled === false) + const preview = await request('GET', `${pdaPath}/loading`, undefined, 'Read incomplete native loading manifest', true) + await request('POST', `${pdaPath}/loading-complete`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-LOAD'), + manifestHash: preview.manifestHash, measurements: [], remark: context.marker }, 'Unready second customer blocks explicit loading', true, + message => /拣货.*核平|尚未核平/.test(message)) + await request('POST', `${pdaPath}/depart`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-DEPART'), remark: context.marker }, + 'Unready second customer blocks whole trip before execution', true, message => /装车|派送/.test(message)) + equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged') + proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED' + await pick(proof.members[1], 1) + await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements) + cargoBasis = proof.snapshots.AFTER_PICK_1 } - const departed=await request('POST',departPath,departBody,'Depart whole trip once',true) - check('Departure includes both customer dispatches',departed.tripNo===fixture.tripNo && departed.status==='IN_TRANSIT' && integer(departed.shippedPackages)===2 - && canonical(departed.dispatches.map(row=>row.dispatchNo).sort())===canonical(fixture.members.map(row=>row.dispatchNo).sort())) - const after=await snapshot('DEPARTED') - check('Both outbounds completed and trip in transit',after.trip.trip.status==='IN_TRANSIT' && after.members.every(row=>row.dispatch.status==='DEPARTED' && row.outbound.outbound.status==='OUTBOUNDED')) - equal(ready.members.map(row=>row.stock),after.members.map(row=>row.stock),'Departure does not deduct either inventory again') - const replay=await request('POST',departPath,departBody,'Replay exact shared departure',true) - check('Shared departure replay is explicit',replay.replayed===true) - equal(after,await snapshot('DEPART_REPLAY'),'Departure replay preserves both complete inventories and execution facts') - for(const [index,member] of fixture.members.entries()) { - const before=await snapshot(`BEFORE_SIGN_${index}`),path=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}`,member.customerId) - await request('PUT',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/status`,member.customerId), - {status:'DELIVERING',expectedVersion:before.members[index].delivery.order.version,remark:context.marker},'Start only this customer delivery') - const documentPath=scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`,member.customerId) - const docs=await request('GET',documentPath,undefined,'Read this delivery document version') - const uploaded=await context.requestMultipart('POST',scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId), - {name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${member.deliveryNo}`)}) - proof.requests.push({method:'POST',path:scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`,member.customerId),label:'Upload own synthetic receipt',http:uploaded?.http,code:uploaded?.body?.code,requestId:uploaded?.requestId,traceId:uploaded?.traceId}) - check('Receipt uploaded through correct delivery endpoint',ok(uploaded) && id(uploaded.body?.data?.fileId)) - const documents=(docs.categories||[]).filter(row=>row.category!=='RECEIPT').map(row=>({category:row.category,fileIds:(row.files||[]).map(file=>file.fileId)})) - documents.push({category:'RECEIPT',fileIds:[id(uploaded.body.data.fileId)]}) - await request('PUT',documentPath,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents},'Attach receipt to independently complete this delivery') - const signed=await snapshot(`SIGNED_${index}`) - check('This customer independently signed',signed.members[index].delivery.order.status==='DELIVERED') - equal(before.members[1-index].delivery,signed.members[1-index].delivery,'Other customer delivery is not signed or changed implicitly') - equal(before.members.map(row=>row.stock),signed.members.map(row=>row.stock),'Signing does not mutate either customer inventory') - // Retain the independently readable result as evidence, not a fabricated completion command. - await request('GET',path,undefined,'Read signed customer delivery') + const arrangement = await request('GET', `${path}/delivery-arrangement`, undefined, 'Read native loaded per-customer delivery arrangement') + if (continuation) check('Continuation has no prior arrangement and retains the original loading completion hash', arrangement.arrangement == null + && arrangement.loadingCompletionHash === originalLoadingHash && id(arrangement.recordVersion) === id(fixture.tripProof.loadingCommand.result.recordVersion)) + check('Loaded arrangement groups cover exactly both real customers', arrangement.dispatchNo === fixture.dispatchNo && arrangement.status === 'ACTIVE' + && arrangement.stage === 'AFTER_LOADING' && arrangement.arrange?.allowed === true && arrangement.cargoHash && arrangement.loadingCompletionHash + && arrangement.groups?.length === 2 && proof.members.every(m => arrangement.groups.some(g => id(g.customerId) === m.customerId && g.cargo?.length + && g.cargo.every(c => id(c.customerId) === m.customerId && id(c.warehousePalletId) === m.palletId) && g.blockReasons?.length === 0 + && g.destination?.key === cargoBasis.items.items.find(i => id(i.customerId) === m.customerId)?.destination?.key))) + const arrangeBody = { recordVersion: arrangement.recordVersion, idempotencyKey: key('ARRANGE'), cargoHash: arrangement.cargoHash, + loadingCompletionHash: arrangement.loadingCompletionHash, groups: arrangement.groups.map(g => ({ groupKey: g.groupKey, groupCargoHash: g.groupCargoHash, + plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString(), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', + loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本轮实际装载', appointmentAccount: 'SELF', remark: `${context.marker} TEST合成资料`, + externalFreightHash: g.externalFreightHash, feeCoverageCargoHash: g.feeCoverageCargoHash, feeCoverage: [], + fees: (g.requiredServices || []).map(feeType => ({ feeType, feeName: 'TEST本轮派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 })) })) } + proof.arrangeRequest = arrangeBody + const arranged = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Arrange both customers after real loading before departure') + check('Native arrangement creates customer-isolated outbounds and deliveries', arranged.dispatchNo === fixture.dispatchNo && arranged.stage === 'AFTER_LOADING' + && arranged.result?.deliveries?.length === 2 && arranged.result.outbounds?.length === 2 + && new Set(arranged.result.deliveries.map(d => d.deliveryNo)).size === 2 && new Set(arranged.result.outbounds.map(o => o.outboundNo)).size === 2) + for (const [index, member] of proof.members.entries()) { + const deliveries = arranged.result.deliveries.filter(d => id(d.customerId) === member.customerId) + check('Each customer has exactly its own source-specific delivery and outbound', deliveries.length === 1 && integer(deliveries[0].packageCount) === 1 + && arranged.result.outbounds.some(o => id(o.customerId) === member.customerId && o.outboundNo === deliveries[0].outboundNo && integer(o.packageCount) === 1)) + Object.assign(member, { outboundNo: deliveries[0].outboundNo, deliveryNo: deliveries[0].deliveryNo }) + Object.assign(fixture.members[index], { outboundNo: member.outboundNo, deliveryNo: member.deliveryNo }) } - proof.status='VERIFIED';return proof - } catch(error) {proof.status='FAILED';proof.error=error.message;throw error} + const arrangedReplay = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Replay exact native customer arrangement') + check('Native arrangement replay keeps original documents and main version', arrangedReplay.replayed === true && id(arrangedReplay.recordVersion) === id(arranged.recordVersion) + && canonical(arrangedReplay.result) === canonical(arranged.result)) + const ready = await snapshot('READY'), departBody = { recordVersion: ready.task.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } + const departPath = `${pdaPath}/depart` + if (verifyDepartureRollback !== undefined) { + check('Explicit rollback verifier is callable', typeof verifyDepartureRollback === 'function') + const rollback = await verifyDepartureRollback({ context, fixture, ready, departPath, departBody, snapshot }) + check('Mid-transaction rollback has exact owned verification', rollback?.verified === true && rollback.scope === 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') + equal(ready, await snapshot('ROLLBACK_RESTORED'), 'Mid-transaction rollback restores both complete customer execution and inventory snapshots') + proof.rollbackEvidence = rollback; proof.midTransactionRollback = 'VERIFIED' + } + const departed = await request('POST', departPath, departBody, 'Depart one cross-customer native dispatch once', true) + check('Departure includes both customers in the one actual native main', departed.dispatchNo === fixture.dispatchNo && departed.status === 'ACTIVE' + && departed.stage === 'DELIVERING' && integer(departed.result?.shippedPackages) === 2 && departed.result.deliveries?.length === 2) + proof.departure = departed + const after = await snapshot('DEPARTED') + check('Both customer outbounds completed and main delivering', after.trip.trip.status === 'IN_TRANSIT' && after.dispatch.stage === 'DELIVERING' + && after.members.every(row => row.outbound.outbound.status === 'OUTBOUNDED' && row.delivery.order.status === 'DELIVERING')) + equal(ready.members.map(row => row.stock), after.members.map(row => row.stock), 'Departure does not deduct either inventory again') + const replay = await request('POST', departPath, departBody, 'Original PDA actor exact replay of native departure', true) + check('Shared departure replay is explicit and returns original result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion) + && canonical(replay.result) === canonical(departed.result)) + const replayed = await snapshot('DEPART_REPLAY') + equal(after, replayed, 'Departure replay preserves both complete inventories and execution facts') + await request('POST', `${path}/depart`, departBody, 'Different platform actor cannot reuse the PDA departure command key', false, 'ACTOR_FORBIDDEN') + equal(replayed, await snapshot('FOREIGN_ACTOR_DEPART_REJECTED'), 'Different actor rejection preserves both complete inventories and execution facts') + for (const [index, member] of proof.members.entries()) { + const before = await snapshot(`BEFORE_SIGN_${index}`), documentPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`, member.customerId) + const docs = await request('GET', documentPath, undefined, 'Read this customer delivery document version') + const uploadPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents/RECEIPT/files`, member.customerId) + const uploadEntry = { method: 'POST', path: uploadPath, label: 'Upload own synthetic receipt', status: 'SENDING' }; proof.requests.push(uploadEntry); await context.checkpoint?.() + let uploaded + try { uploaded = await context.requestMultipart('POST', uploadPath, { name: `${context.marker}-${index}-TEST-receipt.txt`, type: 'text/plain', + content: new TextEncoder().encode(`${context.marker} synthetic TEST receipt ${member.deliveryNo}`) }) } + catch (error) { uploadEntry.status = 'UNKNOWN_RESULT'; await context.checkpoint?.(); throw error } + Object.assign(uploadEntry, { status: 'RESPONDED', http: uploaded?.http, code: uploaded?.body?.code, requestId: uploaded?.requestId, traceId: uploaded?.traceId }); await context.checkpoint?.() + check('Receipt uploaded through correct customer endpoint', ok(uploaded) && uploaded.requestId && uploaded.traceId && id(uploaded.body?.data?.fileId)) + const documents = (docs.categories || []).filter(row => row.category !== 'RECEIPT').map(row => ({ category: row.category, fileIds: (row.files || []).map(file => file.fileId) })) + documents.push({ category: 'RECEIPT', fileIds: [id(uploaded.body.data.fileId)] }) + await request('PUT', documentPath, { expectedVersion: docs.version, idempotencyKey: key(`SIGN-${index}`), bolNo: docs.bolNo || null, documents }, 'Attach receipt to independently complete this delivery') + const signed = await snapshot(`SIGNED_${index}`) + check('This customer independently signed', signed.members[index].delivery.order.status === 'DELIVERED') + equal(before.members[1-index].delivery, signed.members[1-index].delivery, 'Other customer delivery is not signed or changed implicitly') + equal(before.members.map(row => row.stock), signed.members.map(row => row.stock), 'Signing does not mutate either customer inventory') + } + const started = Date.now() + proof.completionPolling = { ...polling, status: 'WAITING', attempts: [] } + for (let attempt = 1; attempt <= polling.maxAttempts; attempt++) { + if (Date.now() - started >= polling.deadlineMs) break + const entry = { attempt, elapsedMs: Date.now() - started, status: 'READING' } + proof.completionPolling.attempts.push(entry) + const current = await request('GET', path, undefined, 'Read bounded automatic delivery completion progress') + check('Completion progress retains the exact normal main, counts and both execution-document owners', current.dispatchNo === fixture.dispatchNo + && current.status === 'ACTIVE' && ['DELIVERING','DELIVERED'].includes(current.stage) + && /^(0|[1-9]\d*)$/.test(id(current.recordVersion)) && integer(current.counts?.packageCount) === 2 + && integer(current.counts?.pickedPackageCount) === 2 && integer(current.counts?.shippedPackageCount) === 2 + && current.documents?.outbounds?.length === 2 && current.documents?.deliveries?.length === 2 + && proof.members.every(member => current.documents.outbounds.some(row => id(row.customerId) === member.customerId && row.outboundNo === member.outboundNo) + && current.documents.deliveries.some(row => id(row.customerId) === member.customerId && row.deliveryNo === member.deliveryNo && row.outboundNo === member.outboundNo))) + Object.assign(entry, { status: 'RESPONDED', stage: current.stage, recordVersion: current.recordVersion, elapsedMs: Date.now() - started }) + if (entry.elapsedMs >= polling.deadlineMs) break + if (current.stage === 'DELIVERED') { proof.completionPolling.status = 'COMPLETED'; break } + await context.checkpoint?.() + const remaining = polling.deadlineMs - (Date.now() - started) + if (attempt < polling.maxAttempts && remaining > 0) await new Promise(resolve => setTimeout(resolve, Math.min(polling.intervalMs, remaining))) + } + proof.completionPolling.elapsedMs = Date.now() - started + if (proof.completionPolling.status !== 'COMPLETED') { + proof.completionPolling.status = 'TIMED_OUT' + await context.checkpoint?.() + throw new Error('Automatic delivery completion did not reach DELIVERED within the explicit read-only polling bounds') + } + const final = await snapshot('FINAL') + check('All normal customer signatures aggregate native delivery completion', final.dispatch.status === 'ACTIVE' && final.dispatch.stage === 'DELIVERED') + proof.status = 'VERIFIED'; return proof + } catch (error) { + proof.status = 'FAILED'; proof.error = error.message + if (proof.completionPolling?.status === 'WAITING') { + proof.completionPolling.status = 'FAILED'; proof.completionPolling.error = error.message + const attempt = proof.completionPolling.attempts.at(-1) + if (attempt?.status === 'READING') attempt.status = 'FAILED' + } + throw error + } } diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs index 6ad5a84cf..699e21fc4 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs @@ -1,10 +1,10 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { createFleetCrossCustomerFixture, preparationContract } from './fleet-cross-customer-fixture.mjs' +import { createFleetCrossCustomerFixture, preparationContract, safeFixtureCount } from './fleet-cross-customer-fixture.mjs' import { fixtureContract } from './fleet-cross-customer-acceptance.mjs' // Strict offline HTTP model executes the real qualification and prepareFleetTrip helpers too. -function model(fault = '') { +function model(fault = '', countPatch = {}) { const copy = value => structuredClone(value), calls = [], customers = [], transfers = [], dispatches = [] const options = { execute: true, warehouseId: '10', destinationWarehouseId: '20', pdaToken: 'caller-pda-secret' } const trip = { trip: { tripNo: 'TRIP', recordVersion: '0', warehouseId: '10', status: 'PLANNED', maxPalletSpaces: 30, @@ -23,11 +23,10 @@ function model(fault = '') { ledger: [{ ...scope, id: `L${n}`, balanceId: `B${n}`, positionId: `P${n}`, palletNo: `PALLET${n}`, businessType: 'STOCK_IN', businessOrderNo: transfer.transferNo, beforePackages: '0', deltaPackages: '1', afterPackages: '1' }] } } - const cargo = () => trip.dispatches.map(binding => { - const d = dispatches.find(row => row.dispatchNo === binding.dispatchNo), n = dispatches.indexOf(d) - return { dispatchNo: d.dispatchNo, customerId: binding.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, - destinationKey: 'FBA:FBA', destinationLabel: 'FBA', packages: 1 } - }) + let native + const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId, + warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0, + destination: { key: 'FBA:FBA' } })) const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' } const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture }, createdResources: { merchantQualificationFixture: { marker: 'PARENT' } }, @@ -58,7 +57,7 @@ function model(fault = '') { customers.push(customer) return response({ tenant: { id }, administratorUsername: customer.username, temporaryPassword: customer.password }) } - if (path.endsWith('/submit-review')) return response({ status: 'PENDING' }) + if (path.endsWith('/submit-review') && path.includes('/iam/')) return response({ status: 'PENDING' }) if (path === '/api/platform/v1/iam/reviews') return response(customers.map(row => ({ id: `REV${row.id}`, tenantId: row.id }))) if (path.endsWith('/dimension-initialization')) return response({ status: 'READY' }) if (path.endsWith('/approve')) return response({}) @@ -77,10 +76,14 @@ function model(fault = '') { if (path === '/api/merchant/v1/iam/tenant/profile') return response({ id: fault === 'foreign login' ? 'OTHER' : merchant.id }) if (path === '/api/merchant/v1/last-leg/warehouse-transfers') { assert.ok(merchant); assert.equal(body.inboundCode, undefined); assert.deepEqual(body.sourceLastlegOrderIds, []) - assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1) + assert.equal(body.needRelabel, false); assert.equal(body.items.length, 1); assert.equal(body.items[0].packageCount, 1); assert.equal(body.items[0].quantity, 1) const transferNo = `IN${transfers.length}` + const itemId = `FORECAST-ITEM${transfers.length}` const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId, - status: 'DRAFT', pallets: [] } + status: 'DRAFT', packageCount: 1, pallets: [], + items: [{ ...copy(body.items[0]), id: itemId, ...(fault === 'missing declared quantity' ? { quantity: null } : {}) }], + cartons: fault === 'missing forecast carton' ? [] : [{ cartonMark: `BX-FORECAST${transfers.length}`, status: 'FORECAST', + transferItemId: fault === 'foreign forecast item' ? 'OTHER-ITEM' : itemId }] } transfers.push(transfer); return response(transfer) } if (path.includes('/warehouse-transfers/')) { @@ -89,10 +92,39 @@ function model(fault = '') { if (path.startsWith('/api/merchant/')) assert.equal(transfer.customerId, merchant.id) if (path.endsWith('/submit')) transfer.status = 'PENDING_REVIEW' else if (path.endsWith('/review')) { assert.equal(url.searchParams.get('customerId'), transfer.customerId); assert.equal(body.decision, 'APPROVE'); transfer.status = 'WAIT_PDA_RECEIVING' } - else if (path.endsWith('/pallets/drafts')) transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: body.cartonCount }] + else if (path.endsWith('/pallets/drafts')) { + // The actual PalletDraft DTO accepts identity only; never derive measurement from ignored request fields. + assert.deepEqual(Object.keys(body), ['idempotencyKey']) + transfer.pallets = [{ id: `DRAFT${n}`, status: 'DRAFT', cartonCount: 0, lengthCm: 0, widthCm: 0, heightCm: 0, volumeCbm: 0, grossWeightKg: 0 }] + } else if (path.endsWith('/generate')) Object.assign(transfer.pallets[0], { status: 'GENERATED', warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}` }) else if (path.endsWith('/bind')) { assert.equal(body.palletCode, transfer.pallets[0].palletNo); transfer.pallets[0].status = 'BOUND' } - else if (path.endsWith('/palletize')) { transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED' } + else if (path.endsWith('/cartons/count')) { + assert.equal(method, 'POST'); assert.equal(transfer.pallets[0].status, 'BOUND') + assert.equal(transfer.pallets[0].cartonCount, 0); assert.equal(body.forecastPackageCount, transfer.packageCount) + assert.equal(body.boxes.length, 1) + const box = body.boxes[0], item = transfer.items[0], carton = transfer.cartons[0], pallet = transfer.pallets[0] + assert.equal(box.cartonMark, carton.cartonMark); assert.equal(box.transferItemId, item.id) + assert.equal(box.transferPalletId, pallet.id); assert.notEqual(box.transferPalletId, pallet.warehousePalletId) + assert.deepEqual([box.length, box.width, box.height, box.weight], [100, 100, 100, 100]) + assert.deepEqual(box.items, [{ skuCode: item.skuCode, quantity: item.quantity }]) + Object.assign(carton, { transferPalletId: pallet.id, status: 'COUNTED', countedAt: '2026-10-20T09:00:00', + length: box.length, width: box.width, height: box.height, weight: box.weight, quantity: 1, + items: [{ transferItemId: item.id, skuCode: item.skuCode, quantity: 1 }] }) + Object.assign(pallet, { cartonCount: 1, volumeCbm: 1, grossWeightKg: 100 }) + if (fault === 'uncounted carton') { carton.status = 'FORECAST'; carton.countedAt = null } + if (fault === 'wrong counted pallet') carton.transferPalletId = 'OTHER-PALLET' + if (fault === 'wrong counted sku') carton.items[0].skuCode = 'OTHER-SKU' + if (fault === 'missing carton measurement') carton.weight = null + if (fault === 'zero counted pallet') pallet.cartonCount = 0 + if (fault === 'wrong measured pallet weight') pallet.grossWeightKg = 0 + } + else if (path.endsWith('/palletize')) { + assert.equal(transfer.cartons.length, 1); assert.equal(transfer.cartons[0].status, 'COUNTED'); assert.ok(transfer.cartons[0].countedAt) + assert.equal(transfer.pallets[0].cartonCount, 1); assert.equal(transfer.pallets[0].grossWeightKg, 100) + assert.equal(transfer.pallets[0].volumeCbm, 1); assert.ok(body.photoFileIds.length) + transfer.status = 'WAIT_CUSTOMER_CONFIRMATION'; transfer.pallets[0].status = 'COMPLETED' + } else if (path.endsWith('/confirm-measurement')) transfer.status = 'READY_FOR_DISPATCH' else assert.equal(method, 'GET') return response(transfer) @@ -108,43 +140,44 @@ function model(fault = '') { if (path.endsWith('/dispatches/candidates')) { const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n] assert.equal(transfer.status, 'READY_FOR_DISPATCH') - return response([{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, overseasWarehouseId: '10', inboundCode: transfer.transferNo, - warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, planPalletId: `PLAN${n}`, sourceStage: 'RECEIVED', packageCount: 1 }]) + return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, + warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' }, + sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] }) } - if (path.endsWith('/drafts/containers')) return response({ id: `CONTAINER${dispatches.length}` }) - if (path.endsWith('/draft-locks/lock')) return response(null) - if (path.endsWith('/pre-arrange')) { - const n = Number(body.planPalletIds[0].slice(-1)) - const dispatch = { dispatchNo: `DS${n}`, overseasWarehouseId: '10', status: 'PENDING_DEPARTURE', items: [{ warehousePalletId: `PAL${n}` }], - packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0 } - dispatches.push(dispatch); return response(dispatch) + if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { + assert.equal(body.cargo.length, 2); assert.equal(body.warehouseId, '10') + assert.deepEqual(body.cargo.map(row => row.planPalletId), ['PLAN0', 'PLAN1']) + assert.ok(body.cargo.every(row => !Object.hasOwn(row, 'customerId'))) + native = { dispatchNo: 'DS-ONE', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: 0, + counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {}, + documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} } + trip.dispatches = [{ dispatchNo: native.dispatchNo }] + Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] }) + dispatches.push(native); return response(native) } - if (path.endsWith('/master-data/vehicles')) return response({ id: 'VEHICLE' }) + if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) } if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' }) if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' }) - if (path.endsWith('/fleet/trips')) { Object.assign(trip.trip, body); return response(trip) } - if (path.includes('/fleet/trips/TRIP')) { - if (path.endsWith('/loading')) return response({ recordVersion: trip.trip.recordVersion, cargo: fault === 'missing second cargo' ? cargo().slice(0, 1) : cargo() }) - if (method !== 'GET') { - assert.equal(body.recordVersion, trip.trip.recordVersion) - trip.trip.recordVersion = String(Number(trip.trip.recordVersion) + 1) + if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 }) + if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) + if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({ dispatchNo: native.dispatchNo, recordVersion: native.recordVersion, + items: fault === 'missing second cargo' ? cargo().slice(0,1) : cargo() }) + if (path.startsWith('/api/platform/v1/last-leg/dispatches/DS-ONE')) { + if (method === 'PUT') { + assert.equal(body.recordVersion, native.recordVersion) + Object.assign(trip.trip, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId }) + native.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: 'VEHICLE', plate: trip.trip.vehiclePlate }, driver: { driverId: 'DRIVER' } } + native.recordVersion++ + } else if (method === 'POST') { + assert.equal(body.recordVersion, native.recordVersion); native.recordVersion++ + if (path.endsWith('/submit-review')) native.reviewState = 'DISPATCH_PENDING' + if (path.endsWith('/dispatch-review')) { native.reviewState = 'LOADING_PENDING'; native.reviews.dispatchReview = { status: 'APPROVED' } } + if (path.endsWith('/loading-review')) { native.reviewState = 'APPROVED'; native.reviews.loadingReview = { status: 'APPROVED' }; native.stage = 'LOADING' } } - if (path.endsWith('/assignment')) Object.assign(trip.trip, { vehicleId: body.vehicleId, driverId: body.driverId }) - else if (path.endsWith('/dispatches/bind')) trip.dispatches.push({ dispatchNo: body.dispatchNo, customerId: transfers[Number(body.dispatchNo.slice(-1))].customerId, review: { status: 'PENDING' } }) - else if (path.endsWith('/plan')) trip.trip.stops = body.stops - else if (path.endsWith('/review')) trip.dispatches.find(row => path.includes(`/${row.dispatchNo}/`)).review.status = 'APPROVED' - return response(trip) + const detail = copy(native) + if (fault === 'foreign delivery') detail.documents.deliveries = [{ customerId: 'OTHER' }] + return response(detail) } - const n = Number(path.match(/(?:DS|OUT|DL)([01])/)[1]), dispatch = dispatches[n] - const order = { customerId: transfers[n].customerId, deliveryNo: `DL${n}`, outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, status: 'WAIT_DELIVERY' } - if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: true, quotes: [] }) - if (path.endsWith('/transfer-delivery')) { - assert.equal(body.fees.length, 1); assert.equal(body.fees[0].amount, 30) - return response({ dispatchNo: dispatch.dispatchNo, outboundNo: `OUT${n}`, deliveries: [order], packageCount: 1 }) - } - if (path.includes('/delivery-orders/')) { assert.equal(url.searchParams.get('customerId'), transfers[n].customerId); return response({ order: fault === 'foreign delivery' ? { ...order, customerId: 'OTHER' } : order }) } - if (path.includes('/outbound-orders/')) return response({ outbound: { outboundNo: `OUT${n}`, dispatchNo: dispatch.dispatchNo, overseasWarehouseId: '10', status: 'WAIT_OUTBOUND' }, deliveries: [order] }) - if (path.endsWith(`/dispatches/${dispatch.dispatchNo}`)) return response(dispatch) throw new Error(`Unexpected offline request ${method} ${path}`) }, } @@ -180,15 +213,28 @@ test('real helpers build independent current-run customers, receipts and one com assert.notEqual(m.customers[0].marker, m.customers[1].marker) const proof = m.context.createdResources.fleetCrossCustomerFixture assert.equal(proof.status, 'READY'); assert.deepEqual(proof.sessionCleanup.map(row => row.status), ['LOGGED_OUT', 'LOGGED_OUT']) - assert.equal(m.trip.dispatches.length, 2); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA']) - assert.equal(m.calls.filter(row => row.raw.endsWith('/plan')).length, 2) - assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 2) + assert.equal(m.trip.dispatches.length, 1); assert.equal(new Set(fixture.members.map(row => row.dispatchNo)).size, 1); assert.deepEqual(m.trip.trip.stops.map(row => row.destinationKey).filter(Boolean), ['FBA:FBA']) + assert.equal(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/platform/v1/last-leg/dispatches').length, 1) + assert.equal(m.calls.filter(row => row.raw.endsWith('/cartons/count')).length, 2) + for (const member of fixture.members) { + const prefix = `/api/pda/v1/last-leg/warehouse-transfers/${member.incomingNo}` + const draft = m.calls.findIndex(row => row.raw === `${prefix}/pallets/drafts`) + const bind = m.calls.findIndex(row => row.raw.startsWith(`${prefix}/pallets/`) && row.raw.endsWith('/bind')) + const count = m.calls.findIndex(row => row.raw === `${prefix}/cartons/count`) + const photo = m.calls.findIndex(row => row.raw === `${prefix}/measurement-photos/files`) + const receipt = m.calls.findIndex(row => row.raw === `${prefix}/palletize`) + assert.ok(draft < bind && bind < count && count < photo && photo < receipt) + assert.equal(member.measurementReceipt.countedCarton.cartonMark, m.calls[count].body.boxes[0].cartonMark) + assert.equal(member.measurementReceipt.syntheticTestMaterials, true) + assert.equal(member.measurementReceipt.finalizedPallet.cartonCount, 1) + } + assert.equal(m.calls.filter(row => row.raw.endsWith('/transfer-delivery')).length, 0) assert.ok(!m.calls.some(row => /\/pick$|\/depart$|\/bookings|\/capacity-plan/.test(row.raw))) const persisted = JSON.stringify(m.context.createdResources) for (const secret of ['caller-pda-secret', 'admin-secret', 'fresh-secret-', 'merchant-100-secret', 'refresh-100-secret']) assert.ok(!persisted.includes(secret)) }) -for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) { +for (const fault of ['disabled receiving', 'reused customer', 'foreign login', 'missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight', 'foreign position', 'truncated ledger', 'wrong raw scope', 'wrong raw ledger position', 'wrong raw ledger balance', 'foreign candidate', 'missing second cargo', 'foreign delivery', 'logout failure']) { test(`fail closed, retain partial resources and clean owned sessions: ${fault}`, async () => { const m = model(fault) await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options)) @@ -198,6 +244,8 @@ for (const fault of ['disabled receiving', 'reused customer', 'foreign login', ' assert.equal(logouts.length, logins.length) assert.ok(!m.calls.some(row => row.method === 'DELETE' || row.raw.includes('/users/'))) if (fault === 'disabled receiving') assert.equal(m.calls.filter(row => row.method !== 'GET').length, 0) + if (['missing forecast carton', 'missing declared quantity', 'foreign forecast item', 'uncounted carton', 'wrong counted pallet', 'wrong counted sku', 'missing carton measurement', 'zero counted pallet', 'wrong measured pallet weight'].includes(fault)) + assert.equal(m.calls.some(row => row.raw.endsWith('/palletize') || row.raw === '/api/platform/v1/last-leg/dispatches'), false) const previousCount = m.calls.length await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /already started/) assert.equal(m.calls.length, previousCount) @@ -211,3 +259,31 @@ test('missing explicit warehouse or caller PDA fails before reading or writing A assert.equal(m.calls.length, 0) } }) + + +test('native Jackson Long count strings build the same complete one-main fixture', async () => { + const m = model('', { packageCount: '2', pickedPackageCount: '0', shippedPackageCount: '0' }) + const fixture = await createFleetCrossCustomerFixture(m.context, m.options) + assert.equal(fixture.dispatchNo, 'DS-ONE') + assert.equal(fixture.tripNo, 'TRIP') + assert.equal(fixture.members.length, 2) + assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo)) +}) + +test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => { + for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) { + assert.equal(safeFixtureCount(value), expected) + } + for (const value of [null, undefined, true, false, '', ' ', ' 2 ', '02', '+2', '-0', '-1', -1, 1.5, '1.5', '1e2', '2E0', NaN, Infinity, {}, [], Number.MAX_SAFE_INTEGER + 1, '9007199254740992']) { + assert.throws(() => safeFixtureCount(value), /非负安全整数/) + } +}) + +test('invalid created main counts fail before resource preparation or picking', async () => { + for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) { + const m = model('', { [field]: field === 'packageCount' ? '2e0' : false }) + await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /非负安全整数/) + assert.equal(m.calls.some(row => row.raw.endsWith('/master-data/vehicles')), false) + assert.equal(m.calls.some(row => row.raw.endsWith('/pick')), false) + } +}) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs index 0a345ce51..e617cea61 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs @@ -8,6 +8,11 @@ const ROOT = '/api/platform/v1/last-leg' const MERCHANT = '/api/merchant/v1/last-leg/warehouse-transfers' const OPTIONS = { scenario: MODULE, deploymentBlocker: true } const id = value => value == null ? '' : String(value) +export function safeFixtureCount(value) { + if (!['number', 'string'].includes(typeof value) || !/^(0|[1-9]\d*)$/.test(String(value)) + || !Number.isSafeInteger(Number(value))) throw new Error('原生夹具数量须为非负安全整数') + return Number(value) +} const encode = encodeURIComponent const ok = response => response?.http === 200 && id(response.body?.code) === '200' const buckets = ['availablePackages', 'holdPackages', 'allocatedPackages', 'pickedPackages', 'receivingPackages'] @@ -22,7 +27,7 @@ const localTime = delay => { export const preparationContract = Object.freeze({ stage: fixtureContract.stage, inputs: 'execute:true, current-run context, warehouseId (public active OVERSEAS with RECEIVING), destinationWarehouseId (public active FBA), pdaToken bound to warehouseId, readInventory(scope) returning all raw balances/positions/ledger with CUSTOMER_WAREHOUSE_COMPLETE scope', - writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; two dispatches, one shared trip with a test vehicle/qualified driver, two outbounds and deliveries. No picking.', + writes: 'Two fresh qualified customers; one ordinary WarehouseTransfer and one PDA-measured pallet/box per customer; one native cross-customer dispatch with one automatic Fleet resource record and a TEST vehicle/qualified driver; both reviews approved. No picking or delivery arrangement yet.', lifecycle: 'Caller creates/disables PDA identity and supplies raw readFleetInventory for verification. This helper logs out its own merchant sessions, retains all business fixtures, and refuses automatic retry. No SQL, environment files or external bookings.', }) @@ -59,8 +64,8 @@ export async function createFleetCrossCustomerFixture(context, { execute = false inboundCode: member.incomingNo, page: '1', size: '200' }) const grouped = await request('GET', `${ROOT}/inventory/balances?${query}`, undefined, 'Read exact fresh receipt balance page') const ledger = await request('GET', `${ROOT}/inventory/ledger?${query}&businessOrderNo=${encode(member.incomingNo)}`, undefined, 'Read exact receipt movement page') - check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && Number(grouped.total) === 1 - && ledger?.records?.length === 1 && Number(ledger.total) === 1 && id(grouped.records[0].id)) + check('Fresh one-box receipt has one complete balance and movement page', grouped?.records?.length === 1 && safeFixtureCount(grouped.total) === 1 + && ledger?.records?.length === 1 && safeFixtureCount(ledger.total) === 1 && id(grouped.records[0].id)) const path = `${ROOT}/inventory/balances/${encode(grouped.records[0].id)}` const balance = await request('GET', `${path}?${own(member)}`, undefined, 'Read original raw balance identity') const positions = await request('GET', `${path}/positions?${own(member)}`, undefined, 'Read original raw physical position') @@ -146,7 +151,7 @@ export async function createFleetCrossCustomerFixture(context, { execute = false destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName, deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000), needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`), - items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1 }] } + items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] } let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken) member.incomingNo = incoming?.transferNo check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo @@ -162,8 +167,14 @@ export async function createFleetCrossCustomerFixture(context, { execute = false check('PDA task is untouched and belongs to the requested receiving warehouse', task?.transferNo === member.incomingNo && task.inboundCode === member.incomingNo && id(task.operatingWarehouseId) === id(warehouseId) && task.status === 'WAIT_PDA_RECEIVING' && task.pallets?.length === 0) - const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`), - cartonCount: 1, lengthCm: 100, widthCm: 100, heightCm: 100, volumeCbm: 1, grossWeightKg: 100 }, 'Measure one real test pallet', pdaToken) + const forecastItem = task.items?.[0], forecastCarton = task.cartons?.[0] + check('One original forecast carton and declared SKU belong to this fresh one-box incoming', safeFixtureCount(task.packageCount) === 1 + && task.items?.length === 1 && forecastItem?.id && forecastItem.skuCode === createBody.items[0].skuCode + && safeFixtureCount(forecastItem.packageCount) === 1 && safeFixtureCount(forecastItem.quantity) === 1 + && task.cartons?.length === 1 && forecastCarton?.cartonMark + && (forecastCarton.transferItemId == null || id(forecastCarton.transferItemId) === id(forecastItem.id))) + // PalletDraft creates only an empty identity; measurements belong to the real carton-count command below. + const measured = await request('POST', `${pdaPath}/pallets/drafts`, { idempotencyKey: key(`MEASURE-${suffix}`) }, 'Create empty TEST pallet draft', pdaToken) check('Measurement created one pallet draft', measured?.pallets?.length === 1 && measured.pallets[0].id && measured.pallets[0].status === 'DRAFT') const palletPath = `${pdaPath}/pallets/${encode(measured.pallets[0].id)}` const generated = await request('POST', `${palletPath}/generate`, { idempotencyKey: key(`GENERATE-${suffix}`) }, 'Generate authoritative warehouse pallet', pdaToken) @@ -171,12 +182,32 @@ export async function createFleetCrossCustomerFixture(context, { execute = false check('Generated physical pallet has real references', generated?.pallets?.length === 1 && pallet.status === 'GENERATED' && pallet.warehousePalletId && pallet.palletNo) member.palletId = id(pallet.warehousePalletId); member.palletNo = pallet.palletNo const bound = await request('POST', `${palletPath}/bind`, { palletCode: member.palletNo, idempotencyKey: key(`BIND-${suffix}`) }, 'Bind scanned physical pallet', pdaToken) - check('Bound pallet is exactly the measured one-box pallet', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND' - && id(bound.pallets[0].warehousePalletId) === member.palletId && Number(bound.pallets[0].cartonCount) === 1) + check('Bound pallet is exactly the generated empty physical pallet before carton counting', bound?.pallets?.length === 1 && bound.pallets[0].status === 'BOUND' + && id(bound.pallets[0].id) === id(measured.pallets[0].id) + && id(bound.pallets[0].warehousePalletId) === member.palletId && safeFixtureCount(bound.pallets[0].cartonCount) === 0) + const counted = await request('POST', `${pdaPath}/cartons/count`, { idempotencyKey: key(`COUNT-${suffix}`), + forecastPackageCount: safeFixtureCount(task.packageCount), boxes: [{ cartonMark: forecastCarton.cartonMark, + transferItemId: id(forecastItem.id), transferPalletId: id(measured.pallets[0].id), + length: 100, width: 100, height: 100, weight: 100, + items: [{ skuCode: forecastItem.skuCode, quantity: safeFixtureCount(forecastItem.quantity) }] }] }, + 'Count original TEST carton, record dimensions/SKU and allocate it to the bound pallet', pdaToken) + const countedCarton = counted?.cartons?.[0], countedPallet = counted?.pallets?.[0] + check('Actual carton count proves one declared box/SKU on the original bound pallet', counted?.transferNo === member.incomingNo + && counted.cartons?.length === 1 && countedCarton.cartonMark === forecastCarton.cartonMark + && countedCarton.status === 'COUNTED' && countedCarton.countedAt + && id(countedCarton.transferPalletId) === id(measured.pallets[0].id) + && id(countedCarton.transferItemId) === id(forecastItem.id) + && ['length', 'width', 'height', 'weight'].every(field => Number(countedCarton[field]) === 100) + && safeFixtureCount(countedCarton.quantity) === 1 && countedCarton.items?.length === 1 + && countedCarton.items[0].skuCode === forecastItem.skuCode + && id(countedCarton.items[0].transferItemId) === id(forecastItem.id) && safeFixtureCount(countedCarton.items[0].quantity) === 1 + && counted.pallets?.length === 1 && countedPallet.status === 'BOUND' && id(countedPallet.id) === id(measured.pallets[0].id) + && id(countedPallet.warehousePalletId) === member.palletId && safeFixtureCount(countedPallet.cartonCount) === 1 + && Number(countedPallet.grossWeightKg) === 100 && Number(countedPallet.volumeCbm) === 1) const photoPath = `${pdaPath}/measurement-photos/files` const photo = record('POST', photoPath, undefined, await context.requestMultipart('POST', photoPath, { name: `${member.customerMarker}-TEST-MEASUREMENT.png`, type: 'image/png', content: testImage }, pdaToken), 'Upload TEST measurement evidence') - check('Measurement evidence is a stored file', photo?.fileId && Number(photo.sizeBytes) > 0) + check('Measurement evidence is a stored file', photo?.fileId && safeFixtureCount(photo.sizeBytes) > 0) incoming = await request('POST', `${pdaPath}/palletize`, { idempotencyKey: key(`PALLETIZE-${suffix}`), photoFileIds: [id(photo.fileId)] }, 'Complete real PDA receipt', pdaToken) check('Real PDA receipt completed with original physical pallet', incoming?.status === 'WAIT_CUSTOMER_CONFIRMATION' && incoming.pallets?.length === 1 && incoming.pallets[0].status === 'COMPLETED' && id(incoming.pallets[0].warehousePalletId) === member.palletId) @@ -184,73 +215,51 @@ export async function createFleetCrossCustomerFixture(context, { execute = false check('Customer confirmation makes ordinary incoming dispatchable', incoming?.transferNo === member.incomingNo && incoming.status === 'READY_FOR_DISPATCH') member.receipt = await stock(member) member.balanceId = id(member.receipt.balance.id); member.positionId = id(member.receipt.positions[0].id); member.receiptVerified = true - const dispatchBase = `${ROOT}/dispatches` - const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: member.incomingNo })}`, undefined, 'Read own real received dispatch candidate') - check('Candidate has exact customer, incoming, warehouse and physical pallet ownership', candidates?.length === 1 - && id(candidates[0].customerId) === member.customerId && id(candidates[0].overseasWarehouseId) === id(warehouseId) - && candidates[0].inboundCode === member.incomingNo && id(candidates[0].warehousePalletId) === member.palletId - && candidates[0].palletNo === member.palletNo && candidates[0].sourceStage === 'RECEIVED' && Number(candidates[0].packageCount) === 1 && candidates[0].planPalletId) - member.draftToken = key(`DRAFT-${suffix}`) - const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: member.draftToken, capacity: 30 }, 'Create isolated dispatch draft') - check('Dispatch draft has explicit container identity', container?.id) - member.draftContainerId = id(container.id) - await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidates[0].planPalletId), draftToken: member.draftToken, - draftContainerId: member.draftContainerId }, 'Lock this customer received pallet') - const dispatch = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds: [id(candidates[0].planPalletId)], draftToken: member.draftToken }, 'Prearrange one independent customer dispatch') - check('Dispatch contains exactly this unpicked pallet', dispatch?.dispatchNo && dispatch.status === 'PENDING_DEPARTURE' - && dispatch.items?.length === 1 && id(dispatch.items[0].warehousePalletId) === member.palletId - && Number(dispatch.packageCount) === 1 && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) - member.dispatchNo = dispatch.dispatchNo + member.measurementReceipt = { sourceBusinessNo: member.incomingNo, syntheticTestMaterials: true, countedCarton, + sourceType: 'WAREHOUSE_TRANSFER_PDA', finalizedPallet: incoming.pallets?.find(row => id(row.warehousePalletId) === member.palletId) } + const candidates = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`, + undefined, 'Read own native received source selection') + check('Candidate list is complete and has exact customer, incoming, warehouse and physical pallet ownership', candidates?.complete === true + && candidates.candidates?.length === 1 && id(candidates.candidates[0].customerId) === member.customerId + && id(candidates.candidates[0].warehouseId) === id(warehouseId) && candidates.candidates[0].inboundCode === member.incomingNo + && candidates.candidates[0].palletNo === member.palletNo && safeFixtureCount(candidates.candidates[0].availablePackages) === 1 + && candidates.candidates[0].selection?.sourceType === 'INBOUND_PLAN' && candidates.candidates[0].selection.planPalletId + && candidates.candidates[0].sourceVersion && candidates.candidates[0].selection.sourceVersion === candidates.candidates[0].sourceVersion + && candidates.candidates[0].blockReasons?.length === 0) + member.selection = candidates.candidates[0].selection + member.destination = candidates.candidates[0].destination } - for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId', 'dispatchNo']) + for (const field of ['customerId', 'incomingNo', 'palletId', 'positionId', 'balanceId']) check(`Both customers have independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2) - const tripProof = await prepareFleetTrip(context, MODULE, proof.members[0].dispatchNo, id(warehouseId)) + proof.createDispatchRequest = { idempotencyKey: key('CREATE_ONE_NATIVE_DISPATCH'), warehouseId: id(warehouseId), + vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, + remark: `${context.marker} TEST跨客户同仓一张排车一趟车`, cargo: proof.members.map(member => member.selection), batchAllocations: [] } + const created = await request('POST', `${ROOT}/dispatches`, proof.createDispatchRequest, 'Create one native dispatch from both real customers') + check('One native Create returns both unpicked pallets without client-supplied customer identity', created?.dispatchNo && created.status === 'ACTIVE' + && created.stage === 'BEFORE_LOADING' && safeFixtureCount(created.counts?.packageCount) === 2 && safeFixtureCount(created.counts?.pickedPackageCount) === 0 && safeFixtureCount(created.counts?.shippedPackageCount) === 0) + proof.dispatchNo = created.dispatchNo + for (const member of proof.members) member.dispatchNo = proof.dispatchNo + const tripProof = await prepareFleetTrip(context, MODULE, proof.dispatchNo, id(warehouseId)) proof.tripNo = tripProof.tripNo - const tripPath = `${ROOT}/fleet/trips/${encode(proof.tripNo)}` - let detail = await request('GET', tripPath, undefined, 'Read first member trip version') - detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion, - dispatchNo: proof.members[1].dispatchNo, reason: context.marker }, 'Bind second independent customer dispatch') - const loading = await request('GET', `${tripPath}/loading`, undefined, 'Read complete two-customer cargo destinations') - check('Shared trip cargo consists of exactly both owned physical pallets', loading?.cargo?.length === 2 && proof.members.every(member => - loading.cargo.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId - && id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destinationKey && Number(row.packages) === 1))) - const destinations = new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])) - detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion, - plannedDepartureAt: detail.trip.plannedDepartureAt, expectedDriverEndAt: detail.trip.expectedDriverEndAt, - stops: [{ name: origin.warehouse.warehouseName }, ...destinations.values()], reason: context.marker }, 'Replace route with complete actual destinations') - tripProof.stops = detail.trip.stops - check('Route includes every actual destination', [...destinations.keys()].every(destinationKey => detail.trip.stops?.slice(1).some(row => row.destinationKey === destinationKey))) - for (const [index, member] of proof.members.entries()) { - const path = `${ROOT}/dispatches/${encode(member.dispatchNo)}` - const freight = await request('GET', `${path}/external-freight`, undefined, 'Verify ordinary incoming has no external quote') - check('Ordinary incoming requires its own explicit delivery fee', freight?.requiresDeliveryFee === true && freight.quotes?.length === 0) - const transferred = await request('POST', `${path}/transfer-delivery`, { idempotencyKey: key(`DELIVERY-${index}`), plannedDeliveryAt: localTime(21600000), - deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', - vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate, - driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', remark: member.customerMarker, - fees: [{ feeType: 'DELIVERY_FEE', feeName: '受控测试派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, 'Create this customer outbound and delivery') - check('One customer dispatch produces one outbound and one delivery', transferred?.dispatchNo === member.dispatchNo && transferred.outboundNo - && transferred.deliveries?.length === 1 && transferred.deliveries[0].deliveryNo && Number(transferred.packageCount) === 1) - member.outboundNo = transferred.outboundNo; member.deliveryNo = transferred.deliveries[0].deliveryNo - const delivery = await request('GET', `${ROOT}/delivery-orders/${encode(member.deliveryNo)}?${own(member)}`, undefined, 'Verify independently owned delivery') - const outbound = await request('GET', `${ROOT}/outbound-orders/${encode(member.outboundNo)}?${own(member)}`, undefined, 'Verify independently owned outbound') - const dispatch = await request('GET', path, undefined, 'Verify customer dispatch remains unpicked') - check('Customer execution records are distinct and untouched', id(delivery?.order?.customerId) === member.customerId - && delivery.order.deliveryNo === member.deliveryNo && delivery.order.dispatchNo === member.dispatchNo && delivery.order.outboundNo === member.outboundNo - && delivery.order.status === 'WAIT_DELIVERY' && outbound?.outbound?.outboundNo === member.outboundNo && outbound.outbound.dispatchNo === member.dispatchNo - && id(outbound.outbound.overseasWarehouseId) === id(warehouseId) && outbound.outbound.status === 'WAIT_OUTBOUND' - && outbound.deliveries?.length === 1 && id(outbound.deliveries[0].customerId) === member.customerId && outbound.deliveries[0].deliveryNo === member.deliveryNo - && dispatch?.status === 'PENDING_DEPARTURE' && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) - const finalStock = await stock(member) - check('Trip preparation preserves original available receipt records', JSON.stringify(finalStock) === JSON.stringify(member.receipt)) + const dispatchPath = `${ROOT}/dispatches/${encode(proof.dispatchNo)}` + const detail = await request('GET', dispatchPath, undefined, 'Read one reviewed native cross-customer dispatch') + const items = await request('GET', `${dispatchPath}/items`, undefined, 'Read native per-customer cargo identities') + check('Both actual customer pallets share one reviewed native dispatch before any picking', detail.dispatchNo === proof.dispatchNo + && detail.status === 'ACTIVE' && detail.stage === 'LOADING' && detail.reviewState === 'APPROVED' + && safeFixtureCount(detail.counts?.packageCount) === 2 && safeFixtureCount(detail.counts?.pickedPackageCount) === 0 && safeFixtureCount(detail.counts?.shippedPackageCount) === 0 + && detail.documents?.outbounds?.length === 0 && detail.documents?.deliveries?.length === 0 + && items.dispatchNo === proof.dispatchNo && id(items.recordVersion) === id(detail.recordVersion) && items.items?.length === 2 + && proof.members.every(member => items.items.some(row => id(row.customerId) === member.customerId + && id(row.warehousePalletId) === member.palletId && row.palletNo === member.palletNo && row.destination?.key === member.destination?.key + && safeFixtureCount(row.scheduledPackages) === 1 && safeFixtureCount(row.pickedPackages) === 0 && row.dispatchItemId))) + for (const member of proof.members) { + member.dispatchItemId = id(items.items.find(row => id(row.customerId) === member.customerId).dispatchItemId) + check('Trip preparation preserves original available receipt records', JSON.stringify(await stock(member)) === JSON.stringify(member.receipt)) } - detail = await request('GET', tripPath, undefined, 'Verify final two-customer shared trip membership') - check('Final trip has only the two fresh customers and remains planned', detail?.trip?.status === 'PLANNED' && detail.dispatches?.length === 2 - && proof.members.every(member => detail.dispatches.some(row => row.dispatchNo === member.dispatchNo && id(row.customerId) === member.customerId))) - for (const field of ['outboundNo', 'deliveryNo']) check(`Independent ${field}`, new Set(proof.members.map(row => row[field])).size === 2) - fixture = { marker: context.marker, stage: fixtureContract.stage, tripNo: proof.tripNo, warehouseId: id(warehouseId), pdaToken, - members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified }) => - ({ marker, customerId, incomingNo, dispatchNo, outboundNo, deliveryNo, balanceId, positionId, palletId, palletNo, receiptVerified })) } + fixture = { marker: context.marker, stage: fixtureContract.stage, dispatchNo: proof.dispatchNo, tripNo: proof.tripNo, + warehouseId: id(warehouseId), pdaToken, tripProof, + members: proof.members.map(({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo, receiptVerified, measurementReceipt, destination }) => + ({ marker, customerId, incomingNo, dispatchNo, dispatchItemId, balanceId, positionId, palletId, palletNo, receiptVerified, measurementReceipt, destination })) } } catch (error) { failure = error } // Always attempt every successfully opened session, including partial fixture failures. PDA lifecycle belongs to caller. const cleanupErrors = [] diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs index 7abd55d8c..b03b98150 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs @@ -1,90 +1,196 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { verifyFleetExternalDispatch } from './fleet-external-dispatch-acceptance.mjs' +import { readFileSync } from 'node:fs' +import { verifyFleetExternalDispatch, nativeArrangementGroups } from './fleet-external-dispatch-acceptance.mjs' -function fixture(fault = '') { - const segment = { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 2, weightKg: 200, volumeCbm: 2 }, - remaining: { palletSpaces: 28, weightKg: 17800, volumeCbm: 88 } } - const tripProof = { tripNo: 'TR-CURRENT', warehouseId: '92', dispatchNo: 'DP-OWN', - stops: [{ name: '起点' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }], externalReceipt: { - status: 'VERIFIED', createRequest: { customerId: '900719925474099301' }, incomingNo: 'WT-CURRENT', bookingNo: 'FB-CURRENT', - warehousePalletId: '900719925474099311', palletNo: 'PALLET-EXT', finalSegments: [segment], finalBooking: { - cargo: { destinationKey: 'FBA:ONT8' }, receipt: { source: { pallets: [{ weightKg: 100, volumeCbm: 1 }] } }, - }, - } } - let version = 10, linked = false, replanned = false, transferred = false - const calls = [], assertions = [] - const customerId = tripProof.externalReceipt.createRequest.customerId - const item = { warehousePalletId: tripProof.externalReceipt.warehousePalletId, packageCount: 1 } - const booking = () => ({ bookingNo: 'FB-CURRENT', status: linked ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', recordVersion: linked ? 4 : 3, - dispatchNo: linked ? 'DP-EXT' : null }) - const binding = () => ({ tripRecordVersion: version, booking: booking(), replayed: false }) - const dispatches = [{ dispatchNo: 'DP-OWN', review: { status: 'PENDING' } }] - const detail = () => ({ trip: { recordVersion: version, maxPalletSpaces: 30, vehiclePlate: 'TEST-TRUCK', driverName: 'TEST-DRIVER', driverPhone: '19900000000' }, dispatches }) - const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: structuredClone(data) }, requestId: 'req', traceId: 'trace' }) - const context = { marker: 'OFFLINE', createdResources: { fleetTrips: [tripProof] }, - assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) }, - async request(method, path, body) { - calls.push({ method, path, body }) - if (path.endsWith('/capacity-plan')) { - if (method === 'POST') { replanned = true; version++ } - return response({ recordVersion: version, manifestHash: 'a'.repeat(64), planValid: !linked || replanned || fault === 'stale-plan', - capacityPlan: { pallets: [{ warehousePalletId: 'OWN-PALLET', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }] }, - cargo: [{ dispatchNo: 'DP-OWN', warehousePalletId: 'OWN-PALLET', packages: 1 }, ...(linked ? [{ dispatchNo: 'DP-EXT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, packages: 1 }] : [])], - availableSegments: replanned && fault === 'double-capacity' ? [{ ...segment, remaining: { ...segment.remaining, palletSpaces: 27 } }] : [segment] }) - } - if (path.includes('/candidates?')) return response([{ customerId: fault === 'foreign-customer' ? 'wrong' : customerId, overseasWarehouseId: '92', - inboundCode: 'WT-CURRENT', warehousePalletId: tripProof.externalReceipt.warehousePalletId, palletNo: 'PALLET-EXT', packageCount: 1, - sourceStage: 'RECEIVED', planPalletId: 'PLAN-PALLET' }]) - if (path.endsWith('/drafts/containers')) return response({ id: 'CONTAINER' }) - if (path.endsWith('/draft-locks/lock')) return response(null) - if (path.endsWith('/pre-arrange')) return response({ dispatchNo: 'DP-EXT', packageCount: 1, pickedPackageCount: 0, shippedPackageCount: 0, items: [item] }) - if (path.endsWith('/bookings')) return response({ tripRecordVersion: version, records: [booking()] }) - if (path.endsWith('/bookings/FB-CURRENT/dispatch')) { - if (linked) return response({ ...binding(), replayed: true }) - linked = true; version++; dispatches.push({ dispatchNo: 'DP-EXT', review: { status: 'PENDING' } }); return response(binding()) - } - if (path.endsWith('/review')) { - assert.equal(body.recordVersion, version); version++ - const number = path.split('/').at(-2) - if (fault !== 'review-not-applied') dispatches.find(row => row.dispatchNo === number).review.status = 'APPROVED' - return response(detail()) - } - if (path.endsWith('/trips/TR-CURRENT')) return response(detail()) - if (path.endsWith('/external-freight')) return response({ requiresDeliveryFee: false, snapshotHash: 'b'.repeat(64), quotes: [ - { bookingNo: 'FB-CURRENT', incomingNo: 'WT-CURRENT', currency: 'CNY', amount: fault === 'stale-price' ? 50 : 30, quantity: 1, unitPrice: 30 }, - ] }) - if (path.endsWith('/transfer-delivery')) { - if (body.fees.length) return response(null, '本排车全部货物已带入外接约定运费,请勿重复填写派送应收') - const replayed = transferred; transferred = true - return response({ dispatchNo: 'DP-EXT', outboundNo: 'OUT-EXT', packageCount: 1, deliveries: [{ deliveryNo: 'DEL-EXT' }], replayed }) - } - assert.match(path, /\/delivery-orders\/DEL-EXT\?customerId=900719925474099301$/) - const rows = [{ feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }] - if (fault === 'duplicate-fee' && calls.filter(row => row.path === path).length > 1) rows.push({ ...rows[0] }) - return response({ order: { dispatchNo: 'DP-EXT' }, fees: rows }) - }, +const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', TRIP = '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT' +const OWN = '900719925474099301', EXT = '900719925474099302', clone = value => structuredClone(value) +function fixture(fault = '', nativeMode = false) { + const calls = [], assertions = [], ledger = new Map() + const externalCustomer = nativeMode ? OWN : EXT + const destination = n => ({ key: `FBA:A${nativeMode ? 1 : n}`, warehouseCode: `A${nativeMode ? 1 : n}` }) + const item = (n, customerId = OWN) => ({ dispatchItemId: `11${n}`, sourceKey: `PLAN:2${n}`, sourceVersion: 'a'.repeat(64), sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, warehousePalletId: `3${n}`, palletNo: `PAL-${n}`, + customerId, inboundCode: n === 3 ? 'WT-CURRENT' : nativeMode ? `WT-OWN-${n}` : `IN-${n}`, orderNo: n === 3 ? 'WT-CURRENT' : nativeMode ? `WT-OWN-${n}` : `ORDER-${n}`, destination: destination(n === 3 ? 1 : n), scheduledPackages: nativeMode ? 1 : n === 3 ? 3 : 2, pickedPackages: 0 }) + const items = [item(1), item(2, nativeMode ? EXT : OWN)], fullItems = [item(4)], external = item(3, externalCustomer) + const segment = { fromIndex: 0, toIndex: 1, occupied: { palletSpaces: 4, weightKg: 57, volumeCbm: 1.1 }, remaining: { palletSpaces: 26, weightKg: 17943, volumeCbm: 88.9 } } + const bookingCargo = { destinationKey: 'FBA:A1', packageCount: nativeMode ? 1 : 3, palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, priceBasis: 'PALLET', unitPrice: 15, currency: 'CNY' } + const accepted = { source: { incomingNo: 'WT-CURRENT', inboundCode: 'WT-CURRENT', warehouseId: '92', destinationKey: 'FBA:A1', status: 'READY_FOR_DISPATCH', receiptHash: 'd'.repeat(64), receivedPackages: nativeMode ? 1 : 3, + pallets: [{ warehousePalletId: '33', palletNo: 'PAL-3', packages: nativeMode ? 1 : 3, weightKg: 37, volumeCbm: 0.7 }] }, pallets: [{ warehousePalletId: '33', palletSpaces: 2, weightKg: 37, volumeCbm: 0.7 }], operatorId: 'OP', confirmedAt: '2026-10-19T01:00:00Z' } + const currentCase = { marker: 'CURRENT', mode: 'PARTIAL', dispatchNo: 'DS-OWN', customerId: OWN, transferBody: { plannedDeliveryAt: '2026-10-22T01:00:00Z', loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '未覆盖派送费', amount: 17.5, currency: 'USD' }] } } + const full = { ...clone(currentCase), mode: 'FULL', dispatchNo: 'DS-FULL' } + const tripProof = { tripNo: 'TR-CURRENT', dispatchNo: 'DS-OWN', warehouseId: '92', vehicleId: '71', driverId: '72', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }], + externalReceipt: { marker: 'CURRENT', status: 'VERIFIED', incomingNo: 'WT-CURRENT', bookingNo: 'BK-CURRENT', warehousePalletId: '33', palletNo: 'PAL-3', createRequest: { customerId: externalCustomer }, finalSegments: [segment], finalBooking: { cargo: bookingCargo, agreedAmount: 30 } } } + if (nativeMode) tripProof.stops.pop() + let recordVersion = 0, fleetVersion = 0, stage = fault === 'already-loaded' ? 'AFTER_LOADING' : 'LOADING', bound = false, planned = false, review = true, saved + const booking = () => ({ bookingNo: 'BK-CURRENT', customerId: externalCustomer, status: bound ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', dispatchNo: bound ? 'DS-OWN' : null, recordVersion: bound ? 1 : 0, cargo: bookingCargo, agreedAmount: 30, receipt: accepted }) + const counts = rows => ({ itemCount: rows.length, palletCount: rows.length, packageCount: rows.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: rows.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 }) + const detail = (full = false) => ({ dispatchNo: full ? 'DS-FULL' : 'DS-OWN', recordVersion: full ? 0 : recordVersion, status: 'ACTIVE', stage: full ? 'AFTER_LOADING' : stage, + reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, + counts: counts(full ? fullItems : items), pickingReconciled: !full && items.every(i => i.pickedPackages === i.scheduledPackages), dispatchInfo: { route: 'A1,A2' }, + orderDetails: (full ? fullItems : items).map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) + const ack = (action, result = {}) => ({ ...detail(), action, result, replayed: false }) + const groups = () => { + const raw = items.map((i, index) => { + const isExternal = i.sourceKey === 'PLAN:23', groupKey = nativeMode ? `G-${i.customerId}` : `G-${i.sourceKey}` + return { groupKey, customerId: i.customerId, destination: i.destination, groupCargoHash: `${index + 1}`.repeat(64), feeCoverageCargoHash: `${index + 1}`.repeat(64), externalFreightHash: 'b'.repeat(64), blockReasons: [], requiredServices: isExternal ? [] : ['DELIVERY_FEE'], + cargo: [{ ...i, sourceRegion: isExternal || nativeMode ? 'LASTLEG' : 'FIRSTLEG', sourceInboundId: isExternal || nativeMode ? null : `9007199254740994${index}`, orderId: `4${index}`, packages: i.scheduledPackages }], + externalQuotes: isExternal ? [{ customerId: externalCustomer, groupKey, bookingNo: 'BK-CURRENT', inboundCode: 'WT-CURRENT', currency: 'CNY', amount: fault === 'stale-price' ? 50 : 30, sourceHash: 'b'.repeat(64) }] : [], + feeSources: isExternal || nativeMode ? [] : [{ financeFeeId: `9007199254740995${index}`, customerId: OWN, currency: 'CNY', amount: 200, sourceVersion: 1, sourceHash: `FEE-${index}`, status: 'AVAILABLE', services: ['DELIVERY_FEE'], sourceKeys: [i.sourceKey] }] } + }) + if (!nativeMode) return raw + const merged = new Map() + for (const group of raw) { const previous = merged.get(group.groupKey); if (previous) { previous.cargo.push(...group.cargo); previous.externalQuotes.push(...group.externalQuotes) } else merged.set(group.groupKey, group) } + return [...merged.values()].map(g => ({ ...g, requiredServices: g.cargo.some(c => !g.externalQuotes.some(q => q.inboundCode === c.inboundCode)) ? ['DELIVERY_FEE'] : [] })) } - return { context, tripProof, calls, assertions } + const trip = () => ({ trip: { tripNo: 'TR-CURRENT', warehouseId: '92', vehicleId: '71', driverId: '72', recordVersion: fleetVersion, stops: tripProof.stops }, dispatches: [{ dispatchNo: fault === 'second-dispatch' ? 'DS-OTHER' : 'DS-OWN' }] }) + const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: 'CURRENT', createdResources: nativeMode ? { fleetTrips: [tripProof] } : { fleetTrips: [tripProof, { dispatchNo: 'DS-FULL', tripNo: 'TR-FULL', warehouseId: '92' }], fleetFeeCoverageCases: [full, currentCase] }, checkpoint: async () => {}, + assert(_module, name, _response, passed) { assertions.push({ name, passed }) }, addStep(_module, name, _response, passed) { assertions.push({ name, passed }) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, + async request(method, raw, body, token) { + calls.push({ method, path: raw, body: clone(body), token }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), key = `${normalized}:${body?.idempotencyKey}` + if (method !== 'GET' && body?.idempotencyKey && ledger.has(key)) { const original = ledger.get(key); assert.deepEqual(body, original.body); if (fault === 'replay-row-drift' && path.endsWith('/items')) items[0].dispatchItemId = '999'; return response({ ...clone(original.data), replayed: true }) } + const remember = data => { if (body?.idempotencyKey) ledger.set(key, { body: clone(body), data: clone(data) }); return response(data) } + if (path === `${TRIP}/capacity-plan`) { + if (method === 'POST') { assert.equal(body.recordVersion, fleetVersion); assert.equal(body.pallets.length, 3); assert.equal(body.pallets.find(p => p.warehousePalletId === '33').palletSpaces, 2); planned = true; fleetVersion++ } + return response({ recordVersion: fleetVersion, manifestHash: 'c'.repeat(64), planValid: items.length === 2 || planned || fault === 'stale-plan', + cargo: items.map(i => ({ dispatchNo: 'DS-OWN', warehousePalletId: i.warehousePalletId, packages: i.scheduledPackages })), + capacityPlan: { pallets: items.slice(0, 2).map((i, n) => ({ warehousePalletId: i.warehousePalletId, palletSpaces: 1, weightKg: 10, volumeCbm: 0.2, deliveryIndex: nativeMode ? 1 : n + 1 })) }, + availableSegments: planned && fault === 'double-capacity' ? [{ ...segment, remaining: { ...segment.remaining, palletSpaces: 25 } }] : [segment] }) + } + if (path === `${TRIP}/bookings`) return response({ tripRecordVersion: fleetVersion, records: [booking()] }) + if (path === `${TRIP}/bookings/BK-CURRENT/dispatch`) { assert.equal(body.dispatchNo, 'DS-OWN'); assert.equal(body.tripRecordVersion, fleetVersion); assert.equal(body.recordVersion, 0); bound = true; fleetVersion++; return remember({ tripRecordVersion: fleetVersion, booking: booking(), replayed: false }) } + if (path === TRIP) return response(trip()) + if (path === `${BASE}/candidates`) { + assert.equal(params.get('warehouseId'), '92'); assert.equal(params.get('sourceStage'), 'RECEIVED'); assert.equal(params.get('orderNo'), 'WT-CURRENT') + return response({ complete: true, snapshotAt: '2026-10-19', candidates: [{ selection: { sourceType: 'INBOUND_PLAN', planPalletId: '23', returnReceiptId: null, relabelNo: null, warehousePalletId: '33', sourceVersion: 'a'.repeat(64) }, + sourceKey: 'PLAN:23', sourceVersion: 'a'.repeat(64), customerId: fault === 'foreign-customer' ? '999' : externalCustomer, warehouseId: '92', inboundCode: 'WT-CURRENT', palletNo: 'PAL-3', availablePackages: nativeMode ? 1 : 3, destination: destination(1), appointmentBatches: [], blockReasons: [] }] }) + } + if (path === BASE) return response({ records: [{ ...detail(params.get('dispatchNo') === 'DS-FULL'), warehouseId: '92' }] }) + if (raw.includes('/outbound-orders?')) return response({ records: [], total: 0, page: 1, size: 200 }) + if (path.includes('/finance-fee-sources')) { + assert.equal(params.get('customerId'), externalCustomer); assert.equal(params.get('businessNo'), nativeMode ? 'DEL-0' : 'DEL-2') + return response([{ id: '980', sourceFeeId: '970', customerId: fault === 'foreign-source-fee' ? externalCustomer === OWN ? EXT : OWN : externalCustomer, direction: 'RECEIVABLE', feeItemCode: 'DELIVERY_FEE', inboundCode: 'WT-CURRENT', businessNo: nativeMode ? 'DEL-0' : 'DEL-2', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED', originalCurrency: 'CNY', originalAmount: 30, chargeableQuantity: fault === 'wrong-quantity' ? 1 : 2, unitPrice: 15, quantityUnit: '托位' }]) + } + const delivery = path.match(/\/delivery-orders\/DEL-(\d)$/) + if (delivery) { + const index = Number(delivery[1]), group = groups()[index], input = saved.groups[index], who = group.customerId + assert.equal(params.get('customerId'), who) + const feeCoverage = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: group.cargo, operatorId: 'OP', confirmedAt: '2026-10-19T01:00:00Z', + references: input.feeCoverage.map(s => ({ services: s.services, source: { ...group.feeSources.find(f => f.financeFeeId === s.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId } })) } : null + const fees = input.fees.map(f => ({ id: String(981 + index), feeDirection: f.direction, feeType: f.feeType, amount: f.amount, currency: f.currency, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' })) + if (group.externalQuotes.length) fees.push({ id: '980', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', amount: 30, currency: 'CNY', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }) + if (fault === 'duplicate-fee' && group.externalQuotes.length && calls.filter(c => c.path === raw).length > 1) fees.push(clone(fees[0])) + return response({ order: { customerId: who, dispatchNo: 'DS-OWN', deliveryNo: `DEL-${index}`, outboundNo: who === EXT ? 'OUT-EXT' : 'OUT-OWN', destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage }) + } + const isFull = path.startsWith(`${BASE}/DS-FULL`), data = detail(isFull), cargo = isFull ? fullItems : items + if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) }) + if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo }) + if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops }) + if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1, + evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) }) + if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return response({ ...data, cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), groups: groups(), arrange: { allowed: !saved }, arrangement: saved ? { dispatchNo: 'DS-OWN' } : null }) + if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) } + if (path.endsWith('/pick') && body.recordVersion !== recordVersion) return response(null, '公共版本已变化') + assert.equal(body.recordVersion, recordVersion, raw) + if (path.endsWith('/return-to-review')) { stage = 'BEFORE_LOADING'; review = false; recordVersion++; return remember(ack('RETURN_TO_REVIEW')) } + if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) } + if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) } + if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) } + if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'AFTER_LOADING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + assert.ok(path.endsWith('/transfer-delivery'), raw); assert.equal(stage, 'AFTER_LOADING') + if (body.cargoHash !== 'a'.repeat(64)) return response(null, '装车或实际货物已经变化') + if (body.groups.some(g => g.feeCoverage.some(s => s.sourceVersion !== 1))) return response(null, '原费用版本已变化') + if (body.groups.some(g => g.externalFreightHash !== 'b'.repeat(64))) return response(null, '外接实收或约定运费已变化') + if (body.groups.some(g => new Set(g.fees.map(f => `${f.direction}:${f.feeType}`)).size !== g.fees.length)) return response(null, '同组同方向同费用不得重复登记') + if (body.groups.some(g => g.groupKey === 'G-PLAN:23' && g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收') + if (!body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对') + saved = clone(body); recordVersion++ + if (fault === 'unknown-arrange') return { http: 0, body: null } + return remember(ack('ARRANGE_DELIVERY', { dispatchNo: 'DS-OWN', cargoHash: 'a'.repeat(64), outbounds: [...new Set(items.map(i => i.customerId))].map(customerId => ({ customerId, outboundNo: customerId === OWN ? 'OUT-OWN' : 'OUT-EXT', status: 'WAIT_OUTBOUND', packageCount: items.filter(i => i.customerId === customerId).reduce((n, i) => n + i.scheduledPackages, 0) })), + deliveries: groups().map((g, n) => ({ groupKey: g.groupKey, customerId: g.customerId, destination: g.destination, outboundNo: g.customerId === EXT ? 'OUT-EXT' : 'OUT-OWN', deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY', packageCount: g.cargo.reduce((n, c) => n + c.packages, 0) })) })) + } } + const options = nativeMode ? { pickerToken: 'OFFLINE-ONLY', arrangement: { mode: 'NATIVE', marker: 'CURRENT', dispatchNo: 'DS-OWN', plannedDeliveryAt: '2026-10-22T01:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', appointmentAccount: 'SELF', + groups: [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户A明确派送费', currency: 'USD', amount: 11 }] }, { customerId: EXT, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户B明确派送费', currency: 'CNY', amount: 17 }] }] } } : { coverageCase: currentCase, pickerToken: 'OFFLINE-ONLY' } + return { context, tripProof, currentCase, calls, assertions, items, options } } -test('external dispatch helper preserves reservation when binding physical cargo and publishes only agreed source fee', async () => { - const f = fixture() - const proof = await verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof) - assert.equal(proof.status, 'VERIFIED') - assert.equal(proof.dispatchNo, 'DP-EXT') - assert.equal(proof.sourceFees.length, 1) - assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED') - assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) - assert.ok(f.calls.every(row => !/\/pick|\/depart|\/cancel/.test(row.path))) - await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof), /尚未执行关联/) +test('native external flow adds actual received cargo to same whole, re-reviews, scans, loads and arranges all customer groups', async () => { + const f = fixture(), proof = await verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.dispatchNo, 'DS-OWN'); assert.equal(proof.sourceFees.length, 1); assert.equal(proof.sourceFees[0].chargeableQuantity, 2) + assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED'); assert.equal(f.currentCase.proof.status, 'VERIFIED'); assert.equal(f.currentCase.proof.newReceivable, 17.5) + assert.ok(f.assertions.every(a => a.passed)); assert.equal(f.calls.some(c => /pre-arrange|dispatches\/bind|dispatches\/unbind|\/cancel/.test(c.path)), false) + assert.equal(f.calls.filter(c => c.method === 'POST' && c.path === BASE).length, 0) + assert.ok(f.calls.findIndex(c => c.path.endsWith('/return-to-review')) < f.calls.findIndex(c => c.path.endsWith('/items') && c.method === 'POST')) + assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery'))) + assert.equal(f.items.length, 3); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /尚未装入/) }) -for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee']) { - test(`external dispatch proof blocks ${fault}`, async () => { - const f = fixture(fault) - await assert.rejects(() => verifyFleetExternalDispatch(f.context, 'fleet-dispatch', f.tripProof)) - assert.equal(f.tripProof.externalDispatch.status, 'CHECKING') - assert.ok(f.assertions.some(row => !row.passed)) - }) -} +for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee', 'foreign-source-fee', 'wrong-quantity', 'replay-row-drift', 'second-dispatch', 'already-loaded', 'unknown-add']) test(`native external proof rejects ${fault}`, async () => { + const f = fixture(fault); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options)); assert.notEqual(f.tripProof.externalDispatch.status, 'VERIFIED') + if (['foreign-customer', 'already-loaded'].includes(fault)) assert.equal(f.calls.some(c => c.method === 'POST'), false) + if (fault === 'unknown-add') { assert.equal(f.tripProof.externalDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(f.calls.at(-1).path.endsWith('/items'), true); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true) } +}) + +test('no external callback injection can replace real registered PARTIAL sources, current PDA or durable journal', async () => { + for (const mutate of [f => { delete f.options.coverageCase }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { + const f = fixture(); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /同主单未扫码PARTIAL/); assert.equal(f.calls.length, 0) + } +}) + +test('server binding requires the same existing whole dispatch and public cargo commands use versioned ADD_CARGO', () => { + const service = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java', import.meta.url), 'utf8') + assert.ok(service.includes('identity.tripId()!=trip.getId()')); assert.ok(service.includes('planning.validateIncomingDispatch')) + const source = readFileSync(new URL('./fleet-external-dispatch-acceptance.mjs', import.meta.url), 'utf8') + assert.equal(source.includes('/pre-arrange'), false); assert.equal(source.includes('draftToken'), false); assert.ok(source.includes('/return-to-review')); assert.ok(source.includes("'ADD_CARGO'")) +}) + +test('ordinary native mode accepts two WAREHOUSE_TRANSFER sources and same-customer external cargo without FULL/PARTIAL fixtures', async () => { + const f = fixture('', true), proof = await verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.arrangementMode, 'NATIVE'); assert.equal(proof.nativeArrangement.status, 'VERIFIED') + assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false); assert.equal(f.currentCase.proof, undefined) + assert.equal(proof.deliveries.length, 2); assert.equal(proof.deliveries.reduce((n, d) => n + d.packageCount, 0), 3) + assert.ok(f.items.some(i => i.inboundCode === 'WT-OWN-1') && f.items.some(i => i.inboundCode === 'WT-OWN-2')); assert.equal(f.items.length, 3) + const requests = f.calls.filter(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')), actual = requests.at(-1) + assert.deepEqual(proof.transferRequest.groups.map(g => g.fees.map(f => [f.currency, f.amount])), [[['USD', 11]], [['CNY', 17]]]) + assert.ok(proof.transferRequest.groups.every(g => g.feeCoverage.length === 0 && !g.groupKey.startsWith('FBA:'))) + assert.equal(requests.length, 5); assert.equal(actual.body.idempotencyKey, proof.transferRequest.idempotencyKey) + assert.ok(f.calls.findIndex(c => c.path.endsWith('/pick')) < f.calls.findIndex(c => c.path.endsWith('/loading-complete'))) + assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery'))) + assert.ok(f.calls.every(c => !c.path.includes('DS-FULL') && !c.path.includes('pre-arrange'))); assert.ok(f.assertions.every(a => a.passed)) + const snapshots = proof.nativeArrangement.snapshots + assert.deepEqual(snapshots['DEL-0'].fees.map(f => [f.currency, f.amount]), [['USD', 11], ['CNY', 30]]) + assert.deepEqual(snapshots['DEL-1'].fees.map(f => [f.currency, f.amount]), [['CNY', 17]]) + assert.equal(proof.sourceFees[0].originalAmount, 30); assert.equal(proof.sourceFees[0].customerId, OWN) +}) + +test('ordinary native configuration and coverage mode are exclusive and require current explicit group configuration', async () => { + for (const mutate of [f => { delete f.options.arrangement }, f => { f.options.coverageCase = f.currentCase }, f => { f.options.arrangement.mode = 'SAME_ROUTE' }, + f => { f.options.arrangement.marker = 'OLD' }, f => { f.options.arrangement.dispatchNo = 'OTHER' }, f => { f.options.arrangement.groups.push(clone(f.options.arrangement.groups[0])) }, + f => { f.options.arrangement.groups = async () => ({ success: true }) }, f => { f.options.arrangement.groups[0].fees[0].amount = 0 }, f => { f.options.arrangement.groups[0].fees.push(clone(f.options.arrangement.groups[0].fees[0])) }, + f => { f.options.arrangement.groups[0].feeCoverage = [] }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { + const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.equal(f.calls.length, 0) + } + for (const mutate of [f => { f.options.arrangement.groups.pop() }, f => { f.options.arrangement.groups[0].customerId = '999' }]) { + const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options), /加货前精确覆盖/); assert.equal(f.calls.some(c => c.method !== 'GET'), false) + } +}) + +for (const fault of ['foreign-customer', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee', 'foreign-source-fee', 'wrong-quantity', 'replay-row-drift', 'already-loaded', 'missing-measurements', 'unknown-arrange']) test(`ordinary native flow preserves protection against ${fault}`, async () => { + const f = fixture(fault, true); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.notEqual(f.tripProof.externalDispatch.status, 'VERIFIED') + assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false) + if (fault === 'missing-measurements') assert.equal(f.calls.some(c => c.path.endsWith('/loading-complete') || c.path.endsWith('/transfer-delivery')), false) + if (fault === 'unknown-arrange') { assert.equal(f.tripProof.externalDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(f.calls.at(-1).path.endsWith('/transfer-delivery'), true); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true); assert.ok(f.tripProof.externalDispatch.nativeArrangement.confirmationRequest.idempotencyKey) } +}) + +test('ordinary native plan permits explicit zero extra fee only for fully quoted cargo and never expands the quote into each group', () => { + const f = fixture('', true), receipt = f.tripProof.externalReceipt, plan = clone(f.options.arrangement) + plan.groups = [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [] }] + const group = { groupKey: 'REAL-GROUP', customerId: OWN, destination: { key: 'FBA:A1' }, groupCargoHash: 'a'.repeat(64), externalFreightHash: 'b'.repeat(64), feeCoverageCargoHash: 'a'.repeat(64), blockReasons: [], requiredServices: [], feeSources: [], + cargo: [{ customerId: OWN, inboundCode: receipt.incomingNo, destination: { key: 'FBA:A1' } }], externalQuotes: [{ groupKey: 'REAL-GROUP', customerId: OWN, bookingNo: receipt.bookingNo, inboundCode: receipt.incomingNo, currency: 'CNY', amount: 30, sourceHash: 'b'.repeat(64) }] } + const groups = nativeArrangementGroups({ groups: [group] }, plan, receipt) + assert.deepEqual(groups[0].fees, []); assert.deepEqual(groups[0].feeCoverage, []); assert.equal(groups[0].groupKey, 'REAL-GROUP') + plan.groups[0].fees = [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: 'duplicate', currency: 'CNY', amount: 30 }] + assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /不得重复登记/) + plan.groups[0].fees = []; group.requiredServices = ['DELIVERY_FEE']; assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /尚未覆盖/) +}) diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs index 76af87ca7..78d9b70b6 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs @@ -1,116 +1,244 @@ -import { randomUUID } from 'node:crypto' +import { DISPATCH_BASE as BASE, id as stringId, version, createBody, candidatePage, readDispatch, replay } from './grouped-dispatch-acceptance.mjs' +import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' -const OPTIONS = { scenario: 'fleet-external-dispatch-price', deploymentBlocker: true } -const id = value => value == null ? '' : String(value) -const key = () => `fleet-dispatch-${randomUUID()}` -const stable = rows => JSON.stringify(rows) - -/** Continues a current-run VERIFIED external receipt. Leaves both dispatches ready for the caller's real PDA picking. */ -export async function verifyFleetExternalDispatch(context, module, tripProof) { - const receipt = tripProof.externalReceipt - if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch) - throw new Error('外接关联只接受本轮已验证实收且尚未执行关联的车次') - const proof = tripProof.externalDispatch = { status: 'CHECKING', marker: context.marker, requests: [] } - const customerId = id(receipt.createRequest.customerId) - const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}` - const dispatchBase = '/api/platform/v1/last-leg/dispatches' - async function check(name, passed, response) { - context.assert(module, name, response, Boolean(passed), '', OPTIONS) - if (!passed) throw new Error(`${module}: ${name}`) +const clone = value => structuredClone(value) +const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) } +const money = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('外接约定金额或单价不完整'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) } +const identity = item => ({ dispatchItemId: id(item.dispatchItemId), sourceKey: item.sourceKey, customerId: id(item.customerId), warehousePalletId: id(item.warehousePalletId), scheduledPackages: Number(item.scheduledPackages), pickedPackages: Number(item.pickedPackages) }) +const groupIdentity = (customerId, destinationKey) => JSON.stringify([id(customerId), destinationKey]) +const sha = value => /^[a-f0-9]{64}$/i.test(value || '') +const feeIdentity = fee => `${fee.direction}:${fee.feeType}:${fee.currency}:${money(fee.amount)}` +function validateNativeArrangement(context, tripProof, plan) { + if (plan?.mode !== 'NATIVE' || plan.marker !== context.marker || plan.dispatchNo !== tripProof.dispatchNo + || typeof plan.plannedDeliveryAt !== 'string' || !Number.isFinite(Date.parse(plan.plannedDeliveryAt)) + || !plan.deliveryMethod || !plan.pickupMethod || plan.loadingType !== 'PALLET' || plan.appointmentAccount !== 'SELF' + || !Array.isArray(plan.groups) || !plan.groups.length) throw Error('普通原生安排须明确本轮主单、派送时间、PALLET/SELF及逐客户目的地费用') + const keys = new Set() + for (const group of plan.groups) { + const key = groupIdentity(group.customerId, group.destinationKey) + if (!/^[1-9]\d*$/.test(id(group.customerId)) || typeof group.destinationKey !== 'string' || !group.destinationKey.trim() || keys.has(key) || !Array.isArray(group.fees) || Object.hasOwn(group, 'feeCoverage')) throw Error('普通安排的客户目的地配置缺失、重复或含原费覆盖选择') + keys.add(key) + const fees = new Set() + for (const fee of group.fees) { + const feeKey = `${fee.direction}:${fee.feeType}` + if (!['RECEIVABLE', 'PAYABLE'].includes(fee.direction) || !['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].includes(fee.feeType) + || !fee.feeName || !['CNY', 'USD'].includes(fee.currency) || money(fee.amount) <= 0n || fees.has(feeKey)) throw Error('普通安排费用必须逐组明确正数、币种和服务,不得重复') + fees.add(feeKey) + } } - async function request(method, path, body, label, expectedMessage) { - const response = await context.request(method, path, body) - proof.requests.push({ method, path, label, http: response?.http, code: response?.body?.code, - requestId: response?.requestId, traceId: response?.traceId }) - const conflict = response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409') - await check(label, response?.requestId && response?.traceId && (expectedMessage - ? conflict && expectedMessage.test(id(response?.body?.message)) : response?.http === 200 && id(response?.body?.code) === '200'), response) - return response.body?.data +} + +// 仅将明确的客户/目的地配置映射到实时groupKey与摘要;不推算费用或原费覆盖。 +export function nativeArrangementGroups(preview, plan, receipt) { + const configured = new Map(plan.groups.map(g => [groupIdentity(g.customerId, g.destinationKey), g])) + if (!Array.isArray(preview.groups) || preview.groups.length !== configured.size || new Set(preview.groups.map(g => groupIdentity(g.customerId, g.destination?.key))).size !== configured.size) throw Error('普通安排配置必须精确覆盖全车全部真实客户目的地') + return preview.groups.map(group => { + const config = configured.get(groupIdentity(group.customerId, group.destination?.key)) + if (!config || !group.groupKey || !sha(group.groupCargoHash) || !sha(group.externalFreightHash) || !sha(group.feeCoverageCargoHash) + || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.requiredServices) || !Array.isArray(group.externalQuotes) + || !Array.isArray(group.feeSources) || !Array.isArray(group.cargo) || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('普通原生安排的实际分组或来源门禁不完整') + if (group.externalQuotes.some(q => q.bookingNo !== receipt.bookingNo || q.inboundCode !== receipt.incomingNo || id(q.customerId) !== id(group.customerId) + || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash || q.currency !== receipt.finalBooking.cargo.currency || money(q.amount) !== money(receipt.finalBooking.agreedAmount))) throw Error('普通安排的外接报价已变化或越过客户来源') + if (group.requiredServices.some(service => !config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === service))) throw Error('普通安排必须明确填写本组尚未覆盖的服务费用') + const quotedAll = group.cargo.every(c => group.externalQuotes.some(q => q.inboundCode === c.inboundCode)) + if (quotedAll && config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE')) throw Error('本组派送已由外接约定覆盖,不得重复登记应收') + return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: plan.plannedDeliveryAt, + deliveryMethod: plan.deliveryMethod, pickupMethod: plan.pickupMethod, loadingType: plan.loadingType, appointmentAccount: plan.appointmentAccount, + waybillNo: plan.waybillNo || null, carrierName: plan.carrierName || null, vehicleType: plan.vehicleType || null, vehicleSize: plan.vehicleSize || null, sellerName: null, remark: plan.remark || null, + externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: clone(config.fees) } + }) +} + +async function arrangeNative(context, module, io, proof, tripProof, plan, pickerToken, actualMeasurements) { + const current = await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements }) + const path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}`, preview = await io.get(`${path}/delivery-arrangement`, '普通模式读取装车后实时全车派送分组') + io.check('普通安排使用当前公共版本和真实实载摘要', null, preview?.dispatchNo === tripProof.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' + && id(version(preview.recordVersion)) === id(version(current.data.recordVersion)) && preview.arrange?.allowed === true && !preview.arrangement && sha(preview.cargoHash) && sha(preview.loadingCompletionHash)) + const groups = nativeArrangementGroups(preview, plan, tripProof.externalReceipt), totals = new Map() + for (const group of preview.groups) for (const row of group.cargo) { + io.check('普通派送货物保留实际客户目的地和原物理行', null, current.items.items.some(i => id(i.dispatchItemId) === id(row.dispatchItemId) && id(i.customerId) === id(group.customerId) && i.destination.key === group.destination.key && id(i.warehousePalletId) === id(row.warehousePalletId))) + totals.set(id(row.dispatchItemId), (totals.get(id(row.dispatchItemId)) || 0) + Number(row.packages)) } - const before = await request('GET', `${base}/capacity-plan`, undefined, '关联外接前冻结有效计划与预留') - await check('实际外接仍计入承诺容量', before?.planValid && before.capacityPlan?.pallets?.length > 0 - && stable(before.availableSegments) === stable(receipt.finalSegments)) - const candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: receipt.incomingNo })}`, - undefined, '查询本轮真实外接来货的可排车实托') - await check('候选精确匹配本轮客户起运仓来货及实托', Array.isArray(candidates) && candidates.length === 1 - && id(candidates[0].customerId) === customerId && id(candidates[0].overseasWarehouseId) === id(tripProof.warehouseId) - && candidates[0].inboundCode === receipt.incomingNo && id(candidates[0].warehousePalletId) === receipt.warehousePalletId - && candidates[0].palletNo === receipt.palletNo && Number(candidates[0].packageCount) === 1 - && candidates[0].sourceStage === 'RECEIVED' && candidates[0].planPalletId) - const candidate = candidates[0] - proof.draftToken = key() - const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken: proof.draftToken, capacity: 30 }, '建立本轮外接排车草稿') - await check('草稿返回明确容器引用', container?.id) - proof.draftContainerId = id(container.id) - await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId: id(candidate.planPalletId), - draftToken: proof.draftToken, draftContainerId: proof.draftContainerId }, '正式锁定本轮外接实托') - proof.preArrangeRequest = { planPalletIds: [id(candidate.planPalletId)], draftToken: proof.draftToken } - const arranged = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '从真实已收货来源生成外接客户排车') - await check('外接排车只包含本票实收货物', arranged?.dispatchNo && arranged.dispatchNo !== tripProof.dispatchNo - && arranged.items?.length === 1 && id(arranged.items[0].warehousePalletId) === receipt.warehousePalletId - && Number(arranged.packageCount) === 1 && Number(arranged.pickedPackageCount) === 0 && Number(arranged.shippedPackageCount) === 0) - proof.dispatchNo = arranged.dispatchNo - const replay = await request('POST', `${dispatchBase}/pre-arrange`, proof.preArrangeRequest, '重放外接预排不生成第二张排车') - await check('预排重放保持排车与实托', replay?.dispatchNo === proof.dispatchNo && stable(replay.items) === stable(arranged.items)) - const bookingPath = `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}` - const bookingList = await request('GET', `${base}/bookings`, undefined, '读取关联前最新预订及车次版本') - const booking = bookingList?.records?.find(row => row.bookingNo === receipt.bookingNo) - await check('本票仍为实收完成且未关联', booking?.status === 'RECEIVED' && booking.incomingNo === receipt.incomingNo && !booking.dispatchNo) - proof.bindRequest = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, - dispatchNo: proof.dispatchNo, idempotencyKey: key() } - const linked = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '将本票外接真实排车关联到承诺车次') - await check('关联后预订绑定同一排车', linked?.booking?.status === 'BOUND' && linked.booking.dispatchNo === proof.dispatchNo) - const linkedReplay = await request('POST', `${bookingPath}/dispatch`, proof.bindRequest, '重放外接排车关联') - await check('关联重放不增加版本或第二次占位', linkedReplay?.replayed === true - && id(linkedReplay.tripRecordVersion) === id(linked.tripRecordVersion) && stable(linkedReplay.booking) === stable(linked.booking)) - const after = await request('GET', `${base}/capacity-plan`, undefined, '读取新增真实货物后的计划容量') - await check('新增外接货物使旧容量确认待重核', after?.planValid === false && after.cargo?.some(row => row.dispatchNo === proof.dispatchNo - && id(row.warehousePalletId) === receipt.warehousePalletId && Number(row.packages) === 1)) - const pallets = new Map(before.capacityPlan.pallets.map(row => [id(row.warehousePalletId), row])) - const actual = receipt.finalBooking.receipt.source.pallets[0] - pallets.set(receipt.warehousePalletId, { warehousePalletId: receipt.warehousePalletId, palletSpaces: 1, - weightKg: Number(actual.weightKg), volumeCbm: Number(actual.volumeCbm), - deliveryIndex: tripProof.stops.findIndex(stop => stop.destinationKey === receipt.finalBooking.cargo.destinationKey) }) - await check('新容量只引用原计划和已核实外接实测', after.cargo.every(row => pallets.has(id(row.warehousePalletId))) - && new Set(after.cargo.map(row => id(row.warehousePalletId))).size === pallets.size) - const replanned = await request('POST', `${base}/capacity-plan`, { recordVersion: after.recordVersion, manifestHash: after.manifestHash, - pallets: [...pallets.values()], reason: '外接预订转实际发运后按本轮实测重新确认,不重复计算原预留' }, '重新核对包含外接实物的全部货物容量') - await check('从预留转实际关联后分段总占用守恒', replanned?.planValid === true && stable(replanned.availableSegments) === stable(before.availableSegments)) - let detail = await request('GET', base, undefined, '读取本车所有客户排车待审核情况') - await check('自有货与外接客户排车均保留', detail?.dispatches?.some(row => row.dispatchNo === tripProof.dispatchNo) - && detail.dispatches.some(row => row.dispatchNo === proof.dispatchNo)) - for (const binding of detail.dispatches) detail = await request('POST', `${base}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, - { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }, '关联变化后逐客户通过调度审核') - await check('每张客户执行单都有通过事实', detail.dispatches.every(row => row.review?.status === 'APPROVED')) - const dispatchPath = `${dispatchBase}/${encodeURIComponent(proof.dispatchNo)}` - const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '读取本票实收约定运费') - await check('外接报价精确对应本票实收和确认单价', freight?.requiresDeliveryFee === false && freight.quotes?.length === 1 - && freight.quotes[0].bookingNo === receipt.bookingNo && freight.quotes[0].incomingNo === receipt.incomingNo - && freight.quotes[0].currency === 'CNY' && Number(freight.quotes[0].amount) === 30 - && Number(freight.quotes[0].quantity) === 1 && Number(freight.quotes[0].unitPrice) === 30) - proof.transferRequest = { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), - deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', - vehicleSize: Number(detail.trip.maxPalletSpaces) <= 15 ? '15托' : '30托', vehiclePlate: detail.trip.vehiclePlate, - driverName: detail.trip.driverName, driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', - remark: `${context.marker} 外接实收报价验收`, externalFreightHash: freight.snapshotHash, fees: [] } - await request('POST', `${dispatchPath}/transfer-delivery`, { ...proof.transferRequest, - idempotencyKey: key(), fees: [{ feeType: 'DELIVERY_FEE', feeName: '重复派送费测试', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 }] }, - '全部外接已有约定运费时拒绝重复派送应收', /重复|再次/) - const transferred = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '按实收报价建立本票派送及外接应收来源') - await check('外接只生成本目的地一张派送', transferred?.dispatchNo === proof.dispatchNo && transferred.outboundNo - && transferred.deliveries?.length === 1 && Number(transferred.packageCount) === 1) - proof.outboundNo = transferred.outboundNo; proof.deliveryNo = transferred.deliveries[0].deliveryNo - const deliveryPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(customerId)}` - const delivery = await request('GET', deliveryPath, undefined, '回查本票派送费用来源') - const fees = delivery?.fees?.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE') - await check('本票只登记约定30元应收来源且没有伪零费用', fees?.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY' - && fees[0].sourceStatus === 'ACTIVE' && fees[0].reviewStatus === 'APPROVED' && delivery.order?.dispatchNo === proof.dispatchNo) - const transferReplay = await request('POST', `${dispatchPath}/transfer-delivery`, proof.transferRequest, '外接转派送原请求重放') - const replayedDelivery = await request('GET', deliveryPath, undefined, '重放后回查外接应收未重复') - await check('外接重放保持派送及费用来源', transferReplay?.replayed === true && transferReplay.outboundNo === proof.outboundNo - && stable(replayedDelivery.fees) === stable(delivery.fees)) - proof.customerId = customerId; proof.finalTripVersion = detail.trip.recordVersion - proof.sourceFees = fees; proof.financeAcknowledgement = 'NOT_VERIFIED' - proof.status = 'VERIFIED' - return proof + io.check('普通安排逐物理行箱数完整守恒', null, totals.size === current.items.items.length && current.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages))) + const body = { recordVersion: version(preview.recordVersion), idempotencyKey: io.key('native-arrangement'), cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups } + proof.nativeArrangement = { mode: 'NATIVE', confirmationRequest: clone(body), status: 'CHECKING' }; await context.checkpoint() + const noDocuments = async () => { + for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) { + const rows = []; let total + for (let page = 1; ; page++) { + const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId, page: String(page), size: '200' })}`, '普通安排拒绝前后按客户回查无出库写入') + io.check('普通安排出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0 && Number(data.page) === page && Number(data.size) === 200 + && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total))) + total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break + } + io.check('普通安排拒绝未生成本主单出库', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== tripProof.dispatchNo)) + } + } + const reject = async (action, changed, pattern) => { + const before = await readDispatch(io, tripProof.dispatchNo) + await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: io.key(action) }, action, pattern) + await noDocuments(); io.check(`${action}不改整车版本及实物`, null, JSON.stringify(await readDispatch(io, tripProof.dispatchNo)) === JSON.stringify(before)) + } + await noDocuments() + const changedHash = hash => `${hash[0] === '0' ? '1' : '0'}${hash.slice(1)}` + await reject('普通安排陈旧货物摘要拒绝', { cargoHash: changedHash(body.cargoHash) }, /货物.*变化/) + const quoted = preview.groups.find(g => g.externalQuotes.length) + io.check('普通安排包含本轮已确认外接报价', null, Boolean(quoted)) + const stale = clone(groups); stale.find(g => g.groupKey === quoted.groupKey).externalFreightHash = changedHash(quoted.externalFreightHash) + await reject('普通安排陈旧外接报价摘要拒绝', { groups: stale }, /外接.*变化/) + const duplicate = clone(groups), quote = quoted.externalQuotes[0] + duplicate.find(g => g.groupKey === quoted.groupKey).fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接应收反例', currency: quote.currency, amount: quote.amount }) + await reject('普通安排重复外接应收拒绝', { groups: duplicate }, /重复|覆盖/) + const command = await io.request('POST', `${path}/transfer-delivery`, body, '普通原生模式一次安排全车全部客户目的地'), result = command?.result + io.check('普通原生命令完整返回真实客户出库和目的地派送', null, command?.dispatchNo === tripProof.dispatchNo && command.action === 'ARRANGE_DELIVERY' && command.status === 'ACTIVE' && command.stage === 'AFTER_LOADING' + && BigInt(version(command.recordVersion)) > BigInt(version(body.recordVersion)) && result?.dispatchNo === tripProof.dispatchNo && result.cargoHash === body.cargoHash + && result.deliveries?.length === preview.groups.length && new Set(result.deliveries.map(d => d.deliveryNo)).size === result.deliveries.length + && result.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size + && result.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key + && g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) && result.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId))) + && result.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && Number(o.packageCount) === result.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0))) + const snapshots = {} + for (const ref of result.deliveries) { + const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = groups.find(g => g.groupKey === ref.groupKey) + const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排回查客户目的地及明确费用') + const expectedFees = [...input.fees.map(feeIdentity), ...group.externalQuotes.filter(q => money(q.amount) > 0n).map(q => feeIdentity({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: q.currency, amount: q.amount }))] + io.check('普通派送保留本客户本目的地费用,无伪零、重复或原费覆盖回执', null, detail?.order?.dispatchNo === tripProof.dispatchNo && detail.order.deliveryNo === ref.deliveryNo && id(detail.order.customerId) === id(ref.customerId) + && detail.order.outboundNo === ref.outboundNo && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId) + && (detail.feeCoverage == null || detail.feeCoverage.references?.length === 0) && Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length + && detail.fees.every(f => money(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED') + && stableRows(detail.fees.map(f => feeIdentity({ direction: f.feeDirection, feeType: f.feeType, currency: f.currency, amount: f.amount }))) === stableRows(expectedFees)) + snapshots[ref.deliveryNo] = { fees: clone(detail.fees), feeCoverage: clone(detail.feeCoverage) } + } + const again = await replay(io, `${path}/transfer-delivery`, body, command, '普通安排原键原载荷重放'); io.check('普通安排明确恢复原成功命令', null, again.replayed === true) + for (const ref of result.deliveries) { + const after = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排重放后核对不追加应收') + io.check('普通安排重放不增费不改覆盖快照', null, JSON.stringify({ fees: after.fees, feeCoverage: after.feeCoverage }) === JSON.stringify(snapshots[ref.deliveryNo])) + } + const after = await io.get(`${path}/delivery-arrangement`, '普通安排后回查报价与原费用候选未被改写') + const originalFacts = value => stableRows(value.groups.map(g => ({ groupKey: g.groupKey, externalQuotes: g.externalQuotes, feeSources: g.feeSources }))) + io.check('普通安排保持报价及原费用来源快照', null, originalFacts(after) === originalFacts(preview)) + Object.assign(proof.nativeArrangement, { status: 'VERIFIED', command, snapshots }) + return { confirmationRequest: body, deliveries: result.deliveries, outbounds: result.outbounds } +} + +/** 已确认实收来源只ADD_CARGO到原整车;复审、真实扫码、显式装车后一次安排全部客户。 + * options二选一:coverageCase为本轮PARTIAL;或arrangement={mode:'NATIVE',marker,dispatchNo, + * plannedDeliveryAt,deliveryMethod,pickupMethod,loadingType:'PALLET',appointmentAccount:'SELF', + * groups:[{customerId,destinationKey,fees:FeeItem[]}]}; 金额和费用覆盖均不自动推断。 + */ +export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase, arrangement } = {}) { + const receipt = tripProof?.externalReceipt, cases = context.createdResources?.fleetFeeCoverageCases + if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch) + throw Error('外接只接受本轮已运营确认且尚未装入本车的真实来货') + if (Boolean(coverageCase) === Boolean(arrangement)) throw Error('必须且只能选择同主单未扫码PARTIAL费用覆盖或普通NATIVE安排模式') + if (arrangement) validateNativeArrangement(context, tripProof, arrangement) + if (coverageCase && (!cases?.includes(coverageCase) || coverageCase.marker !== context.marker || coverageCase.mode !== 'PARTIAL' + || coverageCase.dispatchNo !== tripProof.dispatchNo || coverageCase.proof) || !pickerToken || typeof context.checkpoint !== 'function') + throw Error('外接加货前须有同主单未扫码PARTIAL用例及本轮PDA身份;必须先外接后扫码安排') + const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, arrangementMode: arrangement ? 'NATIVE' : 'FEE_COVERAGE', status: 'CHECKING', requests: [] } + const io = fleetConsumerIo(context, module, proof, `EXTERNAL-${tripProof.dispatchNo}`) + const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`, path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}` + const commandReplay = async (path, body, result, label) => { const again = await replay(io, path, body, result, label); io.check(`${label}明确恢复原结果`, null, again.replayed === true) } + try { + let current = await readDispatch(io, tripProof.dispatchNo) + io.check('只调整本轮同仓零扫码原主单', null, id(current.warehouseId) === id(tripProof.warehouseId) && current.data.status === 'ACTIVE' + && ['BEFORE_LOADING', 'LOADING'].includes(current.data.stage) && Number(current.data.counts.pickedPackageCount) === 0 && Number(current.data.counts.shippedPackageCount) === 0) + const ownItems = current.items.items.map(identity) + const before = await io.get(`${base}/capacity-plan`, '冻结外接预留与真实原容量计划') + io.check('原容量和外接承诺均完整', null, before?.planValid === true && before.capacityPlan?.pallets?.length > 0 && JSON.stringify(before.availableSegments) === JSON.stringify(receipt.finalSegments)) + let list = await io.get(`${base}/bookings`, '核对运营确认后的原booking和原报价') + const booking = list.records?.find(row => row.bookingNo === receipt.bookingNo), accepted = booking?.receipt + io.check('实时booking保持本轮客户仓实收和运营确认', null, booking?.status === 'RECEIVED' && !booking.dispatchNo && booking.incomingNo === receipt.incomingNo + && id(booking.customerId) === id(receipt.createRequest?.customerId) && accepted?.source?.warehouseId === id(tripProof.warehouseId) + && accepted.source.incomingNo === receipt.incomingNo && accepted.source.status === 'READY_FOR_DISPATCH' && accepted.source.receiptHash + && accepted.source.pallets?.length > 0 && Array.isArray(accepted.pallets) && accepted.operatorId && Number.isFinite(Date.parse(accepted.confirmedAt)) + && JSON.stringify(booking.cargo) === JSON.stringify(receipt.finalBooking.cargo) && String(booking.agreedAmount) === String(receipt.finalBooking.agreedAmount)) + const actual = accepted.source.pallets, packages = actual.reduce((n, p) => n + Number(p.packages), 0) + io.check('真实来货托盘和箱数守恒', null, new Set(actual.map(p => id(p.warehousePalletId))).size === actual.length && packages === Number(accepted.source.receivedPackages) + && actual.every(p => p.warehousePalletId && p.palletNo && Number.isSafeInteger(Number(p.packages)) && Number(p.packages) > 0) + && actual.some(p => id(p.warehousePalletId) === id(receipt.warehousePalletId))) + const page = await candidatePage(io, tripProof.warehouseId, { orderNo: receipt.incomingNo }) + const candidates = page.filter(c => c.inboundCode === receipt.incomingNo && id(c.customerId) === id(booking.customerId) + && id(c.warehouseId) === id(tripProof.warehouseId) && actual.some(p => id(p.warehousePalletId) === id(c.selection?.warehousePalletId))) + io.check('候选仅为本次确认的全部真实外接来源', null, candidates.length === actual.length && candidates.every(c => c.destination.key === booking.cargo.destinationKey + && actual.some(p => id(p.warehousePalletId) === id(c.selection.warehousePalletId) && p.palletNo === c.palletNo && Number(p.packages) === Number(c.availablePackages)))) + const addition = createBody(context.marker, `EXTERNAL-${tripProof.dispatchNo}`, candidates) + if (arrangement) { + const actualGroups = new Set([...current.items.items.map(i => groupIdentity(i.customerId, i.destination?.key)), ...candidates.map(c => groupIdentity(c.customerId, c.destination?.key))]) + io.check('普通安排配置在加货前精确覆盖全部实际客户目的地', null, stableRows([...actualGroups]) === stableRows(arrangement.groups.map(g => groupIdentity(g.customerId, g.destinationKey)))) + } + if (current.data.stage === 'LOADING') { + proof.returnRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('explicit-return'), reason: '本轮零扫码外接实收显式退回装车前,保留同一整车' } + const returned = await io.request('POST', `${path}/return-to-review`, proof.returnRequest, '零扫码明确退回审核') + await commandReplay(`${path}/return-to-review`, proof.returnRequest, returned, '退回审核原键重放') + current = await readDispatch(io, tripProof.dispatchNo) + } + io.check('同一主单已处于装车前可加货', null, current.data.stage === 'BEFORE_LOADING') + proof.addRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('add-real-incoming'), cargo: addition.cargo, batchAllocations: addition.batchAllocations } + const added = await io.request('POST', `${path}/items`, proof.addRequest, '真实已运营确认来源ADD_CARGO加入原主单') + io.check('加货命令保持原整车身份', null, added.dispatchNo === tripProof.dispatchNo && added.action === 'ADD_CARGO') + await commandReplay(`${path}/items`, proof.addRequest, added, '同主单加货原键重放') + current = await readDispatch(io, tripProof.dispatchNo) + io.check('加货和重放保留原客户物理行,仅增加本次完整来源', null, current.data.stage === 'BEFORE_LOADING' + && current.items.items.length === ownItems.length + candidates.length && ownItems.every(i => current.items.items.some(j => JSON.stringify(identity(j)) === JSON.stringify(i))) + && candidates.every(c => current.items.items.some(i => i.sourceKey === c.sourceKey && i.sourceVersion === c.sourceVersion && id(i.customerId) === id(booking.customerId) + && id(i.warehousePalletId) === id(c.selection.warehousePalletId) && i.inboundCode === receipt.incomingNo && Number(i.scheduledPackages) === Number(c.availablePackages) && Number(i.pickedPackages) === 0))) + list = await io.get(`${base}/bookings`, '加货后刷新booking与真实车次版本') + const latest = list.records.find(b => b.bookingNo === receipt.bookingNo) + proof.bindRequest = { tripRecordVersion: version(list.tripRecordVersion), recordVersion: version(latest.recordVersion), dispatchNo: tripProof.dispatchNo, idempotencyKey: io.key('bind-same-whole') } + const bound = await io.request('POST', `${base}/bookings/${encodeURIComponent(receipt.bookingNo)}/dispatch`, proof.bindRequest, '原booking绑定本车唯一整车主单') + io.check('实收转关联只指向原主单', null, bound.booking?.status === 'BOUND' && bound.booking.dispatchNo === tripProof.dispatchNo && bound.booking.bookingNo === receipt.bookingNo + && JSON.stringify(bound.booking.cargo) === JSON.stringify(booking.cargo) && String(bound.booking.agreedAmount) === String(booking.agreedAmount)) + await commandReplay(`${base}/bookings/${encodeURIComponent(receipt.bookingNo)}/dispatch`, proof.bindRequest, bound, '同主单booking关联原键重放') + const after = await io.get(`${base}/capacity-plan`, '真实加货和预留转关联后重新核对容量') + io.check('实际新增货物使旧容量计划失效且没有第二张客户排车', null, after.planValid === false + && after.cargo.every(c => c.dispatchNo === tripProof.dispatchNo) && candidates.every(c => after.cargo.some(i => id(i.warehousePalletId) === id(c.selection.warehousePalletId) && Number(i.packages) === Number(c.availablePackages)))) + const pallets = new Map(before.capacityPlan.pallets.map(p => [id(p.warehousePalletId), clone(p)])) + for (const p of actual) { + const measurement = accepted.pallets.find(m => id(m.warehousePalletId) === id(p.warehousePalletId)), index = current.route.stops.findIndex(s => s.destinationKey === booking.cargo.destinationKey) + io.check('新托仅使用实际运营确认托位和来货实测', null, measurement && Number.isSafeInteger(measurement.palletSpaces) && measurement.palletSpaces > 0 + && Number(measurement.weightKg ?? p.weightKg) > 0 && Number(measurement.volumeCbm ?? p.volumeCbm) > 0 && index > 0) + pallets.set(id(p.warehousePalletId), { warehousePalletId: id(p.warehousePalletId), palletSpaces: measurement.palletSpaces, + weightKg: measurement.weightKg ?? p.weightKg, volumeCbm: measurement.volumeCbm ?? p.volumeCbm, deliveryIndex: index }) + } + io.check('新容量逐托覆盖全部实际货物且没有额外资源', null, new Set(after.cargo.map(c => id(c.warehousePalletId))).size === pallets.size && after.cargo.every(c => pallets.has(id(c.warehousePalletId)))) + proof.capacityRequest = { recordVersion: version(after.recordVersion), manifestHash: after.manifestHash, pallets: [...pallets.values()], reason: '本轮同主单外接实收按实际运营确认和原计划量重新核对' } + const planned = await io.request('POST', `${base}/capacity-plan`, proof.capacityRequest, '重新确认实际货物容量') + io.check('预留转实际后分段占用守恒', null, planned.planValid === true && JSON.stringify(planned.availableSegments) === JSON.stringify(before.availableSegments)) + const trip = await io.get(base, '复审前核对资源和唯一整车关联') + io.check('原车辆司机起运仓和唯一排车保持不变', null, trip.dispatches?.length === 1 && trip.dispatches[0].dispatchNo === tripProof.dispatchNo + && id(trip.trip?.warehouseId) === id(tripProof.warehouseId) && id(trip.trip.vehicleId) === id(tripProof.vehicleId) && id(trip.trip.driverId) === id(tripProof.driverId)) + tripProof.stops = trip.trip.stops + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) { + const detail = await io.get(path, `${action}前刷新原主单版本`) + const body = { recordVersion: version(detail.recordVersion), idempotencyKey: io.key(action), ...(action === 'submit-review' ? { remark: '本轮加入外接实物后全车重新提交' } : { decision: 'APPROVED', reason: '本轮外接实收全车重新审核' }) } + const result = await io.request('POST', `${path}/${action}`, body, `整车${action}`) + await commandReplay(`${path}/${action}`, body, result, `整车${action}原键重放`) + } + const ready = await io.get(path, '核对两级审核恢复真实扫码资格') + io.check('同主单两级审核通过且仍零扫码', null, ready.stage === 'LOADING' && ready.reviewState === 'APPROVED' && ready.reviews?.dispatchReview?.status === 'APPROVED' + && ready.reviews?.loadingReview?.status === 'APPROVED' && Number(ready.counts.pickedPackageCount) === 0) + const execution = arrangement ? await arrangeNative(context, module, io, proof, tripProof, arrangement, pickerToken, actualMeasurements) + : await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof }) + const groups = await io.get(`${path}/delivery-arrangement`, '安排后回查原生外接报价快照') + const quotes = groups.groups.flatMap(g => g.externalQuotes), expected = receipt.finalBooking + const matching = quotes.filter(q => q.bookingNo === receipt.bookingNo && q.inboundCode === receipt.incomingNo && id(q.customerId) === id(booking.customerId)) + io.check('原外接报价属于本次客户和来源且等于运营确认约定金额', null, matching.length === 1 && money(matching[0].amount) === money(expected.agreedAmount) + && matching[0].currency === expected.cargo.currency && matching[0].sourceHash && money(expected.agreedAmount) > 0n) + const deliveries = execution.deliveries.filter(d => groups.groups.some(g => g.groupKey === d.groupKey && g.externalQuotes.some(q => q.bookingNo === receipt.bookingNo))) + io.check('本次外接来源只落入其客户目的地派送', null, deliveries.length === 1 && id(deliveries[0].customerId) === id(booking.customerId)) + proof.outboundNo = deliveries[0].outboundNo; proof.deliveryNo = deliveries[0].deliveryNo; proof.deliveries = execution.deliveries + const delivery = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(id(booking.customerId))}`, '回查本客户外接应收来源') + const sources = await io.get(`/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: id(booking.customerId), businessType: 'DELIVERY_ORDER', businessNo: proof.deliveryNo })}`, '按本次客户派送业务号回查外接应收来源') + const fees = Array.isArray(sources) ? sources.filter(f => f.direction === 'RECEIVABLE' && f.feeItemCode === 'DELIVERY_FEE' && f.sourceStatus === 'ACTIVE' && f.inboundCode === receipt.incomingNo) : [] + const quantity = expected.cargo.priceBasis === 'PALLET' ? expected.cargo.palletSpaces : expected.cargo.priceBasis === 'ORDER' ? 1 : null + io.check('外接只登记本次约定金额应收且来源已审核', null, fees.length === 1 && money(fees[0].originalAmount) === money(expected.agreedAmount) + && fees[0].originalCurrency === expected.cargo.currency && fees[0].reviewStatus === 'APPROVED' && id(fees[0].customerId) === id(booking.customerId) + && quantity != null && Number(fees[0].chargeableQuantity) === Number(quantity) && money(fees[0].unitPrice) === money(expected.cargo.unitPrice) + && fees[0].quantityUnit === (expected.cargo.priceBasis === 'PALLET' ? '托位' : '单') + && fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id))) + proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.transferRequest = execution.confirmationRequest + proof.finalTripVersion = (await io.get(base, '交接同车资源版本')).trip.recordVersion + proof.financeAcknowledgement = 'NOT_VERIFIED'; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof + } catch (error) { proof.status = proof.outcomeUnknown || context.createdResources.dispatchWriteBoundary?.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error } } diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs index 53d296077..1ad89c9f2 100644 --- a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs @@ -1,108 +1,122 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' +import { readFileSync } from 'node:fs' +import { verifyFleetFeeCoverage, coveragePlan } from './fleet-fee-coverage-acceptance.mjs' -function fixture(mode = 'FULL', fault = '') { - const source = { marker: 'CURRENT-TEST', mode, dispatchNo: 'D-CURRENT', customerId: '900719925474099301', - transferBody: { loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [ - { direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' }, - ] } } - const cargo = [1, 2].map(n => ({ dispatchItemId: `item-${n}`, warehousePalletId: `pallet-${n}`, orderId: `order-${n}`, - sourceRegion: 'FIRSTLEG', sourceInboundId: `90071992547409940${n}`, inboundCode: `IN-${n}`, packages: 2 })) - const fees = cargo.map((row, index) => ({ financeFeeId: `90071992547409950${index}`, sourceVersion: 1, sourceHash: `fee-hash-${index}`, - sourceInboundId: row.sourceInboundId, customerId: source.customerId, sourceType: 'INBOUND_FEE', currency: 'CNY', amount: 200, status: 'AVAILABLE' })) - if (fault === 'foreign-customer') fees[0].customerId = 'different-customer' - if (fault === 'missing-source') fees.splice(1) - const hash = 'a'.repeat(64), calls = [], assertions = [] - let savedBody, replayed = false, dirtyOutbound = false - const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data }, requestId: 'req-test', traceId: 'trace-test' }) - const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: [source] }, - assert(module, name, res, passed, evidence, options) { assertions.push({ name, passed, options }) }, - async request(method, path, body) { - calls.push({ method, path, body }) - if (method === 'GET' && path === '/api/platform/v1/last-leg/dispatches/D-CURRENT') - return response({ dispatchNo: source.dispatchNo, pickedPackageCount: 0, shippedPackageCount: 0, - items: cargo.map((row, index) => ({ id: row.dispatchItemId, packageCount: row.packages, - destinationWarehouseCode: `FBA-${index + 1}`, fbaWarehouseCode: `ORIGINAL-FBA-${index + 1}` })) }) - if (method === 'GET' && path.endsWith('/fee-coverage')) return response({ dispatchNo: source.dispatchNo, sourceComplete: true, - cargoHash: hash, cargo, feeSources: fees.map(row => fault === 'mutated-original' && savedBody ? { ...row, amount: 201 } : row) }) - if (method === 'GET' && path.includes('/outbound-orders?')) { - const params = new URL(path, 'https://test.invalid').searchParams - assert.equal(params.get('customerId'), source.customerId) - assert.equal(params.has('dispatchNo'), false, 'outbound list does not support dispatchNo filtering') - const rows = dirtyOutbound ? [{ outboundNo: 'OUT-WRONG', dispatchNo: source.dispatchNo }] : [{ outboundNo: 'OUT-OTHER', dispatchNo: 'DS-OTHER' }] - return response({ records: rows, total: rows.length, page: 1, size: 200 }) +const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', customerId = '900719925474099301' +const clone = value => structuredClone(value) +function fixture(mode = 'FULL', fault = '', needsLoading = false) { + const cases = ['FULL', 'PARTIAL'].map((mode, index) => ({ marker: 'CURRENT-TEST', mode, dispatchNo: `DS-${index + 1}`, customerId, + transferBody: { plannedDeliveryAt: '2026-10-22T01:00:00Z', loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' }] } })) + const source = cases.find(c => c.mode === mode), calls = [], assertions = [], ledger = new Map(), saved = new Map() + const states = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, recordVersion: 0, stage: needsLoading && c === source ? 'LOADING' : 'AFTER_LOADING', items: [1, 2].map(i => ({ + dispatchItemId: `${n + 1}1${i}`, sourceKey: `PLAN:${n + 1}2${i}`, sourceVersion: 'a'.repeat(64), customerId, warehousePalletId: `${n + 1}3${i}`, planPalletId: `${n + 1}2${i}`, + inboundCode: `IN-${n + 1}-${i}`, destination: { key: `FBA:A${i}`, warehouseCode: `A${i}` }, scheduledPackages: 2, pickedPackages: needsLoading && c === source ? 0 : 2 })) })) + if (fault === 'overlap') states[1].items[0].warehousePalletId = states[0].items[0].warehousePalletId + const triples = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, tripNo: `TR-${n + 1}`, warehouseId: '92', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }] })) + let dirty = false, replayed = false + const totals = s => ({ itemCount: s.items.length, palletCount: s.items.length, packageCount: 4, pickedPackageCount: s.items.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 }) + const detail = s => ({ dispatchNo: s.dispatchNo, status: 'ACTIVE', stage: s.stage, recordVersion: s.recordVersion, counts: totals(s), pickingReconciled: s.items.every(i => i.pickedPackages === 2), + dispatchInfo: { route: 'A1,A2' }, orderDetails: s.items.map(i => ({ customerId, sourceKey: i.sourceKey, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) + const cargo = s => s.items.map((i, n) => ({ ...i, orderId: `4${s.dispatchNo.slice(-1)}${n + 1}`, sourceRegion: 'FIRSTLEG', sourceInboundId: `9007199254740994${s.dispatchNo.slice(-1)}${n + 1}`, packages: 2 })) + const groups = s => cargo(s).map((row, n) => ({ groupKey: `G-${s.dispatchNo}-${n}`, customerId, destination: row.destination, groupCargoHash: `${n + 1}`.repeat(64), externalFreightHash: 'b'.repeat(64), externalQuotes: [], feeCoverageCargoHash: `${n + 1}`.repeat(64), cargo: [row], blockReasons: [], requiredServices: ['DELIVERY_FEE'], + feeSources: fault === 'missing-source' && n === 1 ? [] : [{ financeFeeId: `9007199254740995${s.dispatchNo.slice(-1)}${n}`, customerId: fault === 'foreign-customer' && n === 0 ? '999' : customerId, + currency: 'CNY', amount: fault === 'mutated-original' && saved.has(s.dispatchNo) ? 201 : 200, sourceVersion: 1, sourceHash: `fee-hash-${n}`, status: 'AVAILABLE', services: ['DELIVERY_FEE'], sourceKeys: [row.sourceKey] }] })) + const ack = (s, action, result = {}) => ({ dispatchNo: s.dispatchNo, action, recordVersion: s.recordVersion, status: 'ACTIVE', stage: s.stage, counts: totals(s), result, replayed: false }) + const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: cases, fleetTrips: triples }, checkpoint: async () => {}, + assert(_module, name, _response, passed) { assertions.push({ name, passed }) }, addStep(_module, name, _response, passed) { assertions.push({ name, passed }) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, + async request(method, raw, body, token) { + calls.push({ method, path: raw, body: clone(body), token }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), number = normalized.slice(BASE.length + 1).split('/')[0] + const state = states.find(s => s.dispatchNo === number), commandKey = `${normalized}:${body?.idempotencyKey}` + if (method === 'POST' && ledger.has(commandKey)) { assert.deepEqual(body, ledger.get(commandKey).body); replayed = true; return response({ ...clone(ledger.get(commandKey).data), replayed: true }) } + const remember = data => { ledger.set(commandKey, { body: clone(body), data: clone(data) }); return response(data) } + if (method === 'GET' && path === BASE) return response({ records: states.filter(s => s.dispatchNo === params.get('dispatchNo')).map(s => ({ ...detail(s), warehouseId: '92' })) }) + if (path.includes('/outbound-orders?') || raw.includes('/outbound-orders?')) return response({ records: dirty ? [{ outboundNo: 'OUT-DIRTY', dispatchNo: source.dispatchNo }] : [], total: dirty ? 1 : 0, page: 1, size: 200 }) + if (path.match(/\/outbound-orders\/OUT-DS-[12]$/)) { const n = path.slice(path.lastIndexOf('OUT-') + 4), s = states.find(s => s.dispatchNo === n), p = saved.get(n); return response({ outbound: { outboundNo: `OUT-${n}`, dispatchNo: n, status: 'WAIT_OUTBOUND' }, deliveries: p.groups.map((g, i) => ({ deliveryNo: `DEL-${n}-${i}`, status: 'WAIT_DELIVERY' })) }) } + const delivery = path.match(/\/delivery-orders\/DEL-(DS-[12])-(\d)$/) + if (delivery) { + assert.equal(params.get('customerId'), customerId) + const s = states.find(s => s.dispatchNo === delivery[1]), index = Number(delivery[2]), group = groups(s)[index], input = saved.get(s.dispatchNo).groups[index] + const references = input.feeCoverage.map(selection => ({ source: { ...group.feeSources.find(f => f.financeFeeId === selection.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId, + ...(fault === 'snapshot-source-amount' ? { amount: 999 } : {}) }, services: selection.services })) + const evidence = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: fault === 'wrong-cargo' ? cargo(s) : group.cargo, + references: fault === 'missing-reference' ? [] : references, operatorId: 'OP', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-19T02:00:00Z' : '2026-10-19T01:00:00Z' } : null + let fees = input.fees.map(f => ({ id: `8${index}`, feeDirection: f.direction, feeType: f.feeType, currency: f.currency, amount: f.amount, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' })) + if (fault === 'duplicate-new-fee' && index === 0 || fault === 'replay-adds-fee' && replayed) fees.push({ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }) + if (fault === 'charge-covered-destination') fees = index === 0 ? [{ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }] : [] + return response({ order: { dispatchNo: s.dispatchNo, deliveryNo: `DEL-${s.dispatchNo}-${index}`, customerId: fault === 'wrong-delivery-customer' ? '999' : customerId, outboundNo: `OUT-${s.dispatchNo}`, destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage: evidence }) } - if (method === 'GET' && path === '/api/platform/v1/last-leg/outbound-orders/OUT-CURRENT') return response({ - outbound: { outboundNo: 'OUT-CURRENT', dispatchNo: source.dispatchNo, status: 'WAIT_OUTBOUND' }, - deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY' })), - }) - if (method === 'GET' && path.endsWith('/external-freight')) return response({ snapshotHash: 'b'.repeat(64), quotes: [], requiresDeliveryFee: true }) + assert.ok(state, `${method} ${raw}`) + if (path.startsWith(PDA) && method === 'GET' && normalized.endsWith(number)) return response({ ...detail(state), items: state.items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: 2 }] })) }) + if (method === 'POST' && normalized.endsWith('/pick')) { assert.ok(token); if (body.recordVersion !== state.recordVersion) return response(null, '公共版本已变化'); const itemId = normalized.split('/').at(-2); state.items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; state.recordVersion++; return remember(ack(state, 'PICK')) } + if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...detail(state), manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: state.items.map((i, n) => ({ ...i, palletNo: `PAL-${n}`, deliveryIndex: n + 1, palletSpaces: 1, weightKg: 20, volumeCbm: 0.2, + evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map(field => ({ field, value: field === 'palletSpaces' ? 1 : field === 'weightKg' ? 20 : 0.2, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) })) }) + if (method === 'POST' && normalized.endsWith('/loading-complete')) { assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.items.every(i => i.pickedPackages === 2), true); state.stage = 'AFTER_LOADING'; state.recordVersion++; return remember(ack(state, 'LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + if (method === 'GET' && path.endsWith('/items')) return response({ dispatchNo: number, recordVersion: state.recordVersion, status: 'ACTIVE', stage: state.stage, items: state.items }) + if (method === 'GET' && path.endsWith('/route')) return response({ dispatchNo: number, recordVersion: state.recordVersion, stops: triples.find(t => t.dispatchNo === number).stops }) + if (method === 'GET' && path.endsWith('/delivery-arrangement')) return response({ ...detail(state), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), arrange: { allowed: !saved.has(number) }, groups: groups(state), arrangement: saved.has(number) ? { dispatchNo: number } : null }) if (method === 'POST' && path.endsWith('/transfer-delivery')) { - if (body.feeCoverageCargoHash !== hash) { - if (fault === 'failed-command-writes') dirtyOutbound = true - return response(null, '原费用对应货物已变化,请重新核对覆盖范围') - } - if (body.feeCoverage[0].sourceVersion !== 1) return response(null, '原费用版本已变化,请重新确认服务覆盖范围') - if (mode === 'FULL' && body.fees.length) return response(null, '本次货物的该服务已由原费用覆盖,请勿重复登记应收') - if (mode === 'PARTIAL' && !body.fees.length) return response(null, '尚未有约定运费的货物须填写派送费') - replayed = Boolean(savedBody) - if (savedBody) assert.deepEqual(body, savedBody) - savedBody = structuredClone(body) - return response({ dispatchNo: source.dispatchNo, outboundNo: 'OUT-CURRENT', outboundStatus: 'WAIT_OUTBOUND', replayed, - deliveries: [1, 2].map(n => ({ deliveryNo: `DEL-${n}`, deliveryStatus: 'WAIT_DELIVERY' })) }) + assert.ok(body.idempotencyKey); assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.stage, 'AFTER_LOADING') + if (body.cargoHash !== 'a'.repeat(64)) { if (fault === 'failed-command-writes') dirty = true; return response(null, '装车或实际货物已经变化') } + if (body.groups.some(g => g.feeCoverage.some(f => f.sourceVersion !== 1))) return response(null, '原费用版本已变化') + if (source.mode === 'FULL' && body.groups.some(g => g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收') + if (source.mode === 'PARTIAL' && !body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对') + saved.set(number, clone(body)); state.recordVersion++ + const result = ack(state, 'ARRANGE_DELIVERY', { dispatchNo: number, cargoHash: body.cargoHash, outbounds: [{ customerId, outboundNo: `OUT-${number}`, status: 'WAIT_OUTBOUND', packageCount: 4 }], + deliveries: groups(state).map((g, n) => ({ groupKey: g.groupKey, customerId, destination: g.destination, outboundNo: `OUT-${number}`, deliveryNo: `DEL-${number}-${n}`, status: 'WAIT_DELIVERY', packageCount: 2 })) }) + if (fault === 'unknown-write') return { http: 0, body: null, raw: 'connection reset' } + return remember(result) } - const detail = path.match(/^\/api\/platform\/v1\/last-leg\/delivery-orders\/DEL-([12])\?customerId=900719925474099301$/) - assert.ok(detail, `Unexpected ${method} ${path}`) - const index = Number(detail[1]) - 1 - const selected = savedBody.feeCoverage.filter(selection => selection.financeFeeId === fees[index].financeFeeId) - const references = selected.map(selection => ({ source: fees[index], services: selection.services })) - const shouldCharge = mode === 'PARTIAL' && (fault === 'charge-covered-destination' ? index === 0 : index === 1) - const newFee = { feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, sourceStatus: 'ACTIVE' } - return response({ order: { dispatchNo: source.dispatchNo, deliveryNo: `DEL-${index + 1}`, destinationWarehouseCode: `FBA-${index + 1}` }, - fees: shouldCharge || (fault === 'duplicate-new-fee' && index === 0) || (fault === 'replay-adds-fee' && replayed) ? [newFee] : [], - feeCoverage: { cargoHash: hash, operatorId: 'op-test', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-03T02:00:00Z' : '2026-10-03T01:00:00Z', - cargo: fault === 'wrong-cargo' ? [cargo[0]] : [cargo[index]], references: fault === 'missing-reference' ? [] : references } }) - }, - } - return { source, context, calls, assertions, clearFault() { fault = '' } } + assert.equal(method, 'GET', raw); assert.equal(path, `${BASE}/${number}`); return response(detail(state)) + } } + return { context, source, cases, calls, assertions, states, groups, clearFault() { fault = '' } } } -test('persisted reconciliation reads exact original documents and only replays the original successful command', async () => { - const f = fixture('FULL', 'wrong-cargo') - await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source)) - assert.equal(f.source.proof.outboundNo, 'OUT-CURRENT') - const originalKey = f.source.proof.confirmationRequest.idempotencyKey - const before = f.calls.length - f.clearFault() - const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true }) - assert.equal(proof.status, 'VERIFIED') - const commands = f.calls.slice(before).filter(row => row.method === 'POST') - assert.ok(commands.length > 0) - assert.ok(commands.every(row => row.body.idempotencyKey === originalKey)) - assert.ok(f.calls.slice(before).some(row => row.method === 'GET' && row.path.endsWith('/outbound-orders/OUT-CURRENT'))) +for (const mode of ['FULL', 'PARTIAL']) test(`${mode} uses native grouped fees, immutable confirmation evidence and original-key replay`, async () => { + const f = fixture(mode), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5); assert.equal(proof.deliveryNos.length, 2) + assert.ok(f.assertions.every(a => a.passed)); assert.ok(f.calls.every(c => !/cancel|unbind|pre-arrange|fee-coverage/.test(c.path))) + assert.equal(proof.confirmationRequest.recordVersion, 0); assert.equal(proof.confirmationRequest.groups.length, 2) + await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/) }) -for (const mode of ['FULL', 'PARTIAL']) { - test(`${mode} fee coverage helper checks source scope, pre-write conflicts, each destination and replay`, async () => { - const f = fixture(mode) - const proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source) - assert.equal(proof.status, 'VERIFIED') - assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5) - assert.deepEqual(proof.deliveryNos, ['DEL-1', 'DEL-2']) - assert.ok(f.assertions.every(row => row.passed && row.options.deploymentBlocker)) - assert.ok(f.calls.every(call => !/cancel|receipt|departure/.test(call.path))) - await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/) - }) -} +test('LOADING performs actual PDA picks, immutable pick replay and explicit loading completion before arranging', async () => { + const f = fixture('FULL', '', true), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { pickerToken: 'OFFLINE-ONLY' }) + assert.equal(proof.status, 'VERIFIED') + const loading = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/loading-complete')), arrangement = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')) + assert.ok(loading > f.calls.findIndex(c => c.path.endsWith('/pick'))); assert.ok(arrangement > loading) +}) -for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference', - 'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee']) { - test(`fee coverage HTTP proof cannot pass ${fault}`, async () => { - const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault) - await assert.rejects(() => verifyFleetFeeCoverage(f.context, 'coverage', f.source)) - assert.equal(f.source.proof.status, 'CHECKING') - assert.ok(f.assertions.some(row => !row.passed)) - if (['foreign-customer', 'missing-source'].includes(fault)) assert.equal(f.calls.some(row => row.method === 'POST'), false) - }) -} +test('reviewed persisted reconciliation only replays original successful native command after actual document readback', async () => { + const f = fixture('FULL', 'wrong-cargo'); await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.ok(f.source.proof.outboundNo) + const original = clone(f.source.proof.confirmationRequest), before = f.calls.length; f.clearFault() + assert.equal((await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true })).status, 'VERIFIED') + assert.ok(f.calls.slice(before).filter(c => c.method === 'POST').every(c => JSON.stringify(c.body) === JSON.stringify(original))) +}) + +for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference', 'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee', 'wrong-delivery-customer', 'overlap', 'unknown-write', 'snapshot-source-amount']) test(`native fee proof rejects ${fault}`, async () => { + const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault) + await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.notEqual(f.source.proof.status, 'VERIFIED') + if (['foreign-customer', 'missing-source', 'overlap'].includes(fault)) assert.equal(f.calls.some(c => c.method === 'POST'), false) + if (fault === 'unknown-write') { assert.equal(f.source.proof.status, 'OUTCOME_UNKNOWN'); const last = f.calls.at(-1); assert.equal(last.method, 'POST'); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true) } +}) + +test('two current registered source groups are required before HTTP; missing PDA and ambiguous mixed provenance never guess', async () => { + const missing = fixture(); missing.context.createdResources.fleetFeeCoverageCases.pop(); await assert.rejects(verifyFleetFeeCoverage(missing.context, 'coverage', missing.source), /两组不重叠/); assert.equal(missing.calls.length, 0) + const noPicker = fixture('FULL', '', true); await assert.rejects(verifyFleetFeeCoverage(noPicker.context, 'coverage', noPicker.source), /PDA身份/); assert.equal(noPicker.calls.some(c => c.method === 'POST'), false) + const f = fixture('PARTIAL'), groups = f.groups(f.states[1]); groups[0].cargo.push({ ...groups[0].cargo[0], sourceInboundId: '999' }); assert.throws(() => coveragePlan({ groups }, f.source), /不能唯一映射/) + const multi = fixture('PARTIAL'), many = multi.groups(multi.states[1]), third = clone(many[1]); third.groupKey = 'GROUP-THIRD'; third.cargo[0].sourceKey = 'PLAN:999'; third.cargo[0].sourceInboundId = '999'; third.feeSources[0].financeFeeId = '9999'; third.feeSources[0].sourceKeys = ['PLAN:999']; many.push(third) + assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/) + multi.source.transferBody.groupFees = { [many[1].groupKey]: [{ ...multi.source.transferBody.fees[0], amount: 6 }] } + assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/) + multi.source.transferBody.groupFees[third.groupKey] = [{ ...multi.source.transferBody.fees[0], amount: 7 }] + assert.deepEqual(coveragePlan({ groups: many }, multi.source).groups.map(g => g.fees.map(f => f.amount)), [[], [6], [7]]) + const unsafe = fixture(); unsafe.source.customerId = 9007199254740992; assert.throws(() => coveragePlan({ groups: unsafe.groups(unsafe.states[0]) }, unsafe.source), /ID精度无效/) +}) + +test('public native delivery DTO retains grouped fees and requires version and loading hashes', () => { + const dto = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java', import.meta.url), 'utf8') + assert.ok(dto.includes('record ArrangeDelivery(Long recordVersion')); assert.ok(dto.includes('String loadingCompletionHash')); assert.ok(dto.includes('List groups')) +}) diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs index eec10d48c..b678c5302 100644 --- a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs @@ -1,167 +1,214 @@ -import { randomUUID } from 'node:crypto' +import { DISPATCH_BASE as BASE, id as stringId, version, commandKey, dispatchIo, replay, readDispatch } from './grouped-dispatch-acceptance.mjs' +import { pickAll } from './phase5-overseas-fulfillment.mjs' +import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs' -const OPTIONS = { scenario: 'fleet-original-fee-coverage', deploymentBlocker: true } -const id = value => value == null ? '' : String(value) -const ok = response => response?.http === 200 && id(response?.body?.code) === '200' -const businessConflict = response => response?.http === 409 || (response?.http === 200 && id(response?.body?.code) === 'A0409') -const canonical = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort()) -const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), - orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) }) -const feeIdentity = row => ({ financeFeeId: id(row.financeFeeId), sourceVersion: Number(row.sourceVersion), sourceHash: row.sourceHash, - sourceInboundId: id(row.sourceInboundId), customerId: id(row.customerId), currency: row.currency, amount: Number(row.amount), status: row.status }) +export const stableRows = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort()) +const clone = value => structuredClone(value) +const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) } +const positive = value => /^[1-9]\d*$/.test(id(value)) +const sha = value => /^[a-f0-9]{64}$/i.test(value || '') +const cents = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('费用必须为明确的非负两位金额'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) } +const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) }) +const feeIdentity = fee => ({ financeFeeId: id(fee.financeFeeId), customerId: id(fee.customerId), currency: fee.currency, amount: String(fee.amount), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, status: fee.status, sourceKeys: [...fee.sourceKeys].sort() }) -/** Dedicated current-run dispatches only: one FULL case and a separate PARTIAL case with >=2 actual inbound sources. */ -export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false } = {}) { - if (!context.createdResources?.fleetFeeCoverageCases?.includes(source) || source.marker !== context.marker || (source.proof && !reconcilePersisted) - || !['FULL', 'PARTIAL'].includes(source.mode) || !source.dispatchNo || !source.customerId) - throw new Error('原费用验收需要本轮专用、未执行的排车用例;失败后保留原请求,不重复创建') - if (reconcilePersisted && (source.mode !== 'FULL' || source.proof?.status !== 'CHECKING' - || !source.proof.outboundNo || !source.proof.deliveryNos?.length || !source.proof.confirmationRequest?.idempotencyKey)) - throw new Error('已落单核对须有原成功请求、出库单及派送集合,不允许重造') - const proof = reconcilePersisted ? source.proof : source.proof = { status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] } - const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(source.dispatchNo)}` - const outboundPath = `/api/platform/v1/last-leg/outbound-orders?size=200&customerId=${encodeURIComponent(source.customerId)}` - async function check(name, passed, response) { - context.assert(module, name, response, Boolean(passed), '', OPTIONS) - if (!passed) throw new Error(`${module}: ${name}`) - } - async function request(method, path, body, label, expectedMessage) { - const response = await context.request(method, path, body) - proof.requests.push({ label, method, path, http: response?.http, code: response?.body?.code, - requestId: response?.requestId, traceId: response?.traceId }) - await check(label, Boolean(response?.requestId) && Boolean(response?.traceId) - && (expectedMessage ? businessConflict(response) && expectedMessage.test(id(response?.body?.message)) : ok(response)), response) +export function fleetConsumerIo(context, module, proof, namespace) { + const io = dispatchIo(context, module, proof, namespace) + const request = async (method, path, body, label, expected) => { + const response = await io.send(method, path, body, label) + io.check(`${label}诊断回执完整`, response, Boolean(response?.requestId) && Boolean(response?.traceId)) + io.check(label, response, expected ? [200, 409].includes(response.http) && String(response.body?.code) === 'A0409' && expected.test(String(response.body?.message)) + : response.http === 200 && String(response.body?.code) === '200') return response.body?.data } - async function noOutbound(label) { - const rows = [] - let total - for (let index = 1; ; index++) { - const page = await request('GET', `${outboundPath}&page=${index}`, undefined, label) - await check('客户出库列表分页元数据完整', Array.isArray(page?.records) && Number.isSafeInteger(Number(page.total)) - && Number(page.total) >= 0 && Number(page.page) === index && Number(page.size) === 200) - total ??= Number(page.total) - await check('客户出库列表跨页范围稳定', total === Number(page.total) && (page.records.length > 0 || rows.length === total)) - rows.push(...page.records) - if (rows.length >= total) break + return { ...io, request, get: (path, label) => request('GET', path, undefined, label) } +} + +export async function readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements } = {}) { + io.check('真实履约写入须有正式持久化检查点', null, typeof context.checkpoint === 'function') + let current = await readDispatch(io, trip.dispatchNo) + io.check('本轮实车仓库及未发车范围', null, context.createdResources.fleetTrips?.includes(trip) && trip.tripNo + && current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && Number(current.data.counts.shippedPackageCount) === 0) + if (current.data.stage === 'LOADING') { + io.check('真实扫码需本轮PDA身份', null, typeof pickerToken === 'string' && pickerToken.length > 0) + io.check('PDA逐来源真实扫码完成', null, await pickAll(context, pickerToken, trip.dispatchNo)) + await confirmFleetLoading(context, module, trip, pickerToken, actualMeasurements) + current = await readDispatch(io, trip.dispatchNo) + } + io.check('安排前已真实扫码并显式装车完成', null, current.data.stage === 'AFTER_LOADING' && current.data.pickingReconciled === true + && Number(current.data.counts.packageCount) > 0 && Number(current.data.counts.pickedPackageCount) === Number(current.data.counts.packageCount) + && current.items.items.every(i => Number(i.pickedPackages) === Number(i.scheduledPackages))) + return current +} + +// FeeCandidate未暴露sourceInboundId,仅在sourceKeys可唯一映射到实际原入仓时选择,混托歧义直接阻断。 +export function coveragePlan(preview, source, externalReceipt) { + if (!preview.groups?.length) throw Error('缺少当前全车派送分组') + const firstleg = preview.groups.flatMap(g => g.cargo.filter(c => c.sourceRegion === 'FIRSTLEG')) + const inboundIds = [...new Set(firstleg.map(c => id(c.sourceInboundId)))].sort() + if (!inboundIds.length || inboundIds.some(v => !positive(v)) || source.mode === 'PARTIAL' && inboundIds.length < 2) throw Error('FULL/PARTIAL需要本轮真实原入仓,PARTIAL至少两票') + const selectedInbounds = new Set(source.mode === 'FULL' ? inboundIds : [inboundIds[0]]) + const originalFees = [], selections = new Map(), uncovered = [] + for (const group of preview.groups) { + if (!group.groupKey || !positive(group.customerId) || !group.destination?.key || !sha(group.groupCargoHash) || !sha(group.feeCoverageCargoHash) + || !sha(group.externalFreightHash) || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.feeSources) || !Array.isArray(group.externalQuotes) + || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('客户目的地或费用预览范围不完整') + const own = group.cargo.filter(c => c.sourceRegion === 'FIRSTLEG') + if (own.some(c => id(c.customerId) !== id(source.customerId))) throw Error('原费用跨客户,不能沿其他客户确认覆盖') + if (group.externalQuotes.some(q => !externalReceipt || q.bookingNo !== externalReceipt.bookingNo || q.inboundCode !== externalReceipt.incomingNo + || id(q.customerId) !== id(group.customerId) || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash + || q.currency !== externalReceipt.finalBooking.cargo.currency || cents(q.amount) !== cents(externalReceipt.finalBooking.agreedAmount))) throw Error('当前外接报价与本轮运营确认金额或来源摘要不一致') + for (const cargo of group.cargo.filter(c => c.sourceRegion !== 'FIRSTLEG')) { + if (!externalReceipt || cargo.inboundCode !== externalReceipt.incomingNo || !externalReceipt.createRequest + || id(cargo.customerId) !== id(externalReceipt.createRequest.customerId) + || !group.externalQuotes.some(q => q.bookingNo === externalReceipt.bookingNo && q.inboundCode === cargo.inboundCode && id(q.customerId) === id(group.customerId))) throw Error('非头程货物没有本轮已确认外接报价') + } + const byInbound = new Map() + for (const fee of group.feeSources) { + if (id(fee.customerId) !== id(group.customerId) || !positive(fee.financeFeeId) || fee.currency !== 'CNY' || !['AVAILABLE', 'SETTLED'].includes(fee.status) + || !Number.isSafeInteger(fee.sourceVersion) || fee.sourceVersion < 1 || !fee.sourceHash || !Array.isArray(fee.sourceKeys) || !fee.sourceKeys.length + || !fee.services?.includes('DELIVERY_FEE')) throw Error('原费用客户、版本或来源不可用') + cents(fee.amount) + const actual = own.filter(c => fee.sourceKeys.includes(c.sourceKey)), ids = new Set(actual.map(c => id(c.sourceInboundId))) + if (ids.size !== 1 || fee.sourceKeys.some(k => !actual.some(c => c.sourceKey === k))) throw Error('原费用sourceKeys不能唯一映射原入仓;需生产预览提供原费用sourceInboundId或无混托真实来源') + const inbound = [...ids][0] + if (!byInbound.has(inbound)) byInbound.set(inbound, fee) + originalFees.push({ groupKey: group.groupKey, ...feeIdentity(fee) }) } - await check('失败命令没有生成本排车出库事实', rows.length === total - && new Set(rows.map(row => row.outboundNo)).size === rows.length && rows.every(row => row.dispatchNo !== source.dispatchNo)) + if (own.some(c => !byInbound.has(id(c.sourceInboundId)))) throw Error('本轮每票必须有实际原费用,不能借其他票补齐') + selections.set(group.groupKey, [...byInbound].filter(([inbound]) => selectedInbounds.has(inbound)).map(([, fee]) => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] }))) + if (own.some(c => !selectedInbounds.has(id(c.sourceInboundId)))) uncovered.push(group.groupKey) } - const dispatch = await request('GET', dispatchPath, undefined, '核对本轮费用验收排车来源') - await check('只处理本轮尚未拣货及发车的排车', dispatch?.dispatchNo === source.dispatchNo - && Number(dispatch.pickedPackageCount) === 0 - && Number(dispatch.shippedPackageCount) === 0 && dispatch.items?.length > 0) - if (!reconcilePersisted) await noOutbound('确认专用排车尚无出库单') - const preview = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '读取本次实际货物及原 Finance 费用来源') - await check('原费用范围完整且可确认', preview?.sourceComplete === true && !preview.blockReason - && preview.dispatchNo === source.dispatchNo && /^[0-9a-f]{64}$/.test(preview.cargoHash || '') - && preview.cargo?.length > 0 && preview.cargo.every(row => row.sourceRegion === 'FIRSTLEG' && id(row.sourceInboundId))) - const itemTotals = new Map() - for (const row of preview.cargo) itemTotals.set(id(row.dispatchItemId), (itemTotals.get(id(row.dispatchItemId)) || 0) + Number(row.packages)) - await check('费用预览逐条排车箱数核平', itemTotals.size === dispatch.items.length - && dispatch.items.every(row => itemTotals.get(id(row.id)) === Number(row.packageCount))) - const inboundIds = [...new Set(preview.cargo.map(row => id(row.sourceInboundId)))].sort() - await check('原费用均属于当前客户和实际原入仓', Array.isArray(preview.feeSources) && preview.feeSources.every(fee => - id(fee.customerId) === id(source.customerId) && inboundIds.includes(id(fee.sourceInboundId)) - && ['AVAILABLE', 'SETTLED'].includes(fee.status) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(fee.sourceType) - && fee.currency === 'CNY' && Number(fee.sourceVersion) >= 1 && fee.sourceHash && id(fee.financeFeeId))) - const sources = inboundIds.map(inboundId => preview.feeSources.find(fee => id(fee.sourceInboundId) === inboundId)) - await check('本轮每票已产生可引用原费用,不借其他票费用补齐', sources.every(Boolean) - && (source.mode === 'FULL' || inboundIds.length >= 2)) - const selectedFees = source.mode === 'FULL' ? sources : [sources[0]] - const selections = selectedFees.map(fee => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: Number(fee.sourceVersion), - sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] })) - const freight = await request('GET', `${dispatchPath}/external-freight`, undefined, '核对本专用排车没有外接报价混入覆盖用例') - await check('本用例为原费用货物且使用托盘派送自有预约', Array.isArray(freight?.quotes) && freight.quotes.length === 0 - && freight.requiresDeliveryFee === true && source.transferBody?.loadingType === 'PALLET' - && source.transferBody.appointmentAccount === 'SELF' && source.transferBody.fees?.length === 1 - && source.transferBody.fees[0].direction === 'RECEIVABLE' && source.transferBody.fees[0].feeType === 'DELIVERY_FEE' - && Number(source.transferBody.fees[0].amount) > 0) - const body = reconcilePersisted ? proof.confirmationRequest : { ...source.transferBody, idempotencyKey: `fleet-fee-${randomUUID()}`, - externalFreightHash: freight.snapshotHash, feeCoverageCargoHash: preview.cargoHash, feeCoverage: selections, - fees: source.mode === 'FULL' ? [] : source.transferBody.fees } - proof.confirmationRequest = body - if (!reconcilePersisted) proof.originalFees = preview.feeSources.map(feeIdentity) - const transferPath = `${dispatchPath}/transfer-delivery` - let transferred - if (reconcilePersisted) { - await check('原成功请求的货物与引用仍对应本次核对范围', body.feeCoverageCargoHash === preview.cargoHash - && canonical(body.feeCoverage) === canonical(selections)) - const stored = await request('GET', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, undefined, '先核对已持久化的原出库与派送集合') - await check('原出库和派送存在且尚未执行', stored?.outbound?.dispatchNo === source.dispatchNo - && stored.outbound.outboundNo === proof.outboundNo && stored.outbound.status === 'WAIT_OUTBOUND' - && canonical((stored.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos) - && stored.deliveries.every(row => row.status === 'WAIT_DELIVERY')) - transferred = await request('POST', transferPath, body, '确认原持久化单据后仅重放同键同载荷成功命令') - await check('核对重放未新建任何业务单据', transferred?.replayed === true && transferred.outboundNo === proof.outboundNo - && canonical((transferred.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)) - } else { - const staleHash = `${preview.cargoHash[0] === '0' ? '1' : '0'}${preview.cargoHash.slice(1)}` - await request('POST', transferPath, { ...body, idempotencyKey: `stale-cargo-${randomUUID()}`, feeCoverageCargoHash: staleHash }, - '货物摘要陈旧时拒绝转派送', /货物已变化/) - await noOutbound('陈旧货物拒绝后回查无业务写入') - await request('POST', transferPath, { ...body, idempotencyKey: `stale-fee-${randomUUID()}`, - feeCoverage: selections.map((row, index) => index === 0 ? { ...row, sourceVersion: row.sourceVersion + 1 } : row) }, - '原费用版本陈旧时拒绝确认', /原费用版本已变化/) - await noOutbound('陈旧原费用拒绝后回查无业务写入') - await request('POST', transferPath, { ...body, idempotencyKey: `fee-scope-${randomUUID()}`, - fees: source.mode === 'FULL' ? source.transferBody.fees : [] }, - source.mode === 'FULL' ? '全覆盖禁止再登记派送应收' : '部分覆盖仍须登记未覆盖派送费', - source.mode === 'FULL' ? /已由原费用覆盖/ : /须填写派送费/) - await noOutbound('覆盖金额校验拒绝后回查无业务写入') - transferred = await request('POST', transferPath, body, '正式保存本次原费用服务覆盖并转派送') + const template = source.transferBody + if (template?.loadingType !== 'PALLET' || template.appointmentAccount !== 'SELF' || template.fees?.length !== 1 + || template.fees[0].direction !== 'RECEIVABLE' || template.fees[0].feeType !== 'DELIVERY_FEE' || cents(template.fees[0].amount) <= 0n) throw Error('覆盖用例须有明确的未覆盖派送应收金额') + if (uncovered.length > 1 && uncovered.some(key => !Array.isArray(template.groupFees?.[key]) || template.groupFees[key].length !== 1)) throw Error('多个未覆盖目的地须由本轮用例明确逐组费用,不能自动分摊') + const groups = preview.groups.map(group => { + const fees = uncovered.includes(group.groupKey) ? clone(template.groupFees?.[group.groupKey] || template.fees) : [] + if (uncovered.includes(group.groupKey) && fees.length !== 1 || fees.some(f => f.direction !== 'RECEIVABLE' || f.feeType !== 'DELIVERY_FEE' || cents(f.amount) <= 0n || !['CNY', 'USD'].includes(f.currency) || f.currency !== template.fees[0].currency)) throw Error('逐组未覆盖应收不完整') + return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: template.plannedDeliveryAt || new Date(Date.now() + 21600000).toISOString(), + deliveryMethod: template.deliveryMethod || 'TRUCK_PALLET', waybillNo: template.waybillNo || null, carrierName: template.carrierName || null, + pickupMethod: template.pickupMethod || 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: template.vehicleType || 'TRUCK', vehicleSize: template.vehicleSize || null, + appointmentAccount: 'SELF', sellerName: null, remark: template.remark || null, externalFreightHash: group.externalFreightHash, + feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: selections.get(group.groupKey), fees } + }) + return { groups, originalFees, selectedInbounds: [...selectedInbounds], inboundIds } +} + +async function noDocuments(io, source, label) { + let total, rows = [] + for (let page = 1; ; page++) { + const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId: id(source.customerId), size: '200', page: String(page) })}`, label) + io.check('客户出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.page) === page && Number(data.size) === 200 + && Number(data.total) >= 0 && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total))) + total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break } - await check('转派送生成本次真实出库和各目的地派送', transferred?.dispatchNo === source.dispatchNo && transferred.outboundNo - && transferred.outboundStatus === 'WAIT_OUTBOUND' && transferred.deliveries?.length > 0 - && transferred.deliveries.every(row => row.deliveryNo && row.deliveryStatus === 'WAIT_DELIVERY')) - proof.outboundNo = transferred.outboundNo - proof.deliveryNos = transferred.deliveries.map(row => row.deliveryNo).sort() - const evidenceByDelivery = {} - const feesByDelivery = {} - let newReceivable = 0 - const allCargo = [] - for (const number of proof.deliveryNos) { - const detailPath = `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}` - const detail = await request('GET', detailPath, undefined, '回查派送详情中的原费用确认快照') - const evidence = detail?.feeCoverage - const destination = id(detail?.order?.destinationWarehouseCode || detail?.order?.destinationWarehouseId) - const destinationItems = new Set(dispatch.items.filter(row => id(row.destinationWarehouseCode || row.fbaWarehouseCode) === destination).map(row => id(row.id))) - await check('费用确认货物只属于当前派送目的地', destination && destinationItems.size > 0 - && evidence?.cargo?.every(row => destinationItems.has(id(row.dispatchItemId)))) - await check('确认快照属于本次派送及实际原费用版本', detail?.order?.dispatchNo === source.dispatchNo - && detail.order.deliveryNo === number && evidence?.cargoHash === preview.cargoHash - && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) && evidence.cargo?.length > 0 - && Array.isArray(evidence.references) && evidence.references.every(ref => evidence.cargo.some(row => id(row.sourceInboundId) === id(ref.source?.sourceInboundId)) && selectedFees.some(fee => - id(fee.financeFeeId) === id(ref.source?.financeFeeId) && Number(fee.sourceVersion) === Number(ref.source?.sourceVersion) - && fee.sourceHash === ref.source?.sourceHash && canonical(ref.services || []) === canonical(['DELIVERY_FEE'])))) - const selectedInbounds = new Set(selectedFees.map(fee => id(fee.sourceInboundId))) - await check('快照保留本派送已选择的每票服务引用', evidence.cargo.every(row => !selectedInbounds.has(id(row.sourceInboundId)) - || evidence.references.some(ref => id(ref.source?.sourceInboundId) === id(row.sourceInboundId) && ref.services.includes('DELIVERY_FEE')))) - await check('费用明细为明确应收列表', Array.isArray(detail.fees) && detail.fees.every(row => row.feeDirection && row.feeType)) - const added = detail.fees.filter(row => row.feeDirection === 'RECEIVABLE' && row.feeType === 'DELIVERY_FEE' && row.sourceStatus !== 'VOIDED') - await check('已全覆盖目的地不承担其他目的地的新增派送费', !evidence.cargo.every(row => selectedInbounds.has(id(row.sourceInboundId))) || added.length === 0) - await check('新增派送费没有伪零值或混合币种', added.every(row => Number(row.amount) > 0 && row.currency === source.transferBody.fees[0].currency)) - newReceivable += added.reduce((sum, row) => sum + Number(row.amount), 0) - allCargo.push(...evidence.cargo.map(cargoIdentity)) - evidenceByDelivery[number] = evidence - feesByDelivery[number] = canonical(detail.fees) - } - await check('多目的地快照汇总与实际原货物一致且不重复', canonical(allCargo) === canonical(preview.cargo.map(cargoIdentity))) - await check('仅未覆盖部分产生本次新增应收', Math.round(newReceivable * 100) - === (source.mode === 'FULL' ? 0 : Math.round(Number(source.transferBody.fees[0].amount) * 100))) - const replay = await request('POST', transferPath, body, '原费用确认成功后同键重放') - await check('重放保持同一出库和派送集合', replay?.replayed === true && replay.outboundNo === proof.outboundNo - && canonical((replay.deliveries || []).map(row => row.deliveryNo)) === canonical(proof.deliveryNos)) - for (const number of proof.deliveryNos) { - const detail = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(source.customerId)}`, undefined, '重放后回查覆盖证据') - await check('重放未改写确认人时间和原费用快照', JSON.stringify(detail.feeCoverage) === JSON.stringify(evidenceByDelivery[number])) - await check('成功命令重放未追加费用', Array.isArray(detail.fees) && canonical(detail.fees) === feesByDelivery[number]) - } - const after = await request('GET', `${dispatchPath}/fee-coverage`, undefined, '回查引用原费用未改变其来源事实') - await check('原费用版本金额与状态保持不变', canonical(after.feeSources.map(feeIdentity)) === canonical(proof.originalFees)) - proof.snapshots = evidenceByDelivery - proof.newReceivable = newReceivable - proof.status = 'VERIFIED' - return proof + io.check('拒绝后无本排车出库写入', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== source.dispatchNo)) +} + +/** 两个本轮不重叠来源用例必须先准备;从不取消已扫码FULL再复用同批实物。 */ +export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false, pickerToken, actualMeasurements, externalTrip } = {}) { + const cases = context.createdResources?.fleetFeeCoverageCases + if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted) throw Error('原费用验收需要本轮专用、未执行的排车用例;保留原请求') + const pair = ['FULL', 'PARTIAL'].map(mode => cases.filter(c => c.marker === context.marker && c.mode === mode)) + if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮生产者预先准备FULL/PARTIAL两组不重叠真实排车,不能取消已扫码FULL复用同批实物') + if (reconcilePersisted && (!source.proof?.confirmationRequest || !source.proof?.outboundNo || !source.proof?.deliveryNos?.length)) throw Error('续验须有原成功命令和已持久化单据,不可重造') + const proof = reconcilePersisted ? source.proof : source.proof = { marker: context.marker, status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] } + const io = fleetConsumerIo(context, module, proof, `FEE-${source.mode}`), path = `${BASE}/${encodeURIComponent(source.dispatchNo)}` + try { + const scopes = [] + for (const [candidate] of pair) scopes.push(await readDispatch(io, candidate.dispatchNo)) + io.check('两组真实来源和实托均不重叠', null, scopes.every(s => s.items.items.length > 0) && scopes[0].items.items.every(i => !scopes[1].items.items.some(j => id(i.warehousePalletId) === id(j.warehousePalletId) || i.sourceKey === j.sourceKey))) + const trip = context.createdResources.fleetTrips?.find(t => t.dispatchNo === source.dispatchNo) + io.check('本排车有本轮正式车辆审核生产回执', null, Boolean(trip)) + const loaded = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements }) + const preview = await io.get(`${path}/delivery-arrangement`, '读取原生全车费用预览') + io.check('原生派送门禁、版本及实载证据完整', null, preview?.dispatchNo === source.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' + && id(version(preview.recordVersion)) === id(version(loaded.data.recordVersion)) && sha(preview.cargoHash) && sha(preview.loadingCompletionHash) + && (reconcilePersisted ? Boolean(preview.arrangement) : preview.arrange?.allowed === true && !preview.arrangement)) + const receipt = externalTrip?.externalReceipt + if (externalTrip && (!context.createdResources.fleetTrips.includes(externalTrip) || externalTrip.dispatchNo !== source.dispatchNo || receipt?.status !== 'VERIFIED')) throw Error('外接费用只接受本轮同主单真实运营确认') + const plan = coveragePlan(preview, source, receipt) + const totals = new Map() + for (const c of preview.groups.flatMap(g => g.cargo)) totals.set(id(c.dispatchItemId), (totals.get(id(c.dispatchItemId)) || 0) + Number(c.packages)) + io.check('费用分组逐实物客户与箱数核平', null, totals.size === loaded.items.items.length && loaded.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages) + && preview.groups.filter(g => g.cargo.some(c => id(c.dispatchItemId) === id(i.dispatchItemId))).every(g => id(g.customerId) === id(i.customerId) && g.destination.key === i.destination.key))) + const body = reconcilePersisted ? proof.confirmationRequest : { recordVersion: version(preview.recordVersion), idempotencyKey: commandKey(context.marker, `FEE-${source.mode}-${source.dispatchNo}`, 'arrange'), + cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups: plan.groups } + if (!reconcilePersisted) { proof.confirmationRequest = clone(body); proof.originalFees = clone(plan.originalFees); await context.checkpoint?.() } + else io.check('原成功请求仍为同货物和实载', null, body.cargoHash === preview.cargoHash && body.loadingCompletionHash === preview.loadingCompletionHash) + const reject = async (label, changed, pattern) => { + const before = await readDispatch(io, source.dispatchNo) + await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: commandKey(context.marker, `${source.dispatchNo}-${source.mode}`, label) }, label, pattern) + for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, `${label}后无单据`) + io.check(`${label}后整车及实物零漂移`, null, JSON.stringify(await readDispatch(io, source.dispatchNo)) === JSON.stringify(before)) + } + if (!reconcilePersisted) { + for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, '执行前确认无出库单') + await reject('陈旧全车货物摘要拒绝', { cargoHash: `${body.cargoHash[0] === '0' ? '1' : '0'}${body.cargoHash.slice(1)}` }, /货物.*变化/) + const stale = clone(body.groups), selected = stale.find(g => g.feeCoverage.length).feeCoverage[0]; selected.sourceVersion = selected.sourceVersion === 2147483647 ? selected.sourceVersion - 1 : selected.sourceVersion + 1 + await reject('陈旧原费用版本拒绝', { groups: stale }, /原费用版本.*变化/) + const wrong = clone(body.groups) + if (source.mode === 'FULL') wrong[0].fees = clone(source.transferBody.fees) + else wrong.find(g => g.fees.length).fees = [] + await reject(source.mode === 'FULL' ? '全覆盖重复应收拒绝' : '部分覆盖漏填应收拒绝', { groups: wrong }, source.mode === 'FULL' ? /覆盖.*重复|覆盖.*登记/ : /未覆盖.*费用/) + if (externalTrip) { + const extra = clone(body.groups), quoted = preview.groups.find(g => g.externalQuotes.length), input = extra.find(g => g.groupKey === quoted.groupKey), quote = quoted.externalQuotes[0] + input.fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接派送应收反例', currency: quote.currency, amount: quote.amount }) + await reject('外接已有约定运费禁止追加重复应收', { groups: extra }, /重复|覆盖.*登记/) + } + } else { + const stored = await io.get(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, '先核对原成功出库和派送集合') + io.check('原单据完整且未执行', null, stored?.outbound?.dispatchNo === source.dispatchNo && stored.outbound.status === 'WAIT_OUTBOUND' + && stableRows((stored.deliveries || []).map(d => d.deliveryNo)) === stableRows(proof.deliveryNos) && stored.deliveries.every(d => d.status === 'WAIT_DELIVERY')) + } + const result = await io.request('POST', `${path}/transfer-delivery`, body, '按全车真实分组确认原费用并安排派送'), arranged = result?.result + io.check('原生命令返回完整客户出库和客户目的地派送', null, result?.dispatchNo === source.dispatchNo && result.action === 'ARRANGE_DELIVERY' && result.status === 'ACTIVE' && result.stage === 'AFTER_LOADING' + && BigInt(version(result.recordVersion)) > BigInt(version(body.recordVersion)) + && arranged?.dispatchNo === source.dispatchNo && arranged.cargoHash === body.cargoHash && arranged.deliveries?.length === preview.groups.length + && new Set(arranged.deliveries.map(d => d.deliveryNo)).size === arranged.deliveries.length + && arranged.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size + && arranged.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && arranged.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0) === Number(o.packageCount)) && arranged.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' + && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key + && g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) + && arranged.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId)))) + if (reconcilePersisted) io.check('仅恢复原出库和派送集合', null, result.replayed === true && stableRows(arranged.deliveries.map(d => d.deliveryNo)) === stableRows(proof.deliveryNos)) + proof.outbounds = arranged.outbounds; proof.deliveries = arranged.deliveries; proof.command = result + proof.outboundNo = arranged.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo + proof.deliveryNos = arranged.deliveries.map(d => d.deliveryNo).sort() + const snapshots = {}, feeRows = {} + let added = 0n + for (const ref of arranged.deliveries) { + const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = body.groups.find(g => g.groupKey === ref.groupKey) + const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查当前客户目的地费用和原费快照') + io.check('派送只归当前客户和目的地', null, detail?.order?.dispatchNo === source.dispatchNo && detail.order.deliveryNo === ref.deliveryNo + && id(detail.order.customerId) === id(group.customerId) && detail.order.outboundNo === ref.outboundNo + && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId)) + const evidence = detail.feeCoverage, expectedCargo = group.cargo.map(cargoIdentity) + if (input.feeCoverage.length) { + io.check('原费快照保留真实货物、确认人、时间和每票引用', null, evidence?.cargoHash === group.feeCoverageCargoHash && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) + && stableRows(evidence.cargo.map(cargoIdentity)) === stableRows(expectedCargo) && Array.isArray(evidence.references) + && stableRows(evidence.references.map(r => ({ financeFeeId: id(r.source?.financeFeeId), sourceVersion: r.source?.sourceVersion, sourceHash: r.source?.sourceHash, services: r.services }))) === stableRows(input.feeCoverage) + && evidence.references.every(r => id(r.source.customerId) === id(group.customerId) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(r.source.sourceType) + && group.cargo.some(c => id(c.sourceInboundId) === id(r.source.sourceInboundId)) && group.feeSources.some(f => id(f.financeFeeId) === id(r.source.financeFeeId) + && f.currency === r.source.currency && cents(f.amount) === cents(r.source.amount) && f.status === r.source.status))) + } else io.check('未选原费不伪造覆盖证据', null, evidence == null || evidence.references?.length === 0) + io.check('费用为本客户明确正数应收列表', null, Array.isArray(detail.fees) && detail.fees.every(f => f.feeDirection && f.feeType)) + const receivables = detail.fees.filter(f => f.feeDirection === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE' && f.sourceStatus !== 'VOIDED') + const expectedAmounts = [...input.fees.map(f => `${f.currency}:${cents(f.amount)}`), ...group.externalQuotes.filter(q => cents(q.amount) > 0n).map(q => `${q.currency}:${cents(q.amount)}`)].sort() + io.check('仅未覆盖货物和原外接报价产生应收,禁止重复或零值', null, stableRows(receivables.map(f => `${f.currency}:${cents(f.amount)}`).sort()) === stableRows(expectedAmounts) + && receivables.every(f => cents(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED')) + added += input.fees.reduce((n, f) => n + cents(f.amount), 0n) + snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees) + } + const replayed = await replay(io, `${path}/transfer-delivery`, body, result, '费用确认原键原载荷重放') + io.check('费用安排重放明确恢复原成功命令', null, replayed.replayed === true) + for (const ref of arranged.deliveries) { + const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '原键重放后回查每客户费用') + io.check('重放不改确认快照也不新增应收', null, JSON.stringify(detail.feeCoverage) === JSON.stringify(snapshots[ref.deliveryNo]) && stableRows(detail.fees) === feeRows[ref.deliveryNo]) + } + const after = await io.get(`${path}/delivery-arrangement`, '回查原费用来源未被确认改变') + const currentFees = after.groups.flatMap(g => g.feeSources.map(f => ({ groupKey: g.groupKey, ...feeIdentity(f) }))) + io.check('原费用版本金额状态保持不变', null, stableRows(currentFees) === stableRows(proof.originalFees)) + proof.snapshots = snapshots; proof.newReceivable = Number(added) / 100; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof + } catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error } } diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs index 7b20a2b86..7ff847759 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance-contract.test.mjs @@ -6,23 +6,34 @@ import { verifyFleetHandover, cleanupFleetHandoverResources } from './fleet-hand const copy = value => value == null ? value : structuredClone(value) function fixture(fault = '') { const marker = 'TEST-HANDOVER-RUN', now = new Date(Date.now() - 60000).toISOString() - const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', vehicleId: '11', driverId: '21', warehouseId: '92', + const proof = { marker: 'FT-CONTROLLED', tripNo: 'TR-ONE', dispatchNo: 'PC-ONE', vehicleId: '11', driverId: '21', warehouseId: '92', stops: [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:ONE' }] } const originalTransport = { vehiclePlate: 'ORIGINAL', driverName: '原司机', driverPhone: '111' } const trip = { tripNo: proof.tripNo, vehicleId: '11', driverId: '21', warehouseId: '92', ...originalTransport, status: 'IN_TRANSIT', recordVersion: '7', departedAt: now, expectedDriverEndAt: new Date(Date.now() + 86400000).toISOString(), maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } - const dispatchDetails = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92', - status: 'READY_TO_DEPART', packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, + const seeds = ['A', 'B'].map(customer => ({ dispatchNo: `PC-${customer}`, customerId: customer, overseasWarehouseId: '92', + packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, ...originalTransport, items: [{ id: `ITEM-${customer}`, warehousePalletId: 'PALLET-INTERNAL', inboundCode: `IN-${customer}`, packageCount: 10 }] })) if (fault === 'r5-layout') { - Object.assign(dispatchDetails[0], { packageCount: 5, pickedPackageCount: 5, items: [ + Object.assign(seeds[0], { packageCount: 5, pickedPackageCount: 5, items: [ { id: 'ITEM-A1', warehousePalletId: 'PALLET-1', packageCount: 1 }, { id: 'ITEM-A2', warehousePalletId: 'PALLET-2', packageCount: 4 }] }) - Object.assign(dispatchDetails[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] }) + Object.assign(seeds[1], { packageCount: 1, pickedPackageCount: 1, items: [{ id: 'ITEM-B1', warehousePalletId: 'PALLET-3', packageCount: 1 }] }) } - const liveDispatches = dispatchDetails.map(({ customerId, ...row }) => ({ ...row, status: 'DEPARTED', shippedPackageCount: row.packageCount })) - const cargo = dispatchDetails.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: row.dispatchNo, + const cargo = seeds.flatMap(row => row.items.map((item, index) => ({ customerId: row.customerId, dispatchNo: 'PC-ONE', dispatchItemId: item.id, warehousePalletId: item.warehousePalletId, palletNo: 'PAL-REAL', packages: item.packageCount, destinationKey: fault === 'r5-layout' && index === 1 ? 'FBA:TWO' : 'FBA:ONE' }))) + const totalPackages = cargo.reduce((n, row) => n + row.packages, 0) + const counts = { itemCount: cargo.length, palletCount: new Set(cargo.map(r => r.warehousePalletId)).size, + packageCount: totalPackages, pickedPackageCount: totalPackages, shippedPackageCount: totalPackages } + const nativeItems = cargo.map(row => ({ dispatchItemId: row.dispatchItemId, warehousePalletId: row.warehousePalletId, + customerId: row.customerId, destination: { key: row.destinationKey }, scheduledPackages: row.packages, pickedPackages: row.packages })) + const nativeReferences = cargo.map((row,index) => ({ deliveryNo: `DL-${row.customerId}-${index}`, customerId: row.customerId, packageCount: row.packages })) + const liveDispatches = [{ dispatchNo: 'PC-ONE', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '19', counts, + documents: { deliveries: nativeReferences, remark: marker } }] + const dispatchDetails = copy(liveDispatches) + const pdaTask = { dispatchNo: 'PC-ONE', taskNo: 'PC-ONE', warehouseId: '92', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '19', + counts, pickingReconciled: true, items: nativeItems.map(row => ({ dispatchItemId: row.dispatchItemId, customerId: row.customerId, + scheduledPackages: row.scheduledPackages, pickedPackages: row.pickedPackages })) } const pallets = [...new Set(cargo.map(row => row.warehousePalletId))].map(warehousePalletId => ({ warehousePalletId, palletSpaces: 1, weightKg: 100, volumeCbm: 1 })) const originalHistory = { action: 'DEPART', recordVersion: '7', snapshot: { trip: copy(trip), confirmedLoad: { cargo, pallets } } } @@ -34,13 +45,13 @@ function fixture(fault = '') { const ok = data => ({ http: 200, body: { code: '200', data: copy(data) }, requestId: 'request', traceId: 'trace' }) const conflict = message => ({ http: 200, body: { code: 'A0409', message }, requestId: 'request', traceId: 'trace' }) const bump = () => { trip.recordVersion = String(BigInt(trip.recordVersion) + 1n) } - const bindings = dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, customerId: row.customerId })) - if (fault === 'binding-customer') bindings[0].customerId = 'OTHER' - if (fault === 'dispatch-cargo') liveDispatches[0].items = [{ ...liveDispatches[0].items[0], id: 'OTHER-ITEM' }] + const bindings = [{ dispatchNo: 'PC-ONE' }] + if (fault === 'binding-customer') nativeItems[0].customerId = 'OTHER' + if (fault === 'dispatch-cargo') nativeItems[0].dispatchItemId = 'OTHER-ITEM' const context = { context: { async checkpointFleetHandover() {} }, marker, createdResources: { fleetTrips: [proof], fleetAcceptance: { marker, tripNo: proof.tripNo, - departure: { tripNo: proof.tripNo, status: 'IN_TRANSIT', departedAt: now, shippedPackages: dispatchDetails.reduce((n,row)=>n+row.packageCount,0), dispatches: dispatchDetails.map(row => ({ dispatchNo: row.dispatchNo, shippedPackages: row.packageCount })) } } }, + departure: { dispatchNo: 'PC-ONE', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: now, shippedPackages: totalPackages } } } }, assert(_module, label, _response, passed) { calls.push({ assertion: label, passed }) }, async requestMultipart(method, path, file) { calls.push({ method, path, certificate: file.name }); assert.ok(file.name.includes('TEST-NOT-REAL')); assert.ok(file.content.byteLength > 0) @@ -55,14 +66,53 @@ function fixture(fault = '') { } if (path.includes('/handover-status')) { assert.equal(token, 'picker-secret') - const dispatchNo = path.split('/').at(-2), customer = dispatchNo.slice(-1) + const dispatchNo = path.split('/').at(-2) const current = handover && fault !== 'pda-keeps-original' ? handover.snapshot.replacement : originalTransport return ok({ dispatchNo, tripNo: trip.tripNo, originalDeparture: fault === 'rewritten-pda-departure' && handover ? current : originalTransport, - deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${customer}-${index}`, status: 'WAIT_DELIVERY', packages: row.packages, terminalAt: null, + deliveries: cargo.filter(row=>row.dispatchNo===dispatchNo).map((row,index)=>({ deliveryNo: `DL-${row.customerId}-${index}`, status: 'DELIVERING', packages: row.packages, terminalAt: null, transport: { vehiclePlate: current.vehiclePlate, driverName: current.driverName, driverPhone: current.driverPhone } })), latestHandover: handover ? { handoverNo: handover.handoverNo } : null }) } - if (path.startsWith('/api/platform/v1/last-leg/dispatches/')) return ok(liveDispatches.find(row => row.dispatchNo === path.split('/').at(-1))) + if (path.startsWith('/api/pda/v1/last-leg/dispatches/') && !path.endsWith('/handover-status')) { + assert.equal(token, 'picker-secret') + const task = copy(pdaTask) + if (fault === 'pda-task-drift' && handover) task.counts.pickedPackageCount-- + return ok(task) + } + if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { + assert.deepEqual(body.cargo, []); assert.deepEqual(body.batchAllocations, []) + assert.equal(body.vehicle, null); assert.equal(body.driver, null) + if (fault === 'unknown-probe-create') throw new Error('native empty dispatch result unknown') + const index = probes.size + 1 + const next = { tripNo: `PROBE-${index}`, dispatchNo: `EMPTY-${index}`, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0', + counts: { itemCount: 0, packageCount: 0, pickedPackageCount: 0, shippedPackageCount: 0 }, documents: { remark: body.remark }, + warehouseId: body.warehouseId } + probes.set(next.tripNo, next) + const { tripNo: internalOnly, ...response } = next + return ok(response) + } + if (path.startsWith('/api/platform/v1/last-leg/dispatches/EMPTY-')) { + const number = path.match(/dispatches\/(EMPTY-\d+)/)[1], probe = [...probes.values()].find(i => i.dispatchNo === number) + if (path.endsWith('/items')) return ok({ dispatchNo: probe.dispatchNo, recordVersion: probe.recordVersion, items: fault === 'probe-has-cargo' ? [{}] : [] }) + if (path.endsWith('/cancel')) { + assert.equal(body.recordVersion, probe.recordVersion) + for (const [resource, owner] of resources) if (owner === probe.tripNo) resources.delete(resource) + probe.status = 'CANCELLED'; return ok(probe) + } + if (method === 'PUT') { + assert.equal(body.vehicle.mode, 'MASTER'); assert.equal(body.driver.mode, 'MASTER'); assert.ok(body.idempotencyKey) + if (resources.has(`VEHICLE:${body.vehicle.vehicleId}`)) return conflict('车辆已被车次占用') + if (resources.has(`DRIVER:${body.driver.driverId}`)) return conflict('司机已被车次占用') + resources.set(`VEHICLE:${body.vehicle.vehicleId}`, probe.tripNo); resources.set(`DRIVER:${body.driver.driverId}`, probe.tripNo) + Object.assign(probe, { vehicleId: body.vehicle.vehicleId, driverId: body.driver.driverId, vehiclePlate: originalTransport.vehiclePlate, recordVersion: '1' }) + } + const current = copy(probe) + if (fault === 'probe-version-drift') current.recordVersion = '99' + if (fault === 'probe-has-scan') current.counts.pickedPackageCount = 1 + return ok(current) + } + if (path === '/api/platform/v1/last-leg/dispatches/PC-ONE/items') return ok({ dispatchNo: 'PC-ONE', recordVersion: '19', items: nativeItems }) + if (path === '/api/platform/v1/last-leg/dispatches/PC-ONE') return ok(liveDispatches[0]) if (path.endsWith('/master-data/vehicles') && method === 'POST') { assert.equal(body.plateNumber, body.plateNumber.toUpperCase(), 'request must use the formal uppercase vehicle plate') const vehicle = { ...body, id: String(12 + createdVehicles++), recordVersion: '0' }; vehicles.set(vehicle.id, vehicle); return ok(vehicle) @@ -86,22 +136,13 @@ function fixture(fault = '') { if (path.endsWith('/status')) { driver.active = body.active; driver.recordVersion = '3' } return ok(fault === 'review-version' && path.endsWith('/review') ? { ...driver, recordVersion: '1' } : driver) } - if (path.endsWith('/fleet/trips') && method === 'POST') { - const next = { ...body, tripNo: `PROBE-${probes.size + 1}`, status: 'PLANNED', recordVersion: '0' }; probes.set(next.tripNo, next); return ok({ trip: next, dispatches: [] }) + if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) { + const rows = [...probes.values()].filter(i => i.vehicleId).map(i => ({ ...i, status: 'PLANNED' })) + return ok({ records: rows, total: rows.length, page: 1, size: 200 }) } if (path.includes('/trips/PROBE-')) { const number = path.match(/\/trips\/([^/]+)/)[1], probe = probes.get(number) - if (path.endsWith('/assignment')) { - if (resources.has(`VEHICLE:${body.vehicleId}`)) return conflict('车辆已被车次占用') - if (resources.has(`DRIVER:${body.driverId}`)) return conflict('司机已被车次占用') - resources.set(`VEHICLE:${body.vehicleId}`, number); resources.set(`DRIVER:${body.driverId}`, number) - Object.assign(probe, { vehicleId: body.vehicleId, driverId: body.driverId, recordVersion: '1' }) - } - if (path.endsWith('/cancel')) { - for (const [resource, owner] of resources) if (owner === number) resources.delete(resource) - probe.status = 'CANCELLED' - } - return ok({ trip: probe, dispatches: [] }) + return ok({ trip: { ...probe, status: 'PLANNED' }, dispatches: [{ dispatchNo: probe.dispatchNo }] }) } if (path.includes('/history?')) { const departure = copy(originalHistory) @@ -159,9 +200,12 @@ test('fresh real departure carries all customer cargo, preserves DEPART and fees assert.equal(result.browser.originalVehiclePlate, 'ORIGINAL'); assert.match(result.browser.replacementVehiclePlate, /^HO-/) assert.equal(f.proof.vehicleId, '12'); assert.equal(f.proof.driverId, '22'); assert.equal(f.proof.licenseFileId, 'TEST-LICENSE-FILE') assert.equal(result.replacement.certificate.synthetic, true); assert.ok(result.replacement.certificate.contentBase64) + assert.equal(result.dispatchNos.length, 1); assert.equal(result.pdaTasksBefore.length, 1) + assert.equal(f.proof.marker, result.replacement.vehiclePlate, 'current unique plate follows the resources for later native closure probes') assert.doesNotMatch(JSON.stringify(result), /picker-secret/) assert.ok(f.calls.filter(row => row.assertion).every(row => row.passed)) assert.ok(!f.calls.some(row => row.path?.endsWith('/depart') || row.path?.endsWith('/complete') || row.path?.endsWith('/cancel'))) + assert.ok(!f.calls.some(row => row.method === 'POST' && /\/fleet\/trips$|\/assignment$/.test(row.path || ''))) assert.equal(f.resources.get('VEHICLE:11'), 'PROBE-1'); assert.equal(f.resources.get('DRIVER:21'), 'PROBE-1') }) @@ -238,7 +282,8 @@ for (const [fault, message] of Object.entries({ 'driver-default-nonempty': '接 'review-version': '接替司机已正式审核', 'preview-identity': '交接预览绑定原车次', 'preview-capacity': '交接预览容量', 'release-version': '原车辆已结束', 'binding-customer': '每张排车已发车', 'dispatch-cargo': '每张排车已发车', 'wrong-customer': '预览精确包含', 'double-count-pallet': '共享实托只计一次', 'stale-accepted': '陈旧版本交接必须拒绝', 'unrelated-conflict': '陈旧版本交接必须拒绝', 'pda-keeps-original': 'PDA保持原发车资源', 'rewritten-pda-departure': 'PDA保持原发车资源', 'fee-changed': '交接不新增或改写派送费用', - 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆' })) { + 'rewritten-departure': '只新增一次交接且原DEPART内容保持不变', 'replay-releases-next': '旧请求重放不能释放下一计划车辆', + 'pda-task-drift': '交接不改变PDA原生整车版本及扫码归属', 'unknown-probe-create': 'native empty dispatch result unknown' })) { test(`business counterexample ${fault} cannot be reported as successful handover`, async () => { const f = fixture(fault) await assert.rejects(verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails), new RegExp(message)) @@ -273,12 +318,22 @@ test('explicit closure cleanup cancels only test probes and disables newly creat assert.ok(!f.calls.some(row => row.method === 'DELETE')) }) +for (const fault of ['probe-has-cargo', 'probe-version-drift', 'probe-has-scan']) test(`native empty-dispatch cleanup refuses unexpected state: ${fault}`, async () => { + const f = fixture(fault) + await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails) + f.trip.status = 'COMPLETED'; f.trip.vehicleRelease = { actualEndAt: new Date().toISOString() }; f.trip.driverRelease = { actualEndAt: new Date().toISOString() } + f.resources.delete('VEHICLE:12'); f.resources.delete('DRIVER:22') + const before = f.calls.length + await assert.rejects(cleanupFleetHandoverResources(f.context, 'test', f.proof), /仅取消本轮未执行/) + assert.ok(f.calls.slice(before).filter(row => row.method).every(row => row.method === 'GET')) +}) + test('formal driver response accepts empty profile with omitted or NULL optional fields', async () => { const f = fixture('driver-default-null') assert.equal((await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails)).status, 'VERIFIED') }) -test('r5 two dispatches, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => { +test('r5 one cross-customer dispatch, three pallets and three deliveries preserve multi-destination cargo through handover and cleanup', async () => { const f = fixture('r5-layout') const result = await verifyFleetHandover(f.context, 'test', f.proof, 'picker-secret', f.dispatchDetails) assert.equal(result.status, 'VERIFIED'); assert.equal(result.pdaAfter.flatMap(row=>row.deliveries).length, 3) diff --git a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs index 086fcf118..616459ace 100644 --- a/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-handover-acceptance.mjs @@ -25,7 +25,8 @@ function requireRun(context, tripProof) { const run = context.createdResources?.fleetAcceptance if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo - || run.departure?.tripNo !== tripProof.tripNo || run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt) + || run.departure?.dispatchNo !== tripProof.dispatchNo || run.departure.status !== 'ACTIVE' + || run.departure.stage !== 'DELIVERING' || !run.departure.result?.departedAt || !count(run.departure.result.shippedPackages)) throw new Error('在途交接仅接受当前marker登记的原车次及真实DEPART结果,禁止借用旧样本') return run } @@ -60,10 +61,17 @@ function io(context, module, proof) { } return { check, request } } -function dispatchFacts(row) { - const fields = ['dispatchNo', 'overseasWarehouseId', 'status', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', - 'vehiclePlate', 'driverName', 'driverPhone', 'outboundNo', 'departAt', 'estimatedArrivalAt', 'items'] - return Object.fromEntries(fields.filter(name => row[name] !== undefined).map(name => [name, row[name]])) +function cargoFacts(values) { + const totals = new Map() + for (const row of values) { + const facts = { itemId: text(row.dispatchItemId), customerId: text(row.customerId), palletId: text(row.warehousePalletId), + destinationKey: row.destination?.key ?? row.destinationKey } + if (!facts.itemId || !facts.customerId || !facts.palletId || !facts.destinationKey) throw new Error('原生货物缺少真实明细客户或目的地') + const identity = stable(facts), packages = Number(row.scheduledPackages ?? row.packages) + if (!count(packages)) throw new Error('原生货物缺少有效真实箱数') + totals.set(identity, { ...facts, packages: (totals.get(identity)?.packages || 0) + packages }) + } + return [...totals.values()].sort((a, b) => stable(a).localeCompare(stable(b))) } function feeFacts(rows) { if (!Array.isArray(rows)) throw new Error('费用来源不是正式列表') @@ -85,10 +93,9 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke if (tripProof.handover || !pickerToken || !Array.isArray(dispatchDetails) || !dispatchDetails.length) throw new Error('交接验收缺少本轮PDA或排车依据,或已开始;保留原请求,不自动续跑') const numbers = dispatchDetails.map(row => row.dispatchNo) - if (new Set(numbers).size !== numbers.length || numbers.some(number => !number) - || !same([...numbers].sort(), run.departure.dispatches?.map(row => row.dispatchNo).sort()) - || !dispatchDetails.every(row => count(row.packageCount)) - || Number(run.departure.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.packageCount), 0)) + if (numbers.length !== 1 || numbers[0] !== tripProof.dispatchNo || numbers[0] !== run.departure.dispatchNo + || !dispatchDetails.every(row => count(row.counts?.packageCount)) + || Number(run.departure.result.shippedPackages) !== dispatchDetails.reduce((sum, row) => sum + Number(row.counts.packageCount), 0)) throw new Error('交接输入排车清单及箱数未覆盖本轮真实发车') const proof = tripProof.handover = { marker: context.marker, fixtureMarker: tripProof.marker, status: 'CHECKING', requests: [], probes: [], dispatchNos: [...numbers], stockEvidence: 'CALLER_FULL_CHAIN_INVENTORY_AUDIT_REQUIRED' } @@ -96,7 +103,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke try { const detail = await request('GET', base, undefined, '读取真实在途车次及分配资源') check('真实车次属于本轮且已经发车', detail?.trip?.tripNo === tripProof.tripNo && detail.trip.status === 'IN_TRANSIT' - && detail.trip.departedAt === run.departure.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId) + && detail.trip.departedAt === run.departure.result.departedAt && text(detail.trip.vehicleId) === text(tripProof.vehicleId) && text(detail.trip.driverId) === text(tripProof.driverId) && text(detail.trip.warehouseId) === text(tripProof.warehouseId) && validVersion(detail.trip.recordVersion) && same(detail.dispatches?.map(row => row.dispatchNo).sort(), [...numbers].sort())) const initialHistory = await request('GET', `${base}/history?page=1&size=200`, undefined, '读取不可变原DEPART证据') @@ -109,26 +116,36 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke && text(original.trip.vehicleId) === text(detail.trip.vehicleId) && text(original.trip.driverId) === text(detail.trip.driverId) && original.confirmedLoad?.cargo?.length > 0 && original.confirmedLoad?.pallets?.length > 0) proof.previous = { vehicleId: text(detail.trip.vehicleId), driverId: text(detail.trip.driverId), ...transport(detail.trip) } - proof.dispatchBefore = []; proof.pdaBefore = []; proof.feesBefore = [] + proof.dispatchBefore = []; proof.pdaBefore = []; proof.pdaTasksBefore = []; proof.feesBefore = [] for (const expected of dispatchDetails) { const row = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对客户排车已经真实出库') - const binding = detail.dispatches.find(item => item.dispatchNo === expected.dispatchNo) + const items = await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '读取原生主单逐客户真实货物') const cargo = original.confirmedLoad.cargo.filter(item => item.dispatchNo === expected.dispatchNo) - const items = values => values.map(item => ({ itemId: text(item.id ?? item.dispatchItemId), palletId: text(item.warehousePalletId), - packages: Number(item.packageCount ?? item.packages) })).sort((a, b) => a.itemId.localeCompare(b.itemId)) - check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'DEPARTED' - && Number(row.shippedPackageCount) === Number(expected.packageCount) && Number(row.pickedPackageCount) === Number(expected.packageCount) - && binding?.customerId && cargo.length > 0 && cargo.every(item => text(item.customerId) === text(binding.customerId)) - && same(items(row.items || []), items(cargo)) && text(row.overseasWarehouseId) === text(tripProof.warehouseId)) - proof.dispatchBefore.push(dispatchFacts(row)) + check('每张排车已发车且货量与客户起运仓一致', row?.dispatchNo === expected.dispatchNo && row.status === 'ACTIVE' && row.stage === 'DELIVERING' + && Number(row.counts?.shippedPackageCount) === Number(expected.counts.packageCount) && Number(row.counts?.pickedPackageCount) === Number(expected.counts.packageCount) + && items.dispatchNo === row.dispatchNo && text(items.recordVersion) === text(row.recordVersion) && cargo.length > 0 + && same(cargoFacts(items.items || []), cargoFacts(cargo)) && row.documents?.deliveries?.length > 0) + proof.dispatchBefore.push({ dispatchNo: row.dispatchNo, detail: row, items }) + const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}`, undefined, 'PDA读取原生整车任务和客户来源', { token: pickerToken }) + check('PDA原生作业与整车真实客户货物核平', task.dispatchNo === row.dispatchNo && task.status === 'ACTIVE' && task.stage === 'DELIVERING' + && text(task.warehouseId) === text(tripProof.warehouseId) && text(task.recordVersion) === text(row.recordVersion) + && same(task.counts, row.counts) && task.pickingReconciled === true + && same(task.items?.map(i => ({ dispatchItemId: text(i.dispatchItemId), customerId: text(i.customerId), scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages })) + .sort((a,b) => a.dispatchItemId.localeCompare(b.dispatchItemId)), + items.items.map(i => ({ dispatchItemId: text(i.dispatchItemId), customerId: text(i.customerId), scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages })) + .sort((a,b) => a.dispatchItemId.localeCompare(b.dispatchItemId)))) + proof.pdaTasksBefore.push(task) const pda = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(row.dispatchNo)}/handover-status`, undefined, 'PDA读取原运输资源', { token: pickerToken }) check('PDA原资源与未签收货物对应当前排车', pda?.dispatchNo === row.dispatchNo && pda.tripNo === tripProof.tripNo && pda.latestHandover == null && same(transport(pda.originalDeparture), transport(original.trip)) && pda.deliveries?.length > 0 - && pda.deliveries.every(item => !item.terminalAt && item.status === 'WAIT_DELIVERY' && same(transport(item.transport), transport(detail.trip))) - && pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.packageCount)) + && pda.deliveries.every(item => !item.terminalAt && item.status === 'DELIVERING' && same(transport(item.transport), transport(detail.trip))) + && pda.deliveries.reduce((sum, item) => sum + Number(item.packages), 0) === Number(expected.counts.packageCount) + && same(pda.deliveries.map(i => i.deliveryNo).sort(), row.documents.deliveries.map(i => i.deliveryNo).sort())) proof.pdaBefore.push(pda) for (const delivery of pda.deliveries) { - const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(binding.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}` + const reference = row.documents.deliveries.find(i => i.deliveryNo === delivery.deliveryNo) + check('派送费用严格沿真实主单逐客户单据归属', reference?.customerId && cargo.some(i => text(i.customerId) === text(reference.customerId))) + const path = `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId: text(reference.customerId), businessType: 'DELIVERY_ORDER', businessNo: delivery.deliveryNo })}` proof.feesBefore.push({ path, facts: feeFacts(await request('GET', path, undefined, '冻结本次派送费用来源')) }) } } @@ -214,8 +231,12 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke await request('POST', `${base}/handovers`, proof.staleRequest, '陈旧版本交接必须拒绝', { conflict: /车次.*(变化|修改)|版本/ }) check('陈旧命令没有改变车次版本或资源', same(await request('GET', base, undefined, '拒绝后核对车次零变更'), after)) const firstProbe = await createProbe(1) - const assignment = { recordVersion: firstProbe.recordVersion, vehicleId: proof.previous.vehicleId, driverId: proof.previous.driverId, reason: '本轮旧资源占用核对' } - await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ }) + const assignment = { recordVersion: firstProbe.recordVersion, idempotencyKey: key(), + vehicle: { mode: 'MASTER', vehicleId: proof.previous.vehicleId }, + driver: { mode: 'MASTER', driverId: proof.previous.driverId, evidence: { expectedTaskEndAt: detail.trip.expectedDriverEndAt } }, + plannedDepartureAt: new Date().toISOString(), estimatedArrivalAt: null, remark: `${context.marker}本轮旧资源占用核对` } + firstProbe.assignmentRequest = clone(assignment) + await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '交接后旧车辆仍被原车次占用', { conflict: /车辆.*占用/ }) const releaseBase = `${base}/handovers/${encodeURIComponent(handover.handoverNo)}/resources/release` proof.vehicleReleaseRequest = { recordVersion: handover.recordVersion, resourceKind: 'VEHICLE', actualEndAt: new Date().toISOString(), location: `${context.marker}车辆任务结束点`, vehicleStatus: 'IN_SERVICE', idempotencyKey: key() } const vehicleReleased = await request('POST', releaseBase, proof.vehicleReleaseRequest, '单独结束交接前车辆任务') @@ -225,7 +246,7 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke && vehicleReleased.vehicleRelease?.idempotencyKey === proof.vehicleReleaseRequest.idempotencyKey && vehicleReleased.vehicleRelease?.location === proof.vehicleReleaseRequest.location && vehicleReleased.vehicleRelease?.vehicleStatus === 'IN_SERVICE' && vehicleReleased.driverRelease == null) - await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ }) + await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '只释放车辆后旧司机仍被占用', { conflict: /司机.*占用/ }) proof.driverReleaseRequest = { recordVersion: vehicleReleased.recordVersion, resourceKind: 'DRIVER', actualEndAt: new Date().toISOString(), location: `${context.marker}司机任务结束点`, idempotencyKey: key() } const released = await request('POST', releaseBase, proof.driverReleaseRequest, '单独结束交接前司机任务') check('原车辆司机分别保留结束事实', released.handoverNo === handover.handoverNo && released.tripNo === tripProof.tripNo @@ -234,20 +255,53 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke && Date.parse(released.driverRelease?.actualEndAt) === Date.parse(proof.driverReleaseRequest.actualEndAt) && released.driverRelease?.idempotencyKey === proof.driverReleaseRequest.idempotencyKey && released.driverRelease?.location === proof.driverReleaseRequest.location) - const assigned = await request('POST', `${pathFor(firstProbe.tripNo)}/assignment`, assignment, '将已释放原资源分配给本轮无货计划') - firstProbe.recordVersion = assigned.trip.recordVersion - check('原资源真正可重新分配', text(assigned.trip.vehicleId) === proof.previous.vehicleId && text(assigned.trip.driverId) === proof.previous.driverId) + const assigned = await request('PUT', `${DISPATCHES}/${encodeURIComponent(firstProbe.dispatchNo)}`, assignment, '将已释放原资源分配给本轮无货计划') + check('原资源真正可重新分配', assigned.dispatchNo === firstProbe.dispatchNo && assigned.status === 'ACTIVE' && assigned.stage === 'BEFORE_LOADING' + && BigInt(text(assigned.recordVersion)) > BigInt(text(firstProbe.recordVersion))) + firstProbe.recordVersion = assigned.recordVersion + const candidateRows = []; let candidateTotal + for (let page = 1; ; page++) { + const candidates = await request('GET', `${FLEET}/trips?${new URLSearchParams({ keyword: proof.previous.vehiclePlate, + warehouseId: text(tripProof.warehouseId), page: text(page), size: '200' })}`, undefined, '定位本轮空主单自动Fleet资源记录') + check('资源候选分页完整且稳定', Array.isArray(candidates.records) && Number(candidates.page) === page && Number(candidates.size) === 200 + && Number.isSafeInteger(Number(candidates.total)) && Number(candidates.total) >= 0 + && (candidateTotal === undefined || candidateTotal === Number(candidates.total))) + candidateTotal ??= Number(candidates.total) + check('资源候选未缺页', candidates.records.length > 0 || candidateRows.length === candidateTotal) + candidateRows.push(...candidates.records); if (candidateRows.length >= candidateTotal) break + } + check('资源候选总数和业务号无重复', candidateRows.length === candidateTotal && new Set(candidateRows.map(i => i.tripNo)).size === candidateRows.length) + const matches = [] + for (const row of candidateRows.filter(row => text(row.vehicleId) === proof.previous.vehicleId && text(row.driverId) === proof.previous.driverId + && row.vehiclePlate === proof.previous.vehiclePlate && text(row.warehouseId) === text(tripProof.warehouseId))) { + const candidate = await request('GET', pathFor(row.tripNo), undefined, '核对新资源记录与原生空单唯一绑定') + if (candidate.dispatches?.some(i => i.dispatchNo === firstProbe.dispatchNo)) { + check('新资源记录只能绑定本轮唯一空主单', candidate.trip?.tripNo === row.tripNo && candidate.dispatches.length === 1 + && text(candidate.trip.vehicleId) === proof.previous.vehicleId && text(candidate.trip.driverId) === proof.previous.driverId) + matches.push(candidate) + } + } + check('原资源真正可重新分配且唯一定位', matches.length === 1 && matches[0].trip.tripNo !== tripProof.tripNo) + firstProbe.tripNo = matches[0].trip.tripNo const persistedReleases = await request('GET', `${base}/handovers?page=1&size=10`, undefined, '读取持久化资源结束记录') const releaseSnapshot = persistedReleases.records?.find(row => row.handoverNo === proof.handoverNo) check('原车辆结束重放不改记录', same(await request('POST', releaseBase, proof.vehicleReleaseRequest, '新计划占用后重放旧车辆结束请求'), releaseSnapshot)) check('原司机结束重放不改记录', same(await request('POST', releaseBase, proof.driverReleaseRequest, '新计划占用后重放旧司机结束请求'), releaseSnapshot)) const secondProbe = await createProbe(2) - await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ }) + const secondAssignment = { ...assignment, idempotencyKey: key(), recordVersion: secondProbe.recordVersion } + await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, secondAssignment, '旧请求重放不能释放下一计划车辆', { conflict: /车辆.*占用/ }) const spareBody = { ...vehicleBody, plateNumber: `HO-PROBE-${randomUUID().slice(0, 10).toUpperCase()}` } const spare = await request('POST', VEHICLES, spareBody, '创建仅用于司机占用隔离的本轮车辆') check('隔离车辆已创建', spare?.id); proof.spareVehicleId = text(spare.id) - await request('POST', `${pathFor(secondProbe.tripNo)}/assignment`, { ...assignment, vehicleId: spare.id, recordVersion: secondProbe.recordVersion }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ }) - for (const expected of proof.dispatchBefore) check('交接不改变原排车出库和货物归属', same(dispatchFacts(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对排车原始出库事实')), expected)) + await request('PUT', `${DISPATCHES}/${encodeURIComponent(secondProbe.dispatchNo)}`, { ...secondAssignment, idempotencyKey: key(), vehicle: { mode: 'MASTER', vehicleId: spare.id } }, '旧请求重放不能释放下一计划司机', { conflict: /司机.*占用/ }) + for (const expected of proof.dispatchBefore) { + check('交接不改变原排车出库和货物归属', same(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}`, undefined, '核对原生排车出库事实'), expected.detail) + && same(await request('GET', `${DISPATCHES}/${encodeURIComponent(expected.dispatchNo)}/items`, undefined, '核对原生排车逐客户货物事实'), expected.items)) + } + for (const previous of proof.pdaTasksBefore) { + const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}`, undefined, 'PDA回查原生整车版本及扫码归属未变化', { token: pickerToken }) + check('交接不改变PDA原生整车版本及扫码归属', same(current, previous)) + } proof.pdaAfter = [] for (const previous of proof.pdaBefore) { const current = await request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(previous.dispatchNo)}/handover-status`, undefined, 'PDA只读核对接替资源与原始发车', { token: pickerToken }) @@ -270,16 +324,19 @@ export async function verifyFleetHandover(context, module, tripProof, pickerToke proof.status = 'VERIFIED' // Caller finishFleetTrip must finish the currently assigned resources, not the original pair. tripProof.vehicleId = proof.replacement.vehicleId; tripProof.driverId = proof.replacement.driverId; tripProof.licenseFileId = proof.replacement.licenseFileId + tripProof.marker = proof.replacement.vehiclePlate return proof } catch (error) { proof.status = 'FAILED'; proof.failure = { message: error.message, requestCount: proof.requests.length }; throw error } async function createProbe(index) { const saved = { marker: context.marker, index }; proof.probes.push(saved) - const value = await request('POST', `${FLEET}/trips`, { warehouseId: tripProof.warehouseId, idempotencyKey: key(), - plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), - stops: tripProof.stops, remark: `${context.marker}在途交接资源验证无货计划${index}` }, '创建本轮无货资源核对计划') - check('核对计划不承载货物且尚未发车', value?.trip?.tripNo && value.trip.status === 'PLANNED' && value.dispatches?.length === 0 && validVersion(value.trip.recordVersion)) - saved.tripNo = value.trip.tripNo; saved.recordVersion = value.trip.recordVersion; return saved + saved.createRequest = { warehouseId: text(tripProof.warehouseId), idempotencyKey: key(), vehicle: null, driver: null, + plannedDepartureAt: new Date().toISOString(), estimatedArrivalAt: null, cargo: [], batchAllocations: [], + remark: `${context.marker}在途交接资源验证原生空排车${index}` } + const value = await request('POST', DISPATCHES, saved.createRequest, '创建本轮无货资源核对计划') + check('核对计划不承载货物且尚未发车', value?.dispatchNo && value.status === 'ACTIVE' && value.stage === 'BEFORE_LOADING' + && value.counts?.packageCount === 0 && value.counts?.pickedPackageCount === 0 && value.counts?.shippedPackageCount === 0 && validVersion(value.recordVersion)) + saved.dispatchNo = value.dispatchNo; saved.recordVersion = value.recordVersion; return saved } } @@ -295,10 +352,15 @@ export async function cleanupFleetHandoverResources(context, module, tripProof) check('整车结案且接替车辆司机均已释放', current.trip?.status === 'COMPLETED' && current.trip.vehicleRelease && current.trip.driverRelease && text(current.trip.vehicleId) === proof.replacement.vehicleId && text(current.trip.driverId) === proof.replacement.driverId) for (const probe of [...proof.probes].reverse()) { - const path = pathFor(probe.tripNo), detail = await request('GET', path, undefined, '核对仅本轮无货资源计划') - check('仅取消本轮未执行无货计划', detail.trip?.tripNo === probe.tripNo && detail.trip.status === 'PLANNED' && detail.trip.remark?.includes(context.marker) && detail.dispatches?.length === 0) - const cancelled = await request('POST', `${path}/cancel`, { recordVersion: detail.trip.recordVersion, reason: '本轮交接验证完成,取消无货核对计划' }, '取消本轮资源核对计划') - check('无货计划已取消', cancelled.trip?.status === 'CANCELLED') + const path = `${DISPATCHES}/${encodeURIComponent(probe.dispatchNo)}`, detail = await request('GET', path, undefined, '核对仅本轮原生空排车') + const items = await request('GET', `${path}/items`, undefined, '核对本轮空排车零实际货物') + check('仅取消本轮未执行无货计划', detail.dispatchNo === probe.dispatchNo && detail.status === 'ACTIVE' && detail.stage === 'BEFORE_LOADING' + && detail.documents?.remark?.includes(context.marker) && text(detail.recordVersion) === text(probe.recordVersion) + && detail.counts?.itemCount === 0 && detail.counts?.packageCount === 0 && detail.counts?.pickedPackageCount === 0 && detail.counts?.shippedPackageCount === 0 + && items.dispatchNo === probe.dispatchNo && text(items.recordVersion) === text(detail.recordVersion) && items.items?.length === 0) + probe.cancelRequest = { recordVersion: detail.recordVersion, idempotencyKey: key(), reason: '本轮交接验证完成,取消原生空排车并保留历史' } + const cancelled = await request('POST', `${path}/cancel`, probe.cancelRequest, '取消本轮资源核对计划') + check('无货计划已取消', cancelled.dispatchNo === probe.dispatchNo && cancelled.status === 'CANCELLED') } for (const vehicleId of [proof.replacement.vehicleId, proof.spareVehicleId]) { const vehicle = await request('GET', `${VEHICLES}/${encodeURIComponent(vehicleId)}`, undefined, '读取本轮接替或隔离车辆版本') diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index 884f156b7..4ee2b7d9e 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -6,180 +6,223 @@ import { tmpdir } from 'node:os' import { createHash } from 'node:crypto' import { selectReturnSources, verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit } from './fleet-return-redispatch-acceptance.mjs' +const BASE = '/api/platform/v1/last-leg', DISPATCH = `${BASE}/dispatches`, PDA = '/api/pda/v1/last-leg/dispatches', CUSTOMER = '900719925474099301', WAREHOUSE = '92' +const copy = value => structuredClone(value) function fixture(fault = '') { - const trip = { tripNo: 'TR-ORIGINAL' }, requests = [], checkpoints = [] - let returned = false, originalSigned = false, newSigned = false, departed = false + const trip = { tripNo: 'TR-ORIGINAL', dispatchNo: 'DP-ORIGINAL' }, requests = [], checkpoints = [], ledger = new Map(), controls = {} const masterIds = { A: '9007199254740993', B: '9007199254740995' } - const relations = ['A', 'B'].map((name, index) => ({ lastMileOrderId: masterIds[name], lastMileOrderNo: `LM-${name}`, - customerId: 'C', overseasWarehouseId: 'W', inboundOrderId: fault === 'no-inbound' ? null : String(index + 10), inboundCode: `IN-${name}`, - deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', returnSources: [{ outboundPalletItemId: String(index + 20), inventoryPositionId: String(index + 30), maxReturnPackages: index ? 3 : 4 }] })) - const tail = name => ({ id: masterIds[name], orderNo: `LM-${name}`, deliveryOrderNo: 'DL-ORIGINAL', outboundOrderNo: 'OUT-ORIGINAL', orderType: 'FIRST_LEG', sourceInboundId: `DOMESTIC-${name}`, customerId: 'C', overseasWarehouseId: 'W', inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, - subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' - : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' }) - const order = name => name === 'DL-NEW' ? { deliveryNo: name, dispatchNo: 'DP-NEW', packageCount: 2, signedPackages: newSigned ? 2 : null, signedAt: newSigned ? 'new-signature' : null } - : { deliveryNo: 'DL-ORIGINAL', dispatchNo: 'DP-ORIGINAL', outboundNo: 'OUT-ORIGINAL', status: 'DELIVERING', packageCount: 7, - signedPackages: originalSigned ? 5 : null, signedAt: originalSigned ? newSigned && fault === 'rewritten-original' ? 'changed' : 'original-signature' : null } - const envelope = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'request-id', traceId: 'trace-id' }) - const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: 'C', warehouseId: 'W', tripNo: trip.tripNo, departure: { status: 'IN_TRANSIT', dispatches: [{ dispatchNo: 'DP-ORIGINAL' }] } } }, - assert() {}, async checkpoint() { checkpoints.push(structuredClone(trip.returnRedispatch)) }, - async requestMultipart(method, path, file) { requests.push({ method, path, file: file.name }); return envelope({ fileId: '99' }) }, - async request(method, path, body, token) { - requests.push({ method, path, body: structuredClone(body), token }) - if (path.startsWith('/api/platform/inbounds?')) { - const code = new URLSearchParams(path.split('?')[1]).get('inboundCode'), name = code.slice(-1) - const delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned - return envelope({ records: [{ inboundCode: code, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) - } - if (path.includes('/orders/')) { - const masterId = path.split('/orders/')[1].split('?')[0] - const name = Object.keys(masterIds).find(name => masterIds[name] === masterId) - assert.ok(name, `unknown master ${masterId}`) - return envelope({ order: tail(name) }) - } - if (path.includes('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations) - if (path.includes('/mark-return')) { - assert.equal(body.lastMileOrderId, masterIds.A) - assert.equal(Object.hasOwn(body, 'inboundOrderId'), false) - assert.deepEqual(body.items, [{ outboundPalletItemId: '20', inventoryPositionId: '30', packageCount: 2 }]) - return envelope({ exceptionNo: 'EX-NEW', version: 0 }) - } - if (path.includes('/start-processing')) return envelope({ status: 'PROCESSING', version: 1 }) - if (path.includes('/exception-orders/') && path.includes('/complete')) { - const replayed = returned; returned = true - if (fault === 'unknown-return') throw Error('connection lost after commit') - return envelope({ status: 'COMPLETED', restoredPackages: fault === 'wrong-return' ? 3 : 2, inventoryAdjustmentId: '88', replayed }) - } - if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') } - if (path.endsWith('/pallets/100')) return envelope({ warehouseId: 'W', locationId: '200', palletCode: context.palletCode }) - if (path.includes('/locations?')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] }) - if (path.includes('/dispatches/candidates')) return envelope([{ sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', returnReceiptNo: 'RSR-300', warehousePalletId: '100', orderId: masterIds.A, packageCount: fault === 'wrong-candidate' ? 4 : 2 }]) - if (path.endsWith('/drafts/containers')) return envelope({ id: '400' }) - if (path.endsWith('/draft-locks/lock')) { assert.equal(body.sourceType, 'RETURN_RECEIPT'); assert.equal(body.planPalletId, undefined); return envelope(null) } - if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, []); assert.deepEqual(body.returnReceiptIds, ['300']); return envelope({ dispatchNo: 'DP-NEW', packageCount: 2 }) } - if (path.endsWith('/fleet/trips/TR-NEW')) return envelope({ trip: { vehiclePlate: 'NEW', driverName: 'driver', driverPhone: 'phone' } }) - if (path.endsWith('/transfer-delivery')) return envelope({ deliveries: [{ deliveryNo: 'DL-NEW' }] }) - if (path.endsWith('/depart')) { assert.equal(token, 'private-pda-token'); const replayed = departed; departed = true; return envelope({ tripNo: 'TR-NEW', shippedPackages: 2, replayed }) } - if (path.includes('/delivery-orders/')) return envelope({ order: order(path.split('/delivery-orders/')[1].split('?')[0]) }) - throw Error(`Unexpected ${method} ${path}`) - } } - const dependencies = { - async onBeforeRedispatchSignature(proof) { - assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING') - if (fault === 'audit-gate') throw Error('Audit gate not approved') - return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } + const relations = ['A', 'B'].map((name, n) => ({ lastMileOrderId: masterIds[name], lastMileOrderNo: `LM-${name}`, customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, + inboundOrderId: fault === 'no-inbound' ? null : `${n + 10}`, inboundCode: `IN-${name}`, deliveryNo: 'DL-ORIGINAL', outboundNo: 'OUT-ORIGINAL', + returnSources: [{ outboundPalletItemId: `${n + 20}`, inventoryPositionId: `${n + 30}`, maxReturnPackages: n ? 3 : 4, returnCartonId: null }] })) + let returned = false, originalSigned = false, newSigned = false, departed = false, picked = false, recordVersion = 0, stage = 'BEFORE_LOADING', review = false, arranged = false, created = false, nextCreated = false, nextCancelled = false + let vehiclePlate, vehicleOwner = 'TR-NEW', driverOwner = 'TR-NEW', closed = false, vehicleReleased = false, driverReleased = false, tripVersion = 0 + const rootDone = name => name === 'A' ? newSigned || fault === 'root-premature-A' && originalSigned : originalSigned + const tail = name => ({ id: masterIds[name], orderNo: `LM-${name}`, deliveryOrderNo: 'DL-ORIGINAL', outboundOrderNo: 'OUT-ORIGINAL', orderType: 'FIRST_LEG', + sourceInboundId: name === 'A' ? '801' : '802', sourceOrderId: name === 'A' ? '701' : '702', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: `IN-${name}`, packageCount: name === 'A' ? 4 : 3, + subOrderStatus: name === 'A' ? newSigned || fault === 'premature-A' && originalSigned ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' : originalSigned && fault !== 'blocked-B' ? 'DELIVERY:DELIVERED' : 'DELIVERY:WAIT_DELIVERY' }) + const signedQuantity = (kind, fallback) => controls[kind]?.override ? controls[kind].value : fallback + const order = name => name === 'DL-NEW' ? { deliveryNo: name, customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, dispatchNo: 'DP-NEW', outboundNo: 'OUT-NEW', status: newSigned ? 'DELIVERED' : departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2, + signedPackages: newSigned ? signedQuantity('newQuantity', 2) : null, signedAt: newSigned ? '2026-10-19T04:00:00Z' : null, version: 0 } + : { deliveryNo: 'DL-ORIGINAL', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, dispatchNo: 'DP-ORIGINAL', outboundNo: 'OUT-ORIGINAL', status: originalSigned ? 'DELIVERED' : controls.waitDelivery ? 'WAIT_DELIVERY' : 'DELIVERING', packageCount: 7, + signedPackages: originalSigned ? signedQuantity('originalQuantity', 5) : null, signedAt: originalSigned ? newSigned && fault === 'rewritten-original' ? 'changed' : '2026-10-19T02:00:00Z' : null, version: controls.deliveryVersion ?? 0 } + const counts = empty => ({ itemCount: empty ? 0 : 1, palletCount: empty ? 0 : 1, packageCount: empty ? 0 : 2, pickedPackageCount: empty ? 0 : picked ? 2 : 0, shippedPackageCount: empty ? 0 : departed ? 2 : 0 }) + const destination = { key: 'FBA:ONT8', type: 'FBA', warehouseCode: 'ONT8' } + const stops = [{ index: 0, name: 'Actual Origin', destinationKey: 'ORIGIN:92' }, { index: 1, name: 'ONT8', destinationKey: 'FBA:ONT8' }] + const nativeItem = () => ({ dispatchItemId: '501', customerId: CUSTOMER, sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), sourceType: 'RETURN_RECEIPT', planPalletId: null, returnReceiptId: '300', warehousePalletId: '100', palletNo: context.palletCode, + inboundCode: 'IN-A', destination, scheduledPackages: 2, pickedPackages: picked ? 2 : 0, positions: [{ positionId: '201', availablePackages: picked ? 0 : 2 }] }) + const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage, + reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked, + vehicleInfo: { vehicle: { vehicleId: '601' }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] }) + const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 }) + const outbound = () => ({ customerId: CUSTOMER, outboundNo: 'OUT-NEW', status: departed ? 'OUTBOUNDED' : 'WAIT_OUTBOUND', packageCount: 2 }) + const command = (action, result = {}, next = false) => ({ ...detail(next), action, result, replayed: false }) + const tripDetail = next => ({ trip: { tripNo: next ? 'TR-NEXT' : 'TR-NEW', warehouseId: WAREHOUSE, vehicleId: '601', driverId: '701', vehiclePlate, tripPurpose: 'DELIVERY', recordVersion: tripVersion, status: closed && !next ? 'COMPLETED' : 'PLANNED', + completedAt: closed && !next ? '2026-10-19T05:00:00Z' : null, vehicleRelease: vehicleReleased && !next ? { endLocation: 'ONT8' } : null, driverRelease: driverReleased && !next ? { endLocation: 'ONT8' } : null, stops }, dispatches: [{ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW' }] }) + const stockLedger = () => returned ? [{ id: '901', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: 'IN-A', palletNo: context.palletCode, businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-NEW', outboundOrderNo: 'OUT-ORIGINAL', deltaPackages: 2 }] : [] + const envelope = (data, code = '200', message = '') => ({ http: 200, body: { code, message, data: copy(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: CUSTOMER, warehouseId: WAREHOUSE, tripNo: trip.tripNo, + departure: { dispatchNo: trip.dispatchNo, action: 'DEPART', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: '2026-10-19T01:00:00Z', deliveries: [{ customerId: CUSTOMER, deliveryNo: 'DL-ORIGINAL' }] } } } }, + assert(_m, label, _r, passed) { if (!passed) throw Error(label) }, addStep(_m, label, _r, passed) { if (!passed) throw Error(label) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, + async checkpoint() { checkpoints.push(copy(trip.returnRedispatch)) }, + async requestMultipart(method, path, file) { + requests.push({ method, path, file: file.name }) + if (path.includes('/fleet/drivers/701/license')) return envelope({ licenseFileId: '801', recordVersion: 1 }) + return envelope({ fileId: '99' }) }, - async prepareFleetTrip() { return { tripNo: 'TR-NEW' } }, async confirmFleetLoading() { return 1 }, async pickAll() { return true }, - async finishFleetTrip() { return { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, - async saveReceiptAndComplete(_ctx, delivery) { if (delivery.deliveryNo === 'DL-NEW') newSigned = true; else originalSigned = true; return { order: order(delivery.deliveryNo) } }, - } - const input = { customerId: 'C', warehouseId: 'W', pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] } - return { context, trip, input, dependencies, requests, checkpoints, relations, masterIds } + async request(method, raw, body, token) { + requests.push({ method, path: raw, body: copy(body), token }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, DISPATCH), ledgerKey = `${method}:${normalized}:${body?.idempotencyKey}` + if (body?.idempotencyKey && ledger.has(ledgerKey) && !path.endsWith('/resources/release')) { const prior = ledger.get(ledgerKey); assert.deepEqual(body, prior.body); return envelope({ ...copy(prior.data), replayed: true }) } + const remember = data => { if (body?.idempotencyKey) ledger.set(ledgerKey, { body: copy(body), data: copy(data) }); return envelope(data) } + if (path === '/api/platform/inbounds') { const name = params.get('inboundCode').slice(-1), delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned && fault !== 'domestic-blocked-B'; return envelope({ records: [{ inboundCode: `IN-${name}`, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) } + if (path.startsWith('/api/platform/v1/orders/first-leg/')) { + const orderId = path.split('/').at(-1), name = orderId === '701' ? 'A' : 'B', done = rootDone(name) + if (['shared-root', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending'].includes(fault)) { + const isRoot = orderId === '700', complete = isRoot ? newSigned || fault === 'root-premature-with-pending' : done || name === 'A' && fault === 'child-premature-A' + return envelope({ order: { id: orderId, orderNo: isRoot ? 'FIRST-ROOT' : `FIRST-${name}`, customerId: CUSTOMER, tenantId: CUSTOMER, orderRole: isRoot ? 'MASTER' : 'CHILD', + rootOrderId: fault === 'borrowed-child' && name === 'B' && !isRoot ? '999' : '700', parentOrderId: '700', status: complete ? 'COMPLETED' : 'PROCESSING' }, + statusHistory: isRoot && complete ? [{ action: 'OVERSEAS_DELIVERED' }, { action: 'FULFILLMENT_COMPLETED' }] : [] }) + } + return envelope({ order: { id: orderId, orderNo: `FIRST-${name}`, customerId: CUSTOMER, tenantId: CUSTOMER, orderRole: 'MASTER', status: done ? 'COMPLETED' : 'PROCESSING' }, statusHistory: done ? [{ action: 'OVERSEAS_DELIVERED' }, { action: 'FULFILLMENT_COMPLETED' }] : [] }) + } + if (path.endsWith('/fulfillment-tree')) { const orderId = path.split('/').at(-2); return envelope({ parentOrderId: orderId, parentOrderNo: orderId === '700' ? 'FIRST-ROOT' : orderId === '701' ? 'FIRST-A' : 'FIRST-B', + children: orderId === '700' ? [{ orderId: '701', orderNo: 'FIRST-A' }, { orderId: '702', orderNo: 'FIRST-B' }] : [{ orderId, orderNo: orderId === '701' ? 'FIRST-A' : 'FIRST-B' }] }) } + if (path.startsWith(`${BASE}/orders/`)) { const name = Object.keys(masterIds).find(name => masterIds[name] === path.split('/').at(-1)); assert.ok(name); return envelope({ order: tail(name) }) } + if (path.endsWith('/return-inbound-options')) return envelope(fault === 'missing-B' ? relations.slice(0, 1) : relations) + if (path.endsWith('/mark-return')) { assert.equal(body.lastMileOrderId, masterIds.A); assert.equal(Object.hasOwn(body, 'inboundOrderId'), false); assert.deepEqual(body.items, [{ outboundPalletItemId: '20', inventoryPositionId: '30', packageCount: 2 }]); return remember({ exceptionNo: 'EX-NEW', version: 0, replayed: false }) } + if (path.endsWith('/start-processing')) return remember({ status: 'PROCESSING', version: 1 }) + if (path.includes('/exception-orders/') && path.endsWith('/complete')) { returned = true; if (fault === 'unknown-return') throw Error('connection lost after commit'); return remember({ status: 'COMPLETED', restoredPackages: fault === 'wrong-return' ? 3 : 2, inventoryAdjustmentId: '88', version: 2, replayed: false }) } + if (path.endsWith('/exception-orders/EX-NEW')) return envelope({ order: { exceptionNo: 'EX-NEW', customerId: CUSTOMER, outboundNo: 'OUT-ORIGINAL' }, returnReceipts: [{ receiptNo: 'RSR-300', receivedPackages: 2 }] }) + if (path.endsWith('/pallets') && method === 'POST') { context.palletCode = body.palletCode; return envelope('100') } + if (path.endsWith('/pallets/100')) return envelope({ warehouseId: WAREHOUSE, locationId: '200', palletCode: context.palletCode }) + if (path.endsWith('/locations')) return envelope({ records: [{ id: '200', locationCode: 'L-1' }] }) + if (path.endsWith('/inventory/balances')) return envelope({ records: returned && !departed ? [{ id: '401', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE }] : [], total: returned && !departed ? 1 : 0 }) + if (path.includes('/inventory/balances/') && path.endsWith('/positions')) return envelope([{ id: '201', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: 'IN-A', palletId: '100', palletNo: context.palletCode, availablePackages: picked ? 0 : 2, pickedPackages: picked ? 2 : 0 }]) + if (path.endsWith('/inventory/ledger')) { const rows = stockLedger(); if (fault === 'duplicate-ledger' && returned) rows.push({ ...rows[0], id: '902' }); return envelope({ records: rows, total: rows.length }) } + if (path === `${DISPATCH}/candidates`) { assert.equal(params.get('warehouseId'), WAREHOUSE); return envelope({ complete: true, snapshotAt: '2026-10-19', candidates: [{ selection: { sourceType: 'RETURN_RECEIPT', returnReceiptId: '300', planPalletId: null, relabelNo: null, warehousePalletId: '100', sourceVersion: 'a'.repeat(64) }, + sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), customerId: CUSTOMER, warehouseId: WAREHOUSE, orderNo: 'LM-A', inboundCode: 'IN-A', palletNo: context.palletCode, destination, availablePackages: fault === 'wrong-candidate' ? 4 : 2, appointmentBatches: [], blockReasons: [] }] }) } + if (path === DISPATCH && method === 'POST') { if (!body.cargo.length) { nextCreated = true; vehicleOwner = 'TR-NEXT'; driverOwner = 'TR-NEXT'; return remember(command('CREATE', {}, true)) }; assert.equal(body.cargo[0].sourceType, 'RETURN_RECEIPT'); assert.equal(body.cargo[0].planPalletId, null); created = true; return remember(command('CREATE')) } + if (path === DISPATCH) return envelope({ records: [{ ...detail(params.get('dispatchNo') === 'DP-NEXT'), warehouseId: WAREHOUSE }] }) + if (path.endsWith('/master-data/vehicles')) { vehiclePlate = body.plateNumber; return envelope({ id: '601' }) } + if (path.endsWith('/fleet/drivers') && method === 'POST') return envelope({ id: '701', recordVersion: 0 }) + if (path.endsWith('/fleet/drivers/701/review')) return envelope({ reviewStatus: 'APPROVED' }) + if (path === `${BASE}/fleet/trips`) { const trips = nextCreated ? [tripDetail(false).trip, tripDetail(true).trip] : [tripDetail(false).trip]; return envelope({ records: trips, page: 1, size: 200, total: trips.length }) } + if (path.endsWith('/fleet/trips/TR-NEW/completion')) return envelope({ canComplete: true, outcomes: [{}], recordVersion: tripVersion }) + if (path.endsWith('/fleet/trips/TR-NEW/complete')) { closed = true; tripVersion++; return remember(tripDetail(false)) } + if (path.endsWith('/resource-occupancy')) return envelope([{ resourceId: params.get('resourceIds'), occupiedTripNo: params.get('kind') === 'VEHICLE' ? vehicleOwner : driverOwner }]) + if (path.endsWith('/resources/release')) { + const prior = ledger.get(ledgerKey) + if (!prior) { if (body.resourceKind === 'VEHICLE') { vehicleReleased = true; vehicleOwner = null } else { driverReleased = true; driverOwner = null }; tripVersion++; ledger.set(ledgerKey, { body: copy(body), data: null }) } + else assert.deepEqual(prior.body, body) + return envelope(tripDetail(false)) + } + if (path.endsWith('/fleet/trips/TR-NEW')) return envelope(tripDetail(false)) + if (path.endsWith('/fleet/trips/TR-NEXT')) return envelope(tripDetail(true)) + if (path.includes('/delivery-orders/') && path.endsWith('/documents')) { + const number = path.split('/delivery-orders/')[1].split('/')[0] + if (method === 'GET') return envelope({ version: 0, categories: [] }) + if (number === 'DL-NEW') newSigned = true; else originalSigned = true + return remember({ version: 1, categories: [{ category: 'RECEIPT', status: 'UPLOADED', fileCount: 1 }], replayed: false }) + } + if (path.endsWith('/status') && path.includes('/delivery-orders/')) { assert.equal(body.expectedVersion, controls.deliveryVersion ?? 0); controls.waitDelivery = false; return envelope({ deliveryStatus: 'DELIVERING' }) } + if (path.includes('/delivery-orders/')) return envelope({ order: order(path.split('/').at(-1)), operations: [{ operationNode: 'RECEIPT_AUTO_COMPLETE' }] }) + const next = normalized.startsWith(`${DISPATCH}/DP-NEXT`) + if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] }) + if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops }) + if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] }) + if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2, + ...(fault === 'missing-measurements' ? {} : { palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, deliveryIndex: 1, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] }) + if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] }) + if (method === 'GET' && normalized === `${DISPATCH}/${next ? 'DP-NEXT' : 'DP-NEW'}`) return envelope(detail(next)) + assert.ok(created, `${method} ${raw}`) + if (normalized.endsWith('/cancel')) { nextCancelled = true; vehicleOwner = null; driverOwner = null; return remember({ ...command('CANCEL', {}, true), status: 'CANCELLED' }) } + if (method === 'PUT' && normalized.endsWith('/DP-NEW')) { if (picked) return envelope(null, 'A0409', '已扫码不能编辑'); assert.equal(body.recordVersion, recordVersion); recordVersion++; return remember(command('EDIT')) } + if (normalized.endsWith('/pick') && body.recordVersion !== recordVersion) return envelope(null, 'A0409', '公共版本已变化') + assert.equal(body.recordVersion, recordVersion, raw) + if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) } + if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) } + if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'AFTER_LOADING'; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + if (normalized.endsWith('/transfer-delivery')) { assert.equal(stage, 'AFTER_LOADING'); arranged = true; recordVersion++; return remember(command('ARRANGE_DELIVERY', { dispatchNo: 'DP-NEW', cargoHash: body.cargoHash, outbounds: [outbound()], deliveries: [ref()] })) } + if (normalized.endsWith('/depart')) { assert.equal(arranged, true); departed = true; stage = 'DELIVERING'; recordVersion++; return remember(command('DEPART', { departedAt: '2026-10-19T03:00:00Z', shippedPackages: 2, outbounds: [outbound()], deliveries: [ref()] })) } + throw Error(`Unexpected ${method} ${raw}`) + } } + const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } } + const input = { customerId: CUSTOMER, warehouseId: WAREHOUSE, pickerToken: 'private-pda-token', dispatchNos: ['DP-ORIGINAL'], deliveryNos: ['DL-ORIGINAL'] } + return { context, trip, input, dependencies, requests, checkpoints, relations, masterIds, controls } } const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) -for (const signedPackages of [undefined, null, '5', -1, 1.5]) test(`unknown or invalid original signed quantity ${signedPackages} stops before redispatch`, async () => { - const f = fixture(), save = f.dependencies.saveReceiptAndComplete - f.dependencies.saveReceiptAndComplete = async (...args) => { - const result = await save(...args) - result.order.signedPackages = signedPackages - return result - } - f.dependencies.prepareFleetTrip = () => assert.fail('invalid signed quantity must stop before preparing redispatch') - await assert.rejects(run(f), /stored quantity/) - assert.equal(f.trip.returnRedispatch.originalSignatures.length, 0) -}) - -for (const signedPackages of [undefined, null, '2', 0, 3]) test(`redispatch requires actual numeric two signed boxes: ${signedPackages}`, async () => { - const f = fixture(), save = f.dependencies.saveReceiptAndComplete - f.dependencies.saveReceiptAndComplete = async (...args) => { - const result = await save(...args) - if (args[1].deliveryNo === 'DL-NEW') result.order.signedPackages = signedPackages - return result - } - f.dependencies.finishFleetTrip = () => assert.fail('invalid new signature must not release trip resources') - await assert.rejects(run(f), /two boxes actually signed/) -}) - -test('mark-return uses the selected master ID even without an ordinary inbound relation', async () => { - const f = fixture('no-inbound') - assert.ok(f.relations.every(row => row.inboundOrderId === null)) - assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') - const writes = f.requests.filter(row => row.path.includes('/mark-return')) - assert.equal(writes.length, 1) - assert.equal(writes[0].body.lastMileOrderId, f.masterIds.A) - assert.equal(Object.hasOwn(writes[0].body, 'inboundOrderId'), false) -}) - -for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'foreign-customer', 'foreign-warehouse', 'borrowed-master']) { - test(`return source identity ${fault} stops before mark-return and stock writes`, async () => { - const f = fixture(), request = f.context.request.bind(f.context) - if (fault === 'missing-master') delete f.relations[0].lastMileOrderId - if (fault === 'wrong-delivery') f.relations[0].deliveryNo = 'DL-OTHER' - if (fault === 'wrong-outbound') f.relations[0].outboundNo = 'OUT-OTHER' - if (fault === 'foreign-customer') f.relations[0].customerId = 'OTHER' - if (fault === 'foreign-warehouse') f.relations[0].overseasWarehouseId = 'OTHER' - if (fault === 'borrowed-master') f.context.request = async (...args) => { - const response = await request(...args) - if (args[1].includes(`/orders/${f.masterIds.A}`)) response.body.data.order.id = f.masterIds.B - return response - } - await assert.rejects(run(f), /real master ID|match this candidate/) - assert.ok(f.requests.every(row => row.method === 'GET')) - assert.equal(f.trip.returnRedispatch.exceptionNo, undefined) - }) -} - -for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) { - test('two-box return never borrows an inventory source from another master or a different source type', async () => { - const f = fixture(); mutate(f.relations[0].returnSources[0]) - await assert.rejects(run(f), /Selected master must contain/) - assert.ok(f.requests.every(row => row.method === 'GET')) - }) -} - -test('reviewed candidate recovery retains DELIVERING and never repeats original delivery start', async () => { - const f = fixture(); f.input.sourceSelectionRecovery = true - assert.equal((await run(f)).status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') - assert.ok(!f.requests.some(row => row.path?.endsWith('/status?customerId=C'))) -}) - -test('reviewed source recovery refuses WAIT_DELIVERY before any delivery-start or return command', async () => { - const f = fixture(); f.input.sourceSelectionRecovery = true - const request = f.context.request - f.context.request = async (...args) => { - const r = await request(...args) - if (r.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') r.body.data.order.status = 'WAIT_DELIVERY' - return r - } - await assert.rejects(run(f), /never start it again/) - assert.ok(f.requests.every(row => row.method === 'GET')) -}) - -test('fresh return/redispatch chain proves A pending, B complete, then A complete without claiming unique source events', async () => { +test('native one-main multi-source return redispatch executes real APIs through new signature and resource closure', async () => { const f = fixture(), result = await run(f) - assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') - assert.equal(result.sourceEventsVerified, false) - assert.equal(result.readOnlyEvidenceRequired.length, 3) - assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY') - assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED') - assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED') - assert.equal(result.afterRedispatch.departed.shippedPackages, 2) - assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')) - assert.ok(f.checkpoints.some(row => row.requests.at(-1)?.status === 'SENDING' && row.requests.at(-1)?.file?.sha256)) - const uploads = f.trip.returnRedispatch.requests.filter(row => row.file) - assert.ok(uploads.length > 0) - assert.ok(uploads.every(row => Number.isSafeInteger(row.file.sizeBytes) && row.file.sizeBytes > 0 && !Object.hasOwn(row.file, 'bytes'))) + assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3) + assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY'); assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED') + assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED'); assert.equal(result.afterRedispatch.departed.action, 'DEPART'); assert.equal(result.afterRedispatch.departed.result.shippedPackages, 2) + assert.equal(result.inventory.map(s => s.ledger.length).every((n, i) => n === (i === 0 ? 0 : 1)), true) + assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')); assert.ok(f.checkpoints.some(p => p.requests.at(-1)?.status === 'SENDING' && p.requests.at(-1)?.file?.sha256)) + assert.ok(f.requests.findIndex(r => r.path.endsWith('/loading-complete')) < f.requests.findIndex(r => r.path.endsWith('/transfer-delivery'))) + assert.equal(f.requests.some(r => r.path.includes('/pre-arrange')), false) }) -for (const missing of [false, true]) test(`unapproved or missing pre-signature gate cannot sign or close new trip; missing=${missing}`, async () => { - const f = fixture('audit-gate') - if (missing) delete f.dependencies.onBeforeRedispatchSignature - const original = f.dependencies.saveReceiptAndComplete - f.dependencies.saveReceiptAndComplete = async (...args) => { assert.notEqual(args[1].deliveryNo, 'DL-NEW'); return original(...args) } - f.dependencies.finishFleetTrip = () => assert.fail('new trip must not close before audited signature') - await assert.rejects(run(f), missing ? /audit gate is required/ : /Audit gate not approved/) - assert.equal(f.trip.returnRedispatch.preSignatureGate.status, 'WAITING') - assert.equal(f.trip.returnRedispatch.afterRedispatch, undefined) +for (const signedPackages of [undefined, null, '5', -1, 1.5]) test(`invalid original signature quantity ${signedPackages} stops before new Create`, async () => { + const f = fixture(); f.controls.originalQuantity = { override: true, value: signedPackages }; await assert.rejects(run(f), /stored quantity/) + assert.equal(f.trip.returnRedispatch.originalSignatures.length, 0); assert.equal(f.requests.some(r => r.path === DISPATCH && r.method === 'POST'), false) +}) +for (const signedPackages of [undefined, null, '2', 0, 3]) test(`new signature must contain numeric exactly two boxes: ${signedPackages}`, async () => { + const f = fixture(); f.controls.newQuantity = { override: true, value: signedPackages }; await assert.rejects(run(f), /two boxes actually signed/) + assert.equal(f.requests.some(r => r.path.endsWith('/TR-NEW/complete')), false) +}) + +test('actual selected master works without ordinary inbound relationship and uses immutable return receipt selector', async () => { + const f = fixture('no-inbound'); assert.equal((await run(f)).returnReceiptId, '300'); assert.ok(f.relations.every(r => r.inboundOrderId == null)) + assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 2) + const create = f.requests.find(r => r.path === DISPATCH && r.body.cargo.length); assert.equal(create.body.cargo[0].sourceType, 'RETURN_RECEIPT'); assert.equal(create.body.cargo[0].planPalletId, null) +}) +for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'foreign-customer', 'foreign-warehouse', 'borrowed-master']) test(`reject return provenance ${fault} before all writes`, async () => { + const f = fixture(), request = f.context.request + if (fault === 'missing-master') delete f.relations[0].lastMileOrderId + if (fault === 'wrong-delivery') f.relations[0].deliveryNo = 'DL-OTHER' + if (fault === 'wrong-outbound') f.relations[0].outboundNo = 'OUT-OTHER' + if (fault === 'foreign-customer') f.relations[0].customerId = '999' + if (fault === 'foreign-warehouse') f.relations[0].overseasWarehouseId = '999' + if (fault === 'borrowed-master') f.context.request = async (...args) => { const response = await request(...args); if (args[1].includes(`/orders/${f.masterIds.A}`)) response.body.data.order.id = f.masterIds.B; return response } + await assert.rejects(run(f), /real master ID|match this candidate/); assert.ok(f.requests.every(r => r.method === 'GET')) +}) +for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) test('ordinary two-box return cannot borrow a source or native carton', async () => { + const f = fixture(); mutate(f.relations[0].returnSources[0]); await assert.rejects(run(f), /Selected master must contain/); assert.ok(f.requests.every(r => r.method === 'GET')) +}) +for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'missing-measurements', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => { + const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') + if (fault === 'missing-B') assert.ok(f.requests.every(r => r.method === 'GET')) + if (fault === 'missing-measurements') assert.equal(f.requests.some(r => r.path.endsWith('/loading-complete') || r.path.endsWith('/transfer-delivery')), false) +}) +test('a shared formal root stays pending for A while child B completes and finishes only after new A signature', async () => { + const f = fixture('shared-root'), proof = await run(f) + assert.equal(proof.rootsBeforeRedispatch[0].root.order.status, 'PROCESSING') + assert.equal(proof.rootsBeforeRedispatch[0].children.find(c => c.id === '701').status, 'PROCESSING') + assert.equal(proof.rootsBeforeRedispatch[0].children.find(c => c.id === '702').status, 'COMPLETED') + assert.ok(proof.rootsAfterRedispatch.every(s => s.root.order.status === 'COMPLETED' && s.children.every(c => c.status === 'COMPLETED'))) +}) +for (const missing of [false, true]) test(`unapproved or missing readonly audit prevents new signature and closure; missing=${missing}`, async () => { + const f = fixture('audit-gate'); if (missing) delete f.dependencies.onBeforeRedispatchSignature + await assert.rejects(run(f), missing ? /audit gate is required/ : /Audit gate not approved/); assert.equal(f.trip.returnRedispatch.preSignatureGate.status, 'WAITING') + assert.equal(f.requests.some(r => r.method === 'PUT' && r.path.includes('/DL-NEW/documents')), false); assert.equal(f.requests.some(r => r.path.endsWith('/TR-NEW/complete')), false) +}) +test('core producer, pick and successful signature cannot be externally substituted', async () => { + for (const name of ['prepareFleetTrip', 'confirmFleetLoading', 'pickAll', 'saveReceiptAndComplete', 'finishFleetTrip']) { const f = fixture(); f.dependencies[name] = async () => ({ success: true }); await assert.rejects(run(f), /cannot be replaced/); assert.equal(f.requests.length, 0) } +}) +test('native owned departure guard rejects old status and wrong original whole before HTTP', async () => { + const f = fixture(); f.context.createdResources.fleetAcceptance.departure.status = 'IN_TRANSIT'; await assert.rejects(run(f), /owned departure/); assert.equal(f.requests.length, 0) + const other = fixture(); other.input.dispatchNos = ['BORROWED']; await assert.rejects(run(other), /owned departure/); assert.equal(other.requests.length, 0) +}) +test('reviewed source selection retains started deliveries and refuses to repeat WAIT_DELIVERY start', async () => { + const f = fixture(); f.input.sourceSelectionRecovery = true; await run(f); assert.equal(f.requests.some(r => r.path.includes('/status')), false) + const waiting = fixture(); waiting.input.sourceSelectionRecovery = true; waiting.controls.waitDelivery = true; await assert.rejects(run(waiting), /never start it again/); assert.ok(waiting.requests.every(r => r.method === 'GET')) +}) +test('fresh WAIT_DELIVERY start preserves exact server Long version including zero', async () => { + for (const version of [0, '9223372036854775807']) { const f = fixture(); f.controls.waitDelivery = true; f.controls.deliveryVersion = version; await run(f); const started = f.requests.find(r => r.path.includes('/status')); assert.equal(started.body.expectedVersion, version) } +}) +test('fixture source A/B selection refuses borrowed or partial shipments', () => { + const row = name => ({ tail: { orderType: 'FIRST_LEG', sourceInboundId: name, inboundCode: name, packageCount: 4 }, relation: { inboundCode: name, returnSources: [{ maxReturnPackages: 3 }] } }) + assert.throws(() => selectReturnSources([row('A'), row('B')]), /cover each selected source completely/) +}) +test('write-ahead failure sends no HTTP and unknown return preserves original key without restart', async () => { + const disk = fixture(); disk.context.checkpoint = async () => { throw Error('disk full') }; await assert.rejects(run(disk), /disk full/); assert.equal(disk.requests.length, 0) + const f = fixture('unknown-return'); await assert.rejects(run(f), /connection lost/); const last = f.checkpoints.at(-1).requests.at(-1); assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey) + const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) +for (const [name, response] of Object.entries({ http0: { http: 0, body: null }, server500: { http: 500, body: { code: 'B0001' }, requestId: 'r', traceId: 't' }, proxy502: { http: 502, body: null }, malformed200: { http: 200, body: { unexpected: true }, requestId: 'r', traceId: 't' }, htmlFailure: { http: 403, body: 'blocked', requestId: 'r', traceId: 't' }, uncorrelatedSuccess: { http: 200, body: { code: '200', data: {} } } })) test(`ambiguous ${name} stops without automatic replay`, async () => { + const f = fixture(), request = f.context.request; f.context.request = async (...args) => { if (args[1].includes('/mark-return')) { f.requests.push({ method: args[0], path: args[1], body: copy(args[2]) }); return response }; return request(...args) } + await assert.rejects(run(f), /outcome unknown/); const last = f.checkpoints.at(-1).requests.at(-1); assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey) + assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 1); const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) +}) +test('unknown multipart writes retain file hash and stop before return stock receipt', async () => { + const f = fixture(); f.context.requestMultipart = async (method, path, file) => { f.requests.push({ method, path, file: file.name }); return { http: 0, body: null } } + await assert.rejects(run(f), /outcome unknown/); const entry = f.checkpoints.at(-1).requests.at(-1); assert.equal(entry.status, 'UNKNOWN_RESULT'); assert.match(entry.file.sha256, /^[a-f0-9]{64}$/) + assert.equal(f.requests.some(r => r.path.includes('/exception-orders/') && r.path.includes('/complete')), false) +}) +test('correlated business conflict is retained as a definite rejection without retry', async () => { + const f = fixture(), request = f.context.request; f.context.request = async (...args) => { if (args[1].includes('/mark-return')) { f.requests.push({ method: args[0], path: args[1], body: copy(args[2]) }); return { http: 409, body: { code: 'A0409' }, requestId: 'r', traceId: 't' } }; return request(...args) } + await assert.rejects(run(f), /HTTP failed/); assert.equal(f.checkpoints.at(-1).requests.at(-1).status, 'RESPONDED'); assert.equal(f.requests.filter(r => r.path.includes('/mark-return')).length, 1) }) for (const fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test(`file gate requires exact checkpoint and hashed source audit: ${fault}`, async () => { @@ -206,97 +249,3 @@ for (const fault of ['none', 'nonce', 'counts', 'hash', 'trip', 'timeout']) test rmSync(root, { recursive: true, force: true }) } }) -for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'wrong-candidate', 'rewritten-original']) { - test(`rejects ${fault} instead of declaring redispatch complete`, async () => { - const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') - if (fault === 'missing-B') assert.ok(f.requests.every(row => row.method === 'GET')) - }) -} -test('unknown return outcome persists the exact original key and cannot automatically restart', async () => { - const f = fixture('unknown-return'); await assert.rejects(run(f), /connection lost/) - const last = f.checkpoints.at(-1).requests.at(-1) - assert.equal(last.status, 'UNKNOWN_RESULT'); assert.ok(last.body.idempotencyKey) - const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) -}) -test('pre-send checkpoint failure cannot send even the first HTTP request', async () => { - const f = fixture(); f.context.checkpoint = async () => { throw Error('disk full') } - await assert.rejects(run(f), /disk full/); assert.equal(f.requests.length, 0) -}) -test('fixture selection refuses partial coverage or borrowed source shipments', () => { - const row = name => ({ tail: { orderType: 'FIRST_LEG', sourceInboundId: name, inboundCode: name, packageCount: 4 }, - relation: { inboundCode: name, returnSources: [{ maxReturnPackages: 3 }] } }) - assert.throws(() => selectReturnSources([row('A'), row('B')]), /cover each selected source completely/) -}) - -test('WAIT_DELIVERY start submits exactly the version read from the current delivery', async () => { - const f = fixture(), original = f.context.request.bind(f.context) - let started = false - f.context.request = async (method, path, body, ...args) => { - if (method === 'PUT' && path.includes('/delivery-orders/DL-ORIGINAL/status')) { - assert.equal(body.expectedVersion, 17); assert.equal(body.status, 'DELIVERING'); started = true - f.requests.push({ method, path, body }) - return { http: 200, body: { code: '200', data: { deliveryStatus: 'DELIVERING' } }, requestId: 'r', traceId: 't' } - } - const response = await original(method, path, body, ...args) - if (!started && response.body?.data?.order?.deliveryNo === 'DL-ORIGINAL') Object.assign(response.body.data.order, { status: 'WAIT_DELIVERY', version: '17' }) - if (path.includes('/return-inbound-options')) assert.equal(started, true) - return response - } - await run(f) - assert.equal(f.requests.filter(row => row.method === 'PUT' && row.path.includes('/status')).length, 1) -}) - -for (const multipart of [false, true]) test(`http:0 records UNKNOWN_RESULT and stops without retry; multipart=${multipart}`, async () => { - const f = fixture(), original = f.context.request.bind(f.context) - if (multipart) f.context.requestMultipart = async (method, path, file) => { - f.requests.push({ method, path, file: file.name }); return { http: 0, body: null, requestId: null, traceId: null } - } - else f.context.request = async (method, path, body, ...args) => { - if (path.includes('/exception-orders/') && path.includes('/complete')) { - f.requests.push({ method, path, body: structuredClone(body) }); return { http: 0, body: null, requestId: null, traceId: null } - } - return original(method, path, body, ...args) - } - await assert.rejects(run(f), /transport outcome unknown/) - const last = f.checkpoints.at(-1).requests.at(-1) - assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, 0) - if (multipart) assert.match(last.file.sha256, /^[a-f0-9]{64}$/) - else assert.ok(last.body.idempotencyKey) - assert.equal(f.requests.filter(row => row.path === last.path && row.method === last.method).length, 1) - const count = f.requests.length - await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) -}) - -for (const [name, response] of Object.entries({ - server500: { http: 500, body: { code: 'B0001', message: 'Internal error' }, requestId: 'r', traceId: 't' }, - proxy502: { http: 502, body: null, requestId: null, traceId: null }, - malformed200: { http: 200, body: { unexpected: true }, requestId: 'r', traceId: 't' }, - htmlFailure: { http: 403, body: 'proxy blocked', requestId: 'r', traceId: 't' }, - uncorrelatedSuccess: { http: 200, body: { code: '200', data: { status: 'COMPLETED' } }, requestId: null, traceId: null }, -})) test(`ambiguous ${name} persists UNKNOWN_RESULT without replay or further requests`, async () => { - const f = fixture(), original = f.context.request.bind(f.context) - f.context.request = async (method, path, body, ...args) => { - if (path.includes('/mark-return')) { f.requests.push({ method, path, body: structuredClone(body) }); return response } - return original(method, path, body, ...args) - } - await assert.rejects(run(f), /outcome unknown/) - const last = f.checkpoints.at(-1).requests.at(-1) - assert.equal(last.status, 'UNKNOWN_RESULT'); assert.equal(last.http, response.http) - assert.ok(last.body.idempotencyKey) - assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1) - assert.equal(f.requests.at(-1).path, last.path) - const count = f.requests.length; await assert.rejects(run(f), /no automatic restart/); assert.equal(f.requests.length, count) -}) - -test('correlated explicit business rejection remains RESPONDED and also stops without replay', async () => { - const f = fixture(), original = f.context.request.bind(f.context) - f.context.request = async (method, path, body, ...args) => { - if (path.includes('/mark-return')) { - f.requests.push({ method, path, body }); return { http: 409, body: { code: 'A0409', message: 'version conflict' }, requestId: 'r', traceId: 't' } - } - return original(method, path, body, ...args) - } - await assert.rejects(run(f), /HTTP failed/) - assert.equal(f.checkpoints.at(-1).requests.at(-1).status, 'RESPONDED') - assert.equal(f.requests.filter(row => row.path.includes('/mark-return')).length, 1) -}) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index ddfafe976..953e9a8d3 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -3,10 +3,11 @@ import {join,resolve} from 'node:path' import {redactEvidence} from '../fba-core-regression-test-env.mjs' import { randomUUID, createHash } from 'node:crypto' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' -import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './phase5-overseas-fulfillment.mjs' +import { version, createBody, candidatePage, readDispatch, replay as replayCommand } from './grouped-dispatch-acceptance.mjs' const BASE = '/api/platform/v1/last-leg' -const id = value => value == null ? '' : String(value) +const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Unsafe server ID; retain Long string'); return value == null ? '' : String(value) } const key = () => `redispatch-${randomUUID()}` const scoped = (path, customerId) => `${path}?${new URLSearchParams({ customerId })}` const ok = response => response?.http === 200 && String(response?.body?.code) === '200' @@ -36,10 +37,14 @@ export function selectReturnSources(rows) { /** Owned departure only; reviewed source-selection recovery preserves prior starts. Requests are checkpointed before dispatch. */ export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) { const run = context.createdResources?.fleetAcceptance + if (Object.keys(dependencies).some(name => !['wait', 'onBeforeRedispatchSignature'].includes(name))) throw Error('Only readonly audit and wait observers may be supplied; native production, picking and signature cannot be replaced') + const departure = run?.departure if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker - || run.tripNo !== trip.tripNo || run.departure?.status !== 'IN_TRANSIT' || !input?.pickerToken + || run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'DEPART' + || !departure.result?.departedAt || !input?.pickerToken || id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId) - || JSON.stringify((run.departure.dispatches || []).map(row => row.dispatchNo).sort()) !== JSON.stringify([...(input.dispatchNos || [])].sort()) + || JSON.stringify([departure.dispatchNo]) !== JSON.stringify([...(input.dispatchNos || [])].sort()) + || JSON.stringify((departure.result.deliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].sort()) || !input.deliveryNos?.length || trip.returnRedispatch || typeof context.checkpoint !== 'function') throw Error('Redispatch requires a new owned departure, current PDA session and durable checkpoints; no automatic restart') const proof = trip.returnRedispatch = { marker: context.marker, status: 'CHECKING', requests: [], returnPackages: 2, @@ -47,15 +52,18 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, 'After redispatch signature and exact replay: A and B each have exactly one OVERSEAS_DELIVERY_COMPLETED outbox event by customer+sourceInboundId; inspect delivery ACK and domestic inbound completion separately', 'Join return receipt ledger_id and source_outbound_no to immutable RETURN_STOCK_IN; join new picks by return_receipt_id; original shipment and signed facts remain unchanged'] } const check = (label, condition) => { if (!condition) throw Error(label) } + let stopped = false const io = Object.create(context) io.assert = (...args) => { context.assert(...args); if (!args[3]) throw Error(args[1]) } async function invoke(method, path, body, token, file, options) { + if (stopped) throw Error('Unknown prior outcome; preserve original command and stop') const entry = { method, path, body: copy(body), actor: token ? 'PDA' : 'PLATFORM', status: 'SENDING' } if (file) entry.file = { name: file.name, type: file.type, sizeBytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } proof.requests.push(entry); await context.checkpoint() try { const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options) if (!definiteResponse(response)) { + stopped = true; proof.outcomeUnknown = true Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId }) await context.checkpoint() @@ -66,16 +74,17 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, await context.checkpoint() check(`Missing HTTP correlation: ${method} ${path}`, response?.requestId && response?.traceId) return response - } catch (error) { if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; await context.checkpoint() } throw error } + } catch (error) { if (entry.status === 'SENDING') { stopped = true; proof.outcomeUnknown = true; entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; await context.checkpoint() } throw error } } io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options) io.requestMultipart = (method, path, file) => invoke(method, path, undefined, undefined, file) async function request(method, path, body, token) { const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data } - const deps = { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, pickAll, saveReceiptAndComplete, - onBeforeRedispatchSignature: context.context?.onBeforeRedispatchSignature, - wait: ms => new Promise(resolve => setTimeout(resolve, ms)), ...dependencies } + const deps = { onBeforeRedispatchSignature: dependencies.onBeforeRedispatchSignature || context.context?.onBeforeRedispatchSignature, + wait: dependencies.wait || (ms => new Promise(resolve => setTimeout(resolve, ms))) } + const native = { command: (method, path, body, label) => request(method, path, body), check: (label, response, passed) => check(label, passed) } + const replay = async (path, body, first, label) => { const again = await replayCommand(native, path, body, first, label); check(label, again.replayed === true); return again } const customerId = id(input.customerId), warehouseId = id(input.warehouseId) const tail = relation => request('GET', scoped(`${BASE}/orders/${relation.lastMileOrderId}`, customerId)) const delivery = number => request('GET', scoped(`${BASE}/delivery-orders/${encodeURIComponent(number)}`, customerId)) @@ -95,19 +104,52 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, } throw Error(allSigned ? 'Domestic source completion has not converged after redispatch signature' : 'Independent source B domestic completion did not converge') } + async function roots(a, b, beforeSignature) { + const snapshots = [] + const known = value => ['DRAFT', 'SUBMITTED', 'APPROVED', 'ACCEPTED', 'REJECTED', 'CANCELLED', 'PROCESSING', 'COMPLETED'].includes(value) + for (const [name, selected] of [['A', a], ['B', b]]) { + const sourceId = id(selected.tail.sourceOrderId) + check('Original source order provenance is required for root and child guards', /^[1-9]\d*$/.test(sourceId)) + const rawPath = order => `/api/platform/v1/orders/first-leg/${encodeURIComponent(order)}?${new URLSearchParams({ tenantId: customerId })}` + const source = await request('GET', rawPath(sourceId)), order = source?.order + const rootId = order?.orderRole === 'MASTER' ? id(order.id) : id(order?.rootOrderId) + check('Original source and fulfillment root belong to current customer', id(order?.id) === sourceId && id(order.customerId) === customerId && id(order.tenantId) === customerId + && known(order.status) && /^[1-9]\d*$/.test(rootId) && (order.orderRole === 'MASTER' || order.orderRole === 'CHILD' && id(order.parentOrderId) === rootId)) + const root = rootId === sourceId ? source : await request('GET', rawPath(rootId)) + const tree = await request('GET', `/api/platform/v1/orders/${rootId}/fulfillment-tree?${new URLSearchParams({ tenantId: customerId })}`) + check('Formal root and explicit child list remain customer-owned', id(root.order?.id) === rootId && root.order.orderRole === 'MASTER' && id(root.order.customerId) === customerId && id(root.order.tenantId) === customerId && known(root.order.status) + && id(tree.parentOrderId) === rootId && tree.parentOrderNo === root.order.orderNo && Array.isArray(tree.children) && Array.isArray(root.statusHistory)) + const children = [] + for (const row of tree.children.filter(row => id(row.orderId) !== rootId)) { + const child = await request('GET', rawPath(id(row.orderId))) + check('Every formal child stays linked to the actual root', id(child.order?.id) === id(row.orderId) && child.order.orderRole === 'CHILD' + && id(child.order.rootOrderId) === rootId && id(child.order.parentOrderId) === rootId && id(child.order.customerId) === customerId && id(child.order.tenantId) === customerId && known(child.order.status) && child.order.orderNo === row.orderNo) + children.push(child.order) + } + check('Original source is present in its real formal root tree', sourceId === rootId || children.some(child => id(child.id) === sourceId)) + const pending = children.some(child => !['COMPLETED', 'CANCELLED'].includes(child.status)) + const completed = root.statusHistory.some(event => event.action === 'OVERSEAS_DELIVERED') && !pending + check('Root cannot complete ahead of valid children or overseas delivery fact', completed + ? root.order.status === 'COMPLETED' && root.statusHistory.some(event => event.action === 'FULFILLMENT_COMPLETED') + : root.order.status !== 'COMPLETED' && !root.statusHistory.some(event => event.action === 'FULFILLMENT_COMPLETED')) + if (beforeSignature && name === 'A') check('Incomplete A child cannot be completed before redispatch signature', order.status !== 'COMPLETED') + snapshots.push({ source: name, sourceOrder: order, root, tree, children }) + } + return snapshots + } try { const rows = [] proof.originalDeliveries = [] for (const number of [...new Set(input.deliveryNos)]) { const current = await delivery(number) - check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo)) + check('Fixture deliveries must be unsigned and from the supplied current dispatches', !current.order.signedAt && input.dispatchNos.includes(current.order.dispatchNo) + && id(current.order.customerId) === customerId && id(current.order.overseasWarehouseId) === warehouseId) proof.originalDeliveries.push(copy(current.order)) if (input.sourceSelectionRecovery) check('Reviewed source recovery must retain the already started delivery; never start it again', current.order.status === 'DELIVERING') if (current.order.status === 'WAIT_DELIVERY') { - check('Delivery status mutation requires the current version', Number.isSafeInteger(Number(current.order.version)) - && current.order.version != null && String(current.order.version).trim() !== '' && Number(current.order.version) >= 0) + version(current.order.version) const started = await request('PUT', scoped(`${BASE}/delivery-orders/${number}/status`, customerId), { - status: 'DELIVERING', expectedVersion: Number(current.order.version), remark: `${context.marker} current-run delivery underway`, + status: 'DELIVERING', expectedVersion: version(current.order.version), remark: `${context.marker} current-run delivery underway`, }) check('Return candidates are opened only after formal delivery start', started.deliveryStatus === 'DELIVERING') } @@ -132,10 +174,13 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, && /^[1-9][0-9]*$/.test(id(row.inventoryPositionId)) && row.returnCartonId == null && Number.isSafeInteger(Number(row.maxReturnPackages)) && Number(row.maxReturnPackages) >= 2) check('Selected master must contain the actual ordinary stock source for the two returned boxes', Boolean(source)) - const marked = await request('POST', scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId), { + const markPath = scoped(`${BASE}/delivery-orders/${a.delivery.deliveryNo}/mark-return`, customerId) + proof.markReturnRequest = { lastMileOrderId: id(a.relation.lastMileOrderId), reason: `${context.marker} two boxes refused before signature`, returnDirection: 'OVERSEAS_WAREHOUSE', returnLogisticsNo: `${context.marker}-RET`, items: [{ outboundPalletItemId: source.outboundPalletItemId, inventoryPositionId: source.inventoryPositionId, packageCount: 2 }], idempotencyKey: key(), - }) + } + const marked = await request('POST', markPath, proof.markReturnRequest) + await replay(markPath, proof.markReturnRequest, marked, 'Mark-return original command replay') proof.exceptionNo = marked.exceptionNo const exceptionPath = `${BASE}/exception-orders/${encodeURIComponent(marked.exceptionNo)}` const resource = `/api/platform/v1/overseas-warehouse-resources/${warehouseId}` @@ -145,6 +190,34 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, const locationPage = await request('GET', `${resource}/locations?page=1&size=200`) const location = (Array.isArray(locationPage) ? locationPage : locationPage.records || []).find(row => id(row.id) === id(pallet.locationId)) check('New return pallet has a real same-warehouse location', id(pallet.warehouseId) === warehouseId && location?.locationCode && pallet.palletCode === palletCode) + const paged = async path => { + const rows = []; let total + for (let page = 1; ; page++) { + const current = await request('GET', `${path}&page=${page}&size=200`) + check('Inventory pagination must remain complete and stable', Array.isArray(current?.records) && Number.isSafeInteger(Number(current.total)) && Number(current.total) >= 0 + && (total === undefined || total === Number(current.total)) && (current.records.length > 0 || rows.length === Number(current.total))) + total ??= Number(current.total); rows.push(...current.records); if (rows.length >= total) { check('Inventory pagination cannot omit or duplicate rows', rows.length === total && new Set(rows.map(row => id(row.id))).size === rows.length); return rows } + } + } + const inventory = async label => { + const filters = new URLSearchParams({ customerId, overseasWarehouseId: warehouseId, inboundCode: a.relation.inboundCode }) + const balances = await paged(`${BASE}/inventory/balances?${filters}`), positions = [] + for (const balance of balances) { + check('Returned balance belongs to original customer and warehouse', id(balance.customerId) === customerId && id(balance.overseasWarehouseId) === warehouseId) + const rows = await request('GET', `${BASE}/inventory/balances/${id(balance.id)}/positions?${new URLSearchParams({ customerId, palletNo: palletCode })}`) + check('Actual return positions are explicit rows', Array.isArray(rows)) + for (const row of rows.filter(row => id(row.palletId) === palletId)) { + check('Returned stock identity stays within original customer warehouse and inbound', id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode) + positions.push(row) + } + } + const ledger = await paged(`${BASE}/inventory/ledger?${filters}&${new URLSearchParams({ businessType: 'RETURN_STOCK_IN', businessOrderNo: marked.exceptionNo, outboundOrderNo: a.delivery.outboundNo, palletNo: palletCode })}`) + check('Return stock-in ledger is immutable original-shipment evidence', ledger.every(row => id(row.customerId) === customerId && id(row.overseasWarehouseId) === warehouseId + && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode && row.businessType === 'RETURN_STOCK_IN' && row.businessOrderNo === marked.exceptionNo && row.outboundOrderNo === a.delivery.outboundNo)) + const result = { label, positions, ledger, available: positions.reduce((n, row) => n + Number(row.availablePackages), 0), picked: positions.reduce((n, row) => n + Number(row.pickedPackages), 0) } + proof.inventory ||= []; proof.inventory.push(copy(result)); return result + } + const empty = await inventory('BEFORE_RETURN'); check('Fresh target pallet contains no previous return stock', empty.available === 0 && empty.picked === 0 && empty.ledger.length === 0) const started = await request('POST', scoped(`${exceptionPath}/start-processing`, customerId), { expectedVersion: marked.version, idempotencyKey: key(), handlingAction: '实际退回2箱' }) const uploaded = await io.requestMultipart('POST', scoped(`${exceptionPath}/evidence`, customerId), { name: `${context.marker}-return.txt`, type: 'text/plain', content: new TextEncoder().encode(`${context.marker} synthetic evidence of 2 returned boxes`), @@ -155,13 +228,20 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, targetPalletId: palletId, targetPalletNo: palletCode, targetLocationId: location.id, targetLocationCode: location.locationCode }] } const restored = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) check('Exactly two boxes restored', restored.status === 'COMPLETED' && Number(restored.restoredPackages) === 2) - const replay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) - check('Same return key replays the same adjustment', replay.replayed === true && id(replay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId)) + const stockIn = await inventory('RETURN_STOCK_IN') + check('Actual two returned boxes produce exactly one stock-in ledger', stockIn.available === 2 && stockIn.picked === 0 && stockIn.ledger.length === 1 && Number(stockIn.ledger[0].deltaPackages) === 2) + const restoredReplay = await request('POST', scoped(`${exceptionPath}/complete`, customerId), receipt) + check('Same return key replays the same adjustment', restoredReplay.replayed === true && id(restoredReplay.inventoryAdjustmentId) === id(restored.inventoryAdjustmentId)) + const stockReplay = await inventory('RETURN_REPLAY') + check('Return replay cannot duplicate stock or ledger', stockReplay.available === stockIn.available && stockReplay.picked === stockIn.picked && JSON.stringify(stockReplay.ledger) === JSON.stringify(stockIn.ledger) && JSON.stringify(stockReplay.positions) === JSON.stringify(stockIn.positions)) + const history = await request('GET', scoped(exceptionPath, customerId)) + check('Owned exception exposes the actual unique returned receipt business number', history?.order?.exceptionNo === marked.exceptionNo && id(history.order.customerId) === customerId + && history.order.outboundNo === a.delivery.outboundNo && history.returnReceipts?.length === 1 && history.returnReceipts[0].receiptNo && Number(history.returnReceipts[0].receivedPackages) === 2) proof.returned = { palletId, palletCode, adjustmentId: restored.inventoryAdjustmentId } proof.originalSignatures = [] for (const [index, number] of input.deliveryNos.entries()) { const current = await delivery(number) - const signed = await deps.saveReceiptAndComplete(io, current.order, customerId, 171 + index) + const signed = await saveReceiptAndComplete(io, current.order, customerId, 171 + index) check('Original remaining cargo actually signed with a stored quantity', signed?.order?.signedAt && Number.isSafeInteger(signed.order.signedPackages) && signed.order.signedPackages >= 0) proof.originalSignatures.push(copy(signed.order)) @@ -170,35 +250,42 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, check('A remains incomplete while independently complete B is delivered', !beforeA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && beforeB.order.subOrderStatus?.includes('DELIVERY:DELIVERED')) proof.beforeRedispatch = { A: beforeA, B: beforeB } proof.domesticBeforeRedispatch = await domesticBoundary(a, b, false) - const candidates = await request('GET', `${BASE}/dispatches/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: a.relation.inboundCode })}`) - const matches = candidates.filter(row => row.sourceType === 'RETURN_RECEIPT' && id(row.warehousePalletId) === palletId && row.returnReceiptNo && id(row.orderId) === id(a.relation.lastMileOrderId)) - check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].packageCount) === 2 && !matches[0].planPalletId) - const selected = matches[0], draftToken = key() - const container = await request('POST', `${BASE}/dispatches/drafts/containers`, { draftToken, capacity: 30 }) - await request('POST', `${BASE}/dispatches/draft-locks/lock`, { sourceType: 'RETURN_RECEIPT', returnReceiptId: selected.returnReceiptId, draftToken, draftContainerId: container.id }) - const arrange = { planPalletIds: [], returnReceiptIds: [selected.returnReceiptId], draftToken } - const arranged = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange) - check('Redispatch is a new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo) && Number(arranged.packageCount) === 2) - const arrangedReplay = await request('POST', `${BASE}/dispatches/pre-arrange`, arrange) - check('Draft replay keeps the same redispatch', arrangedReplay.dispatchNo === arranged.dispatchNo) - const newTrip = await deps.prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId) - proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = selected.returnReceiptNo; proof.redispatchNo = arranged.dispatchNo - const tripDetail = await request('GET', `${BASE}/fleet/trips/${newTrip.tripNo}`) - const transfer = await request('POST', `${BASE}/dispatches/${arranged.dispatchNo}/transfer-delivery`, { - idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), deliveryMethod: 'TRUCK_PALLET', - pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', - vehiclePlate: tripDetail.trip.vehiclePlate, driverName: tripDetail.trip.driverName, driverPhone: tripDetail.trip.driverPhone, appointmentAccount: 'SELF', - fees: [{ feeType: 'DELIVERY_FEE', feeName: '退回重派验收费用', direction: 'RECEIVABLE', currency: 'CNY', amount: 1 }], remark: context.marker, - }) - check('Redispatch produces a new single destination delivery', transfer.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo)) - check('Fresh return stock can be picked via PDA', await deps.pickAll(io, input.pickerToken, arranged.dispatchNo)) - const recordVersion = await deps.confirmFleetLoading(io, module, newTrip) - const departBody = { recordVersion, idempotencyKey: key(), remark: '退回2箱重新实际发车' } + proof.rootsBeforeRedispatch = await roots(a, b, true) + const candidates = await candidatePage(native, warehouseId, { orderNo: a.relation.inboundCode }) + const matches = candidates.filter(row => row.selection?.sourceType === 'RETURN_RECEIPT' && id(row.selection.warehousePalletId) === palletId + && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId && row.orderNo === a.tail.orderNo && row.inboundCode === a.relation.inboundCode && row.palletNo === palletCode) + check('Exactly one fresh receipt candidate contains only the returned two boxes', matches.length === 1 && Number(matches[0].availablePackages) === 2 && matches[0].selection.planPalletId == null) + const selected = matches[0] + proof.createRequest = createBody(context.marker, 'RETURN-REDISPATCH', [selected]) + const arranged = await request('POST', `${BASE}/dispatches`, proof.createRequest) + check('Redispatch is a native new execution document containing only two boxes', arranged.dispatchNo && !input.dispatchNos.includes(arranged.dispatchNo) + && arranged.action === 'CREATE' && arranged.status === 'ACTIVE' && arranged.stage === 'BEFORE_LOADING' && Number(arranged.counts?.packageCount) === 2) + await replay(`${BASE}/dispatches`, proof.createRequest, arranged, 'Native Create original-key replay keeps the same redispatch') + const actual = await readDispatch(native, arranged.dispatchNo) + check('Created redispatch readback preserves exact returned receipt customer and stock identity', actual.warehouseId === warehouseId && actual.items.items.length === 1 + && actual.items.items[0].sourceKey === selected.sourceKey && id(actual.items.items[0].returnReceiptId) === id(selected.selection.returnReceiptId) + && id(actual.items.items[0].warehousePalletId) === palletId && id(actual.items.items[0].customerId) === customerId && Number(actual.items.items[0].scheduledPackages) === 2) + const newTrip = await prepareFleetTrip(io, module, arranged.dispatchNo, warehouseId) + proof.newTripNo = newTrip.tripNo; proof.returnReceiptNo = history.returnReceipts[0].receiptNo; proof.returnReceiptId = selected.selection.returnReceiptId; proof.redispatchNo = arranged.dispatchNo + check('Fresh return stock can be picked via PDA', await pickAll(io, input.pickerToken, arranged.dispatchNo)) + const picked = await inventory('RETURN_PICKED') + check('Actual returned stock is picked once without rewriting the stock-in ledger', picked.available === 0 && picked.picked === 2 && JSON.stringify(picked.ledger) === JSON.stringify(stockIn.ledger)) + await confirmFleetLoading(io, module, newTrip, input.pickerToken, input.actualMeasurements) + const transfer = await arrangeDispatchDelivery(io, arranged.dispatchNo) + check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo) + && id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2) + const current = await request('GET', `${BASE}/dispatches/${encodeURIComponent(arranged.dispatchNo)}`) + check('New whole is loaded and fully arranged before actual departure', current.status === 'ACTIVE' && current.stage === 'AFTER_LOADING' && Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 0) + const departBody = { recordVersion: version(current.recordVersion), idempotencyKey: key(), remark: '退回2箱重新实际发车' } const departPath = `/api/pda/v1/last-leg/dispatches/${arranged.dispatchNo}/depart` const departed = await request('POST', departPath, departBody, input.pickerToken) - check('New DEPART ships exactly two returned boxes', departed.tripNo === newTrip.tripNo && Number(departed.shippedPackages) === 2) + check('New DEPART ships exactly two returned boxes', departed.dispatchNo === arranged.dispatchNo && departed.action === 'DEPART' && departed.status === 'ACTIVE' + && departed.stage === 'DELIVERING' && departed.result?.departedAt && Number(departed.result.shippedPackages) === 2 + && departed.result.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === transfer.deliveries[0].deliveryNo) const departedAgain = await request('POST', departPath, departBody, input.pickerToken) - check('Departure replay does not duplicate shipping', departedAgain.replayed === true && Number(departedAgain.shippedPackages) === 2) + const facts = value => { const result = copy(value); delete result.replayed; return JSON.stringify(result) } + check('Departure replay does not duplicate shipping', departedAgain.replayed === true && facts(departedAgain) === facts(departed)) + const shipped = await inventory('RETURN_DEPARTED'); check('Returned stock ships once and retains the original immutable stock-in ledger', shipped.available === 0 && shipped.picked === 0 && JSON.stringify(shipped.ledger) === JSON.stringify(stockIn.ledger)) const newDelivery = await delivery(transfer.deliveries[0].deliveryNo) proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo, departed, originalSignatureCount: proof.originalSignatures.length } @@ -209,7 +296,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, && audit.sourceBEventCount === 1 && /^[a-f0-9]{64}$/.test(audit.auditEvidenceSha256 || '')) Object.assign(proof.preSignatureGate, { status: 'VERIFIED', audit }) await context.checkpoint() - const newSignature = await deps.saveReceiptAndComplete(io, newDelivery.order, customerId, 271) + const newSignature = await saveReceiptAndComplete(io, newDelivery.order, customerId, 271) check('Redispatched two boxes actually signed', newSignature?.order?.signedAt && newSignature.order.signedPackages === 2) const afterA = await tail(a.relation), afterB = await tail(b.relation) check('A completes only after redispatch; B remains complete', afterA.order.subOrderStatus?.includes('DELIVERY:DELIVERED') && afterB.order.subOrderStatus === beforeB.order.subOrderStatus) @@ -219,7 +306,8 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, } proof.afterRedispatch = { A: afterA, B: afterB, newSignature, departed } proof.domesticAfterRedispatch = await domesticBoundary(a, b, true) - proof.closure = await deps.finishFleetTrip(io, module, newTrip) + proof.rootsAfterRedispatch = await roots(a, b, false) + proof.closure = await finishFleetTrip(io, module, newTrip) check('New trip cargo and resource lifecycle completes', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') proof.status = 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'; proof.sourceEventsVerified = false await context.checkpoint(); return proof diff --git a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs index ab09210bf..d60342863 100644 --- a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance-contract.test.mjs @@ -5,10 +5,10 @@ import { verifyFleetTripCosts, expectedAllocations, TRIP_COST_EXPECTATIONS } fro const copy = value => value === undefined ? undefined : structuredClone(value) const prefix = 'platform:fleet:trip:' function fixture(fault, multiDestination = false) { - const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT' }, calls = [], rows = [], histories = new Map(), keys = new Map() + const trip = { marker: 'fixture-vehicle', tripNo: 'FT-CURRENT', dispatchNo: 'DP-OWN' }, calls = [], rows = [], histories = new Map(), keys = new Map() const shares = [ { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, warehousePalletIds: ['P1'] }, - { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] }, + { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', sourceType: 'EXTERNAL', palletSpaces: 1, warehousePalletIds: ['P2'] }, ] const inputs = { shares, listOnlyToken: 'offline-reader', deniedToken: 'offline-denied' } const depart = { recordVersion: 7, action: 'DEPART', snapshot: { @@ -17,8 +17,8 @@ function fixture(fault, multiDestination = false) { confirmedLoad: { manifestHash: 'original-manifest', pallets: [ { warehousePalletId: 'P1', palletSpaces: 2, deliveryIndex: 1 }, { warehousePalletId: 'P2', palletSpaces: 1, deliveryIndex: 1 }], cargo: [{ warehousePalletId: 'P1', dispatchNo: 'DP-OWN', customerId: 'C1', packages: 5, destinationKey: 'FBA:A' }, - { warehousePalletId: 'P2', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] }, - externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', customerId: 'C2', status: 'BOUND', + { warehousePalletId: 'P2', dispatchNo: 'DP-OWN', customerId: 'C2', packages: 9, destinationKey: 'FBA:A' }] }, + externalBookings: [{ bookingNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', customerId: 'C2', status: 'BOUND', receipt: { source: { receivedPackages: 9, pallets: [{ warehousePalletId: 'P2', packages: 9 }] } } }] } } const load = depart.snapshot.confirmedLoad if (multiDestination) { @@ -57,7 +57,7 @@ function fixture(fault, multiDestination = false) { allocations: fault === 'invented allocation' ? [{ businessNo: 'DP-OWN', amount: amount / 100 }] : [] } return { currency, directCost: amount / 100, ownCost: own / 100, externalCost: external / 100, allocations: [ { businessNo: 'DP-OWN', dispatchNo: 'DP-OWN', sourceType: 'OWN', palletSpaces: 2, amount: own / 100 }, - { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-EXTERNAL', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL', + { businessNo: 'BK-EXTERNAL', dispatchNo: 'DP-OWN', sourceType: fault === 'external counted own' ? 'OWN' : 'EXTERNAL', palletSpaces: 1, amount: (external + (fault === 'lost cent' ? 1 : 0)) / 100 }] } }) } } @@ -71,7 +71,8 @@ function fixture(fault, multiDestination = false) { } const context = { marker: 'CURRENT-RUN', token: 'offline-writer', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'CURRENT-RUN', tripNo: trip.tripNo, - departure: { tripNo: trip.tripNo, status: 'IN_TRANSIT', departedAt: depart.snapshot.trip.departedAt } } }, + departure: { dispatchNo: trip.dispatchNo, status: 'ACTIVE', stage: 'DELIVERING', action: 'DEPART', + result: { departedAt: depart.snapshot.trip.departedAt } } } }, assert(_module, label, _response, condition) { assert.ok(condition, label) }, async checkpoint() {}, async request(method, path, body, token) { calls.push({ method, path, body: copy(body), actor: token }) @@ -178,6 +179,23 @@ test('missing current-run membership or permission session fails before HTTP', a } }) +test('cost execution requires the current single-dispatch native departure, without a legacy Fleet result alias', async () => { + for (const mutate of [ + f => { f.context.createdResources.fleetAcceptance.departure = { tripNo: f.trip.tripNo, status: 'IN_TRANSIT', departedAt: '2026-10-03T01:00:00Z' } }, + f => { f.context.createdResources.fleetAcceptance.departure.dispatchNo = 'OTHER' }, + f => { f.context.createdResources.fleetAcceptance.departure.stage = 'AFTER_LOADING' }, + f => { f.context.createdResources.fleetAcceptance.departure.result.departedAt = null }, + f => { f.inputs.shares[1].dispatchNo = 'SECOND-DISPATCH' }, + ]) { + const f = fixture(); mutate(f) + await assert.rejects(verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)) + assert.equal(f.calls.length, 0) + } + const f = fixture() + f.context.createdResources.fleetAcceptance.departure.result.departedAt = '2026-10-03T01:00:00' + assert.equal((await verifyFleetTripCosts(f.context, 'fleet', f.trip, f.inputs)).status, 'VERIFIED') +}) + test('unknown HTTP outcome retains original key and does not retry or hide failure', async () => { const f = fixture(), original = f.context.request f.context.request = async (...args) => { diff --git a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs index 6d22fa726..65d2c91ba 100644 --- a/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs @@ -12,6 +12,8 @@ const cents = value => { if (!match) throw new Error('成本金额不是非负两位小数') return BigInt(match[1]) * 100n + BigInt((match[2] || '').padEnd(2, '0')) } +const departureMillis = value => typeof value === 'string' && value + ? Date.parse(/(?:Z|[+-]\d\d:\d\d)$/i.test(value) ? value : `${value}Z`) : NaN export const TRIP_COST_EXPECTATIONS = Object.freeze({ initialCny: '100.01', finalCny: '100.03', usd: '13.07', rows: 2, history: { CNY: ['CREATE', 'UPDATE'], USD: ['CREATE'] }, allocationMethod: 'SAME_ROUTE_PALLET_SPACES' }) @@ -38,11 +40,13 @@ export function expectedAllocations(amount, shares) { export async function verifyFleetTripCosts(context, module, tripProof, inputs = {}) { const run = context.createdResources?.fleetAcceptance, shares = inputs.shares if (!context.marker || !context.createdResources?.fleetTrips?.includes(tripProof) || !tripProof?.tripNo || !tripProof.marker - || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || run.departure?.tripNo !== tripProof.tripNo - || run.departure.status !== 'IN_TRANSIT' || !run.departure.departedAt || tripProof.costAcceptance + || run?.marker !== context.marker || run.tripNo !== tripProof.tripNo || !tripProof.dispatchNo + || run.departure?.dispatchNo !== tripProof.dispatchNo || run.departure.action !== 'DEPART' + || run.departure.status !== 'ACTIVE' || run.departure.stage !== 'DELIVERING' + || !Number.isFinite(departureMillis(run.departure.result?.departedAt)) || tripProof.costAcceptance || !inputs.listOnlyToken || !inputs.deniedToken || inputs.listOnlyToken === inputs.deniedToken || !Array.isArray(shares) || !shares.length || new Set(shares.map(row => row.businessNo)).size !== shares.length - || shares.some(row => !row.businessNo || !row.dispatchNo || !['OWN', 'EXTERNAL'].includes(row.sourceType) + || shares.some(row => !row.businessNo || row.dispatchNo !== tripProof.dispatchNo || !['OWN', 'EXTERNAL'].includes(row.sourceType) || !Number.isSafeInteger(row.palletSpaces) || row.palletSpaces < 1 || !Array.isArray(row.warehousePalletIds) || !row.warehousePalletIds.length || row.warehousePalletIds.some(id => !id))) throw new Error('车次成本验收须提供本轮唯一真实DEPART车次、独立托位预期及两类权限会话;禁止借旧样本或重复开始') @@ -113,7 +117,8 @@ export async function verifyFleetTripCosts(context, module, tripProof, inputs = && history.records.filter(row => row.action === 'DEPART').length === 1) departure = history.records.find(row => row.action === 'DEPART'); proof.departure = clone(departure) const snapshot = departure.snapshot, load = snapshot?.confirmedLoad - check('发车实物与本轮车次绑定', snapshot?.trip?.tripNo === tripProof.tripNo && snapshot.trip.departedAt === run.departure.departedAt + check('发车实物与本轮车次绑定', snapshot?.trip?.tripNo === tripProof.tripNo + && departureMillis(snapshot.trip.departedAt) === departureMillis(run.departure.result.departedAt) && load?.manifestHash && load.pallets?.length > 0 && load.cargo?.length > 0) const ids = shares.flatMap(row => row.warehousePalletIds).map(String) check('独立预期覆盖全部实物且没有重叠托盘', new Set(ids).size === ids.length diff --git a/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs index 18334ea93..488c27349 100644 --- a/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-finance-acceptance-contract.test.mjs @@ -9,7 +9,7 @@ test('default fulfillment runs finance verification using its own departed trip const source = readFileSync(new URL('./phase5-overseas-fulfillment.mjs', import.meta.url), 'utf8') assert.match(source, /import \{ verifyFleetTripFinance \} from '\.\/fleet-trip-finance-acceptance\.mjs'/) assert.match(source, /verifyFleetTripFinance\(context, MODULE, \{ tripNo: tripProof\.tripNo, dispatchNos: \[dispatchNo\]/) - assert.match(source, /businesses: deliveryNos\.map\(deliveryNo => \(\{ customerId, deliveryNo \}\)\)/) + assert.match(source, /businesses: deliveries\.map\(row => \(\{ customerId: id\(row\.customerId\), deliveryNo: row\.deliveryNo \}\)\)/) }) function fixture(status = 'AVAILABLE', received = null) { return { tripNo: scope.tripNo, dispatchNos: [...scope.dispatchNos], receiptsVisible: true, @@ -42,6 +42,19 @@ test('currencies are reconciled separately without floating point addition', () value.finance.totals[0].receivableAmount = '100.23' assert.throws(() => validateTripFinance(value, scope), /原币/) }) + +test('one dispatch retains each delivery customer in whole-trip finance attribution', () => { + const value = fixture() + const second = { customerId: '9007199254740994', deliveryNo: 'DL-SECOND' } + const mixed = { ...scope, businesses: [...scope.businesses, second] } + value.finance.sources.push({ ...value.finance.sources[0], customerId: second.customerId, + businessNo: second.deliveryNo, sourceNo: 'FS-SECOND', amount: '20.07' }) + value.finance.totals[0].receivableAmount = '120.10' + assert.equal(validateTripFinance(value, mixed), true) + assert.throws(() => validateTripFinance(value, scope), /其他客户或派送/) + value.finance.sources[1].customerId = scope.businesses[0].customerId + assert.throws(() => validateTripFinance(value, mixed), /其他客户或派送/) +}) test('pending allocation is explicitly unknown and never promoted to complete or zero', () => { const value = fixture('BILLED') value.finance.documents = [{ billNo: 'MIXED-BILL', currency: 'USD', receivedAmount: null, diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 667c33827..145c61597 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -2,272 +2,382 @@ import test from 'node:test' import assert from 'node:assert/strict' import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs' -const stops = [{ name: 'Origin' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] -const cargo = (customerId = 'A') => ({ dispatchNo: `DP-${customerId}`, customerId, warehousePalletId: '9007199254740993', palletNo: 'PLT-1', destinationKey: 'FBA:ONT8', destinationLabel: 'ONT8' }) +const D = '/api/platform/v1/last-leg/dispatches/DP-A' +const P = '/api/pda/v1/last-leg/dispatches/DP-A' +const F = '/api/platform/v1/last-leg/fleet/trips/TR-A' +const stops = [{ name: '真实起运仓' }, { name: 'ONT8', destinationKey: 'FBA:ONT8' }] +const VID = '9007199254740993', DRIVER = '9007199254740995', PALLET = '9007199254740997' +const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) } }) +const conflict = message => ({ http: 200, body: { code: 'A0409', message } }) +const evidence = (field, value) => ({ field, value, actual: true, sourceType: 'TEST_PRODUCER_MEASUREMENT', sourceBusinessNo: 'RECEIPT-A', observedAt: '2026-10-18T10:00:00Z' }) +const pallet = () => ({ warehousePalletId: PALLET, palletNo: 'PLT-A', scheduledPackages: 3, pickedPackages: 3, + palletSpaces: null, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1, missingFields: ['palletSpaces'], + evidence: [evidence('weightKg', '187.25'), evidence('volumeCbm', '1.375')] }) +const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo: 'DP-A', pallets: [ + { warehousePalletId: PALLET, palletSpaces: 2, deliveryIndex: 1, evidence: [evidence('palletSpaces', 2)] }, +] }) -test('test loading measures physical pallet once across customers and rejects conflicting unloading points', () => { - assert.deepEqual(controlledPalletMeasurements([cargo(), cargo('B')], stops), [ - { warehousePalletId: '9007199254740993', palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex: 1 }, - ]) - assert.throws(() => controlledPalletMeasurements([{ ...cargo(), warehousePalletId: null }], stops), /缺少真实托盘/) - assert.throws(() => controlledPalletMeasurements([cargo()], [{ name: 'Origin' }]), /不在车次路线/) - assert.throws(() => controlledPalletMeasurements([cargo(), { ...cargo('B'), destinationKey: 'FBA:LAX9' }], [...stops, { destinationKey: 'FBA:LAX9' }]), /须先拆托/) -}) - -test('fixture prepares actual qualification upload and review before assignment and copies authoritative destination keys', async () => { - const calls = [] - let version = 0 - const context = { - marker: 'TEST-FLEET', createdResources: {}, - apiSuccess(_module, _label, response) { return response.body.data }, - async request(method, path, body) { - calls.push({ method, path, body }) - let data - if (path.endsWith('/master-data/vehicles')) { - assert.equal(body.status, 'IN_SERVICE', 'VehicleCreate requires an explicit status and trip assignment requires an in-service vehicle') - data = { id: 'vehicle-id' } - } - else if (path.endsWith('/drivers')) data = { id: 'driver-id', recordVersion: 0 } - else if (path.includes('/dispatches/') && path.endsWith('/review')) data = { trip: { recordVersion: version++ }, dispatches: [{ dispatchNo: 'DP-A', review: { status: 'APPROVED' } }] } - else if (path.endsWith('/review')) data = { reviewStatus: 'APPROVED' } - else if (path.endsWith('/loading')) data = { cargo: [cargo()] } - else { data = { trip: { tripNo: 'TR-FIXTURE', recordVersion: version++, stops: body.stops || stops } } } - return { http: 200, body: { code: '200', data } } - }, - async requestMultipart(method, path, file) { - calls.push({ method, path, file }) - assert.equal(file.type, 'image/png'); assert.ok(file.content.byteLength > 0) - return { http: 200, body: { code: '200', data: { id: 'driver-id', recordVersion: 1, licenseFileId: 'file-id' } } } - }, +test('real Jackson long counts accept safe decimal strings without changing pallet IDs', () => { + const expected = controlledPalletMeasurements([pallet()], stops, receipt().pallets) + for (const counts of [{ scheduledPackages: '3', pickedPackages: '3' }, { scheduledPackages: '3', pickedPackages: 3 }]) { + const actual = controlledPalletMeasurements([{ ...pallet(), ...counts }], stops, receipt().pallets) + assert.deepEqual(actual, expected) + assert.equal(actual[0].warehousePalletId, PALLET) } - const proof = await prepareFleetTrip(context, 'test', 'DP-A', '92') - assert.equal(proof.tripNo, 'TR-FIXTURE') - assert.equal(proof.reassignment.status, 'PENDING') - assert.ok(calls.findIndex(call => call.path.includes('/license?')) < calls.findIndex(call => call.path.endsWith('/review'))) - assert.ok(calls.findIndex(call => call.path.endsWith('/review')) < calls.findIndex(call => call.path.endsWith('/assignment'))) - assert.equal(calls.find(call => call.path.endsWith('/plan')).body.stops[1].destinationKey, 'FBA:ONT8') - assert.equal(context.createdResources.fleetTrips[0].licenseFileId, 'file-id') - assert.ok(calls.every(call => !call.path.endsWith('/depart'))) + for (const value of [null, undefined, '', '03', '3.0', '3e0', true, -1, '9007199254740993', 9007199254740992]) { + assert.throws(() => controlledPalletMeasurements([{ ...pallet(), scheduledPackages: value }], stops, receipt().pallets), /箱数/) + assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: value }], stops, receipt().pallets), /箱数/) + } + assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: '2' }], stops, receipt().pallets), /核平/) }) -test('unready actual dispatch prevents load confirmation rather than bypassing it with test values', async () => { - const calls = [] - const context = { request: async (method, path) => { calls.push({ method, path }); return { http: 200, body: { code: '200', data: { blockReason: '拣货未完成' } } } }, - apiSuccess(_module, _name, response) { return response.body.data } } - await assert.rejects(confirmFleetLoading(context, 'test', { tripNo: 'TR-1', stops }), /整车未就绪/) - assert.equal(calls.length, 1) - assert.equal(calls[0].method, 'GET') -}) - -function reassignmentServer(fault = '') { - const proof = { tripNo: 'TR-FRESH', dispatchNo: 'DP-A', vehicleId: '101', driverId: '201', stops, - reassignment: { status: 'PENDING' } } - const trip = { tripNo: proof.tripNo, recordVersion: 4, vehicleId: '101', driverId: '201', vehiclePlate: 'ORIGINAL', - driverName: 'Original driver', driverPhone: '123', status: 'PLANNED', stops, maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } - const dispatch = { dispatchNo: proof.dispatchNo, overseasWarehouseId: '92', status: 'READY_TO_DEPART', - packageCount: 10, pickedPackageCount: 10, shippedPackageCount: 0, remainingPackageCount: 0, - estimatedArrivalAt: '2026-10-03T12:00:00', items: [{ id: '9007199254740995', inboundCode: 'IN-TEST', packageCount: 10 }], - vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone } - const calls = [], assertions = [], dispatches = [{ dispatchNo: proof.dispatchNo, customerId: 'A', review: { status: 'APPROVED' } }] - let load = null, currentCargo = [cargo()], assigned = false - const ok = data => ({ http: 200, body: { code: '200', data: fault === 'string-versions' - ? JSON.parse(JSON.stringify(data, (key, value) => key === 'recordVersion' ? String(value) : value)) : structuredClone(data) } }) - if (fault === 'string-versions') trip.recordVersion = 8 - const loading = () => ({ recordVersion: trip.recordVersion, manifestHash: 'a'.repeat(64), cargo: currentCargo, confirmedLoad: load, blockReason: null }) +function server(fault = '') { + const calls = [], assertions = [], saved = new Map(), loadingActors = new Map() + const detail = { dispatchNo: 'DP-A', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: '9007199254740998', + counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, + dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} } + const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops } + let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail + let mutatedItems = false, mutatedLoading = false + const claims = new Map([['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A']]) + const bump = () => detail.recordVersion = String(BigInt(detail.recordVersion) + 1n) + const result = (action, body, extra = {}) => ({ dispatchNo: 'DP-A', status: detail.status, stage: detail.stage, recordVersion: detail.recordVersion, + action, idempotencyKey: body.idempotencyKey, result: extra, replayed: false, counts: structuredClone(detail.counts) }) + const remember = (action, body, extra) => { const r = result(action, body, extra); saved.set(body.idempotencyKey, { body: structuredClone(body), result: structuredClone(r) }); return ok(r) } const context = { - marker: 'TEST-REASSIGN', + marker: 'TEST-NATIVE', createdResources: {}, + checkpoint: async () => { if (checkpointError) throw new Error('checkpoint failed') }, assert(_module, label, _response, passed) { assertions.push({ label, passed }) }, - apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null }, + apiSuccess(_module, _label, response) { return response?.body?.code === '200' ? response.body.data : null }, async requestMultipart(method, path, file) { - calls.push({ method, path }); assert.equal(file.type, 'image/png') - return ok({ licenseFileId: 'FILE-NEW', recordVersion: 1 }) + calls.push({ method, path, file: { name: file.name, size: file.content.byteLength } }) + assert.equal(file.type, 'image/png'); assert.match(file.name, /TEST-LICENSE/) + uploadComplete = true; driverVersion++ + return ok({ id: DRIVER, licenseFileId: '9007199254740999', recordVersion: driverVersion }) }, - async request(method, path, body) { - calls.push({ method, path, body: structuredClone(body) }) - if (path.endsWith('/loading')) { - if (method === 'GET') return ok(loading()) - if (String(body.recordVersion) !== String(trip.recordVersion) && fault !== 'accept-old-version') { - if (fault === 'reject-but-write') load = { manifestHash: body.manifestHash, pallets: body.pallets } - return { http: 200, body: { code: 'A0409', message: fault === 'unrelated-conflict' ? '权限未配置' : '车次已被修改,请刷新后重试' } } + async request(method, path, body, token) { + calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' }) + if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown') + if (path.endsWith('/master-data/vehicles')) { + assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber.toUpperCase() + return ok({ id: VID }) + } + if (path.endsWith('/fleet/drivers')) return ok({ id: DRIVER, recordVersion: 0 }) + if (path.endsWith(`/drivers/${DRIVER}/review`)) { + assert.ok(uploadComplete); assert.equal(body.recordVersion, driverVersion); driverReviewed = true + return ok({ id: DRIVER, reviewStatus: 'APPROVED', recordVersion: ++driverVersion }) + } + if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) { + const rows = fault === 'missing-trip' ? [] : [trip, ...(nextTrip ? [nextTrip] : [])] + if (fault === 'duplicate-trip') rows.push({ ...trip, tripNo: 'TR-B' }) + const wrong = { 'wrong-plate': { vehiclePlate: `${trip.vehiclePlate}-OTHER` }, + 'wrong-vehicle': { vehicleId: 'OTHER-VEHICLE' }, 'wrong-driver': { driverId: 'OTHER-DRIVER' }, + 'wrong-warehouse': { warehouseId: 'OTHER-WAREHOUSE' } }[fault] + if (wrong) rows[0] = { ...rows[0], ...wrong } + return ok({ records: rows, page: 1, size: 200, total: rows.length, pages: 1 }) + } + if (method === 'GET' && path === D) return ok(detail) + if (method === 'GET' && path === `${D.replace('DP-A', 'DP-NEXT')}`) return ok(nextDetail) + if (method === 'GET' && path === `${D.replace('DP-A', 'DP-NEXT')}/items`) return ok({ dispatchNo: 'DP-NEXT', recordVersion: nextDetail.recordVersion, items: [] }) + if (method === 'POST' && path === D.replace('/DP-A', '')) { + assert.deepEqual(body.cargo, []); assert.deepEqual(body.batchAllocations, []) + assert.equal(body.vehicle.vehicleId, VID); assert.equal(body.driver.driverId, DRIVER) + assert.equal(claims.size, 0) + if (fault === 'unknown-next-create') throw new Error('next create unknown') + nextTrip = { ...trip, tripNo: 'TR-NEXT', status: 'PLANNED', recordVersion: 0, vehicleRelease: null, driverRelease: null } + nextDetail = { dispatchNo: 'DP-NEXT', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 0, + counts: { itemCount: 0, palletCount: 0, packageCount: 0, pickedPackageCount: 0, shippedPackageCount: 0 } } + if (fault === 'new-empty-has-cargo') nextDetail.counts.itemCount = 1 + claims.set('VEHICLE', 'TR-NEXT'); claims.set('DRIVER', 'TR-NEXT') + return ok({ ...nextDetail, result: { dispatchNo: 'DP-NEXT' } }) + } + if (method === 'POST' && path === `${D.replace('DP-A', 'DP-NEXT')}/cancel`) { + assert.equal(body.recordVersion, nextDetail.recordVersion) + nextDetail.status = 'CANCELLED'; nextDetail.recordVersion++; claims.clear() + return ok(nextDetail) + } + if (method === 'GET' && path === `${D}/items`) return ok({ dispatchNo: 'DP-A', recordVersion: detail.recordVersion, items: [{ warehousePalletId: PALLET, pickedPackages: detail.counts.pickedPackageCount, customerId: mutatedItems ? 'OTHER-CUSTOMER' : 'CUSTOMER-A' }] }) + if (method === 'GET' && [D, P].some(prefix => path === `${prefix}/loading`)) { + return ok({ dispatchNo: 'DP-A', status: detail.status, stage: detail.stage, recordVersion: detail.recordVersion, pickingReconciled: detail.counts.pickedPackageCount === 3, + manifestHash: mutatedLoading ? 'CHANGED-MANIFEST' : 'REAL-MANIFEST', loadingComplete: { allowed: detail.counts.pickedPackageCount === 3, blockReason: '拣货未核平' }, + pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] }) + } + if (method === 'PUT' && path === D) { + if (detail.stage !== 'BEFORE_LOADING') { + if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount-- + return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派') } - load = { manifestHash: body.manifestHash, pallets: body.pallets, cargo: currentCargo } - if (assigned && fault === 'wrong-final-measurement') load.pallets = [{ ...body.pallets[0], weightKg: 200 }] - trip.recordVersion++; return ok(loading()) + assert.ok(driverReviewed); assert.equal(body.recordVersion, detail.recordVersion) + assert.equal(body.vehicle.mode, 'MASTER'); assert.equal(body.vehicle.vehicleId, VID) + assert.equal(body.driver.driverId, DRIVER); assert.ok(body.driver.evidence.expectedTaskEndAt) + detail.vehicleInfo = { vehicle: { mode: 'MASTER', vehicleId: VID, plate: nextPlate, carrierName: 'TEST自有车', capacity: { palletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } }, + driver: { mode: 'MASTER', driverId: DRIVER, name: 'TEST司机', phone: '19900000000' } } + Object.assign(trip, { vehicleId: VID, vehiclePlate: nextPlate, driverId: DRIVER }) + bump(); return remember('EDIT', body) } - if (path.endsWith('/master-data/vehicles')) { - assert.equal(body.status, 'IN_SERVICE'); assert.equal(body.maxLoadKg, trip.maxLoadKg) - return ok({ id: '102' }) + if (method === 'POST' && path === `${D}/return-to-review`) { + assert.equal(detail.counts.pickedPackageCount, 0); assert.equal(body.recordVersion, detail.recordVersion) + detail.stage = 'BEFORE_LOADING'; detail.reviewState = 'DRAFT'; bump(); return remember('RETURN_TO_REVIEW', body) } - if (path.endsWith('/drivers')) return ok({ id: '202', recordVersion: 0 }) - if (path.includes('/dispatches/') && path.endsWith('/review')) { - assert.equal(String(body.recordVersion), String(trip.recordVersion)); trip.recordVersion++ - dispatches[0].review = { status: 'APPROVED' }; return ok({ trip, dispatches }) + if (method === 'POST' && ['submit-review', 'dispatch-review', 'loading-review'].some(action => path === `${D}/${action}`)) { + assert.equal(body.recordVersion, detail.recordVersion); assert.match(body.idempotencyKey, /^[a-f0-9]{64}$/) + if (path.endsWith('/submit-review')) { detail.reviewState = 'DISPATCH_PENDING'; detail.reviews.dispatchReview = { status: 'PENDING' } } + else if (path.endsWith('/dispatch-review')) { assert.equal(detail.reviewState, 'DISPATCH_PENDING'); detail.reviewState = 'LOADING_PENDING'; detail.reviews.dispatchReview.status = 'APPROVED' } + else { assert.equal(detail.reviewState, 'LOADING_PENDING'); detail.reviewState = 'APPROVED'; detail.stage = 'LOADING'; detail.reviews.loadingReview = { status: 'APPROVED' } } + bump(); return remember('REVIEW', body) } - if (path.endsWith('/loading-review')) { - assert.equal(String(body.recordVersion), String(trip.recordVersion)); assert.ok(load); trip.recordVersion++ - return ok({ trip, dispatches, loadingReview: { status: 'APPROVED' } }) + if (method === 'POST' && [D, P].some(prefix => path === `${prefix}/loading-complete`)) { + if (saved.has(body.idempotencyKey)) { + assert.deepEqual(body, saved.get(body.idempotencyKey).body) + if (loadingActors.get(body.idempotencyKey) !== (token ? 'PDA' : 'PLATFORM')) { + if (fault === 'cross-actor-unknown') throw new Error('cross actor result unknown') + if (fault === 'cross-actor-mutates-main') bump() + if (fault === 'cross-actor-mutates-items') mutatedItems = true + if (fault === 'cross-actor-mutates-loading') mutatedLoading = true + if (fault !== 'cross-actor-accepted') return { http: 200, body: { code: 'A0403', + message: fault === 'cross-actor-generic-forbidden' ? '没有装车权限' : '该请求标识不属于当前操作员' } } + } + const replay = { ...saved.get(body.idempotencyKey).result, replayed: true } + if (fault === 'replay-new-version') replay.recordVersion = String(BigInt(replay.recordVersion) + 1n) + return ok(replay) + } + assert.equal(detail.stage, 'LOADING'); assert.equal(detail.counts.pickedPackageCount, 3) + assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST') + assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }]) + loadingActors.set(body.idempotencyKey, token ? 'PDA' : 'PLATFORM') + detail.stage = 'AFTER_LOADING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } }) } - if (path.endsWith('/review')) return ok({ reviewStatus: 'APPROVED' }) - if (path.endsWith('/assignment')) { - assert.equal(String(body.recordVersion), String(trip.recordVersion)) - Object.assign(trip, { vehicleId: body.vehicleId, driverId: body.driverId, vehiclePlate: 'REPLACEMENT', driverName: 'Replacement driver', driverPhone: '456' }) - trip.recordVersion++; assigned = true; dispatches[0].review = { status: 'PENDING' } - if (fault !== 'keeps-old-load') load = null - Object.assign(dispatch, { vehiclePlate: trip.vehiclePlate, driverName: trip.driverName, driverPhone: trip.driverPhone }) - if (fault === 'changes-picking') dispatch.pickedPackageCount = 0 - if (fault === 'changes-customer') currentCargo = [{ ...cargo(), customerId: 'B' }] - if (fault === 'unknown-assignment-outcome') throw new Error('connection interrupted after commit') - return ok({ trip, dispatches }) + if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) { + const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind') + return ok([{ resourceId: kind === 'VEHICLE' ? VID : DRIVER, occupiedTripNo: claims.get(kind) ?? null, available: true }]) } - if (path.endsWith(`/dispatches/${proof.dispatchNo}`)) return ok(dispatch) - if (path.endsWith(`/trips/${proof.tripNo}`)) return ok({ trip, dispatches }) - throw new Error(`unexpected request ${method} ${path}`) - }, - } - return { proof, calls, assertions, context, trip } -} - -test('fresh-run load checks reassignment, unchanged work, stale version rejection and fresh confirmation before returning departure version', async () => { - const server = reassignmentServer() - const version = await confirmFleetLoading(server.context, 'test', server.proof) - assert.equal(server.proof.reassignment.status, 'VERIFIED') - assert.equal(version, 9) - assert.equal(server.proof.vehicleId, '102'); assert.equal(server.proof.driverId, '202') - assert.equal(server.proof.reassignment.originalTrip.vehicleId, '101') - assert.equal(server.proof.reassignment.dispatchAfter.pickedPackageCount, 10) - assert.equal(server.proof.confirmedLoad.pallets[0].weightKg, 100) - assert.ok(server.assertions.every(row => row.passed)) - assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).length, 3) - assert.ok(server.calls.every(row => !/\/depart|\/pick|\/complete$/.test(row.path)), 'reassignment does not repeat physical warehouse work') -}) - -test('string-serialized versions compare numerically across 9 to 10 and remain unchanged in requests', async () => { - const server = reassignmentServer('string-versions') - assert.equal(await confirmFleetLoading(server.context, 'test', server.proof), '13') - assert.equal(server.proof.reassignment.status, 'VERIFIED') - assert.equal(server.proof.reassignment.assignmentRequest.recordVersion, '9') - assert.equal(server.calls.filter(row => row.method === 'POST' && row.path.endsWith('/loading')).at(-1).body.recordVersion, '11') -}) - -for (const [fault, message] of [ - ['keeps-old-load', /原装载失效/], ['changes-picking', /保留拣货/], ['changes-customer', /客户归属/], - ['accept-old-version', /旧版本不能/], ['unrelated-conflict', /旧版本不能/], - ['reject-but-write', /拒绝后仍未确认/], ['wrong-final-measurement', /装载确认返回/], - ['unknown-assignment-outcome', /connection interrupted/], -]) test(`reassignment stops on ${fault} and retains requests without silently retrying`, async () => { - const server = reassignmentServer(fault) - await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), message) - assert.equal(server.proof.reassignment.status, 'CHECKING') - assert.equal(server.proof.reassignment.assignmentRequest.vehicleId, '102') - const callCount = server.calls.length - await assert.rejects(confirmFleetLoading(server.context, 'test', server.proof), /已开始/) - assert.equal(server.calls.length, callCount) -}) - -test('retained dispatch loading does not create replacement resources or mutate assignment', async () => { - const server = reassignmentServer() - delete server.proof.reassignment - const version = await confirmFleetLoading(server.context, 'test', server.proof) - assert.equal(version, 6) - assert.equal(server.calls.length, 3) - assert.ok(server.calls.every(row => (row.path.endsWith('/loading') || row.path.endsWith('/loading-review')))) -}) - -function closureServer(fault = '') { - const proof = { tripNo: 'TR-ORIGINAL', vehicleId: '9007199254740993', driverId: '9007199254740995', warehouseId: '92', stops } - const trips = new Map([[proof.tripNo, { tripNo: proof.tripNo, recordVersion: 5, vehicleId: proof.vehicleId, driverId: proof.driverId, - status: 'IN_TRANSIT', completedAt: null, vehicleRelease: null, driverRelease: null }]]) - const claims = new Map([[`VEHICLE:${proof.vehicleId}`, proof.tripNo], [`DRIVER:${proof.driverId}`, proof.tripNo]]) - const remembered = new Map(), calls = [], assertions = [] - let sequence = 0 - const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) } }) - const fail = message => ({ http: 200, body: { code: 'A0409', message } }) - const context = { - marker: 'CONTROLLED-TEST', - assert(_module, label, _response, passed) { assertions.push({ label, passed }) }, - apiSuccess(_module, _label, response) { return response.body.code === '200' ? response.body.data : null }, - async request(method, path, body) { - calls.push({ method, path, body: structuredClone(body) }) - if (path.endsWith('/master-data/vehicles')) { - assert.equal(body.status, 'IN_SERVICE', 'the independent driver occupancy probe needs a valid in-service vehicle') - return ok({ id: 'SPARE-VEHICLE' }) - } - if (method === 'POST' && path.endsWith('/fleet/trips')) { - const tripNo = `TR-PROBE-${++sequence}` - const trip = { tripNo, recordVersion: 0, status: 'PLANNED', vehicleId: null, driverId: null } - trips.set(tripNo, trip); return ok({ trip }) - } - const match = path.match(/\/trips\/([^/]+)(.*)$/), trip = trips.get(match?.[1]), suffix = match?.[2] - assert.ok(trip, `unexpected request ${path}`) - if (method === 'GET' && suffix === '/completion') return ok({ recordVersion: trip.recordVersion, canComplete: true, outcomes: [cargo()] }) - if (method === 'GET' && !suffix) return ok({ trip }) - if (suffix === '/complete' || suffix === '/resources/release') { - const key = `${trip.tripNo}:${body.idempotencyKey}` - if (remembered.has(key)) { - assert.deepEqual(body, remembered.get(key), 'retry must preserve exact original body') - if (fault === 'old-release-clears-new-claim' && suffix === '/resources/release') claims.delete(`${body.resourceKind}:${body.resourceKind === 'VEHICLE' ? trip.vehicleId : trip.driverId}`) + if (method === 'GET' && path === `${F}/completion`) return ok({ tripNo: 'TR-A', recordVersion: trip.recordVersion, canComplete: fault !== 'incomplete', outcomes: [{ deliveryNo: 'DL-A', packages: 3 }] }) + if (method === 'GET' && path === F) return ok({ trip, dispatches: fault === 'wrong-binding' ? [{ dispatchNo: 'DP-OTHER' }] : [{ dispatchNo: 'DP-A' }] }) + if (method === 'GET' && path === F.replace('TR-A', 'TR-B')) return ok({ trip: { ...trip, tripNo: 'TR-B' }, dispatches: [{ dispatchNo: 'DP-A' }] }) + if (method === 'GET' && path === F.replace('TR-A', 'TR-NEXT')) return ok({ trip: nextTrip, dispatches: [{ dispatchNo: 'DP-NEXT' }] }) + if (method === 'POST' && [ `${F}/complete`, `${F}/resources/release` ].includes(path)) { + if (saved.has(body.idempotencyKey)) { + assert.deepEqual(body, saved.get(body.idempotencyKey)) + if (fault === 'old-release-clears-new-claim' && nextTrip && path.endsWith('/resources/release')) claims.delete(body.resourceKind) return ok({ trip }) } - assert.equal(body.recordVersion, trip.recordVersion) - remembered.set(key, structuredClone(body)); trip.recordVersion++ - if (suffix === '/complete') { trip.status = 'COMPLETED'; trip.completedAt = '2026-10-02T00:00:00Z' } - else { - const vehicle = body.resourceKind === 'VEHICLE' - claims.delete(`${body.resourceKind}:${vehicle ? trip.vehicleId : trip.driverId}`) - trip[vehicle ? 'vehicleRelease' : 'driverRelease'] = { actualEndAt: body.actualEndAt, endLocation: body.endLocation, vehicleStatus: body.vehicleStatus } - if (fault === 'vehicle-also-frees-driver' && vehicle) claims.delete(`DRIVER:${trip.driverId}`) + assert.equal(body.recordVersion, trip.recordVersion); saved.set(body.idempotencyKey, structuredClone(body)); trip.recordVersion++ + if (path.endsWith('/complete')) { + trip.status = 'COMPLETED'; trip.completedAt = '2026-10-19T00:00:00Z' + if (fault === 'completion-auto-releases') { claims.clear(); trip.vehicleRelease = { actualEndAt: trip.completedAt } } + } else { + claims.delete(body.resourceKind) + trip[body.resourceKind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease'] = { actualEndAt: body.actualEndAt, endLocation: body.endLocation } + if (fault === 'vehicle-frees-driver' && body.resourceKind === 'VEHICLE') claims.delete('DRIVER') } return ok({ trip }) } - if (suffix === '/assignment') { - if (fault === 'unrelated-conflict') return fail('车次版本已变化') - if (claims.has(`VEHICLE:${body.vehicleId}`)) return fail('车辆已被其他车次占用') - if (claims.has(`DRIVER:${body.driverId}`)) return fail('司机已被其他车次占用') - assert.equal(body.recordVersion, trip.recordVersion) - trip.vehicleId = body.vehicleId; trip.driverId = body.driverId; trip.recordVersion++ - claims.set(`VEHICLE:${body.vehicleId}`, trip.tripNo); claims.set(`DRIVER:${body.driverId}`, trip.tripNo) - return ok({ trip }) - } - if (suffix === '/cancel') { - assert.equal(body.recordVersion, trip.recordVersion) - for (const [key, owner] of claims) if (owner === trip.tripNo) claims.delete(key) - trip.status = 'CANCELLED'; trip.recordVersion++; return ok({ trip }) - } - throw new Error(`unhandled request ${method} ${path}`) + assert.fail(`retired or unexpected request ${method} ${path}`) }, } - return { proof, trips, claims, calls, assertions, context } + return { context, detail, trip, calls, assertions, claims, pick() { detail.counts.pickedPackageCount = 3; bump() }, failCheckpoint() { checkpointError = true } } } -test('closure proves independent resource occupancy through real command-shaped probes, then cancels empty plans', async () => { - const server = closureServer() - const result = await finishFleetTrip(server.context, 'test', server.proof) - assert.equal(result.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') - assert.equal(server.claims.size, 0) - assert.ok(server.assertions.every(row => row.passed)) - assert.equal(result.probes.length, 2) - assert.ok(result.probes.every(row => row.finalStatus === 'CANCELLED')) - assert.equal(server.calls.filter(row => row.path.endsWith('/resources/release')).length, 4) - assert.equal(server.calls.filter(row => row.path.endsWith('/assignment')).length, 5) - assert.ok(server.calls.every(row => !/\/depart|\/items\/.*\/pick/.test(row.path)), 'closure never replays physical departure or inventory work') - await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /已开始/) +const prepare = s => prepareFleetTrip(s.context, 'native', 'DP-A', '92') + +function jacksonCountWire(s) { + const names = new Set(['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'scheduledPackages', 'pickedPackages']) + const serialize = value => Array.isArray(value) ? value.map(serialize) + : value && typeof value === 'object' ? Object.fromEntries(Object.entries(value).map(([name, nested]) => + [name, names.has(name) && typeof nested === 'number' ? String(nested) : serialize(nested)])) : value + const original = s.context.request + s.context.request = async (...args) => serialize(await original(...args)) + return s +} + +test('real string-zero counts permit preparation, PDA loading replay and safe empty-dispatch closure', async () => { + const s = jacksonCountWire(server()), proof = await prepare(s) + assert.equal(proof.preparation, 'VERIFIED') + s.pick() + await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt()) + s.trip.status = 'IN_TRANSIT' + const closure = await finishFleetTrip(s.context, 'native', proof) + assert.equal(closure.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED') + assert.ok(s.assertions.every(row => row.passed)) }) -test('closure fails if ending only the vehicle silently releases the driver too', async () => { - const server = closureServer('vehicle-also-frees-driver') - await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /司机仍不能/) - assert.equal(server.proof.closure.status, 'CHECKING') - assert.ok(server.assertions.some(row => row.passed === false)) +test('missing or malformed zero counts cannot create resources before preparation', async () => { + for (const value of [undefined, null, false, '', '00', '0.0', '0e0', '9007199254740993']) { + const s = server(); s.detail.counts.pickedPackageCount = value + await assert.rejects(prepare(s), /箱数/) + assert.deepEqual(s.calls.map(row => row.method), ['GET']) + } }) -test('closure detects an old release command deleting a subsequent trip claim', async () => { - const server = closureServer('old-release-clears-new-claim') - await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /旧结束重放不能释放/) - assert.notEqual(server.proof.closure.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') +test('native preparation edits existing master then submits both reviews, discovers the real sole automatic Fleet record', async () => { + const s = server(), proof = await prepare(s) + assert.equal(proof.tripNo, 'TR-A'); assert.equal(proof.preparation, 'VERIFIED'); assert.equal(proof.vehicleId, VID) + assert.equal(proof.vehicle.plate, proof.marker.toUpperCase()); assert.equal(proof.driver.phone, '19900000000') + assert.equal(proof.recordVersion, '9007199254741002'); assert.equal(proof.fleetRecordVersion, 4) + const writes = s.calls.filter(c => ['POST', 'PUT'].includes(c.method)) + assert.deepEqual(writes.slice(-4).map(c => c.path), [D, `${D}/submit-review`, `${D}/dispatch-review`, `${D}/loading-review`]) + assert.ok(writes.every(c => !/\/trips(?:$|\/.*\/(?:assignment|plan|bind|review))/.test(c.path))) + assert.equal(s.detail.dispatchInfo.fbaAppointments[0].appointmentNo, 'FBA-ORIGINAL') + assert.ok(s.assertions.every(a => a.passed)) }) -test('an unrelated conflict cannot stand in for proof of occupied resources', async () => { - const server = closureServer('unrelated-conflict') - await assert.rejects(finishFleetTrip(server.context, 'test', server.proof), /未证明资源占用/) - assert.equal(server.calls.some(row => row.path.endsWith('/resources/release')), false) +test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => { + const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED' + const proof = await prepare(s) + const returnIndex = s.calls.findIndex(c => c.path.endsWith('/return-to-review')) + assert.ok(returnIndex >= 0 && returnIndex < s.calls.findIndex(c => c.method === 'PUT')) + assert.equal(proof.returnRequest.recordVersion, '9007199254740998'); assert.equal(proof.preparation, 'VERIFIED') +}) + +test('authoritative uppercase vehicle plate discovers both the prepared and subsequent native dispatch without changing marker', async () => { + const s = server(), request = s.context.request, marker = 'FT-1d529b3e-d15' + s.context.request = async (...args) => { + // 固定本轮随机标识以精确复现真实HTTP大小写回执;首次GET仍在任何车辆写入之前。 + if (args[0] === 'GET' && args[1] === D && !s.context.createdResources.fleetTrips[0].vehicleId) + s.context.createdResources.fleetTrips[0].marker = marker + return request(...args) + } + const proof = await prepare(s) + assert.equal(proof.marker, marker); assert.equal(proof.vehicle.plate, 'FT-1D529B3E-D15') + assert.equal(proof.tripNo, 'TR-A') + s.trip.status = 'IN_TRANSIT' + const closure = await finishFleetTrip(s.context, 'native', proof) + assert.equal(closure.nextDispatch.marker, marker); assert.equal(closure.nextDispatch.vehicle, undefined) + assert.equal(closure.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED') + const queries = s.calls.filter(c => c.path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) + assert.equal(queries.length, 2) + for (const call of queries) assert.equal(new URL(`https://unused.test${call.path}`).searchParams.get('keyword'), 'FT-1D529B3E-D15') +}) + +for (const fault of ['missing-trip', 'duplicate-trip', 'wrong-binding', 'wrong-plate', 'wrong-vehicle', 'wrong-driver', 'wrong-warehouse']) test(`automatic Fleet identity fails closed: ${fault}`, async () => { + const s = server(fault); await assert.rejects(prepare(s), /唯一|绑定/) + assert.equal(s.context.createdResources.fleetTrips[0].preparation, 'CHECKING') +}) + +test('started picking cannot be silently prepared, reassigned or returned', async () => { + const s = server(); s.detail.stage = 'LOADING'; s.detail.counts.pickedPackageCount = 1 + await assert.rejects(prepare(s), /零扫码/) + assert.equal(s.calls.length, 1) +}) + +test('unknown write result retains the exact command, does not retry or generate another Fleet record', async () => { + const s = server('unknown-edit'); await assert.rejects(prepare(s), /unknown/) + const last = s.context.createdResources.fleetTripCommands.at(-1) + assert.equal(last.status, 'UNKNOWN'); assert.equal(last.path, D) + assert.equal(s.calls.filter(c => c.method === 'PUT').length, 1) +}) + +test('checkpoint failure sends no business write', async () => { + const s = server(); s.failCheckpoint(); await assert.rejects(prepare(s), /checkpoint/) + assert.deepEqual(s.calls.map(c => c.method), ['GET']) +}) + +test('actual loading uses authoritative decimal measurements and a proven producer pallet-space measurement', () => { + assert.deepEqual(controlledPalletMeasurements([pallet()], stops, receipt().pallets), [ + { warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }, + ]) +}) + +for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) test(`missing actual ${field} blocks; billing values cannot substitute`, () => { + const row = pallet(), r = receipt(); row[field] = null; delete r.pallets[0][field] + row.grossWeightKg = 999; row.billingWeight = 999 + assert.throws(() => controlledPalletMeasurements([row], stops, r.pallets), /实际测量缺少/) +}) + +test('suggested measurements, duplicate physical pallets and unpicked cargo are rejected', () => { + const row = pallet(); row.evidence[0].actual = false + assert.throws(() => controlledPalletMeasurements([row], stops, receipt().pallets), /真实来源/) + assert.throws(() => controlledPalletMeasurements([pallet(), pallet()], stops, receipt().pallets), /重复/) + assert.throws(() => controlledPalletMeasurements([{ ...pallet(), pickedPackages: 2 }], stops, receipt().pallets), /核平/) + assert.throws(() => controlledPalletMeasurements([pallet()], [{ name: 'Origin' }], receipt().pallets), /卸货站/) +}) + +test('extra or inconsistent producer measurements cannot alter another pallet or authoritative actual weight', () => { + const r = receipt(); r.pallets[0].weightKg = 100 + assert.throws(() => controlledPalletMeasurements([pallet()], stops, r.pallets), /不一致/) + assert.throws(() => controlledPalletMeasurements([pallet()], stops, [...receipt().pallets, { warehousePalletId: 'OTHER' }]), /本车以外/) +}) + +test('PDA original-actor replay restores loading while another platform actor is rejected without mutation', async () => { + const s = server(), proof = await prepare(s); s.pick() + const beforeCount = s.detail.counts.pickedPackageCount + const v = await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt()) + assert.equal(v, '9007199254741004'); assert.equal(proof.reassignment.status, 'REJECTED_WITHOUT_MUTATION') + assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'AFTER_LOADING') + const writes = s.calls.filter(c => c.path.endsWith('/loading-complete')) + assert.equal(writes.length, 3) + assert.deepEqual(writes.map(c => c.body), [writes[0].body, writes[0].body, writes[0].body]) + assert.deepEqual(writes.map(c => [c.path, c.actor]), [[`${P}/loading-complete`, 'PDA'], [`${P}/loading-complete`, 'PDA'], [`${D}/loading-complete`, 'PLATFORM']]) + assert.equal(proof.loadingCommand.replay.replayed, true); assert.equal(proof.loadingCommand.platformRejection.code, 'A0403') + assert.equal(proof.loadingCommand.platformPositiveLoading, 'PENDING_SEPARATE_SCENARIO') + assert.deepEqual(proof.loadingCommand.beforeReplay, proof.loadingCommand.afterReplay) + assert.equal(proof.loadingCommand.platformReplay, undefined) + assert.equal(writes[0].body.measurements[0].weightKg, '187.25') + assert.ok(s.calls.every(c => !/\/fleet\/trips\/.*\/(?:assignment|loading-review|loading)$/.test(c.path))) + const count = s.calls.length + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/) + assert.equal(s.calls.length, count) +}) + +test('platform positive loading remains executable in its own scenario with original-actor replay', async () => { + const s = server(), proof = await prepare(s); s.pick() + await confirmFleetLoading(s.context, 'native', proof, undefined, receipt()) + const writes = s.calls.filter(c => c.path.endsWith('/loading-complete')) + assert.deepEqual(writes.map(c => [c.path, c.actor]), [[`${D}/loading-complete`, 'PLATFORM'], [`${D}/loading-complete`, 'PLATFORM']]) + assert.equal(proof.loadingCommand.replay.replayed, true) + assert.equal(proof.loadingCommand.platformPositiveLoading, 'VERIFIED'); assert.equal(proof.loadingCommand.platformRejection, undefined) + assert.equal(s.detail.stage, 'AFTER_LOADING') +}) + +for (const fault of ['cross-actor-accepted', 'cross-actor-generic-forbidden', 'cross-actor-mutates-main', 'cross-actor-mutates-items', 'cross-actor-mutates-loading', 'cross-actor-unknown']) test(`cross-actor loading cannot claim isolation: ${fault}`, async () => { + const s = server(fault), proof = await prepare(s); s.pick() + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /失败|unknown/) + assert.equal(proof.loadingCommand.platformPositiveLoading, undefined) + assert.equal(s.calls.filter(c => c.path.endsWith('/loading-complete')).length, 3) + if (fault === 'cross-actor-unknown') assert.equal(s.context.createdResources.fleetTripCommands.at(-1).status, 'UNKNOWN') + const count = s.calls.length + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/) + assert.equal(s.calls.length, count) +}) + +for (const fault of ['edit-mutates-on-rejection', 'replay-new-version']) test(`loading verification rejects native drift: ${fault}`, async () => { + const s = server(fault), proof = await prepare(s); s.pick() + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /失败/) + if (fault === 'edit-mutates-on-rejection') assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false) +}) + +test('missing actual producer measurement blocks before any edit rejection or loading write', async () => { + const s = server(), proof = await prepare(s); s.pick(); const count = s.calls.length + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA'), /palletSpaces/) + assert.equal(s.calls.length, count + 1); assert.equal(s.calls.at(-1).method, 'GET') +}) + +test('wrong-run producer receipt and unreconciled picking block loading without mutation', async () => { + const s = server(), proof = await prepare(s) + await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /未就绪/) + s.pick(); await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', { ...receipt(), marker: 'OTHER-RUN' }), /不属于本轮/) + assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false) +}) + +test('closure keeps cargo completion, vehicle ending and driver ending distinct using real occupancy owners', async () => { + const s = server(), proof = await prepare(s); s.trip.status = 'IN_TRANSIT' + const state = await finishFleetTrip(s.context, 'native', proof) + assert.equal(state.status, 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'); assert.equal(s.claims.size, 0) + assert.equal(state.subsequentTripClaimProtection, 'VERIFIED_NATIVE_EMPTY_DISPATCH') + assert.equal(state.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(state.nextDispatch.finalStatus, 'CANCELLED') + assert.deepEqual(state.occupancy.map(c => [c.kind, c.rows[0].occupiedTripNo]), [ + ['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A'], ['VEHICLE', null], ['DRIVER', 'TR-A'], ['VEHICLE', null], ['DRIVER', null], + ['VEHICLE', 'TR-NEXT'], ['DRIVER', 'TR-NEXT'], ['VEHICLE', 'TR-NEXT'], ['DRIVER', 'TR-NEXT'], ['VEHICLE', null], ['DRIVER', null], + ]) + assert.equal(s.calls.filter(c => c.path.endsWith('/resources/release')).length, 4) + assert.ok(s.calls.every(c => !(c.method === 'POST' && /\/fleet\/trips$|\/assignment$/.test(c.path)))) + await assert.rejects(finishFleetTrip(s.context, 'native', proof), /已开始/) +}) + +for (const fault of ['incomplete', 'completion-auto-releases', 'vehicle-frees-driver', 'old-release-clears-new-claim', 'new-empty-has-cargo', 'unknown-next-create']) test(`closure does not claim success on independent-resource failure: ${fault}`, async () => { + const s = server(fault), proof = await prepare(s); s.trip.status = 'IN_TRANSIT' + await assert.rejects(finishFleetTrip(s.context, 'native', proof), /失败|unknown/) + assert.equal(proof.closure.status, 'CHECKING') + if (['new-empty-has-cargo', 'unknown-next-create'].includes(fault)) assert.equal(s.calls.some(c => c.path.endsWith('/DP-NEXT/cancel')), false) }) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index 9bfd17468..fb4e4ce17 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -1,296 +1,338 @@ import { createHash, randomUUID } from 'node:crypto' const BASE = '/api/platform/v1/last-leg/fleet' +const DISPATCH = '/api/platform/v1/last-leg/dispatches' +const PDA = '/api/pda/v1/last-leg/dispatches' const OPTIONS = { scenario: 'fleet-physical-trip-departure', deploymentBlocker: true } -const id = value => value == null ? '' : String(value) +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('服务端资源ID超出安全整数范围,必须保留原始字符串') + return value == null ? '' : String(value) +} +const key = (proof, action) => createHash('sha256').update(`${proof.marker}:${proof.dispatchNo}:${action}`).digest('hex') const version = value => { - if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) - throw new Error('服务端车次版本无效') + if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw new Error('服务端排车版本无效') return BigInt(String(value)) } - -async function command(context, module, method, path, body, label) { - const response = await context.request(method, path, body) +function check(context, module, label, condition, evidence = '') { + context.assert(module, label, null, condition, evidence, OPTIONS) + if (!condition) throw new Error(`${module}: ${label}失败`) +} +// 写前冻结原请求;未知结果停写,不能换键重造或自动重发。 +async function command(context, module, method, path, body, label, token, expectedRejection) { + let entry + if (method !== 'GET') { + context.createdResources ??= {}; context.createdResources.fleetTripCommands ??= [] + entry = { method, path, body: structuredClone(body), status: 'PREPARED' } + context.createdResources.fleetTripCommands.push(entry); await context.checkpoint?.() + } + let response + try { response = await context.request(method, path, body, token) } + catch (error) { if (entry) { entry.status = 'UNKNOWN'; await context.checkpoint?.() }; throw error } + if (entry) { + Object.assign(entry, { status: 'RESPONDED', http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId }) + await context.checkpoint?.() + } + if (expectedRejection) { + check(context, module, label, response?.http === 200 && String(response?.body?.code) === expectedRejection.code + && response?.body?.message === expectedRejection.message) + return { http: response.http, code: response.body.code, message: response.body.message, + requestId: response.body.requestId, traceId: response.body.traceId } + } const data = context.apiSuccess(module, label, response, '', OPTIONS) if (response?.http !== 200 || String(response?.body?.code) !== '200' || !data) throw new Error(`${module}: ${label}失败`) return data } -/** Test-only resources are created through real APIs and retained in the run report. No business SQL. */ +// 排车公开详情未提供tripNo,按本轮唯一新车牌分页,再核对真实资源及唯一绑定。 +async function discoverTrip(context, module, proof) { + // 正式车辆主档将车牌转为大写;保留原marker及命令键,仅按该权威规则生成精确查询身份。 + const vehiclePlate = proof.marker.toUpperCase() + const rows = [], seen = new Set(); let total + for (let page = 1; ; page++) { + const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId }) + const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按本轮唯一车牌读取自动资源记录') + if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 + || Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联') + total ??= Number(data.total) + if (Number(data.total) !== total || data.records.length > 200 || (!data.records.length && rows.length < total)) throw new Error('车次分页变化或缺页') + for (const row of data.records) { + if (!row.tripNo || seen.has(row.tripNo)) throw new Error('车次分页业务号缺失或重复') + seen.add(row.tripNo); rows.push(row) + } + if (rows.length >= total) break + } + if (rows.length !== total) throw new Error('车次分页总数不一致') + const matches = rows.filter(row => row.vehiclePlate === vehiclePlate && id(row.vehicleId) === proof.vehicleId + && id(row.driverId) === proof.driverId && id(row.warehouseId) === proof.warehouseId && row.tripPurpose === 'DELIVERY') + const bound = [] + for (const candidate of matches) { + const detail = await command(context, module, 'GET', `${BASE}/trips/${encodeURIComponent(candidate.tripNo)}`, undefined, '核对排车与资源记录唯一绑定') + if (detail.trip?.tripNo !== candidate.tripNo || id(detail.trip.vehicleId) !== proof.vehicleId || id(detail.trip.driverId) !== proof.driverId) + throw new Error('真实车次候选资源发生变化') + if (detail.dispatches?.some(row => row.dispatchNo === proof.dispatchNo)) { + if (detail.dispatches.length !== 1) throw new Error('原生排车资源记录仍关联多张子单') + bound.push(detail) + } + } + if (bound.length !== 1) throw new Error(`本轮排车自动资源记录不是唯一绑定:${bound.length}`) + const [detail] = bound + Object.assign(proof, { tripNo: detail.trip.tripNo, trip: detail.trip, stops: detail.trip.stops, fleetRecordVersion: detail.trip.recordVersion }) +} + +/** 本轮TEST主档经正式API建立;主单创建时已自动建立Fleet资源记录。 */ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) { if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓') - const marker = `FT-${randomUUID().slice(0, 12)}` - const proof = { dispatchNo, warehouseId: id(warehouseId), marker, reassignment: { status: 'PENDING' } } - context.createdResources.fleetTrips ??= [] - context.createdResources.fleetTrips.push(proof) - const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { - plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100, - region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, - remark: `${context.marker} controlled TEST vehicle`, - }, '建立本轮真实车辆档案及容量') - proof.vehicleId = id(vehicle.id) - await prepareQualifiedDriver(context, module, marker, proof) - const plannedDepartureAt = new Date(Date.now() + 3600000).toISOString() - const expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString() - const createKey = createHash('sha256').update(`${context.marker}:${dispatchNo}`).digest('hex') - let detail = await command(context, module, 'POST', `${BASE}/trips`, { - warehouseId, plannedDepartureAt, expectedDriverEndAt, idempotencyKey: createKey, - stops: [{ name: '测试起运仓' }, { name: '待匹配货物目的地' }], remark: context.marker, - }, '创建本轮共享运输车次') - proof.tripNo = detail.trip.tripNo - const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` - detail = await command(context, module, 'POST', `${path}/assignment`, { - recordVersion: detail.trip.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '本轮测试派车', - }, '正式分配车辆司机并核验证照') - detail = await command(context, module, 'POST', `${path}/dispatches/bind`, { - recordVersion: detail.trip.recordVersion, dispatchNo, reason: '关联本轮客户执行单据', - }, '将客户排车单关联至车次') - const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '读取真实货物目的地匹配键') - const destinations = new Map() - for (const cargo of loading.cargo || []) { - if (!cargo.destinationKey) throw new Error('实际排车货物缺少目的地,不能伪造装载站点') - destinations.set(cargo.destinationKey, { name: cargo.destinationLabel, destinationKey: cargo.destinationKey }) + const proof = { dispatchNo, warehouseId: id(warehouseId), marker: `FT-${randomUUID().slice(0, 12)}`, + syntheticTestMaterials: true, preparation: 'CHECKING', reassignment: { status: 'PENDING' } } + context.createdResources.fleetTrips ??= []; context.createdResources.fleetTrips.push(proof) + const path = `${DISPATCH}/${encodeURIComponent(dispatchNo)}` + let before = await command(context, module, 'GET', path, undefined, '读取原生排车主单版本和阶段') + version(before.recordVersion) + if (before.dispatchNo !== dispatchNo || before.status !== 'ACTIVE' || safePackageCount(before.counts?.pickedPackageCount) !== 0 + || safePackageCount(before.counts?.shippedPackageCount) !== 0) throw new Error('仅本轮有效零扫码排车可准备车辆和审核') + if (before.stage === 'LOADING') { + proof.returnRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'RETURN_TO_REVIEW'), reason: 'TEST零扫码明确退回装车前后调整资源' } + await command(context, module, 'POST', `${path}/return-to-review`, proof.returnRequest, '零扫码排车显式退回装车前') + before = await command(context, module, 'GET', path, undefined, '回查显式退回后的审核状态') } - if (!destinations.size) throw new Error('实际排车没有货物') - detail = await command(context, module, 'PUT', `${path}/plan`, { - recordVersion: detail.trip.recordVersion, plannedDepartureAt, expectedDriverEndAt, - stops: [{ name: '测试起运仓' }, ...destinations.values()], reason: '按实际货物目的地冻结路线', - }, '在拣货前匹配真实卸货站') - proof.stops = detail.trip.stops - detail = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(dispatchNo)}/review`, { - recordVersion: detail.trip.recordVersion, decision: 'APPROVED', - }, '拣货前通过本轮客户排车调度审核') - proof.dispatchReview = detail.dispatches.find(row => row.dispatchNo === dispatchNo)?.review - if (proof.dispatchReview?.status !== 'APPROVED') throw new Error('客户排车调度审核未通过,停止拣货准备') - return proof + if (before.stage !== 'BEFORE_LOADING' || !['DRAFT', 'DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(before.reviewState)) + throw new Error('排车未处于可编辑装车前阶段,不能隐式撤销审核或改派') + const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { + plateNumber: proof.marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100, + region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, + remark: `${context.marker} TEST合成车辆主档,容量不是实载测量`, + }, '建立本轮TEST车辆主档及容量') + proof.vehicleId = id(vehicle.id) + if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID') + await prepareQualifiedDriver(context, module, proof) + proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'), + vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId }, + driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt } }, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: before.dispatchInfo?.estimatedArrivalAt ?? null, + remark: `${context.marker} TEST合成资料正常履约` } + let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机') + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) { + const body = { recordVersion: result.recordVersion, idempotencyKey: key(proof, action), + ...(action === 'submit-review' ? { remark: '本轮TEST整车提交审核' } : { decision: 'APPROVED', reason: '本轮TEST整车审核' }) } + result = await command(context, module, 'POST', `${path}/${action}`, body, `原生整车${action}`) + } + const detail = await command(context, module, 'GET', path, undefined, '审核后读取原生资源快照') + check(context, module, '两级审核通过才进入装车中', detail.status === 'ACTIVE' && detail.stage === 'LOADING' + && detail.reviewState === 'APPROVED' && detail.reviews?.dispatchReview?.status === 'APPROVED' + && detail.reviews?.loadingReview?.status === 'APPROVED' && safePackageCount(detail.counts?.pickedPackageCount) === 0 + && version(detail.recordVersion) === version(result.recordVersion)) + Object.assign(proof, { vehicleInfo: detail.vehicleInfo, vehicle: detail.vehicleInfo?.vehicle, driver: detail.vehicleInfo?.driver, + dispatchReview: detail.reviews.dispatchReview, loadingReview: detail.reviews.loadingReview, recordVersion: detail.recordVersion }) + await discoverTrip(context, module, proof) + proof.preparation = 'VERIFIED'; await context.checkpoint?.(); return proof } -async function prepareQualifiedDriver(context, module, marker, proof) { +async function prepareQualifiedDriver(context, module, proof) { + proof.expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString() let driver = await command(context, module, 'POST', `${BASE}/drivers`, { - name: `${marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', - licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', - licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), - remark: `${context.marker} TEST credential fixture; not a real driving licence`, - }, '建立本轮司机证照档案') + name: `${proof.marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', + licenseNumber: proof.marker, licenseType: 'Class A', licenseZone: 'UTC', + licenseExpiresOn: new Date(Date.now() + 365 * 86400000).toISOString().slice(0, 10), remark: `${context.marker} TEST合成证照资料,不是真实驾驶证`, + }, '建立本轮TEST司机档案') proof.driverId = id(driver.id) - const uploaded = await context.requestMultipart('POST', `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`, { - name: `${marker}-TEST-LICENSE.png`, type: 'image/png', - content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64'), - }) - driver = context.apiSuccess(module, '上传本轮测试证照文件', uploaded, '', OPTIONS) - if (!driver?.licenseFileId) throw new Error('测试证照上传失败,不允许伪造已审核资格') + if (!proof.driverId) throw new Error('司机主档没有返回真实资源ID') + proof.licenseUpload = { path: `${BASE}/drivers/${proof.driverId}/license?recordVersion=${driver.recordVersion}`, + name: `${proof.marker}-TEST-LICENSE.png`, syntheticTestMaterials: true, status: 'PREPARED' } + await context.checkpoint?.() + let uploaded + try { + uploaded = await context.requestMultipart('POST', proof.licenseUpload.path, { name: proof.licenseUpload.name, type: 'image/png', + content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') }) + } catch (error) { proof.licenseUpload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error } + Object.assign(proof.licenseUpload, { status: 'RESPONDED', http: uploaded?.http, code: uploaded?.body?.code }); await context.checkpoint?.() + driver = context.apiSuccess(module, '上传TEST合成证照文件', uploaded, '', OPTIONS) + if (uploaded?.http !== 200 || String(uploaded?.body?.code) !== '200' || !driver?.licenseFileId) throw new Error('TEST证照上传失败,不能伪造资格') proof.licenseFileId = id(driver.licenseFileId) const reviewed = await command(context, module, 'POST', `${BASE}/drivers/${proof.driverId}/review`, { - recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅用于本轮受控测试司机', - }, '通过正式命令审核测试司机') - if (reviewed.reviewStatus !== 'APPROVED') throw new Error('测试司机未审核通过,停止派车验证') + recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '仅本轮TEST合成证照验收', + }, '正式审核本轮TEST司机') + if (reviewed.reviewStatus !== 'APPROVED') throw new Error('TEST司机未审核通过') } -export function controlledPalletMeasurements(cargo, stops) { - const pallets = new Map() - for (const item of cargo) { - if (!item.warehousePalletId || !item.palletNo || !item.destinationKey) throw new Error('装载货物缺少真实托盘或目的地') - const deliveryIndex = stops.findIndex(stop => stop.destinationKey === item.destinationKey) - if (deliveryIndex < 1) throw new Error('货物目的地不在车次路线') - const key = id(item.warehousePalletId) - if (pallets.has(key) && pallets.get(key).deliveryIndex !== deliveryIndex) throw new Error('混合目的地实物托盘须先拆托') - // Controlled test measurement, deliberately separate from source billing/allocated mass. - pallets.set(key, { warehousePalletId: key, palletSpaces: 1, weightKg: 100, volumeCbm: 1, deliveryIndex }) +const decimal = value => String(value).replace(/(\.\d*?)0+$/, '$1').replace(/\.$/, '') +function evidenceFor(row, field, value) { + return row?.evidence?.some(e => e.field === field && e.actual === true && e.sourceType && e.sourceBusinessNo + && Number.isFinite(Date.parse(e.observedAt)) && decimal(e.value) === decimal(value)) +} +function safePackageCount(value) { + if ((typeof value !== 'number' && typeof value !== 'string') + || !/^(0|[1-9]\d*)$/.test(String(value)) || !Number.isSafeInteger(Number(value))) + throw new Error('实际托盘箱数缺失或精度无效') + return Number(value) +} +/** 保留导出名称供直接调用方定位;任何实际量均须来自实测证据。 */ +export function controlledPalletMeasurements(pallets, stops, actualPallets = []) { + if (!Array.isArray(pallets) || !pallets.length || !Array.isArray(stops)) throw new Error('没有实际装载托盘或真实路线') + const supplied = new Map(), seen = new Set() + for (const row of actualPallets) { + if (!row.warehousePalletId || supplied.has(id(row.warehousePalletId))) throw new Error('生产实测回执托盘缺失或重复') + supplied.set(id(row.warehousePalletId), row) } - if (!pallets.size) throw new Error('没有实际装载托盘') - return [...pallets.values()] + const result = pallets.map(row => { + const palletId = id(row.warehousePalletId) + if (!palletId || !row.palletNo || seen.has(palletId)) throw new Error('实际预览托盘缺失或重复') + seen.add(palletId) + const scheduled = safePackageCount(row.scheduledPackages), picked = safePackageCount(row.pickedPackages) + if (scheduled <= 0 || picked !== scheduled) throw new Error('实际托盘拣货尚未核平') + const receipt = supplied.get(palletId), output = { warehousePalletId: palletId } + for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) { + const source = row[field] == null ? receipt : row, value = source?.[field] + if (!/^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(value)) || !Number.isFinite(Number(value)) || Number(value) <= 0) throw new Error(`实际测量缺少有效${field}`) + if (!evidenceFor(source, field, value)) throw new Error(`实际测量${field}缺少真实来源证据`) + if (field === 'palletSpaces' && !Number.isSafeInteger(value)) throw new Error('实际托位必须为正整数') + if (row[field] != null && receipt?.[field] != null && decimal(row[field]) !== decimal(receipt[field])) throw new Error(`生产回执与预览${field}不一致`) + output[field] = value + } + const index = row.deliveryIndex ?? receipt?.deliveryIndex + if (!Number.isSafeInteger(index) || index < 1 || !stops[index]?.destinationKey) throw new Error('实际卸货站缺失或不在车次路线') + if (row.deliveryIndex != null && receipt?.deliveryIndex != null && row.deliveryIndex !== receipt.deliveryIndex) throw new Error('生产回执与实际卸货站不一致') + output.deliveryIndex = index; return output + }) + if ([...supplied.keys()].some(palletId => !seen.has(palletId))) throw new Error('生产实测回执包含本车以外托盘') + return result } -export async function confirmFleetLoading(context, module, proof) { +// 已扫码单只能用原生编辑拒绝证明,不能再走旧Fleet强行改派。 +async function verifyPreDepartureReassignment(context, module, proof) { const state = proof.reassignment - if (state && state.status !== 'PENDING') throw new Error('本轮改派检查已开始;保留原请求排查,不自动重复写入') - if (state) state.status = 'CHECKING' - const confirmed = await confirmActualLoad(context, module, proof) - if (!state) return approveLoading(context, module, proof, confirmed.recordVersion) - state.originalConfirmation = confirmed - await verifyPreDepartureReassignment(context, module, proof, confirmed) - const reconfirmed = await confirmActualLoad(context, module, proof) - check(context, module, '重新确认仍覆盖改派前同批货物', reconfirmed.manifestHash === confirmed.manifestHash - && JSON.stringify(reconfirmed.cargo) === JSON.stringify(confirmed.cargo) - && version(reconfirmed.recordVersion) > version(state.assignedTrip.recordVersion)) - state.finalConfirmation = reconfirmed - const reviewedVersion = await approveLoading(context, module, proof, reconfirmed.recordVersion) - state.status = 'VERIFIED' - return reviewedVersion + if (!state) return + if (state.status !== 'PENDING') throw new Error('本轮改派拒绝检查已开始;保留原请求排查,不自动重试') + state.status = 'CHECKING' + const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}` + state.before = await command(context, module, 'GET', path, undefined, '已扫码拒绝前保存主单') + state.itemsBefore = await command(context, module, 'GET', `${path}/items`, undefined, '保存真实扫码与来源') + if (state.before.status !== 'ACTIVE' || state.before.stage !== 'LOADING' || safePackageCount(state.before.counts?.pickedPackageCount) <= 0) throw new Error('拒绝检查缺少本轮真实扫码事实') + state.request = { ...proof.updateRequest, recordVersion: state.before.recordVersion, idempotencyKey: key(proof, 'SCANNED_EDIT_MUST_REJECT'), + plannedDepartureAt: new Date(Date.parse(proof.updateRequest.plannedDepartureAt) + 60000).toISOString(), remark: 'TEST已扫码后编辑资源计划必须拒绝' } + await context.checkpoint?.() + const response = await context.request('PUT', path, state.request) + state.response = { http: response?.http, code: response?.body?.code, message: response?.body?.message } + check(context, module, '已扫码原生排车编辑被服务端拒绝', response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code)))) + state.after = await command(context, module, 'GET', path, undefined, '拒绝后回查主单') + state.itemsAfter = await command(context, module, 'GET', `${path}/items`, undefined, '拒绝后回查真实货物') + check(context, module, '已扫码拒绝后版本资源货物数量不变', JSON.stringify(state.before) === JSON.stringify(state.after) && JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)) + state.status = 'REJECTED_WITHOUT_MUTATION' } -async function approveLoading(context, module, proof, recordVersion) { - const detail = await command(context, module, 'POST', `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading-review`, { - recordVersion, decision: 'APPROVED', - }, '实际装载确认后通过整车理货审核') - proof.loadingReview = detail.loadingReview - if (detail.loadingReview?.status !== 'APPROVED') throw new Error('整车理货审核未通过,不允许发车') - proof.recordVersion = detail.trip.recordVersion - return proof.recordVersion -} - -async function confirmActualLoad(context, module, proof) { - const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}/loading` - const loading = await command(context, module, 'GET', path, undefined, '拣货后读取整车实际装载依据') - if (loading.blockReason) throw new Error(`整车未就绪:${loading.blockReason}`) - const pallets = controlledPalletMeasurements(loading.cargo, proof.stops) - const confirmed = await command(context, module, 'POST', path, { - recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, pallets, - reason: '受控测试测量:每个实物托盘1托位、100kg、1m³;非生产测量', - }, '通过正式命令确认受控测试装载') - proof.confirmedLoad = confirmed.confirmedLoad - proof.recordVersion = confirmed.recordVersion - check(context, module, '装载确认返回当前明细与新版本', confirmed.confirmedLoad?.manifestHash === loading.manifestHash - && version(confirmed.recordVersion) > version(loading.recordVersion) - && JSON.stringify(confirmed.confirmedLoad?.pallets) === JSON.stringify([...pallets].sort((a, b) => a.warehousePalletId.localeCompare(b.warehousePalletId)))) - return confirmed -} - -function dispatchWorkSnapshot(row) { - return JSON.stringify({ dispatchNo: row.dispatchNo, warehouseId: row.overseasWarehouseId, - status: row.status, packageCount: row.packageCount, pickedPackageCount: row.pickedPackageCount, - shippedPackageCount: row.shippedPackageCount, remainingPackageCount: row.remainingPackageCount, - estimatedArrivalAt: row.estimatedArrivalAt, items: row.items }) -} - -async function verifyPreDepartureReassignment(context, module, proof, confirmed) { - const state = proof.reassignment - const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` - const dispatchPath = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}` - const original = await command(context, module, 'GET', path, undefined, '改派前读取车次资源和计划') - const before = await command(context, module, 'GET', dispatchPath, undefined, '改派前保留实际拣货与货物明细') - check(context, module, '改派仅针对本轮已拣完但未发车的车次', original.trip?.status === 'PLANNED' - && version(original.trip.recordVersion) === version(confirmed.recordVersion) && before.status === 'READY_TO_DEPART' - && before.dispatchNo === proof.dispatchNo && before.pickedPackageCount > 0 && before.shippedPackageCount === 0 - && before.pickedPackageCount === before.packageCount && before.items?.length > 0 - && id(original.trip.vehicleId) === proof.vehicleId && id(original.trip.driverId) === proof.driverId) - state.originalTrip = original.trip; state.dispatchBefore = before - const marker = `SWAP-${randomUUID().slice(0, 12)}` - const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { - plateNumber: marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0, - region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: original.trip.maxPalletSpaces, - maxLoadKg: original.trip.maxLoadKg, maxVolumeCbm: original.trip.maxVolumeCbm, remark: `${context.marker} 发车前改派测试`, - }, '创建本轮等容量替换车辆') - state.replacement = { vehicleId: id(vehicle.id) } - await prepareQualifiedDriver(context, module, marker, state.replacement) - state.assignmentRequest = { recordVersion: confirmed.recordVersion, vehicleId: state.replacement.vehicleId, - driverId: state.replacement.driverId, reason: '已拣完发车前换车换司机并重新核验装载' } - const assigned = await command(context, module, 'POST', `${path}/assignment`, state.assignmentRequest, '已拣货车次正式更换车辆和司机') - state.assignedTrip = assigned.trip - check(context, module, '改派完成且保持路线与客户关联', id(assigned.trip?.vehicleId) === state.replacement.vehicleId - && id(assigned.trip?.driverId) === state.replacement.driverId && assigned.trip.status === 'PLANNED' - && version(assigned.trip.recordVersion) > version(confirmed.recordVersion) && JSON.stringify(assigned.trip.stops) === JSON.stringify(original.trip.stops) - && JSON.stringify(assigned.dispatches.map(({ review, ...row }) => row)) === JSON.stringify(original.dispatches.map(({ review, ...row }) => row)) - && assigned.dispatches.every(row => row.review?.status === 'PENDING')) - const after = await command(context, module, 'GET', dispatchPath, undefined, '改派后回查实际拣货与货物明细') - state.dispatchAfter = after - check(context, module, '改派只更新车辆司机并保留拣货货量到达时间', dispatchWorkSnapshot(after) === dispatchWorkSnapshot(before) - && after.vehiclePlate === assigned.trip.vehiclePlate && after.driverName === assigned.trip.driverName && after.driverPhone === assigned.trip.driverPhone) - const loading = await command(context, module, 'GET', `${path}/loading`, undefined, '回查改派后原装载确认失效') - check(context, module, '原装载失效但货物和客户归属保持一致', loading.confirmedLoad == null - && version(loading.recordVersion) === version(assigned.trip.recordVersion) && loading.manifestHash === confirmed.manifestHash - && JSON.stringify(loading.cargo) === JSON.stringify(confirmed.cargo)) - state.staleLoadRequest = { recordVersion: confirmed.recordVersion, manifestHash: confirmed.manifestHash, - pallets: confirmed.confirmedLoad.pallets, reason: '旧改派前页面提交必须被拒绝' } - const stale = await context.request('POST', `${path}/loading`, state.staleLoadRequest) - const conflict = stale?.http === 409 || (stale?.http === 200 && ['409', 'A0409'].includes(String(stale?.body?.code))) - state.staleLoadResult = { http: stale?.http, code: stale?.body?.code, message: stale?.body?.message } - check(context, module, '旧版本不能恢复旧装载确认', conflict && /已被修改/.test(String(stale?.body?.message || ''))) - const unchanged = await command(context, module, 'GET', `${path}/loading`, undefined, '拒绝旧版本后回查装载未发生写入') - check(context, module, '旧版本拒绝后仍未确认且版本保持不变', version(unchanged.recordVersion) === version(assigned.trip.recordVersion) - && unchanged.confirmedLoad == null && unchanged.manifestHash === loading.manifestHash) - let reviewed = assigned - for (const binding of assigned.dispatches) { - reviewed = await command(context, module, 'POST', `${path}/dispatches/${encodeURIComponent(binding.dispatchNo)}/review`, { - recordVersion: reviewed.trip.recordVersion, decision: 'APPROVED', - }, '换车换司机后重新通过调度审核') - if (reviewed.dispatches.find(row => row.dispatchNo === binding.dispatchNo)?.review?.status !== 'APPROVED') throw new Error('改派后调度审核未通过') +export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements) { + if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明') + if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试') + const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}` + const preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken) + if (preview.dispatchNo !== proof.dispatchNo || preview.status !== 'ACTIVE' || preview.stage !== 'LOADING' || preview.pickingReconciled !== true + || preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`) + if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker + || actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车') + const measurements = controlledPalletMeasurements(preview.pallets, proof.stops, actualMeasurements?.pallets) + await verifyPreDepartureReassignment(context, module, proof) + proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements + proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion, + idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, measurements, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } } + const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken) + check(context, module, '显式装车才进入装车后', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'AFTER_LOADING' + && confirmed.result?.confirmedLoad?.manifestHash === preview.manifestHash && version(confirmed.recordVersion) > version(preview.recordVersion)) + const returned = confirmed.result.confirmedLoad.pallets + check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length + && measurements.every(m => returned.some(r => id(r.warehousePalletId) === m.warehousePalletId + && r.palletSpaces === m.palletSpaces && decimal(r.weightKg) === decimal(m.weightKg) + && decimal(r.volumeCbm) === decimal(m.volumeCbm) && r.deliveryIndex === m.deliveryIndex))) + proof.loadingCommand.result = confirmed; proof.confirmedLoad = confirmed.result.confirmedLoad + const snapshot = async label => ({ + main: await command(context, module, 'GET', path, undefined, `${label}主单`), + items: await command(context, module, 'GET', `${path}/items`, undefined, `${label}货物`), + loading: await command(context, module, 'GET', `${path}/loading`, undefined, `${label}装车事实`), + }) + proof.loadingCommand.beforeReplay = await snapshot('装车原键验证前回读') + const replay = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原操作员原端原键重放装车', pdaToken) + check(context, module, '原操作员装车重放恢复原结果且不增加版本', replay.replayed === true && replay.dispatchNo === confirmed.dispatchNo + && version(replay.recordVersion) === version(confirmed.recordVersion) && JSON.stringify(replay.result) === JSON.stringify(confirmed.result)) + proof.loadingCommand.replay = replay + if (pdaToken) { + // 当前场景使用独立平台/PDA操作者;由账本的精确所有者拒绝证明隔离,不能按Token外观猜同一账号。 + proof.loadingCommand.platformRejection = await command(context, module, 'POST', `${path}/loading-complete`, proof.loadingCommand.body, + '另一平台操作员不能重用PDA装车请求标识', undefined, { code: 'A0403', message: '该请求标识不属于当前操作员' }) } - state.reviewedDispatches = reviewed.dispatches - proof.vehicleId = state.replacement.vehicleId; proof.driverId = state.replacement.driverId - proof.licenseFileId = state.replacement.licenseFileId + proof.loadingCommand.afterReplay = await snapshot('装车原键验证后回读') + check(context, module, '原键重放及操作者拒绝后主单货物装车事实不变', + JSON.stringify(proof.loadingCommand.beforeReplay) === JSON.stringify(proof.loadingCommand.afterReplay) + && version(proof.loadingCommand.afterReplay.main.recordVersion) === version(confirmed.recordVersion)) + proof.loadingCommand.platformPositiveLoading = pdaToken ? 'PENDING_SEPARATE_SCENARIO' : 'VERIFIED' + proof.recordVersion = confirmed.recordVersion; await context.checkpoint?.(); return proof.recordVersion } -function check(context, module, label, condition, evidence = '') { - context.assert(module, label, null, condition, evidence, OPTIONS) - if (!condition) throw new Error(`${module}: ${label}失败`) -} - -async function expectOccupied(context, module, path, body, resource, label) { - const response = await context.request('POST', `${path}/assignment`, body) - const conflict = response?.http === 409 || (response?.http === 200 && ['409', 'A0409'].includes(String(response?.body?.code))) - const message = String(response?.body?.message || '') - const valid = conflict && message.includes(resource) && message.includes('占用') - context.assert(module, label, response, valid, `expected occupied ${resource}`, OPTIONS) - if (!valid) throw new Error(`${module}: ${label}未证明资源占用;停止并保留本轮资源`) -} - -/** Fresh-run closure proof only. Retain all commands on proof for diagnostics; do not repair old fixtures. */ +/** 货物结案与车辆司机结束分别执行;不再创建退役Fleet空计划作为探针。 */ export async function finishFleetTrip(context, module, proof) { if (!proof?.tripNo || !proof.vehicleId || !proof.driverId || !proof.warehouseId) throw new Error('缺少本轮车次与资源证明') - if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造或改写请求') - const state = proof.closure = { status: 'CHECKING', probes: [] } - const path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` - const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车所有货物结案条件') + if (proof.closure) throw new Error('本轮车次结案检查已开始;保留原请求排查,不自动重造') + const state = proof.closure = { status: 'CHECKING', occupancy: [] }, path = `${BASE}/trips/${encodeURIComponent(proof.tripNo)}` + const inspection = await command(context, module, 'GET', `${path}/completion`, undefined, '核对本车货物结案条件') check(context, module, '全部货物具有本车结案依据', inspection.canComplete === true && inspection.outcomes?.length > 0, inspection.blockReason || '') state.completeRequest = { recordVersion: inspection.recordVersion, idempotencyKey: `${proof.tripNo}:COMPLETE`, reason: '本轮实际签收及退回终态核对' } - const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案车次货物') - check(context, module, '结案记录原运输完成时间且尚未声明资源释放', completed.trip?.status === 'COMPLETED' - && Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null) - const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '同键重放货物结案') - check(context, module, '结案重放没有新增版本或改变完成时间', replay.trip?.recordVersion === completed.trip.recordVersion && replay.trip.completedAt === completed.trip.completedAt) - - async function probe(index) { - const request = { warehouseId: proof.warehouseId, idempotencyKey: `${proof.tripNo}:RELEASE-PROBE:${index}`, - plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), expectedDriverEndAt: new Date(Date.now() + 48 * 3600000).toISOString(), - stops: proof.stops, remark: '本轮资源占用验收计划,不关联货物或执行发车' } - const evidence = { createRequest: request }; state.probes.push(evidence) - const created = await command(context, module, 'POST', `${BASE}/trips`, request, `建立资源占用核对计划${index}`) - evidence.tripNo = created.trip.tripNo; evidence.recordVersion = created.trip.recordVersion - return { evidence, path: `${BASE}/trips/${encodeURIComponent(evidence.tripNo)}` } + const completed = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '正式结案货物') + check(context, module, '结案未自动结束车辆或司机', completed.trip?.status === 'COMPLETED' && Boolean(completed.trip.completedAt) && completed.trip.vehicleRelease == null && completed.trip.driverRelease == null) + const replay = await command(context, module, 'POST', `${path}/complete`, state.completeRequest, '原键重放结案') + check(context, module, '结案重放不增加版本或改变完成时间', version(replay.trip.recordVersion) === version(completed.trip.recordVersion) && replay.trip.completedAt === completed.trip.completedAt) + async function occupancy(kind, expectedOwner) { + const resourceId = kind === 'VEHICLE' ? proof.vehicleId : proof.driverId + const rows = await command(context, module, 'GET', `${path}/resource-occupancy?${new URLSearchParams({ kind, resourceIds: resourceId })}`, undefined, `读取${kind}独立占用`) + state.occupancy.push({ kind, rows }) + // available对本车为true,必须核对occupiedTripNo的真实拥有者。 + check(context, module, `${kind}占用拥有者符合独立结束阶段`, rows.length === 1 && id(rows[0].resourceId) === id(resourceId) && (rows[0].occupiedTripNo ?? null) === expectedOwner) } - const first = await probe(1) - const assign = { recordVersion: first.evidence.recordVersion, vehicleId: proof.vehicleId, driverId: proof.driverId, reason: '验证本轮资源占用' } - await expectOccupied(context, module, first.path, assign, '车辆', '货物结案后车辆仍不能被另一车次占用') - + await occupancy('VEHICLE', proof.tripNo); await occupancy('DRIVER', proof.tripNo) const endLocation = proof.stops?.at(-1)?.name - check(context, module, '测试任务具有明确结束地点', Boolean(endLocation)) - // These controlled fixtures end at the signed final stop and contain no return-driving task. - state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', - actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: 'IN_SERVICE', - idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: '受控测试车辆在末站结束,无返程安排' } - const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '单独结束测试车辆任务') - check(context, module, '只释放车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null) - await expectOccupied(context, module, first.path, assign, '司机', '车辆已释放但司机仍不能被另一车次占用') - - state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER', - actualEndAt: completed.trip.completedAt, endLocation, vehicleStatus: null, - idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: '受控测试司机在末站结束,无返程安排' } - const driverReleased = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '单独结束测试司机任务') - check(context, module, '车辆和司机各自保留真实任务结束记录', Boolean(driverReleased.trip?.vehicleRelease && driverReleased.trip.driverRelease)) - const assigned = await command(context, module, 'POST', `${first.path}/assignment`, assign, '释放后正式再次分配相同车辆司机') - first.evidence.recordVersion = assigned.trip.recordVersion - check(context, module, '释放后的两项资源可被下一计划使用', id(assigned.trip?.vehicleId) === id(proof.vehicleId) && id(assigned.trip?.driverId) === id(proof.driverId)) - const oldVehicleReplay = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '资源再分配后重放旧车辆结束请求') - const oldDriverReplay = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '资源再分配后重放旧司机结束请求') - check(context, module, '原结束请求重放不增加旧车次版本', oldVehicleReplay.trip.recordVersion === driverReleased.trip.recordVersion && oldDriverReplay.trip.recordVersion === driverReleased.trip.recordVersion) - const second = await probe(2) - await expectOccupied(context, module, second.path, { ...assign, recordVersion: second.evidence.recordVersion }, '车辆', '旧结束重放不能释放下一计划的车辆占用') - // A different free fixture vehicle isolates the driver claim from the vehicle claim. - const spare = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { - plateNumber: `PROBE-${randomUUID().slice(0,12)}`, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 0, - region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, remark: `${context.marker} 仅用于本轮司机占用负向验证`, - }, '建立司机占用隔离用测试车辆') - state.spareVehicleId = id(spare.id) - await expectOccupied(context, module, second.path, { ...assign, vehicleId: state.spareVehicleId, recordVersion: second.evidence.recordVersion }, '司机', '旧结束重放不能释放下一计划的司机占用') - for (const item of [second, first]) { - const cancelled = await command(context, module, 'POST', `${item.path}/cancel`, { recordVersion: item.evidence.recordVersion, reason: '资源占用核对完成,取消无货测试计划' }, '正式取消无货核对计划') - check(context, module, '核对计划取消且保留历史', cancelled.trip?.status === 'CANCELLED') - item.evidence.finalStatus = cancelled.trip.status - } - const final = await command(context, module, 'GET', path, undefined, '回查最终车次结案与资源结束记录') - check(context, module, '车次结案及两项资源结束保持一致', final.trip?.status === 'COMPLETED' && Boolean(final.trip.vehicleRelease && final.trip.driverRelease)) - state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' - state.finalTrip = final.trip - return state + if (!endLocation) throw new Error('TEST运输缺少明确末站结束地点') + state.vehicleReleaseRequest = { recordVersion: completed.trip.recordVersion, resourceKind: 'VEHICLE', actualEndAt: completed.trip.completedAt, + endLocation, vehicleStatus: 'IN_SERVICE', idempotencyKey: `${proof.tripNo}:RELEASE-VEHICLE`, reason: 'TEST车辆末站任务独立结束,无返程安排' } + const vehicleReleased = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '独立结束TEST车辆任务') + check(context, module, '只结束车辆仍保留司机任务', Boolean(vehicleReleased.trip?.vehicleRelease) && vehicleReleased.trip.driverRelease == null) + await occupancy('VEHICLE', null); await occupancy('DRIVER', proof.tripNo) + state.driverReleaseRequest = { recordVersion: vehicleReleased.trip.recordVersion, resourceKind: 'DRIVER', actualEndAt: completed.trip.completedAt, + endLocation, vehicleStatus: null, idempotencyKey: `${proof.tripNo}:RELEASE-DRIVER`, reason: 'TEST司机末站任务独立结束,无返程安排' } + const released = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '独立结束TEST司机任务') + check(context, module, '两项资源各自保留结束记录', Boolean(released.trip?.vehicleRelease && released.trip.driverRelease)) + await occupancy('VEHICLE', null); await occupancy('DRIVER', null) + // 正式空排车是新资源拥有者;不恢复已退役的独立Fleet建单或手工关联。 + state.nextDispatchRequest = { idempotencyKey: key(proof, 'NEXT_NATIVE_DISPATCH'), warehouseId: proof.warehouseId, + vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId }, + driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: new Date(Date.now() + 48 * 3600000).toISOString() } }, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, + remark: `${context.marker} TEST本轮空排车验证旧结束键不能释放新占用`, cargo: [], batchAllocations: [] } + state.nextDispatchResult = await command(context, module, 'POST', DISPATCH, state.nextDispatchRequest, '建立本轮原生空排车占用已结束资源') + if (!state.nextDispatchResult.dispatchNo || state.nextDispatchResult.dispatchNo === proof.dispatchNo) throw new Error('原生空排车未返回独立真实业务号') + state.nextDispatch = { dispatchNo: state.nextDispatchResult.dispatchNo, warehouseId: proof.warehouseId, + marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId } + await discoverTrip(context, module, state.nextDispatch) + if (state.nextDispatch.tripNo === proof.tripNo) throw new Error('新空排车未建立独立资源记录') + await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo) + const oldVehicle = await command(context, module, 'POST', `${path}/resources/release`, state.vehicleReleaseRequest, '原键重放车辆结束') + const oldDriver = await command(context, module, 'POST', `${path}/resources/release`, state.driverReleaseRequest, '原键重放司机结束') + check(context, module, '独立结束重放不增加版本', version(oldVehicle.trip.recordVersion) === version(released.trip.recordVersion) && version(oldDriver.trip.recordVersion) === version(released.trip.recordVersion)) + await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo) + const nextPath = `${DISPATCH}/${encodeURIComponent(state.nextDispatch.dispatchNo)}` + state.nextDispatchBeforeCancel = await command(context, module, 'GET', nextPath, undefined, '取消前核齐本轮新空排车当前版本和零实物') + state.nextDispatchItems = await command(context, module, 'GET', `${nextPath}/items`, undefined, '取消前核齐本轮新空排车零货物来源') + const next = state.nextDispatchBeforeCancel + check(context, module, '仅取消本轮已确定且从未执行的原生空单', next.dispatchNo === state.nextDispatch.dispatchNo + && next.status === 'ACTIVE' && next.stage === 'BEFORE_LOADING' && safePackageCount(next.counts?.itemCount) === 0 + && safePackageCount(next.counts?.packageCount) === 0 && safePackageCount(next.counts?.pickedPackageCount) === 0 && safePackageCount(next.counts?.shippedPackageCount) === 0 + && version(next.recordVersion) === version(state.nextDispatchResult.recordVersion) + && state.nextDispatchItems.dispatchNo === next.dispatchNo && state.nextDispatchItems.items?.length === 0 + && version(state.nextDispatchItems.recordVersion) === version(next.recordVersion)) + state.nextDispatchCancelRequest = { recordVersion: next.recordVersion, + idempotencyKey: key(proof, 'CANCEL_NEXT_NATIVE_DISPATCH'), reason: 'TEST原生空排车占用核对完成,保留取消历史' } + const cancelled = await command(context, module, 'POST', `${nextPath}/cancel`, + state.nextDispatchCancelRequest, '正式取消本轮未执行空排车') + check(context, module, '空排车取消并保留历史', cancelled.dispatchNo === state.nextDispatch.dispatchNo && cancelled.status === 'CANCELLED') + state.nextDispatch.finalStatus = cancelled.status + await occupancy('VEHICLE', null); await occupancy('DRIVER', null) + state.finalTrip = (await command(context, module, 'GET', path, undefined, '回查结案及资源结束记录')).trip + check(context, module, '最终结案和资源结束保持一致', state.finalTrip?.status === 'COMPLETED' && Boolean(state.finalTrip.vehicleRelease && state.finalTrip.driverRelease)) + state.subsequentTripClaimProtection = 'VERIFIED_NATIVE_EMPTY_DISPATCH' + state.status = 'CARGO_COMPLETED_AND_RESOURCES_RELEASED'; await context.checkpoint?.(); return state } diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs index 0745eec95..f5ecdc3b5 100644 --- a/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance-contract.test.mjs @@ -1,93 +1,134 @@ import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' -import { groupedDispatchMatches, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs' +import { DISPATCH_BASE as BASE, verifyGroupedDispatch, groupedDispatchMatches, createBody, dispatchIo, sourceSelection, version, versionAfter } from './grouped-dispatch-acceptance.mjs' -const candidates = [1, 2].map(n => ({ planId: '11', planPalletId: `2${n}`, warehousePalletId: `3${n}`, - overseasWarehouseId: '44', customerId: '55', inboundCode: `IN-${n}`, packageCount: n })) -const groups = candidates.map((row, n) => ({ draftContainerId: `6${n}`, planPalletIds: [row.planPalletId], returnReceiptIds: [] })) -const results = () => groups.map((group, n) => ({ draftContainerId: group.draftContainerId, - dispatch: { dispatchNo: `DS-${n}`, status: 'PENDING_DEPARTURE', palletCount: 1, - packageCount: candidates[n].packageCount, pickedPackageCount: 0, shippedPackageCount: 0, - items: [{ ...candidates[n], id: `7${n}` }] } })) -test('registered dispatch flow consumes current-run selected candidates before its main draft', () => { - const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8') - const call = source.indexOf('await verifyGroupedDispatch(context, MODULE, selectedCandidates)') - assert.ok(call > source.indexOf('const selectedCandidates')) - assert.ok(call < source.indexOf('const draftToken = `${context.marker}-DISPATCH-DRAFT`')) +const rows = () => [1, 2].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) }, + sourceKey: `PLAN:2${n}`, sourceVersion: `${n}`.repeat(64), customerId: '55', warehouseId: '44', inboundCode: `IN-${n}`, palletNo: `P-${n}`, + destination: { key: `FBA:A${n}`, type: 'FBA', warehouseCode: `A${n}` }, availablePackages: n, appointmentBatches: [], blockReasons: [] })) +function server(fault = '') { + const candidates = rows(), drafts = new Map(), orders = new Map(), ledger = new Map(), calls = [], checks = [] + let serial = 0, containerId = 60, itemId = 70 + const response = (data, code = '200') => ({ http: 200, body: { code, data: structuredClone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: 'CURRENT', createdResources: { phase4OverseasPda: { marker: 'CURRENT', finalStatus: 'COMPLETED', customerId: '55', overseasWarehouseId: '44', palletIds: ['31', '32'] } }, + checkpoint: async () => {}, // Explicit offline persistence double; production run supplies atomic disk persistence. + assert: (_module, label, _response, passed) => checks.push({ label, passed }), + async request(method, raw, body) { + calls.push({ method, path: raw, body: structuredClone(body) }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), write = method !== 'GET' + const key = write ? `${method}:${path}:${body?.idempotencyKey}` : null + if (write) { + assert.ok(body?.idempotencyKey, raw) + if (ledger.has(key)) { const old = ledger.get(key); if (old.body !== JSON.stringify(body)) return response(null, 'A0409'); const data = structuredClone(old.data); if (data?.vehicles) data.replayed = true; return response(data) } + } + const remember = data => { ledger.set(key, { body: JSON.stringify(body), data: structuredClone(data) }); return response(data) } + const draft = body?.draftToken ? drafts.get(body.draftToken) : null + if (path === `${BASE}/drafts` && method === 'POST') { assert.deepEqual(Object.keys(body).sort(), ['idempotencyKey', 'warehouseId']); const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', containers: [], sources: [], expiresAt: '2099-01-01' }; drafts.set(d.draftToken, d); return remember(d) } + if (path === `${BASE}/drafts` && method === 'GET') return response([...drafts.values()].filter(d => d.status === 'ACTIVE')) + if (path === `${BASE}/candidates`) { + assert.equal(params.get('warehouseId'), '44') + const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey))) + return response({ complete: true, snapshotAt: '2026-10-19', candidates: candidates.filter(c => !claimed.has(c.sourceKey)) }) + } + if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(body.capacity, 15); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: 15, sourceKeys: [] }); draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/draft-locks/lock`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(body.source.sourceVersion.length, 64); const c = candidates.find(c => c.selection.planPalletId === body.source.planPalletId); assert.ok(c); draft.sources.push(structuredClone(c)); draft.containers.find(g => g.draftContainerId === body.draftContainerId).sourceKeys.push(c.sourceKey); draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/drafts/snapshot`) { assert.equal(body.recordVersion, draft.recordVersion); assert.ok(Array.isArray(body.containers)); assert.equal(Object.hasOwn(body, 'containersJson'), false); draft.containers = structuredClone(body.containers); draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/drafts/pre-arrange`) { + assert.equal(body.recordVersion, draft.recordVersion) + const matches = body.groups.every(g => JSON.stringify(g.dispatch.cargo.map(s => `PLAN:${s.planPalletId}`).sort()) === JSON.stringify(draft.containers.find(c => c.draftContainerId === g.draftContainerId).sourceKeys.toSorted())) + if (!matches) { if (fault === 'reject-mutates-draft') draft.containers[0].sourceKeys = []; return response(null, 'A0409') } + const vehicles = body.groups.map(g => { + assert.equal(g.dispatch.vehicle, null); assert.equal(g.dispatch.driver, null) + const selected = candidates.filter(c => g.dispatch.cargo.some(s => s.planPalletId === c.selection.planPalletId)) + const number = `DS-${orders.size + 1}`, items = selected.map(c => ({ dispatchItemId: String(++itemId), sourceKey: c.sourceKey, sourceVersion: c.sourceVersion, customerId: c.customerId, planPalletId: c.selection.planPalletId, warehousePalletId: c.selection.warehousePalletId, inboundCode: c.inboundCode, destination: c.destination, scheduledPackages: c.availablePackages, pickedPackages: 0, remove: { allowed: true } })) + const o = { dispatchNo: number, recordVersion: 1, items }; orders.set(number, o) + return { draftContainerId: g.draftContainerId, dispatch: { dispatchNo: number, action: 'CREATE', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: 1, replayed: false, counts: counts(o) } } + }) + draft.status = 'CONVERTED' + if (fault === 'unknown-convert') return { http: 0, body: null } + if (fault === 'merged-groups') vehicles[1].dispatch.dispatchNo = vehicles[0].dispatch.dispatchNo + return remember({ draftToken: draft.draftToken, vehicles, replayed: false }) + } + const match = path.slice(BASE.length).match(/^\/([^/]+)(?:\/(items|route)(?:\/([^/]+)\/remove)?)?$/) + if (path === BASE && method === 'GET') return response({ records: [...orders.values()].filter(o => o.dispatchNo === params.get('dispatchNo')).map(o => ({ dispatchNo: o.dispatchNo, warehouseId: '44', recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o) })) }) + const o = orders.get(match?.[1]); assert.ok(o, `${method} ${raw}`) + if (method === 'POST' && match[3]) { assert.equal(body.recordVersion, o.recordVersion); o.items = o.items.filter(i => i.dispatchItemId !== match[3]); o.recordVersion++; return remember({ dispatchNo: o.dispatchNo, action: 'REMOVE_CARGO', recordVersion: o.recordVersion, replayed: false }) } + if (match[2] === 'items') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: o.items }) + if (match[2] === 'route') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, stops: [{ index: 0, name: 'Origin', destinationKey: 'ORIGIN:44' }, ...o.items.map((i, n) => ({ index: n + 1, destinationKey: i.destination.key }))] }) + return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o), dispatchInfo: { route: o.items.map(i => i.destination.warehouseCode).join(',') }, + orderDetails: o.items.map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) + } } + return { context, candidates, calls, checks, orders } +} +function counts(o) { return { itemCount: o.items.length, palletCount: o.items.length, packageCount: o.items.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: 0, shippedPackageCount: 0 } } + +test('actual controller and OpenAPI publish versioned server drafts; standalone legacy creation is absent', () => { + const controller = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java', import.meta.url), 'utf8') + const yaml = readFileSync(new URL('../../lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml', import.meta.url), 'utf8') + for (const path of ['/drafts', '/drafts/containers', '/drafts/snapshot', '/draft-locks/lock', '/drafts/pre-arrange']) { assert.ok(controller.includes(`("${path}")`)); assert.ok(yaml.includes(` ${BASE}${path}:`)) } + assert.equal(yaml.includes(` ${BASE}/pre-arrange:`), false) }) -test('two loading groups preserve distinct dispatches and exact physical sources', () => { - assert.equal(groupedDispatchMatches(results(), groups, candidates), true) - assert.equal(groupedDispatchMatches(results().reverse(), groups, candidates), true) - for (const mutate of [r => r.pop(), r => { r[1].dispatch.dispatchNo = r[0].dispatch.dispatchNo }, - r => { r[1].draftContainerId = r[0].draftContainerId }, - r => { r[0].dispatch.items = r[1].dispatch.items }, - r => { r[0].dispatch.packageCount++ }, r => { r[0].dispatch.items[0].warehousePalletId = '999' }, - r => { r[0].dispatch.items[0].inboundCode = 'FOREIGN' }, r => { r[0].dispatch.status = 'PICKING' }]) { - const value = results(); mutate(value); assert.equal(groupedDispatchMatches(value, groups, candidates), false) +test('grouped native protocol proves exact groups, command replay, real readback and versioned source release', async () => { + const h = server(), proof = await verifyGroupedDispatch(h.context, 'offline', h.candidates) + assert.equal(proof.status, 'GROUPED_DISPATCH_VERIFIED'); assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED') + assert.ok(h.checks.every(c => c.passed)); assert.equal(h.orders.size, 2); assert.ok([...h.orders.values()].every(o => !o.items.length)) + assert.equal(h.calls.filter(c => c.path.endsWith('/drafts/pre-arrange')).length, 3) + assert.equal(h.calls.filter(c => c.path.match(/\/items\/\d+\/remove$/)).length, 2) + assert.ok(h.calls.every(c => c.path !== `${BASE}/pre-arrange` && !/\/pick|\/depart|\/transfer-delivery/.test(c.path))) + assert.ok(h.calls.filter(c => c.method !== 'GET' && c.path !== `${BASE}/drafts`).every(c => Object.hasOwn(c.body, 'recordVersion'))) +}) +test('actual grouping receipt rejects merged group identity even when totals match', () => { + const c = rows(), groups = c.map((r, n) => ({ draftContainerId: String(n), sourceKeys: [r.sourceKey] })) + const receipt = { vehicles: c.map((r, n) => ({ draftContainerId: String(n), dispatch: { dispatchNo: `DS-${n}`, action: 'CREATE', status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: { itemCount: 1, palletCount: 1, packageCount: r.availablePackages } } })) } + assert.equal(groupedDispatchMatches(receipt, groups, c), true) + receipt.vehicles[1].dispatch.dispatchNo = receipt.vehicles[0].dispatch.dispatchNo; assert.equal(groupedDispatchMatches(receipt, groups, c), false) +}) +test('missing marker, source version, physical ownership or ambiguous FBA allocation is NOT_RUN before writes', async () => { + for (const mutate of [h => { h.context.createdResources.phase4OverseasPda.marker = 'OLD' }, h => { delete h.candidates[0].sourceVersion }, h => { h.candidates[0].customerId = '999' }, h => { h.candidates[0].warehouseId = '999' }, h => { h.candidates[0].appointmentBatches = [{}, {}] }]) { + const h = server(); mutate(h); assert.equal((await verifyGroupedDispatch(h.context, 'offline', h.candidates)).status, 'NOT_RUN'); assert.equal(h.calls.length, 0) } }) -function server(fault = '') { - let draft, next = 0, arranged = false - const calls = [], checks = [], orders = new Map() - const response = (data, code = '200') => ({ http: 200, body: { code, data }, requestId: 'offline', traceId: 'offline' }) - const context = { marker: 'CURRENT', createdResources: {}, - assert: (_m, label, _r, passed) => checks.push({ label, passed }), - async request(method, path, body) { - calls.push({ method, path, body: structuredClone(body) }) - if (path.endsWith('/drafts/snapshot')) { draft = { draftToken: body.draftToken, containers: [] }; return response(null) } - if (path.endsWith('/drafts/containers')) { - assert.equal(body.capacity, 15, 'formal draft supports 15 or 30 pallet vehicle capacity') - const container = { id: groups[next++].draftContainerId, palletIds: [], capacity: body.capacity } - draft.containers.push(container); return response(container) - } - if (path.endsWith('/draft-locks/lock')) { - draft.containers.find(row => row.id === body.draftContainerId).palletIds.push(body.planPalletId); return response(null) - } - if (path.endsWith('/drafts')) return response(structuredClone(draft ? [draft] : [])) - if (path.endsWith('/drafts/pre-arrange')) { - if (body.groups[0].planPalletIds[0] !== candidates[0].planPalletId) { - if (fault === 'reject-mutates-draft') draft.containers[0].palletIds = [] - return response(null, 'A0409') - } - if (fault === 'unknown-post') return { http: 0, body: null } - if (!arranged) { for (const row of results()) orders.set(row.dispatch.dispatchNo, row.dispatch); arranged = true } - const result = groups.map(group => ({ draftContainerId: group.draftContainerId, - dispatch: structuredClone(orders.get(`DS-${groups.indexOf(group)}`)) })) - if (fault === 'merged-groups') result[1].dispatch.dispatchNo = result[0].dispatch.dispatchNo - return response(result) - } - if (path.endsWith('/draft-locks/release')) { draft = null; return response(null) } - if (path.includes('/candidates?')) return response(candidates) - const match = path.match(/\/dispatches\/(DS-\d)(?:\/items\/(\d+))?$/) - const order = orders.get(match?.[1]); assert.ok(order, path) - if (method === 'DELETE') { order.items = []; order.palletCount = 0; order.packageCount = 0; return response(null) } - return response(structuredClone(order)) - } } - return { context, calls, checks } -} -test('grouped orchestration proves rejection, same-group replay and releases owned unpicked sources', async () => { - const { context, calls, checks } = server() - const proof = await verifyGroupedDispatch(context, 'offline', candidates) - assert.equal(proof.status, 'GROUPED_PREARRANGE_VERIFIED') - assert.equal(proof.cleanup, 'OWNED_UNPICKED_SOURCES_RELEASED') - assert.equal(proof.results.length, 2) - assert.ok(checks.every(row => row.passed)) - assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 3) - assert.equal(calls.filter(row => row.method === 'DELETE').length, 2) - assert.ok(calls.every(row => !/\/depart|\/pick|\/delivery/.test(row.path))) +test('FBA allocations preserve actual BOOKING source and legal version zero; missing version is never guessed', () => { + const c = rows()[0]; c.appointmentBatches = [{ batchKey: 'FBA_APPOINTMENT:8', customerId: '55', recordVersion: 0, sources: [{ sourceKey: 'BOOKING:ACTUAL', destinationKey: 'FBA:A1', packageCount: 1 }] }] + const body = createBody('CURRENT', 'FBA', [c]); assert.deepEqual(body.batchAllocations[0], { planPalletId: '21', batchKey: 'FBA_APPOINTMENT:8', customerId: '55', sourceKey: 'BOOKING:ACTUAL', destinationKey: 'FBA:A1', packageCount: 1, recordVersion: 0 }) + delete c.appointmentBatches[0].recordVersion; assert.throws(() => createBody('CURRENT', 'FBA', [c]), /权威recordVersion/) + assert.equal(version(0), 0); for (const missing of [null, undefined, '', false, -1]) assert.throws(() => version(missing)) + assert.equal(version('9223372036854775807'), '9223372036854775807'); assert.equal(versionAfter('9007199254740993', '9007199254740992'), true) + assert.equal(versionAfter('9223372036854775806', '9223372036854775807'), false) + assert.throws(() => version('9223372036854775808')); assert.throws(() => version(9007199254740992)) + assert.throws(() => sourceSelection({ planPalletId: '21' }), /原生来源/) }) -test('rejected grouping that mutates the draft stops before any valid pre-arrangement', async () => { - const { context, calls } = server('reject-mutates-draft') - await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原草稿/) - assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 1) - assert.notEqual(context.createdResources.groupedDispatch.status, 'GROUPED_PREARRANGE_VERIFIED') +test('rejection side effects and merged receipts stop without mutating or cleaning more resources', async () => { + for (const fault of ['reject-mutates-draft', 'merged-groups']) { + const h = server(fault); await assert.rejects(verifyGroupedDispatch(h.context, 'offline', h.candidates)) + assert.equal(h.calls.filter(c => /\/items\/\d+\/remove$/.test(c.path)).length, 0) + assert.equal(h.context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW') + } }) -test('unknown successful-write transport result is not automatically retried or deleted', async () => { - const { context, calls } = server('unknown-post') - await assert.rejects(verifyGroupedDispatch(context, 'offline', candidates), /原子创建/) - assert.equal(calls.filter(row => row.path.endsWith('/drafts/pre-arrange')).length, 2) - assert.equal(calls.filter(row => row.method === 'DELETE').length, 0) - assert.equal(calls.filter(row => row.path.endsWith('/draft-locks/release')).length, 0) - assert.equal(context.createdResources.groupedDispatch.cleanup, 'PRESERVED_FOR_REVIEW') +test('unknown converted write keeps original request keys and forbids fresh writes across sessions', async () => { + const h = server('unknown-convert'); await assert.rejects(verifyGroupedDispatch(h.context, 'offline', h.candidates)) + assert.equal(h.context.createdResources.groupedDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(h.orders.size, 2) + assert.equal(h.calls.filter(c => c.path.endsWith('/drafts/pre-arrange')).length, 2); assert.equal(h.calls.filter(c => /\/remove$/.test(c.path)).length, 0) + const io = dispatchIo(h.context, 'offline', {}, 'ANOTHER'); await assert.rejects(io.command('POST', BASE, {}, 'forbidden'), /停写/) +}) +test('checkpoint failure before send causes no HTTP; callback records original intent before a successful send', async () => { + const context = { marker: 'C', createdResources: {}, request: async () => { throw Error('must not send') }, checkpoint: async () => { throw Error('disk full') } } + const proof = {}, io = dispatchIo(context, 'offline', proof, 'CHECKPOINT'); await assert.rejects(io.command('POST', BASE, { idempotencyKey: 'same' }, 'create'), /disk full/) + assert.equal(proof.requests[0].status, 'NOT_SENT') + let sent = false + const noPersistence = { marker: 'NO_DISK', createdResources: {}, request: async () => { sent = true } } + await assert.rejects(dispatchIo(noPersistence, 'offline', {}, 'NO_DISK').send('POST', BASE, { idempotencyKey: 'must-not-send' }, 'blocked'), /持久化checkpoint/); assert.equal(sent, false) + const phases = [], other = { marker: 'D', createdResources: {}, checkpoint: async () => phases.push('checkpoint'), request: async () => { phases.push('request'); return { http: 200, body: { code: '200', data: { recorded: true } } } } } + await dispatchIo(other, 'offline', {}, 'CHECKPOINT').command('POST', BASE, { idempotencyKey: 'same' }, 'create'); assert.deepEqual(phases, ['checkpoint', 'request', 'checkpoint']) +}) + +test('5xx, transport failure and malformed success stop writes while preserving original diagnostic evidence', async () => { + for (const response of [{ http: 503, raw: 'upstream failure', body: null }, { http: 0, raw: 'connection reset', transportError: { code: 'ECONNRESET' } }, { http: 200, raw: 'unreadable receipt', body: null }, { http: 200, body: { code: '200', data: null } }]) { + let sent = 0 + const context = { marker: 'UNKNOWN', createdResources: {}, checkpoint: async () => {}, request: async () => { sent++; return response } }, proof = {}, io = dispatchIo(context, 'offline', proof, 'UNKNOWN') + await io.send('POST', BASE, { idempotencyKey: 'original-key' }, 'original create') + assert.equal(proof.outcomeUnknown, true); assert.equal(proof.requests[0].status, 'UNKNOWN_RESULT'); assert.equal(proof.requests[0].body.idempotencyKey, 'original-key') + assert.equal(proof.requests[0].raw, response.raw); assert.deepEqual(proof.requests[0].transportError, response.transportError) + await assert.rejects(io.command('POST', BASE, { idempotencyKey: 'fresh-key' }, 'forbidden'), /停写/); assert.equal(sent, 1) + } }) diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs index 2716a0553..16ec5aa51 100644 --- a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs @@ -1,124 +1,265 @@ -const BASE = '/api/platform/v1/last-leg/dispatches' -const OPTIONS = { scenario: 'grouped-dispatch-atomic-draft', deploymentBlocker: true } -const id = value => value == null ? '' : String(value) -const ok = response => response?.http === 200 && String(response.body?.code) === '200' -const sorted = values => [...values].map(id).sort() +import { createHash } from 'node:crypto' +import { mkdir, open, writeFile, rename } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' -export function groupedDispatchMatches(result, groups, candidates) { - if (!Array.isArray(result) || result.length !== groups.length) return false - const dispatchNos = new Set(), seen = new Set() - return result.every(row => { - const group = groups.find(group => id(group.draftContainerId) === id(row.draftContainerId)) - const dispatch = row.dispatch - if (!group || seen.has(id(row.draftContainerId)) || !dispatch?.dispatchNo || dispatchNos.has(dispatch.dispatchNo) - || dispatch.status !== 'PENDING_DEPARTURE' || !Array.isArray(dispatch.items)) return false - seen.add(id(row.draftContainerId)); dispatchNos.add(dispatch.dispatchNo) - const expected = candidates.filter(candidate => group.planPalletIds.includes(id(candidate.planPalletId))) - if (JSON.stringify(sorted(dispatch.items.map(item => item.planPalletId))) !== JSON.stringify(sorted(group.planPalletIds)) - || dispatch.items.length !== group.planPalletIds.length || Number(dispatch.palletCount) !== expected.length - || Number(dispatch.packageCount) !== expected.reduce((sum, item) => sum + Number(item.packageCount), 0)) return false - return dispatch.items.every(item => expected.some(candidate => id(candidate.planPalletId) === id(item.planPalletId) - && id(candidate.warehousePalletId) === id(item.warehousePalletId) - && id(candidate.overseasWarehouseId) === id(item.overseasWarehouseId) - && candidate.inboundCode === item.inboundCode && Number(candidate.packageCount) === Number(item.packageCount))) +export const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches' +export const id = value => value == null ? '' : String(value) +export const canonical = value => JSON.stringify(value) +export const success = response => response?.http === 200 && String(response.body?.code) === '200' +const positiveId = value => /^[1-9]\d*$/.test(id(value)) +const amount = value => (typeof value === 'number' || typeof value === 'string' && /^\d+$/.test(value)) && Number.isSafeInteger(Number(value)) && Number(value) > 0 +export class DispatchNotRun extends Error { constructor(message) { super(message); this.notRun = true } } +export function version(value) { + if (!(typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 || typeof value === 'string' && /^(0|[1-9]\d*)$/.test(value)) + || BigInt(value) > 9223372036854775807n) throw Error('缺少服务器recordVersion或精度无效,不能猜测零版本') + return value +} +export const versionAfter = (next, previous) => BigInt(version(next)) > BigInt(version(previous)) + +// 主run提供真实原子落盘;独立调用者必须显式提供checkpoint,禁止内存意图冒充持久化。 +export async function dispatchEvidenceContext(context) { + if (typeof context.checkpoint === 'function') return context + if (typeof context.marker !== 'string' || !/^[A-Za-z0-9_-]+$/.test(context.marker)) throw new DispatchNotRun('缺少可用于独占检查点的本轮marker') + const path = resolve(dirname(context.options?.output || 'reports/admin-test-env.json'), 'phase4-dispatch-checkpoints', `${context.marker}.json`) + const redact = value => { + if (Array.isArray(value)) return value.map(redact) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value) + .filter(([name]) => !/password|authorization|secret|apiKey|credential|accessToken|refreshToken|idToken|^token$|base64|^bytes$|^content$/i.test(name)) + .map(([name, nested]) => [name, redact(nested)])) + return value + } + await mkdir(dirname(path), { recursive: true }) + let reservation + try { reservation = await open(path, 'wx') } + catch (error) { if (error.code === 'EEXIST') throw Error(`已存在本轮排车检查点,须核对原请求结果,禁止自动重跑:${path}`); throw error } + await reservation.close() + const scoped = Object.create(context) + scoped.checkpoint = async () => { + const resources = context.createdResources + const evidence = redact({ marker: context.marker, boundary: resources.dispatchWriteBoundary, + phase4OverseasDispatch: resources.phase4OverseasDispatch, groupedDispatch: resources.groupedDispatch }) + await writeFile(`${path}.tmp`, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8') + await rename(`${path}.tmp`, path) + } + context.createdResources.phase4OverseasDispatch.checkpointPath = path + await scoped.checkpoint() + return scoped +} +export function query(path, values) { + const params = new URLSearchParams() + for (const [key, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') params.set(key, String(value)) + return `${path}?${params}` +} +export function commandKey(marker, namespace, action) { + return `D4-${createHash('sha256').update(`${marker}\n${namespace}\n${action}`).digest('hex').slice(0, 48)}` +} +// 仅接受当前Candidate DTO;来源摘要、客户、仓与FBA批次均取生产回执。 +export function sourceSelection(candidate) { + const source = candidate?.selection + if (!positiveId(candidate?.customerId) || !positiveId(candidate?.warehouseId) || !amount(candidate?.availablePackages) + || !source || !/^[a-f\d]{64}$/i.test(source.sourceVersion || '') || source.sourceVersion !== candidate.sourceVersion + || !positiveId(source.warehousePalletId) || !Array.isArray(candidate.blockReasons) || candidate.blockReasons.length + || !Array.isArray(candidate.appointmentBatches) || !candidate.destination?.key) throw new DispatchNotRun('缺少完整、可选的本轮原生来源回执') + const expected = source.sourceType === 'INBOUND_PLAN' && positiveId(source.planPalletId) && source.returnReceiptId == null && source.relabelNo == null ? `PLAN:${source.planPalletId}` + : source.sourceType === 'RETURN_RECEIPT' && positiveId(source.returnReceiptId) && source.planPalletId == null && source.relabelNo == null ? `RETURN:${source.returnReceiptId}` + : source.sourceType === 'RELABEL' && source.relabelNo && source.planPalletId == null && source.returnReceiptId == null ? `RELABEL:${source.relabelNo}:${source.warehousePalletId}` : null + if (!expected || candidate.sourceKey !== expected) throw new DispatchNotRun('来源选择器或服务器来源键不完整') + return { sourceType: source.sourceType, planPalletId: source.planPalletId, returnReceiptId: source.returnReceiptId, + relabelNo: source.relabelNo, warehousePalletId: source.warehousePalletId, sourceVersion: source.sourceVersion } +} +export function createBody(marker, namespace, candidates) { + if (!candidates.length || new Set(candidates.map(c => id(c.warehouseId))).size !== 1) throw new DispatchNotRun('整车缺少同仓真实货物') + const cargo = candidates.map(sourceSelection), batchAllocations = [] + for (const candidate of candidates) { + const batches = candidate.appointmentBatches + if (!batches.length) continue + if (batches.length !== 1 || !positiveId(candidate.selection.planPalletId)) throw new DispatchNotRun('来源跨多个FBA批次,缺少本轮明确逐批实物分配,不能推算') + const batch = batches[0] + if (!/^FBA_APPOINTMENT:[1-9]\d*$/.test(batch.batchKey || '') || id(batch.customerId) !== id(candidate.customerId) + || !Array.isArray(batch.sources) || !batch.sources.length || batch.sources.some(s => !/^(BOOKING:.+|ORDER:[1-9]\d*)$/.test(s.sourceKey || '') || !s.destinationKey?.startsWith('FBA:') || !amount(s.packageCount)) + || batch.sources.reduce((sum, s) => sum + Number(s.packageCount), 0) !== Number(candidate.availablePackages)) throw new DispatchNotRun('FBA批次未完整覆盖本托真实逐票货物') + let batchVersion + try { batchVersion = version(batch.recordVersion) } catch { throw new DispatchNotRun('FBA批次缺少权威recordVersion') } + for (const source of batch.sources) batchAllocations.push({ planPalletId: candidate.selection.planPalletId, batchKey: batch.batchKey, + customerId: candidate.customerId, sourceKey: source.sourceKey, destinationKey: source.destinationKey, + packageCount: Number(source.packageCount), recordVersion: batchVersion }) + } + return { idempotencyKey: commandKey(marker, namespace, 'create'), warehouseId: candidates[0].warehouseId, + vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, + remark: `${marker}-${namespace}`, cargo, batchAllocations } +} +export function markerCandidates(context, candidates) { + const fixture = context.createdResources?.phase4OverseasPda + const pallets = new Set([...(fixture?.palletIds || []), ...Object.values(fixture?.generatedPalletsByInbound || {}).flat().filter(p => id(p.warehouseId) === id(fixture?.overseasWarehouseId)).map(p => p.palletId)].map(id)) + if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED' || !positiveId(fixture?.customerId) || !positiveId(fixture?.overseasWarehouseId)) throw new DispatchNotRun('缺少本轮PDA完成回执及真实客户仓') + return candidates.filter(candidate => { + try { sourceSelection(candidate) } catch { return false } + return candidate.selection.sourceType === 'INBOUND_PLAN' && pallets.has(id(candidate.selection.warehousePalletId)) + && id(candidate.customerId) === id(fixture.customerId) && id(candidate.warehouseId) === id(fixture.overseasWarehouseId) + && candidate.destination.type === 'FBA' }) } - -// Reuses only current-run unpicked stock; independent empty dispatch records remain as audit evidence. -export async function verifyGroupedDispatch(context, module, candidates) { - if (!Array.isArray(candidates) || candidates.length < 2 - || new Set(candidates.map(row => id(row.planPalletId))).size !== candidates.length - || new Set(candidates.map(row => id(row.warehousePalletId))).size !== candidates.length - || candidates.some(row => ['planId', 'planPalletId', 'warehousePalletId', 'overseasWarehouseId', 'customerId'] - .some(key => typeof row[key] !== 'string' || !/^[1-9]\d*$/.test(row[key])) - || typeof row.inboundCode !== 'string' || !row.inboundCode.trim() - || !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) < 1)) { - throw Error('分组预排缺少本轮两个独立实物托盘') - } - const draftToken = `${context.marker}-GROUPED`.slice(0, 64) - const proof = { marker: context.marker, draftToken, status: 'CHECKING', groups: [], results: [], cleanup: 'PENDING' } - context.createdResources.groupedDispatch = proof - const check = (label, response, passed) => { - context.assert(module, label, response, passed, `draftToken=${draftToken}`, OPTIONS) - if (!passed) throw Error(`分组预排:${label}`) - } - const request = async (method, path, body) => { - let response - try { response = await context.request(method, path, body) } - catch (error) { - if (!['GET', 'HEAD'].includes(method)) { - proof.outcomeUnknown = true - proof.cleanup = 'PRESERVED_FOR_REVIEW' - } - throw error - } - if (!['GET', 'HEAD'].includes(method) && (!response?.http || response.http >= 500)) { - proof.outcomeUnknown = true - proof.cleanup = 'PRESERVED_FOR_REVIEW' - } - return response - } - const command = async (method, path, body, label) => { - const response = await request(method, path, body) - check(label, response, ok(response)); return response.body.data - } - const ownDraft = async () => { - const rows = await command('GET', `${BASE}/drafts`, undefined, '读取分组预排草稿作用域') - if (!Array.isArray(rows)) throw Error('分组预排草稿返回格式无效') - return rows.filter(row => row.draftToken === draftToken) - } - try { - await command('POST', `${BASE}/drafts/snapshot`, { draftToken, - planIds: [...new Set(candidates.map(row => id(row.planId)))], containersJson: '[]' }, '保存本轮分组草稿') - for (const candidate of candidates) { - const container = await command('POST', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }, '建立独立装车分组') - if (!container?.id) throw Error('分组缺少服务端柜ID') - const group = { draftContainerId: id(container.id), planPalletIds: [id(candidate.planPalletId)], returnReceiptIds: [] } - proof.groups.push(group) - await command('POST', `${BASE}/draft-locks/lock`, { draftToken, draftContainerId: group.draftContainerId, - planPalletId: group.planPalletIds[0] }, '将本轮托盘绑定到所属装车分组') - } - const before = JSON.stringify(await ownDraft()) - const swapped = proof.groups.map((group, index) => ({ ...group, - planPalletIds: proof.groups[(index + 1) % proof.groups.length].planPalletIds })) - const rejected = await request('POST', `${BASE}/drafts/pre-arrange`, { draftToken, groups: swapped }) - check('来源与分组不一致明确拒绝A0409', rejected, - [200, 409].includes(rejected.http) && String(rejected.body?.code) === 'A0409') - check('分组拒绝后原草稿全部来源与绑定不变', null, JSON.stringify(await ownDraft()) === before) - const body = { draftToken, groups: proof.groups } - const result = await command('POST', `${BASE}/drafts/pre-arrange`, body, '原子创建每组独立客户排车') - if (Array.isArray(result)) proof.results = result - check('每组一张排车且实物托盘与数量不跨组', null, groupedDispatchMatches(result, proof.groups, candidates)) - const replay = await command('POST', `${BASE}/drafts/pre-arrange`, body, '同草稿同分组重放') - check('分组重放保留原排车业务号与明细', null, JSON.stringify(replay) === JSON.stringify(result)) - proof.status = 'GROUPED_PREARRANGE_CHECKED' - } finally { - // Only release resources whose successful response identified an owned dispatch; never retry an unknown POST. - if (!proof.outcomeUnknown) { - for (const row of proof.results) { - const dispatchNo = row.dispatch?.dispatchNo - if (!dispatchNo) continue - const detail = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '清理前回查分组排车仍未拣货') - check('仅清理本轮分组未拣托盘', null, detail?.status === 'PENDING_DEPARTURE' - && groupedDispatchMatches([{ ...row, dispatch: detail }], - proof.groups.filter(group => id(group.draftContainerId) === id(row.draftContainerId)), candidates) - && detail.items.every(item => /^[1-9]\d*$/.test(id(item.id))) - && Number(detail.pickedPackageCount) === 0 && Number(detail.shippedPackageCount) === 0) - for (const item of detail.items) { - await command('DELETE', `${BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.id))}`, - undefined, '移除分组测试明细释放正式托盘锁') - } - const empty = await command('GET', `${BASE}/${encodeURIComponent(dispatchNo)}`, undefined, '回查分组对照单明细归零') - check('分组对照排车释放后数量归零', null, - empty.items?.length === 0 && Number(empty.palletCount) === 0 && Number(empty.packageCount) === 0) - } - await command('POST', `${BASE}/draft-locks/release`, { draftToken }, '释放本轮分组草稿') - const available = await command('GET', `${BASE}/candidates?sourceStage=RECEIVED`, undefined, '分组检查后回查托盘可继续主链预排') - check('分组验收来源回到正式候选', null, Array.isArray(available) - && candidates.every(candidate => available.some(row => id(row.planPalletId) === id(candidate.planPalletId)))) - proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED' - } - } - proof.status = 'GROUPED_PREARRANGE_VERIFIED' +export function notRun(context, proof, reason) { + proof.status = 'NOT_RUN'; proof.reason = reason + context.addCoverageGap?.('phase4-overseas-dispatch', 'native-dispatch', '缺少当前生产前置', reason, 'fixture', { deploymentBlocker: true }) return proof } +// 请求意图必须先持久化,未知写结果使同marker后续写入全部停止。 +export function dispatchIo(context, module, proof, namespace) { + context.createdResources ??= {}; proof.requests ??= [] + const boundary = context.createdResources.dispatchWriteBoundary ??= { marker: context.marker, stopped: false } + if (!context.marker || boundary.marker !== context.marker) throw new DispatchNotRun('请求边界不属于本轮marker') + const checkpoint = async () => { if (typeof context.checkpoint === 'function') await context.checkpoint() } + const check = (label, response, passed) => { + context.assert?.(module, label, response, passed, `marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true }) + if (!passed) throw Error(`${namespace}: ${label}`) + } + const send = async (method, path, body, label) => { + const write = !['GET', 'HEAD'].includes(method) + if (write && boundary.stopped) throw Error('本轮写请求结果未知或回执存档失败,停写并保留原键') + if (write && typeof context.checkpoint !== 'function') throw new DispatchNotRun('缺少真实持久化checkpoint,未发送排车写请求') + const entry = { method, path, ...(body === undefined ? {} : { body: structuredClone(body) }), label, status: 'SENDING' } + proof.requests.push(entry) + try { await checkpoint() } catch (error) { boundary.stopped = true; entry.status = 'NOT_SENT'; throw error } + let response + try { response = await context.request(method, path, body) } + catch (error) { entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; if (write) { boundary.stopped = true; boundary.outcomeUnknown = true; proof.outcomeUnknown = true; proof.cleanup = 'PRESERVED_FOR_REVIEW' }; await checkpoint(); throw error } + const unknown = !response?.http || response.http >= 500 || write && (response.body?.code == null || success(response) && response.body.data == null) + Object.assign(entry, { status: unknown ? 'UNKNOWN_RESULT' : 'RESPONDED', http: response?.http, + code: response?.body?.code, message: response?.body?.message, raw: response?.raw, transportError: response?.transportError, + requestId: response?.requestId, traceId: response?.traceId, result: structuredClone(response?.body?.data) }) + if (write && entry.status === 'UNKNOWN_RESULT') { boundary.stopped = true; boundary.outcomeUnknown = true; proof.outcomeUnknown = true; proof.cleanup = 'PRESERVED_FOR_REVIEW' } + try { await checkpoint() } catch (error) { if (write) boundary.stopped = true; throw error } + return response + } + const command = async (method, path, body, label) => { const response = await send(method, path, body, label); check(label, response, success(response)); return response.body.data } + const key = action => commandKey(context.marker, namespace, action) + return { check, send, command, key } +} +export function replayFacts(result) { const copy = structuredClone(result); if (copy && typeof copy === 'object') delete copy.replayed; return copy } +export async function replay(io, path, body, result, label, method = 'POST') { + const again = await io.command(method, path, body, label) + io.check(`${label}恢复原命令结果`, null, canonical(replayFacts(again)) === canonical(replayFacts(result))) + return again +} +export async function candidatePage(io, warehouseId, extra = {}) { + const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, { warehouseId, sourceStage: 'RECEIVED', ...extra }), undefined, '读取同仓原生候选') + io.check('候选完整包络', null, result?.complete === true && Array.isArray(result.candidates)) + return result.candidates +} +export async function readDispatch(io, dispatchNo) { + const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}` + const data = await io.command('GET', path, undefined, '回查原生整车详情') + const items = await io.command('GET', `${path}/items`, undefined, '回查真实货物明细') + const route = await io.command('GET', `${path}/route`, undefined, '回查真实起点与目的地') + const page = await io.command('GET', query(DISPATCH_BASE, { page: 1, size: 20, dispatchNo }), undefined, '按业务号核对整车仓库') + const rows = page?.records?.filter(r => r.dispatchNo === dispatchNo) || [] + io.check('主单、货物、路线版本与实际仓一致', null, data?.dispatchNo === dispatchNo && items?.dispatchNo === dispatchNo && route?.dispatchNo === dispatchNo + && rows.length === 1 && positiveId(rows[0].warehouseId) && id(version(data.recordVersion)) === id(version(items.recordVersion)) + && id(version(route.recordVersion)) === id(version(data.recordVersion)) && canonical(rows[0].counts) === canonical(data.counts) + && id(rows[0].recordVersion) === id(data.recordVersion) && rows[0].status === data.status && rows[0].stage === data.stage + && items.status === data.status && items.stage === data.stage) + return { data, items, route, warehouseId: rows[0].warehouseId } +} +export function dispatchMatches(bundle, candidates) { + const items = bundle?.items?.items, counts = bundle?.data?.counts + if (!Array.isArray(items) || !counts || !Array.isArray(candidates) || bundle.data.status !== 'ACTIVE' || bundle.data.stage !== 'BEFORE_LOADING' + || items.length !== candidates.length || Number(counts.itemCount) !== items.length || Number(counts.palletCount) !== new Set(candidates.map(c => id(c.selection.warehousePalletId))).size + || Number(counts.packageCount) !== candidates.reduce((n, c) => n + Number(c.availablePackages), 0) || Number(counts.pickedPackageCount) !== 0 || Number(counts.shippedPackageCount) !== 0) return false + const details = bundle.data.orderDetails + if (!Array.isArray(details) || new Set(details.map(d => d.sourceKey)).size !== candidates.length + || details.some(d => !candidates.some(c => c.sourceKey === d.sourceKey && id(c.customerId) === id(d.customerId) + && c.inboundCode === d.inboundCode && c.destination.warehouseCode === d.destinationFbaWarehouse))) return false + return new Set(items.map(i => id(i.dispatchItemId))).size === items.length && new Set(items.map(i => i.sourceKey)).size === items.length && items.every(i => positiveId(i.dispatchItemId) && candidates.some(c => + c.sourceKey === i.sourceKey && c.sourceVersion === i.sourceVersion && id(c.customerId) === id(i.customerId) && id(c.warehouseId) === id(bundle.warehouseId) + && id(c.selection.warehousePalletId) === id(i.warehousePalletId) && id(c.selection.planPalletId) === id(i.planPalletId) + && c.inboundCode === i.inboundCode && c.destination.key === i.destination?.key && Number(c.availablePackages) === Number(i.scheduledPackages) && Number(i.pickedPackages) === 0)) +} +export function groupedDispatchMatches(result, groups, candidates) { + if (!Array.isArray(result?.vehicles) || result.vehicles.length !== groups.length) return false + const seen = new Set(), numbers = new Set() + return result.vehicles.every(row => { + const group = groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)), command = row.dispatch + const selected = candidates.filter(c => group?.sourceKeys.includes(c.sourceKey)) + if (!group || seen.has(id(row.draftContainerId)) || numbers.has(command?.dispatchNo) || !command?.dispatchNo || command.action !== 'CREATE' || command.status !== 'ACTIVE' || command.stage !== 'BEFORE_LOADING') return false + seen.add(id(row.draftContainerId)); numbers.add(command.dispatchNo) + return Number(command.counts?.itemCount) === selected.length && Number(command.counts?.palletCount) === selected.length + && Number(command.counts?.packageCount) === selected.reduce((n, c) => n + Number(c.availablePackages), 0) + }) +} +export async function newDraft(io, warehouseId) { + const body = { idempotencyKey: io.key('draft-create'), warehouseId } + const draft = await io.command('POST', `${DISPATCH_BASE}/drafts`, body, '创建服务器草稿token') + io.check('服务器新草稿身份及零版本', null, Boolean(draft?.draftToken) && id(draft.warehouseId) === id(warehouseId) && id(version(draft.recordVersion)) === '0' && Array.isArray(draft.containers) && Array.isArray(draft.sources)) + await replay(io, `${DISPATCH_BASE}/drafts`, body, draft, '创建草稿同键重放') + return draft +} +export async function activeDraft(io, token) { + const rows = await io.command('GET', `${DISPATCH_BASE}/drafts`, undefined, '回查当前拥有者草稿') + io.check('原服务器草稿唯一存在', null, Array.isArray(rows) && rows.filter(d => d.draftToken === token).length === 1) + return rows.find(d => d.draftToken === token) +} +// 分组全成功才移除已证明属于本轮的未拣实物;失败或未知响应保留所有已成功资源。 +export async function verifyGroupedDispatch(context, module, candidates) { + const proof = { marker: context.marker, status: 'CHECKING', groups: [], results: [], cleanup: 'PRESERVED_FOR_REVIEW', requests: [] } + if (context.createdResources?.groupedDispatch?.requests?.length) throw Error('本轮分组尝试已经存在,禁止自动重造') + context.createdResources ??= {}; context.createdResources.groupedDispatch = proof + try { + const owned = markerCandidates(context, candidates) + if (owned.length !== candidates.length || candidates.length < 2 || new Set(candidates.map(c => c.sourceKey)).size !== candidates.length + || new Set(candidates.map(c => id(c.selection?.warehousePalletId))).size !== candidates.length) throw new DispatchNotRun('分组须有本轮两个独立未拣实托') + candidates.forEach((c, n) => createBody(context.marker, `GROUP-${n}`, [c])) + const io = dispatchIo(context, module, proof, 'GROUPED') + let draft = await newDraft(io, candidates[0].warehouseId); proof.draftToken = draft.draftToken + for (const [n, candidate] of candidates.entries()) { + const previous = new Set(draft.containers.map(c => id(c.draftContainerId))) + draft = await io.command('POST', `${DISPATCH_BASE}/drafts/containers`, { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(`container-${n}`), draftToken: draft.draftToken, capacity: 15 }, '创建真实草稿容器') + const additions = draft.containers.filter(c => !previous.has(id(c.draftContainerId))) + io.check('容器身份来自服务器草稿', null, additions.length === 1 && positiveId(additions[0].draftContainerId)) + const group = { draftContainerId: additions[0].draftContainerId, sourceKeys: [candidate.sourceKey] }; proof.groups.push(group) + const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(`lock-${n}`), draftToken: draft.draftToken, draftContainerId: group.draftContainerId, source: sourceSelection(candidate) } + draft = await io.command('POST', `${DISPATCH_BASE}/draft-locks/lock`, body, '锁定完整真实来源到原容器') + await replay(io, `${DISPATCH_BASE}/draft-locks/lock`, body, draft, '锁定同键重放') + } + const snapshot = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('snapshot'), draftToken: draft.draftToken, planIds: [], containers: draft.containers } + draft = await io.command('POST', `${DISPATCH_BASE}/drafts/snapshot`, snapshot, '保存版本化完整容器布局') + await replay(io, `${DISPATCH_BASE}/drafts/snapshot`, snapshot, draft, '布局同键重放') + const groups = proof.groups.map((g, n) => ({ draftContainerId: g.draftContainerId, dispatch: createBody(context.marker, `GROUPED-CHILD-${n}`, candidates.filter(c => g.sourceKeys.includes(c.sourceKey))) })) + const before = await activeDraft(io, draft.draftToken) + const wrong = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert-reject'), draftToken: draft.draftToken, + groups: groups.map((g, n) => ({ ...g, dispatch: groups[(n + 1) % groups.length].dispatch })) } + const rejected = await io.send('POST', `${DISPATCH_BASE}/drafts/pre-arrange`, wrong, '错组转换拒绝') + io.check('错组来源明确冲突', rejected, [200, 409].includes(rejected?.http) && String(rejected.body?.code) === 'A0409') + io.check('拒绝后原草稿不变', null, canonical(await activeDraft(io, draft.draftToken)) === canonical(before)) + const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert'), draftToken: draft.draftToken, groups } + const converted = await io.command('POST', `${DISPATCH_BASE}/drafts/pre-arrange`, body, '原子转换一容器一整车') + io.check('真实分组命令数量与货物核平', null, converted?.draftToken === draft.draftToken && groupedDispatchMatches(converted, proof.groups, candidates)) + proof.results = converted.vehicles + for (const row of proof.results) { + const selected = candidates.filter(c => proof.groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)).sourceKeys.includes(c.sourceKey)) + const current = await readDispatch(io, row.dispatch.dispatchNo) + io.check('成功创建后逐车回查真实来源', null, dispatchMatches(current, selected)) + } + await replay(io, `${DISPATCH_BASE}/drafts/pre-arrange`, body, converted, '整批转换同键重放') + for (const row of proof.results) { + let current = await readDispatch(io, row.dispatch.dispatchNo) + const selected = candidates.filter(c => proof.groups.find(g => id(g.draftContainerId) === id(row.draftContainerId)).sourceKeys.includes(c.sourceKey)) + io.check('清理前仍为本轮完整未拣货物', null, dispatchMatches(current, selected)) + for (const item of current.items.items) { + io.check('服务端允许移出本轮实物', null, item.remove?.allowed === true) + const remove = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key(`remove-${item.dispatchItemId}`), reason: `${context.marker}-分组验收释放未拣实物` } + await io.command('POST', `${DISPATCH_BASE}/${encodeURIComponent(row.dispatch.dispatchNo)}/items/${encodeURIComponent(item.dispatchItemId)}/remove`, remove, '版本化移出本轮分组实物') + current = await readDispatch(io, row.dispatch.dispatchNo) + } + io.check('空对照单真实数量归零', null, dispatchMatches(current, [])) + } + const available = await candidatePage(io, candidates[0].warehouseId) + io.check('原来源回到可选集合', null, candidates.every(c => available.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) + proof.cleanup = 'OWNED_UNPICKED_SOURCES_RELEASED'; proof.status = 'GROUPED_DISPATCH_VERIFIED'; await context.checkpoint() + return proof + } catch (error) { + if (error.notRun && !proof.requests.length) return notRun(context, proof, error.message) + proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message + throw error + } +} diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index d03ec8a05..93e69d6f6 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -1,4 +1,5 @@ import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import test from 'node:test' import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs' @@ -17,7 +18,7 @@ test('种子解析覆盖多行多值、函数括号及带逗号和引号的文 }) test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 214]]) { + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 228]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -76,7 +77,47 @@ test('退回实收按真实流水唯一保存,重派拣货必须明确唯一 } const pick = table(overseas, 'baiyue_lastleg', 'll_dispatch_pda_pick_order') assert.match(pick, /\breceipt_id BIGINT NULL/i) - assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL\) \+ \(return_receipt_id IS NOT NULL\) \+ \(return_carton_id IS NOT NULL\) = 1\)/i) + assert.match(pick, /\bdispatch_item_source_id BIGINT NULL/i) + assert.match(pick, /CHECK\(\(receipt_id IS NOT NULL\) \+ \(return_receipt_id IS NOT NULL\) \+ \(return_carton_id IS NOT NULL\) \+ \(dispatch_item_source_id IS NOT NULL\) = 1\)/i) +}) + +test('同实托初始化分离无代表客户的物理占用与逐来源占用', () => { + const physical = table(overseas, 'baiyue_lastleg', 'll_dispatch_physical_pallet_lock') + assert.doesNotMatch(physical, /\bcustomer_id\b/i) + assert.match(physical, /UNIQUE KEY[^\n]*\(active_warehouse_pallet_id\)/i) + assert.match(physical, /CHECK\(\(status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id\)/i) + assert.match(physical, /dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL/i) + assert.match(physical, /dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL/i) + const logical = table(overseas, 'baiyue_lastleg', 'll_dispatch_pallet_lock') + assert.match(logical, /physical_lock_id BIGINT NULL/i) + assert.match(logical, /UNIQUE KEY[^\n]*\(customer_id,active_key\)/i) + assert.doesNotMatch(logical, /active_warehouse_pallet_id/i) + assert.match(table(overseas, 'baiyue_lastleg', 'll_dispatch_item'), /UNIQUE KEY[^\n]*\(dispatch_order_id,source_key\)/i) + const source = table(overseas, 'baiyue_lastleg', 'll_dispatch_item_source') + assert.match(source, /UNIQUE KEY[^\n]*\(dispatch_item_id,relabel_item_id,inventory_position_id\)/i) + assert.match(source, /product_quantity BIGINT NULL/i) + assert.match(source, /source_inbound_order_id BIGINT NULL/i) + assert.match(source, /relabel_round_key VARCHAR\(128\) NOT NULL/i) + assert.match(source, /unit_facts_json JSON NOT NULL/i) +}) + +test('实托组成串行点只保存真实主键和首次创建时间', () => { + const guard = table(overseas, 'baiyue_lastleg', 'll_physical_pallet_guard') + assert.match(guard, /warehouse_pallet_id BIGINT NOT NULL/i) + assert.match(guard, /created_at DATETIME\(3\) NOT NULL DEFAULT CURRENT_TIMESTAMP\(3\)/i) + assert.match(guard, /PRIMARY KEY\s*\(warehouse_pallet_id\)/i) + assert.doesNotMatch(guard, /\b(?:customer_id|status|expires_at|dispatch_order_id|draft_token|draft_container_id)\b/i) +}) + +test('同实托六表定义在模块和统一空库入口保持一致', () => { + const module = 'USE baiyue_lastleg;\n' + readFileSync(new URL('../../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8') + .replace(/^[ \t]*CREATE TABLE IF NOT EXISTS /gm, 'CREATE TABLE ') + .replace(/^[ \t]+\) ENGINE=/gm, ') ENGINE=') + const body = value => value.slice(value.indexOf('('), value.lastIndexOf(') ENGINE=')) + .replace(/\r\n/g, '\n').replace(/[ \t]+/g, ' ').trim() + for (const name of ['ll_dispatch_item', 'll_dispatch_item_source', 'll_physical_pallet_guard', 'll_dispatch_physical_pallet_lock', 'll_dispatch_pallet_lock', 'll_dispatch_pda_pick_order']) { + assert.equal(body(table(overseas, 'baiyue_lastleg', name)), body(table(module, 'baiyue_lastleg', name)), name) + } }) test('车辆报价与实测记录保留业务去重和不可变版本', () => { @@ -133,7 +174,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 433, iam_role_permission: 614, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 434, iam_role_permission: 618, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -160,8 +201,15 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const row of permissions) if (row.parent_id && row.parent_id !== '0') assert.ok(permissionIds.has(row.parent_id), 'missing parent ' + row.parent_id) const merchant = roleAuthorities(cn, 'MERCHANT_OWNER') for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement', - 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) + 'merchant:warehouse:delivery:list']) assert.ok(merchant.has(code), code) + assert.ok(!permissions.some(row => row.permission_code === 'merchant:warehouse:delivery:create')) + assert.ok(!merchant.has('merchant:warehouse:delivery:create')) const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') + for (const action of ['create', 'loading-complete', 'depart']) { + const code = `platform:overseas-warehouse:dispatch:${action}` + assert.ok(admin.has(code), code) + assert.ok(!merchant.has(code), code) + } assert.ok(admin.has('platform:overseas-warehouse:discard:approve'), 'discard review endpoint must be grantable to platform administrator') for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) diff --git a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs index d4c47053d..19fd5a923 100644 --- a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs +++ b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs @@ -39,16 +39,24 @@ test('transfer review integration covers both source types and known blank-reaso test('draft and removal checks use actual IDs plus readback rather than snapshot-only success', async () => { const source = await read('phase4-overseas-dispatch.mjs') - for (const value of ['drafts/containers', 'draftContainerId: containerId', 'container.palletIds', - 'draft-locks/unlock', 'deletedDrafts', 'removableItem.id', 'removedDetail.data?.packageCount', 'freed.data.some']) + for (const value of ['drafts/containers', 'draftContainerId: temporary', 'draft.containers', + 'draft-locks/unlock', 'drafts/pre-arrange', 'item.dispatchItemId', 'current.data.counts.palletCount', + 'available.some(c => c.sourceKey === duplicate.sourceKey', 'dispatchBusinessSnapshot(await readDispatch']) assert.ok(source.includes(value), value) }) test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => { const source = await read('phase5-overseas-fulfillment.mjs') for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery', - 'adminReplay.data?.replayed', 'conflict(deniedRemove)', 'stable.data?.items?.map']) + 'replay.data?.replayed', 'JSON.stringify(replay.data.result) === JSON.stringify(result)', + 'sameSnapshot(proof.stockAfter, proof.stockAfterDepart)', 'sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)']) assert.ok(source.includes(value), value) + const helper = await read('fleet-trip-fulfillment.mjs') + for (const value of ['state.itemsBefore', 'JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)', + 'proof.loadingCommand.replay = replay', 'proof.loadingCommand.platformRejection = await command', + "{ code: 'A0403', message: '该请求标识不属于当前操作员' }", 'JSON.stringify(replay.result) === JSON.stringify(confirmed.result)', + 'JSON.stringify(proof.loadingCommand.beforeReplay) === JSON.stringify(proof.loadingCommand.afterReplay)']) + assert.ok(helper.includes(value), value) }) test('return merge sources are actual consumed batches, not a synthetic MERGED identifier', async () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index 09d37da67..ee62dac6a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -1,193 +1,180 @@ -import test from 'node:test' import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' -import { fileURLToPath } from 'node:url' -import { dispatchBusinessSnapshot, dispatchRouteSnapshot, dispatchRouteMatches, - selectDispatchRouteFixture, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join, resolve, basename } from 'node:path' +import { DISPATCH_BASE as BASE, dispatchEvidenceContext } from './grouped-dispatch-acceptance.mjs' +import { run, selectDispatchRouteFixture, dispatchRouteMatches, dispatchBusinessSnapshot, verifyDispatchRouteLifecycle } from './phase4-overseas-dispatch.mjs' -const moduleUrl = new URL('./phase4-overseas-dispatch.mjs', import.meta.url) -const source = await readFile(fileURLToPath(moduleUrl), 'utf8') - -test('phase4 overseas dispatch module freezes only stable platform query and smart pre-arrange paths', () => { - for (const token of [ - "const BASE = '/api/platform/v1/last-leg/dispatches'", - "query(BASE, { page: 1, size: 20 })", - '`${BASE}/candidates`', '`${BASE}/drafts`', '`${BASE}/drafts/snapshot`', - '`${BASE}/draft-locks/lock`', '`${BASE}/pre-arrange`', '`${BASE}/draft-locks/release`', - '`${BASE}/${encodeURIComponent(dispatchNo)}`', - ]) assert.match(source, new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) - for (const forbidden of ['/api/pda/', '/outbound', '/delivery', '/depart', '/cancel']) { - assert.equal(source.includes(forbidden), false, `must exclude ${forbidden}`) - } -}) - -test('module uses marker-owned completed PDA pallets and never borrows historical candidates', () => { - assert.match(source, /fixture\?\.marker !== context\.marker/) - assert.match(source, /fixture\?\.finalStatus !== 'COMPLETED'/) - assert.match(source, /fixture\.palletIds\.has\(id\(value\?\.warehousePalletId\)\)/) - assert.match(source, /id\(value\?\.overseasWarehouseId\) === id\(fixture\.overseasWarehouseId\)/) - assert.doesNotMatch(source, /candidates\[0\]/) -}) - -test('ordinary dispatch selects one current-run pallet per destination for multi-FBA delivery proof', () => { - assert.match(source, /const candidatesByDestination = new Map\(selectedCandidates.map/) - assert.match(source, /row\?\.fbaWarehouseCode \|\| row\?\.destinationWarehouseCode/) - assert.match(source, /selectedCandidates\.length >= 2/) - assert.match(source, /planPalletIds, fbaAppointmentNo: null/) - assert.match(source, /Number\(arranged\.data\?\.palletCount\) === planPalletIds\.length/) -}) - -function routeCandidates() { - return [['20', '120', 'ABE4', 2], ['30', '130', 'ABE8', 3], ['21', '121', 'ABE4', 4]].map(([planPalletId, warehousePalletId, fbaWarehouseCode, packageCount]) => ({ - planId: '10', planPalletId, warehousePalletId, fbaWarehouseCode, destinationWarehouseCode: fbaWarehouseCode, - overseasWarehouseId: '900', customerId: '100', inboundCode: `IN-${planPalletId}`, sourceStage: 'RECEIVED', destinationType: 'FBA', packageCount, - })) -} -function responseFor(candidates) { - return { id: '1', dispatchNo: 'DS-TEST', overseasWarehouseId: '900', status: 'PENDING_DEPARTURE', - route: [...new Set(candidates.map(row => row.fbaWarehouseCode))].join(','), - items: candidates.map(row => ({ ...row, id: '8' + row.planPalletId })), - itemCount: candidates.length, palletCount: candidates.length, - packageCount: candidates.reduce((sum, row) => sum + row.packageCount, 0), pickedPackageCount: 0, shippedPackageCount: 0 } -} - -test('C52 fixture needs two destinations and two distinct physical pallets at one destination', () => { - const rows = routeCandidates() - assert.equal(selectDispatchRouteFixture(rows.slice(0, 2)), null) - assert.equal(selectDispatchRouteFixture([rows[0], rows[2]]), null) - assert.equal(selectDispatchRouteFixture([rows[0], rows[1], { ...rows[2], warehousePalletId: rows[0].warehousePalletId }]), null) - const result = selectDispatchRouteFixture(rows) - assert.deepEqual(result.selected.map(row => row.planPalletId), ['20', '30']) - assert.equal(result.duplicate.planPalletId, '21') - assert.match(source, /generatedPalletsByInbound/) - assert.match(source, /id\(value\?\.customerId\) === id\(fixture.customerId\)/) - assert.match(source, /value\?\.destinationType === 'FBA'/) -}) - -test('C52 independently derives route from actual rows and rejects fabricated destinations, sources, totals and duplicate route codes', () => { - const rows = routeCandidates(), value = responseFor(rows) - assert.equal(dispatchRouteMatches(value, rows), true) - for (const route of ['ABE4', 'ABE4,ABE8,ABE9', 'ABE4,ABE8,ABE4', 'ABE4,ABE8,', '']) { - assert.equal(dispatchRouteMatches({ ...value, route }, rows), false) - } - for (const patch of [{ itemCount: 9 }, { packageCount: 10 }, { overseasWarehouseId: '901' }, { status: 'PICKING' }]) { - assert.equal(dispatchRouteMatches({ ...value, ...patch }, rows), false) - } - for (const patch of [{ inboundCode: 'FOREIGN' }, { warehousePalletId: '999' }, { fbaWarehouseCode: 'ABE9' }, { planPalletId: '999' }]) { - assert.equal(dispatchRouteMatches({ ...value, items: [{ ...value.items[0], ...patch }, ...value.items.slice(1)] }, rows), false) - } - assert.notDeepEqual(dispatchRouteSnapshot(value), dispatchRouteSnapshot({ ...value, items: [{ ...value.items[0], id: '999' }, ...value.items.slice(1)] })) - assert.notEqual(dispatchBusinessSnapshot(value), dispatchBusinessSnapshot({ ...value, route: 'ABE9' })) -}) - -function lifecycleHarness(fault = '') { - const rows = routeCandidates(), selected = rows.slice(0, 2) - let state = responseFor(selected), nextItemId = 900, preArrangeCount = 0 - const calls = [], checks = [] - const base = '/api/platform/v1/last-leg/dispatches' - const ok = data => ({ http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'REQ', traceId: 'TRACE' }) - const error = code => ({ http: 200, body: { code }, requestId: 'REQ', traceId: 'TRACE' }) - const context = { marker: 'CURRENT', createdResources: { phase4OverseasDispatch: {} }, - apiSuccess: (_module, _name, response) => response.body.data, - assert: (_module, name, _response, passed) => checks.push({ name, passed }), - request: async (method, path, body) => { - calls.push({ method, path, body }) - if (path.includes('/candidates')) return ok(rows.filter(row => !state.items.some(item => item.planPalletId === row.planPalletId))) - if (path.endsWith('/draft-locks/lock')) return ok(null) - if (method === 'GET') return ok(fault === 'wrong-route' ? { ...state, route: 'ABE9' } : state) - if (method === 'DELETE') { - const itemId = path.split('/').at(-1), remaining = state.items.filter(row => row.id !== itemId) - if (remaining.length === state.items.length) { - if (fault === 'rejected-delete-mutates') state.route = 'ABE9' - return error('A0404') +const clone = value => structuredClone(value) +const rows = () => [1, 2, 3].map(n => ({ selection: { sourceType: 'INBOUND_PLAN', planPalletId: `2${n}`, returnReceiptId: null, relabelNo: null, warehousePalletId: `3${n}`, sourceVersion: `${n}`.repeat(64) }, + sourceKey: `PLAN:2${n}`, sourceVersion: `${n}`.repeat(64), customerId: '55', warehouseId: '44', inboundCode: `IN-${n}`, palletNo: `P-${n}`, + destination: { key: `FBA:A${n === 3 ? 1 : n}`, type: 'FBA', warehouseCode: `A${n === 3 ? 1 : n}` }, availablePackages: n + 1, appointmentBatches: [], blockReasons: [] })) +// All state here is offline test data. This server rejects missing versions and preserves original-key results. +function server(fault = '', initial = false) { + const candidates = rows(), drafts = new Map(), orders = new Map(), ledger = new Map(), calls = [], checks = [] + let serial = 0, containerId = 60, itemId = 70 + const counts = o => ({ itemCount: o.items.length, palletCount: o.items.length, packageCount: o.items.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: 0, shippedPackageCount: 0 }) + const item = c => ({ dispatchItemId: String(++itemId), sourceType: c.selection.sourceType, sourceKey: c.sourceKey, sourceVersion: c.sourceVersion, customerId: c.customerId, + planPalletId: c.selection.planPalletId, warehousePalletId: c.selection.warehousePalletId, inboundCode: c.inboundCode, destination: clone(c.destination), scheduledPackages: c.availablePackages, pickedPackages: 0, remove: { allowed: true } }) + const syncRoute = o => { o.keys = [...new Set([...(fault === 'append-only-route' ? o.keys || [] : []), ...o.items.map(i => i.destination.key)])] } + const create = cargo => { const o = { dispatchNo: `DS-${orders.size + 1}`, recordVersion: initial && orders.size === 0 ? 0 : 1, items: cargo.map(s => item(candidates.find(c => c.selection.planPalletId === s.planPalletId))) }; syncRoute(o); orders.set(o.dispatchNo, o); return o } + const command = (o, action) => ({ dispatchNo: o.dispatchNo, action, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: counts(o), replayed: false }) + const view = o => ({ dispatchNo: o.dispatchNo, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: o.recordVersion, counts: counts(o), + dispatchInfo: { route: [...new Set(o.items.map(i => i.destination.warehouseCode))].join(',') }, + orderDetails: o.items.map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) + const stops = o => [{ index: 0, name: 'Actual Origin', address: 'Warehouse 44', destinationKey: 'ORIGIN:44' }, ...o.keys.map((key, n) => ({ index: n + 1, name: key, destinationKey: key }))] + const response = (data, code = '200') => ({ http: 200, body: { code, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) + const context = { marker: 'CURRENT', createdResources: { phase4OverseasPda: { marker: 'CURRENT', finalStatus: 'COMPLETED', customerId: '55', overseasWarehouseId: '44', palletIds: ['31', '32', '33'] } }, + checkpoint: async () => {}, // Explicit offline double; full-run test below removes it and verifies actual disk writes. + assert: (_module, label, _response, passed) => checks.push({ label, passed }), + async request(method, raw, body) { + calls.push({ method, path: raw, body: clone(body) }) + const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), write = method !== 'GET' + const key = `${method}:${path}:${body?.idempotencyKey}` + if (write) { + assert.ok(body?.idempotencyKey, raw) + if (ledger.has(key)) { + const old = ledger.get(key); assert.equal(JSON.stringify(body), old.body, 'replay must use original body and version') + if (fault === 'replay-row-drift' && path.endsWith('/items')) orders.get('DS-1').items[0].dispatchItemId = '9999' + const data = clone(old.data); if (data?.action || data?.vehicles) data.replayed = true; return response(data) } - state = { ...responseFor(remaining), items: remaining } - return ok(state) } - if (path === `${base}/pre-arrange`) { - if (new Set(body.planPalletIds).size !== body.planPalletIds.length) return error('A0400') - if (body.draftToken !== 'DRAFT') return error('A0409') - preArrangeCount++ - const picked = [...body.planPalletIds].sort().map(palletId => rows.find(row => row.planPalletId === palletId)) - const items = picked.map(row => state.items.find(item => item.planPalletId === row.planPalletId) || { ...row, id: String(nextItemId++) }) - state = { ...responseFor(picked), items } - if (fault === 'replay-id-drift' && preArrangeCount === 2) state.items[0].id = String(nextItemId++) - return ok(state) + const remember = data => { ledger.set(key, { body: JSON.stringify(body), data: clone(data) }); return response(data) } + const draft = drafts.get(body?.draftToken) + if (method === 'POST' && path === `${BASE}/drafts`) { const d = { draftToken: `SERVER-${++serial}`, recordVersion: 0, warehouseId: body.warehouseId, status: 'ACTIVE', sources: [], containers: [] }; drafts.set(d.draftToken, d); return remember(d) } + if (method === 'GET' && path === `${BASE}/drafts`) return response([...drafts.values()].filter(d => d.status === 'ACTIVE')) + if (path === `${BASE}/candidates`) { + assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED') + const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey))) + for (const d of drafts.values()) if (d.status === 'ACTIVE' && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey) + return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' }) } - throw new Error(`unexpected request ${method} ${path}`) + if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: body.capacity, sourceKeys: [] }); draft.recordVersion++; return remember(draft) } + const deleteContainer = path.match(/\/drafts\/([^/]+)\/containers\/(\d+)$/) + if (deleteContainer) { const d = drafts.get(deleteContainer[1]); assert.equal(body.recordVersion, d.recordVersion); const c = d.containers.find(c => c.draftContainerId === deleteContainer[2]); if (c.sourceKeys.length) return response(null, 'A0409'); d.containers = d.containers.filter(c => c.draftContainerId !== deleteContainer[2]); d.recordVersion++; return remember(d) } + if (path === `${BASE}/draft-locks/lock` || path === `${BASE}/draft-locks/unlock`) { + assert.equal(body.recordVersion, draft.recordVersion) + const c = candidates.find(c => c.selection.planPalletId === body.source.planPalletId), container = draft.containers.find(g => g.draftContainerId === body.draftContainerId) + assert.deepEqual(body.source, c.selection) + if (path.endsWith('/lock')) { + if ([...orders.values()].some(o => o.items.some(i => i.sourceKey === c.sourceKey))) return response(null, 'A0409') + draft.sources.push(clone(c)); container.sourceKeys.push(c.sourceKey) + } else { draft.sources = draft.sources.filter(s => s.sourceKey !== c.sourceKey); container.sourceKeys = container.sourceKeys.filter(k => k !== c.sourceKey) } + draft.recordVersion++; return remember(draft) + } + if (path === `${BASE}/draft-locks/release`) { assert.equal(body.recordVersion, draft.recordVersion); assert.equal(draft.sources.length, 0); draft.status = 'RELEASED'; draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/drafts/snapshot`) { assert.equal(body.recordVersion, draft.recordVersion); assert.ok(Array.isArray(body.planIds)); assert.ok(Array.isArray(body.containers)); draft.containers = clone(body.containers); draft.recordVersion++; return remember(draft) } + if (path === `${BASE}/drafts/pre-arrange`) { + assert.equal(body.recordVersion, draft.recordVersion) + if (!body.groups.every(g => JSON.stringify(g.dispatch.cargo.map(s => `PLAN:${s.planPalletId}`).sort()) === JSON.stringify([...draft.containers.find(c => c.draftContainerId === g.draftContainerId).sourceKeys].sort()))) return response(null, 'A0409') + const vehicles = body.groups.map(g => ({ draftContainerId: g.draftContainerId, dispatch: command(create(g.dispatch.cargo), 'CREATE') })); draft.status = 'CONVERTED' + return remember({ draftToken: draft.draftToken, vehicles, replayed: false }) + } + if (path === BASE && method === 'POST') { assert.equal(body.vehicle, null); assert.equal(body.driver, null); return remember(command(create(body.cargo), 'CREATE')) } + if (path === BASE && method === 'GET') return response({ records: [...orders.values()].filter(o => o.dispatchNo === params.get('dispatchNo')).map(o => ({ ...command(o, 'READ'), warehouseId: '44' })) }) + const match = path.slice(BASE.length).match(/^\/([^/]+)(?:\/(items|route)(?:\/([^/]+)\/remove)?)?$/), o = orders.get(match?.[1]); assert.ok(o, `${method} ${raw}`) + if (write) { + assert.equal(body.recordVersion, o.recordVersion, 'explicit server version, including zero') + if (match[2] === 'route') { assert.equal(method, 'PUT'); assert.deepEqual([...body.destinationKeys].sort(), [...o.keys].sort()); o.keys = clone(body.destinationKeys); o.recordVersion++; return remember(command(o, 'UPDATE_ROUTE')) } + if (match[3]) { + if (!o.items.some(i => i.dispatchItemId === match[3])) { if (fault === 'rejected-remove-mutates') o.recordVersion++; return response(null, 'A0404') } + o.items = o.items.filter(i => i.dispatchItemId !== match[3]); o.recordVersion++; syncRoute(o); return remember(command(o, 'REMOVE_CARGO')) + } + assert.equal(method, 'POST'); assert.equal(match[2], 'items') + if (new Set(body.cargo.map(s => s.planPalletId)).size !== body.cargo.length) return response(null, 'A0400') + o.items.push(...body.cargo.map(s => item(candidates.find(c => c.selection.planPalletId === s.planPalletId)))); o.recordVersion++; syncRoute(o) + if (fault === 'unknown-add') return { http: 0, body: null } + if (fault === 'route-drift') o.keys = ['FBA:FOREIGN'] + return remember(command(o, 'ADD_CARGO')) + } + if (match[2] === 'items') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, status: 'ACTIVE', stage: 'BEFORE_LOADING', items: o.items }) + if (match[2] === 'route') return response({ dispatchNo: o.dispatchNo, recordVersion: o.recordVersion, stops: stops(o), edit: { allowed: true } }) + return response(view(o)) } } - return { context, calls, checks, options: { dispatchNo: 'DS-TEST', candidates: selected, duplicate: rows[2], draftToken: 'DRAFT', draftContainerId: '50' } } + if (initial) { create(candidates.slice(0, 2).map(c => c.selection)); context.createdResources.phase4OverseasDispatch = {} } + return { context, candidates, calls, checks, orders, bundle: () => { const o = orders.get('DS-1'); return { data: view(o), warehouseId: '44', items: { items: clone(o.items) }, route: { stops: stops(o) } } } } } -test('C52 HTTP orchestration adds duplicate destination, removes last destination, restores and freezes exact replay/rejection facts', async () => { - const h = lifecycleHarness() - await verifyDispatchRouteLifecycle(h.context, h.options) - assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED') - assert.ok(h.checks.length >= 20 && h.checks.every(row => row.passed)) - for (const label of ['同目的地第二托增加箱托数但路线稳定去重', '最后一托移除后路线删除该目的地', '重新加入后恢复原目的地集合', '重复托盘ID拒绝A0400', '其他草稿抢占原排车拒绝A0409']) { - assert.ok(h.checks.some(row => row.name.includes(label))) - } - assert.ok(h.calls.filter(row => row.path.endsWith('/pre-arrange')).every(row => !Object.hasOwn(row.body, 'route'))) - assert.equal(h.calls.filter(row => row.method === 'DELETE').length, 3) - assert.deepEqual(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.stages.at(-1).snapshot.items.map(row => row.planPalletId), ['20', '30']) +test('native route fixture uses real physical pallets and requires two destinations plus a repeated destination', () => { + const c = rows(), selected = selectDispatchRouteFixture(c) + assert.deepEqual(selected.selected.map(c => c.sourceKey), ['PLAN:21', 'PLAN:22']); assert.equal(selected.duplicate.sourceKey, 'PLAN:23') + assert.equal(selectDispatchRouteFixture(c.slice(0, 2)), null) + c[2].selection.warehousePalletId = c[0].selection.warehousePalletId; assert.equal(selectDispatchRouteFixture(c), null) }) -test('C52 stops on wrong route, replay row identity drift or side effects after rejected removal', async () => { - for (const fault of ['wrong-route', 'replay-id-drift', 'rejected-delete-mutates']) { - const h = lifecycleHarness(fault) - await assert.rejects(verifyDispatchRouteLifecycle(h.context, h.options), /C52/) - assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'UNPROVEN') - assert.ok(h.checks.some(row => !row.passed)) +test('native route proof rejects drift in actual identity, physical scope, counts and destination keys', () => { + const h = server('', true), original = h.bundle(), expected = h.candidates.slice(0, 2) + assert.equal(dispatchRouteMatches(original, expected), true) + for (const mutate of [b => { b.warehouseId = '999' }, b => { b.items.items[0].customerId = '999' }, b => { b.items.items[0].warehousePalletId = '999' }, b => { b.items.items[0].sourceVersion = '0'.repeat(64) }, b => { b.data.counts.packageCount++ }, b => { b.route.stops[1].destinationKey = 'FBA:OTHER' }, b => { b.data.dispatchInfo.route = 'A1,A1,A2' }, b => { b.items.items[1].dispatchItemId = b.items.items[0].dispatchItemId }, b => { b.data.orderDetails[0].customerId = '999' }, b => { b.data.orderDetails[0].sourceKey = 'PLAN:999' }, b => { b.data.orderDetails[0].destinationFbaWarehouse = 'OTHER' }]) { const bad = clone(original); mutate(bad); assert.equal(dispatchRouteMatches(bad, expected), false) } + const changed = clone(original); changed.items.items[0].dispatchItemId = '9999'; assert.notEqual(dispatchBusinessSnapshot(changed), dispatchBusinessSnapshot(original)) +}) + +test('C52 uses native versioned cargo commands, immutable replay, actual readback and route keys', async () => { + const h = server('', true), current = await verifyDispatchRouteLifecycle(h.context, { dispatchNo: 'DS-1', candidates: h.candidates.slice(0, 2), duplicate: h.candidates[2] }) + assert.equal(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED'); assert.ok(h.checks.every(c => c.passed)) + assert.equal(current.data.counts.packageCount, 5); assert.equal(current.data.recordVersion, 6) + const firstAdd = h.calls.find(c => c.method === 'POST' && c.path.endsWith('/items')); assert.equal(firstAdd.body.recordVersion, 0) + assert.deepEqual(h.calls.filter(c => c.method === 'PUT').map(c => c.body.destinationKeys), [['FBA:A2', 'FBA:A1'], ['FBA:A2', 'FBA:A1'], ['FBA:A1', 'FBA:A2'], ['FBA:A1', 'FBA:A2']]) + assert.equal(h.calls.some(c => c.method === 'DELETE'), false) +}) + +test('C52 rejects replay row drift, rejection side effects, wrong route and unknown writes without cleanup', async () => { + for (const fault of ['replay-row-drift', 'rejected-remove-mutates', 'route-drift', 'unknown-add']) { + const h = server(fault, true) + await assert.rejects(verifyDispatchRouteLifecycle(h.context, { dispatchNo: 'DS-1', candidates: h.candidates.slice(0, 2), duplicate: h.candidates[2] })) + assert.notEqual(h.context.createdResources.phase4OverseasDispatch.routeLifecycle.status, 'VERIFIED') + assert.equal(h.calls.some(c => c.method === 'PUT' || c.method === 'DELETE'), false) + if (fault === 'unknown-add') { assert.equal(h.context.createdResources.dispatchWriteBoundary.outcomeUnknown, true); assert.equal(h.calls.filter(c => c.method !== 'GET').length, 1); assert.equal(h.orders.get('DS-1').items.length, 3) } } }) -test('module proves draft idempotency, lock isolation, editable pre-arrange replay and readback', () => { - assert.match(source, /相同草稿快照重复保存/) - assert.match(source, /同草稿重复锁定托盘幂等成功/) - assert.match(source, /其他草稿锁定同一托盘返回A0409/) - assert.match(source, /出库前同草稿重复预排复用原排车且业务快照不漂移/) - assert.match(source, /重复释放草稿幂等成功/) - assert.match(source, /排车详情按记录自身客户范围加载托盘明细/) - assert.match(source, /response\?\.http === 409.*codeOf\(response\) === 'A0409'/s) +test('phase4 missing current marker or real multi-destination fixture is explicit NOT_RUN before writes', async () => { + const h = server(); h.context.createdResources.phase4OverseasPda.marker = 'OLD'; assert.equal((await run(h.context)).status, 'NOT_RUN'); assert.equal(h.calls.length, 0) + const other = server(); other.candidates.pop(); assert.equal((await run(other.context)).status, 'NOT_RUN'); assert.equal(other.calls.filter(c => c.method !== 'GET').length, 0) + const ambiguous = server(); ambiguous.candidates[2].appointmentBatches = [{}, {}]; assert.equal((await run(ambiguous.context)).status, 'NOT_RUN'); assert.equal(ambiguous.calls.filter(c => c.method !== 'GET').length, 0) }) -test('module does not invent optimistic version or idempotency headers absent from DispatchRequests', () => { - assert.doesNotMatch(source, /expectedVersion|recordVersion|Idempotency-Key/) - assert.match(source, /PENDING_DEPARTURE/) - assert.match(source, /data == null/) -}) - -test('module records the marker-owned dispatch without attempting excluded cleanup actions', () => { - assert.match(source, /createdResources\.phase4OverseasDispatch/) - assert.match(source, /dispatchNo = dispatchNo/) - assert.match(source, /finalStatus = arranged\.data\?\.status/) - assert.match(source, /PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence/) -}) - -test('module prefers marker-owned relabel pallets and proves only new FBA carton marks are frozen', () => { - assert.match(source, /phase4OverseasRelabel/) - assert.match(source, /sourceStage === 'RELABEL'/) - assert.match(source, /relabel\.targetPalletIds\.includes/) - assert.match(source, /relabelCartonMarks/) - assert.match(source, /!String\(value\?\.cartonMarks \|\| ''\)\.trim\(\)/) - assert.match(source, /relabelNo: relabel\.relabelNo/) -}) - - test('relabel settlement follows pre-arrange and does not replace the ordinary dispatch lifecycle', () => { - assert.match(source, /await runRelabelDispatch\(context, relabel\)[\s\S]*if \(relabel.dispatchNo\) await settleRelabelFulfillment\(context, relabel\)/) - const branch = source.slice(source.indexOf('const relabel = context.createdResources'), source.indexOf('const fixture = markerFixture(context)', source.indexOf('const relabel = context.createdResources'))) - assert.doesNotMatch(branch, /\breturn\b/) - assert.match(source, /Object.assign\(relabel, \{/) -}) - -test('dispatch replay snapshot ignores regenerated item ids but detects business drift', () => { - const a = { id: '1', dispatchNo: 'DS-1', status: 'PENDING_DEPARTURE', packageCount: 2, - palletCount: 1, items: [{ id: '10', planPalletId: '20', warehousePalletId: '30', packageCount: 2 }] } - const replay = { ...a, items: [{ ...a.items[0], id: '11' }] } - assert.equal(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(replay)) - for (const changed of [{ ...replay, id: '2' }, { ...replay, packageCount: 4 }, - { ...replay, items: [{ ...a.items[0], warehousePalletId: '31' }] }]) { - assert.notEqual(dispatchBusinessSnapshot(a), dispatchBusinessSnapshot(changed)) +test('complete offline phase4 hands off the actual native Detail and current source keys at BEFORE_LOADING', async () => { + const directory = await mkdtemp(join(tmpdir(), 'phase4-dispatch-contract-')) + try { + const h = server(); delete h.context.checkpoint; h.context.options = { output: join(directory, 'report.json') } + h.context.createdResources.dispatchWriteBoundary = { marker: 'CURRENT', stopped: false, password: 'PRIVATE_PASSWORD', token: 'PRIVATE_LOGIN_TOKEN' } + const originalRequest = h.context.request + h.context.request = async (method, path, body) => { + if (method !== 'GET') { + const evidence = JSON.parse(await readFile(join(directory, 'phase4-dispatch-checkpoints', 'CURRENT.json'), 'utf8')) + const intents = [...(evidence.phase4OverseasDispatch.requests || []), ...(evidence.groupedDispatch?.requests || []), ...(evidence.phase4OverseasDispatch.routeLifecycle?.requests || [])] + assert.ok(intents.some(r => r.status === 'SENDING' && r.method === method && r.path === path && JSON.stringify(r.body) === JSON.stringify(body)), 'original write must already exist on disk') + } + return originalRequest(method, path, body) + } + const proof = await run(h.context) + assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.finalStatus, 'ACTIVE'); assert.equal(proof.stage, 'BEFORE_LOADING'); assert.ok(h.checks.every(c => c.passed)) + assert.deepEqual(proof.sourceKeys, ['PLAN:21', 'PLAN:22']); assert.deepEqual(proof.destinationWarehouseCodes, ['A1', 'A2']); assert.equal(proof.warehouseId, '44') + const actual = await h.context.request('GET', `${BASE}/${proof.dispatchNo}`); assert.deepEqual(proof.data, actual.body.data) + assert.equal(Object.hasOwn(proof.data, 'items'), false); assert.equal(Object.hasOwn(proof.data, 'route'), false); assert.equal(Object.hasOwn(proof, 'dispatchId'), false) + assert.equal(Object.hasOwn(proof, 'loadingMeasurements'), false); assert.equal(proof.loadingEvidenceStatus, 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS') + assert.equal(h.calls.some(c => /\/review|\/pick|\/scan|\/loading|\/depart|\/transfer-delivery/.test(c.path)), false) + assert.equal(h.calls.some(c => c.path === `${BASE}/pre-arrange` || c.body?.containersJson), false) + const text = await readFile(proof.checkpointPath, 'utf8'), journal = JSON.parse(text) + assert.equal(journal.phase4OverseasDispatch.status, 'VERIFIED'); assert.ok(journal.phase4OverseasDispatch.draftToken) + assert.equal(text.includes('PRIVATE_PASSWORD'), false); assert.equal(text.includes('PRIVATE_LOGIN_TOKEN'), false) + await assert.rejects(dispatchEvidenceContext({ marker: 'CURRENT', options: h.context.options, createdResources: { phase4OverseasDispatch: {} } }), /已存在本轮排车检查点/) + const stale = server('append-only-route'); await assert.rejects(run(stale.context), /最后实物移出后真实数量归零/) + assert.equal(stale.context.createdResources.phase4OverseasDispatch.status, 'UNPROVEN'); assert.equal(stale.orders.size, 1) + assert.equal(stale.calls.some(c => c.path.endsWith('/drafts')), false) + } finally { + assert.ok(basename(directory).startsWith('phase4-dispatch-contract-')); assert.equal(resolve(directory, '..'), resolve(tmpdir())) + await rm(directory, { recursive: true, force: true }) } - assert.doesNotMatch(source, /item\?\.cartonMarks/) +}) + +test('relabel creation cannot prematurely settle fulfillment or fabricate loading measurements', () => { + const source = readFileSync(new URL('./phase4-overseas-dispatch.mjs', import.meta.url), 'utf8') + assert.equal(source.includes('settleRelabelFulfillment'), false) + assert.equal(source.includes('vehicleAppointmentStatus'), false) + assert.equal(source.includes('loadingMeasurements:'), false) + assert.ok(source.includes('fixture.targetPalletIds')); assert.ok(source.includes('READY_FOR_OUTBOUND')) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index 22b0281e2..a7804bb7b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -1,440 +1,197 @@ -import { settleRelabelFulfillment } from './phase4-overseas-pda-operations.mjs' - -import { verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs' +import { DISPATCH_BASE as BASE, id, canonical, version, success, DispatchNotRun, sourceSelection, createBody, markerCandidates, + notRun, dispatchIo, dispatchEvidenceContext, versionAfter, query, candidatePage, replay, readDispatch, dispatchMatches, newDraft, activeDraft, verifyGroupedDispatch } from './grouped-dispatch-acceptance.mjs' const MODULE = 'phase4-overseas-dispatch' -const SCENARIO = 'phase4-overseas-dispatch-lifecycle' -const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } -const BASE = '/api/platform/v1/last-leg/dispatches' - -function id(value) { return value == null ? '' : String(value) } -function records(value) { return Array.isArray(value?.records) ? value.records : [] } -function codeOf(response) { return response?.body?.code == null ? '' : String(response.body.code) } -function success(response) { return response?.http === 200 && codeOf(response) === '200' } -export function dispatchBusinessSnapshot(value) { - return JSON.stringify({ id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status, - route: value?.route, packageCount: value?.packageCount, palletCount: value?.palletCount, - items: (value?.items || []).map(item => ({ planPalletId: id(item.planPalletId), - warehousePalletId: id(item.warehousePalletId), sourceStage: item.sourceStage, - packageCount: item.packageCount, inboundCode: item.inboundCode, - destinationWarehouseCode: item.destinationWarehouseCode, fbaWarehouseCode: item.fbaWarehouseCode })) - .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))), - }) -} -function conflict(response) { return response?.http === 409 || (response?.http === 200 && codeOf(response) === 'A0409') } -function trace(response) { - return `HTTP=${response?.http ?? 'missing'}, code=${codeOf(response) || 'missing'}, requestId=${response?.requestId || 'missing'}, traceId=${response?.traceId || 'missing'}` -} -function query(path, values) { - const params = new URLSearchParams() - for (const [key, value] of Object.entries(values || {})) { - if (value !== null && value !== undefined && String(value) !== '') params.set(key, String(value)) - } - const encoded = params.toString() - return encoded ? `${path}?${encoded}` : path -} - -async function api(context, method, name, path, body) { - const response = await context.request(method, path, body) - const data = context.apiSuccess(MODULE, name, response, trace(response), OPTIONS) - return { response, data } -} - -function blocker(context, name, evidence, response = null) { - context.addStep(MODULE, name, response, false, `${evidence}; ${trace(response)}`, OPTIONS) - context.addCoverageGap(MODULE, SCENARIO, name, evidence, 'fixture', { deploymentBlocker: true }) -} - -function markerFixture(context) { - const fixture = context.createdResources?.phase4OverseasPda - if (fixture?.marker !== context.marker || fixture?.finalStatus !== 'COMPLETED') return null - const palletIds = new Set([...(fixture.palletIds || []), - ...Object.values(fixture.generatedPalletsByInbound || {}).flat().filter(row => - id(row.warehouseId) === id(fixture.overseasWarehouseId)).map(row => row.palletId)].map(id).filter(Boolean)) - if (!fixture.overseasWarehouseId || palletIds.size === 0) return null - return { ...fixture, palletIds } -} - -const destination = row => String(row?.fbaWarehouseCode || row?.destinationWarehouseCode || '').trim() +const destination = candidate => candidate?.destination?.warehouseCode +const same = (a, b) => canonical(a) === canonical(b) const sorted = values => [...values].sort() -const same = (left, right) => JSON.stringify(left) === JSON.stringify(right) -// Item IDs are included here: editable pre-arrange retains existing rows, and rejected -// commands must preserve the exact assigned facts, not merely their counts. -export function dispatchRouteSnapshot(value) { - return { id: id(value?.id), dispatchNo: value?.dispatchNo, status: value?.status, - route: value?.route ?? '', warehouseId: id(value?.overseasWarehouseId), - itemCount: value?.itemCount, palletCount: value?.palletCount, packageCount: value?.packageCount, - pickedPackageCount: value?.pickedPackageCount, shippedPackageCount: value?.shippedPackageCount, - items: (value?.items || []).map(row => ({ id: id(row.id), planPalletId: id(row.planPalletId), - warehousePalletId: id(row.warehousePalletId), palletNo: row.palletNo, - warehouseId: id(row.overseasWarehouseId), inboundCode: row.inboundCode, - orderNo: row.orderNo, orderRefs: row.orderRefs, sourceStage: row.sourceStage, - destinationWarehouseCode: row.destinationWarehouseCode, fbaWarehouseCode: row.fbaWarehouseCode, - packageCount: row.packageCount })).sort((a, b) => a.planPalletId.localeCompare(b.planPalletId)) } +// 读取对象保持三个原生响应独立,不给Detail补回旧items/route字段。 +export function dispatchRouteSnapshot(bundle) { + return { dispatchNo: bundle?.data?.dispatchNo, status: bundle?.data?.status, stage: bundle?.data?.stage, + recordVersion: bundle?.data?.recordVersion, warehouseId: bundle?.warehouseId, counts: bundle?.data?.counts, + route: bundle?.data?.dispatchInfo?.route, stops: bundle?.route?.stops, + items: bundle?.items?.items?.map(i => ({ dispatchItemId: i.dispatchItemId, sourceKey: i.sourceKey, sourceVersion: i.sourceVersion, + customerId: i.customerId, planPalletId: i.planPalletId, warehousePalletId: i.warehousePalletId, + destination: i.destination, inboundCode: i.inboundCode, scheduledPackages: i.scheduledPackages, pickedPackages: i.pickedPackages })).sort((a, b) => a.sourceKey.localeCompare(b.sourceKey)) } } - -export function dispatchRouteMatches(value, expectedCandidates) { - if (!Array.isArray(value?.items) || !Array.isArray(expectedCandidates)) return false - const items = value.items, route = String(value.route || '').trim() - const routeParts = route ? route.split(',').map(part => part.trim()) : [] - const actualDestinations = sorted(new Set(items.map(destination))) - return value.status === 'PENDING_DEPARTURE' - && Number(value.itemCount) === items.length && Number(value.palletCount) === items.length - && Number(value.packageCount) === items.reduce((total, row) => total + Number(row.packageCount), 0) - && items.length === expectedCandidates.length - && new Set(items.map(row => id(row.planPalletId))).size === items.length - && new Set(items.map(row => id(row.warehousePalletId))).size === items.length - && items.every(row => /^[1-9][0-9]*$/.test(id(row.id)) && destination(row) - && expectedCandidates.some(expected => id(expected.planPalletId) === id(row.planPalletId) - && id(expected.warehousePalletId) === id(row.warehousePalletId) - && id(expected.overseasWarehouseId) === id(row.overseasWarehouseId) - && id(value.overseasWarehouseId) === id(row.overseasWarehouseId) - && expected.sourceStage === row.sourceStage && expected.inboundCode === row.inboundCode - && Number(expected.packageCount) === Number(row.packageCount) && Number(row.packageCount) > 0 - && destination(expected) === destination(row))) - && routeParts.every(Boolean) && routeParts.length === new Set(routeParts).size - && same(sorted(routeParts), actualDestinations) +export function dispatchBusinessSnapshot(bundle) { return canonical(dispatchRouteSnapshot(bundle)) } +export function dispatchRouteMatches(bundle, candidates) { + if (!dispatchMatches(bundle, candidates) || !Array.isArray(bundle.route?.stops) || bundle.route.stops[0]?.index !== 0 + || bundle.route.stops[0].destinationKey !== `ORIGIN:${bundle.warehouseId}`) return false + const codes = String(bundle.data.dispatchInfo?.route || '').split(',').map(s => s.trim()).filter(Boolean) + const expected = sorted(new Set(candidates.map(destination))) + const keys = bundle.route.stops.slice(1).map(s => s.destinationKey) + return expected.every(Boolean) && codes.length === new Set(codes).size && same(sorted(codes), expected) + && keys.length === new Set(keys).size && same(sorted(keys), sorted(new Set(candidates.map(c => c.destination.key)))) } - export function selectDispatchRouteFixture(candidates) { - const unique = [...new Map(candidates.map(row => [id(row.warehousePalletId), row])).values()] const groups = new Map() - for (const row of unique) { - const code = destination(row) - if (code) groups.set(code, [...(groups.get(code) || []), row]) + for (const candidate of new Map(candidates.map(c => [id(c.selection?.warehousePalletId), c])).values()) { + const code = destination(candidate); if (code) groups.set(code, [...(groups.get(code) || []), candidate]) } const repeated = [...groups.values()].find(rows => rows.length >= 2) - if (groups.size < 2 || !repeated) return null - return { selected: [...groups.values()].map(rows => rows[0]), duplicate: repeated[1] } + const other = [...groups.values()].find(rows => rows !== repeated) + return groups.size >= 2 && repeated ? { selected: [repeated[0], other[0]], duplicate: repeated[1] } : null } - -export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate, draftToken, draftContainerId }) { - const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = { - marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [], - } - const detailPath = `${BASE}/${encodeURIComponent(dispatchNo)}` - let identity = null - const check = (name, response, passed, evidence) => { - context.assert(MODULE, `C52 ${name}`, response, passed, evidence, OPTIONS) - if (!passed) throw new Error(`C52 ${name}`) - } - const read = async (name, expected) => { - const current = await api(context, 'GET', `C52 ${name}回查实际分配明细`, detailPath) - const snapshot = dispatchRouteSnapshot(current.data) - const passed = success(current.response) && current.data?.dispatchNo === dispatchNo - && (!identity || snapshot.id === identity) && dispatchRouteMatches(current.data, expected) - proof.stages.push({ name, passed, snapshot, expectedPlanPalletIds: expected.map(row => id(row.planPalletId)), - requestId: current.response?.requestId, traceId: current.response?.traceId }) - check(`${name}实际明细独立推导路线及本轮来源核平`, current.response, passed, JSON.stringify(snapshot)) - identity ||= snapshot.id - return current.data - } - const lock = async row => { - const result = await api(context, 'POST', 'C52 新增本轮托盘到原草稿锁', `${BASE}/draft-locks/lock`, - { planPalletId: id(row.planPalletId), draftToken, draftContainerId }) - check('新增草稿锁成功', result.response, success(result.response), trace(result.response)) - } - const arrange = async (name, selected) => { - const body = { planPalletIds: selected.map(row => id(row.planPalletId)), draftToken } - const result = await api(context, 'POST', `C52 ${name}`, `${BASE}/pre-arrange`, body) - check(`${name}复用原排车`, result.response, success(result.response) && result.data?.dispatchNo === dispatchNo, trace(result.response)) - const current = await read(name, selected) - const replay = await api(context, 'POST', `C52 ${name}同草稿同载荷重放`, `${BASE}/pre-arrange`, body) - const after = await read(`${name}重放后`, selected) - check(`${name}重放路线及完整明细零漂移`, replay.response, success(replay.response) - && same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(replay.data)) - && same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(replay.response)) - return after - } - const rejectUnchanged = async (name, method, path, body, expectedCode, current, selected) => { - const result = await context.request(method, path, body) - // These business errors use the current gateway envelope and must retain traces. - check(name, result, result?.http === 200 && codeOf(result) === expectedCode - && Boolean(result.requestId) && Boolean(result.traceId), trace(result)) - const after = await read(`${name}后`, selected) - check(`${name}路线数量及明细零漂移`, result, - same(dispatchRouteSnapshot(current), dispatchRouteSnapshot(after)), trace(result)) - } - const initial = await read('初始多目的地', candidates) - const expanded = [...candidates, duplicate] - await lock(duplicate) - const added = await arrange('新增同目的地第二托', expanded) - check('同目的地第二托增加箱托数但路线稳定去重', null, - initial.route === added.route && added.palletCount === initial.palletCount + 1, - `before=${initial.route}, after=${added.route}`) - const extraItem = added.items.find(row => id(row.planPalletId) === id(duplicate.planPalletId)) - const removeExtra = await api(context, 'DELETE', 'C52 移除同目的地第二托', `${detailPath}/items/${id(extraItem.id)}`) - const dedup = await read('移除同目的地第二托', candidates) - check('同目的地仍有托盘时路线不消失', removeExtra.response, success(removeExtra.response) && dedup.route === initial.route, trace(removeExtra.response)) - const removedCandidate = candidates.find(row => destination(row) !== destination(duplicate)) - const removedItem = dedup.items.find(row => id(row.planPalletId) === id(removedCandidate.planPalletId)) - const reducedCandidates = candidates.filter(row => row !== removedCandidate) - const removePath = `${detailPath}/items/${id(removedItem.id)}` - const removed = await api(context, 'DELETE', 'C52 移除某目的地最后一托', removePath) - check('移除最后一托请求成功', removed.response, success(removed.response), trace(removed.response)) - const reduced = await read('移除目的地最后一托', reducedCandidates) - check('最后一托移除后路线删除该目的地', null, - !reduced.route.split(',').map(part => part.trim()).includes(destination(removedCandidate)), `route=${reduced.route}`) - await rejectUnchanged('重复移除已删除明细A0404', 'DELETE', removePath, undefined, 'A0404', reduced, reducedCandidates) - await lock(removedCandidate) - const restored = await arrange('重新加入已移除目的地', candidates) - check('重新加入后恢复原目的地集合', null, - same(sorted(restored.route.split(',')), sorted(initial.route.split(','))), `route=${restored.route}`) - const body = { planPalletIds: candidates.map(row => id(row.planPalletId)), draftToken } - await rejectUnchanged('重复托盘ID拒绝A0400', 'POST', `${BASE}/pre-arrange`, - { ...body, planPalletIds: [...body.planPalletIds, body.planPalletIds[0]] }, 'A0400', restored, candidates) - await rejectUnchanged('其他草稿抢占原排车拒绝A0409', 'POST', `${BASE}/pre-arrange`, - { ...body, draftToken: `${context.marker}-C52-FOREIGN`.slice(0, 64) }, 'A0409', restored, candidates) - const freed = await api(context, 'GET', 'C52 移除重复目的地托盘后候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - check('额外托盘正式锁已释放', freed.response, success(freed.response) && Array.isArray(freed.data) - && freed.data.some(row => id(row.planPalletId) === id(duplicate.planPalletId) - && id(row.warehousePalletId) === id(duplicate.warehousePalletId)), trace(freed.response)) - proof.status = 'VERIFIED' +async function unchangedAfterRejection(io, method, path, body, code, before, dispatchNo, candidates) { + const result = await io.send(method, path, body, `拒绝${code}`) + io.check(`业务错误${code}有诊断回执`, result, [200, Number(code.slice(2))].includes(result?.http) + && String(result.body?.code) === code && Boolean(result.requestId) && Boolean(result.traceId)) + const after = await readDispatch(io, dispatchNo) + io.check('拒绝后路线、版本及实物零漂移', null, dispatchRouteMatches(after, candidates) && dispatchBusinessSnapshot(after) === dispatchBusinessSnapshot(before)) + return after } - -async function runRelabelDispatch(context, relabel) { - const candidateResult = await api(context, 'GET', '查询本轮换标托盘智能排车候选', - query(`${BASE}/candidates`, { orderNo: relabel.relabelNo, sourceStage: 'RECEIVED' })) - const candidates = (Array.isArray(candidateResult.data) ? candidateResult.data : []) - .filter((value) => value?.sourceStage === 'RELABEL' - && relabel.targetPalletIds.includes(id(value?.warehousePalletId))) - const actualMarks = candidates.flatMap((value) => String(value?.relabelCartonMarks || '') - .split(',').map((mark) => mark.trim()).filter(Boolean)) - context.assert(MODULE, '智能排车只带出本轮换标后的FBA箱唛', candidateResult.response, - candidates.length === relabel.targetPalletIds.length - && relabel.relabelCartonMarks.every((mark) => actualMarks.includes(mark)) - && candidates.every((value) => !String(value?.cartonMarks || '').trim()), - `pallets=${candidates.length}, relabelMarks=${actualMarks.join(',')}, normalMarks=${candidates.map((value) => value?.cartonMarks).filter(Boolean).join(',')}; ${trace(candidateResult.response)}`, - OPTIONS) - if (candidates.length !== relabel.targetPalletIds.length) return - - const arranged = await api(context, 'POST', '将本轮换标托盘创建待出库排车', `${BASE}/pre-arrange`, { - planPalletIds: candidates.map((value) => id(value.warehousePalletId)), - fbaAppointmentNo: null, - fbaAppointmentStatus: null, vehicleAppointmentStatus: null, relabelNo: relabel.relabelNo, - }) - const dispatchNo = id(arranged.data?.dispatchNo) - context.assert(MODULE, '换标多托盘原子预排并冻结换标箱唛', arranged.response, - Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' - && Number(arranged.data?.palletCount) === candidates.length - && Array.isArray(arranged.data?.items) - && arranged.data.items.every((item) => item?.sourceStage === 'RELABEL'), - `dispatchNo=${dispatchNo || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}; ${trace(arranged.response)}`, - OPTIONS) - if (!dispatchNo) return - Object.assign(relabel, { - dispatchNo, dispatchId: id(arranged.data?.id), finalStatus: arranged.data?.status || null, - relabelNo: relabel.relabelNo, planPalletId: null, - }) - const detail = await api(context, 'GET', '回查本轮换标排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) - // DispatchResponse.Item omits cartonMarks. Phase5 verifies the persisted marks - // through OutboundDetail.pallets after transferring this exact dispatch. - context.assert(MODULE, '排车详情保留本轮换标托盘与箱数快照', detail.response, - success(detail.response) && dispatchBusinessSnapshot(detail.data) === dispatchBusinessSnapshot(arranged.data), - trace(detail.response), OPTIONS) +// C52只使用当前货物命令与路线命令,绝不借旧预排对已建主单做隐式编辑。 +export async function verifyDispatchRouteLifecycle(context, { dispatchNo, candidates, duplicate }) { + const proof = context.createdResources.phase4OverseasDispatch.routeLifecycle = { marker: context.marker, dispatchNo, status: 'UNPROVEN', stages: [], requests: [] } + const io = dispatchIo(context, MODULE, proof, 'C52'), path = `${BASE}/${encodeURIComponent(dispatchNo)}` + const read = async (label, expected) => { + const current = await readDispatch(io, dispatchNo) + io.check(label, null, dispatchRouteMatches(current, expected)); proof.stages.push({ label, snapshot: dispatchRouteSnapshot(current) }); return current + } + const mutate = async (action, suffix, fields, expected, method = 'POST') => { + const before = await readDispatch(io, dispatchNo) + const body = { recordVersion: version(before.data.recordVersion), idempotencyKey: io.key(action), ...fields } + const result = await io.command(method, `${path}${suffix}`, body, action) + io.check(`${action}恢复同一整车命令身份`, null, result?.dispatchNo === dispatchNo && versionAfter(result.recordVersion, before.data.recordVersion)) + const current = await read(action, expected) + await replay(io, `${path}${suffix}`, body, result, `${action}原键重放`, method) + io.check(`${action}重放不漂移实际行身份`, null, dispatchBusinessSnapshot(current) === dispatchBusinessSnapshot(await readDispatch(io, dispatchNo))) + return current + } + try { + if (markerCandidates(context, [...candidates, duplicate]).length !== candidates.length + 1) throw new DispatchNotRun('C52来源不属于本轮客户仓') + const initial = await read('初始多目的地', candidates), expanded = [...candidates, duplicate] + const extra = createBody(context.marker, 'C52-extra', [duplicate]) + let current = await mutate('新增同目的地第二托', '/items', { cargo: extra.cargo, batchAllocations: extra.batchAllocations }, expanded) + io.check('同目的地第二托增加箱托数但路线稳定去重', null, initial.data.dispatchInfo.route === current.data.dispatchInfo.route && Number(current.data.counts.palletCount) === Number(initial.data.counts.palletCount) + 1) + const item = current.items.items.find(i => i.sourceKey === duplicate.sourceKey) + current = await mutate('移除同目的地第二托', `/items/${encodeURIComponent(item.dispatchItemId)}/remove`, { reason: `${context.marker}-释放额外测试实物` }, candidates) + const removed = candidates.find(c => destination(c) !== destination(duplicate)), row = current.items.items.find(i => i.sourceKey === removed.sourceKey) + const reduced = candidates.filter(c => c.sourceKey !== removed.sourceKey), removePath = `${path}/items/${encodeURIComponent(row.dispatchItemId)}/remove` + current = await mutate('移除目的地最后一托', `/items/${encodeURIComponent(row.dispatchItemId)}/remove`, { reason: `${context.marker}-路线核验` }, reduced) + io.check('最后一托移除后路线删除该目的地', null, !String(current.data.dispatchInfo.route).split(',').includes(destination(removed))) + await unchangedAfterRejection(io, 'POST', removePath, { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('missing-item'), reason: '明确缺失明细' }, 'A0404', current, dispatchNo, reduced) + const add = createBody(context.marker, 'C52-restore', [removed]) + current = await mutate('重新加入已移除目的地', '/items', { cargo: add.cargo, batchAllocations: add.batchAllocations }, candidates) + const duplicateSource = sourceSelection(candidates[0]) + await unchangedAfterRejection(io, 'POST', `${path}/items`, { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('duplicate-source'), cargo: [duplicateSource, duplicateSource], batchAllocations: [] }, 'A0400', current, dispatchNo, candidates) + const otherIo = dispatchIo(context, MODULE, proof, 'C52-FOREIGN') + let other = await newDraft(otherIo, candidates[0].warehouseId) + other = await otherIo.command('POST', `${BASE}/drafts/containers`, { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('container'), draftToken: other.draftToken, capacity: 15 }, '创建本轮独立冲突草稿容器') + await unchangedAfterRejection(io, 'POST', `${BASE}/draft-locks/lock`, { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('lock-conflict'), draftToken: other.draftToken, draftContainerId: other.containers[0].draftContainerId, source: sourceSelection(candidates[0]) }, 'A0409', current, dispatchNo, candidates) + const releasedBody = { recordVersion: version(other.recordVersion), idempotencyKey: otherIo.key('release'), draftToken: other.draftToken } + const released = await otherIo.command('POST', `${BASE}/draft-locks/release`, releasedBody, '释放确认未取得来源的冲突草稿') + await replay(otherIo, `${BASE}/draft-locks/release`, releasedBody, released, '释放原键重放') + const originalStops = current.route.stops, keys = originalStops.slice(1).map(s => s.destinationKey) + current = await mutate('重排实际卸货顺序', '/route', { destinationKeys: [...keys].reverse(), reason: `${context.marker}-明确既有站序` }, candidates, 'PUT') + io.check('真实起点保留且卸货顺序反转', null, same(current.route.stops[0], originalStops[0]) && same(current.route.stops.slice(1).map(s => s.destinationKey), [...keys].reverse())) + current = await mutate('恢复实际卸货顺序', '/route', { destinationKeys: keys, reason: `${context.marker}-恢复原站序` }, candidates, 'PUT') + io.check('原实际站序恢复', null, same(current.route.stops, originalStops)) + const available = await candidatePage(io, candidates[0].warehouseId) + io.check('额外实物已释放为真实候选', null, available.some(c => c.sourceKey === duplicate.sourceKey && c.blockReasons?.length === 0)) + proof.status = 'VERIFIED'; return current + } catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; throw error } +} +async function removeControl(context, io, candidate) { + const body = createBody(context.marker, 'REMOVAL-CONTROL', [candidate]) + const command = await io.command('POST', BASE, body, '正式Create创建独立移货对照') + await replay(io, BASE, body, command, '正式创建原键恢复') + let current = await readDispatch(io, command.dispatchNo) + io.check('对照车来自本轮真实来源', null, dispatchRouteMatches(current, [candidate])) + const item = current.items.items[0] + const remove = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('control-remove'), reason: `${context.marker}-未拣实物释放` } + await io.command('POST', `${BASE}/${encodeURIComponent(command.dispatchNo)}/items/${encodeURIComponent(item.dispatchItemId)}/remove`, remove, '移出原生明细') + current = await readDispatch(io, command.dispatchNo) + io.check('最后实物移出后真实数量归零', null, dispatchRouteMatches(current, [])); return command.dispatchNo +} +async function prepareDraft(context, io, candidates) { + let draft = await newDraft(io, candidates[0].warehouseId) + const change = async (action, path, fields, method = 'POST') => { + const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key(action), draftToken: draft.draftToken, ...fields } + draft = await io.command(method, path, body, action); await replay(io, path, body, draft, `${action}原键重放`, method); return draft + } + await change('临时容器', `${BASE}/drafts/containers`, { capacity: 15 }) + const temporary = draft.containers[0].draftContainerId, source = sourceSelection(candidates[0]) + await change('临时选货', `${BASE}/draft-locks/lock`, { draftContainerId: temporary, source }) + const occupied = await activeDraft(io, draft.draftToken) + const invalid = await io.send('DELETE', `${BASE}/drafts/${encodeURIComponent(draft.draftToken)}/containers/${encodeURIComponent(temporary)}`, { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('nonempty-delete'), reason: '非空容器删除负例' }, '非空容器拒绝') + io.check('非空容器不能删除', invalid, [200, 409].includes(invalid?.http) && String(invalid.body?.code) === 'A0409') + io.check('拒绝删除后草稿不变', null, same(await activeDraft(io, draft.draftToken), occupied)) + await change('解除原容器来源', `${BASE}/draft-locks/unlock`, { draftContainerId: temporary, source }) + const free = await candidatePage(io, candidates[0].warehouseId) + io.check('解锁恢复真实候选', null, free.some(c => c.sourceKey === candidates[0].sourceKey && c.blockReasons?.length === 0)) + await change('删除已空容器', `${BASE}/drafts/${encodeURIComponent(draft.draftToken)}/containers/${encodeURIComponent(temporary)}`, { reason: `${context.marker}-空容器` }, 'DELETE') + await change('正式容器', `${BASE}/drafts/containers`, { capacity: 15 }) + const container = draft.containers[0].draftContainerId + for (const [n, candidate] of candidates.entries()) await change(`正式选货-${n}`, `${BASE}/draft-locks/lock`, { draftContainerId: container, source: sourceSelection(candidate) }) + await change('完整布局', `${BASE}/drafts/snapshot`, { planIds: [], containers: draft.containers }) + const own = await candidatePage(io, candidates[0].warehouseId, { draftToken: draft.draftToken }) + io.check('原草稿仅放行自己的可选来源', null, candidates.every(c => own.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) + const hidden = await candidatePage(io, candidates[0].warehouseId) + io.check('无草稿身份不能选择活动占用', null, candidates.every(c => !hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) + return draft +} +async function createRelabel(context, io, proof) { + const fixture = context.createdResources?.phase4OverseasRelabel + if (fixture?.marker !== context.marker || fixture.status !== 'READY_FOR_OUTBOUND') return + const page = await candidatePage(io, fixture.overseasWarehouseId, { orderNo: fixture.relabelNo, sourceStage: 'RELABEL' }) + const candidates = page.filter(c => c.selection?.sourceType === 'RELABEL' && c.selection.relabelNo === fixture.relabelNo && id(c.customerId) === id(fixture.customerId) + && id(c.warehouseId) === id(fixture.overseasWarehouseId) && fixture.targetPalletIds?.map(id).includes(id(c.selection.warehousePalletId))) + if (!candidates.length || candidates.length !== fixture.targetPalletIds.length) { proof.relabel = { status: 'NOT_RUN', reason: '没有本轮完整换标申请来源' }; return } + const body = createBody(context.marker, 'RELABEL', candidates), created = await io.command('POST', BASE, body, '真实换标整单Create') + await replay(io, BASE, body, created, '换标Create原键恢复') + const current = await readDispatch(io, created.dispatchNo) + io.check('整单换标来源、客户仓及数量回查', null, dispatchMatches(current, candidates)) + Object.assign(fixture, { dispatchNo: created.dispatchNo, finalStatus: current.data.status, stage: current.data.stage, data: current.data }) + proof.relabel = { dispatchNo: created.dispatchNo, status: 'CREATED_BEFORE_LOADING', sourceKeys: candidates.map(c => c.sourceKey), cartonMarksVerification: 'NOT_RUN_PUBLIC_DETAIL_OMITS_MARKS' } } - export async function run(context) { - context.createdResources.phase4OverseasDispatch = { - marker: context.marker, dispatchNo: null, dispatchId: null, draftToken: null, - planPalletId: null, planPalletIds: [], destinationWarehouseCodes: [], finalStatus: null, - cleanup: 'PENDING_DEPARTURE dispatch is retained as marker-owned acceptance evidence; outbound, PDA and delivery APIs are excluded', + context.createdResources ??= {} + if (context.createdResources.phase4OverseasDispatch?.requests?.some(r => !['GET', 'HEAD'].includes(r.method))) throw Error('本轮排车已经尝试写入,须审核原回执,禁止自动重建') + const proof = context.createdResources.phase4OverseasDispatch = { marker: context.marker, status: 'CHECKING', dispatchNo: null, finalStatus: null, stage: null, requests: [] } + try { + const fixture = context.createdResources.phase4OverseasPda + markerCandidates(context, []) + let io = dispatchIo(context, MODULE, proof, 'MAIN') + const page = await candidatePage(io, fixture.overseasWarehouseId) + const routeFixture = selectDispatchRouteFixture(markerCandidates(context, page)) + if (!routeFixture) throw new DispatchNotRun('本轮同客户仓需两个FBA目的地及同目的地双实托,不借历史样本') + const selected = routeFixture.selected + ;[...selected, routeFixture.duplicate].forEach((c, n) => createBody(context.marker, `PRECHECK-${n}`, [c])) + context = await dispatchEvidenceContext(context) + io = dispatchIo(context, MODULE, proof, 'MAIN') + proof.removalDispatchNo = await removeControl(context, io, selected[0]) + const grouped = await verifyGroupedDispatch(context, MODULE, selected) + if (grouped.status !== 'GROUPED_DISPATCH_VERIFIED') throw new DispatchNotRun(grouped.reason) + const draft = await prepareDraft(context, io, selected) + proof.draftToken = draft.draftToken + const groups = [{ draftContainerId: draft.containers[0].draftContainerId, dispatch: createBody(context.marker, 'MAIN-CHILD', draft.sources) }] + const body = { recordVersion: version(draft.recordVersion), idempotencyKey: io.key('convert'), draftToken: draft.draftToken, groups } + const converted = await io.command('POST', `${BASE}/drafts/pre-arrange`, body, '真实草稿容器转换主链整车') + io.check('转换返回原服务器草稿和唯一子命令', null, converted?.draftToken === draft.draftToken && converted.vehicles?.length === 1 && converted.vehicles[0].dispatch?.action === 'CREATE') + const dispatchNo = converted.vehicles[0].dispatch.dispatchNo + let current = await readDispatch(io, dispatchNo) + io.check('转换成功后回查真实来源', null, dispatchRouteMatches(current, selected)) + await replay(io, `${BASE}/drafts/pre-arrange`, body, converted, '主链转换原键恢复') + const active = await io.command('GET', `${BASE}/drafts`, undefined, '转换后不再出现在活动草稿') + io.check('已转换草稿退出活动列表', null, Array.isArray(active) && !active.some(d => d.draftToken === draft.draftToken)) + current = await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selected, duplicate: routeFixture.duplicate }) + Object.assign(proof, { dispatchNo, warehouseId: current.warehouseId, customerId: fixture.customerId, + customerIds: sorted(new Set(current.items.items.map(i => i.customerId))), inboundCodes: sorted(new Set(current.items.items.map(i => i.inboundCode))), + planPalletIds: selected.map(c => c.selection.planPalletId), destinationWarehouseCodes: sorted(new Set(current.data.orderDetails.map(o => o.destinationFbaWarehouse))), + sourceKeys: sorted(new Set(current.data.orderDetails.map(o => o.sourceKey))), recordVersion: current.data.recordVersion, + finalStatus: current.data.status, stage: current.data.stage, data: current.data, items: current.items, route: current.route, + sourceCandidates: selected, status: 'VERIFIED', loadingEvidenceStatus: 'NOT_RUN_MISSING_ACTUAL_MEASUREMENTS' }) + io.check('交接Detail真实来源键和目的地守恒', null, same(proof.sourceKeys, sorted(selected.map(c => c.sourceKey))) && proof.finalStatus === 'ACTIVE' && proof.stage === 'BEFORE_LOADING') + await createRelabel(context, io, proof) + await context.checkpoint() + } catch (error) { + if (error.notRun && !proof.requests.some(r => !['GET', 'HEAD'].includes(r.method))) return notRun(context, proof, error.message) + proof.outcomeUnknown ||= context.createdResources.dispatchWriteBoundary?.outcomeUnknown === true + proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message + try { await context.checkpoint?.() } catch (persistError) { proof.persistenceError = persistError.message } + throw error } - - const initialPage = await api(context, 'GET', '查询海外排车分页', query(BASE, { page: 1, size: 20 })) - context.assert(MODULE, '排车分页包络稳定', initialPage.response, - Number.isFinite(Number(initialPage.data?.page)) && Number.isFinite(Number(initialPage.data?.size)) - && Number.isFinite(Number(initialPage.data?.total)) && Array.isArray(initialPage.data?.records), - `records=${records(initialPage.data).length}; ${trace(initialPage.response)}`, OPTIONS) - if (!success(initialPage.response)) return - - const relabel = context.createdResources?.phase4OverseasRelabel - if (relabel?.marker === context.marker && relabel?.status === 'READY_FOR_OUTBOUND') { - await runRelabelDispatch(context, relabel) - if (relabel.dispatchNo) await settleRelabelFulfillment(context, relabel) - } - - const fixture = markerFixture(context) - if (!fixture) { - blocker(context, '取得本轮海外PDA已收货托盘前置', - `marker=${context.marker}, pdaMarker=${context.createdResources?.phase4OverseasPda?.marker || 'missing'}, status=${context.createdResources?.phase4OverseasPda?.finalStatus || 'missing'}`) - return - } - - const candidateResult = await api(context, 'GET', '查询本轮已收货托盘智能预排候选', - query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - const candidates = Array.isArray(candidateResult.data) ? candidateResult.data : [] - const markerCandidates = candidates.filter((value) => fixture.palletIds.has(id(value?.warehousePalletId)) - && id(value?.overseasWarehouseId) === id(fixture.overseasWarehouseId) - && id(value?.customerId) === id(fixture.customerId) && value?.destinationType === 'FBA' - && id(value?.planId) && id(value?.planPalletId) - && value?.sourceStage === 'RECEIVED' && Number(value?.packageCount) > 0) - const routeFixture = selectDispatchRouteFixture(markerCandidates) - if (!routeFixture) { - blocker(context, 'C52 多目的地及同目的地双托前置', '本轮同客户同仓实际收货候选须至少两个FBA目的地,且至少一个目的地有两个不同实物托盘;不借历史数据') - return - } - const selectedCandidates = routeFixture.selected - const candidatesByDestination = new Map(selectedCandidates.map(row => [destination(row), row])) - const candidate = selectedCandidates[0] - context.assert(MODULE, '候选严格命中本轮PDA库存托盘且覆盖多个FBA仓', candidateResult.response, - selectedCandidates.length >= 2, - `candidates=${selectedCandidates.length}, destinations=${[...candidatesByDestination.keys()].join(',')}, pdaPallets=${[...fixture.palletIds].join(',')}, warehouseId=${id(fixture.overseasWarehouseId)}; ${trace(candidateResult.response)}`, OPTIONS) - if (selectedCandidates.length < 2) return - - const planId = id(candidate.planId) - const planPalletId = id(candidate.planPalletId) - const planIds = [...new Set(selectedCandidates.map(value => id(value.planId)))] - const planPalletIds = selectedCandidates.map(value => id(value.planPalletId)) - // Exercise removal on a disposable dispatch, then reuse the released current-run pallet. - const removable = await api(context, 'POST', '创建本轮托盘移除对照排车', `${BASE}/pre-arrange`, { - planPalletIds: [planPalletId], - }) - const removableNo = id(removable.data?.dispatchNo) - const removableItem = removable.data?.items?.find(item => id(item.planPalletId) === planPalletId) - if (!success(removable.response) || !removableNo || !removableItem?.id) { - blocker(context, '移除对照排车前置', '本轮托盘未生成排车明细', removable.response) - return - } - const removePath = `${BASE}/${encodeURIComponent(removableNo)}/items/${encodeURIComponent(id(removableItem.id))}` - const removed = await api(context, 'DELETE', '排车移除未拣托盘并释放正式锁', removePath) - const removedDetail = await api(context, 'GET', '回查移除后排车数量', `${BASE}/${encodeURIComponent(removableNo)}`) - context.assert(MODULE, '移除最后托盘后箱数托数明细归零', removedDetail.response, - success(removed.response) && success(removedDetail.response) - && Number(removedDetail.data?.packageCount) === 0 && Number(removedDetail.data?.palletCount) === 0 - && removedDetail.data?.items?.length === 0, trace(removedDetail.response), OPTIONS) - const removedAgain = await context.request('DELETE', removePath) - context.addStep(MODULE, '重复删除已不存在排车明细返回A0404', removedAgain, - removedAgain?.http === 404 || (removedAgain?.http === 200 && codeOf(removedAgain) === 'A0404'), trace(removedAgain), OPTIONS) - const freed = await api(context, 'GET', '移除后本轮托盘重新可预排', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - context.assert(MODULE, '正式锁释放后候选恢复', freed.response, - Array.isArray(freed.data) && freed.data.some(item => id(item.planPalletId) === planPalletId), trace(freed.response), OPTIONS) - context.createdResources.phase4OverseasDispatch.removalDispatchNo = removableNo - await verifyGroupedDispatch(context, MODULE, selectedCandidates) - const draftToken = `${context.marker}-DISPATCH-DRAFT`.slice(0, 64) - const containersJson = JSON.stringify([{ key: 'C1', capacity: Math.max(1, Number(candidate.packageCount)) }]) - const snapshotBody = { draftToken, planIds, containersJson } - context.createdResources.phase4OverseasDispatch.draftToken = draftToken - context.createdResources.phase4OverseasDispatch.planPalletId = planPalletId - context.createdResources.phase4OverseasDispatch.planPalletIds = planPalletIds - context.createdResources.phase4OverseasDispatch.destinationWarehouseCodes = [...candidatesByDestination.keys()] - - const snapshot = await api(context, 'POST', '保存智能预排草稿快照', `${BASE}/drafts/snapshot`, snapshotBody) - context.assert(MODULE, 'Void草稿快照允许data为空且请求成功', snapshot.response, - success(snapshot.response) && (snapshot.response.body?.data == null), trace(snapshot.response), OPTIONS) - if (!success(snapshot.response)) return - const snapshotReplay = await api(context, 'POST', '相同草稿快照重复保存', `${BASE}/drafts/snapshot`, snapshotBody) - if (!success(snapshotReplay.response)) return - - const draftsResult = await api(context, 'GET', '回查当前操作员智能预排草稿', `${BASE}/drafts`) - const draftRows = Array.isArray(draftsResult.data) ? draftsResult.data : [] - const matchingDrafts = draftRows.filter((value) => value?.draftToken === draftToken) - context.assert(MODULE, '同token同载荷快照只保留一份活动草稿', draftsResult.response, - matchingDrafts.length === 1 && planIds.every(value => String(matchingDrafts[0].planIdsJson || '').split(',').includes(value)) - && matchingDrafts[0].containersJson === containersJson, - `matches=${matchingDrafts.length}, planIds=${matchingDrafts[0]?.planIdsJson || 'missing'}; ${trace(draftsResult.response)}`, OPTIONS) - - const disposableContainer = await api(context, 'POST', '创建真实智能预排草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }) - const containerId = id(disposableContainer.data?.id) - if (!success(disposableContainer.response) || !containerId) return - const boundBody = { planPalletId, draftToken, draftContainerId: containerId } - const bound = await api(context, 'POST', '托盘绑定真实草稿柜', `${BASE}/draft-locks/lock`, boundBody) - if (!success(bound.response)) return - const boundDrafts = await api(context, 'GET', '回查真实草稿柜及托盘绑定', `${BASE}/drafts`) - context.assert(MODULE, '草稿结构回显服务端柜ID和托盘', boundDrafts.response, - boundDrafts.data?.find(draft => draft.draftToken === draftToken)?.containers?.some(container => - id(container.id) === containerId && container.palletIds?.map(id).includes(planPalletId)), trace(boundDrafts.response), OPTIONS) - await api(context, 'POST', '移出草稿托盘解锁', `${BASE}/draft-locks/unlock`, boundBody) - const unlocked = await api(context, 'GET', '解锁后回查候选恢复', query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - context.assert(MODULE, '草稿解锁恢复候选', unlocked.response, - unlocked.data?.some(item => id(item.planPalletId) === planPalletId), trace(unlocked.response), OPTIONS) - await api(context, 'POST', '重新锁定以验证删除柜释放锁', `${BASE}/draft-locks/lock`, boundBody) - await api(context, 'DELETE', '删除本轮草稿柜并释放绑定托盘', `${BASE}/drafts/${encodeURIComponent(draftToken)}/containers/${encodeURIComponent(containerId)}`) - const deletedDrafts = await api(context, 'GET', '删除后回查草稿柜消失', `${BASE}/drafts`) - context.assert(MODULE, '删除草稿柜不再出现在结构响应', deletedDrafts.response, - Array.isArray(deletedDrafts.data) && !deletedDrafts.data.find(draft => draft.draftToken === draftToken)?.containers?.some(container => id(container.id) === containerId), trace(deletedDrafts.response), OPTIONS) - const finalContainer = await api(context, 'POST', '创建正式预排使用的草稿柜', `${BASE}/drafts/containers`, { draftToken, capacity: 15 }) - if (!success(finalContainer.response) || !finalContainer.data?.id) return - const lockBodies = planPalletIds.map(value => ({ planPalletId: value, draftToken, draftContainerId: id(finalContainer.data.id) })) - for (const [index, body] of lockBodies.entries()) { - const locked = await api(context, 'POST', `锁定本轮预排托盘${index + 1}`, `${BASE}/draft-locks/lock`, body) - if (!success(locked.response)) return - } - const lockBody = lockBodies[0] - const lockReplay = await api(context, 'POST', '同草稿重复锁定托盘幂等成功', `${BASE}/draft-locks/lock`, lockBody) - context.assert(MODULE, '重复锁定不创建冲突且Void响应可为空', lockReplay.response, - success(lockReplay.response) && lockReplay.response.body?.data == null, trace(lockReplay.response), OPTIONS) - - const visibleInDraft = await api(context, 'GET', '当前草稿仍可查看自身锁定候选', - query(`${BASE}/candidates`, { sourceStage: 'RECEIVED', draftToken })) - const visibleIds = new Set((Array.isArray(visibleInDraft.data) ? visibleInDraft.data : []).map(value => id(value?.planPalletId))) - const visible = planPalletIds.every(value => visibleIds.has(value)) - context.assert(MODULE, 'draftToken仅放行当前草稿自己的锁', visibleInDraft.response, visible, - `planPalletIds=${planPalletIds.join(',')}; ${trace(visibleInDraft.response)}`, OPTIONS) - - const hiddenResult = await api(context, 'GET', '无草稿标识时隐藏已锁托盘', - query(`${BASE}/candidates`, { sourceStage: 'RECEIVED' })) - const hiddenIds = new Set((Array.isArray(hiddenResult.data) ? hiddenResult.data : []).map(value => id(value?.planPalletId))) - const hidden = planPalletIds.every(value => !hiddenIds.has(value)) - context.assert(MODULE, '活动草稿锁阻止其他预排重复选择', hiddenResult.response, hidden, - `planPalletIds=${planPalletIds.join(',')}; ${trace(hiddenResult.response)}`, OPTIONS) - - const foreignToken = `${context.marker}-DISPATCH-FOREIGN`.slice(0, 64) - const foreignLock = await context.request('POST', `${BASE}/draft-locks/lock`, { - planPalletId, draftToken: foreignToken, - }) - context.addStep(MODULE, '其他草稿锁定同一托盘返回A0409', foreignLock, conflict(foreignLock), trace(foreignLock), OPTIONS) - - const preArrangeBody = { - planPalletIds, fbaAppointmentNo: null, - fbaAppointmentStatus: null, vehicleAppointmentStatus: null, draftToken, - } - const arranged = await api(context, 'POST', '将草稿锁原子转换为待出库预排', `${BASE}/pre-arrange`, preArrangeBody) - const dispatchNo = id(arranged.data?.dispatchNo) - context.assert(MODULE, '智能预排仅创建PENDING_DEPARTURE排车并冻结托盘', arranged.response, - Boolean(dispatchNo) && arranged.data?.status === 'PENDING_DEPARTURE' - && Array.isArray(arranged.data?.items) - && planPalletIds.every(value => arranged.data.items.some(item => id(item?.planPalletId) === value)) - && Number(arranged.data?.palletCount) === planPalletIds.length, - `dispatchNo=${dispatchNo || 'missing'}, status=${arranged.data?.status || 'missing'}, pallets=${arranged.data?.palletCount ?? 'missing'}, destinations=${[...candidatesByDestination.keys()].join(',')}; ${trace(arranged.response)}`, OPTIONS) - if (!dispatchNo) return - context.createdResources.phase4OverseasDispatch.dispatchNo = dispatchNo - context.createdResources.phase4OverseasDispatch.dispatchId = id(arranged.data?.id) - context.createdResources.phase4OverseasDispatch.finalStatus = arranged.data?.status || null - - const duplicateArrange = await context.request('POST', `${BASE}/pre-arrange`, preArrangeBody) - context.addStep(MODULE, '出库前同草稿重复预排复用原排车且业务快照不漂移', duplicateArrange, - success(duplicateArrange) - && dispatchBusinessSnapshot(duplicateArrange.body?.data) === dispatchBusinessSnapshot(arranged.data), - trace(duplicateArrange), OPTIONS) - - const pageResult = await api(context, 'GET', '按排车单号回查跨客户分页', query(BASE, { - page: 1, size: 20, dispatchNo, - })) - const row = records(pageResult.data).find((value) => id(value?.dispatchNo) === dispatchNo) - context.assert(MODULE, '排车分页包含本轮marker预排且状态未推进', pageResult.response, - row?.status === 'PENDING_DEPARTURE' && id(row?.id) === id(arranged.data?.id), - `dispatchNo=${dispatchNo}, status=${row?.status || 'missing'}; ${trace(pageResult.response)}`, OPTIONS) - - const detailResult = await api(context, 'GET', '查询本轮排车详情', `${BASE}/${encodeURIComponent(dispatchNo)}`) - context.assert(MODULE, '排车详情按记录自身客户范围加载托盘明细', detailResult.response, - id(detailResult.data?.dispatchNo) === dispatchNo && detailResult.data?.status === 'PENDING_DEPARTURE' - && Array.isArray(detailResult.data?.items) - && detailResult.data.items.some((item) => id(item?.planPalletId) === planPalletId), - `dispatchNo=${id(detailResult.data?.dispatchNo) || 'missing'}, items=${detailResult.data?.items?.length ?? 'missing'}; ${trace(detailResult.response)}`, OPTIONS) - - await verifyDispatchRouteLifecycle(context, { dispatchNo, candidates: selectedCandidates, - duplicate: routeFixture.duplicate, draftToken, draftContainerId: id(finalContainer.data.id) }) - - const released = await api(context, 'POST', '释放已转换后的草稿资源', `${BASE}/draft-locks/release`, { draftToken }) - if (success(released.response)) { - const releaseReplay = await api(context, 'POST', '重复释放草稿幂等成功', `${BASE}/draft-locks/release`, { draftToken }) - context.assert(MODULE, '重复释放不影响正式排车锁且Void响应可为空', releaseReplay.response, - success(releaseReplay.response) && releaseReplay.response.body?.data == null, trace(releaseReplay.response), OPTIONS) - } - - const afterRelease = await api(context, 'GET', '释放后回查活动草稿', `${BASE}/drafts`) - context.assert(MODULE, '释放后当前token不再出现在活动草稿', afterRelease.response, - Array.isArray(afterRelease.data) && !afterRelease.data.some((value) => value?.draftToken === draftToken), - `draftToken=${draftToken}; ${trace(afterRelease.response)}`, OPTIONS) + return proof } diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index 4077802ab..bf99f7a13 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,9 +1,11 @@ import test from 'node:test' import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, - dispatchDestinationConservation } from './phase5-overseas-fulfillment.mjs' + dispatchDestinationConservation, pickAll, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' -import { readFile } from 'node:fs/promises' +import { readFile, mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArguments } from '../admin-test-env-acceptance.mjs' @@ -27,27 +29,26 @@ test('一箱实收从四箱派送退回仅保持RETURN_PENDING,全部实收才 } }) -test('route equals actual item destinations and frozen selected candidates, then conserves split deliveries',()=>{ - const fixture={planPalletIds:['10','11','12'],destinationWarehouseCodes:['ONT8','LAX9']} - const detail={route:'ONT8,LAX9',items:[{planPalletId:'10',fbaWarehouseCode:'ONT8'}, - {planPalletId:'11',destinationWarehouseCode:'LAX9'},{planPalletId:'12',fbaWarehouseCode:'ONT8'}]} - const deliveries=[{destinationWarehouseCode:'LAX9'},{destinationWarehouseCode:'ONT8'}] - assert.equal(dispatchDestinationConservation(detail,fixture),true) - assert.equal(dispatchDestinationConservation(detail,fixture,deliveries),true) - for(const mutate of [ - value=>value.detail.route='ONT8',value=>value.detail.route='ONT8,LAX9,OTHER', - value=>value.detail.route='ONT8,ONT8,LAX9',value=>value.detail.items[1].destinationWarehouseCode='OTHER', - value=>value.detail.items[0].destinationWarehouseCode='REASSIGNED', - value=>value.detail.items[1].planPalletId='99',value=>value.detail.items[1].planPalletId='10', - value=>value.fixture.destinationWarehouseCodes.push('UNSELECTED'),value=>value.deliveries.pop(), - value=>value.deliveries.push({destinationWarehouseCode:'OTHER'}), - value=>value.deliveries.push({destinationWarehouseCode:'ONT8'}), - ]) { - const value=structuredClone({detail,fixture,deliveries});mutate(value) - assert.equal(dispatchDestinationConservation(value.detail,value.fixture,value.deliveries),false) +test('native route conserves source keys and destinations while same destination may contain multiple customers', () => { + const fixture = { sourceKeys: ['PLAN:10', 'PLAN:11', 'PLAN:12'], destinationWarehouseCodes: ['ONT8', 'LAX9'] } + const detail = { dispatchInfo: { route: 'ONT8,LAX9' }, orderDetails: [ + { sourceKey: 'PLAN:10', customerId: '100', destinationFbaWarehouse: 'ONT8' }, + { sourceKey: 'PLAN:11', customerId: '200', destinationFbaWarehouse: 'LAX9' }, + { sourceKey: 'PLAN:12', customerId: '200', destinationFbaWarehouse: 'ONT8' }] } + const deliveries = [{ customerId: '100', destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } }, + { customerId: '200', destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } }, + { customerId: '200', destination: { key: 'FBA:LAX9', warehouseCode: 'LAX9' } }] + assert.equal(dispatchDestinationConservation(detail, fixture), true) + assert.equal(dispatchDestinationConservation(detail, fixture, deliveries), true) + for (const mutate of [value => { value.detail.dispatchInfo.route = 'ONT8' }, + value => { value.detail.dispatchInfo.route = 'ONT8,ONT8,LAX9' }, + value => { value.detail.orderDetails[1].destinationFbaWarehouse = 'OTHER' }, + value => { value.detail.orderDetails[1].sourceKey = 'PLAN:99' }, + value => { value.fixture.destinationWarehouseCodes.push('UNSELECTED') }, + value => { value.deliveries.pop() }, value => { value.deliveries.push(value.deliveries[0]) }]) { + const value = structuredClone({ detail, fixture, deliveries }); mutate(value) + assert.equal(dispatchDestinationConservation(value.detail, value.fixture, value.deliveries), false) } - assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch\)/) - assert.match(source,/dispatchDestinationConservation\(beforeEdit.data,source.dispatch,deliveries\)/) }) test('phase-five fulfillment is a registered default HTTP module after the real phase-four dispatch fixture', () => { @@ -61,40 +62,38 @@ test('phase-five fulfillment is a registered default HTTP module after the real } }) -test('phase-five module covers dispatch edit, transfer, PDA pick/depart and delivery state', () => { - for (const token of [ - "const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'", - "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", - 'query(MERCHANT_DELIVERY_BASE, { dispatchNo })', '/items/${encodeURIComponent(id(item.dispatchItemId))}/pick', - '/complete`', '/depart`', '/outbound-orders/${encodeURIComponent(outboundNo)}', - '/delivery-orders/${encodeURIComponent(currentNo)}/status', - ]) assert.ok(source.includes(token), token) - assert.match(source, /WAIT_OUTBOUND/) - assert.match(source, /WAIT_DELIVERY/) - assert.match(source, /READY_TO_DEPART/) - assert.match(source, /OUTBOUNDED/) - assert.match(source, /DELIVERING/) - assert.match(source, /expectedVersion: deliveryVersion/) - assert.match(source, /transferDeliveries\(transferred\.data\)/) - assert.match(source, /new Set\(destinationKeys\)\.size === deliveries\.length/) - assert.match(source, /deliveries\.reduce\(\(sum, value\) => sum \+ Number\(value\?\.packageCount \|\| 0\), 0\)/) +test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => { + for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'", + "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", '/transfer-delivery', '/delivery-arrangement', + 'cargoHash: value.cargoHash', 'loadingCompletionHash: value.loadingCompletionHash', + 'recordVersion: version(detail.data.recordVersion)', 'positionId: id', + 'departed.data.result?.shippedPackages', 'outboundRef.outboundNo']) { + if (token === 'positionId: id') assert.match(source, /positionId: source.position \? id\(source.position.positionId\)/) + else assert.ok(source.includes(token), token) + } + assert.doesNotMatch(source, /READY_TO_DEPART|PENDING_DEPARTURE|DISPATCH-COMPLETE|\/items\/.*['"]DELETE['"]/) + assert.match(source, /data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'/) + assert.match(source, /departed.data.stage === 'DELIVERING'/) + const run = source.slice(source.indexOf('export async function run')) + assert.ok(run.indexOf('prepareFleetTrip(') < run.indexOf('await pickAll(')) + assert.ok(run.indexOf('await pickAll(') < run.indexOf('await confirmFleetLoading(')) + assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await arrangeDispatchDelivery(')) + assert.ok(run.indexOf('await arrangeDispatchDelivery(') < run.indexOf('await verifyDepartureReadiness(')) + assert.ok(run.indexOf('全部客户正常签收汇总整车DELIVERED') < run.indexOf('await finishFleetTrip(')) + assert.ok(run.indexOf('await finishFleetTrip(') < run.indexOf('const returnRequest')) }) -test('phase-five module proves the client delivery create, list and nested detail contract', () => { - assert.match(source, /const MERCHANT_DELIVERY_BASE = '\/api\/merchant\/v1\/last-leg\/delivery-orders'/) +test('client reads its own native-arranged deliveries and full nested document contract', () => { assert.match(source, /loginMerchant\(context, customerId\)/) - assert.match(source, /context\.options\?\.merchantUsername/) - assert.match(source, /\/api\/merchant\/v1\/iam\/tenant\/profile/) - assert.match(source, /id\(profile\?\.id\) === customerId/) - assert.match(source, /客户创建派送并原子转出库单/) - assert.match(source, /deliveryMethod: 'TRUCK_PALLET'/) - assert.match(source, /fees: \[\{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE',\s*currency: 'USD', amount: 10/) + assert.match(source, /context.options\?.merchantUsername/) + assert.match(source, /id\(profile\?.id\) === customerId/) assert.match(source, /客户分页回查自身派送单/) assert.match(source, /客户读取自身派送详情/) - assert.match(source, /merchantDetail\.data\?\.order\?\.deliveryNo/) - assert.match(source, /merchantDetail\.data\?\.skus/) - assert.match(source, /merchantDetail\.data\?\.fees/) - assert.match(source, /merchantDetail\.data\?\.operations/) + assert.match(source, /merchantDetail.data\?.order\?.deliveryNo/) + assert.match(source, /merchantDetail.data\?.skus/) + assert.match(source, /merchantDetail.data\?.fees/) + assert.match(source, /merchantDetail.data\?.operations/) + assert.doesNotMatch(source, /merchant\(context, merchantToken, 'POST'.*创建派送/) }) test('phase-five module proves receipt-save completion, cross-region convergence, customer visit, exception and return', () => { @@ -134,10 +133,9 @@ test('phase-five module uses marker-owned fixtures and always disables its tempo }) test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => { - assert.match(source, /idempotencyKey: key\(context, 'TRANSFER'\)/) + assert.match(source, /idempotencyKey: key\(context, 'TRANSFER', dispatchNo\)/) assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/) - assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-COMPLETE'\)/) - assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART'\)/) + assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART', dispatchNo\)/) assert.match(source, /addCoverageGap\(MODULE, SCENARIO/) assert.match(source, /deploymentBlocker: true/) }) @@ -153,9 +151,10 @@ test('relabel marks are checked on the authoritative outbound DTO without weaken assert.match(source, /relabel\?\.marker !== context\.marker/) }) -test('manual exception retries only a transport failure with the identical business body', () => { - assert.match(source, /manualException\.response\.http === 0/) - assert.match(source, /人工异常连接失败后使用同载荷重试/) +test('unknown writes preserve the original command and stop instead of transport retry', () => { + assert.match(source, /entry.status = 'UNKNOWN'/) + assert.match(source, /存在未知写结果/) + assert.doesNotMatch(source, /人工异常连接失败后使用同载荷重试/) assert.match(source, /manualExceptionBody/) }) @@ -497,34 +496,28 @@ test('inventory evidence captures zero drift independently for every balance buc } }) -test('departure gate requires the PDA task COMPLETED, server canDepart, and independent matching dispatch READY_TO_DEPART', async () => { - for (const [taskStatus, canDepart, dispatchStatus, dispatchNo, expected] of [ - ['COMPLETED', true, 'READY_TO_DEPART', 'DS-1', true], - ['READY_TO_DEPART', true, 'READY_TO_DEPART', 'DS-1', false], - ['PICKING', true, 'READY_TO_DEPART', 'DS-1', false], - ['COMPLETED', false, 'READY_TO_DEPART', 'DS-1', false], - ['COMPLETED', true, 'PENDING_DEPARTURE', 'DS-1', false], - ['COMPLETED', true, 'DEPARTED', 'DS-1', false], - ['COMPLETED', true, 'READY_TO_DEPART', 'DS-OTHER', false], - ]) { - const context = contextFor((method, path) => path.startsWith('/api/pda/') - ? ok({ dispatchNo: 'DS-1', status: taskStatus, canDepart }) : ok({ dispatchNo, status: dispatchStatus })) - assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), expected) +test('departure requires independent matching native stage, shared version, reconciled picks and both action gates', async () => { + const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993', + pickingReconciled: true, actions: { DEPART: { allowed: true } } } + for (const change of [null, { stage: 'LOADING' }, { stage: 'DELIVERING' }, { status: 'CANCELLED' }, + { dispatchNo: 'OTHER' }, { recordVersion: '9007199254740994' }, { pickingReconciled: false }, + { actions: { DEPART: { allowed: false } } }]) { + const context = contextFor((method, path) => ok(path.startsWith('/api/pda/') ? valid : { ...valid, ...change })) + assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), change == null) assert.equal(context.calls.length, 2) assert.equal(context.calls.some(call => call.method !== 'GET'), false) - assert.equal(context.assertions.some(row => !row.passed), !expected) } }) test('G05 freezes actual PDA balance IDs and measures raw sources independently from grouped representatives without inbound filters', async () => { const task = { dispatchNo: 'DS-1', warehouseId: 'WH-1', items: [ - { positions: [{ id: '11', balanceId: '1' }, { id: '12', balanceId: '2' }, { id: '11', balanceId: '1' }] }] } + { positions: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }, { positionId: '11', balanceId: '1' }] }] } assert.deepEqual(dispatchInventorySources(task, 'DS-1', 'WH-1'), { sourceBalanceIds: ['1', '2'], sourcePositionIds: ['11', '12'], sourcePositionBalances: [{ positionId: '11', balanceId: '1' }, { positionId: '12', balanceId: '2' }] }) assert.equal(dispatchInventorySources(task, 'OTHER', 'WH-1'), null) assert.equal(dispatchInventorySources(task, 'DS-1', 'OTHER'), null) - assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ id: '11' }] }] }, 'DS-1', 'WH-1'), null) + assert.equal(dispatchInventorySources({ ...task, items: [{ positions: [{ positionId: '11' }] }] }, 'DS-1', 'WH-1'), null) let shipped = false const source = balanceId => ({ id: balanceId, customerId: '100', overseasWarehouseId: 'WH-1', destinationWarehouseCode: 'FBA', skuCode: 'MIXED', totalPackages: balanceId === '1' ? 3 : 6, @@ -761,3 +754,129 @@ test('all fragments completed still requires raw aggregate history and matching ? ok({ summary: { orderId: '1002', mainStatus: 'COMPLETED' } }) : originalRequest(...args) assert.equal((await verifyFulfillmentRootStatus(foreignProjection.context, foreignProjection.args)).passed, false) }) + + +test('native PDA scans use exact current versions including zero, immutable replay and stale-version rejection without implicit loading', async () => { + const values = ['0', '9007199254740993', '9007199254740994'] + let currentVersion = values[0], picked = 0, firstBody, firstResult + const context = contextFor((method, path, body) => { + if (method === 'GET') return ok({ dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion, + pickingReconciled: picked === 2, counts: { packageCount: 2, pickedPackageCount: picked }, + items: [{ dispatchItemId: '9007199254740995', customerId: '100', scheduledPackages: 2, pickedPackages: picked, + positions: [{ positionId: '9007199254740996', balanceId: '9007199254740997', availablePackages: 1 }, + { positionId: '9007199254740998', balanceId: '9007199254740999', availablePackages: 1 }] }] }) + assert.match(path, /items\/9007199254740995\/pick$/) + assert.equal(Object.hasOwn(body, 'expectedVersion'), false) + assert.equal(typeof body.positionId, 'string') + if (body.idempotencyKey.endsWith('-STALE')) { + assert.equal(body.recordVersion, '0'); return { http: 200, body: { code: 'A0409' } } + } + if (firstBody?.idempotencyKey === body.idempotencyKey) { + assert.deepEqual(body, firstBody); return ok({ ...firstResult, replayed: true }) + } + assert.equal(body.recordVersion, currentVersion) + picked += 1; currentVersion = values[picked] + firstBody = structuredClone(body) + firstResult = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'LOADING', recordVersion: currentVersion, + result: { dispatchItemId: '9007199254740995', pickedPackages: picked }, counts: { pickedPackageCount: picked } } + return ok(firstResult) + }) + assert.equal(await pickAll(context, 'PDA-IN-MEMORY', 'DS-1'), true) + assert.equal(picked, 2) + assert.equal(context.calls.some(call => /loading-complete|\/complete|\/depart/.test(call.path)), false) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) +}) + +function nativeArrangementPreview(recordVersion = '0') { + return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'CARGO', + loadingCompletionHash: 'LOAD', arrange: { allowed: true }, groups: ['100', '200'].map(customerId => ({ + groupKey: `GROUP-${customerId}`, customerId, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, + groupCargoHash: `GROUP-HASH-${customerId}`, externalFreightHash: 'FREIGHT', feeCoverageCargoHash: 'COVERAGE', + requiredServices: ['DELIVERY_FEE'], feeSources: [], cargo: [{ customerId, packages: 1 }], blockReasons: [] })) } +} + +test('native arrangement carries frozen group and cost hashes, preserves all customer outbounds, and replays the exact original command', async () => { + for (const recordVersion of ['0', '9007199254740993']) { + const preview = nativeArrangementPreview(recordVersion) + let original, result + const context = contextFor((method, path, body) => { + if (method === 'GET') { assert.match(path, /delivery-arrangement$/); return ok(preview) } + assert.match(path, /transfer-delivery$/) + if (original) { assert.deepEqual(body, original); return ok({ ...result, replayed: true }) } + original = structuredClone(body) + assert.equal(body.recordVersion, recordVersion) + assert.equal(body.cargoHash, 'CARGO'); assert.equal(body.loadingCompletionHash, 'LOAD') + assert.deepEqual(body.groups.map(row => row.groupKey), ['GROUP-100', 'GROUP-200']) + assert.ok(body.groups.every(row => row.externalFreightHash === 'FREIGHT' && row.feeCoverageCargoHash === 'COVERAGE')) + result = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740994', + result: { dispatchNo: 'DS-1', cargoHash: 'CARGO', outbounds: ['100', '200'].map(customerId => ({ + customerId, outboundNo: `OB-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 1 })), + deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, + deliveryNo: `DL-${customerId}`, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, status: 'WAIT_DELIVERY', packageCount: 1 })) } } + return ok(result) + }) + const arranged = await arrangeDispatchDelivery(context, 'DS-1') + assert.deepEqual(arranged.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200']) + assert.equal(context.calls.filter(call => call.method === 'POST').length, 2) + assert.deepEqual(context.assertions.filter(row => !row.passed), []) + } +}) + +test('arrangement cannot run before loading or with missing hashes, groups or server gate', async () => { + for (const mutate of [value => { value.stage = 'LOADING' }, value => { value.status = 'CANCELLED' }, + value => { value.cargoHash = '' }, value => { value.loadingCompletionHash = '' }, + value => { value.groups = [] }, value => { value.arrange.allowed = false }, + value => { value.groups[0].blockReasons = ['MISSING_FEE'] }]) { + const preview = nativeArrangementPreview(); mutate(preview) + const context = contextFor((method) => { assert.equal(method, 'GET'); return ok(preview) }) + assert.equal(await arrangeDispatchDelivery(context, 'DS-1'), null) + assert.ok(context.assertions.some(row => !row.passed)) + } +}) + +for (const [name, outcome] of [ + ['transport', { http: 0, body: { code: '' } }], + ['infrastructure', { http: 503, body: { code: 'S0503' } }], + ['malformed success', { http: 200, body: { code: '200', data: null } }], + ['missing business response', { http: 200, body: {} }], +]) test(`write intent persists and ${name} stops commands and checkpoint restarts`, async () => { + const dir = await mkdtemp(join(tmpdir(), 'phase5-contract-')) + try { + const context = contextFor(async (method, path, body) => { + const saved = JSON.parse(await readFile(context.createdResources.phase5OverseasFulfillment.checkpointPath, 'utf8')) + assert.equal(saved.requests.at(-1).status, 'SENDING') + assert.equal(saved.requests.at(-1).body.recordVersion, '0') + assert.equal(saved.requests.at(-1).body.idempotencyKey, body.idempotencyKey) + assert.equal(JSON.stringify(saved).includes('SECRET-CREDENTIAL'), false) + return { ...outcome, requestId: 'REQ-UNKNOWN', traceId: 'TRACE-UNKNOWN' } + }) + context.options.output = join(dir, 'report.json') + context.createdResources.phase5OverseasFulfillment = {} + const scoped = await fulfillmentEvidenceContext(context) + const body = { recordVersion: '0', idempotencyKey: 'ORIGINAL-COMMAND', password: 'SECRET-CREDENTIAL' } + await assert.rejects(scoped.request('POST', '/api/platform/v1/last-leg/dispatches/DS-1/depart', body), /结果未知/) + await assert.rejects(scoped.request('POST', '/api/platform/v1/last-leg/dispatches/DS-1/depart', { ...body, idempotencyKey: 'NEW-KEY' }), /未知写结果/) + assert.equal(context.calls.length, 1) + const saved = JSON.parse(await readFile(context.createdResources.phase5OverseasFulfillment.checkpointPath, 'utf8')) + assert.equal(saved.status, 'UNKNOWN'); assert.equal(saved.requests[0].status, 'UNKNOWN') + assert.equal(saved.requests[0].requestId, 'REQ-UNKNOWN') + await assert.rejects(fulfillmentEvidenceContext(context), /已存在本轮履约检查点/) + } finally { await rm(dir, { recursive: true, force: true }) } +}) + + +test('default normal completion returns before exception writes; explicit legacy return stays after its original receipt guards and before closure', () => { + const run = source.slice(source.indexOf('export async function run')) + const start = run.indexOf('if (context.context?.phase5TailVerification?.runReturnScenario !== true)') + const exception = run.indexOf('const manualExceptionBody') + assert.ok(start > run.indexOf("savedVisit.data?.visit?.status !== 'COMPLETED'")) + const normalBranch = run.slice(start, exception) + assert.ok(normalBranch.includes("finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT'")) + assert.match(normalBranch, /await finishFleetTrip/) + assert.match(normalBranch, /return\s*}/) + assert.doesNotMatch(normalBranch, /createDeliveryReturn|completeReturnedException|exception-orders|mark-return/) + const explicitBranch = run.slice(exception) + assert.ok(explicitBranch.indexOf('const returnReceipt = await completeReturnedException') < explicitBranch.indexOf('await finishFleetTrip')) + assert.match(source, /const sourceBalanceIds = owned.inventoryProof\?.sourceBalanceIds/) + assert.match(source, /原returnSource位置必须存在于正式排车冻结真实余额中/) +}) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 03c355e1b..57a746812 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -2,6 +2,9 @@ import { createPicker, disablePicker, resolvePickerAccess } from './phase4-overs import { run as createMerchant } from './merchant-qualification-fixture.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' import { verifyFleetTripFinance } from './fleet-trip-finance-acceptance.mjs' +import { mkdir, writeFile, rename, readFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { createHash } from 'node:crypto' const MODULE = 'phase5-overseas-fulfillment' const SCENARIO = 'phase5-dispatch-outbound-delivery-exception-visit' @@ -11,7 +14,86 @@ const LASTLEG_BASE = '/api/platform/v1/last-leg' const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches' const MERCHANT_DELIVERY_BASE = '/api/merchant/v1/last-leg/delivery-orders' -function id(value) { return value == null ? '' : String(value) } +function id(value) { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('业务ID精度无效') + return value == null ? '' : String(value) +} +function version(value) { + if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) + throw new Error('服务端recordVersion缺失或精度无效') + return value +} + +// 发送前保存原命令;未知写结果只保留证据,禁止本轮继续推进。 +export async function fulfillmentEvidenceContext(context) { + const marker = String(context.marker) + if (!/^[A-Za-z0-9_-]+$/.test(marker)) throw new Error('验收marker不能用于独占检查点') + const path = resolve(dirname(context.options?.output || 'reports/admin-test-env.json'), 'phase5-checkpoints', `${marker}.json`) + try { await readFile(path); throw new Error(`已存在本轮履约检查点,必须核对原命令结果后续验:${path}`) } + catch (error) { if (error.code !== 'ENOENT') throw error } + const redact = value => { + if (Array.isArray(value)) return value.map(redact) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value) + .filter(([name]) => !/password|token|authorization|base64|^bytes$|^content$/i.test(name)) + .map(([name, nested]) => [name, redact(nested)])) + return value + } + const evidence = { marker, status: 'PREPARED', requests: [] } + const scoped = Object.create(context) + let stopped = false + scoped.checkpoint = async () => { + await mkdir(dirname(path), { recursive: true }) + await writeFile(`${path}.tmp`, `${JSON.stringify(redact({ ...evidence, + fulfillment: context.createdResources.phase5OverseasFulfillment, + fleetTrips: context.createdResources.fleetTrips }), null, 2)}\n`, 'utf8') + await rename(`${path}.tmp`, path) + } + scoped.request = async (method, requestPath, body, ...rest) => { + if (method === 'GET' || /\/auth\//.test(requestPath)) return context.request(method, requestPath, body, ...rest) + const identityCleanup = method === 'PUT' && /^\/api\/platform\/v1\/iam\/users\/[0-9]+\/status$/.test(requestPath) && body?.status === 'DISABLED' + if (stopped && !identityCleanup) throw new Error('存在未知写结果,保留原命令并停止后续写入') + const entry = { method, path: requestPath, body: redact(body), status: 'SENDING' } + evidence.requests.push(entry) + await scoped.checkpoint() + let response + try { response = await context.request(method, requestPath, body, ...rest) } + catch (error) { + stopped = true; entry.status = 'UNKNOWN'; evidence.status = 'UNKNOWN'; await scoped.checkpoint(); throw error + } + const nativeCommand = /^\/api\/(?:platform|pda)\/v1\/last-leg\/dispatches(?:\/|$)/.test(requestPath) + const knownResponse = response?.http > 0 && response.http < 500 && response.body?.code != null + && !(nativeCommand && code(response) === '200' && response.body.data == null) + Object.assign(entry, { status: knownResponse ? 'RESPONDED' : 'UNKNOWN', + http: response?.http, code: code(response), requestId: response?.requestId, traceId: response?.traceId, + result: redact(response?.body?.data) }) + if (entry.status === 'UNKNOWN') { stopped = true; evidence.status = 'UNKNOWN' } + await scoped.checkpoint() + if (stopped && !identityCleanup) throw new Error(`履约写入结果未知,停止并保留原命令;${trace(response)}`) + return response + } + scoped.requestMultipart = async (method, requestPath, file, ...rest) => { + const bytes = file?.content == null ? null : Buffer.from(file.content) + const metadata = { name: file?.name, type: file?.type, size: bytes?.length, + sha256: bytes ? createHash('sha256').update(bytes).digest('hex') : null } + // multipart也必须在发送前落盘;证据只含文件摘要而不含文件字节。 + const entry = { method, path: requestPath, file: metadata, status: 'SENDING' } + if (stopped) throw new Error('未知写结果后禁止继续上传') + evidence.requests.push(entry); await scoped.checkpoint() + let response + try { response = await context.requestMultipart(method, requestPath, file, ...rest) } + catch (error) { stopped = true; entry.status = 'UNKNOWN'; evidence.status = 'UNKNOWN'; await scoped.checkpoint(); throw error } + Object.assign(entry, { status: response?.http > 0 && response.http < 500 ? 'RESPONDED' : 'UNKNOWN', + http: response?.http, code: code(response), requestId: response?.requestId, traceId: response?.traceId, + result: redact(response?.body?.data) }) + if (entry.status === 'UNKNOWN') { stopped = true; evidence.status = 'UNKNOWN' } + await scoped.checkpoint() + if (stopped) throw new Error(`上传结果未知,保留原文件摘要并停写;${trace(response)}`) + return response + } + context.createdResources.phase5OverseasFulfillment.checkpointPath = path + await scoped.checkpoint() + return scoped +} function nonBlank(value) { return typeof value === 'string' && value.trim() !== '' } function success(response) { return response?.http === 200 && String(response?.body?.code) === '200' } function code(response) { return String(response?.body?.code ?? '') } @@ -52,7 +134,7 @@ async function merchant(context, token, method, name, path, body = undefined) { } function transferDeliveries(data) { - return Array.isArray(data?.deliveries) ? data.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : [] + return Array.isArray(data?.result?.deliveries) ? data.result.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : [] } function deliveryNumbers(data) { @@ -63,21 +145,19 @@ export function dispatchDestinationConservation(detail, fixture, deliveries = nu const codes = values => values.map(value => id(value).trim().toUpperCase()) const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank) && right.every(nonBlank) && JSON.stringify([...new Set(left)].sort()) === JSON.stringify([...new Set(right)].sort()) - const items = detail?.items - if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.planPalletIds) - || !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.route)) return false - const actualIds = items.map(row => id(row.planPalletId)), expectedIds=fixture.planPalletIds.map(id) - if (!actualIds.every(nonBlank) || actualIds.length !== expectedIds.length - || new Set(actualIds).size !== actualIds.length || !sameSet(actualIds,expectedIds)) return false - if (items.some(row => nonBlank(row.fbaWarehouseCode) && nonBlank(row.destinationWarehouseCode) - && id(row.fbaWarehouseCode).trim().toUpperCase() !== id(row.destinationWarehouseCode).trim().toUpperCase())) return false - const actual = codes(items.map(row => row.fbaWarehouseCode || row.destinationWarehouseCode)) - const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.route.split(',')) + const items = detail?.orderDetails + if (!Array.isArray(items) || !items.length || !Array.isArray(fixture?.sourceKeys) + || !Array.isArray(fixture?.destinationWarehouseCodes) || !nonBlank(detail?.dispatchInfo?.route)) return false + const actualIds = items.map(row => id(row.sourceKey)), expectedIds=fixture.sourceKeys.map(id) + if (!actualIds.every(nonBlank) || !sameSet(actualIds,expectedIds)) return false + const actual = codes(items.map(row => row.destinationFbaWarehouse)) + const frozen = codes(fixture.destinationWarehouseCodes), route = codes(detail.dispatchInfo.route.split(',')) if (!sameSet(actual,frozen) || !sameSet(route,actual) || route.length !== new Set(route).size) return false if (deliveries == null) return true if (!Array.isArray(deliveries) || !deliveries.length) return false - const delivered=codes(deliveries.map(row => row.destinationWarehouseCode)) - return delivered.length === new Set(delivered).size && sameSet(delivered,actual) + const delivered=codes(deliveries.map(row => row.destination?.warehouseCode)) + const groups = deliveries.map(row => `${id(row.customerId)}:${id(row.destination?.key)}`) + return groups.every(nonBlank) && new Set(groups).size === groups.length && sameSet(delivered,actual) } async function poll(action, predicate, attempts = 45, intervalMs = 1_000) { @@ -223,12 +303,12 @@ export function dispatchInventorySources(task, dispatchNo, warehouseId) { if (task?.dispatchNo !== dispatchNo || id(task.warehouseId) !== warehouseId || !Array.isArray(task.items) || !task.items.length) return null const positions = task.items.flatMap(item => Array.isArray(item.positions) ? item.positions : []) - if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.id)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null + if (!positions.length || positions.some(row => !/^[1-9][0-9]*$/.test(id(row.positionId)) || !/^[1-9][0-9]*$/.test(id(row.balanceId)))) return null const references = new Map() for (const row of positions) { - const previous = references.get(id(row.id)) + const previous = references.get(id(row.positionId)) if (previous && previous !== id(row.balanceId)) return null - references.set(id(row.id), id(row.balanceId)) + references.set(id(row.positionId), id(row.balanceId)) } return { sourceBalanceIds: [...new Set(positions.map(row => id(row.balanceId)))].sort(), sourcePositionIds: [...references.keys()].sort(), @@ -312,7 +392,7 @@ export function dispatchStockConservation(context, { customerId, warehouseId, di const proof = { sourcePositionBalances, totals, checks, ledgerRowChecks: rowChecks, snapshots: { beforePick: stockBefore, afterPick: stockAfter }, ledgers: { beforePick: ledgerBefore, afterPick: ledgerAfter } } if (context.createdResources?.phase5OverseasFulfillment) { - Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, proof) + Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof ||= {}, proof) } for (const [name, passed] of Object.entries(checks)) { context.assert(MODULE, `本排车库存独立核对-${name}`, null, passed, JSON.stringify(totals), OPTIONS) @@ -517,14 +597,14 @@ export async function completeReturnedException(context, { customerId, warehouse id(pallet.data?.warehouseId) === warehouseId && pallet.data?.palletCode === palletCode && Boolean(location?.locationCode), `targetPallet=${palletCode}, location=${location?.locationCode || 'missing'}`, OPTIONS) if (!location?.locationCode) return null - let processingVersion = Number(order.version) + let processingVersion = version(order.version) let evidenceFileId = continuing ? id(previousAttempt.evidenceFileId) : '' if (!continuing) { - const startBody = { expectedVersion: Number(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' } + const startBody = { expectedVersion: version(order.version), idempotencyKey: key(context, 'RETURN-START'), handlingAction: '退回海外仓实收' } const started = await platform(context, 'POST', '开始处理同一派送退回异常', query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/start-processing`, { customerId }), startBody) if (!success(started.response) || started.data?.status !== 'PROCESSING') return null - processingVersion = Number(started.data.version) + processingVersion = version(started.data.version) const evidence = await context.requestMultipart('POST', query(`${LASTLEG_BASE}/exception-orders/${encodeURIComponent(exceptionNo)}/evidence`, { customerId }), { name: `${context.marker}-return-receipt.txt`, type: 'text/plain', @@ -535,7 +615,7 @@ export async function completeReturnedException(context, { customerId, warehouse } const completeIdempotencyKey = continuing && nonBlank(previousAttempt.completeIdempotencyKey) ? previousAttempt.completeIdempotencyKey : key(context, 'RETURN-COMPLETE') - if (!Number.isInteger(processingVersion) || processingVersion < 0) { + if (processingVersion == null || !/^(0|[1-9]\d*)$/.test(String(processingVersion))) { blocker(context, '派送退回处理版本', '当前PROCESSING异常缺少有效服务端版本,不能以默认版本完成', before.response) return null } @@ -602,10 +682,12 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde const deliveryNo = id(delivery.deliveryNo) const documentsPath = query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents`, { customerId }) const before = await platform(context, 'GET', `读取第${index + 1}张派送单资料版本`, documentsPath) - const version = Number(before.data?.version) + const documentVersion = before.data?.version + const validVersion = documentVersion != null && /^(0|[1-9]\d*)$/.test(String(documentVersion)) + && (typeof documentVersion !== 'number' || Number.isSafeInteger(documentVersion)) context.assert(MODULE, `第${index + 1}张派送单资料提供乐观锁版本`, before.response, - Number.isInteger(version) && version >= 0, `version=${before.data?.version ?? 'missing'}; ${trace(before.response)}`, OPTIONS) - if (!success(before.response) || !Number.isInteger(version) || version < 0) return null + validVersion, `version=${documentVersion ?? 'missing'}; ${trace(before.response)}`, OPTIONS) + if (!success(before.response) || !validVersion) return null const uploadResponse = await context.requestMultipart('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/documents/RECEIPT/files`, { customerId }), { @@ -620,7 +702,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde if (!success(uploadResponse) || !fileId) return null if (context.createdResources.phase5OverseasFulfillment) { const receipts = context.createdResources.phase5OverseasFulfillment.receiptFiles ||= [] - receipts.push({ deliveryNo, fileId, + receipts.push({ deliveryNo, customerId, fileId, content: `${context.marker} receipt acceptance for ${deliveryNo}` }) } @@ -629,7 +711,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde fileIds: (category.files || []).map((file) => id(file.fileId)).filter((value) => /^\d+$/.test(value)), })).filter((binding) => binding.category && binding.category !== 'RECEIPT' && binding.fileIds.length > 0) const saveBody = { - expectedVersion: version, idempotencyKey: key(context, 'SAVE-RECEIPT', String(index)), + expectedVersion: version(documentVersion), idempotencyKey: key(context, 'SAVE-RECEIPT', deliveryNo), bolNo: before.data?.bolNo || null, documents: [...existing, { category: 'RECEIPT', fileIds: [fileId] }], } @@ -641,7 +723,7 @@ export async function saveReceiptAndComplete(context, delivery, customerId, inde const savedReplay = await platform(context, 'PUT', `第${index + 1}张派送单签收资料同键重放`, documentsPath, saveBody) context.assert(MODULE, `第${index + 1}张派送单资料重放版本稳定`, savedReplay.response, - savedReplay.data?.replayed === true && Number(savedReplay.data?.version) === Number(saved.data?.version), + savedReplay.data?.replayed === true && String(savedReplay.data?.version) === String(saved.data?.version), trace(savedReplay.response), OPTIONS) const completed = await platform(context, 'GET', `回查第${index + 1}张派送单自动完成`, query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}`, { customerId })) @@ -768,18 +850,21 @@ export function relabelOutboundMarksMatch(detail, relabel) { && Number(detail?.outbound?.packageCount) === expected.length } -async function verifyRelabelDispatchOutbound(context, relabel, transferBody) { +async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseId) { if (relabel?.marker !== context.marker || !nonBlank(relabel?.dispatchNo)) { blocker(context, '换标排车箱唛传递前置', '缺少本轮换标排车,不借用历史数据') return } - const transferred = await platform(context, 'POST', '本轮换标排车转派送并生成出库快照', - `${DISPATCH_BASE}/${encodeURIComponent(relabel.dispatchNo)}/transfer-delivery`, - { ...transferBody, idempotencyKey: key(context, 'RELABEL-TRANSFER') }) - const outboundNo = transferred.data?.outboundNo - if (!success(transferred.response) || !nonBlank(outboundNo)) return + const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId) + if (!await pickAll(context, token, relabel.dispatchNo)) return + await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements) + const transferred = await arrangeDispatchDelivery(context, relabel.dispatchNo) + if (!transferred) return + const customerId = id(relabel.customerId) + const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo + if (!nonBlank(outboundNo)) { blocker(context, '换标排车客户出库', '全车安排结果缺少本轮换标客户出库单'); return } const detail = await platform(context, 'GET', '回查换标排车的真实出库箱唛', - query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 200 })) + `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`) context.assert(MODULE, '排车出库快照精确保留全部新箱唛且无原箱唛或重复箱', detail.response, success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo && detail.data?.outbound?.dispatchNo === relabel.dispatchNo @@ -816,80 +901,138 @@ async function loginMerchant(context, customerId) { function fixture(context) { const dispatch = context.createdResources?.phase4OverseasDispatch const receiving = context.createdResources?.phase4OverseasPda - if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'PENDING_DEPARTURE' + if (dispatch?.marker !== context.marker || dispatch?.finalStatus !== 'ACTIVE' || dispatch?.stage !== 'BEFORE_LOADING' || !nonBlank(dispatch?.dispatchNo)) return null if (receiving?.marker !== context.marker || receiving?.finalStatus !== 'COMPLETED' - || !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId)) return null + || !id(receiving?.overseasWarehouseId) || !id(receiving?.customerId) + || id(dispatch.warehouseId) !== id(receiving.overseasWarehouseId)) return null return { dispatch, receiving } } export async function verifyDepartureReadiness(context, token, dispatchNo) { - const ready = await pda(context, token, 'GET', '发车前回查服务端canDepart门禁', + const ready = await pda(context, token, 'GET', '发车前回查PDA公共动作门禁', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - const dispatch = await platform(context, 'GET', '发车前独立回查实际排车状态', + const dispatch = await platform(context, 'GET', '发车前独立回查实际排车阶段', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) const passed = success(ready.response) && success(dispatch.response) - && ready.data?.dispatchNo === dispatchNo && ready.data?.status === 'COMPLETED' - && ready.data?.canDepart === true && dispatch.data?.dispatchNo === dispatchNo - && dispatch.data?.status === 'READY_TO_DEPART' - context.assert(MODULE, '发车前PDA任务完成且服务端门禁和实际排车待发均通过', ready.response, - passed, `taskStatus=${ready.data?.status || 'missing'}, canDepart=${ready.data?.canDepart}, dispatchStatus=${dispatch.data?.status || 'missing'}; ${trace(ready.response)}`, OPTIONS) + && [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo + && data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING' + && data.pickingReconciled === true && data.actions?.DEPART?.allowed === true) + && String(ready.data.recordVersion) === String(dispatch.data.recordVersion) + context.assert(MODULE, '双端同版本装车后且服务端发车门禁通过', ready.response, + passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS) + if (passed) version(ready.data.recordVersion) return passed ? ready : null } export async function pickAll(context, token, dispatchNo) { let detail = await pda(context, token, 'GET', 'PDA读取本轮排车拣货任务', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - const items = Array.isArray(detail.data?.items) ? detail.data.items : [] - if (!success(detail.response) || items.length === 0) { - blocker(context, 'PDA排车拣货任务', '本轮排车未生成可操作PDA托盘任务', detail.response) + if (!success(detail.response) || detail.data?.status !== 'ACTIVE' || detail.data?.stage !== 'LOADING' + || !Array.isArray(detail.data.items) || !detail.data.items.length) { + blocker(context, 'PDA排车拣货任务', '本轮排车尚未通过两级审核进入LOADING', detail.response) return false } - - for (const [itemIndex, item] of items.entries()) { - let remaining = Number(item.scheduledPackages || 0) - Number(item.pickedPackages || 0) - const positions = Array.isArray(item.positions) ? item.positions : [] - for (const [positionIndex, position] of positions.entries()) { + const items = detail.data.items + for (const [itemIndex, original] of items.entries()) { + // 每条真实命令前重新读取当前公共版本和可用来源,不从上次响应推算版本。 + detail = await pda(context, token, 'GET', '扫码前刷新公共版本及真实来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + let item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId)) + if (!success(detail.response) || !item) return false + const sources = item.returnCartons?.length ? item.returnCartons.map(carton => ({ carton })) : (item.positions || []).map(position => ({ position })) + for (const [sourceIndex, source] of sources.entries()) { + const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) if (remaining <= 0) break - const available = Number(position.availablePackages || 0) - if (available <= 0) continue + const available = source.carton ? 1 : Number(source.position.availablePackages) + if (!Number.isSafeInteger(available) || available <= 0) continue const packages = Math.min(remaining, available) - const body = { - positionId: id(position.id), packages, productQuantity: packages, - idempotencyKey: key(context, 'DISPATCH-PICK', `${itemIndex}-${positionIndex}`), - remark: `${context.marker}-phase5-dispatch-pick`, - } - const picked = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}库存位${positionIndex + 1}`, - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) + const body = { recordVersion: version(detail.data.recordVersion), + positionId: source.position ? id(source.position.positionId) : null, + cartonMark: source.carton?.cartonMark || null, packages, + productQuantity: source.carton ? Number(source.carton.productQuantity) : packages, + idempotencyKey: key(context, 'DISPATCH-PICK', `${dispatchNo}-${itemIndex}-${sourceIndex}`), + remark: `${context.marker}-phase5-dispatch-pick` } + const path = `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick` + const picked = await pda(context, token, 'POST', `PDA真实扫码托盘${itemIndex + 1}来源${sourceIndex + 1}`, path, body) if (!success(picked.response)) return false - const replay = await pda(context, token, 'POST', `PDA拣货托盘${itemIndex + 1}同键重放`, - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(item.dispatchItemId))}/pick`, body) - context.assert(MODULE, `PDA拣货托盘${itemIndex + 1}重放数量稳定`, replay.response, - success(replay.response) && Number(replay.data?.pickedPackages) === Number(picked.data?.pickedPackages), - trace(replay.response), OPTIONS) - remaining -= packages + const replay = await pda(context, token, 'POST', 'PDA扫码原命令重放', path, body) + const stable = success(replay.response) && replay.data?.replayed === true + && String(replay.data.recordVersion) === String(picked.data.recordVersion) + && JSON.stringify(replay.data.result) === JSON.stringify(picked.data.result) + && JSON.stringify(replay.data.counts) === JSON.stringify(picked.data.counts) + context.assert(MODULE, 'PDA扫码重放保留原结果及版本数量', replay.response, stable, trace(replay.response), OPTIONS) + if (!stable) return false + if (itemIndex === 0 && sourceIndex === 0) { + const stale = await context.request('POST', path, { ...body, idempotencyKey: `${body.idempotencyKey}-STALE` }, token, {}) + context.addStep(MODULE, '旧公共版本扫码冲突且不得再扣库存', stale, conflict(stale), trace(stale), OPTIONS) + if (!conflict(stale)) return false + } + detail = await pda(context, token, 'GET', '扫码后重新读取公共版本与数量', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + item = detail.data?.items?.find(row => id(row.dispatchItemId) === id(original.dispatchItemId)) + if (!success(detail.response) || !item) return false } - if (remaining !== 0) { - blocker(context, `PDA托盘${itemIndex + 1}足量库存`, - `scheduled=${item.scheduledPackages}, picked=${item.pickedPackages}, remaining=${remaining}`, detail.response) + if (Number(item.scheduledPackages) !== Number(item.pickedPackages)) { + blocker(context, `PDA托盘${itemIndex + 1}足量库存`, '实际来源不足,保留本轮已扫证据', detail.response) return false } } + const passed = detail.data?.stage === 'LOADING' && detail.data?.pickingReconciled === true + && Number(detail.data?.counts?.packageCount) > 0 + && Number(detail.data.counts.packageCount) === Number(detail.data.counts.pickedPackageCount) + context.assert(MODULE, '真实扫码核平仍停留LOADING等待显式装车完成', detail.response, passed, trace(detail.response), OPTIONS) + return passed +} - const completeBody = { idempotencyKey: key(context, 'DISPATCH-COMPLETE') } - const completed = await pda(context, token, 'POST', 'PDA确认排车拣货全部完成', - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) - context.assert(MODULE, '排车进入READY_TO_DEPART且数量守恒', completed.response, - completed.data?.status === 'READY_TO_DEPART' - && Number(completed.data?.plannedPackages) === Number(completed.data?.pickedPackages) - && Number(completed.data?.remainingPackages) === 0, - trace(completed.response), OPTIONS) - if (!success(completed.response)) return false - const replay = await pda(context, token, 'POST', 'PDA完成排车拣货同键重放', - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/complete`, completeBody) - context.assert(MODULE, 'PDA完成拣货重放明确标记且结果稳定', replay.response, - replay.data?.replayed === true && replay.data?.status === 'READY_TO_DEPART', trace(replay.response), OPTIONS) - return success(replay.response) +export async function arrangeDispatchDelivery(context, dispatchNo) { + const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}` + const preview = await platform(context, 'GET', '装车后读取全车真实派送分组与费用证据', `${path}/delivery-arrangement`) + const value = preview.data + if (!success(preview.response) || value?.dispatchNo !== dispatchNo || value.status !== 'ACTIVE' + || value.stage !== 'AFTER_LOADING' || value.arrange?.allowed !== true + || !nonBlank(value.cargoHash) || !nonBlank(value.loadingCompletionHash) + || !value.groups?.length || value.groups.some(group => !nonBlank(group.groupKey) + || !nonBlank(id(group.customerId)) || !nonBlank(group.destination?.key) + || !group.cargo?.length || !Array.isArray(group.blockReasons) || group.blockReasons.length)) { + blocker(context, '全车派送安排', '装车后正式预览或完整客户目的地分组门禁未满足', preview.response) + return null + } + const body = { recordVersion: version(value.recordVersion), idempotencyKey: key(context, 'TRANSFER', dispatchNo), + cargoHash: value.cargoHash, loadingCompletionHash: value.loadingCompletionHash, + groups: value.groups.map((group, index) => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, + plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), + deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-${index + 1}`.slice(0, 64), + carrierName: '本轮TEST排车', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', + vehicleType: 'TRUCK', vehicleSize: '按本轮真实实载', appointmentAccount: 'SELF', sellerName: null, + remark: `${context.marker}-phase5-arrangement`, externalFreightHash: group.externalFreightHash, + feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], + fees: (group.requiredServices || []).map(service => ({ feeType: service, feeName: service, + direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-controlled-test-fee` })) })) } + const transferred = await platform(context, 'POST', '平台一次安排全车全部客户目的地派送', `${path}/transfer-delivery`, body) + if (!success(transferred.response)) return null + const result = transferred.data?.result + const deliveries = transferDeliveries(transferred.data) + const outbounds = result?.outbounds + const customerGroups = value.groups.map(group => `${id(group.customerId)}:${group.destination.key}`).sort() + const actualGroups = deliveries.map(row => `${id(row.customerId)}:${row.destination?.key}`).sort() + const passed = transferred.data?.dispatchNo === dispatchNo && transferred.data?.status === 'ACTIVE' + && transferred.data?.stage === 'AFTER_LOADING' && result?.dispatchNo === dispatchNo + && result.cargoHash === body.cargoHash && deliveries.length === value.groups.length + && new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length + && JSON.stringify(customerGroups) === JSON.stringify(actualGroups) + && Array.isArray(outbounds) && outbounds.length === new Set(value.groups.map(group => id(group.customerId))).size + && outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'WAIT_OUTBOUND' + && deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).every(delivery => id(delivery.customerId) === id(row.customerId)) + && deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount)) + && deliveries.every(row => row.status === 'WAIT_DELIVERY') + context.assert(MODULE, '派送按客户出库及客户目的地完整分组并数量守恒', transferred.response, passed, trace(transferred.response), OPTIONS) + if (!passed) return null + const replay = await platform(context, 'POST', '全车安排派送原命令重放', `${path}/transfer-delivery`, body) + const stable = success(replay.response) && replay.data?.replayed === true + && String(replay.data.recordVersion) === String(transferred.data.recordVersion) + && JSON.stringify(replay.data.result) === JSON.stringify(result) + context.assert(MODULE, '安排重放返回原命令结果且不增单据', replay.response, stable, trace(replay.response), OPTIONS) + if (!stable) return null + return { ...transferred, preview: value, outbounds, deliveries } } /** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */ @@ -947,120 +1090,23 @@ export async function run(context) { const merchantToken = await loginMerchant(context, customerId) if (!merchantToken) return - const beforeEdit = await platform(context, 'GET', '编辑车辆前读取服务端目的仓线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + context = await fulfillmentEvidenceContext(context) + const beforeEdit = await platform(context, 'GET', '读取本轮原生整车详情及线路', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) if (!success(beforeEdit.response) || beforeEdit.data?.dispatchNo !== dispatchNo) return const routeProof = context.createdResources.phase5OverseasFulfillment.routeProof = { - frozenPlanPalletIds:source.dispatch.planPalletIds, - frozenDestinationWarehouseCodes:source.dispatch.destinationWarehouseCodes, - beforeEdit:beforeEdit.data, - } - context.assert(MODULE,'排车线路目的地集合与实际托盘及本轮冻结候选完全一致',beforeEdit.response, - dispatchDestinationConservation(beforeEdit.data,source.dispatch),trace(beforeEdit.response),OPTIONS) + sourceKeys: source.dispatch.sourceKeys, frozenDestinationWarehouseCodes: source.dispatch.destinationWarehouseCodes, + beforeEdit: beforeEdit.data } + context.assert(MODULE, '排车来源和目的地与本轮生产者守恒', beforeEdit.response, + dispatchDestinationConservation(beforeEdit.data,source.dispatch), trace(beforeEdit.response), OPTIONS) if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch)) return - const updateBody = { - vehiclePlate: `DEV-${context.marker.slice(-8)}`.slice(0, 64), - driverName: `五期验收司机-${context.marker.slice(-6)}`, - driverPhone: '19900000000', carrierName: '五期开发验收车队', - estimatedArrivalAt: new Date(Date.now() + 86_400_000).toISOString(), - } - const updated = await platform(context, 'PUT', '补齐本轮排车车辆司机与线路', - `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`, updateBody) - context.assert(MODULE, '排车编辑字段完整回显', updated.response, - updated.data?.dispatchNo === dispatchNo && updated.data?.vehiclePlate === updateBody.vehiclePlate - && updated.data?.driverName === updateBody.driverName && updated.data?.route === beforeEdit.data.route, - trace(updated.response), OPTIONS) - if (!success(updated.response)) return - + const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) const bol = await context.requestBinary('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/prints/bol`) context.addStep(MODULE, '排车BOL下载为真实PDF而非JSON错误', bol, bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') && new TextDecoder().decode(bol.bytes?.slice(0, 5)) === '%PDF-', trace(bol), OPTIONS) - - const transferBody = { - idempotencyKey: key(context, 'TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), - deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-WAYBILL`.slice(0, 64), - carrierName: updateBody.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', - vehicleType: 'TRUCK', vehicleSize: '15托', driverName: updateBody.driverName, - driverPhone: updateBody.driverPhone, vehiclePlate: updateBody.vehiclePlate, - appointmentAccount: 'SELF', sellerName: null, remark: `${context.marker}-phase5-transfer`, - fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', - currency: 'USD', amount: 10, remark: `${context.marker}-acceptance` }], - } - await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, transferBody) - const transferred = await merchant(context, merchantToken, 'POST', '客户创建派送并原子转出库单', - query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) - const outboundNo = id(transferred.data?.outboundNo) - const deliveries = transferDeliveries(transferred.data) - const deliveryNos = deliveryNumbers(transferred.data) - const destinationKeys = deliveries.map((value) => id(value.destinationWarehouseCode || value.destinationWarehouseId)) - routeProof.deliveries = deliveries - context.assert(MODULE,'转派送目的地集合与实际排车线路及本轮冻结候选守恒',transferred.response, - dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries),trace(transferred.response),OPTIONS) - if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return - const deliveryNo = deliveryNos[0] || '' - context.assert(MODULE, '转派送按目的FBA仓生成WAIT_DELIVERY单据数组', transferred.response, - nonBlank(outboundNo) && deliveries.length > 0 && new Set(deliveryNos).size === deliveries.length - && new Set(destinationKeys).size === deliveries.length && destinationKeys.every(nonBlank) - && transferred.data?.outboundStatus === 'WAIT_OUTBOUND' - && deliveries.every((value) => value?.deliveryStatus === 'WAIT_DELIVERY') - && deliveries.reduce((sum, value) => sum + Number(value?.packageCount || 0), 0) === Number(transferred.data?.packageCount) - && Number(transferred.data?.packageCount) > 0, - `outboundNo=${outboundNo || 'missing'}, deliveries=${deliveryNos.join(',') || 'missing'}, destinations=${destinationKeys.join(',') || 'missing'}; ${trace(transferred.response)}`, OPTIONS) - if (!success(transferred.response) || !outboundNo || deliveries.length === 0) return - Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos }) - const adminReplay = await platform(context, 'POST', '后台转派送复用客户已创建的同一出库派送单', - `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/transfer-delivery`, transferBody) - context.assert(MODULE, '后台与客户转派送共享同一业务事实', adminReplay.response, - adminReplay.data?.replayed === true && id(adminReplay.data?.outboundNo) === outboundNo - && JSON.stringify(deliveryNumbers(adminReplay.data)) === JSON.stringify(deliveryNos), trace(adminReplay.response), OPTIONS) - const lockedItem = updated.data?.items?.[0] - if (lockedItem?.id) { - const deniedRemove = await context.request('DELETE', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/items/${encodeURIComponent(id(lockedItem.id))}`) - context.addStep(MODULE, '已生成出库单禁止移除排车托盘', deniedRemove, conflict(deniedRemove), trace(deniedRemove), OPTIONS) - const stable = await platform(context, 'GET', '移除拒绝后回查排车托盘零漂移', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - context.assert(MODULE, '移除失败不改变箱数和托盘身份', stable.response, - Number(stable.data?.packageCount) === Number(updated.data?.packageCount) - && JSON.stringify(stable.data?.items?.map(item => id(item.id))) === JSON.stringify(updated.data?.items?.map(item => id(item.id))), trace(stable.response), OPTIONS) - } else blocker(context, '转派送后移除门禁前置', '本轮排车缺少真实明细ID') - const transferReplay = await merchant(context, merchantToken, 'POST', '客户创建派送同键重放', - query(MERCHANT_DELIVERY_BASE, { dispatchNo }), transferBody) - context.assert(MODULE, '转派送重放复用同一业务单据', transferReplay.response, - transferReplay.data?.replayed === true && id(transferReplay.data?.outboundNo) === outboundNo - && JSON.stringify(deliveryNumbers(transferReplay.data)) === JSON.stringify(deliveryNos), trace(transferReplay.response), OPTIONS) - const transferConflict = await context.request('POST', query(MERCHANT_DELIVERY_BASE, { dispatchNo }), { ...transferBody, idempotencyKey: key(context, 'TRANSFER-DIFFERENT') }, merchantToken, {}) - context.addStep(MODULE, '同一排车使用另一幂等键仍返回既有单据', transferConflict, - success(transferConflict) && id(transferConflict.body?.data?.outboundNo) === outboundNo - && transferConflict.body?.data?.replayed === true - && JSON.stringify(deliveryNumbers(transferConflict.body?.data)) === JSON.stringify(deliveryNos), trace(transferConflict), OPTIONS) - - const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', - query(MERCHANT_DELIVERY_BASE, { page: 1, size: 20, dispatchNo })) - const merchantRows = Array.isArray(merchantPage.data?.records) ? merchantPage.data.records : [] - context.assert(MODULE, '客户派送列表包含本轮全部FBA派送单且不依赖customerId参数', merchantPage.response, - deliveryNos.every((number) => merchantRows.some((value) => id(value?.deliveryNo) === number - && id(value?.dispatchNo) === dispatchNo)), - `records=${merchantRows.length}; ${trace(merchantPage.response)}`, OPTIONS) - const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', - `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`) - context.assert(MODULE, '客户派送详情返回订单、货物、费用与操作四段正式契约', merchantDetail.response, - id(merchantDetail.data?.order?.deliveryNo) === deliveryNo - && id(merchantDetail.data?.order?.dispatchNo) === dispatchNo - && nonBlank(id(merchantDetail.data?.order?.destinationWarehouseCode - || merchantDetail.data?.order?.destinationWarehouseId)) - && Array.isArray(merchantDetail.data?.skus) && merchantDetail.data.skus.length > 0 - && Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), - `skus=${merchantDetail.data?.skus?.length ?? 'missing'}, fees=${merchantDetail.data?.fees?.length ?? 'missing'}, operations=${merchantDetail.data?.operations?.length ?? 'missing'}; ${trace(merchantDetail.response)}`, - OPTIONS) - const access = await resolvePickerAccess(context) const picker = access ? await createPicker(context, 5, warehouseId, access) : null - const dispatchInboundCodes = [...new Set((updated.data?.items || []).map(item => item.inboundCode).filter(nonBlank))] - let tripProof - if (dispatchInboundCodes.length === 0) { - blocker(context, '排车库存数量守恒', '实际排车详情未提供来源入仓码') - await disablePicker(context, picker, 5) - return - } + let deliveries, outbounds, transferred try { if (!nonBlank(picker?.token)) { blocker(context, '创建本轮五期PDA操作员', '正式IAM账号已创建但海外安全投影或登录未就绪') @@ -1068,90 +1114,117 @@ export async function run(context) { } const sourceTask = await pda(context, picker.token, 'GET', '扫码前冻结实际排车全部库存来源', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - const sourceIds = dispatchInventorySources(sourceTask.data, dispatchNo, warehouseId) - context.assert(MODULE, '真实排车任务冻结全部库存位置及余额ID', sourceTask.response, - success(sourceTask.response) && Boolean(sourceIds), `dispatchNo=${dispatchNo}`, OPTIONS) - if (!sourceIds) return - const { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } = sourceIds - context.createdResources.phase5OverseasFulfillment.inventoryProof = { sourceBalanceIds, sourcePositionIds, sourcePositionBalances } - const stockBeforePick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码前本排车来源库存', sourceBalanceIds) - const ledgerBeforePick = await shipmentLedger(context, customerId, dispatchNo, '扫码前本排车SHIP流水', 'SHIP', dispatchNo) - Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { snapshots: { beforePick: stockBeforePick }, ledgers: { beforePick: ledgerBeforePick } }) - const sourcesBeforeValid = inventorySourcesPresent(stockBeforePick, sourcePositionBalances) - context.assert(MODULE, '扫码前库存完整覆盖任务冻结的每个原位置关联', null, sourcesBeforeValid, `positions=${sourcePositionIds.length}`, OPTIONS) - if (!sourcesBeforeValid || !ledgerBeforePick) return - tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) - const pickedSuccessfully = await pickAll(context, picker.token, dispatchNo) - const stockAfterPick = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '扫码及重放后库存', sourceBalanceIds) - const ledgerAfterPick = await shipmentLedger(context, customerId, dispatchNo, '扫码及重放后SHIP流水', 'SHIP', dispatchNo) - const expectedPackages = Number(transferred.data.packageCount) - const conservation = dispatchStockConservation(context, { customerId, warehouseId, dispatchNo, expectedPackages, - sourcePositionBalances, stockBefore: stockBeforePick, stockAfter: stockAfterPick, - ledgerBefore: ledgerBeforePick, ledgerAfter: ledgerAfterPick }) - const shippedRows = conservation.newLedger - if (!pickedSuccessfully) return - const tripVersion = await confirmFleetLoading(context, MODULE, tripProof) + if (!success(sourceTask.response) || !sourceTask.data?.items?.length) return + const customerIds = [...new Set(sourceTask.data.items.map(item => id(item.customerId)))] + const detailCustomers = [...new Set(beforeEdit.data.orderDetails.map(row => id(row.customerId)))].sort() + if (!customerIds.every(nonBlank) || JSON.stringify([...customerIds].sort()) !== JSON.stringify(detailCustomers)) { + blocker(context, '整车真实客户集合', 'PDA客户与本轮原生详情不一致'); return + } + const proofs = context.createdResources.phase5OverseasFulfillment.inventoryByCustomer = [] + for (const currentCustomer of customerIds) { + const task = { ...sourceTask.data, items: sourceTask.data.items.filter(item => id(item.customerId) === currentCustomer) } + const sources = dispatchInventorySources(task, dispatchNo, warehouseId) + const inboundCodes = [...new Set(beforeEdit.data.orderDetails.filter(row => id(row.customerId) === currentCustomer) + .map(row => row.inboundCode).filter(nonBlank))] + if (!sources || !inboundCodes.length) { blocker(context, '整车客户库存生产证据', '缺少当前客户真实库存位置或入仓码'); return } + const stockBefore = await inventorySnapshot(context, currentCustomer, warehouseId, inboundCodes, `客户${currentCustomer}扫码前库存`, sources.sourceBalanceIds) + const ledgerBefore = await shipmentLedger(context, currentCustomer, dispatchNo, `客户${currentCustomer}扫码前SHIP`, 'SHIP', dispatchNo) + if (!stockBefore || !ledgerBefore || !inventorySourcesPresent(stockBefore, sources.sourcePositionBalances)) return + proofs.push({ customerId: currentCustomer, inboundCodes, ...sources, stockBefore, ledgerBefore, + expectedPackages: task.items.reduce((sum, row) => sum + Number(row.scheduledPackages), 0) }) + } + await context.checkpoint() + if (!await pickAll(context, picker.token, dispatchNo)) return + for (const proof of proofs) { + proof.stockAfter = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes, + `客户${proof.customerId}扫码后库存`, proof.sourceBalanceIds) + proof.ledgerAfter = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}扫码后SHIP`, 'SHIP', dispatchNo) + const conservation = dispatchStockConservation(context, { ...proof, warehouseId, dispatchNo }) + proof.conservation = conservation.proof + if (!conservation.passed) return + } + const primaryInventory = proofs.find(proof => proof.customerId === customerId) + if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation } + await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements) + transferred = await arrangeDispatchDelivery(context, dispatchNo) + if (!transferred) return + deliveries = transferred.deliveries; outbounds = transferred.outbounds + routeProof.deliveries = deliveries + context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response, + dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS) + if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) if (!ready) return - const departBody = { recordVersion: tripVersion, idempotencyKey: key(context, 'DISPATCH-DEPART').slice(0, 128), remark: `${context.marker}-depart` } - const departed = await pda(context, picker.token, 'POST', 'PDA确认车辆发车并完成真实出库', + const departBody = { recordVersion: version(ready.data.recordVersion), + idempotencyKey: key(context, 'DISPATCH-DEPART', dispatchNo).slice(0, 128), remark: `${context.marker}-depart` } + const departed = await pda(context, picker.token, 'POST', 'PDA实际发车并完成真实出库', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) - context.assert(MODULE, '发车推进排车DEPARTED并返回实发数量', departed.response, - departed.data?.status === 'IN_TRANSIT' && departed.data?.tripNo === tripProof.tripNo - && departed.data?.dispatches?.some(item => item.dispatchNo === dispatchNo) && Number(departed.data?.shippedPackages) > 0, - trace(departed.response), OPTIONS) - if (!success(departed.response)) return - const departReplay = await pda(context, picker.token, 'POST', 'PDA发车同键重放', + const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0) + const departedCorrectly = success(departed.response) && departed.data?.dispatchNo === dispatchNo + && departed.data.status === 'ACTIVE' && departed.data.stage === 'DELIVERING' + && Number(departed.data.result?.shippedPackages) === expectedPackages + && JSON.stringify(deliveryNumbers(departed.data)) === JSON.stringify(deliveries.map(row => row.deliveryNo).sort()) + context.assert(MODULE, '实际发车进入DELIVERING且全部客户实发数量守恒', departed.response, departedCorrectly, trace(departed.response), OPTIONS) + if (!departedCorrectly) return + const departReplay = await pda(context, picker.token, 'POST', 'PDA发车原命令重放', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) - context.assert(MODULE, 'PDA发车重放明确标记且发运数量稳定', departReplay.response, - departReplay.data?.replayed === true - && Number(departReplay.data?.shippedPackages) === Number(departed.data?.shippedPackages), - trace(departReplay.response), OPTIONS) - const stockAfterDepart = await inventorySnapshot(context, customerId, warehouseId, dispatchInboundCodes, '发车及重放后库存', sourceBalanceIds) - const ledgerAfterDepart = await shipmentLedger(context, customerId, dispatchNo, '发车及重放后SHIP流水', 'SHIP', dispatchNo) - const inventoryProof = context.createdResources.phase5OverseasFulfillment.inventoryProof - inventoryProof.snapshots.afterDepart = stockAfterDepart - inventoryProof.ledgers.afterDepart = ledgerAfterDepart - context.assert(MODULE, '发车及重放只推进履约且库存和SHIP流水零额外扣减', departReplay.response, - inventorySourcesPresent(stockAfterDepart, sourcePositionBalances) - && sameSnapshot(stockAfterPick, stockAfterDepart) && sameSnapshot(ledgerAfterPick, ledgerAfterDepart), trace(departReplay.response), OPTIONS) - Object.assign(context.createdResources.phase5OverseasFulfillment.inventoryProof, { - sourceBalanceIds, sourcePositionIds, sourcePositionBalances, inboundCodes: dispatchInboundCodes, beforePackages: conservation.proof.totals.positionBefore, - afterPackages: quantityTotal((stockAfterDepart || []).flatMap(row => row.positions.filter(position => sourcePositionBalances.some(reference => reference.positionId === position.id && reference.balanceId === position.balanceId)))), expectedPackages, - shipmentLedgerIds: (shippedRows || []).map(row => row.id), - canDepart: ready.data?.canDepart === true, - }) + const replayStable = success(departReplay.response) && departReplay.data?.replayed === true + && String(departReplay.data.recordVersion) === String(departed.data.recordVersion) + && JSON.stringify(departReplay.data.result) === JSON.stringify(departed.data.result) + context.assert(MODULE, 'PDA发车重放保持原结果及公共版本', departReplay.response, replayStable, trace(departReplay.response), OPTIONS) + if (!replayStable) return + for (const proof of proofs) { + proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes, + `客户${proof.customerId}发车后库存`, proof.sourceBalanceIds) + proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo) + const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances) + && sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart) + context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', departReplay.response, unchanged, trace(departReplay.response), OPTIONS) + if (!unchanged) return + } + // 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。 + await verifyRelabelDispatchOutbound(context, context.createdResources?.phase4OverseasRelabel, picker.token, warehouseId) } finally { - await disablePicker(context, picker, 5) - if (picker) picker.token = null + try { await disablePicker(context, picker, 5) } + finally { if (picker) picker.token = null; await context.checkpoint() } } + const primaryDelivery = deliveries.find(row => id(row.customerId) === customerId) + if (!primaryDelivery) { blocker(context, '客户签收资料前置', '本轮主客户不在实际派送结果中'); return } + const deliveryNo = primaryDelivery.deliveryNo, outboundNo = primaryDelivery.outboundNo + const deliveryNos = deliveries.map(row => row.deliveryNo).sort() + Object.assign(context.createdResources.phase5OverseasFulfillment, { outboundNo, deliveryNo, deliveryNos, outbounds, deliveries }) + for (const outboundRef of outbounds) { + const outbound = await platform(context, 'GET', '逐客户回查发车后真实出库状态', `${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundRef.outboundNo)}`) + const passed = outbound.data?.outbound?.outboundNo === outboundRef.outboundNo + && id(outbound.data?.outbound?.customerId) === id(outboundRef.customerId) && outbound.data?.outbound?.status === 'OUTBOUNDED' + context.assert(MODULE, '每客户出库单独立OUTBOUNDED且归属不漂移', outbound.response, passed, trace(outbound.response), OPTIONS) + if (!passed) return + } + const customerDeliveries = deliveries.filter(row => id(row.customerId) === customerId) + const merchantPage = await merchant(context, merchantToken, 'GET', '客户分页回查自身派送单', query(MERCHANT_DELIVERY_BASE, { page: 1, size: 200, dispatchNo })) + const merchantRows = merchantPage.data?.records + const merchantScoped = Array.isArray(merchantRows) + && customerDeliveries.every(delivery => merchantRows.some(row => row.deliveryNo === delivery.deliveryNo)) + && merchantRows.every(row => id(row.customerId) === customerId) + context.assert(MODULE, '客户只看本车自己的派送单', merchantPage.response, merchantScoped, trace(merchantPage.response), OPTIONS) + if (!merchantScoped) return + const merchantDetail = await merchant(context, merchantToken, 'GET', '客户读取自身派送详情', `${MERCHANT_DELIVERY_BASE}/${encodeURIComponent(deliveryNo)}`) + context.assert(MODULE, '客户派送详情保留订单货物费用操作真实结构', merchantDetail.response, + merchantDetail.data?.order?.deliveryNo === deliveryNo && Array.isArray(merchantDetail.data?.skus) + && Array.isArray(merchantDetail.data?.fees) && Array.isArray(merchantDetail.data?.operations), trace(merchantDetail.response), OPTIONS) - const outbound = await platform(context, 'GET', '回查发车后出库单状态', - query(`${LASTLEG_BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { page: 1, size: 20 })) - context.assert(MODULE, '排车PDA发车同步出库单OUTBOUNDED', outbound.response, - outbound.data?.outbound?.outboundNo === outboundNo && outbound.data?.outbound?.status === 'OUTBOUNDED', - trace(outbound.response), OPTIONS) - - for (const [index, delivery] of deliveries.entries()) { - const currentNo = id(delivery.deliveryNo) - const beforeStatus = await platform(context, 'GET', `读取第${index + 1}张派送单状态版本`, - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}`, { customerId })) - const deliveryVersion = Number(beforeStatus.data?.order?.version) - context.assert(MODULE, `第${index + 1}张派送单提供状态乐观锁版本`, beforeStatus.response, - Number.isInteger(deliveryVersion) && deliveryVersion >= 0, - `version=${beforeStatus.data?.order?.version ?? 'missing'}; ${trace(beforeStatus.response)}`, OPTIONS) - if (!Number.isInteger(deliveryVersion) || deliveryVersion < 0) return - const delivering = await platform(context, 'PUT', `第${index + 1}张派送单推进配送中`, - query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(currentNo)}/status`, { customerId }), - { status: 'DELIVERING', expectedVersion: deliveryVersion, remark: `${context.marker}-delivering-${index + 1}` }) - context.assert(MODULE, `第${index + 1}张派送状态进入DELIVERING`, delivering.response, - delivering.data?.deliveryStatus === 'DELIVERING', trace(delivering.response), OPTIONS) - if (!success(delivering.response)) return + for (const delivery of deliveries) { + const current = await platform(context, 'GET', '逐客户核对发车后真实派送状态', + query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}`, { customerId: id(delivery.customerId) })) + const passed = current.data?.order?.status === 'DELIVERING' + && id(current.data.order.customerId) === id(delivery.customerId) && current.data.order.outboundNo === delivery.outboundNo + context.assert(MODULE, '派送由实际发车进入DELIVERING并保留客户出库关联', current.response, passed, trace(current.response), OPTIONS) + if (!passed) return } const completedDeliveries = [] for (const [index, delivery] of deliveries.entries()) { - const completed = await saveReceiptAndComplete(context, delivery, customerId, index) + const completed = await saveReceiptAndComplete(context, delivery, id(delivery.customerId), index) if (!completed) return completedDeliveries.push(completed) } @@ -1160,6 +1233,12 @@ export async function run(context) { && completedDeliveries.every((value) => value?.order?.status === 'DELIVERED'), `deliveries=${deliveryNos.join(',')}, completed=${completedDeliveries.length}`, OPTIONS) + const normalCompletion = await poll(() => context.request('GET', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`), + response => success(response) && response.body?.data?.status === 'ACTIVE' && response.body?.data?.stage === 'DELIVERED') + context.assert(MODULE, '全部客户正常签收汇总整车DELIVERED', normalCompletion.result, + success(normalCompletion.result) && normalCompletion.result.body?.data?.stage === 'DELIVERED', trace(normalCompletion.result), OPTIONS) + if (normalCompletion.result?.body?.data?.stage !== 'DELIVERED') return + context.createdResources.phase5OverseasFulfillment.normalCompletion = normalCompletion.result.body.data const removedComplete = await context.request('POST', query(`${LASTLEG_BASE}/delivery-orders/${encodeURIComponent(deliveryNo)}/complete`, { customerId }), { status: 'DELIVERED', expectedVersion: 0, remark: `${context.marker}-removed-complete-probe` }) context.addStep(MODULE, '人工确认派送完成接口已移除', removedComplete, removedComplete?.http === 404 || removedComplete?.http === 405 @@ -1201,11 +1280,12 @@ export async function run(context) { return } context.createdResources.phase5OverseasFulfillment.sourceOrderId = sourceOrderId - await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken }) + const rootCompletion = await verifyFulfillmentRootStatus(context, { sourceOrderId, customerId, merchantToken }) + if (!rootCompletion?.passed) return const foreignMerchantToken = context.context?.phase5TailVerification?.foreignMerchantToken || await createSignedDocumentIsolationCustomer(context) - await verifyCustomerSignedDocuments(context, merchantToken, - context.createdResources.phase5OverseasFulfillment.receiptFiles, foreignMerchantToken) + if (!await verifyCustomerSignedDocuments(context, merchantToken, + context.createdResources.phase5OverseasFulfillment.receiptFiles?.filter(receipt => receipt.customerId === customerId), foreignMerchantToken)) return const savedVisit = await platform(context, 'PUT', '保存无附件客户回访结果', query(`${LASTLEG_BASE}/customer-visits/${encodeURIComponent(visitId)}`, { customerId }), { @@ -1216,6 +1296,19 @@ export async function run(context) { savedVisit.data?.visit?.status === 'COMPLETED' && savedVisit.data?.visit?.selectedInboundCode === inbound.inboundCode && savedVisit.data?.visit?.visitResult === 'SATISFIED', trace(savedVisit.response), OPTIONS) + if (!success(savedVisit.response) || savedVisit.data?.visit?.status !== 'COMPLETED' + || savedVisit.data.visit.selectedInboundCode !== inbound.inboundCode + || savedVisit.data.visit.visitResult !== 'SATISFIED') return + + // 默认验收在正常签收与回访完成后结案。旧异常子场景仅接受调用方明确授权的独立开关。 + if (context.context?.phase5TailVerification?.runReturnScenario !== true) { + context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) + await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo], + businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true }) + context.createdResources.phase5OverseasFulfillment.finalStatus = 'DELIVERED_WITH_COMPLETED_VISIT' + await context.checkpoint() + return + } const manualExceptionBody = { customerId, inboundCode: inbound.inboundCode, lastMileOrderNo: inbound.lastMileOrderNo, @@ -1223,10 +1316,6 @@ export async function run(context) { } let manualException = await platform(context, 'POST', '为本轮尾程单创建人工异常', `${LASTLEG_BASE}/exception-orders`, manualExceptionBody) - if (manualException.response.http === 0) { - manualException = await platform(context, 'POST', '人工异常连接失败后使用同载荷重试', - `${LASTLEG_BASE}/exception-orders`, manualExceptionBody) - } const manualExceptionNo = id(manualException.data?.exceptionNo) context.assert(MODULE, '人工异常进入PENDING并返回业务单号', manualException.response, nonBlank(manualExceptionNo) && manualException.data?.status === 'PENDING', trace(manualException.response), OPTIONS) @@ -1264,7 +1353,7 @@ export async function run(context) { context.createdResources.phase5OverseasFulfillment.returnReceipt = returnReceipt context.createdResources.phase5OverseasFulfillment.tripClosure = await finishFleetTrip(context, MODULE, tripProof) await verifyFleetTripFinance(context, MODULE, { tripNo: tripProof.tripNo, dispatchNos: [dispatchNo], - businesses: deliveryNos.map(deliveryNo => ({ customerId, deliveryNo })), receiptsVisible: true }) + businesses: deliveries.map(row => ({ customerId: id(row.customerId), deliveryNo: row.deliveryNo })), receiptsVisible: true }) context.createdResources.phase5OverseasFulfillment.finalStatus = returnReceipt ? 'RETURNED_WITH_COMPLETED_VISIT' : 'RETURN_PENDING_WITH_COMPLETED_VISIT' } diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index 413c509df..00d869942 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -3,7 +3,7 @@ import test from 'node:test' import { readFileSync } from 'node:fs' import { fulfillmentHistorySnapshot, validTwoFragmentTree, validSignatureGate, completionCounts, exactCompletionOutboxProof, rootCompletionTimeMatches, resolveF40MerchantCredentials, - f40TransferBody, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder, + f40CreateDispatchBody, resolveF40LoadingMeasurements, sendFragment, f40LocalDateTime, validFragmentCabinetIdentity, validUnloadedRemainder, validRemainderCancellationGate, exactCancelledRemainderOutboxProof, cancelUnloadedRemainder, verifyRootProjections, verifyRemainderTaxIdentity, validFreshReceiptSave, preserveReceivingEvidence, resolveF40ReceivingWarehouse, run } from './phase5-root-fulfillment.mjs' @@ -153,24 +153,52 @@ test('F40 retains partial and failed receiving raw snapshots without exporting i assert.match(source,/proof.receiptReplayProofs \|\|= \[\]/) }) -test('F40 transfer uses the current formal Transfer DTO including required positive fee and same LocalDateTime tool',() => { - const dto=readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/delivery/dto/DeliveryRequests.java',import.meta.url),'utf8') - const vehicle={carrierName:'offline-fleet',vehiclePlate:'offline-plate',driverName:'offline-driver',driverPhone:'19900000000'} - const now=new Date('2026-09-30T01:02:03.456Z'),body=f40TransferBody('offline',1,vehicle,now) - for(const field of ['idempotencyKey','plannedDeliveryAt','deliveryMethod','pickupMethod','loadingType','fees']) { - assert.match(dto,new RegExp(`\\b${field}\\b`));assert.ok(body[field]) - } - assert.equal(body.plannedDeliveryAt,'2026-10-02T01:02:03') - assert.equal(f40LocalDateTime(now),'2026-09-30T01:02:03') - assert.equal(body.deliveryMethod,'TRUCK_PALLET');assert.equal(body.pickupMethod,'WAREHOUSE_PICKUP') - assert.equal(body.loadingType,'PALLET');assert.equal(body.vehicleSize,'15托') - for(const field of Object.keys(vehicle))assert.equal(body[field],vehicle[field]) - assert.equal(body.fees.length,1) - assert.deepEqual(Object.fromEntries(['feeType','feeName','direction','currency','amount'].map(field=>[field,body.fees[0][field]])), - {feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10}) - assert.match(source,/f40TransferBody\(context.marker,index,vehicle\)/) - assert.match(source,/estimatedArrivalAt: f40LocalDateTime\(/) +function nativeF40Candidate() { + const sourceVersion = 'a'.repeat(64) + return { sourceKey: 'PLAN:9007199254740993', sourceVersion, customerId: '90', warehouseId: '80', + inboundCode: 'IN-F40', availablePackages: '1', destination: { key: 'FBA:ONT8', type: 'FBA', warehouseCode: 'ONT8' }, + selection: { sourceType: 'INBOUND_PLAN', planPalletId: '9007199254740993', warehousePalletId: '9007199254740994', + returnReceiptId: null, relabelNo: null, sourceVersion }, blockReasons: [], appointmentBatches: [{ + batchKey: 'FBA_APPOINTMENT:9007199254740995', customerId: '90', recordVersion: '9007199254740996', + sources: [{ sourceKey: 'ORDER:9007199254740997', destinationKey: 'FBA:ONT8', packageCount: 1 }] }] } +} + +test('F40 native Create uses frozen candidate source and exact FBA batch version including zero, without legacy vehicle/Transfer fields', () => { + const candidate = nativeF40Candidate() + const body = f40CreateDispatchBody('offline', '80', '90', [candidate]) + assert.equal(body.warehouseId, '80') + assert.equal(body.cargo[0].planPalletId, '9007199254740993') + assert.equal(body.cargo[0].warehousePalletId, '9007199254740994') + assert.equal(body.cargo[0].sourceVersion, candidate.sourceVersion) + assert.deepEqual(body.batchAllocations, [{ planPalletId: '9007199254740993', batchKey: 'FBA_APPOINTMENT:9007199254740995', + customerId: '90', sourceKey: 'ORDER:9007199254740997', destinationKey: 'FBA:ONT8', packageCount: 1, recordVersion: '9007199254740996' }]) + candidate.appointmentBatches[0].recordVersion = '0' + assert.equal(f40CreateDispatchBody('offline', '80', '90', [candidate]).batchAllocations[0].recordVersion, '0') + assert.equal(body.vehicle, null); assert.equal(body.driver, null) + assert.equal('vehiclePlate' in body, false); assert.equal('plannedDeliveryAt' in body, false) + assert.equal(f40LocalDateTime(new Date('2026-09-30T01:02:03.456Z')), '2026-09-30T01:02:03') + const dto = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java', import.meta.url), 'utf8') + assert.match(dto, /record Create/) + for (const field of ['warehouseId', 'cargo', 'batchAllocations', 'sourceVersion']) assert.ok(dto.includes(field), field) }) + +test('F40 native Create rejects foreign/ambiguous sources and incomplete batch allocation instead of guessing source or version', () => { + for (const mutate of [value => { value.customerId = '91' }, value => { value.warehouseId = '81' }, + value => { value.sourceVersion = 'stale' }, value => { value.selection.sourceVersion = 'stale' }, + value => { value.selection.planPalletId = 9007199254740992 }, value => { value.availablePackages = '9007199254740993' }, + value => { value.blockReasons = ['NOT_RECEIVED'] }, value => { value.appointmentBatches[0].recordVersion = null }, + value => { value.appointmentBatches[0].recordVersion = 9007199254740992 }, + value => { value.appointmentBatches[0].customerId = '91' }, + value => { value.appointmentBatches[0].sources[0].packageCount = 2 }, + value => { value.appointmentBatches[0].sources[0].destinationKey = 'FBA:OTHER' }, + value => { value.appointmentBatches.push(structuredClone(value.appointmentBatches[0])) }]) { + const candidate = nativeF40Candidate(); mutate(candidate) + assert.throws(() => f40CreateDispatchBody('offline', '80', '90', [candidate])) + } + const candidate = nativeF40Candidate() + assert.throws(() => f40CreateDispatchBody('offline', '80', '90', [candidate, candidate])) +}) + test('F40 credentials prefer a complete current fixture pair and never combine fixture with configured customer',() => { const options={username:'offline-admin',password:'offline-admin-secret',merchantUsername:'configured-customer',merchantPassword:'configured-secret'} const current={username:'fixture-customer',password:'fixture-secret',tenantId:'90'} @@ -537,3 +565,130 @@ test('early F40 failures become FAILED with only a safe stage and preserve the o assert.equal(withLogin.createdResources.phase5RootFulfillment.finalStatus,'FAILED') assert.equal(JSON.stringify(withLogin.createdResources.phase5RootFulfillment).includes(secretError.message),false) }) + + +function nativeFragmentFixture() { + const context = fixture(), events = [], calls = [] + const producer = { tenantId: '90', inboundCode: 'IN-F40', sourceInboundId: '901', sourceOrderId: '100', + rootOrderId: '100', containerBookingId: '902' } + const rootProof = { fragments: [producer], currentStage: 'DISPATCH_AND_SIGNATURE_1' } + context.context = { f40BaseMarker: 'offline-F40', f40RootFulfillmentProof: rootProof } + const measurement = { environment: 'TEST', marker: 'offline-F40-SEND-0', dispatchNo: 'DS-F40', pallets: [] } + context.context.f40LoadingMeasurements = { 'DS-F40': measurement } + context.checkpoint = async () => { events.push('checkpoint') } + const delivery = { customerId: '90', deliveryNo: 'DL-F40', outboundNo: 'OB-F40', packageCount: '1', + destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' } } + let created, departed, signed = false + context.request = async (method, path, body, token) => { + calls.push({ method, path, body: structuredClone(body), token }) + let data + if (method === 'GET' && path.includes('/candidates?')) { + assert.equal(new URL(path, 'https://offline.invalid').searchParams.get('warehouseId'), '80') + assert.equal(new URL(path, 'https://offline.invalid').searchParams.has('sourceStage'), false) + data = { complete: true, candidates: [nativeF40Candidate()] } + } else if (method === 'POST' && path.endsWith('/dispatches')) { + events.push('create') + if (created) data = { ...created, replayed: true } + else data = created = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0', + result: { dispatchNo: 'DS-F40', changedItemIds: ['9007199254740998'] } } + } else if (method === 'POST' && path.endsWith('/depart')) { + events.push('depart') + assert.equal(token, 'pda-memory') + assert.equal(body.recordVersion, '9007199254740999') + if (departed) { + assert.deepEqual(body, calls.find(call => call.path.endsWith('/depart')).body) + data = { ...departed, replayed: true } + } else data = departed = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '9007199254741000', + result: { shippedPackages: '1', deliveries: [delivery], outbounds: [{ customerId: '90', outboundNo: 'OB-F40' }] } } + } else if (method === 'GET' && path.includes('/delivery-orders/')) { + data = { order: { id: '9007199254741001', customerId: '90', outboundNo: 'OB-F40', deliveryNo: 'DL-F40', + version: '9007199254741002', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY' }, skus: [{ inboundCode: 'IN-F40', packageCount: 1 }] } + } else if (method === 'GET' && path.endsWith('/outbound-orders/OB-F40')) { + data = { outbound: { customerId: '90', outboundNo: 'OB-F40', status: 'OUTBOUNDED' }, pallets: [{ inboundCode: 'IN-F40' }] } + } else if (method === 'GET' && path.endsWith('/dispatches/DS-F40')) { + assert.equal(signed, true); events.push('normal-completed') + data = { dispatchNo: 'DS-F40', status: 'ACTIVE', stage: 'DELIVERED', recordVersion: '9007199254741004' } + } else throw new Error(`Unexpected offline wire ${method} ${path}`) + return { http: 200, body: { code: '200', data } } + } + const deps = { + async fulfillmentEvidenceContext(current) { events.push('evidence'); return current }, + async prepareFleetTrip(current, module, dispatchNo, warehouseId) { + events.push('prepare-review'); assert.equal(dispatchNo, 'DS-F40'); assert.equal(warehouseId, '80') + return { dispatchNo, warehouseId, tripNo: 'TR-F40', recordVersion: '1' } + }, + async pickAll(current, token, dispatchNo) { events.push('pick'); assert.equal(token, 'pda-memory'); assert.equal(dispatchNo, 'DS-F40'); return true }, + async confirmFleetLoading(current, module, proof, token, actual) { + events.push('loading-complete'); assert.equal(token, 'pda-memory'); assert.equal(actual, measurement) + if (!actual) throw new Error('实际测量缺少有效palletSpaces') + return '9007199254740998' + }, + async arrangeDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery], + outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { result: { deliveries: [delivery] } } } }, + async verifyDepartureReadiness() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999' } } }, + async saveReceiptAndComplete(current, actual, customerId, index) { + events.push('receipt'); assert.equal(actual, delivery); assert.equal(customerId, '90'); assert.equal(index, 0) + signed = true + return { order: { customerId: '90', deliveryNo: 'DL-F40', status: 'DELIVERED', version: '9007199254741003', signedAt: '2026-10-19T01:00:00' } } + }, + async finishFleetTrip() { events.push('finish'); return { status: 'VERIFIED' } }, + } + return { context, producer, rootProof, deps, events, calls, measurement } +} + +test('F40 fragment uses native creation, reviews, real PDA scan, explicit loading/token/evidence, arrangement, refreshed departure version and normal signature', async () => { + const f = nativeFragmentFixture() + const result = await sendFragment(f.context, f.producer, { warehouseId: '80', token: 'pda-memory' }, 'merchant-memory', 0, f.deps) + assert.equal(result.outboundNo, 'OB-F40'); assert.equal(result.receiptVersion, '9007199254741003') + assert.equal(result.signedAt, '2026-10-19T01:00:00') + assert.deepEqual(f.events.filter(event => event !== 'checkpoint'), ['evidence', 'create', 'create', 'prepare-review', 'pick', + 'loading-complete', 'arrange', 'read-current-depart-version', 'depart', 'depart', 'receipt', 'normal-completed', 'finish']) + assert.equal(f.calls.some(call => /pre-arrange|\/status|\/api\/merchant\//.test(call.path)), false) + assert.equal(f.calls.some(call => call.method === 'PUT'), false) + assert.equal(f.rootProof.dispatches[0].stage, 'DELIVERED') + assert.equal(f.rootProof.dispatches[0].departureReplay.replayed, true) + assert.equal(f.context.steps.every(step => step.passed), true) +}) + +test('F40 missing real measurement evidence stops before arranging, departing or signing while keeping the failed source proof', async () => { + const f = nativeFragmentFixture() + delete f.context.context.f40LoadingMeasurements + f.deps.confirmFleetLoading = async (_context, _module, _proof, token, actual) => { + assert.equal(token, 'pda-memory'); assert.equal(actual, undefined) + throw new Error('实际测量缺少有效palletSpaces') + } + await assert.rejects(sendFragment(f.context, f.producer, { warehouseId: '80', token: 'pda-memory' }, 'merchant-memory', 0, f.deps), /实际测量缺少/) + assert.equal(f.events.includes('arrange'), false) + assert.equal(f.calls.some(call => call.path.endsWith('/depart')), false) + assert.equal(f.events.includes('receipt'), false) + assert.equal(f.rootProof.dispatches[0].dispatchNo, 'DS-F40') + assert.equal(f.rootProof.dispatches[0].stage, 'FAILED') + assert.equal(f.rootProof.dispatches[0].failedAction, 'LOADING_COMPLETE') + assert.equal(f.events.at(-1), 'checkpoint') +}) + +test('F40 measurement reader receives the exact current TEST source scope and preserves its returned evidence without inferred values', async () => { + const f = nativeFragmentFixture(), response = { arbitrary: 'opaque-original-proof' } + f.context.marker = 'offline-F40-SEND-0' + f.context.context.f40LoadingMeasurements = async scope => { + assert.deepEqual(scope, { environment: 'TEST', marker: 'offline-F40-SEND-0', dispatchNo: 'DS-F40', + warehouseId: '80', sourceInboundId: '901', inboundCode: 'IN-F40' }) + return response + } + assert.equal(await resolveF40LoadingMeasurements(f.context, 'DS-F40', f.producer, '80'), response) +}) + +test('F40 fresh receipt revision evidence preserves string versions above safe integer precision', () => { + const oldVersion = '9007199254740993', newVersion = '9007199254740994' + const documents = { version: oldVersion, uploadedCategoryCount: 1, categories: [{ category: 'RECEIPT', files: [{ fileId: '9007199254740995' }] }], replayed: false } + const before = { documents, delivery: { order: { version: oldVersion, status: 'DELIVERED', signedAt: '2026-10-19T01:00:00' }, + operations: [{ id: '9007199254740996', operationNode: 'RECEIPT_AUTO_COMPLETE' }] }, revisions: [{ version: oldVersion }] } + const after = structuredClone(before) + after.documents.version = newVersion; after.delivery.order.version = newVersion + after.delivery.operations.push({ id: '9007199254740997', operationNode: 'SAVE_DOCUMENTS', result: '保存派送资料:1/3类', + operatedAt: '2026-10-19T01:01:00', operatorName: 'TEST' }) + after.revisions.push({ version: newVersion, savedAt: '2026-10-19T01:01:00', operatorId: 'platform', snapshot: after.documents }) + assert.equal(validFreshReceiptSave(before, after), true) + after.documents.version = '9007199254740995' + assert.equal(validFreshReceiptSave(before, after), false) +}) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 7ed2caa47..57d3ef7bc 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -4,7 +4,7 @@ import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { stableLoadedCartonMarks } from './phase3-loading.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' -import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' +import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' import { isDeepStrictEqual } from 'node:util' import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs' import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' @@ -15,7 +15,17 @@ const MODULE = 'phase5-root-fulfillment' const OPTIONS = { scenario: 'f40-real-root-and-remainder-signatures', deploymentBlocker: true } const DISPATCH = '/api/platform/v1/last-leg/dispatches' const PDA = '/api/pda/v1/last-leg/dispatches' -const id = value => value == null ? '' : String(value) +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw new Error('F40业务ID精度无效') + return value == null ? '' : String(value) +} +const validVersion = value => (typeof value === 'string' || typeof value === 'number' && Number.isSafeInteger(value)) + && /^(0|[1-9]\d*)$/.test(String(value)) +const quantity = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : null +const wireVersion = value => { + if (!validVersion(value)) throw new Error('F40服务器版本缺失或精度无效') + return value +} const success = response => response?.http === 200 && String(response?.body?.code) === '200' const query = (path, values) => `${path}?${new URLSearchParams(values)}` @@ -66,11 +76,12 @@ export function preserveReceivingEvidence(proof, scope) { proof.receivingCandidateEvidence = scope.createdResources?.phase4OverseasPda?.candidateWaitEvidence || [] } export function validFreshReceiptSave(before, after) { - const previousVersion = Number(before?.documents?.version) - const nextVersion = Number(after?.documents?.version) - if (!Number.isSafeInteger(previousVersion) || nextVersion !== previousVersion + 1 - || Number(before?.delivery?.order?.version) !== previousVersion - || Number(after?.delivery?.order?.version) !== nextVersion + if (![before?.documents?.version, after?.documents?.version, before?.delivery?.order?.version, after?.delivery?.order?.version].every(validVersion)) return false + const previousVersion = BigInt(before.documents.version) + const nextVersion = BigInt(after.documents.version) + if (nextVersion !== previousVersion + 1n + || BigInt(before.delivery.order.version) !== previousVersion + || BigInt(after.delivery.order.version) !== nextVersion || before?.delivery?.order?.status !== 'DELIVERED' || !before.delivery.order.signedAt) return false const same = (left,right) => JSON.stringify(left) === JSON.stringify(right) if (!same(before.documents,{...after.documents,version:before.documents.version})) return false @@ -87,9 +98,9 @@ export function validFreshReceiptSave(before, after) { operations:oldOperations})) return false if (!Array.isArray(before.revisions) || !Array.isArray(after.revisions) || after.revisions.length !== before.revisions.length + 1) return false - const addedRevisions = after.revisions.filter(row => Number(row.version) === nextVersion) + const addedRevisions = after.revisions.filter(row => validVersion(row.version) && BigInt(row.version) === nextVersion) return addedRevisions.length === 1 && Boolean(addedRevisions[0].savedAt && addedRevisions[0].operatorId) - && same(before.revisions,after.revisions.filter(row => Number(row.version) !== nextVersion)) + && same(before.revisions,after.revisions.filter(row => !validVersion(row.version) || BigInt(row.version) !== nextVersion)) && same(addedRevisions[0].snapshot,after.documents) } export function resolveF40MerchantCredentials(context) { @@ -102,18 +113,48 @@ export function resolveF40MerchantCredentials(context) { if (!fixture.username || !fixture.password || !id(fixture.tenantId)) return null return { username:fixture.username, password:fixture.password, customerId:id(fixture.tenantId) } } -export function f40TransferBody(marker, index, vehicle, now = new Date()) { - return { - idempotencyKey:`${marker}-F40-TRANSFER-${index}`, - plannedDeliveryAt:f40LocalDateTime(new Date(now.getTime()+172800000)), - deliveryMethod:'TRUCK_PALLET', waybillNo:`${marker}-WAYBILL-${index}`.slice(0,64), - carrierName:vehicle.carrierName, pickupMethod:'WAREHOUSE_PICKUP', loadingType:'PALLET', - vehicleType:'TRUCK', vehicleSize:'15托', driverName:vehicle.driverName, - driverPhone:vehicle.driverPhone, vehiclePlate:vehicle.vehiclePlate, - appointmentAccount:'SELF', sellerName:null, remark:`${marker}-phase5-transfer`, - fees:[{feeType:'DELIVERY_FEE',feeName:'派送费',direction:'RECEIVABLE',currency:'USD',amount:10, - remark:`${marker}-acceptance`}], - } +/** 本片只接受正式候选的冻结选择器和FBA逐票来源,不补猜来源版本。 */ +export function f40CreateDispatchBody(marker, warehouseId, customerId, candidates) { + if (!Array.isArray(candidates) || !candidates.length) throw new Error('F40缺少本片真实候选') + const ids = new Set(), batchAllocations = [] + const positive = value => /^[1-9]\d*$/.test(id(value)) + const cargo = candidates.map(candidate => { + const selection = candidate?.selection + if (!selection || selection.sourceType !== 'INBOUND_PLAN' || !positive(selection.planPalletId) + || !positive(selection.warehousePalletId) || selection.returnReceiptId != null || selection.relabelNo != null + || candidate.sourceKey !== `PLAN:${id(selection.planPalletId)}` || ids.has(candidate.sourceKey) + || !/^[a-f\d]{64}$/i.test(selection.sourceVersion || '') || candidate.sourceVersion !== selection.sourceVersion + || id(candidate.customerId) !== id(customerId) || id(candidate.warehouseId) !== id(warehouseId) + || quantity(candidate.availablePackages) == null || quantity(candidate.availablePackages) <= 0 + || candidate.destination?.type !== 'FBA' || !candidate.destination.key + || !Array.isArray(candidate.blockReasons) || candidate.blockReasons.length + || !Array.isArray(candidate.appointmentBatches)) throw new Error('F40本片候选身份、库存或冻结版本不完整') + ids.add(candidate.sourceKey) + if (candidate.appointmentBatches.length) { + if (candidate.appointmentBatches.length !== 1) throw new Error('F40跨批次缺少本轮真实逐票分配,不推算') + const batch = candidate.appointmentBatches[0] + if (!/^FBA_APPOINTMENT:[1-9]\d*$/.test(batch.batchKey || '') || id(batch.customerId) !== id(customerId) + || !Array.isArray(batch.sources) || !batch.sources.length + || batch.sources.some(source => !/^(BOOKING:.+|ORDER:[1-9]\d*)$/.test(source.sourceKey || '') + || source.destinationKey !== candidate.destination.key || quantity(source.packageCount) == null || quantity(source.packageCount) <= 0) + || batch.sources.reduce((sum, source) => sum + quantity(source.packageCount), 0) !== quantity(candidate.availablePackages)) + throw new Error('F40预约批次与本片逐票实物不一致') + for (const source of batch.sources) batchAllocations.push({ planPalletId: id(selection.planPalletId), batchKey: batch.batchKey, + customerId: id(customerId), sourceKey: source.sourceKey, destinationKey: source.destinationKey, + packageCount: quantity(source.packageCount), recordVersion: wireVersion(batch.recordVersion) }) + } + return { sourceType: selection.sourceType, planPalletId: id(selection.planPalletId), + warehousePalletId: id(selection.warehousePalletId), returnReceiptId: null, relabelNo: null, sourceVersion: selection.sourceVersion } + }) + return { idempotencyKey: `${marker}-CREATE`.slice(0, 200), warehouseId: id(warehouseId), vehicle: null, driver: null, + plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${marker}-F40-native`, cargo, batchAllocations } +} +/** 调用方可提供只读实测读取器;不按箱数或计费重量构造物理实载值。 */ +export async function resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId) { + const source = context.context?.f40LoadingMeasurements + if (typeof source === 'function') return source({ environment: 'TEST', marker: context.marker, dispatchNo, + warehouseId: id(warehouseId), sourceInboundId: id(producer.sourceInboundId), inboundCode: producer.inboundCode }) + return source?.[dispatchNo] } export function validFragmentCabinetIdentity(workbench, fragment, task) { const container = workbench?.container @@ -129,7 +170,7 @@ export function fulfillmentHistorySnapshot(detail) { return { orderId: id(detail?.order?.id), rootOrderId: id(detail?.order?.rootOrderId), parentOrderId: id(detail?.order?.parentOrderId), role: detail?.order?.orderRole, - status: detail?.order?.status, version: Number(detail?.order?.version), + status: detail?.order?.status, version: detail?.order?.version, inboundCode: detail?.order?.inboundCode, orderNo: detail?.order?.orderNo, rawOrder: detail?.order, histories: (detail?.statusHistory || []).map(row => ({ ...row, id: id(row.id) })), @@ -443,61 +484,110 @@ async function fragment(context, source, rootOrderId, sequence) { shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt, palletLabelManifest, rootOrderId } } -async function sendFragment(context, producer, picker, merchantToken, index) { - const customerId = producer.tenantId - const candidates = await api(context, 'GET', '读取该来源入仓码实收排车候选', `${DISPATCH}/candidates?sourceStage=RECEIVED`) - const matches = (Array.isArray(candidates) ? candidates : []).filter(row => row.sourceStage === 'RECEIVED' - && row.inboundCode === producer.inboundCode && id(row.overseasWarehouseId) === id(picker.warehouseId)) - check(context, 'F40 只选择该根履约片的真实实收托盘', matches.length > 0 - && matches.every(row => Number(row.packageCount) > 0), `inboundCode=${producer.inboundCode}, pallets=${matches.length}`) - const dispatch = await api(context, 'POST', '该履约片创建独立真实排车', `${DISPATCH}/pre-arrange`, { - planPalletIds: matches.map(row => id(row.planPalletId)), - }) - const dispatchNo = dispatch.dispatchNo - const vehicle = { - vehiclePlate: `F40-${index}-${context.marker.slice(-6)}`.slice(0,64), driverName: 'F40验收司机', - driverPhone: '19900000000', carrierName: 'F40测试车队', - estimatedArrivalAt: f40LocalDateTime(new Date(Date.now()+86400000)), - } - await api(context, 'PUT', '补齐该片车辆司机', `${DISPATCH}/${dispatchNo}`, vehicle) - const transfer = await api(context, 'POST', '客户正式创建该片派送单', - query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), - f40TransferBody(context.marker,index,vehicle), merchantToken) - const deliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] - check(context, 'F40 同目的仓一片对应唯一派送单', deliveries.length === 1 && Boolean(deliveries[0]?.deliveryNo), `count=${deliveries.length}`) - const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) - const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) - check(context, 'F40 派送实际物品来源和一箱数量严格匹配', detail.order?.customerId === customerId - && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) - && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) +const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, + verifyDepartureReadiness, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } + +export async function sendFragment(context, producer, picker, merchantToken, index, overrides = {}) { + const { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness, + saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } = { ...F40_DISPATCH_DEPENDENCIES, ...overrides } + const customerId = id(producer.tenantId), warehouseId = id(picker.warehouseId) context.marker = `${context.context.f40BaseMarker}-SEND-${index}` - const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, picker.warehouseId) - check(context, 'F40 正式 PDA 拣货完成', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) - const tripVersion = await confirmFleetLoading(context, MODULE, tripProof) - check(context, 'F40 发车前置就绪', await verifyDepartureReadiness(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) - const departed = await api(context, 'POST', 'F40 正式 PDA 发车', `${PDA}/${dispatchNo}/depart`, - { recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker }, picker.token) - check(context, 'F40 整车发车包含本片排车', departed.status === 'IN_TRANSIT' && departed.tripNo === tripProof.tripNo - && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), `dispatch=${dispatchNo}`) - const outbound = await api(context, 'GET', '回查实际来源出库完成', - query(`/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}`, { page:'1',size:'20' })) - check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', outbound.outbound?.status === 'OUTBOUNDED' - && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), - `outbound=${transfer.outboundNo}`) - const beforeDelivery = await api(context, 'GET', '读取本片派送推进前版本', deliveryPath) - const delivering = await api(context,'PUT','实际派送推进配送中', - query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}/status`,{customerId}), - {status:'DELIVERING',expectedVersion:Number(beforeDelivery.order.version),remark:context.marker}) - check(context,'F40 派送处于配送中后保存签收',delivering.deliveryStatus === 'DELIVERING',`delivery=${deliveries[0].deliveryNo}`) - const completed = await withDeliveryEventRecovery(context, { - marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id), - sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId), - }, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index)) - check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) - check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) - const tripClosure = await finishFleetTrip(context, MODULE, tripProof) - return { producer, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: deliveries[0].deliveryNo, - receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure } + const rootProof = context.context.f40RootFulfillmentProof + check(context, 'F40 排车具有本轮根来源生产回执', Boolean(rootProof && producer.inboundCode && id(producer.sourceInboundId) + && rootProof.fragments?.some(row => id(row.sourceInboundId) === id(producer.sourceInboundId) + && row.inboundCode === producer.inboundCode && id(row.tenantId) === customerId + && id(row.containerBookingId) === id(producer.containerBookingId))), 'actual-root-source') + const dispatchProof = { marker: context.marker, customerId, warehouseId, sourceInboundId: id(producer.sourceInboundId), + inboundCode: producer.inboundCode, dispatchNo: null, stage: 'PREPARING' } + ;(rootProof.dispatches ||= []).push(dispatchProof) + // 复用同一已验证的写前落盘/未知停写助手;证据对象也保留在根单证明中。 + context.createdResources.phase5OverseasFulfillment = dispatchProof + context = await fulfillmentEvidenceContext(context) + try { + const candidates = await api(context, 'GET', '读取该来源入仓码原生实收排车候选', + query(`${DISPATCH}/candidates`, { warehouseId })) + check(context, 'F40 正式候选列表完整', candidates?.complete === true && Array.isArray(candidates.candidates), 'native-complete-candidate-list') + const matches = candidates.candidates.filter(row => row.selection?.sourceType === 'INBOUND_PLAN' + && row.inboundCode === producer.inboundCode && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId) + check(context, 'F40 只选择本片完整真实实收候选且总量一箱', candidates?.complete === true && matches.length > 0 + && matches.reduce((sum, row) => sum + Number(row.availablePackages), 0) === 1, `inboundCode=${producer.inboundCode}, pallets=${matches.length}`) + dispatchProof.candidates = matches + const createBody = f40CreateDispatchBody(context.marker, warehouseId, customerId, matches) + const dispatch = await api(context, 'POST', '该履约片通过原生创建独立真实整车', DISPATCH, createBody) + const dispatchNo = dispatch?.dispatchNo + check(context, 'F40 创建返回原生整车业务身份及装车前阶段', Boolean(dispatchNo) && dispatch.status === 'ACTIVE' + && dispatch.stage === 'BEFORE_LOADING' && validVersion(dispatch.recordVersion), 'native-create-result') + dispatchProof.dispatchNo = dispatchNo; dispatchProof.createResult = dispatch + const createReplay = await api(context, 'POST', 'F40 原生建单原命令重放', DISPATCH, createBody) + check(context, 'F40 建单重放只恢复原版本及业务结果', createReplay.replayed === true && createReplay.dispatchNo === dispatchNo + && String(createReplay.recordVersion) === String(dispatch.recordVersion) && JSON.stringify(createReplay.result) === JSON.stringify(dispatch.result), 'immutable-create-result') + dispatchProof.action = 'PREPARE_AND_REVIEW' + const tripProof = await prepareFleetTrip(context, MODULE, dispatchNo, warehouseId) + dispatchProof.trip = tripProof + dispatchProof.action = 'PDA_PICK' + check(context, 'F40 正式 PDA 拣货核平仍待显式装车', await pickAll(context, picker.token, dispatchNo), `dispatch=${dispatchNo}`) + dispatchProof.action = 'LOADING_COMPLETE' + const actualMeasurements = await resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId) + await confirmFleetLoading(context, MODULE, tripProof, picker.token, actualMeasurements) + dispatchProof.action = 'ARRANGE_DELIVERY' + const transfer = await arrangeDispatchDelivery(context, dispatchNo) + check(context, 'F40 平台装车后一次安排本片完整客户派送', Boolean(transfer), `dispatch=${dispatchNo}`) + const deliveries = transfer.deliveries, outbounds = transfer.outbounds + check(context, 'F40 同客户同目的仓一片对应唯一出库与派送', deliveries?.length === 1 && outbounds?.length === 1 + && id(deliveries[0].customerId) === customerId && id(outbounds[0].customerId) === customerId + && deliveries[0].outboundNo === outbounds[0].outboundNo && Number(deliveries[0].packageCount) === 1 + && deliveries[0].destination?.key === matches[0].destination.key, 'one-actual-customer-destination') + const outboundNo = outbounds[0].outboundNo + dispatchProof.arrangement = transfer.data?.result; dispatchProof.outboundNo = outboundNo + dispatchProof.deliveryNo = deliveries[0].deliveryNo + const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) + const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) + check(context, 'F40 派送实际物品来源和一箱数量严格匹配', id(detail.order?.customerId) === customerId + && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) + && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) + dispatchProof.action = 'PDA_DEPART' + const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) + check(context, 'F40 双端当前公共版本发车门禁就绪', Boolean(ready), `dispatch=${dispatchNo}`) + const departBody = { recordVersion: wireVersion(ready.data.recordVersion), + idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker } + const departed = await api(context, 'POST', 'F40 正式 PDA 实际发车', `${PDA}/${dispatchNo}/depart`, departBody, picker.token) + check(context, 'F40 本片原生发车进入DELIVERING并且真实出库一箱', departed.dispatchNo === dispatchNo + && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 1 + && departed.result?.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === deliveries[0].deliveryNo, `dispatch=${dispatchNo}`) + const departureReplay = await api(context, 'POST', 'F40 实际发车原命令重放', `${PDA}/${dispatchNo}/depart`, departBody, picker.token) + check(context, 'F40 发车重放保留原公共版本及全部发运结果', departureReplay.replayed === true + && String(departureReplay.recordVersion) === String(departed.recordVersion) + && JSON.stringify(departureReplay.result) === JSON.stringify(departed.result), 'immutable-departure-result') + dispatchProof.departure = departed; dispatchProof.departureReplay = departureReplay; dispatchProof.stage = 'DELIVERING' + const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`) + check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId + && outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED' + && outbound.pallets?.length > 0 && outbound.pallets.every(row => row.inboundCode === producer.inboundCode), `outbound=${outboundNo}`) + const beforeDelivery = await api(context, 'GET', '回查实际发车推进本片派送及其版本', deliveryPath) + check(context, 'F40 实际发车已推进派送DELIVERING且身份未漂移', beforeDelivery.order?.status === 'DELIVERING' + && id(beforeDelivery.order.customerId) === customerId && beforeDelivery.order.outboundNo === outboundNo + && validVersion(beforeDelivery.order.version), 'actual-departure-delivery-facts') + dispatchProof.action = 'SAVE_RECEIPT_AND_EVENT_RECOVERY' + const completed = await withDeliveryEventRecovery(context, { + marker: context.context.f40BaseMarker, customerId: id(customerId), deliveryId: id(beforeDelivery.order.id), + sourceInboundId: id(producer.sourceInboundId), sourceOrderId: id(producer.sourceOrderId), rootOrderId: id(producer.rootOrderId), + }, id(picker.warehouseId), index, () => saveReceiptAndComplete(context, deliveries[0], customerId, index)) + check(context, 'F40 实际签收资料驱动派送完成', completed?.order?.status === 'DELIVERED', `delivery=${deliveries[0].deliveryNo}`) + check(context,'F40 签收完成记录真实签收时间', Boolean(completed.order.signedAt), `delivery=${deliveries[0].deliveryNo}`) + const nativeCompletion = await waitState(() => api(context, 'GET', 'F40 回查本片全部正常签收汇总阶段', `${DISPATCH}/${dispatchNo}`), + value => value?.status === 'ACTIVE' && value?.stage === 'DELIVERED') + check(context, 'F40 正常签收自动汇总原生整车DELIVERED', nativeCompletion?.dispatchNo === dispatchNo + && nativeCompletion.status === 'ACTIVE' && nativeCompletion.stage === 'DELIVERED', 'actual-normal-signature-completion') + dispatchProof.normalCompletion = nativeCompletion; dispatchProof.stage = 'DELIVERED' + const tripClosure = await finishFleetTrip(context, MODULE, tripProof) + dispatchProof.tripClosure = tripClosure + await context.checkpoint() + return { producer, dispatchNo, outboundNo, deliveryNo: deliveries[0].deliveryNo, + receiptVersion: completed.order.version, signedAt: completed.order.signedAt, receiptSnapshot:completed, tripNo: tripProof.tripNo, tripClosure } + } catch (error) { + dispatchProof.stage = 'FAILED'; dispatchProof.failedAction = dispatchProof.action || 'CREATE' + try { await context.checkpoint() } finally { throw error } + } } /** Only the first actual FBA signature is faulted; caller performs the normal receipt command. */ @@ -763,7 +853,7 @@ async function runRootFulfillment(context) { revisions:await api(scope,'GET','F40 新键前冻结资料版本历史',historyPath)} check(scope,'F40 新键前原签收事实保持完整稳定', JSON.stringify(signed.receiptSnapshot) === JSON.stringify(beforeFresh.delivery),`delivery=${signed.deliveryNo}`) - const saveBody = {expectedVersion:Number(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents} + const saveBody = {expectedVersion:wireVersion(current.version),idempotencyKey:`${scope.marker}-SAVE`,bolNo:current.bolNo || null,documents} const receiptProof = {deliveryNo:signed.deliveryNo,beforeFresh,saveBody,afterFresh:{},afterSameKey:{}} ;(proof.receiptReplayProofs ||= []).push(receiptProof) const saved = await api(scope,'PUT','重复已完成派送文件保存不得重发签收事实', diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index 347c9ac59..ca65f2279 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -5,8 +5,9 @@ import { createHash, randomUUID } from 'node:crypto' import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' -import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' -import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' +import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam' @@ -20,7 +21,6 @@ export function poolLocalDateTime(date) { requireThat(date instanceof Date && Number.isFinite(date.getTime()), 'VALID_LOCAL_DATE_REQUIRED') return new Date(date.getTime() - date.getTimezoneOffset() * 60000).toISOString().slice(0, 19) } -const localTime = ms => poolLocalDateTime(new Date(Date.now() + ms)) const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') export const POOL_COVERAGE = Object.freeze([ @@ -271,9 +271,11 @@ async function verifyPauseAndCutoff({ offerNo, bookingNo, salesToken, dispatcher async function poolScenario(context, fixture, { execute = false, readInventory, credentials }) { if (!execute) return poolPlan({ execute: false, runId: 'plan' }) requireThat(fixture?.marker === context.marker && fixture.members?.length === 2 && fixture.pdaToken + && fixture.dispatchNo && fixture.tripProof?.dispatchNo === fixture.dispatchNo + && fixture.members.every(member => member.dispatchNo === fixture.dispatchNo) && typeof readInventory === 'function', 'CURRENT_PHYSICAL_FIXTURE_REQUIRED') const proof = context.createdResources.fleetCapacityPool = { status: 'CHECKING', marker: context.marker, - tripNo: fixture.tripNo, covered: [], remaining: [...POOL_REMAINING], actors: [], orders: [], snapshots: {} } + tripNo: fixture.tripNo, dispatchNo: fixture.dispatchNo, covered: [], remaining: [...POOL_REMAINING], actors: [], orders: [], snapshots: {} } const tripProof = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo) const tripPath = `${API}/fleet/trips/${encode(fixture.tripNo)}` const key = label => createHash('sha256').update(`${context.marker}:POOL:${label}`).digest('hex') @@ -315,7 +317,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory, for (let n = 0; n < 2; n++) dispatchers.push(await createActor('dispatch', n, [PREFIX + 'manage'])) const fulfillmentToken = await createActor('fulfillment', 0, [PREFIX + 'manage', 'platform:fleet:trip:booking-edit', 'platform:fleet:trip:booking-confirm', 'platform:fleet:trip:edit', - 'platform:overseas-warehouse:inbound-order:smart-dispatch']) + 'platform:overseas-warehouse:dispatch:create']) for (const member of fixture.members) { const credential = credentials?.get(member.customerId) check('Original fixture customer credentials available only in memory', Boolean(credential)) @@ -324,15 +326,23 @@ async function poolScenario(context, fixture, { execute = false, readInventory, await reject('Merchant token cannot read internal capacity pool', 'GET', `${POOL}?page=1&size=20`, undefined, login.accessToken) } proof.covered.push('merchant-cannot-read-pool') + const dispatchPath = `${API}/dispatches/${encode(fixture.dispatchNo)}` + let main = await request('GET', dispatchPath) + check('One native main retains both original unpicked customers', main.status === 'ACTIVE' && main.stage === 'LOADING' + && Number(main.counts?.pickedPackageCount) === 0 && Number(main.counts?.shippedPackageCount) === 0) + await request('POST', `${dispatchPath}/return-to-review`, { recordVersion: main.recordVersion, idempotencyKey: key('RETURN-BEFORE-POOL'), reason: 'TEST零扫码显式退回以追加本轮外接实收' }) + main = await request('GET', dispatchPath) + check('Explicit return preserves main identity and all sources before pool changes', main.dispatchNo === fixture.dispatchNo && main.stage === 'BEFORE_LOADING' && main.reviewState === 'DRAFT') let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`) check('One actual thirty-space trip and two original real pallets', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2) - const planned = controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(p => ({ ...p, palletSpaces: 9 })) + const planned = testPlannedCapacity(plan.cargo, detail.trip.stops).map(p => ({ ...p, palletSpaces: 9 })) await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, pallets: planned, reason: 'TEST容量计划18托位;实际装载另行实测确认' }) detail = await request('GET', tripPath) const offerDraft = await request('POST', POOL, { tripNo: fixture.tripNo, tripRecordVersion: detail.trip.recordVersion, cutoffAt: new Date(Date.now() + 1800000).toISOString(), contactName: 'TEST调度', contactPhone: '19900000000', remark: context.marker, idempotencyKey: key('OFFER') }, dispatchers[0]) let offer = (await request('POST', `${POOL}/${offerDraft.offer.offerNo}/publish`, { tripRecordVersion: offerDraft.offer.tripRecordVersion, offerRecordVersion: offerDraft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, dispatchers[0])).offer + proof.planEvidence = { actual: false, reason: 'TEST销售占位计划18/30,不能作为实载回执', pallets: planned } proof.offerNo = offer.offerNo const destinationKey = plan.cargo[0].destinationKey check('Owned physical sources share exact destination', plan.cargo.every(row => row.destinationKey === destinationKey)) @@ -458,33 +468,49 @@ async function poolScenario(context, fixture, { execute = false, readInventory, check('Sales retains its own history after close', history.records.some(row => row.bookingNo === first.bookingNo) && history.records.every(row => row.salesOwnerUserId === first.salesOwnerUserId)) proof.covered.push('close-and-successful-confirmation-replay') - detail = await request('GET', tripPath) - for (const binding of detail.dispatches) detail = await request('POST', `${tripPath}/dispatches/${encode(binding.dispatchNo)}/review`, { recordVersion: detail.trip.recordVersion, decision: 'APPROVED' }) + main = await request('GET', dispatchPath) + check('All appended physical pallets remain on the original one native main before review', main.dispatchNo === fixture.dispatchNo + && main.stage === 'BEFORE_LOADING' && Number(main.counts?.packageCount) === 4 && Number(main.counts?.pickedPackageCount) === 0) + let reviewed = main + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) reviewed = await request('POST', `${dispatchPath}/${action}`, + { recordVersion: reviewed.recordVersion, idempotencyKey: key(`POOL-${action}`), + ...(action === 'submit-review' ? { remark: context.marker } : { decision: 'APPROVED', reason: context.marker }) }) const members = [...fixture.members, ...poolMembers] + check('Original and pool receipts all belong to the same trip main', members.every(member => member.dispatchNo === fixture.dispatchNo)) proof.pickScopes = [] + const namespace = await pickPoolDispatch(context, fixture.pdaToken, fixture.members[0]) + proof.pickScopes.push({ dispatchNo: fixture.dispatchNo, customerIds: [...new Set(members.map(row => row.customerId))], namespace }) + const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken) + check('All four owned and external pallets were really picked within one native main', task.status === 'ACTIVE' && task.stage === 'LOADING' + && task.pickingReconciled === true && Number(task.counts?.packageCount) === 4 && Number(task.counts?.pickedPackageCount) === 4 + && task.items.length === 4 && members.every(member => task.items.some(row => id(row.customerId) === member.customerId + && row.palletNo === member.palletNo && Number(row.pickedPackages) === 1))) + await confirmFleetLoading(context, MODULE, tripProof, fixture.pdaToken, fixture.loadingMeasurements) + const arranged = await arrangeDispatchDelivery(context, fixture.dispatchNo) + check('One native platform arrangement covers all four real sources and both customers', arranged?.deliveries?.length === 2 + && arranged.outbounds?.length === 2 && arranged.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === 4) for (const member of members) { - const namespace = await pickPoolDispatch(context, fixture.pdaToken, member) - proof.pickScopes.push({ customerId: member.customerId, dispatchNo: member.dispatchNo, namespace }) - const task = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(member.dispatchNo)}`, undefined, fixture.pdaToken) - check('Every owned and external pallet was actually picked', task.items.every(row => Number(row.pickedPackages) === Number(row.scheduledPackages))) + const matches = arranged.deliveries.filter(row => id(row.customerId) === member.customerId) + check('Every source maps to its exact customer delivery without invented child dispatches', matches.length === 1) + Object.assign(member, { outboundNo: matches[0].outboundNo, deliveryNo: matches[0].deliveryNo }) } - const loading = await request('GET', `${tripPath}/loading`) - check('All four original physical pallets present for actual loading', loading.cargo.length === 4 && !loading.blockReason) - const loaded = await request('POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, - pallets: controlledPalletMeasurements(loading.cargo, detail.trip.stops), reason: 'TEST受控实测每实托1托位;不同于之前18托位接单计划' }) - detail = await request('POST', `${tripPath}/loading-review`, { recordVersion: loaded.recordVersion, decision: 'APPROVED' }) - const departBody = { recordVersion: detail.trip.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } - const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.members[0].dispatchNo)}/depart` + const readyTask = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken) + check('Arrangement leaves native main ready in AFTER_LOADING', readyTask.status === 'ACTIVE' && readyTask.stage === 'AFTER_LOADING' && readyTask.actions?.DEPART?.allowed === true) + const departBody = { recordVersion: readyTask.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } + const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}/depart` const departed = await request('POST', departPath, departBody, fixture.pdaToken) - check('One whole-trip actual departure includes exactly four dispatches', departed.status === 'IN_TRANSIT' && Number(departed.shippedPackages) === 4 - && departed.dispatches.length === 4 && members.every(member => departed.dispatches.some(row => row.dispatchNo === member.dispatchNo))) - check('Departure original-key replay is explicit', (await request('POST', departPath, departBody, fixture.pdaToken)).replayed === true) - for (const [index, member] of members.entries()) { - const path = `${API}/delivery-orders/${encode(member.deliveryNo)}?customerId=${encode(member.customerId)}` - const currentDelivery = await request('GET', path) - await request('PUT', path.replace('?', '/status?'), { status: 'DELIVERING', expectedVersion: currentDelivery.order.version, remark: context.marker }) - const signed = await saveReceiptAndComplete(context, { deliveryNo: member.deliveryNo }, member.customerId, index) + check('One native actual departure covers four sources and two customer deliveries', departed.dispatchNo === fixture.dispatchNo + && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 4 + && departed.result.deliveries.length === 2) + const departReplay = await request('POST', departPath, departBody, fixture.pdaToken) + check('Departure original-key replay preserves exact version and documents', departReplay.replayed === true + && id(departReplay.recordVersion) === id(departed.recordVersion) && JSON.stringify(departReplay.result) === JSON.stringify(departed.result)) + for (const [index, delivery] of arranged.deliveries.entries()) { + const actual = await request('GET', `${API}/delivery-orders/${encode(delivery.deliveryNo)}?customerId=${encode(delivery.customerId)}`) + check('Actual departure has already set the customer delivery to DELIVERING', actual.order.status === 'DELIVERING') + const signed = await saveReceiptAndComplete(context, delivery, delivery.customerId, index) check('Receipt actually completes this delivery', signed?.order?.status === 'DELIVERED') + check('Signing cannot create or duplicate agreed fees', JSON.stringify(actual.fees) === JSON.stringify(signed.fees)) } proof.covered.push('shared-real-departure-and-signature') for (const [index, order] of proof.orders.entries()) { @@ -494,7 +520,14 @@ async function poolScenario(context, fixture, { execute = false, readInventory, && commercial.summary.fleetArrangement?.fulfillmentStage === 'COMPLETED' && Number(commercial.summary.fleetArrangement.signedPackages) === 1) const member = poolMembers[index], actual = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${order.customerId}`) const fees = actual.fees.filter(row => row.feeType === 'DELIVERY_FEE' && row.feeDirection === 'RECEIVABLE') - check('One agreed receivable remains, no order/receipt duplicate fee', fees.length === 1 && Number(fees[0].amount) === 30 && fees[0].currency === 'CNY') + const sameCustomerOrders = poolMembers.filter(row => row.customerId === order.customerId) + const externalFees = fees.filter(row => row.currency === 'CNY' && Number(row.amount) === 30) + check('Exactly one agreed external receivable per original source plus the separately uncovered owned service', + externalFees.length === sameCustomerOrders.length && fees.length === sameCustomerOrders.length + 1 + && fees.filter(row => row.currency === 'USD' && Number(row.amount) === 10).length === 1 + && new Set(fees.map(row => id(row.id))).size === fees.length + && sameCustomerOrders.every(source => actual.operations.filter(op => op.operationNode === 'CONFIRM_EXTERNAL_FREIGHT' + && id(op.result).includes(`外接预订 ${source.bookingNo};`)).length === 1)) const merchant = merchantSessions.find(session => session.customerId === order.customerId) const merchantPath = `/api/merchant/v1/last-leg/${order.businessType === 'US_TRUCK' ? 'us-truck-orders' : 'local-looseboard-orders'}/${encode(order.businessOrderNo)}` const own = await request('GET', merchantPath, undefined, merchant.accessToken) @@ -571,9 +604,10 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request const inventory = await readInventory({ marker: context.marker, customerId: order.customerId, warehouseId: fixture.warehouseId }) check('Immutable actual incoming STOCK_IN is unique', inventory.scope?.coverage === 'CUSTOMER_WAREHOUSE_COMPLETE' && inventory.ledger.filter(row => row.businessOrderNo === incomingNo && row.businessType === 'STOCK_IN' && Number(row.deltaPackages) === 1).length === 1) - const dispatchBase = `${API}/dispatches`, candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ sourceStage: 'RECEIVED', orderNo: incomingNo })}`) - check('Received candidate exactly belongs to this commercial incoming', candidates.length === 1 && candidates[0].inboundCode === incomingNo - && id(candidates[0].customerId) === order.customerId && id(candidates[0].warehousePalletId) === id(pallet.warehousePalletId)) + const dispatchBase = `${API}/dispatches`, candidates = await request('GET', `${dispatchBase}/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, orderNo: incomingNo })}`) + check('Received native candidate exactly belongs to this commercial incoming', candidates.complete === true && candidates.candidates.length === 1 + && candidates.candidates[0].inboundCode === incomingNo && id(candidates.candidates[0].customerId) === order.customerId + && candidates.candidates[0].palletNo === pallet.palletNo && candidates.candidates[0].selection?.sourceType === 'INBOUND_PLAN') bookingList = await request('GET', `${tripPath}/bookings`); booking = bookingList.records.find(row => row.bookingNo === order.bookingNo) const arrangeBody = { tripRecordVersion: bookingList.tripRecordVersion, recordVersion: booking.recordVersion, idempotencyKey: key(`ARRANGE-DISPATCH-${index}`) } @@ -581,13 +615,13 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request const arranged = await request('POST', `${poolPath}/arrange-dispatch`, arrangeBody, fulfillmentToken) const arrangeReplay = await request('POST', `${poolPath}/arrange-dispatch`, arrangeBody, fulfillmentToken) check('One command generates and binds the actual receipt without a manually supplied number', arranged.booking.status === 'BOUND' - && arranged.booking.dispatchNo && arranged.booking.incomingNo === incomingNo && arranged.booking.customerId === order.customerId) + && arranged.booking.dispatchNo === fixture.dispatchNo && arranged.booking.incomingNo === incomingNo && arranged.booking.customerId === order.customerId) check('Arrange replay returns the original dispatch and version', arrangeReplay.replayed === true && arrangeReplay.booking.dispatchNo === arranged.booking.dispatchNo && arrangeReplay.tripRecordVersion === arranged.tripRecordVersion) const dispatch = { dispatchNo: arranged.booking.dispatchNo } if (index === 0) proof.covered.push('pool-fulfillment-explicit-write-permissions', 'pool-arrange-dispatch-without-manual-number') let plan = await request('GET', `${tripPath}/capacity-plan`), detail = await request('GET', tripPath) - const actualMeasurements = controlledPalletMeasurements(plan.cargo, detail.trip.stops) + const plannedMeasurements = testPlannedCapacity(plan.cargo, detail.trip.stops) const restored = await verifyPoolBindingRevalidation({ offerDetail, check, blockedConfirm: async label => { const before = await latest(proof.revalidationDraftNo) @@ -597,7 +631,7 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request check('Revalidation refusal keeps competing draft unchanged', inquiryFact(before) === inquiryFact(await latest(before.bookingNo))) }, replan: () => request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, - pallets: actualMeasurements, reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }), + pallets: plannedMeasurements, reason: `${context.marker} 实收后重新核对,不重复扣外接预留` }), resume: async offer => (await request('POST', `${POOL}/${proof.offerNo}/resume`, { tripRecordVersion: offer.tripRecordVersion, offerRecordVersion: offer.recordVersion, idempotencyKey: key(`RESUME-AFTER-BIND-${index}`), reason: 'TEST实际绑定核量后显式恢复接单' }, dispatcherToken)).offer }) const bound = await latest(order.bookingNo) @@ -605,34 +639,27 @@ async function receiveAndBindPoolOrder(context, fixture, order, index, { request const allBookings = await request('GET', `${tripPath}/bookings`) const load = rows => rows.reduce((total, row) => { for (const dimension of ['palletSpaces', 'weightKg', 'volumeCbm']) total[dimension] += Number(row[dimension]); return total }, { palletSpaces: 0, weightKg: 0, volumeCbm: 0 }) - const plannedLoad = load(actualMeasurements) + const plannedLoad = load(plannedMeasurements) const reservedLoad = load(allBookings.records.filter(row => ['CONFIRMED', 'RECEIVED'].includes(row.status)).map(row => row.cargo)) const capacity = { palletSpaces: detail.trip.maxPalletSpaces, weightKg: detail.trip.maxLoadKg, volumeCbm: detail.trip.maxVolumeCbm } assertPoolBoundCapacity(restored, destinationKey, plannedLoad, reservedLoad, capacity) check('BOUND reservation is removed exactly once from all capacity dimensions', Number(plannedLoad.palletSpaces) === 3 + index && Number(reservedLoad.palletSpaces) === (index === 0 ? 4 : 0)) - ;(proof.bindingCapacity ||= []).push({ bookingNo: order.bookingNo, status: bound.status, planned: plannedLoad, + ;(proof.bindingCapacity ||= []).push({ bookingNo: order.bookingNo, status: bound.status, actual: false, planned: plannedLoad, reserved: reservedLoad, remaining: restored.destinations.find(row => row.destinationKey === destinationKey).remaining }) - const path = `${dispatchBase}/${encode(dispatch.dispatchNo)}`, freight = await request('GET', `${path}/external-freight`) - check('The one pool commercial source owns the one agreed freight', freight.requiresDeliveryFee === false && freight.quotes.length === 1 - && freight.quotes[0].bookingNo === order.bookingNo && Number(freight.quotes[0].amount) === 30) - detail = await request('GET', tripPath) - const transfer = { idempotencyKey: key(`TRANSFER-${index}`), plannedDeliveryAt: localTime(21600000), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', - loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', vehiclePlate: detail.trip.vehiclePlate, driverName: detail.trip.driverName, - driverPhone: detail.trip.driverPhone, appointmentAccount: 'SELF', externalFreightHash: freight.snapshotHash, fees: [], remark: context.marker } - const converted = await request('POST', `${path}/transfer-delivery`, transfer) - check('Pool source produces exactly one outbound and delivery', converted.outboundNo && converted.deliveries.length === 1) - const replay = await request('POST', `${path}/transfer-delivery`, transfer) - check('Transfer replay reuses outbound and delivery', replay.replayed === true && replay.outboundNo === converted.outboundNo && replay.deliveries[0].deliveryNo === converted.deliveries[0].deliveryNo) + const nativeItems = await request('GET', `${dispatchBase}/${encode(fixture.dispatchNo)}/items`) + check('Received pool source is present in the one native main without early outbound/delivery', nativeItems.items.some(row => id(row.customerId) === order.customerId + && row.inboundCode === incomingNo && id(row.warehousePalletId) === id(pallet.warehousePalletId) && Number(row.pickedPackages) === 0)) return { customerId: order.customerId, bookingNo: order.bookingNo, businessOrderNo: order.businessOrderNo, incomingNo, dispatchNo: dispatch.dispatchNo, - outboundNo: converted.outboundNo, deliveryNo: converted.deliveries[0].deliveryNo, palletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo } + palletId: id(pallet.warehousePalletId), palletNo: pallet.palletNo } + } export async function runPoolAcceptance(config, { env = process.env, outputRoot = ROOT, runner = runCrossCustomer } = {}) { if (!config.execute) return poolPlan(config) const credentials = new Map(); let restoreWrites try { - const result = await runner(config, { env, outputRoot, + const result = await runner(config, { env, outputRoot, verificationScope: 'CAPACITY_POOL', contextFactory: options => { const context = new AcceptanceRun(options), original = context.request context.request = async function (...args) { diff --git a/scripts/fleet-capacity-pool-test-env.test.mjs b/scripts/fleet-capacity-pool-test-env.test.mjs index 4d6cd974b..f35f9ac86 100644 --- a/scripts/fleet-capacity-pool-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-test-env.test.mjs @@ -1,5 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' import { parsePoolArguments, poolPlan, runPoolAcceptance, assertPoolRemaining, assertSingleCommercial, shrinkingVehicleRequest, expiryWaitMs, poolLocalDateTime, poolPickScope, pickPoolDispatch, POOL_COVERAGE, POOL_REMAINING, poolInquiryWrite, poolReadIsDenied, assertPoolBoundCapacity, verifyPoolConfirmedCancellation, verifyPoolBindingRevalidation, @@ -122,7 +123,7 @@ test('PDA namespace belongs to the exact run, customer and dispatch while parent assert.throws(() => poolPickScope(context, { dispatchNo: 'DS-A' }), /PICK_SCOPE_REQUIRED/) }) -test('real pickAll helper handles two same-customer dispatches with separate pick/complete keys and exact replays', async () => { +test('real native pickAll preserves separate command namespaces, version zero, exact replays and stale rejection', async () => { const operations = new Map(), requests = [], steps = [], picked = new Map() const dispatches = ['DS-A', 'DS-B'] const context = { @@ -134,26 +135,34 @@ test('real pickAll helper handles two same-customer dispatches with separate pic const dispatchNo = path.split('/dispatches/')[1].split('/')[0], index = dispatches.indexOf(dispatchNo) assert.ok(index >= 0) const response = data => ({ http: 200, body: { code: '200', data }, requestId: 'test', traceId: 'test' }) - if (method === 'GET') return response({ items: [{ dispatchItemId: `${index + 1}`, scheduledPackages: 1, - pickedPackages: picked.get(dispatchNo) || 0, positions: [{ id: `${index + 100}`, availablePackages: 1 }] }] }) + const version = picked.get(dispatchNo) || 0 + if (method === 'GET') return response({ dispatchNo, status: 'ACTIVE', stage: 'LOADING', recordVersion: index === 0 ? version : String(version), + pickingReconciled: version === 1, counts: { packageCount: 1, pickedPackageCount: version }, + items: [{ dispatchItemId: `${index + 1}`, scheduledPackages: 1, pickedPackages: version, + positions: [{ positionId: `${index + 100}`, availablePackages: 1 }] }] }) requests.push({ method, path, body: structuredClone(body) }) + assert.ok(path.endsWith('/pick')) + assert.equal(body.recordVersion, index === 0 ? 0 : '0') const prior = operations.get(body.idempotencyKey), payload = JSON.stringify([path, body]) if (prior) { - if (prior.payload !== payload) return { http: 200, body: { code: 'A0409', message: 'Idempotency key was reused with a different request' } } + assert.equal(prior.payload, payload) return response({ ...prior.result, replayed: true }) } - let result - if (path.endsWith('/pick')) { picked.set(dispatchNo, 1); result = { pickedPackages: 1 } } - else result = { status: 'READY_TO_DEPART', plannedPackages: 1, pickedPackages: 1, remainingPackages: 0, replayed: false } + if (version !== Number(body.recordVersion)) return { http: 409, body: { code: 'A0409' } } + picked.set(dispatchNo, 1) + const result = { recordVersion: '1', result: { pickedPackages: 1 }, counts: { packageCount: 1, pickedPackageCount: 1 }, replayed: false } operations.set(body.idempotencyKey, { payload, result }) return response(result) }, } for (const dispatchNo of dispatches) await pickPoolDispatch(context, 'test-pda', { customerId: '9', dispatchNo }) - assert.equal(operations.size, 4) - assert.equal(requests.length, 8) + assert.equal(operations.size, 2) + assert.equal(requests.length, 6) assert.ok(steps.every(Boolean)) - for (let index = 0; index < requests.length; index += 2) assert.deepEqual(requests[index], requests[index + 1]) + for (let index = 0; index < requests.length; index += 3) { + assert.deepEqual(requests[index], requests[index + 1]) + assert.notEqual(requests[index].body.idempotencyKey, requests[index + 2].body.idempotencyKey) + } }) test('failed helper result stops the pool stage immediately instead of appending a misleading generic failure', async () => { @@ -277,3 +286,31 @@ test('confirmed cancellation verifies both commercial types and reuses exact suc assert.equal(result.remainingPalletSpaces, 12) } }) + + +test('pool orchestration keeps native one-main boundaries, explicit reviews and real loading before whole arrangement', () => { + const source = readFileSync(new URL('./fleet-capacity-pool-test-env.mjs', import.meta.url), 'utf8') + for (const old of ['controlledPalletMeasurements(', '/external-freight', '/loading-review`', "departed.status === 'IN_TRANSIT'", 'sourceStage:', "'/dispatches/pre-arrange'"]) assert.equal(source.includes(old), false, old) + assert.match(source, /testPlannedCapacity\(plan\.cargo, detail\.trip\.stops\)/) + assert.match(source, /actual: false/) + assert.match(source, /arranged\.booking\.dispatchNo === fixture\.dispatchNo/) + assert.match(source, /\['submit-review', 'dispatch-review', 'loading-review'\]/) + const pick = source.indexOf('const namespace = await pickPoolDispatch('), load = source.indexOf('await confirmFleetLoading('), arrange = source.indexOf('await arrangeDispatchDelivery('), depart = source.indexOf("const departed = await request('POST', departPath") + assert.ok(pick > 0 && pick < load && load < arrange && arrange < depart) + assert.match(source, /fixture\.loadingMeasurements/) + assert.match(source, /recordVersion: current\.recordVersion/) + assert.match(source, /readyTask\.actions\?\.DEPART\?\.allowed === true/) + assert.match(source, /JSON\.stringify\(actual\.fees\) === JSON\.stringify\(signed\.fees\)/) + assert.match(source, /sameCustomerOrders\.every\(source => actual\.operations\.filter/) +}) + +test('pool execution explicitly selects capacity verification before handing off the shared runner', async () => { + const config = parsePoolArguments(['--execute', '--environment', 'TEST', '--run-id', 'pool-scope', '--warehouse-id', '10', '--destination-warehouse-id', '11']) + let selected + await assert.rejects(runPoolAcceptance(config, { env: {}, runner: async (actual, options) => { + assert.equal(actual, config) + selected = options.verificationScope + throw Error('STOP_BEFORE_ENVIRONMENT_ACCESS') + } }), /STOP_BEFORE_ENVIRONMENT_ACCESS/) + assert.equal(selected, 'CAPACITY_POOL') +}) diff --git a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs index 9c8720cfa..470a20cef 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs @@ -58,10 +58,18 @@ test('eight-hour cutoff has coherent later departure delivery and driver end wit assert.deepEqual(grayUiSchedule(now + 999), schedule) assert.match(schedule.cutoffAt, /Z$/); assert.match(schedule.plannedDepartureAt, /Z$/) assert.doesNotMatch(schedule.plannedDeliveryAt, /Z$/) - const body = { idempotencyKey: 'stable', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] } + const body = { idempotencyKey: 'stable', recordVersion: '9007199254740993', groups: [ + { groupKey: 'A:FBA:ONT8', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, + { groupKey: 'B:FBA:ONT8', plannedDeliveryAt: 'other', fees: [{ amount: 20 }] }, + ] } const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) - assert.equal(applied.plannedDeliveryAt, schedule.plannedDeliveryAt) - assert.equal(applied.idempotencyKey, body.idempotencyKey); assert.deepEqual(applied.fees, body.fees); assert.equal(body.plannedDeliveryAt, 'old') + assert.deepEqual(applied.groups.map(group => group.plannedDeliveryAt), [schedule.plannedDeliveryAt, schedule.plannedDeliveryAt].map(value => new Date(value).toISOString())) + assert.equal(applied.idempotencyKey, body.idempotencyKey); assert.equal(applied.recordVersion, body.recordVersion) + assert.deepEqual(applied.groups.map(group => group.fees), body.groups.map(group => group.fees)) + assert.deepEqual(applied.groups.map(group => group.groupKey), body.groups.map(group => group.groupKey)) + assert.deepEqual(body.groups.map(group => group.plannedDeliveryAt), ['old', 'other']) + assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', + { idempotencyKey: 'stable', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, schedule), /NATIVE_DELIVERY/) assert.equal(applyGrayUiTransportWindow('POST', '/api/auth/login', body, schedule), body) assert.equal(applyGrayUiTransportWindow('GET', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule), body) assert.throws(() => grayUiSchedule(NaN), /CLOCK/) diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs index b05c9973c..783df1e02 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs @@ -7,7 +7,7 @@ import { createHash, randomUUID } from 'node:crypto' import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary, createFleetPoolGrayOptions } from './admin-test-env-acceptance.mjs' import { parseCrossCustomerArguments } from './fleet-cross-customer-test-env.mjs' import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs' -import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' import { createTestEvidenceReaders } from './fba-core-regression-evidence.mjs' import { createGrayEvidenceReaders } from './gray-regression-evidence.mjs' import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' @@ -77,8 +77,10 @@ export function grayUiSchedule(now = Date.now()) { export function applyGrayUiTransportWindow(method, path, body, schedule) { if (method !== 'POST' || !/^\/api\/platform\/v1\/last-leg\/dispatches\/[^/]+\/transfer-delivery$/.test(path)) return body - demand(body && schedule?.plannedDeliveryAt, 'GRAY_UI_DELIVERY_WINDOW_REQUIRED') - return { ...body, plannedDeliveryAt: schedule.plannedDeliveryAt } + demand(body && Array.isArray(body.groups) && body.groups.length && body.groups.every(group => group?.groupKey) + && schedule?.plannedDeliveryAt && Number.isFinite(Date.parse(schedule.plannedDeliveryAt)), 'GRAY_UI_NATIVE_DELIVERY_WINDOW_REQUIRED') + const plannedDeliveryAt = new Date(schedule.plannedDeliveryAt).toISOString() + return { ...body, groups: body.groups.map(group => ({ ...group, plannedDeliveryAt })) } } /** Private directory override is for isolated offline tests, never exposed as a CLI option. */ @@ -228,25 +230,35 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) save('fixture.json', fixture) const trip = context.createdResources.fleetTrips.find(row => row.tripNo === fixture.tripNo), tripPath = `${API}/fleet/trips/${encodeURIComponent(fixture.tripNo)}` - check('Existing producer created exactly two untouched customer dispatches', fixture.members.length === 2 && trip) - let detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`) - check('Owned fixture vehicle has actual 30-space capacity', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2) - // GRAY delivery ETA was set on the producer's real transfer command, so all four times stay ordered. - detail = await request('PUT', `${tripPath}/plan`, { recordVersion: detail.trip.recordVersion, + check('Existing producer created two untouched customer sources on exactly one native main', fixture.members.length === 2 && trip + && fixture.dispatchNo && fixture.members.every(member => member.dispatchNo === fixture.dispatchNo) && fixture.tripProof?.dispatchNo === fixture.dispatchNo) + const dispatchPath = `${API}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + let main = await request('GET', dispatchPath), detail = await request('GET', tripPath), plan = await request('GET', `${tripPath}/capacity-plan`) + check('Owned fixture vehicle has actual 30-space capacity with two real source pallets', Number(detail.trip.maxPalletSpaces) === 30 && plan.cargo.length === 2 + && main.status === 'ACTIVE' && main.stage === 'LOADING' && Number(main.counts?.pickedPackageCount) === 0 && Number(main.counts?.shippedPackageCount) === 0) + await request('POST', `${dispatchPath}/return-to-review`, { recordVersion: main.recordVersion, + idempotencyKey: createHash('sha256').update(`${context.marker}:UI:RETURN`).digest('hex'), reason: 'TEST零扫码明确退回以设置UI计划时窗' }) + main = await request('GET', dispatchPath) + check('Native UI plan edits only the explicit unpicked BEFORE_LOADING main', main.stage === 'BEFORE_LOADING' && main.reviewState === 'DRAFT') + const uiPlanBody = { ...fixture.tripProof.updateRequest, recordVersion: main.recordVersion, + idempotencyKey: createHash('sha256').update(`${context.marker}:UI:PLAN`).digest('hex'), plannedDepartureAt: schedule?.plannedDepartureAt || new Date(Date.now() + 4 * 3600000).toISOString(), - expectedDriverEndAt: schedule?.expectedDriverEndAt || detail.trip.expectedDriverEndAt, - stops: detail.trip.stops, reason: gray ? '灰度UI验收:统一截止、发车、到站与司机结束时间,不执行发车' : 'UI验收窗口:四小时后计划发车,不执行发车' }) - if (gray) { - check('GRAY trip departure and driver end follow the explicit UI window', Date.parse(detail.trip.plannedDepartureAt) === Date.parse(schedule.plannedDepartureAt) - && Date.parse(detail.trip.expectedDriverEndAt) === Date.parse(schedule.expectedDriverEndAt)) - for (const member of fixture.members) { - const delivery = await request('GET', `${API}/delivery-orders/${member.deliveryNo}?customerId=${member.customerId}`) - check('Original customer delivery ETA follows the same valid UI window', delivery.order.plannedDeliveryAt === schedule.plannedDeliveryAt) - } - } + estimatedArrivalAt: schedule ? new Date(schedule.plannedDeliveryAt).toISOString() : null, + driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, + evidence: { expectedTaskEndAt: schedule?.expectedDriverEndAt || fixture.tripProof.expectedDriverEndAt } }, + remark: 'TEST仅UI验收计划,不执行实载发车' } + let reviewed = await request('PUT', dispatchPath, uiPlanBody) + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) reviewed = await request('POST', `${dispatchPath}/${action}`, + { recordVersion: reviewed.recordVersion, idempotencyKey: createHash('sha256').update(`${context.marker}:UI:${action}`).digest('hex'), + ...(action === 'submit-review' ? { remark: context.marker } : { decision: 'APPROVED', reason: context.marker }) }) + detail = await request('GET', tripPath) + if (gray) check('GRAY native trip departure and driver end follow the explicit UI window', Date.parse(detail.trip.plannedDepartureAt) === Date.parse(schedule.plannedDepartureAt) + && Date.parse(detail.trip.expectedDriverEndAt) === Date.parse(schedule.expectedDriverEndAt)) plan = await request('GET', `${tripPath}/capacity-plan`) + const plannedPallets = testPlannedCapacity(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })) await request('POST', `${tripPath}/capacity-plan`, { recordVersion: plan.recordVersion, manifestHash: plan.manifestHash, - pallets: controlledPalletMeasurements(plan.cargo, detail.trip.stops).map(row => ({ ...row, palletSpaces: 9 })), reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' }) + pallets: plannedPallets, reason: '仅UI验收:自有计划18/总30,不作为实际发车计量' }) + proof.planEvidence = { actual: false, pallets: plannedPallets, reason: 'TEST UI销售计划18/30,不是实载测量' } const permissions = await request('GET', `${IAM}/permissions`), actorTokens = [] const key = suffix => createHash('sha256').update(`${context.marker}:UI:${suffix}`).digest('hex') for (const kind of ['SALES_A', 'SALES_B', 'MANAGER']) { @@ -275,7 +287,7 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, offerRecordVersion: draft.offer.recordVersion, idempotencyKey: key('PUBLISH') }, actorTokens[2])).offer check('OPEN UI offer is available with exactly 12 planned spaces', offer.status === 'OPEN' && offer.canCreateInquiry && offer.canConfirm && offer.destinations.length === 1 && Number(offer.destinations[0].remaining.palletSpaces) === 12) - proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo + proof.offerNo = offer.offerNo; proof.tripNo = fixture.tripNo; proof.dispatchNo = fixture.dispatchNo for (let index = 0; index < draftCount; index++) { const owner = index < perSales ? 0 : 1, member = fixture.members[owner] const body = uiInquiryBody(offer, member.customerId, index, key(`DRAFT-${index}`), gray) @@ -314,12 +326,14 @@ async function runUiFixtureCore(config, { env = process.env, outputRoot = ROOT, check('UI fixture has not picked departed or signed cargo', detail.trip.status === 'PLANNED') for (const member of fixture.members) { const dispatch = await request('GET', `${API}/dispatches/${member.dispatchNo}`) - check('Original real cargo remains unpicked', Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + check('Original real cargo remains unpicked', dispatch.dispatchNo === fixture.dispatchNo && dispatch.status === 'ACTIVE' && dispatch.stage === 'LOADING' + && Number(dispatch.counts?.pickedPackageCount) === 0 && Number(dispatch.counts?.shippedPackageCount) === 0 + && dispatch.documents?.outbounds?.length === 0 && dispatch.documents?.deliveries?.length === 0) } const final = await request('GET', `${POOL}/${offer.offerNo}`, undefined, actorTokens[2]) check(`UI handoff keeps publication OPEN and ${draftCount} drafts untouched`, final.offer.status === 'OPEN' && final.offer.canCreateInquiry && Number(final.inquiries.total) === draftCount) proof.status = `OPEN_AND_${draftCount}_DRAFTS_READY_FOR_UI` - secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, + secretDocument.business = { offerNo: proof.offerNo, tripNo: fixture.tripNo, dispatchNo: fixture.dispatchNo, warehouseId: fixture.warehouseId, destinationWarehouseId: config.destinationWarehouseId, cutoffAt: final.offer.cutoffAt, plannedDepartureAt: final.offer.plannedDepartureAt, ...(gray ? { uiSchedule: schedule } : {}), customers: fixture.members.map(row => ({ customerId: row.customerId, incomingNo: row.incomingNo, dispatchNo: row.dispatchNo })) } diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs index d70fa5185..c57a3c7b9 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, readFileSync, unlinkSync, rmdirSync } from 'node:fs' import { join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { PRIVATE_DIRECTORY, privateCredentialsPath, parseUiFixtureArguments, uiFixturePlan, - createPrivateCredentialStore, publicUiEvidence, uiInquiryBody, runUiFixture } from './fleet-capacity-pool-ui-fixture-test-env.mjs' + createPrivateCredentialStore, publicUiEvidence, uiInquiryBody, runUiFixture, applyGrayUiTransportWindow, grayUiSchedule } from './fleet-capacity-pool-ui-fixture-test-env.mjs' const credentials = join(PRIVATE_DIRECTORY, 'offline-contract-example.json') const execute = ['--execute', '--environment', 'TEST', '--run-id', 'ui-contract', '--warehouse-id', '10', @@ -112,3 +112,34 @@ test('plan lists minimal independent identities and explicitly excludes full acc assert.match(plan.boundary, /completed-order fabrication/) assert.match(plan.boundary, /Retain business samples and UI accounts/) }) + + +test('UI fixture prepares one shared native main and an explicitly planned 18-of-30 capacity without fake deliveries or loading', () => { + const source = readFileSync(new URL('./fleet-capacity-pool-ui-fixture-test-env.mjs', import.meta.url), 'utf8') + assert.match(source, /fixture\.members\.every\(member => member\.dispatchNo === fixture\.dispatchNo\)/) + assert.match(source, /return-to-review/) + assert.match(source, /request\('PUT', dispatchPath, uiPlanBody\)/) + assert.match(source, /\['submit-review', 'dispatch-review', 'loading-review'\]/) + assert.match(source, /testPlannedCapacity\(plan\.cargo, detail\.trip\.stops\)\.map\(row => \(\{ \.\.\.row, palletSpaces: 9 \}\)\)/) + assert.match(source, /proof\.planEvidence = \{ actual: false/) + assert.match(source, /dispatch\.documents\?\.outbounds\?\.length === 0/) + assert.match(source, /dispatch\.documents\?\.deliveries\?\.length === 0/) + for (const old of ['controlledPalletMeasurements(', "request('PUT', `${tripPath}/plan`", "member.deliveryNo", "dispatch.pickedPackageCount", "dispatch.shippedPackageCount"]) assert.equal(source.includes(old), false, old) + assert.equal(source.includes('/loading-complete'), false) + assert.equal(source.includes('/depart`'), false) +}) + + +test('native GRAY delivery window preserves per-customer group identity and never adds a retired top-level ETA', () => { + const schedule = grayUiSchedule(Date.parse('2030-01-01T00:00:00Z')) + const body = { recordVersion: '0', idempotencyKey: 'same-command', cargoHash: 'real-cargo', loadingCompletionHash: 'real-load', + groups: [{ groupKey: 'A:FBA:1', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, { groupKey: 'B:FBA:1', plannedDeliveryAt: 'old', fees: [] }] } + const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) + assert.equal(applied.recordVersion, '0'); assert.equal(applied.idempotencyKey, body.idempotencyKey) + assert.equal(Object.hasOwn(applied, 'plannedDeliveryAt'), false) + assert.deepEqual(applied.groups.map(group => group.groupKey), body.groups.map(group => group.groupKey)) + assert.ok(applied.groups.every(group => group.plannedDeliveryAt === new Date(schedule.plannedDeliveryAt).toISOString())) + assert.deepEqual(applied.groups[0].fees, body.groups[0].fees) + assert.equal(body.groups[0].plannedDeliveryAt, 'old') + assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', { plannedDeliveryAt: 'old' }, schedule), /NATIVE_DELIVERY_WINDOW_REQUIRED/) +}) diff --git a/scripts/fleet-cross-customer-test-env.mjs b/scripts/fleet-cross-customer-test-env.mjs index f087ac219..a09d3edc7 100644 --- a/scripts/fleet-cross-customer-test-env.mjs +++ b/scripts/fleet-cross-customer-test-env.mjs @@ -1,4 +1,4 @@ -import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync } from 'node:fs' +import { mkdirSync, writeFileSync, openSync, closeSync, unlinkSync, readFileSync, fsyncSync } from 'node:fs' import { execFileSync } from 'node:child_process' import { dirname, resolve, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' @@ -34,7 +34,7 @@ export function parseCrossCustomerArguments(args) { } export function crossCustomerPlan(config) { return { environment: 'TEST', execute: config.execute, runId: config.runId, warehouseId: config.warehouseId || null, destinationWarehouseId: config.destinationWarehouseId || null, - output: `reports/fleet-cross-${config.runId}`, stages: ['two new customers', 'two ordinary transfer receipts', 'two dispatches on one trip', + output: `reports/fleet-cross-${config.runId}`, stages: ['two new customers', 'two ordinary transfer receipts', 'one native cross-customer dispatch and its unique automatic trip', 'unready customer gate rejection', 'shared departure and replay', 'independent receipts', 'trip completion and separate resource release'], limits: 'Prerequisite rejection is not mid-transaction rollback. No deployment, configuration change, SQL writes or automatic rerun. Retain business samples; disable temporary PDA in finally.' } } @@ -45,17 +45,19 @@ export async function runCrossCustomer(config, dependencies = {}) { /** Not a CLI mode: called only by the explicit GRAY pool entry with its verified pool scenario. */ export async function runFleetPoolGrayCrossCustomer(config, dependencies = {}) { - if (Object.keys(dependencies).some(key => !['env', 'outputRoot', 'verify', 'contextFactory'].includes(key))) + if (Object.keys(dependencies).some(key => !['env', 'outputRoot', 'verify', 'contextFactory', 'verificationScope'].includes(key))) throw Error('Gray pool cannot override evidence readers or fixture lifecycle') + if (dependencies.verificationScope != null && dependencies.verificationScope !== 'CAPACITY_POOL') throw Error('Gray pool requires explicit CAPACITY_POOL verification scope') if (config.execute && (typeof dependencies.verify !== 'function' || typeof dependencies.contextFactory !== 'function')) throw Error('Gray pool requires its dedicated scenario and journaled context') - return runCrossCustomerCore(config, dependencies, true) + return runCrossCustomerCore(config, { ...dependencies, verificationScope: 'CAPACITY_POOL' }, true) } async function runCrossCustomerCore(config, { env = process.env, outputRoot = ROOT, readers, prepare, verify = verifyFleetCrossCustomer, - finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}, gray = false) { + verificationScope = 'CROSS_CUSTOMER', finish = finishFleetTrip, contextFactory = options => new AcceptanceRun(options), pickerAccess = resolvePickerAccess, pickerCreate = createPicker, pickerDisable = disablePicker } = {}, gray = false) { + if (!['CROSS_CUSTOMER', 'CAPACITY_POOL', 'SAME_ROUTE_COST'].includes(verificationScope) || gray && verificationScope !== 'CAPACITY_POOL') throw Error('Explicit supported verification scope required') const environment = gray ? 'GRAY' : 'TEST', reportName = `${gray ? 'fleet-pool-gray' : 'fleet-cross'}-${config.runId}` - const plan = { ...crossCustomerPlan(config), environment, output: `reports/${reportName}`, + const plan = { ...crossCustomerPlan(config), verificationScope, environment, output: `reports/${reportName}`, ...(gray ? { scope: 'FLEET_CAPACITY_POOL_GRAY', stages: ['current pool fixture and full pool HTTP matrix', 'trip completion and separate resource release'] } : {}) } if (!config.execute) return plan if (config.environment !== environment || !(gray ? ['gray', 'prod'].includes(env.CHONGCHONG_ENV) : env.CHONGCHONG_ENV === 'test') || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId) @@ -71,10 +73,14 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO const reports = resolve(outputRoot, 'reports'), output = join(reports, reportName) mkdirSync(reports, { recursive: true }) const lockPath = join(reports, '.fleet-acceptance.lock'), lock = openSync(lockPath, 'wx') - let context, picker, createdPicker, initialized = false, report, evidenceIndex = 0, requestIndex = 0 + let context, picker, createdPicker, initialized = false, report, evidenceIndex = 0, requestIndex = 0, stopped = null const sessions = new Map() const secrets = Object.entries(env).filter(([name]) => /PASSWORD|TOKEN/.test(name)).map(([, value]) => value) - const save = (name, value) => writeFileSync(join(output, name), JSON.stringify(redactEvidence(value, secrets), null, 2) + '\n') + const save = (name, value) => { + const descriptor = openSync(join(output, name), 'w') + try { writeFileSync(descriptor, JSON.stringify(redactEvidence(value, secrets), null, 2) + '\n'); fsyncSync(descriptor) } + finally { closeSync(descriptor) } + } try { writeFileSync(lock, JSON.stringify({ pid: process.pid, runId: config.runId })) mkdirSync(output); initialized = true; mkdirSync(join(output, 'evidence')); mkdirSync(join(output, 'requests')) @@ -88,13 +94,50 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO const options = gray ? createFleetPoolGrayOptions(transport) : validateExecutionOptions({ ...parseArguments(['--modules', 'merchant-qualification-fixture', '--quiet', 'true']), ...transport, environment: 'test' }) context = contextFactory(options) + const previousCheckpoint = context.checkpoint + context.checkpoint = async () => { + try { + if (previousCheckpoint) await previousCheckpoint.call(context) + save('progress.json', { marker: context.marker, steps: context.steps, createdResources: context.createdResources }) + } catch (error) { + stopped ||= new Error('Checkpoint persistence failed; no further business request may be sent') + context.createdResources.fleetWriteBoundary = { status: 'STOPPED', outcome: 'PERSISTENCE_FAILED', reason: 'CHECKPOINT_FAILED' } + throw stopped + } + } for (const method of ['request', 'requestWithBody']) { const original = context[method] context[method] = async function (...args) { - const number = ++requestIndex, [verb, path, body] = args - const record = { number, method: verb, path, stage: 'INTENT', body: body instanceof FormData ? '[multipart file]' : body } - save(`requests/${number}.json`, record) - const response = await original.apply(this, args) + const [verb, path, body] = args + const identityCleanup = verb === 'POST' && path === '/api/auth/logout' || verb === 'PUT' + && [picker?.membershipId, createdPicker?.membershipId].filter(Boolean).some(value => path === `/api/platform/v1/iam/users/${value}/status`) + && body?.status === 'DISABLED' + if (stopped && !identityCleanup) throw stopped + const number = ++requestIndex + const write = !['GET', 'HEAD', 'OPTIONS'].includes(String(verb).toUpperCase()) + let evidenceBody = body + if (body instanceof FormData) { + evidenceBody = { multipart: [] } + for (const [field, value] of body.entries()) evidenceBody.multipart.push(typeof value === 'string' ? { field, value } + : { field, name: value.name, type: value.type, size: value.size, sha256: createHash('sha256').update(Buffer.from(await value.arrayBuffer())).digest('hex') }) + } + const record = { number, method: verb, path, stage: 'INTENT', headers: args[4], body: evidenceBody } + let response, sent = false + try { + save(`requests/${number}.json`, record) + await context.checkpoint() + sent = true + response = await original.apply(this, args) + } catch (error) { + if (write) { + stopped ||= new Error(`${sent ? 'Unknown write outcome' : 'Write checkpoint failed before send'}: ${verb} ${path}; preserve original request, do not resend`) + record.stage = sent ? 'UNKNOWN_RESULT' : 'NOT_SENT' + context.createdResources.fleetWriteBoundary = { status: 'STOPPED', method: verb, path, outcome: record.stage } + save(`requests/${number}.json`, { ...record, error: String(error?.message || error) }) + await context.checkpoint().catch(() => {}) + } + throw error + } if (verb === 'POST' && path === '/api/platform/v1/iam/users' && ok(response) && String(body?.overseasWarehouseId) === config.warehouseId && body?.username?.startsWith('pda_overseas_')) { createdPicker = { membershipId: response.body?.data?.membershipId } @@ -104,8 +147,19 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO sessions.set(tokens.accessToken, tokens.refreshToken) secrets.push(tokens.accessToken, tokens.refreshToken) } + const code = String(response?.body?.code || ''), native = /^\/api\/(?:platform|pda)\/v1\/last-leg\/dispatches(?:\/|$)/.test(path) + const definite = Number.isInteger(response?.http) && response.http >= 200 && response.http < 500 && response.requestId && response.traceId + && (response.http === 200 && code === '200' || /^[A-Z]\d{4}$/.test(code) && (response.http === 200 || response.http >= 400)) + && !(native && code === '200' && response.body.data == null) + if (write && !definite) { + stopped ||= new Error(`Unknown write outcome: ${verb} ${path}; preserve original request, do not resend`) + context.createdResources.fleetWriteBoundary = { status: 'STOPPED', method: verb, path, outcome: 'UNKNOWN_RESULT', requestId: response?.requestId, traceId: response?.traceId } + save(`requests/${number}.json`, { ...record, stage: 'UNKNOWN_RESULT', response }) + await context.checkpoint() + throw stopped + } save(`requests/${number}.json`, { ...record, stage: 'RESPONSE', response }) - save('progress.json', { marker: context.marker, steps: context.steps, createdResources: context.createdResources }) + await context.checkpoint() return response } } @@ -125,10 +179,23 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO } const fixture = await prepare(context, { execute: true, warehouseId: config.warehouseId, destinationWarehouseId: config.destinationWarehouseId, pdaToken: picker.token, readInventory }) save('fixture.json', fixture) + if (fixture?.marker !== context.marker || !fixture.dispatchNo || !fixture.tripNo || String(fixture.warehouseId) !== config.warehouseId + || fixture.members?.length !== 2 || new Set(fixture.members.map(member => member.customerId)).size !== 2 + || fixture.members.some(member => member.marker !== context.marker || member.dispatchNo !== fixture.dispatchNo || !member.customerId || member.receiptVerified !== true)) + throw Error('Current fixture requires one exact native main and two owned customer receipts') + const trips = context.createdResources.fleetTrips?.filter(row => row.tripNo === fixture.tripNo && row.dispatchNo === fixture.dispatchNo) + if (trips?.length !== 1 || fixture.tripProof !== trips[0] || String(trips[0].warehouseId) !== config.warehouseId) + throw Error('Original fixture native main/trip proof missing or ambiguous for resource completion') + const trip = trips[0] + await context.checkpoint() const verification = await verify(context, fixture, { execute: true, readInventory }) - if (verification?.status !== 'VERIFIED' || verification.tripNo !== fixture.tripNo) throw Error('Current fixture verification incomplete') - const trip = context.createdResources.fleetTrips?.find(row => row.tripNo === fixture.tripNo) - if (!trip) throw Error('Original fixture trip missing for resource completion') + const proofKey = { CROSS_CUSTOMER: 'fleetCrossCustomer', CAPACITY_POOL: 'fleetCapacityPool', SAME_ROUTE_COST: 'fleetSameRouteCost' }[verificationScope] + const authoritative = context.createdResources[proofKey] + if (verification !== authoritative || verification?.status !== 'VERIFIED' || verification.marker !== context.marker + || verification.dispatchNo !== fixture.dispatchNo || verification.tripNo !== fixture.tripNo) + throw Error('Current fixture verification incomplete or not from the explicitly selected scope') + if (fixture.tripProof !== trip || trip.dispatchNo !== fixture.dispatchNo || trip.tripNo !== fixture.tripNo) throw Error('Native main resource proof changed during verification') + await context.checkpoint() const beforeClosure = [] for (const member of fixture.members) beforeClosure.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: config.warehouseId })) await finish(context, 'fleet-cross-customer-closure', trip) @@ -146,8 +213,10 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO if (context) { let sessionsClosed = true for (const [accessToken, refreshToken] of sessions) { - const response = await context.request('POST', '/api/auth/logout', { refreshToken }, accessToken) - if (!ok(response) && response.http !== 401 && String(response.body?.code) !== 'A0401') sessionsClosed = false + try { + const response = await context.request('POST', '/api/auth/logout', { refreshToken }, accessToken) + if (!ok(response) && response.http !== 401 && String(response.body?.code) !== 'A0401') sessionsClosed = false + } catch { sessionsClosed = false } } report.sessionsClosed = sessionsClosed report.summary = buildAcceptanceSummary(context.steps, context.modules, context.coverageGaps) @@ -155,7 +224,7 @@ async function runCrossCustomerCore(config, { env = process.env, outputRoot = RO report.marker = context.marker; report.createdResources = context.createdResources; report.steps = context.steps } report ||= { success: false } - report.environment = environment; report.runtimeProfile = env.CHONGCHONG_ENV + report.verificationScope = verificationScope; report.environment = environment; report.runtimeProfile = env.CHONGCHONG_ENV if (gray) report.scope = 'FLEET_CAPACITY_POOL_GRAY' report.requestCount = requestIndex; report.inventorySnapshots = evidenceIndex; report.goalComplete = false try { if (initialized) save('report.json', report) } finally { closeSync(lock); unlinkSync(lockPath) } diff --git a/scripts/fleet-cross-customer-test-env.test.mjs b/scripts/fleet-cross-customer-test-env.test.mjs index 660bc30ba..d2ed2d623 100644 --- a/scripts/fleet-cross-customer-test-env.test.mjs +++ b/scripts/fleet-cross-customer-test-env.test.mjs @@ -14,7 +14,7 @@ function harness(root, fail = false) { calls.push({ method, path }) const data = path === '/api/auth/login' ? { accessToken: 'private-access', refreshToken: 'private-refresh' } : path.includes('warehouse-options') ? [{ id: '900' }] : {} - return { http: 200, body: { code: '200', data } } + return { http: 200, body: { code: '200', data }, requestId: 'offline-request', traceId: 'offline-trace' } }, async requestWithBody() { throw Error('Unexpected multipart') } } return { calls, cleanup, context, options: { env, outputRoot: root, contextFactory: () => context, readers: { preflight: async () => {}, readFleetInventory: async scope => ({ scope, balances: [], positions: [], ledger: [] }) }, @@ -24,11 +24,13 @@ function harness(root, fail = false) { assert.equal(options.pdaToken, 'private-pda') await context.request('POST', '/api/test-owned-command', { idempotencyKey: 'stable-command', password: 'private-pda-password' }) if (fail) throw Error('FIXTURE_FAILED') - return { pdaToken: options.pdaToken, marker: context.marker, tripNo: 'TR-OFFLINE', members: [{ customerId: '42' }, { customerId: '43' }] } + const tripProof = { marker: 'FT-OWNED', dispatchNo: 'DS-ONE', tripNo: 'TR-OFFLINE', warehouseId: '900' } + context.createdResources.fleetTrips = [tripProof] + return { pdaToken: options.pdaToken, marker: context.marker, dispatchNo: 'DS-ONE', tripNo: 'TR-OFFLINE', warehouseId: '900', tripProof, + members: ['42', '43'].map(customerId => ({ customerId, marker: context.marker, dispatchNo: 'DS-ONE', receiptVerified: true })) } }, verify: async (_context, _fixture, options) => { await options.readInventory({ marker: context.marker, customerId: '42', warehouseId: '900' }) - context.createdResources.fleetCrossCustomer = { status: 'VERIFIED', tripNo: 'TR-OFFLINE' } - context.createdResources.fleetTrips = [{ tripNo: 'TR-OFFLINE' }] + context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: 'DS-ONE', status: 'VERIFIED', tripNo: 'TR-OFFLINE' } return context.createdResources.fleetCrossCustomer }, finish: async (_context, _module, trip) => { trip.closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, @@ -82,7 +84,7 @@ test('operator creation followed by login failure still disables the newly creat try { const h = harness(root), original = h.context.request h.context.request = async function (...args) { - if (args[1] === '/api/platform/v1/iam/users') return { http: 200, body: { code: '200', data: { membershipId: '82' } } } + if (args[1] === '/api/platform/v1/iam/users') return { http: 200, body: { code: '200', data: { membershipId: '82' } }, requestId: 'offline-request', traceId: 'offline-trace' } return original.apply(this, args) } h.options.pickerCreate = async context => { @@ -99,3 +101,88 @@ test('operator creation followed by login failure still disables the newly creat rmSync(root, { recursive: true, force: true }) } }) + + +for (const [verificationScope, proofKey] of [['CROSS_CUSTOMER', 'fleetCrossCustomer'], ['CAPACITY_POOL', 'fleetCapacityPool'], ['SAME_ROUTE_COST', 'fleetSameRouteCost']]) { + test(`explicit ${verificationScope} requires its own authoritative native proof`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root); h.options.verificationScope = verificationScope + h.options.verify = async (context, fixture) => { + const proof = { marker: context.marker, dispatchNo: fixture.dispatchNo, tripNo: fixture.tripNo, status: 'VERIFIED' } + context.createdResources[proofKey] = proof; return proof + } + const report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, true); assert.equal(report.verificationScope, verificationScope) + } finally { assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-cross-contract-')); rmSync(root, { recursive: true, force: true }) } + }) +} + +for (const mismatch of ['wrong scope', 'wrong dispatch', 'wrong marker', 'unregistered proof', 'foreign trip proof']) { + test(`native proof mismatch cannot close resources: ${mismatch}`, async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root); let closed = false + if (mismatch === 'foreign trip proof') { + const prepare = h.options.prepare + h.options.prepare = async (...args) => { const fixture = await prepare(...args); fixture.tripProof = { ...fixture.tripProof }; return fixture } + } + h.options.verify = async (context, fixture) => { + const proof = { marker: mismatch === 'wrong marker' ? 'OTHER' : context.marker, + dispatchNo: mismatch === 'wrong dispatch' ? 'DS-OTHER' : fixture.dispatchNo, tripNo: fixture.tripNo, status: 'VERIFIED' } + if (mismatch !== 'unregistered proof') context.createdResources[mismatch === 'wrong scope' ? 'fleetCapacityPool' : 'fleetCrossCustomer'] = proof + return proof + } + h.options.finish = async () => { closed = true } + const report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, false); assert.equal(closed, false); assert.deepEqual(h.cleanup, ['81']) + } finally { assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-cross-contract-')); rmSync(root, { recursive: true, force: true }) } + }) +} + +test('native helper checkpoint snapshots and original write intent are durable before HTTP', async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root), original = h.context.request + h.context.request = async function (...args) { + if (args[1] === '/api/test-owned-command') { + const folder = join(root, 'reports/fleet-cross-offline-one') + const progress = JSON.parse(readFileSync(join(folder, 'progress.json'), 'utf8')) + assert.equal(progress.createdResources.nativeBeforeSend.dispatchNo, 'DS-ONE') + const requests = readdirSync(join(folder, 'requests')).map(name => JSON.parse(readFileSync(join(folder, 'requests', name), 'utf8'))) + const intent = requests.find(row => row.path === args[1]) + assert.equal(intent.stage, 'INTENT'); assert.equal(intent.body.idempotencyKey, 'stable-command') + } + return original.apply(this, args) + } + const prepare = h.options.prepare + h.options.prepare = async (context, options) => { + context.createdResources.nativeBeforeSend = { dispatchNo: 'DS-ONE' }; await context.checkpoint() + return prepare(context, options) + } + assert.equal((await runCrossCustomer(config(), h.options)).success, true) + } finally { assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-cross-contract-')); rmSync(root, { recursive: true, force: true }) } +}) + +test('unknown write retains original intent, blocks later business requests and still attempts owned identity cleanup', async () => { + const root = mkdtempSync(join(tmpdir(), 'fleet-cross-contract-')) + try { + const h = harness(root), original = h.context.request + h.context.request = async function (...args) { + if (args[1] === '/api/unknown-command') { h.calls.push({ method: args[0], path: args[1] }); return { http: 503, body: { code: 'S0503' }, requestId: 'offline-request', traceId: 'offline-trace' } } + return original.apply(this, args) + } + h.options.prepare = async context => { + await assert.rejects(context.request('POST', '/api/unknown-command', { idempotencyKey: 'original-unknown', recordVersion: '0' }), /Unknown write outcome/) + await assert.rejects(context.request('POST', '/api/forbidden-followup', { idempotencyKey: 'must-not-send' }), /Unknown write outcome/) + throw Error('UNKNOWN_STOPPED') + } + const report = await runCrossCustomer(config(), h.options) + assert.equal(report.success, false); assert.deepEqual(h.cleanup, ['81']); assert.equal(report.sessionsClosed, true) + assert.equal(h.calls.some(row => row.path === '/api/forbidden-followup'), false) + const folder = join(root, 'reports/fleet-cross-offline-one/requests') + const saved = readdirSync(folder).map(name => JSON.parse(readFileSync(join(folder, name), 'utf8'))).find(row => row.path === '/api/unknown-command') + assert.equal(saved.stage, 'UNKNOWN_RESULT'); assert.equal(saved.body.idempotencyKey, 'original-unknown'); assert.equal(saved.body.recordVersion, '0') + assert.equal(report.createdResources.fleetWriteBoundary.status, 'STOPPED') + } finally { assert.equal(dirname(resolve(root)), resolve(tmpdir())); assert.ok(basename(root).startsWith('fleet-cross-contract-')); rmSync(root, { recursive: true, force: true }) } +}) diff --git a/scripts/fleet-same-route-cost-test-env.mjs b/scripts/fleet-same-route-cost-test-env.mjs index 59a212b29..970476ead 100644 --- a/scripts/fleet-same-route-cost-test-env.mjs +++ b/scripts/fleet-same-route-cost-test-env.mjs @@ -1,119 +1,291 @@ import fs from 'node:fs' import {createHash} from 'node:crypto' -import {resolve} from 'node:path' -import {pathToFileURL} from 'node:url' +import {resolve,dirname} from 'node:path' +import {fileURLToPath,pathToFileURL} from 'node:url' +import {AcceptanceRun} from './admin-test-env-acceptance.mjs' +import {redactEvidence} from './fba-core-regression-test-env.mjs' import {parseCrossCustomerArguments,runCrossCustomer} from './fleet-cross-customer-test-env.mjs' -import {controlledPalletMeasurements} from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import {fixtureContract} from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' +import {version} from './admin-test-env-modules/grouped-dispatch-acceptance.mjs' import {verifyFleetExternalReceipt} from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' import {verifyFleetExternalDispatch} from './admin-test-env-modules/fleet-external-dispatch-acceptance.mjs' import {expectedAllocations} from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' const ROOT='/api/platform/v1/last-leg',PDA='/api/pda/v1/last-leg/dispatches',MODULE='fleet-same-route-cost' -const id=v=>String(v??''),demand=(value,message)=>{if(!value)throw Error(message)} -const sorted=v=>Array.isArray(v)?v.map(sorted):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sorted(v[k])])):v +const REPO=resolve(dirname(fileURLToPath(import.meta.url)),'..') +const demand=(value,message)=>{if(!value)throw Error(message)} +const id=value=>{demand(typeof value!=='number'||Number.isSafeInteger(value),'UNSAFE_ID');return String(value??'')} +const integer=value=>{demand(['number','string'].includes(typeof value)&&/^(0|[1-9]\d*)$/.test(id(value))&&Number.isSafeInteger(Number(value)),'INVALID_QUANTITY');return Number(value)} +const sorted=value=>Array.isArray(value)?value.map(sorted):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,sorted(value[key])])):value const same=(a,b)=>JSON.stringify(sorted(a))===JSON.stringify(sorted(b)) -const cents=v=>{demand(/^\d+(?:\.\d{1,2})?$/.test(id(v)),'INVALID_MONEY');const [a,b='']=id(v).split('.');return BigInt(a)*100n+BigInt(b.padEnd(2,'0'))} +const cents=value=>{const text=String(value);demand(/^\d+(?:\.\d{1,2})?$/.test(text),'INVALID_MONEY');const [whole,fraction='']=text.split('.');return BigInt(whole)*100n+BigInt(fraction.padEnd(2,'0'))} +const encode=encodeURIComponent +const actualValue=(row,field)=>row?.evidence?.some(e=>e.field===field&&e.actual===true&&e.sourceType&&e.sourceBusinessNo&&Number.isFinite(Date.parse(e.observedAt))&&Number(e.value)===Number(row[field])) + +// Capacity planning may precede picking. Read native physical pallets and their actual evidence; +// never pass capacity-plan cargo to the loading helper or supply invented picked counts. +export function sameRouteMeasurements(context,fixture,trip,preview,{external=false}={}) { + demand(preview?.dispatchNo===fixture.dispatchNo&&preview.status==='ACTIVE'&&preview.pallets?.length,'CURRENT_NATIVE_PHYSICAL_PREVIEW_REQUIRED') + const supplied=fixture.loadingMeasurements + demand(!supplied||supplied.environment==='TEST'&&supplied.marker===context.marker&&supplied.dispatchNo===fixture.dispatchNo&&Array.isArray(supplied.pallets),'CURRENT_TEST_MEASUREMENT_RECEIPT_REQUIRED') + const actual=structuredClone(supplied?.pallets||[]),receipt=external?trip.externalReceipt?.finalBooking?.receipt:null + demand(new Set(preview.pallets.map(p=>id(p.warehousePalletId))).size===preview.pallets.length&&preview.pallets.every(p=>id(p.warehousePalletId)&&p.palletNo),'CURRENT_NATIVE_PHYSICAL_PALLET_IDENTITY_REQUIRED') + if(external){ + demand(trip.externalReceipt?.status==='VERIFIED'&&receipt?.operatorId&&Number.isFinite(Date.parse(receipt.confirmedAt))&&receipt.source?.incomingNo,'ACTUAL_EXTERNAL_MEASUREMENT_REQUIRED') + for(const measured of receipt.pallets||[]){ + const source=receipt.source.pallets?.find(p=>id(p.warehousePalletId)===id(measured.warehousePalletId)) + demand(source,'ACTUAL_EXTERNAL_MEASUREMENT_REQUIRED') + actual.push({warehousePalletId:id(measured.warehousePalletId),palletSpaces:measured.palletSpaces,weightKg:measured.weightKg??source.weightKg,volumeCbm:measured.volumeCbm??source.volumeCbm, + deliveryIndex:trip.stops.findIndex(stop=>stop.destinationKey===trip.externalReceipt.finalBooking.cargo.destinationKey), + evidence:['palletSpaces','weightKg','volumeCbm'].map(field=>({field,value:field==='palletSpaces'?measured[field]:measured[field]??source[field],actual:true, + sourceType:'FLEET_RECEIPT_CONFIRMATION',sourceBusinessNo:receipt.source.incomingNo,observedAt:receipt.confirmedAt}))}) + } + } + demand(new Set(actual.map(p=>id(p.warehousePalletId))).size===actual.length,'DUPLICATE_ACTUAL_MEASUREMENT') + const expected=new Set([...preview.pallets.map(p=>id(p.warehousePalletId)),...(receipt?.source?.pallets||[]).map(p=>id(p.warehousePalletId))]) + demand(actual.every(p=>expected.has(id(p.warehousePalletId))),'FOREIGN_ACTUAL_MEASUREMENT') + const pallets=[...expected].map(palletId=>{ + const physical=preview.pallets.find(p=>id(p.warehousePalletId)===palletId),suppliedRow=actual.find(p=>id(p.warehousePalletId)===palletId),output={warehousePalletId:palletId,evidence:[]} + const sourceBusinessNo=fixture.members?.find(m=>id(m.palletId)===palletId)?.incomingNo||(receipt?.source?.pallets?.some(p=>id(p.warehousePalletId)===palletId)?receipt.source.incomingNo:null) + demand(sourceBusinessNo,'CURRENT_MEASUREMENT_SOURCE_REQUIRED') + for(const field of ['palletSpaces','weightKg','volumeCbm']){ + const row=physical?.[field]==null?suppliedRow:physical,value=row?.[field] + demand(Number.isFinite(Number(value))&&Number(value)>0&&actualValue(row,field),'ACTUAL_LOADING_MEASUREMENT_REQUIRED:'+field) + demand(actualValue({...row,evidence:row.evidence.filter(e=>e.sourceBusinessNo===sourceBusinessNo)},field),'CURRENT_MEASUREMENT_SOURCE_REQUIRED') + demand(field!=='palletSpaces'||Number.isSafeInteger(value),'ACTUAL_PALLET_SPACES_MUST_BE_INTEGER') + demand(physical?.[field]==null||suppliedRow?.[field]==null||Number(physical[field])===Number(suppliedRow[field]),'ACTUAL_MEASUREMENT_CONFLICT') + output[field]=value;output.evidence.push(...row.evidence.filter(e=>e.field===field)) + } + output.deliveryIndex=physical?.deliveryIndex??suppliedRow?.deliveryIndex + demand(Number.isSafeInteger(output.deliveryIndex)&&output.deliveryIndex>0&&trip.stops?.[output.deliveryIndex]?.destinationKey,'ACTUAL_UNLOADING_STOP_REQUIRED') + demand(physical?.deliveryIndex==null||suppliedRow?.deliveryIndex==null||physical.deliveryIndex===suppliedRow.deliveryIndex,'ACTUAL_UNLOADING_STOP_CONFLICT') + return output + }) + return {environment:'TEST',marker:context.marker,dispatchNo:fixture.dispatchNo,pallets} +} export function assertSameRouteBasis(departure,shares) { const load=departure?.snapshot?.confirmedLoad,stops=departure?.snapshot?.trip?.stops - demand(departure?.action==='DEPART'&&load?.manifestHash,'ACTUAL_DEPART_HISTORY_REQUIRED') - demand(shares?.length===3&&shares.filter(x=>x.sourceType==='OWN').length===2&&shares.filter(x=>x.sourceType==='EXTERNAL').length===1,'THREE_EXACT_COST_SOURCES_REQUIRED') - demand(new Set(shares.map(x=>x.businessNo)).size===3&&new Set(shares.map(x=>x.dispatchNo)).size===3&&shares.every(x=>x.palletSpaces===1&&x.warehousePalletIds?.length===1),'THREE_INDEPENDENT_STANDARD_PALLETS_REQUIRED') - const ids=shares.flatMap(x=>x.warehousePalletIds).map(id) - demand(new Set(ids).size===3&&load?.pallets?.length===3&&same([...ids].sort(),load.pallets.map(x=>id(x.warehousePalletId)).sort()),'COMPLETE_DEPART_PALLET_SCOPE_REQUIRED') - demand(load.cargo?.length===3&&new Set(load.pallets.map(x=>x.deliveryIndex)).size===1&&load.pallets.every(x=>x.deliveryIndex>0&&stops?.[x.deliveryIndex]?.destinationKey),'SINGLE_REAL_UNLOADING_STOP_REQUIRED') + demand(departure?.action==='DEPART'&&load?.manifestHash,'ACTUAL_DEPART_HISTORY_REQUIRED');version(departure.recordVersion) + demand(shares?.length===2&&shares.filter(s=>s.sourceType==='OWN').length===1&&shares.filter(s=>s.sourceType==='EXTERNAL').length===1,'TWO_ACTUAL_BUSINESS_COST_SOURCES_REQUIRED') + const own=shares.find(s=>s.sourceType==='OWN'),external=shares.find(s=>s.sourceType==='EXTERNAL') + demand(own.dispatchNo&&own.businessNo===own.dispatchNo&&external.dispatchNo===own.dispatchNo&&external.businessNo!==own.businessNo,'ONE_NATIVE_MAIN_TWO_BUSINESS_IDENTITIES_REQUIRED') + demand(own.warehousePalletIds?.length===2&&external.warehousePalletIds?.length===1&&shares.every(s=>Number.isSafeInteger(s.palletSpaces)&&s.palletSpaces>0),'THREE_ACTUAL_PHYSICAL_PALLETS_REQUIRED') + const ids=shares.flatMap(s=>s.warehousePalletIds).map(id) + demand(new Set(ids).size===3&&load.pallets?.length===3&&same([...ids].sort(),load.pallets.map(p=>id(p.warehousePalletId)).sort()),'COMPLETE_DEPART_PALLET_SCOPE_REQUIRED') + demand(load.cargo?.length>=3&&new Set(load.pallets.map(p=>p.deliveryIndex)).size===1&&load.pallets.every(p=>Number.isSafeInteger(p.deliveryIndex)&&p.deliveryIndex>0&&stops?.[p.deliveryIndex]?.destinationKey),'SINGLE_REAL_UNLOADING_STOP_REQUIRED') + demand(load.cargo.every(c=>ids.includes(id(c.warehousePalletId))),'FOREIGN_DEPART_CARGO') for(const share of shares){ - const pallet=load.pallets.find(x=>id(x.warehousePalletId)===id(share.warehousePalletIds[0])),cargo=load.cargo.filter(x=>id(x.warehousePalletId)===id(pallet.warehousePalletId)) - demand(Number(pallet.palletSpaces)===1&&cargo.length===1&&cargo[0].dispatchNo===share.dispatchNo&&id(cargo[0].customerId)===share.customerId&&Number(cargo[0].packages)===1&&cargo[0].destinationKey===stops[pallet.deliveryIndex].destinationKey,'SOURCE_AND_DESTINATION_MISMATCH') + const pallets=load.pallets.filter(p=>share.warehousePalletIds.map(id).includes(id(p.warehousePalletId))) + demand(pallets.every(p=>Number.isSafeInteger(p.palletSpaces)&&p.palletSpaces>0)&&pallets.reduce((sum,p)=>sum+p.palletSpaces,0)===share.palletSpaces,'ACTUAL_CAPACITY_SUM_REQUIRED') + for(const pallet of pallets){ + const cargo=load.cargo.filter(c=>id(c.warehousePalletId)===id(pallet.warehousePalletId)) + demand(cargo.length>0&&cargo.every(c=>c.dispatchNo===share.dispatchNo&&id(c.customerId)&&integer(c.packages)>0&&c.destinationKey===stops[pallet.deliveryIndex].destinationKey) + &&cargo.reduce((sum,c)=>sum+integer(c.packages),0)===1&&new Set(cargo.map(c=>id(c.customerId))).size===1,'SOURCE_AND_DESTINATION_MISMATCH') + } } - const externals=(departure.snapshot.externalBookings||[]).filter(x=>!['DRAFT','CANCELLED'].includes(x.status)),external=shares.find(x=>x.sourceType==='EXTERNAL'),booking=externals[0] - demand(externals.length===1&&booking?.status==='BOUND'&&booking.bookingNo===external.businessNo&&booking.dispatchNo===external.dispatchNo&&id(booking.customerId)===external.customerId&&Number(booking.receipt?.source?.receivedPackages)===1&&booking.receipt.source.pallets?.length===1&&Number(booking.receipt.source.pallets[0].packages)===1&&id(booking.receipt.source.pallets[0].warehousePalletId)===id(external.warehousePalletIds[0]),'ACTUAL_EXTERNAL_RECEIPT_REQUIRED') - demand(shares.filter(x=>x.sourceType==='OWN').every(x=>x.businessNo===x.dispatchNo),'OWN_DISPATCH_IDENTITY_REQUIRED') + const ownCustomers=new Set(load.cargo.filter(c=>own.warehousePalletIds.map(id).includes(id(c.warehousePalletId))).map(c=>id(c.customerId))) + demand(ownCustomers.size===2&&same([...ownCustomers].sort(),own.customerIds?.map(id).sort())&&ownCustomers.has(id(external.customerId)),'TWO_REAL_OWN_CUSTOMERS_REQUIRED') + const externals=(departure.snapshot.externalBookings||[]).filter(b=>!['DRAFT','CANCELLED'].includes(b.status)),booking=externals[0] + const accepted=booking?.receipt,physical=accepted?.source?.pallets + demand(externals.length===1&&booking.status==='BOUND'&&booking.bookingNo===external.businessNo&&booking.dispatchNo===external.dispatchNo&&id(booking.customerId)===id(external.customerId) + &&integer(accepted?.source?.receivedPackages)===1&&physical?.length===1&&integer(physical[0].packages)===1&&id(physical[0].warehousePalletId)===id(external.warehousePalletIds[0]) + &&load.cargo.filter(c=>id(c.warehousePalletId)===id(physical[0].warehousePalletId)).every(c=>id(c.customerId)===id(booking.customerId)),'ACTUAL_EXTERNAL_RECEIPT_REQUIRED') + demand(accepted.pallets?.length===1&&id(accepted.pallets[0].warehousePalletId)===id(physical[0].warehousePalletId)&&accepted.pallets[0].palletSpaces===external.palletSpaces,'ACTUAL_EXTERNAL_CAPACITY_REQUIRED') return load } + +export function sameRouteShares(departure,fixture,receipt,bundle) { + demand(bundle?.detail?.dispatchNo===fixture.dispatchNo&&bundle.items?.dispatchNo===fixture.dispatchNo&&id(version(bundle.detail.recordVersion))===id(version(bundle.items.recordVersion)),'NATIVE_DETAIL_ITEMS_REQUIRED') + const members=fixture.members,externalId=id(receipt.warehousePalletId),ownIds=members.map(m=>id(m.palletId)) + const items=bundle.items.items,load=departure?.snapshot?.confirmedLoad + demand(items?.length===3&&new Set(items.map(i=>id(i.warehousePalletId))).size===3&&members.every(m=>items.some(i=>id(i.warehousePalletId)===id(m.palletId)&&id(i.customerId)===id(m.customerId)&&i.inboundCode===m.incomingNo&&integer(i.scheduledPackages)===1)) + &&items.some(i=>id(i.warehousePalletId)===externalId&&id(i.customerId)===id(receipt.createRequest.customerId)&&i.inboundCode===receipt.incomingNo&&integer(i.scheduledPackages)===1),'CURRENT_THREE_SOURCE_ITEMS_REQUIRED') + demand(load?.pallets?.length===3,'ACTUAL_DEPART_HISTORY_REQUIRED') + const spaces=ids=>load.pallets.filter(p=>ids.includes(id(p.warehousePalletId))).reduce((sum,p)=>{demand(Number.isSafeInteger(p.palletSpaces)&&p.palletSpaces>0,'ACTUAL_CAPACITY_SUM_REQUIRED');return sum+p.palletSpaces},0) + const shares=[{businessNo:fixture.dispatchNo,dispatchNo:fixture.dispatchNo,sourceType:'OWN',customerIds:members.map(m=>id(m.customerId)),warehousePalletIds:ownIds,palletSpaces:spaces(ownIds)}, + {businessNo:receipt.bookingNo,dispatchNo:fixture.dispatchNo,sourceType:'EXTERNAL',customerId:id(receipt.createRequest.customerId),warehousePalletIds:[externalId],palletSpaces:spaces([externalId])}] + assertSameRouteBasis(departure,shares);return shares +} + export function assertSameRouteCost(summary,departure,shares,amount='1.00') { const load=assertSameRouteBasis(departure,shares) demand(summary?.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&summary.allocationBlockReason==null&&id(summary.departureVersion)===id(departure.recordVersion)&&summary.manifestHash===load.manifestHash,'IMMUTABLE_DEPART_BASIS_REQUIRED') demand(summary.totals?.length===1&&summary.totals[0].currency==='USD','SINGLE_ORIGINAL_CURRENCY_REQUIRED') - const total=summary.totals[0],expected=expectedAllocations(amount,shares),actual=[...(total.allocations||[])].sort((a,b)=>a.businessNo.localeCompare(b.businessNo)).map(x=>({businessNo:x.businessNo,sourceType:x.sourceType,dispatchNo:x.dispatchNo,palletSpaces:Number(x.palletSpaces),cents:String(cents(x.amount))})) + const total=summary.totals[0],expected=expectedAllocations(amount,shares),actual=[...(total.allocations||[])].sort((a,b)=>a.businessNob.businessNo?1:0).map(s=>({businessNo:s.businessNo,sourceType:s.sourceType,dispatchNo:s.dispatchNo,palletSpaces:integer(s.palletSpaces),cents:String(cents(s.amount))})) demand(cents(total.directCost)===cents(amount)&&same(actual,expected),'EXACT_LARGEST_REMAINDER_ALLOCATION_REQUIRED') - demand(cents(total.ownCost)===expected.filter(x=>x.sourceType==='OWN').reduce((n,x)=>n+BigInt(x.cents),0n)&¢s(total.externalCost)===expected.filter(x=>x.sourceType==='EXTERNAL').reduce((n,x)=>n+BigInt(x.cents),0n)&¢s(total.ownCost)+cents(total.externalCost)===cents(amount),'OWN_EXTERNAL_CENT_CONSERVATION_REQUIRED') + demand(cents(total.ownCost)===expected.filter(s=>s.sourceType==='OWN').reduce((sum,s)=>sum+BigInt(s.cents),0n)&¢s(total.externalCost)===expected.filter(s=>s.sourceType==='EXTERNAL').reduce((sum,s)=>sum+BigInt(s.cents),0n)&¢s(total.ownCost)+cents(total.externalCost)===cents(amount),'OWN_EXTERNAL_CENT_CONSERVATION_REQUIRED') return expected } +// Explicit test fees belong to each actual own customer/destination group. The +// received external pallet shares its customer's group and retains its own quote. +export function sameRouteArrangement(context,fixture) { + demand(context.marker===fixture.marker&&fixture.dispatchNo&&fixture.members?.length===2&&new Set(fixture.members.map(m=>id(m.customerId))).size===2 + &&fixture.members.every(m=>m.marker===context.marker&&m.dispatchNo===fixture.dispatchNo&&/^[1-9]\d*$/.test(id(m.customerId))&&m.destination?.key?.startsWith('FBA:')) + &&new Set(fixture.members.map(m=>m.destination.key)).size===1,'CURRENT_TWO_CUSTOMER_DESTINATION_GROUPS_REQUIRED') + return {mode:'NATIVE',marker:context.marker,dispatchNo:fixture.dispatchNo,plannedDeliveryAt:new Date(Date.now()+21600000).toISOString(), + deliveryMethod:'TRUCK_PALLET',pickupMethod:'WAREHOUSE_PICKUP',loadingType:'PALLET',appointmentAccount:'SELF',vehicleType:'TRUCK',remark:`${context.marker} TEST synthetic customer delivery fees`, + groups:fixture.members.map(m=>({customerId:id(m.customerId),destinationKey:m.destination.key, + fees:[{direction:'RECEIVABLE',feeType:'DELIVERY_FEE',feeName:'本轮合成测试派送费',currency:'CNY',amount:'30.00'}]}))} +} + +// Unknown writes stop this proof and all dispatch helpers sharing the context. +export function sameRouteIo(context,proof) { + demand(typeof context.checkpoint==='function','DURABLE_CHECKPOINT_REQUIRED') + context.createdResources.dispatchWriteBoundary??={stopped:false} + const boundary=context.createdResources.dispatchWriteBoundary + const check=(label,condition)=>{context.assert(MODULE,label,null,Boolean(condition),'',{scenario:MODULE,deploymentBlocker:true});demand(condition,label)} + const send=async(method,path,body,token=context.token,multipart=false)=>{ + const write=!['GET','HEAD'].includes(method) + demand(!write||!boundary.stopped,'UNKNOWN_WRITE_RESULT_STOPPED') + const entry={method,path,body:multipart?{name:body.name,type:body.type,sizeBytes:body.content.byteLength}:structuredClone(body),status:'SENDING'} + proof.requests.push(entry) + try{await context.checkpoint()}catch(error){boundary.stopped=true;entry.status='NOT_SENT';throw error} + let response + try{response=await (multipart?context.requestMultipart(method,path,body,token):context.request(method,path,body,token))} + catch(error){entry.status='UNKNOWN_RESULT';if(write){boundary.stopped=true;boundary.outcomeUnknown=true;proof.outcomeUnknown=true}await context.checkpoint();throw error} + const unknown=write&&(!response?.http||response.http>=500||response.body?.code==null||String(response.body.code)==='200'&&response.body.data==null) + Object.assign(entry,{status:unknown?'UNKNOWN_RESULT':'RESPONDED',http:response?.http,code:response?.body?.code,requestId:response?.requestId,traceId:response?.traceId,result:structuredClone(response?.body?.data)}) + if(unknown){boundary.stopped=true;boundary.outcomeUnknown=true;proof.outcomeUnknown=true} + try{await context.checkpoint()}catch(error){if(write)boundary.stopped=true;throw error} + check(`${method} ${path}`,!unknown&&response?.http===200&&id(response.body?.code)==='200'&&response.requestId&&response.traceId) + return response.body.data + } + return {check,request:send,upload:(path,body)=>send('POST',path,body,context.token,true)} +} + export async function verifySameRouteCost(context,fixture,{execute=false,readInventory}={}) { if(!execute)return {scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',businessSqlWrites:false} - demand(context?.marker&&context.marker===fixture?.marker&&fixture?.pdaToken&&fixture?.members?.length===2 - &&context.createdResources?.fleetTrips?.some(x=>x.tripNo===fixture.tripNo)&&typeof readInventory==='function','CURRENT_TWO_CUSTOMER_FIXTURE_REQUIRED') + demand(context?.marker===fixture?.marker&&context?.marker&&fixture.stage===fixtureContract.stage&&fixture.dispatchNo&&fixture.pdaToken&&fixture.members?.length===2 + &&fixture.members.every(m=>m.marker===context.marker&&m.dispatchNo===fixture.dispatchNo&&m.receiptVerified===true)&&new Set(fixture.members.map(m=>id(m.customerId))).size===2 + &&fixture.tripProof?.dispatchNo===fixture.dispatchNo&&context.createdResources?.fleetTrips?.includes(fixture.tripProof)&&typeof readInventory==='function','CURRENT_TWO_CUSTOMER_FIXTURE_REQUIRED') demand(!context.createdResources.fleetSameRouteCost,'SAME_ROUTE_VERIFICATION_ALREADY_STARTED') - const proof=context.createdResources.fleetSameRouteCost={status:'CHECKING',tripNo:fixture.tripNo,marker:context.marker,scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',snapshots:{},requests:[]} - const check=(name,condition)=>{context.assert(MODULE,name,null,Boolean(condition),'',{scenario:MODULE,deploymentBlocker:true});demand(condition,name)} - const request=async(method,path,body,token=context.token)=>{const r=await context.request(method,path,body,token);proof.requests.push({method,path,http:r.http,code:r.body?.code,requestId:r.requestId,traceId:r.traceId});check(`${method} ${path}`,r.http===200&&id(r.body?.code)==='200'&&r.requestId&&r.traceId);return r.body.data} - const trip=context.createdResources.fleetTrips.find(x=>x.tripNo===fixture.tripNo),base=`${ROOT}/fleet/trips/${fixture.tripNo}`,key=suffix=>`${context.marker}-SAME-${suffix}` - const inventory=async label=>{const rows=[];for(const customerId of [...new Set(fixture.members.map(x=>x.customerId))])rows.push(await readInventory({marker:context.marker,customerId,warehouseId:fixture.warehouseId}));proof.snapshots[label]=rows;return rows} + const proof=context.createdResources.fleetSameRouteCost={status:'CHECKING',tripNo:fixture.tripNo,dispatchNo:fixture.dispatchNo,marker:context.marker,scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',snapshots:{},requests:[]} + const {check,request,upload}=sameRouteIo(context,proof),trip=fixture.tripProof,base=`${ROOT}/fleet/trips/${encode(fixture.tripNo)}`,path=`${ROOT}/dispatches/${encode(fixture.dispatchNo)}`,pdaPath=`${PDA}/${encode(fixture.dispatchNo)}`,key=suffix=>`${context.marker}-SAME-${suffix}` + const inventory=async label=>{ + const rows=[] + for(const customerId of [...new Set(fixture.members.map(m=>id(m.customerId)))]){ + const scope={marker:context.marker,customerId,warehouseId:id(fixture.warehouseId)},raw=await readInventory(scope) + check('Inventory reader proves complete current customer and warehouse',same(raw?.scope,{...scope,coverage:'CUSTOMER_WAREHOUSE_COMPLETE'})) + for(const collection of ['balances','positions','ledger'])check('Raw customer inventory has unique owned rows',Array.isArray(raw[collection])&&new Set(raw[collection].map(r=>id(r.id))).size===raw[collection].length&&raw[collection].every(r=>id(r.customerId)===customerId&&id(r.overseasWarehouseId)===scope.warehouseId)) + for(const balance of raw.balances){ + for(const field of ['inTransitPackages','procurementPackages'])integer(balance[field]) + for(const field of ['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages'])check('All raw position buckets conserve their balance',integer(balance[field])===raw.positions.filter(p=>id(p.balanceId)===id(balance.id)).reduce((sum,p)=>sum+integer(p[field]),0)) + } + check('No orphan physical positions',raw.positions.every(p=>raw.balances.some(b=>id(b.id)===id(p.balanceId)))) + rows.push(raw) + } + proof.snapshots[label]=rows;await context.checkpoint();return rows + } try{ - let detail=await request('GET',base),plan=await request('GET',`${base}/capacity-plan`) - check('Two owned sources have one real FBA unloading destination',plan.cargo?.length===2&&new Set(plan.cargo.map(x=>x.destinationKey)).size===1&&plan.cargo.every(x=>x.destinationKey?.startsWith('FBA:'))) - await request('POST',`${base}/capacity-plan`,{recordVersion:plan.recordVersion,manifestHash:plan.manifestHash,pallets:controlledPalletMeasurements(plan.cargo,detail.trip.stops),reason:context.marker}) + const detail=await request('GET',base),plan=await request('GET',`${base}/capacity-plan`),physical=await request('GET',`${path}/loading`) + check('Two own physical sources share one native dispatch and FBA stop',detail.dispatches?.length===1&&detail.dispatches[0].dispatchNo===fixture.dispatchNo&&plan.cargo?.length===2&&plan.cargo.every(c=>c.dispatchNo===fixture.dispatchNo)&&new Set(plan.cargo.map(c=>c.destinationKey)).size===1&&plan.cargo.every(c=>c.destinationKey?.startsWith('FBA:'))) + check('Capacity uses exactly both current receipt pallets before any scanning',physical.pallets?.length===2&&fixture.members.every(m=>physical.pallets.some(p=>id(p.warehousePalletId)===id(m.palletId)&&p.palletNo===m.palletNo&&integer(p.scheduledPackages)===1&&integer(p.pickedPackages)===0))) + trip.stops=detail.trip.stops + const measured=sameRouteMeasurements(context,fixture,trip,physical) + proof.capacityRequest={recordVersion:version(plan.recordVersion),manifestHash:plan.manifestHash,pallets:measured.pallets.map(({evidence,...p})=>p),reason:context.marker} + const planned=await request('POST',`${base}/capacity-plan`,proof.capacityRequest) + check('Actual capacity plan is valid',planned.planValid===true) await verifyFleetExternalReceipt(context,MODULE,trip,fixture.pdaToken) - await verifyFleetExternalDispatch(context,MODULE,trip) + const beforePick=await inventory('BEFORE_PICK') + const measurements=sameRouteMeasurements(context,fixture,trip,await request('GET',`${path}/loading`),{external:true}) + proof.arrangementSource=sameRouteArrangement(context,fixture);await context.checkpoint() + await verifyFleetExternalDispatch(context,MODULE,trip,{pickerToken:fixture.pdaToken,actualMeasurements:measurements,arrangement:proof.arrangementSource}) const receipt=trip.externalReceipt,external=trip.externalDispatch - const members=proof.members=[...fixture.members,{customerId:external.customerId,dispatchNo:external.dispatchNo,outboundNo:external.outboundNo,deliveryNo:external.deliveryNo,palletId:receipt.warehousePalletId,palletNo:receipt.palletNo,positionId:id(receipt.finalInventory.positions[0].id)}] - check('Only three distinct current-run dispatches and pallets',new Set(members.map(x=>x.dispatchNo)).size===3&&new Set(members.map(x=>x.palletId)).size===3) - for(const[index,m]of members.entries()){ - const task=await request('GET',`${PDA}/${m.dispatchNo}`,undefined,fixture.pdaToken),item=task.items?.[0] - check('PDA item is the original one-box pallet and source position',task.tripNo===fixture.tripNo&&task.items.length===1&&item.palletNo===m.palletNo&&Number(item.scheduledPackages)===1&&Number(item.pickedPackages)===0&&item.positions?.some(x=>id(x.id)===m.positionId&&Number(x.availablePackages)===1)) - const pick={positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key(`PICK-${index}`),remark:context.marker} - await request('POST',`${PDA}/${m.dispatchNo}/items/${item.dispatchItemId}/pick`,pick,fixture.pdaToken) - await request('POST',`${PDA}/${m.dispatchNo}/complete`,{idempotencyKey:key(`PICK-COMPLETE-${index}`)},fixture.pdaToken) + check('Actual external source is added to the same original main',receipt?.status==='VERIFIED'&&external?.status==='VERIFIED'&&external.dispatchNo===fixture.dispatchNo) + const members=proof.members=[...fixture.members.map(m=>({...m})),{customerId:id(external.customerId),dispatchNo:fixture.dispatchNo,incomingNo:receipt.incomingNo,palletId:id(receipt.warehousePalletId),palletNo:receipt.palletNo, + positionId:id(receipt.finalInventory.positions[0].id),balanceId:id(receipt.finalInventory.positions[0].balanceId)}] + check('Exactly three distinct original physical pallets and positions',new Set(members.map(m=>id(m.palletId))).size===3&&new Set(members.map(m=>id(m.positionId))).size===3) + const stock=await inventory('PICKED') + for(const member of members){ + const before=beforePick.find(s=>id(s.scope.customerId)===id(member.customerId)),after=stock.find(s=>id(s.scope.customerId)===id(member.customerId)) + check('Original one-box position alone is depleted by scanning',before.positions.some(p=>id(p.id)===member.positionId&&integer(p.availablePackages)===1)&&after.positions.some(p=>id(p.id)===member.positionId&&integer(p.availablePackages)===0)) + const shipped=after.ledger.filter(r=>!before.ledger.some(old=>id(old.id)===id(r.id))&&r.businessType==='SHIP'&&id(r.positionId)===member.positionId) + check('Exactly one actual SHIP per original position',shipped.length===1&&shipped[0].businessOrderNo===fixture.dispatchNo&&Number(shipped[0].deltaPackages)===-1) } - const stock=await inventory('PICKED'),positions=stock.flatMap(x=>x.positions) - check('All three original physical positions remain with zero available stock',positions.length===3&&members.every(m=>positions.some(x=>id(x.id)===m.positionId&&Number(x.availablePackages)===0))) - const loading=await request('GET',`${base}/loading`);detail=await request('GET',base) - check('Three real pallets ready for loading',!loading.blockReason&&loading.cargo.length===3) - const measured=await request('POST',`${base}/loading`,{recordVersion:loading.recordVersion,manifestHash:loading.manifestHash,pallets:controlledPalletMeasurements(loading.cargo,detail.trip.stops),reason:context.marker}) - const approved=await request('POST',`${base}/loading-review`,{recordVersion:measured.recordVersion,decision:'APPROVED'}) - const depart={recordVersion:approved.trip.recordVersion,idempotencyKey:key('DEPART'),remark:context.marker},departPath=`${PDA}/${members[0].dispatchNo}/depart` - proof.departure=await request('POST',departPath,depart,fixture.pdaToken) - check('Shared actual departure contains all three customer executions',proof.departure.tripNo===fixture.tripNo&&proof.departure.status==='IN_TRANSIT'&&Number(proof.departure.shippedPackages)===3&&same(proof.departure.dispatches.map(x=>x.dispatchNo).sort(),members.map(x=>x.dispatchNo).sort())) - const departedTrip=proof.departedTrip=await request('GET',base),replay=await request('POST',departPath,depart,fixture.pdaToken) - check('Departure replay is explicit and does not change complete trip or stock',replay.replayed===true&&same(departedTrip,await request('GET',base))&&same(stock,await inventory('DEPARTED'))) + for(const before of beforePick){ + const after=stock.find(s=>id(s.scope.customerId)===id(before.scope.customerId)),owned=members.filter(m=>id(m.customerId)===id(before.scope.customerId)),oldIds=new Set(before.ledger.map(r=>id(r.id))) + check('All original immutable inventory movements remain exact',same(before.ledger,after.ledger.filter(r=>oldIds.has(id(r.id))))) + const added=after.ledger.filter(r=>!oldIds.has(id(r.id))) + check('Only one ALLOCATE PICK SHIP sequence exists per physical source',added.length===owned.length*3&&owned.every(m=>['ALLOCATE','PICK','SHIP'].every(type=>added.filter(r=>r.businessType===type&&id(r.positionId)===m.positionId&&r.businessOrderNo===fixture.dispatchNo&&Number(r.deltaPackages)===(type==='SHIP'?-1:1)).length===1))) + const quantities=raw=>Object.fromEntries(['balances','positions'].map(collection=>[collection,raw[collection].map(row=>({id:id(row.id),...Object.fromEntries(['availablePackages','holdPackages','allocatedPackages','pickedPackages','receivingPackages',...(collection==='balances'?['inTransitPackages','procurementPackages']:[])].map(field=>[field,integer(row[field])]))}))])) + const expected=structuredClone(before) + for(const member of owned){ + expected.positions.find(p=>id(p.id)===member.positionId).availablePackages=0 + const balance=expected.balances.find(b=>id(b.id)===member.balanceId);balance.availablePackages=integer(balance.availablePackages)-1 + } + check('Exactly each own customer position and balance loses its single physical box',same(quantities(expected),quantities(after))) + } + const current={detail:await request('GET',path),items:await request('GET',`${path}/items`)},ready=await request('GET',pdaPath,undefined,fixture.pdaToken) + check('All three boxes are loaded and arranged before native departure',current.detail.status==='ACTIVE'&¤t.detail.stage==='AFTER_LOADING'&¤t.detail.pickingReconciled===true&&integer(current.detail.counts.packageCount)===3&&integer(current.detail.counts.pickedPackageCount)===3&&integer(current.detail.counts.shippedPackageCount)===0 + &&ready.dispatchNo===fixture.dispatchNo&&ready.status==='ACTIVE'&&ready.stage==='AFTER_LOADING'&&ready.pickingReconciled===true&&id(ready.warehouseId)===id(fixture.warehouseId)&&id(version(ready.recordVersion))===id(version(current.detail.recordVersion))&&ready.actions?.DEPART?.allowed===true&¤t.detail.actions?.DEPART?.allowed===true) + const deliveries=proof.deliveries=current.detail.documents?.deliveries + check('Exactly two customer-isolated deliveries cover three actual boxes',deliveries?.length===2&&new Set(deliveries.map(d=>d.deliveryNo)).size===2&&deliveries.reduce((sum,d)=>sum+integer(d.packageCount),0)===3&&members.every(m=>deliveries.some(d=>id(d.customerId)===id(m.customerId)))) + const outbounds=current.detail.documents.outbounds + check('Each delivery belongs to exactly its real customer outbound',outbounds?.length===2&&new Set(outbounds.map(o=>o.outboundNo)).size===2&&outbounds.every(o=>deliveries.filter(d=>d.outboundNo===o.outboundNo&&id(d.customerId)===id(o.customerId)).reduce((sum,d)=>sum+integer(d.packageCount),0)===integer(o.packageCount))) + proof.departureRequest={recordVersion:version(ready.recordVersion),idempotencyKey:key('DEPART'),remark:context.marker} + proof.departure=await request('POST',`${pdaPath}/depart`,proof.departureRequest,fixture.pdaToken) + check('One native departure covers the whole current main',proof.departure.dispatchNo===fixture.dispatchNo&&proof.departure.action==='DEPART'&&proof.departure.status==='ACTIVE'&&proof.departure.stage==='DELIVERING'&&integer(proof.departure.result?.shippedPackages)===3&&BigInt(version(proof.departure.recordVersion))>BigInt(version(ready.recordVersion)) + &&proof.departure.result.deliveries?.length===deliveries.length&&deliveries.every(d=>proof.departure.result.deliveries.some(r=>r.deliveryNo===d.deliveryNo&&r.outboundNo===d.outboundNo&&id(r.customerId)===id(d.customerId)&&integer(r.packageCount)===integer(d.packageCount)))) + const departedTrip=proof.departedTrip=await request('GET',base),departed=await request('GET',path),replay=await request('POST',`${path}/depart`,proof.departureRequest) + check('Actual main and its single resource record are delivering in transit',departedTrip.trip?.status==='IN_TRANSIT'&&departedTrip.dispatches?.length===1&&departedTrip.dispatches[0].dispatchNo===fixture.dispatchNo&&departed.status==='ACTIVE'&&departed.stage==='DELIVERING'&&integer(departed.counts.shippedPackageCount)===3&&id(version(departed.recordVersion))===id(version(proof.departure.recordVersion))) + check('Platform replay returns the original PDA departure and version',replay.replayed===true&&id(version(replay.recordVersion))===id(version(proof.departure.recordVersion))&&same(replay.result,proof.departure.result)) + check('Departure and replay do not deduct stock again or mutate facts',same(departedTrip,await request('GET',base))&&same(departed,await request('GET',path))&&same(stock,await inventory('DEPARTED'))) const history=await request('GET',`${base}/history?page=1&size=200`) - check('Complete trip history contains one actual departure',Number(history.total)===history.records.length&&history.records.filter(x=>x.action==='DEPART').length===1) - const departure=proof.departureHistory=history.records.find(x=>x.action==='DEPART') - const shares=proof.shares=members.map((m,i)=>({businessNo:i===2?receipt.bookingNo:m.dispatchNo,sourceType:i===2?'EXTERNAL':'OWN',dispatchNo:m.dispatchNo,customerId:m.customerId,palletSpaces:1,warehousePalletIds:[m.palletId]})) - assertSameRouteBasis(departure,shares) + check('Complete history has one immutable actual departure',integer(history.total)===history.records.length&&history.records.filter(r=>r.action==='DEPART').length===1) + const departure=proof.departureHistory=history.records.find(r=>r.action==='DEPART'),shares=proof.shares=sameRouteShares(departure,fixture,receipt,current) const costPath=`${base}/costs`,before=await request('GET',`${costPath}?page=1&size=100`) - check('Current trip has no prior cost entries',Number(before.page.total)===0&&before.page.records.length===0&&before.summary.totals.length===0) - check('Empty cost query already uses exact original same-route departure',before.summary.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&before.summary.allocationBlockReason==null&&id(before.summary.departureVersion)===id(departure.recordVersion)&&before.summary.manifestHash===departure.snapshot.confirmedLoad.manifestHash) - proof.costRequest={idempotencyKey:key('COST-USD'),details:{category:'FUEL',currency:'USD',amount:'1.00',incurredAt:new Date().toISOString(),remark:`${context.marker} same-route synthetic cost`}} + check('Fresh trip has no prior costs',integer(before.page.total)===0&&before.page.records.length===0&&before.summary.totals.length===0) + check('Empty query already locks actual departure basis',before.summary.allocationMethod==='SAME_ROUTE_PALLET_SPACES'&&before.summary.allocationBlockReason==null&&id(before.summary.departureVersion)===id(departure.recordVersion)&&before.summary.manifestHash===departure.snapshot.confirmedLoad.manifestHash) + proof.costRequest={idempotencyKey:key('COST-USD'),details:{category:'FUEL',currency:'USD',amount:'1.00',incurredAt:new Date().toISOString(),remark:`${context.marker} TEST synthetic cost`}} const cost=proof.cost=await request('POST',costPath,proof.costRequest),result=proof.costResult=await request('GET',`${costPath}?page=1&size=100`) - check('Exactly one USD cost belongs to original trip',cost.tripNo===fixture.tripNo&&cost.costNo&&Number(cost.recordVersion)===0&&Number(result.page.total)===1&&result.page.records.length===1&&result.page.records[0].costNo===cost.costNo) + check('Exactly one original-currency cost was created at legal zero version',cost.tripNo===fixture.tripNo&&cost.costNo&&BigInt(version(cost.recordVersion))===0n&&integer(result.page.total)===1&&result.page.records.length===1&&result.page.records[0].costNo===cost.costNo) proof.expectedAllocations=assertSameRouteCost(result.summary,departure,shares) - check('Exact cost command replay returns same persisted cost and unchanged allocation',same(result.page.records[0],await request('POST',costPath,proof.costRequest))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) - const costHistory=proof.costHistory=await request('GET',`${costPath}/${cost.costNo}/history?page=1&size=100`) - check('Only one immutable cost creation history',Number(costHistory.total)===1&&costHistory.records.length===1&&costHistory.records[0].action==='CREATE'&&costHistory.records[0].snapshot?.cost?.costNo===cost.costNo) + check('Exact cost command replay returns the same persisted cost and allocation',same(result.page.records[0],await request('POST',costPath,proof.costRequest))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) + const costHistory=proof.costHistory=await request('GET',`${costPath}/${encode(cost.costNo)}/history?page=1&size=100`) + check('Exactly one immutable cost CREATE exists',integer(costHistory.total)===1&&costHistory.records.length===1&&costHistory.records[0].action==='CREATE'&&costHistory.records[0].snapshot?.cost?.costNo===cost.costNo) assertSameRouteCost(costHistory.records[0].snapshot.summary,departure,shares) - check('Cost writes preserve exact departure history and customer stock',same(history,await request('GET',`${base}/history?page=1&size=200`))&&same(stock,await inventory('COST_REPLAY'))) - for(const[index,m]of members.entries()){ - const path=`${ROOT}/delivery-orders/${m.deliveryNo}`,scope=`?customerId=${m.customerId}`,delivery=await request('GET',path+scope) - await request('PUT',`${path}/status${scope}`,{status:'DELIVERING',expectedVersion:delivery.order.version,remark:context.marker}) - const docs=await request('GET',`${path}/documents${scope}`),upload=await context.requestMultipart('POST',`${path}/documents/RECEIPT/files${scope}`,{name:`${context.marker}-${index}-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} synthetic receipt ${m.deliveryNo}`)}) - check('Actual receipt upload belongs to delivery',upload.http===200&&id(upload.body?.code)==='200'&&upload.body.data?.fileId) - const documents=(docs.categories||[]).filter(x=>x.category!=='RECEIPT').map(x=>({category:x.category,fileIds:(x.files||[]).map(f=>f.fileId)}));documents.push({category:'RECEIPT',fileIds:[id(upload.body.data.fileId)]}) - await request('PUT',`${path}/documents${scope}`,{expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents}) - check('This actual delivery completed with saved receipt',(await request('GET',path+scope)).order.status==='DELIVERED') + check('Cost writes preserve original DEPART history, main, trip and all stock',same(history,await request('GET',`${base}/history?page=1&size=200`))&&same(departed,await request('GET',path))&&same(departedTrip,await request('GET',base))&&same(stock,await inventory('COST_REPLAY'))) + for(const [index,ref]of deliveries.entries()){ + const deliveryPath=`${ROOT}/delivery-orders/${encode(ref.deliveryNo)}`,scope=`?customerId=${encode(id(ref.customerId))}`,delivery=await request('GET',deliveryPath+scope) + check('Only this customer actual delivery is signing',delivery.order?.dispatchNo===fixture.dispatchNo&&id(delivery.order.customerId)===id(ref.customerId)&&delivery.order.status==='DELIVERING') + const others=await Promise.all(deliveries.filter(d=>d.deliveryNo!==ref.deliveryNo).map(d=>request('GET',`${ROOT}/delivery-orders/${encode(d.deliveryNo)}?customerId=${encode(id(d.customerId))}`))) + const docsPath=`${deliveryPath}/documents${scope}`,docs=await request('GET',docsPath) + const uploaded=await upload(`${deliveryPath}/documents/RECEIPT/files${scope}`,{name:`${context.marker}-${index}-TEST-receipt.txt`,type:'text/plain',content:new TextEncoder().encode(`${context.marker} TEST synthetic receipt ${ref.deliveryNo}`)}) + check('Actual receipt file has a persisted identity',uploaded?.fileId) + const documents=(docs.categories||[]).filter(d=>d.category!=='RECEIPT').map(d=>({category:d.category,fileIds:(d.files||[]).map(f=>f.fileId)}));documents.push({category:'RECEIPT',fileIds:[id(uploaded.fileId)]}) + const body={expectedVersion:docs.version,idempotencyKey:key(`SIGN-${index}`),bolNo:docs.bolNo||null,documents} + const signed=await request('PUT',docsPath,body),after=await request('GET',deliveryPath+scope) + check('Real saved receipt completes this delivery',after.order.status==='DELIVERED') + await request('PUT',docsPath,body) + check('Signing replay preserves complete delivery evidence',same(after,await request('GET',deliveryPath+scope))) + check('Other customer delivery is unchanged by this signature',same(others,await Promise.all(deliveries.filter(d=>d.deliveryNo!==ref.deliveryNo).map(d=>request('GET',`${ROOT}/delivery-orders/${encode(d.deliveryNo)}?customerId=${encode(id(d.customerId))}`))))) + proof.signatures??=[];proof.signatures.push({deliveryNo:ref.deliveryNo,customerId:ref.customerId,request:body,result:signed});await context.checkpoint() } + const completed=await request('GET',path) + check('All normal signatures aggregate current native main completion',completed.status==='ACTIVE'&&completed.stage==='DELIVERED') check('Signing preserves all customer stock and original cost allocation',same(stock,await inventory('SIGNED'))&&same(result,await request('GET',`${costPath}?page=1&size=100`))) - proof.status='VERIFIED';return proof - }catch(error){proof.status='FAILED';proof.error=error.message;throw error} + proof.status='VERIFIED';await context.checkpoint();return proof + }catch(error){proof.status=proof.outcomeUnknown||context.createdResources.dispatchWriteBoundary?.outcomeUnknown?'OUTCOME_UNKNOWN':'FAILED';proof.error=error.message;await context.checkpoint();throw error} } export async function runSameRouteCost(config,{env=process.env,...dependencies}={}) { - if(!config.execute)return {...config,scope:'TWO_FRESH_OWN_RECEIPTS_PLUS_ONE_EXTERNAL_SAME_ROUTE',cost:'USD 1.00, three independently proven pallet sources, largest remainder by business number',sqlWrites:false,deploy:false,cleanup:'Retain samples; disable own PDA and close own sessions'} - const files=['scripts/fleet-same-route-cost-test-env.mjs','scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs','scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs','scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs'],sources=files.map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(path)).digest('hex')})) - return runCrossCustomer(config,{...dependencies,env,verify:async(context,fixture,options)=>{ - fs.writeFileSync(resolve('reports',`fleet-cross-${config.runId}`,'same-route-source.json'),JSON.stringify({scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',sources},null,2),{flag:'wx'}) + if(!config.execute)return {...config,scope:'TWO_FRESH_OWN_RECEIPTS_PLUS_ONE_EXTERNAL_SAME_ROUTE',cost:'USD 1.00, one OWN dispatch and one EXTERNAL booking on the same main, actual pallet-space largest remainder',sqlWrites:false,deploy:false,cleanup:'Retain samples; disable own PDA and close own sessions'} + const files=['scripts/fleet-same-route-cost-test-env.mjs','scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs','scripts/admin-test-env-modules/fleet-external-receipt-acceptance.mjs','scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs','scripts/admin-test-env-modules/fleet-trip-cost-acceptance.mjs'],sources=files.map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(resolve(REPO,path))).digest('hex')})) + const factory=dependencies.contextFactory|| (options=>new AcceptanceRun(options)) + let runOutput + return runCrossCustomer(config,{...dependencies,env,verificationScope:'SAME_ROUTE_COST',contextFactory:options=>{ + runOutput=options.output + const context=factory(options),prior=context.checkpoint?.bind(context) + context.checkpoint=async()=>{ + await prior?.() + const path=resolve(options.output,'same-route-progress.json'),temp=`${path}.tmp` + fs.writeFileSync(temp,JSON.stringify(redactEvidence({marker:context.marker,createdResources:context.createdResources}),null,2)+'\n');fs.renameSync(temp,path) + } + return context + },verify:async(context,fixture,options)=>{ + fs.writeFileSync(resolve(runOutput,'same-route-source.json'),JSON.stringify({scope:'SAME_ROUTE_COST_WITH_TWO_OWN_AND_ONE_EXTERNAL',sources},null,2),{flag:'wx'}) return verifySameRouteCost(context,fixture,options) }}) } diff --git a/scripts/fleet-same-route-cost-test-env.test.mjs b/scripts/fleet-same-route-cost-test-env.test.mjs index d459a8e07..c768c3e33 100644 --- a/scripts/fleet-same-route-cost-test-env.test.mjs +++ b/scripts/fleet-same-route-cost-test-env.test.mjs @@ -1,40 +1,173 @@ import test from 'node:test' import assert from 'node:assert/strict' -import {assertSameRouteCost,assertSameRouteBasis,runSameRouteCost,verifySameRouteCost} from './fleet-same-route-cost-test-env.mjs' -function sample(){ - const shares=[{businessNo:'DS-A',dispatchNo:'DS-A',sourceType:'OWN',customerId:'101',palletSpaces:1,warehousePalletIds:['1']},{businessNo:'DS-B',dispatchNo:'DS-B',sourceType:'OWN',customerId:'102',palletSpaces:1,warehousePalletIds:['2']},{businessNo:'BK-C',dispatchNo:'DS-C',sourceType:'EXTERNAL',customerId:'101',palletSpaces:1,warehousePalletIds:['3']}] - const departure={action:'DEPART',recordVersion:'7',snapshot:{trip:{stops:[{name:'Origin'},{destinationKey:'FBA:A'},{destinationKey:'FBA:B'}]},confirmedLoad:{manifestHash:'real-hash',pallets:shares.map(x=>({warehousePalletId:x.warehousePalletIds[0],palletSpaces:1,deliveryIndex:1})),cargo:shares.map(x=>({warehousePalletId:x.warehousePalletIds[0],dispatchNo:x.dispatchNo,customerId:x.customerId,packages:1,destinationKey:'FBA:A'}))},externalBookings:[{bookingNo:'BK-C',dispatchNo:'DS-C',customerId:'101',status:'BOUND',receipt:{source:{receivedPackages:1,pallets:[{warehousePalletId:'3',packages:1}]}}}]}} - // Three equal independent sources: the lexical first business number receives the residual cent. - const summary={departureVersion:'7',manifestHash:'real-hash',allocationMethod:'SAME_ROUTE_PALLET_SPACES',totals:[{currency:'USD',directCost:'1.00',ownCost:'0.66',externalCost:'0.34',allocations:shares.map(x=>({businessNo:x.businessNo,dispatchNo:x.dispatchNo,sourceType:x.sourceType,palletSpaces:1,amount:x.sourceType==='OWN'?'0.33':'0.34'}))}]} - return{shares,departure,summary} +import {fixtureContract} from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' +import {assertSameRouteCost,assertSameRouteBasis,sameRouteShares,sameRouteMeasurements,sameRouteArrangement,sameRouteIo,runSameRouteCost,verifySameRouteCost} from './fleet-same-route-cost-test-env.mjs' + +function sample(ownSpaces=[1,1],externalSpaces=1){ + const shares=[{businessNo:'DS-OWN',dispatchNo:'DS-OWN',sourceType:'OWN',customerIds:['101','102'],palletSpaces:ownSpaces.reduce((sum,n)=>sum+n,0),warehousePalletIds:['9007199254740993','2']}, + {businessNo:'BK-C',dispatchNo:'DS-OWN',sourceType:'EXTERNAL',customerId:'101',palletSpaces:externalSpaces,warehousePalletIds:['3']}] + const departure={action:'DEPART',recordVersion:'9007199254740997',snapshot:{trip:{stops:[{name:'Origin'},{destinationKey:'FBA:A'},{destinationKey:'FBA:B'}]}, + confirmedLoad:{manifestHash:'real-hash',pallets:[...ownSpaces,externalSpaces].map((palletSpaces,index)=>({warehousePalletId:['9007199254740993','2','3'][index],palletSpaces,deliveryIndex:1})), + cargo:['101','102','101'].map((customerId,index)=>({warehousePalletId:['9007199254740993','2','3'][index],dispatchNo:'DS-OWN',customerId,packages:'1',destinationKey:'FBA:A'}))}, + externalBookings:[{bookingNo:'BK-C',dispatchNo:'DS-OWN',customerId:'101',status:'BOUND',receipt:{source:{receivedPackages:'1',pallets:[{warehousePalletId:'3',packages:'1'}]},pallets:[{warehousePalletId:'3',palletSpaces:externalSpaces}]}}]}} + // Independent two-business 2:1 oracle: 100 cents => OWN 67, EXTERNAL 33. + const summary={departureVersion:'9007199254740997',manifestHash:'real-hash',allocationMethod:'SAME_ROUTE_PALLET_SPACES',totals:[{currency:'USD',directCost:'1.00',ownCost:'0.67',externalCost:'0.33', + allocations:shares.map(s=>({businessNo:s.businessNo,dispatchNo:s.dispatchNo,sourceType:s.sourceType,palletSpaces:s.palletSpaces,amount:s.sourceType==='OWN'?'0.67':'0.33'}))}]} + return {shares,departure,summary} } test('offline entry does not call remote services and missing runtime scope is rejected',async()=>{ - assert.equal((await runSameRouteCost({execute:false},{env:{}})).deploy,false) - assert.equal((await verifySameRouteCost()).businessSqlWrites,false) - await assert.rejects(verifySameRouteCost({},null,{execute:true}),/FIXTURE/) + assert.equal((await runSameRouteCost({execute:false},{env:{}})).deploy,false) + assert.equal((await verifySameRouteCost()).businessSqlWrites,false) + await assert.rejects(verifySameRouteCost({},null,{execute:true}),/FIXTURE/) }) -test('exact three-source penny conservation follows real business-number tie break',()=>{ - const s=sample();assert.equal(assertSameRouteBasis(s.departure,s.shares).pallets.length,3) - assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>[x.businessNo,x.cents]),[['BK-C','34'],['DS-A','33'],['DS-B','33']]) +test('ordinary native arrangement declares exactly two actual own customer groups without a third external fee',()=>{ + const fixture={marker:'RUN',dispatchNo:'DS-OWN',members:['101','102'].map(customerId=>({marker:'RUN',dispatchNo:'DS-OWN',customerId,destination:{key:'FBA:A'}}))} + const source=sameRouteArrangement({marker:'RUN'},fixture) + assert.equal(source.mode,'NATIVE');assert.equal(source.dispatchNo,'DS-OWN');assert.equal(source.groups.length,2) + assert.deepEqual(source.groups.map(g=>[g.customerId,g.destinationKey,g.fees.length,g.fees[0].amount]),[['101','FBA:A',1,'30.00'],['102','FBA:A',1,'30.00']]) + assert.ok(!source.groups.some(g=>g.bookingNo));assert.ok(Number.isFinite(Date.parse(source.plannedDeliveryAt))) + fixture.members[1].destination.key='FBA:B';assert.throws(()=>sameRouteArrangement({marker:'RUN'},fixture),/GROUPS/) }) -for(const fault of ['wrong action','missing pallet','overlapping source','different destination','wrong customer','wrong dispatch','wrong receipt','unbound booking','uncovered external','stale version','stale hash','invented method','mixed currency','lost penny','wrong source type','wrong aggregate','wrong residual winner'])test(`rejects invalid allocation evidence: ${fault}`,()=>{ - const s=sample(),l=s.departure.snapshot.confirmedLoad,t=s.summary.totals[0],b=s.departure.snapshot.externalBookings[0] - if(fault==='wrong action')s.departure.action='PLAN' - if(fault==='missing pallet')l.pallets.pop() - if(fault==='overlapping source')s.shares[1].warehousePalletIds=['1'] - if(fault==='different destination')l.pallets[2].deliveryIndex=2 - if(fault==='wrong customer')l.cargo[0].customerId='FOREIGN' - if(fault==='wrong dispatch')l.cargo[0].dispatchNo='DS-FOREIGN' - if(fault==='wrong receipt')b.receipt.source.pallets[0].packages=2 - if(fault==='unbound booking')b.status='CONFIRMED' - if(fault==='uncovered external')s.departure.snapshot.externalBookings.push({...b,bookingNo:'BK-OTHER'}) - if(fault==='stale version')s.summary.departureVersion='8' - if(fault==='stale hash')s.summary.manifestHash='new-hash' - if(fault==='invented method')s.summary.allocationMethod='GUESS' - if(fault==='mixed currency')t.currency='CNY' - if(fault==='lost penny')t.allocations[0].amount='0.34' - if(fault==='wrong source type')t.allocations[2].sourceType='OWN' - if(fault==='wrong aggregate'){t.ownCost='0.67';t.externalCost='0.33'} - if(fault==='wrong residual winner'){t.allocations[0].amount='0.34';t.allocations[2].amount='0.33';t.ownCost='0.67';t.externalCost='0.33'} - assert.throws(()=>assertSameRouteCost(s.summary,s.departure,s.shares)) +test('three physical pallets share one native main and two business allocations with independent 67:33 oracle',()=>{ + const s=sample();assert.equal(assertSameRouteBasis(s.departure,s.shares).pallets.length,3) + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>[x.businessNo,x.cents]),[['BK-C','33'],['DS-OWN','67']]) + assert.equal(new Set(s.shares.map(x=>x.dispatchNo)).size,1) +}) +test('actual non-unit capacities aggregate own pallets; 4:2 remains 67:33',()=>{ + const s=sample([1,3],2);assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>x.cents),['33','67']) +}) +test('wire BigDecimal money numbers retain exact integer-cent verification',()=>{ + const s=sample(),t=s.summary.totals[0];t.directCost=1;t.ownCost=0.67;t.externalCost=0.33 + for(const row of t.allocations)row.amount=Number(row.amount) + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>x.cents),['33','67']) +}) +test('other actual weights use actual ratio rather than planned 2:1 or physical pallet count',()=>{ + const s=sample([1,2],2),t=s.summary.totals[0];t.ownCost='0.60';t.externalCost='0.40' + t.allocations.find(x=>x.sourceType==='OWN').amount='0.60';t.allocations.find(x=>x.sourceType==='EXTERNAL').amount='0.40' + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares).map(x=>x.cents),['40','60']) +}) +test('equal business capacities use business-number tie break for a residual cent',()=>{ + const s=sample([1,1],2),t=s.summary.totals[0];t.directCost='0.01';t.ownCost='0.00';t.externalCost='0.01' + t.allocations.find(x=>x.sourceType==='OWN').amount='0.00';t.allocations.find(x=>x.sourceType==='EXTERNAL').amount='0.01' + assert.deepEqual(assertSameRouteCost(s.summary,s.departure,s.shares,'0.01').map(x=>x.cents),['1','0']) +}) +for(const fault of ['wrong action','missing pallet','overlapping source','different destination','wrong customer','wrong dispatch','wrong receipt','unbound booking','uncovered external','stale version','stale hash','invented method','mixed currency','lost penny','wrong source type','wrong aggregate','wrong residual winner','invented separate main','plan capacity','external plan capacity','unsafe quantity','missing own customer','foreign cargo'])test(`rejects invalid allocation evidence: ${fault}`,()=>{ + const s=sample(),l=s.departure.snapshot.confirmedLoad,t=s.summary.totals[0],b=s.departure.snapshot.externalBookings[0] + if(fault==='wrong action')s.departure.action='PLAN' + if(fault==='missing pallet')l.pallets.pop() + if(fault==='overlapping source')s.shares[0].warehousePalletIds[1]='3' + if(fault==='different destination')l.pallets[2].deliveryIndex=2 + if(fault==='wrong customer')l.cargo[0].customerId='FOREIGN' + if(fault==='wrong dispatch')l.cargo[0].dispatchNo='DS-FOREIGN' + if(fault==='wrong receipt')b.receipt.source.pallets[0].packages=2 + if(fault==='unbound booking')b.status='CONFIRMED' + if(fault==='uncovered external')s.departure.snapshot.externalBookings.push({...b,bookingNo:'BK-OTHER'}) + if(fault==='stale version')s.summary.departureVersion='9007199254740998' + if(fault==='stale hash')s.summary.manifestHash='new-hash' + if(fault==='invented method')s.summary.allocationMethod='GUESS' + if(fault==='mixed currency')t.currency='CNY' + if(fault==='lost penny')t.allocations[0].amount='0.66' + if(fault==='wrong source type')t.allocations[1].sourceType='OWN' + if(fault==='wrong aggregate'){t.ownCost='0.66';t.externalCost='0.34'} + if(fault==='wrong residual winner'){t.allocations[0].amount='0.66';t.allocations[1].amount='0.34';t.ownCost='0.66';t.externalCost='0.34'} + if(fault==='invented separate main')s.shares[1].dispatchNo='DS-OTHER' + if(fault==='plan capacity')s.shares[0].palletSpaces=1 + if(fault==='external plan capacity')b.receipt.pallets[0].palletSpaces=2 + if(fault==='unsafe quantity')l.cargo[0].packages=9007199254740992 + if(fault==='missing own customer')s.shares[0].customerIds=['101'] + if(fault==='foreign cargo')l.cargo.push({...l.cargo[0],warehousePalletId:'OTHER'}) + assert.throws(()=>assertSameRouteCost(s.summary,s.departure,s.shares)) +}) +function nativeSource(){ + const s=sample(),fixture={dispatchNo:'DS-OWN',members:[{palletId:'9007199254740993',customerId:'101',incomingNo:'IN-A'},{palletId:'2',customerId:'102',incomingNo:'IN-B'}]},receipt={warehousePalletId:'3',bookingNo:'BK-C',incomingNo:'IN-C',createRequest:{customerId:'101'}}, + bundle={detail:{dispatchNo:'DS-OWN',recordVersion:'0'},items:{dispatchNo:'DS-OWN',recordVersion:'0',items:s.departure.snapshot.confirmedLoad.cargo.map((c,i)=>({...c,inboundCode:['IN-A','IN-B','IN-C'][i],scheduledPackages:'1'}))}} + return {...s,fixture,receipt,bundle} +} +test('shares derive from native Detail/Items, real source identities and immutable measured load',()=>{ + const s=nativeSource();assert.deepEqual(sameRouteShares(s.departure,s.fixture,s.receipt,s.bundle),s.shares) +}) +for(const fault of ['version','source','customer','missing pallet'])test(`native source identity refuses ${fault}`,()=>{ + const s=nativeSource() + if(fault==='version')s.bundle.items.recordVersion='1' + if(fault==='source')s.bundle.items.items[2].inboundCode='HISTORY' + if(fault==='customer')s.bundle.items.items[1].customerId='101' + if(fault==='missing pallet')s.bundle.items.items.pop() + assert.throws(()=>sameRouteShares(s.departure,s.fixture,s.receipt,s.bundle)) +}) +function measurements(){ + const context={marker:'RUN'},trip={stops:[{name:'Origin'},{destinationKey:'FBA:A'}]},preview={dispatchNo:'DS-OWN',status:'ACTIVE',pallets:[{warehousePalletId:'9007199254740993',palletNo:'P1',scheduledPackages:'1',pickedPackages:'0',palletSpaces:null,deliveryIndex:1}]}, + row={warehousePalletId:'9007199254740993',palletSpaces:3,weightKg:'120.25',volumeCbm:'1.75',deliveryIndex:1} + row.evidence=['palletSpaces','weightKg','volumeCbm'].map(field=>({field,value:row[field],actual:true,sourceType:'WAREHOUSE_TRANSFER_PDA',sourceBusinessNo:'IN-A',observedAt:'2026-10-19T00:00:00Z'})) + const fixture={dispatchNo:'DS-OWN',members:[{palletId:'9007199254740993',incomingNo:'IN-A'}],loadingMeasurements:{environment:'TEST',marker:'RUN',dispatchNo:'DS-OWN',pallets:[row]}} + return {context,fixture,trip,preview} +} +test('pre-pick capacity reads physical evidence without falsifying picked counts or unit capacities',()=>{ + const s=measurements(),r=sameRouteMeasurements(s.context,s.fixture,s.trip,s.preview) + assert.equal(r.pallets[0].palletSpaces,3);assert.equal(s.preview.pallets[0].pickedPackages,'0') +}) +for(const fault of ['absent','marker','environment','dispatch','actual','field','duplicate','foreign','unit','conflict','stop','historical source'])test(`missing or foreign actual physical evidence blocks: ${fault}`,()=>{ + const s=measurements(),m=s.fixture.loadingMeasurements,row=m.pallets[0] + if(fault==='absent')delete s.fixture.loadingMeasurements + if(fault==='marker')m.marker='HISTORY' + if(fault==='environment')m.environment='DEV' + if(fault==='dispatch')m.dispatchNo='DS-OTHER' + if(fault==='actual')row.evidence[0].actual=false + if(fault==='field')delete row.palletSpaces + if(fault==='duplicate')m.pallets.push(structuredClone(row)) + if(fault==='foreign')m.pallets.push({...structuredClone(row),warehousePalletId:'OTHER'}) + if(fault==='unit')row.palletSpaces='3' + if(fault==='conflict'){s.preview.pallets[0].palletSpaces=2;s.preview.pallets[0].evidence=[{...row.evidence[0],value:2}]} + if(fault==='stop')row.deliveryIndex=0 + if(fault==='historical source')row.evidence[0].sourceBusinessNo='HISTORY' + assert.throws(()=>sameRouteMeasurements(s.context,s.fixture,s.trip,s.preview)) +}) +test('external actual capacity and dimensions come from operator-confirmed receipt',()=>{ + const s=measurements();s.trip.externalReceipt={status:'VERIFIED',finalBooking:{cargo:{destinationKey:'FBA:A'},receipt:{operatorId:'9007199254740998',confirmedAt:'2026-10-19T01:00:00Z', + source:{incomingNo:'IN-EXT',pallets:[{warehousePalletId:'3',weightKg:'110',volumeCbm:'1.1'}]},pallets:[{warehousePalletId:'3',palletSpaces:2}]}}} + const r=sameRouteMeasurements(s.context,s.fixture,s.trip,s.preview,{external:true}) + assert.equal(r.pallets[1].palletSpaces,2);assert.equal(r.pallets[1].weightKg,'110');assert.equal(r.pallets[1].evidence[0].sourceType,'FLEET_RECEIPT_CONFIRMATION') + delete s.trip.externalReceipt.finalBooking.receipt.operatorId + assert.throws(()=>sameRouteMeasurements(s.context,s.fixture,s.trip,s.preview,{external:true}),/EXTERNAL_MEASUREMENT/) +}) +test('executable native chain fails and checkpoints before any write when actual measurements are absent',async()=>{ + const physical=measurements().preview + physical.pallets.push({...physical.pallets[0],warehousePalletId:'2',palletNo:'P2'}) + const trip={dispatchNo:'DS-OWN',tripNo:'FT-OWN'},members=['101','102'].map((customerId,index)=>({marker:'RUN',customerId,dispatchNo:'DS-OWN',receiptVerified:true,incomingNo:['IN-A','IN-B'][index],palletId:['9007199254740993','2'][index],palletNo:['P1','P2'][index]})), + fixture={marker:'RUN',stage:fixtureContract.stage,dispatchNo:'DS-OWN',tripNo:'FT-OWN',warehouseId:'1',pdaToken:'EPHEMERAL',tripProof:trip,members}, + calls=[],context={marker:'RUN',createdResources:{fleetTrips:[trip]},assert(){},checkpoint:async()=>calls.push('checkpoint'),request:async(method,path)=>{ + calls.push(method) + const data=path.endsWith('/loading')?physical:path.endsWith('/capacity-plan')?{recordVersion:'0',manifestHash:'original',cargo:members.map(()=>({dispatchNo:'DS-OWN',destinationKey:'FBA:A'}))} + :{dispatches:[{dispatchNo:'DS-OWN'}],trip:{stops:[{name:'origin'},{destinationKey:'FBA:A'}]}} + return {http:200,body:{code:'200',data},requestId:'R',traceId:'T'} + }} + await assert.rejects(verifySameRouteCost(context,fixture,{execute:true,readInventory:async()=>{throw Error('should not read before measurements')}}),/ACTUAL_LOADING_MEASUREMENT_REQUIRED/) + assert.equal(context.createdResources.fleetSameRouteCost.status,'FAILED') + assert.ok(calls.includes('checkpoint'));assert.ok(!calls.includes('POST'));assert.ok(!calls.includes('PUT')) + assert.ok(!JSON.stringify(context.createdResources.fleetSameRouteCost).includes('EPHEMERAL')) +}) +function transport(response){ + const events=[],proof={requests:[]},context={createdResources:{},token:'NEVER_PERSIST_ME',assert(){},checkpoint:async()=>events.push(structuredClone(proof)), + request:async(...args)=>{events.push(args);if(response instanceof Error)throw response;return response}} + return {events,proof,context,io:sameRouteIo(context,proof)} +} +test('write intent is persisted before transport and zero / large-string facts remain lossless',async()=>{ + const s=transport({http:200,body:{code:'200',data:{recordVersion:'0',id:'9007199254740997'}},requestId:'R',traceId:'T'}),body={recordVersion:'9007199254740996',idempotencyKey:'ORIGINAL'} + assert.equal((await s.io.request('POST','/native',body)).recordVersion,'0') + assert.deepEqual(s.events[0].requests[0],{method:'POST',path:'/native',body,status:'SENDING'}) + assert.equal(s.proof.requests[0].result.id,'9007199254740997');assert.ok(!JSON.stringify(s.proof).includes('NEVER_PERSIST_ME')) +}) +for(const response of [new Error('network'),{http:503,body:{code:'ERROR'}},{http:200,body:{code:'200',data:null}},{http:200,body:{}}])test(`unknown write stops all later writes and retains original command: ${response instanceof Error?'transport':JSON.stringify(response)}`,async()=>{ + const s=transport(response),body={recordVersion:'0',idempotencyKey:'ORIGINAL'} + await assert.rejects(s.io.request('POST','/native',body));assert.equal(s.proof.requests[0].status,'UNKNOWN_RESULT');assert.equal(s.context.createdResources.dispatchWriteBoundary.stopped,true) + assert.deepEqual(s.proof.requests[0].body,body) + await assert.rejects(s.io.request('POST','/native',{...body,idempotencyKey:'REPLACEMENT'}),/STOPPED/);assert.equal(s.proof.requests.length,1) +}) +test('failed intent checkpoint prevents transport; failed result archive stops new writes',async()=>{ + const s=transport({http:200,body:{code:'200',data:{ok:true}},requestId:'R',traceId:'T'}) + s.context.checkpoint=async()=>{throw Error('disk')} + await assert.rejects(s.io.request('POST','/native',{idempotencyKey:'ORIGINAL'}),/disk/);assert.equal(s.events.length,0);assert.equal(s.proof.requests[0].status,'NOT_SENT') + const t=transport({http:200,body:{code:'200',data:{ok:true}},requestId:'R',traceId:'T'});let writes=0 + t.context.checkpoint=async()=>{if(++writes===2)throw Error('archive')} + await assert.rejects(t.io.request('POST','/native',{idempotencyKey:'ORIGINAL'}),/archive/) + await assert.rejects(t.io.request('POST','/native',{}),/STOPPED/) }) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index 79b8b54ff..ab1c41f77 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -6,7 +6,8 @@ import { AsyncLocalStorage } from 'node:async_hooks' import { AcceptanceRun, parseArguments, validateExecutionOptions, buildAcceptanceSummary } from './admin-test-env-acceptance.mjs' import { redactEvidence } from './fba-core-regression-test-env.mjs' import { createTestEvidenceReaders, buildEvidenceRemoteScript, validateEvidenceConfig } from './fba-core-regression-evidence.mjs' -import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip, controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { prepareFleetTrip, finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { createBody, candidatePage, markerCandidates, dispatchIo, readDispatch, version as dispatchVersion, replay as replayDispatch } from './admin-test-env-modules/grouped-dispatch-acceptance.mjs' import { verifyFleetBookingReservation } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' import { verifyFleetFeeCoverage } from './admin-test-env-modules/fleet-fee-coverage-acceptance.mjs' import { verifyFleetExternalReceipt } from './admin-test-env-modules/fleet-external-receipt-acceptance.mjs' @@ -14,8 +15,8 @@ import { verifyFleetExternalDispatch } from './admin-test-env-modules/fleet-exte import { verifyFleetHandover, cleanupFleetHandoverResources } from './admin-test-env-modules/fleet-handover-acceptance.mjs' import { verifyFleetTripCosts } from './admin-test-env-modules/fleet-trip-cost-acceptance.mjs' import { verifyFleetReturnRedispatch, waitForRedispatchSignatureAudit, selectReturnSources } from './admin-test-env-modules/fleet-return-redispatch-acceptance.mjs' -import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' -import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { resolvePickerAccess, createPicker, disablePicker, pickerFixtureSuffix } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const MODULE = 'fleet-specialized-acceptance' @@ -23,10 +24,55 @@ const OPTIONS = { scenario: MODULE, deploymentBlocker: true } const id = value => value == null ? '' : String(value) const success = response => response?.http === 200 && id(response?.body?.code) === '200' const key = () => `fleet-${randomUUID()}` +const NATIVE_PROTOCOL = 'NATIVE_DISPATCH_V1' +const nativeDeparture = (value, dispatchNo) => value?.action === 'DEPART' && value.dispatchNo === dispatchNo + && value.status === 'ACTIVE' && value.stage === 'DELIVERING' && Number.isFinite(Date.parse(value.result?.departedAt)) + && Number.isSafeInteger(Number(value.result.shippedPackages)) && Number(value.result.shippedPackages) > 0 +const instant = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(value) ? `${value}Z` : value +const sameInstant = (a, b) => Number.isFinite(Date.parse(instant(a))) && Date.parse(instant(a)) === Date.parse(instant(b)) +const deliveryFacts = rows => JSON.stringify(rows.map(row => [id(row.customerId), row.deliveryNo, row.outboundNo, Number(row.packageCount)]).sort()) + +export function nativeFleetDeliveries(detail) { + if (!detail?.dispatchNo || !Array.isArray(detail.documents?.deliveries) || !detail.documents.deliveries.length) throw Error('Native delivery references missing') + const rows = new Map(), owners = new Map() + for (const row of detail.documents.deliveries) { + if (!row.deliveryNo || !row.outboundNo || !id(row.customerId) || !Number.isSafeInteger(Number(row.packageCount)) || Number(row.packageCount) <= 0) + throw Error('Native delivery customer, business identity or quantity missing') + if (owners.has(row.deliveryNo) && owners.get(row.deliveryNo) !== id(row.customerId)) throw Error('Delivery cannot belong to two customers') + const identity = `${row.customerId}:${row.deliveryNo}`, prior = rows.get(identity) + if (prior && (prior.outboundNo !== row.outboundNo || Number(prior.packageCount) !== Number(row.packageCount) || prior.groupKey !== row.groupKey)) + throw Error('Duplicate native delivery references disagree') + owners.set(row.deliveryNo, id(row.customerId)); rows.set(identity, row) + } + return [...rows.values()] +} + +export function selectNativeFleetFullSource(context, original, candidates) { + const source = context.createdResources?.phase4OverseasDispatch + if (source?.marker !== context.marker || source.status !== 'VERIFIED' || original.data.dispatchNo !== source.dispatchNo + || original.data.status !== 'ACTIVE' || original.data.stage !== 'BEFORE_LOADING' || Number(original.data.counts.pickedPackageCount) !== 0 + || Number(original.data.counts.shippedPackageCount) !== 0 || id(original.data.recordVersion) !== id(source.recordVersion) + || original.items.items.length < 2 || new Set(original.items.items.map(i => i.destination?.key)).size !== 2) + throw Error('FULL/PARTIAL require the unchanged current-run two-destination native main') + const history = source.routeLifecycle?.stages?.find(s => s.label === '新增同目的地第二托')?.snapshot?.items + if (!history?.length) throw Error('Current-run released duplicate source evidence missing') + const own = original.items.items + const eligible = markerCandidates(context, candidates).filter(c => id(c.customerId) === id(source.customerId) + && id(c.warehouseId) === id(original.warehouseId) && history.some(i => i.sourceKey === c.sourceKey + && id(i.warehousePalletId) === id(c.selection.warehousePalletId)) + && own.every(i => i.sourceKey !== c.sourceKey && id(i.warehousePalletId) !== id(c.selection.warehousePalletId))) + if (eligible.length !== 1) throw Error('Need exactly one current-run released, free, non-overlapping FULL pallet; never borrow historical inventory') + return eligible[0] +} /** Install before login/prerequisites. A caught unknown write must not permit another request. */ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = {}) { let stopped = null, sequence = 0 + const stop = entry => { + context.createdResources ??= {} + context.createdResources.fleetWriteBoundary = { status: 'STOPPED', method: entry.method, path: entry.path, + outcome: entry.status, requestId: entry.requestId, traceId: entry.traceId } + } const scope = new AsyncLocalStorage(), originalFetch = globalThis.fetch const direct = { async requestWithBody(method, path, body, token, headers, request) { const nativeResponse = await originalFetch(request), copy = nativeResponse.clone() @@ -62,7 +108,7 @@ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = { ? { field, value } : { field, name: value.name, type: value.type, base64: Buffer.from(await value.arrayBuffer()).toString('base64') }) } - try { await checkpoint(entry) } catch (error) { stopped = error; throw error } + try { await checkpoint(entry) } catch (error) { entry.status = 'NOT_SENT'; stopped = error; stop(entry); throw error } try { const response = await scope.run(true, () => original.apply(this, arguments)) const http = response?.http, payload = response?.body, code = id(payload?.code) @@ -79,7 +125,7 @@ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = { responseBody: redactFleetWrite(response?.body), finishedAt: new Date().toISOString() }) if (binary) entry.binary = { contentType: response.contentType, bytes: response.bytes.length, sha256: createHash('sha256').update(response.bytes).digest('hex'), validPdf: Boolean(pdf) } - if (!definite) stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`) + if (!definite) { stopped = Error(`Unknown write outcome: ${method} ${path}; preserve original request, do not resend`); stop(entry) } await checkpoint(entry) if (stopped) throw stopped return response @@ -88,8 +134,9 @@ export function guardFleetWrites(context, checkpoint, { guardFetch = false } = { if (entry.status === 'SENDING') { entry.status = 'UNKNOWN_RESULT'; entry.finishedAt = new Date().toISOString() entry.raw = boundedFleetDiagnostic(error instanceof Error ? error.message : String(error)) - await checkpoint(entry) + stop(entry); await checkpoint(entry) } + stop(entry) throw stopped } } @@ -171,7 +218,7 @@ export function fleetExecutionPlan(config) { outputDir: config.outputDir || `reports/fleet-${config.runId}`, producer: config.resumeReport ? `Reviewed ${config.resumeStage} checkpoint in the same original fixture` : 'phase4-overseas-dispatch with its normal fresh prerequisites', resumeReport: config.resumeReport || null, resumeStage: config.resumeStage || null, resumeSha256: config.resumeSha256 || null, stages: config.resumeStage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN' - ? ['Verify exact source-selection-failure report SHA256 and three definitive delivery starts', 'Read-only original DEPART/handover/cost/cargo/resources and DELIVERING versions', + ? ['Verify exact source-selection-failure report SHA256 and three definitive native delivery reads', 'Read-only original DEPART/handover/cost/cargo/resources and DELIVERING versions', 'Recheck repaired A/B return candidates and complete source quantities before any write', 'Create fresh bound PDA session', 'Original sample actual return and redispatch; no repeated original departure/handover/cost/start-delivery', 'Wait for coordinator pre-signature A0/B1 event evidence gate', 'New signature and source ACK evidence; close new and original resources'] @@ -184,9 +231,9 @@ export function fleetExecutionPlan(config) { afterCosts ? 'Match original verified cost snapshot; no cost commands' : 'Original trip operating costs with actual allocation availability', 'Retain original DEPART; no picking or departure commands', 'In-transit handover and PDA readback', 'Original delivery signatures and resource release', 'Close newly created test sessions'] - : ['FULL original coverage', 'formal cancel/unbind/re-arrange', 'PARTIAL original coverage', 'reservation lifecycle', - 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'each dispatch PDA picking', - 'atomic whole-trip departure', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint', + : ['two disjoint current-run native sources', 'independent FULL loading/coverage/departure/POD/resource closure', 'PARTIAL original main and reservation lifecycle', + 'external actual receipt', 'external dispatch and agreed fee', 'Finance consumer persistence', 'one native main PDA picking and explicit actual loading', + 'native current-version whole-main departure and cross-end exact replay', 'actual in-transit handover and PDA readback', 'same-fixture optional H5 checkpoint', 'per-destination receipt and replacement-resource release', 'explicit handover-fixture cleanup'], boundaries: 'TEST only; serial fresh fixtures; no SQL writes, deployment or configuration changes. Failure retains original requests/resources. No automatic rerun/resume.', limits: 'Finance pool proves consumer persistence; producer ACK requires separate event evidence. UI and remaining fleet product scope are separate.' } @@ -195,28 +242,33 @@ export function fleetExecutionPlan(config) { export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { const resources = report?.createdResources, proof = resources?.fleetAcceptance, full = resources?.fleetFeeCoverageCases?.[0] const trip = resources?.fleetTrips?.find(row => row.tripNo === proof?.tripNo) + if (proof?.protocol !== NATIVE_PROTOCOL || !proof.dispatchNo || !trip || trip.dispatchNo !== proof.dispatchNo + || JSON.stringify(proof.dispatchNos) !== JSON.stringify([proof.dispatchNo]) || resources.dispatchWriteBoundary?.outcomeUnknown + || resources.fleetWriteBoundary?.status === 'STOPPED' + || (proof.departure && !nativeDeparture(proof.departure, proof.dispatchNo))) + throw Error('Only current native single-dispatch reports may resume; legacy or unknown outcomes require explicit reconciliation') if (stage === 'RETURN_SOURCE_SELECTION_BEFORE_RETURN') { const attempt = trip?.returnRedispatch, originals = attempt?.originalDeliveries, writes = attempt?.requests?.filter(row => row.method !== 'GET') const failures = report?.steps?.filter(row => row.passed === false) if (report?.success !== false || proof?.stage !== 'RETURN_REDISPATCH' || proof.status !== 'CHECKING' || proof.marker !== attempt?.marker || trip?.costAcceptance?.status !== 'VERIFIED' || trip.handover?.status !== 'VERIFIED' - || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip.tripNo + || !nativeDeparture(proof.departure, proof.dispatchNo) || trip.closure || proof.closure || proof.receipts?.length || attempt.status !== 'FAILED' || attempt.failure !== 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' || attempt.exceptionNo || attempt.sources || attempt.newTripNo || attempt.originalSignatures - || originals?.length !== 3 || new Set(originals.map(row => row.deliveryNo)).size !== 3 || writes?.length !== 3 + || originals?.length !== 3 || new Set(originals.map(row => row.deliveryNo)).size !== 3 || writes?.length !== 0 || originals.some(row => id(row.customerId) !== id(proof.customerId) || id(row.overseasWarehouseId) !== id(proof.warehouseId) - || !proof.dispatchNos.includes(row.dispatchNo) || row.status !== 'WAIT_DELIVERY' || row.signedAt || Number(row.signedPackages || 0)) + || row.dispatchNo !== proof.dispatchNo || row.status !== 'DELIVERING' || row.signedAt || Number(row.signedPackages || 0)) || attempt.requests.some(row => row.status !== 'RESPONDED' || row.http !== 200 || id(row.code) !== '200' || !row.requestId || !row.traceId) - || writes.some(row => { - const original = originals.find(item => item.deliveryNo === row.result?.deliveryNo) - return !original || row.method !== 'PUT' || row.path !== `/api/platform/v1/last-leg/delivery-orders/${original.deliveryNo}/status?customerId=${proof.customerId}` - || row.body?.status !== 'DELIVERING' || id(row.body.expectedVersion) !== id(original.version) - || row.result.deliveryStatus !== 'DELIVERING' || row.result.replayed !== false - }) || new Set(writes.map(row => row.result?.deliveryNo)).size !== 3 + || attempt.requests.length !== 3 || attempt.requests.some(row => { + const original = originals.find(item => item.deliveryNo === row.result?.order?.deliveryNo) + return row.method !== 'GET' || Object.hasOwn(row.result || {}, 'replayed') || !original + || row.path !== `/api/platform/v1/last-leg/delivery-orders/${original.deliveryNo}?customerId=${proof.customerId}` + || JSON.stringify(row.result.order) !== JSON.stringify(original) + }) || new Set(attempt.requests.map(row => row.result?.order?.deliveryNo)).size !== 3 || resources.fleetCostActors?.status !== 'DISABLED' || failures?.length !== 1 || failures[0].name !== '模块执行' || failures[0].evidence !== attempt.failure) - throw Error('Return source recovery requires the exact known candidate failure and three definitive delivery starts, with no return/signature writes') + throw Error('Return source recovery requires the exact native candidate failure, three already-delivering documents and no return/signature writes') return { resources, marker: proof.marker, returnSourceSelection: true } } if (stage === 'DEPARTED_BEFORE_COSTS' || stage === 'DEPARTED_AFTER_COSTS_BEFORE_HANDOVER' || stage === 'DEPARTED_VEHICLE_CREATED') { @@ -234,7 +286,7 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { || creation[0].result.status !== 'IN_SERVICE' || handover.replacement?.driverId)) throw Error('Created vehicle recovery requires the exact definitive successful creation and no later handover write') if (report?.success !== false || proof?.stage !== (afterCosts ? 'IN_TRANSIT_HANDOVER' : 'TRIP_OPERATING_COSTS') || proof.status !== 'CHECKING' - || proof.marker !== costs?.marker || proof.departure?.status !== 'IN_TRANSIT' || proof.departure.tripNo !== trip?.tripNo + || proof.marker !== costs?.marker || !nativeDeparture(proof.departure, proof.dispatchNo) || !proof.departureRequest?.idempotencyKey || !costs?.departure?.snapshot?.confirmedLoad?.manifestHash || costs.status !== (afterCosts ? 'VERIFIED' : 'FAILED') || !Array.isArray(costs.costs) || costs.costs.length !== (afterCosts ? 2 : 0) || !costs.requests?.length || costs.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId @@ -255,7 +307,7 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { if (report?.success !== false || proof?.stage !== 'RECEIPTS_AND_RESOURCE_RELEASE' || trip?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' || proof.closure?.status !== trip.closure.status || trip.externalReceipt?.status !== 'VERIFIED' - || trip.externalDispatch?.status !== 'VERIFIED' || trip.reassignment?.status !== 'VERIFIED' + || trip.externalDispatch?.status !== 'VERIFIED' || trip.externalDispatch.dispatchNo !== proof.dispatchNo || trip.reassignment?.status !== 'REJECTED_WITHOUT_MUTATION' || !proof.receipts?.length || proof.finance?.status !== 'CONSUMER_PERSISTED' || !failures?.length || failures.some(row => !['本次车次货物结案及车辆司机释放完成', '模块执行'].includes(row.name))) throw Error('Terminal reconciliation requires completed original business proofs and only the known aggregate assertion failure') @@ -265,7 +317,7 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { const confirmed = stage === 'EXTERNAL_RECEIPT_CONFIRMED' const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL'), receipt = trip?.externalReceipt if (report?.success !== false || proof?.stage !== 'EXTERNAL_RECEIPT' || proof.status !== 'CHECKING' - || full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo + || full?.proof?.status !== 'VERIFIED' || proof.fullExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' || partial?.proof || partial?.dispatchNo !== trip?.dispatchNo || trip.reservation?.status !== 'VERIFIED' || receipt?.status !== 'CHECKING' || trip.externalDispatch || (confirmed ? (!receipt.receiptRequest?.idempotencyKey || !receipt.requests?.some(row => row.label === '运营按实际1托位和新单价30确认实收' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) : receipt.receiptRequest) || !receipt.palletizeRequest?.idempotencyKey || !receipt.warehousePalletId || !proof.picker?.membershipId || !proof.pickerMarker @@ -276,20 +328,29 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { if (stage === 'EXTERNAL_RECEIPT_READY') { const partial = resources?.fleetFeeCoverageCases?.find(row => row.mode === 'PARTIAL') if (report?.success !== false || proof?.stage !== 'RESERVATIONS' || proof.status !== 'CHECKING' - || full?.proof?.status !== 'VERIFIED' || partial?.proof?.status !== 'VERIFIED' || partial.dispatchNo !== trip?.dispatchNo + || full?.proof?.status !== 'VERIFIED' || proof.fullExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' || partial?.proof || partial?.dispatchNo !== trip?.dispatchNo || trip.reservation?.status !== 'VERIFIED' || trip.externalReceipt || trip.externalDispatch || proof.picker || !report.steps?.some(row => row.name === '创建海外PDA仓库操作员71' && row.code === 'A0409' && row.message === '手机号已存在')) throw Error('Receipt-ready checkpoint requires verified coverage/reservations and an explicitly rejected operator creation with no receipt writes') return { resources, marker: proof.marker, receiptReady: true } } if (report?.success !== false || proof?.stage !== 'FULL_COVERAGE' || proof.status !== 'CHECKING' || full?.mode !== 'FULL' - || full.marker !== proof.marker || full.dispatchNo !== trip?.dispatchNo || full.proof?.status !== 'CHECKING' + || full.marker !== proof.marker || full.dispatchNo === trip?.dispatchNo || !resources.fleetTrips.some(t => t.dispatchNo === full.dispatchNo && t.tripNo === proof.fullTripNo) + || !['CHECKING','UNPROVEN'].includes(full.proof?.status) || proof.fullExecution?.departureRequest || !full.proof.requests?.length - || resources.fleetFeeCoverageCases.length !== 1 || !proof.originalDispatch || proof.originalDispatch.dispatchNo !== full.dispatchNo) + || full.proof.requests.some(row => row.status !== 'RESPONDED' || !row.requestId || !row.traceId) + || resources.fleetFeeCoverageCases.length !== 2 || !proof.originalDispatch?.detail || proof.originalDispatch.detail.dispatchNo !== proof.dispatchNo) throw Error('Only a reviewed read-only FULL_COVERAGE failure can resume; mutated/unknown outcomes require case-specific reconciliation') const persisted = stage === 'FULL_COVERAGE_PERSISTED' if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !full.proof.confirmationRequest?.idempotencyKey - || !full.proof.requests.some(row => row.label === '正式保存本次原费用服务覆盖并转派送' && row.method === 'POST' && row.http === 200 && String(row.code) === '200')) + || !full.proof.confirmationRequest.groups?.length || full.proof.confirmationRequest.recordVersion == null + || !full.proof.requests.some(row => row.method === 'POST' && row.path === `/api/platform/v1/last-leg/dispatches/${full.dispatchNo}/transfer-delivery` + && row.http === 200 && String(row.code) === '200' && row.status === 'RESPONDED' + && JSON.stringify(row.body) === JSON.stringify(full.proof.confirmationRequest) + && row.result?.action === 'ARRANGE_DELIVERY' && row.result.dispatchNo === full.dispatchNo + && row.result.status === 'ACTIVE' && row.result.stage === 'AFTER_LOADING' + && row.result.result?.outbounds?.some(o => o.outboundNo === full.proof.outboundNo) + && JSON.stringify(row.result.result?.deliveries?.map(d => d.deliveryNo).sort()) === JSON.stringify([...full.proof.deliveryNos].sort()))) : (full.proof.requests.some(row => row.method !== 'GET') || full.proof.outboundNo)) throw Error('Mutated/unknown outcomes require the exact successful command and persisted document reconciliation') return { resources, marker: proof.marker, persisted } @@ -298,58 +359,36 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { export async function verifyFleetTerminalState(context) { const proof = context.createdResources.fleetAcceptance const trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) - const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') const evidence = proof.terminalReadback = { status: 'CHECKING', requests: [], deliveries: [], dispatches: [] } - function check(label, passed, response) { - context.assert(MODULE, label, response, Boolean(passed), '', OPTIONS) - if (!passed) throw Error(label) - } - async function get(path) { - const result = await context.request('GET', path) - evidence.requests.push({ path, http: result.http, code: result.body?.code, requestId: result.requestId, traceId: result.traceId }) - check('终态只读回查成功', success(result) && result.requestId && result.traceId, result) - return result.body.data - } - check('结案助手已完成货物与资源核对', trip.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') - const current = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`) - check('当前车次已完成且两项资源结束事实与原证明一致', current.trip?.status === 'COMPLETED' - && current.trip.completedAt === trip.closure.finalTrip.completedAt - && id(current.trip.recordVersion) === id(trip.closure.finalTrip.recordVersion) - && JSON.stringify(current.trip.vehicleRelease) === JSON.stringify(trip.closure.finalTrip.vehicleRelease) - && JSON.stringify(current.trip.driverRelease) === JSON.stringify(trip.closure.finalTrip.driverRelease) - && current.trip.vehicleRelease && current.trip.driverRelease) - evidence.trip = current.trip - const expectedDispatches = [partial.dispatchNo, trip.externalDispatch.dispatchNo].sort() - check('终态车次仍关联全部原执行单', JSON.stringify(current.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify(expectedDispatches)) - for (const number of expectedDispatches) { - const dispatch = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`) - check('原执行单已发车且拣货实发数量守恒', dispatch.dispatchNo === number && dispatch.status === 'DEPARTED' - && Number(dispatch.packageCount) === Number(dispatch.shippedPackageCount) - && Number(dispatch.pickedPackageCount) === Number(dispatch.shippedPackageCount)) - evidence.dispatches.push(dispatch) - } - check('整车最终箱数与原一次发车事实一致', evidence.dispatches.reduce((n, row) => n + Number(row.shippedPackageCount), 0) === Number(proof.departure.shippedPackages)) - const expectedDeliveries = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo].sort() - check('签收证明覆盖全部目的地派送', JSON.stringify(proof.receipts.map(row => row.deliveryNo).sort()) === JSON.stringify(expectedDeliveries)) - for (const number of expectedDeliveries) { - const delivery = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) - check('每张原派送保留完成及签收时间', delivery.order?.deliveryNo === number && delivery.order.status === 'DELIVERED' - && delivery.order.signedAt === proof.receipts.find(row => row.deliveryNo === number).signedAt) - evidence.deliveries.push(delivery.order) - } - for (const probe of trip.closure.probes) { - const detail = await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(probe.tripNo)}`) - check('资源占用测试计划均已正式取消', detail.trip?.status === 'CANCELLED') - } - const pool = await get(`/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(proof.customerId)}&direction=RECEIVABLE`) - const fees = pool.filter(row => id(row.sourceId) === id(proof.finance.source.sourceFeeId) && Number(row.sourceVersion) === Number(proof.finance.source.sourceVersion)) - check('原外接应收仍唯一且金额正确', fees.length === 1 && fees[0].amount === 30 && fees[0].currency === 'CNY' - && id(fees[0].customerId) === id(proof.customerId) && fees[0].sourceType === 'OVERSEAS_DELIVERY_FEE') - evidence.fees = fees; evidence.status = 'VERIFIED' - proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; proof.stage = 'FINISHED' - return proof + const check = (label,passed) => { context.assert(MODULE,label,null,Boolean(passed),'',OPTIONS);if(!passed)throw Error(label) } + async function get(path) { const r=await context.request('GET',path);evidence.requests.push({path,http:r.http,code:r.body?.code,requestId:r.requestId,traceId:r.traceId});check('终态只读回查成功',success(r)&&r.requestId&&r.traceId);return r.body.data } + check('原生结案助手已完成货物与资源核对', proof.protocol===NATIVE_PROTOCOL && trip?.dispatchNo===proof.dispatchNo && trip.closure?.status==='CARGO_COMPLETED_AND_RESOURCES_RELEASED') + const current=await get(`/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`) + check('当前车次已完成且两项资源结束事实与原证明一致',current.trip?.status==='COMPLETED' + && current.trip.completedAt===trip.closure.finalTrip.completedAt && id(current.trip.recordVersion)===id(trip.closure.finalTrip.recordVersion) + && JSON.stringify(current.trip.vehicleRelease)===JSON.stringify(trip.closure.finalTrip.vehicleRelease) + && JSON.stringify(current.trip.driverRelease)===JSON.stringify(trip.closure.finalTrip.driverRelease) + && current.trip.vehicleRelease&¤t.trip.driverRelease&¤t.dispatches?.length===1&¤t.dispatches[0].dispatchNo===proof.dispatchNo) + evidence.trip=current.trip + const detail=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`) + check('原生主单全部正常派送完成且数量守恒',detail.dispatchNo===proof.dispatchNo&&detail.status==='ACTIVE'&&detail.stage==='DELIVERED' + && Number(detail.counts?.packageCount)===Number(detail.counts?.shippedPackageCount)&&Number(detail.counts?.pickedPackageCount)===Number(detail.counts?.shippedPackageCount) + && Number(detail.counts?.shippedPackageCount)===Number(proof.departure.result.shippedPackages)) + evidence.dispatches.push(detail) + const refs=nativeFleetDeliveries(detail) + check('签收证明恰好覆盖主单每客户派送一次',JSON.stringify(proof.receipts.map(r=>`${r.customerId}:${r.deliveryNo}`).sort())===JSON.stringify(refs.map(r=>`${r.customerId}:${r.deliveryNo}`).sort())) + for(const ref of refs){const delivery=await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`) + check('每张原派送保留客户和真实签收时间',delivery.order?.deliveryNo===ref.deliveryNo&&id(delivery.order.customerId)===id(ref.customerId)&&delivery.order.status==='DELIVERED' + && delivery.order.signedAt===proof.receipts.find(r=>r.deliveryNo===ref.deliveryNo&&r.customerId===id(ref.customerId)).signedAt);evidence.deliveries.push(delivery.order)} + const probe=trip.closure.nextDispatch + check('原生空排车占用保护证明完整',trip.closure.subsequentTripClaimProtection==='VERIFIED_NATIVE_EMPTY_DISPATCH'&&probe?.dispatchNo&&probe.finalStatus==='CANCELLED') + const cancelled=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(probe.dispatchNo)}`) + check('本轮资源核对原生空排车已取消',cancelled.dispatchNo===probe.dispatchNo&&cancelled.status==='CANCELLED'&&Number(cancelled.counts?.packageCount)===0&&Number(cancelled.counts?.pickedPackageCount)===0) + const customerId=id(proof.finance.customerId),pool=await get(`/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`) + const fees=pool.filter(row=>id(row.sourceId)===id(proof.finance.source.sourceFeeId)&&id(row.sourceVersion)===id(proof.finance.source.sourceVersion)) + check('原外接应收仍唯一且金额正确',fees.length===1&&Number(fees[0].amount)===30&&fees[0].currency==='CNY'&&id(fees[0].customerId)===customerId&&fees[0].sourceType==='OVERSEAS_DELIVERY_FEE') + evidence.fees=fees;evidence.status='VERIFIED';proof.status='HTTP_CHAIN_VERIFIED_WITH_ACK_GAP';proof.stage='FINISHED';return proof } - /** All business checks happen before creating replacement test sessions or issuing a business command. */ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = false, createdVehicle, returnSourceReady = false } = {}) { const proof = context.createdResources.fleetAcceptance @@ -369,7 +408,7 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f } const current = await get(base) check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(expectedTrip.recordVersion) - && current.trip.departedAt === proof.departure.departedAt && id(current.trip.warehouseId) === id(proof.warehouseId) + && sameInstant(current.trip.departedAt, proof.departure.result.departedAt) && id(current.trip.warehouseId) === id(proof.warehouseId) && id(current.trip.vehicleId) === id(expectedTrip.vehicleId) && id(current.trip.driverId) === id(expectedTrip.driverId) && !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed') const history = await get(`${base}/history?page=1&size=100`) @@ -405,34 +444,37 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f const expected = trip.handover.requests.filter(row => row.method === 'GET' && row.path.startsWith(`${base}/handovers?`)).at(-1)?.result check(expected && Number(handovers.total) === 1 && same(handovers.records, expected.records), 'original verified handover changed') } else check(handovers.records?.length === 0 && Number(handovers.total) === 0, 'handover already exists') - check(same(current.dispatches.map(row => row.dispatchNo).sort(), [...proof.dispatchNos].sort()) - && current.dispatches.every(row => id(row.customerId) === id(proof.customerId)), 'trip dispatch binding changed') - for (const number of proof.dispatchNos) { - const detail = await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(number)}`) - const cargo = load.cargo.filter(row => row.dispatchNo === number) - const facts = rows => rows.map(row => [id(row.id ?? row.dispatchItemId), id(row.warehousePalletId), Number(row.packageCount ?? row.packages)]) - .sort((a, b) => a[0].localeCompare(b[0])) - check(detail.dispatchNo === number && detail.status === 'DEPARTED' && id(detail.overseasWarehouseId) === id(proof.warehouseId) - && Number(detail.packageCount) === Number(detail.shippedPackageCount) && Number(detail.pickedPackageCount) === Number(detail.shippedPackageCount) - && same(facts(detail.items), facts(cargo)), 'dispatch shipped cargo changed') - evidence.dispatchDetails.push(detail) + check(proof.protocol === NATIVE_PROTOCOL && nativeDeparture(proof.departure,proof.dispatchNo) + && trip.dispatchNo === proof.dispatchNo && current.dispatches?.length === 1 && current.dispatches[0].dispatchNo === proof.dispatchNo + && JSON.stringify(proof.dispatchNos) === JSON.stringify([proof.dispatchNo]), 'trip dispatch binding changed') + const detail=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`) + const items=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}/items`) + const cargo=load.cargo + const facts=rows=>{ + const totals=new Map() + for(const row of rows){const identity=JSON.stringify([id(row.dispatchItemId),id(row.warehousePalletId),id(row.customerId)]);totals.set(identity,(totals.get(identity)||0)+Number(row.scheduledPackages??row.packages))} + return [...totals].sort(([a],[b])=>a.localeCompare(b)) } - const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') - const deliveryNos = [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo] - check(new Set(deliveryNos).size === deliveryNos.length, 'duplicate delivery identities') - for (const number of deliveryNos) { - const detail = await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(number)}?customerId=${encodeURIComponent(proof.customerId)}`) - const order = detail.order - const started = returnSourceReady ? trip.returnRedispatch.requests.find(row => row.method === 'PUT' && row.result?.deliveryNo === number) : null - check(order?.deliveryNo === number && id(order.customerId) === id(proof.customerId) && proof.dispatchNos.includes(order.dispatchNo) - && order.status === (returnSourceReady ? 'DELIVERING' : 'WAIT_DELIVERY') - && id(order.version) === (returnSourceReady ? String(Number(started?.body.expectedVersion) + 1) : '1') - && (!returnSourceReady || order.vehiclePlate === expectedTrip.vehiclePlate && order.driverName === expectedTrip.driverName && order.driverPhone === expectedTrip.driverPhone) - && !order.signedAt && !Number(order.signedPackages || 0) - && !order.completedAt && order.documentSummary?.categories?.every(row => Number(row.fileCount) === 0), 'delivery has started, changed or been signed') + check(detail.dispatchNo===proof.dispatchNo&&detail.status==='ACTIVE'&&detail.stage==='DELIVERING'&&items.dispatchNo===proof.dispatchNo + && id(items.recordVersion)===id(detail.recordVersion)&&Number(detail.counts?.packageCount)===Number(detail.counts?.shippedPackageCount) + && Number(detail.counts?.pickedPackageCount)===Number(detail.counts?.shippedPackageCount)&&cargo.every(row=>row.dispatchNo===proof.dispatchNo) + && same(facts(items.items),facts(cargo))&&proof.departureSnapshot?.detail?.dispatchNo===proof.dispatchNo + && same(items,proof.departureSnapshot.items),'dispatch shipped cargo changed') + evidence.dispatchDetails.push({detail,items}) + const refs=nativeFleetDeliveries(detail) + check(new Set(refs.map(d=>d.deliveryNo)).size===refs.length + && deliveryFacts(refs)===deliveryFacts(nativeFleetDeliveries(proof.departureSnapshot.detail)),'duplicate or changed delivery identities') + for(const ref of refs){ + const response=await get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`),order=response.order + const expected=(returnSourceReady?trip.returnRedispatch.originalDeliveries:proof.departureDeliveries)?.find(d=>d.deliveryNo===ref.deliveryNo&&id(d.customerId)===id(ref.customerId)) + check(expected&&order?.deliveryNo===ref.deliveryNo&&id(order.customerId)===id(ref.customerId)&&order.dispatchNo===proof.dispatchNo + && order.outboundNo===ref.outboundNo&&order.status==='DELIVERING'&&id(order.version)===id(expected.version) + && order.outboundNo===expected.outboundNo&&Number(order.packageCount)===Number(ref.packageCount)&&Number(order.packageCount)===Number(expected.packageCount) + && (!returnSourceReady||order.vehiclePlate===expectedTrip.vehiclePlate&&order.driverName===expectedTrip.driverName&&order.driverPhone===expectedTrip.driverPhone) + && !order.signedAt&&!Number(order.signedPackages||0)&&!order.completedAt&&order.documentSummary?.categories?.every(r=>Number(r.fileCount)===0),'delivery has started, changed or been signed') evidence.deliveries.push(order) } - check(evidence.deliveries.reduce((n, row) => n + Number(row.packageCount), 0) === Number(proof.departure.shippedPackages), 'delivery quantity mismatch') + check(evidence.deliveries.reduce((n,row)=>n+Number(row.packageCount),0)===Number(proof.departure.result.shippedPackages),'delivery quantity mismatch') if (createdVehicle) { const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${encodeURIComponent(createdVehicle.snapshot.id)}`) const canonical = value => value && typeof value === 'object' && !Array.isArray(value) @@ -524,8 +566,8 @@ export async function continueReviewedReturnSources(context, { verify = verifyRe run.picker = { membershipId: picker.membershipId, username: picker.username }; await context.checkpoint() const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') const result = await redispatch(context, MODULE, trip, { customerId: run.customerId, warehouseId: run.warehouseId, - pickerToken: picker.token, dispatchNos: run.dispatchNos, deliveryNos: [...partial.proof.deliveryNos, trip.externalDispatch.deliveryNo], sourceSelectionRecovery: true }) - run.receipts = result.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) + pickerToken: picker.token, dispatchNos: run.dispatchNos, deliveryNos: nativeFleetDeliveries(verified.dispatchDetails[0].detail).map(ref => ref.deliveryNo), sourceSelectionRecovery: true }) + run.receipts = result.originalSignatures.map(row => ({ customerId: id(row.customerId || run.customerId), deliveryNo: row.deliveryNo, signedAt: row.signedAt })) context.context.saveFleetReturnRedispatchFixture(result) run.closure = await closeOriginal(context, MODULE, trip) if (run.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('Recovered original trip did not close') @@ -538,8 +580,8 @@ export async function continueReviewedReturnSources(context, { verify = verifyRe export async function runFleetHandoverStage(context, trip, pickerToken, dispatchDetails, { verify = verifyFleetHandover, onReady, reviewedVehicle } = {}) { const state = await verify(context, MODULE, trip, pickerToken, dispatchDetails, { reviewedVehicle }) const run = context.createdResources.fleetAcceptance - if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.departure?.tripNo !== trip.tripNo - || run.departure.status !== 'IN_TRANSIT' || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser + if (state?.status !== 'VERIFIED' || state.marker !== context.marker || run?.tripNo !== trip.tripNo + || !nativeDeparture(run.departure, trip.dispatchNo) || !state.originalDeparture?.snapshot?.confirmedLoad || !state.browser || trip.costAcceptance?.status !== 'VERIFIED' || trip.costAcceptance.marker !== context.marker) throw Error('Only the current verified DEPART and handover can publish a shared fixture') const fixture = { marker: context.marker, runId: context.context?.fleetRunId, tripNo: trip.tripNo, warehouseId: trip.warehouseId, dispatchNos: state.dispatchNos, @@ -556,21 +598,26 @@ export async function runFleetHandoverStage(context, trip, pickerToken, dispatch export function fleetCostShares(trip, dispatchDetails) { const external = trip.externalReceipt, externalDispatch = trip.externalDispatch - if (external?.status !== 'VERIFIED' || !external.bookingNo || !external.warehousePalletId || !externalDispatch?.dispatchNo) + if (external?.status !== 'VERIFIED' || !external.bookingNo || !external.warehousePalletId + || !trip.dispatchNo || externalDispatch?.dispatchNo !== trip.dispatchNo) throw Error('Cost expectations require this run\'s verified external receipt') - const shares = dispatchDetails.map(row => { - const pallets = [...new Set((row.items || []).map(item => id(item.warehousePalletId)))] - if (!row.dispatchNo || !pallets.length || pallets.some(value => !value)) throw Error('Cost expectations require actual dispatch pallets') - const isExternal = row.dispatchNo === externalDispatch.dispatchNo - if (isExternal && (pallets.length !== 1 || pallets[0] !== id(external.warehousePalletId))) throw Error('External cost source does not match actual receipt') - // The fixture's explicit measurement rule is one space per physical pallet; never derive this from cost totals. - return { businessNo: isExternal ? external.bookingNo : row.dispatchNo, sourceType: isExternal ? 'EXTERNAL' : 'OWN', - dispatchNo: row.dispatchNo, warehousePalletIds: pallets, palletSpaces: pallets.length } - }) - const pallets = shares.flatMap(row => row.warehousePalletIds) - if (shares.filter(row => row.sourceType === 'EXTERNAL').length !== 1 || !shares.some(row => row.sourceType === 'OWN') - || new Set(pallets).size !== pallets.length) throw Error('Cost fixture requires distinct own and external physical pallets') - return shares + const bundle = dispatchDetails?.length === 1 ? dispatchDetails[0] : null + const detail = bundle?.detail, items = bundle?.items + if (detail?.dispatchNo !== trip.dispatchNo || items?.dispatchNo !== trip.dispatchNo + || id(detail.recordVersion) !== id(items.recordVersion) || !items.items?.length) + throw Error('Cost expectations require the same native dispatch Detail and Items version') + const pallets = [...new Set(items.items.map(item => id(item.warehousePalletId)))].sort() + const measured = trip.confirmedLoad?.pallets + if (pallets.some(value => !value) || !trip.confirmedLoad?.manifestHash || !Array.isArray(measured) + || measured.length !== pallets.length || new Set(measured.map(row => id(row.warehousePalletId))).size !== measured.length + || measured.some(row => !pallets.includes(id(row.warehousePalletId)) || !Number.isSafeInteger(row.palletSpaces) || row.palletSpaces < 1)) + throw Error('Cost expectations require exact actual loading measurements for every distinct pallet') + const externalId = id(external.warehousePalletId), own = pallets.filter(value => value !== externalId) + if (!pallets.includes(externalId) || !own.length) throw Error('External receipt and own cargo require distinct actual pallets') + const share = (sourceType, businessNo, ids) => ({ businessNo, sourceType, dispatchNo: trip.dispatchNo, + warehousePalletIds: ids, palletSpaces: measured.filter(row => ids.includes(id(row.warehousePalletId))) + .reduce((sum, row) => sum + row.palletSpaces, 0) }) + return [share('OWN', trip.dispatchNo, own), share('EXTERNAL', external.bookingNo, [externalId])] } export function fleetCostActorJournalData(value) { @@ -681,204 +728,259 @@ export async function findFleetActorPhone(context, actorSuffix) { throw Error('No available phone in the fresh actor scope; do not replay user creation') } +async function nativeRequest(context, method, path, body, token) { + const result = await context.request(method, path, body, token) + if (!success(result) || !result.requestId || !result.traceId) throw Error(`Native fleet request failed: ${method} ${path}`) + return result.body.data +} +export async function readNativeFleetBundle(context, dispatchNo) { + const base = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(dispatchNo)}` + const detail = await nativeRequest(context, 'GET', base) + const items = await nativeRequest(context, 'GET', `${base}/items`) + if (detail.dispatchNo !== dispatchNo || items.dispatchNo !== dispatchNo || id(dispatchVersion(detail.recordVersion)) !== id(dispatchVersion(items.recordVersion))) throw Error('Native Detail/Items version mismatch') + return { detail, items } +} + +// 只读取本轮生产者和运营已确认实测,不从计划托位、计费重量或常数生成实际量。 +export async function currentFleetMeasurements(context, trip) { + const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}/loading` + const preview = await nativeRequest(context, 'GET', path) + if (preview.dispatchNo !== trip.dispatchNo || preview.status !== 'ACTIVE' || !preview.pallets?.length) throw Error('Current native physical loading preview missing') + const producer = context.createdResources.phase4OverseasDispatch?.loadingMeasurements + if (producer && (producer.marker !== context.marker || producer.environment !== 'TEST' || producer.dispatchNo !== trip.dispatchNo || !Array.isArray(producer.pallets))) + throw Error('Physical measurements must be the exact current-run producer receipt for this dispatch') + const actual = (producer?.pallets || []).map(row => structuredClone(row)) + const receipt = trip.externalReceipt?.finalBooking?.receipt + for (const measured of receipt?.pallets || []) { + const source = receipt.source?.pallets?.find(p => id(p.warehousePalletId) === id(measured.warehousePalletId)) + if (!source || !Number.isFinite(Date.parse(receipt.confirmedAt)) || !receipt.operatorId || !receipt.source?.incomingNo) throw Error('External actual measurement ownership missing') + actual.push({ warehousePalletId: id(measured.warehousePalletId), palletSpaces: measured.palletSpaces, + weightKg: measured.weightKg ?? source.weightKg, volumeCbm: measured.volumeCbm ?? source.volumeCbm, + deliveryIndex: trip.stops.findIndex(s => s.destinationKey === trip.externalReceipt.finalBooking.cargo.destinationKey), + evidence: ['palletSpaces','weightKg','volumeCbm'].map(field => ({ field, value: field === 'palletSpaces' ? measured[field] : measured[field] ?? source[field], + actual: true, sourceType: 'FLEET_RECEIPT_CONFIRMATION', sourceBusinessNo: receipt.source.incomingNo, observedAt: receipt.confirmedAt })) }) + } + if (new Set(actual.map(p => id(p.warehousePalletId))).size !== actual.length) throw Error('Duplicate current-run physical measurement receipts') + for (const pallet of preview.pallets) { + const supplied = actual.find(p => id(p.warehousePalletId) === id(pallet.warehousePalletId)) + for (const field of ['palletSpaces','weightKg','volumeCbm']) { + const source = pallet[field] == null ? supplied : pallet, value = source?.[field] + if (!(Number(value) > 0) || !source?.evidence?.some(e => e.field === field && e.actual === true && e.sourceBusinessNo && e.sourceType + && Number.isFinite(Date.parse(e.observedAt)) && Number(e.value) === Number(value))) + throw Error(`Missing current-run actual ${field} for ${pallet.palletNo || 'unassociated pallet'}; no planning constants or external receipt injection`) + } + const index = pallet.deliveryIndex ?? supplied?.deliveryIndex + if (!Number.isSafeInteger(index) || index < 1 || !trip.stops?.[index]?.destinationKey) + throw Error('Current-run physical pallet has no verified unloading stop') + } + if (actual.some(p => !preview.pallets.some(row => id(row.warehousePalletId) === id(p.warehousePalletId)) + && !receipt?.source?.pallets?.some(row => id(row.warehousePalletId) === id(p.warehousePalletId)))) throw Error('Physical receipts contain a pallet outside this dispatch and its confirmed incoming') + return { environment: 'TEST', marker: context.marker, dispatchNo: trip.dispatchNo, pallets: actual } +} + +export async function departNativeFleet(context, trip, pickerToken, run) { + if (!pickerToken || !run || run.dispatchNo !== trip.dispatchNo || run.tripNo !== trip.tripNo || run.departureRequest) + throw Error('Native departure requires owned main and a new original command') + const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}` + const before = await readNativeFleetBundle(context, trip.dispatchNo) + const refs = nativeFleetDeliveries(before.detail) + const task = await nativeRequest(context, 'GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}`, undefined, pickerToken) + if (before.detail.status !== 'ACTIVE' || before.detail.stage !== 'AFTER_LOADING' || task.dispatchNo !== trip.dispatchNo || task.status !== 'ACTIVE' || task.stage !== 'AFTER_LOADING' + || id(task.warehouseId) !== id(trip.warehouseId) || id(task.recordVersion) !== id(before.detail.recordVersion) || task.actions?.DEPART?.allowed !== true + || !before.detail.pickingReconciled || !task.pickingReconciled || Number(before.detail.counts?.packageCount) !== Number(task.counts?.packageCount) + || Number(task.counts?.packageCount) !== Number(task.counts?.pickedPackageCount) || Number(task.counts?.shippedPackageCount) !== 0 + || before.items.items.some(row => Number(row.pickedPackages) !== Number(row.scheduledPackages)) + || before.items.items.reduce((n,row) => n + Number(row.scheduledPackages), 0) !== Number(task.counts?.packageCount) + || refs.reduce((n,d) => n + Number(d.packageCount), 0) !== Number(task.counts.packageCount)) throw Error('Native main is not fully loaded and arranged for both ends') + run.departureRequest = { recordVersion: task.recordVersion, idempotencyKey: key(), remark: 'TEST同一原生主单实际发车,不重复扣库存' } + await context.checkpoint?.() + const departed = await nativeRequest(context, 'POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}/depart`, run.departureRequest, pickerToken) + if (!nativeDeparture(departed, trip.dispatchNo) || BigInt(dispatchVersion(departed.recordVersion)) <= BigInt(dispatchVersion(task.recordVersion)) + || Number(departed.result.shippedPackages) !== Number(task.counts.packageCount) + || !Array.isArray(departed.result.deliveries) || deliveryFacts(departed.result.deliveries) !== deliveryFacts(refs)) + throw Error('Native departure did not preserve all customer shipment identities and quantities') + run.departure = departed + const replayed = await nativeRequest(context, 'POST', `${path}/depart`, run.departureRequest) + if (!nativeDeparture(replayed, trip.dispatchNo) || replayed.replayed !== true || id(replayed.recordVersion) !== id(departed.recordVersion) || JSON.stringify(replayed.result) !== JSON.stringify(departed.result)) + throw Error('Native cross-end departure replay changed the original outcome') + run.departureReplay = replayed + const after = await readNativeFleetBundle(context, trip.dispatchNo) + if (after.detail.status !== 'ACTIVE' || after.detail.stage !== 'DELIVERING' || id(after.detail.recordVersion) !== id(departed.recordVersion) + || Number(after.detail.counts.shippedPackageCount) !== Number(task.counts.packageCount) || Number(after.detail.counts.packageCount) !== Number(task.counts.packageCount) + || Number(after.detail.counts.pickedPackageCount) !== Number(after.detail.counts.shippedPackageCount) + || deliveryFacts(nativeFleetDeliveries(after.detail)) !== deliveryFacts(refs)) throw Error('Native departure readback did not conserve physical quantities') + run.departureSnapshot = after; run.dispatchNos = [trip.dispatchNo] + run.departureDeliveries = [] + for (const ref of nativeFleetDeliveries(after.detail)) { + const value = await nativeRequest(context, 'GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`) + if (value.order?.deliveryNo !== ref.deliveryNo || id(value.order.customerId) !== id(ref.customerId) || value.order.dispatchNo !== trip.dispatchNo + || value.order.status !== 'DELIVERING' || value.order.version == null || Number(value.order.packageCount) !== Number(ref.packageCount)) throw Error('Native departure delivery versions or owners changed') + run.departureDeliveries.push(value.order) + } + await context.checkpoint?.(); return { departed, dispatchDetails: [after] } +} + export async function runFleetScenario(context, { wait = ms => new Promise(resolve => setTimeout(resolve, ms)), onHandoverReady, costActors, resumeFull = false, resumePersisted = false, resumeReceiptReady = false, resumePalletized = false, resumeReceiptConfirmed = false, terminalReadOnly = false } = {}) { if (terminalReadOnly) return verifyFleetTerminalState(context) if (costActors?.marker !== context.marker || !costActors.listOnlyToken || !costActors.deniedToken || costActors.listOnlyToken === costActors.deniedToken || [costActors.listOnlyToken, costActors.deniedToken].includes(context.token)) throw Error('Prepare two independent current-run platform cost permission sessions before starting the fleet scenario') - const proof = resumeFull || resumeReceiptReady ? context.createdResources.fleetAcceptance - : context.createdResources.fleetAcceptance = { marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] } + const resumed = resumeFull || resumeReceiptReady + const proof = resumed ? context.createdResources.fleetAcceptance : context.createdResources.fleetAcceptance = { + protocol: NATIVE_PROTOCOL, marker: context.marker, status: 'CHECKING', stage: 'SOURCES', checkpoints: [] } + if (proof.protocol !== NATIVE_PROTOCOL) throw Error('Legacy multi-dispatch reports cannot enter the native main chain') function stage(value) { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log(`FLEET_STAGE=${value}`) } - function check(label, value, response) { context.assert(MODULE, label, response, Boolean(value), '', OPTIONS); if (!value) throw Error(label) } - async function request(method, path, body, label, token) { - const response = await context.request(method, path, body, token) - check(label, success(response), response); return response.body.data - } const source = context.createdResources.phase4OverseasDispatch, receiving = context.createdResources.phase4OverseasPda - check('专项只承接本轮实际已收货及预排资源', source?.marker === context.marker && source.dispatchNo - && receiving?.marker === context.marker && receiving.finalStatus === 'COMPLETED' && receiving.customerId && receiving.overseasWarehouseId) + if (source?.marker !== context.marker || source.status !== 'VERIFIED' || !source.dispatchNo || receiving?.marker !== context.marker + || receiving.finalStatus !== 'COMPLETED' || !receiving.customerId || !receiving.overseasWarehouseId) throw Error('Current-run native dispatch and real received warehouse evidence required') const customerId = id(receiving.customerId), warehouseId = id(receiving.overseasWarehouseId) - proof.customerId = customerId; proof.warehouseId = warehouseId - const dispatchBase = '/api/platform/v1/last-leg/dispatches' - let trip, partial - if (resumeReceiptReady) { - trip = context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) - partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') - const current = await request('GET', `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}`, undefined, '恢复实收前核对原车次分配与客户执行单') - const dispatch = await request('GET', `${dispatchBase}/${encodeURIComponent(partial.dispatchNo)}`, undefined, '恢复实收前核对原排车尚未拣货或发车') - check('仍是原已核对车辆司机与未执行客户排车', current.trip?.status === 'PLANNED' - && id(current.trip.vehicleId) === id(trip.vehicleId) && id(current.trip.driverId) === id(trip.driverId) - && current.dispatches?.length === 1 && current.dispatches[0].dispatchNo === partial.dispatchNo - && id(current.dispatches[0].customerId) === customerId && dispatch.dispatchNo === partial.dispatchNo - && Number(dispatch.pickedPackageCount) === 0 && Number(dispatch.shippedPackageCount) === 0) + Object.assign(proof, { customerId, warehouseId, dispatchNo: source.dispatchNo, dispatchNos: [source.dispatchNo] }) + const journal = dispatchIo(context, MODULE, proof, 'FLEET-NATIVE') + let full, partial, trip + const template = { plannedDeliveryAt: new Date(Date.now()+21600000).toISOString(), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', + loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本轮实际装载', appointmentAccount: 'SELF', remark: `${context.marker} TEST原费用覆盖`, + fees: [{ feeType: 'DELIVERY_FEE', feeName: '未覆盖原入仓派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10 }] } + if (!resumed) { + const original = await readDispatch(journal, source.dispatchNo) + const candidate = selectNativeFleetFullSource(context, original, await candidatePage(journal, warehouseId)) + proof.originalDispatch = { detail: original.data, items: original.items, route: original.route } + proof.fullCreateRequest = createBody(context.marker, 'FLEET-FULL-INDEPENDENT', [candidate]) + const created = await journal.command('POST', '/api/platform/v1/last-leg/dispatches', proof.fullCreateRequest, '释放的本轮真实第三托创建独立FULL原生排车') + await replayDispatch(journal, '/api/platform/v1/last-leg/dispatches', proof.fullCreateRequest, created, '独立FULL创建原键恢复') + if (!created.dispatchNo || created.dispatchNo === source.dispatchNo) throw Error('FULL must be a distinct newly created native dispatch') + const fullTrip = await prepareFleetTrip(context, MODULE, created.dispatchNo, warehouseId) + trip = await prepareFleetTrip(context, MODULE, source.dispatchNo, warehouseId) + Object.assign(proof, { tripNo: trip.tripNo, fullTripNo: fullTrip.tripNo }) + full = { marker: context.marker, mode: 'FULL', dispatchNo: created.dispatchNo, customerId, transferBody: structuredClone(template) } + partial = { marker: context.marker, mode: 'PARTIAL', dispatchNo: source.dispatchNo, customerId, transferBody: structuredClone(template) } + context.createdResources.fleetFeeCoverageCases = [full, partial] + proof.fullExecution = { protocol: NATIVE_PROTOCOL, marker: context.marker, dispatchNo: full.dispatchNo, tripNo: fullTrip.tripNo } } else { - const original = await request('GET', `${dispatchBase}/${encodeURIComponent(source.dispatchNo)}`, undefined, '读取本轮原始预排实物') - if (resumeFull) check('原样本恢复前排车货物未变且未开始作业', JSON.stringify(original.items) === JSON.stringify(proof.originalDispatch.items) - && Number(original.pickedPackageCount) === 0 && Number(original.shippedPackageCount) === 0) - proof.originalDispatch = original - trip = resumeFull ? context.createdResources.fleetTrips.find(row => row.tripNo === proof.tripNo) - : await prepareFleetTrip(context, MODULE, source.dispatchNo, warehouseId) - proof.tripNo = trip.tripNo - const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(trip.tripNo)}` - function transferBody(vehicle) { return { idempotencyKey: key(), plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString().slice(0, 19), - deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '30托', - vehiclePlate: vehicle.vehiclePlate, driverName: vehicle.driverName, driverPhone: vehicle.driverPhone, appointmentAccount: 'SELF', - remark: `${context.marker} 原费用覆盖专用验证`, fees: [{ feeType: 'DELIVERY_FEE', feeName: '未覆盖货物派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 10 }] } } - let detail = await request('GET', tripPath, undefined, '读取当前派车事实供转派送使用') - check('当前车次资源及原客户排车关联仍一致', id(detail.trip.vehicleId) === id(trip.vehicleId) - && id(detail.trip.driverId) === id(trip.driverId) && detail.dispatches.some(row => row.dispatchNo === source.dispatchNo && id(row.customerId) === customerId)) - if (!resumeFull) context.createdResources.fleetFeeCoverageCases = [] - const full = resumeFull ? context.createdResources.fleetFeeCoverageCases[0] - : { marker: context.marker, mode: 'FULL', dispatchNo: source.dispatchNo, customerId, transferBody: transferBody(detail.trip) } - if (resumeFull && !resumePersisted) { - full.previousReadOnlyAttempts ||= []; full.previousReadOnlyAttempts.push(full.proof); delete full.proof - } else if (!resumeFull) context.createdResources.fleetFeeCoverageCases.push(full) - stage('FULL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, full, { reconcilePersisted: resumePersisted }) - stage('CANCEL_FULL_AND_REARRANGE') - detail = await request('GET', tripPath, undefined, '读取取消未执行全覆盖用例前车次版本') - await request('POST', `${tripPath}/dispatches/unbind`, { recordVersion: detail.trip.recordVersion, dispatchNo: full.dispatchNo, - reason: '全覆盖用例验证完毕,正式取消未执行发运并以新排车验证部分覆盖' }, '先解除未执行全覆盖排车关联') - await request('POST', `/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(full.proof.outboundNo)}/cancel`, - { reason: '本轮全覆盖验证完毕,正式取消未执行出库后重新安排' }, '取消未执行全覆盖出库') - await request('DELETE', `${dispatchBase}/${encodeURIComponent(full.dispatchNo)}`, undefined, '正式取消原排车释放其托盘锁') - const draftToken = key(), planPalletIds = original.items.map(row => id(row.planPalletId)) - check('原排车保留可追溯计划托盘', planPalletIds.length > 0 && planPalletIds.every(value => /^[1-9]\d*$/.test(value))) - proof.rearrange = { originalDispatchNo: full.dispatchNo, draftToken, planPalletIds } - const container = await request('POST', `${dispatchBase}/drafts/containers`, { draftToken, capacity: 30 }, '建立同批当前库存的新排车草稿') - for (const planPalletId of planPalletIds) await request('POST', `${dispatchBase}/draft-locks/lock`, { planPalletId, draftToken, draftContainerId: id(container.id) }, '锁定已正式释放的本轮托盘') - const arranged = await request('POST', `${dispatchBase}/pre-arrange`, { planPalletIds, draftToken }, '以新排车重新安排本轮同批货物') - check('取消重排产生新排车且货量不变', arranged.dispatchNo && arranged.dispatchNo !== full.dispatchNo - && Number(arranged.packageCount) === Number(original.packageCount)) - proof.rearrange.dispatchNo = arranged.dispatchNo - detail = await request('GET', tripPath, undefined, '读取原车次当前可关联版本') - detail = await request('POST', `${tripPath}/dispatches/bind`, { recordVersion: detail.trip.recordVersion, dispatchNo: arranged.dispatchNo, - reason: '本轮取消后新排车重新关联原计划车次' }, '将新排车正式关联原车次') - trip.replacedDispatchNo = trip.dispatchNo; trip.dispatchNo = arranged.dispatchNo - partial = { marker: context.marker, mode: 'PARTIAL', dispatchNo: arranged.dispatchNo, customerId, transferBody: transferBody(detail.trip) } - context.createdResources.fleetFeeCoverageCases.push(partial) - stage('PARTIAL_COVERAGE'); await verifyFleetFeeCoverage(context, MODULE, partial) - stage('RESERVATIONS'); await verifyFleetBookingReservation(context, MODULE, trip, controlledPalletMeasurements) + full = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'FULL') + partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') + trip = context.createdResources.fleetTrips.find(row => row.dispatchNo === proof.dispatchNo) + if (!full || !partial || partial.dispatchNo !== trip?.dispatchNo || full.dispatchNo === partial.dispatchNo) throw Error('Reviewed native FULL/PARTIAL original identities missing') + if (resumeFull && !resumePersisted && JSON.stringify(await readNativeFleetBundle(context, full.dispatchNo)) !== JSON.stringify(proof.fullBeforeAttempt)) + throw Error('Read-only FULL checkpoint changed physical state; review the original commands before any new session or write') } const access = await resolvePickerAccess(context) - let picker = null + let picker try { - if (resumePalletized || resumeReceiptConfirmed) { - const suffix = proof.pickerMarker.replace(/[^A-Za-z0-9]/g, '').slice(-16).toUpperCase() - const username = `pda_overseas_${suffix.toLowerCase()}_71` - check('仅复用本轮创建的临时PDA身份', proof.picker.username === username && access?.role?.id) - const users = await request('GET', `/api/platform/v1/iam/users?${new URLSearchParams({ page: '1', size: '200', keyword: username })}`, undefined, '核对原临时PDA账号绑定及角色') - const user = users?.records?.find(row => id(row.membershipId) === id(proof.picker.membershipId) && row.username === username) - check('原PDA仍仅有本仓作业员权限且不是受保护账号', user && id(user.tenantId) === '0' && !user.protectedAccount - && id(user.overseasWarehouseId) === warehouseId && user.roleIds?.length === 1 && id(user.roleIds[0]) === id(access.role.id)) - picker = { membershipId: user.membershipId, username, token: null } - const active = await request('PUT', `/api/platform/v1/iam/users/${encodeURIComponent(id(user.membershipId))}/status`, { status: 'ACTIVE' }, '为核对原PDA完成请求恢复本轮临时作业员') - check('原临时PDA已恢复启用', active?.status === 'ACTIVE') - const loggedIn = await request('POST', '/api/auth/login', { loginId: username, password: `Pda${suffix}A719` }, '以原生成凭据登录本轮临时PDA', '') - picker.token = loggedIn?.accessToken - } else { - const pickerContext = Object.create(context) - proof.pickerMarker ||= `${context.marker}-FPDA-${randomUUID().slice(0, 8)}` - pickerContext.marker = proof.pickerMarker - picker = access ? await createPicker(pickerContext, 71, warehouseId, access) : null + if (!resumeReceiptReady) { + const fullTrip = context.createdResources.fleetTrips.find(t => t.dispatchNo === full.dispatchNo) + stage('FULL_COVERAGE') + if (!resumed) proof.fullBeforeAttempt = await readNativeFleetBundle(context, full.dispatchNo) + let fullPicker + try { + const child = Object.create(context) + proof.fullExecution.pickerMarker = `${context.marker}-FULL-PDA-${randomUUID().slice(0,8)}` + child.marker = proof.fullExecution.pickerMarker + fullPicker = access ? await createPicker(child, 161, warehouseId, access) : null + if (!fullPicker?.token || !fullPicker.membershipId) throw Error('Independent FULL requires its own bound-warehouse PDA') + proof.fullExecution.picker = { membershipId: fullPicker.membershipId, username: fullPicker.username } + await context.checkpoint?.() + if (!resumePersisted && full.proof) { full.previousReadOnlyAttempts ??= []; full.previousReadOnlyAttempts.push(full.proof); delete full.proof } + await verifyFleetFeeCoverage(context, MODULE, full, { reconcilePersisted: resumePersisted, pickerToken: fullPicker.token, + actualMeasurements: resumePersisted ? undefined : await currentFleetMeasurements(context, fullTrip) }) + const fullDeparted = await departNativeFleet(context, fullTrip, fullPicker.token, proof.fullExecution) + proof.fullExecution.receipts = [] + for (const [index, ref] of nativeFleetDeliveries(fullDeparted.dispatchDetails[0].detail).entries()) { + const detail = await nativeRequest(context, 'GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`) + const signed = await saveReceiptAndComplete(context, detail.order, id(ref.customerId), 161+index) + if (signed?.order?.status !== 'DELIVERED' || !signed.order.signedAt) throw Error('FULL source requires actual customer POD completion') + proof.fullExecution.receipts.push({customerId:id(ref.customerId),deliveryNo:ref.deliveryNo,signedAt:signed.order.signedAt}) + } + proof.fullExecution.closure = await finishFleetTrip(context, MODULE, fullTrip) + if (proof.fullExecution.closure.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') throw Error('FULL independent resources did not finish') + } finally { await disablePicker(context, fullPicker, 161) } + stage('RESERVATIONS'); await verifyFleetBookingReservation(context, MODULE, trip) } - check('专项使用本轮绑定起运仓的PDA账号', picker?.token && picker.membershipId) - proof.picker = { membershipId: picker.membershipId, username: picker.username } - stage('EXTERNAL_RECEIPT'); await verifyFleetExternalReceipt(context, MODULE, trip, picker.token, { reconcilePalletized: resumePalletized, reconcileConfirmed: resumeReceiptConfirmed }) - stage('EXTERNAL_DISPATCH'); await verifyFleetExternalDispatch(context, MODULE, trip) + if (full.proof?.status !== 'VERIFIED' || proof.fullExecution?.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' + || trip.reservation?.status !== 'VERIFIED' || partial.proof) throw Error('PARTIAL must retain its unpicked real main after independent FULL closure and reservation') + if (resumePalletized || resumeReceiptConfirmed) { + const child = Object.create(context); child.marker = proof.pickerMarker + const suffix = pickerFixtureSuffix(child), username = `pda_overseas_${suffix.toLowerCase()}_71`.slice(0, 64) + const users = await nativeRequest(context, 'GET', `/api/platform/v1/iam/users?${new URLSearchParams({page:'1',size:'200',keyword:username})}`) + const user = users.records?.find(row => id(row.membershipId) === id(proof.picker?.membershipId) && row.username === username) + if (!user || user.protectedAccount || id(user.tenantId) !== '0' || id(user.overseasWarehouseId) !== warehouseId + || user.roleIds?.length !== 1 || id(user.roleIds[0]) !== id(access?.role?.id)) throw Error('Reviewed PDA identity or warehouse changed') + picker = { membershipId: user.membershipId, username, token: null } + const active = await nativeRequest(context, 'PUT', `/api/platform/v1/iam/users/${encodeURIComponent(id(user.membershipId))}/status`, {status:'ACTIVE'}) + if (active.status !== 'ACTIVE') throw Error('Original owned PDA not active') + const login = await nativeRequest(context, 'POST', '/api/auth/login', {loginId:username,password:`Pda${suffix}A719`}, '') + picker.token = login.accessToken + } else { + const child = Object.create(context); proof.pickerMarker = `${context.marker}-FPDA-${randomUUID().slice(0,8)}`; child.marker = proof.pickerMarker + picker = access ? await createPicker(child, 71, warehouseId, access) : null + } + if (!picker?.token || !picker.membershipId) throw Error('Fresh bound-warehouse fleet PDA required') + proof.picker = {membershipId:picker.membershipId,username:picker.username}; await context.checkpoint?.() + stage('EXTERNAL_RECEIPT') + await verifyFleetExternalReceipt(context, MODULE, trip, picker.token, {reconcilePalletized:resumePalletized,reconcileConfirmed:resumeReceiptConfirmed}) + stage('EXTERNAL_DISPATCH') + await verifyFleetExternalDispatch(context, MODULE, trip, {pickerToken:picker.token, + actualMeasurements: await currentFleetMeasurements(context, trip), coverageCase:partial}) const external = trip.externalDispatch stage('FINANCE_CONSUMER') - const sources = await request('GET', `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({ customerId, businessType: 'DELIVERY_ORDER', businessNo: external.deliveryNo })}`, - undefined, '读取外接应收稳定来源编号和版本') - const target = sources?.filter(row => row.feeItemCode === 'DELIVERY_FEE' && row.direction === 'RECEIVABLE' && row.sourceStatus === 'ACTIVE') - check('外接应收来源只属于本次客户派送及实收', target?.length === 1 && target[0].businessNo === external.deliveryNo - && id(target[0].customerId) === customerId && Number(target[0].originalAmount) === 30 && target[0].originalCurrency === 'CNY') + const externalCustomer = id(external.customerId) + const sources = await nativeRequest(context, 'GET', `/api/platform/v1/last-leg/finance-fee-sources?${new URLSearchParams({customerId:externalCustomer,businessType:'DELIVERY_ORDER',businessNo:external.deliveryNo})}`) + const target = sources.filter(row => row.feeItemCode === 'DELIVERY_FEE' && row.direction === 'RECEIVABLE' && row.sourceStatus === 'ACTIVE' + && row.inboundCode === trip.externalReceipt.incomingNo && id(row.customerId) === externalCustomer) + if (target.length !== 1 || Number(target[0].originalAmount) !== 30 || target[0].originalCurrency !== 'CNY') throw Error('Exact current-run external fee source missing') let matched = [] - for (let attempt = 0; attempt < 30; attempt++) { - const pool = await request('GET', `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(customerId)}&direction=RECEIVABLE`, undefined, '等待Finance消费本次外接费用') - check('Finance费用池为当前客户应收列表', Array.isArray(pool) && pool.every(row => id(row.customerId) === customerId && row.direction === 'RECEIVABLE')) - matched = pool.filter(row => id(row.sourceId) === id(target[0].sourceFeeId) && Number(row.sourceVersion) === Number(target[0].sourceVersion)) + for (let attempt=0;attempt<30;attempt++) { + const pool = await nativeRequest(context, 'GET', `/api/platform/finance/v1/fee-pool?customerId=${encodeURIComponent(externalCustomer)}&direction=RECEIVABLE`) + if (!Array.isArray(pool) || pool.some(row => id(row.customerId) !== externalCustomer || row.direction !== 'RECEIVABLE')) throw Error('Finance consumer scope mismatch') + matched = pool.filter(row => id(row.sourceId) === id(target[0].sourceFeeId) && id(row.sourceVersion) === id(target[0].sourceVersion)) if (matched.length) break - await wait(1000) + if (attempt<29) await wait(1000) } - check('Finance真实消费且仅一笔30CNY外接应收', matched.length === 1 && matched[0].sourceType === 'OVERSEAS_DELIVERY_FEE' - && Number(matched[0].amount) === 30 && matched[0].currency === 'CNY') - proof.finance = { status: 'CONSUMER_PERSISTED', source: target[0], pool: matched, producerAck: 'NOT_VERIFIED' } + if (matched.length !== 1 || matched[0].sourceType !== 'OVERSEAS_DELIVERY_FEE' || Number(matched[0].amount) !== 30 || matched[0].currency !== 'CNY') throw Error('Finance did not persist exactly this external fee') + proof.finance = {status:'CONSUMER_PERSISTED',customerId:externalCustomer,source:target[0],pool:matched,producerAck:'NOT_VERIFIED'} external.financeAcknowledgement = 'CONSUMER_PERSISTED_PRODUCER_ACK_NOT_VERIFIED' - stage('PICK_EACH_DISPATCH') - const dispatchNos = [partial.dispatchNo, external.dispatchNo] - proof.dispatchNos = dispatchNos - const dispatchDetails = [] - for (const [index, dispatchNo] of dispatchNos.entries()) { - const pickingContext = Object.create(context); pickingContext.marker = `${context.marker}-FLEET-PICK-${index}` - check('逐客户正式PDA拣货及完成成功', await pickAll(pickingContext, picker.token, dispatchNo)) - dispatchDetails.push(await request('GET', `${dispatchBase}/${encodeURIComponent(dispatchNo)}`, undefined, '回查逐单拣货数量及未发车事实')) - } - check('每张客户排车实际拣足但尚未发车', dispatchDetails.every(row => row.status === 'READY_TO_DEPART' - && Number(row.pickedPackageCount) === Number(row.packageCount) && Number(row.shippedPackageCount) === 0)) stage('WHOLE_TRIP_DEPARTURE') - const recordVersion = await confirmFleetLoading(context, MODULE, trip) - proof.departureRequest = { recordVersion, idempotencyKey: key(), remark: '本轮自有货与真实外接货统一发车' } - const departPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(partial.dispatchNo)}/depart` - const departed = await request('POST', departPath, proof.departureRequest, 'PDA一次命令发出全车两张客户执行单', picker.token) - check('整车发车包含所有排车且箱数守恒', departed?.tripNo === trip.tripNo && departed.status === 'IN_TRANSIT' - && JSON.stringify(departed.dispatches.map(row => row.dispatchNo).sort()) === JSON.stringify([...dispatchNos].sort()) - && Number(departed.shippedPackages) === dispatchDetails.reduce((n, row) => n + Number(row.packageCount), 0)) - const departedReplay = await request('POST', departPath, proof.departureRequest, '整车发车同键重放', picker.token) - check('发车重放不改变整车实发数量', departedReplay?.replayed === true && Number(departedReplay.shippedPackages) === Number(departed.shippedPackages)) - proof.departure = departed - await completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady }) + const departure = await departNativeFleet(context, trip, picker.token, proof) + await completeDepartedFleetScenario(context, trip, picker, departure.dispatchDetails, {costActors,onHandoverReady}) } finally { await disablePicker(context, picker, 71) } return proof } - export async function completeDepartedFleetScenario(context, trip, picker, dispatchDetails, { costActors, onHandoverReady, costsAlreadyVerified = false, reviewedVehicle, verifyCosts = verifyFleetTripCosts, handover = runFleetHandoverStage, completeDelivery = saveReceiptAndComplete, closeTrip = finishFleetTrip, cleanup = cleanupFleetHandoverResources } = {}) { - const proof = context.createdResources.fleetAcceptance, customerId = proof.customerId, warehouseId = proof.warehouseId - const partial = context.createdResources.fleetFeeCoverageCases.find(row => row.mode === 'PARTIAL') - const external = trip.externalDispatch, dispatchNos = proof.dispatchNos - const stage = value => { proof.stage = value; proof.checkpoints.push({ stage: value, at: new Date().toISOString() }); console.log('FLEET_STAGE=' + value) } - const check = (label, ok) => { if (!ok) throw Error(label) } - async function request(method, path) { const r = await context.request(method, path); check('Continuation read failed', success(r)); return r.body.data } - if (!costsAlreadyVerified) { - stage('TRIP_OPERATING_COSTS') - await verifyCosts(context, MODULE, trip, { ...costActors, shares: fleetCostShares(trip, dispatchDetails) }) - } else check('Recovery cost proof must remain verified', trip.costAcceptance?.status === 'VERIFIED') - stage('IN_TRANSIT_HANDOVER') - await handover(context, trip, picker.token, dispatchDetails, { - reviewedVehicle, - onReady: onHandoverReady ? async fixture => { - stage('PDA_UI_CHECKPOINT') - await onHandoverReady({ fixture, picker }) - } : undefined, - }) - stage('RECEIPTS_AND_RESOURCE_RELEASE') - proof.receipts = [] - if (context.context.fleetScenario === 'return-redispatch') { - stage('RETURN_REDISPATCH') - const returned = await verifyFleetReturnRedispatch(context, MODULE, trip, { customerId, warehouseId, pickerToken: picker.token, - dispatchNos, deliveryNos: [...partial.proof.deliveryNos, external.deliveryNo] }) - proof.receipts = returned.originalSignatures.map(row => ({ deliveryNo: row.deliveryNo, signedAt: row.signedAt })) - context.context.saveFleetReturnRedispatchFixture(returned) - } else { - for (const [index, deliveryNo] of [...partial.proof.deliveryNos, external.deliveryNo].entries()) { - const delivery = await request('GET', `/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(deliveryNo)}?customerId=${encodeURIComponent(customerId)}`, - undefined, '签收前读取本次实际派送') - const completed = await completeDelivery(context, delivery.order, customerId, index + 71) - check('每个目的地真实保存签收资料后完成', completed?.order?.status === 'DELIVERED' && completed.order.signedAt) - proof.receipts.push({ deliveryNo, signedAt: completed.order.signedAt }) + const proof=context.createdResources.fleetAcceptance,customerId=proof.customerId,warehouseId=proof.warehouseId + if(proof.protocol!==NATIVE_PROTOCOL||!nativeDeparture(proof.departure,trip.dispatchNo)||proof.dispatchNo!==trip.dispatchNo + ||dispatchDetails?.length!==1||dispatchDetails[0].detail?.dispatchNo!==trip.dispatchNo)throw Error('Post-departure continuation requires one native main bundle and its actual DEPART command') + const stage=value=>{proof.stage=value;proof.checkpoints.push({stage:value,at:new Date().toISOString()});console.log('FLEET_STAGE='+value)} + if(!costsAlreadyVerified){stage('TRIP_OPERATING_COSTS');await verifyCosts(context,MODULE,trip,{...costActors,shares:fleetCostShares(trip,dispatchDetails)})} + else if(trip.costAcceptance?.status!=='VERIFIED')throw Error('Recovery cost proof must remain verified') + stage('IN_TRANSIT_HANDOVER') + await handover(context,trip,picker.token,dispatchDetails.map(bundle=>bundle.detail),{reviewedVehicle, + onReady:onHandoverReady?async fixture=>{stage('PDA_UI_CHECKPOINT');await onHandoverReady({fixture,picker})}:undefined}) + stage('RECEIPTS_AND_RESOURCE_RELEASE');proof.receipts=[] + const current=await readNativeFleetBundle(context,trip.dispatchNo),refs=nativeFleetDeliveries(current.detail) + if(context.context.fleetScenario==='return-redispatch'){ + stage('RETURN_REDISPATCH') + if(refs.some(ref=>id(ref.customerId)!==id(customerId)))throw Error('Explicit return-redispatch source selection requires the one actual source customer') + const returned=await verifyFleetReturnRedispatch(context,MODULE,trip,{customerId,warehouseId,pickerToken:picker.token, + dispatchNos:[trip.dispatchNo],deliveryNos:refs.map(ref=>ref.deliveryNo)}) + proof.receipts=returned.originalSignatures.map(row=>({customerId:id(row.customerId||customerId),deliveryNo:row.deliveryNo,signedAt:row.signedAt})) + context.context.saveFleetReturnRedispatchFixture(returned) + }else{ + for(const[index,ref]of refs.entries()){ + const delivery=await nativeRequest(context,'GET',`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`) + if(delivery.order?.deliveryNo!==ref.deliveryNo||id(delivery.order.customerId)!==id(ref.customerId)||delivery.order.dispatchNo!==trip.dispatchNo)throw Error('POD source customer or dispatch drift') + const completed=await completeDelivery(context,delivery.order,id(ref.customerId),index+71) + if(completed?.order?.status!=='DELIVERED'||!completed.order.signedAt)throw Error('每个目的地真实保存签收资料后完成') + proof.receipts.push({customerId:id(ref.customerId),deliveryNo:ref.deliveryNo,signedAt:completed.order.signedAt}) } - } - proof.closure = await closeTrip(context, MODULE, trip) - check('本次车次货物结案及车辆司机释放完成', proof.closure?.status === 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') - stage('HANDOVER_RESOURCE_CLEANUP') - await cleanup(context, MODULE, trip) - proof.status = 'HTTP_CHAIN_VERIFIED_WITH_ACK_GAP'; stage('FINISHED') - return proof + } + proof.closure=await closeTrip(context,MODULE,trip) + if(proof.closure?.status!=='CARGO_COMPLETED_AND_RESOURCES_RELEASED')throw Error('本次车次货物结案及车辆司机释放完成') + stage('HANDOVER_RESOURCE_CLEANUP');await cleanup(context,MODULE,trip) + proof.status='HTTP_CHAIN_VERIFIED_WITH_ACK_GAP';stage('FINISHED');return proof } - export async function continueReviewedDeparture(context, { verify = verifyDepartedRecovery, prepareActors = prepareFleetCostActors, costsAlreadyVerified = false, createdVehicle, resolveAccess = resolvePickerAccess, makePicker = createPicker, closePicker = disablePicker, diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 58682d9d8..916e25c99 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -8,36 +8,30 @@ import { run as runInboundMeasurement } from './admin-test-env-modules/inbound-m import { verifyPalletLabels } from './admin-test-env-modules/phase3-loading-finalize.mjs' function departedRecoveryFixture() { - const snapshotTrip = { tripNo: 'TR-OWN', status: 'IN_TRANSIT', recordVersion: '27', vehicleId: 'V1', driverId: 'D1', warehouseId: 'W1', departedAt: '2026-10-03T11:00:00Z' } - const cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'I1', warehousePalletId: 'P1', packages: 2 }, { dispatchNo: 'EXT', dispatchItemId: 'I2', warehousePalletId: 'P2', packages: 1 }] - const departure = { action: 'DEPART', recordVersion: '27', snapshot: { trip: snapshotTrip, confirmedLoad: { manifestHash: 'a'.repeat(64), cargo } } } - const proof = { marker: 'RUN', tripNo: 'TR-OWN', warehouseId: 'W1', customerId: 'C1', status: 'CHECKING', stage: 'TRIP_OPERATING_COSTS', checkpoints: [], - dispatchNos: ['OWN', 'EXT'], picker: { membershipId: 'OLD-PDA' }, departureRequest: { idempotencyKey: 'old-key' }, - departure: { tripNo: 'TR-OWN', status: 'IN_TRANSIT', departedAt: snapshotTrip.departedAt, shippedPackages: 3 } } - const trip = { marker: 'RUN', tripNo: 'TR-OWN', costAcceptance: { marker: 'RUN', status: 'FAILED', costs: [], departure, - requests: [{ method: 'GET', status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't' }] }, - externalReceipt: { status: 'VERIFIED', bookingNo: 'BK', warehousePalletId: 'P2' }, externalDispatch: { dispatchNo: 'EXT', deliveryNo: 'DL-EXT' } } - const report = { success: false, steps: [{ name: '独立预期覆盖全部实物且没有重叠托盘', passed: false }, { name: '模块执行', passed: false }], - createdResources: { fleetAcceptance: proof, fleetTrips: [trip], fleetCostActors: { status: 'DISABLED' }, fleetFeeCoverageCases: [{ mode: 'PARTIAL', proof: { deliveryNos: ['DL-OWN'] } }] } } - const base = '/api/platform/v1/last-leg/fleet/trips/TR-OWN' - const responses = new Map([[base, { trip: structuredClone(snapshotTrip), dispatches: [{ dispatchNo: 'OWN', customerId: 'C1' }, { dispatchNo: 'EXT', customerId: 'C1' }] }], - [base + '/history?page=1&size=100', { total: 1, records: [structuredClone(departure)] }], - [base + '/costs?page=1&size=100', { page: { records: [], total: 0 }, summary: { manifestHash: 'a'.repeat(64), departureVersion: '27', totals: [] } }], - [base + '/handovers', { records: [], total: 0 }]]) - for (const row of cargo) { - responses.set('/api/platform/v1/last-leg/dispatches/' + row.dispatchNo, { dispatchNo: row.dispatchNo, status: 'DEPARTED', overseasWarehouseId: 'W1', - packageCount: row.packages, pickedPackageCount: row.packages, shippedPackageCount: row.packages, - items: [{ id: row.dispatchItemId, warehousePalletId: row.warehousePalletId, packageCount: row.packages }] }) - responses.set('/api/platform/v1/last-leg/delivery-orders/DL-' + row.dispatchNo + '?customerId=C1', { order: { deliveryNo: 'DL-' + row.dispatchNo, - customerId: 'C1', dispatchNo: row.dispatchNo, status: 'WAIT_DELIVERY', version: '1', packageCount: row.packages, - documentSummary: { categories: [{ fileCount: 0 }] } } }) - } - const calls = [], ctx = { marker: 'RUN', token: 'writer', context: {}, createdResources: report.createdResources, async checkpoint() { calls.push('checkpoint') }, - async request(method, path) { assert.equal(method, 'GET', 'recovery verification must never write'); calls.push(path); assert.ok(responses.has(path), path) - return { http: 200, body: { code: '200', data: responses.get(path) }, requestId: 'r', traceId: 't' } } } - return { report, ctx, calls, responses, base, trip } + const snapshotTrip={tripNo:'TR-OWN',status:'IN_TRANSIT',recordVersion:'27',vehicleId:'V1',driverId:'D1',warehouseId:'W1',departedAt:'2026-10-03T11:00:00Z'} + const cargo=[{dispatchNo:'OWN',dispatchItemId:'I1',customerId:'C1',warehousePalletId:'P1',packages:2},{dispatchNo:'OWN',dispatchItemId:'I2',customerId:'C1',warehousePalletId:'P2',packages:1}] + const measured=[{warehousePalletId:'P1',palletSpaces:2},{warehousePalletId:'P2',palletSpaces:1}] + const departure={action:'DEPART',recordVersion:'27',snapshot:{trip:snapshotTrip,confirmedLoad:{manifestHash:'a'.repeat(64),cargo,pallets:measured}}} + const refs=[{customerId:'C1',deliveryNo:'DL-OWN',outboundNo:'OUT-C1',packageCount:2},{customerId:'C1',deliveryNo:'DL-EXT',outboundNo:'OUT-C1',packageCount:1}] + const detail={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:3,pickedPackageCount:3,shippedPackageCount:3},documents:{deliveries:refs}} + const items={dispatchNo:'OWN',recordVersion:'31',items:cargo.map(row=>({dispatchItemId:row.dispatchItemId,customerId:row.customerId,warehousePalletId:row.warehousePalletId,scheduledPackages:row.packages,pickedPackages:row.packages}))} + const orders=refs.map(ref=>({...ref,dispatchNo:'OWN',status:'DELIVERING',version:'1',documentSummary:{categories:[{fileCount:0}]}})) + const proof={protocol:'NATIVE_DISPATCH_V1',marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',customerId:'C1',status:'CHECKING',stage:'TRIP_OPERATING_COSTS',checkpoints:[], + dispatchNos:['OWN'],picker:{membershipId:'OLD-PDA'},departureRequest:{idempotencyKey:'old-key'},departureSnapshot:{detail:structuredClone(detail),items:structuredClone(items)},departureDeliveries:structuredClone(orders), + departure:{action:'DEPART',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',result:{departedAt:snapshotTrip.departedAt,shippedPackages:3,deliveries:refs}}} + const trip={marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',confirmedLoad:{manifestHash:'a'.repeat(64),pallets:measured},costAcceptance:{marker:'RUN',status:'FAILED',costs:[],departure, + requests:[{method:'GET',status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t'}]},externalReceipt:{status:'VERIFIED',bookingNo:'BK',warehousePalletId:'P2'},externalDispatch:{dispatchNo:'OWN',deliveryNo:'DL-EXT'}} + const report={success:false,steps:[{name:'独立预期覆盖全部实物且没有重叠托盘',passed:false},{name:'模块执行',passed:false}],createdResources:{fleetAcceptance:proof,fleetTrips:[trip],fleetCostActors:{status:'DISABLED'},fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DL-OWN','DL-EXT']}}]}} + const base='/api/platform/v1/last-leg/fleet/trips/TR-OWN' + const responses=new Map([[base,{trip:structuredClone(snapshotTrip),dispatches:[{dispatchNo:'OWN'}]}], + [base+'/history?page=1&size=100',{total:1,records:[structuredClone(departure)]}], + [base+'/costs?page=1&size=100',{page:{records:[],total:0},summary:{manifestHash:'a'.repeat(64),departureVersion:'27',totals:[]}}],[base+'/handovers',{records:[],total:0}], + ['/api/platform/v1/last-leg/dispatches/OWN',structuredClone(detail)],['/api/platform/v1/last-leg/dispatches/OWN/items',structuredClone(items)]]) + for(const order of orders)responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`,{order:structuredClone(order)}) + const calls=[],ctx={marker:'RUN',token:'writer',context:{},createdResources:report.createdResources,async checkpoint(){calls.push('checkpoint')}, + async request(method,path){assert.equal(method,'GET','recovery verification must never write');calls.push(path);assert.ok(responses.has(path),path);return {http:200,body:{code:'200',data:responses.get(path)},requestId:'r',traceId:'t'}}} + return {report,ctx,calls,responses,base,trip} } - function createdVehicleRecoveryFixture() { const f = afterCostsRecoveryFixture(), snapshot = { id: '2106360046045675522', plateNumber: 'HO-24255909-3BC', recordVersion: '0', status: 'IN_SERVICE', ownership: 'OWNED', region: 'OVERSEAS', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90 } @@ -51,57 +45,40 @@ function createdVehicleRecoveryFixture() { } function returnSourceRecoveryFixture() { - const f = afterCostsRecoveryFixture(), { report, trip, responses, base, ctx } = f, run = report.createdResources.fleetAcceptance - run.stage = 'RETURN_REDISPATCH'; run.receipts = []; run.departure.shippedPackages = 5 - report.steps = [{ name: '模块执行', passed: false, evidence: 'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B' }] - const current = { ...responses.get(base).trip, recordVersion: '30', vehicleId: 'V2', driverId: 'D2', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } - responses.get(base).trip = current - const original = trip.costAcceptance.departure - original.snapshot.confirmedLoad.cargo = [{ dispatchNo: 'OWN', dispatchItemId: 'IA', warehousePalletId: 'PA', packages: 3 }, - { dispatchNo: 'OWN', dispatchItemId: 'IB', warehousePalletId: 'PB', packages: 1 }, { dispatchNo: 'EXT', dispatchItemId: 'IX', warehousePalletId: 'PX', packages: 1 }] - const history = { records: [structuredClone(original), { action: 'HANDOVER', recordVersion: '28' }, - { action: 'RELEASE', recordVersion: '29' }, { action: 'RELEASE', recordVersion: '30' }], total: 4 } - responses.set(base + '/history?page=1&size=100', history) - const handover = { records: [{ handoverNo: 'HO-CURRENT', recordVersion: '2' }], total: 1 } - const vehicle = { id: 'V2', recordVersion: '0', plateNumber: 'NEW', status: 'IN_SERVICE' } - const driver = { id: 'D2', recordVersion: '2', active: true, reviewStatus: 'APPROVED', licenseFileId: 'FILE', details: {} } - trip.handover = { status: 'VERIFIED', finalTrip: structuredClone(current), handoverNo: 'HO-CURRENT', requests: [ - { method: 'GET', path: base + '/history?page=1&size=100', result: structuredClone(history) }, - { method: 'GET', path: base + '/handovers?page=1&size=10', result: structuredClone(handover) }, - { method: 'POST', path: '/api/platform/v1/last-leg/master-data/vehicles', result: structuredClone(vehicle) }, - { method: 'POST', path: '/api/platform/v1/last-leg/fleet/drivers/D2/review', result: structuredClone(driver) }] } - responses.set(base + '/handovers', handover); responses.set('/api/platform/v1/last-leg/master-data/vehicles/V2', vehicle) - responses.set('/api/platform/v1/last-leg/fleet/drivers/D2', driver) - ctx.context.readRecoveryTripResources = async () => [{ resourceKind: 'VEHICLE', resourceId: 'V2', tripNo: 'TR-OWN' }, { resourceKind: 'DRIVER', resourceId: 'D2', tripNo: 'TR-OWN' }] - const rows = [['A', 3, 'OWN'], ['B', 1, 'OWN'], ['EXT', 1, 'EXT']] - const originals = rows.map(([label, packages, dispatchNo]) => ({ deliveryNo: 'DL-' + label, outboundNo: 'OUT-' + label, dispatchNo, - customerId: 'C1', overseasWarehouseId: 'W1', status: 'WAIT_DELIVERY', version: '2', packageCount: packages, - documentSummary: { categories: [{ fileCount: 0 }] } })) - trip.returnRedispatch = { marker: 'RUN', status: 'FAILED', failure: report.steps[0].evidence, originalDeliveries: originals, - requests: originals.map(order => ({ method: 'PUT', path: `/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/status?customerId=C1`, - body: { status: 'DELIVERING', expectedVersion: 2 }, status: 'RESPONDED', http: 200, code: '200', requestId: 'r', traceId: 't', - result: { deliveryNo: order.deliveryNo, deliveryStatus: 'DELIVERING', replayed: false } })) } - report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos = ['DL-A', 'DL-B'] - for (const [label, packages, dispatchNo] of rows) { - const order = originals.find(row => row.deliveryNo === 'DL-' + label) - responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`, { order: { ...structuredClone(order), - status: 'DELIVERING', version: '3', vehiclePlate: 'NEW', driverName: 'NEW', driverPhone: 'NEW' } }) - const relation = { lastMileOrderId: label, inboundCode: 'IN-' + label, deliveryNo: order.deliveryNo, outboundNo: order.outboundNo, - returnSources: [{ maxReturnPackages: packages }] } - responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/return-inbound-options?customerId=C1`, [relation]) - responses.set(`/api/platform/v1/last-leg/orders/${label}?customerId=C1`, { order: { id: label, customerId: 'C1', overseasWarehouseId: 'W1', - orderType: label === 'EXT' ? 'WAREHOUSE_TRANSFER' : 'FIRST_LEG', sourceInboundId: 'SOURCE-' + label, inboundCode: relation.inboundCode, packageCount: packages } }) - } - for (const no of ['OWN', 'EXT']) { - const cargo = original.snapshot.confirmedLoad.cargo.filter(row => row.dispatchNo === no), total = cargo.reduce((n,row)=>n+row.packages,0) - responses.set('/api/platform/v1/last-leg/dispatches/' + no, { dispatchNo: no, status: 'DEPARTED', overseasWarehouseId: 'W1', - packageCount: total, pickedPackageCount: total, shippedPackageCount: total, - items: cargo.map(row=>({id:row.dispatchItemId,warehousePalletId:row.warehousePalletId,packageCount:row.packages})) }) + const f=afterCostsRecoveryFixture(),{report,trip,responses,base,ctx}=f,run=report.createdResources.fleetAcceptance + run.stage='RETURN_REDISPATCH';run.receipts=[];run.departure.result.shippedPackages=5 + report.steps=[{name:'模块执行',passed:false,evidence:'Fresh redispatch fixture requires source A with at least 3 boxes and a separate domestic source B'}] + const current={...responses.get(base).trip,recordVersion:'30',vehicleId:'V2',driverId:'D2',vehiclePlate:'NEW',driverName:'NEW',driverPhone:'NEW'} + responses.get(base).trip=current + const original=trip.costAcceptance.departure + original.snapshot.confirmedLoad.cargo=[{dispatchNo:'OWN',customerId:'C1',dispatchItemId:'IA',warehousePalletId:'PA',packages:3}, + {dispatchNo:'OWN',customerId:'C1',dispatchItemId:'IB',warehousePalletId:'PB',packages:1},{dispatchNo:'OWN',customerId:'C1',dispatchItemId:'IX',warehousePalletId:'PX',packages:1}] + const history={records:[structuredClone(original),{action:'HANDOVER',recordVersion:'28'},{action:'RELEASE',recordVersion:'29'},{action:'RELEASE',recordVersion:'30'}],total:4} + responses.set(base+'/history?page=1&size=100',history) + const handover={records:[{handoverNo:'HO-CURRENT',recordVersion:'2'}],total:1},vehicle={id:'V2',recordVersion:'0',plateNumber:'NEW',status:'IN_SERVICE'},driver={id:'D2',recordVersion:'2',active:true,reviewStatus:'APPROVED',licenseFileId:'FILE',details:{}} + trip.handover={status:'VERIFIED',finalTrip:structuredClone(current),handoverNo:'HO-CURRENT',requests:[ + {method:'GET',path:base+'/history?page=1&size=100',result:structuredClone(history)},{method:'GET',path:base+'/handovers?page=1&size=10',result:structuredClone(handover)}, + {method:'POST',path:'/api/platform/v1/last-leg/master-data/vehicles',result:structuredClone(vehicle)},{method:'POST',path:'/api/platform/v1/last-leg/fleet/drivers/D2/review',result:structuredClone(driver)}]} + responses.set(base+'/handovers',handover);responses.set('/api/platform/v1/last-leg/master-data/vehicles/V2',vehicle);responses.set('/api/platform/v1/last-leg/fleet/drivers/D2',driver) + ctx.context.readRecoveryTripResources=async()=>[{resourceKind:'VEHICLE',resourceId:'V2',tripNo:'TR-OWN'},{resourceKind:'DRIVER',resourceId:'D2',tripNo:'TR-OWN'}] + const rows=[['A',3],['B',1],['EXT',1]] + const originals=rows.map(([label,packages])=>({deliveryNo:'DL-'+label,outboundNo:'OUT-C1',dispatchNo:'OWN',customerId:'C1',overseasWarehouseId:'W1',status:'DELIVERING',version:'3',packageCount:packages,documentSummary:{categories:[{fileCount:0}]}})) + trip.returnRedispatch={marker:'RUN',status:'FAILED',failure:report.steps[0].evidence,originalDeliveries:originals, + requests:originals.map(order=>({method:'GET',path:`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`,status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t',result:{order:structuredClone(order)}}))} + report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos=['DL-A','DL-B','DL-EXT'] + const detail={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:5,pickedPackageCount:5,shippedPackageCount:5},documents:{deliveries:originals.map(o=>({deliveryNo:o.deliveryNo,outboundNo:o.outboundNo,customerId:o.customerId,packageCount:o.packageCount}))}} + const items={dispatchNo:'OWN',recordVersion:'31',items:original.snapshot.confirmedLoad.cargo.map(c=>({dispatchItemId:c.dispatchItemId,customerId:c.customerId,warehousePalletId:c.warehousePalletId,scheduledPackages:c.packages,pickedPackages:c.packages}))} + run.departureSnapshot={detail:structuredClone(detail),items:structuredClone(items)} + responses.set('/api/platform/v1/last-leg/dispatches/OWN',detail);responses.set('/api/platform/v1/last-leg/dispatches/OWN/items',items) + for(const[label,packages]of rows){const order=originals.find(o=>o.deliveryNo==='DL-'+label) + responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`,{order:{...structuredClone(order),vehiclePlate:'NEW',driverName:'NEW',driverPhone:'NEW'}}) + const relation={lastMileOrderId:label,inboundCode:'IN-'+label,deliveryNo:order.deliveryNo,outboundNo:order.outboundNo,returnSources:[{maxReturnPackages:packages}]} + responses.set(`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}/return-inbound-options?customerId=C1`,[relation]) + responses.set(`/api/platform/v1/last-leg/orders/${label}?customerId=C1`,{order:{id:label,customerId:'C1',overseasWarehouseId:'W1',orderType:label==='EXT'?'WAREHOUSE_TRANSFER':'FIRST_LEG',sourceInboundId:'SOURCE-'+label,inboundCode:relation.inboundCode,packageCount:packages}}) } return f } - -test('source-selection recovery only accepts original three explicit starts and rejects any actual return or unsigned result', () => { +test('source-selection recovery accepts three definitive native reads and rejects any actual return or unknown result', () => { const f = returnSourceRecoveryFixture() assert.equal(reviewedFullCheckpoint(f.report, 'RETURN_SOURCE_SELECTION_BEFORE_RETURN').returnSourceSelection, true) for (const change of [x=>x.trip.returnRedispatch.requests.push({ method:'POST',path:'/mark-return' }), @@ -226,7 +203,7 @@ for (const mutation of ['none', 'version', 'history', 'cost', 'handover', 'cargo if (mutation === 'history') responses.get(base + '/history?page=1&size=100').records[0].snapshot.confirmedLoad.manifestHash = 'changed' if (mutation === 'cost') responses.get(base + '/costs?page=1&size=100').page.records.push({ costNo: 'COST' }) if (mutation === 'handover') responses.get(base + '/handovers').records.push({ handoverNo: 'H' }) - if (mutation === 'cargo') responses.get('/api/platform/v1/last-leg/dispatches/OWN').items[0].packageCount = 3 + if (mutation === 'cargo') responses.get('/api/platform/v1/last-leg/dispatches/OWN/items').items[0].scheduledPackages = 3 const delivery = responses.get('/api/platform/v1/last-leg/delivery-orders/DL-OWN?customerId=C1').order if (mutation === 'signature') delivery.signedAt = 'now' if (mutation === 'document') delivery.documentSummary.categories[0].fileCount = 1 @@ -241,7 +218,7 @@ test('reviewed recovery archives old evidence and creates fresh sessions before resolveAccess: async () => ({}), makePicker: async child => { assert.match(child.marker, /RECOVERY-PDA/); calls.push('new-picker'); return { membershipId: 'NEW', token: 'new-pda' } }, closePicker: async () => calls.push('close-picker'), complete: async (context, actualTrip, picker, dispatches) => { - assert.equal(actualTrip, trip); assert.equal(trip.costAcceptance, undefined); assert.equal(picker.membershipId, 'NEW'); assert.equal(dispatches.length, 2) + assert.equal(actualTrip, trip); assert.equal(trip.costAcceptance, undefined); assert.equal(picker.membershipId, 'NEW'); assert.equal(dispatches.length, 1) assert.equal(trip.previousReadOnlyCostAttempts[0], oldCost); assert.equal(context.createdResources.fleetAcceptance.previousPicker.membershipId, 'OLD-PDA') calls.push('continue-from-costs') }, @@ -283,7 +260,7 @@ test('changed source report SHA blocks recovery before creating context or loggi rmSync(temporary, { recursive: true, force: true }) } }) -import { parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation, verifyReturnSourceRecovery, continueReviewedReturnSources } from './fleet-test-env-acceptance.mjs' +import { selectNativeFleetFullSource, nativeFleetDeliveries, departNativeFleet, currentFleetMeasurements, parseFleetArguments, fleetExecutionPlan, runFleetAcceptance, reviewedFullCheckpoint, verifyFleetTerminalState, runFleetHandoverStage, fleetCostShares, prepareFleetCostActors, findFleetActorPhone, guardFleetWrites, redactFleetWrite, verifyDepartedRecovery, continueReviewedDeparture, completeDepartedFleetScenario, readReviewedVehicleOccupation, verifyReturnSourceRecovery, continueReviewedReturnSources } from './fleet-test-env-acceptance.mjs' for (const response of [ { http: 0 }, { http: 503, body: { code: 'A0500' }, requestId: 'r', traceId: 't' }, @@ -302,6 +279,7 @@ for (const response of [ body.expectedVersion = 99 assert.equal(saved[0].body.expectedVersion, 6) assert.equal(saved[1].status, 'UNKNOWN_RESULT') + assert.equal(ctx.createdResources.fleetWriteBoundary.status, 'STOPPED') for (const method of ['POST', 'GET', 'DELETE']) await assert.rejects(ctx.request(method, '/must-not-run'), /Unknown write outcome/) assert.deepEqual(calls, ['/orders/1/remarks?tenantId=2']) }) @@ -481,10 +459,10 @@ test('return redispatch is explicit, fresh-only and declares source-event proof }) test('handover stage exports only a verified same-run departure fixture before the optional UI checkpoint', async () => { - const order = [], departure = { tripNo: 'TR-NEW', status: 'IN_TRANSIT' } + const order = [], departure = { action:'DEPART', dispatchNo:'PC-NEW', status:'ACTIVE', stage:'DELIVERING', result:{departedAt:'2026-10-19T00:00:00Z',shippedPackages:1} } const context = { marker: 'CURRENT', context: { saveFleetHandoverFixture(value) { order.push('save'); assert.equal(value.marker, 'CURRENT') } }, - createdResources: { fleetAcceptance: { departure } } } - const trip = { tripNo: 'TR-NEW', warehouseId: '92', costAcceptance: { status: 'VERIFIED', marker: 'CURRENT', expected: { finalCny: '100.03', usd: '13.07' } } }, details = [{ dispatchNo: 'PC-NEW' }] + createdResources: { fleetAcceptance: { tripNo:'TR-NEW', dispatchNo:'PC-NEW', departure } } } + const trip = { tripNo: 'TR-NEW', dispatchNo:'PC-NEW', warehouseId: '92', costAcceptance: { status: 'VERIFIED', marker: 'CURRENT', expected: { finalCny: '100.03', usd: '13.07' } } }, details = [{ dispatchNo: 'PC-NEW' }] const verified = { status: 'VERIFIED', marker: 'CURRENT', originalDeparture: { snapshot: { confirmedLoad: { pallets: [] } } }, browser: { tripNo: 'TR-NEW' }, handoverNo: 'HO-NEW' } const result = await runFleetHandoverStage(context, trip, 'secret-picker-token', details, { @@ -499,16 +477,22 @@ test('handover stage exports only a verified same-run departure fixture before t } }) -test('cost shares use controlled physical pallets and the original external booking, never statistical totals', () => { - const trip = { externalReceipt: { status: 'VERIFIED', bookingNo: 'BK-ONE', warehousePalletId: '3' }, externalDispatch: { dispatchNo: 'EXT' } } - const details = [{ dispatchNo: 'OWN', items: [{ warehousePalletId: '1' }, { warehousePalletId: '1' }, { warehousePalletId: '2' }] }, - { dispatchNo: 'EXT', items: [{ warehousePalletId: '3' }] }] +test('one dispatch cost shares use actual physical measurements and the original external booking', () => { + const trip = { dispatchNo: 'OWN', externalReceipt: { status: 'VERIFIED', bookingNo: 'BK-ONE', warehousePalletId: '3' }, + externalDispatch: { dispatchNo: 'OWN' }, confirmedLoad: { manifestHash: 'ACTUAL', pallets: [ + { warehousePalletId: '1', palletSpaces: 2 }, { warehousePalletId: '2', palletSpaces: 3 }, { warehousePalletId: '3', palletSpaces: 4 }] } } + const details = [{ detail: { dispatchNo: 'OWN', recordVersion: '0' }, items: { dispatchNo: 'OWN', recordVersion: '0', + items: [{ warehousePalletId: '1', customerId: 'A' }, { warehousePalletId: '1', customerId: 'B' }, + { warehousePalletId: '2', customerId: 'A' }, { warehousePalletId: '3', customerId: 'B' }] } }] assert.deepEqual(fleetCostShares(trip, details), [ - { businessNo: 'OWN', sourceType: 'OWN', dispatchNo: 'OWN', warehousePalletIds: ['1', '2'], palletSpaces: 2 }, - { businessNo: 'BK-ONE', sourceType: 'EXTERNAL', dispatchNo: 'EXT', warehousePalletIds: ['3'], palletSpaces: 1 }, + { businessNo: 'OWN', sourceType: 'OWN', dispatchNo: 'OWN', warehousePalletIds: ['1', '2'], palletSpaces: 5 }, + { businessNo: 'BK-ONE', sourceType: 'EXTERNAL', dispatchNo: 'OWN', warehousePalletIds: ['3'], palletSpaces: 4 }, ]) - assert.throws(() => fleetCostShares(trip, [{ ...details[0], items: [{ warehousePalletId: '3' }] }, details[1]]), /distinct/) - assert.throws(() => fleetCostShares(trip, [details[0], { ...details[1], items: [{ warehousePalletId: '4' }] }]), /receipt/) + assert.throws(() => fleetCostShares({ ...trip, confirmedLoad: null }, details), /measurements/) + assert.throws(() => fleetCostShares(trip, [{ ...details[0], items: { ...details[0].items, recordVersion: '1' } }]), /version/) + assert.throws(() => fleetCostShares({ ...trip, externalReceipt: { ...trip.externalReceipt, warehousePalletId: '4' } }, details), /distinct/) + assert.throws(() => fleetCostShares({ ...trip, confirmedLoad: { ...trip.confirmedLoad, + pallets: [...trip.confirmedLoad.pallets, trip.confirmedLoad.pallets[0]] } }, details), /measurements/) }) test('missing isolated cost actor preparation rejects before readers or fixture creation', async () => { @@ -592,16 +576,18 @@ test('phone lookup failure stops without any user creation or guessing', async ( assert.equal(calls, 1) }) -test('main chain performs handover after actual DEPART and before receipts, and cleanup follows current-resource closure', async () => { - const fullSource = readFileSync(new URL('./fleet-test-env-acceptance.mjs', import.meta.url), 'utf8') - const source = fullSource.slice(fullSource.indexOf('export async function runFleetScenario')) - const savedDeparture = source.indexOf('proof.departure = departed'), handover = source.indexOf("stage('IN_TRANSIT_HANDOVER')") - const receipt = source.indexOf("stage('RECEIPTS_AND_RESOURCE_RELEASE')"), closure = source.indexOf('proof.closure = await closeTrip') - const cleanup = source.indexOf('await cleanup(context, MODULE, trip)') - assert.ok(savedDeparture > 0 && savedDeparture < handover && handover < receipt && receipt < closure && closure < cleanup) - assert.match(source, /finally \{ await disablePicker\(context, picker, 71\) \}/) +test('main chain performs handover after actual native DEPART and before receipts, and cleanup follows current-resource closure', async () => { + const fullSource=readFileSync(new URL('./fleet-test-env-acceptance.mjs',import.meta.url),'utf8') + const source=fullSource.slice(fullSource.indexOf('export async function runFleetScenario')) + assert.ok(source.indexOf('const departure = await departNativeFleet')0&&handover { test('checkpoint guard forbids repeating commands whose write outcome may be unknown', () => { const report = { success: false, createdResources: { - fleetAcceptance: { marker: 'M', stage: 'FULL_COVERAGE', status: 'CHECKING', tripNo: 'T', originalDispatch: { dispatchNo: 'D' } }, - fleetTrips: [{ tripNo: 'T', dispatchNo: 'D' }], - fleetFeeCoverageCases: [{ mode: 'FULL', marker: 'M', dispatchNo: 'D', proof: { status: 'CHECKING', requests: [{ method: 'GET' }] } }], + fleetAcceptance: { protocol:'NATIVE_DISPATCH_V1', dispatchNo:'D2', dispatchNos:['D2'], fullTripNo:'F', marker: 'M', stage: 'FULL_COVERAGE', status: 'CHECKING', tripNo: 'T', originalDispatch: { detail:{dispatchNo:'D2'} } }, + fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2' },{tripNo:'F',dispatchNo:'D'}], + fleetFeeCoverageCases: [{ mode: 'FULL', marker: 'M', dispatchNo: 'D', proof: { status: 'CHECKING', requests: [{ method: 'GET',status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t' }] } },{mode:'PARTIAL',marker:'M',dispatchNo:'D2'}], } } assert.equal(reviewedFullCheckpoint(report).marker, 'M') const mutated = structuredClone(report) @@ -719,16 +705,19 @@ test('checkpoint guard forbids repeating commands whose write outcome may be unk mutated.createdResources.fleetFeeCoverageCases[0].proof.outboundNo = 'OUT-1' assert.throws(() => reviewedFullCheckpoint(mutated), /unknown outcomes/) assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) - Object.assign(mutated.createdResources.fleetFeeCoverageCases[0].proof, { deliveryNos: ['DEL-1'], confirmationRequest: { idempotencyKey: 'original-key' } }) - mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ label: '正式保存本次原费用服务覆盖并转派送', method: 'POST', http: 200, code: '200' }) + Object.assign(mutated.createdResources.fleetFeeCoverageCases[0].proof, { deliveryNos: ['DEL-1'], confirmationRequest: { recordVersion:0,groups:[{groupKey:'G'}],idempotencyKey: 'original-key' } }) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ path:'/api/platform/v1/last-leg/dispatches/D/transfer-delivery',label: '正式保存本次原费用服务覆盖并转派送', method: 'POST',status:'RESPONDED',requestId:'r',traceId:'t',http: 200, code: '200', + body: structuredClone(mutated.createdResources.fleetFeeCoverageCases[0].proof.confirmationRequest), result: { action:'ARRANGE_DELIVERY',dispatchNo:'D',status:'ACTIVE',stage:'AFTER_LOADING',result:{outbounds:[{outboundNo:'OUT-1'}],deliveries:[{deliveryNo:'DEL-1'}]} } }) assert.equal(reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED').persisted, true) + mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.at(-1).body.idempotencyKey = 'different-key' + assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) }) test('receipt-ready reconciliation cannot repeat a receipt that has already started', () => { const report = { success: false, steps: [{ name: '创建海外PDA仓库操作员71', code: 'A0409', message: '手机号已存在' }], createdResources: { - fleetAcceptance: { marker: 'M', stage: 'RESERVATIONS', status: 'CHECKING', tripNo: 'T' }, + fleetAcceptance: { protocol:'NATIVE_DISPATCH_V1',dispatchNo:'D2',dispatchNos:['D2'],fullExecution:{closure:{status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED'}},marker: 'M', stage: 'RESERVATIONS', status: 'CHECKING', tripNo: 'T' }, fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2', reservation: { status: 'VERIFIED' } }], - fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2', proof: { status: 'VERIFIED' } }], + fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2' }], } } assert.equal(reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_READY').receiptReady, true) report.createdResources.fleetTrips[0].externalReceipt = { status: 'CHECKING' } @@ -737,12 +726,12 @@ test('receipt-ready reconciliation cannot repeat a receipt that has already star test('palletized checkpoint requires a saved successful command, owned operator, and no later confirmation', () => { const report = { success: false, createdResources: { - fleetAcceptance: { marker: 'M', stage: 'EXTERNAL_RECEIPT', status: 'CHECKING', tripNo: 'T', picker: { membershipId: '71' }, pickerMarker: 'M-PDA' }, + fleetAcceptance: { protocol:'NATIVE_DISPATCH_V1',dispatchNo:'D2',dispatchNos:['D2'],fullExecution:{closure:{status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED'}},marker: 'M', stage: 'EXTERNAL_RECEIPT', status: 'CHECKING', tripNo: 'T', picker: { membershipId: '71' }, pickerMarker: 'M-PDA' }, fleetTrips: [{ tripNo: 'T', dispatchNo: 'D2', reservation: { status: 'VERIFIED' }, externalReceipt: { status: 'CHECKING', warehousePalletId: 'P1', palletizeRequest: { idempotencyKey: 'original' }, requests: [ { label: 'PDA正式完成外接收货及库存入账', method: 'POST', http: 200, code: '200' }, ] } }], - fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2', proof: { status: 'VERIFIED' } }], + fleetFeeCoverageCases: [{ mode: 'FULL', proof: { status: 'VERIFIED' } }, { mode: 'PARTIAL', dispatchNo: 'D2' }], } } assert.equal(reviewedFullCheckpoint(report, 'EXTERNAL_PALLETIZED').palletized, true) assert.throws(() => reviewedFullCheckpoint(report, 'EXTERNAL_RECEIPT_CONFIRMED'), /exact successful PDA command/) @@ -765,35 +754,32 @@ test('palletized checkpoint requires a saved successful command, owned operator, }) function terminalFixture(fault = '') { - const finalTrip = { status: 'COMPLETED', completedAt: '2026-10-02T22:01:00Z', recordVersion: '30', vehicleRelease: { endLocation: 'ONT8' }, driverRelease: { endLocation: 'ONT8' } } - const closure = { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED', finalTrip, probes: [{ tripNo: 'PROBE' }] } - const trip = { tripNo: 'T', closure, externalReceipt: { status: 'VERIFIED' }, reassignment: { status: 'VERIFIED' }, externalDispatch: { status: 'VERIFIED', dispatchNo: 'EXT', deliveryNo: 'DEL-EXT' } } - const proof = { marker: 'M', tripNo: 'T', customerId: '92', stage: 'RECEIPTS_AND_RESOURCE_RELEASE', closure, - receipts: ['DEL-OWN', 'DEL-EXT'].map(deliveryNo => ({ deliveryNo, signedAt: '2026-10-02T22:00:00' })), - departure: { shippedPackages: 6 }, finance: { status: 'CONSUMER_PERSISTED', source: { sourceFeeId: 'F1', sourceVersion: 1 } } } - const resources = { fleetAcceptance: proof, fleetTrips: [trip], fleetFeeCoverageCases: [{ mode: 'PARTIAL', dispatchNo: 'OWN', proof: { deliveryNos: ['DEL-OWN'] } }] } - const calls = [] - const context = { createdResources: resources, assert() {}, async request(method, path) { - calls.push({ method, path }); assert.equal(method, 'GET') - let data - if (path.endsWith('/trips/T')) data = { trip: finalTrip, dispatches: ['OWN', 'EXT'].map(dispatchNo => ({ dispatchNo })) } - else if (path.endsWith('/trips/PROBE')) data = { trip: { status: 'CANCELLED' } } - else if (path.includes('/dispatches/')) { const number = path.split('/').at(-1), count = number === 'OWN' ? 5 : 1 - data = { dispatchNo: number, status: 'DEPARTED', packageCount: count, pickedPackageCount: count, shippedPackageCount: fault === 'quantity' ? 0 : count } } - else if (path.includes('/delivery-orders/')) data = { order: { deliveryNo: path.split('/').at(-1).split('?')[0], status: fault === 'unsigned' ? 'WAIT_DELIVERY' : 'DELIVERED', signedAt: '2026-10-02T22:00:00' } } - else if (path.includes('/fee-pool?')) data = [{ customerId: '92', sourceId: 'F1', sourceVersion: fault === 'wrong-fee-version' ? 2 : 1, amount: 30, currency: 'CNY', sourceType: 'OVERSEAS_DELIVERY_FEE' }] + const finalTrip={status:'COMPLETED',completedAt:'2026-10-02T22:01:00Z',recordVersion:'30',vehicleRelease:{endLocation:'ONT8'},driverRelease:{endLocation:'ONT8'}} + const closure={status:'CARGO_COMPLETED_AND_RESOURCES_RELEASED',finalTrip,subsequentTripClaimProtection:'VERIFIED_NATIVE_EMPTY_DISPATCH',nextDispatch:{dispatchNo:'PROBE',finalStatus:'CANCELLED'}} + const trip={tripNo:'T',dispatchNo:'OWN',closure,externalReceipt:{status:'VERIFIED'},reassignment:{status:'REJECTED_WITHOUT_MUTATION'},externalDispatch:{status:'VERIFIED',dispatchNo:'OWN',deliveryNo:'DEL-EXT'}} + const refs=[{customerId:'92',deliveryNo:'DEL-OWN',outboundNo:'OUT',packageCount:5},{customerId:'92',deliveryNo:'DEL-EXT',outboundNo:'OUT',packageCount:1}] + const proof={protocol:'NATIVE_DISPATCH_V1',marker:'M',tripNo:'T',dispatchNo:'OWN',dispatchNos:['OWN'],customerId:'92',stage:'RECEIPTS_AND_RESOURCE_RELEASE',closure, + receipts:refs.map(row=>({customerId:'92',deliveryNo:row.deliveryNo,signedAt:'2026-10-02T22:00:00'})), + departure:{action:'DEPART',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',result:{departedAt:'2026-10-02T20:00:00Z',shippedPackages:6}},finance:{customerId:'92',status:'CONSUMER_PERSISTED',source:{sourceFeeId:'F1',sourceVersion:1}}} + const resources={fleetAcceptance:proof,fleetTrips:[trip],fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DEL-OWN','DEL-EXT']}}]} + const calls=[],context={createdResources:resources,assert(){},async request(method,path){ + calls.push({method,path});assert.equal(method,'GET');let data + if(path.endsWith('/trips/T'))data={trip:finalTrip,dispatches:[{dispatchNo:'OWN'}]} + else if(path.endsWith('/dispatches/PROBE'))data={dispatchNo:'PROBE',status:'CANCELLED',counts:{packageCount:0,pickedPackageCount:0}} + else if(path.endsWith('/dispatches/OWN'))data={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERED',counts:{packageCount:6,pickedPackageCount:6,shippedPackageCount:fault==='quantity'?0:6},documents:{deliveries:refs}} + else if(path.includes('/delivery-orders/'))data={order:{customerId:'92',deliveryNo:path.split('/').at(-1).split('?')[0],status:fault==='unsigned'?'DELIVERING':'DELIVERED',signedAt:'2026-10-02T22:00:00'}} + else if(path.includes('/fee-pool?'))data=[{customerId:'92',sourceId:'F1',sourceVersion:fault==='wrong-fee-version'?2:1,amount:30,currency:'CNY',sourceType:'OVERSEAS_DELIVERY_FEE'}] else throw Error('unexpected path') - return { http: 200, body: { code: '200', data: structuredClone(data) }, requestId: 'r', traceId: 't' } - } } - return { context, calls, report: { success: false, createdResources: resources, steps: [{ name: '本次车次货物结案及车辆司机释放完成', passed: false }] } } + return {http:200,body:{code:'200',data:structuredClone(data)},requestId:'r',traceId:'t'} + }} + return {context,calls,report:{success:false,createdResources:resources,steps:[{name:'本次车次货物结案及车辆司机释放完成',passed:false}]}} } - test('terminal reconciliation reads actual completion, every dispatch/delivery and exact fee without replaying writes', async () => { const f = terminalFixture() assert.equal(reviewedFullCheckpoint(f.report, 'TERMINAL_READONLY').terminal, true) const result = await verifyFleetTerminalState(f.context) assert.equal(result.terminalReadback.status, 'VERIFIED') - assert.equal(f.calls.length, 7) + assert.equal(f.calls.length, 6) assert.ok(f.calls.every(row => row.method === 'GET')) const invalid = terminalFixture().report invalid.steps.push({ name: '真实业务失败', passed: false }) @@ -805,3 +791,150 @@ for (const fault of ['quantity', 'unsigned', 'wrong-fee-version']) test(`termina await assert.rejects(() => verifyFleetTerminalState(f.context)) assert.equal(f.context.createdResources.fleetAcceptance.terminalReadback.status, 'CHECKING') }) + +function nativeFullSourceFixture() { + const context = { marker: 'RUN', createdResources: { + phase4OverseasPda: { marker: 'RUN', finalStatus: 'COMPLETED', customerId: '11', overseasWarehouseId: '12', palletIds: ['101','102','103'] }, + phase4OverseasDispatch: { marker: 'RUN', status: 'VERIFIED', dispatchNo: 'PARTIAL', customerId: '11', recordVersion: '9007199254740993', + routeLifecycle: { stages: [{ label: '新增同目的地第二托', snapshot: { items: [{ sourceKey: 'PLAN:23', warehousePalletId: '103' }] } }] } } + } } + const original = { warehouseId: '12', data: { dispatchNo: 'PARTIAL', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '9007199254740993', + counts: { pickedPackageCount: 0, shippedPackageCount: 0 } }, items: { items: [ + { sourceKey: 'PLAN:21', warehousePalletId: '101', destination: { key: 'FBA:A' } }, + { sourceKey: 'PLAN:22', warehousePalletId: '102', destination: { key: 'FBA:B' } }] } } + const candidate = { customerId: '11', warehouseId: '12', availablePackages: 2, blockReasons: [], appointmentBatches: [], sourceKey: 'PLAN:23', + sourceVersion: 'a'.repeat(64), destination: { type: 'FBA', key: 'FBA:A' }, selection: { + sourceType: 'INBOUND_PLAN', planPalletId: '23', warehousePalletId: '103', sourceVersion: 'a'.repeat(64) } } + return { context, original, candidate } +} + +test('FULL consumes only the current-run released free duplicate while PARTIAL retains its distinct two destinations', () => { + const f = nativeFullSourceFixture() + assert.equal(selectNativeFleetFullSource(f.context, f.original, [f.candidate]), f.candidate) + for (const mutate of [ + x => x.original.data.recordVersion = '9007199254740992', + x => x.original.data.counts.pickedPackageCount = 1, + x => x.original.items.items[1].destination.key = 'FBA:A', + x => x.candidate.customerId = '99', x => x.candidate.warehouseId = '99', + x => x.candidate.blockReasons.push('已占用'), x => x.candidate.availablePackages = 0, + x => x.context.createdResources.phase4OverseasPda.palletIds = ['101','102'], + x => x.context.createdResources.phase4OverseasDispatch.routeLifecycle.stages = [], + x => x.original.items.items[0].warehousePalletId = '103', + x => x.original.items.items[0].sourceKey = 'PLAN:23' + ]) { const invalid = nativeFullSourceFixture(); mutate(invalid); assert.throws(() => selectNativeFleetFullSource(invalid.context, invalid.original, [invalid.candidate])) } + assert.throws(() => selectNativeFleetFullSource(f.context, f.original, [f.candidate, structuredClone(f.candidate)]), /exactly one/) +}) + +test('native delivery references deduplicate the shared external destination but reject conflicting customer or cargo ownership', () => { + const own = { customerId: '11', deliveryNo: 'D-A', outboundNo: 'OUT-A', groupKey: 'G-A', packageCount: 3 } + const external = { customerId: '22', deliveryNo: 'D-B', outboundNo: 'OUT-B', groupKey: 'G-B', packageCount: 1 } + const detail = { dispatchNo: 'MAIN', documents: { deliveries: [own, external, structuredClone(external)] } } + assert.deepEqual(nativeFleetDeliveries(detail), [own, external]) + for (const changes of [{ customerId: '33' }, { outboundNo: 'FOREIGN' }, { packageCount: 2 }, { groupKey: 'OTHER' }]) { + assert.throws(() => nativeFleetDeliveries({ ...detail, documents: { deliveries: [own, external, { ...external, ...changes }] } })) + } + assert.throws(() => nativeFleetDeliveries({ dispatchNo: 'MAIN', documents: { deliveries: [{ ...own, customerId: null }] } })) + assert.throws(() => nativeFleetDeliveries({ dispatchNo: 'MAIN', documents: { deliveries: [] } })) +}) + +function nativeDepartureFixture(fault = '') { + const refs = [{ customerId: '11', deliveryNo: 'DEL-A', outboundNo: 'OUT-A', packageCount: 2 }, + { customerId: '22', deliveryNo: 'DEL-B', outboundNo: 'OUT-B', packageCount: 1 }] + const before = { dispatchNo: 'MAIN', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993', pickingReconciled: true, + counts: { packageCount: 3, pickedPackageCount: 3, shippedPackageCount: 0 }, documents: { deliveries: refs } } + const task = { ...structuredClone(before), warehouseId: '12', actions: { DEPART: { allowed: true } } } + const items = { dispatchNo: 'MAIN', recordVersion: before.recordVersion, items: [ + { dispatchItemId: '1', scheduledPackages: 2, pickedPackages: 2 }, { dispatchItemId: '2', scheduledPackages: 1, pickedPackages: 1 }] } + if (fault === 'stale-task') task.recordVersion = '9007199254740992' + if (fault === 'wrong-main') task.dispatchNo = 'OTHER' + if (fault === 'not-reconciled') task.pickingReconciled = false + if (fault === 'not-loaded') before.stage = 'LOADING' + if (fault === 'not-arranged') task.actions.DEPART.allowed = false + const command = { dispatchNo: 'MAIN', status: 'ACTIVE', stage: 'DELIVERING', action: 'DEPART', recordVersion: '9007199254740994', replayed: false, + result: { departedAt: '2026-10-19T01:00:00Z', shippedPackages: 3, deliveries: structuredClone(refs) } } + const after = { ...structuredClone(before), stage: 'DELIVERING', recordVersion: command.recordVersion, + counts: { packageCount: 3, pickedPackageCount: 3, shippedPackageCount: 3 } } + let departed = false + const calls = [], saved = [], run = { tripNo: 'TRIP', dispatchNo: 'MAIN' }, trip = { tripNo: 'TRIP', dispatchNo: 'MAIN', warehouseId: '12' } + const context = { async checkpoint() { saved.push(structuredClone(run)) }, async request(method, path, body, token) { + calls.push({ method, path, body: structuredClone(body), token }); let value + if (method === 'POST') { + assert.deepEqual(saved[0].departureRequest, body, 'original current-version command is durable before sending') + assert.equal(body.recordVersion, '9007199254740993') + if (path.startsWith('/api/pda/')) { + assert.equal(token, 'pda'); departed = true; value = structuredClone(command) + if (fault === 'changed-owner') value.result.deliveries[0].customerId = '99' + if (fault === 'changed-quantities') { value.result.deliveries[0].packageCount = 1; value.result.deliveries[1].packageCount = 2 } + } else { + assert.equal(token, undefined); assert.deepEqual(body, calls.find(row => row.method === 'POST').body) + value = { ...structuredClone(command), replayed: fault !== 'replay-miss' } + if (fault === 'replay-version') value.recordVersion = '9007199254740995' + } + } else if (path.startsWith('/api/pda/')) { assert.equal(token, 'pda'); value = task } + else if (path.endsWith('/items')) value = { ...items, recordVersion: departed ? after.recordVersion : before.recordVersion } + else if (path.includes('/delivery-orders/')) { + const ref = refs.find(row => path.includes('/' + row.deliveryNo + '?')) + assert.ok(path.endsWith('customerId=' + ref.customerId)) + value = { order: { ...ref, dispatchNo: 'MAIN', status: 'DELIVERING', version: '3' } } + if (fault === 'foreign-delivery') value.order.customerId = '99' + } else value = departed ? after : before + return { http: 200, body: { code: '200', data: structuredClone(value) }, requestId: 'r', traceId: 't' } + } } + return { context, trip, run, calls, saved } +} + +test('one native current-version PDA DEPART replays exactly through platform and retains every customer delivery version', async () => { + const f = nativeDepartureFixture() + const result = await departNativeFleet(f.context, f.trip, 'pda', f.run) + assert.equal(result.departed, f.run.departure) + assert.deepEqual(f.run.dispatchNos, ['MAIN']) + assert.equal(result.dispatchDetails.length, 1) + assert.deepEqual(f.run.departureDeliveries.map(row => [row.customerId,row.version]), [['11','3'],['22','3']]) + assert.equal(f.calls.filter(row => row.method === 'POST').length, 2) + const before = f.calls.length + await assert.rejects(departNativeFleet(f.context, f.trip, 'pda', f.run), /original command/) + assert.equal(f.calls.length, before, 'no fresh key after an original command exists') +}) + +for (const fault of ['stale-task','wrong-main','not-reconciled','not-loaded','not-arranged','changed-owner','changed-quantities','replay-miss','replay-version','foreign-delivery']) + test(`native departure refuses stale or conflicting actual evidence: ${fault}`, async () => { + const f = nativeDepartureFixture(fault) + await assert.rejects(departNativeFleet(f.context, f.trip, 'pda', f.run)) + if (['stale-task','wrong-main','not-reconciled','not-loaded','not-arranged'].includes(fault)) { + assert.equal(f.calls.filter(row => row.method === 'POST').length, 0) + assert.equal(f.saved.length, 0) + } else assert.ok(f.run.departureRequest?.idempotencyKey, 'sent requests retain their original key') + }) + +test('physical loading requires current-run actual per-field evidence and never fills pallet spaces from planned capacity', async () => { + const calls = [], trip = { dispatchNo: 'MAIN', stops: [{ destinationKey: 'ORIGIN:12' }, { destinationKey: 'FBA:A' }] } + const preview = { dispatchNo: 'MAIN', status: 'ACTIVE', pallets: [{ warehousePalletId: '101', palletNo: 'PALLET', palletSpaces: null, weightKg: null, volumeCbm: null }] } + const context = { marker: 'RUN', createdResources: { phase4OverseasDispatch: {} }, async request(method, path) { + assert.equal(method, 'GET'); calls.push(path) + return { http: 200, body: { code: '200', data: structuredClone(preview) }, requestId: 'r', traceId: 't' } + } } + await assert.rejects(currentFleetMeasurements(context, trip), /Missing current-run actual palletSpaces/) + const actual = { warehousePalletId: '101', palletSpaces: 2, weightKg: 121, volumeCbm: 1.37, deliveryIndex: 1, + evidence: ['palletSpaces','weightKg','volumeCbm'].map(field => ({ field, value: { palletSpaces: 2, weightKg: 121, volumeCbm: 1.37 }[field], + actual: true, sourceType: 'PDA_MEASUREMENT', sourceBusinessNo: 'CURRENT-INCOMING', observedAt: '2026-10-19T01:00:00Z' })) } + context.createdResources.phase4OverseasDispatch.loadingMeasurements = { environment: 'TEST', marker: 'RUN', dispatchNo: 'MAIN', pallets: [actual] } + assert.deepEqual((await currentFleetMeasurements(context, trip)).pallets, [actual]) + for (const mutation of [receipt => receipt.marker = 'OLD', receipt => receipt.dispatchNo = 'OTHER', + receipt => receipt.pallets[0].evidence[0].actual = false, receipt => receipt.pallets[0].evidence[0].value = 1, + receipt => receipt.pallets.push({ ...receipt.pallets[0], warehousePalletId: '999' })]) { + const receipt = structuredClone(context.createdResources.phase4OverseasDispatch.loadingMeasurements) + mutation(receipt) + await assert.rejects(currentFleetMeasurements({ ...context, createdResources: { phase4OverseasDispatch: { loadingMeasurements: receipt } } }, trip)) + } + assert.equal(calls.length, 7) +}) + +test('native checkpoint rejects retired flat reports, multi-main binding and globally unknown writes', () => { + for (const mutation of [report => delete report.createdResources.fleetAcceptance.protocol, + report => report.createdResources.fleetAcceptance.dispatchNos.push('OTHER'), + report => report.createdResources.fleetTrips[0].dispatchNo = 'OTHER', + report => report.createdResources.fleetWriteBoundary = { status: 'STOPPED', outcome: 'UNKNOWN_RESULT' }]) { + const report = terminalFixture().report; mutation(report) + assert.throws(() => reviewedFullCheckpoint(report, 'TERMINAL_READONLY'), /native single-dispatch reports/) + } +}) diff --git a/scripts/fleet-trip-rollback-test-env.mjs b/scripts/fleet-trip-rollback-test-env.mjs index 51dce2e60..beed04da1 100644 --- a/scripts/fleet-trip-rollback-test-env.mjs +++ b/scripts/fleet-trip-rollback-test-env.mjs @@ -6,137 +6,405 @@ import { pathToFileURL } from 'node:url' import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-customer-test-env.mjs' import { verifyFleetCrossCustomer } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' -const demand=(v,message)=>{if(!v)throw Error(message)} -const literal=v=>`CONVERT(0x${Buffer.from(v).toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` -const encoded=v=>'['+Buffer.from(v).toString('base64').match(/.{1,4000}/g).map(x=>`'${x}'`).join(',')+"].join('')" -const id=v=>String(v??'') -const rr=['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;','START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] -const pause=ms=>new Promise(done=>setTimeout(done,ms)) -const canonical=v=>JSON.stringify(sortFields(v)) -function sortFields(v){return Array.isArray(v)?v.map(sortFields):v&&typeof v==='object'?Object.fromEntries(Object.keys(v).sort().map(k=>[k,sortFields(v[k])])):v} -export function validateRollbackScope(s) { - demand(s&&/^[A-Za-z0-9_-]{1,64}$/.test(s.marker||'')&&/^[A-Za-z0-9_-]{1,64}$/.test(s.tripNo||''),'ROLLBACK_SCOPE_REQUIRED') - for(const key of ['firstCustomerId','secondCustomerId','firstDispatchId','secondDispatchId','firstOutboundId','secondPalletId']) - demand(typeof s[key]==='string'&&/^[1-9][0-9]{0,19}$/.test(s[key]),'ROLLBACK_IDENTIFIER_INVALID') - demand(s.firstCustomerId!==s.secondCustomerId&&s.firstDispatchId!==s.secondDispatchId&&/^sha256:[a-f0-9]{64}$/.test(s.expectedImage||''),'ROLLBACK_DISTINCT_CUSTOMERS_AND_RUNTIME_REQUIRED') - demand(/^[A-Za-z0-9_-]{1,64}$/.test(s.firstDispatchNo||''),'ROLLBACK_DISPATCH_NUMBER_INVALID') - return s +const demand = (value, message) => { if (!value) throw Error(message) } +const identifier = value => typeof value === 'string' && /^[1-9][0-9]{0,19}$/.test(value) +const businessNo = value => typeof value === 'string' && /^[A-Za-z0-9_-]{1,128}$/.test(value) +const literal = value => `CONVERT(0x${Buffer.from(value).toString('hex')} USING utf8mb4) COLLATE utf8mb4_bin` +const encoded = value => '[' + Buffer.from(value).toString('base64').match(/.{1,4000}/g).map(x => `'${x}'`).join(',') + "].join('')" +const pause = ms => new Promise(done => setTimeout(done, ms)) +const canonical = value => JSON.stringify(sortFields(value)) +const sha = value => createHash('sha256').update(canonical(value)).digest('hex') +function sortFields(value) { + return Array.isArray(value) ? value.map(sortFields) : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, sortFields(value[key])])) : value } -export function rollbackSql(scope,mode) { - const s=validateRollbackScope(scope),second=`customer_id=${literal(s.secondCustomerId)} AND dispatch_order_id=${s.secondDispatchId} AND warehouse_pallet_id=${s.secondPalletId} AND status='ACTIVE'` - if(mode==='LEASE')return `SET SESSION innodb_lock_wait_timeout=5;START TRANSACTION;SELECT JSON_OBJECT('lockId',CAST(id AS CHAR),'customerId',customer_id,'connectionId',CONNECTION_ID()) FROM ll_dispatch_pallet_lock WHERE ${second} FOR UPDATE;SELECT JSON_OBJECT('marker','ROLLBACK_${s.marker}','elapsed',SLEEP(65));ROLLBACK;SELECT JSON_OBJECT('released',JSON_EXTRACT('true','$'));` - const lease=`SELECT JSON_OBJECT('collection','lease','connectionId',t.trx_mysql_thread_id,'lockId',l.LOCK_DATA) FROM performance_schema.data_locks l JOIN information_schema.innodb_trx t ON t.trx_id=l.ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_dispatch_pallet_lock' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_STATUS='GRANTED' AND l.LOCK_DATA IN (SELECT CAST(id AS CHAR) FROM ll_dispatch_pallet_lock WHERE ${second}) AND t.trx_query LIKE ${literal('%ROLLBACK_'+s.marker+'%')};` - const waits=`SELECT JSON_OBJECT('collection','wait','requester',r.trx_mysql_thread_id,'blocker',b.trx_mysql_thread_id,'lockId',l.LOCK_DATA) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID JOIN information_schema.innodb_trx r ON r.trx_id=w.REQUESTING_ENGINE_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id=w.BLOCKING_ENGINE_TRANSACTION_ID WHERE l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_dispatch_pallet_lock' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_DATA IN (SELECT CAST(id AS CHAR) FROM ll_dispatch_pallet_lock WHERE ${second});` - if(mode==='LOCKS')return [...rr,lease,waits,'COMMIT;'].join('\n') - if(mode==='OBSERVE')return ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;','SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;','START TRANSACTION READ ONLY;',lease,waits, - `SELECT JSON_OBJECT('collection','firstDispatch','id',CAST(id AS CHAR),'status',status,'shipped',shipped_package_count) FROM ll_dispatch_order WHERE id=${s.firstDispatchId} AND customer_id=${literal(s.firstCustomerId)};`, - `SELECT JSON_OBJECT('collection','firstOutbound','id',CAST(id AS CHAR),'status',status) FROM ll_outbound_order WHERE id=${s.firstOutboundId} AND customer_id=${literal(s.firstCustomerId)};`, - `SELECT JSON_OBJECT('collection','firstReplay','count',COUNT(*)) FROM ll_dispatch_pda_operation WHERE customer_id=${literal(s.firstCustomerId)} AND operation_type='DEPART' AND idempotency_key=${literal(s.tripNo+':'+s.firstDispatchNo)};`,'COMMIT;'].join('\n') - if(mode==='RELEASED')return [...rr,lease,waits,'COMMIT;'].join('\n') +const rr = ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL REPEATABLE READ;', 'START TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;'] +const databaseRuntime = "SELECT JSON_OBJECT('collection','databaseRuntime','serverUuid',@@server_uuid,'databaseName',DATABASE(),'performanceSchema',@@performance_schema);" +// Every column is obtained from the actual schema; no partial projection can masquerade as a full rollback baseline. +const tables = ['ll_dispatch_order', 'll_dispatch_history', 'll_dispatch_item', 'll_dispatch_command', 'll_dispatch_pallet_lock', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard', 'll_dispatch_item_source', + 'll_fleet_trip', 'll_fleet_trip_dispatch', 'll_fleet_trip_history', 'll_fleet_pool_order_link', 'll_outbound_order', 'll_delivery_order', + 'll_order', 'll_loading_order_status_history', 'll_warehouse_transfer_order', 'll_dispatch_pda_task', 'll_dispatch_pda_pick', + 'll_dispatch_pda_pick_order', 'll_inventory_balance', 'll_inventory_position', 'll_inventory_ledger'] +const columnsSql = `SELECT JSON_OBJECT('collection','column','table',TABLE_NAME,'column',COLUMN_NAME,'ordinal',ORDINAL_POSITION,'type',COLUMN_TYPE,'key',COLUMN_KEY) FROM information_schema.columns WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN (${tables.map(literal).join(',')}) ORDER BY TABLE_NAME,ORDINAL_POSITION;` +const projection = (collection, table, fields, where) => `SELECT JSON_OBJECT('collection','${collection}',${Object.entries(fields).map(([key, expression]) => `'${key}',CAST(${expression} AS CHAR)`).join(',')}) FROM ${table} WHERE ${where};` +const one = (rows, collection) => { const found = rows.filter(row => row.collection === collection); demand(found.length === 1, `EXACT_${collection.toUpperCase()}_REQUIRED`); return found[0] } + +export function validateRollbackRequest(scope) { + demand(scope && businessNo(scope.marker) && businessNo(scope.tripNo) && businessNo(scope.dispatchNo) + && businessNo(scope.commandKey) && identifier(scope.commandVersion) && identifier(scope.warehouseId) + && /^sha256:[a-f0-9]{64}$/.test(scope.expectedImage || ''), 'NATIVE_ROLLBACK_REQUEST_REQUIRED') + demand(scope.members?.length === 2 && new Set(scope.members.map(m => m.customerId)).size === 2, 'TWO_DISTINCT_CUSTOMERS_REQUIRED') + for (const member of scope.members) { + demand(['customerId', 'dispatchItemId', 'palletId', 'positionId', 'balanceId'].every(field => identifier(member[field])) + && ['incomingNo', 'outboundNo', 'deliveryNo'].every(field => businessNo(member[field])), 'OWNED_MEMBER_IDENTITIES_REQUIRED') + } + for (const field of ['incomingNo', 'dispatchItemId', 'palletId', 'positionId', 'balanceId', 'outboundNo', 'deliveryNo']) + demand(new Set(scope.members.map(m => m[field])).size === 2, 'MEMBER_IDENTITY_REUSED') + return scope +} + +function resolutionSql(scope) { + validateRollbackRequest(scope) + const root = `(SELECT id FROM ll_dispatch_order WHERE dispatch_no=${literal(scope.dispatchNo)})` + const trip = `(SELECT id FROM ll_fleet_trip WHERE trip_no=${literal(scope.tripNo)})` + const items = `dispatch_order_id=${root}` + const sourceOrders = `(SELECT order_id FROM ll_dispatch_item WHERE ${items})` + return [...rr, databaseRuntime, columnsSql, + projection('root', 'll_dispatch_order', { id: 'id', dispatchNo: 'dispatch_no', warehouseId: 'overseas_warehouse_id', version: 'record_version', status: 'status', stage: 'stage' }, `dispatch_no=${literal(scope.dispatchNo)}`), + projection('trip', 'll_fleet_trip', { id: 'id', tripNo: 'trip_no', warehouseId: 'warehouse_id', status: 'status' }, `trip_no=${literal(scope.tripNo)}`), + projection('binding', 'll_fleet_trip_dispatch', { dispatchId: 'dispatch_id', dispatchNo: 'dispatch_no', tripId: 'trip_id', tripNo: 'trip_no' }, `trip_id=${trip} OR dispatch_id=${root}`), + projection('item', 'll_dispatch_item', { id: 'id', dispatchId: 'dispatch_order_id', customerId: 'customer_id', palletId: 'warehouse_pallet_id', orderId: 'order_id', sourceType: 'source_business_type', sourceNo: 'source_business_no', incomingNo: 'inbound_code', packages: 'package_count' }, items), + projection('transfer', 'll_warehouse_transfer_order', { id: 'id', customerId: 'customer_id', incomingNo: 'transfer_no', orderId: 'lastleg_order_id', warehouseId: 'operating_warehouse_id', needRelabel: 'need_relabel', relabelNo: 'relabel_no', outboundAt: 'outbound_at' }, `lastleg_order_id IN ${sourceOrders}`), + projection('outbound', 'll_outbound_order', { id: 'id', customerId: 'customer_id', dispatchId: 'dispatch_order_id', outboundNo: 'outbound_no', status: 'status' }, items), + projection('delivery', 'll_delivery_order', { id: 'id', customerId: 'customer_id', dispatchId: 'dispatch_order_id', deliveryNo: 'delivery_no', outboundNo: 'outbound_no', status: 'status' }, items), + projection('task', 'll_dispatch_pda_task', { id: 'id', dispatchId: 'dispatch_order_id', warehouseId: 'warehouse_id' }, items), + 'COMMIT;'].join('\n') +} + +export function resolveRollbackScope(request, rows) { + validateRollbackRequest(request) + const root = one(rows, 'root'), trip = one(rows, 'trip'), binding = one(rows, 'binding'), task = one(rows, 'task') + demand(identifier(root.id) && identifier(trip.id) && identifier(task.id) && root.dispatchNo === request.dispatchNo + && root.warehouseId === request.warehouseId && root.version === request.commandVersion && root.status === 'ACTIVE' && root.stage === 'AFTER_LOADING' + && trip.tripNo === request.tripNo && trip.warehouseId === request.warehouseId && trip.status === 'PLANNED' + && binding.dispatchId === root.id && binding.tripId === trip.id && binding.dispatchNo === request.dispatchNo && binding.tripNo === request.tripNo + && task.dispatchId === root.id && task.warehouseId === request.warehouseId, 'ONE_NATIVE_ROOT_AND_FLEET_REQUIRED') + for (const collection of ['item', 'transfer', 'outbound', 'delivery']) demand(rows.filter(r => r.collection === collection).length === 2, `TWO_${collection.toUpperCase()}_ROWS_REQUIRED`) + const members = request.members.map(member => { + const own = collection => one(rows.filter(r => r.customerId === member.customerId), collection) + const item = own('item'), transfer = own('transfer'), outbound = own('outbound'), delivery = own('delivery') + demand(item.id === member.dispatchItemId && item.dispatchId === root.id && item.palletId === member.palletId && identifier(item.orderId) + // Native ordinary cargo leaves source_business_type/no NULL; the actual transfer/order/receipt links prove its origin. + && item.sourceType === null && item.sourceNo === null && item.incomingNo === member.incomingNo && item.packages === '1' + && identifier(transfer.id) && transfer.orderId === item.orderId && transfer.incomingNo === member.incomingNo + && transfer.warehouseId === request.warehouseId && transfer.needRelabel === '0' && transfer.relabelNo === null && transfer.outboundAt === null + && identifier(outbound.id) && outbound.dispatchId === root.id && outbound.outboundNo === member.outboundNo && outbound.status === 'WAIT_OUTBOUND' + && identifier(delivery.id) && delivery.dispatchId === root.id && delivery.deliveryNo === member.deliveryNo + && delivery.outboundNo === member.outboundNo && delivery.status === 'WAIT_DELIVERY', 'REAL_ORDINARY_TRANSFER_AND_CHILD_LINKS_REQUIRED') + return { ...member, orderId: item.orderId, transferId: transfer.id, outboundId: outbound.id, deliveryId: delivery.id } + }).sort((a, b) => a.customerId < b.customerId ? -1 : a.customerId > b.customerId ? 1 : 0) // Java String.compareTo for decimal customer IDs; never numeric/locale order. + const scope = { ...request, dispatchId: root.id, tripId: trip.id, taskId: task.id, members, + columns: rows.filter(row => row.collection === 'column') } + return validateRollbackScope(scope) +} + +export function validateRollbackScope(scope) { + validateRollbackRequest(scope) + demand(['dispatchId', 'tripId', 'taskId'].every(field => identifier(scope[field])) + && scope.members.every(member => ['orderId', 'transferId', 'outboundId', 'deliveryId'].every(field => identifier(member[field]))) + && scope.members[0].customerId < scope.members[1].customerId, 'RESOLVED_SHARED_ROOT_AND_JAVA_CUSTOMER_ORDER_REQUIRED') + for (const field of ['orderId', 'transferId', 'outboundId', 'deliveryId']) demand(new Set(scope.members.map(m => m[field])).size === 2, 'RESOLVED_MEMBER_ID_REUSED') + if (scope.observedTransactions) demand(scope.observedTransactions.length === 2 && scope.observedTransactions.every(identifier) + && new Set(scope.observedTransactions).size === 2, 'EXACT_OBSERVED_TRANSACTION_IDS_REQUIRED') + demand(Array.isArray(scope.columns) && scope.columns.every(c => c.collection === 'column' && tables.includes(c.table) + && /^[a-z][a-z0-9_]*$/.test(c.column) && Number.isSafeInteger(c.ordinal) && c.ordinal > 0 && typeof c.type === 'string'), 'ACTUAL_SCHEMA_COLUMNS_REQUIRED') + for (const table of tables) { + const columns = scope.columns.filter(c => c.table === table) + demand(columns.length > 0 && columns.some(c => c.key === 'PRI') && new Set(columns.map(c => c.column)).size === columns.length + && columns.every((c, i) => c.ordinal === i + 1), 'COMPLETE_ACTUAL_SCHEMA_REQUIRED') + } + const fields = table => new Set(scope.columns.filter(column => column.table === table).map(column => column.column)) + const guard = scope.columns.filter(column => column.table === 'll_physical_pallet_guard') + demand(guard.length === 2 && guard.some(column => column.column === 'warehouse_pallet_id' && column.key === 'PRI' && column.type.toLowerCase() === 'bigint') + && guard.some(column => column.column === 'created_at' && column.key !== 'PRI' && column.type.toLowerCase() === 'datetime(3)'), 'PERMANENT_GLOBAL_PHYSICAL_PALLET_GUARD_SCHEMA_REQUIRED') + demand(fields('ll_dispatch_physical_pallet_lock').has('active_warehouse_pallet_id') + && !fields('ll_dispatch_physical_pallet_lock').has('customer_id') + && fields('ll_dispatch_pallet_lock').has('physical_lock_id') && !fields('ll_dispatch_pallet_lock').has('active_warehouse_pallet_id') + && fields('ll_dispatch_item_source').has('dispatch_item_id') && fields('ll_dispatch_pda_pick_order').has('dispatch_item_source_id'), + 'CURRENT_PHYSICAL_AND_LOGICAL_SOURCE_SCHEMA_REQUIRED') + return scope +} + +function snapshotSql(scope) { + const source = scope.members.map(m => `(customer_id=${literal(m.customerId)} AND id=${m.orderId})`).join(' OR ') + const histories = scope.members.map(m => `(customer_id=${literal(m.customerId)} AND order_id=${m.orderId})`).join(' OR ') + const customer = `customer_id IN (${scope.members.map(m => literal(m.customerId)).join(',')})` + const stock = `${customer} AND overseas_warehouse_id=${literal(scope.warehouseId)}` + const predicates = { + ll_dispatch_order: `id=${scope.dispatchId}`, ll_dispatch_history: `dispatch_id=${scope.dispatchId}`, + ll_dispatch_item: `dispatch_order_id=${scope.dispatchId}`, ll_dispatch_command: `dispatch_id=${scope.dispatchId}`, + ll_dispatch_pallet_lock: `dispatch_order_id=${scope.dispatchId}`, + ll_dispatch_physical_pallet_lock: `dispatch_order_id=${scope.dispatchId} OR warehouse_pallet_id IN (${scope.members.map(member => member.palletId).join(',')})`, + ll_physical_pallet_guard: `warehouse_pallet_id IN (${scope.members.map(member => member.palletId).join(',')})`, + ll_dispatch_item_source: `dispatch_order_id=${scope.dispatchId} OR dispatch_item_id IN (${scope.members.map(member => member.dispatchItemId).join(',')})`, + ll_fleet_trip: `id=${scope.tripId}`, + ll_fleet_trip_dispatch: `trip_id=${scope.tripId} OR dispatch_id=${scope.dispatchId}`, ll_fleet_trip_history: `trip_id=${scope.tripId}`, + ll_fleet_pool_order_link: `dispatch_no=${literal(scope.dispatchNo)}`, ll_outbound_order: `dispatch_order_id=${scope.dispatchId}`, + ll_delivery_order: `dispatch_order_id=${scope.dispatchId}`, ll_order: source, ll_loading_order_status_history: histories, + ll_warehouse_transfer_order: `lastleg_order_id IN (${scope.members.map(m => m.orderId).join(',')})`, + ll_dispatch_pda_task: `dispatch_order_id=${scope.dispatchId}`, ll_dispatch_pda_pick: `task_id=${scope.taskId}`, + ll_dispatch_pda_pick_order: `pick_id IN (SELECT id FROM ll_dispatch_pda_pick WHERE task_id=${scope.taskId})`, + ll_inventory_balance: stock, ll_inventory_position: stock, ll_inventory_ledger: stock, + } + return [columnsSql, ...tables.flatMap(table => { + const columns = scope.columns.filter(c => c.table === table) + const json = columns.map(c => `'${c.column}',CAST(\`${c.column}\` AS CHAR CHARACTER SET utf8mb4)`).join(',') + return [`SELECT JSON_OBJECT('collection','coverage','table','${table}','count',COUNT(*)) FROM ${table} WHERE ${predicates[table]};`, + `SELECT JSON_OBJECT('collection','row','table','${table}','row',JSON_OBJECT(${json})) FROM ${table} WHERE ${predicates[table]};`] + })].join('\n') +} + +function lockSql(scope) { + const target = scope.members[1] + const held = `l.OBJECT_SCHEMA=DATABASE() AND l.OBJECT_NAME='ll_warehouse_transfer_order' AND l.INDEX_NAME='PRIMARY' AND l.LOCK_DATA='${target.transferId}'` + const lease = `SELECT JSON_OBJECT('collection','lease','connectionId',CAST(t.trx_mysql_thread_id AS CHAR),'transactionId',CAST(t.trx_id AS CHAR),'lockId',l.LOCK_DATA,'mode',l.LOCK_MODE,'ageSeconds',TIMESTAMPDIFF(SECOND,t.trx_started,NOW())) FROM performance_schema.data_locks l JOIN information_schema.innodb_trx t ON t.trx_id=l.ENGINE_TRANSACTION_ID WHERE ${held} AND l.LOCK_STATUS='GRANTED' AND t.trx_query LIKE ${literal('%ROLLBACK_' + scope.marker + '%')};` + const waits = `SELECT JSON_OBJECT('collection','wait','requester',CAST(r.trx_mysql_thread_id AS CHAR),'blocker',CAST(b.trx_mysql_thread_id AS CHAR),'requestingTransactionId',CAST(r.trx_id AS CHAR),'blockingTransactionId',CAST(b.trx_id AS CHAR),'lockId',l.LOCK_DATA,'blockingLockId',bl.LOCK_DATA,'objectName',l.OBJECT_NAME,'indexName',l.INDEX_NAME,'requestStatus',l.LOCK_STATUS,'blockingStatus',bl.LOCK_STATUS,'statement',r.trx_query,'sessionLockWaitSeconds',v.VARIABLE_VALUE,'ownsRoot',EXISTS(SELECT 1 FROM performance_schema.data_locks dl WHERE dl.ENGINE_TRANSACTION_ID=r.trx_id AND dl.OBJECT_SCHEMA=DATABASE() AND dl.OBJECT_NAME='ll_dispatch_order' AND dl.INDEX_NAME='PRIMARY' AND dl.LOCK_DATA='${scope.dispatchId}' AND dl.LOCK_STATUS='GRANTED' AND dl.LOCK_MODE LIKE 'X%')) FROM performance_schema.data_lock_waits w JOIN performance_schema.data_locks l ON l.ENGINE_LOCK_ID=w.REQUESTING_ENGINE_LOCK_ID AND l.ENGINE=w.ENGINE JOIN performance_schema.data_locks bl ON bl.ENGINE_LOCK_ID=w.BLOCKING_ENGINE_LOCK_ID AND bl.ENGINE=w.ENGINE JOIN information_schema.innodb_trx r ON r.trx_id=w.REQUESTING_ENGINE_TRANSACTION_ID JOIN information_schema.innodb_trx b ON b.trx_id=w.BLOCKING_ENGINE_TRANSACTION_ID JOIN performance_schema.threads th ON th.PROCESSLIST_ID=r.trx_mysql_thread_id JOIN performance_schema.variables_by_thread v ON v.THREAD_ID=th.THREAD_ID AND v.VARIABLE_NAME='innodb_lock_wait_timeout' WHERE ${held} AND bl.OBJECT_SCHEMA=DATABASE() AND bl.OBJECT_NAME=l.OBJECT_NAME AND bl.INDEX_NAME='PRIMARY' AND bl.LOCK_DATA=l.LOCK_DATA AND b.trx_query LIKE ${literal('%ROLLBACK_' + scope.marker + '%')};` + return [lease, waits] +} + +export function rollbackSql(scope, mode) { + if (mode === 'RESOLVE') return resolutionSql(scope) + validateRollbackScope(scope) + const second = scope.members[1] + if (mode === 'LEASE') return `SET NAMES utf8mb4 COLLATE utf8mb4_bin;${databaseRuntime}SET SESSION innodb_lock_wait_timeout=5;START TRANSACTION;SELECT JSON_OBJECT('collection','held','lockId',CAST(id AS CHAR),'customerId',customer_id,'connectionId',CAST(CONNECTION_ID() AS CHAR)) FROM ll_warehouse_transfer_order WHERE id=${second.transferId} AND customer_id=${literal(second.customerId)} AND lastleg_order_id=${second.orderId} AND transfer_no=${literal(second.incomingNo)} AND need_relabel=0 AND relabel_no IS NULL AND outbound_at IS NULL FOR UPDATE;SELECT JSON_OBJECT('marker','ROLLBACK_${scope.marker}','elapsed',SLEEP(65));ROLLBACK;SELECT JSON_OBJECT('released',JSON_EXTRACT('true','$'));` + if (mode === 'LOCKS' || mode === 'RELEASED') return [...rr, databaseRuntime, ...lockSql(scope), + ...(mode === 'RELEASED' ? [(() => { + demand(scope.observedTransactions, 'OBSERVED_TRANSACTIONS_REQUIRED_FOR_RELEASE_PROOF') + return `SELECT JSON_OBJECT('collection','remainingTransaction','transactionId',CAST(trx_id AS CHAR)) FROM information_schema.innodb_trx WHERE trx_id IN (${scope.observedTransactions.map(literal).join(',')});` + })()] : []), 'COMMIT;'].join('\n') + if (['BASELINE', 'RESTORED', 'OBSERVE'].includes(mode)) return [ + ...(mode === 'OBSERVE' ? ['SET NAMES utf8mb4 COLLATE utf8mb4_bin;', 'SET SESSION TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;', 'START TRANSACTION READ ONLY;'] : rr), + databaseRuntime, ...(mode === 'OBSERVE' ? lockSql(scope) : []), snapshotSql(scope), 'COMMIT;'].join('\n') throw Error('ROLLBACK_MODE_INVALID') } -export function buildRollbackRemoteScript(scope,mode,agent) { - demand(['RUNTIME','LEASE','LOCKS','OBSERVE','RELEASED'].includes(mode)&&/^[A-Za-z0-9_.-]{1,128}$/.test(agent||''),'ROLLBACK_REMOTE_MODE_REQUIRED') - const sql=mode==='RUNTIME'?"SELECT JSON_OBJECT('lockWaitSeconds',@@GLOBAL.innodb_lock_wait_timeout);":rollbackSql(scope,mode) - const imageGuard=mode==='RUNTIME'?'':` or c['Image']!='${scope.expectedImage}'` - const python=`import json,os,subprocess,urllib.parse,re + +export function buildRollbackRemoteScript(scope, mode, agent) { + demand(['RUNTIME', 'RESOLVE', 'LEASE', 'LOCKS', 'OBSERVE', 'BASELINE', 'RESTORED', 'RELEASED'].includes(mode) + && /^[A-Za-z0-9_.-]{1,128}$/.test(agent || ''), 'ROLLBACK_REMOTE_MODE_REQUIRED') + const sql = mode === 'RUNTIME' ? [databaseRuntime, + ...['data_locks', 'data_lock_waits', 'threads', 'variables_by_thread'].map(table => `SELECT JSON_OBJECT('collection','privilege','table','${table}','count',COUNT(*)) FROM performance_schema.${table} WHERE 1=0;`), + "SELECT JSON_OBJECT('collection','privilege','table','innodb_trx','count',COUNT(*)) FROM information_schema.innodb_trx WHERE 1=0;"].join('\n') : rollbackSql(scope, mode) + const imageGuard = mode === 'RUNTIME' ? '' : ` or c['Image']!='${scope.expectedImage}'` + const python = `import json,os,subprocess,urllib.parse,re try: c=json.loads(subprocess.check_output(['docker','inspect','firstleg-test-lastleg'],text=True,stderr=subprocess.DEVNULL))[0] e=dict(x.split('=',1) for x in c['Config']['Env']) if c['Name']!='/firstleg-test-lastleg' or c['Config'].get('Labels',{}).get('com.docker.compose.project')!='firstleg-test' or e.get('DEPLOY_ENV')!='test' or c['State'].get('Health',{}).get('Status')!='healthy'${imageGuard}:raise RuntimeError('TEST_RUNTIME_CHANGED') u=urllib.parse.urlsplit(e['LASTLEG_DB_URL'].removeprefix('jdbc:'));host='127.0.0.1' if u.hostname=='host.docker.internal' else u.hostname - q=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['LASTLEG_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=${JSON.stringify(sql)},capture_output=True,text=True,env=dict(os.environ,MYSQL_PWD=e['LASTLEG_DB_PASSWORD']),timeout=${mode==='LEASE'?75:25}) + q=subprocess.run(['mysql','--host='+host,'--port='+str(u.port or 3306),'--user='+e['LASTLEG_DB_USERNAME'],'--batch','--raw','--skip-column-names',u.path.lstrip('/')],input=${JSON.stringify(sql)},capture_output=True,text=True,env=dict(os.environ,MYSQL_PWD=e['LASTLEG_DB_PASSWORD']),timeout=${mode === 'LEASE' ? 75 : 25}) if q.returncode: code=re.search(r'ERROR ([0-9]{3,5})',q.stderr);print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':False,'error':'MYSQL_'+(code.group(1) if code else 'UNKNOWN')})) else:print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':True,'imageId':c['Image'],'rows':[json.loads(x) for x in q.stdout.splitlines() if x.strip()]})) except Exception as error: print('TRIP_ROLLBACK_JSON='+json.dumps({'ok':False,'error':str(error) if re.fullmatch('[A-Z_]{1,64}',str(error)) else 'REMOTE_ROLLBACK_FAILED'})) ` - const groovy=`def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${encoded(python)}),'UTF-8')]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` + const groovy = `def p=new ProcessBuilder(['python3','-c',new String(java.util.Base64.decoder.decode(${encoded(python)}),'UTF-8')]).redirectErrorStream(true).start();def out=p.inputStream.getText('UTF-8');p.waitFor();print(out)` return `def c=jenkins.model.Jenkins.instance.getComputer('${agent}');if(c==null||c.channel==null)throw new RuntimeException('TEST agent unavailable');print(hudson.util.RemotingDiagnostics.executeGroovy(new String(java.util.Base64.decoder.decode(${encoded(groovy)}),'UTF-8'),c.channel))` } -export function assertMidTransactionObservation(rows) { - const one=name=>rows.filter(x=>x.collection===name) - const lease=one('lease'),wait=one('wait'),dispatch=one('firstDispatch'),outbound=one('firstOutbound'),replay=one('firstReplay') - demand(lease.length===1&&wait.length===1&&wait[0].blocker===lease[0].connectionId&&wait[0].lockId===lease[0].lockId&&wait[0].requester!==wait[0].blocker,'OWNED_LATE_ROW_WAIT_NOT_PROVED') - demand(dispatch.length===1&&dispatch[0].status==='DEPARTED'&&dispatch[0].shipped===1&&outbound.length===1&&outbound[0].status==='OUTBOUNDED'&&replay.length===1&&replay[0].count===1,'FIRST_CUSTOMER_UNCOMMITTED_WRITE_NOT_PROVED') - return {leaseConnectionId:lease[0].connectionId,requestingConnectionId:wait[0].requester,lockId:lease[0].lockId,dirtyObservationOnly:true} -} -export function requestRollbackDeparture(context,path,body,token) { - // The normal JSON request stops at 30 seconds. This deliberate 50-second - // database timeout needs the existing 60-second, single-attempt request path. - return context.requestWithBody('POST',path,body,token) -} -export function assertDatabaseLockConflict(response) { - demand(response?.http===200&&String(response.body?.code)==='A0409' - &&response.body?.message==='资源正在处理中,请稍后重试' - &&response.requestId&&response.traceId,'EXPECTED_DATABASE_LOCK_CONFLICT_REQUIRED') -} -async function remote(env,scope,mode) { - const script=buildRollbackRemoteScript(scope,mode,env.FBA_CORE_JENKINS_AGENT) - const response=await fetch(new URL('scriptText',env.JENKINS_URL.replace(/\/?$/,'/')),{method:'POST',redirect:'error',signal:AbortSignal.timeout(mode==='LEASE'?95000:45000),headers:{Authorization:'Basic '+Buffer.from(env.JENKINS_USER+':'+env.JENKINS_API_TOKEN).toString('base64'),'Content-Type':'application/x-www-form-urlencoded'},body:new URLSearchParams({script})}) - demand(response.ok,'ROLLBACK_EVIDENCE_HTTP_FAILED') - const lines=(await response.text()).split(/\r?\n/).filter(x=>x.startsWith('TRIP_ROLLBACK_JSON='));demand(lines.length===1,'ROLLBACK_REMOTE_RESULT_AMBIGUOUS') - const result=JSON.parse(lines[0].slice('TRIP_ROLLBACK_JSON='.length));demand(result.ok===true,/^[A-Z_0-9]+$/.test(result.error||'')?result.error:'ROLLBACK_REMOTE_FAILED') + +export function fullRollbackSnapshot(rows, scope) { + validateRollbackScope(scope) + demand(canonical(rows.filter(r => r.collection === 'column')) === canonical(scope.columns), 'ACTUAL_SCHEMA_CHANGED') + const result = {} + for (const table of tables) { + const coverage = one(rows.filter(r => r.table === table), 'coverage') + const values = rows.filter(r => r.collection === 'row' && r.table === table).map(r => r.row) + const columns = scope.columns.filter(c => c.table === table).map(c => c.column).sort() + demand(Number.isSafeInteger(coverage.count) && coverage.count === values.length + && values.every(r => canonical(Object.keys(r).sort()) === canonical(columns) && Object.values(r).every(v => v === null || typeof v === 'string')) + && new Set(values.map(canonical)).size === values.length, 'COMPLETE_FULL_FIELD_SNAPSHOT_REQUIRED') + result[table] = values.sort((a, b) => canonical(a) < canonical(b) ? -1 : canonical(a) > canonical(b) ? 1 : 0) + } return result } -export function createDepartureRollbackVerifier(env,config,runtime) { - return async({context,fixture,ready,departPath,departBody,snapshot})=>{ - const output=resolve('reports',`fleet-cross-${config.runId}`,'rollback.json'),fd=fs.openSync(output,'wx',0o600) - const report={verified:false,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK',startedAt:new Date().toISOString(),samples:[],command:{path:departPath,body:departBody},runtime} - const save=()=>{fs.ftruncateSync(fd,0);fs.writeSync(fd,JSON.stringify(report,null,2),0,'utf8');fs.fsyncSync(fd)} - let leasePromise,pending +const exactRow = (snapshot, table, id) => { + const found = snapshot[table].filter(row => row.id === id) + demand(found.length === 1, `EXACT_OWNED_ROW_REQUIRED_${table}`); return found[0] +} +const immutableTables = ['ll_physical_pallet_guard', 'll_dispatch_physical_pallet_lock', 'll_dispatch_item_source', 'll_dispatch_pda_task', 'll_dispatch_pda_pick', 'll_dispatch_pda_pick_order', 'll_inventory_balance', 'll_inventory_position', 'll_inventory_ledger'] +export function assertRollbackBaseline(snapshot, scope) { + const root = exactRow(snapshot, 'll_dispatch_order', scope.dispatchId), trip = exactRow(snapshot, 'll_fleet_trip', scope.tripId) + demand(snapshot.ll_dispatch_order.length === 1 && snapshot.ll_fleet_trip.length === 1 && snapshot.ll_fleet_trip_dispatch.length === 1 + && root.status === 'ACTIVE' && root.stage === 'AFTER_LOADING' && root.record_version === scope.commandVersion && root.departed_at === null + && root.shipped_package_count === '0' && trip.status === 'PLANNED' && trip.departed_at === null, 'READY_NATIVE_BASELINE_REQUIRED') + demand(snapshot.ll_fleet_pool_order_link.length === 0, 'ORDINARY_TRANSFER_FIXTURE_CANNOT_HAVE_POOL_LINKS') + demand(snapshot.ll_dispatch_item_source.length === 0, 'ORDINARY_TRANSFER_FIXTURE_CANNOT_HAVE_RELABEL_CONTRIBUTIONS') + for (const table of ['ll_dispatch_item', 'll_outbound_order', 'll_delivery_order', 'll_order', 'll_warehouse_transfer_order', 'll_dispatch_pallet_lock', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard', 'll_dispatch_pda_pick', 'll_dispatch_pda_pick_order']) + demand(snapshot[table].length === 2, 'EXACT_TWO_CUSTOMER_BASELINE_REQUIRED') + demand(snapshot.ll_dispatch_pda_task.length === 1 && snapshot.ll_dispatch_pda_task[0].id === scope.taskId, 'ORIGINAL_TASK_REQUIRED') + demand(!snapshot.ll_dispatch_command.some(r => r.action === 'DEPART' || r.idempotency_key === scope.commandKey) + && !snapshot.ll_dispatch_history.some(r => r.action === 'DEPART') && !snapshot.ll_fleet_trip_history.some(r => r.action === 'DEPART') + && !snapshot.ll_loading_order_status_history.some(r => r.event_id === 'DISPATCH_DEPART:' + scope.dispatchNo), 'FRESH_DEPARTURE_REQUIRED') + for (const member of scope.members) { + const guards = snapshot.ll_physical_pallet_guard.filter(row => row.warehouse_pallet_id === member.palletId) + demand(guards.length === 1 && /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}(?:\.\d{1,3})?$/.test(guards[0].created_at || ''), 'OWNED_PERMANENT_PHYSICAL_PALLET_GUARD_REQUIRED') + const order = exactRow(snapshot, 'll_order', member.orderId), transfer = exactRow(snapshot, 'll_warehouse_transfer_order', member.transferId) + const outbound = exactRow(snapshot, 'll_outbound_order', member.outboundId), delivery = exactRow(snapshot, 'll_delivery_order', member.deliveryId) + demand([order, transfer, outbound, delivery].every(r => r.customer_id === member.customerId) + && order.picked_package_count === '1' && order.shipped_package_count === '0' + && transfer.lastleg_order_id === member.orderId && transfer.transfer_no === member.incomingNo && transfer.outbound_at === null + && outbound.status === 'WAIT_OUTBOUND' && delivery.status === 'WAIT_DELIVERY' && delivery.dispatched_at === null, 'OWNED_SOURCE_AND_CHILD_BASELINE_REQUIRED') + const picks = snapshot.ll_dispatch_pda_pick.filter(r => r.customer_id === member.customerId) + const attribution = snapshot.ll_dispatch_pda_pick_order.filter(r => r.customer_id === member.customerId) + demand(picks.length === 1 && picks[0].task_id === scope.taskId && picks[0].dispatch_item_id === member.dispatchItemId + && picks[0].inventory_position_id === member.positionId && picks[0].packages === '1' && picks[0].return_carton_id === null + && attribution.length === 1 && attribution[0].pick_id === picks[0].id && attribution[0].order_id === member.orderId + && identifier(attribution[0].receipt_id) && attribution[0].packages === '1' && attribution[0].return_receipt_id === null + && attribution[0].return_carton_id === null && attribution[0].dispatch_item_source_id === null, 'COMMITTED_REAL_PICK_ATTRIBUTION_REQUIRED') + const ship = snapshot.ll_inventory_ledger.filter(r => r.customer_id === member.customerId && r.business_type === 'SHIP') + demand(ship.length === 1 && ship[0].business_order_no === scope.dispatchNo && ship[0].position_id === member.positionId + && ship[0].balance_id === member.balanceId && ship[0].delta_packages === '-1', 'PRIOR_COMMITTED_SHIP_REQUIRED') + const claims = snapshot.ll_dispatch_pallet_lock.filter(r => r.customer_id === member.customerId) + demand(claims.length === 1 && claims[0].dispatch_order_id === scope.dispatchId && claims[0].warehouse_pallet_id === member.palletId + && claims[0].status === 'ACTIVE' && identifier(claims[0].physical_lock_id), 'ACTIVE_OWNED_CLAIM_REQUIRED') + const physical = exactRow(snapshot, 'll_dispatch_physical_pallet_lock', claims[0].physical_lock_id) + demand(physical.warehouse_id === scope.warehouseId && physical.warehouse_pallet_id === member.palletId + && physical.active_warehouse_pallet_id === member.palletId && physical.dispatch_order_id === scope.dispatchId + && physical.status === 'ACTIVE' && physical.draft_token === null && physical.draft_container_id === null + && physical.expires_at === null && physical.released_at === null, 'ACTIVE_NATIVE_PHYSICAL_PALLET_OWNER_REQUIRED') + } + return snapshot +} + +export function assertTargetTransferUpdate(statement, scope) { + demand(typeof statement === 'string', 'TARGET_TRANSFER_UPDATE_NOT_PROVED') + const sql = statement.replace(/`/g, '').replace(/\s+/g, ' ').replace(/\s*([=,])\s*/g, '$1').trim() + const value = "(?:\\?|'[^']*')" + const match = sql.match(new RegExp(`^UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',\\s*outbound_at=${value},\\s*updated_at=${value} WHERE customer_id=(\\?|'[0-9]+') AND lastleg_order_id=(\\?|[0-9]+) AND outbound_at IS NULL$`, 'i')) + const second = scope.members[1] + demand(match && (match[1] === '?' || match[1] === `'${second.customerId}'`) && (match[2] === '?' || match[2] === second.orderId), 'TARGET_TRANSFER_UPDATE_NOT_PROVED') + return true +} + +export function assertMidTransactionObservation(rows, scope, baseline) { + const lease = one(rows, 'lease'), wait = one(rows, 'wait'), second = scope.members[1] + demand(identifier(lease.connectionId) && identifier(wait.requester) && wait.blocker === lease.connectionId && wait.requester !== wait.blocker + && identifier(lease.transactionId) && identifier(wait.requestingTransactionId) && wait.blockingTransactionId === lease.transactionId + && wait.requestingTransactionId !== lease.transactionId + && lease.lockId === second.transferId && wait.lockId === lease.lockId && wait.blockingLockId === lease.lockId + && /^X(?:,|$)/.test(lease.mode) && wait.objectName === 'll_warehouse_transfer_order' && wait.indexName === 'PRIMARY' + && wait.requestStatus === 'WAITING' && wait.blockingStatus === 'GRANTED' && wait.ownsRoot === 1, 'OWNED_LATE_ROW_WAIT_NOT_PROVED') + assertTargetTransferUpdate(wait.statement, scope) + demand(wait.sessionLockWaitSeconds === '50', 'REQUESTING_SESSION_50_SECOND_LOCK_TIMEOUT_NOT_PROVED') + const dirty = fullRollbackSnapshot(rows, scope) + for (const member of scope.members) { + const before = exactRow(baseline, 'll_order', member.orderId), order = exactRow(dirty, 'll_order', member.orderId) + const outbound = exactRow(dirty, 'll_outbound_order', member.outboundId), delivery = exactRow(dirty, 'll_delivery_order', member.deliveryId) + demand(outbound.status === 'OUTBOUNDED' && delivery.status === 'DELIVERING' && delivery.dispatched_at !== null + && BigInt(delivery.version) === BigInt(exactRow(baseline, 'll_delivery_order', member.deliveryId).version) + 1n + && BigInt(order.picked_package_count) === BigInt(before.picked_package_count) - 1n + && BigInt(order.shipped_package_count) === BigInt(before.shipped_package_count) + 1n + && order.outbound_order_no === member.outboundNo, 'CUSTOMER_UNCOMMITTED_CHILD_AND_SOURCE_WRITE_NOT_PROVED') + const history = dirty.ll_loading_order_status_history.filter(r => r.customer_id === member.customerId && r.order_id === member.orderId && r.event_id === 'DISPATCH_DEPART:' + scope.dispatchNo) + demand(history.length === 1 && !baseline.ll_loading_order_status_history.some(r => r.id === history[0].id), 'UNCOMMITTED_SOURCE_HISTORY_NOT_PROVED') + } + const firstTransfer = exactRow(dirty, 'll_warehouse_transfer_order', scope.members[0].transferId) + demand(firstTransfer.status === 'OUTBOUNDED' && firstTransfer.outbound_at !== null + && canonical(exactRow(dirty, 'll_warehouse_transfer_order', second.transferId)) === canonical(exactRow(baseline, 'll_warehouse_transfer_order', second.transferId)), 'FIRST_TRANSFER_WRITTEN_SECOND_TRANSFER_WAITING_NOT_PROVED') + for (const table of ['ll_dispatch_order', 'll_fleet_trip', 'll_dispatch_command', 'll_dispatch_history', 'll_fleet_trip_history', 'll_dispatch_pallet_lock', ...immutableTables]) + demand(canonical(dirty[table]) === canonical(baseline[table]), 'LATE_PHASE_OR_COMMITTED_SCAN_FACTS_CHANGED') + return { leaseConnectionId: lease.connectionId, requestingConnectionId: wait.requester, leaseTransactionId: lease.transactionId, + requestingTransactionId: wait.requestingTransactionId, lockId: lease.lockId, + sessionLockWaitSeconds: wait.sessionLockWaitSeconds, statement: wait.statement, dirtyObservationOnly: true, firstCustomerId: scope.members[0].customerId, secondCustomerId: second.customerId } +} + +export function assertRestoredSnapshot(baseline, restored) { + demand(canonical(baseline) === canonical(restored), 'FULL_FIELD_COMMITTED_ROLLBACK_SNAPSHOT_CHANGED') + return { beforeSha256: sha(baseline), afterSha256: sha(restored), fullFields: true, committedRead: true } +} +export function requestRollbackDeparture(context, path, body, token) { + // Existing 60-second, single-attempt channel: an unknown HTTP outcome is never retried here. + return context.requestWithBody('POST', path, body, token) +} +export function assertDatabaseLockConflict(response) { + demand(response?.http === 200 && String(response.body?.code) === 'A0409' && response.body?.message === '资源正在处理中,请稍后重试' + && response.requestId && response.traceId, 'EXPECTED_DATABASE_LOCK_CONFLICT_REQUIRED') +} +async function remote(env, scope, mode) { + const script = buildRollbackRemoteScript(scope, mode, env.FBA_CORE_JENKINS_AGENT) + const response = await fetch(new URL('scriptText', env.JENKINS_URL.replace(/\/?$/, '/')), { method: 'POST', redirect: 'error', + signal: AbortSignal.timeout(mode === 'LEASE' ? 95000 : 45000), headers: { Authorization: 'Basic ' + Buffer.from(env.JENKINS_USER + ':' + env.JENKINS_API_TOKEN).toString('base64'), + 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ script }) }) + demand(response.ok, 'ROLLBACK_EVIDENCE_HTTP_FAILED') + const lines = (await response.text()).split(/\r?\n/).filter(x => x.startsWith('TRIP_ROLLBACK_JSON=')) + demand(lines.length === 1, 'ROLLBACK_REMOTE_RESULT_AMBIGUOUS') + const result = JSON.parse(lines[0].slice('TRIP_ROLLBACK_JSON='.length)) + demand(result.ok === true, `ROLLBACK_${mode}_EVIDENCE_BLOCKED_${/^[A-Z_0-9]+$/.test(result.error || '') ? result.error : 'REMOTE_FAILED'}`) + const identity = one(result.rows, 'databaseRuntime') + demand(identity.performanceSchema === 1 && identity.serverUuid && identity.databaseName && /^sha256:[a-f0-9]{64}$/.test(result.imageId || ''), 'OBSERVABLE_DATABASE_RUNTIME_REQUIRED') + if (scope.runtime) demand(result.imageId === scope.expectedImage && canonical(identity) === canonical(scope.runtime), 'PINNED_DATABASE_RUNTIME_CHANGED') + return result +} + +export function createDepartureRollbackVerifier(env, config, runtime, { read = remote, wait = pause } = {}) { + return async ({ context, fixture, ready, departPath, departBody, snapshot }) => { + const output = resolve('reports', `fleet-cross-${config.runId}`, 'rollback.json'), fd = fs.openSync(output, 'wx', 0o600) + const originalCommand = canonical({ path: departPath, body: departBody }) + const report = { verified: false, scope: 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK', startedAt: new Date().toISOString(), samples: [], + command: JSON.parse(originalCommand), runtime } + const save = () => { fs.ftruncateSync(fd, 0); fs.writeSync(fd, JSON.stringify(report, null, 2), 0, 'utf8'); fs.fsyncSync(fd) } + let leasePromise, pending try { - demand(ready.trip.dispatches?.length===2&&fixture.members.length===2,'TWO_CUSTOMER_READY_SCOPE_REQUIRED') - const [first,second]=ready.trip.dispatches.map(binding=>fixture.members.find(x=>x.dispatchNo===binding.dispatchNo)) - demand(first&&second&&first.customerId!==second.customerId&&first.dispatchNo===fixture.members[0].dispatchNo,'CURRENT_DEPARTURE_ORDER_REQUIRED') - const firstState=ready.members[fixture.members.indexOf(first)],secondState=ready.members[fixture.members.indexOf(second)] - const scope=report.target=validateRollbackScope({marker:context.marker,tripNo:fixture.tripNo,expectedImage:runtime.imageId, - firstCustomerId:first.customerId,secondCustomerId:second.customerId,firstDispatchId:id(firstState.dispatch.id),secondDispatchId:id(secondState.dispatch.id), - firstOutboundId:id(firstState.outbound.outbound.id),secondPalletId:second.palletId,firstDispatchNo:first.dispatchNo}) - save();leasePromise=remote(env,scope,'LEASE').then(value=>({value}),error=>({error:error.message})) - for(let i=0;i<12;i++) { - const rows=(await remote(env,scope,'LOCKS')).rows;report.samples.push({phase:'LEASE',rows});save() - if(rows.filter(x=>x.collection==='lease').length===1)break - demand(i!==11,'LIVE_LEASE_NOT_OBSERVED');await pause(250) + demand(ready.trip.dispatches?.length === 1 && ready.trip.dispatches[0].dispatchNo === fixture.dispatchNo && fixture.members.length === 2 + && ready.dispatch.dispatchNo === fixture.dispatchNo && String(ready.task.recordVersion) === String(departBody.recordVersion), 'ONE_NATIVE_READY_SCOPE_REQUIRED') + const request = validateRollbackRequest({ marker: context.marker, tripNo: fixture.tripNo, dispatchNo: fixture.dispatchNo, warehouseId: fixture.warehouseId, + expectedImage: runtime.imageId, runtime: one(runtime.rows, 'databaseRuntime'), commandKey: departBody.idempotencyKey, commandVersion: String(departBody.recordVersion), + members: fixture.members.map(({ customerId, incomingNo, dispatchItemId, palletId, positionId, balanceId, outboundNo, deliveryNo }) => + ({ customerId, incomingNo, dispatchItemId, palletId, positionId, balanceId, outboundNo, deliveryNo })) }) + const resolved = await read(env, request, 'RESOLVE') + const scope = report.target = resolveRollbackScope(request, resolved.rows) + const baseline = report.baseline = assertRollbackBaseline(fullRollbackSnapshot((await read(env, scope, 'BASELINE')).rows, scope), scope) + save() + leasePromise = read(env, scope, 'LEASE').then(value => ({ value }), error => ({ error: error.message })) + let lease + for (let i = 0; i < 12; i++) { + const rows = (await read(env, scope, 'LOCKS')).rows; report.samples.push({ phase: 'LEASE', rows }); save() + if (rows.filter(x => x.collection === 'lease').length === 1) { + lease = one(rows, 'lease') + demand(lease.lockId === scope.members[1].transferId && identifier(lease.connectionId) && /^X(?:,|$)/.test(lease.mode) + && Number.isSafeInteger(lease.ageSeconds) && lease.ageSeconds >= 0 && lease.ageSeconds <= 5, 'FRESH_OWNED_65_SECOND_LEASE_REQUIRED') + break + } + demand(i !== 11, 'LIVE_LEASE_NOT_OBSERVED'); await wait(250) } - pending=requestRollbackDeparture(context,departPath,departBody,fixture.pdaToken).then(value=>({value}),error=>({error:error.message})) - for(let i=0;i<20;i++) { - const rows=(await remote(env,scope,'OBSERVE')).rows;report.samples.push({phase:'MID_TRANSACTION',rows});save() - if(rows.some(x=>x.collection==='wait')) {report.observation=assertMidTransactionObservation(rows);save();break} - demand(i!==19,'MID_TRANSACTION_NOT_OBSERVED');await pause(250) + pending = requestRollbackDeparture(context, departPath, departBody, fixture.pdaToken).then(value => ({ value }), error => ({ error: error.message })) + for (let i = 0; i < 20; i++) { + const rows = (await read(env, scope, 'OBSERVE')).rows; report.samples.push({ phase: 'MID_TRANSACTION', rows }); save() + if (rows.some(x => x.collection === 'wait')) { + report.observation = assertMidTransactionObservation(rows, scope, baseline) + demand(report.observation.leaseConnectionId === lease.connectionId && report.observation.leaseTransactionId === lease.transactionId, 'LEASE_CONNECTION_CHANGED') + scope.observedTransactions = [report.observation.leaseTransactionId, report.observation.requestingTransactionId] + save(); break + } + demand(i !== 19, 'MID_TRANSACTION_NOT_OBSERVED'); await wait(250) } - report.response=await pending;save() - report.lease=await leasePromise;save() - demand(!report.lease.error&&report.lease.value.rows.at(-1)?.released===true,'LEASE_RELEASE_NOT_PROVED') - const released=report.released=(await remote(env,scope,'RELEASED')).rows - demand(released.length===0,'OWNED_ROW_LOCK_OR_WAITER_REMAINS') - const restored=await snapshot('ROLLBACK_AFTER_FAULT') - report.consistentSnapshot={beforeSha256:createHash('sha256').update(canonical(ready)).digest('hex'),afterSha256:createHash('sha256').update(canonical(restored)).digest('hex')} - demand(report.consistentSnapshot.beforeSha256===report.consistentSnapshot.afterSha256,'FULL_TWO_CUSTOMER_ROLLBACK_SNAPSHOT_CHANGED') - demand(!report.response.error&&report.response.value?.http!==0,'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') + report.response = await pending; save() + report.lease = await leasePromise; save() + demand(!report.lease.error && report.lease.value.rows.at(-1)?.released === true, 'LEASE_RELEASE_NOT_PROVED') + const held = one(report.lease.value.rows, 'held') + demand(held.lockId === lease.lockId && held.connectionId === lease.connectionId && held.customerId === scope.members[1].customerId, 'ORIGINAL_LEASE_RESULT_CHANGED') + const released = report.released = (await read(env, scope, 'RELEASED')).rows + demand(!released.some(r => ['lease', 'wait', 'remainingTransaction'].includes(r.collection)), 'OWNED_ROW_LOCK_OR_TRANSACTION_REMAINS') + // A new RR consistent snapshot after the server outcome and lease completion, never the dirty observer transaction. + report.restored = fullRollbackSnapshot((await read(env, scope, 'RESTORED')).rows, scope) + report.consistentSnapshot = assertRestoredSnapshot(baseline, report.restored) + demand(!report.response.error && report.response.value?.http !== 0, 'DEPARTURE_OUTCOME_UNKNOWN_RETAIN_ORIGINAL_COMMAND') assertDatabaseLockConflict(report.response.value) - // The caller also checks restoration before sending the same original command. - report.verified=true;return report - }catch(error){report.error=error.message;throw error} + demand(canonical(ready) === canonical(await snapshot('ROLLBACK_AFTER_FAULT')), 'PUBLIC_AND_COMPLETE_INVENTORY_SNAPSHOT_CHANGED') + demand(canonical({ path: departPath, body: departBody }) === originalCommand, 'ORIGINAL_DEPART_COMMAND_CHANGED') + // The existing caller now sends this identical original command once, then verifies replay and both customer receipts. + report.verified = true; return report + } catch (error) { report.error = error.message; throw error } finally { - if(pending&&!report.response)report.response=await pending - if(leasePromise&&!report.lease)report.lease=await leasePromise - report.finishedAt=new Date().toISOString();save();fs.closeSync(fd) + if (pending && !report.response) report.response = await pending + if (leasePromise && !report.lease) report.lease = await leasePromise + report.finishedAt = new Date().toISOString(); save(); fs.closeSync(fd) } } } -export async function runTripRollback(config,{env=process.env}={}) { - if(!config.execute)return {...config,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK',fault:'65-second SELECT FOR UPDATE lease on the second customer pallet-lock row; first customer writes observed as uncommitted, default 50-second lock timeout rolls back the entire original request; then same original command and full normal chain',businessSqlWrites:false,deployment:false} - demand(env.CHONGCHONG_ENV==='test'&&['JENKINS_URL','JENKINS_USER','JENKINS_API_TOKEN','FBA_CORE_JENKINS_AGENT'].every(key=>env[key]),'EXPLICIT_TEST_EVIDENCE_PROFILE_REQUIRED') - const runtime=await remote(env,{},'RUNTIME');demand(runtime.rows.length===1&&runtime.rows[0].lockWaitSeconds===50,'DEFAULT_50_SECOND_LOCK_TIMEOUT_REQUIRED') - const sources=['scripts/fleet-trip-rollback-test-env.mjs','scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs'].map(path=>({path,sha256:createHash('sha256').update(fs.readFileSync(path)).digest('hex')})) - return runCrossCustomer(config,{env,verify:async(context,fixture,options)=>{ - fs.writeFileSync(resolve('reports',`fleet-cross-${config.runId}`,'rollback-source.json'),JSON.stringify({runtime,sources},null,2),{flag:'wx'}) - return verifyFleetCrossCustomer(context,fixture,{...options,verifyDepartureRollback:createDepartureRollbackVerifier(env,config,runtime)}) - }}) + +export async function runTripRollback(config, { env = process.env } = {}) { + if (!config.execute) return { ...config, scope: 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK', fault: 'One native dispatch; two ordinary WAREHOUSE_TRANSFER customers in Java string order; 65-second read-only row lease on the second transfer; exact UPDATE waiter and its own 50-second session timeout; uncommitted first customer writes; fresh full-field committed rollback baseline; same original command and normal signing chain', businessSqlWrites: false, deployment: false } + demand(config.environment === 'TEST' && env.CHONGCHONG_ENV === 'test' && ['JENKINS_URL', 'JENKINS_USER', 'JENKINS_API_TOKEN', 'FBA_CORE_JENKINS_AGENT'].every(key => env[key]), 'EXPLICIT_TEST_EVIDENCE_PROFILE_REQUIRED') + const runtime = await remote(env, {}, 'RUNTIME') + demand(runtime.rows.filter(row => row.collection === 'privilege').length === 5, 'LOCK_AND_SESSION_OBSERVABILITY_REQUIRED') + const sources = ['scripts/fleet-trip-rollback-test-env.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs'].map(path => ({ path, sha256: createHash('sha256').update(fs.readFileSync(path)).digest('hex') })) + return runCrossCustomer(config, { env, verify: async (context, fixture, options) => { + fs.writeFileSync(resolve('reports', `fleet-cross-${config.runId}`, 'rollback-source.json'), JSON.stringify({ runtime, sources }, null, 2), { flag: 'wx' }) + return verifyFleetCrossCustomer(context, fixture, { ...options, verifyDepartureRollback: createDepartureRollbackVerifier(env, config, runtime) }) + } }) } -if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href) { - try {const config=parseCrossCustomerArguments(process.argv.slice(2)),result=await runTripRollback(config);console.log(JSON.stringify(config.execute?{success:result.success,error:result.error,summary:result.summary,requests:result.requestCount,goalComplete:false}:result,null,2));if(config.execute&&!result.success)process.exitCode=1} - catch(error){console.error(/^[A-Z_0-9]+$/.test(error.message)?error.message:'ROLLBACK_ENTRY_FAILED');process.exitCode=1} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + try { const config = parseCrossCustomerArguments(process.argv.slice(2)), result = await runTripRollback(config); console.log(JSON.stringify(config.execute ? { success: result.success, error: result.error, summary: result.summary, requests: result.requestCount, goalComplete: false } : result, null, 2)); if (config.execute && !result.success) process.exitCode = 1 } + catch (error) { console.error(/^[A-Z_0-9]+$/.test(error.message) ? error.message : 'ROLLBACK_ENTRY_FAILED'); process.exitCode = 1 } } diff --git a/scripts/fleet-trip-rollback-test-env.test.mjs b/scripts/fleet-trip-rollback-test-env.test.mjs index f8f76d297..7025bac55 100644 --- a/scripts/fleet-trip-rollback-test-env.test.mjs +++ b/scripts/fleet-trip-rollback-test-env.test.mjs @@ -1,51 +1,355 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { validateRollbackScope, rollbackSql, buildRollbackRemoteScript, assertMidTransactionObservation, runTripRollback, requestRollbackDeparture, assertDatabaseLockConflict } from './fleet-trip-rollback-test-env.mjs' -const scope={marker:'OWN_RUN',tripNo:'TR-OWN',firstCustomerId:'101',secondCustomerId:'102',firstDispatchId:'201',secondDispatchId:'202',firstOutboundId:'301',secondPalletId:'401',firstDispatchNo:'DS-OWN',expectedImage:'sha256:'+'a'.repeat(64)} -const rows=()=>[{collection:'lease',connectionId:11,lockId:'99'},{collection:'wait',requester:12,blocker:11,lockId:'99'}, - {collection:'firstDispatch',id:'201',status:'DEPARTED',shipped:1},{collection:'firstOutbound',id:'301',status:'OUTBOUNDED'},{collection:'firstReplay',count:1}] -test('fault request allows database timeout and never retries an unknown write',async()=>{ - const body={recordVersion:'11',idempotencyKey:'ORIGINAL'},calls=[] - const context={request:()=>{throw Error('30_SECOND_CHANNEL')},requestWithBody:async(...args)=>{calls.push(args);return {http:0,body:null}}} - assert.equal((await requestRollbackDeparture(context,'/owned/depart',body,'owned-token')).http,0) - assert.deepEqual(calls,[['POST','/owned/depart',body,'owned-token']]) +import fs from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { validateRollbackRequest, validateRollbackScope, resolveRollbackScope, rollbackSql, buildRollbackRemoteScript, + fullRollbackSnapshot, assertRollbackBaseline, assertTargetTransferUpdate, assertMidTransactionObservation, assertRestoredSnapshot, + runTripRollback, requestRollbackDeparture, assertDatabaseLockConflict, createDepartureRollbackVerifier } from './fleet-trip-rollback-test-env.mjs' + +const sqlSource = fs.readFileSync(new URL('../lastleg-service/src/main/resources/db/lastleg.sql', import.meta.url), 'utf8') +// Read actual CREATE columns (including multiple columns on a line), respecting quoted comments and nested type parentheses. +function ddlColumns(table) { + const body = sqlSource.match(new RegExp(`CREATE TABLE(?: IF NOT EXISTS)? ${table} \\(([\\s\\S]*?)\\) ENGINE=`, 'i'))?.[1] + assert.ok(body, `actual schema table ${table}`) + const parts = []; let start = 0, depth = 0, quoted = false + for (let i = 0; i < body.length; i++) { + if (body[i] === "'" && body[i - 1] !== '\\') quoted = !quoted + if (!quoted) { + if (body[i] === '(') depth++ + if (body[i] === ')') depth-- + if (body[i] === ',' && depth === 0) { parts.push(body.slice(start, i)); start = i + 1 } + } + } + parts.push(body.slice(start)) + const primary = body.match(/PRIMARY KEY\s*\(([^)]+)\)/i)[1].split(',').map(c => c.trim()) + return parts.map(part => part.trim().match(/^([a-z][a-z0-9_]*)\s+((?:BIGINT|INT|VARCHAR|CHAR|TEXT|LONGTEXT|MEDIUMTEXT|DATETIME|TIMESTAMP|DATE|TIME|JSON|DECIMAL|TINYINT|SMALLINT|BOOLEAN|BIT|DOUBLE|FLOAT)(?:\([^)]*\))?)/i)) + .filter(Boolean).map((match, i) => ({ collection: 'column', table, column: match[1], ordinal: i + 1, type: match[2].toLowerCase(), key: primary.includes(match[1]) ? 'PRI' : '' })) +} +const tableNames = ['ll_dispatch_order', 'll_dispatch_history', 'll_dispatch_item', 'll_dispatch_command', 'll_dispatch_pallet_lock', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard', 'll_dispatch_item_source', + 'll_fleet_trip', 'll_fleet_trip_dispatch', 'll_fleet_trip_history', 'll_fleet_pool_order_link', 'll_outbound_order', 'll_delivery_order', + 'll_order', 'll_loading_order_status_history', 'll_warehouse_transfer_order', 'll_dispatch_pda_task', 'll_dispatch_pda_pick', + 'll_dispatch_pda_pick_order', 'll_inventory_balance', 'll_inventory_position', 'll_inventory_ledger'] +const columns = [...tableNames].sort().flatMap(ddlColumns) +const request = { marker: 'OWN_RUN', tripNo: 'TR-OWN', dispatchNo: 'DS-OWN', warehouseId: '51', commandKey: 'ORIGINAL-DEPART', commandVersion: '11', expectedImage: 'sha256:' + 'a'.repeat(64), + // Reverse input and numeric ordering deliberately differ from Java string ordering. + members: [{ customerId: '99', incomingNo: 'WT-99', dispatchItemId: '302', palletId: '402', positionId: '502', balanceId: '602', outboundNo: 'OB-99', deliveryNo: 'DL-99' }, + { customerId: '101', incomingNo: 'WT-101', dispatchItemId: '301', palletId: '401', positionId: '501', balanceId: '601', outboundNo: 'OB-101', deliveryNo: 'DL-101' }] } +function resolution() { + return [{ collection: 'root', id: '201', dispatchNo: request.dispatchNo, warehouseId: '51', status: 'ACTIVE', stage: 'AFTER_LOADING', version: '11' }, + { collection: 'trip', id: '211', tripNo: request.tripNo, warehouseId: '51', status: 'PLANNED' }, + { collection: 'binding', dispatchId: '201', dispatchNo: request.dispatchNo, tripId: '211', tripNo: request.tripNo }, + { collection: 'task', id: '221', dispatchId: '201', warehouseId: '51' }, ...columns, + ...request.members.flatMap(m => { + const suffix = m.customerId === '101' ? '1' : '2' + return [{ collection: 'item', id: m.dispatchItemId, dispatchId: '201', customerId: m.customerId, palletId: m.palletId, orderId: '70' + suffix, sourceType: null, sourceNo: null, incomingNo: m.incomingNo, packages: '1' }, + { collection: 'transfer', id: '80' + suffix, customerId: m.customerId, incomingNo: m.incomingNo, orderId: '70' + suffix, warehouseId: '51', needRelabel: '0', relabelNo: null, outboundAt: null }, + { collection: 'outbound', id: '90' + suffix, customerId: m.customerId, dispatchId: '201', outboundNo: m.outboundNo, status: 'WAIT_OUTBOUND' }, + { collection: 'delivery', id: '100' + suffix, customerId: m.customerId, dispatchId: '201', deliveryNo: m.deliveryNo, outboundNo: m.outboundNo, status: 'WAIT_DELIVERY' }] + })] +} +const scope = resolveRollbackScope(request, resolution()) +const row = (table, values) => { + const actual = columns.filter(c => c.table === table).map(c => c.column) + for (const key of Object.keys(values)) assert.ok(actual.includes(key), `real ${table}.${key}`) + return { ...Object.fromEntries(actual.map(c => [c, null])), ...values } +} +function baseline() { + const value = Object.fromEntries(tableNames.map(table => [table, []])) + value.ll_dispatch_order.push(row('ll_dispatch_order', { id: '201', dispatch_no: request.dispatchNo, trip_id: '211', overseas_warehouse_id: '51', status: 'ACTIVE', stage: 'AFTER_LOADING', record_version: '11', shipped_package_count: '0' })) + value.ll_fleet_trip.push(row('ll_fleet_trip', { id: '211', trip_no: request.tripNo, status: 'PLANNED', record_version: '7' })) + value.ll_fleet_trip_dispatch.push(row('ll_fleet_trip_dispatch', { dispatch_id: '201', dispatch_no: request.dispatchNo, trip_id: '211', trip_no: request.tripNo })) + value.ll_dispatch_pda_task.push(row('ll_dispatch_pda_task', { id: '221', dispatch_order_id: '201' })) + for (const [index, m] of scope.members.entries()) { + const suffix = String(index + 1) + value.ll_dispatch_item.push(row('ll_dispatch_item', { id: m.dispatchItemId, dispatch_order_id: '201', customer_id: m.customerId })) + value.ll_outbound_order.push(row('ll_outbound_order', { id: m.outboundId, customer_id: m.customerId, dispatch_order_id: '201', outbound_no: m.outboundNo, status: 'WAIT_OUTBOUND' })) + value.ll_delivery_order.push(row('ll_delivery_order', { id: m.deliveryId, customer_id: m.customerId, dispatch_order_id: '201', delivery_no: m.deliveryNo, status: 'WAIT_DELIVERY', version: '0' })) + value.ll_order.push(row('ll_order', { id: m.orderId, customer_id: m.customerId, picked_package_count: '1', shipped_package_count: '0' })) + value.ll_warehouse_transfer_order.push(row('ll_warehouse_transfer_order', { id: m.transferId, customer_id: m.customerId, lastleg_order_id: m.orderId, transfer_no: m.incomingNo, status: 'RECEIVED' })) + value.ll_dispatch_pallet_lock.push(row('ll_dispatch_pallet_lock', { id: '110' + suffix, physical_lock_id: '170' + suffix, customer_id: m.customerId, dispatch_order_id: '201', warehouse_pallet_id: m.palletId, status: 'ACTIVE', active_key: 'OWN-' + suffix })) + value.ll_dispatch_physical_pallet_lock.push(row('ll_dispatch_physical_pallet_lock', { id: '170' + suffix, warehouse_id: '51', + warehouse_pallet_id: m.palletId, active_warehouse_pallet_id: m.palletId, dispatch_order_id: scope.dispatchId, status: 'ACTIVE' })) + value.ll_physical_pallet_guard.push(row('ll_physical_pallet_guard', { warehouse_pallet_id: m.palletId, created_at: `2026-10-19 11:00:00.00${suffix}` })) + value.ll_dispatch_pda_pick.push(row('ll_dispatch_pda_pick', { id: '120' + suffix, customer_id: m.customerId, task_id: '221', dispatch_item_id: m.dispatchItemId, inventory_position_id: m.positionId, packages: '1' })) + value.ll_dispatch_pda_pick_order.push(row('ll_dispatch_pda_pick_order', { id: '130' + suffix, customer_id: m.customerId, pick_id: '120' + suffix, receipt_id: '140' + suffix, order_id: m.orderId, packages: '1' })) + value.ll_inventory_balance.push(row('ll_inventory_balance', { id: m.balanceId, customer_id: m.customerId, overseas_warehouse_id: '51', available_packages: '0' })) + value.ll_inventory_position.push(row('ll_inventory_position', { id: m.positionId, balance_id: m.balanceId, customer_id: m.customerId, overseas_warehouse_id: '51', available_packages: '0' })) + value.ll_inventory_ledger.push(row('ll_inventory_ledger', { id: '150' + suffix, customer_id: m.customerId, overseas_warehouse_id: '51', business_type: 'SHIP', business_order_no: request.dispatchNo, balance_id: m.balanceId, position_id: m.positionId, delta_packages: '-1' })) + } + return value +} +function snapshotRows(snapshot) { + return [...structuredClone(columns), ...tableNames.flatMap(table => [{ collection: 'coverage', table, count: snapshot[table].length }, + ...snapshot[table].map(value => ({ collection: 'row', table, row: structuredClone(value) }))])] +} +const updateSql = "UPDATE ll_warehouse_transfer_order SET status='OUTBOUNDED',outbound_at=?,updated_at=? WHERE customer_id=? AND lastleg_order_id=? AND outbound_at IS NULL" +const lease = () => ({ collection: 'lease', connectionId: '11', transactionId: '111', lockId: '802', mode: 'X,REC_NOT_GAP', ageSeconds: 1 }) +function midRows() { + const dirty = baseline() + for (const [index, m] of scope.members.entries()) { + Object.assign(dirty.ll_outbound_order[index], { status: 'OUTBOUNDED' }) + Object.assign(dirty.ll_delivery_order[index], { status: 'DELIVERING', dispatched_at: '2026-10-19 12:00:00.000', version: '1' }) + Object.assign(dirty.ll_order[index], { picked_package_count: '0', shipped_package_count: '1', outbound_order_no: m.outboundNo }) + dirty.ll_loading_order_status_history.push(row('ll_loading_order_status_history', { id: '160' + (index + 1), customer_id: m.customerId, order_id: m.orderId, event_id: 'DISPATCH_DEPART:' + scope.dispatchNo })) + } + Object.assign(dirty.ll_warehouse_transfer_order[0], { status: 'OUTBOUNDED', outbound_at: '2026-10-19 12:00:00.000' }) + return [lease(), { collection: 'wait', requester: '12', blocker: '11', requestingTransactionId: '112', blockingTransactionId: '111', lockId: '802', blockingLockId: '802', objectName: 'll_warehouse_transfer_order', indexName: 'PRIMARY', + requestStatus: 'WAITING', blockingStatus: 'GRANTED', ownsRoot: 1, statement: updateSql, sessionLockWaitSeconds: '50' }, ...snapshotRows(dirty)] +} +const conflict = () => ({ http: 200, body: { code: 'A0409', message: '资源正在处理中,请稍后重试' }, requestId: 'request', traceId: 'trace' }) + +test('business-only request resolves one native root and real customer children in Java string order', () => { + assert.equal(scope.dispatchId, '201'); assert.equal(scope.members[0].customerId, '101'); assert.equal(scope.members[1].customerId, '99') + assert.equal(scope.members[1].transferId, '802'); assert.equal(request.members[0].customerId, '99') + assert.doesNotThrow(() => validateRollbackScope(scope)) + const sql = rollbackSql(request, 'RESOLVE') + assert.match(sql, /ll_dispatch_order WHERE dispatch_no=/) + assert.match(sql, /'incomingNo',CAST\(inbound_code AS CHAR\)/) + assert.doesNotMatch(sql, /FOR UPDATE|firstDispatchId|secondDispatchId/) }) -test('database conflict requires exact handler response and trace, not arbitrary rejection',()=>{ - const response={http:200,body:{code:'A0409',message:'资源正在处理中,请稍后重试'},requestId:'request',traceId:'trace'} - assert.doesNotThrow(()=>assertDatabaseLockConflict(response)) - for(const wrong of [{http:0},{http:500},{body:{code:'S0500',message:'error'}},{body:{code:'A0409',message:'请完成拣货'}},{traceId:null}]) - assert.throws(()=>assertDatabaseLockConflict({...response,...wrong})) +for (const [name, mutate] of [ + ['two roots', rows => rows.push({ ...rows[0], id: '999' })], + ['two Fleet bindings', rows => rows.push({ ...rows.find(r => r.collection === 'binding'), dispatchId: '999' })], + ['wrong root version', rows => { rows[0].version = '12' }], + ['invented ordinary source alias', rows => { rows.find(r => r.collection === 'item').sourceType = 'WAREHOUSE_TRANSFER' }], + ['relabel source', rows => { rows.find(r => r.collection === 'transfer').needRelabel = '1' }], + ['wrong incoming', rows => { rows.find(r => r.collection === 'transfer').incomingNo = 'BORROWED' }], + ['foreign child', rows => { rows.find(r => r.collection === 'delivery').dispatchId = '999' }], + ['missing private ID', rows => { rows.find(r => r.collection === 'transfer').id = undefined }], + ['truncated schema', rows => rows.splice(rows.findIndex(r => r.collection === 'column' && r.table === 'll_order'), 1)], +]) test(`native resolution blocks ${name}`, () => { + const rows = resolution(); mutate(rows); assert.throws(() => resolveRollbackScope(request, rows)) }) -test('plan and import are network-free; execution rejects missing TEST profile before any network',async()=>{ - const plan=await runTripRollback({execute:false},{env:{}}) - assert.equal(plan.businessSqlWrites,false);assert.equal(plan.deployment,false) - await assert.rejects(runTripRollback({execute:true},{env:{CHONGCHONG_ENV:'prod'}}),/TEST/) +test('scope rejects injection, repeated ownership, reversed sort and unpinned image', () => { + for (const change of [{ marker: "OWN';DELETE" }, { expectedImage: 'latest' }, { dispatchId: '1 OR 1=1' }, { members: [...scope.members].reverse() }]) + assert.throws(() => validateRollbackScope({ ...scope, ...change })) + const bad = structuredClone(request); bad.members[1].customerId = bad.members[0].customerId + assert.throws(() => validateRollbackRequest(bad)) }) -test('scope rejects reused customers, injection and unpinned runtime',()=>{ - assert.equal(validateRollbackScope(scope),scope) - for(const changed of [{secondCustomerId:'101'},{secondDispatchId:'201'},{firstDispatchId:'1 OR 1=1'},{marker:"RUN';DELETE"},{expectedImage:'latest'}])assert.throws(()=>validateRollbackScope({...scope,...changed})) +test('lease is only the second actual transfer PK, fixed read-only lease and automatic rollback', () => { + const sql = rollbackSql(scope, 'LEASE') + assert.match(sql, /FROM ll_warehouse_transfer_order WHERE id=802 AND customer_id=/) + assert.match(sql, /lastleg_order_id=702 AND transfer_no=/) + assert.match(sql, /outbound_at IS NULL FOR UPDATE;.*SLEEP\(65\)\);ROLLBACK;/) + assert.doesNotMatch(sql, /\b(?:INSERT|DELETE|ALTER|DROP|TRUNCATE|REPLACE|KILL|SET GLOBAL)\b|ll_dispatch_pallet_lock/) + assert.equal((sql.match(/\bUPDATE\b/g) || []).length, 1) + assert.throws(() => rollbackSql(scope, 'CUSTOM_SQL')) }) -test('lease contains only owned locking reads, fixed duration and rollback, without mutation or schema change',()=>{ - const sql=rollbackSql(scope,'LEASE') - assert.match(sql,/dispatch_order_id=202/);assert.match(sql,/warehouse_pallet_id=401/);assert.match(sql,/FOR UPDATE;.*SLEEP\(65\)\);ROLLBACK;/) - assert.doesNotMatch(sql,/\b(?:INSERT|DELETE|ALTER|DROP|TRUNCATE|REPLACE|KILL|SET GLOBAL)\b/) - assert.equal((sql.match(/UPDATE/g)||[]).length,1) - assert.throws(()=>rollbackSql(scope,'CUSTOM_SQL')) - assert.throws(()=>buildRollbackRemoteScript(scope,'LEASE',"agent');evil()")) +test('wait proof joins exact granted/blocking PK, real transaction SQL, root ownership and requester session variable', () => { + const sql = rollbackSql(scope, 'OBSERVE') + for (const text of ['READ UNCOMMITTED', 'w.BLOCKING_ENGINE_LOCK_ID', "bl.LOCK_DATA=l.LOCK_DATA", "l.LOCK_DATA='802'", 'r.trx_query', + 'v.THREAD_ID=th.THREAD_ID', 'th.PROCESSLIST_ID=r.trx_mysql_thread_id', "v.VARIABLE_NAME='innodb_lock_wait_timeout'", "dl.LOCK_DATA='201'"]) + assert.ok(sql.includes(text), text) + assert.doesNotMatch(sql, /@@GLOBAL|ll_dispatch_pda_operation|DEPARTED/) + assert.match(rollbackSql(scope, 'RESTORED'), /REPEATABLE READ;\nSTART TRANSACTION WITH CONSISTENT SNAPSHOT, READ ONLY;/) + assert.doesNotMatch(rollbackSql(scope, 'RESTORED'), /READ UNCOMMITTED/) }) -test('dirty observation is separate from final consistent cleanup evidence',()=>{ - assert.match(rollbackSql(scope,'OBSERVE'),/READ UNCOMMITTED/) - assert.match(rollbackSql(scope,'RELEASED'),/REPEATABLE READ/) - assert.match(rollbackSql(scope,'RELEASED'),/READ ONLY/) - assert.equal(assertMidTransactionObservation(rows()).dirtyObservationOnly,true) +test('generated resolution and snapshot projections use current native schema columns', () => { + const sql = rollbackSql(request, 'RESOLVE') + '\n' + rollbackSql(scope, 'BASELINE') + for (const query of sql.split(';')) { + const match = query.match(/SELECT JSON_OBJECT\([\s\S]*? FROM (ll_[a-z_]+) WHERE/) + if (!match) continue + const actual = new Set(ddlColumns(match[1]).map(c => c.column)) + for (const column of query.slice(0, match.index + match[0].indexOf(' FROM ')).matchAll(/CAST\(`?([a-z_]+)`? AS CHAR/g)) + assert.ok(actual.has(column[1]), `${match[1]}.${column[1]}`) + } + assert.ok(columns.filter(c => c.table === 'll_order').length > 50) + assert.doesNotThrow(() => assertRollbackBaseline(fullRollbackSnapshot(snapshotRows(baseline()), scope), scope)) }) -for(const fault of ['foreign blocker','no wait','two waiters','not yet written','no outbound write','no replay write'])test(`refuses indirect mid-transaction evidence: ${fault}`,()=>{ - const value=rows() - if(fault==='foreign blocker')value[1].blocker=77 - if(fault==='no wait')value.splice(1,1) - if(fault==='two waiters')value.push({...value[1],requester:13}) - if(fault==='not yet written')value[2].status='READY_TO_DEPART' - if(fault==='no outbound write')value[3].status='WAIT_OUTBOUND' - if(fault==='no replay write')value[4].count=0 - assert.throws(()=>assertMidTransactionObservation(value)) +test('remote envelope pins TEST container, healthy image, DB identity and preflights observation permissions', () => { + function decode(script) { const encoded = script.match(/decoder.decode\(\[((?:'[A-Za-z0-9+/=]*',?)+)\]\.join\(''\)\)/)[1]; return Buffer.from([...encoded.matchAll(/'([^']*)'/g)].map(m => m[1]).join(''), 'base64').toString() } + const python = decode(decode(buildRollbackRemoteScript(scope, 'OBSERVE', 'test-agent'))) + for (const text of ['firstleg-test-lastleg', 'firstleg-test', "e.get('DEPLOY_ENV')!='test'", "get('Status')!='healthy'", scope.expectedImage, 'MYSQL_PWD', 'variables_by_thread']) assert.ok(python.includes(text), text) + assert.ok(buildRollbackRemoteScript({}, 'RUNTIME', 'test-agent')) + assert.throws(() => buildRollbackRemoteScript(scope, 'LEASE', "agent');evil()")) +}) +test('full-field baseline cannot accept missing columns, rows, coverage or changed schema', () => { + for (const mutate of [ + rows => { delete rows.find(r => r.collection === 'row').row.updated_at }, + rows => rows.splice(rows.findIndex(r => r.collection === 'row'), 1), + rows => rows.splice(rows.findIndex(r => r.collection === 'coverage'), 1), + rows => { rows.find(r => r.collection === 'column').type = 'changed' }, + rows => rows.push({ ...rows.find(r => r.collection === 'coverage') }), + ]) { const rows = snapshotRows(baseline()); mutate(rows); assert.throws(() => fullRollbackSnapshot(rows, scope)) } +}) +for (const [name, mutate] of [ + ['no committed SHIP', b => { b.ll_inventory_ledger = [] }], + ['wrong scan source', b => { b.ll_dispatch_pda_pick_order[0].order_id = '999' }], + ['prior departure command', b => { b.ll_dispatch_command.push(row('ll_dispatch_command', { id: '999', action: 'DEPART' })) }], + ['source contribution on ordinary transfer', b => { b.ll_dispatch_item_source.push(row('ll_dispatch_item_source', { id: '991', dispatch_order_id: '201', dispatch_item_id: scope.members[0].dispatchItemId })) }], + ['ordinary receipt also claims relabel contribution', b => { b.ll_dispatch_pda_pick_order[0].dispatch_item_source_id = '991' }], + ['ordinary receipt also claims native return carton', b => { b.ll_dispatch_pda_pick_order[0].return_carton_id = '991' }], + ['missing physical lock', b => { b.ll_dispatch_physical_pallet_lock.pop() }], + ['missing permanent physical guard', b => { b.ll_physical_pallet_guard.pop() }], + ['guard for a foreign pallet', b => { b.ll_physical_pallet_guard[0].warehouse_pallet_id = '991' }], + ['extra foreign permanent guard', b => { b.ll_physical_pallet_guard.push(row('ll_physical_pallet_guard', { warehouse_pallet_id: '991', created_at: '2026-10-19 11:00:00.001' })) }], + ['guard without original created timestamp', b => { b.ll_physical_pallet_guard[0].created_at = null }], + ['guard with invalid creation timestamp', b => { b.ll_physical_pallet_guard[0].created_at = 'NOT-A-CREATION-TIME' }], + ['logical claim points to other physical pallet', b => { b.ll_dispatch_pallet_lock[0].physical_lock_id = b.ll_dispatch_physical_pallet_lock[1].id }], + ['logical claim on other main', b => { b.ll_dispatch_pallet_lock[0].dispatch_order_id = '991' }], + ['physical claim on other main', b => { b.ll_dispatch_physical_pallet_lock[0].dispatch_order_id = '991' }], + ['physical claim on other warehouse', b => { b.ll_dispatch_physical_pallet_lock[0].warehouse_id = '991' }], + ['physical claim for other pallet', b => { b.ll_dispatch_physical_pallet_lock[0].warehouse_pallet_id = '991' }], + ['physical active key mismatch', b => { b.ll_dispatch_physical_pallet_lock[0].active_warehouse_pallet_id = '991' }], + ['physical claim released', b => { b.ll_dispatch_physical_pallet_lock[0].status = 'RELEASED'; b.ll_dispatch_physical_pallet_lock[0].active_warehouse_pallet_id = null }], + ['physical draft ownership', b => { b.ll_dispatch_physical_pallet_lock[0].draft_token = 'OTHER-DRAFT'; b.ll_dispatch_physical_pallet_lock[0].draft_container_id = '991' }], + ['pool fixture', b => { b.ll_fleet_pool_order_link.push(row('ll_fleet_pool_order_link', { id: '999' })) }], +]) test(`baseline rejects ${name}`, () => { const value = baseline(); mutate(value); assert.throws(() => assertRollbackBaseline(value, scope)) }) +test('late dirty observer proves first completed transfer, both source writes, unchanged native roots and prior SHIP', () => { + const proof = assertMidTransactionObservation(midRows(), scope, baseline()) + assert.equal(proof.dirtyObservationOnly, true); assert.equal(proof.firstCustomerId, '101'); assert.equal(proof.sessionLockWaitSeconds, '50') + assert.doesNotThrow(() => assertTargetTransferUpdate(updateSql.replaceAll('=', ' = '), scope)) + assert.doesNotThrow(() => assertTargetTransferUpdate(updateSql.replace('customer_id=?', "customer_id='99'").replace('lastleg_order_id=?', 'lastleg_order_id=702'), scope)) +}) +for (const [name, mutate] of [ + ['foreign blocker', r => { r[1].blocker = '77' }], + ['missing wait', r => r.splice(1, 1)], + ['multiple waits', r => r.push({ ...r[1], requester: '13' })], + ['wrong target row', r => { r[1].lockId = '801' }], + ['wrong blocking row', r => { r[1].blockingLockId = '801' }], + ['wrong blocking transaction', r => { r[1].blockingTransactionId = '113' }], + ['unrelated transaction', r => { r[1].ownsRoot = 0 }], + ['unavailable session variable', r => { delete r[1].sessionLockWaitSeconds }], + ['global timeout stand-in', r => { delete r[1].sessionLockWaitSeconds; r[1].globalLockWaitSeconds = '50' }], + ['session timeout beyond lease', r => { r[1].sessionLockWaitSeconds = '120' }], + ['unavailable requesting SQL', r => { r[1].statement = null }], + ['early child lock', r => { r[1].statement = 'SELECT * FROM ll_outbound_order WHERE dispatch_order_id=201 FOR UPDATE' }], + ['different transfer UPDATE', r => { r[1].statement = updateSql.replace('lastleg_order_id=?', 'lastleg_order_id=701') }], + ['first outbound not written', r => { r.find(x => x.table === 'll_outbound_order' && x.row?.id === '901').row.status = 'WAIT_OUTBOUND' }], + ['first order not written', r => { r.find(x => x.table === 'll_order' && x.row?.id === '701').row.shipped_package_count = '0' }], + ['no first source history', r => { r.find(x => x.table === 'll_loading_order_status_history' && x.row?.order_id === '701').row.event_id = 'OTHER' }], + ['first transfer not written', r => { r.find(x => x.table === 'll_warehouse_transfer_order' && x.row?.id === '801').row.outbound_at = null }], + ['second transfer already changed', r => { r.find(x => x.table === 'll_warehouse_transfer_order' && x.row?.id === '802').row.status = 'OUTBOUNDED' }], + ['root changed too soon', r => { r.find(x => x.table === 'll_dispatch_order' && x.row).row.stage = 'DELIVERING' }], + ['physical lock released before late source update', r => { r.find(x => x.table === 'll_dispatch_physical_pallet_lock' && x.row).row.status = 'RELEASED' }], + ['permanent guard deleted in departure transaction', r => { r.splice(r.findIndex(x => x.table === 'll_physical_pallet_guard' && x.row), 1); r.find(x => x.collection === 'coverage' && x.table === 'll_physical_pallet_guard').count-- }], + ['permanent guard recreated in departure transaction', r => { r.find(x => x.table === 'll_physical_pallet_guard' && x.row).row.created_at = '2026-10-19 12:00:00.999' }], + ['permanent guard key replaced in departure transaction', r => { r.find(x => x.table === 'll_physical_pallet_guard' && x.row).row.warehouse_pallet_id = '991' }], + ['source contribution added before late source update', r => { const value = row('ll_dispatch_item_source', { id: '991', dispatch_order_id: '201' }); r.find(x => x.collection === 'coverage' && x.table === 'll_dispatch_item_source').count = 1; r.push({ collection: 'row', table: 'll_dispatch_item_source', row: value }) }], + ['prior SHIP changed', r => { r.find(x => x.table === 'll_inventory_ledger' && x.row).row.delta_packages = '0' }], +]) test(`rejects insufficient middle evidence: ${name}`, () => { const value = midRows(); mutate(value); assert.throws(() => assertMidTransactionObservation(value, scope, baseline())) }) +test('rollback compares every full field, not just business status or inventory totals', () => { + const before = baseline(), after = structuredClone(before) + const proof = assertRestoredSnapshot(before, after) + assert.equal(proof.beforeSha256, proof.afterSha256); assert.equal(proof.committedRead, true) + for (const table of tableNames.filter(t => after[t].length)) { + const changed = structuredClone(after); changed[table][0][Object.keys(changed[table][0]).at(-1)] = 'CHANGED' + assert.throws(() => assertRestoredSnapshot(before, changed), table) + } +}) +test('database conflict requires exact handler result and trace; timeout or precondition rejection fails', () => { + assert.doesNotThrow(() => assertDatabaseLockConflict(conflict())) + for (const wrong of [{ http: 0 }, { http: 500 }, { body: { code: 'S0500', message: 'error' } }, { body: { code: 'A0409', message: '请完成拣货' } }, { traceId: null }]) + assert.throws(() => assertDatabaseLockConflict({ ...conflict(), ...wrong })) +}) +test('single-attempt request keeps original key/version/body on the 60-second channel', async () => { + const body = { recordVersion: '11', idempotencyKey: 'ORIGINAL' }, calls = [] + const context = { request: () => { throw Error('30_SECOND_CHANNEL') }, requestWithBody: async (...args) => { calls.push(args); return { http: 0, body: null } } } + assert.equal((await requestRollbackDeparture(context, '/owned/depart', body, 'owned-token')).http, 0) + assert.deepEqual(calls, [['POST', '/owned/depart', body, 'owned-token']]); assert.equal(calls[0][2], body) +}) +test('plan has no network; non-TEST execution fails before any fetch', async () => { + const fetch = globalThis.fetch; globalThis.fetch = () => { throw Error('NETWORK_FORBIDDEN') } + try { + const plan = await runTripRollback({ execute: false }, { env: {} }) + assert.equal(plan.businessSqlWrites, false); assert.equal(plan.deployment, false); assert.match(plan.fault, /One native dispatch/) + await assert.rejects(runTripRollback({ execute: true, environment: 'TEST' }, { env: { CHONGCHONG_ENV: 'prod' } }), /TEST/) + } finally { globalThis.fetch = fetch } +}) +test('offline verifier preserves original command and requires all phases before caller can depart/sign', async () => { + const original = process.cwd(), temp = fs.mkdtempSync(join(tmpdir(), 'fleet-rollback-offline-')) + try { + process.chdir(temp); fs.mkdirSync('reports/fleet-cross-offline', { recursive: true }) + const calls = [], ready = { trip: { dispatches: [{ dispatchNo: request.dispatchNo }] }, dispatch: { dispatchNo: request.dispatchNo }, task: { recordVersion: '11' } } + const fixture = { ...request, pdaToken: 'synthetic-offline-token' }, body = { recordVersion: '11', idempotencyKey: request.commandKey, remark: request.marker } + const runtime = { imageId: request.expectedImage, rows: [{ collection: 'databaseRuntime', serverUuid: 'offline', databaseName: 'offline', performanceSchema: 1 }] } + const read = async (_env, _scope, mode) => { + calls.push(mode) + if (mode === 'RESOLVE') return { rows: resolution() } + if (mode === 'BASELINE' || mode === 'RESTORED') return { rows: snapshotRows(baseline()) } + if (mode === 'LEASE') return { rows: [{ collection: 'held', lockId: '802', connectionId: '11', customerId: '99' }, { released: true }] } + if (mode === 'LOCKS') return { rows: [lease()] } + if (mode === 'OBSERVE') return { rows: midRows() } + if (mode === 'RELEASED') return { rows: [] } + throw Error('UNEXPECTED_MODE') + } + const context = { marker: request.marker, requestWithBody: async (...args) => { calls.push(args); return conflict() } } + const proof = await createDepartureRollbackVerifier({}, { runId: 'offline' }, runtime, { read, wait: () => { throw Error('NO_REAL_WAIT') } }) + ({ context, fixture, ready, departPath: '/owned/depart', departBody: body, snapshot: async () => ready }) + assert.equal(proof.verified, true); assert.equal(proof.consistentSnapshot.fullFields, true) + assert.deepEqual(calls.filter(Array.isArray), [['POST', '/owned/depart', body, fixture.pdaToken]]) + assert.deepEqual(calls.filter(x => typeof x === 'string'), ['RESOLVE', 'BASELINE', 'LEASE', 'LOCKS', 'OBSERVE', 'RELEASED', 'RESTORED']) + assert.deepEqual(proof.command.body, body) + const saved = JSON.parse(fs.readFileSync('reports/fleet-cross-offline/rollback.json', 'utf8')) + assert.equal(saved.verified, true); assert.ok(saved.finishedAt) + const caller = fs.readFileSync(new URL('./admin-test-env-modules/fleet-cross-customer-acceptance.mjs', import.meta.url), 'utf8') + assert.ok(caller.indexOf('await verifyDepartureRollback(') < caller.indexOf("const departed = await request('POST', departPath, departBody")) + assert.match(caller, /Platform exact replay of PDA native departure/); assert.match(caller, /This customer independently signed/) + } finally { + process.chdir(original) + assert.ok(resolve(temp).startsWith(resolve(tmpdir()) + '\\') || resolve(temp).startsWith(resolve(tmpdir()) + '/')) + fs.rmSync(temp, { recursive: true, force: true }) + } +}) + + +test('23 full snapshots include batch-only permanent guards, empty ordinary contributions and both customer-free physical owners from actual DDL', () => { + const full = fullRollbackSnapshot(snapshotRows(baseline()), scope) + assert.equal(tableNames.length, 23); assert.equal(Object.keys(full).length, 23) + assert.equal(snapshotRows(baseline()).filter(row => row.collection === 'coverage').length, 23) + assert.equal(full.ll_dispatch_item_source.length, 0) + assert.deepEqual(full.ll_physical_pallet_guard.map(row => row.warehouse_pallet_id).sort(), ['401', '402']) + assert.deepEqual(ddlColumns('ll_physical_pallet_guard').map(column => [column.column, column.type, column.key]), [['warehouse_pallet_id', 'bigint', 'PRI'], ['created_at', 'datetime(3)', '']]) + assert.deepEqual(Object.keys(full.ll_physical_pallet_guard[0]).sort(), ['created_at', 'warehouse_pallet_id']) + assert.equal(full.ll_dispatch_physical_pallet_lock.length, 2) + assert.deepEqual(Object.keys(full.ll_dispatch_physical_pallet_lock[0]).sort(), ddlColumns('ll_dispatch_physical_pallet_lock').map(column => column.column).sort()) + assert.equal(Object.hasOwn(full.ll_dispatch_physical_pallet_lock[0], 'customer_id'), false) + assert.equal(ddlColumns('ll_dispatch_pallet_lock').some(column => column.column === 'active_warehouse_pallet_id'), false) + assert.ok(ddlColumns('ll_dispatch_pallet_lock').some(column => column.column === 'physical_lock_id')) + assert.ok(ddlColumns('ll_dispatch_pda_pick_order').some(column => column.column === 'dispatch_item_source_id')) + assert.doesNotThrow(() => assertRollbackBaseline(full, scope)) + const sql = rollbackSql(scope, 'BASELINE') + assert.match(sql, /FROM ll_dispatch_physical_pallet_lock WHERE dispatch_order_id=201 OR warehouse_pallet_id IN \(401,402\)/) + assert.match(sql, /FROM ll_dispatch_item_source WHERE dispatch_order_id=201 OR dispatch_item_id IN \(301,302\)/) + for (const mode of ['BASELINE', 'OBSERVE', 'RESTORED']) { + const guardQueries = rollbackSql(scope, mode).split(';').filter(query => query.includes('FROM ll_physical_pallet_guard')) + assert.equal(guardQueries.length, 2) + for (const query of guardQueries) assert.ok(query.endsWith('FROM ll_physical_pallet_guard WHERE warehouse_pallet_id IN (401,402)')) + assert.ok(guardQueries.some(query => query.includes('COUNT(*)'))) + assert.ok(guardQueries.some(query => query.includes("'created_at',CAST(`created_at` AS CHAR CHARACTER SET utf8mb4)"))) + } + assert.match(sql, /'dispatch_item_source_id',CAST\(`dispatch_item_source_id` AS CHAR CHARACTER SET utf8mb4\)/) +}) + +test('missing source-table zero coverage or partial new-table schema cannot masquerade as a full rollback snapshot', () => { + for (const table of ['ll_dispatch_item_source', 'll_dispatch_physical_pallet_lock', 'll_physical_pallet_guard']) { + const rows = snapshotRows(baseline()).filter(row => !(row.collection === 'coverage' && row.table === table)) + assert.throws(() => fullRollbackSnapshot(rows, scope), /EXACT_COVERAGE_REQUIRED/) + const resolvedRows = resolution().filter(row => !(row.collection === 'column' && row.table === table)) + assert.throws(() => resolveRollbackScope(request, resolvedRows), /COMPLETE_ACTUAL_SCHEMA_REQUIRED/) + } + const changed = snapshotRows(baseline()) + const physical = changed.find(row => row.table === 'll_dispatch_physical_pallet_lock' && row.row) + delete physical.row.released_at + assert.throws(() => fullRollbackSnapshot(changed, scope), /COMPLETE_FULL_FIELD_SNAPSHOT_REQUIRED/) +}) + +test('permanent guard schema cannot introduce customer, owner, state or TTL or change the original PK/time types', () => { + for (const mutate of [ + rows => { rows.find(row => row.table === 'll_physical_pallet_guard' && row.column === 'warehouse_pallet_id').key = '' }, + rows => { rows.find(row => row.table === 'll_physical_pallet_guard' && row.column === 'created_at').type = 'datetime' }, + ...['customer_id', 'owner', 'state', 'expires_at'].map(column => rows => rows.push({ collection: 'column', table: 'll_physical_pallet_guard', column, ordinal: 3, type: 'varchar(128)', key: '' })), + ]) { + const rows = resolution(); mutate(rows) + assert.throws(() => resolveRollbackScope(request, rows)) + } +}) +for (const fault of ['delete', 'recreate', 'created timestamp', 'key replacement']) test(`committed rollback rejects permanent guard ${fault}`, () => { + const before = baseline(), restored = structuredClone(before) + if (fault === 'delete') restored.ll_physical_pallet_guard.pop() + if (fault === 'recreate') { const old = restored.ll_physical_pallet_guard.shift(); restored.ll_physical_pallet_guard.push({ ...old, created_at: '2026-10-19 12:00:00.123' }) } + if (fault === 'created timestamp') restored.ll_physical_pallet_guard[0].created_at = '2026-10-19 11:00:00.999' + if (fault === 'key replacement') restored.ll_physical_pallet_guard[0].warehouse_pallet_id = '991' + assert.throws(() => assertRestoredSnapshot(before, restored), /FULL_FIELD_COMMITTED_ROLLBACK_SNAPSHOT_CHANGED/) }) diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index 4e698f3c7..d8bb15b80 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 13 }, "openapi": { - "documents": 72, + "documents": 100, "coveredServices": 11, "codeFirstServices": 2 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 1efe09648..89ab9fb46 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,7 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 }) - assert.equal(summary.openapi.documents, 72) + assert.equal(summary.openapi.documents, 100) for (const name of ['lastleg-exports', 'return-packing', 'fleet-capacity-offers']) { assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) } @@ -75,6 +75,8 @@ test('repository freezes the current topology and non-blocking aggregate summary assert.ok(summary.modules.every(row => !['gateway-overseas', 'event-bridge-service'].includes(row.module))) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/loading-evidence-v1.yaml')) assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/overseas-relabel-document-v1.yaml')) + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/dispatch-evidence-v1.yaml')) + assert.ok(summary.openapi.files.includes('lastleg-service/src/main/resources/openapi/fleet-preparation-v1.yaml')) assert.ok(summary.openapi.files.includes('finance/src/main/resources/openapi/finance-container-settlement-v1.yaml')) assert.ok(summary.openapi.files.includes('warehouse-overseas-service/src/main/resources/openapi/warehouse-pallet-batch-internal-v1.yaml')) assert.equal(baseline.openapi.documents, summary.openapi.documents) diff --git a/scripts/mixed-container-outbound-test-env.mjs b/scripts/mixed-container-outbound-test-env.mjs index 383edbff1..e5ac68416 100644 --- a/scripts/mixed-container-outbound-test-env.mjs +++ b/scripts/mixed-container-outbound-test-env.mjs @@ -1,49 +1,295 @@ -import { readFileSync, writeFileSync, existsSync } from 'node:fs' -import { resolve } from 'node:path' +import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' -import { pickAll, saveReceiptAndComplete } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' -import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery, verifyDepartureReadiness, + dispatchInventorySources, inventorySnapshot, dispatchStockConservation, inventorySourcesPresent, + relabelOutboundMarksMatch } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { f40CreateDispatchBody } from './admin-test-env-modules/phase5-root-fulfillment.mjs' -// Explicit retained fixture continuation, not a new fixture generator. -const [sourcePath, containerNo, label, outputPath] = process.argv.slice(2) -if (!sourcePath || !containerNo || !['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) || !outputPath) { - throw new Error('Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit test authorization required') +const LASTLEG = '/api/platform/v1/last-leg' +const DISPATCH = `${LASTLEG}/dispatches` +const PDA = '/api/pda/v1/last-leg/dispatches' +const MODULE = 'native-retained-outbound' +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Retained ID lost precision') + return value == null ? '' : String(value) } -const options = parseArguments([]) -validateExecutionOptions(options) -const context = new AcceptanceRun(options) -const source = JSON.parse(readFileSync(resolve(sourcePath), 'utf8')) -const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label) -if (!record || record.overseas?.plan?.containerNo !== containerNo || !record.overseas?.receiving?.receivingVerified) { - throw new Error('Fixture must be the explicitly selected, completed mixed-container receipt') +const validVersion = value => (typeof value === 'string' || typeof value === 'number' && Number.isSafeInteger(value)) && /^(0|[1-9]\d*)$/.test(String(value)) +const success = response => response?.http === 200 && String(response?.body?.code) === '200' +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const requireFact = (condition, message) => { if (!condition) throw Error(message) } +const sorted = values => [...new Set(values)].sort() +const redact = value => { + if (Array.isArray(value)) return value.map(redact) + if (value && typeof value === 'object') return Object.fromEntries(Object.entries(value) + .filter(([name]) => !/password|token|authorization|base64|^bytes$|^content$/i.test(name)).map(([name, nested]) => [name, redact(nested)])) + return value } -const inboundCode = record.source.inboundCode -const customerId = record.overseas.plan.customerId -const output = resolve(outputPath) -const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { - marker: context.marker, containerNo, inboundCode, label, source: resolve(sourcePath), events: [], +export function saveRetainedReport(output, result) { + mkdirSync(dirname(output), { recursive: true }) + writeFileSync(`${output}.tmp`, `${JSON.stringify(redact({ ...result, updatedAt: new Date().toISOString() }), null, 2)}\n`) + renameSync(`${output}.tmp`, output) } -if (result.inboundCode !== inboundCode || result.containerNo !== containerNo || result.label !== label) throw new Error('Checkpoint scope mismatch') -context.marker = result.marker -const save = () => writeFileSync(output, JSON.stringify({ ...result, updatedAt: new Date().toISOString() }, null, 2)) -const base = '/api/platform/v1/last-leg' -async function api(method, path, body, token) { - const response = await context.requestOverseas(method, path, body, {}, token || context.overseasToken) - result.events.push({ method, path, http: response.http, code: response.body?.code, - message: response.body?.message, requestId: response.requestId, traceId: response.traceId }) - save() - if (response.http !== 200 || String(response.body?.code) !== '200') throw new Error(`${method} ${path}: ${response.body?.message}`) - return response.body.data +export function validateRetainedScope(result, scope) { + requireFact(result.schema === 'NATIVE_RETAINED_OUTBOUND_V1' && result.environment === 'TEST' + && result.marker === scope.marker && result.sourceSha256 === scope.sourceSha256 + && result.customerId === id(scope.customerId) && result.warehouseId === id(scope.warehouseId) + && same(result.inboundCodes, scope.inboundCodes), 'Retained native marker/source/customer/warehouse scope mismatch; legacy reports cannot resume') + requireFact(!result.requests?.some(row => ['SENDING', 'UNKNOWN'].includes(row.status)), + 'Unknown write outcome retained: reconcile the original command before further writes') } -let picker -try { - const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') - if (!login.body?.data?.overseasAccessToken) throw new Error('Platform login failed') - context.token = login.body.data.accessToken - context.overseasToken = login.body.data.overseasAccessToken - if (label === 'MIXED-DROPSHIP') { - const candidatePath = `${base}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}` +/** Every business write is saved before sending. Unknown results never generate another key. */ +export function installRetainedJournal(context, result, save) { + const request = context.request.bind(context), multipart = context.requestMultipart?.bind(context) + context.createdResources = result.resources ||= {} + context.createdResources.phase5OverseasFulfillment = result.fulfillment ||= {} + context.context ||= {} + context.context.phase5InventorySources = result.inventorySources || {} + context.checkpoint = async () => { result.inventorySources = context.context.phase5InventorySources; await save() } + result.requests ||= [] + let stopped = false + const execute = async (method, path, body, send, file) => { + if (method === 'GET' || path.startsWith('/api/auth/')) return send() + const cleanup = method === 'PUT' && /^\/api\/platform\/v1\/iam\/users\/[0-9]+\/status$/.test(path) && body?.status === 'DISABLED' + if (stopped && !cleanup) throw Error('Unknown write outcome: retained business writes stopped') + const entry = { method, path, ...(file ? { file } : { body: structuredClone(body) }), status: 'SENDING' } + result.requests.push(entry); await context.checkpoint() + let response + try { response = await send() } + catch (error) { stopped = true; entry.status = 'UNKNOWN'; await context.checkpoint(); throw error } + const missingNativeResult = success(response) && (path.startsWith(DISPATCH) || path.startsWith(`${LASTLEG}/fleet/`)) + && (response.body?.data == null || typeof response.body.data !== 'object') + const known = response?.http > 0 && response.http < 500 && response.body?.code != null && !missingNativeResult + Object.assign(entry, { status: known ? 'RESPONDED' : 'UNKNOWN', http: response?.http, + code: response?.body?.code, requestId: response?.requestId, traceId: response?.traceId, result: response?.body?.data }) + if (entry.status === 'UNKNOWN') stopped = true + await context.checkpoint() + if (stopped && !cleanup) throw Error('Unknown write outcome retained; no automatic retry or replacement command') + return response + } + context.request = (method, path, body, token, headers) => execute(method, path, body, () => request(method, path, body, token, headers)) + context.requestMultipart = (method, path, file, token, headers) => { + const bytes = Buffer.from(file.content) + return execute(method, path, undefined, () => multipart(method, path, file, token, headers), + { name: file.name, type: file.type, size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }) + } + return context +} +export async function retainedApi(context, method, path, body, token) { + const response = await context.request(method, path, body, token) + const data = context.apiSuccess(MODULE, `${method} ${path}`, response, '', { deploymentBlocker: true }) + if (!success(response)) throw Error(`Retained request failed: ${method} ${path}; HTTP=${response?.http}; code=${response?.body?.code}`) + return data +} +function sourceKey(selection) { + if (selection.sourceType === 'INBOUND_PLAN') return `PLAN:${id(selection.planPalletId)}` + if (selection.sourceType === 'RELABEL') return `RELABEL:${selection.relabelNo}:${id(selection.warehousePalletId)}` + throw Error('Unsupported retained source selector') +} +export function validateNativeRetainedDispatch(native, scope) { + requireFact(native?.marker === scope.marker && native?.dispatchNo && native.createBody?.idempotencyKey + && id(native.createBody.warehouseId) === id(scope.warehouseId) && native.createBody.cargo?.length + && native.createResult?.dispatchNo === native.dispatchNo && native.createResult?.action === 'CREATE' + && native.createResult?.status === 'ACTIVE' && native.createResult?.stage === 'BEFORE_LOADING' + && validVersion(native.createResult.recordVersion) && native.detail?.dispatchNo === native.dispatchNo + && native.detail.status === 'ACTIVE' && validVersion(native.detail.recordVersion) + && Array.isArray(native.detail.orderDetails) && native.detail.orderDetails.length, + 'Retained dispatch requires native Create command/result/Detail and the original marker; dispatch number alone is insufficient') + const keys = native.createBody.cargo.map(sourceKey) + requireFact(new Set(keys).size === keys.length && same(sorted(keys), sorted(native.detail.orderDetails.map(row => row.sourceKey))) + && native.detail.orderDetails.every(row => id(row.customerId) === id(scope.customerId)), 'Retained native source/customer identity mismatch') + requireFact(native.createBody.cargo.every(row => /^[a-f\d]{64}$/i.test(row.sourceVersion || '') + && /^[1-9]\d*$/.test(id(row.warehousePalletId))), 'Retained selectors require original immutable source versions and physical pallet IDs') + return keys +} +async function ledger(context, scope, dispatchNo) { + const rows = []; let total + for (let page = 1; page <= 50; page++) { + const params = new URLSearchParams({ customerId: id(scope.customerId), businessType: 'SHIP', businessOrderNo: dispatchNo, + outboundOrderNo: dispatchNo, page: String(page), size: '200' }) + const data = await retainedApi(context, 'GET', `${LASTLEG}/inventory/ledger?${params}`) + requireFact(Array.isArray(data.records) && Number.isSafeInteger(Number(data.total)), 'Incomplete retained SHIP ledger') + total ??= Number(data.total); requireFact(Number(data.total) === total, 'Ledger snapshot changed during pagination') + rows.push(...data.records) + if (rows.length >= total) break + requireFact(data.records.length > 0, 'Truncated retained SHIP ledger') + } + requireFact(rows.length === total && new Set(rows.map(row => id(row.id))).size === rows.length + && rows.every(row => id(row.customerId) === id(scope.customerId) && row.businessType === 'SHIP' + && row.businessOrderNo === dispatchNo && row.outboundOrderNo === dispatchNo), 'Foreign or duplicated retained shipment ledger') + return rows.map(row => ({ ...row, id: id(row.id), customerId: id(row.customerId), overseasWarehouseId: id(row.overseasWarehouseId), + beforePackages: Number(row.beforePackages), deltaPackages: Number(row.deltaPackages), afterPackages: Number(row.afterPackages) })).sort((a, b) => a.id.localeCompare(b.id)) +} +const NATIVE_DEPS = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness, + saveReceiptAndComplete, finishFleetTrip, inventorySnapshot, dispatchInventorySources, dispatchStockConservation, ledger } +/** Continue only a proved native dispatch. All mutations remain real public API commands. */ +export async function runRetainedNativeDispatch(context, result, scope, pdaToken, overrides = {}) { + const deps = { ...NATIVE_DEPS, ...overrides } + const api = (method, path, body, token) => retainedApi(context, method, path, body, token) + validateRetainedScope(result, scope) + context.marker = scope.marker + let native = result.nativeDispatch + requireFact(native, 'No original native dispatch proof; create it from current authoritative candidates first') + const expectedSources = validateNativeRetainedDispatch(native, scope), dispatchNo = native.dispatchNo, path = `${DISPATCH}/${encodeURIComponent(dispatchNo)}` + result.dispatchNo = dispatchNo + let detail = await api('GET', path) + const command = await api('GET', `${path}/commands/${encodeURIComponent(native.createBody.idempotencyKey)}`) + requireFact(command.dispatchNo === dispatchNo && command.action === 'CREATE' + && String(command.recordVersion) === String(native.createResult.recordVersion) + && same(command.result, native.createResult.result), 'Original CREATE command is absent or differs from retained production receipt') + const items = await api('GET', `${path}/items`) + requireFact(detail.status === 'ACTIVE' && validVersion(detail.recordVersion) + && same(sorted(items.items?.map(row => row.sourceKey) || []), sorted(expectedSources)) + && items.items.every(row => id(row.customerId) === id(scope.customerId) && scope.inboundCodes.includes(row.inboundCode)) + && Number(detail.counts?.packageCount) === scope.expectedPackages, 'Live native cargo/customer/quantity drift') + const task = await api('GET', `${PDA}/${encodeURIComponent(dispatchNo)}`, undefined, pdaToken) + requireFact(task.dispatchNo === dispatchNo && id(task.warehouseId) === id(scope.warehouseId) + && Array.isArray(task.items) && task.items.length > 0 && (!scope.expectedPalletItems || task.items.length === scope.expectedPalletItems) + && task.items.every(row => id(row.customerId) === id(scope.customerId)) + && task.items.reduce((sum, row) => sum + Number(row.scheduledPackages), 0) === scope.expectedPackages, 'PDA warehouse/customer/item quantity differs from the original physical sources') + const bol = await context.requestBinary('GET', `${path}/prints/bol`) + requireFact(bol.http === 200 && /application\/pdf/i.test(bol.contentType || '') && Buffer.from(bol.bytes || []).subarray(0, 5).toString() === '%PDF-', 'BOL is not a real PDF') + result.bol = { sha256: createHash('sha256').update(bol.bytes).digest('hex'), size: bol.bytes.length } + let trip = result.tripProof || (context.createdResources.fleetTrips || []).find(row => row.dispatchNo === dispatchNo) + if (detail.stage === 'BEFORE_LOADING') { + requireFact(!trip, 'Partly prepared resources already exist; retain the original commands instead of creating replacements') + trip = await deps.prepareFleetTrip(context, MODULE, dispatchNo, id(scope.warehouseId)); result.tripProof = trip + detail = await api('GET', path) + } + requireFact(trip?.preparation === 'VERIFIED' && trip.dispatchNo === dispatchNo && id(trip.warehouseId) === id(scope.warehouseId), 'Native resource/review proof is missing or incomplete') + result.tripProof = trip + if (detail.stage === 'LOADING') { + if (!result.inventory) { + requireFact(Number(task.counts?.pickedPackageCount) === 0, 'Cannot reconstruct a pre-scan inventory baseline after physical scanning') + const sources = deps.dispatchInventorySources(task, dispatchNo, id(scope.warehouseId)) + requireFact(sources, 'Missing real PDA source positions/balances') + result.inventory = { ...sources, + stockBefore: await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-before-pick', sources.sourceBalanceIds), + ledgerBefore: await deps.ledger(context, scope, dispatchNo) } + requireFact(result.inventory.stockBefore && inventorySourcesPresent(result.inventory.stockBefore, sources.sourcePositionBalances), 'Incomplete original pre-scan source inventory') + await context.checkpoint() + } + requireFact(await deps.pickAll(context, pdaToken, dispatchNo), 'PDA real picks failed') + result.inventory.stockAfter = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-pick', result.inventory.sourceBalanceIds) + result.inventory.ledgerAfter = await deps.ledger(context, scope, dispatchNo) + const conservation = deps.dispatchStockConservation(context, { ...result.inventory, customerId: id(scope.customerId), warehouseId: id(scope.warehouseId), + dispatchNo, expectedPackages: scope.expectedPackages }) + result.inventory.conservation = conservation.proof + requireFact(conservation.passed, 'Physical inventory/SHIP conservation failed') + await context.checkpoint() + try { await deps.confirmFleetLoading(context, MODULE, trip, pdaToken, result.loadingMeasurements || native.loadingMeasurements) } + catch (error) { + const field = ['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'].find(value => String(error?.message).includes(value)) + result.blocker = { stage: 'LOADING_COMPLETE', code: field ? 'ACTUAL_MEASUREMENT_REQUIRED' : 'LOADING_COMPLETE_NOT_VERIFIED', field: field || null } + await context.checkpoint(); throw error + } + detail = await api('GET', path) + } + requireFact(result.inventory?.conservation, 'No original scan quantity/ledger proof for this retained native dispatch') + const recovered = action => result.requests?.find(row => row.status === 'RESPONDED' && String(row.code) === '200' && row.result?.dispatchNo === dispatchNo && row.result?.action === action)?.result + if (!result.transfer) { + const saved = recovered('ARRANGE_DELIVERY') + if (saved) result.transfer = { data: saved, deliveries: saved.result.deliveries, outbounds: saved.result.outbounds } + else { + requireFact(detail.stage === 'AFTER_LOADING', 'Missing original arrangement command; do not borrow existing historical delivery rows') + result.transfer = await deps.arrangeDispatchDelivery(context, dispatchNo) + requireFact(result.transfer, 'Native arrangement failed') + } + await context.checkpoint() + } + const deliveries = result.transfer.deliveries, outbounds = result.transfer.outbounds + requireFact(Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length === 1 + && id(outbounds[0].customerId) === id(scope.customerId) && deliveries.every(row => id(row.customerId) === id(scope.customerId) + && row.outboundNo === outbounds[0].outboundNo) && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === scope.expectedPackages, + 'Native arrangement does not conserve the retained customer and physical quantity') + result.outboundNo = outbounds[0].outboundNo; result.deliveryNos = deliveries.map(row => row.deliveryNo) + if (!result.departure) result.departure = recovered('DEPART') + if (detail.stage === 'AFTER_LOADING') { + const ready = await deps.verifyDepartureReadiness(context, pdaToken, dispatchNo) + requireFact(ready && validVersion(ready.data.recordVersion), 'Native server DEPART gates unavailable') + result.departBody ||= { recordVersion: ready.data.recordVersion, idempotencyKey: `${scope.marker}-DEPART`.slice(0, 128), remark: `${scope.marker}-TEST` } + await context.checkpoint() + result.departure = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken) + const replay = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken) + requireFact(replay.replayed === true && String(replay.recordVersion) === String(result.departure.recordVersion) + && same(replay.result, result.departure.result), 'Departure original command replay drift') + result.departureReplay = replay + detail = await api('GET', path) + } + requireFact(result.departure?.stage === 'DELIVERING' && result.departure?.status === 'ACTIVE' + && Number(result.departure.result?.shippedPackages) === scope.expectedPackages + && same(sorted(result.departure.result?.deliveries?.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`) || []), + sorted(deliveries.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`))) + && ['DELIVERING', 'DELIVERED'].includes(detail.stage), 'Original native departure receipt missing or invalid') + result.inventory.stockAfterDepart = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-depart', result.inventory.sourceBalanceIds) + result.inventory.ledgerAfterDepart = await deps.ledger(context, scope, dispatchNo) + requireFact(same(result.inventory.stockAfter, result.inventory.stockAfterDepart) + && same(result.inventory.ledgerAfter, result.inventory.ledgerAfterDepart), 'Departure repeated SHIP or changed inventory') + result.outbound = await api('GET', `${LASTLEG}/outbound-orders/${encodeURIComponent(result.outboundNo)}`) + requireFact(result.outbound.outbound?.status === 'OUTBOUNDED' && id(result.outbound.outbound.customerId) === id(scope.customerId) + && result.outbound.outbound.outboundNo === result.outboundNo && result.outbound.outbound.dispatchNo === dispatchNo + && Number(result.outbound.outbound.packageCount) === scope.expectedPackages, 'Actual retained outbound is not complete') + if (scope.relabelCartonMarks) requireFact(relabelOutboundMarksMatch(result.outbound, scope), 'New relabel carton marks were lost, duplicated or replaced with old marks') + result.deliveries ||= [] + for (const [index, delivery] of deliveries.entries()) { + const deliveryPath = `${LASTLEG}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}?customerId=${encodeURIComponent(scope.customerId)}` + const current = await api('GET', deliveryPath) + requireFact(id(current.order?.customerId) === id(scope.customerId) && current.order.outboundNo === result.outboundNo, 'Delivery customer/outbound identity drift') + if (current.order.status !== 'DELIVERED') { + requireFact(current.order.status === 'DELIVERING', 'Actual native departure must advance delivery before POD') + requireFact(await deps.saveReceiptAndComplete(context, delivery, id(scope.customerId), index), 'POD did not automatically complete delivery') + } else { + const original = result.requests.find(row => row.method === 'PUT' && row.path === `${LASTLEG}/delivery-orders/${encodeURIComponent(delivery.deliveryNo)}/documents?customerId=${encodeURIComponent(scope.customerId)}` && row.status === 'RESPONDED' && row.result?.categories) + requireFact(original, 'Already signed delivery has no original retained POD save command') + const replay = await api('PUT', original.path, original.body) + requireFact(replay.replayed === true && String(replay.version) === String(original.result.version), 'POD original-command replay drift') + } + const signed = await api('GET', deliveryPath) + requireFact(signed.order?.status === 'DELIVERED' && signed.order.signedAt, 'Real signedAt/POD completion missing') + result.deliveries[index] = signed; await context.checkpoint() + } + for (let attempt = 0; attempt < 30; attempt++) { + detail = await api('GET', path) + if (detail.stage === 'DELIVERED') break + await new Promise(resolve => setTimeout(resolve, 1000)) + } + requireFact(detail.status === 'ACTIVE' && detail.stage === 'DELIVERED', 'All normal signatures did not aggregate native DELIVERED') + result.nativeCompleted = detail + if (!trip.closure) result.tripClosure = await deps.finishFleetTrip(context, MODULE, trip) + else requireFact(trip.closure.status === 'VERIFIED', 'Retained resource closure is incomplete; preserve original release commands') + result.finalStatus = 'PASSED'; await context.checkpoint(); return result +} + +export async function mainMixed(args = process.argv.slice(2)) { + const [sourcePath, containerNo, label, outputPath] = args + requireFact(sourcePath && containerNo && ['MIXED-FBA', 'MIXED-DROPSHIP'].includes(label) && outputPath, + 'Usage: source-report containerNo MIXED-FBA|MIXED-DROPSHIP output-report; explicit TEST authorization required') + const options = parseArguments([]); options.modules = []; validateExecutionOptions(options) + requireFact(options.environment === 'test', 'Retained mixed outbound only supports explicit TEST') + const sourceBytes = readFileSync(resolve(sourcePath)), source = JSON.parse(sourceBytes) + const record = source.createdResources?.dropshipPalletMatrix?.records?.find(row => row.label === label) + requireFact(source.environment === 'test' && source.marker && record?.overseas?.plan?.containerNo === containerNo + && record.overseas?.receiving?.receivingVerified, 'Explicit current TEST receipt source is required') + const inboundCode = record.source.inboundCode, customerId = id(record.overseas.plan.customerId), warehouseId = id(record.overseas.warehouseId) + const output = resolve(outputPath), scope = { marker: `${source.marker}-${label}`, customerId, warehouseId, inboundCodes: [inboundCode], expectedPackages: 4, + sourceSha256: createHash('sha256').update(sourceBytes).digest('hex') } + const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { schema: 'NATIVE_RETAINED_OUTBOUND_V1', environment: 'TEST', + ...scope, containerNo, inboundCode, label, source: resolve(sourcePath), requests: [] } + validateRetainedScope(result, scope) + const context = installRetainedJournal(new AcceptanceRun(options), result, () => saveRetainedReport(output, result)) + context.marker = scope.marker + const api = (method, path, body, token) => retainedApi(context, method, path, body, token) + let picker + try { + const login = await context.request('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + requireFact(success(login) && login.body?.data?.accessToken, 'Unified platform login failed') + context.token = login.body.data.accessToken + if (label === 'MIXED-DROPSHIP') { + const candidatePath = `${LASTLEG}/outbound-orders/one-click-dropship/candidates?customerId=${customerId}&warehouseId=${record.overseas.warehouseId}` if (!result.task) { const candidates = await api('GET', candidatePath) const selected = candidates.filter(row => row.inboundCode === inboundCode) @@ -57,17 +303,17 @@ try { result.selected = selected result.inputs = inputs result.createBody = { recipient: `${context.marker} TEST ONLY`, items: inputs, remark: `${context.marker}-test-only`, idempotencyKey: `${context.marker}-OUTBOUND` } - save() - result.task = await api('POST', `${base}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody) - save() + await context.checkpoint() + result.task = await api('POST', `${LASTLEG}/outbound-orders/one-click-dropship?customerId=${customerId}`, result.createBody) + await context.checkpoint() } - const before = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`) + const before = await api('GET', `${LASTLEG}/outbound-orders/${result.task.outboundNo}`) if (before.outbound.status !== 'OUTBOUNDED') { const access = await resolvePickerAccess(context) picker = access ? await createPicker(context, 9, record.overseas.warehouseId, access) : null if (!picker?.token) throw new Error('PDA operator unavailable') result.pickerMembershipId = picker.membershipId - save() + await context.checkpoint() const path = `/api/pda/v1/last-leg/outbound-tasks/${result.task.taskNo}` const detail = await api('GET', path, undefined, picker.token) for (const [index, candidate] of result.selected.entries()) { @@ -80,95 +326,66 @@ try { } result.completedTask = await api('POST', `${path}/complete`, { idempotencyKey: `${context.marker}-COMPLETE` }, picker.token) } - result.outbound = await api('GET', `${base}/outbound-orders/${result.task.outboundNo}`) + result.outbound = await api('GET', `${LASTLEG}/outbound-orders/${result.task.outboundNo}`) result.remainingCandidates = (await api('GET', candidatePath)).filter(row => row.inboundCode === inboundCode) result.finalStatus = result.outbound.outbound.status === 'OUTBOUNDED' && result.remainingCandidates.length === 0 ? 'OUTBOUND_PASSED_DELIVERY_NOT_SUPPORTED' : 'OUTBOUND_FAILED' - } else { - const candidates = await api('GET', `${base}/dispatches/candidates?sourceStage=RECEIVED&orderNo=${encodeURIComponent(record.overseas.summary.orderNo)}`) - const selected = candidates.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo) - if (!result.dispatchNo) { - if (selected.reduce((sum, row) => sum + Number(row.packageCount), 0) !== 4 || selected.some(row => row.fbaWarehouseCode !== 'ABE4')) throw new Error('Live FBA candidate quantity or destination changed') - // The caller must retain the checkpoint after every mutation; do not retry creation without checking live candidates. - const dispatch = await api('POST', `${base}/dispatches/pre-arrange`, { planPalletIds: selected.map(row => row.planPalletId) }) - result.dispatchNo = dispatch.dispatchNo - save() - } - const dispatchPath = `${base}/dispatches/${encodeURIComponent(result.dispatchNo)}` - let dispatch = await api('GET', dispatchPath) - if (!result.transfer) { - await api('PUT', dispatchPath, { vehiclePlate: `TEST-${context.marker.slice(-8)}`, driverName: 'HTTP测试司机', - driverPhone: '19900000000', carrierName: 'HTTP测试车队', estimatedArrivalAt: new Date(Date.now() + 86400000).toISOString() }) - result.transfer = await api('POST', `${dispatchPath}/transfer-delivery`, { - idempotencyKey: `${context.marker}-TRANSFER`, plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), - deliveryMethod: 'TRUCK_PALLET', carrierName: 'HTTP测试车队', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', - vehicleType: 'TRUCK', vehicleSize: '15托', driverName: 'HTTP测试司机', driverPhone: '19900000000', - vehiclePlate: `TEST-${context.marker.slice(-8)}`, appointmentAccount: 'SELF', remark: `${context.marker}-test-only`, - fees: [{ feeType: 'DELIVERY_FEE', feeName: '测试派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 1, remark: `${context.marker}-test-only` }], - }) - save() - } - dispatch = await api('GET', dispatchPath) - if (dispatch.status !== 'DEPARTED') { - const access = await resolvePickerAccess(context) - picker = access ? await createPicker(context, 8, record.overseas.warehouseId, access) : null - if (!picker?.token) throw new Error('PDA operator unavailable') - result.pickerMembershipId = picker.membershipId - save() - const tripTask = await api('GET', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}`, undefined, picker.token) - if (!tripTask.tripNo) throw new Error('Retained dispatch must be assigned to a qualified fleet trip before picking; prepare through the current fleet API') - const trip = await api('GET', `${base}/fleet/trips/${encodeURIComponent(tripTask.tripNo)}`) - if (dispatch.status !== 'READY_TO_DEPART' && !await pickAll(context, picker.token, result.dispatchNo)) throw new Error('PDA picking failed') - const tripVersion = await confirmFleetLoading(context, 'mixed-container-outbound', { tripNo: tripTask.tripNo, stops: trip.trip.stops }) - result.departure = await api('POST', `/api/pda/v1/last-leg/dispatches/${result.dispatchNo}/depart`, - { recordVersion: tripVersion, idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: `${context.marker}-test-only` }, picker.token) - if (result.departure.status !== 'IN_TRANSIT' || !result.departure.dispatches?.some(item => item.dispatchNo === result.dispatchNo)) throw new Error('Whole-trip departure result missing retained dispatch') - save() - } - const deliveries = result.transfer.deliveries - if (!Array.isArray(deliveries) || deliveries.length !== 1 || Number(result.transfer.packageCount) !== 4) throw new Error('Unexpected transfer quantities') - result.deliveries = [] - for (const [index, delivery] of deliveries.entries()) { - const path = `${base}/delivery-orders/${delivery.deliveryNo}?customerId=${customerId}` - const detail = await api('GET', path) - if (detail.order.status !== 'DELIVERED') { - if (detail.order.status !== 'DELIVERING') await api('PUT', `${base}/delivery-orders/${delivery.deliveryNo}/status?customerId=${customerId}`, - { status: 'DELIVERING', expectedVersion: detail.order.version, remark: `${context.marker}-test-only` }) - if (!await saveReceiptAndComplete(context, delivery, customerId, index)) throw new Error('Receipt signature failed') + + } else { + if (!result.nativeDispatch) { + const savedCreate = result.requests.find(row => row.method === 'POST' && row.path === DISPATCH && row.status === 'RESPONDED' + && String(row.code) === '200' && row.result?.action === 'CREATE') + if (savedCreate) { + requireFact(result.createBody && same(savedCreate.body, result.createBody), 'Original native CREATE body must remain exact') + const detail = await api('GET', `${DISPATCH}/${encodeURIComponent(savedCreate.result.dispatchNo)}`) + result.nativeDispatch = { marker: scope.marker, dispatchNo: savedCreate.result.dispatchNo, createBody: savedCreate.body, createResult: savedCreate.result, detail, + loadingMeasurements: record.loadingMeasurements } + await context.checkpoint() + } + } + if (!result.nativeDispatch) { + const page = await api('GET', `${DISPATCH}/candidates?warehouseId=${warehouseId}`) + const selected = page?.candidates?.filter(row => row.inboundCode === inboundCode && row.containerNo === containerNo + && id(row.customerId) === customerId && id(row.warehouseId) === warehouseId) + requireFact(page?.complete === true && selected?.length && selected.reduce((sum, row) => sum + Number(row.availablePackages), 0) === 4 + && selected.every(row => row.destination?.warehouseCode === 'ABE4'), 'Live native FBA source/destination/quantity changed') + result.createBody ||= f40CreateDispatchBody(scope.marker, warehouseId, customerId, selected) + await context.checkpoint() + const created = await api('POST', DISPATCH, result.createBody) + const detail = await api('GET', `${DISPATCH}/${encodeURIComponent(created.dispatchNo)}`) + result.nativeDispatch = { marker: scope.marker, dispatchNo: created.dispatchNo, createBody: result.createBody, createResult: created, detail, + loadingMeasurements: record.loadingMeasurements } + await context.checkpoint() + } + const access = await resolvePickerAccess(context) + picker = access ? await createPicker(context, 8, warehouseId, access) : null + requireFact(picker?.token, 'PDA operator unavailable') + result.pickerMembershipId = picker.membershipId; await context.checkpoint() + await runRetainedNativeDispatch(context, result, scope, picker.token) + for (let attempt = 0; attempt < 30; attempt++) { + const data = await api('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${encodeURIComponent(inboundCode)}&page=1&size=20`) + result.domesticInbound = data.records?.find(row => row.inboundCode === inboundCode) + if (result.domesticInbound?.processStatus === 'DELIVERED') break + await new Promise(resolve => setTimeout(resolve, 1000)) + } + requireFact(result.domesticInbound?.orderId, 'Missing original domestic source order') + result.domesticOrder = (await api('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`))?.summary + requireFact(result.domesticInbound.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED', 'Domestic source completion callback did not converge') } - result.deliveries.push(await api('GET', path)) - save() - } - result.outbound = await api('GET', `${base}/outbound-orders/${result.transfer.outboundNo}`) - for (let attempt = 0; attempt < 30; attempt++) { - const response = await context.request('GET', `/api/platform/inbounds?tenantId=${customerId}&inboundCode=${inboundCode}&page=1&size=20`) - if (String(response.body?.code) !== '200') throw new Error('Domestic inbound query failed') - result.domesticInbound = response.body.data.records.find(row => row.inboundCode === inboundCode) - if (result.domesticInbound?.processStatus === 'DELIVERED') break - await new Promise(resolve => setTimeout(resolve, 1000)) - } - if (!result.domesticInbound?.orderId) throw new Error('Missing domestic source order') - const order = await context.request('GET', `/api/platform/v1/orders/${result.domesticInbound.orderId}?tenantId=${customerId}`) - result.domesticOrder = order.body?.data?.summary - result.finalStatus = result.domesticInbound?.processStatus === 'DELIVERED' && result.domesticOrder?.mainStatus === 'COMPLETED' - && result.outbound?.outbound?.status === 'OUTBOUNDED' ? 'PASSED' : 'STATE_CALLBACK_FAILED' - } -} catch (error) { - result.error = error.message - result.finalStatus = 'BLOCKED' - process.exitCode = 1 -} finally { - try { - await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8) } catch (error) { - result.cleanupError = error.message - result.finalStatus = 'CLEANUP_FAILED' + result.finalStatus = 'BLOCKED'; result.failedStage = result.nativeDispatch?.dispatchNo ? 'NATIVE_FULFILLMENT' : 'SOURCE_PREPARATION' + process.exitCode = 1 + } finally { + try { await disablePicker(context, picker, label === 'MIXED-DROPSHIP' ? 9 : 8) } + catch { result.finalStatus = 'CLEANUP_FAILED' } + finally { if (picker) picker.token = null } + result.checks = context.steps + if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED' + if (result.finalStatus !== 'PASSED') process.exitCode = 1 + await context.checkpoint() + console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, + dispatchNo: result.dispatchNo, outboundNo: result.outboundNo || result.task?.outboundNo, deliveryNos: result.deliveryNos, report: output })) } - result.checks = context.steps - if (context.steps.some(step => step.passed === false)) result.finalStatus = 'CHECK_FAILED' - if (result.finalStatus !== 'PASSED') process.exitCode = 1 - save() - console.log(JSON.stringify({ containerNo, inboundCode, finalStatus: result.finalStatus, error: result.error, - dispatchNo: result.dispatchNo, outboundNo: result.transfer?.outboundNo || result.task?.outboundNo, - deliveryNos: result.transfer?.deliveries?.map(row => row.deliveryNo), report: output })) + return result } +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainMixed() diff --git a/scripts/native-retained-outbound-contract.test.mjs b/scripts/native-retained-outbound-contract.test.mjs new file mode 100644 index 000000000..8d7b6f1df --- /dev/null +++ b/scripts/native-retained-outbound-contract.test.mjs @@ -0,0 +1,175 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, mkdtempSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { installRetainedJournal, validateRetainedScope, validateNativeRetainedDispatch, runRetainedNativeDispatch, + saveRetainedReport } from './mixed-container-outbound-test-env.mjs' +import { retainedRelabelSource } from './overseas-relabel-fulfillment-acceptance.mjs' + +const BASE = '/api/platform/v1/last-leg/dispatches' +const DOCS = '/api/platform/v1/last-leg/delivery-orders/DL-1/documents?customerId=100' +const ok = data => ({ http: 200, body: { code: '200', data }, requestId: 'REQ-1', traceId: 'TRACE-1' }) +const scope = () => ({ marker: 'RUN-1', sourceSha256: 'a'.repeat(64), customerId: '100', warehouseId: '80', inboundCodes: ['IN-1'], expectedPackages: 4 }) +function nativeProof() { + return { marker: 'RUN-1', customerId: '100', dispatchNo: 'DS-1', + createBody: { warehouseId: '80', idempotencyKey: 'RUN-1-CREATE', cargo: [{ sourceType: 'INBOUND_PLAN', planPalletId: '9007199254740993', + warehousePalletId: '9007199254740994', sourceVersion: 'b'.repeat(64) }] }, + createResult: { dispatchNo: 'DS-1', action: 'CREATE', recordVersion: '0', status: 'ACTIVE', stage: 'BEFORE_LOADING', result: { dispatchNo: 'DS-1', changedItemIds: ['9007199254740995'] } }, + detail: { dispatchNo: 'DS-1', recordVersion: '0', status: 'ACTIVE', stage: 'BEFORE_LOADING', counts: { packageCount: '4' }, + orderDetails: [{ sourceKey: 'PLAN:9007199254740993', customerId: '100', inboundCode: 'IN-1' }] } } +} +const resultFor = () => ({ schema: 'NATIVE_RETAINED_OUTBOUND_V1', environment: 'TEST', ...scope(), nativeDispatch: nativeProof(), requests: [] }) + +test('retained scope rejects legacy data, foreign marker/source identity and unresolved original writes before continuation', () => { + assert.throws(() => validateRetainedScope({ dispatchNo: 'DS-1' }, scope()), /legacy reports/) + for (const mutate of [value => { value.marker = 'HISTORY' }, value => { value.sourceSha256 = 'other' }, + value => { value.customerId = '200' }, value => { value.warehouseId = '81' }, + value => { value.requests.push({ status: 'UNKNOWN' }) }, value => { value.requests.push({ status: 'SENDING' }) }]) { + const value = resultFor(); mutate(value); assert.throws(() => validateRetainedScope(value, scope())) + } + assert.deepEqual(validateNativeRetainedDispatch(nativeProof(), scope()), ['PLAN:9007199254740993']) + for (const mutate of [value => { delete value.createBody }, value => { value.createResult.recordVersion = 9007199254740992 }, + value => { value.createResult.action = 'EDIT' }, value => { value.detail.orderDetails[0].customerId = '200' }, + value => { value.createBody.cargo[0].sourceVersion = 'unknown' }, value => { value.detail.orderDetails[0].sourceKey = 'PLAN:OTHER' }]) { + const value = nativeProof(); mutate(value); assert.throws(() => validateNativeRetainedDispatch(value, scope())) + } +}) + +test('journal saves exact original intent before network, blocks unknown/malformed native successes, and refuses replacement commands', async () => { + for (const response of [{ http: 0 }, { http: 503, body: { code: 'A0500' } }, ok(null), { http: 200, body: null }]) { + const result = resultFor(), snapshots = [], calls = [] + const context = installRetainedJournal({ createdResources: {}, context: {}, + async request(method, path, body) { calls.push({ method, path, body }); assert.equal(snapshots.at(-1).requests.at(-1).status, 'SENDING'); return response }, + }, result, async () => { snapshots.push(structuredClone(result)) }) + const body = { recordVersion: '0', idempotencyKey: 'ORIGINAL' } + await assert.rejects(context.request('POST', `${BASE}/DS-1/depart`, body), /Unknown write/) + assert.equal(result.requests[0].status, 'UNKNOWN') + assert.deepEqual(result.requests[0].body, body) + await assert.rejects(context.request('POST', `${BASE}/DS-1/depart`, { ...body, idempotencyKey: 'NEW' }), /stopped/) + assert.equal(calls.length, 1) + assert.throws(() => validateRetainedScope(result, scope()), /Unknown write/) + } +}) + +test('retained report stores file hashes and redacts credentials and binary bytes', () => { + const directory = mkdtempSync(join(tmpdir(), 'native-retained-contract-')) + try { + const path = join(directory, 'report.json') + saveRetainedReport(path, { marker: 'RUN-1', password: 'SECRET-PASSWORD', accessToken: 'SECRET-TOKEN', + requests: [{ file: { sha256: 'a'.repeat(64), content: 'SECRET-BYTES' } }] }) + const text = readFileSync(path, 'utf8') + assert.doesNotMatch(text, /SECRET-/) + assert.ok(text.includes('a'.repeat(64))) + } finally { rmSync(directory, { recursive: true, force: true }) } +}) + +function stock(after) { + const quantity = after ? 0 : 4 + return [{ id: '1', totalPackages: quantity, balance: { id: '1', totalPackages: quantity }, + positions: [{ id: '11', balanceId: '1', customerId: '100', overseasWarehouseId: '80', inboundCode: 'IN-1', palletNo: 'P-1', + totalPackages: quantity, availablePackages: quantity, holdPackages: 0, allocatedPackages: 0, pickedPackages: 0, receivingPackages: 0 }], + globalGroups: [{ totalPackages: quantity }] }] +} +function nativeRunFixture() { + const result = resultFor(), events = [], calls = [] + let stage = 'BEFORE_LOADING', picked = false, signed = false, departure, pod + result.loadingMeasurements = { environment: 'TEST', marker: 'RUN-1', dispatchNo: 'DS-1', pallets: [] } + const delivery = { customerId: '100', deliveryNo: 'DL-1', outboundNo: 'OB-1', packageCount: '4', destination: { key: 'FBA:ABE4', warehouseCode: 'ABE4' } } + const context = { token: 'PLATFORM-IN-MEMORY', marker: 'RUN-1', createdResources: {}, context: {}, + apiSuccess(_module, _label, response) { return response.body?.data }, assert(_m, _l, _r, passed) { assert.equal(passed, true) }, + async requestBinary() { return { http: 200, contentType: 'application/pdf', bytes: Buffer.from('%PDF-1.4 TEST') } }, + async request(method, path, body, token) { + calls.push({ method, path, body: structuredClone(body), token }) + if (method === 'GET' && path.includes('/commands/')) return ok(result.nativeDispatch.createResult) + if (method === 'GET' && path.endsWith('/items')) return ok({ items: [{ sourceKey: 'PLAN:9007199254740993', customerId: '100', inboundCode: 'IN-1' }] }) + if (method === 'GET' && path.startsWith('/api/pda/')) return ok({ dispatchNo: 'DS-1', warehouseId: '80', counts: { pickedPackageCount: picked ? '4' : '0' }, + items: [{ customerId: '100', scheduledPackages: '4', positions: [{ positionId: '11', balanceId: '1' }] }] }) + if (method === 'GET' && path === `${BASE}/DS-1`) return ok({ dispatchNo: 'DS-1', status: 'ACTIVE', stage, recordVersion: '9007199254740997', counts: { packageCount: '4' } }) + if (method === 'POST' && path.endsWith('/depart')) { + events.push('depart'); assert.equal(body.recordVersion, '9007199254740996'); assert.equal(token, 'PDA-IN-MEMORY') + if (departure) { assert.deepEqual(body, result.departBody); return ok({ ...departure, replayed: true }) } + stage = 'DELIVERING' + departure = { dispatchNo: 'DS-1', action: 'DEPART', status: 'ACTIVE', stage, recordVersion: '9007199254740997', result: { shippedPackages: '4', deliveries: [delivery] } } + return ok(departure) + } + if (method === 'GET' && path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED', customerId: '100', outboundNo: 'OB-1', dispatchNo: 'DS-1', packageCount: 4 }, pallets: [{ cartonMarks: 'NEW-1,NEW-2,NEW-3,NEW-4' }] }) + if (method === 'GET' && path.includes('/delivery-orders/')) return ok({ order: { customerId: '100', outboundNo: 'OB-1', status: signed ? 'DELIVERED' : 'DELIVERING', signedAt: signed ? '2026-10-19T01:00:00' : null } }) + if (method === 'PUT' && path === DOCS) { + events.push('pod') + if (pod) return ok({ ...pod, replayed: true }) + signed = true; stage = 'DELIVERED'; pod = { version: '9007199254740998', categories: [{ category: 'RECEIPT' }] } + return ok(pod) + } + throw Error(`Unexpected offline ${method} ${path}`) + }, + } + installRetainedJournal(context, result, async () => { events.push('checkpoint') }) + const deps = { + async prepareFleetTrip(_context, _module, dispatchNo, warehouseId) { events.push('prepare'); stage = 'LOADING'; return { dispatchNo, warehouseId, tripNo: 'TR-1', preparation: 'VERIFIED' } }, + async pickAll(_context, token) { events.push('pick'); assert.equal(token, 'PDA-IN-MEMORY'); picked = true; return true }, + async confirmFleetLoading(_context, _module, _proof, token, measurements) { + events.push('loading'); assert.equal(token, 'PDA-IN-MEMORY'); assert.equal(measurements, result.loadingMeasurements) + if (!measurements) throw Error('Actual palletSpaces measurement is missing') + stage = 'AFTER_LOADING'; return '9007199254740995' + }, + async arrangeDispatchDelivery() { events.push('arrange'); return { data: { result: {} }, deliveries: [delivery], outbounds: [{ customerId: '100', outboundNo: 'OB-1' }] } }, + async verifyDepartureReadiness() { events.push('ready'); return { data: { recordVersion: '9007199254740996' } } }, + async saveReceiptAndComplete(current) { await current.request('PUT', DOCS, { expectedVersion: '0', idempotencyKey: 'ORIGINAL-POD' }); return { order: { status: 'DELIVERED' } } }, + async finishFleetTrip(_context, _module, proof) { events.push('finish'); proof.closure = { status: 'VERIFIED' }; return proof.closure }, + async inventorySnapshot() { return stock(picked) }, + async ledger() { return picked ? [{ id: '81', customerId: '100', overseasWarehouseId: '80', businessType: 'SHIP', businessOrderNo: 'DS-1', + outboundOrderNo: 'DS-1', palletNo: 'P-1', beforePackages: 4, deltaPackages: -4, afterPackages: 0 }] : [] }, + } + return { context, result, deps, events, calls } +} + +test('retained native execution preserves real ordering, token/measurement scope, current zero-safe version, SHIP once, POD and same-run resume', async () => { + const f = nativeRunFixture() + await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps) + assert.equal(f.result.finalStatus, 'PASSED') + assert.deepEqual(f.events.filter(value => value !== 'checkpoint'), ['prepare', 'pick', 'loading', 'arrange', 'ready', 'depart', 'depart', 'pod', 'finish']) + assert.equal(f.result.inventory.conservation.totals.ledgerShipped, 4) + assert.deepEqual(f.result.inventory.ledgerAfter, f.result.inventory.ledgerAfterDepart) + assert.equal(f.result.departBody.recordVersion, '9007199254740996') + const originalDepartCount = f.calls.filter(row => row.method === 'POST').length + await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps) + assert.equal(f.calls.filter(row => row.method === 'POST').length, originalDepartCount) + assert.equal(f.events.filter(value => value === 'pod').length, 2) + assert.equal(f.events.filter(value => value === 'pick').length, 1) + assert.equal(f.calls.some(row => /pre-arrange|transfer-delivery/.test(row.path)), false) +}) + +test('missing real measurements stops after scanned quantity proof and before arrangement, departure and POD', async () => { + const f = nativeRunFixture(); delete f.result.loadingMeasurements + await assert.rejects(runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps), /measurement is missing/) + assert.equal(f.events.includes('arrange'), false); assert.equal(f.events.includes('depart'), false); assert.equal(f.events.includes('pod'), false) + assert.equal(f.result.inventory.conservation.totals.ledgerShipped, 4) + assert.equal(f.result.blocker.code, 'ACTUAL_MEASUREMENT_REQUIRED') + assert.equal(f.result.blocker.field, 'palletSpaces') +}) + +test('relabel input retains original/new mark evidence and cannot upgrade legacy report or a bare business number into native success', () => { + assert.throws(() => retainedRelabelSource({ marker: 'RUN-1', scenarios: [{ name: 'SPLIT-TWO-TARGETS', dispatchNo: 'DS-1' }] }, '100'), /native source report/) + const native = nativeProof() + native.createBody.cargo[0] = { sourceType: 'RELABEL', relabelNo: 'RL-1', warehousePalletId: '9007199254740994', sourceVersion: 'b'.repeat(64) } + native.detail.orderDetails[0].sourceKey = 'RELABEL:RL-1:9007199254740994' + const source = { environment: 'TEST', marker: 'RUN-1', scenarios: [{ name: 'SPLIT-TWO-TARGETS', relabelNo: 'RL-1', nativeDispatch: native, + relabelCartonMarks: ['NEW-1', 'NEW-2', 'NEW-3', 'NEW-4'], sourceCartonMarks: ['OLD-1', 'OLD-2', 'OLD-3', 'OLD-4'] }] } + assert.equal(retainedRelabelSource(source, '100', 'DS-1').scope.expectedPackages, 4) + const missing = structuredClone(source); delete missing.scenarios[0].sourceCartonMarks + assert.throws(() => retainedRelabelSource(missing, '100', 'DS-1'), /original\/new/) + assert.throws(() => retainedRelabelSource(source, '200', 'DS-1'), /native source report/) + assert.throws(() => retainedRelabelSource(source, '100', 'DS-HISTORY'), /native source report/) +}) + +test('both live entries use unified Auth and native helper without fixed physical measurements or an implicit historical delivery fallback', () => { + const mixed = readFileSync(new URL('./mixed-container-outbound-test-env.mjs', import.meta.url), 'utf8') + const relabel = readFileSync(new URL('./overseas-relabel-fulfillment-acceptance.mjs', import.meta.url), 'utf8') + for (const source of [mixed, relabel]) assert.doesNotMatch(source, /overseasAccessToken|ADMIN_OVERSEAS_GATEWAY|requestOverseas|READY_TO_DEPART|tripVersion|controlledPalletMeasurements/) + assert.match(mixed, /confirmFleetLoading\(context, MODULE, trip, pdaToken, result.loadingMeasurements \|\| native.loadingMeasurements\)/) + assert.match(mixed, /\/prints\/bol/) + assert.match(mixed, /OUTBOUND_PASSED_DELIVERY_NOT_SUPPORTED/) + assert.match(relabel, /runRetainedNativeDispatch/) + assert.doesNotMatch(relabel, /existingDeliveries|\/api\/merchant\/v1\/last-leg\/delivery-orders/) +}) diff --git a/scripts/overseas-relabel-fulfillment-acceptance.mjs b/scripts/overseas-relabel-fulfillment-acceptance.mjs index b23f8a9c1..e04da064b 100644 --- a/scripts/overseas-relabel-fulfillment-acceptance.mjs +++ b/scripts/overseas-relabel-fulfillment-acceptance.mjs @@ -1,177 +1,83 @@ -import { readFile, writeFile } from 'node:fs/promises' -import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { saveRetainedReport, validateRetainedScope, validateNativeRetainedDispatch, + installRetainedJournal, retainedApi, runRetainedNativeDispatch } from './mixed-container-outbound-test-env.mjs' -const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' -const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' -const sourceReport = process.env.OVERSEAS_ACCEPTANCE_SOURCE -const directDispatchNo = process.env.OVERSEAS_DISPATCH_NO -const directRelabelNo = process.env.OVERSEAS_RELABEL_NO -const output = process.env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json' -const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID -const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN -const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN -const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN -const marker = `FUL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` -if (!directPlatformToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing platform credentials or token') -if (!directMerchantToken && (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD)) throw new Error('Missing merchant token or platform credentials') -if (!directPdaToken && (!process.env.PDA_TEST_USERNAME || !process.env.PDA_TEST_PASSWORD)) throw new Error('Missing PDA credentials or token') -if ((!sourceReport && !directDispatchNo) || !customerId) throw new Error('Missing acceptance source/dispatch or customer id') +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Relabel ID lost precision') + return value == null ? '' : String(value) +} +const requireFact = (condition, message) => { if (!condition) throw Error(message) } -function id(value) { return value == null ? '' : String(value) } -function key(value) { return `${marker}-${value}`.slice(0, 200) } -function assert(value, message) { if (!value) throw new Error(message) } -async function api(base, token, method, path, body) { - const response = await fetch(`${base}${path}`, { method, - headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }) }, - body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body) }) - const text = await response.text(); let value - try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } - if (!response.ok || String(value?.code) !== '200') { - throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) - } - return value.data +/** A dispatch number or legacy pre-arrange report cannot establish current source ownership. */ +export function retainedRelabelSource(source, customerId, dispatchNo) { + const scenario = source?.scenarios?.find(value => value.name === 'SPLIT-TWO-TARGETS') + const native = scenario?.nativeDispatch + requireFact(source?.environment === 'TEST' && source.marker && native?.marker === source.marker + && scenario.relabelNo && native.customerId === id(customerId) + && (!dispatchNo || native.dispatchNo === dispatchNo), 'Relabel continuation requires this run native source report/customer/dispatch; old reports or a direct number cannot resume') + const inboundCodes = [...new Set((native.detail?.orderDetails || []).map(row => row.inboundCode).filter(Boolean))] + const expectedPackages = Number(native.detail?.counts?.packageCount) + requireFact(inboundCodes.length > 0 && Number.isSafeInteger(expectedPackages) && expectedPackages > 0 + && native.createBody?.cargo?.every(row => row.sourceType === 'RELABEL' && row.relabelNo === scenario.relabelNo), + 'Relabel native cargo does not belong to the original changed-carton sources') + const newMarks = scenario.relabelCartonMarks, oldMarks = scenario.sourceCartonMarks + requireFact(Array.isArray(newMarks) && newMarks.length === expectedPackages && newMarks.every(mark => typeof mark === 'string' && mark.trim()) + && new Set(newMarks).size === newMarks.length && Array.isArray(oldMarks) && oldMarks.length > 0 + && oldMarks.every(mark => typeof mark === 'string' && mark.trim()) && oldMarks.every(mark => !newMarks.includes(mark)), + 'Relabel source must retain original/new unique carton mark evidence; cannot infer marks from live final inventory') + const scope = { marker: native.marker, customerId: id(customerId), warehouseId: id(native.createBody.warehouseId), + inboundCodes, expectedPackages, expectedPalletItems: scenario.partialRelabel ? 1 : 2, relabelCartonMarks: newMarks, sourceCartonMarks: oldMarks } + validateNativeRetainedDispatch(native, scope) + return { scenario, native, scope } } -const source = sourceReport ? JSON.parse(await readFile(sourceReport, 'utf8')) : null -const scenario = source?.scenarios.find((value) => value.name === 'SPLIT-TWO-TARGETS') - || { dispatchNo: directDispatchNo, relabelNo: directRelabelNo || '' } -assert(scenario?.dispatchNo, 'Dispatch not found in source report') -const dispatchNo = scenario.dispatchNo -const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { - loginId: process.env.ADMIN_TEST_USERNAME, password: process.env.ADMIN_TEST_PASSWORD }) -const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { - loginId: process.env.PDA_TEST_USERNAME, password: process.env.PDA_TEST_PASSWORD }) -const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST', - `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程履约验收` }) -const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) -const platformToken = directPlatformToken || platformLogin.overseasAccessToken -const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken -const pdaToken = directPdaToken || pdaLogin.overseasAccessToken - -let task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) -let alreadyDeparted = task.status === 'DEPARTED' -assert(alreadyDeparted || task.tripNo, 'Prepare and bind a qualified fleet trip before running retained dispatch fulfillment') -const existingPage = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders?customerId=${customerId}&page=1&size=20&dispatchNo=${encodeURIComponent(dispatchNo)}`) -const existingDeliveries = (existingPage.records || []).filter((value) => value.dispatchNo === dispatchNo) -const existingOutboundNos = new Set(existingDeliveries.map((value) => value.outboundNo).filter(Boolean)) -assert(existingOutboundNos.size <= 1, 'Dispatch is linked to multiple outbound orders') -const transfer = existingDeliveries.length > 0 ? { - outboundNo: [...existingOutboundNos][0], - deliveries: existingDeliveries.map((value) => ({ - deliveryNo: value.deliveryNo, deliveryStatus: value.status, - destinationWarehouseId: value.destinationWarehouseId, - destinationWarehouseCode: value.destinationWarehouseCode, - destinationWarehouseName: value.destinationWarehouseName, - palletCount: value.palletCount, packageCount: value.packageCount, - })), - replayed: true, -} : await api(overseas, merchantToken, 'POST', - `/api/merchant/v1/last-leg/delivery-orders?dispatchNo=${encodeURIComponent(dispatchNo)}`, { - idempotencyKey: key('TRANSFER'), plannedDeliveryAt: new Date(Date.now() + 172800000).toISOString(), - deliveryMethod: 'TRUCK_PALLET', waybillNo: `${marker}-WAYBILL`, carrierName: '尾程验收车队', - pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '15托', - appointmentAccount: 'SELF', remark: marker, fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', - direction: 'RECEIVABLE', currency: 'CNY', amount: 10, remark: marker }], - }) -const transferDeliveries = Array.isArray(transfer.deliveries) ? transfer.deliveries : [] -assert(transfer.outboundNo && transferDeliveries.length > 0, - 'Transfer did not create outbound and delivery') -assert(transferDeliveries.length === 1, - `Expected one destination delivery, got ${transferDeliveries.length}`) -const deliveryNo = transferDeliveries[0].deliveryNo - -const expectedPdaItems = scenario.partialRelabel ? 1 : 2 -assert(Array.isArray(task.items) && task.items.length === expectedPdaItems, - `Expected ${expectedPdaItems} PDA pallet items`) -if (!alreadyDeparted) { - for (const [itemIndex, item] of task.items.entries()) { - let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) - for (const [positionIndex, position] of item.positions.entries()) { - if (remaining <= 0) break - const packages = Math.min(remaining, Number(position.availablePackages)) - if (packages <= 0) continue - await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/items/${id(item.dispatchItemId)}/pick`, { - positionId: id(position.id), packages, productQuantity: packages, - idempotencyKey: key(`PICK-${itemIndex}-${positionIndex}`), remark: marker, - }) - remaining -= packages +export async function mainRelabel(env = process.env) { + const sourcePath = env.OVERSEAS_ACCEPTANCE_SOURCE + requireFact(sourcePath && env.OVERSEAS_TEST_CUSTOMER_ID, 'Explicit native source report and customer are required; dispatch number alone is insufficient') + const sourceBytes = readFileSync(resolve(sourcePath)), source = JSON.parse(sourceBytes) + const { scenario, native, scope } = retainedRelabelSource(source, env.OVERSEAS_TEST_CUSTOMER_ID, env.OVERSEAS_DISPATCH_NO) + if (env.OVERSEAS_RELABEL_NO) requireFact(env.OVERSEAS_RELABEL_NO === scenario.relabelNo, 'Explicit relabel number differs from original source') + scope.sourceSha256 = createHash('sha256').update(sourceBytes).digest('hex') + const output = resolve(env.OVERSEAS_FULFILLMENT_OUTPUT || 'reports/overseas-relabel-fulfillment-acceptance.json') + const result = existsSync(output) ? JSON.parse(readFileSync(output, 'utf8')) : { schema: 'NATIVE_RETAINED_OUTBOUND_V1', environment: 'TEST', + ...scope, sourceReport: resolve(sourcePath), relabelNo: scenario.relabelNo, nativeDispatch: native, requests: [] } + validateRetainedScope(result, scope) + const options = parseArguments([]); options.modules = []; options.output = output; validateExecutionOptions(options) + requireFact(options.environment === 'test', 'Relabel native continuation only supports explicit TEST') + const context = installRetainedJournal(new AcceptanceRun(options), result, () => saveRetainedReport(output, result)) + context.marker = scope.marker + const api = (method, path, body, token) => retainedApi(context, method, path, body, token) + try { + const platform = await api('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + requireFact(platform.accessToken, 'Unified platform accessToken is missing') + context.token = platform.accessToken + requireFact(env.PDA_TEST_ACCESS_TOKEN || options.pdaUsername && options.pdaPassword, 'Explicit current PDA token or complete credentials are required') + const pdaToken = env.PDA_TEST_ACCESS_TOKEN || (await api('POST', '/api/auth/login', { + loginId: options.pdaUsername, password: options.pdaPassword }, '')).accessToken + requireFact(pdaToken, 'Unified PDA credentials or explicit current token are required') + await runRetainedNativeDispatch(context, result, scope, pdaToken) + result.visits = [] + for (const deliveryNo of result.deliveryNos) { + const query = new URLSearchParams({ page: '1', size: '200', customerId: scope.customerId, keyword: deliveryNo }) + const visits = await api('GET', `/api/platform/v1/last-leg/customer-visits?${query}`) + const visit = visits.records?.find(row => row.deliveryNo === deliveryNo) + requireFact(visit?.status === 'PENDING' && id(visit.customerId) === scope.customerId, 'Real signature did not create the original customer visit') + result.visits.push({ id: id(visit.id), deliveryNo, status: visit.status }) } - assert(remaining === 0, `PDA item ${item.dispatchItemId} has insufficient inventory`) + result.passed = true; result.scope = 'OVERSEAS_LASTLEG_ONLY' + } catch (error) { + result.passed = false; result.finalStatus = 'BLOCKED'; result.failedStage = 'NATIVE_RELABEL_FULFILLMENT'; process.exitCode = 1 + } finally { + result.checks = context.steps + if (context.steps.some(step => step.passed === false)) { result.passed = false; result.finalStatus = 'CHECK_FAILED'; process.exitCode = 1 } + await context.checkpoint() + console.log(JSON.stringify({ marker: result.marker, dispatchNo: result.dispatchNo, outboundNo: result.outboundNo, + deliveryNos: result.deliveryNos, passed: result.passed === true, finalStatus: result.finalStatus, report: output })) } - task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) - alreadyDeparted = task.status === 'DEPARTED' + return result } -if (!alreadyDeparted && task.status !== 'COMPLETED') { - const pickingCompleted = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/dispatches/${dispatchNo}/complete`, { idempotencyKey: key('PICK-COMPLETE') }) - assert(pickingCompleted.status === 'READY_TO_DEPART', 'Dispatch did not reach READY_TO_DEPART') - task = await api(overseas, pdaToken, 'GET', `/api/pda/v1/last-leg/dispatches/${dispatchNo}`) -} -assert(alreadyDeparted || task.status === 'COMPLETED', 'Dispatch did not finish picking') -if (!alreadyDeparted) { - const tripPath = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(task.tripNo)}` - const trip = await api(overseas, platformToken, 'GET', tripPath) - const loading = await api(overseas, platformToken, 'GET', `${tripPath}/loading`) - assert(!loading.blockReason, `Trip not ready: ${loading.blockReason}`) - const confirmed = await api(overseas, platformToken, 'POST', `${tripPath}/loading`, { - recordVersion: loading.recordVersion, manifestHash: loading.manifestHash, - pallets: controlledPalletMeasurements(loading.cargo, trip.trip.stops), reason: '受控测试测量,每实托100kg/1m³/1托位', - }) - const departed = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/dispatches/${dispatchNo}/depart`, - { recordVersion: confirmed.recordVersion, idempotencyKey: key('DEPART').slice(0,128), remark: marker }) - assert(departed.status === 'IN_TRANSIT' && departed.dispatches?.some(item => item.dispatchNo === dispatchNo), 'Whole-trip departure missing dispatch') -} -const departedDispatch = await api(overseas, platformToken, 'GET', `/api/platform/v1/last-leg/dispatches/${dispatchNo}`) -assert(departedDispatch.status === 'DEPARTED', 'Dispatch did not reach DEPARTED') - -const outbound = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/outbound-orders/${transfer.outboundNo}?page=1&size=20`) -assert(outbound.outbound.status === 'OUTBOUNDED', 'Outbound did not reach OUTBOUNDED') -let delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) -if (delivery.order.status === 'WAIT_DELIVERY') { - const delivering = await api(overseas, platformToken, 'PUT', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/status?customerId=${customerId}`, { - status: 'DELIVERING', expectedVersion: Number(delivery.order.version), remark: marker, - }) - assert(delivering.deliveryStatus === 'DELIVERING', 'Delivery did not reach DELIVERING') - delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) -} -if (delivery.order.status !== 'DELIVERED') { - assert(delivery.order.status === 'DELIVERING', `Unexpected delivery status ${delivery.order.status}`) - const documentsPath = `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents?customerId=${customerId}` - const documents = await api(overseas, platformToken, 'GET', documentsPath) - const form = new FormData() - form.append('file', new Blob([`${marker} receipt acceptance for ${deliveryNo}`], { type: 'text/plain' }), - `${marker}-${deliveryNo}-receipt.txt`) - const receipt = await api(overseas, platformToken, 'POST', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}/documents/RECEIPT/files?customerId=${customerId}`, form) - const existingBindings = (documents.categories || []).map((category) => ({ - category: category.category, - fileIds: (category.files || []).map((file) => id(file.fileId)), - })).filter((binding) => binding.category !== 'RECEIPT' && binding.fileIds.length > 0) - await api(overseas, platformToken, 'PUT', documentsPath, { - expectedVersion: Number(documents.version), idempotencyKey: key('SAVE-RECEIPT'), - bolNo: documents.bolNo || null, - documents: [...existingBindings, { category: 'RECEIPT', fileIds: [id(receipt.fileId)] }], - }) - delivery = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/delivery-orders/${deliveryNo}?customerId=${customerId}`) -} -assert(delivery.order.status === 'DELIVERED', 'Receipt did not automatically complete delivery') -const visits = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/customer-visits?page=1&size=200&customerId=${customerId}&keyword=${encodeURIComponent(deliveryNo)}`) -const visit = (visits.records || []).find((value) => value.deliveryNo === deliveryNo) -assert(visit?.status === 'PENDING', 'Delivery completion did not create a pending customer visit') - -const report = { marker, scope: 'OVERSEAS_LASTLEG_ONLY', sourceReport, relabelNo: scenario.relabelNo, - dispatchNo, dispatchStatus: departedDispatch.status, outboundNo: transfer.outboundNo, - outboundStatus: outbound.outbound.status, deliveryNo, - deliveryStatus: delivery.order.status, visitId: id(visit.id), shippedPackages: departedDispatch.shippedPackageCount, - passed: true, finishedAt: new Date().toISOString() } -await writeFile(output, JSON.stringify(report, null, 2), 'utf8') -console.log(JSON.stringify(report, null, 2)) +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainRelabel() diff --git a/scripts/overseas-relabel-real-acceptance.mjs b/scripts/overseas-relabel-real-acceptance.mjs index 329754dc4..6ff22c002 100644 --- a/scripts/overseas-relabel-real-acceptance.mjs +++ b/scripts/overseas-relabel-real-acceptance.mjs @@ -1,44 +1,275 @@ -import { writeFile } from 'node:fs/promises' +import { readFileSync, existsSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createHash } from 'node:crypto' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { installRetainedJournal, retainedApi, saveRetainedReport } from './mixed-container-outbound-test-env.mjs' -const domestic = process.env.ADMIN_TEST_GATEWAY || 'http://127.0.0.1:9000' -const overseas = process.env.ADMIN_OVERSEAS_GATEWAY || 'http://127.0.0.1:9300' -const platformUser = process.env.ADMIN_TEST_USERNAME -const platformPassword = process.env.ADMIN_TEST_PASSWORD -const pdaUser = process.env.PDA_TEST_USERNAME -const pdaPassword = process.env.PDA_TEST_PASSWORD -const customerId = process.env.OVERSEAS_TEST_CUSTOMER_ID -const warehouseId = process.env.OVERSEAS_TEST_WAREHOUSE_ID -const directPlatformToken = process.env.ADMIN_OVERSEAS_ACCESS_TOKEN -const directMerchantToken = process.env.MERCHANT_OVERSEAS_ACCESS_TOKEN -const directPdaToken = process.env.PDA_OVERSEAS_ACCESS_TOKEN -const output = process.env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json' -const marker = `TAIL-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}-${process.pid}` -const chargeAmount = Number(process.env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01') -if (!Number.isFinite(chargeAmount) || chargeAmount < 0) throw new Error('Invalid charge amount') - -for (const [name, value] of Object.entries({ customerId, warehouseId })) { - if (!value) throw new Error(`Missing environment variable: ${name}`) +const MODULE = 'overseas-relabel-producer' +const DISPATCH = '/api/platform/v1/last-leg/dispatches' +const id = value => { + if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('Relabel ID lost precision') + return value == null ? '' : String(value) } -if (!directPlatformToken && (!platformUser || !platformPassword)) throw new Error('Missing platform credentials or token') -if (!directMerchantToken && (!platformUser || !platformPassword)) throw new Error('Missing merchant token or platform credentials') -if (!directPdaToken && (!pdaUser || !pdaPassword)) throw new Error('Missing PDA credentials or token') - -const report = { marker, startedAt: new Date().toISOString(), scope: 'OVERSEAS_LASTLEG_ONLY', chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], passed: false } -function key(value) { return `${marker}-${value}`.slice(0, 200) } -function id(value) { return value == null ? '' : String(value) } -async function api(base, token, method, path, body, headers = {}) { - const response = await fetch(`${base}${path}`, { - method, headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), - ...(body === undefined || body instanceof FormData ? {} : { 'Content-Type': 'application/json' }), ...headers }, - body: body === undefined ? undefined : body instanceof FormData ? body : JSON.stringify(body), - }) - const text = await response.text() - let value - try { value = text ? JSON.parse(text) : null } catch { value = { raw: text } } - if (!response.ok || String(value?.code) !== '200') { - throw new Error(`${method} ${path}: HTTP=${response.status}, code=${value?.code}, message=${value?.message}`) +const exactVersion = value => { + if ((typeof value === 'number' && !Number.isSafeInteger(value)) || !/^(0|[1-9]\d*)$/.test(String(value))) throw Error('Authoritative relabel version is missing or imprecise') + return value +} +const quantity = value => /^(0|[1-9]\d*)$/.test(String(value)) && Number.isSafeInteger(Number(value)) ? Number(value) : null +const sorted = values => [...new Set(values)].sort() +const same = (a, b) => JSON.stringify(a) === JSON.stringify(b) +const canonical = value => JSON.stringify(normalize(value)) +function normalize(value) { + if (Array.isArray(value)) return value.map(normalize) + return value && typeof value === 'object' ? Object.fromEntries(Object.keys(value).sort().map(key => [key, normalize(value[key])])) : value +} +function requireFact(condition, message) { if (!condition) throw Error(message) } +function producerResponsePresent(path, data) { + if (data == null || typeof data !== 'object') return false + if (path === DISPATCH) return data.dispatchNo && data.action === 'CREATE' && data.recordVersion != null && data.result?.dispatchNo === data.dispatchNo + if (path.endsWith('/photos/files')) return /^[1-9]\d*$/.test(id(data.fileId)) + if (path.endsWith('/pallets/resolve')) return data.palletCode && /^[1-9]\d*$/.test(id(data.palletId)) && data.totalPackages != null + if (path.endsWith('/relabel-pallet-label')) return data.palletCode && /^[1-9]\d*$/.test(id(data.palletId)) + if (path.includes('/relabel-tasks/')) return data.relabelNo && data.version != null && Array.isArray(data.items) + if (path.includes('/relabel-applications')) return data.application?.relabelNo && Array.isArray(data.items) + return true +} +export function installRelabelJournal(context, report, save) { + installRetainedJournal(context, report, save) + const request = context.request.bind(context), upload = context.requestMultipart.bind(context) + const verify = async (method, path, send) => { + if (method !== 'GET' && report.requests.some(row => ['SENDING', 'UNKNOWN'].includes(row.status))) throw Error('Unknown producer write outcome: stop with original commands') + const response = await send() + if (method !== 'GET' && response.http === 200 && String(response.body?.code) === '200' + && !producerResponsePresent(path, response.body?.data)) { + const entry = report.requests.at(-1) + if (entry) entry.status = 'UNKNOWN' + await context.checkpoint(); throw Error('Unknown producer write result: native business response missing') + } + return response } - return value.data + context.request = (method, path, body, token, headers) => verify(method, path, () => request(method, path, body, token, headers)) + context.requestMultipart = (method, path, file, token, headers) => verify(method, path, () => upload(method, path, file, token, headers)) + return context +} +function validateLoadingEvidence(value, marker, dispatchNo, targets) { + const physicalIds = sorted(targets.map(row => id(row.palletId))) + requireFact(value?.environment === 'TEST' && value.marker === marker && typeof value.dispatchNo === 'string' && value.dispatchNo.trim() && value.dispatchNo === dispatchNo && Array.isArray(value.pallets) + && same(sorted(value.pallets.map(row => id(row.warehousePalletId))), physicalIds) + && value.pallets.length === physicalIds.length, 'Loading evidence belongs to another marker/dispatch/physical pallet') + for (const row of value.pallets) { + requireFact(Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0 && Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0, + 'Actual pallet spaces and physical stop index are required') + for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) { + requireFact(/^(0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number(row[field]) > 0 + && row.evidence?.some(e => e.actual === true && e.field === field && e.sourceType && e.sourceBusinessNo + && Number.isFinite(Date.parse(e.observedAt)) && Number(e.value) === Number(row[field])), 'Actual physical loading field evidence is missing') + } + } + return value +} +export function selectExplicitRelabelSource(candidates, config) { + requireFact(config.sourceBalanceId && config.sourcePositionId, 'Explicit source balance and position are required; no historical inventory fallback') + const matches = candidates.filter(row => id(row.inventoryBalanceId) === id(config.sourceBalanceId) + && id(row.overseasWarehouseId) === id(config.warehouseId) + && (!config.inboundCode || row.inboundCode === config.inboundCode) + && row.positions?.some(position => id(position.positionId) === id(config.sourcePositionId))) + requireFact(matches.length === 1, 'Explicit relabel source is absent or ambiguous in this customer warehouse') + const source = matches[0], positions = source.positions.filter(row => id(row.positionId) === id(config.sourcePositionId)) + requireFact(positions.length === 1 && String(source.inboundOrderNo || '').startsWith('DS-') && source.destinationFbaWarehouseCode + && quantity(source.availablePackages) >= 5 && quantity(positions[0].availablePackages) >= 5, 'Explicit source requires original DS inventory and five real available cartons') + return { source, sourcePosition: positions[0] } +} +function scenarioCargo(scenario, rows, customerId, warehouseId) { + const targets = sorted(scenario.targets.map(target => id(target.palletId))) + const expected = scenario.items.reduce((sum, item) => sum + Number(item.notifiedPackageCount), 0) + requireFact(rows.length === targets.length && same(sorted(rows.map(row => id(row.selection?.warehousePalletId))), targets) + && rows.reduce((sum, row) => sum + Number(row.availablePackages), 0) === expected, 'Native relabel candidates do not exactly cover original targets and carton quantity') + const cargo = rows.map(row => { + const selection = row.selection + requireFact(selection?.sourceType === 'RELABEL' && selection.relabelNo === scenario.relabelNo + && selection.planPalletId == null && selection.returnReceiptId == null + && /^[1-9]\d*$/.test(id(selection.warehousePalletId)) + && row.sourceKey === `RELABEL:${scenario.relabelNo}:${id(selection.warehousePalletId)}` + && /^[a-f\d]{64}$/i.test(row.sourceVersion || '') && row.sourceVersion === selection.sourceVersion + && id(row.customerId) === id(customerId) && id(row.warehouseId) === id(warehouseId) + && quantity(row.availablePackages) > 0 && row.destination?.type === 'FBA' + && row.destination.warehouseCode === scenario.destinationFbaWarehouseCode + && Array.isArray(row.blockReasons) && !row.blockReasons.length && Array.isArray(row.appointmentBatches) && !row.appointmentBatches.length, + 'Incomplete native RELABEL selector, warehouse/customer/destination or source version') + requireFact(!Object.hasOwn(row, 'cartonMarks') && !Object.hasOwn(row, 'relabelCartonMarks'), 'Native candidates must not expose legacy carton mark projections') + return { sourceType: 'RELABEL', planPalletId: null, returnReceiptId: null, relabelNo: scenario.relabelNo, + warehousePalletId: id(selection.warehousePalletId), sourceVersion: selection.sourceVersion } + }) + return cargo +} + +/** The complete server group is the selection unit, including the whole application closure. */ +export function nativeRelabelCreateBody(marker, scenarios, rows, customerId, warehouseId) { + requireFact(Array.isArray(scenarios) && scenarios.length > 0 && rows.length > 0 + && new Set(scenarios.map(s => s.relabelNo)).size === scenarios.length, 'Owned relabel scenarios are missing or duplicate') + const group = rows[0].selectionGroup + requireFact(typeof group?.groupKey === 'string' && group.groupKey.trim() && typeof group.groupVersion === 'string' && group.groupVersion.trim() + && group.complete === true && Array.isArray(group.blockReasons) && !group.blockReasons.length + && Array.isArray(group.sources) && group.sources.length && Array.isArray(group.pallets) && group.pallets.length, + 'Authoritative complete selectionGroup and groupVersion are required') + requireFact(rows.every(row => canonical(row.selectionGroup) === canonical(group)), 'Selection group version or membership drift') + requireFact(new Set(rows.map(row => row.sourceKey)).size === rows.length && group.sources.length === rows.length + && new Set(group.sources.map(source => source.sourceKey)).size === group.sources.length + && same(sorted(group.sources.map(source => source.sourceKey)), sorted(rows.map(row => row.sourceKey))), 'Selection group logical sources are incomplete or duplicate') + const cargo = scenarios.flatMap(scenario => scenarioCargo(scenario, rows.filter(row => row.selection?.relabelNo === scenario.relabelNo), customerId, warehouseId)) + requireFact(cargo.length === rows.length, 'Selection group contains an unknown relabel business member') + for (const source of group.sources) { + const row = rows.find(value => value.sourceKey === source.sourceKey), scenario = scenarios.find(value => value.relabelNo === source.sourceBusinessNo) + requireFact(scenario?.relabelVerified === true && row && source.sourceBusinessType && id(source.customerId) === id(customerId) + && source.sourceBusinessNo === row.selection.relabelNo && canonical(source.selection) === canonical(row.selection) + && quantity(source.packages) > 0 && quantity(source.packages) === quantity(row.availablePackages), 'Selection group business, selector version or source quantity drift') + } + requireFact(new Set(group.pallets.map(p => id(p.warehousePalletId))).size === group.pallets.length + && same(sorted(group.pallets.map(p => id(p.warehousePalletId))), sorted(cargo.map(c => id(c.warehousePalletId)))), 'Selection group physical pallet closure is incomplete or duplicate') + for (const pallet of group.pallets) { + const sources = group.sources.filter(source => id(source.selection.warehousePalletId) === id(pallet.warehousePalletId)) + requireFact(/^[1-9]\d*$/.test(id(pallet.warehousePalletId)) && pallet.palletNo && sources.length + && Array.isArray(pallet.sourceKeys) && pallet.sourceKeys.length === sources.length + && same(sorted(pallet.sourceKeys), sorted(sources.map(source => source.sourceKey))) + && quantity(pallet.packageCount) === sources.reduce((sum, source) => sum + quantity(source.packages), 0) + && rows.filter(row => id(row.selection.warehousePalletId) === id(pallet.warehousePalletId)).every(row => row.palletNo === pallet.palletNo), + 'Selection group physical source contributions or carton quantity drift') + } + return { idempotencyKey: createHash('sha256').update(`${marker}\n${group.groupKey}\n${group.groupVersion}\nCREATE`).digest('hex'), warehouseId: id(warehouseId), + vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${marker}-${scenarios.map(s => s.name).join('+')}`, cargo, batchAllocations: [] } +} + +export function nativeRelabelGroups(scenarios, rows, customerId, warehouseId) { + const groups = new Map() + for (const scenario of scenarios) { + requireFact(scenario.relabelVerified === true && scenario.relabelCartonMarks?.length === scenario.expectedCartons + && new Set(scenario.relabelCartonMarks).size === scenario.relabelCartonMarks.length + && scenario.relabelCartonMarks.every(mark => typeof mark === 'string' && mark.trim() && !scenario.sourceCartonMarks.includes(mark)), 'Original/new relabel carton mark evidence is incomplete or overlaps') + const candidates = rows.filter(row => row.selection?.sourceType === 'RELABEL' && row.selection.relabelNo === scenario.relabelNo) + scenarioCargo(scenario, candidates, customerId, warehouseId) + requireFact(candidates.every(row => row.selectionGroup?.groupKey) && new Set(candidates.map(row => row.selectionGroup.groupKey)).size === 1, + 'Whole relabel application must belong to one authoritative complete selection group') + const key = candidates[0].selectionGroup.groupKey + if (!groups.has(key)) groups.set(key, { scenarios: [], selectionGroup: candidates[0].selectionGroup }) + groups.get(key).scenarios.push(scenario) + scenario.dispatchCandidates = candidates + } + const physicalOwners = new Map(), result = [] + for (const entry of groups.values()) { + const keys = entry.selectionGroup.sources?.map(source => source.sourceKey) || [] + entry.candidates = rows.filter(row => keys.includes(row.sourceKey)) + entry.createBody = nativeRelabelCreateBody('PREFLIGHT', entry.scenarios, entry.candidates, customerId, warehouseId) + for (const pallet of entry.selectionGroup.pallets) { + const key = id(pallet.warehousePalletId) + requireFact(!physicalOwners.has(key), 'One physical pallet cannot be split across selection groups') + physicalOwners.set(key, entry.selectionGroup.groupKey) + } + result.push(entry) + } + return result +} + +// Compare physical values once, retaining per-scenario raw receipts without adding whole-pallet weights. +export function relabelGroupMeasurements(scenarios, group) { + const values = new Map(), fields = ['palletCode', 'packages', 'stockVersion', 'lengthCm', 'widthCm', 'heightCm', 'grossWeightKg', 'volumeCbm'] + for (const scenario of scenarios) for (const measured of scenario.physicalMeasurements || []) { + const palletId = id(measured.palletId), pallet = group.pallets.find(p => id(p.warehousePalletId) === palletId) + if (!pallet) continue + requireFact(measured.palletCode === pallet.palletNo && quantity(measured.packages) === quantity(pallet.packageCount), 'Physical measurement does not cover the complete shared pallet') + const old = values.get(palletId) + if (old) requireFact(fields.every(field => canonical(old[field]) === canonical(measured[field])), 'Shared physical measurement versions or values conflict') + else values.set(palletId, structuredClone(measured)) + } + return [...values.values()] +} + +function groupLoadingEvidence(config, report, scenarios, group, dispatchNo) { + const targets = group.pallets.map(p => ({ palletId: id(p.warehousePalletId) })), inputs = scenarios.map(s => config.loadingEvidenceByScenario?.[s.name]).filter(Boolean) + if (!inputs.length) return undefined + const validated = inputs.map(value => validateLoadingEvidence(value, report.marker, dispatchNo || value.dispatchNo, targets)) + const physical = value => [...value.pallets].sort((a, b) => id(a.warehousePalletId).localeCompare(id(b.warehousePalletId))).map(p => ({ warehousePalletId: id(p.warehousePalletId), + palletSpaces: p.palletSpaces, weightKg: String(Number(p.weightKg)), volumeCbm: String(Number(p.volumeCbm)), deliveryIndex: p.deliveryIndex })) + requireFact(validated.every(value => value.dispatchNo === validated[0].dispatchNo && canonical(physical(value)) === canonical(physical(validated[0]))), 'Shared scenario loading receipts must name the same dispatch and identical physical measurements') + return structuredClone(validated[0]) +} + +/** Read the complete candidate universe and validate every owned group before the first CREATE. */ +export async function publishNativeRelabelDispatches(context, report, config) { + const scenarios = report.scenarios.filter(row => row.operationType === 'CARTON_AND_SKU') + report.blockers ||= [] + const block = (scenario, code, evidence) => { + scenario.dispatchStatus = 'BLOCKED'; scenario.nativeDispatchBlocker = { code, ...evidence } + report.blockers.push({ scenario: scenario.name, code, ...evidence }) + context.addCoverageGap?.(MODULE, scenario.name, 'Native relabel dispatch', code, 'physical-source-blocked', { deploymentBlocker: true }) + context.addStep?.(MODULE, `${scenario.name} native dispatch blocked`, null, false, code, { deploymentBlocker: true }) + } + const page = await retainedApi(context, 'GET', `${DISPATCH}/candidates?${new URLSearchParams({ warehouseId: id(config.warehouseId), sourceStage: 'RELABEL' })}`) + requireFact(page?.complete === true && Array.isArray(page.candidates), 'Native candidate list is incomplete') + for (const scenario of scenarios) { + scenario.sourceAfter = Number((await retainedApi(context, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: report.sourcePosition.palletNo }, config.pdaToken)).totalPackages) + scenario.targetPallets = scenario.targets.map(target => ({ palletId: id(target.palletId), palletCode: target.palletCode })) + scenario.physicalMeasurements = await retainedApi(context, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-measurements', { relabelNo: scenario.relabelNo }, config.pdaToken) + await context.checkpoint() + } + let groups + try { + groups = nativeRelabelGroups(scenarios, page.candidates, config.customerId, config.warehouseId) + for (const group of groups) { + group.physicalMeasurements = relabelGroupMeasurements(group.scenarios, group.selectionGroup) + groupLoadingEvidence(config, report, group.scenarios, group.selectionGroup) + } + } catch (error) { + report.nativeSelectionFailure = { message: error.message, candidates: page.candidates } + for (const scenario of scenarios) block(scenario, 'NATIVE_SELECTION_GROUP_NOT_VERIFIED', { reason: error.message }) + await context.checkpoint(); throw error + } + for (const group of groups) { + const members = group.scenarios + try { + const createBody = nativeRelabelCreateBody(report.marker, members, group.candidates, config.customerId, config.warehouseId) + const native = { marker: report.marker, customerId: id(config.customerId), warehouseId: id(config.warehouseId), createBody, physicalMeasurements: group.physicalMeasurements } + for (const scenario of members) { + scenario.nativeDispatch = native; scenario.nativeCreateBody = createBody; scenario.selectionGroup = group.selectionGroup + scenario.sourceContributions = group.selectionGroup.sources.filter(source => source.sourceBusinessNo === scenario.relabelNo) + } + await context.checkpoint() + const createResult = await retainedApi(context, 'POST', DISPATCH, createBody) + Object.assign(native, { createResult, dispatchNo: createResult?.dispatchNo }); await context.checkpoint() + requireFact(createResult?.action === 'CREATE' && createResult.dispatchNo && createResult.status === 'ACTIVE' + && createResult.stage === 'BEFORE_LOADING', 'Native CREATE did not return original active vehicle identity') + exactVersion(createResult.recordVersion) + const replay = await retainedApi(context, 'POST', DISPATCH, createBody) + native.createReplay = replay; await context.checkpoint() + requireFact(replay.replayed === true && replay.dispatchNo === createResult.dispatchNo + && String(replay.recordVersion) === String(createResult.recordVersion) && same(replay.result, createResult.result), 'Native CREATE replay changed original result') + const detail = await retainedApi(context, 'GET', `${DISPATCH}/${encodeURIComponent(createResult.dispatchNo)}`) + const sourceKeys = group.candidates.map(row => row.sourceKey) + requireFact(detail?.dispatchNo === createResult.dispatchNo && detail.status === 'ACTIVE' && detail.stage === 'BEFORE_LOADING' + && quantity(detail.counts?.packageCount) === group.selectionGroup.sources.reduce((sum, source) => sum + quantity(source.packages), 0) + && quantity(detail.counts?.palletCount) === group.selectionGroup.pallets.length + && id(exactVersion(detail.recordVersion)) === id(createResult.recordVersion) + && detail.orderDetails?.length >= sourceKeys.length + && same(sorted(detail.orderDetails?.map(row => row.sourceKey) || []), sorted(sourceKeys)) + && detail.orderDetails.every(row => group.selectionGroup.sources.some(source => source.sourceKey === row.sourceKey && id(source.customerId) === id(row.customerId) + && source.sourceBusinessNo === row.sourceBusinessNo && source.sourceBusinessType === row.sourceBusinessType)), 'Native Detail source/business/customer/carton identity drift') + native.detail = detail + const measurements = groupLoadingEvidence(config, report, members, group.selectionGroup, createResult.dispatchNo) + if (measurements) native.loadingMeasurements = measurements + for (const scenario of members) { + scenario.dispatchNo = createResult.dispatchNo; scenario.dispatchStatus = detail.status + if (!measurements) block(scenario, 'ACTUAL_LOADING_MEASUREMENT_REQUIRED', { fields: ['palletSpaces', 'weightKg', 'volumeCbm', 'deliveryIndex'], + dispatchNo: createResult.dispatchNo, evidenceKind: 'PHYSICAL_MEASUREMENTS_NOT_FABRICATED' }) + } + await context.checkpoint() + } catch (error) { + if (report.requests?.some(row => ['SENDING', 'UNKNOWN'].includes(row.status))) throw error + for (const scenario of members) block(scenario, 'NATIVE_DISPATCH_NOT_VERIFIED', { candidates: scenario.dispatchCandidates, sourceBeforePackages: scenario.sourceBefore, + sourceAfterPackages: scenario.sourceAfter, reason: error.message, evidenceKind: 'RETAINED_ORIGINAL_REQUEST_AND_RESPONSE' }) + await context.checkpoint() + } + } + return scenarios } async function poll(action, predicate, attempts = 40) { for (let index = 0; index < attempts; index += 1) { @@ -50,183 +281,226 @@ async function poll(action, predicate, attempts = 40) { } function assert(condition, message) { if (!condition) throw new Error(message) } -const platformLogin = directPlatformToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: platformUser, password: platformPassword }) -const pdaLogin = directPdaToken ? null : await api(domestic, '', 'POST', '/api/auth/login', { loginId: pdaUser, password: pdaPassword }) -const ticket = directMerchantToken ? null : await api(domestic, platformLogin.accessToken, 'POST', - `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} 海外尾程真实验收` }) -const merchantLogin = directMerchantToken ? null : await api(domestic, '', 'POST', '/api/auth/impersonations/exchange', { code: ticket.code }) -const platformToken = directPlatformToken || platformLogin.overseasAccessToken -const merchantToken = directMerchantToken || merchantLogin.overseasAccessToken -const pdaToken = directPdaToken || pdaLogin.overseasAccessToken -assert(platformToken && merchantToken && pdaToken, 'Missing overseas token') -const candidates = await api(overseas, merchantToken, 'GET', - `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`) -const source = candidates.find((item) => String(item.inboundOrderNo || '').startsWith('DS-') - && Number(item.availablePackages) >= 5 - && item.destinationFbaWarehouseCode && item.positions?.some((position) => Number(position.availablePackages) >= 5)) -assert(source, 'No overseas inventory source with at least five available cartons') -const sourcePosition = source.positions.find((position) => Number(position.availablePackages) >= 5) +export async function runRelabelProducer(context, config, report) { + const { customerId, warehouseId, merchantToken, pdaToken, chargeAmount } = config + const platformToken = context.token, marker = report.marker + const key = value => `${marker}-${value}`.slice(0, 200) + const api = (token, method, path, body) => retainedApi(context, method, path, body, token) + const candidates = await api(merchantToken, 'GET', `/api/merchant/v1/last-leg/relabel-applications/inventory-candidates?operationWarehouseId=${warehouseId}`) + const { source, sourcePosition } = selectExplicitRelabelSource(candidates, config) + report.source = source; report.sourcePosition = sourcePosition + await context.checkpoint() + async function resolvePallet(palletCode) { + return api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) + } -async function resolvePallet(palletCode) { - return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/pallets/resolve', { code: palletCode }) -} - -async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) { - const suffix = `${name}-${Date.now()}` - const items = Array.from({ length: cartonCount }, (_, index) => ({ - sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), - sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode, - productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, - shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), - labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, - fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null, - destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, - operationType, skuItems: [{ skuCode: source.skuCode, - newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], - })) - const created = await api(overseas, platformToken, 'POST', - `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { - customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, - labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), - contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}), - remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), - deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK', - printShippingLabel: false, items, - }) - const relabelNo = created.application.relabelNo - if (chargeAmount > 0) await poll(() => api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), - (detail) => detail.application.walletStatus === 'FROZEN') - let detail = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) - for (const [index, item] of detail.items.entries()) { - if (operationType === 'CARTON_AND_SKU') { - detail = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, { - cartonMark: item.cartonMark, expectedVersion: Number(detail.version), - idempotencyKey: key(`${name}-CARTON-${index}`), - }) - const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId)) - assert(Number(stageItem?.cartonProcessedPackages) === 1 - && Number(stageItem?.skuProcessedPackages) === 0, - `${relabelNo} carton stage incorrectly locked or completed SKU stage`) + async function createAndConfirm(name, cartonCount, operationType = 'CARTON_AND_SKU', prepare = null) { + const suffix = `${name}-${marker}` + const items = Array.from({ length: cartonCount }, (_, index) => ({ + sourceInventoryBalanceId: id(source.inventoryBalanceId), sourceInventoryPositionId: id(sourcePosition.positionId), + sourceInboundOrderId: id(source.inboundOrderId), skuCode: source.skuCode, + productNameZh: source.productNameZh || source.skuCode, totalPackageCount: 1, notifiedPackageCount: 1, + shipmentNo: `SHIP-${suffix}-${index + 1}`, newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), + labelRequirement: marker, cartonMark: `CM-${suffix}-${index + 1}`, + fbaCartonMark: operationType === 'CARTON_AND_SKU' ? `FBA-${suffix}-${index + 1}` : null, + destinationFbaWarehouseCode: source.destinationFbaWarehouseCode, + operationType, skuItems: [{ skuCode: source.skuCode, + newSkuCode: `${source.skuCode}-NEW-${suffix}-${index + 1}`.slice(0, 128), quantity: 1, remark: marker }], + })) + const scenario = { name, operationType, expectedCartons: cartonCount, items: [], targets: [], requestItems: items, + sourceCartonMarks: items.map(item => item.cartonMark), relabelVerified: false } + report.scenarios.push(scenario) + const created = await api(platformToken, 'POST', + `/api/platform/v1/last-leg/relabel-applications?customerId=${customerId}`, { + customerOrderNo: `TAIL-${suffix}`.slice(0, 128), operationWarehouseId: warehouseId, + labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), + contactName: '尾程验收', contactPhone: '13800000006', ...(chargeAmount > 0 ? { chargeAmount } : {}), + remark: `${marker}-${name}`, idempotencyKey: key(`${name}-CREATE`), + deliveryMethod: operationType === 'SKU_LABEL' ? null : 'TRUCK', + printShippingLabel: false, items, + }) + const relabelNo = created.application.relabelNo + scenario.relabelNo = relabelNo; scenario.createResult = created + assert(created.application.overseasWarehouseId === warehouseId, 'Created relabel belongs to a different warehouse') + await context.checkpoint() + if (chargeAmount > 0) await poll(() => api(platformToken, 'GET', + `/api/platform/v1/last-leg/relabel-applications/${relabelNo}`), + (detail) => detail.application.walletStatus === 'FROZEN') + let detail = await api(pdaToken, 'POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/claim`, { idempotencyKey: key(`${name}-CLAIM`) }) + for (const [index, item] of detail.items.entries()) { + if (operationType === 'CARTON_AND_SKU') { + const cartonBody = { cartonMark: item.cartonMark, expectedVersion: exactVersion(detail.version), idempotencyKey: key(`${name}-CARTON-${index}`) } + detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, cartonBody) + const cartonReplay = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/cartons/scan`, cartonBody) + assert(String(cartonReplay.version) === String(detail.version) && same(cartonReplay.items, detail.items), 'Original carton scan replay changed quantities/version') + const stageItem = detail.items.find((row) => id(row.itemId) === id(item.itemId)) + assert(Number(stageItem?.cartonProcessedPackages) === 1 + && Number(stageItem?.skuProcessedPackages) === 0, + `${relabelNo} carton stage incorrectly locked or completed SKU stage`) + } + const scanBody = { + itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null, + skuVerifications: (item.skuItems || []).map((sku) => ({ + skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), + })), + expectedVersion: exactVersion(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), + } + detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, scanBody) + const scanReplay = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, scanBody) + assert(String(scanReplay.version) === String(detail.version) && same(scanReplay.items, detail.items), 'Original SKU verification replay changed quantities/version') } - detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/units`, { - itemId: id(item.itemId), oldLabel: item.cartonMark, newLabel: null, - skuVerifications: (item.skuItems || []).map((sku) => ({ - skuCode: sku.skuCode, newSkuCode: sku.newSkuCode, quantity: Number(sku.quantity), - })), - expectedVersion: Number(detail.version), idempotencyKey: key(`${name}-SCAN-${index}`), + const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + Object.assign(scenario, { items: detail.items, sourceBefore, sourceCartonMarks: items.map(item => item.cartonMark), requestItems: items }) + if (prepare) await prepare(scenario) + // Valid synthetic PNG for API validation; no physical warehouse photograph is claimed. + const photoResponse = await context.requestMultipart('POST', + `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, { + name: `${marker}-${name}.png`, type: 'image/png', + content: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64'), + }, pdaToken) + const photo = context.apiSuccess('overseas-relabel-producer', 'Upload synthetic relabel PNG', photoResponse) + assert(photoResponse.http === 200 && String(photoResponse.body?.code) === '200' && photo?.fileId, 'Synthetic photo upload failed') + scenario.photo = { fileId: id(photo.fileId), evidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK' } + detail = await api(pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { + photoFileIds: [id(photo.fileId)], expectedVersion: exactVersion(detail.version), + idempotencyKey: key(`${name}-COMPLETE`), remark: marker, + }) + assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) + const confirmed = await api(merchantToken, 'POST', + `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { + idempotencyKey: key(`${name}-CONFIRM`), remark: marker, + }) + const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' + assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`) + assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode), + `${relabelNo} did not preserve the destination warehouse`) + Object.assign(scenario, { items: confirmed.items, confirmedResult: confirmed, relabelVerified: true, + relabelCartonMarks: operationType === 'CARTON_AND_SKU' ? confirmed.items.map(item => item.fbaCartonMark) : [] }) + scenario.sourceAfterRelabel = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + await context.checkpoint() + return scenario + } + + async function printPallet(relabelNo, suffix) { + return api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { + relabelNo, idempotencyKey: key(`PRINT-${suffix}`), }) } - const sourceBefore = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) - const scenario = { name, operationType, relabelNo, items: detail.items, sourceBefore } - if (prepare) await prepare(scenario) - const form = new FormData() - // Valid synthetic PNG for HTTP validation; not a photograph of real warehouse work. - form.append('file', new Blob([Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aWQAAAABJRU5ErkJggg==', 'base64')], { type: 'image/png' }), `${marker}.png`) - const photo = await api(overseas, pdaToken, 'POST', - `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/photos/files`, form) - detail = await api(overseas, pdaToken, 'POST', `/api/pda/v1/last-leg/relabel-tasks/${relabelNo}/complete`, { - photoFileIds: [id(photo.fileId)], expectedVersion: Number(detail.version), - idempotencyKey: key(`${name}-COMPLETE`), remark: marker, - }) - assert(detail.applicationStatus === 'CUSTOMER_CONFIRM_PENDING', `${relabelNo} did not reach customer confirmation`) - const confirmed = await api(overseas, merchantToken, 'POST', - `/api/merchant/v1/last-leg/relabel-applications/${relabelNo}/confirm-completion`, { - idempotencyKey: key(`${name}-CONFIRM`), remark: marker, + async function moveCarton(scenario, item, target, index) { + const sourcePallet = await resolvePallet(sourcePosition.palletNo) + const targetPallet = await resolvePallet(target.palletCode) + const result = await api(pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { + idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo, + source: { palletCode: sourcePallet.palletCode, expectedVersion: exactVersion(sourcePallet.palletVersion) }, + target: { palletCode: targetPallet.palletCode, expectedVersion: exactVersion(targetPallet.palletVersion) }, + cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`, }) - const expectedStatus = operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' - assert(confirmed.application.status === expectedStatus, `${relabelNo} did not reach ${expectedStatus}`) - assert(confirmed.items.every((item) => item.destinationFbaWarehouseCode === source.destinationFbaWarehouseCode), - `${relabelNo} did not preserve the destination warehouse`) - return { ...scenario, items: confirmed.items } -} + assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type') + return result + } -async function printPallet(relabelNo, suffix) { - return api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-pallet-label', { - relabelNo, idempotencyKey: key(`PRINT-${suffix}`), + const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL') + skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) + assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets') + skuOnly.targetPallets = [] + skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode + + + let sharedTarget + const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => { + sharedTarget = await printPallet(scenario.relabelNo, 'SHARED') + const printReplay = await printPallet(scenario.relabelNo, 'SHARED') + assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] }) -} -async function moveCarton(scenario, item, target, index) { - const sourcePallet = await resolvePallet(sourcePosition.palletNo) - const targetPallet = await resolvePallet(target.palletCode) - const result = await api(overseas, pdaToken, 'POST', '/api/pda/v1/last-leg/pallet-operations/relabel-disassemble', { - idempotencyKey: key(`${scenario.name}-MOVE-${index}`), relabelNo: scenario.relabelNo, - source: { palletCode: sourcePallet.palletCode, expectedVersion: Number(sourcePallet.palletVersion) }, - target: { palletCode: targetPallet.palletCode, expectedVersion: Number(targetPallet.palletVersion) }, - cartonMarks: [item.cartonMark], reason: `${marker}-${scenario.name}`, + + const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) + const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => { + await moveCarton(scenario, scenario.items[0], sharedTarget, 0) + scenario.targets = [sharedTarget] }) - assert(result.operationType === 'RELABEL_PALLET_DISASSEMBLE', 'Unexpected pallet operation type') - return result -} + const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) + assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') + merged.targets = [sharedTarget] + report.sharedMerge = { palletId: id(sharedTarget.palletId), palletCode: sharedTarget.palletCode, beforePackages: sharedBefore, afterPackages: sharedAfter, + sourceScenarios: [{ name: fresh.name, cartons: fresh.expectedCartons }, { name: merged.name, cartons: merged.expectedCartons }] } -const skuOnly = await createAndConfirm('SKU-ONLY', 1, 'SKU_LABEL') -skuOnly.sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) -assert(skuOnly.sourceAfter === skuOnly.sourceBefore, 'SKU-only relabel must not split or regroup pallets') -skuOnly.targetPallets = [] -skuOnly.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode -delete skuOnly.items - -let sharedTarget -const fresh = await createAndConfirm('NEW-PALLET', 1, 'CARTON_AND_SKU', async scenario => { - sharedTarget = await printPallet(scenario.relabelNo, 'SHARED') - const printReplay = await printPallet(scenario.relabelNo, 'SHARED') - assert(id(sharedTarget.palletId) === id(printReplay.palletId), 'Pallet print replay was not idempotent') - await moveCarton(scenario, scenario.items[0], sharedTarget, 0) - scenario.targets = [sharedTarget] -}) - -const sharedBefore = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) -const merged = await createAndConfirm('MERGE-EXISTING', 1, 'CARTON_AND_SKU', async scenario => { - await moveCarton(scenario, scenario.items[0], sharedTarget, 0) - scenario.targets = [sharedTarget] -}) -const sharedAfter = Number((await resolvePallet(sharedTarget.palletCode)).totalPackages) -assert(sharedAfter === sharedBefore + 1, 'Existing target pallet carton count did not increase') -merged.targets = [sharedTarget] - -const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => { - const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A') - const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B') - await moveCarton(scenario, scenario.items[0], splitA, 0) - await moveCarton(scenario, scenario.items[1], splitB, 1) - scenario.targets = [splitA, splitB] -}) - -for (const scenario of [fresh, merged, split]) { - const sourceAfter = Number((await resolvePallet(sourcePosition.palletNo)).totalPackages) - scenario.sourceAfter = sourceAfter - scenario.targetPallets = scenario.targets.map((target) => ({ palletId: id(target.palletId), palletCode: target.palletCode })) - const candidateRows = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/dispatches/candidates?orderNo=${encodeURIComponent(scenario.relabelNo)}&sourceStage=RECEIVED`) - const targetIds = new Set(scenario.targets.map((target) => id(target.palletId))) - const rows = candidateRows.filter((row) => row.sourceStage === 'RELABEL' && targetIds.has(id(row.warehousePalletId))) - const marks = rows.flatMap((row) => String(row.relabelCartonMarks || '').split(',').filter(Boolean)) - assert(rows.length === targetIds.size, `${scenario.relabelNo} missing smart-dispatch pallet candidate`) - assert(rows.every((row) => !String(row.cartonMarks || '').trim()), `${scenario.relabelNo} exposed normal carton marks`) - assert(scenario.items.every((item) => marks.includes(item.fbaCartonMark)), `${scenario.relabelNo} missing relabel carton mark`) - const dispatch = await api(overseas, platformToken, 'POST', '/api/platform/v1/last-leg/dispatches/pre-arrange', { - planPalletIds: rows.map((row) => id(row.warehousePalletId)), route: `${marker}-${scenario.name}`, - relabelNo: scenario.relabelNo, + const split = await createAndConfirm('SPLIT-TWO-TARGETS', 2, 'CARTON_AND_SKU', async scenario => { + const splitA = await printPallet(scenario.relabelNo, 'SPLIT-A') + const splitB = await printPallet(scenario.relabelNo, 'SPLIT-B') + await moveCarton(scenario, scenario.items[0], splitA, 0) + await moveCarton(scenario, scenario.items[1], splitB, 1) + scenario.targets = [splitA, splitB] }) - scenario.dispatchNo = dispatch.dispatchNo - scenario.dispatchStatus = dispatch.status - scenario.relabelCartonMarks = marks - delete scenario.items - delete scenario.targets + + for (const scenario of [fresh, merged, split]) scenario.destinationFbaWarehouseCode = source.destinationFbaWarehouseCode + const ledger = await api(platformToken, 'GET', `/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`) + const ledgerRows = ledger.records.filter(row => String(row.remark || '').includes(marker)) + assert(ledgerRows.some(row => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') + assert(ledgerRows.some(row => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') + assert(ledgerRows.every(row => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') + report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map(row => Number(row.deltaPackages)), records: ledgerRows } + report.relabelScenariosVerified = report.scenarios.length === 4 && report.scenarios.every(scenario => scenario.relabelVerified) + await publishNativeRelabelDispatches(context, report, config) + report.passed = report.relabelScenariosVerified && !report.blockers.length + report.finalStatus = report.passed ? 'RELABEL_AND_NATIVE_DISPATCH_VERIFIED' : 'BLOCKED_NATIVE_DISPATCH_OR_PHYSICAL_LOADING_EVIDENCE' + await context.checkpoint(); return report } -const ledger = await api(overseas, platformToken, 'GET', - `/api/platform/v1/last-leg/inventory/ledger?customerId=${customerId}&page=1&size=100&businessType=RELABEL_PALLET_DISASSEMBLE`) -const ledgerRows = ledger.records.filter((row) => String(row.remark || '').includes(marker)) -assert(ledgerRows.some((row) => Number(row.deltaPackages) < 0), 'Missing negative relabel pallet ledger') -assert(ledgerRows.some((row) => Number(row.deltaPackages) > 0), 'Missing positive relabel pallet ledger') -assert(ledgerRows.every((row) => String(row.remark).startsWith('换标拆托')), 'Unexpected relabel ledger reason') -report.scenarios = [skuOnly, fresh, merged, split] -report.ledger = { rows: ledgerRows.length, deltas: ledgerRows.map((row) => Number(row.deltaPackages)) } -report.passed = true -report.finishedAt = new Date().toISOString() -await writeFile(output, JSON.stringify(report, null, 2), 'utf8') -console.log(JSON.stringify(report, null, 2)) +export async function mainRelabelProducer(env = process.env) { + const options = parseArguments([]); options.modules = [] + const output = resolve(env.OVERSEAS_ACCEPTANCE_OUTPUT || 'reports/overseas-relabel-real-acceptance.json') + options.output = output; validateExecutionOptions(options) + requireFact(options.environment === 'test', 'Relabel producer only supports explicit TEST') + const customerId = id(env.OVERSEAS_TEST_CUSTOMER_ID), warehouseId = id(env.OVERSEAS_TEST_WAREHOUSE_ID) + const sourceBalanceId = id(env.OVERSEAS_RELABEL_SOURCE_BALANCE_ID), sourcePositionId = id(env.OVERSEAS_RELABEL_SOURCE_POSITION_ID) + requireFact(/^[1-9]\d*$/.test(customerId) && /^[1-9]\d*$/.test(warehouseId) && /^[1-9]\d*$/.test(sourceBalanceId) && /^[1-9]\d*$/.test(sourcePositionId), + 'Explicit customer, warehouse, original source balance and position IDs are required before HTTP') + requireFact(!existsSync(output), 'Existing producer checkpoint must be reconciled with its original commands; do not overwrite it or regenerate a marker') + const chargeAmount = Number(env.OVERSEAS_RELABEL_CHARGE_AMOUNT ?? '0.01') + requireFact(Number.isFinite(chargeAmount) && chargeAmount >= 0, 'Invalid charge amount') + const context = new AcceptanceRun(options), marker = env.OVERSEAS_RELABEL_MARKER || context.marker + requireFact(/^[A-Za-z0-9_-]+$/.test(marker), 'Invalid explicit producer marker') + const report = { marker, environment: 'TEST', startedAt: new Date().toISOString(), scope: 'RELABEL_PRODUCER_AND_NATIVE_DISPATCH_ONLY', + chargeAmount, photoEvidence: 'SYNTHETIC_PNG_NOT_PHYSICAL_WORK', scenarios: [], blockers: [], passed: false, requests: [] } + const raw = async (method, path, body, token) => { + const response = await context.request(method, path, body, token) + requireFact(response.http === 200 && String(response.body?.code) === '200', 'Unified authentication failed') + return response.body.data + } + try { + // Session tickets and tokens remain in memory and never enter the business journal. + const platform = await raw('POST', '/api/auth/login', { loginId: options.username, password: options.password }, '') + context.token = platform.accessToken + requireFact(context.token && options.pdaUsername && options.pdaPassword, 'Unified platform and explicit PDA credentials required') + const pda = await raw('POST', '/api/auth/login', { loginId: options.pdaUsername, password: options.pdaPassword }, '') + let merchant + if (options.merchantUsername && options.merchantPassword) merchant = await raw('POST', '/api/auth/login', { loginId: options.merchantUsername, password: options.merchantPassword }, '') + else { + const ticket = await raw('POST', `/api/platform/v1/iam/tenants/${customerId}/impersonation`, { reason: `${marker} TEST relabel producer` }, context.token) + merchant = await raw('POST', '/api/auth/impersonations/exchange', { code: ticket.code }, '') + } + requireFact(pda.accessToken && merchant.accessToken, 'Unified PDA/customer accessToken required') + const profile = await raw('GET', '/api/merchant/v1/iam/tenant/profile', undefined, merchant.accessToken) + requireFact(id(profile.id) === customerId, 'Merchant session does not own the explicit customer') + installRelabelJournal(context, report, () => saveRetainedReport(output, report)); context.marker = marker + const loadingEvidenceByScenario = env.OVERSEAS_RELABEL_LOADING_EVIDENCE ? JSON.parse(readFileSync(resolve(env.OVERSEAS_RELABEL_LOADING_EVIDENCE), 'utf8')) : undefined + await runRelabelProducer(context, { customerId, warehouseId, sourceBalanceId, sourcePositionId, inboundCode: env.OVERSEAS_RELABEL_SOURCE_INBOUND_CODE, + merchantToken: merchant.accessToken, pdaToken: pda.accessToken, chargeAmount, loadingEvidenceByScenario }, report) + } catch (error) { + report.passed = false; report.finalStatus = report.requests.some(row => ['SENDING', 'UNKNOWN'].includes(row.status)) ? 'UNKNOWN_WRITE_STOPPED' : 'BLOCKED' + report.failedStage = report.scenarios.at(-1)?.name || 'CUSTOMER_OR_EXPLICIT_SOURCE'; process.exitCode = 1 + } finally { + report.checks = context.steps; report.finishedAt = new Date().toISOString() + if (!report.passed || context.steps.some(step => step.passed === false)) process.exitCode = 1 + saveRetainedReport(output, report) + console.log(JSON.stringify({ marker, finalStatus: report.finalStatus, passed: report.passed, + relabelScenariosVerified: report.relabelScenariosVerified, scenarios: report.scenarios.map(scenario => ({ name: scenario.name, + relabelNo: scenario.relabelNo, dispatchNo: scenario.dispatchNo, dispatchStatus: scenario.dispatchStatus })), report: output })) + } + return report +} +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) await mainRelabelProducer() diff --git a/scripts/overseas-relabel-real-contract.test.mjs b/scripts/overseas-relabel-real-contract.test.mjs new file mode 100644 index 000000000..f28c8d048 --- /dev/null +++ b/scripts/overseas-relabel-real-contract.test.mjs @@ -0,0 +1,289 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { installRelabelJournal, selectExplicitRelabelSource, nativeRelabelCreateBody, nativeRelabelGroups, relabelGroupMeasurements, runRelabelProducer } from './overseas-relabel-real-acceptance.mjs' +import { retainedRelabelSource } from './overseas-relabel-fulfillment-acceptance.mjs' +const ok = data => ({ http: 200, body: { code: '200', data }, requestId: 'REQ', traceId: 'TRACE' }) +const config = () => ({ customerId: '100', warehouseId: '80', sourceBalanceId: '1', sourcePositionId: '2', merchantToken: 'MERCHANT-MEMORY', pdaToken: 'PDA-MEMORY', chargeAmount: 0 }) +const sourceCandidate = () => ({ inventoryBalanceId: '1', inboundOrderId: '3', inboundOrderNo: 'DS-TEST', inboundCode: 'IN-TEST', overseasWarehouseId: '80', + availablePackages: '5', skuCode: 'SKU', destinationFbaWarehouseCode: 'ABE4', positions: [{ positionId: '2', palletNo: 'SOURCE', availablePackages: '5' }] }) + +test('explicit original balance/position selection never borrows history or a different customer warehouse', () => { + const candidate = sourceCandidate() + assert.equal(selectExplicitRelabelSource([candidate], config()).source, candidate) + for (const mutate of [value => { value.sourceBalanceId = '' }, value => { value.sourcePositionId = '99' }, + value => { value.warehouseId = '81' }, value => { value.inboundCode = 'OTHER' }]) { + const value = config(); mutate(value); assert.throws(() => selectExplicitRelabelSource([candidate], value)) + } + assert.throws(() => selectExplicitRelabelSource([candidate, candidate], config()), /ambiguous/) + const unavailable = sourceCandidate(); unavailable.positions[0].availablePackages = '4' + assert.throws(() => selectExplicitRelabelSource([unavailable], config()), /five real/) +}) + +test('empty successful relabel response is UNKNOWN, keeps the original version/key, and stops replacement writes', async () => { + const report = { marker: 'RUN-1', requests: [] }, snapshots = [], calls = [] + const context = installRelabelJournal({ context: {}, createdResources: {}, + async request(method, path, body) { calls.push({ method, path, body }); assert.equal(snapshots.at(-1).requests.at(-1).status, 'SENDING'); return ok(null) }, + async requestMultipart() { throw Error('Unexpected upload') }, + }, report, async () => snapshots.push(structuredClone(report))) + const original = { expectedVersion: '0', idempotencyKey: 'ORIGINAL', oldLabel: 'OLD' } + await assert.rejects(context.request('POST', '/api/pda/v1/last-leg/relabel-tasks/RL-1/units', original, 'PDA-MEMORY'), /business response missing/) + assert.equal(report.requests[0].status, 'UNKNOWN'); assert.deepEqual(report.requests[0].body, original) + assert.equal(report.requests[0].requestId, 'REQ') + await assert.rejects(context.request('POST', '/api/pda/v1/last-leg/relabel-tasks/RL-1/units', { ...original, idempotencyKey: 'NEW' }), /stop with original/) + assert.equal(calls.length, 1) +}) +for (const data of [null, {}, { dispatchNo: 'DS-UNKNOWN', action: 'CREATE', recordVersion: '0', result: null }]) test(`native CREATE without its original business result stops with its original key: ${JSON.stringify(data)}`, async () => { + const report = { marker: 'RUN-1', requests: [] }, calls = [] + const context = installRelabelJournal({ context: {}, createdResources: {}, request: async (...args) => { calls.push(args); return ok(data) }, requestMultipart: async () => { throw Error('Unexpected upload') } }, report, async () => {}) + const body = { idempotencyKey: 'ORIGINAL-CREATE', cargo: [{ sourceType: 'RELABEL', relabelNo: 'RL-1', warehousePalletId: '9007199254740993', sourceVersion: 'a'.repeat(64) }] } + await assert.rejects(context.request('POST', '/api/platform/v1/last-leg/dispatches', body), /Unknown/) + assert.equal(report.requests[0].status, 'UNKNOWN'); assert.deepEqual(report.requests[0].body, body) + await assert.rejects(context.request('POST', '/api/platform/v1/last-leg/dispatches', { ...body, idempotencyKey: 'NEW' }), /Unknown/) + assert.equal(calls.length, 1) +}) + +function producerFixture(corruptCartonStage = false, mutateCandidates = value => value, mutateDetail = value => value) { + const report = { environment: 'TEST', marker: 'RUN-1', requests: [], scenarios: [], blockers: [], passed: false }, calls = [], snapshots = [] + const applications = new Map(), replay = new Map(), pallets = new Map([['SOURCE', { palletId: '4', palletCode: 'SOURCE', totalPackages: 5, palletVersion: 0 }]]) + let appSequence = 0, palletSequence = 10, photoSequence = 20 + const dispatches = new Map() + const clone = value => structuredClone(value) + const detail = app => ({ relabelNo: app.no, version: app.version, items: clone(app.items), applicationStatus: app.status }) + const context = { token: 'PLATFORM-MEMORY', context: {}, createdResources: {}, steps: [], + apiSuccess(_m, _l, response) { return response.body?.data }, addCoverageGap() {}, + addStep(_m, _l, _r, passed) { this.steps.push({ passed }) }, + async requestMultipart(method, path, file) { + calls.push({ method, path, file: { name: file.name, type: file.type, size: file.content.length } }) + assert.equal(file.content.subarray(0, 8).toString('hex'), '89504e470d0a1a0a') + return ok({ fileId: String(photoSequence++) }) + }, + async request(method, path, body, token) { + calls.push({ method, path, body: clone(body), token }) + if (path.includes('/inventory-candidates')) { assert.equal(token, 'MERCHANT-MEMORY'); return ok([sourceCandidate()]) } + if (path.startsWith('/api/platform/v1/last-leg/relabel-applications?') && method === 'POST') { + const no = `RL-${++appSequence}`, items = body.items.map((item, index) => ({ ...clone(item), id: `90071992547410${appSequence}${index}`, + itemId: `90071992547410${appSequence}${index}`, cartonProcessedPackages: 0, skuProcessedPackages: 0 })) + const app = { no, items, version: 0, body: clone(body), status: 'PROCESSING' }; applications.set(no, app) + return ok({ application: { relabelNo: no, overseasWarehouseId: '80' }, items: clone(items) }) + } + if (path.includes('/relabel-tasks/')) { + assert.equal(token, 'PDA-MEMORY') + const no = path.split('/relabel-tasks/')[1].split('/')[0], app = applications.get(no) + if (replay.has(body.idempotencyKey)) return ok(clone(replay.get(body.idempotencyKey))) + if (path.endsWith('/claim')) return ok(detail(app)) + if (path.endsWith('/cartons/scan')) { + assert.equal(body.expectedVersion, app.version) + const item = app.items.find(item => item.cartonMark === body.cartonMark); item.cartonProcessedPackages = 1 + if (corruptCartonStage) item.skuProcessedPackages = 1 + app.version++ + } else if (path.endsWith('/units')) { + assert.equal(body.expectedVersion, app.version) + const item = app.items.find(item => item.itemId === body.itemId) + assert.equal(body.skuVerifications[0].quantity, 1); item.skuProcessedPackages = 1; app.version++ + } else if (path.endsWith('/complete')) { + assert.equal(body.expectedVersion, app.version); assert.equal(body.photoFileIds.length, 1) + app.status = 'CUSTOMER_CONFIRM_PENDING'; app.version++ + } else throw Error(`Unexpected relabel task ${path}`) + const result = detail(app); replay.set(body.idempotencyKey, clone(result)); return ok(result) + } + if (path.includes('/confirm-completion')) { + assert.equal(token, 'MERCHANT-MEMORY') + const no = path.split('/relabel-applications/')[1].split('/')[0], app = applications.get(no) + return ok({ application: { relabelNo: no, overseasWarehouseId: '80', status: app.body.items[0].operationType === 'SKU_LABEL' ? 'COMPLETED' : 'READY_FOR_OUTBOUND' }, items: clone(app.items) }) + } + if (path.endsWith('/pallets/resolve')) return ok(clone(pallets.get(body.code))) + if (path.endsWith('/relabel-pallet-label')) { + if (replay.has(body.idempotencyKey)) return ok(clone(replay.get(body.idempotencyKey))) + const palletId = String(palletSequence++), palletCode = `P-${palletId}` + const value = { palletId, palletCode, totalPackages: 0, palletVersion: 0 }; pallets.set(palletCode, value) + replay.set(body.idempotencyKey, value); return ok(clone(value)) + } + if (path.endsWith('/relabel-disassemble')) { + pallets.get(body.source.palletCode).totalPackages--; pallets.get(body.source.palletCode).palletVersion++ + pallets.get(body.target.palletCode).totalPackages++; pallets.get(body.target.palletCode).palletVersion++ + return ok({ operationType: 'RELABEL_PALLET_DISASSEMBLE' }) + } + if (path.includes('/inventory/ledger?')) return ok({ records: [{ remark: '换标拆托 RUN-1', deltaPackages: -1 }, { remark: '换标拆托 RUN-1', deltaPackages: 1 }] }) + if (path.endsWith('/relabel-pallet-measurements')) return ok([]) + if (path.includes('/dispatches/candidates?')) { + const query = new URL(path, 'https://offline.invalid').searchParams + assert.equal(query.get('warehouseId'), '80'); assert.equal(query.get('sourceStage'), 'RELABEL') + assert.equal(query.has('orderNo'), false) + const rows = report.scenarios.filter(scenario => scenario.operationType === 'CARTON_AND_SKU').flatMap(scenario => scenario.targets.map(target => ({ + selection: { sourceType: 'RELABEL', relabelNo: scenario.relabelNo, planPalletId: null, returnReceiptId: null, warehousePalletId: target.palletId, sourceVersion: 'a'.repeat(64) }, + sourceKey: `RELABEL:${scenario.relabelNo}:${target.palletId}`, sourceVersion: 'a'.repeat(64), customerId: '100', warehouseId: '80', + palletNo: target.palletCode, availablePackages: '1', destination: { type: 'FBA', warehouseCode: 'ABE4', key: 'FBA:ABE4' }, blockReasons: [], appointmentBatches: [] }))) + for (const subset of [rows.filter(row => row.selection.relabelNo !== 'RL-4'), rows.filter(row => row.selection.relabelNo === 'RL-4')]) attachGroup(subset) + return ok({ complete: true, candidates: mutateCandidates(rows) }) + } + if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { + assert.equal(calls.filter(call => call.path.includes('/dispatches/candidates?')).length, 1) + assert.equal(body.cargo.length, 2) + const old = dispatches.get(body.idempotencyKey) + if (old) { assert.deepEqual(body, old.body); return ok({ ...clone(old.result), replayed: true }) } + const dispatchNo = `DS-NATIVE-${dispatches.size + 1}` + const result = { dispatchNo, action: 'CREATE', status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0', result: { dispatchNo, changedItemIds: ['9007199254740993'] } } + dispatches.set(body.idempotencyKey, { body: clone(body), result }); return ok(result) + } + if (path.includes('/dispatches/DS-NATIVE-') && method === 'GET') { + const dispatch = [...dispatches.values()].find(value => path.endsWith(value.result.dispatchNo)) + return ok(mutateDetail({ dispatchNo: dispatch.result.dispatchNo, status: 'ACTIVE', stage: 'BEFORE_LOADING', recordVersion: '0', + counts: { packageCount: '2', palletCount: String(new Set(dispatch.body.cargo.map(row => row.warehousePalletId)).size) }, + orderDetails: dispatch.body.cargo.map(row => ({ customerId: '100', inboundCode: 'IN-TEST', sourceBusinessType: 'RELABEL', sourceBusinessNo: row.relabelNo, sourceKey: `RELABEL:${row.relabelNo}:${row.warehousePalletId}` })) })) + } + throw Error(`Unexpected offline producer ${method} ${path}`) + }, + } + installRelabelJournal(context, report, async () => { snapshots.push(clone(report)) }) + return { context, report, calls, snapshots } +} + +function attachGroup(rows) { + const sources = rows.map(row => ({ selection: structuredClone(row.selection), sourceKey: row.sourceKey, customerId: row.customerId, customerName: 'TEST', sourceBusinessType: 'RELABEL', sourceBusinessNo: row.selection.relabelNo, packages: row.availablePackages })) + const pallets = [...new Set(rows.map(row => row.selection.warehousePalletId))].map(warehousePalletId => ({ warehousePalletId, palletNo: rows.find(row => row.selection.warehousePalletId === warehousePalletId).palletNo, + sourceKeys: sources.filter(source => source.selection.warehousePalletId === warehousePalletId).map(source => source.sourceKey), packageCount: String(sources.filter(source => source.selection.warehousePalletId === warehousePalletId).reduce((sum, source) => sum + Number(source.packages), 0)) })) + const group = { groupKey: `GROUP-${sources[0].sourceBusinessNo}`, groupVersion: 'b'.repeat(64), sources, pallets, complete: true, blockReasons: [] } + for (const row of rows) row.selectionGroup = structuredClone(group) + return group +} + +test('four relabel scenes retain scans/photos/counts; shared NEW/MERGE create one group once and split retains its application closure', async () => { + const f = producerFixture() + await runRelabelProducer(f.context, config(), f.report) + assert.deepEqual(f.report.scenarios.map(scenario => scenario.name), ['SKU-ONLY', 'NEW-PALLET', 'MERGE-EXISTING', 'SPLIT-TWO-TARGETS']) + assert.equal(f.report.relabelScenariosVerified, true) + assert.equal(f.report.scenarios[0].sourceBefore, f.report.scenarios[0].sourceAfter) + assert.deepEqual(f.report.sharedMerge.sourceScenarios.map(row => row.cartons), [1, 1]) + assert.equal(f.report.sharedMerge.beforePackages, 1); assert.equal(f.report.sharedMerge.afterPackages, 2) + const fresh = f.report.scenarios[1], merged = f.report.scenarios[2] + assert.equal(fresh.nativeDispatch, merged.nativeDispatch); assert.equal(fresh.dispatchNo, 'DS-NATIVE-1'); assert.equal(merged.dispatchNo, fresh.dispatchNo) + assert.equal(fresh.nativeDispatch.createBody.cargo.length, 2); assert.equal(new Set(fresh.nativeDispatch.createBody.cargo.map(row => row.warehousePalletId)).size, 1) + assert.deepEqual([fresh, merged].map(scenario => [scenario.sourceContributions[0].sourceBusinessNo, scenario.sourceContributions[0].packages]), [['RL-2', '1'], ['RL-3', '1']]) + assert.deepEqual(fresh.nativeDispatch.detail.orderDetails.map(row => row.sourceBusinessNo), ['RL-2', 'RL-3']) + assert.equal(f.calls.filter(call => call.path === '/api/platform/v1/last-leg/dispatches').length, 4) + const split = f.report.scenarios[3] + assert.equal(split.nativeDispatch.createBody.cargo.length, 2); assert.equal(split.nativeDispatch.createResult.recordVersion, '0') + assert.equal(split.nativeDispatch.createReplay.replayed, true) + assert.equal(split.items.length, 2); assert.equal(split.targets.length, 2) + assert.equal(split.sourceCartonMarks.length, 2); assert.equal(split.relabelCartonMarks.length, 2) + assert.equal(split.nativeDispatch.loadingMeasurements, undefined) + assert.equal(split.nativeDispatchBlocker.code, 'ACTUAL_LOADING_MEASUREMENT_REQUIRED') + assert.equal(f.report.passed, false) + assert.equal(retainedRelabelSource(f.report, '100', 'DS-NATIVE-2').scope.expectedPackages, 2) + assert.equal(f.calls.filter(call => call.path.endsWith('/photos/files')).length, 4) + assert.equal(f.calls.some(call => /pre-arrange/.test(call.path)), false) + const scans = f.calls.filter(call => call.path.endsWith('/units')) + assert.equal(scans.length, 10) + for (let index = 0; index < scans.length; index += 2) assert.deepEqual(scans[index].body, scans[index + 1].body) +}) + +test('carton stage may not complete the SKU stage and a failure keeps original scene items/source request proof', async () => { + const f = producerFixture(true) + await assert.rejects(runRelabelProducer(f.context, config(), f.report), /carton stage incorrectly/) + assert.equal(f.report.scenarios[0].relabelVerified, true) + assert.equal(f.report.scenarios[1].relabelVerified, false) + assert.equal(f.report.scenarios[1].requestItems.length, 1) + assert.equal(f.report.scenarios[1].sourceCartonMarks.length, 1) + assert.equal(f.calls.some(call => call.path === '/api/platform/v1/last-leg/dispatches'), false) +}) + +test('native producer selectors cannot replace customer, warehouse, destination, original source hash or physical quantity', () => { + const scenario = { name: 'SPLIT-TWO-TARGETS', relabelVerified: true, relabelNo: 'RL-1', targets: [{ palletId: '9007199254740993' }], items: [{ notifiedPackageCount: 1 }], destinationFbaWarehouseCode: 'ABE4' } + const candidate = { selection: { sourceType: 'RELABEL', relabelNo: 'RL-1', warehousePalletId: '9007199254740993', sourceVersion: 'a'.repeat(64) }, + sourceKey: 'RELABEL:RL-1:9007199254740993', sourceVersion: 'a'.repeat(64), customerId: '100', warehouseId: '80', availablePackages: '1', palletNo: 'P-ORIGINAL', + destination: { type: 'FBA', warehouseCode: 'ABE4' }, blockReasons: [], appointmentBatches: [] } + attachGroup([candidate]) + assert.equal(nativeRelabelCreateBody('RUN-1', [scenario], [candidate], '100', '80').cargo[0].warehousePalletId, '9007199254740993') + for (const mutate of [value => { value.customerId = '200' }, value => { value.warehouseId = '81' }, + value => { value.destination.warehouseCode = 'OTHER' }, value => { value.availablePackages = '2' }, + value => { value.selection.sourceVersion = 'stale' }, value => { value.cartonMarks = 'OLD' }]) { + const value = structuredClone(candidate); mutate(value); assert.throws(() => nativeRelabelCreateBody('RUN-1', [scenario], [value], '100', '80')) + } +}) + +for (const fault of ['missing group', 'incomplete group', 'group blocker', 'missing version', 'version drift', 'member selector version', 'unknown business', 'missing member', + 'duplicate member', 'source packages', 'physical packages', 'missing physical source', 'duplicate physical', 'split application closure']) test(`authority group preflight blocks every CREATE: ${fault}`, async () => { + const f = producerFixture(false, rows => { + if (fault === 'missing group') delete rows[0].selectionGroup + if (fault === 'incomplete group') rows[0].selectionGroup.complete = false + if (fault === 'group blocker') rows[0].selectionGroup.blockReasons = ['真实组尚未核平'] + if (fault === 'missing version') rows[0].selectionGroup.groupVersion = '' + if (fault === 'version drift') rows[0].selectionGroup.groupVersion = 'CHANGED' + if (fault === 'member selector version') rows[0].selectionGroup.sources[0].selection.sourceVersion = 'c'.repeat(64) + if (fault === 'unknown business') rows[0].selectionGroup.sources[0].sourceBusinessNo = 'RL-HISTORY' + if (fault === 'missing member') rows.splice(1, 1) + if (fault === 'duplicate member') rows.push(structuredClone(rows[0])) + if (fault === 'source packages') rows[0].selectionGroup.sources[0].packages = '2' + if (fault === 'physical packages') rows[0].selectionGroup.pallets[0].packageCount = '3' + if (fault === 'missing physical source') rows[0].selectionGroup.pallets[0].sourceKeys.pop() + if (fault === 'duplicate physical') rows[0].selectionGroup.pallets.push(structuredClone(rows[0].selectionGroup.pallets[0])) + if (fault === 'split application closure') rows.at(-1).selectionGroup.groupKey = 'OTHER-PHYSICAL-GROUP' + if (['incomplete group', 'group blocker', 'missing version', 'member selector version', 'unknown business', 'source packages', 'physical packages', 'missing physical source', 'duplicate physical'].includes(fault)) + for (const row of rows.slice(1).filter(row => row.selectionGroup.groupKey === rows[0].selectionGroup.groupKey)) row.selectionGroup = structuredClone(rows[0].selectionGroup) + return rows + }) + await assert.rejects(runRelabelProducer(f.context, config(), f.report)) + assert.equal(f.report.relabelScenariosVerified, true) + assert.equal(f.calls.some(call => call.method === 'POST' && call.path === '/api/platform/v1/last-leg/dispatches'), false) + assert.ok(f.report.nativeSelectionFailure.message); assert.equal(f.report.passed, false) +}) + +test('native source business number is required in actual Detail and cannot fall back to inboundCode', async () => { + const f = producerFixture(false, rows => rows, detail => { delete detail.orderDetails[0].sourceBusinessNo; return detail }) + await runRelabelProducer(f.context, config(), f.report) + assert.equal(f.report.passed, false) + assert.equal(f.report.scenarios[1].nativeDispatchBlocker.code, 'NATIVE_DISPATCH_NOT_VERIFIED') + assert.equal(f.report.scenarios[1].nativeDispatch.createResult.recordVersion, '0') + assert.equal(f.report.scenarios[1].nativeDispatch.createReplay.replayed, true) +}) + +function loadingReceipt(dispatchNo, palletIds) { + return { environment: 'TEST', marker: 'RUN-1', dispatchNo, pallets: palletIds.map(warehousePalletId => ({ warehousePalletId, palletSpaces: 2, weightKg: '15.5', volumeCbm: '0.75', deliveryIndex: 1, + evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map((field, index) => ({ field, value: [2, '15.5', '0.75'][index], actual: true, sourceType: 'OFFLINE_TEST_RECEIPT', sourceBusinessNo: 'OFFLINE-ONLY', observedAt: '2026-10-19T00:00:00Z' })) })) } +} +test('consistent shared scenario loading receipts publish one physical envelope and one dispatch command pair', async () => { + const f = producerFixture(), c = config() + c.loadingEvidenceByScenario = { 'NEW-PALLET': loadingReceipt('DS-NATIVE-1', ['10']), 'MERGE-EXISTING': loadingReceipt('DS-NATIVE-1', ['10']), 'SPLIT-TWO-TARGETS': loadingReceipt('DS-NATIVE-2', ['11', '12']) } + await runRelabelProducer(f.context, c, f.report) + assert.equal(f.report.passed, true) // Offline script contract only; no environment claim. + const fresh = f.report.scenarios[1], merged = f.report.scenarios[2] + assert.equal(fresh.nativeDispatch, merged.nativeDispatch) + assert.equal(fresh.nativeDispatch.loadingMeasurements.pallets.length, 1) + assert.equal(fresh.nativeDispatch.loadingMeasurements.pallets[0].weightKg, '15.5') + assert.equal(f.calls.filter(call => call.path === '/api/platform/v1/last-leg/dispatches').length, 4) + assert.equal(f.calls.some(call => /loading-complete|\/pick$|\/depart$/.test(call.path)), false) +}) +for (const fault of ['dispatch', 'weight', 'space', 'stop', 'pallet', 'duplicate']) test(`shared loading receipts conflict before any CREATE: ${fault}`, async () => { + const f = producerFixture(), c = config(), fresh = loadingReceipt('DS-NATIVE-1', ['10']), merged = structuredClone(fresh) + if (fault === 'dispatch') merged.dispatchNo = 'DS-OTHER' + if (fault === 'weight') { merged.pallets[0].weightKg = '16'; merged.pallets[0].evidence[1].value = '16' } + if (fault === 'space') { merged.pallets[0].palletSpaces = 3; merged.pallets[0].evidence[0].value = 3 } + if (fault === 'stop') merged.pallets[0].deliveryIndex = 2 + if (fault === 'pallet') merged.pallets[0].warehousePalletId = '9007199254740993' + if (fault === 'duplicate') merged.pallets.push(structuredClone(merged.pallets[0])) + c.loadingEvidenceByScenario = { 'NEW-PALLET': fresh, 'MERGE-EXISTING': merged } + await assert.rejects(runRelabelProducer(f.context, c, f.report)) + assert.equal(f.calls.some(call => call.path === '/api/platform/v1/last-leg/dispatches'), false) +}) +test('shared physical measurements deduplicate whole-pallet mass and reject composition or value drift', () => { + const measured = { palletId: '9007199254740993', palletCode: 'P-SHARED', packages: '2', stockVersion: 'STOCK-ORIGINAL', lengthCm: '100', widthCm: '100', heightCm: '100', grossWeightKg: '15.5', volumeCbm: '1' }, + group = { pallets: [{ warehousePalletId: '9007199254740993', palletNo: 'P-SHARED', packageCount: '2' }] }, + scenarios = [{ physicalMeasurements: [measured] }, { physicalMeasurements: [structuredClone(measured)] }] + const result = relabelGroupMeasurements(scenarios, group) + assert.equal(result.length, 1); assert.equal(result[0].grossWeightKg, '15.5') + scenarios[1].physicalMeasurements[0].stockVersion = 'NEW-STOCK' + assert.throws(() => relabelGroupMeasurements(scenarios, group), /conflict/) + scenarios[1].physicalMeasurements[0] = { ...measured, packages: '1' } + assert.throws(() => relabelGroupMeasurements(scenarios, group), /complete shared pallet/) +}) + +test('producer import is inert, current TEST Auth is unified, and evidence is retained instead of deleting scenario inputs', () => { + const source = readFileSync(new URL('./overseas-relabel-real-acceptance.mjs', import.meta.url), 'utf8') + assert.match(source, /fileURLToPath\(import.meta.url\)\) await mainRelabelProducer\(\)/) + assert.doesNotMatch(source, /overseasAccessToken|ADMIN_OVERSEAS_GATEWAY|ADMIN_OVERSEAS_ACCESS_TOKEN|delete scenario.items|delete scenario.targets|\/dispatches\/pre-arrange/) + assert.match(source, /options.environment === 'test'/) + assert.match(source, /profile.id\) === customerId/) + assert.match(source, /SYNTHETIC_PNG_NOT_PHYSICAL_WORK/) + assert.match(source, /Existing producer checkpoint must be reconciled/) +}) diff --git a/scripts/prod-release-overseas-stage.mjs b/scripts/prod-release-overseas-stage.mjs index e936369ef..27e28f075 100644 --- a/scripts/prod-release-overseas-stage.mjs +++ b/scripts/prod-release-overseas-stage.mjs @@ -1,10 +1,15 @@ +import { readFile } from 'node:fs/promises' +import { isAbsolute } from 'node:path' +import { createHash } from 'node:crypto' import { fingerprint } from './prod-release-order-stage.mjs' import { controlledPalletMeasurements } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' const BASE = '/api/platform/v1/last-leg' const PDA = '/api/pda/v1/last-leg' const check = (ok, message) => { if (!ok) throw new Error(message) } -const id = value => value == null ? '' : String(value) +const id = value => { check(typeof value !== 'number' || Number.isSafeInteger(value), '业务ID精度无效'); return value == null ? '' : String(value) } +const version = value => { check(/^(0|[1-9]\d*)$/.test(String(value)) && (typeof value !== 'number' || Number.isSafeInteger(value)), '原生排车recordVersion缺失或精度无效'); return value } +const DISPATCH_PROTOCOL = 'NATIVE_DISPATCH_V1' const query = (path, values) => `${path}?${new URLSearchParams(values)}` const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') @@ -17,13 +22,14 @@ export function overseasCommands(context, checkpoint) { return response.body.data } async function command(name, method, path, body, token = context.token, headers = {}, file = null) { - const binding = fingerprint({ method, path, body: body ?? null, headers, file }) + const request = { method, path, body: body ?? null, headers, file } + const binding = fingerprint(request) const previous = state.commands?.[name] check(!previous || previous.binding === binding, `海外命令 ${name} 与检查点不符`) if (previous?.status === 'DONE') return previous.result check(!previous, `海外命令 ${name} 结果未知;停止重发并保留检查点`) const persist = async value => { const commands = { ...state.commands, [name]: value }; await save({ commands }); state.commands = commands } - await persist({ binding, status: 'INTENT' }) + await persist({ binding, request, status: 'INTENT' }) let response if (file) { const form = new FormData() @@ -31,10 +37,22 @@ export function overseasCommands(context, checkpoint) { response = await context.requestWithBody(method, path, form, token) } else response = await context.request(method, path, body, token, headers) const result = unwrap(response) - await persist({ binding, status: 'DONE', result }) + await persist({ binding, request, status: 'DONE', result }) return result } - return { command, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)), + async function recorded(name, method, path, createBody, token = context.token, headers = {}) { + const previous = state.commands?.[name] + if (previous) { + check(previous.status === 'DONE', `海外命令 ${name} 结果未知;停止重发并保留检查点`) + check(previous.request && previous.binding === fingerprint(previous.request) + && previous.request.method === method && previous.request.path === path + && fingerprint(previous.request.headers) === fingerprint(headers) && previous.request.file === null, + `海外命令 ${name} 缺少原生原请求或检查点不符`) + return command(name, method, path, previous.request.body, token, headers) + } + return command(name, method, path, await createBody(), token, headers) + } + return { command, recorded, read: async (path, token = context.token) => unwrap(await context.request('GET', path, undefined, token)), readCurrentReceivingSession: async token => { const response = await context.request('GET', `${PDA}/receiving-containers/current`, undefined, token) // Only the initial current-session probe treats the service's absent-session business code as empty. @@ -51,6 +69,9 @@ export function validateOverseasInput(input, domestic) { for (const key of ['customerId', 'warehouseId']) check(/^[1-9]\d*$/.test(input[key] || ''), `缺少 ${key}`) for (const key of ['vehicleId', 'driverId']) check(/^[1-9]\d*$/.test(input.resources?.[key] || ''), `缺少既有 ${key}`) check(Boolean(input.resources.carrierName), '缺少既有车队名称') + check(!(input.loadingMeasurementFile != null && input.loadingMeasurements != null), '真实测量文件与内联测量不能同时提供') + if (input.loadingMeasurementFile != null) check(typeof input.loadingMeasurementFile === 'string' && isAbsolute(input.loadingMeasurementFile), '真实测量文件须为预先绑定的明确绝对路径') + if (input.loadingMeasurementFile != null || input.loadingMeasurements != null) check(['test', 'gray'].includes(input.environment?.mode), '真实测量缺少本次TEST或GRAY环境绑定') for (const key of ['containerBookingId', 'sourceInboundId', 'orderId']) check(/^[1-9]\d*$/.test(domestic[key] || ''), `缺少 ${key}`) for (const key of ['containerNo', 'inboundCode', 'orderNo']) check(Boolean(domestic[key]), `缺少 ${key}`) if (domestic.cartonMarks != null) check(Array.isArray(domestic.cartonMarks) @@ -64,7 +85,12 @@ export async function runOverseasStage({ input, domestic, context, checkpoint }) check(context.overseasPdaToken && context.merchantToken, '缺少既有海外 PDA 或本客户会话') const { state, save } = checkpoint check(!Object.values(state.commands || {}).some(row => row.status !== 'DONE'), '海外命令结果未知;停止重发并保留检查点') - const { read, lookup, command, readCurrentReceivingSession } = overseasCommands(context, checkpoint) + check(!state.dispatchProtocol || state.dispatchProtocol === DISPATCH_PROTOCOL, '海外排车检查点协议不符') + const oldDispatchCommands = ['dispatch', 'dispatch-vehicle', 'transfer', 'trip', 'trip-assign', 'trip-bind', 'trip-plan', 'trip-review', 'pickComplete', 'loading', 'loading-review', 'depart'] + check(state.dispatchProtocol === DISPATCH_PROTOCOL || !oldDispatchCommands.some(name => state.commands?.[name]) + && !Object.keys(state.commands || {}).some(name => name.startsWith('pick-')), '旧排车协议检查点不能映射原生主单,请人工核对原业务') + if (!state.dispatchProtocol) { await save({ dispatchProtocol: DISPATCH_PROTOCOL }); state.dispatchProtocol = DISPATCH_PROTOCOL } + const { read, lookup, command, recorded, readCurrentReceivingSession } = overseasCommands(context, checkpoint) const pdaToken = context.overseasPdaToken const remark = `RELEASE-ACCEPTANCE:${input.batch}` const key = name => `${input.batch}-${name}` @@ -78,7 +104,7 @@ export async function runOverseasStage({ input, domestic, context, checkpoint }) const vehicle = await read(`${BASE}/master-data/vehicles/${input.resources.vehicleId}`) const driver = await read(query(`${BASE}/fleet/drivers/${input.resources.driverId}`, { expectedEnd: schedule.expectedDriverEndAt })) check(id(vehicle.id) === input.resources.vehicleId && vehicle.region === 'OVERSEAS' && vehicle.status === 'IN_SERVICE', '既有海外车辆不可用') - check(id(driver.id) === input.resources.driverId && driver.qualification?.canDispatch === true, '既有司机证照/审核不满足本次出车') + check(id(driver.id) === input.resources.driverId && (state.commands?.dispatch || driver.qualification?.canDispatch === true), '既有司机证照/审核不满足本次出车') let sources for (let attempt = 0; attempt < 60; attempt++) { const page = await read(query(`${BASE}/orders`, { containerNo: domestic.containerNo, page: '1', size: '200' })) @@ -160,84 +186,170 @@ export async function runOverseasStage({ input, domestic, context, checkpoint }) }, undefined, { 'Idempotency-Key': key('empty-return') }) check(returned.status === 'EMPTY_RETURNED', '实际还柜未完成') } - let dispatch = state.commands?.dispatch?.result - if (!dispatch) { - const candidates = await read(`${BASE}/dispatches/candidates?sourceStage=RECEIVED`) - const matches = candidates.filter(row => row.sourceStage === 'RECEIVED' && row.inboundCode === domestic.inboundCode - && id(row.customerId) === input.customerId && id(row.overseasWarehouseId) === input.warehouseId) - check(matches.length === 1 && Number(matches[0].packageCount) === input.packageCount, '本票排车候选不唯一或数量不符') - dispatch = await command('dispatch', 'POST', `${BASE}/dispatches/pre-arrange`, { planPalletIds: matches.map(row => id(row.planPalletId)) }) - } + const dispatch = await recorded('dispatch', 'POST', `${BASE}/dispatches`, async () => { + const candidates = await read(query(`${BASE}/dispatches/candidates`, { warehouseId: input.warehouseId, orderNo: domestic.inboundCode })) + check(candidates.complete === true && Array.isArray(candidates.candidates), '本票原生排车候选不完整') + const matches = candidates.candidates.filter(row => row.inboundCode === domestic.inboundCode && id(row.customerId) === input.customerId + && id(row.warehouseId) === input.warehouseId && row.palletNo === pallet.palletNo) + check(matches.length === 1 && candidates.candidates.length === 1 && Number(matches[0].availablePackages) === input.packageCount + && matches[0].selection?.sourceType === 'INBOUND_PLAN' && matches[0].selection.planPalletId + && matches[0].selection.sourceVersion && matches[0].selection.sourceVersion === matches[0].sourceVersion + && matches[0].blockReasons?.length === 0, '本票原生排车候选身份、来源版本或数量不符') + return { idempotencyKey: key('dispatch'), warehouseId: input.warehouseId, + vehicle: { mode: 'MASTER', vehicleId: input.resources.vehicleId }, + driver: { mode: 'MASTER', driverId: input.resources.driverId, evidence: { expectedTaskEndAt: schedule.expectedDriverEndAt } }, + plannedDepartureAt: schedule.plannedDepartureAt, estimatedArrivalAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(), + remark, cargo: matches.map(row => row.selection), batchAllocations: [] } + }) const dispatchNo = dispatch.dispatchNo - check(Boolean(dispatchNo), '排车单号缺失') - const transport = { vehiclePlate: vehicle.plateNumber, driverName: driver.details?.name, - driverPhone: driver.details?.phone, carrierName: input.resources.carrierName, estimatedArrivalAt: schedule.arrival } - await command('dispatch-vehicle', 'PUT', `${BASE}/dispatches/${dispatchNo}`, transport) - const transfer = await command('transfer', 'POST', query('/api/merchant/v1/last-leg/delivery-orders', { dispatchNo }), { - idempotencyKey: key('transfer'), plannedDeliveryAt: schedule.arrival, deliveryMethod: 'TRUCK_PALLET', - waybillNo: key('WAYBILL'), carrierName: transport.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', - vehicleType: 'TRUCK', vehicleSize: '15托', driverName: transport.driverName, driverPhone: transport.driverPhone, - vehiclePlate: transport.vehiclePlate, appointmentAccount: 'SELF', sellerName: null, remark, - fees: [{ feeType: 'DELIVERY_FEE', feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark }], - }, context.merchantToken) - check(transfer.deliveries?.length === 1 && transfer.outboundNo, '本票未形成唯一出库/派送单') - let trip = await command('trip', 'POST', `${BASE}/fleet/trips`, { warehouseId: input.warehouseId, - plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, - stops: [{ name: '验收起运仓' }, { name: '待匹配本票目的地' }], remark, idempotencyKey: key('trip') }) - const tripNo = trip.trip.tripNo, tripPath = `${BASE}/fleet/trips/${tripNo}` - trip = await command('trip-assign', 'POST', `${tripPath}/assignment`, { recordVersion: trip.trip.recordVersion, - vehicleId: input.resources.vehicleId, driverId: input.resources.driverId, reason: remark }) - trip = await command('trip-bind', 'POST', `${tripPath}/dispatches/bind`, { recordVersion: trip.trip.recordVersion, dispatchNo, reason: remark }) - let stops = state.stops - if (!stops) { - const loading = await read(`${tripPath}/loading`) - check(loading.cargo?.length > 0 && loading.cargo.every(row => row.destinationKey), '本票装载缺少真实目的地') - stops = [{ name: '验收起运仓' }, ...new Map(loading.cargo.map(row => [row.destinationKey, { name: row.destinationLabel, destinationKey: row.destinationKey }])).values()] - await save({ stops }); state.stops = stops + check(dispatchNo && dispatch.status === 'ACTIVE' && dispatch.stage === 'BEFORE_LOADING', '本票原生建单回执不符') + const dispatchPath = `${BASE}/dispatches/${encodeURIComponent(dispatchNo)}`, pdaDispatch = `${PDA}/dispatches/${encodeURIComponent(dispatchNo)}` + const ownMain = async () => { + const detail = await read(dispatchPath), items = await read(`${dispatchPath}/items`) + check(detail.dispatchNo === dispatchNo && detail.status === 'ACTIVE' && detail.orderDetails?.length > 0 + && detail.orderDetails.every(row => row.inboundCode === domestic.inboundCode && id(row.customerId) === input.customerId) + && id(detail.vehicleInfo?.vehicle?.vehicleId) === input.resources.vehicleId && id(detail.vehicleInfo?.driver?.driverId) === input.resources.driverId + && items.dispatchNo === dispatchNo && items.items?.length > 0 && items.items.every(row => row.inboundCode === domestic.inboundCode + && id(row.customerId) === input.customerId && row.palletNo === pallet.palletNo) + && Number(detail.counts?.packageCount) === input.packageCount, '原生主单含其他票、客户、资源或箱数不符') + version(detail.recordVersion); return { detail, items: items.items } } - trip = await command('trip-plan', 'PUT', `${tripPath}/plan`, { recordVersion: trip.trip.recordVersion, - plannedDepartureAt: schedule.plannedDepartureAt, expectedDriverEndAt: schedule.expectedDriverEndAt, stops, reason: remark }) - trip = await command('trip-review', 'POST', `${tripPath}/dispatches/${dispatchNo}/review`, { recordVersion: trip.trip.recordVersion, decision: 'APPROVED' }) - const pdaDispatch = `${PDA}/dispatches/${dispatchNo}` - if (!state.commands?.pickComplete) { - const picking = await read(pdaDispatch, pdaToken) - const exactDispatch = await read(`${BASE}/dispatches/${dispatchNo}`) - check(exactDispatch.items?.length > 0 && exactDispatch.items.every(row => row.inboundCode === domestic.inboundCode) - && picking.items?.length === exactDispatch.items.length - && picking.items.every(row => exactDispatch.items.some(item => id(item.id) === id(row.dispatchItemId))), 'PDA拣货包含其他票') - for (const item of picking.items) { - let remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) - for (const position of item.positions || []) { - if (remaining <= 0) break - const packages = Math.min(remaining, Number(position.availablePackages)) - if (!(packages > 0)) continue - await command(`pick-${item.dispatchItemId}-${position.id}`, 'POST', `${pdaDispatch}/items/${item.dispatchItemId}/pick`, { - positionId: id(position.id), packages, productQuantity: packages, idempotencyKey: key(`pick-${position.id}`), remark, - }, pdaToken) - remaining -= packages - } - check(remaining === 0, '本票库存不足以完成拣货') + await ownMain() + for (const action of ['submit-review', 'dispatch-review', 'loading-review']) await recorded(action, 'POST', `${dispatchPath}/${action}`, async () => { + const { detail } = await ownMain() + check(detail.stage === 'BEFORE_LOADING', '本票原生审核阶段不符') + return { recordVersion: detail.recordVersion, idempotencyKey: key(action), + ...(action === 'submit-review' ? { remark } : { decision: 'APPROVED', reason: remark }) } + }) + const candidates = []; let total + for (let page = 1; ; page++) { + const rows = await read(query(`${BASE}/fleet/trips`, { warehouseId: input.warehouseId, keyword: vehicle.plateNumber, page: String(page), size: '200' })) + check(Array.isArray(rows.records) && Number.isSafeInteger(Number(rows.total)) && Number(rows.total) >= 0 + && Number(rows.page) === page && Number(rows.size) === 200, '自动车次分页缺失或不完整') + total ??= Number(rows.total) + check(Number(rows.total) === total && (rows.records.length > 0 || candidates.length === total), '自动车次分页变化或缺页') + for (const row of rows.records) { check(row.tripNo && !candidates.some(saved => saved.tripNo === row.tripNo), '自动车次业务号缺失或重复'); candidates.push(row) } + if (candidates.length >= total) break + } + check(candidates.length === total, '自动车次分页总数不一致') + const bound = [] + for (const row of candidates.filter(row => id(row.vehicleId) === input.resources.vehicleId && id(row.driverId) === input.resources.driverId + && id(row.warehouseId) === input.warehouseId && row.vehiclePlate === vehicle.plateNumber && row.tripPurpose === 'DELIVERY')) { + const detail = await read(`${BASE}/fleet/trips/${encodeURIComponent(row.tripNo)}`) + if (detail.dispatches?.some(binding => binding.dispatchNo === dispatchNo)) { + check(detail.trip?.tripNo === row.tripNo && id(detail.trip.vehicleId) === input.resources.vehicleId + && id(detail.trip.driverId) === input.resources.driverId && detail.dispatches.length === 1, '自动车次绑定混入其他排车或资源') + bound.push(detail) } - await command('pickComplete', 'POST', `${pdaDispatch}/complete`, { idempotencyKey: key('pick-complete') }, pdaToken) } - let loadingResult = state.commands?.loading?.result - if (!loadingResult) { - const loading = await read(`${tripPath}/loading`) - check(!loading.blockReason, '整车装载未就绪') - loadingResult = await command('loading', 'POST', `${tripPath}/loading`, { recordVersion: loading.recordVersion, - manifestHash: loading.manifestHash, pallets: controlledPalletMeasurements(loading.cargo, stops), reason: `${remark} 合成验收测量` }) + check(bound.length === 1, '本票主单自动Fleet资源记录不唯一') + const trip = bound[0], tripNo = trip.trip.tripNo + if (!state.pickingVerified) { + const { detail, items } = await ownMain() + const picking = await read(pdaDispatch, pdaToken) + check(detail.stage === 'LOADING' && picking.dispatchNo === dispatchNo && picking.status === 'ACTIVE' && picking.stage === 'LOADING' + && id(picking.warehouseId) === input.warehouseId && picking.items?.length === items.length + && picking.items.every(row => items.some(item => id(item.dispatchItemId) === id(row.dispatchItemId) + && id(row.customerId) === input.customerId && row.palletNo === pallet.palletNo)), 'PDA拣货包含其他票或尚未通过两级审核') + if (!state.pickSources) { + const pickSources = picking.items.flatMap(item => (item.positions || []).map(position => ({ dispatchItemId: id(item.dispatchItemId), positionId: id(position.positionId) }))) + check(pickSources.length > 0 && pickSources.every(row => /^[1-9]\d*$/.test(row.positionId)), '本票真实库存位置缺失') + await save({ pickSources }); state.pickSources = pickSources + } + for (const source of state.pickSources) { + const name = `pick-${source.dispatchItemId}-${source.positionId}` + if (state.commands?.[name]) { + await recorded(name, 'POST', `${pdaDispatch}/items/${source.dispatchItemId}/pick`, () => { throw new Error('成功扫码不得重新构造原请求') }, pdaToken) + continue + } + const current = await read(pdaDispatch, pdaToken), item = current.items?.find(row => id(row.dispatchItemId) === source.dispatchItemId) + check(current.status === 'ACTIVE' && current.stage === 'LOADING' && item && id(item.customerId) === input.customerId, '扫码前本票任务身份变化') + const remaining = Number(item.scheduledPackages) - Number(item.pickedPackages) + if (remaining === 0) continue + const position = item.positions?.find(row => id(row.positionId) === source.positionId) + const packages = Math.min(remaining, Number(position?.availablePackages)) + check(Number.isSafeInteger(packages) && packages > 0, '本票库存不足以完成拣货') + await recorded(name, 'POST', `${pdaDispatch}/items/${source.dispatchItemId}/pick`, async () => ({ recordVersion: version(current.recordVersion), + positionId: source.positionId, cartonMark: null, packages, productQuantity: packages, idempotencyKey: key(name), remark }), pdaToken) + } + const reconciled = await read(pdaDispatch, pdaToken) + check(reconciled.status === 'ACTIVE' && reconciled.stage === 'LOADING' && reconciled.pickingReconciled === true + && Number(reconciled.counts?.packageCount) === input.packageCount && Number(reconciled.counts?.pickedPackageCount) === input.packageCount + && reconciled.items.every(item => Number(item.scheduledPackages) === Number(item.pickedPackages)), '本票真实扫码未核平或提前越过装车中') + await save({ pickingVerified: true }); state.pickingVerified = true } - const reviewed = await command('loading-review', 'POST', `${tripPath}/loading-review`, { recordVersion: loadingResult.recordVersion, decision: 'APPROVED' }) - if (!state.commands?.depart) { - const ready = await read(pdaDispatch, pdaToken) - check(ready.status === 'COMPLETED' && ready.canDepart === true, 'PDA发车门禁未通过') - } - await command('depart', 'POST', `${pdaDispatch}/depart`, { recordVersion: reviewed.trip.recordVersion, idempotencyKey: key('depart'), remark }, pdaToken) - const outbound = await read(`${BASE}/outbound-orders/${transfer.outboundNo}`) - check(outbound.outbound?.status === 'OUTBOUNDED' && outbound.pallets?.length > 0 - && outbound.pallets.every(row => row.inboundCode === domestic.inboundCode), '本票未真实完成出库') + const loaded = await recorded('loading-complete', 'POST', `${pdaDispatch}/loading-complete`, async () => { + const preview = await read(`${pdaDispatch}/loading`, pdaToken) + check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'LOADING' + && preview.pickingReconciled === true && preview.loadingComplete?.allowed === true && preview.manifestHash + && preview.pallets?.length === 1 && id(preview.pallets[0].warehousePalletId) === id(pallet.warehousePalletId), '本票实际装载预览未就绪或包含其他托盘') + let actual = input.loadingMeasurements, sourceSha256 = null + if (input.loadingMeasurementFile != null) { + let bytes + try { bytes = await readFile(input.loadingMeasurementFile) } + catch { throw new Error('本票已就绪等待真实测量:请在预先绑定的loadingMeasurementFile绝对路径提供本轮JSON回执,保留同一输入与检查点续跑') } + sourceSha256 = createHash('sha256').update(bytes).digest('hex') + try { actual = JSON.parse(bytes.toString('utf8')) } + catch { throw new Error('本轮真实测量文件不是有效JSON,停止装车写入') } + } else if (actual != null) sourceSha256 = createHash('sha256').update(JSON.stringify(actual)).digest('hex') + if (actual != null) { + check(actual.environment === input.environment.mode.toUpperCase() && actual.batch === input.batch && actual.dispatchNo === dispatchNo + && Array.isArray(actual.pallets) && actual.pallets.length === preview.pallets.length, '本轮实际测量回执环境、批次、排车或托盘绑定不符') + for (const measured of actual.pallets) { + check(preview.pallets.some(row => id(row.warehousePalletId) === id(measured.warehousePalletId)), '本轮实际测量包含其他托盘') + for (const field of ['palletSpaces', 'weightKg', 'volumeCbm']) check(measured[field] != null && measured.evidence?.some(evidence => evidence.actual === true + && evidence.field === field && evidence.sourceType && evidence.sourceBusinessNo && Number.isFinite(Date.parse(evidence.observedAt)) + && Number(evidence.value) === Number(measured[field])), `本轮实际测量${field}缺少逐字段actual来源证据`) + } + } + const measurements = controlledPalletMeasurements(preview.pallets, trip.trip.stops, actual?.pallets) + const loadingMeasurementEvidence = { environment: input.environment?.mode?.toUpperCase() ?? null, batch: input.batch, dispatchNo, + source: input.loadingMeasurementFile != null ? 'EXPLICIT_FILE' : actual != null ? 'INLINE' : 'NATIVE_PREVIEW', + file: input.loadingMeasurementFile ?? null, sourceSha256, receipt: actual ?? null, manifestHash: preview.manifestHash, measurements } + if (state.loadingMeasurementEvidence) check(fingerprint(state.loadingMeasurementEvidence) === fingerprint(loadingMeasurementEvidence), + '已冻结的本轮真实测量发生变化,禁止替换原证据或重造装车命令') + else { await save({ loadingMeasurementEvidence }); state.loadingMeasurementEvidence = loadingMeasurementEvidence } + return { recordVersion: version(preview.recordVersion), idempotencyKey: key('loading-complete'), manifestHash: preview.manifestHash, + measurements, remark: `${remark}仅采用真实来源测量,不从计费或计划常量推算` } + }, pdaToken) + check(loaded.dispatchNo === dispatchNo && loaded.status === 'ACTIVE' && loaded.stage === 'AFTER_LOADING' + && loaded.result?.confirmedLoad?.pallets?.length === 1, '本票显式装车回执不符') + const transfer = await recorded('transfer', 'POST', `${dispatchPath}/transfer-delivery`, async () => { + const preview = await read(`${dispatchPath}/delivery-arrangement`) + check(preview.dispatchNo === dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' + && preview.arrange?.allowed === true && preview.cargoHash && preview.loadingCompletionHash && preview.groups?.length === 1 + && id(preview.groups[0].customerId) === input.customerId && preview.groups[0].cargo?.length > 0 + && preview.groups[0].cargo.every(row => id(row.customerId) === input.customerId && row.inboundCode === domestic.inboundCode) + && preview.groups[0].blockReasons?.length === 0 && preview.groups[0].externalQuotes?.length === 0, '本票派送安排不完整、混入其他票或存在外接约定') + return { recordVersion: version(preview.recordVersion), idempotencyKey: key('transfer'), cargoHash: preview.cargoHash, + loadingCompletionHash: preview.loadingCompletionHash, groups: preview.groups.map(group => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, + plannedDeliveryAt: new Date(Date.parse(schedule.plannedDepartureAt) + 86400000).toISOString(), deliveryMethod: 'TRUCK_PALLET', waybillNo: key('WAYBILL'), + carrierName: input.resources.carrierName, pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本票真实实载', + appointmentAccount: 'SELF', sellerName: null, remark, externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, + feeCoverage: [], fees: (group.requiredServices || []).map(feeType => ({ feeType, feeName: '派送费', direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark })) })) } + }) + check(transfer.dispatchNo === dispatchNo && transfer.status === 'ACTIVE' && transfer.stage === 'AFTER_LOADING' + && transfer.result?.deliveries?.length === 1 && transfer.result.outbounds?.length === 1 + && id(transfer.result.deliveries[0].customerId) === input.customerId && id(transfer.result.outbounds[0].customerId) === input.customerId + && transfer.result.deliveries[0].outboundNo === transfer.result.outbounds[0].outboundNo + && Number(transfer.result.deliveries[0].packageCount) === input.packageCount, '本票未形成原生唯一客户出库和派送') + const outboundNo = transfer.result.outbounds[0].outboundNo, deliveryNo = transfer.result.deliveries[0].deliveryNo + const departed = await recorded('depart', 'POST', `${pdaDispatch}/depart`, async () => { + const ready = await read(pdaDispatch, pdaToken), { detail } = await ownMain() + check(ready.dispatchNo === dispatchNo && ready.status === 'ACTIVE' && ready.stage === 'AFTER_LOADING' + && ready.pickingReconciled === true && ready.actions?.DEPART?.allowed === true && detail.actions?.DEPART?.allowed === true + && id(ready.recordVersion) === id(detail.recordVersion), 'PDA及平台当前主版本发车门禁未通过') + return { recordVersion: version(ready.recordVersion), idempotencyKey: key('depart'), remark } + }, pdaToken) + check(departed.dispatchNo === dispatchNo && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' + && Number(departed.result?.shippedPackages) === input.packageCount && departed.result.deliveries?.length === 1 + && departed.result.deliveries[0].deliveryNo === deliveryNo, '本票原生发车结果不符') + const outbound = await read(query(`${BASE}/outbound-orders/${encodeURIComponent(outboundNo)}`, { customerId: input.customerId })) + check(id(outbound.outbound?.customerId) === input.customerId && outbound.outbound.dispatchNo === dispatchNo + && outbound.outbound.status === 'OUTBOUNDED' && outbound.pallets?.length > 0 + && outbound.pallets.every(row => row.inboundCode === domestic.inboundCode) && Number(outbound.outbound.packageCount) === input.packageCount, '本票未真实完成出库') const result = { ...domestic, batch: input.batch, packageCount: input.packageCount, warehouseId: input.warehouseId, - tailOrderId, dispatchNo, outboundNo: transfer.outboundNo, deliveryNo: transfer.deliveries[0].deliveryNo, + tailOrderId, dispatchNo, outboundNo, deliveryNo, tripNo, status: 'OVERSEAS_OUTBOUNDED', fullFlowVerified: false, photos: 'SYNTHETIC_ACCEPTANCE_ONLY' } await save({ result }); return result } diff --git a/scripts/prod-release-overseas-stage.test.mjs b/scripts/prod-release-overseas-stage.test.mjs index 80cbf4dd6..88e4d515c 100644 --- a/scripts/prod-release-overseas-stage.test.mjs +++ b/scripts/prod-release-overseas-stage.test.mjs @@ -1,8 +1,12 @@ import test from 'node:test' import assert from 'node:assert/strict' +import { mkdtemp, writeFile, rm, readFile, rmdir } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' import { overseasCommands, runOverseasStage, validateOverseasInput, finishSingleOrderTrip } from './prod-release-overseas-stage.mjs' -const input = { batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1, +const input = { environment: { mode: 'test', apiBase: 'https://test.example.invalid/api' }, batch: 'release-test-001', customerId: '1', warehouseId: '2', packageCount: 1, resources: { vehicleId: '3', driverId: '4', carrierName: '验收既有车队' } } const domestic = { containerBookingId: '5', sourceInboundId: '6', orderId: '7', containerNo: 'TEST0000001', inboundCode: 'IN-TEST', orderNo: 'ORDER-TEST', cartonMarks: ['CARTON-TEST'] } @@ -57,43 +61,81 @@ test('current receiving probe preserves auth, transport and other business error } }) -function fixture({ foreignSource = false, destinationType = 'OVERSEAS' } = {}) { +function fixture({ foreignSource = false, destinationType = 'OVERSEAS', missingActual = false, foreignCargo = false, + foreignTrip = false, candidateComplete = true, loseCommand = null } = {}) { const checkpoint = journal(), calls = [] const session = { containerNo: domestic.containerNo, overseasWarehouseId: '2', totalPackages: 1, pendingPackages: 0, inbounds: [{ inboundCode: domestic.inboundCode, destinationType }] } - const trip = { trip: { tripNo: 'TRIP-TEST', recordVersion: 1, stops: [] } } - const cargo = [{ warehousePalletId: '9', palletNo: 'PLT-TEST', destinationKey: 'DEST', destinationLabel: 'Destination' }] + const dispatchNo = 'DISPATCH-TEST', dispatchPath = `/api/platform/v1/last-leg/dispatches/${dispatchNo}`, pdaPath = `/api/pda/v1/last-leg/dispatches/${dispatchNo}` + let stage = 'BEFORE_LOADING', recordVersion = 0, picked = 0, created = false, receivingRead = 0 + const stops = [{ name: 'Origin' }, { name: 'Destination', destinationKey: 'FBA:DEST' }] + const counts = () => ({ packageCount: 1, pickedPackageCount: picked, shippedPackageCount: stage === 'DELIVERING' ? 1 : 0 }) + const nativeResult = result => ({ dispatchNo, status: 'ACTIVE', stage, recordVersion, counts: counts(), result }) + const ownItem = () => ({ dispatchItemId: '12', customerId: foreignCargo ? '99' : '1', inboundCode: domestic.inboundCode, + warehousePalletId: '9', palletNo: 'PLT-TEST', scheduledPackages: 1, pickedPackages: picked, + positions: [{ positionId: '13', availablePackages: 1 - picked }] }) + const delivery = { customerId: '1', outboundNo: 'OUT-TEST', deliveryNo: 'DELIVERY-TEST', packageCount: 1, destination: { key: 'FBA:DEST' } } + const group = { groupKey: 'GROUP-1', groupCargoHash: 'group-hash', customerId: '1', destination: { key: 'FBA:DEST' }, + cargo: [{ customerId: '1', inboundCode: domestic.inboundCode }], requiredServices: ['DELIVERY_FEE'], blockReasons: [], + externalQuotes: [], externalFreightHash: 'freight-hash', feeCoverageCargoHash: 'coverage-hash' } + const trip = () => ({ trip: { tripNo: 'TRIP-TEST', vehicleId: '3', driverId: '4', warehouseId: '2', vehiclePlate: 'TEST', + tripPurpose: 'DELIVERY', recordVersion: 1, stops }, dispatches: [{ dispatchNo: foreignTrip ? 'OTHER-DISPATCH' : dispatchNo }] }) + const measured = () => ({ warehousePalletId: '9', palletNo: 'PLT-TEST', scheduledPackages: 1, pickedPackages: picked, + palletSpaces: missingActual ? null : 2, weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1, + evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].filter(field => !missingActual || field !== 'palletSpaces').map(field => ({ field, actual: true, + sourceType: 'WAREHOUSE_MEASUREMENT', sourceBusinessNo: 'RECEIPT-TEST', observedAt: '2026-10-19T10:00:00Z', + value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }) const context = { token: 'admin', overseasPdaToken: 'pda', merchantToken: 'merchant', wait: async () => {}, requestWithBody: async (method, path, form, token) => { calls.push({ method, path, token }); assert.equal(token, 'pda'); assert.ok(form instanceof FormData); return ok({ fileId: '10' }) }, request: async (method, path, body, token) => { - calls.push({ method, path, body, token }) + calls.push({ method, path, body: body == null ? body : structuredClone(body), token }) if (path.includes('/master-data/vehicles/')) return ok({ id: '3', region: 'OVERSEAS', status: 'IN_SERVICE', plateNumber: 'TEST' }) - if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: true }, details: { name: 'Driver', phone: '10000000000' } }) + if (path.includes('/fleet/drivers/')) return ok({ id: '4', qualification: { canDispatch: !created }, details: { name: 'Driver', phone: '10000000000' } }) if (path.includes('/orders?containerNo=')) return ok({ records: [{ sourceContainerBookingId: '5', customerId: foreignSource ? '99' : '1', sourceInboundId: '6', inboundCode: domestic.inboundCode, overseasWarehouseId: '2', id: '8' }] }) if (path.endsWith('/container-arrival-times/query')) return ok([{ sourceContainerBookingId: '5', version: 0 }]) if (path.includes('/container-preview?')) return ok({ ready: true, totalOrderCount: 1, pendingOrderCount: 1, snapshotVersion: 1, snapshotHash: 'hash' }) if (path.endsWith('/convert-container')) return ok([{ lines: [{ inboundCode: domestic.inboundCode }], pallets: [{ palletNo: 'PLT-TEST', warehousePalletId: '9' }] }]) - if (path.endsWith('/current') && method === 'GET') return calls.filter(c => c.path.endsWith('/current')).length === 1 - ? { http: 200, body: { code: 'A0404' } } : ok(session) + if (path.endsWith('/current') && method === 'GET') return ++receivingRead === 1 ? { http: 200, body: { code: 'A0404' } } : ok(session) if (path.includes('/receiving-containers/TEST0000001?')) return ok(session) if (path.endsWith('/pallets/resolve')) return ok({ compatible: true }) if (path.endsWith('/cartons/resolve')) return ok({ inboundCode: domestic.inboundCode }) if (path.endsWith('/terminal-drayages/5')) return ok({ containerBookingId: '5', status: 'UNLOADED', version: 1, unloadingCompletedAt: '2026-10-12T12:00:00', allowedActions: ['RECORD_EMPTY_RETURN'] }) if (path.endsWith('/empty-return')) return ok({ status: 'EMPTY_RETURNED' }) - if (path.endsWith('/candidates?sourceStage=RECEIVED')) return ok([{ sourceStage: 'RECEIVED', inboundCode: domestic.inboundCode, - customerId: '1', overseasWarehouseId: '2', packageCount: 1, planPalletId: '11' }]) - if (path.endsWith('/pre-arrange')) return ok({ dispatchNo: 'DISPATCH-TEST' }) - if (path.includes('/merchant/v1/last-leg/delivery-orders?')) return ok({ outboundNo: 'OUT-TEST', deliveries: [{ deliveryNo: 'DELIVERY-TEST' }] }) - if (path.endsWith('/loading') && method === 'GET') return ok({ cargo, recordVersion: 1, manifestHash: 'manifest' }) - if (path.endsWith('/loading') && method === 'POST') return ok({ recordVersion: 2 }) - if (path.includes('/fleet/trips')) return ok(trip) - if (path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST') return ok({ status: 'COMPLETED', canDepart: true, - items: [{ dispatchItemId: '12', scheduledPackages: 1, pickedPackages: 0, positions: [{ id: '13', availablePackages: 1 }] }] }) - if (path === '/api/platform/v1/last-leg/dispatches/DISPATCH-TEST' && method === 'GET') return ok({ items: [{ id: '12', inboundCode: domestic.inboundCode }] }) - if (path.includes('/outbound-orders/')) return ok({ outbound: { status: 'OUTBOUNDED' }, pallets: [{ inboundCode: domestic.inboundCode }] }) + if (path.includes('/dispatches/candidates?')) return ok({ complete: candidateComplete, candidates: [{ inboundCode: domestic.inboundCode, + customerId: '1', warehouseId: '2', palletNo: 'PLT-TEST', availablePackages: 1, sourceVersion: 'real-source', blockReasons: [], + selection: { sourceType: 'INBOUND_PLAN', planPalletId: '11', sourceVersion: 'real-source' } }] }) + if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { created = true; return ok(nativeResult(null)) } + if (path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) return ok({ records: [trip().trip], total: 1, page: 1, size: 200 }) + if (path.endsWith('/fleet/trips/TRIP-TEST')) return ok(trip()) + if (path === dispatchPath && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1, + orderDetails: [{ inboundCode: domestic.inboundCode, customerId: '1' }], + vehicleInfo: { vehicle: { vehicleId: '3' }, driver: { driverId: '4' } }, actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } }) + if (path === `${dispatchPath}/items`) return ok({ dispatchNo, recordVersion, items: [ownItem()] }) + if (path === pdaPath && method === 'GET') return ok({ ...nativeResult(null), warehouseId: '2', pickingReconciled: picked === 1, + items: [ownItem()], actions: { DEPART: { allowed: stage === 'AFTER_LOADING' } } }) + if (path === `${pdaPath}/loading` && method === 'GET') return ok({ ...nativeResult(null), pickingReconciled: picked === 1, + loadingComplete: { allowed: picked === 1 }, manifestHash: 'real-manifest', pallets: [measured()] }) + if (path === `${dispatchPath}/delivery-arrangement`) return ok({ ...nativeResult(null), arrange: { allowed: true }, + cargoHash: 'real-cargo', loadingCompletionHash: 'real-load', groups: [group] }) + if (method === 'POST' && (path.startsWith(`${dispatchPath}/`) || path.startsWith(`${pdaPath}/`))) { + assert.equal(body.recordVersion, recordVersion, `must use current main version: ${path}`) + recordVersion++ + if (path.endsWith('/loading-review')) stage = 'LOADING' + if (path.endsWith('/pick')) { assert.equal(stage, 'LOADING'); picked = 1 } + if (path.endsWith('/loading-complete')) { stage = 'AFTER_LOADING'; return ok(nativeResult({ confirmedLoad: { pallets: body.measurements } })) } + if (path.endsWith('/transfer-delivery')) { + assert.equal(token, 'admin'); assert.equal(stage, 'AFTER_LOADING'); assert.equal(body.groups.length, 1) + assert.equal(body.groups[0].fees[0].currency, 'USD'); assert.equal(body.groups[0].fees[0].amount, 10) + return ok(nativeResult({ deliveries: [delivery], outbounds: [{ customerId: '1', outboundNo: 'OUT-TEST' }] })) + } + if (path.endsWith('/depart')) { stage = 'DELIVERING'; return ok(nativeResult({ shippedPackages: 1, deliveries: [delivery] })) } + if (path.endsWith(loseCommand || 'NOT_A_COMMAND')) throw Error('simulated response loss') + return ok(nativeResult(null)) + } + if (path.includes('/outbound-orders/')) return ok({ outbound: { customerId: '1', dispatchNo, status: 'OUTBOUNDED', packageCount: 1 }, pallets: [{ inboundCode: domestic.inboundCode }] }) if (method === 'GET') assert.fail(`unexpected GET ${path}`) return ok({}) } } @@ -156,3 +198,169 @@ test('signed single ticket closes its trip and releases only its existing resour assert.equal(writes.length, 3) assert.deepEqual(writes.map(row => row.body.recordVersion), [1, 2, 3]) }) + + +test('native successful continuation restores original dynamic requests without sending any business write again', async () => { + const f = fixture() + const result = await runOverseasStage(f) + const writes = f.calls.filter(row => row.method !== 'GET').length + const firstSubmit = structuredClone(f.checkpoint.state.commands['submit-review'].request) + assert.equal((await runOverseasStage(f)).dispatchNo, result.dispatchNo) + assert.equal(f.calls.filter(row => row.method !== 'GET' && !row.path.endsWith('/container-arrival-times/query')).length, writes - 1) + assert.deepEqual(f.checkpoint.state.commands['submit-review'].request, firstSubmit) + assert.equal(firstSubmit.body.recordVersion, 0) + assert.equal(f.calls.filter(row => row.path.endsWith('/loading-complete')).length, 1) +}) + +test('completed recorded commands retain original payload instead of reading a new current version', async () => { + const checkpoint = journal(); let calls = 0 + const helper = overseasCommands({ request: async (_method, _path, body) => { calls++; return ok({ recordVersion: body.recordVersion + 1 }) } }, checkpoint) + await helper.recorded('review', 'POST', '/native/review', async () => ({ recordVersion: 0, idempotencyKey: 'original' })) + const saved = checkpoint.state.commands.review + assert.equal(saved.request.body.recordVersion, 0) + assert.deepEqual(await helper.recorded('review', 'POST', '/native/review', () => assert.fail('must not rebuild with current version')), { recordVersion: 1 }) + assert.equal(calls, 1) + saved.request.body.recordVersion = 2 + await assert.rejects(helper.recorded('review', 'POST', '/native/review', () => assert.fail('must stop')), /检查点不符/) +}) + +test('legacy flat dispatch checkpoint is explicitly rejected before network without converting old replies', async () => { + for (const name of ['dispatch', 'trip-bind', 'pickComplete', 'loading-review', 'depart']) { + const f = fixture(); f.checkpoint.state.commands = { [name]: { status: 'DONE', result: { dispatchNo: 'OLD' } } } + await assert.rejects(runOverseasStage(f), /旧排车协议/) + assert.equal(f.calls.length, 0) + } +}) + +test('real missing pallet spaces blocks loading before any arrangement or departure rather than fabricating one', async () => { + const f = fixture({ missingActual: true }) + await assert.rejects(runOverseasStage(f), /palletSpaces/) + assert.ok(f.calls.some(row => row.path.endsWith('/pick'))) + assert.equal(f.calls.filter(row => /loading-complete|transfer-delivery|\/depart$/.test(row.path)).length, 0) + assert.equal(f.checkpoint.state.commands['loading-complete'], undefined) +}) + +test('incomplete candidates, foreign cargo and wrong auto-trip binding cannot enter actual picking', async () => { + for (const options of [{ candidateComplete: false }, { foreignCargo: true }, { foreignTrip: true }]) { + const f = fixture(options) + await assert.rejects(runOverseasStage(f), /候选不完整|主单含其他票|自动Fleet资源记录不唯一/) + assert.equal(f.calls.filter(row => row.path.endsWith('/pick')).length, 0) + assert.equal(f.calls.filter(row => row.method === 'POST' && /\/fleet\/trips$|\/dispatches\/bind$/.test(row.path)).length, 0) + } +}) + +test('actual native loading keeps measured 2 spaces, 17.75kg and 0.072m3 and distinct explicit stages', async () => { + const f = fixture(); await runOverseasStage(f) + const load = f.calls.find(row => row.path.endsWith('/loading-complete')) + assert.deepEqual(load.body.measurements, [{ warehousePalletId: '9', palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1 }]) + const create = f.calls.find(row => row.method === 'POST' && row.path === '/api/platform/v1/last-leg/dispatches') + assert.deepEqual(create.body.vehicle, { mode: 'MASTER', vehicleId: '3' }) + assert.equal(create.body.driver.mode, 'MASTER'); assert.equal(create.body.driver.driverId, '4') + assert.equal(create.body.driver.evidence.expectedTaskEndAt, f.checkpoint.state.schedule.expectedDriverEndAt) + assert.equal(Object.hasOwn(create.body, 'customerId'), false) + const pick = f.calls.findIndex(row => row.path.endsWith('/pick')), loading = f.calls.indexOf(load), arrangement = f.calls.findIndex(row => row.path.endsWith('/transfer-delivery')), depart = f.calls.findIndex(row => row.path.endsWith('/depart')) + assert.ok(pick < loading && loading < arrangement && arrangement < depart) + assert.equal(f.calls.filter(row => row.path === '/api/pda/v1/last-leg/dispatches/DISPATCH-TEST/complete').length, 0) + assert.equal(f.calls.filter(row => row.path.includes('/merchant/v1/last-leg/delivery-orders')).length, 0) +}) + + +test('lost native review result preserves exact INTENT request and prevents all continuation HTTP', async () => { + const f = fixture({ loseCommand: '/dispatch-review' }) + await assert.rejects(runOverseasStage(f), /response loss/) + const intent = f.checkpoint.state.commands['dispatch-review'] + assert.equal(intent.status, 'INTENT') + assert.equal(intent.request.body.recordVersion, 1) + assert.equal(intent.request.body.idempotencyKey, `${input.batch}-dispatch-review`) + const calls = f.calls.length + await assert.rejects(runOverseasStage(f), /结果未知/) + assert.equal(f.calls.length, calls) +}) + +test('native successful checkpoint without its original request cannot rebuild a versioned command', async () => { + const checkpoint = journal(); checkpoint.state.commands = { review: { status: 'DONE', binding: 'old-hash', result: { recordVersion: 9 } } } + const helper = overseasCommands({ request: () => assert.fail('no HTTP') }, checkpoint) + await assert.rejects(helper.recorded('review', 'POST', '/native/review', () => assert.fail('no rebuild')), /原请求或检查点不符/) +}) + + +function measurementReceipt(patch = {}) { + return { environment: 'TEST', batch: input.batch, dispatchNo: 'DISPATCH-TEST', pallets: [{ warehousePalletId: '9', palletSpaces: 2, + weightKg: 17.75, volumeCbm: '0.072', deliveryIndex: 1, + evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map(field => ({ field, actual: true, sourceType: 'WAREHOUSE_MEASUREMENT', + sourceBusinessNo: 'MEASURE-SAME-RUN', observedAt: '2026-10-19T10:00:00Z', value: { palletSpaces: 2, weightKg: 17.75, volumeCbm: '0.072' }[field] })) }], ...patch } +} + +test('explicit fixed measurement file can be supplied after picking and continued without changing the original input', async () => { + const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-')) + try { + const file = join(directory, 'actual.json'), f = fixture({ missingActual: true }) + f.input = { ...f.input, loadingMeasurementFile: file } + await assert.rejects(runOverseasStage(f), /等待真实测量/) + assert.equal(f.checkpoint.state.pickingVerified, true) + assert.equal(f.checkpoint.state.commands['loading-complete'], undefined) + const picks = f.calls.filter(row => row.path.endsWith('/pick')).length + await writeFile(file, JSON.stringify(measurementReceipt()), 'utf8') + assert.equal((await runOverseasStage(f)).status, 'OVERSEAS_OUTBOUNDED') + assert.equal(f.calls.filter(row => row.path.endsWith('/pick')).length, picks) + const proof = f.checkpoint.state.loadingMeasurementEvidence + assert.equal(proof.source, 'EXPLICIT_FILE'); assert.equal(proof.file, file) + assert.equal(proof.sourceSha256, createHash('sha256').update(await readFile(file)).digest('hex')) + assert.deepEqual(proof.receipt, measurementReceipt()) + assert.equal(proof.measurements[0].palletSpaces, 2) + const original = structuredClone(f.checkpoint.state.commands['loading-complete'].request), writes = f.calls.filter(row => row.method !== 'GET').length + await writeFile(file, JSON.stringify(measurementReceipt({ batch: 'OTHER-BATCH', pallets: [] })), 'utf8') + await runOverseasStage(f) + assert.deepEqual(f.checkpoint.state.commands['loading-complete'].request, original) + assert.deepEqual(f.checkpoint.state.loadingMeasurementEvidence, proof) + assert.equal(f.calls.filter(row => row.method !== 'GET').length, writes + 1) // only the existing read-only arrival lookup + } finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) } +}) + +test('actual file environment, batch, dispatch, pallet and field evidence are checked before any loading command', async () => { + const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-scope-')) + try { + const file = join(directory, 'actual.json') + for (const alter of [receipt => { receipt.environment = 'GRAY' }, receipt => { receipt.batch = 'OTHER-BATCH' }, + receipt => { receipt.dispatchNo = 'OTHER-DISPATCH' }, receipt => { receipt.pallets[0].warehousePalletId = '999' }, + receipt => { receipt.pallets[0].evidence[0].actual = false }]) { + const receipt = measurementReceipt(); alter(receipt) + await writeFile(file, JSON.stringify(receipt), 'utf8') + const f = fixture({ missingActual: true }); f.input = { ...f.input, loadingMeasurementFile: file } + await assert.rejects(runOverseasStage(f), /绑定不符|其他托盘|actual来源证据/) + assert.equal(f.calls.filter(row => /loading-complete|transfer-delivery|\/depart$/.test(row.path)).length, 0) + assert.equal(f.checkpoint.state.commands['loading-complete'], undefined) + } + } finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) } +}) + +test('measurement input rejects ambiguous or relative paths before HTTP', () => { + assert.throws(() => validateOverseasInput({ ...input, loadingMeasurementFile: 'relative.json' }, domestic), /绝对路径/) + assert.throws(() => validateOverseasInput({ ...input, loadingMeasurementFile: join(tmpdir(), 'actual.json'), loadingMeasurements: measurementReceipt() }, domestic), /不能同时提供/) +}) + + +test('changed file after an unknown loading write cannot replace frozen original evidence or trigger resend', async () => { + const directory = await mkdtemp(join(tmpdir(), 'release-native-measurement-unknown-')) + try { + const file = join(directory, 'actual.json'), f = fixture({ missingActual: true }) + f.input = { ...f.input, loadingMeasurementFile: file } + await writeFile(file, JSON.stringify(measurementReceipt()), 'utf8') + const originalRequest = f.context.request + f.context.request = async (...args) => { + const result = await originalRequest(...args) + if (args[0] === 'POST' && args[1].endsWith('/loading-complete')) throw Error('loading response lost') + return result + } + await assert.rejects(runOverseasStage(f), /loading response lost/) + assert.equal(f.checkpoint.state.commands['loading-complete'].status, 'INTENT') + const proof = structuredClone(f.checkpoint.state.loadingMeasurementEvidence), original = structuredClone(f.checkpoint.state.commands['loading-complete'].request) + const edited = measurementReceipt(); edited.pallets[0].palletSpaces = 3; edited.pallets[0].evidence[0].value = 3 + await writeFile(file, JSON.stringify(edited), 'utf8') + const calls = f.calls.length + await assert.rejects(runOverseasStage(f), /结果未知/) + assert.equal(f.calls.length, calls) + assert.deepEqual(f.checkpoint.state.loadingMeasurementEvidence, proof) + assert.deepEqual(f.checkpoint.state.commands['loading-complete'].request, original) + } finally { await rm(join(directory, 'actual.json'), { force: true }); await rmdir(directory) } +}) diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 1dda4fdbf..40f839f8b 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -57,6 +57,9 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ http('lastleg-service/src/main/java/com/baiyue/lastleg/fleet/reporting/HttpTripOperationsEvidenceGateway.java', 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '出车实测凭证按平台/车次独立归属;上传无远端幂等键,当前/历史版本引用经Lastleg校验后读取,不自动重试上传'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '排车车辆及司机证据通过File内部令牌入口按平台0、证据类别和排车ID复合核验;POST上传无远端幂等键,禁止自动重试,GET元数据及有界内容只读,不借文件上传推进排车状态'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/FinanceFeeCoverageClient.java', 'lastleg-service', 'finance-service', ['REQUEST'], 'READ', 'SAFE_READ', '原运费覆盖只读:按实际客户与头程入仓ID批量读取Finance已接受来源;目标端只查本库,无下游调用;不提供费用写入或资金命令'), diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 6675777cd..4ce1d3a59 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -123,7 +123,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 28) + assert.equal(reviewed.length, 29) assert.deepEqual(discovered.sort(), reviewed) }) From 16ac817524d888223a09d89e583194ab9362685e Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 11:30:34 +0800 Subject: [PATCH 031/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + .../V8__chargeable_weight_confirmation.sql | 15 ++ deploy/sql/头程数据库统一增量-MySQL8.sql | 16 +- .../InternalInboundOrderController.java | 18 ++ .../PlatformContainerController.java | 10 +- .../firstleg/dto/UnifiedInboundQuery.java | 2 +- .../mapper/ContainerBookingMapper.java | 8 + .../repository/ContainerRepository.java | 2 + .../repository/InboundOrderRepository.java | 23 ++- .../firstleg/service/ContainerService.java | 7 + .../MerchantWorkbenchFilterService.java | 4 +- .../service/UnifiedInboundQueryService.java | 69 ++++++- .../workflow/ChargeableWeightService.java | 57 ++++++ .../workflow/LastlegWorkflowClient.java | 12 ++ .../MerchantChargeableWeightController.java | 26 +++ .../firstleg/workflow/WorkflowFactMapper.java | 41 +++++ .../workflow/WorkflowFactRepository.java | 22 +++ .../firstleg/workflow/WorkflowFacts.java | 17 ++ .../firstleg/workflow/WorkflowTabService.java | 75 ++++++++ .../db/chargeable-weight-confirmation.sql | 13 ++ .../db/phase3-channel-container-migration.sql | 7 +- .../main/resources/openapi/firstleg-v1.yaml | 61 +++++-- .../resources/openapi/order-workflow-v1.yaml | 64 +++++++ ...alInboundOrderReferenceControllerTest.java | 14 ++ .../LoadingCompletionSqlContractTest.java | 10 ++ .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../UnifiedInboundTaxModePaginationTest.java | 11 ++ .../service/ContainerServiceTest.java | 12 ++ .../MerchantWorkbenchFilterServiceTest.java | 14 ++ .../UnifiedInboundQueryServiceTest.java | 67 +++++++ .../workflow/WorkflowBoundaryTest.java | 63 +++++++ .../workflow/WorkflowBusinessTest.java | 63 +++++++ .../workflow/WorkflowFactPersistenceTest.java | 120 +++++++++++++ .../OutboundQuantityRepository.java | 12 +- .../service/DeliveryTransferService.java | 7 +- .../controller/CargoDiscardController.java | 8 + .../MerchantCargoDiscardController.java | 8 + .../discard/dto/CargoDiscardRequests.java | 9 +- .../discard/dto/CargoDiscardResponses.java | 7 +- .../mapper/CargoDiscardInventoryMapper.java | 11 +- .../discard/model/CargoDiscardReturnMark.java | 17 ++ .../pda/service/PdaCargoDiscardService.java | 3 +- .../service/CargoDiscardOutboundService.java | 8 +- .../discard/service/CargoDiscardService.java | 31 ++-- .../service/CargoDiscardStockService.java | 6 +- .../dispatch/mapper/DispatchBolMapper.java | 5 + .../dto/OverseasProcurementRequests.java | 2 +- .../service/OverseasProcurementService.java | 2 +- .../MerchantRelabelApplicationController.java | 9 + .../RelabelApplicationController.java | 10 ++ .../service/RelabelApplicationService.java | 25 +-- .../service/ReturnInventoryService.java | 5 +- .../service/ReturnReceiptService.java | 10 +- .../mapper/InboundSnapshotMapper.java | 22 +++ .../TransferSourceReservationMapper.java | 19 ++ .../TransferInventorySourceRepository.java | 5 + .../service/WarehouseTransferService.java | 15 +- .../InternalOrderWorkflowFactController.java | 20 +++ .../workflow/OrderWorkflowFactMapper.java | 31 ++++ .../workflow/OrderWorkflowFactRepository.java | 12 ++ .../workflow/OrderWorkflowFactService.java | 12 ++ .../lastleg/workflow/OrderWorkflowFacts.java | 17 ++ .../resources/openapi/order-workflow-v1.yaml | 52 ++++++ .../openapi/overseas-business-v1.yaml | 2 +- .../OutboundQuantityRepositoryTest.java | 33 ++++ .../service/DeliveryTransferServiceTest.java | 24 +++ .../service/ReturnOutboundClosureTest.java | 5 + .../ReturnOutboundPdaPersistenceTest.java | 2 + .../discard/CargoDiscardReturnMarkTest.java | 20 +++ .../ReturnDiscardMovementMapperTest.java | 3 +- .../service/PdaCargoDiscardServiceTest.java | 7 +- .../CargoDiscardOutboundServiceTest.java | 5 +- .../service/CargoDiscardServiceTest.java | 20 +++ .../service/CargoDiscardStockServiceTest.java | 12 +- .../ProcurementRequestBoundaryTest.java | 13 ++ .../service/ReturnInventoryServiceTest.java | 2 +- .../service/ReturnReceiptServiceTest.java | 13 ++ .../service/TransferInventorySourceTest.java | 20 ++- .../WarehouseTransferCartonCountTest.java | 12 ++ .../service/WarehouseTransferServiceTest.java | 2 + .../workflow/OrderWorkflowFactMapperTest.java | 47 +++++ .../workflow/OrderWorkflowSecurityTest.java | 23 +++ .../client/FirstlegUnifiedOrderClient.java | 12 ++ .../PlatformUnifiedOrderController.java | 14 ++ .../baiyue/order/dto/UnifiedOrderQuery.java | 2 +- .../order/repository/OrderRepository.java | 26 ++- .../service/UnifiedOrderQueryService.java | 152 +++++++++++++--- .../src/main/resources/openapi/order-v1.yaml | 57 +++++- .../UnifiedOrderApiContractTest.java | 40 ++++- .../UnifiedOrderTaxModePaginationTest.java | 11 ++ .../service/UnifiedOrderQueryServiceTest.java | 168 +++++++++++++----- .../initialization-baseline-contract.test.mjs | 12 +- 92 files changed, 1916 insertions(+), 190 deletions(-) create mode 100644 deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java create mode 100644 firstleg/src/main/resources/db/chargeable-weight-confirmation.sql create mode 100644 firstleg/src/main/resources/openapi/order-workflow-v1.yaml create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardReturnMark.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/workflow/InternalOrderWorkflowFactController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFacts.java create mode 100644 lastleg-service/src/main/resources/openapi/order-workflow-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/OutboundQuantityRepositoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardReturnMarkTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowSecurityTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 46052ade5..336d17dea 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。 + 费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 排车整车作业结构同步于 Lastleg 源基线及海外统一空库基线:整车主单移除客户头部,真实客户归属保留在货物、出库、派送和费用;新增公共命令账本、主单历史、派送安排及签收待汇总信号。空排车的计划时间可暂缺,提交审核前由业务校验补齐;`ll_dispatch_physical_pallet_lock` 独立保存无代表客户的实托排他占用,`ll_dispatch_pallet_lock.physical_lock_id` 关联逐客户逻辑来源,同车可共享、跨主单或草稿容器仍排他。`ll_dispatch_item` 按主单和完整来源键唯一,`ll_dispatch_item_source` 冻结换标明细的实际位置、绑定箱数、真实件数(未知为空)及原货源事实;拣货归属新增 `dispatch_item_source_id`,与实收、退回实收、原生退货箱三类凭据四者互斥,不伪造普通收货凭据。智能草稿按拥有者、起运仓和版本维护,一容器转换一整车;独立草稿命令保存原请求和结果。必须与匹配的 Lastleg、IAM、File、Admin 和 PDA 一起核对,空库脚本不能向已有 TEST 重放。本条仅说明代码基线,不表示 SQL 已执行、已部署或全流程已验收;唯一业务规则见工作区 `docs/海外仓业务/排车列表统一作业改造契约.md`。 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql new file mode 100644 index 000000000..47620cd03 --- /dev/null +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql @@ -0,0 +1,15 @@ +-- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。 +USE baiyue_firstleg; +-- 客户独立确认计费重,不回填既有费用确认。 +CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( + id BIGINT NOT NULL COMMENT '确认记录ID', + tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', + inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', + operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', + command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', + fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', + chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', + confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), + KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) +) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index e52387846..ecb9eaca6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -4437,6 +4437,20 @@ CREATE TABLE fl_inbound_split_item ( SET SESSION foreign_key_checks = 1; +-- 客户独立确认计费重,不回填既有费用确认。 +CREATE TABLE fl_chargeable_weight_confirmation ( + id BIGINT NOT NULL COMMENT '确认记录ID', + tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', + inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', + operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', + command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', + fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', + chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', + confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), + KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) +) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; + -- 服务 schema:baiyue_file CREATE DATABASE IF NOT EXISTS baiyue_file DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; USE baiyue_file; @@ -8371,4 +8385,4 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'CN' AS region, 228 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java index 88cd299c9..0059b8b86 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/InternalInboundOrderController.java @@ -157,6 +157,24 @@ public class InternalInboundOrderController { return ApiResponse.success(workbenchFilters.read(request)); } + /** 当前页摘要继续应用原筛选,防止父子或分片入库投影错配。 */ + @PostMapping("/unified-orders/status-summaries") + public ApiResponse> unifiedStatusSummaries( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody UnifiedInboundQuery request) { + requireToken(token); + return ApiResponse.success(unifiedQueryService.statusSummaries(request)); + } + + /** 服务令牌保护的轻量状态投影,供 Order 统计和分页校正。 */ + @PostMapping("/unified-orders/status-rows") + public ApiResponse> unifiedStatusRows( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody UnifiedInboundQuery request) { + requireToken(token); + return ApiResponse.success(unifiedQueryService.statusRows(request)); + } + @PostMapping("/unified-orders/query") public ApiResponse> unifiedPage( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java index d7bdccc2c..54d20d654 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformContainerController.java @@ -140,7 +140,7 @@ public class PlatformContainerController { public ApiResponse clearance(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id) { FirstlegScopeGuard.platform(user); - service.requireOverseas(id); + service.requireClearanceAccess(id); return ApiResponse.success(loadingLists.containerClearance(id)); } @@ -151,7 +151,7 @@ public class PlatformContainerController { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long id, @PathVariable @Positive long fileId) { FirstlegScopeGuard.platform(user); - service.requireOverseas(id); + service.requireClearanceAccess(id); var download = loadingLists.downloadContainerClearance(id, fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .header(HttpHeaders.CONTENT_DISPOSITION, org.springframework.http.ContentDisposition.attachment() @@ -168,7 +168,7 @@ public class PlatformContainerController { @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody DocumentAttachRequest request) { FirstlegScopeGuard.platform(user); - service.requireOverseas(id); + service.requireClearanceAccess(id); return ApiResponse.success(loadingLists.attachContainerClearance( id, user.userId(), user.username(), key, request)); } @@ -183,7 +183,7 @@ public class PlatformContainerController { @RequestParam("expectedVersion") @Min(0) int expectedVersion, @RequestParam(value = "operationReason", required = false) @Size(max = 500) String operationReason) { FirstlegScopeGuard.platform(user); - service.requireOverseas(id); + service.requireClearanceAccess(id); return ApiResponse.success(loadingLists.uploadContainerClearance( id, user.userId(), user.username(), key, expectedVersion, operationReason, file)); } @@ -196,7 +196,7 @@ public class PlatformContainerController { @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody DocumentCompleteRequest request) { FirstlegScopeGuard.platform(user); - service.requireOverseas(id); + service.requireClearanceAccess(id); return ApiResponse.success(loadingLists.completeContainerClearance( id, user.userId(), user.username(), key, request)); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java index ccea8652c..a457c75df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/UnifiedInboundQuery.java @@ -15,7 +15,7 @@ public record UnifiedInboundQuery( @Size(max = 64) String keyword, @Size(max = 64) String orderNo, @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED"}) + "LOADING", "LOADED", "DELIVERED", "WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW"}) @Size(max = 32) String mainStatus, Long customerId, Long receivingWarehouseId, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java index 66605e865..f8d3764ca 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/ContainerBookingMapper.java @@ -158,6 +158,14 @@ public interface ContainerBookingMapper extends BaseMapper 0; } + /** 读取本服务维护的实际开船事实,不依赖第三方查询任务是否建立。 */ + public boolean sailed(long id) { return mapper.hasActualVesselDeparture(id) > 0; } public boolean containerNoExists(long oceanBookingId, String no, Long excludedId) { if (no == null) return false; return mapper.selectCount(new LambdaQueryWrapper() diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index cd38a4284..74f5fbb73 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -114,9 +114,27 @@ public class InboundOrderRepository { .last("LIMIT 1")); } - /** Internal unified-query accessors. They always retain the tenant predicate when one is supplied. */ + /** 统一订单列表按共享筛选分页。 */ public Page pageUnifiedEntities(UnifiedInboundQuery query, List processStatuses) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + return orders.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(query, processStatuses)); + } + + /** 只读取身份和流程状态,顺序与列表一致;空候选不能扩大查询范围。 */ + public List unifiedStatusEntities(UnifiedInboundQuery query) { + if (query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) return List.of(); + return orders.selectList(unifiedFilter(query, List.of()).select(InboundOrderEntity::getId, + InboundOrderEntity::getTenantId, InboundOrderEntity::getOrderId, InboundOrderEntity::getProcessStatus)); + } + + /** 当前页候选订单沿原筛选读取匹配的入库记录,避免读到另一条不匹配投影。 */ + public List unifiedMatchedEntities(UnifiedInboundQuery query, List processStatuses) { + if (query.candidateOrderIds() == null || query.candidateOrderIds().isEmpty()) return List.of(); + return orders.selectList(unifiedFilter(query, processStatuses)); + } + + /** 列表与统计共享客户隔离、费用、测量及船期等筛选语义。 */ + private LambdaQueryWrapper unifiedFilter(UnifiedInboundQuery query, List processStatuses) { + return new LambdaQueryWrapper() .eq(query.tenantId() != null, InboundOrderEntity::getTenantId, query.tenantId()) .eq(query.taxMode() != null, InboundOrderEntity::getTaxMode, query.taxMode()) .in(processStatuses != null && !processStatuses.isEmpty(), InboundOrderEntity::getProcessStatus, processStatuses) @@ -187,7 +205,6 @@ public class InboundOrderRepository { .ge(query.createdFrom() != null, InboundOrderEntity::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, InboundOrderEntity::getCreatedAt, query.createdTo()) .orderByDesc(InboundOrderEntity::getCreatedAt).orderByDesc(InboundOrderEntity::getId); - return orders.selectPage(new Page<>(query.page(), query.size()), wrapper); } public List findByOrderIds(Long tenantId, List orderIds) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java index 33ead8723..172078964 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/ContainerService.java @@ -45,6 +45,13 @@ public class ContainerService { if (!repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国外工作台货柜不存在"); } + /** 目的港清关资料允许已离仓或已实际开船货柜维护,不改写国内出仓事实。 */ + public void requireClearanceAccess(long id) { + required(id); + if (!repository.departed(id) && !repository.sailed(id)) + throw new BizException(ResultCode.NOT_FOUND, "货柜尚未离仓或实际开船,不能维护目的港清关资料"); + } + public void requireDomestic(long id) { required(id); if (repository.departed(id)) throw new BizException(ResultCode.NOT_FOUND, "国内工作台货柜不存在"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java index ef0880e5d..3418b78c4 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterService.java @@ -9,7 +9,9 @@ import org.springframework.stereotype.Service; public class MerchantWorkbenchFilterService { @Resource private MerchantWorkbenchFilterMapper mapper; @Resource private UnifiedInboundQueryService unified; + @Resource private com.baiyue.firstleg.workflow.ChargeableWeightService weights; + /** 精确详情只读进度;工作台待确认队列使用计费重事实,金额确认不能代替。 */ @org.springframework.transaction.annotation.Transactional(readOnly=true) public MerchantWorkbenchFilters.Response read(MerchantWorkbenchFilters.Request request) { if (request.orderIds() != null) { @@ -19,7 +21,7 @@ public class MerchantWorkbenchFilterService { return new MerchantWorkbenchFilters.Response( request.mainStatus() == null ? null : mapper.byProcessStatuses(request.tenantId(), unified.internalStatuses(request.mainStatus())), - mapper.pendingFees(request.tenantId()), mapper.pendingCustoms(request.tenantId()), + weights.pendingOrders(request.tenantId()), mapper.pendingCustoms(request.tenantId()), progress(mapper.progressFragments(request.tenantId(), null))); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java index d63a1b027..143ac3971 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/UnifiedInboundQueryService.java @@ -36,6 +36,38 @@ public class UnifiedInboundQueryService { @Resource private FbaPricingRepository pricing; @Resource private UnifiedInboundReadRepository operations; @Resource private ObjectMapper json; + @Resource private com.baiyue.firstleg.workflow.WorkflowTabService workflowTabs; + + /** 以稳定列表顺序返回去重履约状态,不触发逐票摘要及费用查询。 */ + public List statusRows(UnifiedInboundQuery query) { + validate(query); + Map byOrder = new LinkedHashMap<>(); + List matched = inbounds.unifiedStatusEntities(query); + for (InboundOrderEntity inbound : matched) { + String status = mainStatus(inbound.getProcessStatus()); + // 分片订单可同时命中不同履约页签;同一页签内每个订单只统计一次。 + byOrder.putIfAbsent(inbound.getOrderId() + ":" + status, new StatusRow(inbound.getTenantId(), + inbound.getOrderId(), status)); + } + workflowTabs.rows(matched).forEach(row -> byOrder.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row)); + return List.copyOf(byOrder.values()); + } + + /** 只对当前页最多200个订单读取匹配摘要,选取规则与状态投影一致。 */ + public List statusSummaries(UnifiedInboundQuery query) { + validate(query); + if (query.candidateOrderIds() == null || query.candidateOrderIds().size() > 200) { + throw new com.baiyue.common.core.exception.BizException( + com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "状态摘要必须提供最多200个候选订单"); + } + Map byOrder = new LinkedHashMap<>(); + for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities(query, internalStatuses(query.mainStatus()))) { + byOrder.putIfAbsent(inbound.getOrderId(), inbound); + } + return summaries(List.copyOf(byOrder.values())); + } + + public record StatusRow(Long tenantId, Long orderId, String mainStatus) {} public PageResult page(UnifiedInboundQuery query) { validate(query); @@ -43,11 +75,45 @@ public class UnifiedInboundQueryService { || query.candidateOrderIds() != null && query.candidateOrderIds().isEmpty()) { return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); } + if (StringUtils.hasText(query.mainStatus()) + && com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus())) { + return workflowPage(query); + } Page source = inbounds.pageUnifiedEntities(query, internalStatuses(query.mainStatus())); List records = summaries(source.getRecords()); return new PageResult<>(records, source.getCurrent(), source.getSize(), source.getTotal(), source.getPages()); } + /** 独立事实页签先筛选完整来源再分页,内部查询也不能把业务页签解释成全部状态。 */ + private PageResult workflowPage(UnifiedInboundQuery query) { + List matched = inbounds.unifiedStatusEntities(query); + Set matchingOrders = workflowTabs.rows(matched).stream() + .filter(row -> query.mainStatus().equals(row.mainStatus())).map(StatusRow::orderId) + .collect(Collectors.toSet()); + Map unique = new LinkedHashMap<>(); + // 轻量事实不能用于摘要,重新读取当前条件下的完整来源。 + for (InboundOrderEntity inbound : inbounds.unifiedMatchedEntities( + withCandidates(query, matchingOrders.stream().toList()), List.of())) { + if (matchingOrders.contains(inbound.getOrderId())) unique.putIfAbsent(inbound.getOrderId(), inbound); + } + List records = List.copyOf(unique.values()); + long total = records.size(); + long start = query.page() - 1 > Long.MAX_VALUE / query.size() + ? total : Math.min((query.page() - 1) * query.size(), total); + var current = records.subList((int) start, (int) Math.min(start + query.size(), total)); + return new PageResult<>(summaries(current), query.page(), query.size(), total, + total / query.size() + (total % query.size() == 0 ? 0 : 1)); + } + + /** 保留全部过滤条件,只收窄为业务事实已命中的订单。 */ + private UnifiedInboundQuery withCandidates(UnifiedInboundQuery q, List candidates) { + return new UnifiedInboundQuery(q.tenantId(), q.keyword(), q.orderNo(), q.mainStatus(), q.customerId(), + q.receivingWarehouseId(), q.destinationWarehouseId(), q.inboundCode(), q.internalOrderStatus(), + q.expectedArrivalDate(), q.feeReviewStatus(), q.channelName(), q.containerNo(), q.cabinetNo(), + q.sailingDate(), q.loaded(), q.fullContainer(), q.measurementReviewStatus(), candidates, + q.createdFrom(), q.createdTo(), q.page(), q.size(), q.taxMode()); + } + public List summaries(Long tenantId, List orderIds) { Map byOrder = new LinkedHashMap<>(); for (UnifiedInboundSummary item : summaries(inbounds.findByOrderIds(tenantId, orderIds))) @@ -203,7 +269,7 @@ public class UnifiedInboundQueryService { } List internalStatuses(String mainStatus) { - if (!StringUtils.hasText(mainStatus)) return List.of(); + if (!StringUtils.hasText(mainStatus) || com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(mainStatus)) return List.of(); return switch (mainStatus) { case "WAIT_MEASURE" -> List.of("WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", "WAIT_PRICING", "FEE_CONFIRMING", "INBOUNDING"); @@ -231,6 +297,7 @@ public class UnifiedInboundQueryService { } if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus()) + && !com.baiyue.firstleg.workflow.WorkflowTabService.KEYS.contains(query.mainStatus()) ) { throw new com.baiyue.common.core.exception.BizException( com.baiyue.common.core.api.ResultCode.BAD_REQUEST, "统一订单主状态不合法"); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java new file mode 100644 index 000000000..a2274c93b --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/ChargeableWeightService.java @@ -0,0 +1,57 @@ +package com.baiyue.firstleg.workflow; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import java.util.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.LocalDateTime; +/** 客户独立确认计费重,金额确认和主流程不受影响。 */ +@Service +public class ChargeableWeightService { + private final WorkflowFactRepository repository; + /** 注入当前事实和确认历史。 */ + public ChargeableWeightService(WorkflowFactRepository repository) { this.repository=repository; } + /** 客户仅能读取本订单已审核的计费重。 */ + public List read(long tenant,long orderId) { + return repository.facts(repository.orderInbounds(tenant,orderId)).stream().filter(f->f.tenantId()==tenant && eligible(f)).map(ChargeableWeightService::weight).toList(); + } + /** 客户工作台按独立确认事实计算待办,不借用费用确认。 */ + public List pendingOrders(long tenant) { + var ids=repository.weightCandidates(tenant);var orders=new LinkedHashSet(); + for(int offset=0;offset0; } + /** 只认可当前版本的确认。 */ + public static WorkflowFacts.Weight weight(WorkflowFacts.Fact f) { + String hash=fingerprint(f);boolean confirmed=hash.equals(f.confirmationFingerprint()); + return new WorkflowFacts.Weight(f.inboundId(),f.orderId(),f.chargeableWeightKg(),hash,confirmed,confirmed?f.confirmedAt():null); + } + /** 入仓锁、预期版本和幂等键防止确认旧值或异参重放。 */ + @Transactional + public WorkflowFacts.Weight confirm(long tenant,long operator,long id,String key,WorkflowFacts.Confirm request) { + if(key==null || key.isBlank() || key.length()>128) throw new BizException(ResultCode.BAD_REQUEST,"确认幂等键不能为空且最多128字符"); + var inbound=repository.lock(tenant,id); + if(inbound==null || "CANCELLED".equals(inbound.getProcessStatus())) throw new BizException(ResultCode.NOT_FOUND,"入仓单不存在"); + var replay=repository.command(tenant,operator,key); + if(replay!=null && (replay.inboundId()!=id || !replay.fingerprint().equals(request.expectedFingerprint()))) throw new BizException(ResultCode.CONFLICT,"同一幂等键不能确认不同计费重"); + var facts=repository.facts(List.of(id)); + if(facts.isEmpty()) throw new BizException(ResultCode.CONFLICT,"请分别确认分柜后的独立订单计费重"); + var f=facts.get(0); + if(!eligible(f)) throw new BizException(ResultCode.CONFLICT,"计费重尚未审核通过"); + var current=weight(f); + if(!current.fingerprint().equals(request.expectedFingerprint())) throw new BizException(ResultCode.CONFLICT,"计费重已变化,请刷新后重新确认"); + if(replay==null) repository.insert(new WorkflowFacts.Confirmation(com.baomidou.mybatisplus.core.toolkit.IdWorker.getId(),tenant,id,operator,key,current.fingerprint(),current.chargeableWeightKg(),LocalDateTime.now())); + return weight(repository.facts(List.of(id)).get(0)); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java new file mode 100644 index 000000000..d4eff4ecd --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java @@ -0,0 +1,12 @@ +package com.baiyue.firstleg.workflow; +import com.baiyue.common.core.api.ApiResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.*; +import java.util.List; +/** 经内部接口读取尾程,禁止跨库查询。 */ +@FeignClient(name="lastleg-service",contextId="firstlegWorkflowFacts",url="${LASTLEG_SERVICE_URL:${baiyue.clients.lastleg.url:}}") +public interface LastlegWorkflowClient { + /** 最多500个精确来源,不转发客户JWT。 */ + @PostMapping("/internal/lastleg/v1/order-workflow-facts") + ApiResponse> read(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody WorkflowFacts.Request request); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java new file mode 100644 index 000000000..7be5a3db9 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/MerchantChargeableWeightController.java @@ -0,0 +1,26 @@ +package com.baiyue.firstleg.workflow; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.firstleg.service.FirstlegScopeGuard; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import jakarta.validation.Valid; +import java.util.List; +/** 客户计费重确认入口,独立于费用确认。 */ +@RestController +@RequestMapping("/api/merchant/v1/first-leg") +public class MerchantChargeableWeightController { + private final ChargeableWeightService service; + /** 注入独立确认服务。 */ + public MerchantChargeableWeightController(ChargeableWeightService service) { this.service=service; } + /** 客户范围取认证上下文。 */ + @GetMapping("/orders/{orderId}/chargeable-weights") + @PreAuthorize("hasAuthority('merchant:order:view')") + public ApiResponse> read(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long orderId) { return ApiResponse.success(service.read(FirstlegScopeGuard.merchant(user),orderId)); } + /** 不触发费用确认与金额锁定。 */ + @com.baiyue.common.audit.BusinessAudit(event="CUSTOMER_CHARGEABLE_WEIGHT_CONFIRM",resourceType="INBOUND_ORDER",resourceId="#inboundId",description="客户独立确认计费重") + @PostMapping("/chargeable-weights/{inboundId}/confirm") + @PreAuthorize("hasAuthority('merchant:order:edit')") + public ApiResponse confirm(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable long inboundId,@RequestHeader("Idempotency-Key") String key,@Valid @RequestBody WorkflowFacts.Confirm request) { return ApiResponse.success(service.confirm(FirstlegScopeGuard.merchant(user),user.userId(),inboundId,key,request)); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java new file mode 100644 index 000000000..dce644169 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactMapper.java @@ -0,0 +1,41 @@ +package com.baiyue.firstleg.workflow; +import org.apache.ibatis.annotations.*; +import java.util.List; +/** 柜事实和独立确认SQL入口。 */ +@Mapper +public interface WorkflowFactMapper { + String ACTUAL=""" + FROM fl_loading_plan_item li JOIN fl_loading_plan p ON p.id=li.loading_plan_id AND p.plan_status='COMPLETED' + JOIN fl_loading_pick_task t ON t.loading_plan_id=p.id AND t.task_status='COMPLETED' + JOIN fl_loading_pick_item pk ON pk.pick_task_id=t.id AND pk.loading_plan_item_id=li.id + JOIN fl_container_booking b ON b.id=p.container_booking_id AND b.record_status='ACTIVE' + WHERE li.inbound_id=i.id AND li.account_id=i.tenant_id AND li.deleted=0 + AND pk.item_status IN ('PICKED','PARTIAL') AND pk.picked_package_count>0 + """; + /** 按独立实装票核验资料;来源单承接实装时,余量子单不能将来源票排除。 */ + @Select("") + List facts(@Param("ids") List ids); + /** 锁定客户自己的入仓单,串行确认与测量写入。 */ + @Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE") + com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id); + /** 客户订单下全部独立入仓单。 */ + @Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id") + List orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId); + /** 客户工作台只读取审核通过且有正计费重的来源。 */ + @Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id") + List weightCandidates(@Param("tenantId") long tenantId); + /** 成员幂等键不允许跨确认对象复用。 */ + @Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}") + WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key); + /** 独立记录,不修改费用或物流主状态。 */ + @Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})") + int insert(WorkflowFacts.Confirmation value); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java new file mode 100644 index 000000000..567aaba6a --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java @@ -0,0 +1,22 @@ +package com.baiyue.firstleg.workflow; +import org.springframework.stereotype.Repository; +import java.util.List; +/** 本地柜事实与确认记录的持久化入口。 */ +@Repository +public class WorkflowFactRepository { + private final WorkflowFactMapper mapper; + /** 注入SQL提供者。 */ + public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; } + /** 空候选不读取数据。 */ + public List facts(List ids) { return ids.isEmpty()?List.of():mapper.facts(ids); } + /** 锁定所属客户来源。 */ + public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); } + /** 查询订单下独立来源。 */ + public List orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); } + /** 客户计费重待确认候选。 */ + public List weightCandidates(long tenant) { return mapper.weightCandidates(tenant); } + /** 读取成员确认命令。 */ + public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); } + /** 追加确认历史。 */ + public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java new file mode 100644 index 000000000..afa08a995 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java @@ -0,0 +1,17 @@ +package com.baiyue.firstleg.workflow; +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +/** 页签事实与独立计费重确认契约。 */ +public final class WorkflowFacts { + /** 仅承载查询及确认契约,不允许实例化。 */ + private WorkflowFacts() {} + public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt, + boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {} + public record Source(long tenantId,long inboundId) {} + public record Request(List sources) {} + public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {} + public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {} + public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {} + public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {} +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java new file mode 100644 index 000000000..c513cbf9f --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -0,0 +1,75 @@ +package com.baiyue.firstleg.workflow; +import org.springframework.stereotype.Service; +import org.springframework.beans.factory.annotation.Value; +import com.baiyue.firstleg.entity.InboundOrderEntity; +import com.baiyue.firstleg.service.UnifiedInboundQueryService.StatusRow; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import java.util.*; +/** 将已确认的独立业务维度映射为页签,不改物流主状态。 */ +@Service +public class WorkflowTabService { + public static final Set KEYS=Set.of("WAIT_WEIGHT_CONFIRM","WEIGHT_CONFIRMED","WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE","WAIT_UNLOAD","WAIT_DISPATCH","OUTBOUND_DOCUMENT","DELIVERING","SIGNED","MEASUREMENT_REVIEW"); + private final WorkflowFactRepository repository; + private final LastlegWorkflowClient lastleg; + @Value("${baiyue.security.internal-service-token}") private String token; + /** 注入本地事实与尾程内部契约。 */ + public WorkflowTabService(WorkflowFactRepository repository,LastlegWorkflowClient lastleg) { this.repository=repository;this.lastleg=lastleg; } + /** 分批取事实,失败整体关闭,不能把服务异常伪装成零。 */ + public List rows(List inbounds) { + var result=new LinkedHashMap(); + for(int offset=0;offset();batch.forEach(i->expected.put(i.getId(),i)); + var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList()); + var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); + Map overseas=new HashMap<>(); + if(!sources.isEmpty()) { + try { + var response=lastleg.read(token,new WorkflowFacts.Request(sources)); + if(response==null || !response.successful() || response.data()==null) throw unavailable(); + var allowed=new HashSet<>(sources); + for(var fact:response.data()) { + if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable(); + overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded())); + } + if(overseas.size()!=sources.size()) throw unavailable(); + } catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); } + } + // SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。 + for(var fact:facts) { + var inbound=expected.get(fact.inboundId()); + if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable(); + var o=overseas.get(fact.tenantId()+":"+fact.inboundId()); + for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key)); + } + } + return List.copyOf(result.values()); + } + /** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */ + public static Set classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) { + Set keys=new LinkedHashSet<>(); + if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); + if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM"); + boolean signed="DELIVERED".equals(process); + // 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。 + if(!f.departed() && !signed) { + if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS"); + if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS"); + } + if(signed) keys.add("SIGNED"); + if(f.departed() && !signed) { + if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE"); + if(o!=null) { + if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD"); + // 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。 + if(!o.outboundExists()) keys.add("WAIT_DISPATCH"); + if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT"); + if(o.delivering()) keys.add("DELIVERING"); + } + } + return keys; + } + /** 下游缺失或归属异常不能生成部分数量。 */ + private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); } +} diff --git a/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql b/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql new file mode 100644 index 000000000..1dcc7e9c7 --- /dev/null +++ b/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql @@ -0,0 +1,13 @@ +-- 客户独立确认计费重,不回填既有费用确认。 +CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( + id BIGINT NOT NULL COMMENT '确认记录ID', + tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', + inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', + operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', + command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', + fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', + chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', + confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), + KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) +) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; diff --git a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql index 5cde7af51..d936e6ec1 100644 --- a/firstleg/src/main/resources/db/phase3-channel-container-migration.sql +++ b/firstleg/src/main/resources/db/phase3-channel-container-migration.sql @@ -134,8 +134,11 @@ BEGIN OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; END IF; - IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; + -- 与装柜主基线一致,允许持久化完成命令的处理中、失败重试及最终完成。 + IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING')) + OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED')) + OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法'; END IF; END$$ DELIMITER ; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index f22a25424..6e2607eea 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -355,7 +355,7 @@ paths: /internal/firstleg/v1/inbounds/unified-orders/workbench-filters: post: summary: 查询单一客户工作台履约筛选标识(Order内部) - description: 只读返回命中状态、最新待确认费用及待上传资料的订单标识。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 + description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 security: [{internalServiceToken: []}] requestBody: required: true @@ -366,7 +366,7 @@ paths: required: [tenantId] properties: tenantId: {type: integer, format: int64, minimum: 1} - mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED]} + mainStatus: {type: string, nullable: true, enum: [WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]} orderIds: type: array nullable: true @@ -388,7 +388,7 @@ paths: required: [feeOrderIds, customsOrderIds, progress] properties: matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}} - feeOrderIds: {type: array, items: {type: integer, format: int64}} + feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}} customsOrderIds: {type: array, items: {type: integer, format: int64}} progress: type: array @@ -401,6 +401,47 @@ paths: stage: {type: string, enum: [loaded, declared, cleared]} partial: {type: boolean} + /internal/firstleg/v1/inbounds/unified-orders/status-summaries: + post: + summary: 当前页订单按原筛选读取履约摘要(Order 内部) + description: candidateOrderIds 必须提供且最多200项;保留统一筛选,避免读到不匹配的其他入库分片。 + security: [{internalServiceToken: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UnifiedInboundQuery'} + responses: + '200': + description: 标准 ApiResponse,data 为 UnifiedInboundSummary 数组 + /internal/firstleg/v1/inbounds/unified-orders/status-rows: + post: + summary: 统一订单筛选后的轻量状态投影(Order 内部) + description: 非分页查询,共享统一履约筛选;按创建时间和ID降序排列,同一订单同一主状态去重,保留不同分片主状态,不查询逐票详情。调用方不传mainStatus。 + security: [{internalServiceToken: []}] + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/UnifiedInboundQuery'} + responses: + '200': + description: 标准 ApiResponse;依赖失败不能返回空成功 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + data: + type: array + items: + type: object + required: [tenantId, orderId] + properties: + tenantId: {type: string} + orderId: {type: string} + mainStatus: {type: string, nullable: true} /internal/firstleg/v1/inbounds/unified-orders/query: post: summary: 按统一订单条件分页查询履约摘要(Order 内部) @@ -594,13 +635,13 @@ paths: /api/platform/v1/containers/{id}/clearance: get: summary: 查询货柜目的港清关资料 - description: 需要 platform:firstleg:terminal-drayage:list 权限;仅国外工作台货柜 + description: 需要 platform:firstleg:terminal-drayage:list 权限;有效货柜已确认国内离仓,或已记录 DEPARTED_ORIGIN_PORT 且 actual_at 非空时可读取;人工与拉取节点使用相同事实 parameters: [{$ref: '#/components/parameters/ContainerBookingId'}] responses: {'200': {description: 成功或货柜不存在}} /api/platform/v1/containers/{id}/clearance/document: post: summary: 上传或替换货柜目的港清关资料 - description: 需要 platform:firstleg:terminal-drayage:document:edit 权限,fileId 必须严格绑定当前货柜 + description: 需要 platform:firstleg:terminal-drayage:document:edit 权限;有效货柜已离仓或已实际开船,fileId 必须严格绑定当前货柜 parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -609,7 +650,7 @@ paths: /api/platform/v1/containers/{id}/clearance/document/{fileId}/download: get: summary: 下载货柜目的港清关附件 - description: 需要 platform:firstleg:terminal-drayage:list 权限;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 + description: 需要 platform:firstleg:terminal-drayage:list 权限;有效货柜已离仓或已实际开船;服务端校验当前 DESTINATION_CLEARANCE fileId 及 File 业务归属,完成态仍可下载,响应禁止缓存 parameters: - {$ref: '#/components/parameters/ContainerBookingId'} - {name: fileId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -619,7 +660,7 @@ paths: /api/platform/v1/containers/{id}/clearance/document/upload: post: summary: 直接选择文件上传或替换货柜目的港清关资料 - description: 需要 platform:firstleg:terminal-drayage:document:edit;Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌。 + description: 需要 platform:firstleg:terminal-drayage:document:edit;有效货柜已离仓或已实际开船;Firstleg 使用内部服务令牌上传 File,浏览器不得传递 fileId 或内部令牌。上传成功即作为待拆柜的资料事实,不要求另行确认清关完成。 parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -630,7 +671,7 @@ paths: /api/platform/v1/containers/{id}/clearance/complete: post: summary: 点击完成目的港清关 - description: 需要 platform:firstleg:terminal-drayage:document:edit 权限 + description: 需要 platform:firstleg:terminal-drayage:document:edit 权限;有效货柜已离仓或已实际开船;此完成动作不作为上传后进入待拆柜的附加条件 parameters: [{$ref: '#/components/parameters/ContainerBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: required: true @@ -1631,9 +1672,9 @@ components: orderNo: {type: string, maxLength: 64, nullable: true} mainStatus: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED] + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] nullable: true - description: WAIT_SPLIT 为唯一待分柜筛选值 + description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。 customerId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/main/resources/openapi/order-workflow-v1.yaml b/firstleg/src/main/resources/openapi/order-workflow-v1.yaml new file mode 100644 index 000000000..9c9abf1e4 --- /dev/null +++ b/firstleg/src/main/resources/openapi/order-workflow-v1.yaml @@ -0,0 +1,64 @@ +openapi: 3.0.3 +info: {title: 客户独立计费重确认, version: 1.0.0} +security: [{bearerAuth: []}] +paths: + /api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights: + get: + summary: 客户读取本票全部独立入仓计费重 + description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。 + parameters: + - {name: orderId, in: path, required: true, schema: {type: integer, format: int64}} + responses: + '200': + description: 标准ApiResponse,认证客户无可确认来源时返回空数组 + content: + application/json: + schema: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: array + items: {$ref: '#/components/schemas/Weight'} + /api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm: + post: + summary: 客户独立确认当前计费重 + description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。 + parameters: + - {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}} + - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [expectedFingerprint] + properties: + expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'} + responses: + '200': + description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态 + content: + application/json: + schema: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/Weight'} +components: + securitySchemes: + bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} + schemas: + Weight: + type: object + required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed] + properties: + inboundId: {type: string, description: 独立来源入仓单ID} + orderId: {type: string, description: 独立订单ID} + chargeableWeightKg: {type: number} + fingerprint: {type: string} + confirmed: {type: boolean} + confirmedAt: {type: string, format: date-time, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java index c72e432b4..0157bbffd 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InternalInboundOrderReferenceControllerTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java index 8ef0f7aa8..76d026638 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionSqlContractTest.java @@ -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", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index fc8bb1ade..a37339ac9 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java index a6d8dcd1c..8d0dac123 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/UnifiedInboundTaxModePaginationTest.java @@ -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")); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java index 4d4834e08..02cc706d6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/ContainerServiceTest.java @@ -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"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java index b2c74a883..f9d21c3b5 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java index d1077117c..ee0a6ece3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/UnifiedInboundQueryServiceTest.java @@ -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); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java new file mode 100644 index 000000000..409acb688 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -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); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java new file mode 100644 index 000000000..6772b8ab7 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -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(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java new file mode 100644 index 000000000..75d5c5565 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java @@ -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(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java index 27f9a21bf..e3565a338 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/repository/OutboundQuantityRepository.java @@ -11,20 +11,22 @@ import java.util.HashSet; public class OutboundQuantityRepository { @Resource private OutboundPalletItemMapper pallets; - /** 废弃退货来源依据已冻结箱唛计数,读取已有单据也不会把件数当箱数。 */ + /** 废弃来源依据已冻结箱唛去重计数,无箱唛的库存明细保留箱数快照。 */ public Integer boxes(OutboundOrder outbound) { if (!"DISCARD_OUTBOUND".equals(outbound.getOutboundType())) return outbound.getPackageCount(); var lines = pallets.findByOutbound(outbound.getCustomerId(), outbound.getId()); if (lines.isEmpty()) return outbound.getPackageCount(); - var returnBoxes = new HashSet(); + var markedBoxes = new HashSet(); int count = 0; for (var line : lines) { - if (line.getReturnSkuQuantity() != null && line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) { - returnBoxes.add(line.getInboundCode() + ":" + line.getCartonMarks()); + if (line.getCartonMarks() != null && !line.getCartonMarks().isBlank()) { + // 同一箱的多个 SKU 共用箱唛,只计一个实物箱;一行也可能冻结多个箱唛。 + java.util.Arrays.stream(line.getCartonMarks().split(",")).map(String::trim) + .filter(mark -> !mark.isEmpty()).forEach(markedBoxes::add); } else { count = Math.addExact(count, java.util.Objects.requireNonNullElse(line.getPackageCount(), 0)); } } - return Math.addExact(count, returnBoxes.size()); + return Math.addExact(count, markedBoxes.size()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index ecd857967..35762deed 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -130,6 +130,7 @@ public class DeliveryTransferService { @Resource private DeliveryExecutionGuard deliveryExecution; @Resource private com.baiyue.lastleg.dispatch.port.DispatchExecutionPort execution; @Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations; + @Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities; @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { @@ -1341,7 +1342,11 @@ public class DeliveryTransferService { } catch (JsonProcessingException exception) { throw new IllegalStateException("费用覆盖确认无法读取", exception); } } - private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), o.getPackageCount(), o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); } + /** 废弃出库按冻结实物箱汇总,避免历史件数快照被展示为箱数。 */ + private DeliveryResponses.OutboundRow outboundRow(OutboundOrder o) { + Integer boxes = "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? outboundQuantities.boxes(o) : o.getPackageCount(); + return new DeliveryResponses.OutboundRow(o.getId(), o.getOutboundNo(), o.getDispatchNo(), o.getOverseasWarehouseId(), "DISCARD_OUTBOUND".equals(o.getOutboundType()) ? null : o.getLxCodes(), o.getStatus(), o.getPalletCount(), boxes, o.getGrossWeightKg(), o.getVolumeWeightKg(), o.getVolumeCbm(), o.getCreatedAt(), o.getOutboundType()); + } private DeliveryResponses.OutboundPalletRow outboundPalletRow(OutboundPalletItem item) { return new DeliveryResponses.OutboundPalletRow(item.getId(), item.getPalletNo(), item.getCartonMarks(), item.getInboundCode(), item.getDestinationWarehouseCode(), item.getDestinationWarehouseName(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java index 560138fe0..84bcf3cbb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/CargoDiscardController.java @@ -46,6 +46,14 @@ public class CargoDiscardController { return ApiResponse.success(service.page(scope(user, customerId), query)); } + /** 读取全部筛选结果的数量统计,与分页记录分开返回。 */ + @GetMapping("/statistics") + public ApiResponse statistics( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, + @Valid CargoDiscardRequests.Query query) { + return ApiResponse.success(service.statistics(scope(user, customerId), query)); + } + @GetMapping("/{discardNo}") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required = false) String customerId, @PathVariable String discardNo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java index 300108ecc..0ee2d1c55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardController.java @@ -32,6 +32,14 @@ public class MerchantCargoDiscardController { @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, @Valid CargoDiscardRequests.Query query) { return ApiResponse.success(service.page(customer(user), query)); } + /** 客户统计只读取当前认证客户,不接受客户端客户归属。 */ + @GetMapping("/statistics") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") + public ApiResponse statistics( + @AuthenticationPrincipal AuthenticatedUser user, @Valid CargoDiscardRequests.Query query) { + return ApiResponse.success(service.statistics(customer(user), query)); + } + @GetMapping("/{discardNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_DISCARD_LIST + "')") public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java index 8cbac94b8..82d603b03 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -33,5 +33,12 @@ public final class CargoDiscardRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String discardNo, String inboundCode, String overseasWarehouseId, - String status, LocalDateTime createdFrom, LocalDateTime createdTo) {} + String status, LocalDateTime createdFrom, LocalDateTime createdTo, + LocalDateTime completedFrom, LocalDateTime completedTo) { + /** 未筛完成时间的调用保持创建时间查询语义。 */ + public Query(long page, long size, String discardNo, String inboundCode, String overseasWarehouseId, + String status, LocalDateTime createdFrom, LocalDateTime createdTo) { + this(page, size, discardNo, inboundCode, overseasWarehouseId, status, createdFrom, createdTo, null, null); + } + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index efde17c22..c62094d6d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -37,7 +37,12 @@ public final class CargoDiscardResponses { String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {} public record Item(Long id, Long orderId, String inboundCode, String skuCode, - String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks) { + String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks, + String quantityUnit, Integer actualDiscardPackages, String pdaStatus) { + /** 无来源事实的调用不猜测数量单位或实际完成数。 */ + public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks) { + this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, cartonMarks, null, null, null); + } public Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages) { this(id, orderId, inboundCode, skuCode, productName, availablePackagesSnapshot, discardPackages, List.of()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java index 758b47ccd..ba4238a99 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -25,6 +25,7 @@ public interface CargoDiscardInventoryMapper { @Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE") com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); + /** 按实收托盘及 SKU 关联已清点箱,避免漏箱或混入其他托盘的箱号。 */ @Select(""" SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type FROM ll_inbound_plan_carton c @@ -38,13 +39,13 @@ public interface CargoDiscardInventoryMapper { JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id AND t.transfer_no=p.batch_no AND t.operating_warehouse_id=p.overseas_warehouse_id - JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND c.status='EXPECTED' + JOIN ll_warehouse_transfer_pallet tp ON tp.customer_id=t.customer_id AND tp.transfer_order_id=t.id + AND tp.warehouse_pallet_id=p.pallet_id AND tp.status='COMPLETED' + JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id + AND c.transfer_pallet_id=tp.id AND c.status='COUNTED' JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id + AND i.transfer_order_id=t.id AND COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'') WHERE p.customer_id=#{customerId} AND p.id=#{positionId} - AND (SELECT COUNT(*) FROM ll_inventory_position current_position - WHERE current_position.customer_id=p.customer_id AND current_position.balance_id=p.balance_id - AND current_position.batch_no=p.batch_no AND current_position.overseas_warehouse_id=p.overseas_warehouse_id - AND current_position.available_packages+current_position.hold_packages+current_position.allocated_packages+current_position.picked_packages>0)=1 ORDER BY id """) List genericCartons(@Param("customerId") String customerId, @Param("positionId") Long positionId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardReturnMark.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardReturnMark.java new file mode 100644 index 000000000..206644d50 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardReturnMark.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.discard.model; + +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; + +/** 废弃作业沿用签收时实物标签,兼容历史包裹内部 RP 编号。 */ +public final class CargoDiscardReturnMark { + /** 工具类不允许实例化。 */ + private CargoDiscardReturnMark() { } + + /** 包裹取签收单元编码;箱子及缺少来源编码的历史数据保留原箱唛。 */ + public static String of(ReturnInventoryCarton carton) { + if (carton == null) return null; + if ("PARCEL".equals(carton.getSourceUnitType()) && carton.getSourceUnitCode() != null + && !carton.getSourceUnitCode().isBlank()) return carton.getSourceUnitCode().trim(); + return carton.getCartonMark(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index b17b3483e..ad828118b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -201,7 +201,8 @@ public class PdaCargoDiscardService { if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item); if (isReturnItem(item)) { var carton = returnCartons.selectById(item.getReturnCartonId()); - return carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark()); + String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); + return mark == null ? List.of() : List.of(mark); } return cartons.listApplication(application.getCustomerId(), application.getId()).stream() .filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index b7cd7fc4c..84d361e9f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -73,7 +73,7 @@ public class CargoDiscardOutboundService { || !application.getOverseasWarehouseId().equals(carton.getOverseasWarehouseId())) throw conflict("废弃明细关联退货箱不存在或仓库不匹配"); palletNo = carton.getPalletCode() == null || carton.getPalletCode().isBlank() ? null : carton.getPalletCode(); - cartonMarks = carton.getCartonMark(); + cartonMarks = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); } else { var profile = profiles.selectById(line.getProfileId()); if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) @@ -108,6 +108,12 @@ public class CargoDiscardOutboundService { var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst() .orElseThrow(() -> conflict("废弃出库明细关联失效")); boolean returned = line.getReturnCartonId() != null; + if (returned) { + var carton = returnCartons.selectById(line.getReturnCartonId()); + if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) + throw conflict("废弃明细关联退货箱不存在"); + snapshot.setCartonMarks(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton)); + } int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages(); snapshot.setPackageCount(boxes); if ("COMPLETED".equals(line.getPdaStatus())) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index dcc4c3df0..81640a372 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -50,6 +50,7 @@ import java.util.Set; @Service public class CargoDiscardService { + @Resource private CargoDiscardStatisticsService statisticsService; @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardQuantityRepository quantities; @Resource private com.baiyue.lastleg.service.LastlegOrderNoGenerator orderNoGenerator; public static final String APPROVAL_PERMISSION = "platform:overseas-warehouse:discard:approve"; @@ -97,14 +98,14 @@ public class CargoDiscardService { if (returnCartons == null || returnCartonItems == null) return result; for (ReturnInventoryCarton carton : returnCartons.findAvailable(customerId, null)) { if (StringUtils.hasText(keyword) && !containsIgnoreCase(carton.getSourceReturnNo(), keyword) - && !containsIgnoreCase(carton.getCartonMark(), keyword)) continue; + && !containsIgnoreCase(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton), keyword)) continue; List skus = returnCartonItems.listCarton(carton.getId()).stream() .filter(item -> value(item.getQuantity()) > 0) .map(item -> new CargoDiscardResponses.CandidateSku(item.getSkuCode(), item.getProductNameZh(), value(item.getQuantity()))) .toList(); long available = skus.stream().mapToLong(CargoDiscardResponses.CandidateSku::availablePackages).sum(); if (available <= 0) continue; - result.add(new CargoDiscardResponses.Candidate(null, "RETURN_CARTON", carton.getId(), carton.getCartonMark(), + result.add(new CargoDiscardResponses.Candidate(null, "RETURN_CARTON", carton.getId(), com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton), carton.getSourceReturnNo(), carton.getSourceReturnNo(), carton.getOverseasWarehouseId(), null, null, available, skus)); } @@ -253,7 +254,8 @@ public class CargoDiscardService { if ("INVENTORY".equals(line.getSourceType())) return stockService.cartonMarks(line); if (isReturnItem(line)) { var carton = returnCartons.selectById(line.getReturnCartonId()); - return carton == null || carton.getCartonMark() == null ? List.of() : List.of(carton.getCartonMark()); + String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); + return mark == null ? List.of() : List.of(mark); } return discardCartons.listApplication(customer, application.getId()).stream().filter(link -> link.getItemId().equals(line.getId())) .map(CargoDiscardCarton::getCartonMark).distinct().sorted().toList(); @@ -261,16 +263,7 @@ public class CargoDiscardService { public PageResult page(String customerId, CargoDiscardRequests.Query query) { validateQuery(query); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(StringUtils.hasText(customerId), CargoDiscardApplication::getCustomerId, customerId) - .like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo()) - .like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode()) - .eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId, - query.overseasWarehouseId()) - .eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status()) - .ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom()) - .le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo()) - .orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId); + var wrapper = com.baiyue.lastleg.discard.repository.CargoDiscardQueryRepository.filter(customerId, query); Page result = applications.selectPage(Page.of(query.page(), query.size()), wrapper); Map names = result.getRecords().stream().anyMatch(a -> !StringUtils.hasText(a.getOverseasWarehouseName())) ? warehouseNames() : Map.of(); @@ -278,12 +271,19 @@ public class CargoDiscardService { return new PageResult<>(records, query.page(), query.size(), result.getTotal(), result.getPages()); } + /** 全筛选汇总与列表使用同一输入校验,不受当前分页影响。 */ + public CargoDiscardStatisticsService.Statistics statistics(String customerId, CargoDiscardRequests.Query query) { + validateQuery(query); + return statisticsService.statistics(customerId, query); + } + public CargoDiscardResponses.Detail detail(String customerId, String discardNo) { CargoDiscardApplication application = applications.findNo(customerId, discardNo); if (application == null) throw notFound("Cargo discard application not found"); List itemViews = items.listApplication(customerId, application.getId()).stream() .map(value -> new CargoDiscardResponses.Item(value.getId(), value.getOrderId(), value.getInboundCode(), - value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value))) + value.getSkuCode(), value.getProductName(), value.getAvailablePackagesSnapshot(), value.getDiscardPackages(), selectedMarks(customerId, application, value), + value.getReturnCartonId() != null ? "件" : "箱", value.getActualDiscardPackages(), value.getPdaStatus())) .toList(); List historyViews = histories.listApplication(customerId, application.getId()).stream() .map(value -> new CargoDiscardResponses.History(value.getFromStatus(), value.getToStatus(), @@ -528,6 +528,9 @@ public class CargoDiscardService { } private void validateQuery(CargoDiscardRequests.Query query) { + if (query.completedFrom() != null && query.completedTo() != null && query.completedFrom().isAfter(query.completedTo())) { + throw badRequest("completedFrom must not be after completedTo"); + } if (StringUtils.hasText(query.status()) && !STATUSES.contains(query.status())) throw badRequest("Unsupported discard status"); if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { throw badRequest("createdFrom must not be after createdTo"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java index 57b06fa14..081158224 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java @@ -66,6 +66,7 @@ public class CargoDiscardStockService { return balance; } + /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); var balance = validate(customer, request); @@ -80,7 +81,7 @@ public class CargoDiscardStockService { : availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList(); if (!selected.isEmpty()) { for (var carton : selected) { - if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "EXPECTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选"); + if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "COUNTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选"); var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId()); link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link); } @@ -115,9 +116,10 @@ public class CargoDiscardStockService { try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); } catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); } } + /** 按真实箱来源完成废弃或释放,转运箱释放后恢复已清点状态。 */ private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) { for (var link : links.listApplication(customer, application.getId())) { - if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "EXPECTED" : status) != 1) + if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "COUNTED" : status) != 1) throw conflict("预留箱子的库存状态发生变化"); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index a150fdfd0..ede9b6852 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -5,6 +5,11 @@ import java.util.List; /** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ @Mapper public interface DispatchBolMapper { + /** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */ + default List findByDispatchOrder(String customerId, Long dispatchOrderId) { + return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList(); + } + @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java index d17d98de1..2760b13d3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/dto/OverseasProcurementRequests.java @@ -17,7 +17,7 @@ public final class OverseasProcurementRequests { public record Create( @Size(max = 255) String customerName, - @NotBlank @Size(max = 128) String purchaseCategory, + @Size(max = 128) String purchaseCategory, @NotBlank @Size(max = 255) String productName, @Size(max = 500) String productSpecification, @Size(max = 2000) String productUrl, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 5e13eca43..0c9019998 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -75,7 +75,7 @@ public class OverseasProcurementService { order.setCustomerId(customerId); order.setCustomerName(trim(request.customerName())); order.setOrderNo(orderNoGenerator.next(customerId, request.customerName())); - order.setPurchaseCategory(request.purchaseCategory().trim()); + order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim()); order.setProductName(request.productName().trim()); order.setProductSpecification(trim(request.productSpecification())); order.setProductUrl(trim(request.productUrl())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index fdc6b2fcc..0ddf2936c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -25,6 +25,15 @@ import java.util.List; @RequestMapping("/api/merchant/v1/last-leg/relabel-applications") public class MerchantRelabelApplicationController { @Resource private RelabelApplicationService service; + @Resource private com.baiyue.lastleg.relabel.service.RelabelQuantityService quantities; + + /** 客户汇总始终取认证客户范围,不接收客户端客户ID。 */ + @GetMapping("/quantity-summary") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") + public ApiResponse quantities( + @AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) { + return ApiResponse.success(quantities.totals(customer(user), query)); + } @GetMapping("/inventory-candidates") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse> candidates( diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index 9fa946db2..f0b77d159 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -25,6 +25,16 @@ import org.springframework.web.server.ResponseStatusException; @RequestMapping("/api/platform/v1/last-leg/relabel-applications") public class RelabelApplicationController { @Resource private RelabelApplicationService service; + @Resource private com.baiyue.lastleg.relabel.service.RelabelQuantityService quantities; + + /** 按平台当前全部筛选结果返回独立数量维度。 */ + @GetMapping("/quantity-summary") + @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") + public ApiResponse quantities( + @AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) { + requirePlatform(user); + return ApiResponse.success(quantities.totals(null, query)); + } @Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService; @PostMapping("/{relabelNo}/rework") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index e0cab9da6..65b28a945 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -570,30 +570,7 @@ public class RelabelApplicationService implements WalletResultHandler { @Transactional(readOnly = true) public PageResult page(String customerId, RelabelRequests.Query query) { - if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { - throw badRequest("createdFrom must not be after createdTo"); - } - String requestedStatus = trim(query.status()); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId) - .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) - .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) - .eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, - trim(query.overseasWarehouseId())) - .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) - .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) - .apply(StringUtils.hasText(query.skuCode()), - "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND ri.sku_code LIKE CONCAT('%',{0},'%'))", - query.skuCode()) - .orderByDesc(RelabelApplication::getCreatedAt); - if (PENDING_PROCESSING.equals(requestedStatus)) { - wrapper.in(RelabelApplication::getStatus, PENDING_PROCESSING, CUSTOMER_CONFIRM_PENDING, - "REJECTED", "CANCELLED"); - } else if ("COMPLETED".equals(requestedStatus)) { - wrapper.in(RelabelApplication::getStatus, READY_FOR_OUTBOUND, "COMPLETED"); - } else if (StringUtils.hasText(requestedStatus)) { - wrapper.eq(RelabelApplication::getStatus, requestedStatus); - } + LambdaQueryWrapper wrapper = com.baiyue.lastleg.relabel.model.RelabelQueryFilter.build(customerId, query); Page page = applications.selectPage(new Page<>(query.page(), query.size()), wrapper); List records = page.getRecords().stream().map(value -> summary(value, itemList(value.getId()))).toList(); return new PageResult<>(records, page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 93747dfa8..0186a7b4c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -529,6 +529,7 @@ public class ReturnInventoryService { if (cartons.updateById(carton) != 1) throw conflict("退货箱状态已变化,请重新核对"); } + /** 包裹直接沿用签收实物编码,避免同一实物生成两套标签。 */ @Transactional public ReturnInventoryCarton createReceiptCarton(ReturnOrder order, ReturnReceipt receipt, String requestedMark, List lines) { @@ -537,8 +538,8 @@ public class ReturnInventoryService { required(lines.get(0).skuCode(), "SKU 不能为空"); String cartonMark = text(requestedMark); long cartonId = IdWorker.getId(); - // One durable barcode identifies a physical parcel, irrespective of SKU count. - if ("PARCEL".equals(receipt.getUnitType())) cartonMark = "RP-" + Long.toString(receipt.getId(), 36).toUpperCase(java.util.Locale.ROOT); + // 包裹签收已生成 PK 标签,后续库存、废弃和扫码沿用同一编码。 + if ("PARCEL".equals(receipt.getUnitType())) cartonMark = normalizedUnitCode; else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); LocalDateTime now = LocalDateTime.now(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 4539015bd..655bdefe7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -458,7 +458,9 @@ public class ReturnReceiptService { RelabelApplication app = new RelabelApplication(); app.setId(appId); app.setCustomerId(customerId); app.setRelabelNo(relabelNo); app.setLastlegOrderId(tail.getId()); app.setCustomerOrderNo(order.getReturnNo()); app.setOverseasWarehouseId(order.getOverseasWarehouseId()); app.setLabelDeadline(now.plusDays(7)); - app.setContactName(text(order.getWarehouseContactName())); app.setContactPhone(text(order.getWarehouseContactPhone())); + // 退货预报的仓库联系人选填;转换标时保留空值语义并满足申请表非空约束。 + app.setContactName(java.util.Objects.requireNonNullElse(text(order.getWarehouseContactName()), "")); + app.setContactPhone(java.util.Objects.requireNonNullElse(text(order.getWarehouseContactPhone()), "")); app.setFulfillmentStatus("WAIT_APPROVAL"); app.setRemark(text(request.cartonRemark())); app.setStatus("PENDING_REVIEW"); app.setItemCount(request.items().size()); app.setTotalPackageCount(Math.toIntExact(totalQuantity)); app.setNotifiedPackageCount(Math.toIntExact(totalQuantity)); app.setChargeAmount(java.math.BigDecimal.ZERO); app.setChargeCurrency("CNY"); app.setWalletStatus("NOT_REQUIRED"); app.setIdempotencyKey(key); app.setRequestHash(requestHash); app.setCreatedBy(text(operatorId)); app.setCreatedAt(now); app.setUpdatedAt(now); relabelApplications.insert(app); List availableCartons = cartons.findAvailable(customerId, order.getOverseasWarehouseId()); Map selectedCartons = new LinkedHashMap<>(); @@ -577,6 +579,11 @@ public class ReturnReceiptService { String unitType = norm(request.unitType()); if (unitType == null) unitType = "PALLET"; String unitCode = text(request.unitCode()); + // 箱和包裹编码会直接生成 Code128 标签,入账前拒绝无法打印的编码。 + if (unitCode != null && Set.of("BOX", "PARCEL").contains(unitType)) { + try { new com.google.zxing.oned.Code128Writer().encode(unitCode); } + catch (IllegalArgumentException invalid) { throw badRequest("实物编码无法生成条码,请使用 ASCII 编码"); } + } ReturnOrder order = orders.lockNo(text(request.returnNo())); if (order == null || !String.valueOf(identity.overseasWarehouseId()).equals(order.getOverseasWarehouseId())) { throw notFound("当前绑定仓没有该退货单"); @@ -869,3 +876,4 @@ public class ReturnReceiptService { private static ResponseStatusException notFound(String message) { return new ResponseStatusException(HttpStatus.NOT_FOUND, message); } private record Allocation(ReturnOrderItem item, long quantity) { } } + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java index 8e40695fe..11de3837f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/InboundSnapshotMapper.java @@ -40,6 +40,28 @@ import org.apache.ibatis.annotations.Select; ) + UNION ALL + SELECT c.carton_mark,c.sku_code,NULL,c.product_quantity,NULL,NULL, + (SELECT JSON_ARRAYAGG(JSON_OBJECT('skuCode',s.sku_code,'quantity',s.product_quantity)) + FROM ll_dropship_carton_sku s WHERE s.customer_id=c.customer_id AND s.carton_id=c.id) + FROM ll_dropship_carton c + JOIN ll_order o ON o.id=c.order_id AND o.customer_id=c.customer_id + JOIN ll_dropship_order_profile p ON p.id=c.profile_id AND p.customer_id=c.customer_id + JOIN ll_inventory_position pos ON pos.id=p.inventory_position_id AND pos.customer_id=c.customer_id + JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=c.customer_id + WHERE c.customer_id=#{customerId} AND o.inbound_code=#{inboundCode} + AND o.overseas_warehouse_id=#{warehouseId} AND c.status='AVAILABLE' + AND p.status='ACTIVE' AND pos.available_packages>0 AND b.available_packages>0 + AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_carton existing + WHERE existing.customer_id=c.customer_id AND existing.order_id=c.order_id + AND existing.carton_mark=c.carton_mark) + AND NOT EXISTS ( + SELECT 1 FROM ll_relabel_item ri + JOIN ll_relabel_application ra ON ra.id=ri.application_id AND ra.customer_id=ri.customer_id + WHERE ri.customer_id=c.customer_id AND ri.source_inbound_code=o.inbound_code + AND ra.overseas_warehouse_id=#{warehouseId} + AND UPPER(TRIM(COALESCE(NULLIF(ri.source_carton_mark,''),ri.carton_mark)))=UPPER(TRIM(c.carton_mark)) + AND ra.status NOT IN ('CANCELLED','REJECTED')) ORDER BY cartonMark """) java.util.List sourceCartons(@Param("customerId") String customerId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java index e2c1b5cb4..3515549e9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferSourceReservationMapper.java @@ -3,11 +3,30 @@ package com.baiyue.lastleg.transfer.mapper; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; +import org.apache.ibatis.annotations.Update; import java.util.List; /** 使用提交单据的原箱快照表达业务占用,不提前扣减实物库存。 */ @Mapper public interface TransferSourceReservationMapper { + /** 在出库事务中按原箱身份更新来源箱,保留箱内件数和清点时间。 */ + @Update(""" + UPDATE ll_warehouse_transfer_carton original + JOIN ll_warehouse_transfer_order origin_order + ON origin_order.id=original.transfer_order_id AND origin_order.customer_id=original.customer_id + JOIN ll_warehouse_transfer_order outgoing + ON outgoing.customer_id=origin_order.customer_id AND outgoing.inbound_code=origin_order.inbound_code + AND outgoing.operating_warehouse_id=origin_order.operating_warehouse_id + JOIN ll_warehouse_transfer_carton selected + ON selected.customer_id=outgoing.customer_id AND selected.transfer_order_id=outgoing.id + AND COALESCE(NULLIF(selected.customer_carton_mark,''),selected.carton_mark)=original.carton_mark + SET original.status='SHIPPED',original.updated_at=CURRENT_TIMESTAMP + WHERE outgoing.customer_id=#{customerId} AND outgoing.id=#{transferOrderId} + AND outgoing.transfer_no<>outgoing.inbound_code AND origin_order.transfer_no=origin_order.inbound_code + AND original.status='COUNTED' + """) + int shipSelectedCartons(@Param("customerId") String customerId, @Param("transferOrderId") Long transferOrderId); + /** 按同一入仓快照串行提交,防止不同草稿同时抢占原箱。 */ @Select("SELECT id FROM ll_inbound_order WHERE customer_id=#{customerId} AND id=#{inboundOrderId} FOR UPDATE") Long lockInbound(@Param("customerId") String customerId, @Param("inboundOrderId") Long inboundOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java index 85fc1c81b..3a0fd34da 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferInventorySourceRepository.java @@ -21,6 +21,11 @@ public class TransferInventorySourceRepository { return reservations.occupiedMarks(customerId, warehouseId, sourceCode); } + /** 按当前转运实际选择的箱唛记录来源箱出库,不按箱数截断历史箱集合。 */ + public int shipSelectedCartons(String customerId, Long transferOrderId) { + return reservations.shipSelectedCartons(customerId, transferOrderId); + } + /** 读取当前来源单未被有效换标申请使用的箱级快照。 */ public List sourceCartons(String customerId, String warehouseId, String inboundCode) { return snapshots.sourceCartons(customerId, warehouseId, inboundCode); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 46d16fc16..ff0b99069 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -1063,8 +1063,13 @@ public class WarehouseTransferService { boolean hasSkuReceiptDifference(List cartons, List items) { Map expected = new java.util.HashMap<>(); Map received = new java.util.HashMap<>(); + Set unknown = new java.util.HashSet<>(); Map byId = items.stream().collect(Collectors.toMap(WarehouseTransferItem::getId, Function.identity())); - for (var item : items) expected.merge(item.getSkuCode(), item.getDeclaredQuantity() == null ? 0L : item.getDeclaredQuantity(), Long::sum); + // 同SKU任一申报缺失时整体不可比较,不能把未知件数视作零制造差异。 + for (var item : items) { + if (item.getDeclaredQuantity() == null) unknown.add(item.getSkuCode()); + else expected.merge(item.getSkuCode(), item.getDeclaredQuantity(), Long::sum); + } for (var carton : cartons) { var contents = cartonContents(carton); if (contents.isEmpty()) { @@ -1075,8 +1080,10 @@ public class WarehouseTransferService { for (var line : contents) received.merge(line.skuCode(), line.quantity(), Long::sum); } } - return expected.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(received.getOrDefault(entry.getKey(), 0L))) - || received.entrySet().stream().anyMatch(entry -> !entry.getValue().equals(expected.getOrDefault(entry.getKey(), 0L))); + return expected.entrySet().stream().anyMatch(entry -> !unknown.contains(entry.getKey()) + && !entry.getValue().equals(received.getOrDefault(entry.getKey(), 0L))) + || received.entrySet().stream().anyMatch(entry -> !unknown.contains(entry.getKey()) + && !entry.getValue().equals(expected.getOrDefault(entry.getKey(), 0L))); } private List requireCountedCartons(WarehouseTransferOrder transfer) { @@ -1520,6 +1527,8 @@ public class WarehouseTransferService { allocation.setStatus("SHIPPED"); allocation.setActivePositionKey(null); allocation.setShippedAt(now); allocation.setUpdatedAt(now); inventoryAllocations.updateById(allocation); } + // 同一事务记录所选原箱已出库,当前库存候选据此排除;签收快照仍完整保留。 + inventorySources.shipSelectedCartons(customerId, transfer.getId()); transfer.setStatus(OUTBOUNDED); transfer.setOutboundIdempotencyKey(key); transfer.setOutboundRequestHash(requestHash); transfer.setOutboundAt(now); transfer.setUpdatedAt(now); transfers.updateById(transfer); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/InternalOrderWorkflowFactController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/InternalOrderWorkflowFactController.java new file mode 100644 index 000000000..81fda6eec --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/InternalOrderWorkflowFactController.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.workflow; +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.springframework.web.bind.annotation.*; +import jakarta.validation.Valid; +import java.util.List; +/** 服务令牌保护的来源事实接口。 */ +@RestController +@RequestMapping("/internal/lastleg/v1/order-workflow-facts") +public class InternalOrderWorkflowFactController { + private final InternalServiceTokenGuard guard; + private final OrderWorkflowFactService service; + /** 注入服务身份守卫。 */ + public InternalOrderWorkflowFactController(InternalServiceTokenGuard guard,OrderWorkflowFactService service) { this.guard=guard;this.service=service; } + /** 身份验证后批量查询,不暴露公网。 */ + @PostMapping + public ApiResponse> read(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,@Valid @RequestBody OrderWorkflowFacts.Request request) { + guard.verify(token);return ApiResponse.success(service.read(request)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java new file mode 100644 index 000000000..dc8dc34e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapper.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.workflow; +import org.apache.ibatis.annotations.*; +import java.util.List; +/** 以不可变来源明细核对出库,取消不再遮挡待排车。 */ +@Mapper +public interface OrderWorkflowFactMapper { + /** 精确核对客户及来源;字符串参数沿用列排序规则,避免连接默认排序规则冲突。 */ + @Select(""" + + """) + List read(@Param("sources") List sources); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactRepository.java new file mode 100644 index 000000000..deb9ba94e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactRepository.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.workflow; +import org.springframework.stereotype.Repository; +import java.util.List; +/** 尾程作业事实持久化入口。 */ +@Repository +public class OrderWorkflowFactRepository { + private final OrderWorkflowFactMapper mapper; + /** 注入事实SQL提供者。 */ + public OrderWorkflowFactRepository(OrderWorkflowFactMapper mapper) { this.mapper=mapper; } + /** 空来源不能扩大查询。 */ + public List read(List sources) { return sources.isEmpty()?List.of():mapper.read(sources); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactService.java new file mode 100644 index 000000000..228de947d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFactService.java @@ -0,0 +1,12 @@ +package com.baiyue.lastleg.workflow; +import org.springframework.stereotype.Service; +import java.util.List; +/** 只读提供国内订单列表所需的真实作业事实。 */ +@Service +public class OrderWorkflowFactService { + private final OrderWorkflowFactRepository repository; + /** 注入领域仓储。 */ + public OrderWorkflowFactService(OrderWorkflowFactRepository repository) { this.repository=repository; } + /** 批量读取精确来源事实。 */ + public List read(OrderWorkflowFacts.Request request) { return repository.read(request.sources()); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFacts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFacts.java new file mode 100644 index 000000000..884e45b29 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/workflow/OrderWorkflowFacts.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.workflow; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +/** 按客户和来源入仓单查询独立作业事实。 */ +public final class OrderWorkflowFacts { + /** 仅作为内部契约命名空间,不允许实例化。 */ + private OrderWorkflowFacts() {} + public record Source(@Positive long tenantId,@Positive long inboundId) { + /** 字符型客户键以字符串绑定,避免隐式数字比较丢失长整型精度。 */ + public String tenantKey() { return Long.toString(tenantId); } + /** 来源入库键保持原值,不通过数据库CAST引入连接排序规则。 */ + public String inboundKey() { return Long.toString(inboundId); } + } + public record Request(@NotEmpty @Size(max=500) List<@NotNull @Valid Source> sources) {} + public record Fact(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {} +} diff --git a/lastleg-service/src/main/resources/openapi/order-workflow-v1.yaml b/lastleg-service/src/main/resources/openapi/order-workflow-v1.yaml new file mode 100644 index 000000000..cb4e55250 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/order-workflow-v1.yaml @@ -0,0 +1,52 @@ +openapi: 3.0.3 +info: {title: 尾程订单作业事实内部查询, version: 1.0.0} +paths: + /internal/lastleg/v1/order-workflow-facts: + post: + summary: 批量查询精确客户和来源入仓单的作业事实 + description: 仅内部服务令牌。最多500个来源,禁止公网访问。无写入。有效出库包含实出库,pendingOutbound仅未取消且未出库。卸柜含真实差异卸柜完成。 + parameters: + - {name: X-Internal-Service-Token, in: header, required: true, schema: {type: string}} + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [sources] + properties: + sources: + type: array + minItems: 1 + maxItems: 500 + items: + type: object + required: [tenantId, inboundId] + properties: + tenantId: {type: integer, format: int64, minimum: 1} + inboundId: {type: integer, format: int64, minimum: 1} + responses: + '200': + description: 标准ApiResponse;不存在的来源不返回行,调用方须检查来源完整性 + content: + application/json: + schema: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: + type: array + items: {$ref: '#/components/schemas/Fact'} +components: + schemas: + Fact: + type: object + required: [tenantId, inboundId, outboundExists, pendingOutbound, delivering, unloaded] + properties: + tenantId: {type: string} + inboundId: {type: string} + outboundExists: {type: boolean} + pendingOutbound: {type: boolean} + delivering: {type: boolean} + unloaded: {type: boolean} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 76d732dd7..8245d27b3 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -4979,7 +4979,7 @@ components: purchaseWarehouseId: { type: integer, format: int64, minimum: 1, nullable: true } ProcurementCreate: type: object - required: [purchaseCategory, productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey] + required: [productName, purchaseQuantity, budgetUnitPrice, currency, receivingAddress, contactName, contactPhone, idempotencyKey] properties: customerName: { type: string, nullable: true, maxLength: 255 } purchaseCategory: { type: string, maxLength: 128 } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/OutboundQuantityRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/OutboundQuantityRepositoryTest.java new file mode 100644 index 000000000..75d1a7fa6 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/OutboundQuantityRepositoryTest.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.delivery; + +import com.baiyue.lastleg.delivery.entity.OutboundOrder; +import com.baiyue.lastleg.delivery.entity.OutboundPalletItem; +import com.baiyue.lastleg.delivery.mapper.OutboundPalletItemMapper; +import com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class OutboundQuantityRepositoryTest { + /** 箱唛事实优先于历史件数,同箱多 SKU、多箱一行和无箱唛库存均须正确计数。 */ + @Test void countsUniqueMarksWithoutDependingOnHistoricPieceMetadata() { + var mapper = mock(OutboundPalletItemMapper.class); + var repository = new OutboundQuantityRepository(); + ReflectionTestUtils.setField(repository, "pallets", mapper); + var outbound = new OutboundOrder(); + outbound.setId(1L); outbound.setCustomerId("C-1"); outbound.setOutboundType("DISCARD_OUTBOUND"); + outbound.setPackageCount(99); + var a = new OutboundPalletItem(); a.setCartonMarks("BOX-1, BOX-2"); a.setPackageCount(5); + var b = new OutboundPalletItem(); b.setCartonMarks("BOX-2,BOX-1"); b.setPackageCount(8); + var unmarked = new OutboundPalletItem(); unmarked.setPackageCount(3); + when(mapper.findByOutbound("C-1", 1L)).thenReturn(List.of(a, b, unmarked)); + assertEquals(5, repository.boxes(outbound)); + assertEquals(99, outbound.getPackageCount()); + outbound.setOutboundType("RETURN_OUTBOUND"); + assertEquals(99, repository.boxes(outbound)); + verify(mapper, times(1)).findByOutbound("C-1", 1L); + verifyNoMoreInteractions(mapper); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index b7a5b9034..9377fa9f5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -133,6 +133,9 @@ class DeliveryTransferServiceTest { @BeforeEach void setUp() { service = new DeliveryTransferService(); + var quantities = new com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository(); + ReflectionTestUtils.setField(quantities, "pallets", outboundPallets); + ReflectionTestUtils.setField(service, "outboundQuantities", quantities); ReflectionTestUtils.setField(service, "deliveryExecution", new DeliveryExecutionGuard(new com.baiyue.lastleg.delivery.repository.DeliveryExecutionRepository(outboundOrders, deliveryOrders))); ReflectionTestUtils.setField(service, "execution", execution); ReflectionTestUtils.setField(service,"cancellations",new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository( @@ -491,6 +494,27 @@ class DeliveryTransferServiceTest { assertEquals("DISCARD_OUTBOUND".equals(type) ? null : "LX-REAL-001", detail.outbound().lxCodes()); } + /** 历史废弃单一箱内的多种 SKU 件数不能累加成出库箱数。 */ + @Test + void discardListAndDetailCountPhysicalCartonsInsteadOfHistoricPieces() { + OutboundOrder outbound = outbound(301L, null, null, "OUT-301", "discard-301"); + outbound.setOutboundType("DISCARD_OUTBOUND"); outbound.setPackageCount(5); + var a = pallet(501L, "P-1", null, null, "RT-1", 2); + var b = pallet(502L, "P-1", null, null, "RT-1", 3); + a.setReturnSkuQuantity(2L); b.setReturnSkuQuantity(3L); + a.setCartonMarks("BOX-1"); b.setCartonMarks("BOX-1"); + when(outboundOrders.findGlobalByNo("OUT-301")).thenReturn(outbound); + when(outboundPallets.findByOutbound("TENANT-A", 301L)).thenReturn(List.of(a, b)); + when(outboundOrders.selectPage(any(Page.class), any())).thenReturn(new Page(1, 10, 1).setRecords(List.of(outbound))); + + assertEquals(1, service.outboundDetail("OUT-301").outbound().packageCount()); + var query = new DeliveryRequests.Query(1, 10, "OUT-301", null, null, null, null, null, null, null, null, null, null, "DISCARD_OUTBOUND"); + assertEquals(1, service.outboundPage(null, query).records().get(0).packageCount()); + assertEquals(5, outbound.getPackageCount()); + assertEquals(2L, a.getReturnSkuQuantity()); assertEquals(3L, b.getReturnSkuQuantity()); + verify(outboundOrders, never()).updateById(any(OutboundOrder.class)); + } + @Test void dispatchOutboundStillRequiresItsDeliveryDocument() { OutboundOrder outbound = outbound(301L, 101L, "DSP-101", "OUT-301", "dispatch-301"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index b26dead8f..58b8ffe06 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -57,6 +57,11 @@ class ReturnOutboundClosureTest { ReturnInventoryCarton carton; @BeforeEach void setup() { + // 取消入口先查询幂等记录,手动组装的测试服务也需要注入该仓储。 + ReflectionTestUtils.setField(service, "cancellations", new com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository( + outbounds, items, deliveries, tasks, mock(DeliveryOperationMapper.class), relabelApplications, cartons, + mock(com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper.class), + mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class), mock(com.baiyue.lastleg.mapper.LastlegOrderStatusHistoryMapper.class))); ReflectionTestUtils.setField(service, "outboundOrders", outbounds); ReflectionTestUtils.setField(service, "outboundPallets", items); ReflectionTestUtils.setField(service, "pdaTasks", tasks); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index 1510f5b3f..9626cdc09 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -54,6 +54,8 @@ class ReturnOutboundPdaPersistenceTest { + (field.getName().equals("id") ? " PRIMARY KEY" : "")).collect(Collectors.joining(",")); jdbc.execute("CREATE TABLE " + entity.getAnnotation(TableName.class).value() + " (" + columns + ")"); } + jdbc.execute("CREATE TABLE ll_cargo_discard_application(id BIGINT,customer_id VARCHAR(2000),overseas_warehouse_id VARCHAR(2000),discard_no VARCHAR(2000))"); + jdbc.execute("CREATE TABLE ll_cargo_discard_item(id BIGINT,application_id BIGINT,customer_id VARCHAR(2000),return_carton_id BIGINT,sku_code VARCHAR(2000),product_name VARCHAR(2000),pda_status VARCHAR(2000),available_packages_snapshot BIGINT,actual_discard_packages BIGINT,pda_operator_id VARCHAR(2000),pda_completed_at TIMESTAMP)"); var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); for (Class mapper : List.of(OutboundOrderMapper.class, OutboundPalletItemMapper.class, OutboundPdaTaskMapper.class, OutboundPdaPickMapper.class, DeliveryOrderMapper.class, ReturnInventoryCartonMapper.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardReturnMarkTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardReturnMarkTest.java new file mode 100644 index 000000000..e829e048b --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardReturnMarkTest.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.discard; + +import com.baiyue.lastleg.discard.model.CargoDiscardReturnMark; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +/** 废弃标签与签收实物标签一致,箱及托盘来源仍使用独立箱唛。 */ +class CargoDiscardReturnMarkTest { + /** 兼容历史 RP 库存记录,不通过字符串替换猜测 PK 编码。 */ + @Test void historicalParcelUsesExactReceiptUnitCode() { + var carton = new ReturnInventoryCarton(); carton.setSourceUnitType("PARCEL"); + carton.setCartonMark("RP-G1OTH7E5RRB6"); carton.setSourceUnitCode("PK-G1OTH7E5RRB5"); + assertEquals("PK-G1OTH7E5RRB5", CargoDiscardReturnMark.of(carton)); + carton.setSourceUnitType("BOX"); assertEquals("RP-G1OTH7E5RRB6", CargoDiscardReturnMark.of(carton)); + carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode(" "); + assertEquals("RP-G1OTH7E5RRB6", CargoDiscardReturnMark.of(carton)); + assertNull(CargoDiscardReturnMark.of(null)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/ReturnDiscardMovementMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/ReturnDiscardMovementMapperTest.java index 844099164..65de01334 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/ReturnDiscardMovementMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/ReturnDiscardMovementMapperTest.java @@ -20,8 +20,7 @@ class ReturnDiscardMovementMapperTest { var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); var db = new JdbcTemplate(source); db.execute("CREATE TABLE ll_cargo_discard_application(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),discard_no VARCHAR(30))"); - db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))"); - db.execute("ALTER TABLE ll_return_inventory_carton ADD carton_mark VARCHAR(30), ADD source_batch_no VARCHAR(30), ADD created_at TIMESTAMP"); + db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20),carton_mark VARCHAR(30),source_batch_no VARCHAR(30),created_at TIMESTAMP)"); db.execute("CREATE TABLE ll_return_inventory_carton_item(carton_id BIGINT,sku_code VARCHAR(30),product_name_zh VARCHAR(30),quantity BIGINT)"); db.execute("CREATE TABLE ll_return_receipt(id BIGINT,return_order_id BIGINT,receipt_no VARCHAR(30),signed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_return_order(id BIGINT,customer_id VARCHAR(20),overseas_warehouse_id VARCHAR(20))"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index f558e0df9..1a310889a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -193,18 +193,19 @@ class PdaCargoDiscardServiceTest { @Test void returnCartonUsesPieceUnitAndRequiresSelectedCartonEvenWhenBoundToPallet() { item.setSourceType("RETURN_CARTON"); item.setReturnCartonId(31L); item.setDiscardPackages(5); var carton = new com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton(); - carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RETURN-BOX-1"); + carton.setCustomerId("CUSTOMER"); carton.setPalletCode("PALLET-1"); carton.setCartonMark("RP-OLD"); + carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-REAL"); when(returnCartons.selectById(31L)).thenReturn(carton); when(applications.selectList(any())).thenReturn(List.of(application)); var task = service.resolve(identity, "PALLET-1", "CD-1"); assertEquals("CARTON", task.scanMode()); - assertEquals(List.of("RETURN-BOX-1"), task.cartonMarks()); + assertEquals(List.of("PK-REAL"), task.cartonMarks()); assertEquals(5, task.skus().get(0).plannedPackages()); assertEquals("件", task.skus().get(0).quantityUnit()); assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(5))); verifyNoInteractions(adjustments); var confirm = new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 5)), - List.of(101L), List.of("RETURN-BOX-1"), false); + List.of(101L), List.of("PK-REAL"), false); service.confirm(identity, "CD-1", "PALLET-1", confirm); assertEquals(1, application.getTotalPackages()); assertEquals(new BigDecimal("5.00"), application.getTotalFee()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java index 4a9d22b56..625dfc82d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java @@ -94,9 +94,10 @@ class CargoDiscardOutboundServiceTest { @Test void returnSourceKeepsRealCartonMarkWithoutInventingPalletNumber() { first.setReturnCartonId(31L); first.setSourceType("RETURN_CARTON"); var carton = new ReturnInventoryCarton(); carton.setId(31L); carton.setCustomerId("C1"); - carton.setOverseasWarehouseId("99"); carton.setCartonMark("BOX-31"); + carton.setOverseasWarehouseId("99"); carton.setCartonMark("RP-OLD"); + carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-REAL"); when(returnCartons.selectById(31L)).thenReturn(carton); - service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("BOX-31", snapshots.get(0).getCartonMarks()); + service.create(application, "OP"); assertNull(snapshots.get(0).getPalletNo()); assertEquals("PK-REAL", snapshots.get(0).getCartonMarks()); assertEquals(3L, snapshots.get(0).getReturnSkuQuantity()); } @Test void invalidSourcePreventsAnyOutboundInsert() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index 1915af51e..1daa421b3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -47,6 +47,26 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class CargoDiscardServiceTest { + /** 历史包裹候选必须沿用签收 PK 码,不能展示内部 RP 码。 */ + @Test + void parcelCandidatesAndDetailsUseReceiptLabel() { + var carton = new ReturnInventoryCarton(); carton.setId(901L); carton.setCustomerId("100"); + carton.setOverseasWarehouseId("99"); carton.setSourceReturnNo("TT-BYWL-261019180631"); + carton.setSourceUnitType("PARCEL"); carton.setSourceUnitCode("PK-G1OTH7E5RRB5"); + carton.setCartonMark("RP-G1OTH7E5RRB6"); carton.setStatus("AVAILABLE"); + var sku = new ReturnInventoryCartonItem(); sku.setSkuCode("SKU"); sku.setQuantity(6L); + when(returnCartons.findAvailable("100", null)).thenReturn(List.of(carton)); + when(returnCartons.selectById(901L)).thenReturn(carton); + when(returnCartonItems.listCarton(901L)).thenReturn(List.of(sku)); + var candidates = service.candidates("100", "PK-G1OTH7E5RRB5"); + assertEquals(1, candidates.size()); assertEquals("PK-G1OTH7E5RRB5", candidates.get(0).cartonMark()); + assertEquals("PK-G1OTH7E5RRB5", candidates.get(0).cartons().get(0).cartonMark()); + storedApplication.set(pendingApplication()); + var line = item(); line.setSourceType("RETURN_CARTON"); line.setReturnCartonId(901L); storedItems.add(line); + var detail = service.detail("100", "FH-1"); + assertEquals(List.of("PK-G1OTH7E5RRB5"), detail.items().get(0).cartonMarks()); + } + @Test void customerConfirmationClosesPdaCompletedApplicationOnceWithoutAnotherDeduction() { CargoDiscardApplication application = new CargoDiscardApplication(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java index d2da844a0..777b3d501 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java @@ -112,20 +112,24 @@ class CargoDiscardStockServiceTest { verifyNoInteractions(commands, items, links); } - @Test void singlePositionTransferBoxesReleaseBackToTheirOriginalExpectedStatus() { + @Test void receivedTransferBoxesAreSelectableAndReleaseBackToCountedStatus() { position.setAvailablePackages(2L); balance.setAvailablePackages(2L); var first = carton(1L, "WT-1#2-1"); first.setSourceType("TRANSFER"); var second = carton(2L, "WT-1#2-2"); second.setSourceType("TRANSFER"); when(stock.genericCartons("C1", 42L)).thenReturn(List.of(first, second)); - when(stock.changeTransferCartonStatus("C1", 2L, "EXPECTED", "DISCARD_PENDING")).thenReturn(1); + when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position)); + var candidate = service.candidates("C1", "WT").get(0); + assertTrue(candidate.cartonSelectionSupported()); + assertEquals(List.of("WT-1#2-1", "WT-1#2-2"), candidate.cartons().stream().map(box -> box.cartonMark()).toList()); + when(stock.changeTransferCartonStatus("C1", 2L, "COUNTED", "DISCARD_PENDING")).thenReturn(1); service.reserve("C1", "OP", application, new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(2L)), LocalDateTime.now()); var capture = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(capture.capture()); assertEquals("TRANSFER", capture.getValue().getSourceType()); when(links.listApplication("C1", 11L)).thenReturn(List.of(capture.getValue())); - when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED")).thenReturn(1); + when(stock.changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "COUNTED")).thenReturn(1); var line = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(line.capture()); service.release("C1", application, line.getValue()); - verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "EXPECTED"); + verify(stock).changeTransferCartonStatus("C1", 2L, "DISCARD_PENDING", "COUNTED"); verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java index 9669a8e8a..7dd703a09 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/ProcurementRequestBoundaryTest.java @@ -10,6 +10,19 @@ import java.util.List; import static org.assertj.core.api.Assertions.*; class ProcurementRequestBoundaryTest { + @Test void purchaseCategoryMayBeOmittedOrBlankButCannotExceedLimit() throws Exception { + var json = new ObjectMapper(); + try (var factory = jakarta.validation.Validation.buildDefaultValidatorFactory()) { + var validator = factory.getValidator(); + for (String category : List.of("null", "\"\"", "\"sample\"")) { + var request = json.readValue("{\"purchaseCategory\":" + category + "}", OverseasProcurementRequests.Create.class); + assertThat(validator.validate(request)).noneMatch(v -> "purchaseCategory".equals(v.getPropertyPath().toString())); + } + var oversized = json.readValue("{\"purchaseCategory\":\"" + "x".repeat(129) + "\"}", OverseasProcurementRequests.Create.class); + assertThat(validator.validate(oversized)).anyMatch(v -> "purchaseCategory".equals(v.getPropertyPath().toString())); + } + } + @Test void createRejectsMultiProductAndBatchExtensionsWithApplicationJacksonDefaults() { new org.springframework.boot.test.context.runner.ApplicationContextRunner() .withConfiguration(org.springframework.boot.autoconfigure.AutoConfigurations.of( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index 93396c04a..12e253f98 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -185,7 +185,7 @@ class ReturnInventoryServiceTest { var receipt = new ReturnReceipt(); receipt.setId(21L); receipt.setUnitType("PARCEL"); receipt.setUnitCode("PK-1"); var source = service.createReceiptCarton(completedOrder(), receipt, null, List.of(new ReturnInventoryService.ReceiptLine("A", "商品A", 3))); - assertThat(source.getCartonMark()).startsWith("RP-").hasSizeLessThanOrEqualTo(16); + assertThat(source.getCartonMark()).isEqualTo("PK-1"); assertThat(source.getSourceUnitType()).isEqualTo("PARCEL"); verifyNoInteractions(numbers); assertThat(source.getLabelStatus()).isEqualTo("CONFIRMED"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index d67a66ccc..99f173a76 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -56,6 +56,16 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) class ReturnReceiptServiceTest { + @Test void rejectsUnprintablePhysicalCodesBeforeReceiptWrites() { + for (String code : List.of("中文包裹", "PK-中文")) { + var request = new PdaReturnReceiptRequests.Confirm("RT-1", null, "RETURN_NO", null, + List.of(new PdaReturnReceiptRequests.Item("SKU1", 1)), "INVALID-CODE", "PARCEL", code, + java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, null, true); + assertThat(assertThrows(ResponseStatusException.class, () -> service.confirm(identity, request)).getReason()) + .contains("实物编码无法生成条码"); + } + verifyNoInteractions(orders, receipts, orderItems, receiptItems, inventory); + } @Test void countedCargoReplacesForecastTotalsAndRepeatedSyncUpdatesTheSameDetail() { var details = org.mockito.Mockito.mock(com.baiyue.lastleg.mapper.LastlegOrderDetailMapper.class); ReflectionTestUtils.setField(service, "lastlegOrderDetails", details); @@ -268,6 +278,8 @@ class ReturnReceiptServiceTest { assertEquals("RL-MULTI", order.getRelabelNo()); var application = ArgumentCaptor.forClass(RelabelApplication.class); verify(relabelApplications).insert(application.capture()); + assertEquals("", application.getValue().getContactName()); + assertEquals("", application.getValue().getContactPhone()); assertEquals(firstQuantity + secondQuantity, application.getValue().getNotifiedPackageCount().longValue()); assertEquals(2, application.getValue().getItemCount()); var lines = ArgumentCaptor.forClass(RelabelItem.class); @@ -637,3 +649,4 @@ class ReturnReceiptServiceTest { "BOX", palletCode, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, java.math.BigDecimal.ONE, 1L, true); } } + diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java index e3696f080..a15d0d10c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferInventorySourceTest.java @@ -36,6 +36,16 @@ class TransferInventorySourceTest { db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR,status VARCHAR,customer_confirmed_at TIMESTAMP)"); db.execute("CREATE TABLE ll_relabel_item(application_id BIGINT,customer_id VARCHAR,source_inbound_code VARCHAR,source_carton_mark VARCHAR,carton_mark VARCHAR)"); db.execute("CREATE TABLE ll_relabel_pda_unit_fact(item_id BIGINT,result VARCHAR)"); + db.execute("CREATE TABLE ll_dropship_carton(id BIGINT,customer_id VARCHAR,order_id BIGINT,profile_id BIGINT,carton_mark VARCHAR,sku_code VARCHAR,product_quantity BIGINT,status VARCHAR)"); + db.execute("CREATE TABLE ll_dropship_carton_sku(customer_id VARCHAR,carton_id BIGINT,sku_code VARCHAR,product_quantity BIGINT)"); + db.execute("CREATE TABLE ll_dropship_order_profile(id BIGINT,customer_id VARCHAR,inventory_position_id BIGINT,status VARCHAR)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,available_packages BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,available_packages BIGINT)"); + db.update("INSERT INTO ll_dropship_order_profile VALUES(101,'A',201,'ACTIVE')"); + db.update("INSERT INTO ll_inventory_position VALUES(201,'A',301,2)"); + db.update("INSERT INTO ll_inventory_balance VALUES(301,'A',2)"); + db.update("INSERT INTO ll_dropship_carton VALUES(401,'A',1,101,'DROPSHIP','SKU',5,'AVAILABLE'),(402,'A',1,101,'SHIPPED','SKU',5,'OUTBOUNDED')"); + db.update("INSERT INTO ll_dropship_carton_sku VALUES('A',401,'SKU',3),('A',401,'SKU-B',2)"); db.update("INSERT INTO ll_order VALUES(1,'A','IN','WH'),(2,'B','IN','WH'),(3,'A','IN','OTHER'),(4,'A','OTHER','WH')"); db.update("INSERT INTO ll_inbound_plan_carton VALUES('A',1,'FIRST','S','ONT8','Amazon'),('A',1,'RELABELED','S','ONT8','Amazon'),('B',2,'FOREIGN','S','ONT8','Amazon'),('A',3,'WRONG-WH','S','ONT8','Amazon'),('A',4,'WRONG-IN','S','ONT8','Amazon'),('B',1,'FORGED','S','ONT8','Amazon')"); db.update("INSERT INTO ll_relabel_application VALUES(51,'A','WH','COMPLETED',CURRENT_TIMESTAMP()),(52,'A','WH','PROCESSING',NULL),(53,'A','WH','CANCELLED',NULL),(54,'A','WH','REJECTED',NULL),(55,'B','WH','COMPLETED',CURRENT_TIMESTAMP()),(56,'A','OTHER','COMPLETED',CURRENT_TIMESTAMP())"); @@ -47,9 +57,13 @@ class TransferInventorySourceTest { var config=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(InboundSnapshotMapper.class); try(var session=new SqlSessionFactoryBuilder().build(config).openSession()) { var result=session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN"); - assertEquals(List.of("FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); - assertEquals("ONT8",result.get(0).destinationWarehouseCode()); - assertNull(result.get(0).quantity()); assertEquals(20L,result.get(1).quantity()); + assertEquals(List.of("DROPSHIP", "FIRST", "MEASURED"),result.stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); + assertEquals(5L,result.get(0).quantity()); assertTrue(result.get(0).skuItemsJson().contains("SKU")); + assertTrue(result.get(0).skuItemsJson().contains("SKU-B"), "混装箱内所有 SKU 均须保留,箱子只返回一次"); + assertEquals("ONT8",result.get(1).destinationWarehouseCode()); + assertNull(result.get(1).quantity()); assertEquals(20L,result.get(2).quantity()); + db.update("UPDATE ll_inventory_position SET available_packages=0"); session.clearCache(); + assertEquals(List.of("FIRST", "MEASURED"),session.getMapper(InboundSnapshotMapper.class).sourceCartons("A","WH","IN").stream().map(InboundSnapshotMapper.SourceCarton::cartonMark).toList()); } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index eeb0558c9..3beaf15b2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -175,6 +175,18 @@ class WarehouseTransferCartonCountTest { ia.setDeclaredQuantity(4L); assertTrue(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib))); } + /** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */ + @Test void missingDeclarationIsUnknownButKnownSkuDifferenceStillRequiresReason() { + var unknown = new WarehouseTransferItem(); unknown.setId(10L); unknown.setSkuCode("A"); + var known = new WarehouseTransferItem(); known.setId(11L); known.setSkuCode("B"); known.setDeclaredQuantity(7L); + a.setSkuItemsJson("[{\"transferItemId\":10,\"skuCode\":\"A\",\"quantity\":4},{\"transferItemId\":11,\"skuCode\":\"B\",\"quantity\":7}]"); + assertFalse(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known))); + known.setDeclaredQuantity(8L); + assertTrue(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known))); + known.setDeclaredQuantity(7L); unknown.setDeclaredQuantity(0L); + assertTrue(service.hasSkuReceiptDifference(List.of(a), List.of(unknown, known))); + } + /** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */ @Test void extraReceiptRequiresForecastCartonFirst() { var extra = new WarehouseTransferRequests.CountCarton(null,11L,200L,new BigDecimal("10"),new BigDecimal("20"),new BigDecimal("30"),BigDecimal.ONE,List.of(new WarehouseTransferRequests.CountSku("B",4))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index abab245b1..c3ec0bb4e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -521,6 +521,8 @@ class WarehouseTransferServiceTest { transfer.setInboundCode("IN-1"); transfer.setNeedRelabel(true); transfer.setPackageCount(1); + var selected = new WarehouseTransferCarton(); + selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX"); when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")) .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null))); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java new file mode 100644 index 000000000..6daca4429 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowFactMapperTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.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.util.List; +import static org.assertj.core.api.Assertions.*; +/** 通过真实Mapper验证出库取消和客户隔离,使用隔离内存库。 */ +class OrderWorkflowFactMapperTest { + /** 有效出库、取消、实出库和差异卸柜均由持久化事实决定。 */ + @Test void cancellationAndCustomerAttribution() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workflow-"+java.util.UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); + var db=new JdbcTemplate(ds); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(30),source_inbound_id VARCHAR(30),source_region VARCHAR(20),order_type VARCHAR(30))"); + db.execute("CREATE TABLE ll_outbound_pallet_item(outbound_order_id BIGINT,customer_id VARCHAR(30),source_order_id BIGINT,destination_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30),dispatch_item_id BIGINT)"); + db.execute("CREATE TABLE ll_outbound_order(id BIGINT,customer_id VARCHAR(30),status VARCHAR(30),dispatch_order_id BIGINT)"); + db.execute("CREATE TABLE ll_delivery_order(outbound_order_id BIGINT,customer_id VARCHAR(30),status VARCHAR(30),dispatched_at TIMESTAMP,destination_warehouse_code VARCHAR(30),destination_warehouse_id VARCHAR(30),destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,customer_id VARCHAR(30),dispatch_order_id BIGINT,destination_type VARCHAR(20),delivery_address VARCHAR(100),postal_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inbound_plan(customer_id VARCHAR(30),source_order_id BIGINT,status VARCHAR(40))"); + db.update("INSERT INTO ll_order VALUES(1,'11','101','FIRSTLEG','FBA'),(2,'22','101','FIRSTLEG','FBA')"); + db.update("INSERT INTO ll_outbound_order VALUES(9,'11','WAIT_OUTBOUND',50),(10,'22','WAIT_OUTBOUND',51)"); + db.update("INSERT INTO ll_outbound_pallet_item VALUES(9,'11',1,'FBA1','3',60),(10,'22',2,'FBA1','3',61)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(OrderWorkflowFactMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(OrderWorkflowFactMapper.class); + var sources=List.of(new OrderWorkflowFacts.Source(11,101)); + assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.tenantId()).isEqualTo(11);assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isTrue();}); + db.update("UPDATE ll_outbound_order SET status='CANCELLED' WHERE id=9"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isFalse();assertThat(f.pendingOutbound()).isFalse();}); + db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED' WHERE id=9"); + db.update("INSERT INTO ll_delivery_order VALUES(9,'11','DELIVERING',CURRENT_TIMESTAMP,'FBA1','3','FBA',NULL,NULL)"); + db.update("INSERT INTO ll_inbound_plan VALUES('11',1,'PENDING_RECONCILIATION')"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->{assertThat(f.outboundExists()).isTrue();assertThat(f.pendingOutbound()).isFalse();assertThat(f.delivering()).isTrue();assertThat(f.unloaded()).isTrue();}); + db.update("UPDATE ll_delivery_order SET destination_type='PRIVATE',destination_warehouse_code=NULL,destination_warehouse_id=NULL,delivery_address='Private A',postal_code='10001'"); + db.update("INSERT INTO ll_dispatch_item VALUES(60,'11',50,'PRIVATE','Private A','10001')"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isTrue()); + db.update("UPDATE ll_dispatch_item SET customer_id='22'"); + assertThat(mapper.read(sources)).singleElement().satisfies(f->assertThat(f.delivering()).isFalse()); + assertThat(mapper.read(List.of(new OrderWorkflowFacts.Source(33,101)))).isEmpty(); + // 相邻雪花ID超过浮点安全范围,必须按字符串精确匹配客户和来源。 + db.update("INSERT INTO ll_order VALUES(3,'2092814835845664769','2092814880129130499','FIRSTLEG','FBA'),(4,'2092814835845664768','2092814880129130499','FIRSTLEG','FBA'),(5,'2092814835845664769','2092814880129130498','FIRSTLEG','FBA')"); + assertThat(mapper.read(List.of(new OrderWorkflowFacts.Source(2092814835845664769L,2092814880129130499L)))) + .singleElement().satisfies(f->{assertThat(f.tenantId()).isEqualTo(2092814835845664769L);assertThat(f.inboundId()).isEqualTo(2092814880129130499L);}); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowSecurityTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowSecurityTest.java new file mode 100644 index 000000000..a1333f9b1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/workflow/OrderWorkflowSecurityTest.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.workflow; + +import com.baiyue.lastleg.security.InternalServiceTokenGuard; +import org.junit.jupiter.api.Test; +import java.util.List; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 内部事实入口不能被缺失或错误服务身份读取。 */ +class OrderWorkflowSecurityTest { + /** 无效服务令牌在仓储查询前被拒绝。 */ + @Test void rejectsInvalidServiceIdentityBeforeReadingFacts() { + var service=mock(OrderWorkflowFactService.class); + var controller=new InternalOrderWorkflowFactController(new InternalServiceTokenGuard("test-service-token"),service); + var request=new OrderWorkflowFacts.Request(List.of(new OrderWorkflowFacts.Source(11,101))); + assertThatThrownBy(()->controller.read(null,request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + assertThatThrownBy(()->controller.read("wrong-token",request)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + verifyNoInteractions(service); + when(service.read(request)).thenReturn(List.of()); + assertThat(controller.read("test-service-token",request).successful()).isTrue(); + verify(service).read(request); + } +} diff --git a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java index 091c70060..ea30fd016 100644 --- a/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java +++ b/order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java @@ -24,6 +24,18 @@ public interface FirstlegUnifiedOrderClient { record WorkbenchFilters(List matchingStatusOrderIds, List feeOrderIds, List customsOrderIds, List progress) {} + /** 当前页候选订单按原筛选批量读取摘要。 */ + @PostMapping("/status-summaries") + ApiResponse> statusSummaries(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Query request); + + /** 批量读取符合筛选的去重订单状态,不返回履约明细。 */ + @PostMapping("/status-rows") + ApiResponse> statusRows(@RequestHeader("X-Internal-Service-Token") String token, + @RequestBody Query request); + + record StatusRow(Long tenantId, Long orderId, String mainStatus) {} + @PostMapping("/query") ApiResponse> page(@RequestHeader("X-Internal-Service-Token") String token, @RequestBody Query request); diff --git a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java index 044907daa..eabd6858f 100644 --- a/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java +++ b/order/src/main/java/com/baiyue/order/controller/PlatformUnifiedOrderController.java @@ -5,6 +5,7 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderStatusCountQuery; import com.baiyue.order.dto.UnifiedOrderResponse; import com.baiyue.order.dto.OrderRequests; import com.baiyue.order.dto.OrderDeclarationDtos; @@ -52,6 +53,19 @@ public class PlatformUnifiedOrderController { return ApiResponse.success(service.platformPage(tenantId, query)); } + /** 与订单列表共用平台权限及客户范围,不提供全部汇总数量。 */ + @GetMapping("/status-counts") + @PreAuthorize("hasAuthority('platform:order:list')") + @Operation(summary = "统一订单页签数量", description = "忽略mainStatus和分页,按其余列表筛选全量去重统计;不返回全部数量。") + public ApiResponse statusCounts( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) Long tenantId, + @Valid UnifiedOrderStatusCountQuery query) { + OrderScopeGuard.platform(user); + if (tenantId != null) OrderScopeGuard.platform(user, tenantId); + return ApiResponse.success(service.statusCounts(tenantId, query.toOrderQuery())); + } + @GetMapping("/{orderId}") @PreAuthorize("hasAuthority('platform:order:list')") @Operation(summary = "统一订单详情", description = "合并订单和履约时间线;平台响应包含内部备注。") diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java index 39fd95ede..f3cba0051 100644 --- a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderQuery.java @@ -13,7 +13,7 @@ public record UnifiedOrderQuery( @Size(max = 64) String keyword, @Size(max = 64) String orderNo, @Schema(allowableValues = {"PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", - "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED"}) + "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED", "WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW"}) @Size(max = 32) String mainStatus, Long customerId, Long receivingWarehouseId, diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index d329f517b..f1303b094 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -297,9 +297,28 @@ public class OrderRepository { return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } - public PageResult unifiedPage(Long tenantId, UnifiedOrderQuery query, - List statuses) { - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + /** 统一订单列表按共享筛选分页。 */ + public PageResult unifiedPage(Long tenantId, UnifiedOrderQuery query, List statuses) { + return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), unifiedFilter(tenantId, query, statuses))); + } + + /** 全量轻量投影只读取订单身份和权威交易状态。 */ + public List unifiedStatusOrders(Long tenantId, UnifiedOrderQuery query, List statuses) { + return orderMapper.selectList(unifiedFilter(tenantId, query, statuses) + .select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getStatus)); + } + + /** 状态覆盖只接受本客户范围内未删除订单的权威状态。 */ + public List statusAuthorities(Long tenantId, List orderIds) { + if (orderIds.isEmpty()) return List.of(); + return orderMapper.selectList(new LambdaQueryWrapper() + .select(OrderEntity::getId, OrderEntity::getTenantId, OrderEntity::getStatus) + .eq(tenantId != null, OrderEntity::getTenantId, tenantId).in(OrderEntity::getId, orderIds)); + } + + /** 列表和统计共用非分页筛选及客户隔离。 */ + private LambdaQueryWrapper unifiedFilter(Long tenantId, UnifiedOrderQuery query, List statuses) { + return new LambdaQueryWrapper() .eq(tenantId != null, OrderEntity::getTenantId, tenantId) .eq(query.taxMode() != null, OrderEntity::getTaxMode, query.taxMode()) .in(OrderEntity::getStatus, statuses) @@ -325,7 +344,6 @@ public class OrderRepository { .like(OrderEntity::getOrderNo, query.keyword()) .or().like(OrderEntity::getCustomerName, query.keyword())) .orderByDesc(OrderEntity::getCreatedAt).orderByDesc(OrderEntity::getId); - return PageResult.from(orderMapper.selectPage(new Page<>(query.page(), query.size()), wrapper)); } public List findOrderIdsBySkuNo(Long tenantId, String fbaCode) { diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index 3c5805787..8bbb704ed 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -42,6 +42,7 @@ public class UnifiedOrderQueryService { private static final Set MAIN_STATUSES = Set.of( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED", "COMPLETED"); + private static final List WORKFLOW_STATUSES = List.of("WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW"); private static final Set REVIEW_STATUSES = Set.of("UNRECORDED", "PENDING", "APPROVED", "REJECTED"); private static final List ACTIVE_ORDER_STATUSES = List.copyOf(EnumSet.of( OrderStatus.SUBMITTED, OrderStatus.APPROVED, OrderStatus.ACCEPTED, @@ -60,6 +61,102 @@ public class UnifiedOrderQueryService { return page(tenantId, query, true); } + /** 页签统计忽略主状态和分页,返回筛选全量订单的准确去重数量。 */ + public StatusCounts statusCounts(Long tenantId, UnifiedOrderQuery query) { + query = withoutMainStatus(query); + validate(query); + Map counts = new java.util.LinkedHashMap<>(); + for (String key : List.of("PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "WAIT_SPLIT", + "LOADING", "LOADED", "DELIVERED", "COMPLETED")) counts.put(key, 0L); + WORKFLOW_STATUSES.forEach(key -> counts.put(key, 0L)); + boolean operational = requiresFirstlegQuery(query); + if (!operational) { + // 审核与完成由 Order 权威状态决定,不依赖 Firstleg 是否存在投影。 + for (OrderEntity order : orders.unifiedStatusOrders(tenantId, query, + List.of(OrderStatus.SUBMITTED, OrderStatus.COMPLETED))) { + counts.merge(orderMainStatus(order.getStatus()), 1L, Long::sum); + } + } + for (FirstlegUnifiedOrderClient.StatusRow row : matchingStatusRows(tenantId, query)) { + String status = row.mainStatus(); + if (!operational && Set.of("PENDING_REVIEW", "COMPLETED").contains(status)) continue; + if (counts.containsKey(status)) counts.merge(status, 1L, Long::sum); + } + return new StatusCounts(java.util.Collections.unmodifiableMap(counts)); + } + + public record StatusCounts( + @com.fasterxml.jackson.databind.annotation.JsonSerialize(contentUsing = CountNumberSerializer.class) + Map counts) {} + + /** 数量保持 JSON number;不能被全局雪花 ID 的 Long 字符串序列化规则改写。 */ + public static final class CountNumberSerializer extends com.fasterxml.jackson.databind.JsonSerializer { + /** 按数值输出数量,不影响系统其他 Long ID 的安全传输。 */ + @Override + public void serialize(Long value, com.fasterxml.jackson.core.JsonGenerator generator, + com.fasterxml.jackson.databind.SerializerProvider provider) throws java.io.IOException { + generator.writeNumber(value); + } + } + + /** 统计不受当前页签及分页字段影响;其他条件原样保留。 */ + private UnifiedOrderQuery withoutMainStatus(UnifiedOrderQuery query) { + return new UnifiedOrderQuery(query.keyword(), query.orderNo(), null, query.customerId(), + query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), + query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), + query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), + query.measurementReviewStatus(), query.fbaCode(), query.createdFrom(), query.createdTo(), 1, 200, query.taxMode()); + } + + /** 在分页前按订单去重并覆盖审核/完成状态,不能用履约快照冒充订单权威状态。 */ + private List matchingStatusRows(Long tenantId, UnifiedOrderQuery query) { + List candidates = StringUtils.hasText(query.fbaCode()) + ? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null; + if (candidates != null && candidates.isEmpty()) return List.of(); + var request = statusRequest(tenantId, withoutMainStatus(query), candidates); + List source = require( + call(() -> firstleg.statusRows(internalToken, request)), "统一订单状态查询响应无效"); + Map distinct = new java.util.LinkedHashMap<>(); + for (var row : source) { + if (row == null || row.orderId() == null || row.tenantId() == null + || tenantId != null && !tenantId.equals(row.tenantId())) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "统一订单状态查询响应无效"); + } + distinct.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row); + } + Map authority = new HashMap<>(); + List ids = distinct.values().stream().map(FirstlegUnifiedOrderClient.StatusRow::orderId).distinct().toList(); + // 有界批量读取权威状态,避免逐票远程调用及超长 IN 条件。 + for (int offset = 0; offset < ids.size(); offset += 500) { + orders.statusAuthorities(tenantId, ids.subList(offset, Math.min(offset + 500, ids.size()))) + .forEach(order -> authority.put(order.getId(), order)); + } + List result = new ArrayList<>(); + for (var row : distinct.values()) { + OrderEntity order = authority.get(row.orderId()); + if (order == null || !Objects.equals(order.getTenantId(), row.tenantId()) + || !ACTIVE_ORDER_STATUSES.contains(order.getStatus())) continue; + String status = order.getStatus() == OrderStatus.SUBMITTED ? "PENDING_REVIEW" + : order.getStatus() == OrderStatus.COMPLETED && !WORKFLOW_STATUSES.contains(row.mainStatus()) ? "COMPLETED" : row.mainStatus(); + if (status != null) result.add(new FirstlegUnifiedOrderClient.StatusRow(row.tenantId(), row.orderId(), status)); + } + // 同一订单的多个分片覆盖为待审核/已完成后仍只计一次。 + Map uniqueStatuses = new java.util.LinkedHashMap<>(); + result.forEach(row -> uniqueStatuses.putIfAbsent(row.orderId() + ":" + row.mainStatus(), row)); + return List.copyOf(uniqueStatuses.values()); + } + + /** 状态投影和当前页摘要共用原筛选,候选订单用于服务内批量收敛范围。 */ + private FirstlegUnifiedOrderClient.Query statusRequest(Long tenantId, UnifiedOrderQuery query, List candidates) { + String status = Set.of("PENDING_REVIEW", "COMPLETED").contains( + query.mainStatus() == null ? "" : query.mainStatus()) ? null : query.mainStatus(); + return new FirstlegUnifiedOrderClient.Query(tenantId, query.keyword(), query.orderNo(), status, + query.customerId(), query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), + query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), + query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), + query.measurementReviewStatus(), candidates, query.createdFrom(), query.createdTo(), 1, 200, query.taxMode()); + } + /** 平台导出选择项按业务订单号读取;不暴露内部 ID 作为选择契约。 */ public List platformRowsByOrderNos(List orderNos) { if (orderNos == null || orderNos.isEmpty()) return List.of(); @@ -83,13 +180,11 @@ public class UnifiedOrderQueryService { return selected.stream().map(order->row(order,summaries.get(order.getId()),null,children,cargo.get(order.getId()))).toList(); } + /** 依条件选择订单查询或履约筛选,保留订单审核优先规则。 */ private PageResult page(Long tenantId, UnifiedOrderQuery query, boolean includeCoLoadGroups) { validate(query); if (requiresFirstlegQuery(query)) { - if ("PENDING_REVIEW".equals(query.mainStatus())) { - return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); - } return pageFromFirstleg(tenantId, query, includeCoLoadGroups); } List statuses = "PENDING_REVIEW".equals(query.mainStatus()) @@ -162,25 +257,33 @@ public class UnifiedOrderQueryService { return new UnifiedOrderResponse.Detail(summary, orderDetail, timeline, fragments); } + /** 先校正权威状态并去重,再对匹配订单分页和批量补齐当前页摘要。 */ private PageResult pageFromFirstleg(Long tenantId, UnifiedOrderQuery query, boolean includeCoLoadGroups) { - List candidateOrderIds = StringUtils.hasText(query.fbaCode()) - ? orders.findOrderIdsBySkuNo(tenantId, query.fbaCode()) : null; - if (candidateOrderIds != null && candidateOrderIds.isEmpty()) { - return new PageResult<>(List.of(), query.page(), query.size(), 0, 0); - } - FirstlegUnifiedOrderClient.Query request = new FirstlegUnifiedOrderClient.Query( - tenantId, query.keyword(), query.orderNo(), query.mainStatus(), query.customerId(), - query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), - query.internalOrderStatus(), query.expectedArrivalDate(), query.feeReviewStatus(), query.channelName(), - query.containerNo(), query.cabinetNo(), query.sailingDate(), query.loaded(), query.fullContainer(), - query.measurementReviewStatus(), candidateOrderIds, query.createdFrom(), query.createdTo(), - query.page(), query.size(), query.taxMode()); - PageResult source = require( - call(() -> firstleg.page(internalToken, request)), "统一订单履约查询响应无效"); - List summaries = validateSummaries( - source.records(), "统一订单履约查询响应无效"); - List orderIds = summaries.stream().map(item -> item.inbound().orderId()).toList(); + List matching = matchingStatusRows(tenantId, query).stream() + .filter(row -> !StringUtils.hasText(query.mainStatus()) || query.mainStatus().equals(row.mainStatus())) + .collect(Collectors.collectingAndThen(Collectors.toMap(FirstlegUnifiedOrderClient.StatusRow::orderId, + Function.identity(), (left, right) -> left, java.util.LinkedHashMap::new), + values -> List.copyOf(values.values()))); + long total = matching.size(); + long start = query.page() - 1 > Long.MAX_VALUE / query.size() + ? total : Math.min((query.page() - 1) * query.size(), total); + List orderIds = matching.subList((int) start, (int) Math.min(start + query.size(), total)) + .stream().map(FirstlegUnifiedOrderClient.StatusRow::orderId).toList(); + Map byOrder = orderIds.isEmpty() ? Map.of() + : validateSummaries(require(call(() -> firstleg.statusSummaries(internalToken, + statusRequest(tenantId, query, orderIds))), "统一订单履约摘要响应无效"), + "统一订单履约摘要响应无效").stream().collect(Collectors.toMap( + item -> item.inbound().orderId(), Function.identity(), (left, right) -> left)); + Map expectedTenants = matching.stream().collect(Collectors.toMap( + FirstlegUnifiedOrderClient.StatusRow::orderId, FirstlegUnifiedOrderClient.StatusRow::tenantId)); + List summaries = orderIds.stream().map(id -> { + var summary = byOrder.get(id); + if (summary == null || !Objects.equals(summary.inbound().tenantId(), expectedTenants.get(id))) { + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "统一订单履约摘要缺失或客户不匹配"); + } + return summary; + }).toList(); Map orderById = orders.findByIds(tenantId, orderIds).stream() .collect(Collectors.toMap(OrderEntity::getId, Function.identity(), (left, right) -> left)); Map cargoSummaries = cargoSummaries(tenantId, List.copyOf(orderById.values())); @@ -193,7 +296,8 @@ public class UnifiedOrderQueryService { cargoSummaries.get(summary.inbound().orderId()), coLoadByOrder.get(summary.inbound().orderId()))) .toList(); - return new PageResult<>(records, source.page(), source.size(), source.total(), source.pages()); + return new PageResult<>(records, query.page(), query.size(), total, + total / query.size() + (total % query.size() == 0 ? 0 : 1)); } private Map summaries(Long tenantId, List orderIds) { @@ -391,14 +495,17 @@ public class UnifiedOrderQueryService { order.getCreatedAt(), order.getUpdatedAt(), inbound.requestedPickupAt()); } + /** 将当前页匹配履约记录映射为订单行,审核与完成优先使用 Order 权威状态。 */ private UnifiedOrderResponse.Row row(FirstlegUnifiedOrderClient.Summary summary, OrderEntity order, String latestInternalNote, Map childCounts, CargoSummary cargo, UnifiedOrderResponse.CoLoadGroupSummary coLoadGroup) { var inbound = summary.inbound(); boolean completed = order != null && order.getStatus() == OrderStatus.COMPLETED; + boolean pending = order != null && order.getStatus() == OrderStatus.SUBMITTED; return new UnifiedOrderResponse.Row(inbound.orderId(), order == null ? null : order.getVersion(), inbound.tenantId(), inbound.orderNo(), - completed ? "COMPLETED" : summary.mainStatus(), completed ? null : summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts), + pending ? "PENDING_REVIEW" : completed ? "COMPLETED" : summary.mainStatus(), + pending ? "APPROVE_ORDER" : completed ? null : summary.nextAction(), Boolean.TRUE.equals(summary.customerFeeConfirmationRequired()), relation(order, childCounts), parseOrderStatus(inbound.orderStatusSnapshot()), inbound.customerId(), inbound.customerName(), inbound.channelCode(), inbound.channelName(), order != null && Boolean.TRUE.equals(order.getChannelChanged()), inbound.warehouseId(), @@ -577,6 +684,7 @@ public class UnifiedOrderQueryService { } if (StringUtils.hasText(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus()) + && !WORKFLOW_STATUSES.contains(query.mainStatus()) ) { throw new BizException(ResultCode.BAD_REQUEST, "统一订单主状态不合法"); } diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index ea2281f74..fcaf50e23 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -378,7 +378,7 @@ paths: inboundId、inboundVersion、loadingVersion、pickingVersion;inboundVersion 才是 C04 loading/complete 的 expectedVersion,loadingVersion 当前等于 pickingVersion(兼容别名)。 tenantId 省略时跨全部客户;mainStatus 省略时按订单创建时间分页,指定 PENDING_REVIEW 时由 Order 查询, - 指定其他主状态时由 Firstleg 精确分页。旧 /orders/first-leg 接口保持兼容。 + 指定履约主状态时先按 Order 权威状态校正并去重,再分页读取 Firstleg 摘要。旧 /orders/first-leg 接口保持兼容。 列表行同时返回 customerRemark(客户下单备注)、latestInternalNote(最新一条平台内部备注)和 relation(仅有关联时返回;PARENT 含 childCount,CHILD 含 rootOrderId、parentOrderId 与 splitSequence)。 coLoadGroup 仅平台响应返回,由 Order 经 Firstleg 内部批量只读契约聚合;成员业务标识由 Order 权威订单片段补齐。 @@ -414,6 +414,55 @@ paths: application/json: schema: {$ref: '#/components/schemas/UnifiedOrderPageEnvelope'} '403': {description: 需要平台身份和 platform:order:list 权限} + /api/platform/v1/orders/status-counts: + get: + summary: 平台统一订单页签数量 + description: >- + 需要平台身份和 platform:order:list;tenantId 省略时按授权平台范围查询全部客户。 + 按统一列表非分页、非 mainStatus 筛选全量去重;忽略主状态和分页,不返回全部数量。 + 订单审核与完成状态优先于履约投影,依赖异常返回业务错误,不能以零代替失败。 + MEASUREMENT_REVIEW 为独立任务,暂不返回该 key。 + parameters: + - $ref: '#/components/parameters/UnifiedTaxMode' + - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedKeyword' + - $ref: '#/components/parameters/UnifiedOrderNo' + - {name: customerId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: receivingWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: destinationWarehouseId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - $ref: '#/components/parameters/UnifiedInboundCode' + - $ref: '#/components/parameters/UnifiedInternalOrderStatus' + - $ref: '#/components/parameters/UnifiedExpectedArrivalDate' + - $ref: '#/components/parameters/UnifiedFeeReviewStatus' + - $ref: '#/components/parameters/UnifiedChannelName' + - $ref: '#/components/parameters/UnifiedContainerNo' + - $ref: '#/components/parameters/UnifiedCabinetNo' + - $ref: '#/components/parameters/UnifiedSailingDate' + - $ref: '#/components/parameters/UnifiedLoaded' + - $ref: '#/components/parameters/UnifiedFullContainer' + - $ref: '#/components/parameters/UnifiedMeasurementReviewStatus' + - $ref: '#/components/parameters/UnifiedFbaCode' + - $ref: '#/components/parameters/UnifiedCreatedFrom' + - $ref: '#/components/parameters/UnifiedCreatedTo' + responses: + '200': + description: 标准 ApiResponse;仅 code 为字符串 200 时可使用数量 + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + data: + type: object + required: [counts] + properties: + counts: + type: object + description: PENDING_REVIEW、WAIT_MEASURE、WAIT_PALLETIZE、WAIT_SPLIT、LOADING、LOADED、DELIVERED、COMPLETED + additionalProperties: {type: integer, format: int64, minimum: 0} + '403': {description: 需要平台身份和 platform:order:list 权限} /api/platform/v1/orders/{orderId}: get: summary: 平台统一订单详情 @@ -1038,7 +1087,7 @@ components: tenantId: {type: string, pattern: '^[1-9][0-9]*$'} keyword: {type: string, maxLength: 64} orderNo: {type: string, maxLength: 64} - mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED]} + mainStatus: {type: string, enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW]} customerId: {type: string, pattern: '^[1-9][0-9]*$'} receivingWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} destinationWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} @@ -1694,10 +1743,10 @@ components: name: mainStatus in: query required: false - description: 统一订单八态主状态筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询 + description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 为实际开船后待上传清关资料;清关资料上传后进入 WAIT_UNLOAD,持续到实际卸柜完成。实际开船统一读取手动或拉取的实际离港节点。 schema: type: string - enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED] + enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] UnifiedInboundCode: {name: inboundCode, in: query, required: false, description: 订单创建时生成的入仓码,所有订单状态均可查询, schema: {type: string, maxLength: 40}} UnifiedInternalOrderStatus: diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java index 7cdb81731..47dbeb7b7 100644 --- a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderApiContractTest.java @@ -19,6 +19,38 @@ import java.util.Map; import static org.assertj.core.api.Assertions.assertThat; class UnifiedOrderApiContractTest { + /** 全局Long ID配置启用时,数量必须仍为JSON数字。 */ + @Test + void statusCountValuesRemainNumbersWithLongIdSerializationEnabled() throws Exception { + var mapper = new com.fasterxml.jackson.databind.ObjectMapper(); + var module = new com.fasterxml.jackson.databind.module.SimpleModule(); + module.addSerializer(Long.class, com.fasterxml.jackson.databind.ser.std.ToStringSerializer.instance); + mapper.registerModule(module); + var json = mapper.readTree(mapper.writeValueAsString(new com.baiyue.order.service.UnifiedOrderQueryService.StatusCounts( + Map.of("LOADED", 1205L)))); + assertThat(json.path("counts").path("LOADED").isIntegralNumber()).isTrue(); + assertThat(json.path("counts").path("LOADED").longValue()).isEqualTo(1205L); + } + + /** 页签数量沿用正式列表权限,不额外开放查询域。 */ + @Test + void statusCountsKeepListPermissionAndFilterContract() throws Exception { + Method method = PlatformUnifiedOrderController.class.getMethod("statusCounts", + com.baiyue.common.security.AuthenticatedUser.class, Long.class, com.baiyue.order.dto.UnifiedOrderStatusCountQuery.class); + assertThat(method.getAnnotation(org.springframework.web.bind.annotation.GetMapping.class).value()) + .containsExactly("/status-counts"); + assertThat(method.getAnnotation(org.springframework.security.access.prepost.PreAuthorize.class).value()) + .isEqualTo("hasAuthority('platform:order:list')"); + try (var input = new ClassPathResource("openapi/order-v1.yaml").getInputStream()) { + Map document = new Yaml().load(input); + var paths = stringMap(document.get("paths")); + var counts = stringMap(stringMap(paths.get("/api/platform/v1/orders/status-counts")).get("get")); + String parameters = counts.get("parameters").toString(); + assertThat(parameters).contains("UnifiedTaxMode", "UnifiedFbaCode", "UnifiedCreatedFrom", "UnifiedMeasurementReviewStatus") + .doesNotContain("UnifiedMainStatus", "UnifiedPage", "UnifiedSize"); + } + } + @Test void keepsPlatformAndMerchantBoundaries() { assertThat(PlatformUnifiedOrderController.class.getAnnotation(RequestMapping.class).value()) @@ -43,7 +75,9 @@ class UnifiedOrderApiContractTest { Map schema = stringMap(mainStatus.get("schema")); assertThat(stringList(schema.get("enum"))).containsExactly( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED", "COMPLETED"); + "LOADING", "LOADED", "DELIVERED", "COMPLETED", + "WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", + "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW"); assertThat(mainStatus.get("description").toString()) .contains("WAIT_PALLETIZE", "WAIT_SPLIT", "INBOUNDED"); @@ -59,7 +93,9 @@ class UnifiedOrderApiContractTest { assertThat(queryAnnotation).isNotNull(); assertThat(queryAnnotation.allowableValues()).containsExactly( "PENDING_REVIEW", "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", - "LOADING", "LOADED", "DELIVERED", "COMPLETED"); + "LOADING", "LOADED", "DELIVERED", "COMPLETED", + "WAIT_WEIGHT_CONFIRM", "WEIGHT_CONFIRMED", "WAIT_CUSTOMS_DOCS", "EXPORT_CUSTOMS", + "SAILING_CLEARANCE", "WAIT_UNLOAD", "WAIT_DISPATCH", "OUTBOUND_DOCUMENT", "DELIVERING", "SIGNED", "MEASUREMENT_REVIEW"); Map row = stringMap(schemas.get("UnifiedOrderRow")); assertThat(stringMap(row.get("properties"))).containsKeys( diff --git a/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java b/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java index 756055ee9..9314470b6 100644 --- a/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java +++ b/order/src/test/java/com/baiyue/order/repository/UnifiedOrderTaxModePaginationTest.java @@ -75,6 +75,17 @@ class UnifiedOrderTaxModePaginationTest { assertPage(null, 2, 4, 4); } + /** 轻量统计使用完整匹配集,并保留税种、客户及软删除条件。 */ + @Test + void statusProjectionIgnoresPagingWithoutLeakingDeletedOrForeignOrders() { + var query = new UnifiedOrderQuery(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.unifiedStatusOrders(11L, query, List.of(com.baiyue.order.entity.OrderStatus.APPROVED))) + .extracting(OrderEntity::getId).containsExactly(6L, 2L); + assertThat(repository.statusAuthorities(11L, List.of(2L, 3L, 7L))) + .extracting(OrderEntity::getId).containsExactly(2L); + } + @org.junit.jupiter.params.ParameterizedTest @org.junit.jupiter.params.provider.ValueSource(strings = {"TAX_REFUND", "BUY_DECLARATION"}) void realSplitChildIsPersistedAndIncludedInTaxFilteredParentChildPages(String taxMode) { diff --git a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java index b3c27bfa6..52c715994 100644 --- a/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/UnifiedOrderQueryServiceTest.java @@ -586,8 +586,7 @@ class UnifiedOrderQueryServiceTest { void fulfillmentStatusFilterUsesFirstlegPagingWithoutPostPageFiltering() { UnifiedOrderQuery query = query("INBOUNDED"); var summary = summary("INBOUNDED", "CONFIRM_PRELOAD"); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); var result = service.page(null, query); @@ -617,8 +616,7 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = query("INBOUNDED"); var summary = summary("INBOUNDED", null); when(summary.inbound().actualNetWeightKg()).thenReturn(null); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); var row = service.page(null, query).records().get(0); @@ -631,8 +629,7 @@ class UnifiedOrderQueryServiceTest { UnifiedOrderQuery query = query("INBOUNDED"); var summary = summary("INBOUNDED", null); when(summary.inbound().locationCodes()).thenReturn("A0、 A0, A0,A0"); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); var row = service.page(null, query).records().get(0); @@ -643,8 +640,7 @@ class UnifiedOrderQueryServiceTest { void waitSplitFilterForwardsAndReturnsWaitSplitIndependently() { UnifiedOrderQuery query = query("WAIT_SPLIT"); var summary = summary("WAIT_SPLIT", "CONFIRM_PRELOAD"); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING))); var result = service.page(10001L, query); @@ -653,17 +649,16 @@ class UnifiedOrderQueryServiceTest { .extracting(row -> row.mainStatus()).isEqualTo("WAIT_SPLIT"); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captor.capture()); + verify(firstleg).statusRows(eq("internal-token"), captor.capture()); assertThat(captor.getValue().tenantId()).isEqualTo(10001L); - assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_SPLIT"); + assertThat(captor.getValue().mainStatus()).isNull(); } @Test void waitPalletizeFilterReturnsDropshipProjectionAndForwardsToFirstleg() { UnifiedOrderQuery query = query("WAIT_PALLETIZE"); var summary = summary("WAIT_PALLETIZE", "PALLETIZE_DROPSHIP"); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); when(orders.findByIds(eq(10001L), eq(List.of(77L)))).thenReturn(List.of(order(OrderStatus.PROCESSING))); var result = service.page(10001L, query); @@ -676,8 +671,8 @@ class UnifiedOrderQueryServiceTest { }); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captor.capture()); - assertThat(captor.getValue().mainStatus()).isEqualTo("WAIT_PALLETIZE"); + verify(firstleg).statusRows(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().mainStatus()).isNull(); } @Test @@ -800,8 +795,7 @@ class UnifiedOrderQueryServiceTest { var summary = summary("INBOUNDED", null); when(summary.inbound().holdFlag()).thenReturn(false); when(summary.inbound().currentHoldReason()).thenReturn(null); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 4, 10, 31, 4))); + stubStatusProjection(List.of(summary), 31); var result = service.page(10001L, query); @@ -815,10 +809,10 @@ class UnifiedOrderQueryServiceTest { }); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captor.capture()); - assertThat(captor.getValue().mainStatus()).isEqualTo("INBOUNDED"); - assertThat(captor.getValue().page()).isEqualTo(4); - assertThat(captor.getValue().size()).isEqualTo(10); + verify(firstleg).statusRows(eq("internal-token"), captor.capture()); + assertThat(captor.getValue().mainStatus()).isNull(); + assertThat(captor.getValue().page()).isEqualTo(1); + assertThat(captor.getValue().size()).isEqualTo(200); } @Test @@ -830,8 +824,7 @@ class UnifiedOrderQueryServiceTest { when(summary.inbound().orderId()).thenReturn(orderId); when(summary.inbound().id()).thenReturn(inboundId); when(summary.inbound().tenantId()).thenReturn(tenantId); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); assertThat(service.page(null, query("INBOUNDED")).records()).singleElement().satisfies(row -> { assertThat(row.orderId()).isEqualTo(orderId); @@ -862,8 +855,7 @@ class UnifiedOrderQueryServiceTest { void missingHoldFlagFailsClosed() { var summary = summary("INBOUNDED", null); when(summary.inbound().holdFlag()).thenReturn(null); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); } @@ -872,8 +864,7 @@ class UnifiedOrderQueryServiceTest { void heldInboundWithoutReasonFailsClosed() { var summary = summary("INBOUNDED", null); when(summary.inbound().currentHoldReason()).thenReturn(" "); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); } @@ -883,8 +874,7 @@ class UnifiedOrderQueryServiceTest { var summary = summary("INBOUNDED", null); when(summary.inbound().holdFlag()).thenReturn(false); when(summary.inbound().currentHoldReason()).thenReturn("旧扣货原因"); - when(firstleg.page(eq("internal-token"), any())).thenReturn( - ApiResponse.success(new PageResult<>(List.of(summary), 1, 20, 1, 1))); + stubStatusProjection(List.of(summary), 1); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); } @@ -905,7 +895,7 @@ class UnifiedOrderQueryServiceTest { @Test void firstlegCallExceptionFailsClosed() { - when(firstleg.page(eq("internal-token"), any())).thenThrow(new IllegalStateException("boom")); + when(firstleg.statusRows(eq("internal-token"), any())).thenThrow(new IllegalStateException("boom")); assertServiceUnavailable(() -> service.page(null, query("INBOUNDED"))); } @@ -914,8 +904,7 @@ class UnifiedOrderQueryServiceTest { void fulfillmentTabsForwardStatusSearchAndPaginationToFirstleg() { List statuses = List.of( "WAIT_MEASURE", "WAIT_PALLETIZE", "INBOUNDED", "WAIT_SPLIT", "LOADING", "LOADED", "DELIVERED"); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 3, 20, 0, 0))); + stubStatusProjection(List.of(), 0); statuses.forEach(status -> service.page(null, new UnifiedOrderQuery( "CC-2026", "FL-77", status, null, null, null, null, null, @@ -925,14 +914,14 @@ class UnifiedOrderQueryServiceTest { ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); verify(firstleg, org.mockito.Mockito.times(statuses.size())) - .page(eq("internal-token"), captor.capture()); + .statusRows(eq("internal-token"), captor.capture()); IntStream.range(0, statuses.size()).forEach(index -> assertThat(captor.getAllValues().get(index)).satisfies(request -> { - assertThat(request.mainStatus()).isEqualTo(statuses.get(index)); + assertThat(request.mainStatus()).isNull(); assertThat(request.keyword()).isEqualTo("CC-2026"); assertThat(request.orderNo()).isEqualTo("FL-77"); - assertThat(request.page()).isEqualTo(3); - assertThat(request.size()).isEqualTo(20); + assertThat(request.page()).isEqualTo(1); + assertThat(request.size()).isEqualTo(200); })); } @@ -944,14 +933,13 @@ class UnifiedOrderQueryServiceTest { LocalDate.of(2026, 8, 8), true, true, "UNRECORDED", "FBA-1", null, null, 1, 20, null); when(orders.findOrderIdsBySkuNo(10001L, "FBA-1")).thenReturn(List.of(77L)); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 1, 20, 0, 0))); + stubStatusProjection(List.of(), 0); service.page(10001L, query); ArgumentCaptor captor = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captor.capture()); + verify(firstleg).statusRows(eq("internal-token"), captor.capture()); assertThat(captor.getValue()).satisfies(request -> { assertThat(request.inboundCode()).isEqualTo("RK-77"); assertThat(request.internalOrderStatus()).isEqualTo("APPROVED"); @@ -978,6 +966,101 @@ class UnifiedOrderQueryServiceTest { verify(firstleg, never()).page(any(), any()); } + /** 全量重复投影和审核/完成优先不能污染其他页签,也不能受当前页限制。 */ + @Test + void statusCountsDeduplicateAndHonorAuthoritativeStatesBeyondCurrentPage() { + var pending = order(OrderStatus.SUBMITTED); + var completed = order(OrderStatus.COMPLETED); completed.setId(78L); + var active = order(OrderStatus.PROCESSING); active.setId(79L); + when(orders.unifiedStatusOrders(eq(10001L), any(), any())).thenReturn(List.of(pending, completed)); + when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending, completed, active)); + var rows = new java.util.ArrayList(); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "LOADING")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 78L, "LOADED")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 79L, "WAIT_SPLIT")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 79L, "LOADING")); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(10001L, 80L, "LOADED")); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows)); + var result = service.statusCounts(10001L, taxQuery("LOADED", "TAX_REFUND")); + assertThat(result.counts()).containsEntry("PENDING_REVIEW", 1L).containsEntry("COMPLETED", 1L) + .containsEntry("WAIT_SPLIT", 1L).containsEntry("LOADING", 1L).containsEntry("LOADED", 0L) + .containsEntry("WAIT_MEASURE", 0L).containsEntry("MEASUREMENT_REVIEW", 0L).doesNotContainKeys("ALL"); + verify(firstleg, never()).summaries(any(), any()); + verify(firstleg, never()).page(any(), any()); + } + + /** 履约条件下的待审核订单保留在审核页签,先校正再分页。 */ + @Test + void operationalFiltersKeepPendingReviewAndMatchCountsToListTotal() { + var query = new UnifiedOrderQuery(null, null, "PENDING_REVIEW", null, null, null, null, null, + null, null, null, null, null, null, null, null, "PENDING", null, null, null, 1, 1, null); + var pending = order(OrderStatus.SUBMITTED); + when(orders.statusAuthorities(eq(10001L), any())).thenReturn(List.of(pending)); + when(orders.findByIds(eq(10001L), any())).thenReturn(List.of(pending)); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of( + new FirstlegUnifiedOrderClient.StatusRow(10001L, 77L, "WAIT_MEASURE")))); + var projected = summary("WAIT_MEASURE", null); + when(firstleg.statusSummaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of(projected))); + var counts = service.statusCounts(10001L, query); + var page = service.page(10001L, query); + assertThat(counts.counts()).containsEntry("PENDING_REVIEW", 1L).containsEntry("WAIT_MEASURE", 0L); + assertThat(page.total()).isEqualTo(counts.counts().get("PENDING_REVIEW")); + assertThat(page.records()).singleElement().satisfies(row -> { + assertThat(row.mainStatus()).isEqualTo("PENDING_REVIEW"); + assertThat(row.nextAction()).isEqualTo("APPROVE_ORDER"); + }); + } + + /** 错误响应和跨客户投影均失败关闭,不能返回看似可信的零。 */ + @Test + void statusCountsRejectDependencyFailuresAndForeignCustomerRows() { + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.failure(ResultCode.SERVICE_UNAVAILABLE, "offline", null, null)); + assertServiceUnavailable(() -> service.statusCounts(10001L, query(null))); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(List.of( + new FirstlegUnifiedOrderClient.StatusRow(20002L, 77L, "LOADED")))); + assertServiceUnavailable(() -> service.statusCounts(10001L, query(null))); + } + + /** 超过分页上限的匹配订单仍全量计数,权威状态校验采用有界批次。 */ + @Test + void countsAll1205OrdersUsingThreeAuthorityBatches() { + var rows = IntStream.range(0, 1205).mapToObj(index -> + new FirstlegUnifiedOrderClient.StatusRow(10001L, (long) index, "LOADED")).toList(); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows)); + when(orders.statusAuthorities(eq(10001L), any())).thenAnswer(invocation -> { + List ids = invocation.getArgument(1); + return ids.stream().map(id -> { + var item = order(OrderStatus.PROCESSING); item.setId(id); return item; + }).toList(); + }); + assertThat(service.statusCounts(10001L, taxQuery("WAIT_SPLIT", null)).counts()) + .containsEntry("LOADED", 1205L).containsEntry("WAIT_SPLIT", 0L); + verify(orders, org.mockito.Mockito.times(3)).statusAuthorities(eq(10001L), any()); + } + + /** 使用轻量投影准备全量匹配集,摘要仍只读取当前页。 */ + private void stubStatusProjection(List summaries, long total) { + var rows = new java.util.ArrayList(); + for (int i = summaries.size(); i < total; i++) { + var sample = summaries.get(0); + rows.add(new FirstlegUnifiedOrderClient.StatusRow(sample.inbound().tenantId(), 1000L + i, sample.mainStatus())); + } + summaries.forEach(summary -> rows.add(new FirstlegUnifiedOrderClient.StatusRow( + summary.inbound().tenantId(), summary.inbound().orderId(), summary.mainStatus()))); + when(firstleg.statusRows(eq("internal-token"), any())).thenReturn(ApiResponse.success(rows)); + when(firstleg.statusSummaries(eq("internal-token"), any())).thenReturn(ApiResponse.success(summaries)); + when(orders.statusAuthorities(any(), any())).thenAnswer(invocation -> { + List ids = invocation.getArgument(1); + return rows.stream().filter(row -> ids.contains(row.orderId())).map(row -> { + OrderEntity authority = order(OrderStatus.PROCESSING); + authority.setId(row.orderId()); + authority.setTenantId(row.tenantId()); + return authority; + }).toList(); + }); + } + private UnifiedOrderQuery query(String status) { return new UnifiedOrderQuery(null, null, status, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, @@ -1000,15 +1083,14 @@ class UnifiedOrderQueryServiceTest { service.page(10001L, direct); verify(orders).unifiedPage(eq(10001L), eq(direct), any()); org.mockito.Mockito.verify(firstleg, org.mockito.Mockito.never()).page(any(), any()); - when(firstleg.page(eq("internal-token"), any())) - .thenReturn(ApiResponse.success(new PageResult<>(List.of(), 2, 1, 0, 0))); + stubStatusProjection(List.of(), 0); service.page(10001L, taxQuery("LOADED", taxMode)); ArgumentCaptor captured = ArgumentCaptor.forClass(FirstlegUnifiedOrderClient.Query.class); - verify(firstleg).page(eq("internal-token"), captured.capture()); + verify(firstleg).statusRows(eq("internal-token"), captured.capture()); assertThat(captured.getValue().taxMode()).isEqualTo(taxMode); assertThat(captured.getValue().tenantId()).isEqualTo(10001L); - assertThat(captured.getValue().page()).isEqualTo(2); - assertThat(captured.getValue().size()).isEqualTo(1); + assertThat(captured.getValue().page()).isEqualTo(1); + assertThat(captured.getValue().size()).isEqualTo(200); } @org.junit.jupiter.params.ParameterizedTest diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index 93e69d6f6..b5f255651 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -1,5 +1,4 @@ import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' import test from 'node:test' import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs' @@ -174,7 +173,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 434, iam_role_permission: 618, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 433, iam_role_permission: 614, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -201,15 +200,8 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const row of permissions) if (row.parent_id && row.parent_id !== '0') assert.ok(permissionIds.has(row.parent_id), 'missing parent ' + row.parent_id) const merchant = roleAuthorities(cn, 'MERCHANT_OWNER') for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement', - 'merchant:warehouse:delivery:list']) assert.ok(merchant.has(code), code) - assert.ok(!permissions.some(row => row.permission_code === 'merchant:warehouse:delivery:create')) - assert.ok(!merchant.has('merchant:warehouse:delivery:create')) + 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') - for (const action of ['create', 'loading-complete', 'depart']) { - const code = `platform:overseas-warehouse:dispatch:${action}` - assert.ok(admin.has(code), code) - assert.ok(!merchant.has(code), code) - } assert.ok(admin.has('platform:overseas-warehouse:discard:approve'), 'discard review endpoint must be grantable to platform administrator') for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) From 6fa5d281ec62a8044b94bb95beff5c2625741524 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 11:33:20 +0800 Subject: [PATCH 032/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../discard/dto/CargoDiscardRequests.java | 8 +- .../mapper/CargoDiscardDimensionMapper.java | 22 + .../CargoDiscardDimensionRepository.java | 19 + .../CargoDiscardQueryRepository.java | 36 + .../CargoDiscardStatisticsService.java | 54 + .../relabel/model/RelabelQueryFilter.java | 34 + .../repository/RelabelQuantityRepository.java | 31 + .../service/RelabelQuantityService.java | 89 + .../openapi/relabel-quantities-v1.yaml | 83 + .../CargoDiscardTransferCartonMapperTest.java | 61 + .../MerchantCargoDiscardControllerTest.java | 2 +- .../CargoDiscardStatisticsServiceTest.java | 60 + .../service/RelabelQuantityServiceTest.java | 75 + .../dto/UnifiedOrderStatusCountQuery.java | 40 + .../UnifiedOrderStatusCountBindingTest.java | 91 + .../local-schema-repair/actual-changes.json | 224 + .../actual-incremental-review.sql | 2420 ++++ output/local-schema-repair/columns-before.tsv | 2745 +++++ .../customs-tab-consistency.json | 116 + .../local-schema-repair/customs-tab-live.json | 523 + .../firstleg-columns-before.tsv | 2138 ++++ .../firstleg-incremental.sql | 357 + output/local-schema-repair/firstleg-plan.json | 29 + output/local-schema-repair/http-smoke.json | 84 + output/local-schema-repair/http-smoke.mjs | 25 + output/local-schema-repair/incremental.sql | 895 ++ output/local-schema-repair/index-repair.json | 100 + output/local-schema-repair/initial-plan.json | 267 + .../local-schema-repair/node-fact-before.tsv | 2 + .../pda-task-check-before.tsv | 2 + .../pda-task-trigger-before.tsv | 2 + .../pda-task-trigger-fix.sql | 22 + output/local-schema-repair/plan.json | 119 + output/local-schema-repair/repair.mjs | 52 + .../restart-firstleg-customs.ps1 | 27 + .../restart-lastleg-flow.ps1 | 27 + .../restart-order-counts.ps1 | 27 + output/local-schema-repair/restart-tabs.ps1 | 27 + .../restart-warehouse-flow.ps1 | 28 + output/local-schema-repair/restart.ps1 | 25 + output/local-schema-repair/schema-after.sql | 9729 +++++++++++++++++ output/local-schema-repair/schema-before.sql | 7980 ++++++++++++++ .../schema-verification.json | 20 + output/local-schema-repair/summarize.mjs | 12 + .../tab-count-consistency.json | 116 + output/local-schema-repair/tab-smoke.json | 537 + output/local-schema-repair/tab-smoke.mjs | 17 + .../tail-final-consistency.json | 67 + .../tail-flow-inspect.json | 189 + .../local-schema-repair/tail-flow-inspect.mjs | 16 + .../local-schema-repair/tail-flow-state.json | 1233 +++ .../tail-flow-warehouse-before.tsv | 2 + output/local-schema-repair/tail-flow.mjs | 84 + output/local-schema-repair/verify-schema.mjs | 26 + .../warehouse-block-resource-before.tsv | 4 + .../warehouse-block-schema.sql | 52 + .../warehouse-test-block-recovery.sql | 23 + output/local-schema-repair/weight-flow.json | 78 + output/local-schema-repair/weight-flow.mjs | 32 + .../local-schema-repair/新增页签验收记录.md | 127 + output/local-schema-repair/验收记录.md | 34 + 61 files changed, 31358 insertions(+), 8 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQueryRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelQueryFilter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelQuantityRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelQuantityService.java create mode 100644 lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java create mode 100644 order/src/main/java/com/baiyue/order/dto/UnifiedOrderStatusCountQuery.java create mode 100644 order/src/test/java/com/baiyue/order/controller/UnifiedOrderStatusCountBindingTest.java create mode 100644 output/local-schema-repair/actual-changes.json create mode 100644 output/local-schema-repair/actual-incremental-review.sql create mode 100644 output/local-schema-repair/columns-before.tsv create mode 100644 output/local-schema-repair/customs-tab-consistency.json create mode 100644 output/local-schema-repair/customs-tab-live.json create mode 100644 output/local-schema-repair/firstleg-columns-before.tsv create mode 100644 output/local-schema-repair/firstleg-incremental.sql create mode 100644 output/local-schema-repair/firstleg-plan.json create mode 100644 output/local-schema-repair/http-smoke.json create mode 100644 output/local-schema-repair/http-smoke.mjs create mode 100644 output/local-schema-repair/incremental.sql create mode 100644 output/local-schema-repair/index-repair.json create mode 100644 output/local-schema-repair/initial-plan.json create mode 100644 output/local-schema-repair/node-fact-before.tsv create mode 100644 output/local-schema-repair/pda-task-check-before.tsv create mode 100644 output/local-schema-repair/pda-task-trigger-before.tsv create mode 100644 output/local-schema-repair/pda-task-trigger-fix.sql create mode 100644 output/local-schema-repair/plan.json create mode 100644 output/local-schema-repair/repair.mjs create mode 100644 output/local-schema-repair/restart-firstleg-customs.ps1 create mode 100644 output/local-schema-repair/restart-lastleg-flow.ps1 create mode 100644 output/local-schema-repair/restart-order-counts.ps1 create mode 100644 output/local-schema-repair/restart-tabs.ps1 create mode 100644 output/local-schema-repair/restart-warehouse-flow.ps1 create mode 100644 output/local-schema-repair/restart.ps1 create mode 100644 output/local-schema-repair/schema-after.sql create mode 100644 output/local-schema-repair/schema-before.sql create mode 100644 output/local-schema-repair/schema-verification.json create mode 100644 output/local-schema-repair/summarize.mjs create mode 100644 output/local-schema-repair/tab-count-consistency.json create mode 100644 output/local-schema-repair/tab-smoke.json create mode 100644 output/local-schema-repair/tab-smoke.mjs create mode 100644 output/local-schema-repair/tail-final-consistency.json create mode 100644 output/local-schema-repair/tail-flow-inspect.json create mode 100644 output/local-schema-repair/tail-flow-inspect.mjs create mode 100644 output/local-schema-repair/tail-flow-state.json create mode 100644 output/local-schema-repair/tail-flow-warehouse-before.tsv create mode 100644 output/local-schema-repair/tail-flow.mjs create mode 100644 output/local-schema-repair/verify-schema.mjs create mode 100644 output/local-schema-repair/warehouse-block-resource-before.tsv create mode 100644 output/local-schema-repair/warehouse-block-schema.sql create mode 100644 output/local-schema-repair/warehouse-test-block-recovery.sql create mode 100644 output/local-schema-repair/weight-flow.json create mode 100644 output/local-schema-repair/weight-flow.mjs create mode 100644 output/local-schema-repair/新增页签验收记录.md create mode 100644 output/local-schema-repair/验收记录.md diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java index 82d603b03..75c21ab9b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardRequests.java @@ -34,11 +34,5 @@ public final class CargoDiscardRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String discardNo, String inboundCode, String overseasWarehouseId, String status, LocalDateTime createdFrom, LocalDateTime createdTo, - LocalDateTime completedFrom, LocalDateTime completedTo) { - /** 未筛完成时间的调用保持创建时间查询语义。 */ - public Query(long page, long size, String discardNo, String inboundCode, String overseasWarehouseId, - String status, LocalDateTime createdFrom, LocalDateTime createdTo) { - this(page, size, discardNo, inboundCode, overseasWarehouseId, status, createdFrom, createdTo, null, null); - } - } + LocalDateTime completedFrom, LocalDateTime completedTo) {} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java new file mode 100644 index 000000000..5f62485bc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.discard.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; + +/** 只读取废弃明细真实关联的本库托盘身份。 */ +@Mapper +public interface CargoDiscardDimensionMapper { + /** 无托盘事实以空串保留,避免把不适用维度伪造为零。 */ + @Select(""" + SELECT COALESCE(CASE WHEN i.return_carton_id IS NOT NULL THEN r.pallet_code + WHEN i.inventory_position_id IS NOT NULL THEN p.pallet_no ELSE d.pallet_no END,'') + FROM ll_cargo_discard_item i + LEFT JOIN ll_return_inventory_carton r ON r.id=i.return_carton_id AND r.customer_id=i.customer_id + LEFT JOIN ll_inventory_position p ON p.id=i.inventory_position_id AND p.customer_id=i.customer_id + LEFT JOIN ll_dropship_order_profile d ON d.id=i.profile_id AND d.customer_id=i.customer_id + WHERE i.customer_id=#{customerId} AND i.application_id=#{applicationId} + """) + List palletNos(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java new file mode 100644 index 000000000..b375f8202 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java @@ -0,0 +1,19 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import com.baiyue.lastleg.discard.mapper.CargoDiscardDimensionMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 封装数量统计的明细与来源托盘读取。 */ +@Repository +public class CargoDiscardDimensionRepository { + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardDimensionMapper dimensions; + /** 读取一票客户隔离明细。 */ + public List items(String customer, Long application) { return items.listApplication(customer, application); } + /** 读取一票托盘身份,保留无事实行。 */ + public List pallets(String customer, Long application) { return dimensions.palletNos(customer, application); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQueryRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQueryRepository.java new file mode 100644 index 000000000..0a121de66 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQueryRepository.java @@ -0,0 +1,36 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.mapper.CargoDiscardApplicationMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; +import java.util.List; + +/** 列表和全筛选统计共用同一客户及时间过滤条件。 */ +@Repository +public class CargoDiscardQueryRepository { + @Resource private CargoDiscardApplicationMapper applications; + + /** 返回全部筛选单据,不受页面大小影响。 */ + public List list(String customerId, CargoDiscardRequests.Query query) { + return applications.selectList(filter(customerId, query)); + } + + /** 完成时间使用客户确认时间,与客户端完成时间展示保持一致。 */ + public static LambdaQueryWrapper filter(String customerId, CargoDiscardRequests.Query query) { + return new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), CargoDiscardApplication::getCustomerId, customerId) + .like(StringUtils.hasText(query.discardNo()), CargoDiscardApplication::getDiscardNo, query.discardNo()) + .like(StringUtils.hasText(query.inboundCode()), CargoDiscardApplication::getInboundCodes, query.inboundCode()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), CargoDiscardApplication::getOverseasWarehouseId, query.overseasWarehouseId()) + .eq(StringUtils.hasText(query.status()), CargoDiscardApplication::getStatus, query.status()) + .ge(query.createdFrom() != null, CargoDiscardApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, CargoDiscardApplication::getCreatedAt, query.createdTo()) + .ge(query.completedFrom() != null, CargoDiscardApplication::getCustomerConfirmedAt, query.completedFrom()) + .le(query.completedTo() != null, CargoDiscardApplication::getCustomerConfirmedAt, query.completedTo()) + .orderByDesc(CargoDiscardApplication::getCreatedAt).orderByDesc(CargoDiscardApplication::getId); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java new file mode 100644 index 000000000..76508b8e8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.model.CargoDiscardQuantities; +import com.baiyue.lastleg.discard.repository.CargoDiscardQueryRepository; +import com.baiyue.lastleg.discard.repository.CargoDiscardDimensionRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import java.util.HashSet; + +/** 按全部筛选单据统计票、SKU、件、箱和真实来源托盘。 */ +@Service +public class CargoDiscardStatisticsService { + @Resource private CargoDiscardQueryRepository queries; + @Resource private CargoDiscardDimensionRepository dimensions; + + public record Statistics(long orderCount, int skuCount, long requestedPackages, Long actualPackages, + Long requestedPieces, Long actualPieces, Integer palletCount) {} + + /** 已确认实际数独立汇总;缺失件数或托盘事实返回空值,不从箱数换算。 */ + public Statistics statistics(String customer, CargoDiscardRequests.Query query) { + var applications = queries.list(customer, query); + var skus = new HashSet(); var pallets = new HashSet(); + long boxes = 0, actualBoxes = 0, pieces = 0, actualPieces = 0; + boolean piecesKnown = true, actualPiecesKnown = true, actualKnown = false, palletsKnown = true; + for (var application : applications) { + var lines = dimensions.items(application.getCustomerId(), application.getId()); + boxes += lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); + if (lines.isEmpty()) { piecesKnown = false; palletsKnown = false; } + var confirmed = lines.stream().filter(i -> i.getActualDiscardPackages() != null).toList(); + if (!confirmed.isEmpty()) { actualKnown = true; actualBoxes += CargoDiscardQuantities.boxes(confirmed, true); } + for (var line : lines) { + if (line.getSkuCode() != null && !line.getSkuCode().isBlank()) skus.add(line.getSkuCode()); + if (line.getReturnCartonId() == null) { + piecesKnown = false; + if (line.getActualDiscardPackages() != null) actualPiecesKnown = false; + } else { + pieces += line.getDiscardPackages(); + if (line.getActualDiscardPackages() != null) actualPieces += line.getActualDiscardPackages(); + } + } + var sourcePallets = dimensions.pallets(application.getCustomerId(), application.getId()); + if (sourcePallets.isEmpty()) palletsKnown = false; + for (var pallet : sourcePallets) { + if (pallet == null || pallet.isBlank() || "散箱".equals(pallet)) palletsKnown = false; + else pallets.add(application.getCustomerId() + ":" + application.getOverseasWarehouseId() + ":" + pallet); + } + } + return new Statistics(applications.size(), skus.size(), boxes, actualKnown ? actualBoxes : null, + !applications.isEmpty() && piecesKnown ? pieces : null, + actualKnown && actualPiecesKnown ? actualPieces : null, + !applications.isEmpty() && palletsKnown ? pallets.size() : null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelQueryFilter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelQueryFilter.java new file mode 100644 index 000000000..d0c131111 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/model/RelabelQueryFilter.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.relabel.model; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import org.springframework.util.StringUtils; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; + +/** 列表与数量汇总共用客户范围及筛选条件,避免分页和汇总口径漂移。 */ +public final class RelabelQueryFilter { + /** 禁止创建无状态筛选工具实例。 */ + private RelabelQueryFilter() { } + + /** 构造完整筛选条件;同时间单据再按主键排序,保持分页稳定。 */ + public static LambdaQueryWrapper build(String customerId, RelabelRequests.Query query) { + if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); + var wrapper = new LambdaQueryWrapper() + .eq(StringUtils.hasText(customerId), RelabelApplication::getCustomerId, customerId) + .like(StringUtils.hasText(query.relabelNo()), RelabelApplication::getRelabelNo, query.relabelNo()) + .like(StringUtils.hasText(query.customerOrderNo()), RelabelApplication::getCustomerOrderNo, query.customerOrderNo()) + .eq(StringUtils.hasText(query.overseasWarehouseId()), RelabelApplication::getOverseasWarehouseId, query.overseasWarehouseId()) + .ge(query.createdFrom() != null, RelabelApplication::getCreatedAt, query.createdFrom()) + .le(query.createdTo() != null, RelabelApplication::getCreatedAt, query.createdTo()) + .apply(StringUtils.hasText(query.skuCode()), "EXISTS (SELECT 1 FROM ll_relabel_item ri WHERE ri.application_id=ll_relabel_application.id AND ri.customer_id=ll_relabel_application.customer_id AND (ri.sku_code LIKE CONCAT('%',{0},'%') OR JSON_SEARCH(CASE WHEN JSON_VALID(ri.sku_relabel_items_json) THEN ri.sku_relabel_items_json ELSE JSON_ARRAY() END, 'one', CONCAT('%',{0},'%'), NULL, '$[*].skuCode') IS NOT NULL))", query.skuCode()) + .orderByDesc(RelabelApplication::getCreatedAt).orderByDesc(RelabelApplication::getId); + String status = query.status() == null ? "" : query.status().trim(); + if ("PENDING_PROCESSING".equals(status)) wrapper.eq(RelabelApplication::getStatus, status); + else if ("COMPLETED".equals(status)) wrapper.in(RelabelApplication::getStatus, "READY_FOR_OUTBOUND", "COMPLETED"); + else if (StringUtils.hasText(status)) wrapper.eq(RelabelApplication::getStatus, status); + return wrapper; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelQuantityRepository.java new file mode 100644 index 000000000..77a0cb979 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelQuantityRepository.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.relabel.repository; + +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; +import com.baiyue.lastleg.relabel.mapper.RelabelItemMapper; +import com.baiyue.lastleg.relabel.model.RelabelQueryFilter; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import org.springframework.stereotype.Repository; +import jakarta.annotation.Resource; +import java.util.List; + +/** 数量查询只读取本服务申请与箱任务,不跨服务读库存库。 */ +@Repository +public class RelabelQuantityRepository { + @Resource private RelabelApplicationMapper applications; + @Resource private RelabelItemMapper items; + + /** 读取全部筛选结果,不附加当前页限制。 */ + public List filtered(String customerId, RelabelRequests.Query query) { + return applications.selectList(RelabelQueryFilter.build(customerId, query)); + } + + /** 明细同时限定客户与申请,避免跨客户聚合关联。 */ + public List items(RelabelApplication application) { + return items.selectList(new LambdaQueryWrapper() + .eq(RelabelItem::getApplicationId, application.getId()) + .eq(RelabelItem::getCustomerId, application.getCustomerId())); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelQuantityService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelQuantityService.java new file mode 100644 index 000000000..d270b043c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelQuantityService.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.repository.RelabelQuantityRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; + +/** 分开统计贴标件数、真实箱任务和未知维度,不按固定箱规换算。 */ +@Service +public class RelabelQuantityService { + @Resource private RelabelQuantityRepository repository; + @Resource private ObjectMapper objectMapper; + public record Quantity(Long labelPieceCount, Long cartonCount, int skuCount, boolean duplicateCarton, + String quantityUnit, Long sourcePalletCount, Long targetPalletCount) { } + public record Totals(long orderCount, long skuCount, long knownLabelPieceCount, long unknownPieceOrders, + long knownCartonCount, long unknownCartonOrders, + Map orders) { } + + /** 按全部筛选单据聚合;未知件数和箱数保留未知单数,不伪造为零。 */ + @Transactional(readOnly = true) + public Totals totals(String customerId, RelabelRequests.Query query) { + var dimensions = new LinkedHashMap(); + var skus = new HashSet(); + long pieces = 0, cartons = 0, unknownPieces = 0, unknownCartons = 0; + for (var application : repository.filtered(customerId, query)) { + var detail = repository.items(application); + Quantity quantity = measure(detail); + dimensions.put(application.getRelabelNo(), quantity); + detail.forEach(item -> collectSkus(item, skus)); + if (quantity.labelPieceCount() == null) unknownPieces++; else pieces = Math.addExact(pieces, quantity.labelPieceCount()); + if (quantity.cartonCount() == null) unknownCartons++; else cartons = Math.addExact(cartons, quantity.cartonCount()); + } + return new Totals(dimensions.size(), skus.size(), pieces, unknownPieces, cartons, unknownCartons, dimensions); + } + + /** 箱级任务按箱唛去重;历史重复关联不重复增加件数并显式告警。 */ + public Quantity measure(List items) { + if (items.isEmpty()) return new Quantity(null, null, 0, false, "UNKNOWN", null, null); + long pieces = 0; + boolean unknownPieces = false, bulk = false, duplicate = false, missingCarton = false; + var cartons = new HashSet(); + var piecesSeen = new HashSet(); + var skus = new HashSet(); + for (var item : items) { + collectSkus(item, skus); + String mark = item.getCartonMark() == null ? "" : item.getCartonMark().trim(); + if (mark.isEmpty()) missingCarton = true; + else if (!cartons.add(mark) && !"RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) duplicate = true; + if ("RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { + bulk = true; + if (item.getNotifiedPackageCount() == null) unknownPieces = true; + else if (piecesSeen.add(mark + "|" + item.getSkuCode() + "|" + item.getNewSkuCode())) pieces = Math.addExact(pieces, item.getNotifiedPackageCount()); + else duplicate = true; + } else if ("CARTON_LABEL".equals(item.getOperationType())) { + // 只换箱唛不能推导商品件数,保留未提供口径。 + unknownPieces = true; + } else { + try { + var lines = objectMapper.readTree(Objects.requireNonNullElse(item.getSkuRelabelItemsJson(), "[]")); + if (!lines.isArray() || lines.isEmpty()) unknownPieces = true; + for (var line : lines) { + String key = mark + "|" + line.path("skuCode").asText() + "|" + line.path("newSkuCode").asText(); + if (!line.path("quantity").canConvertToLong()) unknownPieces = true; + else if (piecesSeen.add(key)) pieces = Math.addExact(pieces, line.path("quantity").asLong()); + } + } catch (Exception exception) { unknownPieces = true; } + } + } + // 按件退货来源可能是托盘或包裹标识,不能将标识数冒充真实箱数。 + return new Quantity(unknownPieces || duplicate ? null : pieces, bulk || missingCarton ? null : (long) cartons.size(), + skus.size(), duplicate, bulk ? "PIECE" : "CARTON", null, null); + } + + /** SKU种数来自子项;旧快照无子项时才采用旧SKU汇总字段。 */ + private void collectSkus(RelabelItem item, Set skus) { + try { + var lines = objectMapper.readTree(Objects.requireNonNullElse(item.getSkuRelabelItemsJson(), "[]")); + if (lines.isArray() && !lines.isEmpty()) { + for (var line : lines) if (!line.path("skuCode").asText().isBlank()) skus.add(line.path("skuCode").asText().trim()); + return; + } + } catch (Exception ignored) { /* 旧快照解析失败时只展示已有旧SKU,不推算件数。 */ } + for (var sku : Objects.requireNonNullElse(item.getSkuCode(), "").split(",")) if (!sku.isBlank()) skus.add(sku.trim()); + } +} diff --git a/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml b/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml new file mode 100644 index 000000000..4777b1eda --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml @@ -0,0 +1,83 @@ +openapi: 3.0.3 +info: + title: 贴标申请数量维度 + version: 1.0.0 + description: 全部筛选结果汇总,不受 page/size 影响。件、箱、SKU分别统计;未知不能解释为零,禁止固定箱规换算。 +paths: + /api/platform/v1/last-leg/relabel-applications/quantity-summary: + get: + operationId: platformRelabelQuantitySummary + description: 平台列表权限 platform:overseas-warehouse:relabel:list;范围遵循平台认证访问域。 + parameters: + - $ref: '#/components/parameters/RelabelNo' + - $ref: '#/components/parameters/CustomerOrderNo' + - $ref: '#/components/parameters/SkuCode' + - $ref: '#/components/parameters/Status' + - $ref: '#/components/parameters/Warehouse' + - $ref: '#/components/parameters/CreatedFrom' + - $ref: '#/components/parameters/CreatedTo' + responses: + '200': + $ref: '#/components/responses/Summary' + /api/merchant/v1/last-leg/relabel-applications/quantity-summary: + get: + operationId: merchantRelabelQuantitySummary + description: 仅认证客户范围;不接受请求参数覆盖客户身份。权限 merchant:warehouse:relabel:list。 + parameters: + - $ref: '#/components/parameters/RelabelNo' + - $ref: '#/components/parameters/CustomerOrderNo' + - $ref: '#/components/parameters/SkuCode' + - $ref: '#/components/parameters/Status' + - $ref: '#/components/parameters/Warehouse' + - $ref: '#/components/parameters/CreatedFrom' + - $ref: '#/components/parameters/CreatedTo' + responses: + '200': + $ref: '#/components/responses/Summary' +components: + parameters: + RelabelNo: {name: relabelNo, in: query, schema: {type: string}, description: 单号模糊匹配} + CustomerOrderNo: {name: customerOrderNo, in: query, schema: {type: string}} + SkuCode: {name: skuCode, in: query, schema: {type: string}, description: 旧SKU字段或SKU子项匹配} + Status: {name: status, in: query, schema: {type: string}, description: PENDING_PROCESSING精确匹配;COMPLETED包含READY_FOR_OUTBOUND与COMPLETED} + Warehouse: {name: overseasWarehouseId, in: query, schema: {type: string}} + CreatedFrom: {name: createdFrom, in: query, schema: {type: string, format: date-time}} + CreatedTo: {name: createdTo, in: query, schema: {type: string, format: date-time}} + responses: + Summary: + description: 标准ApiResponse;code为200时读取data + content: + application/json: + schema: + type: object + properties: + code: {type: string} + message: {type: string} + requestId: {type: string} + traceId: {type: string} + data: {$ref: '#/components/schemas/Totals'} + schemas: + Totals: + type: object + required: [orderCount, skuCount, knownLabelPieceCount, unknownPieceOrders, knownCartonCount, unknownCartonOrders, orders] + properties: + orderCount: {type: integer, format: int64, minimum: 0} + skuCount: {type: integer, format: int64, minimum: 0, description: 全部筛选结果的不同旧SKU数} + knownLabelPieceCount: {type: integer, format: int64, minimum: 0} + unknownPieceOrders: {type: integer, format: int64, minimum: 0} + knownCartonCount: {type: integer, format: int64, minimum: 0} + unknownCartonOrders: {type: integer, format: int64, minimum: 0} + orders: + type: object + description: 以换标业务单号为键,覆盖全部筛选单据 + additionalProperties: {$ref: '#/components/schemas/Quantity'} + Quantity: + type: object + properties: + labelPieceCount: {type: integer, format: int64, nullable: true, description: 按件任务采用通知件数;箱任务采用SKU子项件数;重复关联或缺失为null} + cartonCount: {type: integer, format: int64, nullable: true, description: 箱级任务按箱唛去重;按件来源标识不作为箱数} + skuCount: {type: integer, minimum: 0} + duplicateCarton: {type: boolean, description: 历史重复任务关联提示,不将同一来源不同SKU按件任务视为重复} + quantityUnit: {type: string, enum: [PIECE, CARTON, UNKNOWN]} + sourcePalletCount: {type: integer, format: int64, nullable: true, description: 缺少不可变原托快照时为null} + targetPalletCount: {type: integer, format: int64, nullable: true, description: 未提供汇总,详情以本轮有效绑定历史为准} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java new file mode 100644 index 000000000..3df612690 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java @@ -0,0 +1,61 @@ +package com.baiyue.lastleg.discard; + +import com.baiyue.lastleg.discard.mapper.CargoDiscardInventoryMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.util.List; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.*; + +/** 使用真实 SQL 核对转运实收箱与库存仓位的关联及废弃状态流转。 */ +class CargoDiscardTransferCartonMapperTest { + /** 同批多托盘、多 SKU 时仅返回本仓位实收箱,排除预报、短收、占用及其他客户的箱。 */ + @Test + void receivedBoxesFollowTheirActualPalletAndSku() { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(source); + db.execute("CREATE TABLE ll_inbound_plan_carton(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),received_at TIMESTAMP,destination_warehouse_code VARCHAR(20),carton_mark VARCHAR(80),sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,destination_warehouse_code VARCHAR(20),sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT,batch_no VARCHAR(80),overseas_warehouse_id VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))"); + db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP)"); + db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','SKU'),(2,'C',10,'FBA','OTHER')"); + db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'ZY-BYWL-261019194301','W'),(42,'C',1,200,'ZY-BYWL-261019194301','W'),(43,'C',2,100,'ZY-BYWL-261019194301','W')"); + db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'ZY-BYWL-261019194301','W')"); + db.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(11,'C',10,100,'COMPLETED'),(12,'C',10,200,'COMPLETED'),(13,'C',10,100,'BOUND')"); + db.update("INSERT INTO ll_warehouse_transfer_item VALUES(21,'C',10,'SKU'),(22,'C',10,'OTHER')"); + db.update("INSERT INTO ll_warehouse_transfer_carton(id,customer_id,transfer_order_id,transfer_item_id,transfer_pallet_id,status,carton_mark) VALUES " + + "(1,'C',10,21,11,'COUNTED','BOX-1'),(2,'C',10,21,11,'COUNTED','BOX-2')," + + "(3,'C',10,21,12,'COUNTED','OTHER-PALLET'),(4,'C',10,22,11,'COUNTED','OTHER-SKU')," + + "(5,'C',10,21,11,'EXPECTED','FORECAST'),(6,'C',10,21,11,'NOT_RECEIVED','SHORT')," + + "(7,'C',10,21,11,'DISCARD_PENDING','HELD'),(8,'OTHER',10,21,11,'COUNTED','FOREIGN')," + + "(9,'C',10,21,13,'COUNTED','NOT-STOCKED')"); + var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source)); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(CargoDiscardInventoryMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper = session.getMapper(CargoDiscardInventoryMapper.class); + assertEquals(List.of("BOX-1", "BOX-2"), mapper.genericCartons("C", 41L).stream().map(c -> c.getCartonMark()).toList()); + assertEquals(List.of("OTHER-PALLET"), mapper.genericCartons("C", 42L).stream().map(c -> c.getCartonMark()).toList()); + assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList()); + assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty()); + assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "COUNTED", "DISCARD_PENDING")); + assertEquals(1, mapper.genericCartons("C", 41L).size()); + assertEquals(0, mapper.changeTransferCartonStatus("OTHER", 2L, "DISCARD_PENDING", "COUNTED")); + assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "DISCARD_PENDING", "COUNTED")); + assertEquals(2, mapper.genericCartons("C", 41L).size()); + assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "COUNTED", "DISCARD_PENDING")); + assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "DISCARD_PENDING", "DISCARDED")); + assertEquals(1, mapper.genericCartons("C", 41L).size()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java index d61b43786..2defcb9df 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/MerchantCargoDiscardControllerTest.java @@ -34,7 +34,7 @@ class MerchantCargoDiscardControllerTest { private final CargoDiscardService service = mock(CargoDiscardService.class); private final MerchantCargoDiscardController controller = controller(); private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query( - 1, 20, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null); @Test void merchantQueriesUseOnlyAuthenticatedTenant() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java new file mode 100644 index 000000000..6081879d2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.discard.service; + +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.repository.CargoDiscardQueryRepository; +import com.baiyue.lastleg.discard.repository.CargoDiscardDimensionRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.mockito.Mockito.*; +import static org.junit.jupiter.api.Assertions.*; + +/** 全筛选统计不能受当前页影响,混装退货箱和缺失维度必须分别处理。 */ +class CargoDiscardStatisticsServiceTest { + private final CargoDiscardQueryRepository queries = mock(CargoDiscardQueryRepository.class); + private final CargoDiscardDimensionRepository dimensions = mock(CargoDiscardDimensionRepository.class); + private final CargoDiscardStatisticsService service = new CargoDiscardStatisticsService(); + private final CargoDiscardRequests.Query query = new CargoDiscardRequests.Query(1, 1, null, null, null, null, null, null, null, null); + + /** 给当前服务注入隔离只读仓储。 */ + CargoDiscardStatisticsServiceTest() { + ReflectionTestUtils.setField(service, "queries", queries); + ReflectionTestUtils.setField(service, "dimensions", dimensions); + } + /** 合成两票同托盘退货,第一页大小为一仍汇总两票;同箱SKU不重复计箱。 */ + @Test void allFilteredOrdersDeduplicateMixedCartonsAndPallets() { + var a = application(1L); var b = application(2L); + when(queries.list("C", query)).thenReturn(List.of(a, b)); + when(dimensions.items("C", 1L)).thenReturn(List.of(item(10L, "A", 2, 2), item(10L, "B", 3, 3))); + when(dimensions.items("C", 2L)).thenReturn(List.of(item(20L, "A", 7, null))); + when(dimensions.pallets("C", 1L)).thenReturn(List.of("P", "P")); + when(dimensions.pallets("C", 2L)).thenReturn(List.of("P")); + var result = service.statistics("C", query); + assertEquals(2, result.orderCount()); assertEquals(2, result.skuCount()); + assertEquals(2, result.requestedPackages()); assertEquals(1L, result.actualPackages()); + assertEquals(12L, result.requestedPieces()); assertEquals(5L, result.actualPieces()); assertEquals(1, result.palletCount()); + } + /** 非退货箱级记录不包含件数,实际零与未作业空值必须区分。 */ + @Test void unknownPiecesAndLoosePalletNeverBecomeZero() { + when(queries.list("C", query)).thenReturn(List.of(application(1L))); + when(dimensions.items("C", 1L)).thenReturn(List.of(item(null, "A", 2, 0))); + when(dimensions.pallets("C", 1L)).thenReturn(List.of("")); + var result = service.statistics("C", query); + assertEquals(2, result.requestedPackages()); assertEquals(0L, result.actualPackages()); + assertNull(result.requestedPieces()); assertNull(result.actualPieces()); assertNull(result.palletCount()); + } + /** 空筛选有零票零申请箱,但不能伪造实际作业或商品、托盘事实。 */ + @Test void emptySelectionHasNoInventedActualFacts() { + when(queries.list("C", query)).thenReturn(List.of()); + var result = service.statistics("C", query); + assertEquals(0, result.orderCount()); assertEquals(0, result.requestedPackages()); + assertNull(result.actualPackages()); assertNull(result.requestedPieces()); assertNull(result.palletCount()); + verifyNoInteractions(dimensions); + } + /** 创建一票同客户同仓的隔离单据。 */ + private static CargoDiscardApplication application(long id) { var a = new CargoDiscardApplication(); a.setId(id); a.setCustomerId("C"); a.setOverseasWarehouseId("W"); return a; } + /** 明确退货件数和实际值,不引入固定箱规。 */ + private static CargoDiscardItem item(Long carton, String sku, int planned, Integer actual) { var i = new CargoDiscardItem(); i.setReturnCartonId(carton); i.setSkuCode(sku); i.setDiscardPackages(planned); i.setActualDiscardPackages(actual); return i; } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java new file mode 100644 index 000000000..f2411c501 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java @@ -0,0 +1,75 @@ +package com.baiyue.lastleg.relabel.service; + +import com.baiyue.lastleg.relabel.entity.RelabelItem; +import com.baiyue.lastleg.relabel.entity.RelabelApplication; +import com.baiyue.lastleg.relabel.repository.RelabelQuantityRepository; +import com.baiyue.lastleg.relabel.dto.RelabelRequests; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class RelabelQuantityServiceTest { + /** 以真实对象测试件数、箱数的不同业务口径。 */ + private RelabelQuantityService service() { + var service = new RelabelQuantityService(); + ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); + return service; + } + /** 构造单个箱任务,件数由SKU子项提供。 */ + private RelabelItem item(String type, String mark, int quantity) { + var item = new RelabelItem(); + item.setOperationType(type); item.setCartonMark(mark); item.setSkuCode("A"); + item.setNotifiedPackageCount(quantity); item.setTotalPackageCount(quantity); + item.setSkuRelabelItemsJson("[{\"skuCode\":\"A\",\"newSkuCode\":\"B\",\"quantity\":8}]"); + return item; + } + @Test void bulkPiecesNeverBecomeCartons() { + var result = service().measure(List.of(item("RETURN_BULK_SKU_LABEL", "PALLET-1", 50))); + assertEquals(50L, result.labelPieceCount()); assertNull(result.cartonCount()); + assertNull(result.sourcePalletCount()); assertNull(result.targetPalletCount()); + } + @Test void skuPiecesAndCartonCountAreIndependent() { + var result = service().measure(List.of(item("SKU_LABEL", "BOX-1", 1))); + assertEquals(8L, result.labelPieceCount()); assertEquals(1L, result.cartonCount()); assertEquals(1, result.skuCount()); + } + @Test void duplicateAssociationIsFlaggedAndCannotInflateCartonsOrGuessPieces() { + var result = service().measure(List.of(item("SKU_LABEL", "BOX-1", 1), item("SKU_LABEL", "BOX-1", 1))); + assertTrue(result.duplicateCarton()); assertEquals(1L, result.cartonCount()); assertNull(result.labelPieceCount()); + } + @Test void cartonOnlyAndMissingDataDoNotInventZeroPieces() { + var result = service().measure(List.of(item("CARTON_LABEL", "BOX-1", 1))); + assertNull(result.labelPieceCount()); assertEquals(1L, result.cartonCount()); + assertNull(service().measure(List.of()).cartonCount()); + } + @Test void mixedStagesDoNotDoubleCountTheBox() { + var result = service().measure(List.of(item("CARTON_AND_SKU", "BOX-1", 1))); + assertEquals(1L, result.cartonCount()); assertEquals(8L, result.labelPieceCount()); + } + @Test void differentBulkSkusInOneSourceAreNotDuplicateTasks() { + var first = item("RETURN_BULK_SKU_LABEL", "PALLET-1", 2); + var second = item("RETURN_BULK_SKU_LABEL", "PALLET-1", 3); + second.setSkuCode("C"); second.setSkuRelabelItemsJson("[]"); first.setSkuRelabelItemsJson("[]"); + var result = service().measure(List.of(first, second)); + assertFalse(result.duplicateCarton()); assertEquals(5L, result.labelPieceCount()); + assertNull(result.cartonCount()); assertEquals(2, result.skuCount()); + } + @Test void totalsUseAllFilteredApplicationsAndPreserveUnknownDimensions() { + var service = service(); var repository = mock(RelabelQuantityRepository.class); + ReflectionTestUtils.setField(service, "repository", repository); + var query = new RelabelRequests.Query(2, 1, null, null, "A", null, null, null, null); + var first = new RelabelApplication(); first.setRelabelNo("R1"); first.setCustomerId("C1"); + var second = new RelabelApplication(); second.setRelabelNo("R2"); second.setCustomerId("C1"); + when(repository.filtered("C1", query)).thenReturn(List.of(first, second)); + when(repository.items(first)).thenReturn(List.of(item("RETURN_BULK_SKU_LABEL", "P1", 50))); + when(repository.items(second)).thenReturn(List.of(item("CARTON_LABEL", "B1", 1))); + var totals = service.totals("C1", query); + assertEquals(2, totals.orderCount()); assertEquals(50, totals.knownLabelPieceCount()); + assertEquals(1, totals.unknownPieceOrders()); assertEquals(1, totals.knownCartonCount()); + assertEquals(1, totals.unknownCartonOrders()); assertEquals(1, totals.skuCount()); + verify(repository).filtered("C1", query); + assertEquals(2, totals.orders().size()); + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/UnifiedOrderStatusCountQuery.java b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderStatusCountQuery.java new file mode 100644 index 000000000..76776ae1b --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/UnifiedOrderStatusCountQuery.java @@ -0,0 +1,40 @@ +package com.baiyue.order.dto; + +import jakarta.validation.constraints.Size; +import jakarta.validation.constraints.Pattern; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.time.LocalDateTime; + +/** 页签统计独立接收筛选条件,避免未传分页时基本类型构造绑定失败。 */ +public record UnifiedOrderStatusCountQuery( + @Size(max = 64) String keyword, + @Size(max = 64) String orderNo, + Long customerId, + Long receivingWarehouseId, + Long destinationWarehouseId, + @Size(max = 64) String inboundCode, + @Size(max = 32) String internalOrderStatus, + LocalDate expectedArrivalDate, + @Size(max = 32) String feeReviewStatus, + @Size(max = 128) String channelName, + @Size(max = 64) String containerNo, + @Size(max = 64) String cabinetNo, + LocalDate sailingDate, + Boolean loaded, + Boolean fullContainer, + @Size(max = 32) String measurementReviewStatus, + @Size(max = 64) String fbaCode, + LocalDateTime createdFrom, + LocalDateTime createdTo, + @Schema(allowableValues = {"TAX_REFUND", "BUY_DECLARATION"}) + @Pattern(regexp = "TAX_REFUND|BUY_DECLARATION") String taxMode +) { + /** 复用列表查询条件,统计不受当前页签和分页影响。 */ + public UnifiedOrderQuery toOrderQuery() { + return new UnifiedOrderQuery(keyword, orderNo, null, customerId, receivingWarehouseId, + destinationWarehouseId, inboundCode, internalOrderStatus, expectedArrivalDate, + feeReviewStatus, channelName, containerNo, cabinetNo, sailingDate, loaded, fullContainer, + measurementReviewStatus, fbaCode, createdFrom, createdTo, 1, 200, taxMode); + } +} diff --git a/order/src/test/java/com/baiyue/order/controller/UnifiedOrderStatusCountBindingTest.java b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderStatusCountBindingTest.java new file mode 100644 index 000000000..e4464e347 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/controller/UnifiedOrderStatusCountBindingTest.java @@ -0,0 +1,91 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.service.UnifiedOrderQueryService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import java.time.LocalDateTime; +import java.util.Map; +import java.util.Set; +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +class UnifiedOrderStatusCountBindingTest { + private UnifiedOrderQueryService service; + private MockMvc mvc; + + /** 使用真实 MVC 构造绑定,复现浏览器不传分页的调用。 */ + @BeforeEach + void setup() { + service = mock(UnifiedOrderQueryService.class); + when(service.statusCounts(any(), any())).thenReturn( + new UnifiedOrderQueryService.StatusCounts(Map.of("WAIT_WEIGHT_CONFIRM", 125L, "SIGNED", 0L))); + var controller = new PlatformUnifiedOrderController(); + ReflectionTestUtils.setField(controller, "service", service); + mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + /** 仅为测试注入已认证的平台身份。 */ + public boolean supportsParameter(MethodParameter p) { return p.getParameterType() == AuthenticatedUser.class; } + /** 保持平台作用域守卫正常执行。 */ + public Object resolveArgument(MethodParameter p, ModelAndViewContainer c, NativeWebRequest r, WebDataBinderFactory f) { + return new AuthenticatedUser(1L, 0L, null, 1L, "operator", "PLATFORM", "WEB", Set.of("platform:order:list")); + } + }).build(); + } + + /** 不传分页时正常返回,并保留零数量。 */ + @Test + void acceptsRequestWithoutPagination() throws Exception { + mvc.perform(get("/api/platform/v1/orders/status-counts")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.counts.WAIT_WEIGHT_CONFIRM").value(125)) + .andExpect(jsonPath("$.data.counts.SIGNED").value(0)); + var query = org.mockito.ArgumentCaptor.forClass(UnifiedOrderQuery.class); + verify(service).statusCounts(isNull(), query.capture()); + assertThat(query.getValue().mainStatus()).isNull(); + } + + /** 筛选值完整保留,外部附带的页签和分页不会缩小统计范围。 */ + @Test + void preservesFiltersAndIgnoresPaginationAndStatus() throws Exception { + mvc.perform(get("/api/platform/v1/orders/status-counts") + .param("tenantId", "11").param("customerId", "11").param("loaded", "false") + .param("fullContainer", "false").param("taxMode", "TAX_REFUND") + .param("fbaCode", "FBA123").param("measurementReviewStatus", "APPROVED") + .param("createdFrom", "2026-10-01T00:00:00") + .param("mainStatus", "LOADED").param("page", "null").param("size", "null")) + .andExpect(status().isOk()); + var captured = org.mockito.ArgumentCaptor.forClass(UnifiedOrderQuery.class); + verify(service).statusCounts(eq(11L), captured.capture()); + var query = captured.getValue(); + assertThat(query.customerId()).isEqualTo(11L); + assertThat(query.loaded()).isFalse(); + assertThat(query.fullContainer()).isFalse(); + assertThat(query.taxMode()).isEqualTo("TAX_REFUND"); + assertThat(query.fbaCode()).isEqualTo("FBA123"); + assertThat(query.measurementReviewStatus()).isEqualTo("APPROVED"); + assertThat(query.createdFrom()).isEqualTo(LocalDateTime.of(2026, 10, 1, 0, 0)); + assertThat(query.mainStatus()).isNull(); + assertThat(query.page()).isEqualTo(1); + assertThat(query.size()).isEqualTo(200); + } + + /** 独立统计对象仍执行原有筛选值校验。 */ + @Test + void rejectsInvalidFilter() throws Exception { + mvc.perform(get("/api/platform/v1/orders/status-counts").param("taxMode", "INVALID")) + .andExpect(status().isBadRequest()); + verifyNoInteractions(service); + } +} diff --git a/output/local-schema-repair/actual-changes.json b/output/local-schema-repair/actual-changes.json new file mode 100644 index 000000000..bc1a05aa7 --- /dev/null +++ b/output/local-schema-repair/actual-changes.json @@ -0,0 +1,224 @@ +{ + "baiyue_lastleg": { + "createdTables": [ + "event_delivery_retry_audit", + "ll_appointment_cargo_verification", + "ll_appointment_followup", + "ll_appointment_impact", + "ll_appointment_impact_history", + "ll_delivery_facts_changed", + "ll_dispatch_command", + "ll_dispatch_delivery_arrangement", + "ll_dispatch_draft_command", + "ll_dispatch_history", + "ll_fba_appointment_command", + "ll_fba_receiving_agreement", + "ll_fba_receiving_change", + "ll_fba_receiving_member", + "ll_fba_receiving_plan", + "ll_fba_receiving_revision", + "ll_procurement_budget_authorization", + "ll_procurement_carton", + "ll_procurement_carton_source", + "ll_procurement_closure", + "ll_procurement_closure_item", + "ll_procurement_command", + "ll_procurement_control_note", + "ll_procurement_disposition", + "ll_procurement_estimate_review", + "ll_procurement_fee_correction", + "ll_procurement_fee_correction_item", + "ll_procurement_flow", + "ll_procurement_label_item", + "ll_procurement_label_set", + "ll_procurement_manifest", + "ll_procurement_receipt", + "ll_procurement_repack", + "ll_procurement_shipment", + "ll_procurement_shipment_item", + "ll_procurement_wallet_attempt", + "ll_return_carton_stock_receipt", + "ll_selfop_inventory_daily", + "ll_selfop_inventory_day_version", + "ll_selfop_inventory_flow", + "ll_selfop_inventory_snapshot", + "ll_selfop_outbound_discovery_scope", + "ll_selfop_outbound_item", + "ll_selfop_outbound_order", + "ll_selfop_owner", + "ll_selfop_product", + "ll_selfop_sku_daily_sales", + "ll_selfop_sku_profile", + "ll_selfop_sync_run", + "ll_selfop_sync_scope", + "ll_selfop_sync_stage", + "ll_selfop_sync_task", + "ll_selfop_warehouse_daily_sales" + ], + "addedColumns": [ + "ll_cargo_discard_carton.source_type", + "ll_dispatch_draft.warehouse_id", + "ll_dispatch_draft.record_version", + "ll_dispatch_draft.sources_json", + "ll_dispatch_draft_container.source_keys_json", + "ll_dispatch_item.customer_name", + "ll_dispatch_item.source_key", + "ll_dispatch_item.source_version", + "ll_dispatch_item.source_snapshot_json", + "ll_dispatch_order.trip_id", + "ll_dispatch_order.overseas_warehouse_code", + "ll_dispatch_order.stage", + "ll_dispatch_order.record_version", + "ll_dispatch_order.cargo_revision", + "ll_dispatch_order.cargo_hash", + "ll_dispatch_order.review_state", + "ll_dispatch_order.review_round", + "ll_dispatch_order.review_basis_hash", + "ll_dispatch_order.dispatch_review_json", + "ll_dispatch_order.loading_review_json", + "ll_dispatch_order.resources_json", + "ll_dispatch_order.planned_departure_at", + "ll_dispatch_order.loading_completed_at", + "ll_dispatch_order.loading_completion_hash", + "ll_dispatch_order.loading_confirmation_json", + "ll_dispatch_order.arrangement_key", + "ll_dispatch_order.arrangement_business_hash", + "ll_dispatch_order.departed_at", + "ll_dispatch_order.delivery_completed_at", + "ll_dispatch_order.remark", + "ll_dispatch_pallet_lock.active_warehouse_pallet_id", + "ll_dropship_carton.product_quantity", + "ll_dropship_order_profile.manifest_version", + "ll_dropship_order_profile.manifest_package_count", + "ll_dropship_order_profile.manifest_fragment_count", + "ll_dropship_order_profile.manifest_hash", + "ll_exception_order.return_context_json", + "ll_exception_return_item.return_carton_id", + "ll_exception_return_item.return_carton_mark", + "ll_exception_return_item.received_packages", + "ll_inbound_fba_appointment.confirmation_no", + "ll_inbound_fba_appointment.planned_date", + "ll_inbound_fba_appointment.planned_at", + "ll_inbound_fba_appointment.planned_end_at", + "ll_inbound_fba_appointment.appointment_start_utc", + "ll_inbound_fba_appointment.appointment_end_utc", + "ll_inbound_fba_appointment.timezone", + "ll_inbound_fba_appointment.origin_warehouse_id", + "ll_inbound_fba_appointment.replacement_keys_json", + "ll_inbound_fba_appointment.repartition_reason", + "ll_inbound_fba_appointment_item.cargo_verification_version", + "ll_inbound_fba_appointment_item.shipment_references_json", + "ll_local_looseboard_order.product_name", + "ll_local_looseboard_order.estimated_pallet_count", + "ll_local_looseboard_order.origin_contact_name", + "ll_local_looseboard_order.origin_contact_phone", + "ll_local_looseboard_order.destination_contact_name", + "ll_local_looseboard_order.destination_contact_phone", + "ll_local_looseboard_order.pickup_at", + "ll_local_looseboard_order.delivery_at", + "ll_outbound_pallet_item.dispatch_item_id", + "ll_outbound_pallet_item.source_attribution_id", + "ll_outbound_pallet_item.source_order_id", + "ll_outbound_pallet_item.source_inventory_position_id", + "ll_outbound_pallet_item.source_return_receipt_id", + "ll_outbound_pallet_item.source_return_carton_id", + "ll_overseas_procurement_fee.purpose", + "ll_overseas_procurement_fee.confirmation_origin", + "ll_overseas_procurement_fee.fee_version", + "ll_overseas_procurement_fee.active", + "ll_overseas_procurement_fee.determination", + "ll_overseas_procurement_fee.original_currency", + "ll_overseas_procurement_fee.original_amount", + "ll_overseas_procurement_fee.exchange_rate", + "ll_overseas_procurement_fee.exchange_rate_month", + "ll_overseas_procurement_fee.business_date", + "ll_overseas_procurement_fee.wallet_business_id", + "ll_overseas_procurement_fee.wallet_business_no", + "ll_overseas_procurement_fee.wallet_hold_id", + "ll_overseas_procurement_fee.wallet_ledger_id", + "ll_overseas_procurement_fee.settled_command_event_id", + "ll_overseas_procurement_fee.source_fee_id", + "ll_overseas_procurement_fee.parent_fee_id", + "ll_overseas_procurement_fee.request_hash", + "ll_overseas_procurement_status_history.operation_type", + "ll_overseas_procurement_status_history.change_detail", + "ll_receiving_container_session.source_container_booking_id", + "ll_receiving_container_session.container_fact_hash", + "ll_receiving_container_session.overseas_warehouse_code", + "ll_return_inventory_outbound_movement.return_sku_quantity", + "ll_us_truck_order.vehicle_type", + "ll_us_truck_order.origin_contact_name", + "ll_us_truck_order.origin_contact_phone", + "ll_us_truck_order.destination_contact_name", + "ll_us_truck_order.destination_contact_phone", + "ll_us_truck_order.pickup_at", + "ll_us_truck_order.delivery_at" + ], + "addedIndexes": [ + "ll_cargo_discard_item.uk_ll_discard_position_sku", + "ll_dispatch_draft_container.uk_ll_dispatch_draft_container_sequence", + "ll_dispatch_order.uk_dispatch_trip", + "ll_dispatch_pallet_lock.uk_ll_dispatch_active_warehouse_pallet", + "ll_dispatch_pda_pick_order.idx_dispatch_pick_return_receipt", + "ll_dropship_order_profile.idx_ll_dropship_profile_manifest", + "ll_dropship_sku_inventory.uk_ll_dropship_sku_inventory", + "ll_exception_order.idx_ll_exception_delivery_return", + "ll_exception_return_item.uk_ll_exception_return_carton", + "ll_fleet_trip_dispatch.uk_fleet_trip_dispatch_trip", + "ll_inbound_fba_appointment.idx_ll_fba_appointment_planned", + "ll_inbound_fba_appointment_item.idx_ll_fba_active_source", + "ll_inbound_fba_appointment_item.idx_ll_fba_active_order", + "ll_inbound_plan_pallet.idx_ll_inbound_pallet_no", + "ll_outbound_pallet_item.uk_outbound_source_attribution", + "ll_overseas_procurement_fee.uk_ll_procurement_fee_version", + "ll_return_packing_plan.idx_ll_return_packing_plan_warehouse", + "ll_warehouse_transfer_inventory_allocation.idx_ll_wh_transfer_allocation_position" + ] + }, + "baiyue_firstleg": { + "createdTables": [ + "event_delivery_retry_audit", + "fl_chargeable_weight_confirmation", + "fl_customs_broker_submission", + "fl_customs_clearance_result", + "fl_customs_completion_action_history", + "fl_customs_declaration_batch", + "fl_sailing", + "fl_sailing_assignment", + "fl_sailing_booking_link", + "fl_sailing_channel", + "fl_sailing_history", + "fl_sailing_inbound_volume_forecast", + "fl_sailing_receipt_batch_fact", + "fl_sailing_template" + ], + "addedColumns": [ + "fl_customs_completion_document.customer_confirm_status", + "fl_customs_completion_document.customer_confirmed_by", + "fl_customs_completion_document.customer_confirmed_by_name", + "fl_customs_completion_document.customer_confirmed_at", + "fl_customs_completion_document.customer_reject_reason", + "fl_fee_item.default_price" + ], + "addedIndexes": [ + "fl_carrier_schedule_node_fact.idx_container_shipping_node_timeline", + "fl_container_booking.uk_container_booking_ocean_container", + "fl_container_booking.idx_container_booking_container_no", + "fl_container_booking.idx_container_booking_merge", + "fl_container_booking.idx_container_booking_transit_summary", + "fl_container_fee_detail.idx_container_fee_review", + "fl_container_transport_history.uk_container_transport_history_operation", + "fl_customer_charge_rule.idx_charge_rule_initialization", + "fl_inbound_measurement.idx_measure_inbound_status_id", + "fl_inbound_measurement_item.idx_measure_item_candidate", + "fl_inbound_order.idx_inbound_smart_loading", + "fl_inbound_split_item.idx_inbound_split_candidate", + "fl_loading_plan_item.uk_loading_item_plan_assignment", + "fl_loading_plan_item.idx_loading_item_candidate", + "fl_loading_plan_item.idx_loading_item_pallet", + "fl_ocean_booking.uk_ocean_booking_bill_of_lading", + "fl_truck_appointment.uk_truck_active_ocean_booking", + "fl_truck_appointment.idx_truck_appointment_ocean" + ] + } +} \ No newline at end of file diff --git a/output/local-schema-repair/actual-incremental-review.sql b/output/local-schema-repair/actual-incremental-review.sql new file mode 100644 index 000000000..a168a19ee --- /dev/null +++ b/output/local-schema-repair/actual-incremental-review.sql @@ -0,0 +1,2420 @@ +-- 本次本机结构修复的实际增量,仅用于审阅与原始快照对照。已执行,禁止在当前库重复执行。 + +SET NAMES utf8mb4; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +CREATE TABLE `event_delivery_retry_audit` ( + `request_id` varchar(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + `producer_table` varchar(64) NOT NULL COMMENT '本服务发件箱表名', + `entry_id` varchar(64) NOT NULL COMMENT '发件箱记录主键', + `event_id` varchar(128) NOT NULL COMMENT '原事件全局标识', + `event_type` varchar(64) NOT NULL COMMENT '原事件类型', + `payload_hash` varchar(71) NOT NULL COMMENT '原事件不可变负载摘要', + `operator_name` varchar(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + `reason` varchar(500) NOT NULL COMMENT '人工重试原因', + `previous_failures` int NOT NULL COMMENT '重试前累计失败次数', + `previous_error` varchar(1000) DEFAULT NULL COMMENT '重试前最后一次脱敏错误', + `requested_at` datetime(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (`request_id`), + KEY `idx_event_retry_entry` (`producer_table`,`entry_id`,`requested_at`), + KEY `idx_event_retry_event` (`event_id`,`requested_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_appointment_cargo_verification` ( + `id` bigint NOT NULL COMMENT '核实记录主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原货源所属客户', + `source_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定ORDER或BOOKING原来源键', + `destination_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '核实时的原目的地', + `source_version` varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '核实时的原来源版本', + `source_packages` int DEFAULT NULL COMMENT '核实时的原来源箱数,未知为空', + `version` int NOT NULL COMMENT '同一来源的核实版本,从一递增', + `package_count` int DEFAULT NULL COMMENT '独立核实箱数,不回写原订单', + `shipment_references_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '核实说明', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登录操作人', + `operator_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人名称快照', + `command_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源内幂等命令键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源、操作人及请求摘要', + `created_at` datetime(3) NOT NULL COMMENT '核实UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_cargo_verification_version` (`customer_id`,`source_key`,`version`), + UNIQUE KEY `uk_ll_cargo_verification_command` (`customer_id`,`source_key`,`command_key`), + CONSTRAINT `chk_ll_cargo_verification_quantity` CHECK (((`package_count` is null) or (`package_count` > 0))), + CONSTRAINT `chk_ll_cargo_verification_version` CHECK ((`version` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_appointment_followup` ( + `id` bigint NOT NULL COMMENT '预约筹划跟进记录ID', + `source_key` varchar(160) NOT NULL COMMENT '稳定ORDER、BOOKING或CONTAINER及接仓点来源键', + `customer_id` varchar(128) NOT NULL COMMENT '货源客户;整柜共享记录由服务端固定为平台作用域0', + `source_order_id` bigint DEFAULT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空', + `stage` varchar(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING', + `version` int NOT NULL COMMENT '同一货源及跟进对象的记录版本', + `contacted_party` varchar(200) DEFAULT NULL COMMENT '本次联系对象,不替代承运主档', + `result` varchar(1000) NOT NULL COMMENT '本次反馈事实,不自动确认预约或推进实物作业', + `next_action` varchar(500) DEFAULT NULL COMMENT '下一步安排', + `followup_date` date DEFAULT NULL COMMENT '按业务地点当地日期记录的下次跟进日', + `operator_id` varchar(128) NOT NULL COMMENT '登录平台操作者ID', + `operator_name` varchar(200) DEFAULT NULL COMMENT '操作人姓名快照', + `command_key` varchar(64) NOT NULL COMMENT '持久幂等键', + `request_hash` char(64) NOT NULL COMMENT '含操作者身份的请求SHA256摘要', + `created_at` datetime(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_appointment_followup_version` (`source_key`,`stage`,`version`), + UNIQUE KEY `uk_appointment_followup_command` (`source_key`,`stage`,`command_key`), + KEY `idx_appointment_followup_date` (`followup_date`,`source_order_id`), + KEY `fk_appointment_followup_order` (`source_order_id`), + CONSTRAINT `fk_appointment_followup_order` FOREIGN KEY (`source_order_id`) REFERENCES `ll_order` (`id`), + CONSTRAINT `chk_appointment_followup_stage` CHECK ((`stage` in (_utf8mb4'DRAYAGE_CONTACT',_utf8mb4'TERMINAL_APPOINTMENT',_utf8mb4'WAREHOUSE_RECEIVING',_utf8mb4'FBA_RECEIVING'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='真实在途货源的预约筹划联系记录,追加留痕'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_appointment_impact` ( + `container_booking_id` varchar(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示', + `impact_no` varchar(40) NOT NULL COMMENT '船期核对事项业务号', + `baseline_json` longtext NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径', + `latest_json` longtext NOT NULL COMMENT '最新已读取观察,未知可为JSON null', + `forecast_token` char(64) NOT NULL COMMENT '最新观察摘要,处理时防止旧预测结案', + `status` varchar(20) NOT NULL COMMENT '事项状态:MONITORING监测/OPEN待处理/RESOLVED已完成', + `reason` varchar(64) DEFAULT NULL COMMENT '预测变化或资料待核实原因', + `owner_id` varchar(128) DEFAULT NULL COMMENT '认领平台人员ID', + `owner_name` varchar(200) DEFAULT NULL COMMENT '负责人姓名快照', + `version` int NOT NULL COMMENT '观察及人工处理共同版本', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + PRIMARY KEY (`container_booking_id`), + UNIQUE KEY `uk_appointment_impact_no` (`impact_no`), + KEY `idx_appointment_impact_work` (`status`,`updated_at`), + CONSTRAINT `chk_appointment_impact_status` CHECK ((`status` in (_utf8mb4'MONITORING',_utf8mb4'OPEN',_utf8mb4'RESOLVED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_appointment_impact_history` ( + `id` bigint NOT NULL COMMENT '历史记录标识', + `container_booking_id` varchar(64) NOT NULL COMMENT '关联柜次标识', + `version` int NOT NULL COMMENT '记录版本', + `action` varchar(20) NOT NULL COMMENT '操作类型:OBSERVED观察/CLAIM认领/RECORD记录/RESOLVE完成', + `operator_id` varchar(128) NOT NULL COMMENT '操作人标识', + `operator_name` varchar(200) DEFAULT NULL COMMENT '操作人姓名', + `result` varchar(1000) DEFAULT NULL COMMENT '人工处理结果;观察记录为空', + `next_action` varchar(500) DEFAULT NULL COMMENT '下一步处理事项', + `followup_date` date DEFAULT NULL COMMENT '业务跟进日期', + `command_key` varchar(64) DEFAULT NULL COMMENT '人工命令幂等键;观察记录为空', + `request_hash` char(64) DEFAULT NULL COMMENT '请求内容摘要', + `response_json` longtext NOT NULL COMMENT '该版本完整响应快照,支持精确重放', + `created_at` datetime(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_appointment_impact_version` (`container_booking_id`,`version`), + UNIQUE KEY `uk_appointment_impact_command` (`container_booking_id`,`operator_id`,`command_key`), + CONSTRAINT `fk_appointment_impact_history` FOREIGN KEY (`container_booking_id`) REFERENCES `ll_appointment_impact` (`container_booking_id`), + CONSTRAINT `chk_appointment_impact_action` CHECK ((`action` in (_utf8mb4'OBSERVED',_utf8mb4'CLAIM',_utf8mb4'RECORD',_utf8mb4'RESOLVE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期观察及人工处理追加历史'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_cargo_discard_carton` ADD COLUMN `source_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER'; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_cargo_discard_item` ADD UNIQUE KEY `uk_ll_discard_position_sku` (`application_id`,`inventory_position_id`,`sku_code`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_delivery_facts_changed` ( + `id` bigint NOT NULL COMMENT '持久信号内部主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实签收货物客户ID', + `dispatch_id` bigint NOT NULL COMMENT '整车排车主单ID', + `dispatch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实排车业务号', + `delivery_id` bigint NOT NULL COMMENT '本客户派送单ID', + `delivery_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实派送业务号', + `delivery_version` bigint NOT NULL COMMENT '触发信号的真实派送版本', + `facts_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '该版本真实签收事实摘要', + `occurred_at` datetime(3) NOT NULL COMMENT '签收事实实际发生时间', + `state` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '待核对PENDING或已核对PROCESSED', + `attempt_count` int NOT NULL DEFAULT '0' COMMENT '已记录的汇总失败次数', + `next_attempt_at` datetime(3) NOT NULL COMMENT '下次可重试时间', + `last_error_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近失败诊断码不保存敏感异常正文', + `processed_at` datetime(3) DEFAULT NULL COMMENT '与主阶段汇总同事务确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_delivery_fact_version` (`customer_id`,`delivery_id`,`delivery_version`), + KEY `idx_delivery_fact_due` (`state`,`next_attempt_at`,`id`), + KEY `idx_delivery_fact_dispatch` (`dispatch_id`,`state`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正常签收事实变化的本地持久待汇总信号'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_dispatch_command` ( + `id` bigint NOT NULL COMMENT '持久命令内部主键', + `dispatch_id` bigint NOT NULL COMMENT '命令所属整车排车主单ID', + `dispatch_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令所属真实排车业务号', + `action` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次整车作业动作', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始可信操作主体ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨操作主体全局唯一的原始请求键', + `record_version` bigint NOT NULL COMMENT '原请求明确提交的排车版本,零为合法首版', + `command_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '绑定原主体动作业务号版本和完整载荷的严格摘要', + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '按动作类型恢复的不可变成功结果JSON', + `created_at` datetime(3) NOT NULL COMMENT '成功命令记录时间,使用UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dispatch_command_key` (`idempotency_key`), + KEY `idx_dispatch_command_history` (`dispatch_id`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车公共持久命令账本'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_dispatch_delivery_arrangement` ( + `id` bigint NOT NULL COMMENT '安排事实内部主键', + `dispatch_order_id` bigint NOT NULL COMMENT '唯一整车排车主单ID', + `dispatch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实排车业务号', + `arrangement_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次唯一安排业务事实标识', + `business_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '排除操作者及纯并发版本的安排业务摘要', + `cargo_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本车实际拣货来源摘要', + `loading_completion_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '装车完成事实摘要', + `original_command_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '首次安排的全局请求键', + `original_operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '首次安排操作主体', + `result_json` json NOT NULL COMMENT '全部客户出库及客户目的地派送不可变结果', + `cargo_json` json NOT NULL COMMENT '逐客户逐拣货归属的不可变真实来源', + `created_at` datetime(3) NOT NULL COMMENT '本次安排实际创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dispatch_arrangement_dispatch` (`dispatch_order_id`), + UNIQUE KEY `uk_dispatch_arrangement_key` (`arrangement_key`), + UNIQUE KEY `uk_dispatch_arrangement_command` (`original_command_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车唯一派送安排事实'; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dispatch_draft` ADD COLUMN `warehouse_id` bigint DEFAULT NULL COMMENT '本草稿唯一起运公共仓库引用'; + +ALTER TABLE `ll_dispatch_draft` ADD COLUMN `record_version` bigint NOT NULL DEFAULT '0' COMMENT '草稿并发版本,显式零版本合法'; + +ALTER TABLE `ll_dispatch_draft` ADD COLUMN `sources_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '服务端核验的真实来源及客户版本快照数组'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_dispatch_draft_command` ( + `id` bigint NOT NULL COMMENT '草稿命令内部主键', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿操作全局唯一幂等键', + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原命令操作主体,恢复必须为本人', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿创建、维护、选货、释放或转换动作', + `target_token` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令目标草稿标识或创建范围标识', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原动作、主体、版本及完整载荷摘要', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '本事务完成后保存的不可变原结果,不提交空结果', + `created_at` datetime(3) NOT NULL COMMENT '命令创建UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_draft_command_key` (`idempotency_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿持久命令及原结果'; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dispatch_draft_container` ADD COLUMN `source_keys_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '本装车容器已取得本草稿占用的来源键数组'; + +ALTER TABLE `ll_dispatch_draft_container` ADD UNIQUE KEY `uk_ll_dispatch_draft_container_sequence` (`draft_id`,`sequence_no`); + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_dispatch_history` ( + `id` bigint NOT NULL COMMENT '排车历史内部主键', + `dispatch_id` bigint NOT NULL COMMENT '本次历史所属整车主单ID', + `record_version` bigint NOT NULL COMMENT '操作完成后的整车公共版本', + `action` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次真实业务动作', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '可信操作主体ID', + `reason` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核驳回或变更原因', + `snapshot_json` json NOT NULL COMMENT '动作完成当时的完整主单事实快照', + `occurred_at` datetime(3) NOT NULL COMMENT '真实操作发生时间', + PRIMARY KEY (`id`), + KEY `idx_dispatch_history_version` (`dispatch_id`,`record_version`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车版本与审核轮次的追加历史'; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dispatch_item` ADD COLUMN `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本条真实客户名称快照'; + +ALTER TABLE `ll_dispatch_item` ADD COLUMN `source_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本条货物稳定权威来源键'; + +ALTER TABLE `ll_dispatch_item` ADD COLUMN `source_version` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '选货时核对的真实来源版本摘要'; + +ALTER TABLE `ll_dispatch_item` ADD COLUMN `source_snapshot_json` json DEFAULT NULL COMMENT '本条货物不可变来源及关联事实快照'; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `trip_id` bigint DEFAULT NULL COMMENT '本趟唯一车辆资源记录ID'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `overseas_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运海外仓编码快照'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `stage` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BEFORE_LOADING' COMMENT '作业阶段:BEFORE_LOADING装车前、LOADING装车中、AFTER_LOADING装车后、DELIVERING派送中、DELIVERED派送完成'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `record_version` bigint NOT NULL DEFAULT '0' COMMENT '整车公共并发版本,首版为零'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `cargo_revision` bigint NOT NULL DEFAULT '0' COMMENT '本趟货物修订版本'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `cargo_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前整车真实货物及来源摘要'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `review_state` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `review_round` bigint NOT NULL DEFAULT '0' COMMENT '当前审核轮次'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `review_basis_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本审核轮次核对的货物和资源依据摘要'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `dispatch_review_json` json DEFAULT NULL COMMENT '当前调度审核事实快照'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `loading_review_json` json DEFAULT NULL COMMENT '当前理货审核事实快照'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `resources_json` json DEFAULT NULL COMMENT '本趟主档或临时车辆司机的真实资源快照'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `planned_departure_at` datetime(3) DEFAULT NULL COMMENT '计划发车时间,草稿未知时为空'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `loading_completed_at` datetime(3) DEFAULT NULL COMMENT '实际确认装车完成时间'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `loading_completion_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际装车完成事实摘要'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `loading_confirmation_json` json DEFAULT NULL COMMENT '装车确认时逐实托真实实载完整快照'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `arrangement_key` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本车唯一派送安排事实标识'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `arrangement_business_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本车派送安排业务内容摘要'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `departed_at` datetime(3) DEFAULT NULL COMMENT '本趟实际发车时间'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `delivery_completed_at` datetime(3) DEFAULT NULL COMMENT '全部派送正常签收后的整车完成时间'; + +ALTER TABLE `ll_dispatch_order` ADD COLUMN `remark` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '排车业务备注'; + +ALTER TABLE `ll_dispatch_order` ADD UNIQUE KEY `uk_dispatch_trip` (`trip_id`); + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dispatch_pallet_lock` ADD COLUMN `active_warehouse_pallet_id` bigint DEFAULT NULL COMMENT '活动占用的真实仓库托盘,释放后为空,跨客户及来源别名唯一'; + +ALTER TABLE `ll_dispatch_pallet_lock` ADD UNIQUE KEY `uk_ll_dispatch_active_warehouse_pallet` (`active_warehouse_pallet_id`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dispatch_pda_pick_order` ADD KEY `idx_dispatch_pick_return_receipt` (`customer_id`,`return_receipt_id`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dropship_carton` ADD COLUMN `product_quantity` bigint DEFAULT NULL COMMENT '该箱实收商品件数'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dropship_order_profile` ADD COLUMN `manifest_version` int DEFAULT NULL COMMENT '整托清单版本'; + +ALTER TABLE `ll_dropship_order_profile` ADD COLUMN `manifest_package_count` int DEFAULT NULL COMMENT '整托总箱数'; + +ALTER TABLE `ll_dropship_order_profile` ADD COLUMN `manifest_fragment_count` int DEFAULT NULL COMMENT '整托客户分片总数'; + +ALTER TABLE `ll_dropship_order_profile` ADD COLUMN `manifest_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '整托完整清单摘要'; + +ALTER TABLE `ll_dropship_order_profile` ADD KEY `idx_ll_dropship_profile_manifest` (`source_pallet_no`,`manifest_hash`); + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_dropship_sku_inventory` ADD UNIQUE KEY `uk_ll_dropship_sku_inventory` (`profile_id`,`sku_code`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_exception_order` ADD COLUMN `return_context_json` json DEFAULT NULL COMMENT '退回异常创建前的派送及尾程状态快照'; + +ALTER TABLE `ll_exception_order` ADD KEY `idx_ll_exception_delivery_return` (`customer_id`,`delivery_order_id`,`exception_type`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_exception_return_item` ADD COLUMN `return_carton_id` bigint DEFAULT NULL COMMENT '原生退货箱标识;与普通库存位置互斥'; + +ALTER TABLE `ll_exception_return_item` ADD COLUMN `return_carton_mark` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原生退货箱业务箱码快照'; + +ALTER TABLE `ll_exception_return_item` ADD COLUMN `received_packages` bigint NOT NULL DEFAULT '0' COMMENT '已实际退回入仓的累计箱数'; + +ALTER TABLE `ll_exception_return_item` ADD UNIQUE KEY `uk_ll_exception_return_carton` (`customer_id`,`exception_order_id`,`outbound_pallet_item_id`,`return_carton_id`); + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_fba_appointment_command` ( + `command_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '持久化幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作、操作者和请求摘要', + `operation` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型及目标业务号', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登录操作人', + `appointment_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '系统预约业务单号', + `response_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '原命令返回快照,与业务写入同事务', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', + PRIMARY KEY (`command_key`), + KEY `idx_ll_fba_command_appointment` (`appointment_no`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约命令与变更历史'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_fba_receiving_agreement` ( + `agreement_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货预约独立业务键', + `version` int NOT NULL COMMENT '预约覆盖范围与确认资料版本', + `status` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '有效、取消或已替代状态', + `destination_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '权威目的仓关联键', + `confirmation_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前有效外部约号,失效后清空以释放覆盖', + `snapshot_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户逐来源覆盖、确认时区及仓库依据快照', + PRIMARY KEY (`agreement_key`), + UNIQUE KEY `uk_fba_receiving_confirmation` (`destination_key`,`confirmation_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立于送货批次的收货预约'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_fba_receiving_change` ( + `change_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户预约变化业务键', + `version` int NOT NULL COMMENT '变化续办版本', + `status` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '待仓库确认、已应用或已撤回状态', + `batch_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '受影响原送货批次键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次变化所属客户', + `snapshot_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原覆盖快照、逐来源拟保留量、负责人及跟进资料', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '确认后的实际批次去向及逐步骤结果', + `active_ref` varchar(260) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '同批同客户唯一未解决变化引用', + PRIMARY KEY (`change_key`), + UNIQUE KEY `uk_fba_receiving_pending` (`active_ref`), + KEY `idx_fba_receiving_pending_batch` (`batch_key`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='保留原安排且阻止误发车的客户变化'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_fba_receiving_member` ( + `agreement_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属收货预约业务键', + `batch_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定送货批次键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '货物所属客户标识', + `active_ref` varchar(260) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '有效批次与客户唯一引用,退出后清空且历史保留', + PRIMARY KEY (`agreement_key`,`batch_key`,`customer_id`), + UNIQUE KEY `uk_fba_receiving_active_member` (`active_ref`), + KEY `idx_fba_receiving_batch` (`batch_key`,`customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约当前覆盖关联及退出历史'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_fba_receiving_plan` ( + `batch_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定送货批次键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '计划所属客户标识', + `snapshot_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本客户未确认计划、目标窗口及业务时区,不含有效预约确认', + PRIMARY KEY (`batch_key`,`customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按批次及客户保存的未确认收货计划'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_fba_receiving_revision` ( + `agreement_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货预约业务键', + `version` int NOT NULL COMMENT '不可变覆盖与确认版本', + `snapshot_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '该版本完整客户覆盖、确认时段及回复依据', + PRIMARY KEY (`agreement_key`,`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约覆盖与确认不可变历史'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_fleet_trip_dispatch` ADD UNIQUE KEY `uk_fleet_trip_dispatch_trip` (`trip_id`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `confirmation_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '收货方正式外部确认号'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `planned_date` date DEFAULT NULL COMMENT '计划当地日期,可只有日期而无时段'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `planned_at` datetime(3) DEFAULT NULL COMMENT '计划开始当地时间,待定可空'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `planned_end_at` datetime(3) DEFAULT NULL COMMENT '计划结束当地时间'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `appointment_start_utc` datetime(3) DEFAULT NULL COMMENT '确认开始UTC时间点'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `appointment_end_utc` datetime(3) DEFAULT NULL COMMENT '确认结束UTC时间点'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `timezone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓权威IANA时区,未维护不得确认'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `origin_warehouse_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际起运仓'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `replacement_keys_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '替代批次不可变键列表,只用于内部业务关联'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD COLUMN `repartition_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '分批或合并原因,原安排及来源明细保留'; + +ALTER TABLE `ll_inbound_fba_appointment` ADD KEY `idx_ll_fba_appointment_planned` (`planned_at`,`appointment_status`); + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_inbound_fba_appointment_item` ADD COLUMN `cargo_verification_version` int DEFAULT NULL COMMENT '采用的预约货物核实版本,未核实时为空'; + +ALTER TABLE `ll_inbound_fba_appointment_item` ADD COLUMN `shipment_references_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '本次采用的货件或追踪号核实快照'; + +ALTER TABLE `ll_inbound_fba_appointment_item` ADD KEY `idx_ll_fba_active_source` (`customer_id`,`source_key`,`active`); + +ALTER TABLE `ll_inbound_fba_appointment_item` ADD KEY `idx_ll_fba_active_order` (`customer_id`,`order_id`,`active`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_inbound_plan_pallet` ADD KEY `idx_ll_inbound_pallet_no` (`pallet_no`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '商品名称'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN `estimated_pallet_count` int DEFAULT NULL COMMENT '客户申报预计托数,不代表实际收货'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN `origin_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地联系人'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN `origin_contact_phone` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地联系电话'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN `destination_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地联系人'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN `destination_contact_phone` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地联系电话'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN `pickup_at` datetime(3) DEFAULT NULL COMMENT '取货时间(当地时间,不转换时区)'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN `delivery_at` datetime(3) DEFAULT NULL COMMENT '送货时间(当地时间,不转换时区)'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN `dispatch_item_id` bigint DEFAULT NULL COMMENT '本次整车排车作业明细ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN `source_attribution_id` bigint DEFAULT NULL COMMENT '不可变PDA真实拣货归属ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN `source_order_id` bigint DEFAULT NULL COMMENT '本行真实来源尾程订单ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN `source_inventory_position_id` bigint DEFAULT NULL COMMENT '本行实际拣货库存仓位ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN `source_return_receipt_id` bigint DEFAULT NULL COMMENT '本行实际退回再派实收记录ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN `source_return_carton_id` bigint DEFAULT NULL COMMENT '本行实际退货换标来源箱ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD UNIQUE KEY `uk_outbound_source_attribution` (`customer_id`,`source_attribution_id`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `purpose` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用业务用途;由履约节点确定'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `confirmation_origin` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `fee_version` int DEFAULT NULL COMMENT '同用途费用确认版本'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否当前有效版本'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `determination` varchar(24) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `original_currency` char(3) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '核定原币CNY或USD'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `original_amount` decimal(20,2) DEFAULT NULL COMMENT '核定原币金额'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `exchange_rate` decimal(20,6) DEFAULT NULL COMMENT '已确认汇率快照'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `exchange_rate_month` char(7) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '已确认汇率所属月份'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `business_date` date DEFAULT NULL COMMENT '实际费用业务日期'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `wallet_business_id` bigint DEFAULT NULL COMMENT '资金冻结对应采购费用记录'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `wallet_business_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金冻结业务号;实际国际运费引用原预估'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `wallet_hold_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Finance冻结引用'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `wallet_ledger_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Finance实际处理流水引用'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `settled_command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '成功实扣或退款的资金命令事件'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `source_fee_id` bigint DEFAULT NULL COMMENT '已实扣费用来源ID'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `parent_fee_id` bigint DEFAULT NULL COMMENT '原费用或预估引用'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN `request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '确认请求摘要'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD UNIQUE KEY `uk_ll_procurement_fee_version` (`order_id`,`purpose`,`fee_item_code`,`fee_version`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_overseas_procurement_status_history` ADD COLUMN `operation_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE'; + +ALTER TABLE `ll_overseas_procurement_status_history` ADD COLUMN `change_detail` json DEFAULT NULL COMMENT '字段变更前后值JSON'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_budget_authorization` ( + `id` bigint NOT NULL COMMENT '预算授权ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `previous_amount` decimal(20,2) DEFAULT NULL COMMENT '原货款预算总上限;未授权保持空', + `previous_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原预算币种', + `authorized_amount` decimal(20,2) NOT NULL COMMENT '客户授权的新货款总上限', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户授权币种', + `voucher_file_id` bigint NOT NULL COMMENT '客户确认书面或聊天凭证', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '授权依据', + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '核验人员', + `reviewed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', + PRIMARY KEY (`id`), + KEY `idx_procurement_budget_order` (`customer_id`,`order_id`,`id`), + CONSTRAINT `chk_procurement_budget_amount` CHECK (((`authorized_amount` >= 0) and (`previous_amount` >= 0))), + CONSTRAINT `chk_procurement_budget_currency` CHECK (((`currency` in (_utf8mb4'CNY',_utf8mb4'USD')) and (`previous_currency` in (_utf8mb4'CNY',_utf8mb4'USD')))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_carton` ( + `id` bigint NOT NULL COMMENT '版本内箱记录ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `manifest_id` bigint NOT NULL COMMENT '装箱清单版本', + `carton_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购订单内业务箱号', + `length_cm` decimal(12,2) NOT NULL COMMENT '外箱长厘米', + `width_cm` decimal(12,2) NOT NULL COMMENT '外箱宽厘米', + `height_cm` decimal(12,2) NOT NULL COMMENT '外箱高厘米', + `gross_weight_kg` decimal(12,3) NOT NULL COMMENT '封箱毛重千克', + `quantity` bigint NOT NULL COMMENT '本箱合格商品总件数', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_carton_version` (`manifest_id`,`carton_no`), + KEY `idx_procurement_carton_order` (`order_id`,`manifest_id`), + CONSTRAINT `chk_procurement_carton_measurement` CHECK (((`length_cm` > 0) and (`width_cm` > 0) and (`height_cm` > 0) and (`gross_weight_kg` > 0) and (`quantity` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_carton_source` ( + `carton_id` bigint NOT NULL COMMENT '版本内箱ID', + `receipt_id` bigint NOT NULL COMMENT '海外实收明细来源', + `quantity` bigint NOT NULL COMMENT '从该实收装入本箱的件数', + PRIMARY KEY (`carton_id`,`receipt_id`), + KEY `idx_procurement_carton_receipt` (`receipt_id`), + CONSTRAINT `chk_procurement_carton_source_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_closure` ( + `id` bigint NOT NULL COMMENT '采购异常关闭ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `closure_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '异常关闭业务号', + `customer_voucher_file_id` bigint NOT NULL COMMENT '客户同意停止履约凭证', + `financial_conclusion` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '内部关闭原因', + `client_summary` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '人工确认的对客关闭摘要', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购管理确认人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_closure_order` (`order_id`), + UNIQUE KEY `uk_procurement_closure_no` (`closure_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_closure_item` ( + `id` bigint NOT NULL COMMENT '关闭货物处置ID', + `closure_id` bigint NOT NULL COMMENT '采购关闭ID', + `source_kind` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', + `source_id` bigint DEFAULT NULL COMMENT '原实收或国际交寄箱,购买未达为空', + `quantity` bigint NOT NULL COMMENT '本来源最终明确去向件数', + `decision` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', + `destination` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际去向及接收方说明', + `voucher_file_id` bigint NOT NULL COMMENT '本单实物处置证明', + PRIMARY KEY (`id`), + KEY `idx_procurement_closure_source` (`closure_id`,`source_kind`,`source_id`), + CONSTRAINT `chk_procurement_closure_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_command` ( + `id` bigint NOT NULL COMMENT '操作记录ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `action` varchar(48) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购节点动作', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求摘要', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '执行人', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作依据', + `detail_json` json NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_command` (`order_id`,`action`,`idempotency_key`), + KEY `idx_procurement_command_customer` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_control_note` ( + `id` bigint NOT NULL COMMENT '采购更正说明ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `note_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更正说明业务号', + `target_type` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', + `target_id` bigint DEFAULT NULL COMMENT '本单事实对象引用,购买为空', + `blocking` tinyint(1) NOT NULL COMMENT '是否阻断后续计划履约', + `voucher_file_id` bigint NOT NULL COMMENT '本单内部事实更正凭证', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '内部更正说明', + `client_summary` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', + `original_value_json` json DEFAULT NULL COMMENT '服务端记录的原事实快照', + `corrected_value_json` json DEFAULT NULL COMMENT '服务端记录的更正事实快照', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购管理操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + `resolved_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理确认人', + `resolved_at` datetime(3) DEFAULT NULL COMMENT '阻断解除时间', + `resolution_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '内部处理结果', + `resolution_voucher_id` bigint DEFAULT NULL COMMENT '本单处理凭证', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_note_no` (`note_no`), + KEY `idx_procurement_note_block` (`order_id`,`blocking`,`resolved_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_disposition` ( + `id` bigint NOT NULL COMMENT '差异处理ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `leg` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + `source_carton_id` bigint DEFAULT NULL COMMENT '国内原交寄箱来源', + `receipt_id` bigint DEFAULT NULL COMMENT '破损或多货对应实收记录,短少为空', + `kind` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', + `quantity` bigint NOT NULL COMMENT '已批准处置件数', + `decision` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '当前有效处置;撤销只改变有效性并保留原事实', + `voucher_file_id` bigint NOT NULL COMMENT '本单客户确认或实际处置凭证', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处置依据', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购管理人员', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY (`id`), + KEY `idx_procurement_disposition_source` (`order_id`,`leg`,`source_carton_id`,`receipt_id`,`kind`), + CONSTRAINT `chk_procurement_disposition_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_estimate_review` ( + `id` bigint NOT NULL COMMENT '预估核对ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `label_set_id` bigint NOT NULL COMMENT '已核对国际面单版本', + `fee_id` bigint NOT NULL COMMENT '唯一有效预估费用版本', + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '核价人员', + `reviewed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_estimate_review` (`label_set_id`,`fee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_fee_correction` ( + `id` bigint NOT NULL COMMENT '费用链更正ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `correction_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '财务更正业务号', + `root_fee_id` bigint NOT NULL COMMENT '原实际费用链根', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本单更正请求幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更正目标及凭证请求摘要', + `target_original_amount` decimal(20,2) NOT NULL COMMENT '本次正确原币总额', + `original_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '沿用原费用币种', + `exchange_rate` decimal(20,6) NOT NULL COMMENT '沿用原费用汇率快照', + `exchange_rate_month` char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原汇率月份,不取本次操作月份', + `target_amount` decimal(20,2) NOT NULL COMMENT '正确总额折算CNY', + `previous_net_amount` decimal(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', + `difference_amount` decimal(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', + `voucher_file_id` bigint NOT NULL COMMENT '本订单财务更正凭证', + `reason` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更正依据', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '授权财务操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_correction_no` (`correction_no`), + UNIQUE KEY `uk_procurement_correction_request` (`order_id`,`idempotency_key`), + KEY `idx_procurement_correction_root` (`order_id`,`root_fee_id`,`id`), + CONSTRAINT `chk_procurement_correction_amount` CHECK (((`target_original_amount` >= 0) and (`target_amount` >= 0) and (`previous_net_amount` >= 0) and (`exchange_rate` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_fee_correction_item` ( + `correction_id` bigint NOT NULL COMMENT '本次费用链更正ID', + `fee_id` bigint NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', + PRIMARY KEY (`correction_id`,`fee_id`), + UNIQUE KEY `uk_procurement_correction_fee` (`fee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_flow` ( + `order_id` bigint NOT NULL COMMENT '采购订单ID,一单一个履约批次', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `purchased_at` datetime(3) DEFAULT NULL COMMENT '真实购买时间', + `purchased_product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际购买商品', + `purchased_specification` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际购买规格', + `purchased_quantity` bigint DEFAULT NULL COMMENT '实际购买件数', + `purchase_amount` decimal(20,2) DEFAULT NULL COMMENT '供应商实付原币金额', + `purchase_currency` char(3) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商实付原币CNY或USD', + `purchase_voucher_id` bigint DEFAULT NULL COMMENT '购买凭证引用', + `purchase_issue` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '购买未核定或异常说明', + `purchase_confirmed_at` datetime(3) DEFAULT NULL COMMENT '货款核定时间,不代表扣费成功', + `actual_warehouse_id` bigint DEFAULT NULL COMMENT '首次实际收货冻结的仓库ID', + `actual_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际收货仓编码快照', + `actual_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际收货仓名称快照', + `overseas_received_at` datetime(3) DEFAULT NULL COMMENT '海外整批收货完成时间', + `packing_version` int DEFAULT NULL COMMENT '当前确认装箱清单版本', + `overseas_shipped_at` datetime(3) DEFAULT NULL COMMENT '国际段整批实际交寄时间', + `domestic_received_at` datetime(3) DEFAULT NULL COMMENT '国内整批收货完成时间', + `charges_confirmed_at` datetime(3) DEFAULT NULL COMMENT '国内整单费用核定时间', + `customer_shipped_at` datetime(3) DEFAULT NULL COMMENT '国内寄客户实际交寄时间', + `domestic_receiver_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '国际段国内收件人', + `domestic_receiver_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '国际段国内收件电话', + `domestic_receiver_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '国际段国内接收地址', + `cancelled_at` datetime(3) DEFAULT NULL COMMENT '购买前取消时间', + `closed_at` datetime(3) DEFAULT NULL COMMENT '异常关闭时间', + `control_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '取消或关闭原因', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`order_id`), + KEY `idx_procurement_flow_customer` (`customer_id`,`order_id`), + CONSTRAINT `chk_procurement_purchase_amount` CHECK (((`purchase_amount` is null) or (`purchase_amount` >= 0))), + CONSTRAINT `chk_procurement_purchase_quantity` CHECK (((`purchased_quantity` is null) or (`purchased_quantity` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_label_item` ( + `label_set_id` bigint NOT NULL COMMENT '面单批版本ID', + `carton_id` bigint NOT NULL COMMENT '原海外清单箱ID', + `file_id` bigint NOT NULL COMMENT '本采购订单面单文件', + `page_no` int NOT NULL COMMENT '该箱对应文件页码,从1开始', + `carrier` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '承运商', + `tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱主或子运单号', + `applied_at` datetime(3) DEFAULT NULL COMMENT '真实贴单时间', + `applied_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '贴单确认人', + PRIMARY KEY (`label_set_id`,`carton_id`), + CONSTRAINT `chk_procurement_label_page` CHECK ((`page_no` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_label_set` ( + `id` bigint NOT NULL COMMENT '面单批版本ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `leg` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', + `version` int NOT NULL COMMENT '订单段内面单版本', + `manifest_id` bigint NOT NULL COMMENT '原海外确认装箱版本', + `cargo_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱规及有效货物来源摘要', + `sender_snapshot` json NOT NULL COMMENT '寄件人姓名电话地址快照', + `receiver_snapshot` json NOT NULL COMMENT '收件人姓名电话地址快照', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '制单操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_label_version` (`order_id`,`leg`,`version`), + CONSTRAINT `chk_procurement_label_leg` CHECK ((`leg` in (_utf8mb4'OVERSEAS',_utf8mb4'DOMESTIC'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_manifest` ( + `id` bigint NOT NULL COMMENT '装箱清单ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `version` int NOT NULL COMMENT '订单内装箱版本', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '确认时间', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '制单人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_manifest_version` (`order_id`,`version`), + CONSTRAINT `chk_procurement_manifest_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'CONFIRMED',_utf8mb4'SUPERSEDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_receipt` ( + `id` bigint NOT NULL COMMENT '实收明细ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `receipt_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购收货业务编号', + `leg` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + `source_carton_id` bigint DEFAULT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', + `warehouse_id` bigint DEFAULT NULL COMMENT '海外实际收货仓', + `good_quantity` bigint NOT NULL COMMENT '可发合格件数', + `damaged_quantity` bigint NOT NULL COMMENT '破损待处置件数', + `extra_quantity` bigint NOT NULL COMMENT '多到件数,不增加可发数量', + `received_at` datetime(3) NOT NULL COMMENT '实际收货时间', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '当前有效实收事实', + `supersedes_id` bigint DEFAULT NULL COMMENT '受控更正所替代的原实收记录', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登记人员', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_receipt_no` (`receipt_no`), + KEY `idx_procurement_receipt_source` (`order_id`,`leg`,`source_carton_id`,`active`), + CONSTRAINT `chk_procurement_receipt_leg` CHECK ((((`leg` = _utf8mb4'OVERSEAS') and (`source_carton_id` is null)) or ((`leg` = _utf8mb4'DOMESTIC') and (`source_carton_id` is not null)))), + CONSTRAINT `chk_procurement_receipt_quantity` CHECK (((`good_quantity` >= 0) and (`damaged_quantity` >= 0) and (`extra_quantity` >= 0) and (((`good_quantity` + `damaged_quantity`) + `extra_quantity`) > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_repack` ( + `id` bigint NOT NULL COMMENT '换包装版本ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `carton_id` bigint NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', + `length_cm` decimal(12,2) NOT NULL COMMENT '新外箱长厘米', + `width_cm` decimal(12,2) NOT NULL COMMENT '新外箱宽厘米', + `height_cm` decimal(12,2) NOT NULL COMMENT '新外箱高厘米', + `gross_weight_kg` decimal(12,3) NOT NULL COMMENT '新封箱毛重千克', + `voucher_file_id` bigint NOT NULL COMMENT '本单换包装证据', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换包装依据', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内作业人员', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY (`id`), + KEY `idx_procurement_repack_carton` (`order_id`,`carton_id`,`id`), + CONSTRAINT `chk_procurement_repack_measurement` CHECK (((`length_cm` > 0) and (`width_cm` > 0) and (`height_cm` > 0) and (`gross_weight_kg` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_shipment` ( + `id` bigint NOT NULL COMMENT '采购交寄ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `shipment_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '交寄业务编号', + `leg` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', + `manifest_id` bigint NOT NULL COMMENT '原海外装箱清单版本', + `shipped_at` datetime(3) NOT NULL COMMENT '整批真实交承运方时间', + `address_snapshot` json NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '交寄确认人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_shipment_leg` (`order_id`,`leg`), + UNIQUE KEY `uk_procurement_shipment_no` (`shipment_no`), + CONSTRAINT `chk_procurement_shipment_leg` CHECK ((`leg` in (_utf8mb4'OVERSEAS',_utf8mb4'DOMESTIC'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_shipment_item` ( + `shipment_id` bigint NOT NULL COMMENT '采购交寄ID', + `carton_id` bigint NOT NULL COMMENT '原海外装箱版本内箱ID', + `quantity` bigint NOT NULL COMMENT '本次实际交寄件数', + `carrier` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '承运商快照', + `tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运单号快照', + `label_snapshot` json NOT NULL COMMENT '当次箱规、面单及货物来源快照', + PRIMARY KEY (`shipment_id`,`carton_id`), + CONSTRAINT `chk_procurement_shipment_item_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_procurement_wallet_attempt` ( + `id` bigint NOT NULL COMMENT '请求尝试ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `fee_id` bigint NOT NULL COMMENT '不可变费用版本ID', + `attempt_no` bigint NOT NULL COMMENT '费用版本内请求序号', + `operation` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', + `business_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Finance资金业务号', + `amount` decimal(20,2) DEFAULT NULL COMMENT '请求金额;全额结算或释放可空', + `command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金指令事件唯一引用', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', + `result_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原业务回执摘要', + `wallet_hold_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Finance冻结引用', + `wallet_ledger_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Finance处理流水引用', + `error_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '业务失败码', + `error_message` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '内部失败诊断,不直接对客展示', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '最终结果时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_attempt_event` (`command_event_id`), + UNIQUE KEY `uk_procurement_attempt_number` (`fee_id`,`attempt_no`), + KEY `idx_procurement_attempt_order` (`customer_id`,`order_id`,`status`), + CONSTRAINT `chk_procurement_attempt_status` CHECK ((`status` in (_utf8mb4'PENDING',_utf8mb4'SUCCEEDED',_utf8mb4'FAILED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_receiving_container_session` ADD COLUMN `source_container_booking_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '稳定来源柜预订ID'; + +ALTER TABLE `ll_receiving_container_session` ADD COLUMN `container_fact_hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '领取时整柜订单与计划事实摘要'; + +ALTER TABLE `ll_receiving_container_session` ADD COLUMN `overseas_warehouse_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓业务编码快照'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_return_carton_stock_receipt` ( + `id` bigint NOT NULL COMMENT '原箱回仓凭据明细标识', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `receipt_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号', + `exception_order_id` bigint NOT NULL COMMENT '本次异常退回订单', + `exception_return_item_id` bigint NOT NULL COMMENT '本次冻结的原箱来源明细', + `delivery_order_id` bigint NOT NULL COMMENT '原运输派送单', + `source_pick_id` bigint NOT NULL COMMENT '原箱实际出库拣货凭据', + `source_outbound_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号', + `source_dispatch_item_id` bigint NOT NULL COMMENT '原排车货物明细', + `source_outbound_pallet_item_id` bigint NOT NULL COMMENT '原出库托盘明细', + `return_carton_id` bigint NOT NULL COMMENT '真实退货原箱标识', + `carton_mark` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识', + `target_pallet_id` bigint NOT NULL COMMENT '实际回仓目标实物托盘', + `target_pallet_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码', + `target_location_id` bigint NOT NULL COMMENT '实际回仓目标库位', + `target_location_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库快照中的中文品名', + `quantity` bigint NOT NULL COMMENT '本次原SKU实际恢复件数', + `packages` bigint NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零', + `evidence_file_ids_json` json NOT NULL COMMENT '本次实收证据文件引用', + `received_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次原箱实收确认操作人', + `received_at` datetime(3) NOT NULL COMMENT '实际原箱回仓入账时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_return_carton_receipt_source` (`customer_id`,`source_pick_id`,`sku_code`), + KEY `idx_return_carton_receipt_exception` (`customer_id`,`exception_order_id`,`received_at`), + KEY `idx_return_carton_receipt_delivery` (`customer_id`,`delivery_order_id`), + CONSTRAINT `chk_return_carton_receipt_quantity` CHECK (((`quantity` > 0) and (`packages` in (0,1)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_return_inventory_outbound_movement` ADD COLUMN `return_sku_quantity` bigint DEFAULT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_return_packing_plan` ADD KEY `idx_ll_return_packing_plan_warehouse` (`overseas_warehouse_id`,`status`,`pushed_at`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_inventory_daily` ( + `day_version_id` bigint NOT NULL COMMENT '所属历史日版本', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源货主编码', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '来源商品编码', + `product_quality` int NOT NULL COMMENT '来源库存品质', + `physical_quantity` decimal(20,6) DEFAULT NULL COMMENT '日末实物库存,未知为空,已确认零为零', + `quality_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '日末资格,有效或未知', + `quality_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '缺覆盖或锚点等未知原因', + `anchor_reference` bigint DEFAULT NULL COMMENT '独立库存锚点版本', + `evidence_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '该日计算证据摘要', + `source` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '库存来源标记,流水还原', + PRIMARY KEY (`day_version_id`,`customer_code`,`sku_code`,`product_quality`), + KEY `idx_selfop_daily_identity` (`customer_code`,`sku_code`,`day_version_id`), + CONSTRAINT `chk_selfop_daily_quantity` CHECK ((((`quality_status` = _utf8mb4'VALID') and (`physical_quantity` is not null) and (`physical_quantity` >= 0)) or ((`quality_status` = _utf8mb4'UNKNOWN') and (`physical_quantity` is null)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_inventory_day_version` ( + `id` bigint NOT NULL COMMENT '不可变历史日版本标识', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库编码', + `business_date` date NOT NULL COMMENT '北京时间日末所属日期', + `scope_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '业务范围摘要', + `rule_version` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '日末库存还原规则版本', + `build_run_id` bigint NOT NULL COMMENT '历史重建执行尝试', + `publication_group_id` bigint NOT NULL COMMENT '必须一起切换的发布组标识', + `sales_run_id` bigint DEFAULT NULL COMMENT '构建时固定的销量证据版本', + `anchor_run_id` bigint NOT NULL COMMENT '固定的独立库存锚点版本', + `input_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要', + `evidence_json` json NOT NULL COMMENT '固定输入清单及不可变证据引用', + `status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败', + `head_slot` tinyint DEFAULT NULL COMMENT '当前日头为一,非当前版本为空', + `valid_sku_count` bigint NOT NULL DEFAULT '0' COMMENT '合格商品数', + `unknown_sku_count` bigint NOT NULL DEFAULT '0' COMMENT '待核验商品数', + `created_at` datetime(3) NOT NULL COMMENT '版本创建UTC时刻', + `published_at` datetime(3) DEFAULT NULL COMMENT '发布组提交UTC时刻', + `superseded_at` datetime(3) DEFAULT NULL COMMENT '被新组替代的UTC时刻', + `invalidated_at` datetime(3) DEFAULT NULL COMMENT '已确认输入失效的UTC时刻', + `invalidation_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '旧版本失效原因', + `rebuild_run_id` bigint DEFAULT NULL COMMENT '处理失效版本的重建尝试', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_history_build_day` (`build_run_id`,`business_date`), + UNIQUE KEY `uk_selfop_history_head` (`source_account`,`warehouse_code`,`business_date`,`head_slot`), + KEY `idx_selfop_history_group` (`publication_group_id`,`status`), + CONSTRAINT `chk_selfop_history_head` CHECK (((`head_slot` is null) or ((`head_slot` = 1) and (`status` = _utf8mb4'PUBLISHED')))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_inventory_flow` ( + `id` bigint NOT NULL COMMENT '应用生成的流水记录标识', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库编码', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源货主编码', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '来源商品编码', + `source_flow_id` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源流水字符串标识', + `product_quality` int NOT NULL COMMENT '来源品质,零正品一次品,其他未知', + `cell_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源库位编码', + `inner_batch_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源内部批次号', + `bill_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源业务单号', + `source_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源关联单号', + `bill_type` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源业务类型,未验证类型不得参与正式还原', + `book_qty` decimal(20,6) DEFAULT NULL COMMENT '来源记账增减量,未知为空', + `book_change_qty_raw` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源带符号变动量原文', + `delta_quantity` decimal(20,6) DEFAULT NULL COMMENT '解析后的有符号实物变动量', + `close_quantity` decimal(20,6) DEFAULT NULL COMMENT '对应库位批次品质链的结存', + `source_create_time` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源北京时间原文', + `event_at_utc` datetime(3) DEFAULT NULL COMMENT '流水发生UTC时刻,无法解析时为空', + `business_date` date DEFAULT NULL COMMENT '北京时间业务日期', + `quantity_unit` varchar(16) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '已确认数量单位,未知为空', + `content_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '当前事实内容摘要', + `revision_no` bigint NOT NULL COMMENT '同来源流水单调增加的修订号', + `stage_id` bigint NOT NULL COMMENT '当前事实对应不可变暂存证据', + `first_run_id` bigint NOT NULL COMMENT '首次观察执行尝试', + `last_run_id` bigint NOT NULL COMMENT '最近确认执行尝试', + `last_observed_at` datetime(3) NOT NULL COMMENT '最近观察UTC时刻', + `validation_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '字段资格,有效或未知', + `validation_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '数量或身份待核验原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_flow_source` (`source_account`,`warehouse_code`,`source_flow_id`), + KEY `idx_selfop_flow_sku` (`source_account`,`warehouse_code`,`customer_code`,`sku_code`,`event_at_utc`), + KEY `idx_selfop_flow_day` (`source_account`,`warehouse_code`,`business_date`), + CONSTRAINT `chk_selfop_flow_revision` CHECK ((`revision_no` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_inventory_snapshot` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `snapshot_run_id` bigint NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '原始产品 SKU', + `product_quality` smallint NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存', + `product_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源名称', + `physical_quantity` decimal(20,6) DEFAULT NULL COMMENT '来源stkQty实物库存,未知为空', + `available_quantity` decimal(20,6) DEFAULT NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结', + `frozen_quantity` decimal(20,6) DEFAULT NULL COMMENT '来源freezeQty冻结库存,未知为空', + `transit_quantity` decimal(20,6) DEFAULT NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存', + `source_unique_key` varchar(512) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '原 uniqueKey4tenant,只作来源证据', + `observed_at_utc` datetime(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间', + `quality_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生', + `quality_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_stock_snapshot` (`snapshot_run_id`,`source_account`,`customer_code`,`warehouse_code`,`sku_code`,`product_quality`), + KEY `idx_selfop_stock_lookup` (`source_account`,`customer_code`,`warehouse_code`,`sku_code`,`product_quality`,`snapshot_run_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_outbound_discovery_scope` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `catalog_from_utc` datetime(3) DEFAULT NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖', + `catalog_until_utc` datetime(3) DEFAULT NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖', + `next_sequence` bigint NOT NULL DEFAULT '1' COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧', + `next_observation_sequence` bigint NOT NULL DEFAULT '1' COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次', + `active_task_id` bigint DEFAULT NULL COMMENT '当前共享发现/详情补齐任务', + `lease_owner` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '账号发现执行者', + `lease_until_utc` datetime(3) DEFAULT NULL COMMENT '账号发现租约到期时刻', + `fence_token` bigint NOT NULL DEFAULT '0' COMMENT '防止过期发现进程继续写共享暂存或派发任务', + `request_budget` int NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正', + `time_budget_seconds` int NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正', + `min_request_interval_ms` int NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置', + `api_budget_json` json DEFAULT NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据', + `api_cooldown_until_utc` datetime(3) DEFAULT NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除', + `api_pause_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_discovery_account` (`source_account`), + CONSTRAINT `chk_selfop_outbound_discovery_scope` CHECK (((`request_budget` > 0) and (`time_budget_seconds` > 0) and (`min_request_interval_ms` > 0) and (`fence_token` >= 0) and (`next_sequence` > 0) and (`next_observation_sequence` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_outbound_item` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `order_id` bigint NOT NULL COMMENT '对应外部出库事实', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '原始 productSku', + `product_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源商品名称,仅作展示', + `actual_quantity` decimal(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值', + `quantity_unit` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_order_sku` (`order_id`,`sku_code`), + KEY `idx_selfop_item_sku` (`sku_code`,`order_id`), + CONSTRAINT `chk_selfop_outbound_item` CHECK ((`actual_quantity` >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_outbound_order` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `delivery_no` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份', + `source_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示', + `source_status` int NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码', + `source_created_time` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代', + `source_outbound_time` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代', + `source_timezone` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '解析采用的源时区', + `outbound_at_utc` datetime(3) DEFAULT NULL COMMENT '已核准的实际出库时间点;非出库状态可空', + `business_date` date DEFAULT NULL COMMENT '已核准业务时区下的出库日', + `business_timezone` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日', + `sales_eligible` tinyint NOT NULL DEFAULT '0' COMMENT '仅已出库、时间与全部数量有效时为 1', + `revision_no` bigint NOT NULL DEFAULT '1' COMMENT '该单内容变更次数,不等于拉取次数', + `content_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要', + `last_run_id` bigint NOT NULL COMMENT '最后一次确认或修订的执行尝试', + `last_observation_sequence` bigint NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_order` (`source_account`,`customer_code`,`delivery_no`), + KEY `idx_selfop_order_daily` (`source_account`,`warehouse_code`,`sales_eligible`,`business_date`,`id`), + KEY `idx_selfop_order_run` (`last_run_id`), + CONSTRAINT `chk_selfop_outbound_order` CHECK (((`revision_no` > 0) and (`last_observation_sequence` > 0) and (`sales_eligible` in (0,1)) and ((`sales_eligible` = 0) or ((`source_status` = 100) and (`source_timezone` is not null) and (`outbound_at_utc` is not null) and (`business_date` is not null))))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_owner` ( + `id` bigint NOT NULL COMMENT '主键', + `run_id` bigint NOT NULL COMMENT '完整资料采集版本', + `source_account` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号', + `customer_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码', + `customer_name` varchar(255) DEFAULT NULL COMMENT '领星货主名称', + `source_status` varchar(32) DEFAULT NULL COMMENT '来源启停状态', + `observed_at_utc` datetime(6) NOT NULL COMMENT '资料观测时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_owner_version` (`run_id`,`source_account`,`customer_code`), + KEY `idx_selfop_owner_lookup` (`source_account`,`customer_code`,`run_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='自营领星货主资料版本'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_product` ( + `id` bigint NOT NULL COMMENT '主键', + `run_id` bigint NOT NULL COMMENT '完整资料采集版本', + `source_account` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号', + `customer_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '货主商品编码', + `product_name` varchar(255) DEFAULT NULL COMMENT '商品名称', + `declared_length` decimal(24,6) DEFAULT NULL COMMENT '商品资料长度', + `declared_width` decimal(24,6) DEFAULT NULL COMMENT '商品资料宽度', + `declared_height` decimal(24,6) DEFAULT NULL COMMENT '商品资料高度', + `declared_size_unit` varchar(32) DEFAULT NULL COMMENT '商品资料尺寸单位', + `measured_length` decimal(24,6) DEFAULT NULL COMMENT '仓库实测长度', + `measured_width` decimal(24,6) DEFAULT NULL COMMENT '仓库实测宽度', + `measured_height` decimal(24,6) DEFAULT NULL COMMENT '仓库实测高度', + `measured_size_unit` varchar(32) DEFAULT NULL COMMENT '仓库实测尺寸单位', + `observed_at_utc` datetime(6) NOT NULL COMMENT '资料观测时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_product_version` (`run_id`,`source_account`,`customer_code`,`sku_code`), + KEY `idx_selfop_product_lookup` (`source_account`,`customer_code`,`sku_code`,`run_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='自营领星商品资料版本'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_sku_daily_sales` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `sales_run_id` bigint NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '原始产品 SKU', + `business_date` date NOT NULL COMMENT '已核准业务日', + `outbound_quantity` decimal(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负', + `outbound_order_count` bigint NOT NULL COMMENT '该维度去重发货单数,非负', + `quantity_unit` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '同一统计维度内一致的业务单位', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_daily_version` (`sales_run_id`,`source_account`,`customer_code`,`warehouse_code`,`sku_code`,`business_date`), + KEY `idx_selfop_daily_window_cover` (`sales_run_id`,`source_account`,`warehouse_code`,`business_date`,`customer_code`,`sku_code`,`outbound_quantity`,`outbound_order_count`,`quantity_unit`), + KEY `idx_selfop_daily_sku_cover` (`sales_run_id`,`source_account`,`warehouse_code`,`customer_code`,`sku_code`,`business_date`,`outbound_quantity`,`outbound_order_count`,`quantity_unit`), + CONSTRAINT `chk_selfop_sku_daily_sales` CHECK (((`outbound_quantity` >= 0) and (`outbound_order_count` >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_sku_profile` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `profile_run_id` bigint NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '原始产品 SKU', + `product_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '本版本确认的名称,不实时连接其他版本名称', + `quantity_unit` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标', + `first_seen_at_utc` datetime(3) DEFAULT NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据', + `earliest_evidence_at_utc` datetime(3) DEFAULT NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移', + `evidence_type` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据', + `evidence_run_id` bigint DEFAULT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空', + `last_seen_run_id` bigint DEFAULT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_profile_version` (`profile_run_id`,`source_account`,`customer_code`,`warehouse_code`,`sku_code`), + KEY `idx_selfop_profile_evidence` (`profile_run_id`,`earliest_evidence_at_utc`,`customer_code`,`sku_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_sync_run` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `task_id` bigint NOT NULL COMMENT '本次执行请求', + `execution_purpose` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本', + `scope_id` bigint DEFAULT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `run_role` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + `upstream_task_id` bigint DEFAULT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合', + `attempt_no` int NOT NULL COMMENT '同一请求从 1 起递增', + `scope_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '本版本的业务范围摘要', + `scope_snapshot_json` json NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版', + `observed_dimensions_json` json DEFAULT NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围', + `fence_token` bigint NOT NULL COMMENT '本尝试持有的栅栏版本', + `base_published_run_id` bigint DEFAULT NULL COMMENT '执行开始时的上一可见版本', + `reference_sales_run_id` bigint DEFAULT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实', + `inventory_daily_sample_date` date DEFAULT NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空', + `inventory_sample_rule_version` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存', + `status` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头', + `last_phase` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实', + `fetch_started_at_utc` datetime(3) DEFAULT NULL COMMENT '实际采集开始UTC时间', + `fetch_finished_at_utc` datetime(3) DEFAULT NULL COMMENT '实际采集完成UTC时间,未完成为空', + `build_started_at_utc` datetime(3) DEFAULT NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', + `warehouse_daily_ready` tinyint NOT NULL DEFAULT '0' COMMENT '仓库日汇总已完整构建并校验,含完整空版本', + `finished_at_utc` datetime(3) DEFAULT NULL COMMENT '本次尝试终止UTC时间', + `published_at_utc` datetime(3) DEFAULT NULL COMMENT '实际原子切换发布指针UTC时间', + `superseded_at_utc` datetime(3) DEFAULT NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', + `page_count` int NOT NULL DEFAULT '0' COMMENT '已完整获取页数', + `source_total_count` bigint DEFAULT NULL COMMENT '上游可靠返回的总数;未返回则为空', + `unique_object_count` bigint NOT NULL DEFAULT '0' COMMENT '规范化去重后对象数', + `progress_snapshot_json` json DEFAULT NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写', + `heartbeat_at_utc` datetime(3) DEFAULT NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进', + `coverage_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断', + `covered_from_utc` datetime(3) DEFAULT NULL COMMENT '本仓销量版本实际连续覆盖UTC下界', + `covered_until_utc` datetime(3) DEFAULT NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点', + `complete_day_from` date DEFAULT NULL COMMENT '本仓完整自然日区间首日,包含', + `complete_day_until` date DEFAULT NULL COMMENT '本仓完整自然日区间末日,不含', + `applied_fact_revision` bigint DEFAULT NULL COMMENT '本次尝试归并后的事实修订号,库存为空', + `calculated_fact_revision` bigint DEFAULT NULL COMMENT '候选销量版本计算所依据的固定事实修订号', + `error_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '内部可诊断错误分类,不保存认证信息', + `error_message` varchar(500) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '脱敏后的固定错误摘要', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_run_attempt` (`task_id`,`attempt_no`), + UNIQUE KEY `uk_selfop_inventory_daily` (`source_account`,`warehouse_code`,`inventory_daily_sample_date`), + KEY `idx_selfop_run_scope` (`scope_id`,`id`), + KEY `idx_selfop_run_status` (`status`,`updated_at`), + CONSTRAINT `chk_selfop_inventory_daily` CHECK ((((`inventory_daily_sample_date` is null) and (`inventory_sample_rule_version` is null)) or ((`inventory_daily_sample_date` is not null) and (`inventory_sample_rule_version` is not null) and (`inventory_sample_rule_version` = _utf8mb4'DAILY_SCHEDULED_FIRST_V1') and (`execution_purpose` = _utf8mb4'BUSINESS') and (`run_role` = _utf8mb4'WAREHOUSE_SYNC') and (`status` = _utf8mb4'PUBLISHED') and (`coverage_status` = _utf8mb4'COMPLETE')))), + CONSTRAINT `chk_selfop_sync_run` CHECK (((`attempt_no` > 0) and (`page_count` >= 0) and (`unique_object_count` >= 0) and (`execution_purpose` in (_utf8mb4'BUSINESS',_utf8mb4'VALIDATION')) and ((`execution_purpose` <> _utf8mb4'VALIDATION') or (`status` not in (_utf8mb4'PUBLISHED',_utf8mb4'REUSED'))) and ((`source_total_count` is null) or (`source_total_count` >= 0)) and (((`run_role` = _utf8mb4'ACCOUNT_DISCOVERY') and (`scope_id` is null) and (`warehouse_code` is null)) or ((`run_role` = _utf8mb4'WAREHOUSE_SYNC') and (`scope_id` is not null) and (`warehouse_code` is not null))))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_sync_scope` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `data_family` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥', + `scope_json` json NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单', + `scope_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合', + `business_timezone` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准', + `source_timezone` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '本仓已核准的出库源时间时区', + `schedule_cron` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行', + `stale_after_seconds` int NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整', + `warehouse_policy_ref` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表', + `initial_from_utc` datetime(3) DEFAULT NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月', + `initial_until_utc` datetime(3) DEFAULT NULL COMMENT '首采固定UTC截止,重试不重新取当前时间', + `discovery_from_utc` datetime(3) DEFAULT NULL COMMENT '本仓已发布的连续销量覆盖UTC下界', + `discovery_until_utc` datetime(3) DEFAULT NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点', + `fact_revision` bigint NOT NULL DEFAULT '0' COMMENT '出库事实归并修订号;内容实际变化才增加', + `published_fact_revision` bigint NOT NULL DEFAULT '0' COMMENT '本仓最后已发布销量版所使用的事实修订号', + `rebuild_required` tinyint NOT NULL DEFAULT '0' COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1', + `invalidated_at_utc` datetime(3) DEFAULT NULL COMMENT '最近一次因事实修订使当前版待重建的时刻', + `published_run_id` bigint DEFAULT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次', + `lease_owner` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '当前执行者令牌,不使用仅进程内锁', + `lease_until_utc` datetime(3) DEFAULT NULL COMMENT '租约到期时刻', + `fence_token` bigint NOT NULL DEFAULT '0' COMMENT '每次取得租约递增,事实提交和发布均校验', + `known_order_cursor_id` bigint DEFAULT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标', + `known_order_round_max_id` bigint DEFAULT NULL COMMENT '本轮已知单复查固定最大内部标识', + `known_order_round_started_at_utc` datetime(3) DEFAULT NULL COMMENT '本仓已知单复查轮次开始UTC时间', + `known_order_round_finished_at_utc` datetime(3) DEFAULT NULL COMMENT '本仓已知单完整轮转完成UTC时间', + `history_round_from_utc` datetime(3) DEFAULT NULL COMMENT '本仓历史核对固定UTC下界', + `history_round_until_utc` datetime(3) DEFAULT NULL COMMENT '本仓历史核对固定UTC上界', + `history_slice_cursor_utc` datetime(3) DEFAULT NULL COMMENT '本仓下一待核对历史切片UTC位置', + `history_round_finished_at_utc` datetime(3) DEFAULT NULL COMMENT '上一轮历史时间片全部完成时刻', + `review_request_budget` int NOT NULL COMMENT '本仓单次复查最大请求数,必须为正', + `review_time_budget_seconds` int NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正', + `review_target_days` int NOT NULL DEFAULT '7' COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_scope` (`source_account`,`warehouse_code`,`data_family`), + KEY `idx_selfop_scope_lease` (`lease_until_utc`), + CONSTRAINT `chk_selfop_sync_scope` CHECK (((`data_family` in (_utf8mb4'OUTBOUND',_utf8mb4'INVENTORY')) and (`stale_after_seconds` > 0) and (`review_request_budget` > 0) and (`review_time_budget_seconds` > 0) and (`review_target_days` > 0) and (`fact_revision` >= 0) and (`published_fact_revision` >= 0) and (`fence_token` >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_sync_stage` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `run_id` bigint NOT NULL COMMENT '首次写入该暂存对象的执行尝试', + `stage_batch_id` bigint NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页', + `last_fetch_run_id` bigint NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源', + `observation_sequence` bigint DEFAULT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `previous_warehouse_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓', + `object_type` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK', + `object_key_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对', + `first_page_no` int NOT NULL COMMENT '首次发现页码', + `observed_at_utc` datetime(3) NOT NULL COMMENT '实际接收该对象的时刻', + `content_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '完整规范化对象摘要', + `payload_json` json NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段', + `before_payload_json` json DEFAULT NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空', + `validation_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常', + `validation_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '字段缺失、冲突、未知时区等原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_stage_object` (`stage_batch_id`,`object_type`,`object_key_hash`), + KEY `idx_selfop_stage_history` (`object_type`,`object_key_hash`,`stage_batch_id`), + KEY `idx_selfop_stage_observation` (`object_type`,`object_key_hash`,`validation_status`,`observation_sequence`), + KEY `idx_selfop_stage_fact_observation` (`observation_sequence`,`warehouse_code`,`object_type`,`validation_status`,`run_id`,`observed_at_utc`), + KEY `idx_selfop_stage_route` (`stage_batch_id`,`warehouse_code`,`validation_status`), + CONSTRAINT `chk_selfop_sync_stage` CHECK (((`first_page_no` > 0) and ((`object_type` <> _utf8mb4'OUTBOUND_ORDER') or (`validation_status` <> _utf8mb4'VALID') or (`observation_sequence` is not null)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_sync_task` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `task_no` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识', + `execution_purpose` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承', + `validation_root_task_id` bigint DEFAULT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空', + `validation_budget_json` json DEFAULT NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因', + `business_root_task_id` bigint DEFAULT NULL COMMENT '测试人工正式采集共享预算根任务;生产任务为空', + `business_budget_json` json DEFAULT NULL COMMENT '仅测试正式采集根保存冻结请求上限、截止时间、消耗及停止原因', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `task_role` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + `scope_id` bigint DEFAULT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `discovery_scope_id` bigint DEFAULT NULL COMMENT '共享发现必填,引用账号发现协调器', + `upstream_task_id` bigint DEFAULT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游', + `discovery_sequence` bigint DEFAULT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定', + `observation_baseline` bigint DEFAULT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', + `request_key` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', + `request_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', + `task_type` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原', + `planned_at_utc` datetime(3) DEFAULT NULL COMMENT '计划时点;人工请求可空', + `query_from_utc` datetime(3) DEFAULT NULL COMMENT '出库查询区间UTC下界,库存不填', + `query_until_utc` datetime(3) DEFAULT NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', + `query_scope_json` json NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号', + `routing_manifest_json` json DEFAULT NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷', + `scope_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '建任务时的业务范围摘要', + `status` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功', + `apply_state` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE', + `affected_warehouse_codes_json` json DEFAULT NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补', + `apply_started_at_utc` datetime(3) DEFAULT NULL COMMENT '首段事实提交UTC时间', + `apply_completed_at_utc` datetime(3) DEFAULT NULL COMMENT '全部必需事实归并完成UTC时间,失败不填', + `attempt_count` int NOT NULL DEFAULT '0' COMMENT '已创建的执行尝试数', + `next_retry_at_utc` datetime(3) DEFAULT NULL COMMENT '有限退避后下一次可执行时刻', + `requested_by` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '人工请求的平台操作人,仅内部审计', + `result_published_run_id` bigint DEFAULT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空', + `progress_json` json DEFAULT NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合', + `progress_updated_at_utc` datetime(3) DEFAULT NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_task_no` (`task_no`), + UNIQUE KEY `uk_selfop_task_request` (`source_account`,`request_key`), + KEY `idx_selfop_task_poll` (`status`,`next_retry_at_utc`,`id`), + KEY `idx_selfop_task_upstream` (`upstream_task_id`,`warehouse_code`), + KEY `idx_selfop_task_apply` (`apply_state`,`id`), + KEY `idx_selfop_task_scope_list` (`scope_id`,`created_at`,`id`), + KEY `idx_selfop_task_account_list` (`source_account`,`task_role`,`created_at`,`id`), + KEY `idx_selfop_task_business_root` (`business_root_task_id`,`status`,`id`), + CONSTRAINT `chk_selfop_business_budget` CHECK ((((`business_root_task_id` is null) and (`business_budget_json` is null)) or ((`execution_purpose` = _utf8mb4'BUSINESS') and (`business_root_task_id` is not null) and (((`business_root_task_id` = `id`) and (`business_budget_json` is not null)) or ((`business_root_task_id` <> `id`) and (`business_budget_json` is null)))))), + CONSTRAINT `chk_selfop_sync_task` CHECK (((`attempt_count` >= 0) and (`execution_purpose` in (_utf8mb4'BUSINESS',_utf8mb4'VALIDATION')) and (((`execution_purpose` = _utf8mb4'BUSINESS') and (`validation_root_task_id` is null) and (`validation_budget_json` is null)) or ((`execution_purpose` = _utf8mb4'VALIDATION') and (`validation_root_task_id` is not null) and (`apply_state` = _utf8mb4'NOT_APPLICABLE') and (`result_published_run_id` is null) and (`status` <> _utf8mb4'SUCCEEDED'))) and (((`task_role` = _utf8mb4'ACCOUNT_DISCOVERY') and (`scope_id` is null) and (`warehouse_code` is null) and (`discovery_scope_id` is not null)) or ((`task_role` = _utf8mb4'WAREHOUSE_SYNC') and (`scope_id` is not null) and (`warehouse_code` is not null))))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求'; + +USE `baiyue_lastleg`; + +CREATE TABLE `ll_selfop_warehouse_daily_sales` ( + `sales_run_id` bigint NOT NULL COMMENT '完整销量计算版本标识', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码', + `business_date` date NOT NULL COMMENT '已核准业务日', + `observed_quantity` decimal(38,6) DEFAULT NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空', + `invalid_row_count` bigint NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数', + `source_row_count` bigint NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + PRIMARY KEY (`sales_run_id`,`source_account`,`warehouse_code`,`business_date`), + CONSTRAINT `chk_selfop_warehouse_daily_sales` CHECK (((`source_row_count` > 0) and (`invalid_row_count` >= 0) and (`invalid_row_count` <= `source_row_count`) and ((`observed_quantity` is null) or (`observed_quantity` >= 0)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建'; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN `vehicle_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN `origin_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地联系人'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN `origin_contact_phone` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地联系电话'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN `destination_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地联系人'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN `destination_contact_phone` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地联系电话'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN `pickup_at` datetime DEFAULT NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN `delivery_at` datetime DEFAULT NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空'; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +ALTER TABLE `ll_warehouse_transfer_inventory_allocation` ADD KEY `idx_ll_wh_transfer_allocation_position` (`transfer_order_id`,`inventory_position_id`); + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_lastleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +CREATE TABLE `event_delivery_retry_audit` ( + `request_id` varchar(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + `producer_table` varchar(64) NOT NULL COMMENT '本服务发件箱表名', + `entry_id` varchar(64) NOT NULL COMMENT '发件箱记录主键', + `event_id` varchar(128) NOT NULL COMMENT '原事件全局标识', + `event_type` varchar(64) NOT NULL COMMENT '原事件类型', + `payload_hash` varchar(71) NOT NULL COMMENT '原事件不可变负载摘要', + `operator_name` varchar(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + `reason` varchar(500) NOT NULL COMMENT '人工重试原因', + `previous_failures` int NOT NULL COMMENT '重试前累计失败次数', + `previous_error` varchar(1000) DEFAULT NULL COMMENT '重试前最后一次脱敏错误', + `requested_at` datetime(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (`request_id`), + KEY `idx_event_retry_entry` (`producer_table`,`entry_id`,`requested_at`), + KEY `idx_event_retry_event` (`event_id`,`requested_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_carrier_schedule_node_fact` ADD KEY `idx_container_shipping_node_timeline` (`container_booking_id`,`observed_at`,`id`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_chargeable_weight_confirmation` ( + `id` bigint NOT NULL COMMENT '确认记录ID', + `tenant_id` bigint NOT NULL COMMENT '客户隔离ID', + `inbound_id` bigint NOT NULL COMMENT '独立来源入仓单ID', + `operator_id` bigint NOT NULL COMMENT '确认客户成员ID', + `command_key` varchar(128) NOT NULL COMMENT '成员确认幂等键', + `fingerprint` char(64) NOT NULL COMMENT '计费重及测量轮次摘要', + `chargeable_weight_kg` decimal(18,4) NOT NULL COMMENT '客户确认计费重千克', + `confirmed_at` datetime(3) NOT NULL COMMENT '客户确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_weight_confirmation_command` (`tenant_id`,`operator_id`,`command_key`), + KEY `idx_weight_confirmation_inbound` (`tenant_id`,`inbound_id`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户计费重独立确认历史'; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_container_booking` ADD UNIQUE KEY `uk_container_booking_ocean_container` (`ocean_booking_id`,`container_no`); + +ALTER TABLE `fl_container_booking` ADD KEY `idx_container_booking_container_no` (`container_no`,`updated_at`,`id`); + +ALTER TABLE `fl_container_booking` ADD KEY `idx_container_booking_merge` (`record_status`,`merged_into_container_booking_id`); + +ALTER TABLE `fl_container_booking` ADD KEY `idx_container_booking_transit_summary` (`actual_sailing_at`,`actual_warehouse_arrival_at`,`ocean_booking_id`,`container_type`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_container_fee_detail` ADD KEY `idx_container_fee_review` (`tenant_id`,`review_status`,`source_status`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_container_transport_history` ADD UNIQUE KEY `uk_container_transport_history_operation` (`container_booking_id`,`operator_id`,`operation_id`); + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_customer_charge_rule` ADD KEY `idx_charge_rule_initialization` (`tenant_id`,`initialization_key`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_customs_broker_submission` ( + `id` bigint unsigned NOT NULL COMMENT '首次发送报关行批次ID', + `customs_container_id` bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + `batch_no` varchar(64) NOT NULL COMMENT '首次发送报关行批次号', + `status` varchar(16) NOT NULL COMMENT '发送状态:SENT已发送', + `source_version` int unsigned NOT NULL COMMENT '发送时整柜报关聚合版本', + `source_snapshot_json` json NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + `sent_by` bigint unsigned NOT NULL COMMENT '首次发送操作人ID', + `sent_by_name` varchar(64) DEFAULT NULL COMMENT '首次发送操作人名称快照', + `sent_at` datetime(3) NOT NULL COMMENT '首次发送时间', + `version` int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_broker_submission_container` (`customs_container_id`), + UNIQUE KEY `uk_customs_broker_submission_batch` (`batch_no`), + KEY `idx_customs_broker_submission_plan` (`loading_plan_id`,`sent_at`), + CONSTRAINT `fk_customs_broker_submission_container` FOREIGN KEY (`customs_container_id`) REFERENCES `fl_customs_container` (`id`), + CONSTRAINT `chk_customs_broker_submission_status` CHECK ((`status` = _utf8mb4'SENT')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='冻结退税报关资料首次发送报关行批次事实'; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_customs_clearance_result` ( + `id` bigint unsigned NOT NULL COMMENT '海关结果事实ID', + `declaration_batch_id` bigint unsigned NOT NULL COMMENT '所属正式申报批次ID', + `customs_container_id` bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + `result_type` varchar(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER', + `result_status` varchar(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED', + `customs_entry_no` varchar(64) DEFAULT NULL COMMENT '报关单号快照', + `occurred_at` datetime(3) NOT NULL COMMENT '海关事实发生时间', + `resolution` varchar(1000) DEFAULT NULL COMMENT '处理结果或异常说明', + `evidence_file_id` bigint unsigned DEFAULT NULL COMMENT '结果凭证File文件ID', + `recorded_by` bigint unsigned NOT NULL COMMENT '平台录入人', + `recorded_by_name` varchar(64) DEFAULT NULL COMMENT '平台录入人名称快照', + `recorded_at` datetime(3) NOT NULL COMMENT '平台录入时间', + `previous_result_id` bigint unsigned DEFAULT NULL COMMENT '前序结果事实ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间', + PRIMARY KEY (`id`), + KEY `idx_customs_clearance_result_batch` (`declaration_batch_id`,`occurred_at`,`id`), + KEY `idx_customs_clearance_result_container` (`customs_container_id`,`result_type`,`result_status`), + CONSTRAINT `fk_customs_clearance_result_batch` FOREIGN KEY (`declaration_batch_id`) REFERENCES `fl_customs_declaration_batch` (`id`), + CONSTRAINT `chk_customs_clearance_result_status` CHECK ((`result_status` in (_utf8mb4'OPEN',_utf8mb4'RESOLVED',_utf8mb4'CANCELLED'))), + CONSTRAINT `chk_customs_clearance_result_type` CHECK ((`result_type` in (_utf8mb4'INSPECTION',_utf8mb4'SUPPLEMENT_REQUIRED',_utf8mb4'RELEASED',_utf8mb4'DETAINED',_utf8mb4'OTHER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='正式申报后的海关结果不可变事实'; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_customs_completion_action_history` ( + `id` bigint unsigned NOT NULL COMMENT '客户回执动作历史ID', + `completion_document_id` bigint unsigned NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + `receipt_version` int unsigned NOT NULL COMMENT '动作对应的回执文件版本', + `action` varchar(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + `reason` varchar(500) DEFAULT NULL COMMENT '客户打回原因;确认动作为空', + `operator_id` bigint unsigned NOT NULL COMMENT '客户操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '客户操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY (`id`), + KEY `idx_customs_completion_action_history` (`completion_document_id`,`receipt_version`,`created_at`,`id`), + CONSTRAINT `fk_customs_completion_action_history_document` FOREIGN KEY (`completion_document_id`) REFERENCES `fl_customs_completion_document` (`id`), + CONSTRAINT `chk_customs_completion_action_history_action` CHECK ((`action` in (_utf8mb4'CONFIRM',_utf8mb4'REJECT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报关行回执确认与打回不可变历史'; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN `customer_confirm_status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '客户回执确认状态:PENDING/CONFIRMED/REJECTED'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN `customer_confirmed_by` bigint unsigned DEFAULT NULL COMMENT '客户确认人ID'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN `customer_confirmed_by_name` varchar(64) DEFAULT NULL COMMENT '客户确认人名称快照'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认时间'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN `customer_reject_reason` varchar(500) DEFAULT NULL COMMENT '客户打回原因'; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_customs_declaration_batch` ( + `id` bigint unsigned NOT NULL COMMENT '正式申报批次ID', + `customs_container_id` bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + `batch_no` varchar(64) DEFAULT NULL COMMENT '平台正式申报批次号', + `status` varchar(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED', + `source_version` int unsigned NOT NULL COMMENT '创建时整柜报关聚合版本', + `source_snapshot_json` json NOT NULL COMMENT '提交时资料、回执和确认版本快照', + `customs_entry_no` varchar(64) DEFAULT NULL COMMENT '报关单号', + `submitted_by` bigint unsigned DEFAULT NULL COMMENT '正式申报提交人', + `submitted_by_name` varchar(64) DEFAULT NULL COMMENT '正式申报提交人名称快照', + `submitted_at` datetime(3) DEFAULT NULL COMMENT '正式申报提交时间', + `acknowledged_by` bigint unsigned DEFAULT NULL COMMENT '报关行确认受理人', + `acknowledged_by_name` varchar(64) DEFAULT NULL COMMENT '报关行确认受理人名称快照', + `acknowledged_at` datetime(3) DEFAULT NULL COMMENT '报关行确认受理时间', + `remark` varchar(500) DEFAULT NULL COMMENT '批次说明', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_declaration_batch_container` (`customs_container_id`,`id`), + KEY `idx_customs_declaration_batch_plan` (`loading_plan_id`,`status`,`created_at`), + CONSTRAINT `fk_customs_declaration_batch_container` FOREIGN KEY (`customs_container_id`) REFERENCES `fl_customs_container` (`id`), + CONSTRAINT `chk_customs_declaration_batch_status` CHECK ((`status` in (_utf8mb4'READY',_utf8mb4'SUBMITTED',_utf8mb4'ACKNOWLEDGED',_utf8mb4'RESULT_PENDING',_utf8mb4'CLOSED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜正式申报批次事实'; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_fee_item` ADD COLUMN `default_price` decimal(12,2) DEFAULT NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改'; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_inbound_measurement` ADD KEY `idx_measure_inbound_status_id` (`tenant_id`,`inbound_id`,`status`,`id`); + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_inbound_measurement_item` ADD KEY `idx_measure_item_candidate` (`tenant_id`,`measurement_id`,`inbound_detail_id`); + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_inbound_order` ADD KEY `idx_inbound_smart_loading` (`warehouse_id`,`process_status`,`channel_code`,`expected_arrival_at`,`id`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_inbound_split_item` ADD KEY `idx_inbound_split_candidate` (`tenant_id`,`parent_inbound_id`,`source_inbound_detail_id`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_loading_plan_item` ADD UNIQUE KEY `uk_loading_item_plan_assignment` (`loading_plan_id`,`active_assignment_key`); + +ALTER TABLE `fl_loading_plan_item` ADD KEY `idx_loading_item_candidate` (`account_id`,`inbound_id`,`inbound_detail_id`,`deleted`); + +ALTER TABLE `fl_loading_plan_item` ADD KEY `idx_loading_item_pallet` (`pallet_id`,`deleted`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_ocean_booking` ADD UNIQUE KEY `uk_ocean_booking_bill_of_lading` (`bill_of_lading_no`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_sailing` ( + `id` bigint NOT NULL, + `sailing_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `template_id` bigint DEFAULT NULL, + `channel_id` bigint NOT NULL, + `channel_code_snapshot` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `channel_name_snapshot` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `warehouse_id` bigint NOT NULL, + `warehouse_name_snapshot` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `departure_port_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_port_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `vessel_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `voyage_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `etd_at` datetime(3) NOT NULL, + `warehouse_cutoff_at` datetime(3) NOT NULL, + `eta_at` datetime(3) DEFAULT NULL, + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'OPEN', + `source_type` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANUAL', + `generated_key` varchar(192) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `planned_capacity_cbm` decimal(14,3) DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` bigint NOT NULL, + `created_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL, + `updated_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `version` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fl_sailing_no` (`sailing_no`), + UNIQUE KEY `uk_fl_sailing_generated_key` (`generated_key`), + UNIQUE KEY `uk_fl_sailing_business` (`warehouse_id`,`channel_code_snapshot`,`departure_port_code`,`destination_port_code`,`etd_at`,`voyage_no`), + KEY `idx_fl_sailing_calendar` (`etd_at`,`warehouse_id`,`channel_id`,`status`), + KEY `idx_fl_sailing_route` (`departure_port_code`,`destination_port_code`), + CONSTRAINT `ck_fl_sailing_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'OPEN',_utf8mb4'CLOSED',_utf8mb4'DEPARTED',_utf8mb4'CANCELLED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_sailing_assignment` ( + `id` bigint unsigned NOT NULL COMMENT '归班事实ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键', + `sailing_id` bigint unsigned DEFAULT NULL COMMENT '船期实例ID,UNMATCHED为空', + `origin_sailing_id` bigint unsigned DEFAULT NULL COMMENT '异常重排/取消前的原船期实例ID', + `inbound_id` bigint unsigned DEFAULT NULL COMMENT '归班对象入库单ID', + `inbound_code` varchar(64) DEFAULT NULL COMMENT '入仓码快照,仅用于展示', + `receipt_batch_id` bigint unsigned DEFAULT NULL COMMENT '收货批次事实ID', + `forecast_id` bigint unsigned DEFAULT NULL COMMENT '货量预报事实ID', + `assignment_source` varchar(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER', + `assignment_version` int unsigned NOT NULL COMMENT '同一对象的归班版本', + `match_status` varchar(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '已收实测体积', + `forecast_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '未收有效预计体积', + `route_source` varchar(32) DEFAULT NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT', + `volume_source` varchar(32) DEFAULT NULL COMMENT 'MEASURED_ACTUAL或预报来源', + `rule_version` varchar(64) NOT NULL COMMENT '自动归班规则版本', + `matched_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `superseded_at` datetime(3) DEFAULT NULL, + `locked_at` datetime(3) DEFAULT NULL COMMENT '截止/订舱/关闭后冻结时间', + `lock_reason` varchar(32) DEFAULT NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED', + `operator_id` bigint unsigned DEFAULT NULL, + `reason` varchar(500) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_sailing_assignment_current` (`tenant_id`,`sailing_id`,`match_status`,`matched_at`,`id`), + KEY `idx_sailing_assignment_origin` (`tenant_id`,`origin_sailing_id`,`match_status`,`id`), + KEY `idx_sailing_assignment_receipt` (`tenant_id`,`receipt_batch_id`,`assignment_version`,`id`), + KEY `idx_sailing_assignment_forecast` (`tenant_id`,`forecast_id`,`assignment_version`,`id`), + KEY `idx_sailing_assignment_inbound` (`tenant_id`,`inbound_id`,`match_status`,`id`), + CONSTRAINT `chk_sailing_assignment_source` CHECK ((`assignment_source` in (_utf8mb4'AUTO',_utf8mb4'MANUAL',_utf8mb4'ROLLOVER'))), + CONSTRAINT `chk_sailing_assignment_status` CHECK ((`match_status` in (_utf8mb4'FORECAST_MATCHED',_utf8mb4'RECEIVED',_utf8mb4'LATE',_utf8mb4'UNMATCHED',_utf8mb4'SUPERSEDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期日历自动归班事实及不可变版本历史'; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_sailing_booking_link` ( + `id` bigint unsigned NOT NULL COMMENT '船期-订舱关联ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '平台关联隔离键', + `sailing_id` bigint unsigned NOT NULL COMMENT '计划船期实例ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '海运订舱ID', + `link_status` varchar(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED', + `active_booking_guard` tinyint DEFAULT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束', + `booking_status_snapshot` varchar(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立', + `warehouse_id` bigint unsigned DEFAULT NULL COMMENT '关联仓库快照', + `channel_code` varchar(64) DEFAULT NULL COMMENT '关联渠道快照', + `departure_port_code` varchar(32) DEFAULT NULL COMMENT '关联起运港快照', + `destination_port_code` varchar(32) DEFAULT NULL COMMENT '关联目的港快照', + `linked_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `revoked_at` datetime(3) DEFAULT NULL, + `operator_id` bigint unsigned DEFAULT NULL, + `operator_name` varchar(64) DEFAULT NULL, + `reason` varchar(500) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sailing_booking_active` (`ocean_booking_id`,`active_booking_guard`), + KEY `idx_sailing_booking_link` (`sailing_id`,`link_status`,`ocean_booking_id`), + KEY `idx_sailing_booking_booking` (`ocean_booking_id`,`link_status`,`id`), + CONSTRAINT `chk_sailing_booking_link_guard` CHECK ((((`link_status` = _utf8mb4'ACTIVE') and (`active_booking_guard` = 1)) or ((`link_status` = _utf8mb4'REVOKED') and (`active_booking_guard` is null)))), + CONSTRAINT `chk_sailing_booking_link_status` CHECK ((`link_status` in (_utf8mb4'ACTIVE',_utf8mb4'REVOKED'))), + CONSTRAINT `chk_sailing_booking_released` CHECK ((`booking_status_snapshot` = _utf8mb4'RELEASED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期计划与已放舱订舱的最小关联及历史'; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_sailing_channel` ( + `id` bigint NOT NULL, + `channel_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `channel_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_type` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL, + `mapped_channel_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `default_departure_port_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `default_destination_port_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `default_cutoff_days` tinyint NOT NULL DEFAULT '3', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` bigint NOT NULL, + `created_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL, + `updated_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `version` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fl_sailing_channel_code` (`channel_code`), + KEY `idx_fl_sailing_channel_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_sailing_history` ( + `id` bigint NOT NULL, + `sailing_id` bigint NOT NULL, + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `from_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `operator_id` bigint DEFAULT NULL, + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_fl_sailing_history_sailing` (`sailing_id`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_sailing_inbound_volume_forecast` ( + `id` bigint unsigned NOT NULL COMMENT '货量预报事实ID(forecastId)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键,继承入库事实', + `forecast_key` varchar(64) NOT NULL COMMENT '同一未收货对象的不可变业务键', + `fact_version` int unsigned NOT NULL DEFAULT '1' COMMENT '预报修订版本', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `receiving_warehouse_id` bigint unsigned NOT NULL COMMENT '预计收货仓ID', + `channel_code` varchar(64) DEFAULT NULL COMMENT '预计渠道编码快照', + `channel_name` varchar(128) DEFAULT NULL COMMENT '预计渠道名称快照', + `departure_port_code` varchar(32) DEFAULT NULL COMMENT '预计起运港编码快照', + `destination_port_code` varchar(32) DEFAULT NULL COMMENT '预计目的港编码快照', + `expected_arrival_at` datetime(3) NOT NULL COMMENT '预计到仓时间', + `forecast_volume_cbm` decimal(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替', + `source_type` varchar(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报', + `fact_status` varchar(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED', + `effective_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间', + `created_by` bigint unsigned DEFAULT NULL COMMENT '写入人或系统主体', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sailing_forecast_version` (`tenant_id`,`forecast_key`,`fact_version`), + KEY `idx_sailing_forecast_current` (`tenant_id`,`forecast_key`,`fact_status`,`fact_version`), + KEY `idx_sailing_forecast_match` (`tenant_id`,`receiving_warehouse_id`,`channel_code`,`expected_arrival_at`,`id`), + KEY `idx_sailing_forecast_inbound` (`tenant_id`,`inbound_id`,`expected_arrival_at`,`id`), + CONSTRAINT `chk_sailing_forecast_status` CHECK ((`fact_status` in (_utf8mb4'VALID',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED',_utf8mb4'CONSUMED'))), + CONSTRAINT `chk_sailing_forecast_volume` CHECK ((`forecast_volume_cbm` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期日历未收货量预报事实及版本历史'; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_sailing_receipt_batch_fact` ( + `id` bigint unsigned NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键,继承入库事实', + `receipt_batch_key` varchar(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键', + `fact_version` int unsigned NOT NULL DEFAULT '1' COMMENT '同一批次复测/冲正版本', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `measurement_id` bigint unsigned DEFAULT NULL COMMENT '最终审核测量批次ID', + `receiving_warehouse_id` bigint unsigned NOT NULL COMMENT '实际收货仓ID', + `channel_code` varchar(64) DEFAULT NULL COMMENT '收货渠道编码快照', + `channel_name` varchar(128) DEFAULT NULL COMMENT '收货渠道名称快照', + `departure_port_code` varchar(32) DEFAULT NULL COMMENT '起运港编码快照', + `destination_port_code` varchar(32) DEFAULT NULL COMMENT '目的港编码快照', + `received_at` datetime(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代', + `actual_volume_cbm` decimal(18,4) NOT NULL COMMENT '最终审核实测体积m3', + `fact_status` varchar(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED', + `reversal_of_fact_id` bigint unsigned DEFAULT NULL COMMENT '冲正所替代的事实ID', + `source_type` varchar(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源', + `created_by` bigint unsigned DEFAULT NULL COMMENT '写入人或系统主体', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sailing_receipt_batch_version` (`tenant_id`,`receipt_batch_key`,`fact_version`), + KEY `idx_sailing_receipt_batch_current` (`tenant_id`,`receipt_batch_key`,`fact_status`,`fact_version`), + KEY `idx_sailing_receipt_match` (`tenant_id`,`receiving_warehouse_id`,`channel_code`,`received_at`,`id`), + KEY `idx_sailing_receipt_inbound` (`tenant_id`,`inbound_id`,`received_at`,`id`), + CONSTRAINT `chk_sailing_receipt_status` CHECK ((`fact_status` in (_utf8mb4'APPROVED',_utf8mb4'REVERSED',_utf8mb4'VOIDED',_utf8mb4'SUPERSEDED'))), + CONSTRAINT `chk_sailing_receipt_volume` CHECK ((`actual_volume_cbm` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期日历稳定收货批次实测事实及版本历史'; + +USE `baiyue_firstleg`; + +CREATE TABLE `fl_sailing_template` ( + `id` bigint NOT NULL, + `template_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `template_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `channel_id` bigint NOT NULL, + `channel_code_snapshot` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `channel_name_snapshot` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `warehouse_id` bigint NOT NULL, + `warehouse_name_snapshot` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `departure_port_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_port_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `weekday` tinyint NOT NULL, + `weekday_mask` tinyint NOT NULL DEFAULT '1', + `etd_local_time` time NOT NULL, + `cutoff_days` tinyint NOT NULL DEFAULT '3', + `cutoff_local_time` time NOT NULL DEFAULT '18:00:00', + `valid_from` date NOT NULL, + `valid_to` date DEFAULT NULL, + `voyage_rule` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `vessel_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` bigint NOT NULL, + `created_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL, + `updated_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `version` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fl_sailing_template_no` (`template_no`), + KEY `idx_fl_sailing_template_filter` (`warehouse_id`,`channel_id`,`status`,`weekday`), + CONSTRAINT `ck_fl_sailing_template_cutoff_days` CHECK ((`cutoff_days` between 0 and 30)), + CONSTRAINT `ck_fl_sailing_template_weekday` CHECK ((`weekday` between 1 and 7)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +ALTER TABLE `fl_truck_appointment` ADD UNIQUE KEY `uk_truck_active_ocean_booking` (`active_ocean_booking_id`); + +ALTER TABLE `fl_truck_appointment` ADD KEY `idx_truck_appointment_ocean` (`ocean_booking_id`); + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; + +USE `baiyue_firstleg`; \ No newline at end of file diff --git a/output/local-schema-repair/columns-before.tsv b/output/local-schema-repair/columns-before.tsv new file mode 100644 index 000000000..08a2351fb --- /dev/null +++ b/output/local-schema-repair/columns-before.tsv @@ -0,0 +1,2745 @@ +backup_fba_appointment_item_20261013 id +backup_fba_appointment_item_20261013 tenant_id +backup_fba_appointment_item_20261013 customer_id +backup_fba_appointment_item_20261013 appointment_id +backup_fba_appointment_item_20261013 plan_id +backup_fba_appointment_item_20261013 line_id +backup_fba_appointment_item_20261013 order_id +backup_fba_appointment_item_20261013 inbound_code +backup_fba_appointment_item_20261013 fba_warehouse_code +backup_fba_appointment_item_20261013 active +backup_fba_appointment_item_20261013 created_at +event_delivery_attempt attempt_id +event_delivery_attempt service_name +event_delivery_attempt event_type +event_delivery_attempt entry_id +event_delivery_attempt event_id +event_delivery_attempt target_name +event_delivery_attempt started_at +event_delivery_attempt finished_at +event_delivery_attempt duration_ms +event_delivery_attempt outcome +event_delivery_attempt error_code +event_delivery_attempt next_retry_at +ll_appointment_cargo_verification id +ll_appointment_cargo_verification customer_id +ll_appointment_cargo_verification source_key +ll_appointment_cargo_verification destination_key +ll_appointment_cargo_verification source_version +ll_appointment_cargo_verification source_packages +ll_appointment_cargo_verification version +ll_appointment_cargo_verification package_count +ll_appointment_cargo_verification shipment_references_json +ll_appointment_cargo_verification remark +ll_appointment_cargo_verification operator_id +ll_appointment_cargo_verification operator_name +ll_appointment_cargo_verification command_key +ll_appointment_cargo_verification request_hash +ll_appointment_cargo_verification created_at +ll_appointment_work work_item_key +ll_appointment_work container_booking_id +ll_appointment_work version +ll_appointment_work retired +ll_appointment_work state_json +ll_appointment_work_commitment entity_key +ll_appointment_work_commitment state_json +ll_appointment_work_commitment impact_version +ll_appointment_work_guard id +ll_appointment_work_guard scan_cursor +ll_appointment_work_guard initialized +ll_appointment_work_guard scope_version +ll_appointment_work_guard scan_scope_version +ll_appointment_work_guard completed_at +ll_appointment_work_guard requirement_version +ll_appointment_work_guard synchronized_requirement_version +ll_appointment_work_guard scan_requirement_version +ll_appointment_work_history id +ll_appointment_work_history work_item_key +ll_appointment_work_history operator_id +ll_appointment_work_history operator_name +ll_appointment_work_history action +ll_appointment_work_history summary +ll_appointment_work_history command_key +ll_appointment_work_history request_hash +ll_appointment_work_history response_json +ll_appointment_work_history occurred_at +ll_cargo_discard_application id +ll_cargo_discard_application tenant_id +ll_cargo_discard_application customer_id +ll_cargo_discard_application discard_no +ll_cargo_discard_application outbound_no +ll_cargo_discard_application overseas_warehouse_id +ll_cargo_discard_application overseas_warehouse_name +ll_cargo_discard_application inbound_codes +ll_cargo_discard_application total_packages +ll_cargo_discard_application fee_per_package +ll_cargo_discard_application total_fee +ll_cargo_discard_application currency +ll_cargo_discard_application status +ll_cargo_discard_application remark +ll_cargo_discard_application idempotency_key +ll_cargo_discard_application request_hash +ll_cargo_discard_application review_idempotency_key +ll_cargo_discard_application review_request_hash +ll_cargo_discard_application created_by +ll_cargo_discard_application reviewed_by +ll_cargo_discard_application review_remark +ll_cargo_discard_application reviewed_at +ll_cargo_discard_application completed_at +ll_cargo_discard_application created_at +ll_cargo_discard_application updated_at +ll_cargo_discard_application customer_confirmed_at +ll_cargo_discard_carton id +ll_cargo_discard_carton tenant_id +ll_cargo_discard_carton application_id +ll_cargo_discard_carton item_id +ll_cargo_discard_carton customer_id +ll_cargo_discard_carton carton_id +ll_cargo_discard_carton carton_mark +ll_cargo_discard_carton created_at +ll_cargo_discard_carton source_type +ll_cargo_discard_item id +ll_cargo_discard_item tenant_id +ll_cargo_discard_item application_id +ll_cargo_discard_item customer_id +ll_cargo_discard_item order_id +ll_cargo_discard_item source_type +ll_cargo_discard_item return_carton_id +ll_cargo_discard_item inbound_code +ll_cargo_discard_item profile_id +ll_cargo_discard_item sku_inventory_id +ll_cargo_discard_item sku_code +ll_cargo_discard_item product_name +ll_cargo_discard_item available_packages_snapshot +ll_cargo_discard_item discard_packages +ll_cargo_discard_item created_at +ll_cargo_discard_item actual_discard_packages +ll_cargo_discard_item pda_status +ll_cargo_discard_item pda_operator_id +ll_cargo_discard_item pda_completed_at +ll_cargo_discard_item completion_photo_file_ids +ll_cargo_discard_item inventory_position_id +ll_cargo_discard_item carton_marks_json +ll_cargo_discard_item carton_scan_required +ll_cargo_discard_photo id +ll_cargo_discard_photo customer_id +ll_cargo_discard_photo application_id +ll_cargo_discard_photo pallet_no +ll_cargo_discard_photo file_id +ll_cargo_discard_photo file_name +ll_cargo_discard_photo content_type +ll_cargo_discard_photo size_bytes +ll_cargo_discard_photo operator_id +ll_cargo_discard_photo created_at +ll_cargo_discard_photo submitted_at +ll_cargo_discard_status_history id +ll_cargo_discard_status_history tenant_id +ll_cargo_discard_status_history application_id +ll_cargo_discard_status_history customer_id +ll_cargo_discard_status_history from_status +ll_cargo_discard_status_history to_status +ll_cargo_discard_status_history operator_id +ll_cargo_discard_status_history remark +ll_cargo_discard_status_history created_at +ll_compact_number_clock number_key +ll_compact_number_clock last_second +ll_container_arrival_time_operation id +ll_container_arrival_time_operation source_container_booking_id +ll_container_arrival_time_operation idempotency_key +ll_container_arrival_time_operation request_hash +ll_container_arrival_time_operation result_version +ll_container_arrival_time_operation operator_name +ll_container_arrival_time_operation remark +ll_container_arrival_time_operation created_at +ll_container_loading_evidence id +ll_container_loading_evidence source_container_booking_id +ll_container_loading_evidence evidence_type +ll_container_loading_evidence source_file_id +ll_container_loading_evidence file_name +ll_container_loading_evidence created_at +ll_container_plan_conversion_command id +ll_container_plan_conversion_command source_container_booking_id +ll_container_plan_conversion_command idempotency_key +ll_container_plan_conversion_command request_hash +ll_container_plan_conversion_command snapshot_version +ll_container_plan_conversion_command snapshot_hash +ll_container_plan_conversion_command warehouse_id +ll_container_plan_conversion_command batch_key +ll_container_plan_conversion_command status +ll_container_plan_conversion_command lease_token +ll_container_plan_conversion_command preparation_json +ll_container_plan_conversion_command result_json +ll_container_plan_conversion_command last_error +ll_container_plan_conversion_command created_at +ll_container_plan_conversion_command updated_at +ll_container_shipping_gate container_booking_id +ll_container_shipping_gate customer_id +ll_container_shipping_gate ocean_booking_id +ll_container_shipping_gate container_no +ll_container_shipping_gate status +ll_container_shipping_gate standard_node +ll_container_shipping_gate node_sequence +ll_container_shipping_gate observed_at +ll_container_shipping_gate actual_at +ll_container_shipping_gate location_code +ll_container_shipping_gate location_name +ll_container_shipping_gate provider_code +ll_container_shipping_gate provider_event_id +ll_container_shipping_gate provider_node_code +ll_container_shipping_gate source_reference +ll_container_shipping_gate raw_payload_hash +ll_container_shipping_gate aggregate_version +ll_container_shipping_gate handover_version +ll_container_shipping_gate last_event_id +ll_container_shipping_gate payload_hash +ll_container_shipping_gate created_at +ll_container_shipping_gate updated_at +ll_customer_visit id +ll_customer_visit customer_id +ll_customer_visit delivery_order_id +ll_customer_visit delivery_no +ll_customer_visit customer_name +ll_customer_visit contact_name +ll_customer_visit contact_phone +ll_customer_visit destination +ll_customer_visit delivery_completed_at +ll_customer_visit status +ll_customer_visit selected_last_mile_order_id +ll_customer_visit selected_last_mile_order_no +ll_customer_visit selected_inbound_order_id +ll_customer_visit selected_inbound_code +ll_customer_visit visit_result +ll_customer_visit customer_feedback +ll_customer_visit remark +ll_customer_visit attachment_json +ll_customer_visit visited_by +ll_customer_visit visited_at +ll_customer_visit visit_count +ll_customer_visit created_at +ll_customer_visit updated_at +ll_delivery_document_revision id +ll_delivery_document_revision customer_id +ll_delivery_document_revision delivery_order_id +ll_delivery_document_revision version +ll_delivery_document_revision idempotency_key +ll_delivery_document_revision request_hash +ll_delivery_document_revision snapshot_json +ll_delivery_document_revision operator_id +ll_delivery_document_revision created_at +ll_delivery_fee id +ll_delivery_fee customer_id +ll_delivery_fee delivery_order_id +ll_delivery_fee fee_direction +ll_delivery_fee fee_type +ll_delivery_fee currency +ll_delivery_fee amount +ll_delivery_fee remark +ll_delivery_fee created_by +ll_delivery_fee created_at +ll_delivery_operation id +ll_delivery_operation customer_id +ll_delivery_operation delivery_order_id +ll_delivery_operation operation_node +ll_delivery_operation operator_id +ll_delivery_operation operator_name +ll_delivery_operation result +ll_delivery_operation created_at +ll_delivery_order id +ll_delivery_order tenant_id +ll_delivery_order customer_id +ll_delivery_order delivery_no +ll_delivery_order outbound_order_id +ll_delivery_order outbound_no +ll_delivery_order dispatch_order_id +ll_delivery_order dispatch_no +ll_delivery_order overseas_warehouse_id +ll_delivery_order overseas_warehouse_name +ll_delivery_order customer_name +ll_delivery_order destination_warehouse_id +ll_delivery_order destination_warehouse_code +ll_delivery_order destination_warehouse_name +ll_delivery_order destination_type +ll_delivery_order delivery_address +ll_delivery_order contact_name +ll_delivery_order contact_phone +ll_delivery_order delivery_group_key +ll_delivery_order status +ll_delivery_order seller_name +ll_delivery_order package_count +ll_delivery_order pallet_count +ll_delivery_order remark +ll_delivery_order idempotency_key +ll_delivery_order created_by +ll_delivery_order version +ll_delivery_order created_at +ll_delivery_order updated_at +ll_delivery_order waybill_no +ll_delivery_order delivery_method +ll_delivery_order carrier_name +ll_delivery_order pickup_method +ll_delivery_order loading_type +ll_delivery_order vehicle_type +ll_delivery_order vehicle_size +ll_delivery_order vehicle_plate +ll_delivery_order driver_name +ll_delivery_order driver_phone +ll_delivery_order appointment_account +ll_delivery_order total_weight_kg +ll_delivery_order total_volume_cbm +ll_delivery_order planned_delivery_at +ll_delivery_order dispatched_at +ll_delivery_order expected_arrival_at +ll_delivery_order signed_at +ll_delivery_order bol_no +ll_delivery_order documents_json +ll_delivery_order postal_code +ll_delivery_order signed_packages +ll_destuff_pallet_cost id +ll_destuff_pallet_cost source_container_booking_id +ll_destuff_pallet_cost container_no +ll_destuff_pallet_cost warehouse_id +ll_destuff_pallet_cost pallet_count +ll_destuff_pallet_cost total_actual_volume_cbm +ll_destuff_pallet_cost total_cost +ll_destuff_pallet_cost currency +ll_destuff_pallet_cost status +ll_destuff_pallet_cost created_at +ll_destuff_pallet_cost_allocation id +ll_destuff_pallet_cost_allocation cost_id +ll_destuff_pallet_cost_allocation customer_id +ll_destuff_pallet_cost_allocation actual_volume_cbm +ll_destuff_pallet_cost_allocation allocated_amount +ll_destuff_pallet_cost_allocation fee_source_id +ll_destuff_pallet_cost_allocation created_at +ll_dispatch_appointment_allocation dispatch_id +ll_dispatch_appointment_allocation dispatch_item_id +ll_dispatch_appointment_allocation plan_pallet_id +ll_dispatch_appointment_allocation batch_key +ll_dispatch_appointment_allocation customer_id +ll_dispatch_appointment_allocation source_key +ll_dispatch_appointment_allocation destination_key +ll_dispatch_appointment_allocation package_count +ll_dispatch_draft id +ll_dispatch_draft draft_token +ll_dispatch_draft customer_id +ll_dispatch_draft operator_id +ll_dispatch_draft plan_ids_json +ll_dispatch_draft containers_json +ll_dispatch_draft status +ll_dispatch_draft expires_at +ll_dispatch_draft released_at +ll_dispatch_draft created_at +ll_dispatch_draft updated_at +ll_dispatch_draft warehouse_id +ll_dispatch_draft record_version +ll_dispatch_draft sources_json +ll_dispatch_draft_command id +ll_dispatch_draft_command idempotency_key +ll_dispatch_draft_command operator_id +ll_dispatch_draft_command action +ll_dispatch_draft_command target_token +ll_dispatch_draft_command request_hash +ll_dispatch_draft_command result_json +ll_dispatch_draft_command created_at +ll_dispatch_draft_container id +ll_dispatch_draft_container draft_id +ll_dispatch_draft_container draft_token +ll_dispatch_draft_container sequence_no +ll_dispatch_draft_container capacity +ll_dispatch_draft_container created_at +ll_dispatch_draft_container updated_at +ll_dispatch_draft_container source_keys_json +ll_dispatch_history id +ll_dispatch_history dispatch_id +ll_dispatch_history record_version +ll_dispatch_history action +ll_dispatch_history operator_id +ll_dispatch_history reason +ll_dispatch_history snapshot_json +ll_dispatch_history occurred_at +ll_dispatch_item id +ll_dispatch_item tenant_id +ll_dispatch_item dispatch_order_id +ll_dispatch_item customer_id +ll_dispatch_item plan_id +ll_dispatch_item plan_pallet_id +ll_dispatch_item warehouse_pallet_id +ll_dispatch_item order_id +ll_dispatch_item order_no +ll_dispatch_item order_refs +ll_dispatch_item inbound_code +ll_dispatch_item pallet_no +ll_dispatch_item overseas_warehouse_id +ll_dispatch_item overseas_warehouse_name +ll_dispatch_item destination_warehouse_id +ll_dispatch_item destination_warehouse_code +ll_dispatch_item destination_warehouse_name +ll_dispatch_item fba_warehouse_code +ll_dispatch_item package_count +ll_dispatch_item gross_weight_kg +ll_dispatch_item volume_weight_kg +ll_dispatch_item volume_cbm +ll_dispatch_item source_stage +ll_dispatch_item carton_marks +ll_dispatch_item source_business_type +ll_dispatch_item source_business_no +ll_dispatch_item created_at +ll_dispatch_item destination_type +ll_dispatch_item delivery_address +ll_dispatch_item postal_code +ll_dispatch_item contact_name +ll_dispatch_item contact_phone +ll_dispatch_item return_receipt_id +ll_dispatch_item customer_name +ll_dispatch_item source_key +ll_dispatch_item source_version +ll_dispatch_item source_snapshot_json +ll_dispatch_order id +ll_dispatch_order tenant_id +ll_dispatch_order customer_id +ll_dispatch_order dispatch_no +ll_dispatch_order overseas_warehouse_id +ll_dispatch_order overseas_warehouse_name +ll_dispatch_order vehicle_plate +ll_dispatch_order driver_name +ll_dispatch_order driver_phone +ll_dispatch_order carrier_name +ll_dispatch_order route +ll_dispatch_order depart_at +ll_dispatch_order fba_appointment_no +ll_dispatch_order fba_appointment_status +ll_dispatch_order vehicle_appointment_status +ll_dispatch_order estimated_arrival_at +ll_dispatch_order status +ll_dispatch_order item_count +ll_dispatch_order pallet_count +ll_dispatch_order package_count +ll_dispatch_order picked_package_count +ll_dispatch_order shipped_package_count +ll_dispatch_order partial_completion_reason +ll_dispatch_order created_by +ll_dispatch_order created_at +ll_dispatch_order updated_at +ll_dispatch_order trip_id +ll_dispatch_order overseas_warehouse_code +ll_dispatch_order stage +ll_dispatch_order record_version +ll_dispatch_order cargo_revision +ll_dispatch_order cargo_hash +ll_dispatch_order review_state +ll_dispatch_order review_round +ll_dispatch_order review_basis_hash +ll_dispatch_order dispatch_review_json +ll_dispatch_order loading_review_json +ll_dispatch_order resources_json +ll_dispatch_order planned_departure_at +ll_dispatch_order loading_completed_at +ll_dispatch_order loading_completion_hash +ll_dispatch_order loading_confirmation_json +ll_dispatch_order arrangement_key +ll_dispatch_order arrangement_business_hash +ll_dispatch_order departed_at +ll_dispatch_order delivery_completed_at +ll_dispatch_order remark +ll_dispatch_pallet_lock id +ll_dispatch_pallet_lock tenant_id +ll_dispatch_pallet_lock customer_id +ll_dispatch_pallet_lock source_pallet_key +ll_dispatch_pallet_lock active_key +ll_dispatch_pallet_lock plan_id +ll_dispatch_pallet_lock plan_pallet_id +ll_dispatch_pallet_lock warehouse_pallet_id +ll_dispatch_pallet_lock dispatch_order_id +ll_dispatch_pallet_lock draft_container_id +ll_dispatch_pallet_lock draft_token +ll_dispatch_pallet_lock operator_id +ll_dispatch_pallet_lock status +ll_dispatch_pallet_lock locked_at +ll_dispatch_pallet_lock expires_at +ll_dispatch_pallet_lock released_at +ll_dispatch_pallet_lock created_at +ll_dispatch_pallet_lock updated_at +ll_dispatch_pallet_lock return_receipt_id +ll_dispatch_pallet_lock active_warehouse_pallet_id +ll_dispatch_pda_operation id +ll_dispatch_pda_operation tenant_id +ll_dispatch_pda_operation customer_id +ll_dispatch_pda_operation task_id +ll_dispatch_pda_operation operation_type +ll_dispatch_pda_operation idempotency_key +ll_dispatch_pda_operation request_hash +ll_dispatch_pda_operation result_json +ll_dispatch_pda_operation status +ll_dispatch_pda_operation created_at +ll_dispatch_pda_operation updated_at +ll_dispatch_pda_pick id +ll_dispatch_pda_pick tenant_id +ll_dispatch_pda_pick customer_id +ll_dispatch_pda_pick task_id +ll_dispatch_pda_pick dispatch_item_id +ll_dispatch_pda_pick inventory_position_id +ll_dispatch_pda_pick packages +ll_dispatch_pda_pick product_quantity +ll_dispatch_pda_pick idempotency_key +ll_dispatch_pda_pick picked_at +ll_dispatch_pda_pick created_at +ll_dispatch_pda_pick return_carton_id +ll_dispatch_pda_pick_order id +ll_dispatch_pda_pick_order tenant_id +ll_dispatch_pda_pick_order customer_id +ll_dispatch_pda_pick_order pick_id +ll_dispatch_pda_pick_order receipt_id +ll_dispatch_pda_pick_order order_id +ll_dispatch_pda_pick_order packages +ll_dispatch_pda_pick_order product_quantity +ll_dispatch_pda_pick_order created_at +ll_dispatch_pda_pick_order return_carton_id +ll_dispatch_pda_pick_order return_receipt_id +ll_dispatch_pda_task id +ll_dispatch_pda_task tenant_id +ll_dispatch_pda_task customer_id +ll_dispatch_pda_task dispatch_order_id +ll_dispatch_pda_task task_no +ll_dispatch_pda_task status +ll_dispatch_pda_task operator_id +ll_dispatch_pda_task operator_name +ll_dispatch_pda_task warehouse_id +ll_dispatch_pda_task pushed_at +ll_dispatch_pda_task completed_at +ll_dispatch_pda_task created_at +ll_dispatch_pda_task updated_at +ll_dropship_carton id +ll_dropship_carton tenant_id +ll_dropship_carton customer_id +ll_dropship_carton order_id +ll_dropship_carton profile_id +ll_dropship_carton warehouse_pallet_id +ll_dropship_carton pallet_no +ll_dropship_carton sku_code +ll_dropship_carton carton_mark +ll_dropship_carton carton_sequence +ll_dropship_carton status +ll_dropship_carton outbound_order_no +ll_dropship_carton version +ll_dropship_carton created_at +ll_dropship_carton updated_at +ll_dropship_carton product_quantity +ll_dropship_carton_ledger id +ll_dropship_carton_ledger tenant_id +ll_dropship_carton_ledger customer_id +ll_dropship_carton_ledger order_id +ll_dropship_carton_ledger carton_id +ll_dropship_carton_ledger carton_mark +ll_dropship_carton_ledger sku_code +ll_dropship_carton_ledger operation_type +ll_dropship_carton_ledger before_packages +ll_dropship_carton_ledger delta_packages +ll_dropship_carton_ledger after_packages +ll_dropship_carton_ledger business_order_no +ll_dropship_carton_ledger idempotency_key +ll_dropship_carton_ledger remark +ll_dropship_carton_ledger occurred_at +ll_dropship_carton_ledger created_at +ll_dropship_carton_sku id +ll_dropship_carton_sku customer_id +ll_dropship_carton_sku order_id +ll_dropship_carton_sku profile_id +ll_dropship_carton_sku carton_id +ll_dropship_carton_sku sku_code +ll_dropship_carton_sku product_name +ll_dropship_carton_sku product_quantity +ll_dropship_carton_sku created_at +ll_dropship_carton_sku updated_at +ll_dropship_order_profile id +ll_dropship_order_profile tenant_id +ll_dropship_order_profile customer_id +ll_dropship_order_profile order_id +ll_dropship_order_profile warehouse_pallet_id +ll_dropship_order_profile pallet_no +ll_dropship_order_profile source_pallet_no +ll_dropship_order_profile inventory_position_id +ll_dropship_order_profile status +ll_dropship_order_profile idempotency_key +ll_dropship_order_profile version +ll_dropship_order_profile created_at +ll_dropship_order_profile updated_at +ll_dropship_order_profile manifest_version +ll_dropship_order_profile manifest_package_count +ll_dropship_order_profile manifest_fragment_count +ll_dropship_order_profile manifest_hash +ll_dropship_sku_inventory id +ll_dropship_sku_inventory tenant_id +ll_dropship_sku_inventory customer_id +ll_dropship_sku_inventory order_id +ll_dropship_sku_inventory profile_id +ll_dropship_sku_inventory sku_code +ll_dropship_sku_inventory product_name +ll_dropship_sku_inventory package_count +ll_dropship_sku_inventory product_quantity +ll_dropship_sku_inventory created_at +ll_dropship_sku_inventory updated_at +ll_event_conflict id +ll_event_conflict tenant_id +ll_event_conflict event_id +ll_event_conflict customer_id +ll_event_conflict conflict_type +ll_event_conflict conflict_message +ll_event_conflict created_at +ll_event_inbox id +ll_event_inbox tenant_id +ll_event_inbox event_id +ll_event_inbox customer_id +ll_event_inbox event_type +ll_event_inbox aggregate_type +ll_event_inbox aggregate_id +ll_event_inbox aggregate_version +ll_event_inbox payload_hash +ll_event_inbox status +ll_event_inbox error_message +ll_event_inbox created_at +ll_event_inbox updated_at +ll_event_outbox id +ll_event_outbox customer_id +ll_event_outbox aggregate_type +ll_event_outbox aggregate_id +ll_event_outbox event_type +ll_event_outbox event_version +ll_event_outbox event_key +ll_event_outbox payload_json +ll_event_outbox status +ll_event_outbox retry_count +ll_event_outbox next_retry_at +ll_event_outbox published_at +ll_event_outbox locked_at +ll_event_outbox locked_by +ll_event_outbox last_error +ll_event_outbox created_at +ll_exception_order id +ll_exception_order customer_id +ll_exception_order exception_no +ll_exception_order exception_type +ll_exception_order status +ll_exception_order reason +ll_exception_order last_mile_order_id +ll_exception_order last_mile_order_no +ll_exception_order inbound_order_id +ll_exception_order inbound_code +ll_exception_order delivery_order_id +ll_exception_order delivery_no +ll_exception_order outbound_no +ll_exception_order customer_name +ll_exception_order overseas_warehouse_id +ll_exception_order overseas_warehouse_name +ll_exception_order sku_code +ll_exception_order package_count +ll_exception_order return_direction +ll_exception_order return_logistics_no +ll_exception_order handling_action +ll_exception_order handling_result +ll_exception_order evidence_file_ids_json +ll_exception_order inventory_adjustment_id +ll_exception_order handler_id +ll_exception_order handler_name +ll_exception_order source_type +ll_exception_order created_by +ll_exception_order exception_at +ll_exception_order completed_at +ll_exception_order version +ll_exception_order created_at +ll_exception_order updated_at +ll_exception_order return_context_json +ll_exception_order_command id +ll_exception_order_command customer_id +ll_exception_order_command exception_order_id +ll_exception_order_command action +ll_exception_order_command idempotency_key +ll_exception_order_command request_hash +ll_exception_order_command result_json +ll_exception_order_command created_at +ll_exception_return_item id +ll_exception_return_item customer_id +ll_exception_return_item exception_order_id +ll_exception_return_item outbound_pallet_item_id +ll_exception_return_item inventory_position_id +ll_exception_return_item pallet_no +ll_exception_return_item sku_code +ll_exception_return_item max_return_packages +ll_exception_return_item approved_return_packages +ll_exception_return_item created_at +ll_exception_return_item return_carton_id +ll_exception_return_item return_carton_mark +ll_exception_return_item received_packages +ll_fba_appointment_command command_key +ll_fba_appointment_command request_hash +ll_fba_appointment_command operation +ll_fba_appointment_command operator_id +ll_fba_appointment_command appointment_no +ll_fba_appointment_command response_json +ll_fba_appointment_command created_at +ll_fba_receiving_agreement agreement_key +ll_fba_receiving_agreement version +ll_fba_receiving_agreement status +ll_fba_receiving_agreement destination_key +ll_fba_receiving_agreement confirmation_no +ll_fba_receiving_agreement snapshot_json +ll_fba_receiving_change change_key +ll_fba_receiving_change version +ll_fba_receiving_change status +ll_fba_receiving_change batch_key +ll_fba_receiving_change customer_id +ll_fba_receiving_change snapshot_json +ll_fba_receiving_change result_json +ll_fba_receiving_change active_ref +ll_fba_receiving_member agreement_key +ll_fba_receiving_member batch_key +ll_fba_receiving_member customer_id +ll_fba_receiving_member active_ref +ll_fba_receiving_plan batch_key +ll_fba_receiving_plan customer_id +ll_fba_receiving_plan snapshot_json +ll_fba_receiving_revision agreement_key +ll_fba_receiving_revision version +ll_fba_receiving_revision snapshot_json +ll_finance_fee_command id +ll_finance_fee_command operator_id +ll_finance_fee_command action +ll_finance_fee_command idempotency_key +ll_finance_fee_command request_hash +ll_finance_fee_command source_fee_id +ll_finance_fee_command source_version +ll_finance_fee_command created_at +ll_finance_fee_source id +ll_finance_fee_source source_fee_id +ll_finance_fee_source source_version +ll_finance_fee_source customer_id +ll_finance_fee_source customer_name +ll_finance_fee_source source_type +ll_finance_fee_source source_no +ll_finance_fee_source business_type +ll_finance_fee_source business_id +ll_finance_fee_source business_no +ll_finance_fee_source order_id +ll_finance_fee_source inbound_id +ll_finance_fee_source inbound_code +ll_finance_fee_source container_id +ll_finance_fee_source container_booking_id +ll_finance_fee_source container_no +ll_finance_fee_source warehouse_id +ll_finance_fee_source fee_item_code +ll_finance_fee_source fee_item_name +ll_finance_fee_source direction +ll_finance_fee_source supplier_id +ll_finance_fee_source supplier_name +ll_finance_fee_source supplier_category +ll_finance_fee_source settlement_mode +ll_finance_fee_source paid_at +ll_finance_fee_source payment_method +ll_finance_fee_source payment_reference_no +ll_finance_fee_source payment_voucher_file_ids +ll_finance_fee_source wallet_settlement +ll_finance_fee_source business_date +ll_finance_fee_source currency +ll_finance_fee_source amount +ll_finance_fee_source review_status +ll_finance_fee_source publication_status +ll_finance_fee_source source_status +ll_finance_fee_source review_reason +ll_finance_fee_source reviewer_id +ll_finance_fee_source reviewed_at +ll_finance_fee_source created_by +ll_finance_fee_source updated_by +ll_finance_fee_source created_at +ll_finance_fee_source updated_at +ll_finance_fee_source original_currency +ll_finance_fee_source original_amount +ll_finance_fee_source exchange_rate +ll_finance_fee_source exchange_rate_month +ll_finance_fee_source chargeable_quantity +ll_finance_fee_source quantity_unit +ll_finance_fee_source unit_price +ll_finance_fee_source period_start +ll_finance_fee_source period_end +ll_fleet_capacity_offer id +ll_fleet_capacity_offer offer_no +ll_fleet_capacity_offer trip_id +ll_fleet_capacity_offer trip_no +ll_fleet_capacity_offer status +ll_fleet_capacity_offer cutoff_at +ll_fleet_capacity_offer contact_name +ll_fleet_capacity_offer contact_phone +ll_fleet_capacity_offer remark +ll_fleet_capacity_offer published_fingerprint +ll_fleet_capacity_offer record_version +ll_fleet_capacity_offer created_by +ll_fleet_capacity_offer created_at +ll_fleet_capacity_offer updated_by +ll_fleet_capacity_offer updated_at +ll_fleet_capacity_offer_command id +ll_fleet_capacity_offer_command trip_id +ll_fleet_capacity_offer_command operator_id +ll_fleet_capacity_offer_command action +ll_fleet_capacity_offer_command scope_no +ll_fleet_capacity_offer_command idempotency_key +ll_fleet_capacity_offer_command request_hash +ll_fleet_capacity_offer_command result_json +ll_fleet_capacity_offer_command created_at +ll_fleet_driver id +ll_fleet_driver driver_no +ll_fleet_driver warehouse_id +ll_fleet_driver warehouse_code +ll_fleet_driver warehouse_name +ll_fleet_driver vehicle_models +ll_fleet_driver languages_and_area +ll_fleet_driver medical_expires_on +ll_fleet_driver training_expires_on +ll_fleet_driver insurance_expires_on +ll_fleet_driver name +ll_fleet_driver phone +ll_fleet_driver ownership +ll_fleet_driver region +ll_fleet_driver active +ll_fleet_driver review_status +ll_fleet_driver license_number +ll_fleet_driver license_type +ll_fleet_driver license_issued_on +ll_fleet_driver license_expires_on +ll_fleet_driver license_zone +ll_fleet_driver license_file_id +ll_fleet_driver remark +ll_fleet_driver record_version +ll_fleet_driver created_by +ll_fleet_driver created_at +ll_fleet_driver updated_by +ll_fleet_driver updated_at +ll_fleet_driver_history id +ll_fleet_driver_history driver_id +ll_fleet_driver_history record_version +ll_fleet_driver_history action +ll_fleet_driver_history reason +ll_fleet_driver_history license_file_id +ll_fleet_driver_history snapshot_json +ll_fleet_driver_history operator_id +ll_fleet_driver_history occurred_at +ll_fleet_external_booking id +ll_fleet_external_booking booking_no +ll_fleet_external_booking trip_id +ll_fleet_external_booking trip_no +ll_fleet_external_booking customer_id +ll_fleet_external_booking customer_code +ll_fleet_external_booking customer_name +ll_fleet_external_booking status +ll_fleet_external_booking cargo_json +ll_fleet_external_booking quote_source_json +ll_fleet_external_booking destination_key +ll_fleet_external_booking destination_label +ll_fleet_external_booking agreed_amount +ll_fleet_external_booking idempotency_key +ll_fleet_external_booking request_hash +ll_fleet_external_booking confirm_key +ll_fleet_external_booking confirm_hash +ll_fleet_external_booking cancel_key +ll_fleet_external_booking cancel_hash +ll_fleet_external_booking incoming_no +ll_fleet_external_booking dispatch_no +ll_fleet_external_booking receipt_json +ll_fleet_external_booking incoming_key +ll_fleet_external_booking incoming_hash +ll_fleet_external_booking receipt_key +ll_fleet_external_booking receipt_hash +ll_fleet_external_booking binding_key +ll_fleet_external_booking binding_hash +ll_fleet_external_booking offer_id +ll_fleet_external_booking sales_owner_user_id +ll_fleet_external_booking business_type +ll_fleet_external_booking delivery_mode +ll_fleet_external_booking estimated_arrival_at +ll_fleet_external_booking expected_pallet_count +ll_fleet_external_booking record_version +ll_fleet_external_booking created_by +ll_fleet_external_booking created_at +ll_fleet_external_booking updated_by +ll_fleet_external_booking updated_at +ll_fleet_finance_source id +ll_fleet_finance_source record_id +ll_fleet_finance_source source_version +ll_fleet_finance_source record_version +ll_fleet_finance_source operator_id +ll_fleet_finance_source request_key +ll_fleet_finance_source request_hash +ll_fleet_finance_source event_id +ll_fleet_finance_source payload_json +ll_fleet_finance_source created_at +ll_fleet_pickup_command trip_no +ll_fleet_pickup_command request_key +ll_fleet_pickup_command request_hash +ll_fleet_pickup_history id +ll_fleet_pickup_history customer_id +ll_fleet_pickup_history transfer_no +ll_fleet_pickup_history trip_no +ll_fleet_pickup_history action +ll_fleet_pickup_history operator_id +ll_fleet_pickup_history occurred_at +ll_fleet_pickup_history detail +ll_fleet_pickup_plan trip_no +ll_fleet_pickup_plan request_hash +ll_fleet_pickup_task transfer_order_id +ll_fleet_pickup_task customer_id +ll_fleet_pickup_task transfer_no +ll_fleet_pickup_task trip_no +ll_fleet_pickup_task status +ll_fleet_pickup_task actual_packages +ll_fleet_pickup_task picked_at +ll_fleet_pickup_task arrived_at +ll_fleet_pickup_task evidence_json +ll_fleet_pickup_task remark +ll_fleet_pickup_task updated_at +ll_fleet_pickup_upload transfer_order_id +ll_fleet_pickup_upload trip_no +ll_fleet_pickup_upload file_id +ll_fleet_pool_order_link id +ll_fleet_pool_order_link booking_id +ll_fleet_pool_order_link booking_no +ll_fleet_pool_order_link customer_id +ll_fleet_pool_order_link business_type +ll_fleet_pool_order_link business_order_id +ll_fleet_pool_order_link business_order_no +ll_fleet_pool_order_link warehouse_id +ll_fleet_pool_order_link destination_key +ll_fleet_pool_order_link delivery_mode +ll_fleet_pool_order_link delivery_instructions +ll_fleet_pool_order_link incoming_no +ll_fleet_pool_order_link lastleg_order_id +ll_fleet_pool_order_link dispatch_no +ll_fleet_pool_order_link fulfillment_stage +ll_fleet_pool_order_link stage_occurred_at +ll_fleet_pool_order_link actual_pallet_count +ll_fleet_pool_order_link actual_package_count +ll_fleet_pool_order_link actual_pallet_spaces +ll_fleet_pool_order_link record_version +ll_fleet_pool_order_link creation_request_hash +ll_fleet_pool_order_link created_by +ll_fleet_pool_order_link created_at +ll_fleet_pool_order_link updated_at +ll_fleet_quote id +ll_fleet_quote quote_no +ll_fleet_quote kind +ll_fleet_quote party_id +ll_fleet_quote party_name +ll_fleet_quote origin_city +ll_fleet_quote destination_code +ll_fleet_quote valid_from +ll_fleet_quote valid_to +ll_fleet_quote status +ll_fleet_quote natural_key +ll_fleet_quote details_json +ll_fleet_quote record_version +ll_fleet_quote create_key +ll_fleet_quote create_hash +ll_fleet_quote created_by +ll_fleet_quote created_at +ll_fleet_quote updated_at +ll_fleet_quote_history id +ll_fleet_quote_history quote_id +ll_fleet_quote_history record_version +ll_fleet_quote_history action +ll_fleet_quote_history operator_id +ll_fleet_quote_history occurred_at +ll_fleet_quote_history snapshot_json +ll_fleet_resource_assignment resource_kind +ll_fleet_resource_assignment resource_id +ll_fleet_resource_assignment trip_id +ll_fleet_resource_assignment trip_no +ll_fleet_trip id +ll_fleet_trip trip_no +ll_fleet_trip idempotency_key +ll_fleet_trip request_hash +ll_fleet_trip status +ll_fleet_trip warehouse_id +ll_fleet_trip warehouse_code +ll_fleet_trip warehouse_name +ll_fleet_trip vehicle_id +ll_fleet_trip vehicle_plate +ll_fleet_trip driver_id +ll_fleet_trip driver_name +ll_fleet_trip driver_phone +ll_fleet_trip max_pallet_spaces +ll_fleet_trip max_load_kg +ll_fleet_trip max_volume_cbm +ll_fleet_trip planned_departure_at +ll_fleet_trip expected_driver_end_at +ll_fleet_trip departed_at +ll_fleet_trip completed_at +ll_fleet_trip stops_json +ll_fleet_trip planned_load_json +ll_fleet_trip load_json +ll_fleet_trip loading_review_json +ll_fleet_trip departure_key +ll_fleet_trip departure_hash +ll_fleet_trip departure_result_json +ll_fleet_trip completion_json +ll_fleet_trip vehicle_release_json +ll_fleet_trip driver_release_json +ll_fleet_trip remark +ll_fleet_trip record_version +ll_fleet_trip created_by +ll_fleet_trip created_at +ll_fleet_trip updated_by +ll_fleet_trip updated_at +ll_fleet_trip trip_purpose +ll_fleet_trip_cost id +ll_fleet_trip_cost trip_id +ll_fleet_trip_cost cost_no +ll_fleet_trip_cost category +ll_fleet_trip_cost currency +ll_fleet_trip_cost amount +ll_fleet_trip_cost incurred_at +ll_fleet_trip_cost remark +ll_fleet_trip_cost record_version +ll_fleet_trip_cost request_key +ll_fleet_trip_cost request_hash +ll_fleet_trip_cost created_by +ll_fleet_trip_cost updated_by +ll_fleet_trip_cost created_at +ll_fleet_trip_cost updated_at +ll_fleet_trip_cost_history id +ll_fleet_trip_cost_history cost_id +ll_fleet_trip_cost_history record_version +ll_fleet_trip_cost_history action +ll_fleet_trip_cost_history reason +ll_fleet_trip_cost_history operator_id +ll_fleet_trip_cost_history occurred_at +ll_fleet_trip_cost_history snapshot_json +ll_fleet_trip_dispatch dispatch_id +ll_fleet_trip_dispatch dispatch_no +ll_fleet_trip_dispatch customer_id +ll_fleet_trip_dispatch trip_id +ll_fleet_trip_dispatch trip_no +ll_fleet_trip_dispatch package_count +ll_fleet_trip_dispatch bound_by +ll_fleet_trip_dispatch bound_at +ll_fleet_trip_dispatch review_json +ll_fleet_trip_handover id +ll_fleet_trip_handover trip_id +ll_fleet_trip_handover trip_version +ll_fleet_trip_handover trip_no +ll_fleet_trip_handover handover_no +ll_fleet_trip_handover request_key +ll_fleet_trip_handover request_hash +ll_fleet_trip_handover record_version +ll_fleet_trip_handover handed_over_at +ll_fleet_trip_handover snapshot_json +ll_fleet_trip_handover vehicle_release_json +ll_fleet_trip_handover driver_release_json +ll_fleet_trip_handover created_by +ll_fleet_trip_handover created_at +ll_fleet_trip_history id +ll_fleet_trip_history trip_id +ll_fleet_trip_history record_version +ll_fleet_trip_history action +ll_fleet_trip_history reason +ll_fleet_trip_history operator_id +ll_fleet_trip_history occurred_at +ll_fleet_trip_history snapshot_json +ll_fleet_trip_operations_record id +ll_fleet_trip_operations_record trip_no +ll_fleet_trip_operations_record record_version +ll_fleet_trip_operations_record details_json +ll_fleet_trip_operations_record reason +ll_fleet_trip_operations_record operator_id +ll_fleet_trip_operations_record request_key +ll_fleet_trip_operations_record request_hash +ll_fleet_trip_operations_record occurred_at +ll_fleet_vehicle_record id +ll_fleet_vehicle_record record_no +ll_fleet_vehicle_record vehicle_id +ll_fleet_vehicle_record vehicle_plate +ll_fleet_vehicle_record record_type +ll_fleet_vehicle_record occurred_at +ll_fleet_vehicle_record details_json +ll_fleet_vehicle_record files_json +ll_fleet_vehicle_record idempotency_key +ll_fleet_vehicle_record request_hash +ll_fleet_vehicle_record record_version +ll_fleet_vehicle_record created_by +ll_fleet_vehicle_record updated_by +ll_fleet_vehicle_record created_at +ll_fleet_vehicle_record updated_at +ll_fleet_vehicle_record_history id +ll_fleet_vehicle_record_history record_id +ll_fleet_vehicle_record_history record_version +ll_fleet_vehicle_record_history action +ll_fleet_vehicle_record_history reason +ll_fleet_vehicle_record_history operator_id +ll_fleet_vehicle_record_history occurred_at +ll_fleet_vehicle_record_history snapshot_json +ll_inbound_fba_appointment id +ll_inbound_fba_appointment tenant_id +ll_inbound_fba_appointment customer_id +ll_inbound_fba_appointment appointment_no +ll_inbound_fba_appointment appointment_at +ll_inbound_fba_appointment appointment_status +ll_inbound_fba_appointment appointment_remark +ll_inbound_fba_appointment fba_warehouse_code +ll_inbound_fba_appointment selected_count +ll_inbound_fba_appointment replacement_nos_json +ll_inbound_fba_appointment created_by +ll_inbound_fba_appointment created_at +ll_inbound_fba_appointment updated_at +ll_inbound_fba_appointment expected_loading_at +ll_inbound_fba_appointment appointment_end_at +ll_inbound_fba_appointment version +ll_inbound_fba_appointment confirmation_no +ll_inbound_fba_appointment planned_date +ll_inbound_fba_appointment planned_at +ll_inbound_fba_appointment planned_end_at +ll_inbound_fba_appointment appointment_start_utc +ll_inbound_fba_appointment appointment_end_utc +ll_inbound_fba_appointment timezone +ll_inbound_fba_appointment origin_warehouse_id +ll_inbound_fba_appointment replacement_keys_json +ll_inbound_fba_appointment repartition_reason +ll_inbound_fba_appointment_item id +ll_inbound_fba_appointment_item appointment_id +ll_inbound_fba_appointment_item source_key +ll_inbound_fba_appointment_item source_version +ll_inbound_fba_appointment_item customer_id +ll_inbound_fba_appointment_item order_id +ll_inbound_fba_appointment_item inbound_code +ll_inbound_fba_appointment_item fba_warehouse_code +ll_inbound_fba_appointment_item package_count +ll_inbound_fba_appointment_item active +ll_inbound_fba_appointment_item active_source_key +ll_inbound_fba_appointment_item active_order_id +ll_inbound_fba_appointment_item created_at +ll_inbound_fba_appointment_item cargo_verification_version +ll_inbound_fba_appointment_item shipment_references_json +ll_inbound_order id +ll_inbound_order tenant_id +ll_inbound_order customer_id +ll_inbound_order source_region +ll_inbound_order source_inbound_id +ll_inbound_order root_inbound_id +ll_inbound_order source_order_id +ll_inbound_order source_order_no +ll_inbound_order root_order_id +ll_inbound_order inbound_code +ll_inbound_order destination_warehouse_id +ll_inbound_order destination_warehouse_code +ll_inbound_order destination_warehouse_name +ll_inbound_order destination_type +ll_inbound_order package_count +ll_inbound_order product_quantity +ll_inbound_order pallet_count +ll_inbound_order gross_weight_kg +ll_inbound_order net_weight_kg +ll_inbound_order volume_cbm +ll_inbound_order source_event_id +ll_inbound_order source_loading_plan_id +ll_inbound_order source_loading_plan_version +ll_inbound_order handover_version +ll_inbound_order payload_hash +ll_inbound_order status +ll_inbound_order exception_flag +ll_inbound_order created_at +ll_inbound_order updated_at +ll_inbound_order lx_code +ll_inbound_order_item id +ll_inbound_order_item tenant_id +ll_inbound_order_item customer_id +ll_inbound_order_item order_id +ll_inbound_order_item source_inbound_detail_id +ll_inbound_order_item package_count +ll_inbound_order_item product_quantity +ll_inbound_order_item pallet_count +ll_inbound_order_item gross_weight_kg +ll_inbound_order_item net_weight_kg +ll_inbound_order_item dimensional_weight_kg +ll_inbound_order_item volume_cbm +ll_inbound_order_item destination_warehouse_id +ll_inbound_order_item destination_warehouse_code +ll_inbound_order_item destination_warehouse_name +ll_inbound_order_item fba_tracking_no +ll_inbound_order_item shipment_no +ll_inbound_order_item shipping_mark +ll_inbound_order_item sku_code +ll_inbound_order_item product_name_zh +ll_inbound_order_item product_name_en +ll_inbound_order_item created_at +ll_inbound_pallet_merge id +ll_inbound_pallet_merge tenant_id +ll_inbound_pallet_merge customer_id +ll_inbound_pallet_merge task_id +ll_inbound_pallet_merge merge_group_no +ll_inbound_pallet_merge source_pallet_id +ll_inbound_pallet_merge target_pallet_id +ll_inbound_pallet_merge source_allocation_id +ll_inbound_pallet_merge target_allocation_id +ll_inbound_pallet_merge source_line_id +ll_inbound_pallet_merge target_line_id +ll_inbound_pallet_merge source_order_id +ll_inbound_pallet_merge target_order_id +ll_inbound_pallet_merge moved_package_count +ll_inbound_pallet_merge moved_volume_cbm +ll_inbound_pallet_merge source_pallet_scrapped +ll_inbound_pallet_merge status +ll_inbound_pallet_merge created_at +ll_inbound_pda_operation id +ll_inbound_pda_operation tenant_id +ll_inbound_pda_operation customer_id +ll_inbound_pda_operation task_id +ll_inbound_pda_operation allocation_id +ll_inbound_pda_operation action +ll_inbound_pda_operation idempotency_key +ll_inbound_pda_operation request_hash +ll_inbound_pda_operation request_json +ll_inbound_pda_operation result_json +ll_inbound_pda_operation created_at +ll_inbound_pda_task id +ll_inbound_pda_task tenant_id +ll_inbound_pda_task customer_id +ll_inbound_pda_task plan_id +ll_inbound_pda_task task_no +ll_inbound_pda_task status +ll_inbound_pda_task operator_id +ll_inbound_pda_task operator_name +ll_inbound_pda_task pushed_at +ll_inbound_pda_task started_at +ll_inbound_pda_task completed_at +ll_inbound_pda_task unload_photo_file_ids +ll_inbound_pda_task unload_photo_uploaded_at +ll_inbound_pda_task estimated_empty_return_at +ll_inbound_pda_task version +ll_inbound_pda_task created_at +ll_inbound_pda_task updated_at +ll_inbound_plan id +ll_inbound_plan tenant_id +ll_inbound_plan plan_no +ll_inbound_plan customer_id +ll_inbound_plan source_order_id +ll_inbound_plan container_no +ll_inbound_plan overseas_warehouse_id +ll_inbound_plan planned_arrival_date +ll_inbound_plan pallet_type +ll_inbound_plan assigned_flag +ll_inbound_plan merge_required +ll_inbound_plan urgent_flag +ll_inbound_plan creation_mode +ll_inbound_plan plan_remark +ll_inbound_plan status +ll_inbound_plan line_count +ll_inbound_plan pallet_count +ll_inbound_plan total_volume_cbm +ll_inbound_plan created_at +ll_inbound_plan updated_at +ll_inbound_plan version +ll_inbound_plan_carton id +ll_inbound_plan_carton customer_id +ll_inbound_plan_carton order_id +ll_inbound_plan_carton source_inbound_detail_id +ll_inbound_plan_carton sku_code +ll_inbound_plan_carton carton_mark +ll_inbound_plan_carton destination_warehouse_id +ll_inbound_plan_carton destination_warehouse_code +ll_inbound_plan_carton destination_warehouse_name +ll_inbound_plan_carton plan_id +ll_inbound_plan_carton line_id +ll_inbound_plan_carton plan_pallet_id +ll_inbound_plan_carton warehouse_pallet_id +ll_inbound_plan_carton warehouse_location_id +ll_inbound_plan_carton pda_task_id +ll_inbound_plan_carton status +ll_inbound_plan_carton version +ll_inbound_plan_carton bound_at +ll_inbound_plan_carton received_at +ll_inbound_plan_carton updated_at +ll_inbound_plan_line id +ll_inbound_plan_line tenant_id +ll_inbound_plan_line customer_id +ll_inbound_plan_line plan_id +ll_inbound_plan_line order_id +ll_inbound_plan_line inbound_code +ll_inbound_plan_line destination_type +ll_inbound_plan_line fba_warehouse_code +ll_inbound_plan_line destination_warehouse_id +ll_inbound_plan_line destination_warehouse_code +ll_inbound_plan_line destination_warehouse_name +ll_inbound_plan_line volume_cbm +ll_inbound_plan_line planned_pallet_count +ll_inbound_plan_line required_package_count +ll_inbound_plan_line delivery_address +ll_inbound_plan_line postal_code +ll_inbound_plan_line contact_name +ll_inbound_plan_line contact_phone +ll_inbound_plan_pallet id +ll_inbound_plan_pallet tenant_id +ll_inbound_plan_pallet customer_id +ll_inbound_plan_pallet plan_id +ll_inbound_plan_pallet pallet_no +ll_inbound_plan_pallet source_pallet_no +ll_inbound_plan_pallet pallet_type +ll_inbound_plan_pallet destination_warehouse_id +ll_inbound_plan_pallet destination_warehouse_code +ll_inbound_plan_pallet destination_warehouse_name +ll_inbound_plan_pallet capacity_cbm +ll_inbound_plan_pallet allocated_volume_cbm +ll_inbound_plan_pallet sequence_no +ll_inbound_plan_pallet warehouse_pallet_id +ll_inbound_plan_pallet warehouse_location_id +ll_inbound_plan_pallet warehouse_resource_lock_id +ll_inbound_plan_pallet assignment_status +ll_inbound_plan_pallet locked_at +ll_inbound_plan_pallet actual_volume_cbm +ll_inbound_plan_pallet actual_package_count +ll_inbound_plan_pallet version +ll_inbound_plan_pallet destination_type +ll_inbound_plan_pallet delivery_address +ll_inbound_plan_pallet postal_code +ll_inbound_plan_pallet contact_name +ll_inbound_plan_pallet contact_phone +ll_inbound_plan_pallet_allocation id +ll_inbound_plan_pallet_allocation tenant_id +ll_inbound_plan_pallet_allocation customer_id +ll_inbound_plan_pallet_allocation plan_id +ll_inbound_plan_pallet_allocation line_id +ll_inbound_plan_pallet_allocation pallet_id +ll_inbound_plan_pallet_allocation allocated_volume_cbm +ll_inbound_plan_pallet_allocation planned_package_count +ll_inbound_plan_pallet_allocation counted_package_count +ll_inbound_plan_pallet_allocation picked_package_count +ll_inbound_plan_pallet_allocation actual_volume_cbm +ll_inbound_plan_pallet_allocation status +ll_inbound_plan_pallet_allocation version +ll_inbound_stock_receipt id +ll_inbound_stock_receipt tenant_id +ll_inbound_stock_receipt customer_id +ll_inbound_stock_receipt plan_id +ll_inbound_stock_receipt task_id +ll_inbound_stock_receipt allocation_id +ll_inbound_stock_receipt line_id +ll_inbound_stock_receipt order_id +ll_inbound_stock_receipt inbound_code +ll_inbound_stock_receipt sku_code +ll_inbound_stock_receipt product_name_zh +ll_inbound_stock_receipt product_name_en +ll_inbound_stock_receipt sku_name_zh +ll_inbound_stock_receipt source_detail_ids +ll_inbound_stock_receipt warehouse_pallet_id +ll_inbound_stock_receipt pallet_no +ll_inbound_stock_receipt source_pallet_no +ll_inbound_stock_receipt warehouse_location_id +ll_inbound_stock_receipt warehouse_location_code +ll_inbound_stock_receipt batch_no +ll_inbound_stock_receipt received_packages +ll_inbound_stock_receipt received_quantity +ll_inbound_stock_receipt inventory_balance_id +ll_inbound_stock_receipt inventory_position_id +ll_inbound_stock_receipt inventory_operation_key +ll_inbound_stock_receipt received_at +ll_inbound_stock_receipt created_at +ll_inventory_balance id +ll_inventory_balance tenant_id +ll_inventory_balance customer_id +ll_inventory_balance customer_name +ll_inventory_balance overseas_warehouse_id +ll_inventory_balance overseas_warehouse_code +ll_inventory_balance overseas_warehouse_name +ll_inventory_balance destination_warehouse_id +ll_inventory_balance destination_warehouse_code +ll_inventory_balance destination_warehouse_name +ll_inventory_balance inbound_order_id +ll_inventory_balance inbound_order_no +ll_inventory_balance inbound_code +ll_inventory_balance sku_code +ll_inventory_balance sku_name_zh +ll_inventory_balance product_name_zh +ll_inventory_balance product_name_en +ll_inventory_balance available_packages +ll_inventory_balance hold_packages +ll_inventory_balance allocated_packages +ll_inventory_balance picked_packages +ll_inventory_balance in_transit_packages +ll_inventory_balance receiving_packages +ll_inventory_balance procurement_packages +ll_inventory_balance available_quantity +ll_inventory_balance hold_quantity +ll_inventory_balance allocated_quantity +ll_inventory_balance picked_quantity +ll_inventory_balance in_transit_quantity +ll_inventory_balance receiving_quantity +ll_inventory_balance procurement_quantity +ll_inventory_balance version +ll_inventory_balance created_at +ll_inventory_balance updated_at +ll_inventory_ledger id +ll_inventory_ledger tenant_id +ll_inventory_ledger customer_id +ll_inventory_ledger customer_name +ll_inventory_ledger balance_id +ll_inventory_ledger position_id +ll_inventory_ledger overseas_warehouse_id +ll_inventory_ledger destination_warehouse_code +ll_inventory_ledger inbound_order_id +ll_inventory_ledger sku_code +ll_inventory_ledger carton_mark +ll_inventory_ledger carton_packages +ll_inventory_ledger pallet_id +ll_inventory_ledger pallet_no +ll_inventory_ledger batch_no +ll_inventory_ledger warehouse_location_code +ll_inventory_ledger business_type +ll_inventory_ledger business_order_id +ll_inventory_ledger business_order_no +ll_inventory_ledger inbound_code +ll_inventory_ledger inbound_order_no +ll_inventory_ledger outbound_order_no +ll_inventory_ledger source_event_id +ll_inventory_ledger idempotency_key +ll_inventory_ledger status_from +ll_inventory_ledger status_to +ll_inventory_ledger before_packages +ll_inventory_ledger delta_packages +ll_inventory_ledger after_packages +ll_inventory_ledger operator_id +ll_inventory_ledger remark +ll_inventory_ledger occurred_at +ll_inventory_ledger created_at +ll_inventory_operation id +ll_inventory_operation tenant_id +ll_inventory_operation customer_id +ll_inventory_operation idempotency_key +ll_inventory_operation operation_type +ll_inventory_operation request_hash +ll_inventory_operation status +ll_inventory_operation result_json +ll_inventory_operation last_error +ll_inventory_operation created_at +ll_inventory_operation updated_at +ll_inventory_position id +ll_inventory_position tenant_id +ll_inventory_position customer_id +ll_inventory_position balance_id +ll_inventory_position overseas_warehouse_id +ll_inventory_position destination_warehouse_code +ll_inventory_position sku_code +ll_inventory_position pallet_id +ll_inventory_position pallet_no +ll_inventory_position batch_no +ll_inventory_position warehouse_location_id +ll_inventory_position warehouse_location_code +ll_inventory_position available_packages +ll_inventory_position hold_packages +ll_inventory_position allocated_packages +ll_inventory_position picked_packages +ll_inventory_position receiving_packages +ll_inventory_position product_quantity +ll_inventory_position status +ll_inventory_position version +ll_inventory_position created_at +ll_inventory_position updated_at +ll_loading_event_inbox event_id +ll_loading_event_inbox customer_id +ll_loading_event_inbox event_type +ll_loading_event_inbox event_version +ll_loading_event_inbox payload_hash +ll_loading_event_inbox payload_json +ll_loading_event_inbox status +ll_loading_event_inbox received_at +ll_loading_event_inbox processed_at +ll_loading_event_inbox last_error +ll_loading_event_inbox tenant_id +ll_loading_order_status_history id +ll_loading_order_status_history customer_id +ll_loading_order_status_history order_id +ll_loading_order_status_history from_status +ll_loading_order_status_history to_status +ll_loading_order_status_history event_id +ll_loading_order_status_history remark +ll_loading_order_status_history created_at +ll_loading_order_status_history tenant_id +ll_local_looseboard_history id +ll_local_looseboard_history order_id +ll_local_looseboard_history customer_id +ll_local_looseboard_history operation_type +ll_local_looseboard_history from_status +ll_local_looseboard_history to_status +ll_local_looseboard_history operator_id +ll_local_looseboard_history idempotency_key +ll_local_looseboard_history change_detail +ll_local_looseboard_history remark +ll_local_looseboard_history created_at +ll_local_looseboard_operation id +ll_local_looseboard_operation order_id +ll_local_looseboard_operation customer_id +ll_local_looseboard_operation operation_type +ll_local_looseboard_operation idempotency_key +ll_local_looseboard_operation request_hash +ll_local_looseboard_operation result_version +ll_local_looseboard_operation created_by +ll_local_looseboard_operation created_at +ll_local_looseboard_order id +ll_local_looseboard_order lastleg_order_id +ll_local_looseboard_order customer_id +ll_local_looseboard_order order_no +ll_local_looseboard_order transport_mode +ll_local_looseboard_order pallet_required +ll_local_looseboard_order transport_type +ll_local_looseboard_order origin +ll_local_looseboard_order origin_address +ll_local_looseboard_order destination +ll_local_looseboard_order destination_address +ll_local_looseboard_order container_type +ll_local_looseboard_order package_count +ll_local_looseboard_order total_weight_kg +ll_local_looseboard_order total_volume_cbm +ll_local_looseboard_order budget_amount +ll_local_looseboard_order currency +ll_local_looseboard_order contact_name +ll_local_looseboard_order contact_info +ll_local_looseboard_order requested_date +ll_local_looseboard_order status +ll_local_looseboard_order remark +ll_local_looseboard_order idempotency_key +ll_local_looseboard_order request_hash +ll_local_looseboard_order created_by +ll_local_looseboard_order version +ll_local_looseboard_order created_at +ll_local_looseboard_order updated_at +ll_monthly_exchange_rate id +ll_monthly_exchange_rate rate_month +ll_monthly_exchange_rate base_currency +ll_monthly_exchange_rate quote_currency +ll_monthly_exchange_rate usd_to_cny_average +ll_monthly_exchange_rate status +ll_monthly_exchange_rate remark +ll_monthly_exchange_rate record_version +ll_monthly_exchange_rate created_by +ll_monthly_exchange_rate updated_by +ll_monthly_exchange_rate created_at +ll_monthly_exchange_rate updated_at +ll_monthly_exchange_rate_history id +ll_monthly_exchange_rate_history exchange_rate_id +ll_monthly_exchange_rate_history before_rate +ll_monthly_exchange_rate_history after_rate +ll_monthly_exchange_rate_history before_status +ll_monthly_exchange_rate_history after_status +ll_monthly_exchange_rate_history operator_id +ll_monthly_exchange_rate_history remark +ll_monthly_exchange_rate_history created_at +ll_order id +ll_order order_no +ll_order customer_id +ll_order order_type +ll_order lx_code +ll_order destination_type +ll_order inbound_code +ll_order source_region +ll_order source_inbound_id +ll_order root_inbound_id +ll_order source_order_id +ll_order source_order_no +ll_order root_order_id +ll_order customer_name +ll_order source_warehouse_id +ll_order source_warehouse_name +ll_order overseas_warehouse_id +ll_order overseas_warehouse_code +ll_order overseas_warehouse_name +ll_order receiving_warehouse_version +ll_order transfer_warehouse_id +ll_order transfer_warehouse_code +ll_order transfer_warehouse_name +ll_order fba_warehouse_code +ll_order container_no +ll_order container_status +ll_order abnormal +ll_order detained +ll_order hold_chargeable +ll_order hold_reason +ll_order manual_node_override +ll_order version +ll_order pallet_count +ll_order package_count +ll_order received_package_count +ll_order picked_package_count +ll_order shipped_package_count +ll_order volume_cbm +ll_order product_quantity +ll_order volume_weight_kg +ll_order estimated_arrival_at +ll_order actual_arrival_at +ll_order estimated_warehouse_at +ll_order arrival_time_version +ll_order inbound_order_no +ll_order outbound_order_no +ll_order delivery_order_no +ll_order relabel_order_no +ll_order relabel_status +ll_order pallet_merge_flag +ll_order pallet_merge_batch_id +ll_order current_status +ll_order private_remark +ll_order customer_remark +ll_order warehouse_remark +ll_order source_event_id +ll_order source_loading_plan_id +ll_order source_container_booking_id +ll_order source_loading_plan_version +ll_order source_handover_version +ll_order source_payload_hash +ll_order created_at +ll_order updated_at +ll_order tenant_id +ll_order sub_order_status +ll_order_command_record id +ll_order_command_record customer_id +ll_order_command_record order_id +ll_order_command_record action +ll_order_command_record operator_id +ll_order_command_record idempotency_key +ll_order_command_record request_hash +ll_order_command_record result_ref +ll_order_command_record result_json +ll_order_command_record result_version +ll_order_command_record created_at +ll_order_detail id +ll_order_detail customer_id +ll_order_detail order_id +ll_order_detail source_inbound_detail_id +ll_order_detail sku_code +ll_order_detail product_name_zh +ll_order_detail product_name_en +ll_order_detail package_count +ll_order_detail product_quantity +ll_order_detail pallet_count +ll_order_detail gross_weight_kg +ll_order_detail net_weight_kg +ll_order_detail dimensional_weight_kg +ll_order_detail volume_cbm +ll_order_detail destination_warehouse_id +ll_order_detail destination_warehouse_code +ll_order_detail destination_warehouse_name +ll_order_detail fba_tracking_no +ll_order_detail shipment_no +ll_order_detail shipping_mark +ll_order_detail tenant_id +ll_order_fee id +ll_order_fee customer_id +ll_order_fee order_id +ll_order_fee fee_item +ll_order_fee currency +ll_order_fee amount +ll_order_fee status +ll_order_fee supplier_description +ll_order_fee created_by +ll_order_fee created_at +ll_order_fee updated_at +ll_order_hold_history id +ll_order_hold_history customer_id +ll_order_hold_history order_id +ll_order_hold_history held +ll_order_hold_history chargeable +ll_order_hold_history reason +ll_order_hold_history order_version +ll_order_hold_history operator_id +ll_order_hold_history operator_name +ll_order_hold_history idempotency_key +ll_order_hold_history created_at +ll_order_manual_node id +ll_order_manual_node customer_id +ll_order_manual_node order_id +ll_order_manual_node node_code +ll_order_manual_node node_at +ll_order_manual_node source +ll_order_manual_node order_version +ll_order_manual_node updated_by +ll_order_manual_node updated_at +ll_order_manual_node_history id +ll_order_manual_node_history customer_id +ll_order_manual_node_history order_id +ll_order_manual_node_history nodes_json +ll_order_manual_node_history order_version +ll_order_manual_node_history operator_id +ll_order_manual_node_history operator_name +ll_order_manual_node_history idempotency_key +ll_order_manual_node_history created_at +ll_order_no_daily_sequence business_date +ll_order_no_daily_sequence last_sequence +ll_order_operation_log id +ll_order_operation_log customer_id +ll_order_operation_log order_id +ll_order_operation_log operation_node +ll_order_operation_log operator_name +ll_order_operation_log result +ll_order_operation_log created_at +ll_order_source_pallet id +ll_order_source_pallet customer_id +ll_order_source_pallet order_id +ll_order_source_pallet source_pallet_no +ll_order_source_pallet sequence_no +ll_order_source_pallet source_event_id +ll_order_source_pallet created_at +ll_order_status_history id +ll_order_status_history customer_id +ll_order_status_history order_id +ll_order_status_history from_status +ll_order_status_history to_status +ll_order_status_history reason +ll_order_status_history created_at +ll_order_status_history tenant_id +ll_outbound_order id +ll_outbound_order customer_id +ll_outbound_order outbound_no +ll_outbound_order dispatch_order_id +ll_outbound_order dispatch_no +ll_outbound_order overseas_warehouse_id +ll_outbound_order status +ll_outbound_order outbound_type +ll_outbound_order lx_codes +ll_outbound_order recipient_info +ll_outbound_order request_hash +ll_outbound_order package_count +ll_outbound_order pallet_count +ll_outbound_order gross_weight_kg +ll_outbound_order volume_weight_kg +ll_outbound_order volume_cbm +ll_outbound_order idempotency_key +ll_outbound_order created_by +ll_outbound_order created_at +ll_outbound_order updated_at +ll_outbound_order tenant_id +ll_outbound_order fee_coverage_json +ll_outbound_pallet_item id +ll_outbound_pallet_item customer_id +ll_outbound_pallet_item outbound_order_id +ll_outbound_pallet_item warehouse_pallet_id +ll_outbound_pallet_item pallet_no +ll_outbound_pallet_item destination_warehouse_id +ll_outbound_pallet_item destination_warehouse_code +ll_outbound_pallet_item destination_warehouse_name +ll_outbound_pallet_item package_count +ll_outbound_pallet_item gross_weight_kg +ll_outbound_pallet_item volume_weight_kg +ll_outbound_pallet_item volume_cbm +ll_outbound_pallet_item picked_package_count +ll_outbound_pallet_item status +ll_outbound_pallet_item order_refs +ll_outbound_pallet_item inbound_code +ll_outbound_pallet_item created_at +ll_outbound_pallet_item tenant_id +ll_outbound_pallet_item sku_code +ll_outbound_pallet_item return_sku_quantity +ll_outbound_pallet_item carton_marks +ll_outbound_pda_pick id +ll_outbound_pda_pick customer_id +ll_outbound_pda_pick task_id +ll_outbound_pda_pick outbound_pallet_item_id +ll_outbound_pda_pick inventory_position_id +ll_outbound_pda_pick packages +ll_outbound_pda_pick idempotency_key +ll_outbound_pda_pick picked_at +ll_outbound_pda_task id +ll_outbound_pda_task customer_id +ll_outbound_pda_task outbound_order_id +ll_outbound_pda_task task_no +ll_outbound_pda_task status +ll_outbound_pda_task completion_idempotency_key +ll_outbound_pda_task pushed_at +ll_outbound_pda_task completed_at +ll_outbound_pda_task created_at +ll_outbound_pda_task updated_at +ll_overseas_procurement_fee id +ll_overseas_procurement_fee order_id +ll_overseas_procurement_fee customer_id +ll_overseas_procurement_fee fee_no +ll_overseas_procurement_fee fee_item_code +ll_overseas_procurement_fee fee_item_name +ll_overseas_procurement_fee amount +ll_overseas_procurement_fee currency +ll_overseas_procurement_fee voucher_file_id +ll_overseas_procurement_fee voucher_file_name +ll_overseas_procurement_fee remark +ll_overseas_procurement_fee wallet_status +ll_overseas_procurement_fee wallet_error_code +ll_overseas_procurement_fee wallet_error_message +ll_overseas_procurement_fee wallet_command_event_id +ll_overseas_procurement_fee created_by +ll_overseas_procurement_fee created_at +ll_overseas_procurement_fee updated_at +ll_overseas_procurement_file id +ll_overseas_procurement_file order_id +ll_overseas_procurement_file customer_id +ll_overseas_procurement_file category +ll_overseas_procurement_file file_id +ll_overseas_procurement_file file_name +ll_overseas_procurement_file content_type +ll_overseas_procurement_file size_bytes +ll_overseas_procurement_file created_by +ll_overseas_procurement_file created_at +ll_overseas_procurement_order id +ll_overseas_procurement_order customer_id +ll_overseas_procurement_order customer_name +ll_overseas_procurement_order order_no +ll_overseas_procurement_order purchase_category +ll_overseas_procurement_order product_name +ll_overseas_procurement_order product_specification +ll_overseas_procurement_order product_url +ll_overseas_procurement_order purchase_quantity +ll_overseas_procurement_order budget_unit_price +ll_overseas_procurement_order currency +ll_overseas_procurement_order receiving_address +ll_overseas_procurement_order contact_name +ll_overseas_procurement_order contact_phone +ll_overseas_procurement_order purchase_warehouse_id +ll_overseas_procurement_order purchase_warehouse_name +ll_overseas_procurement_order inbound_quantity +ll_overseas_procurement_order outbound_details +ll_overseas_procurement_order outbound_quantity +ll_overseas_procurement_order domestic_carrier +ll_overseas_procurement_order domestic_tracking_no +ll_overseas_procurement_order overseas_carrier +ll_overseas_procurement_order overseas_tracking_no +ll_overseas_procurement_order estimated_arrival_at +ll_overseas_procurement_order status +ll_overseas_procurement_order remark +ll_overseas_procurement_order idempotency_key +ll_overseas_procurement_order request_hash +ll_overseas_procurement_order created_by +ll_overseas_procurement_order reviewed_by +ll_overseas_procurement_order review_remark +ll_overseas_procurement_order review_idempotency_key +ll_overseas_procurement_order review_request_hash +ll_overseas_procurement_order reviewed_at +ll_overseas_procurement_order version +ll_overseas_procurement_order created_at +ll_overseas_procurement_order updated_at +ll_overseas_procurement_order tenant_id +ll_overseas_procurement_outbound id +ll_overseas_procurement_outbound order_id +ll_overseas_procurement_outbound customer_id +ll_overseas_procurement_outbound quantity +ll_overseas_procurement_outbound outbound_no +ll_overseas_procurement_outbound carrier +ll_overseas_procurement_outbound tracking_no +ll_overseas_procurement_outbound outbound_at +ll_overseas_procurement_outbound remark +ll_overseas_procurement_outbound created_by +ll_overseas_procurement_outbound created_at +ll_overseas_procurement_outbound tenant_id +ll_overseas_procurement_status_history id +ll_overseas_procurement_status_history order_id +ll_overseas_procurement_status_history customer_id +ll_overseas_procurement_status_history from_status +ll_overseas_procurement_status_history to_status +ll_overseas_procurement_status_history operator_id +ll_overseas_procurement_status_history idempotency_key +ll_overseas_procurement_status_history remark +ll_overseas_procurement_status_history created_at +ll_overseas_procurement_status_history tenant_id +ll_overseas_quote id +ll_overseas_quote name +ll_overseas_quote warehouse_id +ll_overseas_quote warehouse_code +ll_overseas_quote warehouse_name +ll_overseas_quote country_code +ll_overseas_quote provider_name +ll_overseas_quote currency +ll_overseas_quote effective_start +ll_overseas_quote effective_end +ll_overseas_quote version_code +ll_overseas_quote status +ll_overseas_quote remark +ll_overseas_quote record_version +ll_overseas_quote created_by +ll_overseas_quote updated_by +ll_overseas_quote created_at +ll_overseas_quote updated_at +ll_overseas_quote_fee id +ll_overseas_quote_fee quote_id +ll_overseas_quote_fee sequence_no +ll_overseas_quote_fee item +ll_overseas_quote_fee billing_unit +ll_overseas_quote_fee unit_price +ll_overseas_quote_fee minimum_charge +ll_overseas_quote_fee remark +ll_overseas_quote_transit_price id +ll_overseas_quote_transit_price quote_id +ll_overseas_quote_transit_price sequence_no +ll_overseas_quote_transit_price fba_warehouse_code +ll_overseas_quote_transit_price delivery_method +ll_overseas_quote_transit_price estimated_miles +ll_overseas_quote_transit_price price +ll_overseas_quote_transit_price minimum_charge +ll_overseas_quote_transit_price remark +ll_pda_inventory_move id +ll_pda_inventory_move move_no +ll_pda_inventory_move overseas_warehouse_id +ll_pda_inventory_move pallet_id +ll_pda_inventory_move pallet_code +ll_pda_inventory_move source_location_id +ll_pda_inventory_move source_location_code +ll_pda_inventory_move target_location_id +ll_pda_inventory_move target_location_code +ll_pda_inventory_move customer_count +ll_pda_inventory_move position_count +ll_pda_inventory_move package_count +ll_pda_inventory_move pallet_version_before +ll_pda_inventory_move pallet_version_after +ll_pda_inventory_move operator_key +ll_pda_inventory_move reason +ll_pda_inventory_move idempotency_key +ll_pda_inventory_move request_hash +ll_pda_inventory_move status +ll_pda_inventory_move result_json +ll_pda_inventory_move created_at +ll_pda_inventory_move completed_at +ll_pda_operation_history id +ll_pda_operation_history overseas_warehouse_id +ll_pda_operation_history customer_id +ll_pda_operation_history operator_key +ll_pda_operation_history operation_type +ll_pda_operation_history business_key +ll_pda_operation_history status +ll_pda_operation_history idempotency_key +ll_pda_operation_history request_hash +ll_pda_operation_history summary +ll_pda_operation_history occurred_at +ll_pda_operation_history created_at +ll_pda_pallet_operation id +ll_pda_pallet_operation overseas_warehouse_id +ll_pda_pallet_operation operator_key +ll_pda_pallet_operation idempotency_key +ll_pda_pallet_operation operation_type +ll_pda_pallet_operation request_hash +ll_pda_pallet_operation status +ll_pda_pallet_operation result_json +ll_pda_pallet_operation remote_transitions_json +ll_pda_pallet_operation last_error +ll_pda_pallet_operation created_at +ll_pda_pallet_operation updated_at +ll_pda_stocktake_action id +ll_pda_stocktake_action overseas_warehouse_id +ll_pda_stocktake_action operator_key +ll_pda_stocktake_action idempotency_key +ll_pda_stocktake_action action_type +ll_pda_stocktake_action task_id +ll_pda_stocktake_action request_hash +ll_pda_stocktake_action status +ll_pda_stocktake_action result_json +ll_pda_stocktake_action created_at +ll_pda_stocktake_action updated_at +ll_pda_stocktake_item id +ll_pda_stocktake_item task_id +ll_pda_stocktake_item customer_id +ll_pda_stocktake_item balance_id +ll_pda_stocktake_item position_id +ll_pda_stocktake_item sku_code +ll_pda_stocktake_item product_name +ll_pda_stocktake_item inbound_code +ll_pda_stocktake_item pallet_id +ll_pda_stocktake_item pallet_code +ll_pda_stocktake_item location_id +ll_pda_stocktake_item location_code +ll_pda_stocktake_item book_packages +ll_pda_stocktake_item actual_packages +ll_pda_stocktake_item difference_packages +ll_pda_stocktake_item status +ll_pda_stocktake_item position_version +ll_pda_stocktake_item counted_by +ll_pda_stocktake_item version +ll_pda_stocktake_item counted_at +ll_pda_stocktake_item created_at +ll_pda_stocktake_item updated_at +ll_pda_stocktake_task id +ll_pda_stocktake_task task_no +ll_pda_stocktake_task overseas_warehouse_id +ll_pda_stocktake_task scope_type +ll_pda_stocktake_task scope_resource_id +ll_pda_stocktake_task scope_code +ll_pda_stocktake_task status +ll_pda_stocktake_task position_count +ll_pda_stocktake_task counted_count +ll_pda_stocktake_task difference_count +ll_pda_stocktake_task created_by +ll_pda_stocktake_task submitted_by +ll_pda_stocktake_task submit_reason +ll_pda_stocktake_task idempotency_key +ll_pda_stocktake_task request_hash +ll_pda_stocktake_task version +ll_pda_stocktake_task created_at +ll_pda_stocktake_task updated_at +ll_pda_stocktake_task submitted_at +ll_procurement_daily_sequence customer_id +ll_procurement_daily_sequence business_date +ll_procurement_daily_sequence sequence_value +ll_receiving_container_operation id +ll_receiving_container_operation session_id +ll_receiving_container_operation customer_id +ll_receiving_container_operation operator_key +ll_receiving_container_operation action +ll_receiving_container_operation idempotency_key +ll_receiving_container_operation request_hash +ll_receiving_container_operation request_json +ll_receiving_container_operation result_json +ll_receiving_container_operation created_at +ll_receiving_container_session id +ll_receiving_container_session customer_id +ll_receiving_container_session container_no +ll_receiving_container_session overseas_warehouse_id +ll_receiving_container_session overseas_warehouse_name +ll_receiving_container_session operator_key +ll_receiving_container_session operator_name +ll_receiving_container_session active_operator_key +ll_receiving_container_session active_container_key +ll_receiving_container_session status +ll_receiving_container_session sign_photos +ll_receiving_container_session prepared_at +ll_receiving_container_session signed_at +ll_receiving_container_session receiving_at +ll_receiving_container_session completed_at +ll_receiving_container_session created_at +ll_receiving_container_session updated_at +ll_receiving_container_session version +ll_receiving_fba_pallet_count id +ll_receiving_fba_pallet_count session_id +ll_receiving_fba_pallet_count group_key +ll_receiving_fba_pallet_count pallet_code +ll_receiving_fba_pallet_count actual_packages +ll_receiving_fba_pallet_count created_at +ll_receiving_fba_pallet_count updated_at +ll_receiving_sku_receipt id +ll_receiving_sku_receipt customer_id +ll_receiving_sku_receipt session_id +ll_receiving_sku_receipt plan_id +ll_receiving_sku_receipt line_id +ll_receiving_sku_receipt pallet_id +ll_receiving_sku_receipt order_id +ll_receiving_sku_receipt inbound_code +ll_receiving_sku_receipt sku_code +ll_receiving_sku_receipt product_name +ll_receiving_sku_receipt received_packages +ll_receiving_sku_receipt created_at +ll_receiving_sku_receipt updated_at +ll_receiving_sku_receipt version +ll_receiving_warehouse_operation id +ll_receiving_warehouse_operation source_container_booking_id +ll_receiving_warehouse_operation idempotency_key +ll_receiving_warehouse_operation request_hash +ll_receiving_warehouse_operation result_version +ll_receiving_warehouse_operation operator_name +ll_receiving_warehouse_operation remark +ll_receiving_warehouse_operation created_at +ll_relabel_application id +ll_relabel_application customer_id +ll_relabel_application relabel_no +ll_relabel_application lastleg_order_id +ll_relabel_application customer_order_no +ll_relabel_application overseas_warehouse_id +ll_relabel_application label_deadline +ll_relabel_application contact_name +ll_relabel_application contact_phone +ll_relabel_application delivery_method +ll_relabel_application print_shipping_label +ll_relabel_application shipping_label_file_id +ll_relabel_application shipping_label_file_name +ll_relabel_application shipping_label_file_url +ll_relabel_application fulfillment_status +ll_relabel_application outbound_order_no +ll_relabel_application customer_confirmation_key +ll_relabel_application customer_confirmed_at +ll_relabel_application remark +ll_relabel_application total_file_id +ll_relabel_application total_file_name +ll_relabel_application total_file_url +ll_relabel_application carton_total_file_id +ll_relabel_application carton_total_file_name +ll_relabel_application carton_total_file_url +ll_relabel_application sku_total_file_id +ll_relabel_application sku_total_file_name +ll_relabel_application sku_total_file_url +ll_relabel_application status +ll_relabel_application item_count +ll_relabel_application total_package_count +ll_relabel_application notified_package_count +ll_relabel_application charge_amount +ll_relabel_application charge_currency +ll_relabel_application wallet_status +ll_relabel_application wallet_error_code +ll_relabel_application wallet_error_message +ll_relabel_application wallet_command_event_id +ll_relabel_application idempotency_key +ll_relabel_application request_hash +ll_relabel_application created_by +ll_relabel_application created_at +ll_relabel_application updated_at +ll_relabel_application tenant_id +ll_relabel_item id +ll_relabel_item application_id +ll_relabel_item customer_id +ll_relabel_item sku_code +ll_relabel_item product_image_file_id +ll_relabel_item product_image_file_name +ll_relabel_item product_image_url +ll_relabel_item product_name_zh +ll_relabel_item total_package_count +ll_relabel_item notified_package_count +ll_relabel_item shipment_no +ll_relabel_item tracking_code +ll_relabel_item new_sku_code +ll_relabel_item label_requirement +ll_relabel_item remark +ll_relabel_item relabel_file_id +ll_relabel_item relabel_file_name +ll_relabel_item relabel_file_url +ll_relabel_item created_at +ll_relabel_item tenant_id +ll_relabel_item carton_mark +ll_relabel_item return_carton_id +ll_relabel_item source_carton_mark +ll_relabel_item new_carton_mark +ll_relabel_item fba_carton_mark +ll_relabel_item destination_warehouse_id +ll_relabel_item destination_fba_warehouse_code +ll_relabel_item operation_type +ll_relabel_item sku_relabel_items_json +ll_relabel_item source_inventory_balance_id +ll_relabel_item source_inventory_position_id +ll_relabel_item source_inbound_order_id +ll_relabel_item source_inbound_order_no +ll_relabel_item source_inbound_code +ll_relabel_item source_warehouse_id +ll_relabel_item source_warehouse_code +ll_relabel_item source_warehouse_name +ll_relabel_pda_history id +ll_relabel_pda_history task_id +ll_relabel_pda_history application_id +ll_relabel_pda_history customer_id +ll_relabel_pda_history action +ll_relabel_pda_history from_status +ll_relabel_pda_history to_status +ll_relabel_pda_history operator_key +ll_relabel_pda_history operator_name +ll_relabel_pda_history detail_json +ll_relabel_pda_history created_at +ll_relabel_pda_operation id +ll_relabel_pda_operation task_id +ll_relabel_pda_operation customer_id +ll_relabel_pda_operation operator_key +ll_relabel_pda_operation action +ll_relabel_pda_operation idempotency_key +ll_relabel_pda_operation request_hash +ll_relabel_pda_operation result_json +ll_relabel_pda_operation created_at +ll_relabel_pda_task id +ll_relabel_pda_task application_id +ll_relabel_pda_task customer_id +ll_relabel_pda_task relabel_no +ll_relabel_pda_task overseas_warehouse_id +ll_relabel_pda_task status +ll_relabel_pda_task operator_key +ll_relabel_pda_task operator_name +ll_relabel_pda_task reviewer_key +ll_relabel_pda_task reviewer_name +ll_relabel_pda_task expected_packages +ll_relabel_pda_task relabelled_packages +ll_relabel_pda_task skipped_packages +ll_relabel_pda_task exception_packages +ll_relabel_pda_task completion_photo_file_ids +ll_relabel_pda_task completion_remark +ll_relabel_pda_task review_decision +ll_relabel_pda_task review_remark +ll_relabel_pda_task claimed_at +ll_relabel_pda_task submitted_at +ll_relabel_pda_task reviewed_at +ll_relabel_pda_task version +ll_relabel_pda_task created_at +ll_relabel_pda_task updated_at +ll_relabel_pda_unit_fact id +ll_relabel_pda_unit_fact task_id +ll_relabel_pda_unit_fact application_id +ll_relabel_pda_unit_fact item_id +ll_relabel_pda_unit_fact customer_id +ll_relabel_pda_unit_fact old_label +ll_relabel_pda_unit_fact new_label +ll_relabel_pda_unit_fact result +ll_relabel_pda_unit_fact exception_code +ll_relabel_pda_unit_fact reason +ll_relabel_pda_unit_fact quantity +ll_relabel_pda_unit_fact operator_key +ll_relabel_pda_unit_fact operator_name +ll_relabel_pda_unit_fact occurred_at +ll_relabel_status_history id +ll_relabel_status_history customer_id +ll_relabel_status_history application_id +ll_relabel_status_history from_status +ll_relabel_status_history to_status +ll_relabel_status_history operator_id +ll_relabel_status_history remark +ll_relabel_status_history created_at +ll_relabel_status_history tenant_id +ll_return_carton_count_session operator_id +ll_return_carton_count_session warehouse_id +ll_return_carton_count_session receipt_id +ll_return_carton_count_session started_at +ll_return_carton_count_session created_at +ll_return_carton_count_session updated_at +ll_return_carton_stock_receipt id +ll_return_carton_stock_receipt customer_id +ll_return_carton_stock_receipt receipt_no +ll_return_carton_stock_receipt exception_order_id +ll_return_carton_stock_receipt exception_return_item_id +ll_return_carton_stock_receipt delivery_order_id +ll_return_carton_stock_receipt source_pick_id +ll_return_carton_stock_receipt source_outbound_no +ll_return_carton_stock_receipt source_dispatch_item_id +ll_return_carton_stock_receipt source_outbound_pallet_item_id +ll_return_carton_stock_receipt return_carton_id +ll_return_carton_stock_receipt carton_mark +ll_return_carton_stock_receipt overseas_warehouse_id +ll_return_carton_stock_receipt target_pallet_id +ll_return_carton_stock_receipt target_pallet_no +ll_return_carton_stock_receipt target_location_id +ll_return_carton_stock_receipt target_location_code +ll_return_carton_stock_receipt sku_code +ll_return_carton_stock_receipt product_name_zh +ll_return_carton_stock_receipt quantity +ll_return_carton_stock_receipt packages +ll_return_carton_stock_receipt evidence_file_ids_json +ll_return_carton_stock_receipt received_by +ll_return_carton_stock_receipt received_at +ll_return_inventory_balance id +ll_return_inventory_balance customer_id +ll_return_inventory_balance overseas_warehouse_id +ll_return_inventory_balance sku_code +ll_return_inventory_balance product_name_zh +ll_return_inventory_balance total_quantity +ll_return_inventory_balance available_quantity +ll_return_inventory_balance carton_quantity +ll_return_inventory_balance created_at +ll_return_inventory_balance updated_at +ll_return_inventory_carton id +ll_return_inventory_carton customer_id +ll_return_inventory_carton overseas_warehouse_id +ll_return_inventory_carton source_receipt_id +ll_return_inventory_carton source_batch_no +ll_return_inventory_carton source_return_no +ll_return_inventory_carton source_unit_type +ll_return_inventory_carton source_unit_code +ll_return_inventory_carton pallet_code +ll_return_inventory_carton carton_mark +ll_return_inventory_carton fba_carton_mark +ll_return_inventory_carton destination_warehouse_code +ll_return_inventory_carton destination_warehouse_name +ll_return_inventory_carton relabel_application_id +ll_return_inventory_carton relabel_no +ll_return_inventory_carton relabel_status +ll_return_inventory_carton relabel_completed_at +ll_return_inventory_carton status +ll_return_inventory_carton sku_types +ll_return_inventory_carton total_quantity +ll_return_inventory_carton created_at +ll_return_inventory_carton updated_at +ll_return_inventory_carton merged_at +ll_return_inventory_carton length +ll_return_inventory_carton width +ll_return_inventory_carton height +ll_return_inventory_carton weight +ll_return_inventory_carton original_packaging +ll_return_inventory_carton new_carton_mark +ll_return_inventory_carton location_code +ll_return_inventory_carton putaway_at +ll_return_inventory_carton count_status +ll_return_inventory_carton counted_at +ll_return_inventory_carton counted_by +ll_return_inventory_carton label_status +ll_return_inventory_carton_item id +ll_return_inventory_carton_item carton_id +ll_return_inventory_carton_item customer_id +ll_return_inventory_carton_item sku_code +ll_return_inventory_carton_item product_name_zh +ll_return_inventory_carton_item quantity +ll_return_inventory_carton_item packing_reserved_quantity +ll_return_inventory_carton_item relabelled_quantity +ll_return_inventory_operation id +ll_return_inventory_operation operator_id +ll_return_inventory_operation idempotency_key +ll_return_inventory_operation request_hash +ll_return_inventory_operation operation_type +ll_return_inventory_operation result_carton_id +ll_return_inventory_operation created_at +ll_return_inventory_operation updated_at +ll_return_inventory_operation result_json +ll_return_inventory_outbound_movement id +ll_return_inventory_outbound_movement outbound_pick_id +ll_return_inventory_outbound_movement customer_id +ll_return_inventory_outbound_movement overseas_warehouse_id +ll_return_inventory_outbound_movement carton_id +ll_return_inventory_outbound_movement carton_mark +ll_return_inventory_outbound_movement outbound_no +ll_return_inventory_outbound_movement sku_code +ll_return_inventory_outbound_movement product_name_zh +ll_return_inventory_outbound_movement quantity +ll_return_inventory_outbound_movement total_before +ll_return_inventory_outbound_movement total_after +ll_return_inventory_outbound_movement available_before +ll_return_inventory_outbound_movement available_after +ll_return_inventory_outbound_movement cartons_before +ll_return_inventory_outbound_movement cartons_after +ll_return_inventory_outbound_movement occurred_at +ll_return_order id +ll_return_order customer_id +ll_return_order return_no +ll_return_order overseas_warehouse_id +ll_return_order warehouse_contact_name +ll_return_order warehouse_contact_phone +ll_return_order customer_reference_no +ll_return_order lastleg_order_id +ll_return_order lastleg_order_no +ll_return_order need_relabel +ll_return_order relabel_application_id +ll_return_order relabel_no +ll_return_order relabel_status +ll_return_order status +ll_return_order expected_sku_types +ll_return_order expected_quantity +ll_return_order received_quantity +ll_return_order remark +ll_return_order discrepancy_reason +ll_return_order manually_completed_at +ll_return_order idempotency_key +ll_return_order request_hash +ll_return_order created_by +ll_return_order created_at +ll_return_order updated_at +ll_return_order_item id +ll_return_order_item return_order_id +ll_return_order_item customer_id +ll_return_order_item sku_code +ll_return_order_item product_name_zh +ll_return_order_item expected_quantity +ll_return_order_item received_quantity +ll_return_order_item created_at +ll_return_order_item updated_at +ll_return_packing_plan id +ll_return_packing_plan plan_no +ll_return_packing_plan customer_id +ll_return_packing_plan overseas_warehouse_id +ll_return_packing_plan return_nos_json +ll_return_packing_plan boxes_json +ll_return_packing_plan mode +ll_return_packing_plan status +ll_return_packing_plan remark +ll_return_packing_plan idempotency_key +ll_return_packing_plan created_at +ll_return_packing_plan updated_at +ll_return_packing_plan pushed_at +ll_return_packing_plan completed_at +ll_return_packing_plan source_items_json +ll_return_packing_plan result_marks_json +ll_return_packing_plan request_hash +ll_return_packing_plan completion_hash +ll_return_packing_plan result_labels_json +ll_return_packing_trace id +ll_return_packing_trace customer_id +ll_return_packing_trace operation_id +ll_return_packing_trace source_carton_mark +ll_return_packing_trace target_carton_mark +ll_return_packing_trace return_no +ll_return_packing_trace sku_code +ll_return_packing_trace quantity +ll_return_packing_trace created_at +ll_return_receipt id +ll_return_receipt return_order_id +ll_return_receipt customer_id +ll_return_receipt receipt_no +ll_return_receipt receipt_batch_no +ll_return_receipt unit_type +ll_return_receipt unit_code +ll_return_receipt total_box_count +ll_return_receipt length +ll_return_receipt width +ll_return_receipt height +ll_return_receipt weight +ll_return_receipt original_packaging +ll_return_receipt measurement_source +ll_return_receipt pallet_code +ll_return_receipt carton_mark +ll_return_receipt tracking_no +ll_return_receipt bound_by +ll_return_receipt status +ll_return_receipt received_quantity +ll_return_receipt operator_id +ll_return_receipt operator_name +ll_return_receipt idempotency_key +ll_return_receipt request_hash +ll_return_receipt signed_at +ll_return_receipt created_at +ll_return_receipt_batch id +ll_return_receipt_batch return_order_id +ll_return_receipt_batch receipt_id +ll_return_receipt_batch customer_id +ll_return_receipt_batch batch_no +ll_return_receipt_batch overseas_warehouse_id +ll_return_receipt_batch storage_billing_status +ll_return_receipt_batch storage_started_at +ll_return_receipt_batch created_at +ll_return_receipt_item id +ll_return_receipt_item receipt_id +ll_return_receipt_item return_order_item_id +ll_return_receipt_item customer_id +ll_return_receipt_item sku_code +ll_return_receipt_item received_quantity +ll_return_receipt_item box_count +ll_return_sku_conversion id +ll_return_sku_conversion customer_id +ll_return_sku_conversion overseas_warehouse_id +ll_return_sku_conversion application_id +ll_return_sku_conversion relabel_no +ll_return_sku_conversion item_id +ll_return_sku_conversion round_key +ll_return_sku_conversion carton_id +ll_return_sku_conversion carton_mark +ll_return_sku_conversion old_sku_code +ll_return_sku_conversion new_sku_code +ll_return_sku_conversion product_name_zh +ll_return_sku_conversion quantity +ll_return_sku_conversion status +ll_return_sku_conversion ownership_json +ll_return_sku_conversion completed_at +ll_return_sku_conversion confirmed_at +ll_return_sku_conversion reversed_at +ll_return_stock_receipt id +ll_return_stock_receipt customer_id +ll_return_stock_receipt receipt_no +ll_return_stock_receipt inventory_ledger_id +ll_return_stock_receipt exception_order_id +ll_return_stock_receipt order_id +ll_return_stock_receipt inbound_order_id +ll_return_stock_receipt source_outbound_no +ll_return_stock_receipt source_outbound_pallet_item_id +ll_return_stock_receipt source_inventory_position_id +ll_return_stock_receipt inventory_balance_id +ll_return_stock_receipt inventory_position_id +ll_return_stock_receipt received_packages +ll_return_stock_receipt received_at +ll_return_stock_receipt created_at +ll_security_event_inbox event_id +ll_security_event_inbox event_type +ll_security_event_inbox payload_hash +ll_security_event_inbox payload_json +ll_security_event_inbox status +ll_security_event_inbox result +ll_security_event_inbox received_at +ll_security_event_inbox processed_at +ll_security_projection aggregate_type +ll_security_projection aggregate_id +ll_security_projection tenant_id +ll_security_projection security_status +ll_security_projection permission_version +ll_security_projection roles_json +ll_security_projection authorities_json +ll_security_projection aggregate_version +ll_security_projection payload_hash +ll_security_projection last_event_id +ll_security_projection changed_at +ll_security_projection updated_at +ll_us_truck_order id +ll_us_truck_order customer_id +ll_us_truck_order order_no +ll_us_truck_order transport_type +ll_us_truck_order origin +ll_us_truck_order origin_address +ll_us_truck_order destination +ll_us_truck_order destination_address +ll_us_truck_order container_type +ll_us_truck_order pallet_count +ll_us_truck_order package_count +ll_us_truck_order received_pallet_count +ll_us_truck_order allocated_pallet_count +ll_us_truck_order shipped_pallet_count +ll_us_truck_order total_weight_kg +ll_us_truck_order total_volume_cbm +ll_us_truck_order budget_amount +ll_us_truck_order currency +ll_us_truck_order contact_name +ll_us_truck_order contact_info +ll_us_truck_order requested_date +ll_us_truck_order status +ll_us_truck_order remark +ll_us_truck_order receipt_idempotency_key +ll_us_truck_order receipt_request_hash +ll_us_truck_order idempotency_key +ll_us_truck_order request_hash +ll_us_truck_order created_by +ll_us_truck_order version +ll_us_truck_order created_at +ll_us_truck_order updated_at +ll_us_truck_order tenant_id +ll_us_truck_order_operation id +ll_us_truck_order_operation order_id +ll_us_truck_order_operation customer_id +ll_us_truck_order_operation operation_type +ll_us_truck_order_operation idempotency_key +ll_us_truck_order_operation request_hash +ll_us_truck_order_operation result_version +ll_us_truck_order_operation created_by +ll_us_truck_order_operation created_at +ll_us_truck_order_pallet id +ll_us_truck_order_pallet order_id +ll_us_truck_order_pallet customer_id +ll_us_truck_order_pallet pallet_no +ll_us_truck_order_pallet sequence_no +ll_us_truck_order_pallet source +ll_us_truck_order_pallet status +ll_us_truck_order_pallet warehouse_pallet_id +ll_us_truck_order_pallet package_count +ll_us_truck_order_pallet received_at +ll_us_truck_order_pallet created_at +ll_us_truck_order_pallet updated_at +ll_us_truck_order_pallet tenant_id +ll_us_truck_order_status_history id +ll_us_truck_order_status_history order_id +ll_us_truck_order_status_history customer_id +ll_us_truck_order_status_history operation_type +ll_us_truck_order_status_history from_status +ll_us_truck_order_status_history to_status +ll_us_truck_order_status_history operator_id +ll_us_truck_order_status_history idempotency_key +ll_us_truck_order_status_history remark +ll_us_truck_order_status_history created_at +ll_us_truck_order_status_history tenant_id +ll_vehicle id +ll_vehicle plate_number +ll_vehicle model +ll_vehicle fuel_consumption_per100_km +ll_vehicle mileage_km +ll_vehicle driver_or_dispatch_company +ll_vehicle phone +ll_vehicle region +ll_vehicle ownership +ll_vehicle status +ll_vehicle remark +ll_vehicle record_version +ll_vehicle created_by +ll_vehicle updated_by +ll_vehicle created_at +ll_vehicle updated_at +ll_vehicle max_pallet_spaces +ll_vehicle max_load_kg +ll_vehicle max_volume_cbm +ll_wallet_event_inbox event_id +ll_wallet_event_inbox command_event_id +ll_wallet_event_inbox payload_hash +ll_wallet_event_inbox payload_json +ll_wallet_event_inbox status +ll_wallet_event_inbox received_at +ll_wallet_event_inbox processed_at +ll_warehouse_transfer_carton id +ll_warehouse_transfer_carton transfer_order_id +ll_warehouse_transfer_carton transfer_item_id +ll_warehouse_transfer_carton customer_id +ll_warehouse_transfer_carton carton_mark +ll_warehouse_transfer_carton customer_carton_mark +ll_warehouse_transfer_carton carton_index +ll_warehouse_transfer_carton total_cartons +ll_warehouse_transfer_carton status +ll_warehouse_transfer_carton created_at +ll_warehouse_transfer_carton updated_at +ll_warehouse_transfer_carton length_cm +ll_warehouse_transfer_carton width_cm +ll_warehouse_transfer_carton height_cm +ll_warehouse_transfer_carton gross_weight_kg +ll_warehouse_transfer_carton counted_quantity +ll_warehouse_transfer_carton count_idempotency_key +ll_warehouse_transfer_carton count_request_hash +ll_warehouse_transfer_carton counted_by +ll_warehouse_transfer_carton counted_at +ll_warehouse_transfer_carton discrepancy_reason +ll_warehouse_transfer_carton transfer_pallet_id +ll_warehouse_transfer_carton sku_items_json +ll_warehouse_transfer_inventory_allocation id +ll_warehouse_transfer_inventory_allocation transfer_order_id +ll_warehouse_transfer_inventory_allocation customer_id +ll_warehouse_transfer_inventory_allocation inventory_position_id +ll_warehouse_transfer_inventory_allocation inventory_balance_id +ll_warehouse_transfer_inventory_allocation inbound_order_id +ll_warehouse_transfer_inventory_allocation inbound_code +ll_warehouse_transfer_inventory_allocation pallet_id +ll_warehouse_transfer_inventory_allocation pallet_no +ll_warehouse_transfer_inventory_allocation package_count +ll_warehouse_transfer_inventory_allocation status +ll_warehouse_transfer_inventory_allocation active_position_key +ll_warehouse_transfer_inventory_allocation allocated_at +ll_warehouse_transfer_inventory_allocation shipped_at +ll_warehouse_transfer_inventory_allocation received_at +ll_warehouse_transfer_inventory_allocation created_at +ll_warehouse_transfer_inventory_allocation updated_at +ll_warehouse_transfer_inventory_allocation tenant_id +ll_warehouse_transfer_inventory_allocation sku_code +ll_warehouse_transfer_item id +ll_warehouse_transfer_item transfer_order_id +ll_warehouse_transfer_item customer_id +ll_warehouse_transfer_item sku_code +ll_warehouse_transfer_item product_name_zh +ll_warehouse_transfer_item package_count +ll_warehouse_transfer_item shipping_mark +ll_warehouse_transfer_item created_at +ll_warehouse_transfer_item tenant_id +ll_warehouse_transfer_item declared_quantity +ll_warehouse_transfer_measurement_photo id +ll_warehouse_transfer_measurement_photo transfer_order_id +ll_warehouse_transfer_measurement_photo customer_id +ll_warehouse_transfer_measurement_photo file_id +ll_warehouse_transfer_measurement_photo file_name +ll_warehouse_transfer_measurement_photo content_type +ll_warehouse_transfer_measurement_photo size_bytes +ll_warehouse_transfer_measurement_photo uploaded_by +ll_warehouse_transfer_measurement_photo uploaded_at +ll_warehouse_transfer_measurement_photo submitted_at +ll_warehouse_transfer_order id +ll_warehouse_transfer_order customer_id +ll_warehouse_transfer_order transfer_no +ll_warehouse_transfer_order inbound_order_id +ll_warehouse_transfer_order inbound_code +ll_warehouse_transfer_order lastleg_order_id +ll_warehouse_transfer_order lastleg_order_no +ll_warehouse_transfer_order operating_warehouse_id +ll_warehouse_transfer_order fba_warehouse_code +ll_warehouse_transfer_order delivery_address +ll_warehouse_transfer_order pickup_contact_name +ll_warehouse_transfer_order pickup_contact_phone +ll_warehouse_transfer_order package_count +ll_warehouse_transfer_order delivery_method +ll_warehouse_transfer_order estimated_arrival_at +ll_warehouse_transfer_order estimated_ship_at +ll_warehouse_transfer_order fba_shipment_no +ll_warehouse_transfer_order tracking_no +ll_warehouse_transfer_order fba_box_nos +ll_warehouse_transfer_order remark +ll_warehouse_transfer_order need_relabel +ll_warehouse_transfer_order relabel_application_id +ll_warehouse_transfer_order relabel_no +ll_warehouse_transfer_order relabel_completed_at +ll_warehouse_transfer_order status +ll_warehouse_transfer_order review_decision +ll_warehouse_transfer_order review_remark +ll_warehouse_transfer_order reviewed_by +ll_warehouse_transfer_order reviewed_at +ll_warehouse_transfer_order allocation_idempotency_key +ll_warehouse_transfer_order allocation_request_hash +ll_warehouse_transfer_order outbound_idempotency_key +ll_warehouse_transfer_order outbound_request_hash +ll_warehouse_transfer_order outbound_at +ll_warehouse_transfer_order measured_at +ll_warehouse_transfer_order actual_ship_at +ll_warehouse_transfer_order idempotency_key +ll_warehouse_transfer_order request_hash +ll_warehouse_transfer_order created_by +ll_warehouse_transfer_order created_at +ll_warehouse_transfer_order updated_at +ll_warehouse_transfer_order tenant_id +ll_warehouse_transfer_order label_deadline +ll_warehouse_transfer_order relabel_contact_name +ll_warehouse_transfer_order relabel_contact_phone +ll_warehouse_transfer_order relabel_charge_amount +ll_warehouse_transfer_order relabel_delivery_method +ll_warehouse_transfer_order palletize_idempotency_key +ll_warehouse_transfer_order palletize_request_hash +ll_warehouse_transfer_order confirmation_idempotency_key +ll_warehouse_transfer_order confirmation_request_hash +ll_warehouse_transfer_order customer_confirmed_at +ll_warehouse_transfer_order destination_type +ll_warehouse_transfer_order destination_warehouse_id +ll_warehouse_transfer_order destination_warehouse_code +ll_warehouse_transfer_order destination_warehouse_name +ll_warehouse_transfer_order private_destination_address +ll_warehouse_transfer_order private_destination_postal_code +ll_warehouse_transfer_order private_destination_contact_name +ll_warehouse_transfer_order private_destination_contact_phone +ll_warehouse_transfer_order destination_binding_idempotency_key +ll_warehouse_transfer_order destination_binding_request_hash +ll_warehouse_transfer_pallet id +ll_warehouse_transfer_pallet transfer_order_id +ll_warehouse_transfer_pallet customer_id +ll_warehouse_transfer_pallet sequence_no +ll_warehouse_transfer_pallet warehouse_pallet_id +ll_warehouse_transfer_pallet pallet_no +ll_warehouse_transfer_pallet warehouse_location_id +ll_warehouse_transfer_pallet warehouse_location_code +ll_warehouse_transfer_pallet warehouse_resource_lock_id +ll_warehouse_transfer_pallet carton_count +ll_warehouse_transfer_pallet length_cm +ll_warehouse_transfer_pallet width_cm +ll_warehouse_transfer_pallet height_cm +ll_warehouse_transfer_pallet gross_weight_kg +ll_warehouse_transfer_pallet volume_cbm +ll_warehouse_transfer_pallet status +ll_warehouse_transfer_pallet draft_idempotency_key +ll_warehouse_transfer_pallet draft_request_hash +ll_warehouse_transfer_pallet generate_idempotency_key +ll_warehouse_transfer_pallet bind_idempotency_key +ll_warehouse_transfer_pallet bound_at +ll_warehouse_transfer_pallet completed_at +ll_warehouse_transfer_pallet created_at +ll_warehouse_transfer_pallet updated_at +ll_warehouse_transfer_pallet_item id +ll_warehouse_transfer_pallet_item transfer_order_id +ll_warehouse_transfer_pallet_item transfer_pallet_id +ll_warehouse_transfer_pallet_item transfer_item_id +ll_warehouse_transfer_pallet_item customer_id +ll_warehouse_transfer_pallet_item package_count +ll_warehouse_transfer_pallet_item created_at +ll_warehouse_transfer_receipt id +ll_warehouse_transfer_receipt transfer_order_id +ll_warehouse_transfer_receipt allocation_id +ll_warehouse_transfer_receipt customer_id +ll_warehouse_transfer_receipt source_inventory_position_id +ll_warehouse_transfer_receipt destination_inventory_balance_id +ll_warehouse_transfer_receipt destination_inventory_position_id +ll_warehouse_transfer_receipt package_count +ll_warehouse_transfer_receipt idempotency_key +ll_warehouse_transfer_receipt request_hash +ll_warehouse_transfer_receipt inventory_operation_key +ll_warehouse_transfer_receipt created_by +ll_warehouse_transfer_receipt received_at +ll_warehouse_transfer_receipt created_at +ll_warehouse_transfer_receipt tenant_id +ll_warehouse_transfer_receipt_command id +ll_warehouse_transfer_receipt_command customer_id +ll_warehouse_transfer_receipt_command transfer_order_id +ll_warehouse_transfer_receipt_command source_inventory_position_id +ll_warehouse_transfer_receipt_command idempotency_key +ll_warehouse_transfer_receipt_command request_hash +ll_warehouse_transfer_receipt_command request_json +ll_warehouse_transfer_receipt_command target_warehouse_id +ll_warehouse_transfer_receipt_command target_pallet_id +ll_warehouse_transfer_receipt_command target_location_id +ll_warehouse_transfer_receipt_command target_hash +ll_warehouse_transfer_receipt_command target_json +ll_warehouse_transfer_receipt_command reservation_key +ll_warehouse_transfer_receipt_command resource_lock_id +ll_warehouse_transfer_receipt_command status +ll_warehouse_transfer_receipt_command created_at +ll_warehouse_transfer_receipt_command updated_at +ll_warehouse_transfer_relabel_binding id +ll_warehouse_transfer_relabel_binding transfer_order_id +ll_warehouse_transfer_relabel_binding customer_id +ll_warehouse_transfer_relabel_binding operation_type +ll_warehouse_transfer_relabel_binding customer_carton_mark +ll_warehouse_transfer_relabel_binding original_sku +ll_warehouse_transfer_relabel_binding new_sku +ll_warehouse_transfer_relabel_binding quantity +ll_warehouse_transfer_relabel_binding fba_carton_mark +ll_warehouse_transfer_relabel_binding destination_warehouse_code +ll_warehouse_transfer_relabel_binding remark +ll_warehouse_transfer_relabel_binding created_at +ll_warehouse_transfer_relabel_binding source_allocation_id +ll_warehouse_transfer_relabel_binding relabel_item_id +ll_warehouse_transfer_source_order id +ll_warehouse_transfer_source_order transfer_order_id +ll_warehouse_transfer_source_order customer_id +ll_warehouse_transfer_source_order source_lastleg_order_id +ll_warehouse_transfer_source_order source_lastleg_order_no +ll_warehouse_transfer_source_order inbound_order_id +ll_warehouse_transfer_source_order inbound_code +ll_warehouse_transfer_source_order created_at +ll_warehouse_transfer_source_order tenant_id \ No newline at end of file diff --git a/output/local-schema-repair/customs-tab-consistency.json b/output/local-schema-repair/customs-tab-consistency.json new file mode 100644 index 000000000..6d5916ad7 --- /dev/null +++ b/output/local-schema-repair/customs-tab-consistency.json @@ -0,0 +1,116 @@ +[ + { + "status": "PENDING_REVIEW", + "count": 127, + "total": 127, + "passed": true + }, + { + "status": "WAIT_MEASURE", + "count": 195, + "total": 195, + "passed": true + }, + { + "status": "WAIT_WEIGHT_CONFIRM", + "count": 125, + "total": 125, + "passed": true + }, + { + "status": "WEIGHT_CONFIRMED", + "count": 1, + "total": 1, + "passed": true + }, + { + "status": "WAIT_CUSTOMS_DOCS", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "WAIT_PALLETIZE", + "count": 6, + "total": 6, + "passed": true + }, + { + "status": "WAIT_SPLIT", + "count": 126, + "total": 126, + "passed": true + }, + { + "status": "LOADING", + "count": 9, + "total": 9, + "passed": true + }, + { + "status": "LOADED", + "count": 73, + "total": 73, + "passed": true + }, + { + "status": "EXPORT_CUSTOMS", + "count": 12, + "total": 12, + "passed": true + }, + { + "status": "SAILING_CLEARANCE", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "WAIT_UNLOAD", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "WAIT_DISPATCH", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "OUTBOUND_DOCUMENT", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "DELIVERING", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "SIGNED", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "DELIVERED", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "COMPLETED", + "count": 2, + "total": 2, + "passed": true + }, + { + "status": "MEASUREMENT_REVIEW", + "count": 120, + "total": 120, + "passed": true + } +] diff --git a/output/local-schema-repair/customs-tab-live.json b/output/local-schema-repair/customs-tab-live.json new file mode 100644 index 000000000..6c67deaac --- /dev/null +++ b/output/local-schema-repair/customs-tab-live.json @@ -0,0 +1,523 @@ +[ + { + "path": "/api/auth/login", + "http": 200, + "code": "200", + "requestId": "d8386ec4-397e-479f-a02f-ed5248d2d772, 3b6b0258-8944-403d-8290-b2a3f6393bd4", + "passed": true + }, + { + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200", + "requestId": "36a52333-5a14-4eea-a5ed-bd3f5b77bd95, 83a342cc-b86b-44ce-a70b-5cd38d043caa", + "passed": true, + "counts": { + "PENDING_REVIEW": 127, + "WAIT_MEASURE": 195, + "WAIT_PALLETIZE": 6, + "WAIT_SPLIT": 126, + "LOADING": 9, + "LOADED": 73, + "DELIVERED": 0, + "COMPLETED": 2, + "WAIT_WEIGHT_CONFIRM": 125, + "WEIGHT_CONFIRMED": 1, + "WAIT_CUSTOMS_DOCS": 0, + "EXPORT_CUSTOMS": 12, + "SAILING_CLEARANCE": 0, + "WAIT_UNLOAD": 0, + "WAIT_DISPATCH": 0, + "OUTBOUND_DOCUMENT": 0, + "DELIVERING": 0, + "SIGNED": 0, + "MEASUREMENT_REVIEW": 120 + } + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=PENDING_REVIEW", + "http": 200, + "code": "200", + "requestId": "cec5abdd-3d32-4ec4-9646-ee82ae4bbb53, 2ceadbc6-b8b5-4966-bb5f-14785549e57e", + "passed": true, + "total": "127", + "records": [ + { + "orderId": "2112105953787518978", + "tenantId": "2112105501490544641", + "inboundId": "2112105957231161346", + "mainStatus": "PENDING_REVIEW" + }, + { + "orderId": "2112105886309556225", + "tenantId": "2112105195373461506", + "inboundId": "2112105890017439745", + "mainStatus": "PENDING_REVIEW" + }, + { + "orderId": "2112105822484832258", + "tenantId": "2112105195373461506", + "inboundId": "2112105825983000577", + "mainStatus": "PENDING_REVIEW" + }, + { + "orderId": "2112105768302813186", + "tenantId": "2112105195373461506", + "inboundId": "2112105772081999873", + "mainStatus": "PENDING_REVIEW" + }, + { + "orderId": "2112105682319581185", + "tenantId": "2112105501490544641", + "inboundId": "2112105687122178049", + "mainStatus": "PENDING_REVIEW" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_MEASURE", + "http": 200, + "code": "200", + "requestId": "970d462f-c6c0-4be5-a625-f56bcd015380, 97d186ed-00e7-4d2c-84c3-e4cf8bd264b8", + "passed": true, + "total": "195", + "records": [ + { + "orderId": "2112102981498183681", + "tenantId": "2112102958765047809", + "inboundId": "2112103003577118721", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2098325342631428097", + "tenantId": "2098324852501774338", + "inboundId": "2098325343935848449", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2098325190034259969", + "tenantId": "2098324852501774338", + "inboundId": "2098325191338680322", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2098325037621641217", + "tenantId": "2098324852501774338", + "inboundId": "2098325038921867265", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2098324887905959938", + "tenantId": "2098324852501774338", + "inboundId": "2098324891550801921", + "mainStatus": "WAIT_MEASURE" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_WEIGHT_CONFIRM", + "http": 200, + "code": "200", + "requestId": "3df59adf-731b-4388-bf88-4439adf9fa90, d2d74f05-5875-4b2f-93ff-e161dbe4186e", + "passed": true, + "total": "125", + "records": [ + { + "orderId": "2085355345216114689", + "tenantId": "2085308606829346818", + "inboundId": "2085355347941904385", + "mainStatus": null + }, + { + "orderId": "2085355345975283713", + "tenantId": "2085308606829346818", + "inboundId": "2085355347992236034", + "mainStatus": null + }, + { + "orderId": "2085355346734452738", + "tenantId": "2085308606829346818", + "inboundId": "2085355360826806273", + "mainStatus": null + }, + { + "orderId": "2085355347493621762", + "tenantId": "2085308606829346818", + "inboundId": "2085355360881332226", + "mainStatus": null + }, + { + "orderId": "2085355348282150913", + "tenantId": "2085308606829346818", + "inboundId": "2085355360931663873", + "mainStatus": null + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WEIGHT_CONFIRMED", + "http": 200, + "code": "200", + "requestId": "089af451-73c8-461d-9e7f-d39305be69e7, dc1098ac-4bc2-4538-852a-4e592363658b", + "passed": true, + "total": "1", + "records": [ + { + "orderId": "2085341741574098946", + "tenantId": "2085308606829346818", + "inboundId": "2085341749429522434", + "mainStatus": null + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_CUSTOMS_DOCS", + "http": 200, + "code": "200", + "requestId": "789f82d6-5b7e-4f28-b542-12c4537f695c, 94e8eb5d-c0d9-4f0c-854d-2df1458c81be", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_PALLETIZE", + "http": 200, + "code": "200", + "requestId": "9fca92f3-47d2-4a5b-9360-73ba15570f8b, d66b74f3-a292-418f-91d3-5b364e5d1141", + "passed": true, + "total": "6", + "records": [ + { + "orderId": "2112105882580819970", + "tenantId": "2112105195373461506", + "inboundId": "2112105899853082626", + "mainStatus": "WAIT_PALLETIZE" + }, + { + "orderId": "2112105818881925122", + "tenantId": "2112105195373461506", + "inboundId": "2112105836158382082", + "mainStatus": "WAIT_PALLETIZE" + }, + { + "orderId": "2112105764741849089", + "tenantId": "2112105195373461506", + "inboundId": "2112105781263331330", + "mainStatus": "WAIT_PALLETIZE" + }, + { + "orderId": "2112104925457424385", + "tenantId": "2112104631512203266", + "inboundId": "2112104942167650305", + "mainStatus": "WAIT_PALLETIZE" + }, + { + "orderId": "2112104871636115457", + "tenantId": "2112104551203864578", + "inboundId": "2112104887486509058", + "mainStatus": "WAIT_PALLETIZE" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_SPLIT", + "http": 200, + "code": "200", + "requestId": "c051f77b-bf8b-40f7-ae48-b0199c425a2c, 379ba9b5-caa8-4ba6-b7bc-ae5e9a1b7096", + "passed": true, + "total": "126", + "records": [ + { + "orderId": "2112105949454802945", + "tenantId": "2112105501490544641", + "inboundId": "2112105974222286850", + "mainStatus": "WAIT_SPLIT" + }, + { + "orderId": "2112105677974282242", + "tenantId": "2112105501490544641", + "inboundId": "2112105696567750658", + "mainStatus": "WAIT_SPLIT" + }, + { + "orderId": "2112105550538743809", + "tenantId": "2112105501490544641", + "inboundId": "2112105615210835970", + "mainStatus": "WAIT_SPLIT" + }, + { + "orderId": "2112105269298077697", + "tenantId": "2112105195373461506", + "inboundId": "2112105371026845697", + "mainStatus": "WAIT_SPLIT" + }, + { + "orderId": "2112104769605476353", + "tenantId": "2112104551203864578", + "inboundId": "2112104783950114818", + "mainStatus": "WAIT_SPLIT" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=LOADING", + "http": 200, + "code": "200", + "requestId": "f1731715-f948-457e-9e66-95bcae492242, d1559d04-db13-41e0-85fa-b0776f32c224", + "passed": true, + "total": "9", + "records": [ + { + "orderId": "2092816910277038081", + "tenantId": "2092816814294667266", + "inboundId": "2092816913754202115", + "mainStatus": "LOADING" + }, + { + "orderId": "2092816871253233665", + "tenantId": "2092816814294667266", + "inboundId": "2092816875699281923", + "mainStatus": "LOADING" + }, + { + "orderId": "2092816836365012994", + "tenantId": "2092816814294667266", + "inboundId": "2092816850256633858", + "mainStatus": "LOADING" + }, + { + "orderId": "2092814878690398210", + "tenantId": "2092814835845664769", + "inboundId": "2092814880129130499", + "mainStatus": "LOADING" + }, + { + "orderId": "2091000814000001101", + "tenantId": "10001", + "inboundId": "2091000814000001301", + "mainStatus": "LOADING" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=LOADED", + "http": 200, + "code": "200", + "requestId": "8adb0c88-721d-49e5-ae76-400c43aebf81, 4b3c1edd-8a2e-40c4-82ac-36ac366afe8c", + "passed": true, + "total": "73", + "records": [ + { + "orderId": "2093269574496440322", + "tenantId": "2093269089353961474", + "inboundId": "2093269587792433154", + "mainStatus": "LOADED" + }, + { + "orderId": "2093269527184691202", + "tenantId": "2093269089353961474", + "inboundId": "2093269537020383234", + "mainStatus": "LOADED" + }, + { + "orderId": "2093269460814024706", + "tenantId": "2093269089353961474", + "inboundId": "2093269473657032707", + "mainStatus": "LOADED" + }, + { + "orderId": "2093269413967843329", + "tenantId": "2093269089353961474", + "inboundId": "2093269422700433411", + "mainStatus": "LOADED" + }, + { + "orderId": "2093269346426966017", + "tenantId": "2093269089353961474", + "inboundId": "2093269359261585410", + "mainStatus": "LOADED" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=EXPORT_CUSTOMS", + "http": 200, + "code": "200", + "requestId": "ef20726d-cda3-4f23-b1aa-b05efe574617, 9e8b9d3d-bd7b-43b4-bd3e-f7488276292a", + "passed": true, + "total": "12", + "records": [ + { + "orderId": "2086081433932726274", + "tenantId": "10001", + "inboundId": "2086081437650956290", + "mainStatus": null + }, + { + "orderId": "2086085225151528961", + "tenantId": "10001", + "inboundId": "2086085239259049985", + "mainStatus": null + }, + { + "orderId": "2086085487601713154", + "tenantId": "10001", + "inboundId": "2086085491298971650", + "mainStatus": null + }, + { + "orderId": "2086086464165707778", + "tenantId": "10001", + "inboundId": "2086086473193926658", + "mainStatus": null + }, + { + "orderId": "2086088193535967234", + "tenantId": "10001", + "inboundId": "2086088197887537154", + "mainStatus": null + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=SAILING_CLEARANCE", + "http": 200, + "code": "200", + "requestId": "152a2d06-7280-40ca-b2b4-e431e38aad09, 038f2149-53ea-4d8d-80c2-f179a964be24", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_UNLOAD", + "http": 200, + "code": "200", + "requestId": "39d0e8cf-3864-4353-a368-e1d534d80b31, 7a5cd168-cb61-4459-b20a-f2f2313941e0", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_DISPATCH", + "http": 200, + "code": "200", + "requestId": "f2b1a845-bed6-4816-8b8f-eafd958ec354, 33d70b80-347e-42c8-a9f7-2850abc390e3", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=OUTBOUND_DOCUMENT", + "http": 200, + "code": "200", + "requestId": "507b600b-e51a-4dfd-a1b4-4e521a8a5b43, 8d82c176-67f8-4f2d-a67e-cb1f07cb8bb9", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=DELIVERING", + "http": 200, + "code": "200", + "requestId": "a4ce3c7c-093c-46ef-86f4-562713de83fa, 7800d83e-902c-4596-8a2c-9ac5622999e1", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=SIGNED", + "http": 200, + "code": "200", + "requestId": "1d9273c0-16b9-46a7-8d91-de893b3fda4e, 8e2e2611-b64a-4875-975b-3f70247d2ba8", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=DELIVERED", + "http": 200, + "code": "200", + "requestId": "15254699-ce87-40d2-ab92-524be410a608, 03296c5a-921c-474b-bc94-b163d9bbeca9", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=COMPLETED", + "http": 200, + "code": "200", + "requestId": "e0ba9860-9602-40e7-9480-a8f270ab2ada, aaf30bb1-06be-454a-8f94-051961520d6b", + "passed": true, + "total": "2", + "records": [ + { + "orderId": "2084515377635758081", + "tenantId": "2084513933705150465", + "inboundId": "2085623586303365121", + "mainStatus": "COMPLETED" + }, + { + "orderId": "2084210113483173890", + "tenantId": "10001", + "inboundId": "2085623585678413826", + "mainStatus": "COMPLETED" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=MEASUREMENT_REVIEW", + "http": 200, + "code": "200", + "requestId": "c19b7ce9-6fd0-4d0b-a21a-7c89ba39b244, 756e5250-341e-4b41-998d-419a68fc9102", + "passed": true, + "total": "120", + "records": [ + { + "orderId": "2085355293542289410", + "tenantId": "2085308606829346818", + "inboundId": "2085355307198435329", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2085355294326624258", + "tenantId": "2085308606829346818", + "inboundId": "2085355307252961281", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2085355295115153410", + "tenantId": "2085308606829346818", + "inboundId": "2085355307303292930", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2085355295895293953", + "tenantId": "2085308606829346818", + "inboundId": "2085355307357818882", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2085355296650268673", + "tenantId": "2085308606829346818", + "inboundId": "2085355307408150530", + "mainStatus": "WAIT_MEASURE" + } + ] + }, + { + "path": "9202/actuator/health", + "http": 200, + "body": { + "status": "UP" + }, + "passed": true + }, + { + "path": "9303/actuator/health", + "http": 200, + "body": { + "status": "UP" + }, + "passed": true + } +] \ No newline at end of file diff --git a/output/local-schema-repair/firstleg-columns-before.tsv b/output/local-schema-repair/firstleg-columns-before.tsv new file mode 100644 index 000000000..e402d1f9c --- /dev/null +++ b/output/local-schema-repair/firstleg-columns-before.tsv @@ -0,0 +1,2138 @@ +event_delivery_attempt attempt_id +event_delivery_attempt service_name +event_delivery_attempt event_type +event_delivery_attempt entry_id +event_delivery_attempt event_id +event_delivery_attempt target_name +event_delivery_attempt started_at +event_delivery_attempt finished_at +event_delivery_attempt duration_ms +event_delivery_attempt outcome +event_delivery_attempt error_code +event_delivery_attempt next_retry_at +fl_assignment_history id +fl_assignment_history tenant_id +fl_assignment_history task_id +fl_assignment_history previous_assignee_id +fl_assignment_history assignee_id +fl_assignment_history assignee_name +fl_assignment_history reason +fl_assignment_history operator_id +fl_assignment_history created_at +fl_carrier_fba_rate id +fl_carrier_fba_rate tenant_id +fl_carrier_fba_rate price_book_version_id +fl_carrier_fba_rate rate_code +fl_carrier_fba_rate rate_version +fl_carrier_fba_rate origin_warehouse_id +fl_carrier_fba_rate origin_warehouse_code +fl_carrier_fba_rate origin_warehouse_name +fl_carrier_fba_rate business_type +fl_carrier_fba_rate channel_code +fl_carrier_fba_rate price_model +fl_carrier_fba_rate carrier_id +fl_carrier_fba_rate carrier_name +fl_carrier_fba_rate destination_type +fl_carrier_fba_rate destination_code +fl_carrier_fba_rate destination_warehouse_id +fl_carrier_fba_rate destination_name +fl_carrier_fba_rate region_code +fl_carrier_fba_rate pricing_tax_mode +fl_carrier_fba_rate weight_from_kg +fl_carrier_fba_rate charge_unit +fl_carrier_fba_rate unit_price +fl_carrier_fba_rate currency +fl_carrier_fba_rate additional_fee +fl_carrier_fba_rate source_sheet_name +fl_carrier_fba_rate source_row_number +fl_carrier_fba_rate source_destination_text +fl_carrier_fba_rate effective_from +fl_carrier_fba_rate effective_to +fl_carrier_fba_rate status +fl_carrier_fba_rate remark +fl_carrier_fba_rate created_by +fl_carrier_fba_rate created_at +fl_carrier_fba_rate updated_by +fl_carrier_fba_rate updated_at +fl_carrier_fba_rate deleted +fl_carrier_fba_rate version +fl_carrier_schedule_exception_fact id +fl_carrier_schedule_exception_fact query_id +fl_carrier_schedule_exception_fact event_key +fl_carrier_schedule_exception_fact provider_code +fl_carrier_schedule_exception_fact provider_event_id +fl_carrier_schedule_exception_fact provider_status +fl_carrier_schedule_exception_fact exception_code +fl_carrier_schedule_exception_fact active +fl_carrier_schedule_exception_fact observed_at +fl_carrier_schedule_exception_fact location_code +fl_carrier_schedule_exception_fact location_name +fl_carrier_schedule_exception_fact source_reference +fl_carrier_schedule_exception_fact raw_payload_hash +fl_carrier_schedule_exception_fact created_at +fl_carrier_schedule_node_fact id +fl_carrier_schedule_node_fact query_id +fl_carrier_schedule_node_fact event_key +fl_carrier_schedule_node_fact provider_code +fl_carrier_schedule_node_fact provider_event_id +fl_carrier_schedule_node_fact provider_node_code +fl_carrier_schedule_node_fact standard_node +fl_carrier_schedule_node_fact node_sequence +fl_carrier_schedule_node_fact observed_at +fl_carrier_schedule_node_fact estimated_at +fl_carrier_schedule_node_fact actual_at +fl_carrier_schedule_node_fact location_code +fl_carrier_schedule_node_fact location_name +fl_carrier_schedule_node_fact source_reference +fl_carrier_schedule_node_fact raw_payload_hash +fl_carrier_schedule_node_fact created_at +fl_carrier_schedule_node_fact container_booking_id +fl_carrier_schedule_query id +fl_carrier_schedule_query query_no +fl_carrier_schedule_query customer_id +fl_carrier_schedule_query ocean_booking_id +fl_carrier_schedule_query container_booking_id +fl_carrier_schedule_query container_scope_id +fl_carrier_schedule_query carrier_code +fl_carrier_schedule_query tracking_type +fl_carrier_schedule_query tracking_no +fl_carrier_schedule_query official_url +fl_carrier_schedule_query access_mode +fl_carrier_schedule_query status +fl_carrier_schedule_query window_started_at +fl_carrier_schedule_query attempt_count +fl_carrier_schedule_query next_allowed_at +fl_carrier_schedule_query last_attempt_at +fl_carrier_schedule_query last_success_at +fl_carrier_schedule_query source_updated_at +fl_carrier_schedule_query expires_at +fl_carrier_schedule_query result_port_schedule_id +fl_carrier_schedule_query last_attempt_status +fl_carrier_schedule_query manual_reason +fl_carrier_schedule_query last_error_code +fl_carrier_schedule_query last_error_message +fl_carrier_schedule_query remark +fl_carrier_schedule_query created_by +fl_carrier_schedule_query created_by_name +fl_carrier_schedule_query created_at +fl_carrier_schedule_query updated_by +fl_carrier_schedule_query updated_by_name +fl_carrier_schedule_query updated_at +fl_carrier_schedule_query version +fl_carrier_schedule_query consecutive_failures +fl_carrier_schedule_query latest_node_observed_at +fl_carrier_schedule_query latest_standard_node +fl_carrier_schedule_query lease_owner +fl_carrier_schedule_query lease_until +fl_carrier_schedule_query stop_reason +fl_carrier_schedule_query stopped_at +fl_carrier_schedule_query trigger_source +fl_carrier_schedule_query_attempt id +fl_carrier_schedule_query_attempt query_id +fl_carrier_schedule_query_attempt window_attempt_no +fl_carrier_schedule_query_attempt carrier_code +fl_carrier_schedule_query_attempt access_mode +fl_carrier_schedule_query_attempt official_url +fl_carrier_schedule_query_attempt attempt_status +fl_carrier_schedule_query_attempt coverage_gap +fl_carrier_schedule_query_attempt coverage_gap_code +fl_carrier_schedule_query_attempt manual_reason +fl_carrier_schedule_query_attempt error_code +fl_carrier_schedule_query_attempt error_message +fl_carrier_schedule_query_attempt operator_id +fl_carrier_schedule_query_attempt operator_name +fl_carrier_schedule_query_attempt created_at +fl_carrier_schedule_query_history id +fl_carrier_schedule_query_history query_id +fl_carrier_schedule_query_history action +fl_carrier_schedule_query_history from_version +fl_carrier_schedule_query_history to_version +fl_carrier_schedule_query_history reason +fl_carrier_schedule_query_history before_snapshot +fl_carrier_schedule_query_history after_snapshot +fl_carrier_schedule_query_history operator_id +fl_carrier_schedule_query_history operator_name +fl_carrier_schedule_query_history created_at +fl_carrier_schedule_query_operation id +fl_carrier_schedule_query_operation query_id +fl_carrier_schedule_query_operation operator_id +fl_carrier_schedule_query_operation operation +fl_carrier_schedule_query_operation idempotency_key +fl_carrier_schedule_query_operation request_hash +fl_carrier_schedule_query_operation status +fl_carrier_schedule_query_operation result_json +fl_carrier_schedule_query_operation created_at +fl_carrier_schedule_query_operation completed_at +fl_coload_command id +fl_coload_command actor_id +fl_coload_command action_type +fl_coload_command idempotency_key +fl_coload_command request_hash +fl_coload_command command_status +fl_coload_command result_group_id +fl_coload_command created_at +fl_coload_command completed_at +fl_coload_group id +fl_coload_group group_no +fl_coload_group group_status +fl_coload_group warehouse_id +fl_coload_group channel_code +fl_coload_group locked_plan_id +fl_coload_group version +fl_coload_group created_by +fl_coload_group created_by_name +fl_coload_group created_at +fl_coload_group updated_at +fl_coload_group locked_by +fl_coload_group locked_by_name +fl_coload_group locked_at +fl_coload_group consumed_by +fl_coload_group consumed_by_name +fl_coload_group consumed_at +fl_coload_group cancelled_by +fl_coload_group cancelled_by_name +fl_coload_group cancelled_at +fl_coload_group_history id +fl_coload_group_history group_id +fl_coload_group_history action_type +fl_coload_group_history from_status +fl_coload_group_history to_status +fl_coload_group_history plan_id +fl_coload_group_history operation_id +fl_coload_group_history remark +fl_coload_group_history operator_id +fl_coload_group_history operator_name +fl_coload_group_history created_at +fl_coload_group_member id +fl_coload_group_member group_id +fl_coload_group_member tenant_id +fl_coload_group_member order_id +fl_coload_group_member inbound_id +fl_coload_group_member full_package_count +fl_coload_group_member active_flag +fl_coload_group_member created_at +fl_coload_group_member closed_at +fl_consumed_event event_id +fl_consumed_event event_type +fl_consumed_event consumed_at +fl_container_booking id +fl_container_booking booking_no +fl_container_booking container_no +fl_container_booking cabinet_no +fl_container_booking channel_code +fl_container_booking channel_name +fl_container_booking ocean_booking_id +fl_container_booking ocean_booking_requirement_id +fl_container_booking warehouse_id +fl_container_booking warehouse_name +fl_container_booking bill_of_lading_no +fl_container_booking vessel_name +fl_container_booking voyage_no +fl_container_booking container_type +fl_container_booking shipping_company_id +fl_container_booking shipping_company_name +fl_container_booking departure_port_code +fl_container_booking departure_port_name +fl_container_booking destination_port_code +fl_container_booking destination_port_name +fl_container_booking destination_warehouse_code +fl_container_booking destination_warehouse_name +fl_container_booking capacity_cbm +fl_container_booking max_weight_kg +fl_container_booking terminal_trucking_company +fl_container_booking loading_at +fl_container_booking estimated_sailing_at +fl_container_booking actual_sailing_at +fl_container_booking estimated_port_arrival_at +fl_container_booking actual_port_arrival_at +fl_container_booking estimated_warehouse_arrival_at +fl_container_booking actual_warehouse_arrival_at +fl_container_booking created_by +fl_container_booking created_by_name +fl_container_booking created_at +fl_container_booking updated_by +fl_container_booking updated_by_name +fl_container_booking updated_at +fl_container_booking version +fl_container_booking source_type +fl_container_booking record_status +fl_container_booking merged_into_container_booking_id +fl_container_booking customer_id +fl_container_booking customer_name +fl_container_booking terminal_trucking_company_id +fl_container_booking unloading_completed_at +fl_container_booking unloading_photo_uploaded_at +fl_container_booking estimated_empty_reported_at +fl_container_booking actual_empty_reported_at +fl_container_booking empty_report_source_version +fl_container_booking channel_group_code +fl_container_booking pickup_code +fl_container_booking pickup_code_updated_by +fl_container_booking pickup_code_updated_by_name +fl_container_booking pickup_code_updated_at +fl_container_document id +fl_container_document container_booking_id +fl_container_document document_type +fl_container_document file_id +fl_container_document file_name +fl_container_document file_hash +fl_container_document file_url +fl_container_document uploaded_by +fl_container_document uploaded_by_name +fl_container_document completed_by +fl_container_document completed_by_name +fl_container_document created_at +fl_container_document updated_at +fl_container_document completed_at +fl_container_document version +fl_container_event_inbox event_id +fl_container_event_inbox event_type +fl_container_event_inbox event_version +fl_container_event_inbox aggregate_id +fl_container_event_inbox aggregate_version +fl_container_event_inbox payload_hash +fl_container_event_inbox payload_json +fl_container_event_inbox status +fl_container_event_inbox received_at +fl_container_event_inbox processed_at +fl_container_exception_history id +fl_container_exception_history container_booking_id +fl_container_exception_history action +fl_container_exception_history reason +fl_container_exception_history operator_id +fl_container_exception_history operator_name +fl_container_exception_history operation_id +fl_container_exception_history request_hash +fl_container_exception_history occurred_at +fl_container_fee_detail id +fl_container_fee_detail tenant_id +fl_container_fee_detail loading_plan_id +fl_container_fee_detail container_booking_id +fl_container_fee_detail container_no +fl_container_fee_detail fee_item_id +fl_container_fee_detail fee_item_code_snapshot +fl_container_fee_detail fee_item_name_snapshot +fl_container_fee_detail direction +fl_container_fee_detail business_date +fl_container_fee_detail amount +fl_container_fee_detail currency +fl_container_fee_detail settlement_included +fl_container_fee_detail review_status +fl_container_fee_detail source_status +fl_container_fee_detail source_version +fl_container_fee_detail remark +fl_container_fee_detail reviewer_id +fl_container_fee_detail reviewer_name +fl_container_fee_detail reviewed_at +fl_container_fee_detail review_reason +fl_container_fee_detail created_by +fl_container_fee_detail created_at +fl_container_fee_detail updated_by +fl_container_fee_detail updated_at +fl_container_fee_detail paid_at +fl_container_fee_detail payment_method +fl_container_fee_detail payment_reference_no +fl_container_fee_detail payment_voucher_file_ids +fl_container_fee_detail settlement_mode +fl_container_fee_detail supplier_category +fl_container_fee_detail supplier_id +fl_container_fee_detail supplier_name +fl_container_fee_detail allocation_group_id +fl_container_fee_detail allocation_order_id +fl_container_fee_detail allocation_order_no +fl_container_fee_detail allocation_inbound_id +fl_container_fee_detail allocation_inbound_code +fl_container_fee_detail allocation_group_amount +fl_container_fee_source_snapshot id +fl_container_fee_source_snapshot source_fee_id +fl_container_fee_source_snapshot source_version +fl_container_fee_source_snapshot tenant_id +fl_container_fee_source_snapshot loading_plan_id +fl_container_fee_source_snapshot container_booking_id +fl_container_fee_source_snapshot container_no +fl_container_fee_source_snapshot fee_item_id +fl_container_fee_source_snapshot fee_item_code +fl_container_fee_source_snapshot fee_item_name +fl_container_fee_source_snapshot direction +fl_container_fee_source_snapshot supplier_id +fl_container_fee_source_snapshot supplier_name +fl_container_fee_source_snapshot supplier_category +fl_container_fee_source_snapshot settlement_mode +fl_container_fee_source_snapshot paid_at +fl_container_fee_source_snapshot payment_method +fl_container_fee_source_snapshot payment_reference_no +fl_container_fee_source_snapshot payment_voucher_file_ids +fl_container_fee_source_snapshot business_date +fl_container_fee_source_snapshot amount +fl_container_fee_source_snapshot currency +fl_container_fee_source_snapshot review_status +fl_container_fee_source_snapshot reviewed_at +fl_container_fee_source_snapshot created_at +fl_container_fee_source_snapshot allocation_group_id +fl_container_fee_source_snapshot allocation_order_id +fl_container_fee_source_snapshot allocation_order_no +fl_container_fee_source_snapshot allocation_inbound_id +fl_container_fee_source_snapshot allocation_inbound_code +fl_container_fee_source_snapshot allocation_group_amount +fl_container_transport_history id +fl_container_transport_history container_booking_id +fl_container_transport_history before_container_no +fl_container_transport_history after_container_no +fl_container_transport_history before_voyage_no +fl_container_transport_history after_voyage_no +fl_container_transport_history reason +fl_container_transport_history operator_id +fl_container_transport_history operator_name +fl_container_transport_history created_at +fl_container_transport_history operation_id +fl_container_transport_history request_hash +fl_customer_charge_rule id +fl_customer_charge_rule tenant_id +fl_customer_charge_rule rule_code +fl_customer_charge_rule rule_version +fl_customer_charge_rule customer_id +fl_customer_charge_rule customer_name +fl_customer_charge_rule business_type +fl_customer_charge_rule charge_mode +fl_customer_charge_rule tax_mode +fl_customer_charge_rule conversion_factor +fl_customer_charge_rule length_factor +fl_customer_charge_rule width_factor +fl_customer_charge_rule height_factor +fl_customer_charge_rule rounding_scale +fl_customer_charge_rule rounding_mode +fl_customer_charge_rule currency +fl_customer_charge_rule effective_from +fl_customer_charge_rule effective_to +fl_customer_charge_rule status +fl_customer_charge_rule default_rule +fl_customer_charge_rule remark +fl_customer_charge_rule created_by +fl_customer_charge_rule created_at +fl_customer_charge_rule updated_by +fl_customer_charge_rule updated_at +fl_customer_charge_rule deleted +fl_customer_charge_rule version +fl_customer_charge_rule initialization_key +fl_customer_dimension_config id +fl_customer_dimension_config tenant_id +fl_customer_dimension_config box_type +fl_customer_dimension_config inner_length_cm +fl_customer_dimension_config inner_width_cm +fl_customer_dimension_config inner_height_cm +fl_customer_dimension_config edge_addition_cm +fl_customer_dimension_config default_calculation_mode +fl_customer_dimension_config remark +fl_customer_dimension_config initialization_key +fl_customer_dimension_config created_by +fl_customer_dimension_config created_at +fl_customer_dimension_config updated_by +fl_customer_dimension_config updated_at +fl_customer_dimension_config deleted +fl_customer_dimension_config version +fl_customs_case id +fl_customs_case customs_no +fl_customs_case tenant_id +fl_customs_case order_id +fl_customs_case inbound_id +fl_customs_case container_booking_id +fl_customs_case checklist_version_id +fl_customs_case checklist_code +fl_customs_case checklist_version_no +fl_customs_case status +fl_customs_case remark +fl_customs_case created_by +fl_customs_case created_by_name +fl_customs_case created_at +fl_customs_case updated_by +fl_customs_case updated_by_name +fl_customs_case updated_at +fl_customs_case version +fl_customs_checklist_version id +fl_customs_checklist_version checklist_code +fl_customs_checklist_version version_no +fl_customs_checklist_version country_code +fl_customs_checklist_version channel_code +fl_customs_checklist_version cargo_type +fl_customs_checklist_version items_json +fl_customs_checklist_version status +fl_customs_checklist_version created_by +fl_customs_checklist_version created_by_name +fl_customs_checklist_version created_at +fl_customs_command id +fl_customs_command actor_id +fl_customs_command action +fl_customs_command idempotency_key +fl_customs_command request_hash +fl_customs_command status +fl_customs_command result_id +fl_customs_command created_at +fl_customs_command completed_at +fl_customs_completion_document id +fl_customs_completion_document source_document_id +fl_customs_completion_document file_id +fl_customs_completion_document file_name +fl_customs_completion_document file_hash +fl_customs_completion_document content_type +fl_customs_completion_document size_bytes +fl_customs_completion_document uploaded_by +fl_customs_completion_document uploaded_by_name +fl_customs_completion_document uploaded_at +fl_customs_completion_document version +fl_customs_completion_document created_at +fl_customs_completion_document updated_at +fl_customs_completion_document_version id +fl_customs_completion_document_version completion_document_id +fl_customs_completion_document_version document_version +fl_customs_completion_document_version file_id +fl_customs_completion_document_version file_name +fl_customs_completion_document_version file_hash +fl_customs_completion_document_version content_type +fl_customs_completion_document_version size_bytes +fl_customs_completion_document_version operator_id +fl_customs_completion_document_version operator_name +fl_customs_completion_document_version created_at +fl_customs_container id +fl_customs_container loading_plan_id +fl_customs_container container_booking_id +fl_customs_container status +fl_customs_container started_by +fl_customs_container started_by_name +fl_customs_container started_at +fl_customs_container completed_by +fl_customs_container completed_by_name +fl_customs_container completed_at +fl_customs_container created_at +fl_customs_container updated_at +fl_customs_container version +fl_customs_container broker_submitted_by +fl_customs_container broker_submitted_by_name +fl_customs_container broker_submitted_at +fl_customs_container_history id +fl_customs_container_history customs_container_id +fl_customs_container_history action +fl_customs_container_history from_status +fl_customs_container_history to_status +fl_customs_container_history remark +fl_customs_container_history operator_id +fl_customs_container_history operator_name +fl_customs_container_history created_at +fl_customs_customer_document id +fl_customs_customer_document customs_container_id +fl_customs_customer_document tenant_id +fl_customs_customer_document file_id +fl_customs_customer_document file_name +fl_customs_customer_document file_hash +fl_customs_customer_document content_type +fl_customs_customer_document size_bytes +fl_customs_customer_document status +fl_customs_customer_document uploaded_by +fl_customs_customer_document uploaded_at +fl_customs_customer_document frozen_version +fl_customs_customer_document frozen_at +fl_customs_customer_document deleted_by +fl_customs_customer_document deleted_at +fl_customs_customer_document version +fl_customs_customer_document created_at +fl_customs_customer_document updated_at +fl_customs_customer_document_inbound document_id +fl_customs_customer_document_inbound inbound_id +fl_customs_customer_document_inbound tenant_id +fl_customs_customer_document_inbound order_id +fl_customs_customer_document_inbound inbound_code +fl_customs_customer_document_inbound created_at +fl_customs_customer_document_version id +fl_customs_customer_document_version document_id +fl_customs_customer_document_version document_version +fl_customs_customer_document_version file_id +fl_customs_customer_document_version file_name +fl_customs_customer_document_version file_hash +fl_customs_customer_document_version content_type +fl_customs_customer_document_version size_bytes +fl_customs_customer_document_version scope_snapshot_json +fl_customs_customer_document_version action +fl_customs_customer_document_version operator_id +fl_customs_customer_document_version created_at +fl_customs_document id +fl_customs_document customs_case_id +fl_customs_document checklist_item_code +fl_customs_document file_id +fl_customs_document document_version +fl_customs_document replaces_document_id +fl_customs_document file_name +fl_customs_document file_hash +fl_customs_document status +fl_customs_document visibility +fl_customs_document uploaded_by +fl_customs_document uploaded_by_type +fl_customs_document uploaded_at +fl_customs_document reviewed_by +fl_customs_document reviewed_by_name +fl_customs_document reviewed_at +fl_customs_document review_reason +fl_customs_final_document id +fl_customs_final_document source_document_id +fl_customs_final_document file_id +fl_customs_final_document file_name +fl_customs_final_document file_hash +fl_customs_final_document content_type +fl_customs_final_document size_bytes +fl_customs_final_document uploaded_by +fl_customs_final_document uploaded_by_name +fl_customs_final_document uploaded_at +fl_customs_final_document version +fl_customs_final_document created_at +fl_customs_final_document updated_at +fl_customs_frozen_document_inbound document_id +fl_customs_frozen_document_inbound inbound_id +fl_customs_frozen_document_inbound tenant_id +fl_customs_frozen_document_inbound order_id +fl_customs_frozen_document_inbound inbound_code +fl_customs_frozen_document_inbound created_at +fl_customs_history id +fl_customs_history customs_case_id +fl_customs_history action +fl_customs_history from_status +fl_customs_history to_status +fl_customs_history resource_type +fl_customs_history resource_id +fl_customs_history note +fl_customs_history operator_id +fl_customs_history operator_name +fl_customs_history operator_type +fl_customs_history created_at +fl_customs_operation id +fl_customs_operation operator_id +fl_customs_operation action +fl_customs_operation idempotency_key +fl_customs_operation request_hash +fl_customs_operation status +fl_customs_operation result_type +fl_customs_operation result_id +fl_customs_operation created_at +fl_customs_operation completed_at +fl_customs_receipt id +fl_customs_receipt customs_case_id +fl_customs_receipt submission_batch_id +fl_customs_receipt receipt_type +fl_customs_receipt file_id +fl_customs_receipt receipt_version +fl_customs_receipt replaces_receipt_id +fl_customs_receipt file_name +fl_customs_receipt file_hash +fl_customs_receipt visibility +fl_customs_receipt note +fl_customs_receipt uploaded_by +fl_customs_receipt uploaded_by_name +fl_customs_receipt uploaded_at +fl_customs_submission_batch id +fl_customs_submission_batch customs_case_id +fl_customs_submission_batch batch_no +fl_customs_submission_batch batch_version +fl_customs_submission_batch customs_agent_id +fl_customs_submission_batch customs_agent_name +fl_customs_submission_batch external_reference +fl_customs_submission_batch file_snapshot_json +fl_customs_submission_batch status +fl_customs_submission_batch note +fl_customs_submission_batch submitted_by +fl_customs_submission_batch submitted_by_name +fl_customs_submission_batch submitted_at +fl_customs_supplement_request id +fl_customs_supplement_request customs_case_id +fl_customs_supplement_request source_document_id +fl_customs_supplement_request resolved_document_id +fl_customs_supplement_request checklist_item_code +fl_customs_supplement_request reason +fl_customs_supplement_request due_at +fl_customs_supplement_request status +fl_customs_supplement_request requested_by +fl_customs_supplement_request requested_by_name +fl_customs_supplement_request requested_at +fl_customs_supplement_request resolved_by +fl_customs_supplement_request resolved_at +fl_direct_delivery_unloading container_booking_id +fl_direct_delivery_unloading inbound_id +fl_direct_delivery_unloading actual_at +fl_direct_delivery_unloading timezone +fl_direct_delivery_unloading received_by +fl_direct_delivery_unloading evidence_no +fl_direct_delivery_unloading remark +fl_direct_delivery_unloading version +fl_direct_delivery_unloading idempotency_key +fl_direct_delivery_unloading request_hash +fl_direct_delivery_unloading created_by +fl_direct_delivery_unloading created_by_name +fl_direct_delivery_unloading created_at +fl_document_sequence sequence_key +fl_document_sequence current_value +fl_dropship_pallet id +fl_dropship_pallet task_id +fl_dropship_pallet tenant_id +fl_dropship_pallet inbound_id +fl_dropship_pallet warehouse_id +fl_dropship_pallet pallet_no +fl_dropship_pallet pallet_status +fl_dropship_pallet open_operation_id +fl_dropship_pallet seal_operation_id +fl_dropship_pallet created_by +fl_dropship_pallet created_by_name +fl_dropship_pallet completed_by +fl_dropship_pallet completed_by_name +fl_dropship_pallet completed_at +fl_dropship_pallet version +fl_dropship_pallet created_at +fl_dropship_pallet updated_at +fl_dropship_pallet_carton id +fl_dropship_pallet_carton task_id +fl_dropship_pallet_carton pallet_id +fl_dropship_pallet_carton tenant_id +fl_dropship_pallet_carton inbound_id +fl_dropship_pallet_carton inbound_detail_id +fl_dropship_pallet_carton carton_label_id +fl_dropship_pallet_carton sku_code +fl_dropship_pallet_carton cargo_name +fl_dropship_pallet_carton box_code +fl_dropship_pallet_carton expected_sku_quantity +fl_dropship_pallet_carton actual_sku_quantity +fl_dropship_pallet_carton discrepancy_status +fl_dropship_pallet_carton discrepancy_remark +fl_dropship_pallet_carton count_operation_id +fl_dropship_pallet_carton bind_operation_id +fl_dropship_pallet_carton last_operation_id +fl_dropship_pallet_carton counted_by +fl_dropship_pallet_carton counted_by_name +fl_dropship_pallet_carton counted_at +fl_dropship_pallet_carton bound_by +fl_dropship_pallet_carton bound_by_name +fl_dropship_pallet_carton bound_at +fl_dropship_pallet_carton_sku id +fl_dropship_pallet_carton_sku carton_id +fl_dropship_pallet_carton_sku inbound_detail_id +fl_dropship_pallet_carton_sku sku_code +fl_dropship_pallet_carton_sku cargo_name +fl_dropship_pallet_carton_sku expected_quantity +fl_dropship_pallet_carton_sku actual_quantity +fl_dropship_pallet_carton_sku discrepancy_status +fl_dropship_pallet_carton_sku discrepancy_remark +fl_dropship_pallet_carton_sku line_no +fl_dropship_pallet_task id +fl_dropship_pallet_task tenant_id +fl_dropship_pallet_task inbound_id +fl_dropship_pallet_task warehouse_id +fl_dropship_pallet_task task_status +fl_dropship_pallet_task total_cartons +fl_dropship_pallet_task counted_cartons +fl_dropship_pallet_task allocated_cartons +fl_dropship_pallet_task discrepancy_cartons +fl_dropship_pallet_task assignee_id +fl_dropship_pallet_task assignee_name +fl_dropship_pallet_task claimed_at +fl_dropship_pallet_task completed_at +fl_dropship_pallet_task version +fl_dropship_pallet_task created_at +fl_dropship_pallet_task updated_at +fl_fee_item id +fl_fee_item tenant_id +fl_fee_item category +fl_fee_item trade_scope +fl_fee_item fee_code +fl_fee_item fee_type_name +fl_fee_item pickup_payable +fl_fee_item pickup_receivable +fl_fee_item warehouse_payable +fl_fee_item warehouse_receivable +fl_fee_item allocation_fee +fl_fee_item description +fl_fee_item status +fl_fee_item created_by +fl_fee_item created_by_name +fl_fee_item created_at +fl_fee_item updated_by +fl_fee_item updated_by_name +fl_fee_item updated_at +fl_fee_item deleted +fl_fee_item version +fl_fee_item supplier_category +fl_finance_fee_source_command id +fl_finance_fee_source_command customer_id +fl_finance_fee_source_command operator_id +fl_finance_fee_source_command operator_name +fl_finance_fee_source_command action +fl_finance_fee_source_command idempotency_key +fl_finance_fee_source_command request_hash +fl_finance_fee_source_command source_type +fl_finance_fee_source_command source_fee_id +fl_finance_fee_source_command source_version +fl_finance_fee_source_command inbound_id +fl_finance_fee_source_command container_id +fl_finance_fee_source_command reason +fl_finance_fee_source_command status +fl_finance_fee_source_command result_status +fl_finance_fee_source_command created_at +fl_finance_fee_source_command completed_at +fl_fulfillment_task id +fl_fulfillment_task tenant_id +fl_fulfillment_task order_id +fl_fulfillment_task order_no +fl_fulfillment_task order_snapshot_json +fl_fulfillment_task status +fl_fulfillment_task assignee_id +fl_fulfillment_task assignee_name +fl_fulfillment_task assigned_at +fl_fulfillment_task started_at +fl_fulfillment_task completed_at +fl_fulfillment_task created_by +fl_fulfillment_task created_at +fl_fulfillment_task updated_by +fl_fulfillment_task updated_at +fl_fulfillment_task deleted +fl_fulfillment_task version +fl_inbound_carton_label id +fl_inbound_carton_label tenant_id +fl_inbound_carton_label inbound_id +fl_inbound_carton_label order_id +fl_inbound_carton_label inbound_detail_id +fl_inbound_carton_label cargo_item_no +fl_inbound_carton_label cargo_name +fl_inbound_carton_label sku_code +fl_inbound_carton_label units_per_carton +fl_inbound_carton_label detail_package_count +fl_inbound_carton_label inbound_code +fl_inbound_carton_label box_code +fl_inbound_carton_label total_carton_count +fl_inbound_carton_label carton_index +fl_inbound_carton_label label_version +fl_inbound_carton_label batch_status +fl_inbound_carton_label cargo_fingerprint +fl_inbound_carton_label voided_at +fl_inbound_carton_label box_specification +fl_inbound_carton_label length_cm +fl_inbound_carton_label width_cm +fl_inbound_carton_label height_cm +fl_inbound_carton_label created_at +fl_inbound_carton_label_sku id +fl_inbound_carton_label_sku tenant_id +fl_inbound_carton_label_sku inbound_id +fl_inbound_carton_label_sku carton_label_id +fl_inbound_carton_label_sku inbound_detail_id +fl_inbound_carton_label_sku sku_code +fl_inbound_carton_label_sku cargo_name +fl_inbound_carton_label_sku expected_quantity +fl_inbound_carton_label_sku line_no +fl_inbound_carton_label_sku created_at +fl_inbound_destination_snapshot id +fl_inbound_destination_snapshot tenant_id +fl_inbound_destination_snapshot inbound_id +fl_inbound_destination_snapshot destination_type +fl_inbound_destination_snapshot destination_code +fl_inbound_destination_snapshot destination_name +fl_inbound_destination_snapshot country_code +fl_inbound_destination_snapshot state_province +fl_inbound_destination_snapshot city +fl_inbound_destination_snapshot address_line +fl_inbound_destination_snapshot postal_code +fl_inbound_destination_snapshot contact_name +fl_inbound_destination_snapshot contact_phone +fl_inbound_destination_snapshot created_at +fl_inbound_fee id +fl_inbound_fee tenant_id +fl_inbound_fee inbound_id +fl_inbound_fee measurement_id +fl_inbound_fee measurement_source +fl_inbound_fee fee_no +fl_inbound_fee fee_version +fl_inbound_fee charge_rule_id +fl_inbound_fee charge_rule_version +fl_inbound_fee carrier_rate_id +fl_inbound_fee carrier_rate_version +fl_inbound_fee price_book_version_id +fl_inbound_fee rule_code +fl_inbound_fee customer_id +fl_inbound_fee customer_name +fl_inbound_fee business_type +fl_inbound_fee tax_mode +fl_inbound_fee pricing_tax_mode +fl_inbound_fee rounding_scale +fl_inbound_fee rounding_mode +fl_inbound_fee rule_effective_from +fl_inbound_fee rule_effective_to +fl_inbound_fee rate_code +fl_inbound_fee origin_warehouse_id +fl_inbound_fee origin_warehouse_name +fl_inbound_fee channel_code +fl_inbound_fee carrier_id +fl_inbound_fee carrier_name +fl_inbound_fee destination_type +fl_inbound_fee destination_warehouse_id +fl_inbound_fee destination_name +fl_inbound_fee destination_code +fl_inbound_fee rate_effective_from +fl_inbound_fee rate_effective_to +fl_inbound_fee charge_mode +fl_inbound_fee conversion_factor +fl_inbound_fee actual_weight_kg +fl_inbound_fee actual_volume_cbm +fl_inbound_fee converted_quantity +fl_inbound_fee chargeable_quantity +fl_inbound_fee charge_unit +fl_inbound_fee currency +fl_inbound_fee subtotal_amount +fl_inbound_fee additional_amount +fl_inbound_fee total_amount +fl_inbound_fee fee_status +fl_inbound_fee review_status +fl_inbound_fee exception_release +fl_inbound_fee calculated_by +fl_inbound_fee calculated_at +fl_inbound_fee confirmed_by +fl_inbound_fee confirmed_at +fl_inbound_fee sent_by +fl_inbound_fee sent_at +fl_inbound_fee customer_confirmed_by +fl_inbound_fee customer_confirmed_by_name +fl_inbound_fee customer_confirmed_at +fl_inbound_fee created_at +fl_inbound_fee updated_at +fl_inbound_fee baseline_chargeable_quantity +fl_inbound_fee tolerance_percent +fl_inbound_fee_detail id +fl_inbound_fee_detail tenant_id +fl_inbound_fee_detail inbound_id +fl_inbound_fee_detail fee_item_id +fl_inbound_fee_detail source_type +fl_inbound_fee_detail source_fee_id +fl_inbound_fee_detail source_fee_version +fl_inbound_fee_detail source_fee_no +fl_inbound_fee_detail inbound_code +fl_inbound_fee_detail direction +fl_inbound_fee_detail business_date +fl_inbound_fee_detail customer_id +fl_inbound_fee_detail customer_name +fl_inbound_fee_detail invoice_title +fl_inbound_fee_detail fee_category_snapshot +fl_inbound_fee_detail trade_scope_snapshot +fl_inbound_fee_detail fee_type_name_snapshot +fl_inbound_fee_detail channel_code +fl_inbound_fee_detail channel_name +fl_inbound_fee_detail destination_code +fl_inbound_fee_detail destination_name +fl_inbound_fee_detail package_count +fl_inbound_fee_detail actual_gross_weight_kg +fl_inbound_fee_detail actual_volume_cbm +fl_inbound_fee_detail volume_weight_kg +fl_inbound_fee_detail chargeable_weight_kg +fl_inbound_fee_detail chargeable_volume_cbm +fl_inbound_fee_detail chargeable_quantity +fl_inbound_fee_detail charge_unit +fl_inbound_fee_detail unit_price +fl_inbound_fee_detail currency +fl_inbound_fee_detail amount +fl_inbound_fee_detail remark +fl_inbound_fee_detail review_status +fl_inbound_fee_detail reviewer_id +fl_inbound_fee_detail reviewer_name +fl_inbound_fee_detail reviewed_at +fl_inbound_fee_detail review_reason +fl_inbound_fee_detail publication_status +fl_inbound_fee_detail published_by +fl_inbound_fee_detail published_by_name +fl_inbound_fee_detail published_at +fl_inbound_fee_detail inbounded_at +fl_inbound_fee_detail settlement_included +fl_inbound_fee_detail created_by +fl_inbound_fee_detail created_at +fl_inbound_fee_detail updated_by +fl_inbound_fee_detail updated_at +fl_inbound_fee_detail deleted +fl_inbound_fee_detail version +fl_inbound_fee_detail paid_at +fl_inbound_fee_detail payment_method +fl_inbound_fee_detail payment_reference_no +fl_inbound_fee_detail payment_voucher_file_ids +fl_inbound_fee_detail settlement_mode +fl_inbound_fee_detail supplier_category +fl_inbound_fee_detail supplier_id +fl_inbound_fee_detail supplier_name +fl_inbound_fee_item id +fl_inbound_fee_item tenant_id +fl_inbound_fee_item fee_id +fl_inbound_fee_item item_no +fl_inbound_fee_item fee_code +fl_inbound_fee_item fee_name +fl_inbound_fee_item quantity +fl_inbound_fee_item unit +fl_inbound_fee_item unit_price +fl_inbound_fee_item amount +fl_inbound_fee_item currency +fl_inbound_fee_item automatic_flag +fl_inbound_fee_item remark +fl_inbound_fee_item created_at +fl_inbound_file id +fl_inbound_file tenant_id +fl_inbound_file inbound_id +fl_inbound_file file_id +fl_inbound_file file_type +fl_inbound_file file_name_snapshot +fl_inbound_file visibility +fl_inbound_file file_version +fl_inbound_file uploaded_by +fl_inbound_file uploaded_at +fl_inbound_file deleted +fl_inbound_hold_record id +fl_inbound_hold_record tenant_id +fl_inbound_hold_record inbound_id +fl_inbound_hold_record hold_action +fl_inbound_hold_record reason +fl_inbound_hold_record evidence_file_id +fl_inbound_hold_record operator_id +fl_inbound_hold_record operator_name +fl_inbound_hold_record operated_at +fl_inbound_idempotency id +fl_inbound_idempotency tenant_id +fl_inbound_idempotency operation +fl_inbound_idempotency operator_id +fl_inbound_idempotency inbound_id +fl_inbound_idempotency idempotency_key +fl_inbound_idempotency request_hash +fl_inbound_idempotency status +fl_inbound_idempotency result_json +fl_inbound_idempotency result_resource_id +fl_inbound_idempotency completed_at +fl_inbound_idempotency created_at +fl_inbound_idempotency updated_at +fl_inbound_internal_note id +fl_inbound_internal_note tenant_id +fl_inbound_internal_note inbound_id +fl_inbound_internal_note content +fl_inbound_internal_note created_by +fl_inbound_internal_note created_by_name +fl_inbound_internal_note created_at +fl_inbound_loading_pick_task id +fl_inbound_loading_pick_task tenant_id +fl_inbound_loading_pick_task inbound_id +fl_inbound_loading_pick_task status +fl_inbound_loading_pick_task version +fl_inbound_loading_pick_task expected_count +fl_inbound_loading_pick_task picked_count +fl_inbound_loading_pick_task remark +fl_inbound_loading_pick_task updated_by +fl_inbound_loading_pick_task updated_by_name +fl_inbound_loading_pick_task created_at +fl_inbound_loading_pick_task updated_at +fl_inbound_measurement id +fl_inbound_measurement tenant_id +fl_inbound_measurement inbound_id +fl_inbound_measurement split_batch_id +fl_inbound_measurement source_measurement_id +fl_inbound_measurement source_type +fl_inbound_measurement source_code +fl_inbound_measurement external_event_no +fl_inbound_measurement external_request_hash +fl_inbound_measurement device_code +fl_inbound_measurement warehouse_code +fl_inbound_measurement measured_at +fl_inbound_measurement status +fl_inbound_measurement raw_payload_json +fl_inbound_measurement original_document_no +fl_inbound_measurement original_gross_weight_kg +fl_inbound_measurement original_volume_cbm +fl_inbound_measurement actual_goods_name +fl_inbound_measurement total_package_count +fl_inbound_measurement total_volume_raw_cbm +fl_inbound_measurement total_volume_cbm +fl_inbound_measurement volume_weight_kg +fl_inbound_measurement created_by +fl_inbound_measurement created_at +fl_inbound_measurement updated_at +fl_inbound_measurement version +fl_inbound_measurement_item id +fl_inbound_measurement_item tenant_id +fl_inbound_measurement_item measurement_id +fl_inbound_measurement_item split_item_id +fl_inbound_measurement_item source_measurement_item_id +fl_inbound_measurement_item inbound_detail_id +fl_inbound_measurement_item external_detail_no +fl_inbound_measurement_item box_code +fl_inbound_measurement_item scanned_shipment_id +fl_inbound_measurement_item item_no +fl_inbound_measurement_item box_specification +fl_inbound_measurement_item package_count +fl_inbound_measurement_item length_cm +fl_inbound_measurement_item width_cm +fl_inbound_measurement_item height_cm +fl_inbound_measurement_item girth_cm +fl_inbound_measurement_item unit_weight_kg +fl_inbound_measurement_item unit_volume_cbm +fl_inbound_measurement_item volume_raw_cbm +fl_inbound_measurement_item volume_cbm +fl_inbound_measurement_item total_weight_kg +fl_inbound_measurement_item total_volume_cbm +fl_inbound_measurement_item volume_weight_kg +fl_inbound_measurement_item charge_rule_id +fl_inbound_measurement_item coefficient_mode +fl_inbound_measurement_item calculation_rule_version +fl_inbound_measurement_item source_image_file_id +fl_inbound_measurement_item machine_no +fl_inbound_measurement_item device_measured_at +fl_inbound_measurement_item over_limit_warning +fl_inbound_measurement_item outer_length_cm +fl_inbound_measurement_item outer_width_cm +fl_inbound_measurement_item outer_height_cm +fl_inbound_measurement_item manual_outer_length_cm +fl_inbound_measurement_item manual_outer_width_cm +fl_inbound_measurement_item manual_outer_height_cm +fl_inbound_measurement_item remark +fl_inbound_measurement_item created_at +fl_inbound_measurement_item updated_at +fl_inbound_order id +fl_inbound_order tenant_id +fl_inbound_order task_id +fl_inbound_order order_id +fl_inbound_order order_no +fl_inbound_order inbound_code +fl_inbound_order inbound_role +fl_inbound_order parent_inbound_id +fl_inbound_order root_inbound_id +fl_inbound_order split_sequence +fl_inbound_order split_status +fl_inbound_order process_status +fl_inbound_order order_status_snapshot +fl_inbound_order measure_review_status +fl_inbound_order fee_status +fl_inbound_order fee_review_status +fl_inbound_order notification_status +fl_inbound_order expected_arrival_at +fl_inbound_order customer_id +fl_inbound_order customer_name +fl_inbound_order company_id +fl_inbound_order company_name +fl_inbound_order warehouse_id +fl_inbound_order warehouse_name +fl_inbound_order delivery_warehouse_id +fl_inbound_order delivery_warehouse_name +fl_inbound_order business_type +fl_inbound_order fulfillment_type +fl_inbound_order lx_code +fl_inbound_order channel_code +fl_inbound_order channel_name +fl_inbound_order carrier_id +fl_inbound_order carrier_name +fl_inbound_order tax_mode +fl_inbound_order pricing_tax_mode +fl_inbound_order container_mode +fl_inbound_order customs_inspection +fl_inbound_order destination_type +fl_inbound_order destination_warehouse_id +fl_inbound_order destination_code +fl_inbound_order destination_name +fl_inbound_order hold_flag +fl_inbound_order current_hold_reason +fl_inbound_order last_overseas_hold_version +fl_inbound_order total_package_count +fl_inbound_order package_count +fl_inbound_order split_purpose +fl_inbound_order actual_gross_weight_kg +fl_inbound_order chargeable_weight_kg +fl_inbound_order actual_volume_cbm +fl_inbound_order chargeable_volume_cbm +fl_inbound_order pallet_count +fl_inbound_order volume_weight_kg +fl_inbound_order actual_net_weight_kg +fl_inbound_order measure_completed_at +fl_inbound_order inbounded_at +fl_inbound_order express_company_code +fl_inbound_order express_company_name +fl_inbound_order express_tracking_no +fl_inbound_order public_remark +fl_inbound_order created_by +fl_inbound_order created_by_name +fl_inbound_order created_at +fl_inbound_order updated_by +fl_inbound_order updated_at +fl_inbound_order deleted +fl_inbound_order version +fl_inbound_order master_order_id +fl_inbound_order active_remainder_root_id +fl_inbound_outbox id +fl_inbound_outbox tenant_id +fl_inbound_outbox aggregate_type +fl_inbound_outbox aggregate_id +fl_inbound_outbox event_type +fl_inbound_outbox event_key +fl_inbound_outbox event_version +fl_inbound_outbox aggregate_sequence +fl_inbound_outbox payload_hash +fl_inbound_outbox payload_json +fl_inbound_outbox publish_status +fl_inbound_outbox retry_count +fl_inbound_outbox next_retry_at +fl_inbound_outbox published_at +fl_inbound_outbox locked_at +fl_inbound_outbox locked_by +fl_inbound_outbox last_error +fl_inbound_outbox created_at +fl_inbound_pallet_allocation id +fl_inbound_pallet_allocation tenant_id +fl_inbound_pallet_allocation inbound_id +fl_inbound_pallet_allocation allocation_no +fl_inbound_pallet_allocation block_id +fl_inbound_pallet_allocation slot_no +fl_inbound_pallet_allocation weight_result_id +fl_inbound_pallet_allocation pallet_sequence +fl_inbound_pallet_allocation warehouse_id +fl_inbound_pallet_allocation warehouse_resource_tenant_id +fl_inbound_pallet_allocation pallet_id +fl_inbound_pallet_allocation pallet_code +fl_inbound_pallet_allocation pallet_capacity_cbm +fl_inbound_pallet_allocation allocated_volume_cbm +fl_inbound_pallet_allocation allocated_package_count +fl_inbound_pallet_allocation picked_package_count +fl_inbound_pallet_allocation location_id +fl_inbound_pallet_allocation location_code +fl_inbound_pallet_allocation shared_overflow +fl_inbound_pallet_allocation reservation_id +fl_inbound_pallet_allocation slot_status +fl_inbound_pallet_allocation reservation_no +fl_inbound_pallet_allocation allocation_status +fl_inbound_pallet_allocation created_at +fl_inbound_pallet_allocation updated_at +fl_inbound_pickup_snapshot id +fl_inbound_pickup_snapshot tenant_id +fl_inbound_pickup_snapshot inbound_id +fl_inbound_pickup_snapshot pickup_method +fl_inbound_pickup_snapshot pickup_at +fl_inbound_pickup_snapshot contact_name +fl_inbound_pickup_snapshot contact_phone +fl_inbound_pickup_snapshot contact_email +fl_inbound_pickup_snapshot country_code +fl_inbound_pickup_snapshot province +fl_inbound_pickup_snapshot city +fl_inbound_pickup_snapshot district +fl_inbound_pickup_snapshot address_line +fl_inbound_pickup_snapshot postal_code +fl_inbound_pickup_snapshot created_at +fl_inbound_split_batch id +fl_inbound_split_batch tenant_id +fl_inbound_split_batch parent_inbound_id +fl_inbound_split_batch source_inbound_id +fl_inbound_split_batch operation_id +fl_inbound_split_batch idempotency_key +fl_inbound_split_batch expected_parent_version +fl_inbound_split_batch package_count +fl_inbound_split_batch loaded_child_inbound_id +fl_inbound_split_batch remainder_child_inbound_id +fl_inbound_split_batch loaded_package_count +fl_inbound_split_batch remaining_package_count +fl_inbound_split_batch loading_plan_id +fl_inbound_split_batch container_booking_id +fl_inbound_split_batch status +fl_inbound_split_batch remark +fl_inbound_split_batch created_by +fl_inbound_split_batch created_by_name +fl_inbound_split_batch created_at +fl_inbound_split_batch applied_at +fl_inbound_split_batch version +fl_inbound_split_detail id +fl_inbound_split_detail tenant_id +fl_inbound_split_detail parent_inbound_id +fl_inbound_split_detail child_inbound_id +fl_inbound_split_detail inbound_detail_id +fl_inbound_split_detail source_package_count +fl_inbound_split_detail locked_package_count +fl_inbound_split_detail remaining_package_count +fl_inbound_split_detail status +fl_inbound_split_detail operation_id +fl_inbound_split_detail created_by +fl_inbound_split_detail created_by_name +fl_inbound_split_detail completed_by +fl_inbound_split_detail completed_by_name +fl_inbound_split_detail completed_at +fl_inbound_split_detail created_at +fl_inbound_split_detail updated_at +fl_inbound_split_detail version +fl_inbound_split_history id +fl_inbound_split_history tenant_id +fl_inbound_split_history split_batch_id +fl_inbound_split_history operation_id +fl_inbound_split_history action_type +fl_inbound_split_history before_snapshot +fl_inbound_split_history after_snapshot +fl_inbound_split_history operator_id +fl_inbound_split_history operator_name +fl_inbound_split_history operation_reason +fl_inbound_split_history created_at +fl_inbound_split_item id +fl_inbound_split_item tenant_id +fl_inbound_split_item split_batch_id +fl_inbound_split_item parent_inbound_id +fl_inbound_split_item child_inbound_id +fl_inbound_split_item line_no +fl_inbound_split_item source_inbound_detail_id +fl_inbound_split_item source_measurement_id +fl_inbound_split_item source_measurement_item_id +fl_inbound_split_item package_count +fl_inbound_split_item created_at +fl_inbound_status_history id +fl_inbound_status_history tenant_id +fl_inbound_status_history inbound_id +fl_inbound_status_history from_status +fl_inbound_status_history to_status +fl_inbound_status_history action +fl_inbound_status_history reason +fl_inbound_status_history operator_id +fl_inbound_status_history operator_name +fl_inbound_status_history occurred_at +fl_inbound_transport_snapshot id +fl_inbound_transport_snapshot tenant_id +fl_inbound_transport_snapshot inbound_id +fl_inbound_transport_snapshot loading_status +fl_inbound_transport_snapshot container_no +fl_inbound_transport_snapshot cabinet_no +fl_inbound_transport_snapshot vessel_name +fl_inbound_transport_snapshot voyage_no +fl_inbound_transport_snapshot sailing_at +fl_inbound_transport_snapshot port_arrival_at +fl_inbound_transport_snapshot source_updated_at +fl_inbound_transport_snapshot created_at +fl_inbound_transport_snapshot updated_at +fl_inbound_weight_result id +fl_inbound_weight_result tenant_id +fl_inbound_weight_result inbound_id +fl_inbound_weight_result file_id +fl_inbound_weight_result pallet_id +fl_inbound_weight_result pallet_code +fl_inbound_weight_result pallet_type +fl_inbound_weight_result pallet_capacity_cbm +fl_inbound_weight_result pallet_tare_weight_kg +fl_inbound_weight_result ocr_provider +fl_inbound_weight_result ocr_raw_text +fl_inbound_weight_result ocr_weight_value +fl_inbound_weight_result ocr_weight_unit +fl_inbound_weight_result confidence +fl_inbound_weight_result adjusted_weight_value +fl_inbound_weight_result adjusted_weight_unit +fl_inbound_weight_result gross_weight_kg +fl_inbound_weight_result net_weight_kg +fl_inbound_weight_result recognition_status +fl_inbound_weight_result adjustment_reason +fl_inbound_weight_result adjusted_by +fl_inbound_weight_result adjusted_at +fl_inbound_weight_result confirmed_by +fl_inbound_weight_result confirmed_at +fl_inbound_weight_result created_by +fl_inbound_weight_result created_at +fl_inbound_weight_result updated_by +fl_inbound_weight_result updated_at +fl_inbound_weight_result deleted +fl_inbound_weight_result version +fl_inbound_weight_revision id +fl_inbound_weight_revision tenant_id +fl_inbound_weight_revision inbound_id +fl_inbound_weight_revision weight_result_id +fl_inbound_weight_revision revision_no +fl_inbound_weight_revision action +fl_inbound_weight_revision before_weight_value +fl_inbound_weight_revision before_weight_unit +fl_inbound_weight_revision after_weight_value +fl_inbound_weight_revision after_weight_unit +fl_inbound_weight_revision gross_weight_kg +fl_inbound_weight_revision net_weight_kg +fl_inbound_weight_revision reason +fl_inbound_weight_revision operator_id +fl_inbound_weight_revision operator_name +fl_inbound_weight_revision operated_at +fl_loading_cabinet_evidence id +fl_loading_cabinet_evidence container_booking_id +fl_loading_cabinet_evidence source_task_id +fl_loading_cabinet_evidence evidence_type +fl_loading_cabinet_evidence file_id +fl_loading_cabinet_evidence file_name +fl_loading_cabinet_evidence seal_no +fl_loading_cabinet_evidence remark +fl_loading_cabinet_evidence status +fl_loading_cabinet_evidence active_flag +fl_loading_cabinet_evidence operation_key +fl_loading_cabinet_evidence request_hash +fl_loading_cabinet_evidence void_reason +fl_loading_cabinet_evidence voided_by +fl_loading_cabinet_evidence voided_by_name +fl_loading_cabinet_evidence voided_at +fl_loading_cabinet_evidence version +fl_loading_cabinet_evidence created_by +fl_loading_cabinet_evidence created_by_name +fl_loading_cabinet_evidence updated_by +fl_loading_cabinet_evidence updated_by_name +fl_loading_cabinet_evidence created_at +fl_loading_cabinet_evidence updated_at +fl_loading_completion_checkpoint command_id +fl_loading_completion_checkpoint tenant_id +fl_loading_completion_checkpoint inbound_id +fl_loading_completion_checkpoint owner_sequence +fl_loading_completion_checkpoint checkpoint_stage +fl_loading_completion_checkpoint created_at +fl_loading_completion_checkpoint updated_at +fl_loading_completion_command id +fl_loading_completion_command pick_task_id +fl_loading_completion_command operation_id +fl_loading_completion_command request_hash +fl_loading_completion_command command_status +fl_loading_completion_command expected_task_version +fl_loading_completion_command remark +fl_loading_completion_command operator_id +fl_loading_completion_command operator_name +fl_loading_completion_command attempt_count +fl_loading_completion_command next_retry_at +fl_loading_completion_command lease_owner +fl_loading_completion_command lease_until +fl_loading_completion_command failure_stage +fl_loading_completion_command failure_code +fl_loading_completion_command failure_request_id +fl_loading_completion_command failure_trace_id +fl_loading_completion_command created_at +fl_loading_completion_command updated_at +fl_loading_completion_command completed_at +fl_loading_evidence id +fl_loading_evidence tenant_id +fl_loading_evidence inbound_id +fl_loading_evidence evidence_type +fl_loading_evidence file_id +fl_loading_evidence file_name +fl_loading_evidence seal_no +fl_loading_evidence remark +fl_loading_evidence version +fl_loading_evidence created_by +fl_loading_evidence created_by_name +fl_loading_evidence updated_by +fl_loading_evidence updated_by_name +fl_loading_evidence created_at +fl_loading_evidence updated_at +fl_loading_evidence status +fl_loading_evidence void_reason +fl_loading_evidence voided_by +fl_loading_evidence voided_by_name +fl_loading_evidence voided_at +fl_loading_operation id +fl_loading_operation tenant_id +fl_loading_operation inbound_id +fl_loading_operation operator_id +fl_loading_operation operation +fl_loading_operation idempotency_key +fl_loading_operation request_hash +fl_loading_operation status +fl_loading_operation result_json +fl_loading_operation created_at +fl_loading_operation completed_at +fl_loading_pallet_label id +fl_loading_pallet_label loading_plan_id +fl_loading_pallet_label loading_plan_item_id +fl_loading_pallet_label account_id +fl_loading_pallet_label inbound_id +fl_loading_pallet_label inbound_code +fl_loading_pallet_label container_no +fl_loading_pallet_label destination_warehouse_code +fl_loading_pallet_label destination_warehouse_name +fl_loading_pallet_label pallet_index +fl_loading_pallet_label pallet_code +fl_loading_pallet_label created_at +fl_loading_pick_assignment pick_task_id +fl_loading_pick_assignment assignee_id +fl_loading_pick_assignment assignee_name +fl_loading_pick_assignment assigned_by +fl_loading_pick_assignment assigned_by_name +fl_loading_pick_assignment assignment_type +fl_loading_pick_assignment version +fl_loading_pick_assignment created_at +fl_loading_pick_assignment updated_at +fl_loading_pick_history id +fl_loading_pick_history pick_task_id +fl_loading_pick_history pick_item_id +fl_loading_pick_history operation_id +fl_loading_pick_history request_hash +fl_loading_pick_history action_type +fl_loading_pick_history picked_package_count +fl_loading_pick_history operation_reason +fl_loading_pick_history operator_id +fl_loading_pick_history operator_name +fl_loading_pick_history created_at +fl_loading_pick_item id +fl_loading_pick_item pick_task_id +fl_loading_pick_item loading_plan_item_id +fl_loading_pick_item inbound_id +fl_loading_pick_item inbound_detail_id +fl_loading_pick_item planned_package_count +fl_loading_pick_item picked_package_count +fl_loading_pick_item item_status +fl_loading_pick_item release_mode +fl_loading_pick_item release_location_ids +fl_loading_pick_item version +fl_loading_pick_item created_at +fl_loading_pick_item updated_at +fl_loading_pick_item_assignment pick_item_id +fl_loading_pick_item_assignment assignee_id +fl_loading_pick_item_assignment assignee_name +fl_loading_pick_item_assignment assigned_by +fl_loading_pick_item_assignment assigned_by_name +fl_loading_pick_item_assignment assignment_type +fl_loading_pick_item_assignment version +fl_loading_pick_item_assignment created_at +fl_loading_pick_item_assignment updated_at +fl_loading_pick_operation id +fl_loading_pick_operation pick_task_id +fl_loading_pick_operation operation_id +fl_loading_pick_operation request_hash +fl_loading_pick_operation action_type +fl_loading_pick_operation operator_id +fl_loading_pick_operation operator_name +fl_loading_pick_operation created_at +fl_loading_pick_review pick_task_id +fl_loading_pick_review review_status +fl_loading_pick_review reviewed_by +fl_loading_pick_review reviewed_by_name +fl_loading_pick_review remark +fl_loading_pick_review version +fl_loading_pick_review created_at +fl_loading_pick_review updated_at +fl_loading_pick_task id +fl_loading_pick_task loading_plan_id +fl_loading_pick_task container_booking_id +fl_loading_pick_task cabinet_no +fl_loading_pick_task container_no +fl_loading_pick_task task_status +fl_loading_pick_task started_by +fl_loading_pick_task started_by_name +fl_loading_pick_task started_at +fl_loading_pick_task completed_by +fl_loading_pick_task completed_by_name +fl_loading_pick_task completed_at +fl_loading_pick_task version +fl_loading_plan id +fl_loading_plan plan_no +fl_loading_plan container_booking_id +fl_loading_plan plan_status +fl_loading_plan push_idempotency_key +fl_loading_plan preload_pushed_by +fl_loading_plan preload_pushed_by_name +fl_loading_plan preload_pushed_at +fl_loading_plan preload_completed_by +fl_loading_plan preload_completed_by_name +fl_loading_plan preload_completed_at +fl_loading_plan confirmed_by +fl_loading_plan confirmed_by_name +fl_loading_plan confirmed_at +fl_loading_plan remark +fl_loading_plan created_by +fl_loading_plan created_by_name +fl_loading_plan created_at +fl_loading_plan updated_by +fl_loading_plan updated_by_name +fl_loading_plan updated_at +fl_loading_plan version +fl_loading_plan_history id +fl_loading_plan_history loading_plan_id +fl_loading_plan_history loading_plan_item_id +fl_loading_plan_history operation_id +fl_loading_plan_history action_type +fl_loading_plan_history from_status +fl_loading_plan_history to_status +fl_loading_plan_history before_snapshot +fl_loading_plan_history after_snapshot +fl_loading_plan_history operator_id +fl_loading_plan_history operator_name +fl_loading_plan_history operation_reason +fl_loading_plan_history created_at +fl_loading_plan_item id +fl_loading_plan_item loading_plan_id +fl_loading_plan_item account_id +fl_loading_plan_item customer_name +fl_loading_plan_item inbound_id +fl_loading_plan_item inbound_detail_id +fl_loading_plan_item order_id +fl_loading_plan_item order_no +fl_loading_plan_item inbound_code +fl_loading_plan_item destination_warehouse_id +fl_loading_plan_item destination_warehouse_code +fl_loading_plan_item destination_warehouse_name +fl_loading_plan_item sku_code +fl_loading_plan_item product_name_zh +fl_loading_plan_item product_name_en +fl_loading_plan_item product_image_file_id +fl_loading_plan_item product_image_url +fl_loading_plan_item package_count +fl_loading_plan_item product_quantity +fl_loading_plan_item units_per_carton +fl_loading_plan_item pallet_count +fl_loading_plan_item gross_weight_kg +fl_loading_plan_item net_weight_kg +fl_loading_plan_item dimensional_weight_kg +fl_loading_plan_item volume_cbm +fl_loading_plan_item fba_tracking_no +fl_loading_plan_item shipment_no +fl_loading_plan_item shipping_mark +fl_loading_plan_item load_sequence +fl_loading_plan_item position_snapshot +fl_loading_plan_item internal_remark +fl_loading_plan_item customer_remark +fl_loading_plan_item warehouse_remark +fl_loading_plan_item frozen_at +fl_loading_plan_item frozen_by +fl_loading_plan_item removed_by +fl_loading_plan_item removed_by_name +fl_loading_plan_item removed_at +fl_loading_plan_item deleted +fl_loading_plan_item active_inbound_detail_id +fl_loading_plan_item created_by +fl_loading_plan_item created_by_name +fl_loading_plan_item created_at +fl_loading_plan_item updated_by +fl_loading_plan_item updated_by_name +fl_loading_plan_item updated_at +fl_loading_plan_item version +fl_loading_plan_item unit_type +fl_loading_plan_item pallet_id +fl_loading_plan_item pallet_no +fl_loading_plan_item indivisible +fl_loading_plan_item customer_service_remark +fl_loading_plan_item active_assignment_key +fl_loading_work_pallet id +fl_loading_work_pallet pick_task_id +fl_loading_work_pallet loading_plan_id +fl_loading_work_pallet warehouse_id +fl_loading_work_pallet warehouse_pallet_id +fl_loading_work_pallet pallet_code +fl_loading_work_pallet open_operation_id +fl_loading_work_pallet seal_operation_id +fl_loading_work_pallet pallet_status +fl_loading_work_pallet created_by +fl_loading_work_pallet created_by_name +fl_loading_work_pallet sealed_by +fl_loading_work_pallet sealed_by_name +fl_loading_work_pallet sealed_at +fl_loading_work_pallet version +fl_loading_work_pallet created_at +fl_loading_work_pallet updated_at +fl_loading_work_pallet_carton id +fl_loading_work_pallet_carton pick_task_id +fl_loading_work_pallet_carton work_pallet_id +fl_loading_work_pallet_carton loading_plan_item_id +fl_loading_work_pallet_carton pick_item_id +fl_loading_work_pallet_carton tenant_id +fl_loading_work_pallet_carton inbound_id +fl_loading_work_pallet_carton inbound_detail_id +fl_loading_work_pallet_carton carton_label_id +fl_loading_work_pallet_carton box_code +fl_loading_work_pallet_carton sku_code +fl_loading_work_pallet_carton units_per_carton +fl_loading_work_pallet_carton bound_by +fl_loading_work_pallet_carton bound_by_name +fl_loading_work_pallet_carton operation_id +fl_loading_work_pallet_carton version +fl_loading_work_pallet_carton created_at +fl_loading_work_pallet_carton updated_at +fl_measure_review_record id +fl_measure_review_record tenant_id +fl_measure_review_record inbound_id +fl_measure_review_record measurement_id +fl_measure_review_record review_result +fl_measure_review_record review_reason +fl_measure_review_record exception_release +fl_measure_review_record release_reason +fl_measure_review_record allow_rejected_config +fl_measure_review_record reviewer_id +fl_measure_review_record reviewer_name +fl_measure_review_record reviewed_at +fl_mojia_measurement_receipt id +fl_mojia_measurement_receipt tenant_id +fl_mojia_measurement_receipt subject_id +fl_mojia_measurement_receipt box_code +fl_mojia_measurement_receipt inbound_code +fl_mojia_measurement_receipt measurement_kind +fl_mojia_measurement_receipt scanned_shipment_id +fl_mojia_measurement_receipt length_cm +fl_mojia_measurement_receipt width_cm +fl_mojia_measurement_receipt height_cm +fl_mojia_measurement_receipt weight_kg +fl_mojia_measurement_receipt machine_no +fl_mojia_measurement_receipt measured_at +fl_mojia_measurement_receipt image_file_id +fl_mojia_measurement_receipt status +fl_mojia_measurement_receipt sync_status +fl_mojia_measurement_receipt sync_failure_code +fl_mojia_measurement_receipt sync_failure_reason +fl_mojia_measurement_receipt lingxing_sku_id +fl_mojia_measurement_receipt revision +fl_mojia_measurement_receipt match_failure_code +fl_mojia_measurement_receipt match_failure_reason +fl_mojia_measurement_receipt request_id +fl_mojia_measurement_receipt trace_id +fl_mojia_measurement_receipt created_at +fl_mojia_measurement_receipt updated_at +fl_ocean_booking id +fl_ocean_booking booking_no +fl_ocean_booking status +fl_ocean_booking customer_id +fl_ocean_booking customer_name +fl_ocean_booking channel_code +fl_ocean_booking channel_name +fl_ocean_booking warehouse_id +fl_ocean_booking warehouse_name +fl_ocean_booking bill_of_lading_no +fl_ocean_booking shipping_company_id +fl_ocean_booking shipping_company_name +fl_ocean_booking carrier_code +fl_ocean_booking booking_agent_id +fl_ocean_booking booking_agent_name +fl_ocean_booking vessel_name +fl_ocean_booking voyage_no +fl_ocean_booking departure_port_code +fl_ocean_booking departure_port_name +fl_ocean_booking destination_port_code +fl_ocean_booking destination_port_name +fl_ocean_booking place_of_delivery +fl_ocean_booking service_term +fl_ocean_booking freight_term +fl_ocean_booking estimated_sailing_at +fl_ocean_booking estimated_arrival_at +fl_ocean_booking pending_vessel_name +fl_ocean_booking pending_voyage_no +fl_ocean_booking pending_estimated_sailing_at +fl_ocean_booking pending_estimated_arrival_at +fl_ocean_booking change_reason +fl_ocean_booking cancellation_reason +fl_ocean_booking remark +fl_ocean_booking created_by +fl_ocean_booking created_by_name +fl_ocean_booking created_at +fl_ocean_booking updated_by +fl_ocean_booking updated_by_name +fl_ocean_booking updated_at +fl_ocean_booking version +fl_ocean_booking_container_allocation id +fl_ocean_booking_container_allocation ocean_booking_id +fl_ocean_booking_container_allocation requirement_id +fl_ocean_booking_container_allocation container_booking_id +fl_ocean_booking_container_allocation allocated_by +fl_ocean_booking_container_allocation allocated_by_name +fl_ocean_booking_container_allocation allocated_at +fl_ocean_booking_container_requirement id +fl_ocean_booking_container_requirement ocean_booking_id +fl_ocean_booking_container_requirement container_type +fl_ocean_booking_container_requirement container_kind +fl_ocean_booking_container_requirement capacity_cbm +fl_ocean_booking_container_requirement max_weight_kg +fl_ocean_booking_container_requirement quantity +fl_ocean_booking_container_requirement sort_no +fl_ocean_booking_document id +fl_ocean_booking_document ocean_booking_id +fl_ocean_booking_document file_id +fl_ocean_booking_document document_type +fl_ocean_booking_document file_name +fl_ocean_booking_document file_hash +fl_ocean_booking_document size_bytes +fl_ocean_booking_document content_type +fl_ocean_booking_document valid +fl_ocean_booking_document uploaded_by +fl_ocean_booking_document uploaded_by_name +fl_ocean_booking_document uploaded_at +fl_ocean_booking_document invalidated_by +fl_ocean_booking_document invalidated_by_name +fl_ocean_booking_document invalidated_at +fl_ocean_booking_operation id +fl_ocean_booking_operation ocean_booking_id +fl_ocean_booking_operation operator_id +fl_ocean_booking_operation operation +fl_ocean_booking_operation idempotency_key +fl_ocean_booking_operation request_hash +fl_ocean_booking_operation status +fl_ocean_booking_operation result_json +fl_ocean_booking_operation created_at +fl_ocean_booking_operation completed_at +fl_ocean_booking_party id +fl_ocean_booking_party ocean_booking_id +fl_ocean_booking_party party_type +fl_ocean_booking_party party_name +fl_ocean_booking_party address +fl_ocean_booking_party contact_name +fl_ocean_booking_party contact_phone +fl_ocean_booking_status_history id +fl_ocean_booking_status_history ocean_booking_id +fl_ocean_booking_status_history from_status +fl_ocean_booking_status_history to_status +fl_ocean_booking_status_history action +fl_ocean_booking_status_history reason +fl_ocean_booking_status_history before_snapshot +fl_ocean_booking_status_history after_snapshot +fl_ocean_booking_status_history operator_id +fl_ocean_booking_status_history operator_name +fl_ocean_booking_status_history created_at +fl_order_declaration_event tenant_id +fl_order_declaration_event event_id +fl_order_declaration_event order_id +fl_order_declaration_event order_version +fl_order_declaration_event request_hash +fl_order_declaration_event consumed_at +fl_order_declaration_snapshot tenant_id +fl_order_declaration_snapshot order_id +fl_order_declaration_snapshot event_id +fl_order_declaration_snapshot order_version +fl_order_declaration_snapshot request_hash +fl_order_declaration_snapshot customer_remark +fl_order_declaration_snapshot expected_arrival_at +fl_order_declaration_snapshot channel_code +fl_order_declaration_snapshot channel_name +fl_order_declaration_snapshot cargoes_json +fl_order_declaration_snapshot changes_json +fl_order_declaration_snapshot occurred_at +fl_order_declaration_snapshot created_at +fl_order_declaration_snapshot updated_at +fl_outbound_dispatch id +fl_outbound_dispatch tenant_id +fl_outbound_dispatch order_id +fl_outbound_dispatch inbound_id +fl_outbound_dispatch container_booking_id +fl_outbound_dispatch status +fl_outbound_dispatch version +fl_outbound_dispatch confirmed_by +fl_outbound_dispatch confirmed_by_name +fl_outbound_dispatch confirmed_at +fl_outbound_dispatch reversed_by +fl_outbound_dispatch reversed_by_name +fl_outbound_dispatch reversed_at +fl_outbound_dispatch remark +fl_outbound_dispatch created_at +fl_outbound_dispatch updated_at +fl_outbound_dispatch_history id +fl_outbound_dispatch_history dispatch_id +fl_outbound_dispatch_history tenant_id +fl_outbound_dispatch_history inbound_id +fl_outbound_dispatch_history container_booking_id +fl_outbound_dispatch_history action +fl_outbound_dispatch_history from_status +fl_outbound_dispatch_history to_status +fl_outbound_dispatch_history remark +fl_outbound_dispatch_history operator_id +fl_outbound_dispatch_history operator_name +fl_outbound_dispatch_history created_at +fl_outbound_dispatch_operation id +fl_outbound_dispatch_operation dispatch_id +fl_outbound_dispatch_operation operator_id +fl_outbound_dispatch_operation operation +fl_outbound_dispatch_operation idempotency_key +fl_outbound_dispatch_operation request_hash +fl_outbound_dispatch_operation status +fl_outbound_dispatch_operation created_at +fl_outbound_dispatch_operation completed_at +fl_port_schedule id +fl_port_schedule schedule_no +fl_port_schedule customer_id +fl_port_schedule ocean_booking_id +fl_port_schedule container_booking_id +fl_port_schedule source_type +fl_port_schedule source_name +fl_port_schedule source_ref +fl_port_schedule vessel_name +fl_port_schedule voyage_no +fl_port_schedule departure_port_code +fl_port_schedule departure_port_name +fl_port_schedule destination_port_code +fl_port_schedule destination_port_name +fl_port_schedule etd_at +fl_port_schedule eta_at +fl_port_schedule booking_cutoff_at +fl_port_schedule customs_cutoff_at +fl_port_schedule port_cutoff_at +fl_port_schedule observed_at +fl_port_schedule expires_at +fl_port_schedule remark +fl_port_schedule created_by +fl_port_schedule created_by_name +fl_port_schedule created_at +fl_port_schedule updated_by +fl_port_schedule updated_by_name +fl_port_schedule updated_at +fl_port_schedule version +fl_port_schedule_history id +fl_port_schedule_history port_schedule_id +fl_port_schedule_history action +fl_port_schedule_history from_version +fl_port_schedule_history to_version +fl_port_schedule_history reason +fl_port_schedule_history before_snapshot +fl_port_schedule_history after_snapshot +fl_port_schedule_history operator_id +fl_port_schedule_history operator_name +fl_port_schedule_history created_at +fl_port_schedule_operation id +fl_port_schedule_operation port_schedule_id +fl_port_schedule_operation operator_id +fl_port_schedule_operation operation +fl_port_schedule_operation idempotency_key +fl_port_schedule_operation request_hash +fl_port_schedule_operation status +fl_port_schedule_operation result_json +fl_port_schedule_operation created_at +fl_port_schedule_operation completed_at +fl_preload_loading_plan_link id +fl_preload_loading_plan_link tenant_id +fl_preload_loading_plan_link inbound_id +fl_preload_loading_plan_link preload_plan_id +fl_preload_loading_plan_link loading_plan_id +fl_preload_loading_plan_link idempotency_key +fl_preload_loading_plan_link request_hash +fl_preload_loading_plan_link created_by +fl_preload_loading_plan_link created_by_name +fl_preload_loading_plan_link created_at +fl_preload_plan id +fl_preload_plan tenant_id +fl_preload_plan inbound_id +fl_preload_plan status +fl_preload_plan version +fl_preload_plan items_json +fl_preload_plan remark +fl_preload_plan confirmed_by +fl_preload_plan confirmed_by_name +fl_preload_plan confirmed_at +fl_preload_plan created_by +fl_preload_plan created_by_name +fl_preload_plan updated_by +fl_preload_plan updated_by_name +fl_preload_plan created_at +fl_preload_plan updated_at +fl_price_book_channel id +fl_price_book_channel price_book_version_id +fl_price_book_channel channel_code +fl_price_book_channel channel_name +fl_price_book_channel price_model +fl_price_book_channel source_sheet_name +fl_price_book_channel estimated_departure_date +fl_price_book_channel estimated_departure_text +fl_price_book_channel latest_warehouse_entry +fl_price_book_channel vessel_name +fl_price_book_channel voyage_no +fl_price_book_channel reference_transit +fl_price_book_channel surcharge_description +fl_price_book_channel remark +fl_price_book_channel created_by +fl_price_book_channel created_at +fl_price_book_channel updated_by +fl_price_book_channel updated_at +fl_price_book_channel deleted +fl_price_book_channel version +fl_price_book_version id +fl_price_book_version tenant_id +fl_price_book_version version_no +fl_price_book_version original_file_name +fl_price_book_version source_file_id +fl_price_book_version source_sha256 +fl_price_book_version status +fl_price_book_version blocking_error_count +fl_price_book_version warning_count +fl_price_book_version parse_issues_json +fl_price_book_version channel_count +fl_price_book_version rate_count +fl_price_book_version created_by +fl_price_book_version created_at +fl_price_book_version published_by +fl_price_book_version published_at +fl_price_book_version superseded_at +fl_price_book_version updated_by +fl_price_book_version updated_at +fl_price_book_version deleted +fl_price_book_version version +fl_price_book_version voided_by +fl_price_book_version voided_at +fl_price_book_version void_reason +fl_price_channel_catalog id +fl_price_channel_catalog channel_code +fl_price_channel_catalog display_name +fl_price_channel_catalog price_model +fl_price_channel_catalog source_sheet_name +fl_price_channel_catalog aliases_json +fl_price_channel_catalog inbound_code_prefix +fl_price_channel_catalog overseas_inbound_code_prefix +fl_price_channel_catalog status +fl_price_channel_catalog created_at +fl_price_channel_catalog updated_at +fl_price_origin_alias id +fl_price_origin_alias warehouse_code +fl_price_origin_alias alias_name +fl_price_origin_alias status +fl_price_origin_alias created_at +fl_price_origin_alias updated_at +fl_price_warehouse_mapping id +fl_price_warehouse_mapping source_type +fl_price_warehouse_mapping source_value +fl_price_warehouse_mapping warehouse_id +fl_price_warehouse_mapping warehouse_code +fl_price_warehouse_mapping warehouse_type +fl_price_warehouse_mapping status +fl_price_warehouse_mapping created_at +fl_price_warehouse_mapping updated_at +fl_split_operation id +fl_split_operation tenant_id +fl_split_operation inbound_id +fl_split_operation operator_id +fl_split_operation operation +fl_split_operation idempotency_key +fl_split_operation status +fl_split_operation result_json +fl_split_operation created_at +fl_terminal_drayage id +fl_terminal_drayage container_booking_id +fl_terminal_drayage fleet_supplier_id +fl_terminal_drayage fleet_supplier_name +fl_terminal_drayage pickup_appointment_at +fl_terminal_drayage actual_pickup_at +fl_terminal_drayage estimated_empty_return_at +fl_terminal_drayage actual_empty_return_at +fl_terminal_drayage version +fl_terminal_drayage created_by +fl_terminal_drayage created_by_name +fl_terminal_drayage created_at +fl_terminal_drayage updated_by +fl_terminal_drayage updated_by_name +fl_terminal_drayage updated_at +fl_terminal_drayage_history id +fl_terminal_drayage_history drayage_id +fl_terminal_drayage_history container_booking_id +fl_terminal_drayage_history action +fl_terminal_drayage_history from_status +fl_terminal_drayage_history to_status +fl_terminal_drayage_history from_version +fl_terminal_drayage_history to_version +fl_terminal_drayage_history reason +fl_terminal_drayage_history operator_id +fl_terminal_drayage_history operator_name +fl_terminal_drayage_history created_at +fl_terminal_drayage_mail id +fl_terminal_drayage_mail container_booking_id +fl_terminal_drayage_mail fleet_supplier_id +fl_terminal_drayage_mail fleet_name +fl_terminal_drayage_mail recipient +fl_terminal_drayage_mail kind +fl_terminal_drayage_mail subject +fl_terminal_drayage_mail body +fl_terminal_drayage_mail status +fl_terminal_drayage_mail attempts +fl_terminal_drayage_mail failure_reason +fl_terminal_drayage_mail created_by +fl_terminal_drayage_mail updated_by +fl_terminal_drayage_mail created_at +fl_terminal_drayage_mail updated_at +fl_terminal_drayage_mail sent_at +fl_terminal_drayage_operation id +fl_terminal_drayage_operation container_booking_id +fl_terminal_drayage_operation operation +fl_terminal_drayage_operation idempotency_key +fl_terminal_drayage_operation request_hash +fl_terminal_drayage_operation result_version +fl_terminal_drayage_operation operator_id +fl_terminal_drayage_operation created_at +fl_third_party_call_log id +fl_third_party_call_log tenant_id +fl_third_party_call_log app_key +fl_third_party_call_log signature_subject +fl_third_party_call_log request_method +fl_third_party_call_log request_path +fl_third_party_call_log request_timestamp +fl_third_party_call_log nonce +fl_third_party_call_log idempotency_key +fl_third_party_call_log idempotency_anchor +fl_third_party_call_log signature_value +fl_third_party_call_log request_hash +fl_third_party_call_log raw_payload +fl_third_party_call_log processing_status +fl_third_party_call_log result_code +fl_third_party_call_log result_message +fl_third_party_call_log result_json +fl_third_party_call_log requested_at +fl_third_party_call_log processed_at +fl_truck_appointment id +fl_truck_appointment appointment_no +fl_truck_appointment outbound_order_id +fl_truck_appointment ocean_booking_id +fl_truck_appointment container_booking_id +fl_truck_appointment loading_plan_id +fl_truck_appointment warehouse_id +fl_truck_appointment customer_id +fl_truck_appointment customer_name +fl_truck_appointment carrier_id +fl_truck_appointment carrier_name +fl_truck_appointment bill_of_lading_no +fl_truck_appointment fleet_contact +fl_truck_appointment fleet_phone +fl_truck_appointment driver_name +fl_truck_appointment driver_phone +fl_truck_appointment driver_id_card +fl_truck_appointment vehicle_no +fl_truck_appointment store_name +fl_truck_appointment store_contact +fl_truck_appointment store_phone +fl_truck_appointment store_address +fl_truck_appointment store_city +fl_truck_appointment store_zipcode +fl_truck_appointment package_count +fl_truck_appointment gross_weight_kg +fl_truck_appointment volume_cbm +fl_truck_appointment pallet_count +fl_truck_appointment po_no +fl_truck_appointment planned_loading_at +fl_truck_appointment actual_arrived_warehouse_at +fl_truck_appointment actual_loading_at +fl_truck_appointment actual_departed_at +fl_truck_appointment transit_minutes +fl_truck_appointment payable_amount +fl_truck_appointment receivable_amount +fl_truck_appointment currency +fl_truck_appointment status +fl_truck_appointment revision +fl_truck_appointment version +fl_truck_appointment remark +fl_truck_appointment created_at +fl_truck_appointment updated_at +fl_truck_appointment active_container_booking_id +fl_truck_appointment active_ocean_booking_id +fl_truck_appointment_attachment id +fl_truck_appointment_attachment appointment_id +fl_truck_appointment_attachment file_id +fl_truck_appointment_attachment attachment_type +fl_truck_appointment_attachment file_name +fl_truck_appointment_attachment file_hash +fl_truck_appointment_attachment visibility +fl_truck_appointment_attachment uploaded_by +fl_truck_appointment_attachment uploaded_at +fl_truck_appointment_history id +fl_truck_appointment_history appointment_id +fl_truck_appointment_history revision +fl_truck_appointment_history action +fl_truck_appointment_history from_status +fl_truck_appointment_history to_status +fl_truck_appointment_history reason +fl_truck_appointment_history operator_id +fl_truck_appointment_history operator_name +fl_truck_appointment_history request_id +fl_truck_appointment_history created_at +fl_truck_appointment_operation id +fl_truck_appointment_operation appointment_id +fl_truck_appointment_operation idempotency_key +fl_truck_appointment_operation request_hash +fl_truck_appointment_operation action +fl_truck_appointment_operation response_snapshot +fl_truck_appointment_operation created_at +sys_audit_outbox event_id +sys_audit_outbox event_type +sys_audit_outbox resource_type +sys_audit_outbox resource_id +sys_audit_outbox service_name +sys_audit_outbox payload_json +sys_audit_outbox status +sys_audit_outbox attempt_count +sys_audit_outbox compensation_count +sys_audit_outbox next_retry_at +sys_audit_outbox lease_owner +sys_audit_outbox lease_until +sys_audit_outbox last_error +sys_audit_outbox dead_at +sys_audit_outbox sent_at +sys_audit_outbox created_at +sys_audit_outbox updated_at \ No newline at end of file diff --git a/output/local-schema-repair/firstleg-incremental.sql b/output/local-schema-repair/firstleg-incremental.sql new file mode 100644 index 000000000..76edc2b7b --- /dev/null +++ b/output/local-schema-repair/firstleg-incremental.sql @@ -0,0 +1,357 @@ +-- 本地服务结构补齐;仅创建缺失表、添加缺失字段,不删除现有数据。 +SET NAMES utf8mb4; +CREATE TABLE fl_customs_broker_submission ( + id bigint unsigned NOT NULL COMMENT '首次发送报关行批次ID', + customs_container_id bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + batch_no varchar(64) NOT NULL COMMENT '首次发送报关行批次号', + status varchar(16) NOT NULL COMMENT '发送状态:SENT已发送', + source_version int unsigned NOT NULL COMMENT '发送时整柜报关聚合版本', + source_snapshot_json json NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + sent_by bigint unsigned NOT NULL COMMENT '首次发送操作人ID', + sent_by_name varchar(64) DEFAULT NULL COMMENT '首次发送操作人名称快照', + sent_at datetime(3) NOT NULL COMMENT '首次发送时间', + version int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_customs_broker_submission_container (customs_container_id), + UNIQUE KEY uk_customs_broker_submission_batch (batch_no), + KEY idx_customs_broker_submission_plan (loading_plan_id,sent_at), + CONSTRAINT fk_customs_broker_submission_container FOREIGN KEY (customs_container_id) REFERENCES fl_customs_container (id), + CONSTRAINT chk_customs_broker_submission_status CHECK ((status in (_utf8mb4'SENT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='冻结退税报关资料首次发送报关行批次事实'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN customer_confirm_status varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '客户回执确认状态:PENDING/CONFIRMED/REJECTED'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN customer_confirmed_by bigint unsigned DEFAULT NULL COMMENT '客户确认人ID'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN customer_confirmed_by_name varchar(64) DEFAULT NULL COMMENT '客户确认人名称快照'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN customer_confirmed_at datetime(3) DEFAULT NULL COMMENT '客户确认时间'; + +ALTER TABLE `fl_customs_completion_document` ADD COLUMN customer_reject_reason varchar(500) DEFAULT NULL COMMENT '客户打回原因'; + +CREATE TABLE fl_customs_completion_action_history ( + id bigint unsigned NOT NULL COMMENT '客户回执动作历史ID', + completion_document_id bigint unsigned NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + receipt_version int unsigned NOT NULL COMMENT '动作对应的回执文件版本', + action varchar(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + reason varchar(500) DEFAULT NULL COMMENT '客户打回原因;确认动作为空', + operator_id bigint unsigned NOT NULL COMMENT '客户操作人ID', + operator_name varchar(64) DEFAULT NULL COMMENT '客户操作人名称快照', + created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY (id), + KEY idx_customs_completion_action_history (completion_document_id,receipt_version,created_at,id), + CONSTRAINT fk_customs_completion_action_history_document FOREIGN KEY (completion_document_id) REFERENCES fl_customs_completion_document (id), + CONSTRAINT chk_customs_completion_action_history_action CHECK ((action in (_utf8mb4'CONFIRM',_utf8mb4'REJECT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报关行回执确认与打回不可变历史'; + +CREATE TABLE fl_customs_declaration_batch ( + id BIGINT UNSIGNED NOT NULL COMMENT '正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + loading_plan_id BIGINT UNSIGNED NOT NULL COMMENT '装柜计划ID快照', + batch_no VARCHAR(64) NULL COMMENT '平台正式申报批次号', + status VARCHAR(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED', + source_version INT UNSIGNED NOT NULL COMMENT '创建时整柜报关聚合版本', + source_snapshot_json JSON NOT NULL COMMENT '提交时资料、回执和确认版本快照', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号', + submitted_by BIGINT UNSIGNED NULL COMMENT '正式申报提交人', + submitted_by_name VARCHAR(64) NULL COMMENT '正式申报提交人名称快照', + submitted_at DATETIME(3) NULL COMMENT '正式申报提交时间', + acknowledged_by BIGINT UNSIGNED NULL COMMENT '报关行确认受理人', + acknowledged_by_name VARCHAR(64) NULL COMMENT '报关行确认受理人名称快照', + acknowledged_at DATETIME(3) NULL COMMENT '报关行确认受理时间', + remark VARCHAR(500) NULL COMMENT '批次说明', + version INT UNSIGNED NOT NULL DEFAULT 0 COMMENT '乐观锁版本号', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY(id), + UNIQUE KEY uk_customs_declaration_batch_container(customs_container_id, id), + KEY idx_customs_declaration_batch_plan(loading_plan_id,status,created_at), + CONSTRAINT fk_customs_declaration_batch_container FOREIGN KEY(customs_container_id) REFERENCES fl_customs_container(id), + CONSTRAINT chk_customs_declaration_batch_status CHECK(status IN ('READY','SUBMITTED','ACKNOWLEDGED','RESULT_PENDING','CLOSED','CANCELLED')) +) ENGINE=InnoDB COMMENT='整柜正式申报批次事实'; + +CREATE TABLE fl_customs_clearance_result ( + id BIGINT UNSIGNED NOT NULL COMMENT '海关结果事实ID', + declaration_batch_id BIGINT UNSIGNED NOT NULL COMMENT '所属正式申报批次ID', + customs_container_id BIGINT UNSIGNED NOT NULL COMMENT '所属整柜报关聚合ID', + result_type VARCHAR(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER', + result_status VARCHAR(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED', + customs_entry_no VARCHAR(64) NULL COMMENT '报关单号快照', + occurred_at DATETIME(3) NOT NULL COMMENT '海关事实发生时间', + resolution VARCHAR(1000) NULL COMMENT '处理结果或异常说明', + evidence_file_id BIGINT UNSIGNED NULL COMMENT '结果凭证File文件ID', + recorded_by BIGINT UNSIGNED NOT NULL COMMENT '平台录入人', + recorded_by_name VARCHAR(64) NULL COMMENT '平台录入人名称快照', + recorded_at DATETIME(3) NOT NULL COMMENT '平台录入时间', + previous_result_id BIGINT UNSIGNED NULL COMMENT '前序结果事实ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间', + PRIMARY KEY(id), + KEY idx_customs_clearance_result_batch(declaration_batch_id,occurred_at,id), + KEY idx_customs_clearance_result_container(customs_container_id, result_type, result_status), + CONSTRAINT fk_customs_clearance_result_batch FOREIGN KEY(declaration_batch_id) REFERENCES fl_customs_declaration_batch(id), + CONSTRAINT chk_customs_clearance_result_type CHECK(result_type IN ('INSPECTION','SUPPLEMENT_REQUIRED','RELEASED','DETAINED','OTHER')), + CONSTRAINT chk_customs_clearance_result_status CHECK(result_status IN ('OPEN','RESOLVED','CANCELLED')) +) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实'; + +ALTER TABLE `fl_fee_item` ADD COLUMN default_price decimal(12,2) DEFAULT NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改'; + +CREATE TABLE fl_chargeable_weight_confirmation ( + id BIGINT NOT NULL COMMENT '确认记录ID', + tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', + inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', + operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', + command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', + fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', + chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', + confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), + KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) +) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; + +CREATE TABLE fl_sailing_channel ( + id BIGINT NOT NULL, + channel_code VARCHAR(64) NOT NULL, + channel_name VARCHAR(128) NOT NULL, + source_type VARCHAR(24) NOT NULL, + mapped_channel_code VARCHAR(64) NULL, + default_departure_port_code VARCHAR(32) NULL, + default_destination_port_code VARCHAR(32) NULL, + default_cutoff_days TINYINT NOT NULL DEFAULT 3, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_channel_code (channel_code), + KEY idx_fl_sailing_channel_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_template ( + id BIGINT NOT NULL, + template_no VARCHAR(64) NOT NULL, + template_name VARCHAR(128) NOT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + weekday TINYINT NOT NULL, + weekday_mask TINYINT NOT NULL DEFAULT 1, + etd_local_time TIME NOT NULL, + cutoff_days TINYINT NOT NULL DEFAULT 3, + cutoff_local_time TIME NOT NULL DEFAULT '18:00:00', + valid_from DATE NOT NULL, + valid_to DATE NULL, + voyage_rule VARCHAR(64) NULL, + vessel_name VARCHAR(128) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'ACTIVE', + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_template_no (template_no), + KEY idx_fl_sailing_template_filter (warehouse_id,channel_id,status,weekday), + CONSTRAINT ck_fl_sailing_template_weekday CHECK (weekday BETWEEN 1 AND 7), + CONSTRAINT ck_fl_sailing_template_cutoff_days CHECK (cutoff_days BETWEEN 0 AND 30) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing ( + id BIGINT NOT NULL, + sailing_no VARCHAR(64) NOT NULL, + template_id BIGINT NULL, + channel_id BIGINT NOT NULL, + channel_code_snapshot VARCHAR(64) NOT NULL, + channel_name_snapshot VARCHAR(128) NOT NULL, + warehouse_id BIGINT NOT NULL, + warehouse_name_snapshot VARCHAR(128) NULL, + departure_port_code VARCHAR(32) NOT NULL, + destination_port_code VARCHAR(32) NOT NULL, + vessel_name VARCHAR(128) NULL, + voyage_no VARCHAR(64) NULL, + etd_at DATETIME(3) NOT NULL, + warehouse_cutoff_at DATETIME(3) NOT NULL, + eta_at DATETIME(3) NULL, + status VARCHAR(16) NOT NULL DEFAULT 'OPEN', + source_type VARCHAR(24) NOT NULL DEFAULT 'MANUAL', + generated_key VARCHAR(192) NULL, + planned_capacity_cbm DECIMAL(14,3) NULL, + remark VARCHAR(1000) NULL, + created_by BIGINT NOT NULL, + created_by_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_by BIGINT NOT NULL, + updated_by_name VARCHAR(128) NULL, + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + version INT NOT NULL DEFAULT 0, + PRIMARY KEY (id), + UNIQUE KEY uk_fl_sailing_no (sailing_no), + UNIQUE KEY uk_fl_sailing_generated_key (generated_key), + UNIQUE KEY uk_fl_sailing_business (warehouse_id,channel_code_snapshot,departure_port_code,destination_port_code,etd_at,voyage_no), + KEY idx_fl_sailing_calendar (etd_at,warehouse_id,channel_id,status), + KEY idx_fl_sailing_route (departure_port_code,destination_port_code), + CONSTRAINT ck_fl_sailing_status CHECK (status IN ('DRAFT','OPEN','CLOSED','DEPARTED','CANCELLED','COMPLETED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_history ( + id BIGINT NOT NULL, + sailing_id BIGINT NOT NULL, + action VARCHAR(32) NOT NULL, + from_status VARCHAR(16) NULL, + to_status VARCHAR(16) NULL, + reason VARCHAR(1000) NULL, + operator_id BIGINT NULL, + operator_name VARCHAR(128) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_fl_sailing_history_sailing (sailing_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +CREATE TABLE fl_sailing_receipt_batch_fact ( + id BIGINT UNSIGNED NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + receipt_batch_key VARCHAR(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '同一批次复测/冲正版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + measurement_id BIGINT UNSIGNED NULL COMMENT '最终审核测量批次ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '实际收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '收货渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '收货渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '目的港编码快照', + received_at DATETIME(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代', + actual_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '最终审核实测体积m3', + fact_status VARCHAR(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED', + reversal_of_fact_id BIGINT UNSIGNED NULL COMMENT '冲正所替代的事实ID', + source_type VARCHAR(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_receipt_batch_version (tenant_id, receipt_batch_key, fact_version), + KEY idx_sailing_receipt_batch_current (tenant_id, receipt_batch_key, fact_status, fact_version), + KEY idx_sailing_receipt_match (tenant_id, receiving_warehouse_id, channel_code, received_at, id), + KEY idx_sailing_receipt_inbound (tenant_id, inbound_id, received_at, id), + CONSTRAINT chk_sailing_receipt_volume CHECK (actual_volume_cbm > 0), + CONSTRAINT chk_sailing_receipt_status CHECK (fact_status IN ('APPROVED','REVERSED','VOIDED','SUPERSEDED')) +) ENGINE=InnoDB COMMENT='船期日历稳定收货批次实测事实及版本历史'; + +CREATE TABLE fl_sailing_inbound_volume_forecast ( + id BIGINT UNSIGNED NOT NULL COMMENT '货量预报事实ID(forecastId)', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键,继承入库事实', + forecast_key VARCHAR(64) NOT NULL COMMENT '同一未收货对象的不可变业务键', + fact_version INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '预报修订版本', + inbound_id BIGINT UNSIGNED NOT NULL COMMENT '入库单ID', + receiving_warehouse_id BIGINT UNSIGNED NOT NULL COMMENT '预计收货仓ID', + channel_code VARCHAR(64) NULL COMMENT '预计渠道编码快照', + channel_name VARCHAR(128) NULL COMMENT '预计渠道名称快照', + departure_port_code VARCHAR(32) NULL COMMENT '预计起运港编码快照', + destination_port_code VARCHAR(32) NULL COMMENT '预计目的港编码快照', + expected_arrival_at DATETIME(3) NOT NULL COMMENT '预计到仓时间', + forecast_volume_cbm DECIMAL(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替', + source_type VARCHAR(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报', + fact_status VARCHAR(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED', + effective_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间', + created_by BIGINT UNSIGNED NULL COMMENT '写入人或系统主体', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_forecast_version (tenant_id, forecast_key, fact_version), + KEY idx_sailing_forecast_current (tenant_id, forecast_key, fact_status, fact_version), + KEY idx_sailing_forecast_match (tenant_id, receiving_warehouse_id, channel_code, expected_arrival_at, id), + KEY idx_sailing_forecast_inbound (tenant_id, inbound_id, expected_arrival_at, id), + CONSTRAINT chk_sailing_forecast_volume CHECK (forecast_volume_cbm > 0), + CONSTRAINT chk_sailing_forecast_status CHECK (fact_status IN ('VALID','SUPERSEDED','VOIDED','CONSUMED')) +) ENGINE=InnoDB COMMENT='船期日历未收货量预报事实及版本历史'; + +CREATE TABLE fl_sailing_assignment ( + id BIGINT UNSIGNED NOT NULL COMMENT '归班事实ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '客户隔离键', + sailing_id BIGINT UNSIGNED NULL COMMENT '船期实例ID,UNMATCHED为空', + origin_sailing_id BIGINT UNSIGNED NULL COMMENT '异常重排/取消前的原船期实例ID', + inbound_id BIGINT UNSIGNED NULL COMMENT '归班对象入库单ID', + inbound_code VARCHAR(64) NULL COMMENT '入仓码快照,仅用于展示', + receipt_batch_id BIGINT UNSIGNED NULL COMMENT '收货批次事实ID', + forecast_id BIGINT UNSIGNED NULL COMMENT '货量预报事实ID', + assignment_source VARCHAR(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER', + assignment_version INT UNSIGNED NOT NULL COMMENT '同一对象的归班版本', + match_status VARCHAR(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED', + volume_cbm DECIMAL(18,4) NULL COMMENT '已收实测体积', + forecast_volume_cbm DECIMAL(18,4) NULL COMMENT '未收有效预计体积', + route_source VARCHAR(32) NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT', + volume_source VARCHAR(32) NULL COMMENT 'MEASURED_ACTUAL或预报来源', + rule_version VARCHAR(64) NOT NULL COMMENT '自动归班规则版本', + matched_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + superseded_at DATETIME(3) NULL, + locked_at DATETIME(3) NULL COMMENT '截止/订舱/关闭后冻结时间', + lock_reason VARCHAR(32) NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED', + operator_id BIGINT UNSIGNED NULL, + reason VARCHAR(500) NULL, + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (id), + KEY idx_sailing_assignment_current (tenant_id, sailing_id, match_status, matched_at, id), + KEY idx_sailing_assignment_origin (tenant_id, origin_sailing_id, match_status, id), + KEY idx_sailing_assignment_receipt (tenant_id, receipt_batch_id, assignment_version, id), + KEY idx_sailing_assignment_forecast (tenant_id, forecast_id, assignment_version, id), + KEY idx_sailing_assignment_inbound (tenant_id, inbound_id, match_status, id), + CONSTRAINT chk_sailing_assignment_status CHECK (match_status IN ('FORECAST_MATCHED','RECEIVED','LATE','UNMATCHED','SUPERSEDED')), + CONSTRAINT chk_sailing_assignment_source CHECK (assignment_source IN ('AUTO','MANUAL','ROLLOVER')) +) ENGINE=InnoDB COMMENT='船期日历自动归班事实及不可变版本历史'; + +CREATE TABLE fl_sailing_booking_link ( + id BIGINT UNSIGNED NOT NULL COMMENT '船期-订舱关联ID', + tenant_id BIGINT UNSIGNED NOT NULL COMMENT '平台关联隔离键', + sailing_id BIGINT UNSIGNED NOT NULL COMMENT '计划船期实例ID', + ocean_booking_id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', + link_status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED', + active_booking_guard TINYINT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束', + booking_status_snapshot VARCHAR(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立', + warehouse_id BIGINT UNSIGNED NULL COMMENT '关联仓库快照', + channel_code VARCHAR(64) NULL COMMENT '关联渠道快照', + departure_port_code VARCHAR(32) NULL COMMENT '关联起运港快照', + destination_port_code VARCHAR(32) NULL COMMENT '关联目的港快照', + linked_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + revoked_at DATETIME(3) NULL, + operator_id BIGINT UNSIGNED NULL, + operator_name VARCHAR(64) NULL, + reason VARCHAR(500) NULL, + PRIMARY KEY (id), + UNIQUE KEY uk_sailing_booking_active (ocean_booking_id, active_booking_guard), + KEY idx_sailing_booking_link (sailing_id, link_status, ocean_booking_id), + KEY idx_sailing_booking_booking (ocean_booking_id, link_status, id), + CONSTRAINT chk_sailing_booking_link_status CHECK (link_status IN ('ACTIVE','REVOKED')), + CONSTRAINT chk_sailing_booking_link_guard CHECK ((link_status='ACTIVE' AND active_booking_guard=1) OR (link_status='REVOKED' AND active_booking_guard IS NULL)), + CONSTRAINT chk_sailing_booking_released CHECK (booking_status_snapshot='RELEASED') +) ENGINE=InnoDB COMMENT='船期计划与已放舱订舱的最小关联及历史'; + +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; diff --git a/output/local-schema-repair/firstleg-plan.json b/output/local-schema-repair/firstleg-plan.json new file mode 100644 index 000000000..9279543a4 --- /dev/null +++ b/output/local-schema-repair/firstleg-plan.json @@ -0,0 +1,29 @@ +{ + "baselineTables": 131, + "actualTables": 128, + "addedTables": [ + "fl_customs_broker_submission", + "fl_customs_completion_action_history", + "fl_customs_declaration_batch", + "fl_customs_clearance_result", + "fl_chargeable_weight_confirmation", + "fl_sailing_channel", + "fl_sailing_template", + "fl_sailing", + "fl_sailing_history", + "fl_sailing_receipt_batch_fact", + "fl_sailing_inbound_volume_forecast", + "fl_sailing_assignment", + "fl_sailing_booking_link", + "event_delivery_retry_audit" + ], + "addedColumns": [ + "fl_customs_completion_document.customer_confirm_status", + "fl_customs_completion_document.customer_confirmed_by", + "fl_customs_completion_document.customer_confirmed_by_name", + "fl_customs_completion_document.customer_confirmed_at", + "fl_customs_completion_document.customer_reject_reason", + "fl_fee_item.default_price" + ], + "unsafe": [] +} \ No newline at end of file diff --git a/output/local-schema-repair/http-smoke.json b/output/local-schema-repair/http-smoke.json new file mode 100644 index 000000000..bd59ef203 --- /dev/null +++ b/output/local-schema-repair/http-smoke.json @@ -0,0 +1,84 @@ +[ + { + "path": "/api/auth/login", + "http": 200, + "code": "200", + "requestId": "83e3631c-8cda-4729-80c4-106a1a6275d6, b8d91668-aad6-4513-bc55-9e1e8e2312c1", + "passed": true + }, + { + "path": "/api/platform/v1/last-leg/orders?page=1&size=5", + "http": 200, + "code": "200", + "requestId": "3ececcb5-f6d3-452d-b675-83a1422132ab, 3494117d-b608-442c-86d3-930723d31c72", + "passed": true + }, + { + "path": "/api/platform/v1/last-leg/dispatches?page=1&size=5", + "http": 200, + "code": "200", + "requestId": "9f1a9717-a0b7-4400-ae51-5e95695bdbb3, 2867b52e-a75c-4375-97fc-3b785414944e", + "passed": true + }, + { + "path": "/api/platform/v1/last-leg/dispatches/drafts?page=1&size=5", + "http": 200, + "code": "200", + "requestId": "b7e68664-fa9f-4e57-93f5-560ca5c7afe7, 81e0d34d-c37e-4160-84d6-1d77d25b7549", + "passed": true + }, + { + "path": "/api/platform/v1/last-leg/appointment-work/summary", + "http": 200, + "code": "A0403", + "message": "Access denied", + "requestId": "43f418ef-f7d3-4fad-a506-1ff1e909404d, e45bdbc3-25a0-42c3-8ba0-67d3dda55408", + "passed": false + }, + { + "path": "/api/platform/inbounds?page=1&size=5", + "http": 200, + "code": "200", + "requestId": "c2db90b4-0132-4ebb-bf01-65bbdd4e8f72, af8884fb-8002-4d59-a3c0-89310f36e614", + "passed": true + }, + { + "path": "/api/platform/v1/first-leg/tasks?tenantId=10001&page=1&size=5", + "http": 200, + "code": "200", + "requestId": "b9ce00ca-d62c-41dc-a752-7f83d8f72786, 264cbdd1-f6a5-4317-95b2-280f09772e41", + "passed": true + }, + { + "path": "/api/platform/v1/sailing-channels", + "http": 200, + "code": "A0403", + "message": "Access denied", + "requestId": "b9d52e7d-b9e4-434b-9daf-74a492cb4176, bb9b768f-c314-4d6e-a45d-6b99c697fe07", + "passed": false + }, + { + "path": "/api/platform/v1/sailing-templates?page=1&size=5", + "http": 200, + "code": "A0403", + "message": "Access denied", + "requestId": "d88b13dc-af9d-43b7-ad44-cd7ec30e4fb0, 4fdc6213-1b44-48ae-bfcf-22ee6917a8d3", + "passed": false + }, + { + "path": "9202/actuator/health", + "http": 200, + "body": { + "status": "UP" + }, + "passed": true + }, + { + "path": "9303/actuator/health", + "http": 200, + "body": { + "status": "UP" + }, + "passed": true + } +] \ No newline at end of file diff --git a/output/local-schema-repair/http-smoke.mjs b/output/local-schema-repair/http-smoke.mjs new file mode 100644 index 000000000..968537c84 --- /dev/null +++ b/output/local-schema-repair/http-smoke.mjs @@ -0,0 +1,25 @@ +import fs from 'node:fs'; +const base=process.env.ADMIN_TEST_GATEWAY; +if(!base||!['127.0.0.1','localhost'].includes(new URL(base).hostname))throw new Error('仅允许本机'); +let token='';const checks=[]; +async function call(path,body) { + const r=await fetch(base+path,{method:body?'POST':'GET',headers:{...(token?{Authorization:'Bearer '+token}:{}),...(body?{'Content-Type':'application/json'}:{})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(45000)}); + const value=await r.json().catch(()=>null); + checks.push({path,http:r.status,code:value?.code,message:String(value?.code)==='200'?undefined:value?.message,requestId:r.headers.get('x-request-id'),passed:r.status===200&&String(value?.code)==='200'}); + fs.writeFileSync(new URL('http-smoke.json',import.meta.url),JSON.stringify(checks,null,2)); + return value; +} +const login=await call('/api/auth/login',{loginId:process.env.ADMIN_TEST_USERNAME,password:process.env.ADMIN_TEST_PASSWORD}); +if(!login?.data?.accessToken)throw new Error('本地登录失败');token=login.data.accessToken; +for(const path of [ + '/api/platform/v1/last-leg/orders?page=1&size=5', + '/api/platform/v1/last-leg/dispatches?page=1&size=5', + '/api/platform/v1/last-leg/dispatches/drafts?page=1&size=5', + '/api/platform/v1/last-leg/appointment-work/summary', + '/api/platform/inbounds?page=1&size=5', + '/api/platform/v1/first-leg/tasks?tenantId=10001&page=1&size=5', + '/api/platform/v1/sailing-channels', + '/api/platform/v1/sailing-templates?page=1&size=5' +])await call(path); +for(const port of [9202,9303]){const r=await fetch(`http://127.0.0.1:${port}/actuator/health`);checks.push({path:`${port}/actuator/health`,http:r.status,body:await r.json(),passed:r.ok});} +fs.writeFileSync(new URL('http-smoke.json',import.meta.url),JSON.stringify(checks,null,2));console.log(JSON.stringify(checks,null,2)); diff --git a/output/local-schema-repair/incremental.sql b/output/local-schema-repair/incremental.sql new file mode 100644 index 000000000..254ad5205 --- /dev/null +++ b/output/local-schema-repair/incremental.sql @@ -0,0 +1,895 @@ +-- 本地服务结构补齐;仅创建缺失表、添加缺失字段,不删除现有数据。 +SET NAMES utf8mb4; +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN product_name VARCHAR(255) NULL COMMENT '商品名称'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN estimated_pallet_count INT NULL COMMENT '客户申报预计托数,不代表实际收货'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN pickup_at DATETIME(3) NULL COMMENT '取货时间(当地时间,不转换时区)'; + +ALTER TABLE `ll_local_looseboard_order` ADD COLUMN delivery_at DATETIME(3) NULL COMMENT '送货时间(当地时间,不转换时区)'; + +CREATE TABLE ll_appointment_followup ( + id BIGINT NOT NULL COMMENT '预约筹划跟进记录ID', + source_key VARCHAR(160) NOT NULL COMMENT '稳定ORDER、BOOKING或CONTAINER及接仓点来源键', + customer_id VARCHAR(128) NOT NULL COMMENT '货源客户;整柜共享记录由服务端固定为平台作用域0', + source_order_id BIGINT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空', + stage VARCHAR(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING', + version INT NOT NULL COMMENT '同一货源及跟进对象的记录版本', + contacted_party VARCHAR(200) NULL COMMENT '本次联系对象,不替代承运主档', + result VARCHAR(1000) NOT NULL COMMENT '本次反馈事实,不自动确认预约或推进实物作业', + next_action VARCHAR(500) NULL COMMENT '下一步安排', + followup_date DATE NULL COMMENT '按业务地点当地日期记录的下次跟进日', + operator_id VARCHAR(128) NOT NULL COMMENT '登录平台操作者ID', + operator_name VARCHAR(200) NULL COMMENT '操作人姓名快照', + command_key VARCHAR(64) NOT NULL COMMENT '持久幂等键', + request_hash CHAR(64) NOT NULL COMMENT '含操作者身份的请求SHA256摘要', + created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_followup_version (source_key,stage,version), + UNIQUE KEY uk_appointment_followup_command (source_key,stage,command_key), + KEY idx_appointment_followup_date (followup_date,source_order_id), + CONSTRAINT fk_appointment_followup_order FOREIGN KEY (source_order_id) REFERENCES ll_order(id), + CONSTRAINT chk_appointment_followup_stage CHECK (stage IN ('DRAYAGE_CONTACT','TERMINAL_APPOINTMENT','WAREHOUSE_RECEIVING','FBA_RECEIVING')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='真实在途货源的预约筹划联系记录,追加留痕'; + +CREATE TABLE ll_dispatch_command ( + id BIGINT NOT NULL COMMENT '持久命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '命令所属整车排车主单ID', + dispatch_no VARCHAR(64) NOT NULL COMMENT '命令所属真实排车业务号', + action VARCHAR(40) NOT NULL COMMENT '本次整车作业动作', + operator_id VARCHAR(128) NOT NULL COMMENT '原始可信操作主体ID', + idempotency_key VARCHAR(255) NOT NULL COMMENT '跨操作主体全局唯一的原始请求键', + record_version BIGINT NOT NULL COMMENT '原请求明确提交的排车版本,零为合法首版', + command_hash CHAR(64) NOT NULL COMMENT '绑定原主体动作业务号版本和完整载荷的严格摘要', + result_json LONGTEXT NOT NULL COMMENT '按动作类型恢复的不可变成功结果JSON', + created_at DATETIME(3) NOT NULL COMMENT '成功命令记录时间,使用UTC', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_command_key (idempotency_key), + KEY idx_dispatch_command_history (dispatch_id,created_at,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车公共持久命令账本'; + +CREATE TABLE ll_dispatch_delivery_arrangement ( + id BIGINT NOT NULL COMMENT '安排事实内部主键', + dispatch_order_id BIGINT NOT NULL COMMENT '唯一整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + arrangement_key VARCHAR(128) NOT NULL COMMENT '本次唯一安排业务事实标识', + business_hash CHAR(64) NOT NULL COMMENT '排除操作者及纯并发版本的安排业务摘要', + cargo_hash CHAR(64) NOT NULL COMMENT '本车实际拣货来源摘要', + loading_completion_hash CHAR(64) NOT NULL COMMENT '装车完成事实摘要', + original_command_key VARCHAR(255) NOT NULL COMMENT '首次安排的全局请求键', + original_operator_id VARCHAR(128) NOT NULL COMMENT '首次安排操作主体', + result_json JSON NOT NULL COMMENT '全部客户出库及客户目的地派送不可变结果', + cargo_json JSON NOT NULL COMMENT '逐客户逐拣货归属的不可变真实来源', + created_at DATETIME(3) NOT NULL COMMENT '本次安排实际创建时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_arrangement_dispatch (dispatch_order_id), + UNIQUE KEY uk_dispatch_arrangement_key (arrangement_key), + UNIQUE KEY uk_dispatch_arrangement_command (original_command_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车唯一派送安排事实'; + +CREATE TABLE ll_delivery_facts_changed ( + id BIGINT NOT NULL COMMENT '持久信号内部主键', + customer_id VARCHAR(128) NOT NULL COMMENT '真实签收货物客户ID', + dispatch_id BIGINT NOT NULL COMMENT '整车排车主单ID', + dispatch_no VARCHAR(128) NOT NULL COMMENT '真实排车业务号', + delivery_id BIGINT NOT NULL COMMENT '本客户派送单ID', + delivery_no VARCHAR(128) NOT NULL COMMENT '真实派送业务号', + delivery_version BIGINT NOT NULL COMMENT '触发信号的真实派送版本', + facts_hash CHAR(64) NOT NULL COMMENT '该版本真实签收事实摘要', + occurred_at DATETIME(3) NOT NULL COMMENT '签收事实实际发生时间', + state VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '待核对PENDING或已核对PROCESSED', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已记录的汇总失败次数', + next_attempt_at DATETIME(3) NOT NULL COMMENT '下次可重试时间', + last_error_code VARCHAR(64) NULL COMMENT '最近失败诊断码不保存敏感异常正文', + processed_at DATETIME(3) NULL COMMENT '与主阶段汇总同事务确认时间', + PRIMARY KEY (id), + UNIQUE KEY uk_delivery_fact_version (customer_id,delivery_id,delivery_version), + KEY idx_delivery_fact_due (state,next_attempt_at,id), + KEY idx_delivery_fact_dispatch (dispatch_id,state,id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正常签收事实变化的本地持久待汇总信号'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN dispatch_item_id BIGINT NULL COMMENT '本次整车排车作业明细ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN source_attribution_id BIGINT NULL COMMENT '不可变PDA真实拣货归属ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN source_order_id BIGINT NULL COMMENT '本行真实来源尾程订单ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN source_inventory_position_id BIGINT NULL COMMENT '本行实际拣货库存仓位ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN source_return_receipt_id BIGINT NULL COMMENT '本行实际退回再派实收记录ID'; + +ALTER TABLE `ll_outbound_pallet_item` ADD COLUMN source_return_carton_id BIGINT NULL COMMENT '本行实际退货换标来源箱ID'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN purpose VARCHAR(32) NULL COMMENT '费用业务用途;由履约节点确定'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN confirmation_origin VARCHAR(16) NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN fee_version INT NULL COMMENT '同用途费用确认版本'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否当前有效版本'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN determination VARCHAR(24) NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN original_currency CHAR(3) NULL COMMENT '核定原币CNY或USD'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN original_amount DECIMAL(20,2) NULL COMMENT '核定原币金额'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN exchange_rate DECIMAL(20,6) NULL COMMENT '已确认汇率快照'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN exchange_rate_month CHAR(7) NULL COMMENT '已确认汇率所属月份'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN business_date DATE NULL COMMENT '实际费用业务日期'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN wallet_business_id BIGINT NULL COMMENT '资金冻结对应采购费用记录'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN wallet_business_no VARCHAR(128) NULL COMMENT '资金冻结业务号;实际国际运费引用原预估'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance实际处理流水引用'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN parent_fee_id BIGINT NULL COMMENT '原费用或预估引用'; + +ALTER TABLE `ll_overseas_procurement_fee` ADD COLUMN request_hash CHAR(64) NULL COMMENT '确认请求摘要'; + +ALTER TABLE `ll_overseas_procurement_status_history` ADD COLUMN operation_type varchar(32) COLLATE utf8mb4_unicode_ci NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE'; + +ALTER TABLE `ll_overseas_procurement_status_history` ADD COLUMN change_detail json DEFAULT NULL COMMENT '字段变更前后值JSON'; + +ALTER TABLE `ll_receiving_container_session` ADD COLUMN source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NULL COMMENT '稳定来源柜预订ID'; + +ALTER TABLE `ll_receiving_container_session` ADD COLUMN container_fact_hash varchar(64) COLLATE utf8mb4_unicode_ci NULL COMMENT '领取时整柜订单与计划事实摘要'; + +ALTER TABLE `ll_receiving_container_session` ADD COLUMN overseas_warehouse_code varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓业务编码快照'; + +ALTER TABLE `ll_return_inventory_outbound_movement` ADD COLUMN return_sku_quantity BIGINT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN vehicle_type VARCHAR(32) NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN origin_contact_name VARCHAR(128) NULL COMMENT '起运地联系人'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN origin_contact_phone VARCHAR(128) NULL COMMENT '起运地联系电话'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN destination_contact_name VARCHAR(128) NULL COMMENT '目的地联系人'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN destination_contact_phone VARCHAR(128) NULL COMMENT '目的地联系电话'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN pickup_at DATETIME NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区'; + +ALTER TABLE `ll_us_truck_order` ADD COLUMN delivery_at DATETIME NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空'; + +CREATE TABLE event_delivery_retry_audit ( + request_id VARCHAR(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + producer_table VARCHAR(64) NOT NULL COMMENT '本服务发件箱表名', + entry_id VARCHAR(64) NOT NULL COMMENT '发件箱记录主键', + event_id VARCHAR(128) NOT NULL COMMENT '原事件全局标识', + event_type VARCHAR(64) NOT NULL COMMENT '原事件类型', + payload_hash VARCHAR(71) NOT NULL COMMENT '原事件不可变负载摘要', + operator_name VARCHAR(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + reason VARCHAR(500) NOT NULL COMMENT '人工重试原因', + previous_failures INT NOT NULL COMMENT '重试前累计失败次数', + previous_error VARCHAR(1000) NULL COMMENT '重试前最后一次脱敏错误', + requested_at DATETIME(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (request_id), + KEY idx_event_retry_entry (producer_table,entry_id,requested_at), + KEY idx_event_retry_event (event_id,requested_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; + +CREATE TABLE ll_procurement_wallet_attempt ( + id BIGINT NOT NULL COMMENT '请求尝试ID', + order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + fee_id BIGINT NOT NULL COMMENT '不可变费用版本ID', + attempt_no BIGINT NOT NULL COMMENT '费用版本内请求序号', + operation VARCHAR(32) NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', + business_no VARCHAR(128) NOT NULL COMMENT 'Finance资金业务号', + amount DECIMAL(20,2) NULL COMMENT '请求金额;全额结算或释放可空', + command_event_id VARCHAR(64) NOT NULL COMMENT '资金指令事件唯一引用', + status VARCHAR(16) NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', + result_hash CHAR(64) NULL COMMENT '原业务回执摘要', + wallet_hold_id VARCHAR(64) NULL COMMENT 'Finance冻结引用', + wallet_ledger_id VARCHAR(64) NULL COMMENT 'Finance处理流水引用', + error_code VARCHAR(64) NULL COMMENT '业务失败码', + error_message VARCHAR(500) NULL COMMENT '内部失败诊断,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', + completed_at DATETIME(3) NULL COMMENT '最终结果时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_attempt_event(command_event_id), + UNIQUE KEY uk_procurement_attempt_number(fee_id,attempt_no), + KEY idx_procurement_attempt_order(customer_id,order_id,status), + CONSTRAINT chk_procurement_attempt_status CHECK(status IN('PENDING','SUCCEEDED','FAILED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; + +CREATE TABLE ll_procurement_flow ( + order_id BIGINT NOT NULL COMMENT '采购订单ID,一单一个履约批次', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + purchased_at DATETIME(3) NULL COMMENT '真实购买时间', + purchased_product_name VARCHAR(255) NULL COMMENT '实际购买商品', + purchased_specification VARCHAR(500) NULL COMMENT '实际购买规格', + purchased_quantity BIGINT NULL COMMENT '实际购买件数', + purchase_amount DECIMAL(20,2) NULL COMMENT '供应商实付原币金额', + purchase_currency CHAR(3) NULL COMMENT '供应商实付原币CNY或USD', + purchase_voucher_id BIGINT NULL COMMENT '购买凭证引用', + purchase_issue VARCHAR(500) NULL COMMENT '购买未核定或异常说明', + purchase_confirmed_at DATETIME(3) NULL COMMENT '货款核定时间,不代表扣费成功', + actual_warehouse_id BIGINT NULL COMMENT '首次实际收货冻结的仓库ID', + actual_warehouse_code VARCHAR(128) NULL COMMENT '实际收货仓编码快照', + actual_warehouse_name VARCHAR(255) NULL COMMENT '实际收货仓名称快照', + overseas_received_at DATETIME(3) NULL COMMENT '海外整批收货完成时间', + packing_version INT NULL COMMENT '当前确认装箱清单版本', + overseas_shipped_at DATETIME(3) NULL COMMENT '国际段整批实际交寄时间', + domestic_received_at DATETIME(3) NULL COMMENT '国内整批收货完成时间', + charges_confirmed_at DATETIME(3) NULL COMMENT '国内整单费用核定时间', + customer_shipped_at DATETIME(3) NULL COMMENT '国内寄客户实际交寄时间', + domestic_receiver_name VARCHAR(128) NULL COMMENT '国际段国内收件人', + domestic_receiver_phone VARCHAR(64) NULL COMMENT '国际段国内收件电话', + domestic_receiver_address VARCHAR(1000) NULL COMMENT '国际段国内接收地址', + cancelled_at DATETIME(3) NULL COMMENT '购买前取消时间', + closed_at DATETIME(3) NULL COMMENT '异常关闭时间', + control_reason VARCHAR(1000) NULL COMMENT '取消或关闭原因', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY(order_id), KEY idx_procurement_flow_customer(customer_id,order_id), + CONSTRAINT chk_procurement_purchase_quantity CHECK(purchased_quantity IS NULL OR purchased_quantity>0), + CONSTRAINT chk_procurement_purchase_amount CHECK(purchase_amount IS NULL OR purchase_amount>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; + +CREATE TABLE ll_procurement_command ( + id BIGINT NOT NULL COMMENT '操作记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', action VARCHAR(48) NOT NULL COMMENT '采购节点动作', + idempotency_key VARCHAR(128) NOT NULL COMMENT '操作幂等键', request_hash CHAR(64) NOT NULL COMMENT '请求摘要', + operator_id VARCHAR(128) NOT NULL COMMENT '执行人', reason VARCHAR(1000) NOT NULL COMMENT '操作依据', + detail_json JSON NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_command(order_id,action,idempotency_key), + KEY idx_procurement_command_customer(customer_id,order_id,created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; + +CREATE TABLE ll_procurement_budget_authorization ( + id BIGINT NOT NULL COMMENT '预算授权ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', + customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + previous_amount DECIMAL(20,2) NULL COMMENT '原货款预算总上限;未授权保持空', previous_currency CHAR(3) NOT NULL COMMENT '原预算币种', + authorized_amount DECIMAL(20,2) NOT NULL COMMENT '客户授权的新货款总上限', currency CHAR(3) NOT NULL COMMENT '客户授权币种', + voucher_file_id BIGINT NOT NULL COMMENT '客户确认书面或聊天凭证', reason VARCHAR(1000) NOT NULL COMMENT '授权依据', + reviewed_by VARCHAR(128) NOT NULL COMMENT '核验人员', reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', + PRIMARY KEY(id), KEY idx_procurement_budget_order(customer_id,order_id,id), + CONSTRAINT chk_procurement_budget_amount CHECK(authorized_amount>=0 AND previous_amount>=0), + CONSTRAINT chk_procurement_budget_currency CHECK(currency IN('CNY','USD') AND previous_currency IN('CNY','USD')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; + +CREATE TABLE ll_procurement_receipt ( + id BIGINT NOT NULL COMMENT '实收明细ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + receipt_no VARCHAR(64) NOT NULL COMMENT '采购收货业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + source_carton_id BIGINT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', warehouse_id BIGINT NULL COMMENT '海外实际收货仓', + good_quantity BIGINT NOT NULL COMMENT '可发合格件数', damaged_quantity BIGINT NOT NULL COMMENT '破损待处置件数', extra_quantity BIGINT NOT NULL COMMENT '多到件数,不增加可发数量', + received_at DATETIME(3) NOT NULL COMMENT '实际收货时间', active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效实收事实', + supersedes_id BIGINT NULL COMMENT '受控更正所替代的原实收记录', created_by VARCHAR(128) NOT NULL COMMENT '登记人员', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_receipt_no(receipt_no), KEY idx_procurement_receipt_source(order_id,leg,source_carton_id,active), + CONSTRAINT chk_procurement_receipt_quantity CHECK(good_quantity>=0 AND damaged_quantity>=0 AND extra_quantity>=0 AND good_quantity+damaged_quantity+extra_quantity>0), + CONSTRAINT chk_procurement_receipt_leg CHECK((leg='OVERSEAS' AND source_carton_id IS NULL) OR (leg='DOMESTIC' AND source_carton_id IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; + +CREATE TABLE ll_procurement_disposition ( + id BIGINT NOT NULL COMMENT '差异处理ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', source_carton_id BIGINT NULL COMMENT '国内原交寄箱来源', + receipt_id BIGINT NULL COMMENT '破损或多货对应实收记录,短少为空', kind VARCHAR(24) NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', + quantity BIGINT NOT NULL COMMENT '已批准处置件数', decision VARCHAR(32) NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', + active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '当前有效处置;撤销只改变有效性并保留原事实', + voucher_file_id BIGINT NOT NULL COMMENT '本单客户确认或实际处置凭证', reason VARCHAR(1000) NOT NULL COMMENT '处置依据', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), KEY idx_procurement_disposition_source(order_id,leg,source_carton_id,receipt_id,kind), + CONSTRAINT chk_procurement_disposition_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; + +CREATE TABLE ll_procurement_manifest ( + id BIGINT NOT NULL COMMENT '装箱清单ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + version INT NOT NULL COMMENT '订单内装箱版本', status VARCHAR(16) NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', + confirmed_at DATETIME(3) NULL COMMENT '确认时间', created_by VARCHAR(128) NOT NULL COMMENT '制单人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_manifest_version(order_id,version), + CONSTRAINT chk_procurement_manifest_status CHECK(status IN('DRAFT','CONFIRMED','SUPERSEDED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; + +CREATE TABLE ll_procurement_carton ( + id BIGINT NOT NULL COMMENT '版本内箱记录ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', manifest_id BIGINT NOT NULL COMMENT '装箱清单版本', + carton_no VARCHAR(64) NOT NULL COMMENT '采购订单内业务箱号', length_cm DECIMAL(12,2) NOT NULL COMMENT '外箱长厘米', + width_cm DECIMAL(12,2) NOT NULL COMMENT '外箱宽厘米', height_cm DECIMAL(12,2) NOT NULL COMMENT '外箱高厘米', + gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '封箱毛重千克', quantity BIGINT NOT NULL COMMENT '本箱合格商品总件数', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_carton_version(manifest_id,carton_no), KEY idx_procurement_carton_order(order_id,manifest_id), + CONSTRAINT chk_procurement_carton_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0 AND quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; + +CREATE TABLE ll_procurement_carton_source ( + carton_id BIGINT NOT NULL COMMENT '版本内箱ID', receipt_id BIGINT NOT NULL COMMENT '海外实收明细来源', quantity BIGINT NOT NULL COMMENT '从该实收装入本箱的件数', + PRIMARY KEY(carton_id,receipt_id), KEY idx_procurement_carton_receipt(receipt_id), CONSTRAINT chk_procurement_carton_source_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; + +CREATE TABLE ll_procurement_shipment ( + id BIGINT NOT NULL COMMENT '采购交寄ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + shipment_no VARCHAR(64) NOT NULL COMMENT '交寄业务编号', leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', + manifest_id BIGINT NOT NULL COMMENT '原海外装箱清单版本', shipped_at DATETIME(3) NOT NULL COMMENT '整批真实交承运方时间', + address_snapshot JSON NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', created_by VARCHAR(128) NOT NULL COMMENT '交寄确认人', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_shipment_leg(order_id,leg), UNIQUE KEY uk_procurement_shipment_no(shipment_no), + CONSTRAINT chk_procurement_shipment_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; + +CREATE TABLE ll_procurement_shipment_item ( + shipment_id BIGINT NOT NULL COMMENT '采购交寄ID', carton_id BIGINT NOT NULL COMMENT '原海外装箱版本内箱ID', quantity BIGINT NOT NULL COMMENT '本次实际交寄件数', + carrier VARCHAR(128) NOT NULL COMMENT '承运商快照', tracking_no VARCHAR(128) NOT NULL COMMENT '运单号快照', + label_snapshot JSON NOT NULL COMMENT '当次箱规、面单及货物来源快照', + PRIMARY KEY(shipment_id,carton_id), CONSTRAINT chk_procurement_shipment_item_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; + +CREATE TABLE ll_procurement_label_set ( + id BIGINT NOT NULL COMMENT '面单批版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + leg VARCHAR(16) NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', version INT NOT NULL COMMENT '订单段内面单版本', + manifest_id BIGINT NOT NULL COMMENT '原海外确认装箱版本', cargo_hash CHAR(64) NOT NULL COMMENT '箱规及有效货物来源摘要', + sender_snapshot JSON NOT NULL COMMENT '寄件人姓名电话地址快照', receiver_snapshot JSON NOT NULL COMMENT '收件人姓名电话地址快照', + created_by VARCHAR(128) NOT NULL COMMENT '制单操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_label_version(order_id,leg,version), + CONSTRAINT chk_procurement_label_leg CHECK(leg IN('OVERSEAS','DOMESTIC')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; + +CREATE TABLE ll_procurement_label_item ( + label_set_id BIGINT NOT NULL COMMENT '面单批版本ID', carton_id BIGINT NOT NULL COMMENT '原海外清单箱ID', + file_id BIGINT NOT NULL COMMENT '本采购订单面单文件', page_no INT NOT NULL COMMENT '该箱对应文件页码,从1开始', + carrier VARCHAR(128) NOT NULL COMMENT '承运商', tracking_no VARCHAR(128) NOT NULL COMMENT '箱主或子运单号', + applied_at DATETIME(3) NULL COMMENT '真实贴单时间', applied_by VARCHAR(128) NULL COMMENT '贴单确认人', + PRIMARY KEY(label_set_id,carton_id), CONSTRAINT chk_procurement_label_page CHECK(page_no>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; + +CREATE TABLE ll_procurement_estimate_review ( + id BIGINT NOT NULL COMMENT '预估核对ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', label_set_id BIGINT NOT NULL COMMENT '已核对国际面单版本', + fee_id BIGINT NOT NULL COMMENT '唯一有效预估费用版本', reviewed_by VARCHAR(128) NOT NULL COMMENT '核价人员', + reviewed_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_estimate_review(label_set_id,fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; + +CREATE TABLE ll_procurement_repack ( + id BIGINT NOT NULL COMMENT '换包装版本ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', carton_id BIGINT NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', + length_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱长厘米', width_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱宽厘米', + height_cm DECIMAL(12,2) NOT NULL COMMENT '新外箱高厘米', gross_weight_kg DECIMAL(12,3) NOT NULL COMMENT '新封箱毛重千克', + voucher_file_id BIGINT NOT NULL COMMENT '本单换包装证据', reason VARCHAR(1000) NOT NULL COMMENT '换包装依据', + created_by VARCHAR(128) NOT NULL COMMENT '国内作业人员', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY(id), KEY idx_procurement_repack_carton(order_id,carton_id,id), + CONSTRAINT chk_procurement_repack_measurement CHECK(length_cm>0 AND width_cm>0 AND height_cm>0 AND gross_weight_kg>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; + +CREATE TABLE ll_procurement_fee_correction ( + id BIGINT NOT NULL COMMENT '费用链更正ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + correction_no VARCHAR(64) NOT NULL COMMENT '财务更正业务号', root_fee_id BIGINT NOT NULL COMMENT '原实际费用链根', + idempotency_key VARCHAR(128) NOT NULL COMMENT '本单更正请求幂等键', request_hash CHAR(64) NOT NULL COMMENT '更正目标及凭证请求摘要', + target_original_amount DECIMAL(20,2) NOT NULL COMMENT '本次正确原币总额', original_currency CHAR(3) NOT NULL COMMENT '沿用原费用币种', + exchange_rate DECIMAL(20,6) NOT NULL COMMENT '沿用原费用汇率快照', exchange_rate_month CHAR(7) NOT NULL COMMENT '原汇率月份,不取本次操作月份', + target_amount DECIMAL(20,2) NOT NULL COMMENT '正确总额折算CNY', previous_net_amount DECIMAL(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', + difference_amount DECIMAL(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', + voucher_file_id BIGINT NOT NULL COMMENT '本订单财务更正凭证', reason VARCHAR(500) NOT NULL COMMENT '更正依据', + created_by VARCHAR(128) NOT NULL COMMENT '授权财务操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_correction_no(correction_no), UNIQUE KEY uk_procurement_correction_request(order_id,idempotency_key), + KEY idx_procurement_correction_root(order_id,root_fee_id,id), + CONSTRAINT chk_procurement_correction_amount CHECK(target_original_amount>=0 AND target_amount>=0 AND previous_net_amount>=0 AND exchange_rate>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; + +CREATE TABLE ll_procurement_fee_correction_item ( + correction_id BIGINT NOT NULL COMMENT '本次费用链更正ID', fee_id BIGINT NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', + PRIMARY KEY(correction_id,fee_id), UNIQUE KEY uk_procurement_correction_fee(fee_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; + +CREATE TABLE ll_procurement_control_note ( + id BIGINT NOT NULL COMMENT '采购更正说明ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + note_no VARCHAR(64) NOT NULL COMMENT '更正说明业务号', target_type VARCHAR(24) NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', + target_id BIGINT NULL COMMENT '本单事实对象引用,购买为空', blocking TINYINT(1) NOT NULL COMMENT '是否阻断后续计划履约', + voucher_file_id BIGINT NOT NULL COMMENT '本单内部事实更正凭证', reason VARCHAR(1000) NOT NULL COMMENT '内部更正说明', + client_summary VARCHAR(500) NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', + original_value_json JSON NULL COMMENT '服务端记录的原事实快照', corrected_value_json JSON NULL COMMENT '服务端记录的更正事实快照', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理操作人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + resolved_by VARCHAR(128) NULL COMMENT '处理确认人', resolved_at DATETIME(3) NULL COMMENT '阻断解除时间', + resolution_reason VARCHAR(1000) NULL COMMENT '内部处理结果', resolution_voucher_id BIGINT NULL COMMENT '本单处理凭证', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_note_no(note_no), KEY idx_procurement_note_block(order_id,blocking,resolved_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; + +CREATE TABLE ll_procurement_closure ( + id BIGINT NOT NULL COMMENT '采购异常关闭ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户隔离键', + closure_no VARCHAR(64) NOT NULL COMMENT '异常关闭业务号', customer_voucher_file_id BIGINT NOT NULL COMMENT '客户同意停止履约凭证', + financial_conclusion VARCHAR(1000) NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', + reason VARCHAR(1000) NOT NULL COMMENT '内部关闭原因', client_summary VARCHAR(500) NOT NULL COMMENT '人工确认的对客关闭摘要', + created_by VARCHAR(128) NOT NULL COMMENT '采购管理确认人', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', + PRIMARY KEY(id), UNIQUE KEY uk_procurement_closure_order(order_id), UNIQUE KEY uk_procurement_closure_no(closure_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; + +CREATE TABLE ll_procurement_closure_item ( + id BIGINT NOT NULL COMMENT '关闭货物处置ID', closure_id BIGINT NOT NULL COMMENT '采购关闭ID', + source_kind VARCHAR(32) NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', source_id BIGINT NULL COMMENT '原实收或国际交寄箱,购买未达为空', + quantity BIGINT NOT NULL COMMENT '本来源最终明确去向件数', decision VARCHAR(40) NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', + destination VARCHAR(500) NOT NULL COMMENT '实际去向及接收方说明', voucher_file_id BIGINT NOT NULL COMMENT '本单实物处置证明', + PRIMARY KEY(id), KEY idx_procurement_closure_source(closure_id,source_kind,source_id), + CONSTRAINT chk_procurement_closure_quantity CHECK(quantity>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; + +CREATE TABLE ll_selfop_outbound_discovery_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + catalog_from_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖', + catalog_until_utc DATETIME(3) NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖', + next_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧', + next_observation_sequence BIGINT NOT NULL DEFAULT 1 COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次', + active_task_id BIGINT NULL COMMENT '当前共享发现/详情补齐任务', + lease_owner VARCHAR(128) NULL COMMENT '账号发现执行者', + lease_until_utc DATETIME(3) NULL COMMENT '账号发现租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '防止过期发现进程继续写共享暂存或派发任务', + request_budget INT NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正', + time_budget_seconds INT NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正', + min_request_interval_ms INT NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置', + api_budget_json JSON NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据', + api_cooldown_until_utc DATETIME(3) NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除', + api_pause_reason VARCHAR(64) NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_discovery_account (source_account), + CONSTRAINT chk_selfop_outbound_discovery_scope CHECK (request_budget > 0 AND time_budget_seconds > 0 AND min_request_interval_ms > 0 AND fence_token >= 0 AND next_sequence > 0 AND next_observation_sequence > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域'; + +CREATE TABLE ll_selfop_sync_scope ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + data_family VARCHAR(16) NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥', + scope_json JSON NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单', + scope_hash CHAR(64) NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合', + business_timezone VARCHAR(64) NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准', + source_timezone VARCHAR(64) NULL COMMENT '本仓已核准的出库源时间时区', + schedule_cron VARCHAR(128) NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行', + stale_after_seconds INT NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整', + warehouse_policy_ref VARCHAR(128) NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表', + initial_from_utc DATETIME(3) NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月', + initial_until_utc DATETIME(3) NULL COMMENT '首采固定UTC截止,重试不重新取当前时间', + discovery_from_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC下界', + discovery_until_utc DATETIME(3) NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点', + fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '出库事实归并修订号;内容实际变化才增加', + published_fact_revision BIGINT NOT NULL DEFAULT 0 COMMENT '本仓最后已发布销量版所使用的事实修订号', + rebuild_required TINYINT NOT NULL DEFAULT 0 COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1', + invalidated_at_utc DATETIME(3) NULL COMMENT '最近一次因事实修订使当前版待重建的时刻', + published_run_id BIGINT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次', + lease_owner VARCHAR(128) NULL COMMENT '当前执行者令牌,不使用仅进程内锁', + lease_until_utc DATETIME(3) NULL COMMENT '租约到期时刻', + fence_token BIGINT NOT NULL DEFAULT 0 COMMENT '每次取得租约递增,事实提交和发布均校验', + known_order_cursor_id BIGINT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标', + known_order_round_max_id BIGINT NULL COMMENT '本轮已知单复查固定最大内部标识', + known_order_round_started_at_utc DATETIME(3) NULL COMMENT '本仓已知单复查轮次开始UTC时间', + known_order_round_finished_at_utc DATETIME(3) NULL COMMENT '本仓已知单完整轮转完成UTC时间', + history_round_from_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC下界', + history_round_until_utc DATETIME(3) NULL COMMENT '本仓历史核对固定UTC上界', + history_slice_cursor_utc DATETIME(3) NULL COMMENT '本仓下一待核对历史切片UTC位置', + history_round_finished_at_utc DATETIME(3) NULL COMMENT '上一轮历史时间片全部完成时刻', + review_request_budget INT NOT NULL COMMENT '本仓单次复查最大请求数,必须为正', + review_time_budget_seconds INT NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正', + review_target_days INT NOT NULL DEFAULT 7 COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_scope (source_account,warehouse_code,data_family), + KEY idx_selfop_scope_lease (lease_until_utc), + CONSTRAINT chk_selfop_sync_scope CHECK (data_family IN ('OUTBOUND','INVENTORY') AND stale_after_seconds > 0 AND review_request_budget > 0 AND review_time_budget_seconds > 0 AND review_target_days > 0 AND fact_revision >= 0 AND published_fact_revision >= 0 AND fence_token >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针'; + +CREATE TABLE ll_selfop_sync_task ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_no VARCHAR(64) NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识', + execution_purpose VARCHAR(16) NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承', + validation_root_task_id BIGINT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空', + validation_budget_json JSON NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因', + business_root_task_id BIGINT NULL COMMENT '测试人工正式采集共享预算根任务;生产任务为空', + business_budget_json JSON NULL COMMENT '仅测试正式采集根保存冻结请求上限、截止时间、消耗及停止原因', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + task_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + scope_id BIGINT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + discovery_scope_id BIGINT NULL COMMENT '共享发现必填,引用账号发现协调器', + upstream_task_id BIGINT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游', + discovery_sequence BIGINT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定', + observation_baseline BIGINT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', + request_key VARCHAR(128) NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', + request_hash CHAR(64) NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', + task_type VARCHAR(24) NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原', + planned_at_utc DATETIME(3) NULL COMMENT '计划时点;人工请求可空', + query_from_utc DATETIME(3) NULL COMMENT '出库查询区间UTC下界,库存不填', + query_until_utc DATETIME(3) NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', + query_scope_json JSON NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号', + routing_manifest_json JSON NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷', + scope_hash CHAR(64) NOT NULL COMMENT '建任务时的业务范围摘要', + status VARCHAR(20) NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功', + apply_state VARCHAR(20) NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE', + affected_warehouse_codes_json JSON NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补', + apply_started_at_utc DATETIME(3) NULL COMMENT '首段事实提交UTC时间', + apply_completed_at_utc DATETIME(3) NULL COMMENT '全部必需事实归并完成UTC时间,失败不填', + attempt_count INT NOT NULL DEFAULT 0 COMMENT '已创建的执行尝试数', + next_retry_at_utc DATETIME(3) NULL COMMENT '有限退避后下一次可执行时刻', + requested_by VARCHAR(128) NULL COMMENT '人工请求的平台操作人,仅内部审计', + result_published_run_id BIGINT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空', + progress_json JSON NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合', + progress_updated_at_utc DATETIME(3) NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_task_no (task_no), + UNIQUE KEY uk_selfop_task_request (source_account,request_key), + KEY idx_selfop_task_poll (status,next_retry_at_utc,id), + KEY idx_selfop_task_upstream (upstream_task_id,warehouse_code), + KEY idx_selfop_task_apply (apply_state,id), + KEY idx_selfop_task_scope_list (scope_id,created_at,id), + KEY idx_selfop_task_account_list (source_account,task_role,created_at,id), + KEY idx_selfop_task_business_root (business_root_task_id,status,id), + CONSTRAINT chk_selfop_business_budget CHECK ((business_root_task_id IS NULL AND business_budget_json IS NULL) OR (execution_purpose='BUSINESS' AND business_root_task_id IS NOT NULL AND ((business_root_task_id=id AND business_budget_json IS NOT NULL) OR (business_root_task_id<>id AND business_budget_json IS NULL)))), + CONSTRAINT chk_selfop_sync_task CHECK (attempt_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND ((execution_purpose = 'BUSINESS' AND validation_root_task_id IS NULL AND validation_budget_json IS NULL) OR (execution_purpose = 'VALIDATION' AND validation_root_task_id IS NOT NULL AND apply_state = 'NOT_APPLICABLE' AND result_published_run_id IS NULL AND status <> 'SUCCEEDED')) AND ((task_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL AND discovery_scope_id IS NOT NULL) OR (task_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求'; + +CREATE TABLE ll_selfop_sync_run ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + task_id BIGINT NOT NULL COMMENT '本次执行请求', + execution_purpose VARCHAR(16) NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本', + scope_id BIGINT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NULL COMMENT '来源仓库原始编码,按仓独立统计', + run_role VARCHAR(24) NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + upstream_task_id BIGINT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合', + attempt_no INT NOT NULL COMMENT '同一请求从 1 起递增', + scope_hash CHAR(64) NOT NULL COMMENT '本版本的业务范围摘要', + scope_snapshot_json JSON NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版', + observed_dimensions_json JSON NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围', + fence_token BIGINT NOT NULL COMMENT '本尝试持有的栅栏版本', + base_published_run_id BIGINT NULL COMMENT '执行开始时的上一可见版本', + reference_sales_run_id BIGINT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实', + inventory_daily_sample_date DATE NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空', + inventory_sample_rule_version VARCHAR(32) NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存', + status VARCHAR(24) NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头', + last_phase VARCHAR(24) NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实', + fetch_started_at_utc DATETIME(3) NULL COMMENT '实际采集开始UTC时间', + fetch_finished_at_utc DATETIME(3) NULL COMMENT '实际采集完成UTC时间,未完成为空', + build_started_at_utc DATETIME(3) NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', + warehouse_daily_ready TINYINT NOT NULL DEFAULT 0 COMMENT '仓库日汇总已完整构建并校验,含完整空版本', + finished_at_utc DATETIME(3) NULL COMMENT '本次尝试终止UTC时间', + published_at_utc DATETIME(3) NULL COMMENT '实际原子切换发布指针UTC时间', + superseded_at_utc DATETIME(3) NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', + page_count INT NOT NULL DEFAULT 0 COMMENT '已完整获取页数', + source_total_count BIGINT NULL COMMENT '上游可靠返回的总数;未返回则为空', + unique_object_count BIGINT NOT NULL DEFAULT 0 COMMENT '规范化去重后对象数', + progress_snapshot_json JSON NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写', + heartbeat_at_utc DATETIME(3) NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进', + coverage_status VARCHAR(16) NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断', + covered_from_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC下界', + covered_until_utc DATETIME(3) NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点', + complete_day_from DATE NULL COMMENT '本仓完整自然日区间首日,包含', + complete_day_until DATE NULL COMMENT '本仓完整自然日区间末日,不含', + applied_fact_revision BIGINT NULL COMMENT '本次尝试归并后的事实修订号,库存为空', + calculated_fact_revision BIGINT NULL COMMENT '候选销量版本计算所依据的固定事实修订号', + error_code VARCHAR(64) NULL COMMENT '内部可诊断错误分类,不保存认证信息', + error_message VARCHAR(500) NULL COMMENT '脱敏后的固定错误摘要', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_run_attempt (task_id,attempt_no), + UNIQUE KEY uk_selfop_inventory_daily (source_account,warehouse_code,inventory_daily_sample_date), + KEY idx_selfop_run_scope (scope_id,id), + KEY idx_selfop_run_status (status,updated_at), + CONSTRAINT chk_selfop_inventory_daily CHECK ((inventory_daily_sample_date IS NULL AND inventory_sample_rule_version IS NULL) OR (inventory_daily_sample_date IS NOT NULL AND inventory_sample_rule_version IS NOT NULL AND inventory_sample_rule_version = 'DAILY_SCHEDULED_FIRST_V1' AND execution_purpose = 'BUSINESS' AND run_role = 'WAREHOUSE_SYNC' AND status = 'PUBLISHED' AND coverage_status = 'COMPLETE')), + CONSTRAINT chk_selfop_sync_run CHECK (attempt_no > 0 AND page_count >= 0 AND unique_object_count >= 0 AND execution_purpose IN ('BUSINESS','VALIDATION') AND (execution_purpose <> 'VALIDATION' OR status NOT IN ('PUBLISHED','REUSED')) AND (source_total_count IS NULL OR source_total_count >= 0) AND ((run_role = 'ACCOUNT_DISCOVERY' AND scope_id IS NULL AND warehouse_code IS NULL) OR (run_role = 'WAREHOUSE_SYNC' AND scope_id IS NOT NULL AND warehouse_code IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头'; + +CREATE TABLE ll_selfop_sync_stage ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + run_id BIGINT NOT NULL COMMENT '首次写入该暂存对象的执行尝试', + stage_batch_id BIGINT NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页', + last_fetch_run_id BIGINT NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源', + observation_sequence BIGINT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + previous_warehouse_code VARCHAR(64) NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓', + object_type VARCHAR(16) NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK', + object_key_hash CHAR(64) NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对', + first_page_no INT NOT NULL COMMENT '首次发现页码', + observed_at_utc DATETIME(3) NOT NULL COMMENT '实际接收该对象的时刻', + content_hash CHAR(64) NOT NULL COMMENT '完整规范化对象摘要', + payload_json JSON NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段', + before_payload_json JSON NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空', + validation_status VARCHAR(16) NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常', + validation_code VARCHAR(64) NULL COMMENT '字段缺失、冲突、未知时区等原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stage_object (stage_batch_id,object_type,object_key_hash), + KEY idx_selfop_stage_history (object_type,object_key_hash,stage_batch_id), + KEY idx_selfop_stage_observation (object_type,object_key_hash,validation_status,observation_sequence), + KEY idx_selfop_stage_fact_observation (observation_sequence,warehouse_code,object_type,validation_status,run_id,observed_at_utc), + KEY idx_selfop_stage_route (stage_batch_id,warehouse_code,validation_status), + CONSTRAINT chk_selfop_sync_stage CHECK (first_page_no > 0 AND (object_type <> 'OUTBOUND_ORDER' OR validation_status <> 'VALID' OR observation_sequence IS NOT NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据'; + +CREATE TABLE ll_selfop_outbound_order ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + delivery_no VARCHAR(128) NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份', + source_no VARCHAR(128) NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示', + source_status INT NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码', + source_created_time VARCHAR(64) NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代', + source_outbound_time VARCHAR(64) NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代', + source_timezone VARCHAR(64) NULL COMMENT '解析采用的源时区', + outbound_at_utc DATETIME(3) NULL COMMENT '已核准的实际出库时间点;非出库状态可空', + business_date DATE NULL COMMENT '已核准业务时区下的出库日', + business_timezone VARCHAR(64) NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日', + sales_eligible TINYINT NOT NULL DEFAULT 0 COMMENT '仅已出库、时间与全部数量有效时为 1', + revision_no BIGINT NOT NULL DEFAULT 1 COMMENT '该单内容变更次数,不等于拉取次数', + content_hash CHAR(64) NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要', + last_run_id BIGINT NOT NULL COMMENT '最后一次确认或修订的执行尝试', + last_observation_sequence BIGINT NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order (source_account,customer_code,delivery_no), + KEY idx_selfop_order_daily (source_account,warehouse_code,sales_eligible,business_date,id), + KEY idx_selfop_order_run (last_run_id), + CONSTRAINT chk_selfop_outbound_order CHECK (revision_no > 0 AND last_observation_sequence > 0 AND sales_eligible IN (0,1) AND (sales_eligible = 0 OR (source_status = 100 AND source_timezone IS NOT NULL AND outbound_at_utc IS NOT NULL AND business_date IS NOT NULL))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实'; + +CREATE TABLE ll_selfop_outbound_item ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + order_id BIGINT NOT NULL COMMENT '对应外部出库事实', + sku_code VARCHAR(128) NOT NULL COMMENT '原始 productSku', + product_name VARCHAR(255) NULL COMMENT '来源商品名称,仅作展示', + actual_quantity DECIMAL(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值', + quantity_unit VARCHAR(32) NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_order_sku (order_id,sku_code), + KEY idx_selfop_item_sku (sku_code,order_id), + CONSTRAINT chk_selfop_outbound_item CHECK (actual_quantity >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细'; + +CREATE TABLE ll_selfop_inventory_snapshot ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + snapshot_run_id BIGINT NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_quality SMALLINT NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存', + product_name VARCHAR(255) NULL COMMENT '来源名称', + physical_quantity DECIMAL(20,6) NULL COMMENT '来源stkQty实物库存,未知为空', + available_quantity DECIMAL(20,6) NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结', + frozen_quantity DECIMAL(20,6) NULL COMMENT '来源freezeQty冻结库存,未知为空', + transit_quantity DECIMAL(20,6) NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存', + source_unique_key VARCHAR(512) NULL COMMENT '原 uniqueKey4tenant,只作来源证据', + observed_at_utc DATETIME(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间', + quality_status VARCHAR(16) NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生', + quality_reason VARCHAR(64) NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_stock_snapshot (snapshot_run_id,source_account,customer_code,warehouse_code,sku_code,product_quality), + KEY idx_selfop_stock_lookup (source_account,customer_code,warehouse_code,sku_code,product_quality,snapshot_run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细'; + +CREATE TABLE ll_selfop_sku_daily_sales ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + sales_run_id BIGINT NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + business_date DATE NOT NULL COMMENT '已核准业务日', + outbound_quantity DECIMAL(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负', + outbound_order_count BIGINT NOT NULL COMMENT '该维度去重发货单数,非负', + quantity_unit VARCHAR(32) NOT NULL COMMENT '同一统计维度内一致的业务单位', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_daily_version (sales_run_id,source_account,customer_code,warehouse_code,sku_code,business_date), + KEY idx_selfop_daily_window_cover (sales_run_id,source_account,warehouse_code,business_date,customer_code,sku_code,outbound_quantity,outbound_order_count,quantity_unit), + KEY idx_selfop_daily_sku_cover (sales_run_id,source_account,warehouse_code,customer_code,sku_code,business_date,outbound_quantity,outbound_order_count,quantity_unit), + CONSTRAINT chk_selfop_sku_daily_sales CHECK (outbound_quantity >= 0 AND outbound_order_count >= 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; + +CREATE TABLE ll_selfop_warehouse_daily_sales ( + sales_run_id BIGINT NOT NULL COMMENT '完整销量计算版本标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码', + business_date DATE NOT NULL COMMENT '已核准业务日', + observed_quantity DECIMAL(38,6) NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空', + invalid_row_count BIGINT NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数', + source_row_count BIGINT NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + PRIMARY KEY (sales_run_id,source_account,warehouse_code,business_date), + CONSTRAINT chk_selfop_warehouse_daily_sales CHECK (source_row_count > 0 AND invalid_row_count >= 0 AND invalid_row_count <= source_row_count AND (observed_quantity IS NULL OR observed_quantity >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建'; + +CREATE TABLE ll_selfop_sku_profile ( + id BIGINT NOT NULL COMMENT '应用生成的记录标识', + created_at DATETIME(3) NOT NULL COMMENT 'UTC创建时间', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + profile_run_id BIGINT NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号别名,不保存密钥', + customer_code VARCHAR(64) NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + sku_code VARCHAR(128) NOT NULL COMMENT '原始产品 SKU', + product_name VARCHAR(255) NULL COMMENT '本版本确认的名称,不实时连接其他版本名称', + quantity_unit VARCHAR(32) NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标', + first_seen_at_utc DATETIME(3) NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据', + earliest_evidence_at_utc DATETIME(3) NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移', + evidence_type VARCHAR(24) NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据', + evidence_run_id BIGINT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空', + last_seen_run_id BIGINT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_profile_version (profile_run_id,source_account,customer_code,warehouse_code,sku_code), + KEY idx_selfop_profile_evidence (profile_run_id,earliest_evidence_at_utc,customer_code,sku_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据'; + +CREATE TABLE ll_appointment_impact ( + container_booking_id VARCHAR(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示', + impact_no VARCHAR(40) NOT NULL COMMENT '船期核对事项业务号', + baseline_json LONGTEXT NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径', + latest_json LONGTEXT NOT NULL COMMENT '最新已读取观察,未知可为JSON null', + forecast_token CHAR(64) NOT NULL COMMENT '最新观察摘要,处理时防止旧预测结案', + status VARCHAR(20) NOT NULL COMMENT '事项状态:MONITORING监测/OPEN待处理/RESOLVED已完成', + reason VARCHAR(64) NULL COMMENT '预测变化或资料待核实原因', + owner_id VARCHAR(128) NULL COMMENT '认领平台人员ID', + owner_name VARCHAR(200) NULL COMMENT '负责人姓名快照', + version INT NOT NULL COMMENT '观察及人工处理共同版本', + updated_at DATETIME(3) NOT NULL COMMENT 'UTC更新时间', + PRIMARY KEY (container_booking_id), + UNIQUE KEY uk_appointment_impact_no (impact_no), + KEY idx_appointment_impact_work (status,updated_at), + CONSTRAINT chk_appointment_impact_status CHECK (status IN ('MONITORING','OPEN','RESOLVED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输'; + +CREATE TABLE ll_appointment_impact_history ( + id BIGINT NOT NULL COMMENT '历史记录标识', + container_booking_id VARCHAR(64) NOT NULL COMMENT '关联柜次标识', + version INT NOT NULL COMMENT '记录版本', + action VARCHAR(20) NOT NULL COMMENT '操作类型:OBSERVED观察/CLAIM认领/RECORD记录/RESOLVE完成', + operator_id VARCHAR(128) NOT NULL COMMENT '操作人标识', + operator_name VARCHAR(200) NULL COMMENT '操作人姓名', + result VARCHAR(1000) NULL COMMENT '人工处理结果;观察记录为空', + next_action VARCHAR(500) NULL COMMENT '下一步处理事项', + followup_date DATE NULL COMMENT '业务跟进日期', + command_key VARCHAR(64) NULL COMMENT '人工命令幂等键;观察记录为空', + request_hash CHAR(64) NULL COMMENT '请求内容摘要', + response_json LONGTEXT NOT NULL COMMENT '该版本完整响应快照,支持精确重放', + created_at DATETIME(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (id), + UNIQUE KEY uk_appointment_impact_version (container_booking_id,version), + UNIQUE KEY uk_appointment_impact_command (container_booking_id,operator_id,command_key), + CONSTRAINT fk_appointment_impact_history FOREIGN KEY (container_booking_id) REFERENCES ll_appointment_impact(container_booking_id), + CONSTRAINT chk_appointment_impact_action CHECK (action IN ('OBSERVED','CLAIM','RECORD','RESOLVE')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='船期观察及人工处理追加历史'; + +CREATE TABLE ll_selfop_owner ( + id BIGINT NOT NULL COMMENT '主键', + run_id BIGINT NOT NULL COMMENT '完整资料采集版本', + source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号', + customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码', + customer_name VARCHAR(255) NULL COMMENT '领星货主名称', + source_status VARCHAR(32) NULL COMMENT '来源启停状态', + observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_owner_version (run_id,source_account,customer_code), + KEY idx_selfop_owner_lookup (source_account,customer_code,run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星货主资料版本'; + +CREATE TABLE ll_selfop_product ( + id BIGINT NOT NULL COMMENT '主键', + run_id BIGINT NOT NULL COMMENT '完整资料采集版本', + source_account VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号', + customer_code VARCHAR(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码', + sku_code VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '货主商品编码', + product_name VARCHAR(255) NULL COMMENT '商品名称', + declared_length DECIMAL(24,6) NULL COMMENT '商品资料长度', + declared_width DECIMAL(24,6) NULL COMMENT '商品资料宽度', + declared_height DECIMAL(24,6) NULL COMMENT '商品资料高度', + declared_size_unit VARCHAR(32) NULL COMMENT '商品资料尺寸单位', + measured_length DECIMAL(24,6) NULL COMMENT '仓库实测长度', + measured_width DECIMAL(24,6) NULL COMMENT '仓库实测宽度', + measured_height DECIMAL(24,6) NULL COMMENT '仓库实测高度', + measured_size_unit VARCHAR(32) NULL COMMENT '仓库实测尺寸单位', + observed_at_utc DATETIME(6) NOT NULL COMMENT '资料观测时间', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_product_version (run_id,source_account,customer_code,sku_code), + KEY idx_selfop_product_lookup (source_account,customer_code,sku_code,run_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='自营领星商品资料版本'; + +CREATE TABLE ll_selfop_inventory_flow ( + id BIGINT NOT NULL COMMENT '应用生成的流水记录标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + source_flow_id VARCHAR(64) NOT NULL COMMENT '来源流水字符串标识', + product_quality INT NOT NULL COMMENT '来源品质,零正品一次品,其他未知', + cell_no VARCHAR(128) NULL COMMENT '来源库位编码', + inner_batch_no VARCHAR(128) NULL COMMENT '来源内部批次号', + bill_no VARCHAR(128) NULL COMMENT '来源业务单号', + source_no VARCHAR(128) NULL COMMENT '来源关联单号', + bill_type VARCHAR(32) NULL COMMENT '来源业务类型,未验证类型不得参与正式还原', + book_qty DECIMAL(20,6) NULL COMMENT '来源记账增减量,未知为空', + book_change_qty_raw VARCHAR(64) NULL COMMENT '来源带符号变动量原文', + delta_quantity DECIMAL(20,6) NULL COMMENT '解析后的有符号实物变动量', + close_quantity DECIMAL(20,6) NULL COMMENT '对应库位批次品质链的结存', + source_create_time VARCHAR(64) NULL COMMENT '来源北京时间原文', + event_at_utc DATETIME(3) NULL COMMENT '流水发生UTC时刻,无法解析时为空', + business_date DATE NULL COMMENT '北京时间业务日期', + quantity_unit VARCHAR(16) NULL COMMENT '已确认数量单位,未知为空', + content_hash CHAR(64) NOT NULL COMMENT '当前事实内容摘要', + revision_no BIGINT NOT NULL COMMENT '同来源流水单调增加的修订号', + stage_id BIGINT NOT NULL COMMENT '当前事实对应不可变暂存证据', + first_run_id BIGINT NOT NULL COMMENT '首次观察执行尝试', + last_run_id BIGINT NOT NULL COMMENT '最近确认执行尝试', + last_observed_at DATETIME(3) NOT NULL COMMENT '最近观察UTC时刻', + validation_status VARCHAR(16) NOT NULL COMMENT '字段资格,有效或未知', + validation_reason VARCHAR(64) NULL COMMENT '数量或身份待核验原因', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_flow_source (source_account,warehouse_code,source_flow_id), + KEY idx_selfop_flow_sku (source_account,warehouse_code,customer_code,sku_code,event_at_utc), + KEY idx_selfop_flow_day (source_account,warehouse_code,business_date), + CONSTRAINT chk_selfop_flow_revision CHECK (revision_no > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据'; + +CREATE TABLE ll_selfop_inventory_day_version ( + id BIGINT NOT NULL COMMENT '不可变历史日版本标识', + source_account VARCHAR(64) NOT NULL COMMENT '受控来源账号', + warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码', + business_date DATE NOT NULL COMMENT '北京时间日末所属日期', + scope_hash CHAR(64) NOT NULL COMMENT '业务范围摘要', + rule_version VARCHAR(32) NOT NULL COMMENT '日末库存还原规则版本', + build_run_id BIGINT NOT NULL COMMENT '历史重建执行尝试', + publication_group_id BIGINT NOT NULL COMMENT '必须一起切换的发布组标识', + sales_run_id BIGINT NULL COMMENT '构建时固定的销量证据版本', + anchor_run_id BIGINT NOT NULL COMMENT '固定的独立库存锚点版本', + input_hash CHAR(64) NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要', + evidence_json JSON NOT NULL COMMENT '固定输入清单及不可变证据引用', + status VARCHAR(16) NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败', + head_slot TINYINT NULL COMMENT '当前日头为一,非当前版本为空', + valid_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '合格商品数', + unknown_sku_count BIGINT NOT NULL DEFAULT 0 COMMENT '待核验商品数', + created_at DATETIME(3) NOT NULL COMMENT '版本创建UTC时刻', + published_at DATETIME(3) NULL COMMENT '发布组提交UTC时刻', + superseded_at DATETIME(3) NULL COMMENT '被新组替代的UTC时刻', + invalidated_at DATETIME(3) NULL COMMENT '已确认输入失效的UTC时刻', + invalidation_reason VARCHAR(64) NULL COMMENT '旧版本失效原因', + rebuild_run_id BIGINT NULL COMMENT '处理失效版本的重建尝试', + PRIMARY KEY (id), + UNIQUE KEY uk_selfop_history_head (source_account,warehouse_code,business_date,head_slot), + UNIQUE KEY uk_selfop_history_build_day (build_run_id,business_date), + KEY idx_selfop_history_group (publication_group_id,status), + CONSTRAINT chk_selfop_history_head CHECK (head_slot IS NULL OR (head_slot=1 AND status='PUBLISHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头'; + +CREATE TABLE ll_selfop_inventory_daily ( + day_version_id BIGINT NOT NULL COMMENT '所属历史日版本', + customer_code VARCHAR(64) NOT NULL COMMENT '来源货主编码', + sku_code VARCHAR(128) NOT NULL COMMENT '来源商品编码', + product_quality INT NOT NULL COMMENT '来源库存品质', + physical_quantity DECIMAL(20,6) NULL COMMENT '日末实物库存,未知为空,已确认零为零', + quality_status VARCHAR(16) NOT NULL COMMENT '日末资格,有效或未知', + quality_reason VARCHAR(64) NULL COMMENT '缺覆盖或锚点等未知原因', + anchor_reference BIGINT NULL COMMENT '独立库存锚点版本', + evidence_hash CHAR(64) NOT NULL COMMENT '该日计算证据摘要', + source VARCHAR(32) NOT NULL COMMENT '库存来源标记,流水还原', + PRIMARY KEY (day_version_id,customer_code,sku_code,product_quality), + KEY idx_selfop_daily_identity (customer_code,sku_code,day_version_id), + CONSTRAINT chk_selfop_daily_quantity CHECK ((quality_status='VALID' AND physical_quantity IS NOT NULL AND physical_quantity>=0) OR (quality_status='UNKNOWN' AND physical_quantity IS NULL)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; diff --git a/output/local-schema-repair/index-repair.json b/output/local-schema-repair/index-repair.json new file mode 100644 index 000000000..1044ee558 --- /dev/null +++ b/output/local-schema-repair/index-repair.json @@ -0,0 +1,100 @@ +[ + { + "db": "baiyue_firstleg", + "baselineTables": 131, + "missingColumns": [], + "missingIndexes": [ + "fl_container_booking.uk_container_booking_ocean_container", + "fl_container_booking.idx_container_booking_container_no", + "fl_container_booking.idx_container_booking_merge", + "fl_container_booking.idx_container_booking_transit_summary", + "fl_container_fee_detail.idx_container_fee_review", + "fl_container_transport_history.uk_container_transport_history_operation", + "fl_customer_charge_rule.idx_charge_rule_initialization", + "fl_inbound_order.idx_inbound_smart_loading", + "fl_inbound_measurement.idx_measure_inbound_status_id", + "fl_loading_plan_item.uk_loading_item_plan_assignment", + "fl_loading_plan_item.idx_loading_item_candidate", + "fl_loading_plan_item.idx_loading_item_pallet", + "fl_ocean_booking.uk_ocean_booking_bill_of_lading", + "fl_carrier_schedule_node_fact.idx_container_shipping_node_timeline", + "fl_truck_appointment.uk_truck_active_ocean_booking", + "fl_truck_appointment.idx_truck_appointment_ocean", + "fl_inbound_measurement_item.idx_measure_item_candidate", + "fl_inbound_split_item.idx_inbound_split_candidate" + ], + "appliedIndexes": [ + "fl_container_booking.uk_container_booking_ocean_container", + "fl_container_booking.idx_container_booking_container_no", + "fl_container_booking.idx_container_booking_merge", + "fl_container_booking.idx_container_booking_transit_summary", + "fl_container_fee_detail.idx_container_fee_review", + "fl_container_transport_history.uk_container_transport_history_operation", + "fl_customer_charge_rule.idx_charge_rule_initialization", + "fl_inbound_order.idx_inbound_smart_loading", + "fl_inbound_measurement.idx_measure_inbound_status_id", + "fl_loading_plan_item.uk_loading_item_plan_assignment", + "fl_loading_plan_item.idx_loading_item_candidate", + "fl_loading_plan_item.idx_loading_item_pallet", + "fl_ocean_booking.uk_ocean_booking_bill_of_lading", + "fl_carrier_schedule_node_fact.idx_container_shipping_node_timeline", + "fl_truck_appointment.uk_truck_active_ocean_booking", + "fl_truck_appointment.idx_truck_appointment_ocean", + "fl_inbound_measurement_item.idx_measure_item_candidate", + "fl_inbound_split_item.idx_inbound_split_candidate" + ], + "blockedIndexes": [] + }, + { + "db": "baiyue_lastleg", + "baselineTables": 218, + "missingColumns": [], + "missingIndexes": [ + "ll_cargo_discard_item.uk_ll_discard_position_sku", + "ll_dispatch_draft_container.uk_ll_dispatch_draft_container_sequence", + "ll_dispatch_order.uk_dispatch_trip", + "ll_dispatch_pallet_lock.uk_ll_dispatch_active_warehouse_pallet", + "ll_dispatch_pda_pick_order.idx_dispatch_pick_return_receipt", + "ll_dropship_order_profile.idx_ll_dropship_profile_manifest", + "ll_dropship_sku_inventory.uk_ll_dropship_sku_inventory", + "ll_exception_order.idx_ll_exception_delivery_return", + "ll_exception_return_item.uk_ll_exception_return_carton", + "ll_inbound_fba_appointment.idx_ll_fba_appointment_planned", + "ll_inbound_fba_appointment_item.idx_ll_fba_active_source", + "ll_inbound_fba_appointment_item.idx_ll_fba_active_order", + "ll_inbound_plan.uk_ll_inbound_plan_source_order", + "ll_inbound_plan_pallet.idx_ll_inbound_pallet_no", + "ll_outbound_pallet_item.uk_outbound_source_attribution", + "ll_overseas_procurement_fee.uk_ll_procurement_fee_version", + "ll_fleet_trip_dispatch.uk_fleet_trip_dispatch_trip", + "ll_warehouse_transfer_inventory_allocation.idx_ll_wh_transfer_allocation_position", + "ll_return_packing_plan.idx_ll_return_packing_plan_warehouse" + ], + "appliedIndexes": [ + "ll_cargo_discard_item.uk_ll_discard_position_sku", + "ll_dispatch_draft_container.uk_ll_dispatch_draft_container_sequence", + "ll_dispatch_order.uk_dispatch_trip", + "ll_dispatch_pallet_lock.uk_ll_dispatch_active_warehouse_pallet", + "ll_dispatch_pda_pick_order.idx_dispatch_pick_return_receipt", + "ll_dropship_order_profile.idx_ll_dropship_profile_manifest", + "ll_dropship_sku_inventory.uk_ll_dropship_sku_inventory", + "ll_exception_order.idx_ll_exception_delivery_return", + "ll_exception_return_item.uk_ll_exception_return_carton", + "ll_inbound_fba_appointment.idx_ll_fba_appointment_planned", + "ll_inbound_fba_appointment_item.idx_ll_fba_active_source", + "ll_inbound_fba_appointment_item.idx_ll_fba_active_order", + "ll_inbound_plan_pallet.idx_ll_inbound_pallet_no", + "ll_outbound_pallet_item.uk_outbound_source_attribution", + "ll_overseas_procurement_fee.uk_ll_procurement_fee_version", + "ll_fleet_trip_dispatch.uk_fleet_trip_dispatch_trip", + "ll_warehouse_transfer_inventory_allocation.idx_ll_wh_transfer_allocation_position", + "ll_return_packing_plan.idx_ll_return_packing_plan_warehouse" + ], + "blockedIndexes": [ + { + "index": "ll_inbound_plan.uk_ll_inbound_plan_source_order", + "reason": "ERROR 1062 (23000) at line 1: Duplicate entry '10001-0' for key 'll_inbound_plan.uk_ll_inbound_plan_source_order'\r\n" + } + ] + } +] \ No newline at end of file diff --git a/output/local-schema-repair/initial-plan.json b/output/local-schema-repair/initial-plan.json new file mode 100644 index 000000000..9500fd24e --- /dev/null +++ b/output/local-schema-repair/initial-plan.json @@ -0,0 +1,267 @@ +{ + "baselineTables": 218, + "actualTables": 170, + "addedTables": [ + "ll_dispatch_draft_command", + "ll_dispatch_history", + "ll_return_carton_stock_receipt", + "ll_fba_receiving_agreement", + "ll_fba_receiving_member", + "ll_fba_receiving_change", + "ll_fba_receiving_revision", + "ll_fba_receiving_plan", + "ll_appointment_cargo_verification", + "ll_fba_appointment_command", + "ll_appointment_followup", + "ll_dispatch_command", + "ll_dispatch_delivery_arrangement", + "ll_delivery_facts_changed", + "event_delivery_retry_audit", + "ll_procurement_wallet_attempt", + "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", + "ll_procurement_label_set", + "ll_procurement_label_item", + "ll_procurement_estimate_review", + "ll_procurement_repack", + "ll_procurement_fee_correction", + "ll_procurement_fee_correction_item", + "ll_procurement_control_note", + "ll_procurement_closure", + "ll_procurement_closure_item", + "ll_selfop_outbound_discovery_scope", + "ll_selfop_sync_scope", + "ll_selfop_sync_task", + "ll_selfop_sync_run", + "ll_selfop_sync_stage", + "ll_selfop_outbound_order", + "ll_selfop_outbound_item", + "ll_selfop_inventory_snapshot", + "ll_selfop_sku_daily_sales", + "ll_selfop_warehouse_daily_sales", + "ll_selfop_sku_profile", + "ll_appointment_impact", + "ll_appointment_impact_history", + "ll_selfop_owner", + "ll_selfop_product", + "ll_selfop_inventory_flow", + "ll_selfop_inventory_day_version", + "ll_selfop_inventory_daily" + ], + "addedColumns": [ + "ll_cargo_discard_carton.source_type", + "ll_dispatch_draft.warehouse_id", + "ll_dispatch_draft.record_version", + "ll_dispatch_draft.sources_json", + "ll_dispatch_draft_container.source_keys_json", + "ll_dispatch_item.customer_name", + "ll_dispatch_item.source_key", + "ll_dispatch_item.source_version", + "ll_dispatch_item.source_snapshot_json", + "ll_dispatch_order.trip_id", + "ll_dispatch_order.overseas_warehouse_code", + "ll_dispatch_order.stage", + "ll_dispatch_order.record_version", + "ll_dispatch_order.cargo_revision", + "ll_dispatch_order.cargo_hash", + "ll_dispatch_order.review_state", + "ll_dispatch_order.review_round", + "ll_dispatch_order.review_basis_hash", + "ll_dispatch_order.dispatch_review_json", + "ll_dispatch_order.loading_review_json", + "ll_dispatch_order.resources_json", + "ll_dispatch_order.planned_departure_at", + "ll_dispatch_order.loading_completed_at", + "ll_dispatch_order.loading_completion_hash", + "ll_dispatch_order.loading_confirmation_json", + "ll_dispatch_order.arrangement_key", + "ll_dispatch_order.arrangement_business_hash", + "ll_dispatch_order.departed_at", + "ll_dispatch_order.delivery_completed_at", + "ll_dispatch_order.remark", + "ll_dispatch_pallet_lock.active_warehouse_pallet_id", + "ll_dropship_carton.product_quantity", + "ll_dropship_order_profile.manifest_version", + "ll_dropship_order_profile.manifest_package_count", + "ll_dropship_order_profile.manifest_fragment_count", + "ll_dropship_order_profile.manifest_hash", + "ll_exception_order.return_context_json", + "ll_exception_return_item.return_carton_id", + "ll_exception_return_item.return_carton_mark", + "ll_exception_return_item.received_packages", + "ll_inbound_fba_appointment.confirmation_no", + "ll_inbound_fba_appointment.planned_date", + "ll_inbound_fba_appointment.planned_at", + "ll_inbound_fba_appointment.planned_end_at", + "ll_inbound_fba_appointment.appointment_start_utc", + "ll_inbound_fba_appointment.appointment_end_utc", + "ll_inbound_fba_appointment.timezone", + "ll_inbound_fba_appointment.origin_warehouse_id", + "ll_inbound_fba_appointment.replacement_keys_json", + "ll_inbound_fba_appointment.repartition_reason", + "ll_inbound_fba_appointment_item.cargo_verification_version", + "ll_inbound_fba_appointment_item.shipment_references_json", + "ll_local_looseboard_order.product_name", + "ll_local_looseboard_order.origin_contact_name", + "ll_local_looseboard_order.destination_contact_name", + "ll_local_looseboard_order.pickup_at", + "ll_outbound_pallet_item.dispatch_item_id", + "ll_outbound_pallet_item.source_attribution_id", + "ll_outbound_pallet_item.source_order_id", + "ll_outbound_pallet_item.source_inventory_position_id", + "ll_outbound_pallet_item.source_return_receipt_id", + "ll_outbound_pallet_item.source_return_carton_id", + "ll_overseas_procurement_fee.purpose", + "ll_overseas_procurement_fee.confirmation_origin", + "ll_overseas_procurement_fee.fee_version", + "ll_overseas_procurement_fee.active", + "ll_overseas_procurement_fee.determination", + "ll_overseas_procurement_fee.original_currency", + "ll_overseas_procurement_fee.original_amount", + "ll_overseas_procurement_fee.exchange_rate", + "ll_overseas_procurement_fee.exchange_rate_month", + "ll_overseas_procurement_fee.business_date", + "ll_overseas_procurement_fee.wallet_business_id", + "ll_overseas_procurement_fee.wallet_business_no", + "ll_overseas_procurement_fee.wallet_hold_id", + "ll_overseas_procurement_fee.wallet_ledger_id", + "ll_overseas_procurement_fee.settled_command_event_id", + "ll_overseas_procurement_fee.source_fee_id", + "ll_overseas_procurement_fee.parent_fee_id", + "ll_overseas_procurement_fee.request_hash", + "ll_overseas_procurement_status_history.operation_type", + "ll_overseas_procurement_status_history.change_detail", + "ll_receiving_container_session.source_container_booking_id", + "ll_receiving_container_session.container_fact_hash", + "ll_receiving_container_session.overseas_warehouse_code", + "ll_us_truck_order.vehicle_type", + "ll_us_truck_order.origin_contact_name", + "ll_us_truck_order.origin_contact_phone", + "ll_us_truck_order.destination_contact_name", + "ll_us_truck_order.destination_contact_phone", + "ll_us_truck_order.pickup_at", + "ll_us_truck_order.delivery_at" + ], + "unsafe": [ + { + "table": "ll_dispatch_draft", + "column": "warehouse_id", + "count": 11, + "definition": "warehouse_id BIGINT NOT NULL COMMENT '本草稿唯一起运公共仓库引用'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dispatch_draft", + "column": "sources_json", + "count": 11, + "definition": "sources_json LONGTEXT NOT NULL COMMENT '服务端核验的真实来源及客户版本快照数组'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dispatch_draft_container", + "column": "source_keys_json", + "count": 7, + "definition": "source_keys_json LONGTEXT NOT NULL COMMENT '本装车容器已取得本草稿占用的来源键数组'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dispatch_item", + "column": "source_key", + "count": 60, + "definition": "source_key VARCHAR(255) NOT NULL COMMENT '本条货物稳定权威来源键'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dispatch_item", + "column": "source_version", + "count": 60, + "definition": "source_version CHAR(64) NOT NULL COMMENT '选货时核对的真实来源版本摘要'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dispatch_item", + "column": "source_snapshot_json", + "count": 60, + "definition": "source_snapshot_json JSON NOT NULL COMMENT '本条货物不可变来源及关联事实快照'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dispatch_order", + "column": "trip_id", + "count": 48, + "definition": "trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dispatch_order", + "column": "cargo_hash", + "count": 48, + "definition": "cargo_hash CHAR(64) NOT NULL COMMENT '当前整车真实货物及来源摘要'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dropship_carton", + "column": "product_quantity", + "count": 83, + "definition": "product_quantity bigint NOT NULL COMMENT '该箱实收商品件数'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dropship_order_profile", + "column": "manifest_version", + "count": 17, + "definition": "manifest_version int NOT NULL COMMENT '整托清单版本'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dropship_order_profile", + "column": "manifest_package_count", + "count": 17, + "definition": "manifest_package_count int NOT NULL COMMENT '整托总箱数'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dropship_order_profile", + "column": "manifest_fragment_count", + "count": 17, + "definition": "manifest_fragment_count int NOT NULL COMMENT '整托客户分片总数'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_dropship_order_profile", + "column": "manifest_hash", + "count": 17, + "definition": "manifest_hash char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '整托完整清单摘要'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_overseas_procurement_status_history", + "column": "operation_type", + "count": 9, + "definition": "operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_receiving_container_session", + "column": "source_container_booking_id", + "count": 1, + "definition": "source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_receiving_container_session", + "column": "container_fact_hash", + "count": 1, + "definition": "container_fact_hash varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '领取时整柜订单与计划事实摘要'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + } + ] +} \ No newline at end of file diff --git a/output/local-schema-repair/node-fact-before.tsv b/output/local-schema-repair/node-fact-before.tsv new file mode 100644 index 000000000..5797c2a74 --- /dev/null +++ b/output/local-schema-repair/node-fact-before.tsv @@ -0,0 +1,2 @@ +Table Create Table +fl_carrier_schedule_node_fact CREATE TABLE `fl_carrier_schedule_node_fact` (\n `id` bigint unsigned NOT NULL COMMENT '标准船节点事实ID',\n `query_id` bigint unsigned NOT NULL COMMENT '所属查询任务ID',\n `event_key` varchar(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键',\n `provider_code` varchar(32) NOT NULL COMMENT '轨迹供应商编码',\n `provider_event_id` varchar(128) NOT NULL COMMENT '供应商稳定事件ID',\n `provider_node_code` varchar(64) NOT NULL COMMENT '供应商原始节点编码',\n `standard_node` varchar(32) NOT NULL COMMENT '标准船节点',\n `node_sequence` int unsigned NOT NULL COMMENT '标准节点单调序号',\n `observed_at` datetime(3) NOT NULL COMMENT '供应商观测时间',\n `estimated_at` datetime(3) DEFAULT NULL COMMENT '供应商预计发生时间',\n `actual_at` datetime(3) DEFAULT NULL COMMENT '供应商确认实际发生时间',\n `location_code` varchar(32) DEFAULT NULL COMMENT '节点地点编码',\n `location_name` varchar(128) DEFAULT NULL COMMENT '节点地点名称',\n `source_reference` varchar(500) DEFAULT NULL COMMENT '可审计来源引用',\n `raw_payload_hash` char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文',\n `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间',\n `container_booking_id` bigint unsigned NOT NULL COMMENT '所属真实货柜ID',\n PRIMARY KEY (`id`),\n UNIQUE KEY `uk_carrier_schedule_node_event` (`query_id`,`event_key`),\n KEY `idx_carrier_schedule_node_timeline` (`query_id`,`node_sequence`,`observed_at`,`id`),\n KEY `idx_container_shipping_node_timeline` (`container_booking_id`,`observed_at`,`id`),\n CONSTRAINT `fk_carrier_schedule_node_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`),\n CONSTRAINT `chk_carrier_schedule_node_sequence` CHECK ((`node_sequence` in (10,20,30,40,50,60,70,80,90,100))),\n CONSTRAINT `chk_carrier_schedule_node_standard` CHECK ((`standard_node` in (_utf8mb4'DEPARTED_WAREHOUSE',_utf8mb4'AT_ORIGIN_PORT',_utf8mb4'LOADED_ON_VESSEL',_utf8mb4'DEPARTED_ORIGIN_PORT',_utf8mb4'IN_TRANSIT',_utf8mb4'ARRIVED_DESTINATION_PORT',_utf8mb4'DISCHARGED_FROM_VESSEL',_utf8mb4'PICKUP_READY',_utf8mb4'PICKED_UP',_utf8mb4'EMPTY_RETURNED')))\n) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='供应商标准船节点不可变事实' diff --git a/output/local-schema-repair/pda-task-check-before.tsv b/output/local-schema-repair/pda-task-check-before.tsv new file mode 100644 index 000000000..d2036f31a --- /dev/null +++ b/output/local-schema-repair/pda-task-check-before.tsv @@ -0,0 +1,2 @@ +CONSTRAINT_NAME CHECK_CLAUSE +chk_loading_pick_task_status (`task_status` in (_utf8mb4\\'LOADING\\',_utf8mb4\\'COMPLETED\\')) diff --git a/output/local-schema-repair/pda-task-trigger-before.tsv b/output/local-schema-repair/pda-task-trigger-before.tsv new file mode 100644 index 000000000..4a73f324f --- /dev/null +++ b/output/local-schema-repair/pda-task-trigger-before.tsv @@ -0,0 +1,2 @@ +Trigger sql_mode SQL Original Statement character_set_client collation_connection Database Collation Created +trg_loading_pick_task_before_update ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION CREATE DEFINER=`root`@`localhost` TRIGGER `trg_loading_pick_task_before_update` BEFORE UPDATE ON `fl_loading_pick_task` FOR EACH ROW BEGIN IF OLD.task_status = 'COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改'; END IF; IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_booking_id <=> OLD.container_booking_id) OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; END IF; IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; END IF; END utf8mb4 utf8mb4_0900_ai_ci utf8mb4_0900_ai_ci 2026-08-27 14:02:32.25 diff --git a/output/local-schema-repair/pda-task-trigger-fix.sql b/output/local-schema-repair/pda-task-trigger-fix.sql new file mode 100644 index 000000000..59b0234ab --- /dev/null +++ b/output/local-schema-repair/pda-task-trigger-fix.sql @@ -0,0 +1,22 @@ +DROP TRIGGER IF EXISTS trg_loading_pick_task_before_update; +DELIMITER $$ +CREATE TRIGGER trg_loading_pick_task_before_update +BEFORE UPDATE ON fl_loading_pick_task +FOR EACH ROW +BEGIN + IF OLD.task_status = 'COMPLETED' THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改'; + END IF; + IF NEW.loading_plan_id <> OLD.loading_plan_id + OR NOT (NEW.container_booking_id <=> OLD.container_booking_id) + OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; + END IF; + IF NOT ((OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING','COMPLETING')) + OR (OLD.task_status = 'COMPLETING' AND NEW.task_status IN ('COMPLETING','COMPLETION_FAILED','COMPLETED')) + OR (OLD.task_status = 'COMPLETION_FAILED' AND NEW.task_status = 'COMPLETING')) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态流转非法'; + END IF; +END$$ +DELIMITER ; + diff --git a/output/local-schema-repair/plan.json b/output/local-schema-repair/plan.json new file mode 100644 index 000000000..bf2ecdd6c --- /dev/null +++ b/output/local-schema-repair/plan.json @@ -0,0 +1,119 @@ +{ + "baselineTables": 218, + "actualTables": 180, + "addedTables": [ + "ll_appointment_followup", + "ll_dispatch_command", + "ll_dispatch_delivery_arrangement", + "ll_delivery_facts_changed", + "event_delivery_retry_audit", + "ll_procurement_wallet_attempt", + "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", + "ll_procurement_label_set", + "ll_procurement_label_item", + "ll_procurement_estimate_review", + "ll_procurement_repack", + "ll_procurement_fee_correction", + "ll_procurement_fee_correction_item", + "ll_procurement_control_note", + "ll_procurement_closure", + "ll_procurement_closure_item", + "ll_selfop_outbound_discovery_scope", + "ll_selfop_sync_scope", + "ll_selfop_sync_task", + "ll_selfop_sync_run", + "ll_selfop_sync_stage", + "ll_selfop_outbound_order", + "ll_selfop_outbound_item", + "ll_selfop_inventory_snapshot", + "ll_selfop_sku_daily_sales", + "ll_selfop_warehouse_daily_sales", + "ll_selfop_sku_profile", + "ll_appointment_impact", + "ll_appointment_impact_history", + "ll_selfop_owner", + "ll_selfop_product", + "ll_selfop_inventory_flow", + "ll_selfop_inventory_day_version", + "ll_selfop_inventory_daily" + ], + "addedColumns": [ + "ll_local_looseboard_order.product_name", + "ll_local_looseboard_order.estimated_pallet_count", + "ll_local_looseboard_order.origin_contact_name", + "ll_local_looseboard_order.origin_contact_phone", + "ll_local_looseboard_order.destination_contact_name", + "ll_local_looseboard_order.destination_contact_phone", + "ll_local_looseboard_order.pickup_at", + "ll_local_looseboard_order.delivery_at", + "ll_outbound_pallet_item.dispatch_item_id", + "ll_outbound_pallet_item.source_attribution_id", + "ll_outbound_pallet_item.source_order_id", + "ll_outbound_pallet_item.source_inventory_position_id", + "ll_outbound_pallet_item.source_return_receipt_id", + "ll_outbound_pallet_item.source_return_carton_id", + "ll_overseas_procurement_fee.purpose", + "ll_overseas_procurement_fee.confirmation_origin", + "ll_overseas_procurement_fee.fee_version", + "ll_overseas_procurement_fee.active", + "ll_overseas_procurement_fee.determination", + "ll_overseas_procurement_fee.original_currency", + "ll_overseas_procurement_fee.original_amount", + "ll_overseas_procurement_fee.exchange_rate", + "ll_overseas_procurement_fee.exchange_rate_month", + "ll_overseas_procurement_fee.business_date", + "ll_overseas_procurement_fee.wallet_business_id", + "ll_overseas_procurement_fee.wallet_business_no", + "ll_overseas_procurement_fee.wallet_hold_id", + "ll_overseas_procurement_fee.wallet_ledger_id", + "ll_overseas_procurement_fee.settled_command_event_id", + "ll_overseas_procurement_fee.source_fee_id", + "ll_overseas_procurement_fee.parent_fee_id", + "ll_overseas_procurement_fee.request_hash", + "ll_overseas_procurement_status_history.operation_type", + "ll_overseas_procurement_status_history.change_detail", + "ll_receiving_container_session.source_container_booking_id", + "ll_receiving_container_session.container_fact_hash", + "ll_receiving_container_session.overseas_warehouse_code", + "ll_return_inventory_outbound_movement.return_sku_quantity", + "ll_us_truck_order.vehicle_type", + "ll_us_truck_order.origin_contact_name", + "ll_us_truck_order.origin_contact_phone", + "ll_us_truck_order.destination_contact_name", + "ll_us_truck_order.destination_contact_phone", + "ll_us_truck_order.pickup_at", + "ll_us_truck_order.delivery_at" + ], + "unsafe": [ + { + "table": "ll_overseas_procurement_status_history", + "column": "operation_type", + "count": 9, + "definition": "operation_type varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_receiving_container_session", + "column": "source_container_booking_id", + "count": 1, + "definition": "source_container_booking_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + }, + { + "table": "ll_receiving_container_session", + "column": "container_fact_hash", + "count": 1, + "definition": "container_fact_hash varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '领取时整柜订单与计划事实摘要'", + "handling": "历史行保留NULL,不伪造业务事实;新写入由应用校验" + } + ] +} \ No newline at end of file diff --git a/output/local-schema-repair/repair.mjs b/output/local-schema-repair/repair.mjs new file mode 100644 index 000000000..4b38f5d62 --- /dev/null +++ b/output/local-schema-repair/repair.mjs @@ -0,0 +1,52 @@ +import fs from 'node:fs'; +import {spawnSync} from 'node:child_process'; +const dir = new URL('./', import.meta.url); +const schema=process.argv.includes('--firstleg')?'baiyue_firstleg':'baiyue_lastleg'; +const prefix=schema==='baiyue_firstleg'?'firstleg-':''; +function definitions(body) { + let depth=0,quote='',start=0;const parts=[]; + for(let i=0;i0 && /NOT NULL/i.test(definition) && !/DEFAULT|AUTO_INCREMENT/i.test(definition)) { + unsafe.push({table,column:field[1],count,definition,handling:'历史行保留NULL,不伪造业务事实;新写入由应用校验'}); + appliedDefinition=definition.replace(/NOT NULL/i,'NULL'); + } + plan.push('ALTER TABLE `'+table+'` ADD COLUMN '+appliedDefinition+';');addedColumns.push(table+'.'+field[1]); + } +} +const report={baselineTables:tables.length,actualTables:current.size,addedTables,addedColumns,unsafe}; +fs.writeFileSync(new URL(prefix+'plan.json',dir),JSON.stringify(report,null,2)); +fs.writeFileSync(new URL(prefix+'incremental.sql',dir),'-- 本地服务结构补齐;仅创建缺失表、添加缺失字段,不删除现有数据。\nSET NAMES utf8mb4;\n'+plan.join('\n\n')+'\n'); +console.log(JSON.stringify(report,null,2)); +if(process.argv.includes('--apply')) { + mysql('SET SESSION lock_wait_timeout=15;\n'+plan.join('\n'));fs.appendFileSync(new URL(prefix+'applied.log',dir),`${new Date().toISOString()} ${plan.length} DDL applied\n`); + console.log('APPLIED '+plan.length); +} diff --git a/output/local-schema-repair/restart-firstleg-customs.ps1 b/output/local-schema-repair/restart-firstleg-customs.ps1 new file mode 100644 index 000000000..e4584faaa --- /dev/null +++ b/output/local-schema-repair/restart-firstleg-customs.ps1 @@ -0,0 +1,27 @@ +$ErrorActionPreference='Stop' +$repo='D:\project\baiyue-firstleg' +. "$repo\scripts\local-env.ps1" +$env:LASTLEG_DB_PASSWORD=$env:AUTH_DB_PASSWORD +$env:LASTLEG_DB_USERNAME='root' +$env:LASTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:FIRSTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:WAREHOUSE_SERVICE_URL='http://127.0.0.1:9205' +$env:ORDER_SERVICE_URL='http://127.0.0.1:9204' +$env:FILE_SERVICE_URL='http://127.0.0.1:9206' +$env:DOMESTIC_WAREHOUSE_URL='http://127.0.0.1:9205' +$env:WAREHOUSE_OVERSEAS_SERVICE_URL='http://127.0.0.1:9305' +$env:FIRSTLEG_SERVICE_URL='http://127.0.0.1:9202' +$env:LASTLEG_SERVICE_URL='http://127.0.0.1:9303' +foreach($entry in @(@{Port=9202;Name='firstleg'})) { + $entry.Id=(Get-NetTCPConnection -LocalPort $entry.Port -State Listen | Select-Object -First 1).OwningProcess + $process=Get-CimInstance Win32_Process -Filter "ProcessId=$($entry.Id)" + if(!$process -or $process.CommandLine -notmatch "com\.baiyue\.$($entry.Name)\.") {throw '原服务进程身份不匹配'} + $cp=[regex]::Match($process.CommandLine,'(?:-classpath|-cp)\s+(?:"([^"]+)"|(\S+))') + $classpath=if($cp.Groups[1].Success){$cp.Groups[1].Value}else{$cp.Groups[2].Value} + $main=[regex]::Match($process.CommandLine,'com\.baiyue\.[\w.]+Application').Value + if(!$classpath -or !$main){throw '无法保留原服务启动类路径'} + Stop-Process -Id $entry.Id + $new=Start-Process -FilePath $process.ExecutablePath -ArgumentList "-Dfile.encoding=UTF-8 -Xmx1024m -cp `"$classpath`" $main" -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput "$PSScriptRoot\$($entry.Name)-tabs-runtime.log" -RedirectStandardError "$PSScriptRoot\$($entry.Name)-tabs-runtime.err.log" + "$($entry.Name) PID=$($new.Id)" +} + diff --git a/output/local-schema-repair/restart-lastleg-flow.ps1 b/output/local-schema-repair/restart-lastleg-flow.ps1 new file mode 100644 index 000000000..883fb1bbf --- /dev/null +++ b/output/local-schema-repair/restart-lastleg-flow.ps1 @@ -0,0 +1,27 @@ +$ErrorActionPreference='Stop' +$repo='D:\project\baiyue-firstleg' +. "$repo\scripts\local-env.ps1" +$env:LASTLEG_DB_PASSWORD=$env:AUTH_DB_PASSWORD +$env:LASTLEG_DB_USERNAME='root' +$env:LASTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:FIRSTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:WAREHOUSE_SERVICE_URL='http://127.0.0.1:9205' +$env:ORDER_SERVICE_URL='http://127.0.0.1:9204' +$env:FILE_SERVICE_URL='http://127.0.0.1:9206' +$env:DOMESTIC_WAREHOUSE_URL='http://127.0.0.1:9205' +$env:WAREHOUSE_OVERSEAS_SERVICE_URL='http://127.0.0.1:9305' +$env:FIRSTLEG_SERVICE_URL='http://127.0.0.1:9202' +$env:LASTLEG_SERVICE_URL='http://127.0.0.1:9303' +foreach($entry in @(@{Port=9303;Name='lastleg'})) { + $entry.Id=(Get-NetTCPConnection -LocalPort $entry.Port -State Listen | Select-Object -First 1).OwningProcess + $process=Get-CimInstance Win32_Process -Filter "ProcessId=$($entry.Id)" + if(!$process -or $process.CommandLine -notmatch "com\.baiyue\.$($entry.Name)\.") {throw '原服务进程身份不匹配'} + $cp=[regex]::Match($process.CommandLine,'(?:-classpath|-cp)\s+(?:"([^"]+)"|(\S+))') + $classpath=if($cp.Groups[1].Success){$cp.Groups[1].Value}else{$cp.Groups[2].Value} + $main=[regex]::Match($process.CommandLine,'com\.baiyue\.[\w.]+Application').Value + if(!$classpath -or !$main){throw '无法保留原服务启动类路径'} + Stop-Process -Id $entry.Id + $new=Start-Process -FilePath $process.ExecutablePath -ArgumentList "-Dfile.encoding=UTF-8 -Xmx1024m -cp `"$classpath`" $main" -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput "$PSScriptRoot\$($entry.Name)-tabs-runtime.log" -RedirectStandardError "$PSScriptRoot\$($entry.Name)-tabs-runtime.err.log" + "$($entry.Name) PID=$($new.Id)" +} + diff --git a/output/local-schema-repair/restart-order-counts.ps1 b/output/local-schema-repair/restart-order-counts.ps1 new file mode 100644 index 000000000..1fbccfca7 --- /dev/null +++ b/output/local-schema-repair/restart-order-counts.ps1 @@ -0,0 +1,27 @@ +$ErrorActionPreference='Stop' +$repo='D:\project\baiyue-firstleg' +. "$repo\scripts\local-env.ps1" +$env:LASTLEG_DB_PASSWORD=$env:AUTH_DB_PASSWORD +$env:LASTLEG_DB_USERNAME='root' +$env:LASTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:FIRSTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:WAREHOUSE_SERVICE_URL='http://127.0.0.1:9205' +$env:ORDER_SERVICE_URL='http://127.0.0.1:9204' +$env:FILE_SERVICE_URL='http://127.0.0.1:9206' +$env:DOMESTIC_WAREHOUSE_URL='http://127.0.0.1:9205' +$env:WAREHOUSE_OVERSEAS_SERVICE_URL='http://127.0.0.1:9305' +$env:FIRSTLEG_SERVICE_URL='http://127.0.0.1:9202' +$env:LASTLEG_SERVICE_URL='http://127.0.0.1:9303' +foreach($entry in @(@{Port=9204;Name='order'})) { + $entry.Id=(Get-NetTCPConnection -LocalPort $entry.Port -State Listen | Select-Object -First 1).OwningProcess + $process=Get-CimInstance Win32_Process -Filter "ProcessId=$($entry.Id)" + if(!$process -or $process.CommandLine -notmatch "com\.baiyue\.$($entry.Name)\.") {throw '原服务进程身份不匹配'} + $cp=[regex]::Match($process.CommandLine,'(?:-classpath|-cp)\s+(?:"([^"]+)"|(\S+))') + $classpath=if($cp.Groups[1].Success){$cp.Groups[1].Value}else{$cp.Groups[2].Value} + $main=[regex]::Match($process.CommandLine,'com\.baiyue\.[\w.]+Application').Value + if(!$classpath -or !$main){throw '无法保留原服务启动类路径'} + Stop-Process -Id $entry.Id + $new=Start-Process -FilePath $process.ExecutablePath -ArgumentList "-Dfile.encoding=UTF-8 -Xmx1024m -cp `"$classpath`" $main" -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput "$PSScriptRoot\$($entry.Name)-tabs-runtime.log" -RedirectStandardError "$PSScriptRoot\$($entry.Name)-tabs-runtime.err.log" + "$($entry.Name) PID=$($new.Id)" +} + diff --git a/output/local-schema-repair/restart-tabs.ps1 b/output/local-schema-repair/restart-tabs.ps1 new file mode 100644 index 000000000..91e31ee6a --- /dev/null +++ b/output/local-schema-repair/restart-tabs.ps1 @@ -0,0 +1,27 @@ +$ErrorActionPreference='Stop' +$repo='D:\project\baiyue-firstleg' +. "$repo\scripts\local-env.ps1" +$env:LASTLEG_DB_PASSWORD=$env:AUTH_DB_PASSWORD +$env:LASTLEG_DB_USERNAME='root' +$env:LASTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:FIRSTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:WAREHOUSE_SERVICE_URL='http://127.0.0.1:9205' +$env:ORDER_SERVICE_URL='http://127.0.0.1:9204' +$env:FILE_SERVICE_URL='http://127.0.0.1:9206' +$env:DOMESTIC_WAREHOUSE_URL='http://127.0.0.1:9205' +$env:WAREHOUSE_OVERSEAS_SERVICE_URL='http://127.0.0.1:9305' +$env:FIRSTLEG_SERVICE_URL='http://127.0.0.1:9202' +$env:LASTLEG_SERVICE_URL='http://127.0.0.1:9303' +foreach($entry in @(@{Port=9202;Name='firstleg'},@{Port=9303;Name='lastleg'},@{Port=9204;Name='order'})) { + $entry.Id=(Get-NetTCPConnection -LocalPort $entry.Port -State Listen | Select-Object -First 1).OwningProcess + $process=Get-CimInstance Win32_Process -Filter "ProcessId=$($entry.Id)" + if(!$process -or $process.CommandLine -notmatch "com\.baiyue\.$($entry.Name)\.") {throw '原服务进程身份不匹配'} + $cp=[regex]::Match($process.CommandLine,'(?:-classpath|-cp)\s+(?:"([^"]+)"|(\S+))') + $classpath=if($cp.Groups[1].Success){$cp.Groups[1].Value}else{$cp.Groups[2].Value} + $main=[regex]::Match($process.CommandLine,'com\.baiyue\.[\w.]+Application').Value + if(!$classpath -or !$main){throw '无法保留原服务启动类路径'} + Stop-Process -Id $entry.Id + $new=Start-Process -FilePath $process.ExecutablePath -ArgumentList "-Dfile.encoding=UTF-8 -Xmx1024m -cp `"$classpath`" $main" -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput "$PSScriptRoot\$($entry.Name)-tabs-runtime.log" -RedirectStandardError "$PSScriptRoot\$($entry.Name)-tabs-runtime.err.log" + "$($entry.Name) PID=$($new.Id)" +} + diff --git a/output/local-schema-repair/restart-warehouse-flow.ps1 b/output/local-schema-repair/restart-warehouse-flow.ps1 new file mode 100644 index 000000000..473677dc1 --- /dev/null +++ b/output/local-schema-repair/restart-warehouse-flow.ps1 @@ -0,0 +1,28 @@ +$ErrorActionPreference='Stop' +$repo='D:\project\baiyue-firstleg' +. "$repo\scripts\local-env.ps1" +$env:LASTLEG_DB_PASSWORD=$env:AUTH_DB_PASSWORD +$env:LASTLEG_DB_USERNAME='root' +$env:LASTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:FIRSTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:WAREHOUSE_SERVICE_URL='http://127.0.0.1:9205' +$env:ORDER_SERVICE_URL='http://127.0.0.1:9204' +$env:FILE_SERVICE_URL='http://127.0.0.1:9206' +$env:DOMESTIC_WAREHOUSE_URL='http://127.0.0.1:9205' +$env:WAREHOUSE_OVERSEAS_SERVICE_URL='http://127.0.0.1:9305' +$env:FIRSTLEG_SERVICE_URL='http://127.0.0.1:9202' +$env:LASTLEG_SERVICE_URL='http://127.0.0.1:9303' +foreach($entry in @(@{Port=9205;Name='warehouse'})) { + $entry.Id=(Get-NetTCPConnection -LocalPort $entry.Port -State Listen | Select-Object -First 1).OwningProcess + $process=Get-CimInstance Win32_Process -Filter "ProcessId=$($entry.Id)" + if(!$process -or $process.CommandLine -notmatch "com\.baiyue\.$($entry.Name)\.") {throw '原服务进程身份不匹配'} + $cp=[regex]::Match($process.CommandLine,'(?:-classpath|-cp)\s+(?:"([^"]+)"|(\S+))') + $classpath=if($cp.Groups[1].Success){$cp.Groups[1].Value}else{$cp.Groups[2].Value} + $main=[regex]::Match($process.CommandLine,'com\.baiyue\.[\w.]+Application').Value + if(!$classpath -or !$main){throw '无法保留原服务启动类路径'} + Stop-Process -Id $entry.Id + $new=Start-Process -FilePath $process.ExecutablePath -ArgumentList "-Dfile.encoding=UTF-8 -Xmx1024m -cp `"$classpath`" $main" -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput "$PSScriptRoot\$($entry.Name)-tabs-runtime.log" -RedirectStandardError "$PSScriptRoot\$($entry.Name)-tabs-runtime.err.log" + "$($entry.Name) PID=$($new.Id)" +} + + diff --git a/output/local-schema-repair/restart.ps1 b/output/local-schema-repair/restart.ps1 new file mode 100644 index 000000000..b33e0d452 --- /dev/null +++ b/output/local-schema-repair/restart.ps1 @@ -0,0 +1,25 @@ +$ErrorActionPreference='Stop' +$repo='D:\project\baiyue-firstleg' +. "$repo\scripts\local-env.ps1" +$env:LASTLEG_DB_PASSWORD=$env:AUTH_DB_PASSWORD +$env:LASTLEG_DB_USERNAME='root' +$env:LASTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_lastleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:FIRSTLEG_DB_URL='jdbc:mysql://127.0.0.1:3306/baiyue_firstleg?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true' +$env:WAREHOUSE_SERVICE_URL='http://127.0.0.1:9205' +$env:ORDER_SERVICE_URL='http://127.0.0.1:9204' +$env:FILE_SERVICE_URL='http://127.0.0.1:9206' +$env:DOMESTIC_WAREHOUSE_URL='http://127.0.0.1:9205' +$env:WAREHOUSE_OVERSEAS_SERVICE_URL='http://127.0.0.1:9305' +$env:FIRSTLEG_SERVICE_URL='http://127.0.0.1:9202' +$env:LASTLEG_SERVICE_URL='http://127.0.0.1:9303' +foreach($entry in @(@{Id=73832;Name='firstleg'},@{Id=63968;Name='lastleg'})) { + $process=Get-CimInstance Win32_Process -Filter "ProcessId=$($entry.Id)" + if(!$process -or $process.CommandLine -notmatch "com\.baiyue\.$($entry.Name)\.") {throw '原服务进程身份不匹配'} + $cp=[regex]::Match($process.CommandLine,'(?:-classpath|-cp)\s+(?:"([^"]+)"|(\S+))') + $classpath=if($cp.Groups[1].Success){$cp.Groups[1].Value}else{$cp.Groups[2].Value} + $main=[regex]::Match($process.CommandLine,'com\.baiyue\.[\w.]+Application').Value + if(!$classpath -or !$main){throw '无法保留原服务启动类路径'} + Stop-Process -Id $entry.Id + $new=Start-Process -FilePath $process.ExecutablePath -ArgumentList "-Dfile.encoding=UTF-8 -Xmx1024m -cp `"$classpath`" $main" -WorkingDirectory $repo -WindowStyle Hidden -PassThru -RedirectStandardOutput "$PSScriptRoot\$($entry.Name)-runtime.log" -RedirectStandardError "$PSScriptRoot\$($entry.Name)-runtime.err.log" + "$($entry.Name) PID=$($new.Id)" +} diff --git a/output/local-schema-repair/schema-after.sql b/output/local-schema-repair/schema-after.sql new file mode 100644 index 000000000..01fe729eb --- /dev/null +++ b/output/local-schema-repair/schema-after.sql @@ -0,0 +1,9729 @@ + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `baiyue_lastleg` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; + +USE `baiyue_lastleg`; +DROP TABLE IF EXISTS `backup_fba_appointment_item_20261013`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `backup_fba_appointment_item_20261013` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '绉熸埛/瀹㈡埛璐﹀彿鏍囪瘑', + `appointment_id` bigint NOT NULL, + `plan_id` bigint NOT NULL, + `line_id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '取消或失败后释放货源占用', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_fba_appointment_item_line` (`appointment_id`,`line_id`), + KEY `idx_ll_fba_appointment_item_plan` (`plan_id`), + KEY `idx_ll_fba_appointment_item_order` (`order_id`), + KEY `idx_ll_fba_item_tenant_appointment` (`customer_id`,`appointment_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA appointment line snapshot'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `event_delivery_attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `event_delivery_attempt` ( + `attempt_id` varchar(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + `service_name` varchar(32) NOT NULL COMMENT '本库所属生产服务', + `event_type` varchar(96) NOT NULL COMMENT '白名单业务事件类型', + `entry_id` varchar(128) NOT NULL COMMENT '生产者发件箱主键', + `event_id` varchar(128) DEFAULT NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + `target_name` varchar(64) NOT NULL COMMENT '固定目标业务服务', + `started_at` datetime(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + `finished_at` datetime(3) DEFAULT NULL COMMENT '尝试完成或租约过期恢复时间', + `duration_ms` bigint DEFAULT NULL COMMENT '尝试耗时毫秒', + `outcome` varchar(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + `error_code` varchar(64) DEFAULT NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (`attempt_id`), + KEY `idx_delivery_attempt_entry` (`service_name`,`entry_id`,`started_at`), + KEY `idx_delivery_attempt_outcome` (`outcome`,`started_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本服务业务事件逐次投递审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `event_delivery_retry_audit`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `event_delivery_retry_audit` ( + `request_id` varchar(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + `producer_table` varchar(64) NOT NULL COMMENT '本服务发件箱表名', + `entry_id` varchar(64) NOT NULL COMMENT '发件箱记录主键', + `event_id` varchar(128) NOT NULL COMMENT '原事件全局标识', + `event_type` varchar(64) NOT NULL COMMENT '原事件类型', + `payload_hash` varchar(71) NOT NULL COMMENT '原事件不可变负载摘要', + `operator_name` varchar(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + `reason` varchar(500) NOT NULL COMMENT '人工重试原因', + `previous_failures` int NOT NULL COMMENT '重试前累计失败次数', + `previous_error` varchar(1000) DEFAULT NULL COMMENT '重试前最后一次脱敏错误', + `requested_at` datetime(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (`request_id`), + KEY `idx_event_retry_entry` (`producer_table`,`entry_id`,`requested_at`), + KEY `idx_event_retry_event` (`event_id`,`requested_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_cargo_verification`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_cargo_verification` ( + `id` bigint NOT NULL COMMENT '核实记录主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原货源所属客户', + `source_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定ORDER或BOOKING原来源键', + `destination_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '核实时的原目的地', + `source_version` varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '核实时的原来源版本', + `source_packages` int DEFAULT NULL COMMENT '核实时的原来源箱数,未知为空', + `version` int NOT NULL COMMENT '同一来源的核实版本,从一递增', + `package_count` int DEFAULT NULL COMMENT '独立核实箱数,不回写原订单', + `shipment_references_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本票货件号或追踪号列表,不回写原订单', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '核实说明', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登录操作人', + `operator_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人名称快照', + `command_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源内幂等命令键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源、操作人及请求摘要', + `created_at` datetime(3) NOT NULL COMMENT '核实UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_cargo_verification_version` (`customer_id`,`source_key`,`version`), + UNIQUE KEY `uk_ll_cargo_verification_command` (`customer_id`,`source_key`,`command_key`), + CONSTRAINT `chk_ll_cargo_verification_quantity` CHECK (((`package_count` is null) or (`package_count` > 0))), + CONSTRAINT `chk_ll_cargo_verification_version` CHECK ((`version` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约货物核实资料及不可覆盖的版本历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_followup`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_followup` ( + `id` bigint NOT NULL COMMENT '预约筹划跟进记录ID', + `source_key` varchar(160) NOT NULL COMMENT '稳定ORDER、BOOKING或CONTAINER及接仓点来源键', + `customer_id` varchar(128) NOT NULL COMMENT '货源客户;整柜共享记录由服务端固定为平台作用域0', + `source_order_id` bigint DEFAULT NULL COMMENT '已形成的真实尾程货源ID,尚未生成时为空', + `stage` varchar(32) NOT NULL COMMENT '跟进对象:DRAYAGE_CONTACT/TERMINAL_APPOINTMENT/WAREHOUSE_RECEIVING/FBA_RECEIVING', + `version` int NOT NULL COMMENT '同一货源及跟进对象的记录版本', + `contacted_party` varchar(200) DEFAULT NULL COMMENT '本次联系对象,不替代承运主档', + `result` varchar(1000) NOT NULL COMMENT '本次反馈事实,不自动确认预约或推进实物作业', + `next_action` varchar(500) DEFAULT NULL COMMENT '下一步安排', + `followup_date` date DEFAULT NULL COMMENT '按业务地点当地日期记录的下次跟进日', + `operator_id` varchar(128) NOT NULL COMMENT '登录平台操作者ID', + `operator_name` varchar(200) DEFAULT NULL COMMENT '操作人姓名快照', + `command_key` varchar(64) NOT NULL COMMENT '持久幂等键', + `request_hash` char(64) NOT NULL COMMENT '含操作者身份的请求SHA256摘要', + `created_at` datetime(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_appointment_followup_version` (`source_key`,`stage`,`version`), + UNIQUE KEY `uk_appointment_followup_command` (`source_key`,`stage`,`command_key`), + KEY `idx_appointment_followup_date` (`followup_date`,`source_order_id`), + KEY `fk_appointment_followup_order` (`source_order_id`), + CONSTRAINT `fk_appointment_followup_order` FOREIGN KEY (`source_order_id`) REFERENCES `ll_order` (`id`), + CONSTRAINT `chk_appointment_followup_stage` CHECK ((`stage` in (_utf8mb4'DRAYAGE_CONTACT',_utf8mb4'TERMINAL_APPOINTMENT',_utf8mb4'WAREHOUSE_RECEIVING',_utf8mb4'FBA_RECEIVING'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='真实在途货源的预约筹划联系记录,追加留痕'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_impact`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_impact` ( + `container_booking_id` varchar(64) NOT NULL COMMENT '稳定柜次关联键,不作业务编号展示', + `impact_no` varchar(40) NOT NULL COMMENT '船期核对事项业务号', + `baseline_json` longtext NOT NULL COMMENT '已核对基准或首次变化前观察,原来源时间口径', + `latest_json` longtext NOT NULL COMMENT '最新已读取观察,未知可为JSON null', + `forecast_token` char(64) NOT NULL COMMENT '最新观察摘要,处理时防止旧预测结案', + `status` varchar(20) NOT NULL COMMENT '事项状态:MONITORING监测/OPEN待处理/RESOLVED已完成', + `reason` varchar(64) DEFAULT NULL COMMENT '预测变化或资料待核实原因', + `owner_id` varchar(128) DEFAULT NULL COMMENT '认领平台人员ID', + `owner_name` varchar(200) DEFAULT NULL COMMENT '负责人姓名快照', + `version` int NOT NULL COMMENT '观察及人工处理共同版本', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + PRIMARY KEY (`container_booking_id`), + UNIQUE KEY `uk_appointment_impact_no` (`impact_no`), + KEY `idx_appointment_impact_work` (`status`,`updated_at`), + CONSTRAINT `chk_appointment_impact_status` CHECK ((`status` in (_utf8mb4'MONITORING',_utf8mb4'OPEN',_utf8mb4'RESOLVED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一柜一个稳定船期变化核对事项,不改写预约或运输'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_impact_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_impact_history` ( + `id` bigint NOT NULL COMMENT '历史记录标识', + `container_booking_id` varchar(64) NOT NULL COMMENT '关联柜次标识', + `version` int NOT NULL COMMENT '记录版本', + `action` varchar(20) NOT NULL COMMENT '操作类型:OBSERVED观察/CLAIM认领/RECORD记录/RESOLVE完成', + `operator_id` varchar(128) NOT NULL COMMENT '操作人标识', + `operator_name` varchar(200) DEFAULT NULL COMMENT '操作人姓名', + `result` varchar(1000) DEFAULT NULL COMMENT '人工处理结果;观察记录为空', + `next_action` varchar(500) DEFAULT NULL COMMENT '下一步处理事项', + `followup_date` date DEFAULT NULL COMMENT '业务跟进日期', + `command_key` varchar(64) DEFAULT NULL COMMENT '人工命令幂等键;观察记录为空', + `request_hash` char(64) DEFAULT NULL COMMENT '请求内容摘要', + `response_json` longtext NOT NULL COMMENT '该版本完整响应快照,支持精确重放', + `created_at` datetime(3) NOT NULL COMMENT 'UTC记录时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_appointment_impact_version` (`container_booking_id`,`version`), + UNIQUE KEY `uk_appointment_impact_command` (`container_booking_id`,`operator_id`,`command_key`), + CONSTRAINT `fk_appointment_impact_history` FOREIGN KEY (`container_booking_id`) REFERENCES `ll_appointment_impact` (`container_booking_id`), + CONSTRAINT `chk_appointment_impact_action` CHECK ((`action` in (_utf8mb4'OBSERVED',_utf8mb4'CLAIM',_utf8mb4'RECORD',_utf8mb4'RESOLVE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期观察及人工处理追加历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_work`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_work` ( + `work_item_key` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '不可变事项键,业务身份确定性生成', + `container_booking_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号', + `version` int NOT NULL DEFAULT '0' COMMENT '工作命令乐观锁版本', + `retired` tinyint(1) NOT NULL DEFAULT '0' COMMENT '已替代或已完成路线需求,不计活动工作', + `state_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实', + PRIMARY KEY (`work_item_key`), + KEY `idx_appointment_work_container` (`container_booking_id`,`retired`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_work_commitment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_work_commitment` ( + `entity_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原业务实体稳定键', + `state_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划', + `impact_version` bigint NOT NULL DEFAULT '0' COMMENT '承诺影响单调递增版本', + PRIMARY KEY (`entity_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_work_guard`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_work_guard` ( + `id` int NOT NULL COMMENT '工作事项协调锁,固定1', + `scan_cursor` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '已开船来源同步游标', + `initialized` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否完成首次完整来源扫描', + `scope_version` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完整同步已确认的权威来源集合版本', + `scan_scope_version` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前扫描来源集合版本,防跨页漂移', + `completed_at` datetime(3) DEFAULT NULL COMMENT '最近完整同步UTC时间', + `requirement_version` bigint NOT NULL DEFAULT '0' COMMENT '原域命令在同一事务推进的需求版本', + `synchronized_requirement_version` bigint NOT NULL DEFAULT '0' COMMENT '完整扫描已消费需求版本', + `scan_requirement_version` bigint NOT NULL DEFAULT '0' COMMENT '当前扫描需求版本,防跨页漂移', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_work_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_work_history` ( + `id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '历史记录标识', + `work_item_key` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事项稳定键', + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实操作人员标识', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人显示名称', + `action` varchar(48) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '工作命令或观察动作', + `summary` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理或责任变更依据', + `command_key` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求幂等键,观察记录为空', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '包含操作者的规范请求摘要', + `response_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '原命令响应快照,重放不随当前状态改变', + `occurred_at` datetime(3) NOT NULL COMMENT '历史发生UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_appointment_work_command` (`work_item_key`,`command_key`), + KEY `idx_appointment_work_history` (`work_item_key`,`occurred_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_application`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_application` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `discard_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `outbound_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '废弃出库单号', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_codes` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL, + `total_packages` int NOT NULL, + `fee_per_package` decimal(18,2) NOT NULL, + `total_fee` decimal(18,2) NOT NULL, + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `review_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', + `review_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `review_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reviewed_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL COMMENT '废弃完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_cargo_discard_no` (`customer_id`,`discard_no`), + UNIQUE KEY `uk_ll_cargo_discard_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_cargo_discard_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_cargo_discard_warehouse` (`customer_id`,`overseas_warehouse_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Cargo discard application requiring warehouse approval'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_carton` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `application_id` bigint NOT NULL, + `item_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `carton_id` bigint NOT NULL, + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `source_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '箱子来源:一件代发DROPSHIP、入仓箱INBOUND、转运箱TRANSFER', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_cargo_discard_carton` (`application_id`,`carton_id`), + KEY `idx_ll_cargo_discard_carton_application` (`customer_id`,`application_id`,`item_id`), + KEY `idx_ll_cargo_discard_carton_identity` (`customer_id`,`carton_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Cartons reserved while a cargo discard application awaits review'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_item` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `application_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint DEFAULT NULL COMMENT '一件代发订单ID', + `source_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON', + `return_carton_id` bigint DEFAULT NULL COMMENT '退货库存箱ID', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `profile_id` bigint DEFAULT NULL COMMENT '托盘档案ID', + `sku_inventory_id` bigint DEFAULT NULL COMMENT 'SKU库存记录ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `available_packages_snapshot` int NOT NULL, + `discard_packages` int NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `actual_discard_packages` int DEFAULT NULL, + `pda_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `pda_operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pda_completed_at` datetime(3) DEFAULT NULL, + `completion_photo_file_ids` text COLLATE utf8mb4_unicode_ci COMMENT 'PDA完工照片文件ID JSON数组', + `inventory_position_id` bigint DEFAULT NULL COMMENT '废弃来源库存仓位ID', + `carton_marks_json` text COLLATE utf8mb4_unicode_ci COMMENT '本次废弃所选真实箱唛快照JSON数组', + `carton_scan_required` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要逐箱核对本次所选箱子', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_cargo_discard_item` (`application_id`,`profile_id`,`sku_code`), + UNIQUE KEY `uk_ll_discard_position_sku` (`application_id`,`inventory_position_id`,`sku_code`), + KEY `idx_ll_cargo_discard_item_list` (`customer_id`,`application_id`), + KEY `idx_ll_cargo_discard_item_inbound` (`customer_id`,`inbound_code`,`sku_code`), + KEY `idx_ll_cargo_discard_return_carton` (`customer_id`,`return_carton_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Frozen SKU quantities in a cargo discard application'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_photo`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_photo` ( + `id` bigint NOT NULL, + `customer_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `application_id` bigint NOT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `file_id` bigint NOT NULL, + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `content_type` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `size_bytes` bigint NOT NULL, + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(6) NOT NULL, + `submitted_at` datetime(6) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cargo_discard_photo_file` (`file_id`), + KEY `idx_cargo_discard_photo_application` (`customer_id`,`application_id`,`pallet_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_status_history` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `application_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_ll_cargo_discard_history` (`customer_id`,`application_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable cargo discard approval history'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_compact_number_clock`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_compact_number_clock` ( + `number_key` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务前缀和客户名称缩写', + `last_second` bigint NOT NULL COMMENT '最后分配的Unix逻辑秒', + PRIMARY KEY (`number_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_container_arrival_time_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_container_arrival_time_operation` ( + `id` bigint NOT NULL COMMENT '柜级预计到仓时间修改操作ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台请求幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化修改请求SHA-256摘要', + `result_version` int NOT NULL COMMENT '修改成功后的柜级时间版本', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人名称', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '预计到仓时间修改备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_container_arrival_time_idempotency` (`idempotency_key`), + KEY `idx_ll_container_arrival_time_container` (`source_container_booking_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级预计到仓时间修改幂等审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_container_loading_evidence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_container_loading_evidence` ( + `id` bigint NOT NULL COMMENT '海外整柜装柜照片投影ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内稳定来源柜预订ID', + `evidence_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL', + `source_file_id` bigint NOT NULL COMMENT '国内File服务文件ID', + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内文件名快照', + `created_at` datetime(3) NOT NULL COMMENT '投影创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_container_loading_evidence_type` (`source_container_booking_id`,`evidence_type`), + UNIQUE KEY `uk_ll_container_loading_evidence_file` (`source_container_booking_id`,`source_file_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外拆柜只读国内装柜照片引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_container_plan_conversion_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_container_plan_conversion_command` ( + `id` bigint NOT NULL COMMENT '柜级转换命令ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '调用方幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '确认请求SHA-256摘要', + `snapshot_version` bigint NOT NULL COMMENT '确认的柜级快照版本', + `snapshot_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '确认的柜级快照SHA-256', + `warehouse_id` bigint NOT NULL COMMENT '海外接柜仓ID', + `batch_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓托盘批次幂等键', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', + `lease_token` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', + `preparation_json` json NOT NULL COMMENT '稳定计划号与托盘业务键快照', + `result_json` json DEFAULT NULL COMMENT '本地计划提交结果', + `last_error` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次跨服务错误', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_container_conversion_command` (`source_container_booking_id`), + UNIQUE KEY `uk_ll_container_conversion_batch` (`warehouse_id`,`batch_key`), + KEY `idx_ll_container_conversion_status` (`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级入库计划转换Saga与幂等命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_container_shipping_gate`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_container_shipping_gate` ( + `container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源头程柜预订ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', + `ocean_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源海运订舱ID', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际集装箱号', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + `standard_node` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最新有效标准船节点', + `node_sequence` int NOT NULL DEFAULT '0' COMMENT '最新有效节点单调序号', + `observed_at` datetime(3) DEFAULT NULL COMMENT '供应商节点观测时间', + `actual_at` datetime(3) DEFAULT NULL COMMENT '节点实际发生时间', + `location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '节点地点编码', + `location_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '节点地点名称', + `provider_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '船节点供应商编码', + `provider_event_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商事件ID', + `provider_node_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商原始节点编码', + `source_reference` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商来源引用', + `raw_payload_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商原始负载SHA-256', + `aggregate_version` bigint NOT NULL DEFAULT '0' COMMENT '来源查询聚合版本', + `handover_version` bigint NOT NULL DEFAULT '1' COMMENT '跨区交接版本', + `last_event_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近采纳的跨区事件ID', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近采纳事件规范化负载摘要', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`container_booking_id`), + KEY `idx_ll_shipping_gate_candidate` (`customer_id`,`container_no`,`status`), + KEY `idx_ll_shipping_gate_version` (`container_booking_id`,`aggregate_version`,`node_sequence`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号船节点收货资格快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_customer_visit`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_customer_visit` ( + `id` bigint NOT NULL COMMENT '客户回访记录ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `delivery_order_id` bigint NOT NULL COMMENT '关联派送单ID', + `delivery_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联派送单号', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户联系人姓名快照', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户联系人电话快照', + `destination` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送目的仓编码汇总快照', + `delivery_completed_at` datetime(3) NOT NULL COMMENT '派送完成时间', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', + `selected_last_mile_order_id` bigint DEFAULT NULL COMMENT '本次回访选择的尾程订单ID', + `selected_last_mile_order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次回访选择的尾程订单号', + `selected_inbound_order_id` bigint DEFAULT NULL COMMENT '本次回访选择的入仓单ID', + `selected_inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次回访选择的入仓码', + `visit_result` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访结果', + `customer_feedback` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次客户反馈', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访备注', + `attachment_json` json DEFAULT NULL COMMENT '当前回访资料文件元数据列表', + `visited_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访操作人ID', + `visited_at` datetime(3) DEFAULT NULL COMMENT '最近一次回访时间', + `visit_count` int NOT NULL DEFAULT '0' COMMENT '累计保存回访次数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_customer_visit_delivery` (`customer_id`,`delivery_order_id`), + KEY `idx_ll_customer_visit_list` (`customer_id`,`status`,`delivery_completed_at`), + KEY `idx_ll_customer_visit_delivery_no` (`customer_id`,`delivery_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送单维度客户回访记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_document_revision`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_document_revision` ( + `id` bigint NOT NULL COMMENT '派送资料历史ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派送所属客户隔离键', + `delivery_order_id` bigint NOT NULL COMMENT '派送单ID', + `version` bigint NOT NULL COMMENT '保存成功时派送单版本', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资料保存幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '完整保存请求哈希', + `snapshot_json` json NOT NULL COMMENT '不可变三类资料及BOL号结果快照', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '保存操作人ID', + `created_at` datetime(3) NOT NULL COMMENT '资料保存时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_delivery_document_command` (`customer_id`,`delivery_order_id`,`idempotency_key`), + UNIQUE KEY `uk_delivery_document_version` (`customer_id`,`delivery_order_id`,`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送资料版本历史与幂等保存记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_facts_changed`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_facts_changed` ( + `id` bigint NOT NULL COMMENT '持久信号内部主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实签收货物客户ID', + `dispatch_id` bigint NOT NULL COMMENT '整车排车主单ID', + `dispatch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实排车业务号', + `delivery_id` bigint NOT NULL COMMENT '本客户派送单ID', + `delivery_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实派送业务号', + `delivery_version` bigint NOT NULL COMMENT '触发信号的真实派送版本', + `facts_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '该版本真实签收事实摘要', + `occurred_at` datetime(3) NOT NULL COMMENT '签收事实实际发生时间', + `state` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '待核对PENDING或已核对PROCESSED', + `attempt_count` int NOT NULL DEFAULT '0' COMMENT '已记录的汇总失败次数', + `next_attempt_at` datetime(3) NOT NULL COMMENT '下次可重试时间', + `last_error_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近失败诊断码不保存敏感异常正文', + `processed_at` datetime(3) DEFAULT NULL COMMENT '与主阶段汇总同事务确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_delivery_fact_version` (`customer_id`,`delivery_id`,`delivery_version`), + KEY `idx_delivery_fact_due` (`state`,`next_attempt_at`,`id`), + KEY `idx_delivery_fact_dispatch` (`dispatch_id`,`state`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='正常签收事实变化的本地持久待汇总信号'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_fee` ( + `id` bigint NOT NULL COMMENT '派送费用主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `delivery_order_id` bigint NOT NULL COMMENT '派送单ID', + `fee_direction` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', + `fee_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用类型', + `currency` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '币种', + `amount` decimal(18,2) NOT NULL COMMENT '费用金额', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用备注', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_delivery_fee_order` (`customer_id`,`delivery_order_id`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送单费用明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_operation` ( + `id` bigint NOT NULL COMMENT '派送操作主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `delivery_order_id` bigint NOT NULL COMMENT '派送单ID', + `operation_node` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作节点', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人名称', + `result` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理结果', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_ll_delivery_operation_order` (`customer_id`,`delivery_order_id`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送单操作记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `delivery_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `outbound_order_id` bigint NOT NULL, + `outbound_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `dispatch_order_id` bigint DEFAULT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空', + `dispatch_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联派车单号,仓储转运直接出库时为空', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓名称快照', + `destination_type` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地类型:FBA或PRIVATE', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓派送地址', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', + `delivery_group_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_DELIVERY', + `seller_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '卖家姓名', + `package_count` int NOT NULL DEFAULT '0', + `pallet_count` int NOT NULL DEFAULT '0', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送备注', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` bigint NOT NULL DEFAULT '0' COMMENT '派送单乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `waybill_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运运单号', + `delivery_method` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式', + `carrier_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送承运商', + `pickup_method` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货方式', + `loading_type` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '装车类型', + `vehicle_type` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车辆类型', + `vehicle_size` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车辆大小', + `vehicle_plate` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车牌号', + `driver_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机或车队名称', + `driver_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车队联系电话', + `appointment_account` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '预约账号', + `total_weight_kg` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总重量(千克)', + `total_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米),来源未确认时为空', + `planned_delivery_at` datetime(3) DEFAULT NULL COMMENT '预计派送时间', + `dispatched_at` datetime(3) DEFAULT NULL COMMENT '实际派送时间', + `expected_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到达时间', + `signed_at` datetime(3) DEFAULT NULL COMMENT '签收时间', + `bol_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送资料BOL号,不作为文件归属标识', + `documents_json` json DEFAULT NULL COMMENT '娲鹃�佷笁绫昏祫鏂欏綋鍓嶇粦瀹氬強鏉冨▉鏂囦欢鍏冩暟鎹揩鐓�', + `postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + `signed_packages` int DEFAULT NULL COMMENT '保存签收资料时冻结的实际签收箱数,不含此前已退回量', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_delivery_no` (`customer_id`,`delivery_no`), + UNIQUE KEY `uk_ll_delivery_idempotency` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_delivery_outbound_destination` (`customer_id`,`outbound_order_id`,`delivery_group_key`), + KEY `idx_ll_delivery_list` (`customer_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Delivery document linked to an outbound document'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_destuff_pallet_cost`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_destuff_pallet_cost` ( + `id` bigint NOT NULL COMMENT '主键ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源柜次业务ID', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + `warehouse_id` bigint DEFAULT NULL COMMENT '海外仓ID', + `pallet_count` int NOT NULL COMMENT '实际打托数', + `total_actual_volume_cbm` decimal(20,6) NOT NULL COMMENT '客户实收总体积', + `total_cost` decimal(20,2) NOT NULL COMMENT '打托总成本', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '成本币种', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '生成状态', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_destuff_cost_booking` (`source_container_booking_id`), + CONSTRAINT `chk_ll_destuff_cost` CHECK (((`pallet_count` > 0) and (`total_actual_volume_cbm` > 0) and (`total_cost` > 0) and (`currency` = _utf8mb4'CNY'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='拆柜打托实际成本汇总'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_destuff_pallet_cost_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_destuff_pallet_cost_allocation` ( + `id` bigint NOT NULL COMMENT '主键ID', + `cost_id` bigint NOT NULL COMMENT '打托成本汇总ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + `actual_volume_cbm` decimal(20,6) NOT NULL COMMENT '客户实收体积', + `allocated_amount` decimal(20,2) NOT NULL COMMENT '客户分摊金额', + `fee_source_id` bigint NOT NULL COMMENT '海外费用来源ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_destuff_customer` (`cost_id`,`customer_id`), + UNIQUE KEY `uk_ll_destuff_fee_source` (`fee_source_id`), + CONSTRAINT `chk_ll_destuff_allocation` CHECK (((`actual_volume_cbm` > 0) and (`allocated_amount` >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='拆柜打托成本按客户实收体积分摊'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_appointment_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_appointment_allocation` ( + `dispatch_id` bigint NOT NULL COMMENT '排车单标识', + `dispatch_item_id` bigint NOT NULL COMMENT '排车明细标识', + `plan_pallet_id` bigint NOT NULL COMMENT '入仓计划托盘标识', + `batch_key` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '预约稳定批次键', + `customer_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属客户标识', + `source_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '货物来源稳定键', + `destination_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地稳定键', + `package_count` int NOT NULL COMMENT '本票分配实物箱数', + PRIMARY KEY (`dispatch_item_id`,`batch_key`,`source_key`,`destination_key`), + KEY `idx_dispatch_appointment_batch` (`batch_key`,`customer_id`), + KEY `idx_dispatch_appointment_dispatch` (`dispatch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_command` ( + `id` bigint NOT NULL COMMENT '持久命令内部主键', + `dispatch_id` bigint NOT NULL COMMENT '命令所属整车排车主单ID', + `dispatch_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令所属真实排车业务号', + `action` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次整车作业动作', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始可信操作主体ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨操作主体全局唯一的原始请求键', + `record_version` bigint NOT NULL COMMENT '原请求明确提交的排车版本,零为合法首版', + `command_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '绑定原主体动作业务号版本和完整载荷的严格摘要', + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '按动作类型恢复的不可变成功结果JSON', + `created_at` datetime(3) NOT NULL COMMENT '成功命令记录时间,使用UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dispatch_command_key` (`idempotency_key`), + KEY `idx_dispatch_command_history` (`dispatch_id`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车公共持久命令账本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_delivery_arrangement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_delivery_arrangement` ( + `id` bigint NOT NULL COMMENT '安排事实内部主键', + `dispatch_order_id` bigint NOT NULL COMMENT '唯一整车排车主单ID', + `dispatch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实排车业务号', + `arrangement_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次唯一安排业务事实标识', + `business_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '排除操作者及纯并发版本的安排业务摘要', + `cargo_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本车实际拣货来源摘要', + `loading_completion_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '装车完成事实摘要', + `original_command_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '首次安排的全局请求键', + `original_operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '首次安排操作主体', + `result_json` json NOT NULL COMMENT '全部客户出库及客户目的地派送不可变结果', + `cargo_json` json NOT NULL COMMENT '逐客户逐拣货归属的不可变真实来源', + `created_at` datetime(3) NOT NULL COMMENT '本次安排实际创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dispatch_arrangement_dispatch` (`dispatch_order_id`), + UNIQUE KEY `uk_dispatch_arrangement_key` (`arrangement_key`), + UNIQUE KEY `uk_dispatch_arrangement_command` (`original_command_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车唯一派送安排事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_draft`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_draft` ( + `id` bigint NOT NULL, + `draft_token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `plan_ids_json` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `containers_json` longtext COLLATE utf8mb4_unicode_ci, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `expires_at` datetime(3) DEFAULT NULL, + `released_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `warehouse_id` bigint DEFAULT NULL COMMENT '本草稿唯一起运公共仓库引用', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '草稿并发版本,显式零版本合法', + `sources_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '服务端核验的真实来源及客户版本快照数组', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_draft_token` (`draft_token`), + KEY `idx_ll_dispatch_draft_operator` (`operator_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_draft_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_draft_command` ( + `id` bigint NOT NULL COMMENT '草稿命令内部主键', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿操作全局唯一幂等键', + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原命令操作主体,恢复必须为本人', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '草稿创建、维护、选货、释放或转换动作', + `target_token` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令目标草稿标识或创建范围标识', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原动作、主体、版本及完整载荷摘要', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '本事务完成后保存的不可变原结果,不提交空结果', + `created_at` datetime(3) NOT NULL COMMENT '命令创建UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_draft_command_key` (`idempotency_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿持久命令及原结果'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_draft_container`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_draft_container` ( + `id` bigint NOT NULL, + `draft_id` bigint NOT NULL, + `draft_token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `sequence_no` int NOT NULL, + `capacity` int NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `source_keys_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '本装车容器已取得本草稿占用的来源键数组', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_draft_container_sequence` (`draft_id`,`sequence_no`), + KEY `idx_ll_dispatch_draft_container_token` (`draft_token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车托盘'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_history` ( + `id` bigint NOT NULL COMMENT '排车历史内部主键', + `dispatch_id` bigint NOT NULL COMMENT '本次历史所属整车主单ID', + `record_version` bigint NOT NULL COMMENT '操作完成后的整车公共版本', + `action` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次真实业务动作', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '可信操作主体ID', + `reason` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核驳回或变更原因', + `snapshot_json` json NOT NULL COMMENT '动作完成当时的完整主单事实快照', + `occurred_at` datetime(3) NOT NULL COMMENT '真实操作发生时间', + PRIMARY KEY (`id`), + KEY `idx_dispatch_history_version` (`dispatch_id`,`record_version`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='整车排车版本与审核轮次的追加历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_item` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `dispatch_order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `plan_id` bigint DEFAULT NULL, + `plan_pallet_id` bigint DEFAULT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `order_id` bigint DEFAULT NULL, + `order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `order_refs` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `package_count` int NOT NULL DEFAULT '0', + `gross_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + `volume_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', + `volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `source_stage` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PLAN or RECEIVED', + `carton_marks` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标箱唛映射快照', + `source_business_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型', + `source_business_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + `postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', + `return_receipt_id` bigint DEFAULT NULL COMMENT '明确选用的真实退回入仓来源', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本条真实客户名称快照', + `source_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本条货物稳定权威来源键', + `source_version` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '选货时核对的真实来源版本摘要', + `source_snapshot_json` json DEFAULT NULL COMMENT '本条货物不可变来源及关联事实快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_item_source` (`dispatch_order_id`,`plan_pallet_id`,`warehouse_pallet_id`), + KEY `idx_ll_dispatch_item_order` (`customer_id`,`order_id`), + KEY `idx_ll_dispatch_item_inbound` (`customer_id`,`inbound_code`), + KEY `idx_ll_dispatch_item_pallet` (`customer_id`,`plan_pallet_id`,`warehouse_pallet_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Frozen pallet and cargo snapshot for a dispatch order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `dispatch_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Dispatch order number', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `vehicle_plate` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Editable vehicle plate; required before PDA picking', + `driver_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `driver_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `carrier_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `route` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `depart_at` datetime(3) DEFAULT NULL, + `fba_appointment_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fba_appointment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `vehicle_appointment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `estimated_arrival_at` datetime(3) DEFAULT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_DEPARTURE', + `item_count` int NOT NULL DEFAULT '0', + `pallet_count` int NOT NULL DEFAULT '0', + `package_count` int NOT NULL DEFAULT '0', + `picked_package_count` int NOT NULL DEFAULT '0', + `shipped_package_count` int NOT NULL DEFAULT '0', + `partial_completion_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `trip_id` bigint DEFAULT NULL COMMENT '本趟唯一车辆资源记录ID', + `overseas_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运海外仓编码快照', + `stage` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BEFORE_LOADING' COMMENT '作业阶段:BEFORE_LOADING装车前、LOADING装车中、AFTER_LOADING装车后、DELIVERING派送中、DELIVERED派送完成', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '整车公共并发版本,首版为零', + `cargo_revision` bigint NOT NULL DEFAULT '0' COMMENT '本趟货物修订版本', + `cargo_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前整车真实货物及来源摘要', + `review_state` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', + `review_round` bigint NOT NULL DEFAULT '0' COMMENT '当前审核轮次', + `review_basis_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + `dispatch_review_json` json DEFAULT NULL COMMENT '当前调度审核事实快照', + `loading_review_json` json DEFAULT NULL COMMENT '当前理货审核事实快照', + `resources_json` json DEFAULT NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', + `planned_departure_at` datetime(3) DEFAULT NULL COMMENT '计划发车时间,草稿未知时为空', + `loading_completed_at` datetime(3) DEFAULT NULL COMMENT '实际确认装车完成时间', + `loading_completion_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际装车完成事实摘要', + `loading_confirmation_json` json DEFAULT NULL COMMENT '装车确认时逐实托真实实载完整快照', + `arrangement_key` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本车唯一派送安排事实标识', + `arrangement_business_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本车派送安排业务内容摘要', + `departed_at` datetime(3) DEFAULT NULL COMMENT '本趟实际发车时间', + `delivery_completed_at` datetime(3) DEFAULT NULL COMMENT '全部派送正常签收后的整车完成时间', + `remark` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '排车业务备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_order_no` (`customer_id`,`dispatch_no`), + UNIQUE KEY `uk_dispatch_trip` (`trip_id`), + KEY `idx_ll_dispatch_order_list` (`customer_id`,`status`,`depart_at`), + KEY `idx_ll_dispatch_order_vehicle` (`customer_id`,`vehicle_plate`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Last-leg pre-dispatch order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pallet_lock`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pallet_lock` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_pallet_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PLAN:{planPalletId} or RECEIVED:{warehousePalletId}', + `active_key` varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Equals source key only while lock is active', + `plan_id` bigint DEFAULT NULL, + `plan_pallet_id` bigint DEFAULT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `dispatch_order_id` bigint DEFAULT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + `draft_container_id` bigint DEFAULT NULL COMMENT '智能排车草稿装车托盘ID', + `draft_token` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '智能排车草稿随机标识', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿锁操作人ID', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `locked_at` datetime(3) NOT NULL, + `expires_at` datetime(3) DEFAULT NULL COMMENT '草稿锁失效时间,正式派车锁为空', + `released_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `return_receipt_id` bigint DEFAULT NULL COMMENT '明确选用的真实退回入仓来源', + `active_warehouse_pallet_id` bigint DEFAULT NULL COMMENT '活动占用的真实仓库托盘,释放后为空,跨客户及来源别名唯一', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_active_pallet` (`customer_id`,`active_key`), + UNIQUE KEY `uk_ll_dispatch_active_warehouse_pallet` (`active_warehouse_pallet_id`), + KEY `idx_ll_dispatch_pallet_lock_order` (`customer_id`,`dispatch_order_id`,`status`), + KEY `idx_ll_dispatch_pallet_lock_source` (`customer_id`,`source_pallet_key`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Active dispatch pallet exclusion lock'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pda_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pda_operation` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint DEFAULT NULL, + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `result_json` longtext COLLATE utf8mb4_unicode_ci, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_pda_operation_key` (`customer_id`,`idempotency_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Idempotent dispatch PDA operations'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pda_pick`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pda_pick` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint NOT NULL, + `dispatch_item_id` bigint NOT NULL, + `inventory_position_id` bigint DEFAULT NULL COMMENT '普通库存仓位记录ID', + `packages` bigint NOT NULL, + `product_quantity` bigint NOT NULL DEFAULT '0', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `picked_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `return_carton_id` bigint DEFAULT NULL COMMENT '独立退货库存箱ID,与普通仓位互斥', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_pda_pick_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_dispatch_pda_pick_item` (`customer_id`,`task_id`,`dispatch_item_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable dispatch PDA pallet package picks'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pda_pick_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pda_pick_order` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `pick_id` bigint NOT NULL, + `receipt_id` bigint DEFAULT NULL COMMENT '原入仓实收来源,与退回来源或原生退货箱互斥', + `order_id` bigint NOT NULL, + `packages` bigint NOT NULL, + `product_quantity` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `return_carton_id` bigint DEFAULT NULL COMMENT '原生退货箱来源ID,与实收来源互斥', + `return_receipt_id` bigint DEFAULT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (`id`), + KEY `idx_ll_dispatch_pick_order_pick` (`customer_id`,`pick_id`), + KEY `idx_ll_dispatch_pick_order_receipt` (`customer_id`,`receipt_id`), + KEY `idx_dispatch_pick_return_receipt` (`customer_id`,`return_receipt_id`), + CONSTRAINT `chk_dispatch_pick_receipt_source` CHECK (((((`receipt_id` is not null) + (`return_receipt_id` is not null)) + (`return_carton_id` is not null)) = 1)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA pick attribution to frozen inbound receipt and tail order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pda_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pda_task` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `dispatch_order_id` bigint NOT NULL, + `task_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pushed_at` datetime(3) NOT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_pda_task_dispatch` (`customer_id`,`dispatch_order_id`), + UNIQUE KEY `uk_ll_dispatch_pda_task_no` (`customer_id`,`task_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA task for a pre-arranged dispatch'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_carton` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint NOT NULL, + `profile_id` bigint NOT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `carton_sequence` int NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'AVAILABLE', + `outbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `product_quantity` bigint DEFAULT NULL COMMENT '该箱实收商品件数', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_carton` (`customer_id`,`order_id`,`carton_mark`), + KEY `idx_ll_dropship_carton_status` (`customer_id`,`status`), + KEY `idx_ll_dropship_carton_sku` (`customer_id`,`sku_code`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_carton_ledger`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_carton_ledger` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint NOT NULL, + `carton_id` bigint NOT NULL, + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `before_packages` bigint NOT NULL, + `delta_packages` bigint NOT NULL, + `after_packages` bigint NOT NULL, + `business_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `occurred_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_ledger_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_dropship_ledger_sku` (`customer_id`,`sku_code`,`carton_mark`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_carton_sku`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_carton_sku` ( + `id` bigint NOT NULL COMMENT '主键ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `profile_id` bigint NOT NULL COMMENT '一件代发托盘档案ID', + `carton_id` bigint NOT NULL COMMENT '一件代发物理箱ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱内SKU编码', + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称快照', + `product_quantity` bigint NOT NULL COMMENT '该物理箱内此SKU实收件数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_carton_sku` (`carton_id`,`sku_code`), + KEY `idx_ll_dropship_carton_sku_order` (`customer_id`,`order_id`,`sku_code`), + KEY `idx_ll_dropship_carton_sku_profile` (`customer_id`,`profile_id`,`sku_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发物理箱内SKU明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_order_profile`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_order_profile` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint NOT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Pallet number snapshot supplied by first-leg handover', + `inventory_position_id` bigint DEFAULT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `manifest_version` int DEFAULT NULL COMMENT '整托清单版本', + `manifest_package_count` int DEFAULT NULL COMMENT '整托总箱数', + `manifest_fragment_count` int DEFAULT NULL COMMENT '整托客户分片总数', + `manifest_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '整托完整清单摘要', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_profile_key` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_dropship_profile_order_pallet` (`customer_id`,`order_id`,`source_pallet_no`), + KEY `idx_ll_dropship_profile_order` (`customer_id`,`order_id`), + KEY `idx_ll_dropship_profile_manifest` (`source_pallet_no`,`manifest_hash`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_sku_inventory`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_sku_inventory` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint NOT NULL, + `profile_id` bigint NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `package_count` bigint NOT NULL, + `product_quantity` bigint NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_sku_profile` (`profile_id`,`sku_code`), + UNIQUE KEY `uk_ll_dropship_sku_inventory` (`profile_id`,`sku_code`), + KEY `idx_ll_dropship_sku_order` (`customer_id`,`order_id`,`sku_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_event_conflict`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_event_conflict` ( + `id` bigint NOT NULL COMMENT '冲突记录主键', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冲突事件 ID', + `customer_id` bigint NOT NULL COMMENT '租户 ID', + `conflict_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冲突类型', + `conflict_message` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冲突说明', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_conflict_event` (`event_id`,`customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件冲突记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_event_inbox` ( + `id` bigint NOT NULL COMMENT '事件接收记录主键', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局唯一 ID', + `customer_id` bigint NOT NULL COMMENT '租户 ID', + `event_type` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + `aggregate_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + `aggregate_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合 ID', + `aggregate_version` bigint NOT NULL COMMENT '聚合版本', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '负载摘要', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态', + `error_message` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbox_event` (`event_id`), + KEY `idx_ll_inbox_source` (`customer_id`,`aggregate_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用事件接收幂等箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_event_outbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_event_outbox` ( + `id` bigint NOT NULL COMMENT '事件主键ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `aggregate_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + `aggregate_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', + `event_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + `event_version` int NOT NULL COMMENT '事件版本', + `event_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + `payload_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件载荷JSON', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', + `retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '下次重试时间', + `published_at` datetime(3) DEFAULT NULL COMMENT '发布时间', + `locked_at` datetime(3) DEFAULT NULL COMMENT '投递租约获取时间', + `locked_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '投递租约持有者', + `last_error` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后一次发布错误', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_event_outbox_key` (`event_key`), + KEY `idx_ll_event_outbox_publish` (`status`,`next_retry_at`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程跨区业务事件发件箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_exception_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_exception_order` ( + `id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `exception_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `exception_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL, + `last_mile_order_id` bigint DEFAULT NULL, + `last_mile_order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_order_id` bigint DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `delivery_order_id` bigint DEFAULT NULL, + `delivery_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `outbound_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `package_count` int DEFAULT NULL, + `return_direction` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `return_logistics_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `handling_action` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `handling_result` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常处理结果', + `evidence_file_ids_json` json DEFAULT NULL COMMENT '异常处理证据文件ID列表', + `inventory_adjustment_id` bigint DEFAULT NULL COMMENT '退回库存恢复操作ID', + `handler_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `handler_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANUAL', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `exception_at` datetime(3) NOT NULL, + `completed_at` datetime(3) DEFAULT NULL COMMENT '异常处理完成时间', + `version` bigint NOT NULL DEFAULT '0' COMMENT '异常订单乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `return_context_json` json DEFAULT NULL COMMENT '退回异常创建前的派送及尾程状态快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_exception_no` (`customer_id`,`exception_no`), + UNIQUE KEY `uk_ll_exception_delivery_return` (`customer_id`,`delivery_order_id`,`exception_type`), + KEY `idx_ll_exception_list` (`customer_id`,`status`,`exception_at`), + KEY `idx_ll_exception_tail` (`customer_id`,`last_mile_order_no`), + KEY `idx_ll_exception_inbound` (`customer_id`,`inbound_code`), + KEY `idx_ll_exception_delivery_return` (`customer_id`,`delivery_order_id`,`exception_type`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓异常订单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_exception_order_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_exception_order_command` ( + `id` bigint NOT NULL COMMENT '异常处理命令ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `exception_order_id` bigint NOT NULL COMMENT '异常订单ID', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '异常处理动作', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA256哈希', + `result_json` json NOT NULL COMMENT '命令结果快照', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_exception_command_key` (`customer_id`,`action`,`idempotency_key`), + KEY `idx_ll_exception_command_order` (`customer_id`,`exception_order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常处理命令幂等审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_exception_return_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_exception_return_item` ( + `id` bigint NOT NULL COMMENT '异常退回明细ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `exception_order_id` bigint NOT NULL COMMENT '异常订单ID', + `outbound_pallet_item_id` bigint NOT NULL COMMENT '原出库托盘明细ID', + `inventory_position_id` bigint NOT NULL COMMENT '原出库库存位置ID', + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照', + `max_return_packages` bigint NOT NULL COMMENT '原出库可退箱数上限', + `approved_return_packages` bigint NOT NULL COMMENT '批准退回箱数', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `return_carton_id` bigint DEFAULT NULL COMMENT '原生退货箱标识;与普通库存位置互斥', + `return_carton_mark` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原生退货箱业务箱码快照', + `received_packages` bigint NOT NULL DEFAULT '0' COMMENT '已实际退回入仓的累计箱数', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_exception_return_source` (`customer_id`,`exception_order_id`,`outbound_pallet_item_id`,`inventory_position_id`), + UNIQUE KEY `uk_ll_exception_return_carton` (`customer_id`,`exception_order_id`,`outbound_pallet_item_id`,`return_carton_id`), + KEY `idx_ll_exception_return_order` (`customer_id`,`exception_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fba_appointment_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fba_appointment_command` ( + `command_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '持久化幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作、操作者和请求摘要', + `operation` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型及目标业务号', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登录操作人', + `appointment_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '系统预约业务单号', + `response_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '原命令返回快照,与业务写入同事务', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', + PRIMARY KEY (`command_key`), + KEY `idx_ll_fba_command_appointment` (`appointment_no`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约命令与变更历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fba_receiving_agreement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fba_receiving_agreement` ( + `agreement_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货预约独立业务键', + `version` int NOT NULL COMMENT '预约覆盖范围与确认资料版本', + `status` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '有效、取消或已替代状态', + `destination_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '权威目的仓关联键', + `confirmation_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前有效外部约号,失效后清空以释放覆盖', + `snapshot_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户逐来源覆盖、确认时区及仓库依据快照', + PRIMARY KEY (`agreement_key`), + UNIQUE KEY `uk_fba_receiving_confirmation` (`destination_key`,`confirmation_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='独立于送货批次的收货预约'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fba_receiving_change`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fba_receiving_change` ( + `change_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户预约变化业务键', + `version` int NOT NULL COMMENT '变化续办版本', + `status` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '待仓库确认、已应用或已撤回状态', + `batch_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '受影响原送货批次键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次变化所属客户', + `snapshot_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原覆盖快照、逐来源拟保留量、负责人及跟进资料', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '确认后的实际批次去向及逐步骤结果', + `active_ref` varchar(260) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '同批同客户唯一未解决变化引用', + PRIMARY KEY (`change_key`), + UNIQUE KEY `uk_fba_receiving_pending` (`active_ref`), + KEY `idx_fba_receiving_pending_batch` (`batch_key`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='保留原安排且阻止误发车的客户变化'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fba_receiving_member`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fba_receiving_member` ( + `agreement_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属收货预约业务键', + `batch_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定送货批次键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '货物所属客户标识', + `active_ref` varchar(260) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '有效批次与客户唯一引用,退出后清空且历史保留', + PRIMARY KEY (`agreement_key`,`batch_key`,`customer_id`), + UNIQUE KEY `uk_fba_receiving_active_member` (`active_ref`), + KEY `idx_fba_receiving_batch` (`batch_key`,`customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约当前覆盖关联及退出历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fba_receiving_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fba_receiving_plan` ( + `batch_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定送货批次键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '计划所属客户标识', + `snapshot_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本客户未确认计划、目标窗口及业务时区,不含有效预约确认', + PRIMARY KEY (`batch_key`,`customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='按批次及客户保存的未确认收货计划'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fba_receiving_revision`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fba_receiving_revision` ( + `agreement_key` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '收货预约业务键', + `version` int NOT NULL COMMENT '不可变覆盖与确认版本', + `snapshot_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '该版本完整客户覆盖、确认时段及回复依据', + PRIMARY KEY (`agreement_key`,`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='收货预约覆盖与确认不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_finance_fee_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_finance_fee_command` ( + `id` bigint NOT NULL COMMENT '幂等命令ID', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + `action` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256', + `source_fee_id` bigint NOT NULL COMMENT '稳定费用来源ID', + `source_version` int NOT NULL COMMENT '命令结果版本', + `created_at` datetime(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_finance_fee_command` (`operator_id`,`action`,`idempotency_key`), + KEY `idx_ll_finance_fee_command_source` (`source_fee_id`,`source_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外费用来源写操作幂等审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_finance_fee_source`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_finance_fee_source` ( + `id` bigint NOT NULL COMMENT '费用版本记录ID', + `source_fee_id` bigint NOT NULL COMMENT '跨版本稳定费用来源ID', + `source_version` int NOT NULL COMMENT '费用来源版本,从1递增', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `customer_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + `source_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外费用来源类型', + `source_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务编号或入仓码快照', + `business_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务类型', + `business_id` bigint DEFAULT NULL COMMENT '来源业务记录ID', + `business_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务编号', + `order_id` bigint DEFAULT NULL COMMENT '关联订单ID', + `inbound_id` bigint DEFAULT NULL COMMENT '关联海外入仓计划或入仓记录ID', + `inbound_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联入仓码;并非所有费用必填', + `container_id` bigint DEFAULT NULL COMMENT '关联柜次ID', + `container_booking_id` bigint DEFAULT NULL COMMENT '来源订舱柜ID', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实集装箱号快照', + `warehouse_id` bigint DEFAULT NULL COMMENT '关联海外仓ID', + `fee_item_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照', + `fee_item_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照', + `direction` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + `supplier_id` bigint DEFAULT NULL COMMENT '应付供应商ID', + `supplier_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照', + `supplier_category` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商类别约束', + `settlement_mode` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + `paid_at` datetime(3) DEFAULT NULL COMMENT '直接已付时间', + `payment_method` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付方式', + `payment_reference_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付流水号', + `payment_voucher_file_ids` json DEFAULT NULL COMMENT '直接已付凭证文件ID快照', + `wallet_settlement` json DEFAULT NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用', + `business_date` date NOT NULL COMMENT '费用业务日期', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY', + `amount` decimal(20,6) NOT NULL COMMENT '费用金额', + `review_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + `publication_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', + `source_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + `review_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因', + `reviewer_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + `original_currency` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '原币币种', + `original_amount` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '原币金额', + `exchange_rate` decimal(18,8) NOT NULL DEFAULT '1.00000000' COMMENT '原币折算CNY汇率', + `exchange_rate_month` char(7) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '汇率月份YYYY-MM', + `chargeable_quantity` decimal(18,4) DEFAULT NULL COMMENT '计费数量', + `quantity_unit` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费单位', + `unit_price` decimal(18,4) DEFAULT NULL COMMENT '原币单价', + `period_start` date DEFAULT NULL COMMENT '周期费用开始日期', + `period_end` date DEFAULT NULL COMMENT '周期费用结束日期', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_finance_fee_version` (`source_fee_id`,`source_version`), + KEY `idx_ll_finance_fee_customer` (`customer_id`,`source_status`,`created_at`), + KEY `idx_ll_finance_fee_business` (`business_type`,`business_no`,`created_at`), + KEY `idx_ll_finance_fee_container_customer` (`container_booking_id`,`customer_id`,`source_status`,`created_at`), + CONSTRAINT `chk_ll_finance_fee_amount` CHECK ((`amount` > 0)), + CONSTRAINT `chk_ll_finance_fee_currency` CHECK ((`currency` = _utf8mb4'CNY')), + CONSTRAINT `chk_ll_finance_fee_direction` CHECK ((`direction` in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), + CONSTRAINT `chk_ll_finance_fee_publication` CHECK ((`publication_status` in (_utf8mb4'UNPUBLISHED',_utf8mb4'PUBLISHED'))), + CONSTRAINT `chk_ll_finance_fee_review` CHECK ((`review_status` in (_utf8mb4'DRAFT',_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))), + CONSTRAINT `chk_ll_finance_fee_status` CHECK ((`source_status` in (_utf8mb4'ACTIVE',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_capacity_offer`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_capacity_offer` ( + `id` bigint NOT NULL COMMENT '内部主键', + `offer_no` varchar(64) NOT NULL COMMENT '拼车运力发布业务编号', + `trip_id` bigint NOT NULL COMMENT '唯一所属车次', + `trip_no` varchar(64) NOT NULL COMMENT '车次业务编号', + `status` varchar(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭', + `cutoff_at` datetime(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间', + `contact_name` varchar(128) DEFAULT NULL COMMENT '对内部销售公开的调度联系人', + `contact_phone` varchar(128) DEFAULT NULL COMMENT '对内部销售公开的调度联系电话', + `remark` varchar(1000) DEFAULT NULL COMMENT '公开承运说明', + `published_fingerprint` char(64) DEFAULT NULL COMMENT '已核对路线车辆及容量计划事实摘要', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(64) NOT NULL COMMENT '创建人', + `created_at` datetime(3) NOT NULL COMMENT '创建UTC时间', + `updated_by` varchar(64) NOT NULL COMMENT '修改人', + `updated_at` datetime(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_capacity_offer_no` (`offer_no`), + UNIQUE KEY `uk_fleet_capacity_offer_trip` (`trip_id`), + KEY `idx_fleet_capacity_offer_status` (`status`,`cutoff_at`,`id`), + CONSTRAINT `fk_fleet_capacity_offer_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`), + CONSTRAINT `chk_fleet_capacity_offer_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'OPEN',_utf8mb4'PAUSED',_utf8mb4'CLOSED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='内部拼车车次余位发布;容量实时计算不在此另存'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_capacity_offer_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_capacity_offer_command` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_id` bigint NOT NULL COMMENT '所属车次锁作用域', + `operator_id` varchar(64) NOT NULL COMMENT '原请求操作者', + `action` varchar(32) NOT NULL COMMENT '业务命令类型', + `scope_no` varchar(64) NOT NULL COMMENT '发布或预订业务编号作用域', + `idempotency_key` varchar(128) NOT NULL COMMENT '原请求幂等键', + `request_hash` char(64) NOT NULL COMMENT '原请求业务内容摘要', + `result_json` json NOT NULL COMMENT '成功命令原始结果用于精确重放', + `created_at` datetime(3) NOT NULL COMMENT '命令完成UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_capacity_offer_command` (`trip_id`,`operator_id`,`action`,`scope_no`,`idempotency_key`), + CONSTRAINT `fk_fleet_capacity_command_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拼车发布及销售意向持久幂等结果'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_driver`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_driver` ( + `id` bigint NOT NULL COMMENT '内部主键', + `driver_no` varchar(32) NOT NULL COMMENT '司机业务编号', + `warehouse_id` bigint DEFAULT NULL COMMENT '所属公共仓库引用', + `warehouse_code` varchar(64) DEFAULT NULL COMMENT '所属仓库编码快照', + `warehouse_name` varchar(256) DEFAULT NULL COMMENT '所属仓库名称快照', + `vehicle_models` varchar(128) DEFAULT NULL COMMENT '可驾驶车型描述', + `languages_and_area` varchar(256) DEFAULT NULL COMMENT '语言及服务区域', + `medical_expires_on` date DEFAULT NULL COMMENT '体检到期日,当前不作为必需资质', + `training_expires_on` date DEFAULT NULL COMMENT '培训安全到期日,当前不作为必需资质', + `insurance_expires_on` date DEFAULT NULL COMMENT '保险到期日,当前不作为必需资质', + `name` varchar(128) NOT NULL COMMENT '姓名', + `phone` varchar(64) NOT NULL COMMENT '联系电话', + `ownership` varchar(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派', + `region` varchar(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `review_status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回', + `license_number` varchar(128) DEFAULT NULL COMMENT '驾驶证编号', + `license_type` varchar(64) DEFAULT NULL COMMENT '驾驶证类型', + `license_issued_on` date DEFAULT NULL COMMENT '驾驶证签发日期', + `license_expires_on` date DEFAULT NULL COMMENT '驾驶证有效截止日', + `license_zone` varchar(64) NOT NULL COMMENT '证照有效日期的IANA时区', + `license_file_id` bigint DEFAULT NULL COMMENT '平台驾驶证文件引用', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '并发版本', + `created_by` varchar(64) NOT NULL COMMENT '创建人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间UTC', + `updated_by` varchar(64) NOT NULL COMMENT '修改人', + `updated_at` datetime(3) NOT NULL COMMENT '修改时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_driver_no` (`driver_no`), + KEY `idx_fleet_driver_page` (`active`,`review_status`,`updated_at`,`id`), + KEY `idx_fleet_driver_expiry` (`license_expires_on`), + CONSTRAINT `chk_fleet_driver_dates` CHECK (((`license_issued_on` is null) or (`license_expires_on` is null) or (`license_issued_on` <= `license_expires_on`))), + CONSTRAINT `chk_fleet_driver_ownership` CHECK ((`ownership` in (_utf8mb4'OWNED',_utf8mb4'DISPATCHED'))), + CONSTRAINT `chk_fleet_driver_region` CHECK ((`region` in (_utf8mb4'DOMESTIC',_utf8mb4'OVERSEAS'))), + CONSTRAINT `chk_fleet_driver_review` CHECK ((`review_status` in (_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台司机档案'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_driver_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_driver_history` ( + `id` bigint NOT NULL COMMENT '内部主键', + `driver_id` bigint NOT NULL COMMENT '司机内部引用', + `record_version` bigint NOT NULL COMMENT '操作后的档案版本', + `action` varchar(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作', + `reason` varchar(1000) DEFAULT NULL COMMENT '操作原因或审核意见', + `license_file_id` bigint DEFAULT NULL COMMENT '当时驾驶证文件引用', + `snapshot_json` json NOT NULL COMMENT '操作后不可变档案快照', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_driver_history_version` (`driver_id`,`record_version`), + KEY `idx_fleet_driver_history_file` (`driver_id`,`license_file_id`), + CONSTRAINT `fk_fleet_driver_history_driver` FOREIGN KEY (`driver_id`) REFERENCES `ll_fleet_driver` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='司机档案及审核历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_external_booking`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_external_booking` ( + `id` bigint NOT NULL COMMENT '内部主键', + `booking_no` varchar(32) NOT NULL COMMENT '外接预订业务编号', + `trip_id` bigint NOT NULL COMMENT '所属车次', + `trip_no` varchar(32) NOT NULL COMMENT '车次业务编号', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属', + `customer_code` varchar(64) NOT NULL COMMENT '客户编号快照', + `customer_name` varchar(255) NOT NULL COMMENT '客户名称快照', + `status` varchar(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', + `cargo_json` json NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照', + `quote_source_json` json DEFAULT NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存', + `destination_key` varchar(160) NOT NULL COMMENT '已有路线卸货站匹配键', + `destination_label` varchar(255) NOT NULL COMMENT '约定卸货站名称', + `agreed_amount` decimal(16,2) DEFAULT NULL COMMENT '约定金额,非已入账应收或实收', + `idempotency_key` varchar(128) NOT NULL COMMENT '登记请求键', + `request_hash` char(64) NOT NULL COMMENT '登记内容摘要', + `confirm_key` varchar(128) DEFAULT NULL COMMENT '承运确认请求键', + `confirm_hash` char(64) DEFAULT NULL COMMENT '承运确认摘要', + `cancel_key` varchar(128) DEFAULT NULL COMMENT '取消请求键', + `cancel_hash` char(64) DEFAULT NULL COMMENT '取消摘要', + `incoming_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '正式来货单业务编号', + `dispatch_no` varchar(64) DEFAULT NULL COMMENT '已关联客户排车业务编号', + `receipt_json` json DEFAULT NULL COMMENT '实测来源及运营确认的托位和运费依据', + `incoming_key` varchar(128) DEFAULT NULL COMMENT '来货登记请求键', + `incoming_hash` char(64) DEFAULT NULL COMMENT '来货登记摘要', + `receipt_key` varchar(128) DEFAULT NULL COMMENT '实收确认请求键', + `receipt_hash` char(64) DEFAULT NULL COMMENT '实收确认摘要', + `binding_key` varchar(128) DEFAULT NULL COMMENT '关联发运请求键', + `binding_hash` char(64) DEFAULT NULL COMMENT '关联发运摘要', + `offer_id` bigint DEFAULT NULL COMMENT '来源运力发布,普通车次预订为空', + `sales_owner_user_id` varchar(64) DEFAULT NULL COMMENT '登录身份冻结的销售负责人', + `business_type` varchar(32) DEFAULT NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK', + `delivery_mode` varchar(32) DEFAULT NULL COMMENT '客户送仓或人工安排提货回仓', + `estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计实际到我仓UTC时间', + `expected_pallet_count` int DEFAULT NULL COMMENT '预计实托数,与托位分开且未知为空', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '记录版本', + `created_by` varchar(64) NOT NULL COMMENT '登记人', + `created_at` datetime(3) NOT NULL COMMENT '登记UTC时间', + `updated_by` varchar(64) NOT NULL COMMENT '修改人', + `updated_at` datetime(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_booking_no` (`booking_no`), + UNIQUE KEY `uk_fleet_booking_create` (`trip_id`,`created_by`,`idempotency_key`), + UNIQUE KEY `uk_fleet_booking_incoming` (`incoming_no`), + KEY `idx_fleet_booking_offer` (`offer_id`,`status`,`id`), + KEY `idx_fleet_booking_sales` (`sales_owner_user_id`,`offer_id`,`id`), + KEY `idx_fleet_booking_trip` (`trip_id`,`status`,`id`), + KEY `idx_fleet_booking_dispatch` (`dispatch_no`), + CONSTRAINT `fk_fleet_booking_offer` FOREIGN KEY (`offer_id`) REFERENCES `ll_fleet_capacity_offer` (`id`), + CONSTRAINT `fk_fleet_booking_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`), + CONSTRAINT `chk_fleet_booking_expected_pallet` CHECK (((`expected_pallet_count` is null) or (`expected_pallet_count` > 0))), + CONSTRAINT `chk_fleet_booking_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'CONFIRMED',_utf8mb4'RECEIVED',_utf8mb4'BOUND',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_finance_source`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_finance_source` ( + `id` bigint unsigned NOT NULL COMMENT '来源提交主键', + `record_id` bigint unsigned NOT NULL COMMENT '车辆运维记录主键', + `source_version` bigint unsigned NOT NULL COMMENT '独立财务来源版本,从1递增', + `record_version` bigint unsigned NOT NULL COMMENT '提交时运维记录版本', + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提交操作人标识', + `request_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原请求幂等键', + `request_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范请求摘要,含sha256前缀', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联公共Outbox事件ID', + `payload_json` json NOT NULL COMMENT '不可变公司成本来源快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_finance_source_version` (`record_id`,`source_version`), + UNIQUE KEY `uk_fleet_finance_source_request` (`record_id`,`operator_id`,`request_key`), + UNIQUE KEY `uk_fleet_finance_source_event` (`event_id`), + CONSTRAINT `chk_fleet_finance_source_version` CHECK ((`source_version` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='维修救援公司成本提交来源,不保存付款推定'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_command` ( + `trip_no` varchar(64) NOT NULL COMMENT '取货车次号', + `request_key` varchar(128) NOT NULL COMMENT '成功命令幂等键', + `request_hash` varchar(64) NOT NULL COMMENT '命令动作与内容摘要', + PRIMARY KEY (`trip_no`,`request_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='取货命令幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_history` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '操作流水', + `customer_id` varchar(64) NOT NULL COMMENT '客户隔离键', + `transfer_no` varchar(64) NOT NULL COMMENT '原转运单号', + `trip_no` varchar(64) NOT NULL COMMENT '当时取货车次号', + `action` varchar(32) NOT NULL COMMENT '安排解除取货到仓动作', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime NOT NULL COMMENT '操作UTC时间', + `detail` varchar(2000) DEFAULT NULL COMMENT '操作说明', + PRIMARY KEY (`id`), + KEY `idx_pickup_history_source` (`customer_id`,`transfer_no`,`id`), + KEY `idx_pickup_history_trip` (`trip_no`,`id`) +) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='取货不可变操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_plan` ( + `trip_no` varchar(64) NOT NULL COMMENT '现有车辆车次号', + `request_hash` varchar(64) NOT NULL COMMENT '创建来源与计划摘要', + PRIMARY KEY (`trip_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运取货计划'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_task` ( + `transfer_order_id` bigint NOT NULL COMMENT '原仓储转运单ID,一票唯一', + `customer_id` varchar(64) NOT NULL COMMENT '客户隔离键', + `transfer_no` varchar(64) NOT NULL COMMENT '原仓储转运单号', + `trip_no` varchar(64) DEFAULT NULL COMMENT '当前取货车次,解除后为空', + `status` varchar(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', + `actual_packages` int DEFAULT NULL COMMENT '司机实提箱数,非仓库实收', + `picked_at` datetime DEFAULT NULL COMMENT '实际确认取货UTC时间', + `arrived_at` datetime DEFAULT NULL COMMENT '实际确认送仓UTC时间', + `evidence_json` longtext COMMENT '本票已确认凭证元数据', + `remark` varchar(1000) DEFAULT NULL COMMENT '取货备注', + `updated_at` datetime NOT NULL COMMENT '最近修改UTC时间', + PRIMARY KEY (`transfer_order_id`), + UNIQUE KEY `uk_pickup_source` (`customer_id`,`transfer_no`), + KEY `idx_pickup_trip` (`trip_no`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运逐票取货执行'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_upload`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_upload` ( + `transfer_order_id` bigint NOT NULL COMMENT '凭证所属原单', + `trip_no` varchar(64) NOT NULL COMMENT '凭证所属车次', + `file_id` bigint NOT NULL COMMENT '平台归属文件ID', + PRIMARY KEY (`file_id`), + KEY `idx_pickup_upload_source` (`transfer_order_id`,`trip_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='取货上传凭证精确单据归属'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pool_order_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pool_order_link` ( + `id` bigint NOT NULL COMMENT '内部主键', + `booking_id` bigint NOT NULL COMMENT '唯一外接预订', + `booking_no` varchar(64) NOT NULL COMMENT '预订业务编号', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围', + `business_type` varchar(32) NOT NULL COMMENT '本土散板或本土卡车商业类型', + `business_order_id` bigint NOT NULL COMMENT '对应商业订单内部关联', + `business_order_no` varchar(64) NOT NULL COMMENT '本土商业订单业务编号', + `warehouse_id` varchar(128) NOT NULL COMMENT '真实我仓上车仓库', + `destination_key` varchar(160) NOT NULL COMMENT '冻结卸货站匹配键', + `delivery_mode` varchar(32) NOT NULL COMMENT '客户送仓或人工提货回仓', + `delivery_instructions` varchar(1000) DEFAULT NULL COMMENT '实际到仓交货说明', + `incoming_no` varchar(64) DEFAULT NULL COMMENT '真实来货业务编号,未登记为空', + `lastleg_order_id` bigint DEFAULT NULL COMMENT '唯一真实尾程来源,未发生为空', + `dispatch_no` varchar(64) DEFAULT NULL COMMENT '已关联客户排车编号', + `fulfillment_stage` varchar(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成', + `stage_occurred_at` datetime(3) NOT NULL COMMENT '实际阶段发生UTC时间', + `actual_pallet_count` int DEFAULT NULL COMMENT '实际去重托盘数,未知为空', + `actual_package_count` int DEFAULT NULL COMMENT '实际箱数,未知为空', + `actual_pallet_spaces` int DEFAULT NULL COMMENT '实际核准占用托位,未知为空', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '桥记录版本', + `creation_request_hash` char(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要', + `created_by` varchar(64) NOT NULL COMMENT '确认承运操作者', + `created_at` datetime(3) NOT NULL COMMENT '创建UTC时间', + `updated_at` datetime(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_pool_order_booking` (`booking_id`), + UNIQUE KEY `uk_fleet_pool_order_booking_no` (`booking_no`), + UNIQUE KEY `uk_fleet_pool_order_business` (`business_type`,`business_order_id`), + UNIQUE KEY `uk_fleet_pool_order_incoming` (`incoming_no`), + UNIQUE KEY `uk_fleet_pool_order_lastleg` (`lastleg_order_id`), + KEY `idx_fleet_pool_order_customer` (`customer_id`,`business_type`,`business_order_no`), + KEY `idx_fleet_pool_order_dispatch` (`customer_id`,`dispatch_no`), + CONSTRAINT `fk_fleet_pool_order_booking` FOREIGN KEY (`booking_id`) REFERENCES `ll_fleet_external_booking` (`id`), + CONSTRAINT `chk_fleet_pool_order_actual` CHECK ((((`actual_pallet_count` is null) or (`actual_pallet_count` >= 0)) and ((`actual_package_count` is null) or (`actual_package_count` >= 0)) and ((`actual_pallet_spaces` is null) or (`actual_pallet_spaces` >= 0)))), + CONSTRAINT `chk_fleet_pool_order_type` CHECK ((`business_type` in (_utf8mb4'LOCAL_LOOSEBOARD',_utf8mb4'US_TRUCK'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_quote`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_quote` ( + `id` bigint NOT NULL COMMENT '报价记录主键', + `quote_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价业务编号', + `kind` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价用途采购或销售', + `party_id` bigint NOT NULL COMMENT '供应商或客户主档标识', + `party_name` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '往来单位名称快照', + `origin_city` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发货城市', + `destination_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的仓业务编码', + `valid_from` date NOT NULL COMMENT '报价生效日期', + `valid_to` date NOT NULL COMMENT '报价失效日期含当天', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价启停或删除状态', + `natural_key` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价组合去重摘要', + `details_json` json NOT NULL COMMENT '完整报价明细快照', + `record_version` bigint NOT NULL COMMENT '报价记录版本', + `create_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + `create_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求含导入批次内容摘要', + `created_by` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建操作人标识', + `created_at` datetime(3) NOT NULL COMMENT '创建时间UTC', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_quote_no` (`quote_no`), + UNIQUE KEY `uk_fleet_quote_natural` (`natural_key`), + UNIQUE KEY `uk_fleet_quote_create` (`created_by`,`create_key`), + KEY `idx_fleet_quote_search` (`kind`,`status`,`valid_from`,`valid_to`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_quote_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_quote_history` ( + `id` bigint NOT NULL COMMENT '报价历史主键', + `quote_id` bigint NOT NULL COMMENT '报价记录标识', + `record_version` bigint NOT NULL COMMENT '报价记录版本', + `action` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价操作类型', + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人标识', + `occurred_at` datetime(3) NOT NULL COMMENT '操作时间UTC', + `snapshot_json` json NOT NULL COMMENT '报价完整版本快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_quote_history_version` (`quote_id`,`record_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_resource_assignment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_resource_assignment` ( + `resource_kind` varchar(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER', + `resource_id` bigint NOT NULL COMMENT '对应车辆或司机档案引用', + `trip_id` bigint NOT NULL COMMENT '占用车次', + `trip_no` varchar(32) NOT NULL COMMENT '占用车次业务编号', + PRIMARY KEY (`resource_kind`,`resource_id`), + KEY `idx_fleet_resource_trip` (`trip_id`), + CONSTRAINT `fk_fleet_resource_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`), + CONSTRAINT `chk_fleet_resource_kind` CHECK ((`resource_kind` in (_utf8mb4'VEHICLE',_utf8mb4'DRIVER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_no` varchar(32) NOT NULL COMMENT '车次业务编号', + `idempotency_key` varchar(128) NOT NULL COMMENT '创建请求幂等键', + `request_hash` varchar(64) NOT NULL COMMENT '创建请求SHA256', + `status` varchar(16) NOT NULL COMMENT '车次状态', + `warehouse_id` bigint NOT NULL COMMENT '起运公共仓库引用', + `warehouse_code` varchar(64) NOT NULL COMMENT '起运仓编码快照', + `warehouse_name` varchar(256) NOT NULL COMMENT '起运仓名称快照', + `vehicle_id` bigint DEFAULT NULL COMMENT '车辆档案引用', + `vehicle_plate` varchar(64) DEFAULT NULL COMMENT '分配时车牌快照', + `driver_id` bigint DEFAULT NULL COMMENT '司机档案引用', + `driver_name` varchar(128) DEFAULT NULL COMMENT '分配时司机姓名', + `driver_phone` varchar(64) DEFAULT NULL COMMENT '分配时司机电话', + `max_pallet_spaces` int DEFAULT NULL COMMENT '分配时托位容量快照', + `max_load_kg` decimal(12,3) DEFAULT NULL COMMENT '分配时载重千克', + `max_volume_cbm` decimal(12,3) DEFAULT NULL COMMENT '分配时可用容积立方米', + `planned_departure_at` datetime(3) NOT NULL COMMENT '计划发车UTC', + `expected_driver_end_at` datetime(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + `departed_at` datetime(3) DEFAULT NULL COMMENT '实际发车UTC', + `completed_at` datetime(3) DEFAULT NULL COMMENT '实际运输完成UTC', + `stops_json` json NOT NULL COMMENT '按顺序保存的路线站点', + `planned_load_json` json DEFAULT NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量', + `load_json` json DEFAULT NULL COMMENT '已确认实物装载及客户货物来源快照', + `loading_review_json` longtext COMMENT '整车理货审核快照,空值为待审核', + `departure_key` varchar(128) DEFAULT NULL COMMENT '整车发车幂等标识', + `departure_hash` char(64) DEFAULT NULL COMMENT '整车发车请求摘要', + `departure_result_json` json DEFAULT NULL COMMENT '整车发车结果及各客户出库结果', + `completion_json` json DEFAULT NULL COMMENT '货物结案来源及幂等结果快照', + `vehicle_release_json` json DEFAULT NULL COMMENT '车辆任务结束地点状态与幂等记录', + `driver_release_json` json DEFAULT NULL COMMENT '司机任务结束地点与幂等记录', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '并发版本', + `created_by` varchar(64) NOT NULL COMMENT '创建人', + `created_at` datetime(3) NOT NULL COMMENT '创建UTC时间', + `updated_by` varchar(64) NOT NULL COMMENT '更新人', + `updated_at` datetime(3) NOT NULL COMMENT '更新UTC时间', + `trip_purpose` varchar(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_trip_no` (`trip_no`), + UNIQUE KEY `uk_fleet_trip_create` (`created_by`,`idempotency_key`), + KEY `idx_fleet_trip_page` (`warehouse_id`,`status`,`updated_at`,`id`), + KEY `fk_fleet_trip_vehicle` (`vehicle_id`), + KEY `fk_fleet_trip_driver` (`driver_id`), + CONSTRAINT `fk_fleet_trip_driver` FOREIGN KEY (`driver_id`) REFERENCES `ll_fleet_driver` (`id`), + CONSTRAINT `fk_fleet_trip_vehicle` FOREIGN KEY (`vehicle_id`) REFERENCES `ll_vehicle` (`id`), + CONSTRAINT `chk_fleet_trip_status` CHECK ((`status` in (_utf8mb4'PLANNED',_utf8mb4'DEPARTING',_utf8mb4'IN_TRANSIT',_utf8mb4'COMPLETED',_utf8mb4'CANCELLED'))), + CONSTRAINT `chk_fleet_trip_time` CHECK ((`expected_driver_end_at` > `planned_departure_at`)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台运输车次'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_cost`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_cost` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_id` bigint NOT NULL COMMENT '车次内部引用', + `cost_no` varchar(32) NOT NULL COMMENT '车次成本业务编号', + `category` varchar(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + `currency` varchar(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + `amount` decimal(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + `incurred_at` datetime(3) NOT NULL COMMENT '实际费用发生UTC时间', + `remark` varchar(1000) DEFAULT NULL COMMENT '费用备注', + `record_version` bigint NOT NULL COMMENT '成本记录版本', + `request_key` varchar(128) NOT NULL COMMENT '创建幂等键', + `request_hash` char(64) NOT NULL COMMENT '创建请求摘要', + `created_by` varchar(64) NOT NULL COMMENT '登记人', + `updated_by` varchar(64) NOT NULL COMMENT '最近更正人', + `created_at` datetime(3) NOT NULL COMMENT '登记UTC时间', + `updated_at` datetime(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_trip_cost_no` (`cost_no`), + UNIQUE KEY `uk_fleet_trip_cost_request` (`trip_id`,`created_by`,`request_key`), + KEY `idx_fleet_trip_cost_page` (`trip_id`,`incurred_at`,`id`), + CONSTRAINT `fk_fleet_trip_cost_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`), + CONSTRAINT `ck_fleet_trip_cost_amount` CHECK ((`amount` >= 0)), + CONSTRAINT `ck_fleet_trip_cost_category` CHECK ((`category` in (_utf8mb4'FUEL',_utf8mb4'DRIVER',_utf8mb4'TOLL',_utf8mb4'PARKING',_utf8mb4'OTHER'))), + CONSTRAINT `ck_fleet_trip_cost_currency` CHECK ((`currency` in (_utf8mb4'CNY',_utf8mb4'USD'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车次直接运营成本,不生成客户账单或付款'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_cost_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_cost_history` ( + `id` bigint NOT NULL COMMENT '内部主键', + `cost_id` bigint NOT NULL COMMENT '成本记录内部引用', + `record_version` bigint NOT NULL COMMENT '成本更正后版本', + `action` varchar(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + `reason` varchar(1000) DEFAULT NULL COMMENT '登记或更正说明', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime(3) NOT NULL COMMENT '操作UTC时间', + `snapshot_json` json NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_trip_cost_history` (`cost_id`,`record_version`), + CONSTRAINT `fk_fleet_trip_cost_history` FOREIGN KEY (`cost_id`) REFERENCES `ll_fleet_trip_cost` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车次成本不可变更正历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_dispatch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_dispatch` ( + `dispatch_id` bigint NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', + `dispatch_no` varchar(64) NOT NULL COMMENT '客户排车业务单号', + `customer_id` varchar(128) NOT NULL COMMENT '排车单自身客户归属', + `trip_id` bigint NOT NULL COMMENT '共享运输车次内部引用', + `trip_no` varchar(32) NOT NULL COMMENT '共享运输车次业务编号', + `package_count` int NOT NULL COMMENT '关联时待发运箱数快照', + `bound_by` varchar(64) NOT NULL COMMENT '关联操作人', + `bound_at` datetime(3) NOT NULL COMMENT '关联UTC时间', + `review_json` longtext COMMENT '客户排车调度审核快照,空值为待审核', + PRIMARY KEY (`dispatch_id`), + UNIQUE KEY `uk_fleet_trip_dispatch_no` (`dispatch_no`), + UNIQUE KEY `uk_fleet_trip_dispatch_trip` (`trip_id`), + KEY `idx_fleet_trip_dispatch_trip` (`trip_id`,`dispatch_id`), + CONSTRAINT `fk_fleet_trip_dispatch_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_handover`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_handover` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_id` bigint NOT NULL COMMENT '所属车次', + `trip_version` bigint NOT NULL COMMENT '交接对应车次历史版本', + `trip_no` varchar(32) NOT NULL COMMENT '车次业务编号', + `handover_no` varchar(32) NOT NULL COMMENT '交接业务编号', + `request_key` varchar(128) NOT NULL COMMENT '交接请求幂等键', + `request_hash` char(64) NOT NULL COMMENT '交接请求摘要', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '资源结束并发版本', + `handed_over_at` datetime(3) NOT NULL COMMENT '实际交接UTC时间', + `snapshot_json` json NOT NULL COMMENT '不可变资源及剩余货物交接快照', + `vehicle_release_json` json DEFAULT NULL COMMENT '原车辆任务结束及幂等结果', + `driver_release_json` json DEFAULT NULL COMMENT '原司机任务结束及幂等结果', + `created_by` varchar(64) NOT NULL COMMENT '记录操作人', + `created_at` datetime(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_handover_no` (`handover_no`), + UNIQUE KEY `uk_fleet_handover_request` (`trip_id`,`request_key`), + UNIQUE KEY `uk_fleet_handover_version` (`trip_id`,`trip_version`), + KEY `idx_fleet_handover_page` (`trip_id`,`id`), + CONSTRAINT `fk_fleet_handover_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='在途车辆司机交接'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_history` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_id` bigint NOT NULL COMMENT '车次内部引用', + `record_version` bigint NOT NULL COMMENT '操作后版本', + `action` varchar(32) NOT NULL COMMENT '车次操作', + `reason` varchar(1000) DEFAULT NULL COMMENT '操作原因', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime(3) NOT NULL COMMENT '操作UTC时间', + `snapshot_json` json NOT NULL COMMENT '操作后不可变车次快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_trip_history_version` (`trip_id`,`record_version`), + CONSTRAINT `fk_fleet_trip_history` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运输车次操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_operations_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_operations_record` ( + `id` bigint NOT NULL COMMENT '出车实测历史主键', + `trip_no` varchar(64) NOT NULL COMMENT '车次业务编号', + `record_version` bigint NOT NULL COMMENT '实测记录版本从一递增', + `details_json` json NOT NULL COMMENT '实际里程英里油耗升与备注快照', + `reason` varchar(1000) NOT NULL COMMENT '实测更正说明', + `operator_id` varchar(64) NOT NULL COMMENT '实测操作人标识', + `request_key` varchar(128) NOT NULL COMMENT '实测保存幂等键', + `request_hash` char(64) NOT NULL COMMENT '实测请求内容摘要', + `occurred_at` datetime(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_operations_version` (`trip_no`,`record_version`), + UNIQUE KEY `uk_fleet_operations_request` (`trip_no`,`operator_id`,`request_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车次里程油耗不可变更正记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_vehicle_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_vehicle_record` ( + `id` bigint NOT NULL COMMENT '记录内部主键', + `record_no` varchar(32) NOT NULL COMMENT '车辆运维业务编号', + `vehicle_id` bigint NOT NULL COMMENT '所属车辆主键', + `vehicle_plate` varchar(64) NOT NULL COMMENT '建档时车牌快照', + `record_type` varchar(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常', + `occurred_at` datetime(3) NOT NULL COMMENT '记录发生时间(UTC)', + `details_json` json NOT NULL COMMENT '带明确单位的业务字段快照', + `files_json` json NOT NULL COMMENT '所属记录照片与上传幂等摘要', + `idempotency_key` varchar(128) NOT NULL COMMENT '创建请求幂等键', + `request_hash` char(64) NOT NULL COMMENT '原创建业务内容摘要', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(64) NOT NULL COMMENT '创建操作人', + `updated_by` varchar(64) NOT NULL COMMENT '最后操作人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间(UTC)', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间(UTC)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vehicle_record_no` (`record_no`), + UNIQUE KEY `uk_vehicle_record_creation` (`vehicle_id`,`created_by`,`idempotency_key`), + KEY `idx_vehicle_record_page` (`vehicle_id`,`record_type`,`occurred_at`,`id`), + CONSTRAINT `fk_vehicle_record_vehicle` FOREIGN KEY (`vehicle_id`) REFERENCES `ll_vehicle` (`id`), + CONSTRAINT `ck_vehicle_record_type` CHECK ((`record_type` in (_utf8mb4'MAINTENANCE',_utf8mb4'FUEL',_utf8mb4'REPAIR',_utf8mb4'BREAKDOWN',_utf8mb4'RESCUE',_utf8mb4'EXCEPTION'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车辆运维记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_vehicle_record_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_vehicle_record_history` ( + `id` bigint NOT NULL COMMENT '历史内部主键', + `record_id` bigint NOT NULL COMMENT '运维记录主键', + `record_version` bigint NOT NULL COMMENT '对应业务版本', + `action` varchar(32) NOT NULL COMMENT '操作类型', + `reason` varchar(1000) DEFAULT NULL COMMENT '更正原因或附件名称', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime(3) NOT NULL COMMENT '操作时间(UTC)', + `snapshot_json` json NOT NULL COMMENT '当时完整业务快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vehicle_record_revision` (`record_id`,`record_version`), + CONSTRAINT `fk_vehicle_record_history` FOREIGN KEY (`record_id`) REFERENCES `ll_fleet_vehicle_record` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车辆运维记录不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_fba_appointment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_fba_appointment` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `appointment_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '预约单号', + `appointment_at` datetime(3) NOT NULL COMMENT '预约时间', + `appointment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `appointment_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `selected_count` int NOT NULL DEFAULT '0', + `replacement_nos_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `expected_loading_at` datetime(3) NOT NULL, + `appointment_end_at` datetime(3) DEFAULT NULL COMMENT '预约结束时间,未明确时为空', + `version` int NOT NULL DEFAULT '0' COMMENT '并发版本', + `confirmation_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '收货方正式外部确认号', + `planned_date` date DEFAULT NULL COMMENT '计划当地日期,可只有日期而无时段', + `planned_at` datetime(3) DEFAULT NULL COMMENT '计划开始当地时间,待定可空', + `planned_end_at` datetime(3) DEFAULT NULL COMMENT '计划结束当地时间', + `appointment_start_utc` datetime(3) DEFAULT NULL COMMENT '确认开始UTC时间点', + `appointment_end_utc` datetime(3) DEFAULT NULL COMMENT '确认结束UTC时间点', + `timezone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓权威IANA时区,未维护不得确认', + `origin_warehouse_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际起运仓', + `replacement_keys_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '替代批次不可变键列表,只用于内部业务关联', + `repartition_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '分批或合并原因,原安排及来源明细保留', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_fba_appointment_no` (`customer_id`,`appointment_no`), + KEY `idx_ll_fba_appointment_list` (`customer_id`,`appointment_status`,`fba_warehouse_code`,`created_at`), + KEY `idx_ll_fba_appointment_planned` (`planned_at`,`appointment_status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Batch FBA appointment'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_fba_appointment_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_fba_appointment_item` ( + `id` bigint NOT NULL COMMENT '记录主键', + `appointment_id` bigint NOT NULL COMMENT '预约主记录标识', + `source_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定ORDER或BOOKING货源键', + `source_version` varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '保存时核实的来源版本', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本票所属客户', + `order_id` bigint DEFAULT NULL COMMENT '尚未形成尾程订单时为空', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实入仓码,不伪造', + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的FBA仓编码', + `package_count` int DEFAULT NULL COMMENT '所选整票预报箱数,未知保持空', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '取消或失败后释放货源占用', + `active_source_key` varchar(160) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS ((case when (`active` = 1) then `source_key` else NULL end)) STORED COMMENT '有效货源唯一占用键', + `active_order_id` bigint GENERATED ALWAYS AS ((case when (`active` = 1) then `order_id` else NULL end)) STORED COMMENT '有效订单唯一占用键', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', + `cargo_verification_version` int DEFAULT NULL COMMENT '采用的预约货物核实版本,未核实时为空', + `shipment_references_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '本次采用的货件或追踪号核实快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_fba_item_source` (`appointment_id`,`source_key`), + UNIQUE KEY `uk_ll_fba_active_source` (`active_source_key`), + UNIQUE KEY `uk_ll_fba_active_order` (`active_order_id`), + KEY `idx_ll_fba_item_source_lookup` (`source_key`,`customer_id`), + KEY `idx_ll_fba_item_customer_order` (`customer_id`,`order_id`), + KEY `idx_ll_fba_active_source` (`customer_id`,`source_key`,`active`), + KEY `idx_ll_fba_active_order` (`customer_id`,`order_id`,`active`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_order` ( + `id` bigint NOT NULL COMMENT '入仓单主键', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` bigint NOT NULL COMMENT '租户 ID', + `source_region` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + `source_inbound_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓分片 ID', + `root_inbound_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根入仓单 ID', + `source_order_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单 ID', + `source_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单号', + `root_order_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根订单 ID', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓码', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓 ID', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + `package_count` int NOT NULL DEFAULT '0' COMMENT '箱数', + `product_quantity` int DEFAULT NULL COMMENT '数量', + `pallet_count` int DEFAULT NULL COMMENT '托数', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '毛重(千克)', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '净重(千克)', + `volume_cbm` decimal(18,6) DEFAULT NULL COMMENT '体积(立方米)', + `source_event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源事件 ID', + `source_loading_plan_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '装柜计划 ID', + `source_loading_plan_version` bigint NOT NULL COMMENT '装柜计划版本', + `handover_version` bigint NOT NULL COMMENT '交接版本', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '负载摘要', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单状态', + `exception_flag` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NONE' COMMENT '异常标记', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `lx_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发领星入库单代码', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_source` (`customer_id`,`source_region`,`source_inbound_id`), + UNIQUE KEY `uk_ll_order_event` (`source_event_id`,`source_inbound_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入仓单快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_order_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_order_item` ( + `id` bigint NOT NULL COMMENT '入仓明细主键', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` bigint NOT NULL COMMENT '租户ID', + `order_id` bigint NOT NULL COMMENT '入仓单ID', + `source_inbound_detail_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓明细ID', + `package_count` int NOT NULL COMMENT '箱数', + `product_quantity` int DEFAULT NULL COMMENT '数量', + `pallet_count` int DEFAULT NULL COMMENT '托数', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '毛重(千克)', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '净重(千克)', + `dimensional_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '体积重量(千克)', + `volume_cbm` decimal(18,6) DEFAULT NULL COMMENT '体积(立方米)', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + `fba_tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA跟踪号', + `shipment_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号', + `shipping_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '唛头', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码', + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `product_name_en` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_item_order` (`customer_id`,`order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入仓商品明细快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_pallet_merge`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_pallet_merge` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint NOT NULL, + `merge_group_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_pallet_id` bigint NOT NULL, + `target_pallet_id` bigint NOT NULL, + `source_allocation_id` bigint NOT NULL, + `target_allocation_id` bigint NOT NULL, + `source_line_id` bigint NOT NULL, + `target_line_id` bigint NOT NULL, + `source_order_id` bigint NOT NULL, + `target_order_id` bigint NOT NULL, + `moved_package_count` int NOT NULL, + `moved_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `source_pallet_scrapped` tinyint NOT NULL DEFAULT '0', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_ll_inbound_merge_task` (`customer_id`,`task_id`), + KEY `idx_ll_inbound_merge_group` (`merge_group_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_pda_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_pda_operation` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint NOT NULL, + `allocation_id` bigint DEFAULT NULL, + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_pda_operation_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_inbound_pda_operation_task` (`task_id`), + KEY `idx_ll_inbound_pda_operation_allocation` (`allocation_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_pda_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_pda_task` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `plan_id` bigint NOT NULL, + `task_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pushed_at` datetime(3) DEFAULT NULL, + `started_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `unload_photo_file_ids` json DEFAULT NULL COMMENT '卸柜照片文件ID列表', + `unload_photo_uploaded_at` datetime(3) DEFAULT NULL COMMENT '卸柜照片上传成功时间', + `estimated_empty_return_at` datetime(3) DEFAULT NULL COMMENT '预计报空时间(卸柜照片上传成功后两天)', + `version` int NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_pda_task_plan` (`customer_id`,`plan_id`), + UNIQUE KEY `uk_ll_inbound_pda_task_no` (`task_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan` ( + `id` bigint NOT NULL COMMENT '入库计划ID(雪花ID)', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `plan_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入库单号', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + `source_order_id` bigint NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + `overseas_warehouse_id` bigint DEFAULT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结', + `planned_arrival_date` date DEFAULT NULL COMMENT '人工计划预计到仓日期', + `pallet_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '人工计划托盘类型', + `assigned_flag` tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否已分配:0否1是', + `merge_required` tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否要求合托:0否1是', + `urgent_flag` tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否加急:0否1是', + `creation_mode` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运', + `plan_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓计划备注', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态:PENDING_ASSIGNMENT待分配及后续正式状态', + `line_count` int NOT NULL DEFAULT '0' COMMENT '入仓码明细数量', + `pallet_count` int NOT NULL DEFAULT '0' COMMENT '系统自动分配托盘数量', + `total_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '集装箱总体积(立方米)', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `version` int NOT NULL DEFAULT '0' COMMENT '入库计划乐观锁版本', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_plan_no` (`plan_no`), + KEY `idx_ll_inbound_plan_container` (`customer_id`,`container_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan_carton` ( + `id` bigint NOT NULL COMMENT '箱唛作业主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `order_id` bigint DEFAULT NULL COMMENT '海外首扫确权后的尾程订单ID', + `source_inbound_detail_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入库明细ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码快照', + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内冻结箱唛', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + `plan_id` bigint DEFAULT NULL COMMENT '海外入库计划ID', + `line_id` bigint DEFAULT NULL COMMENT '入库计划明细ID', + `plan_pallet_id` bigint DEFAULT NULL COMMENT '海外计划托盘ID', + `warehouse_pallet_id` bigint DEFAULT NULL COMMENT '海外实物托盘ID', + `warehouse_location_id` bigint DEFAULT NULL COMMENT '海外库位ID', + `pda_task_id` bigint DEFAULT NULL COMMENT '首次扫码PDA任务ID', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `bound_at` datetime(3) DEFAULT NULL COMMENT '绑定托盘时间', + `received_at` datetime(3) DEFAULT NULL COMMENT '完成入库时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_carton_mark` (`customer_id`,`carton_mark`), + KEY `idx_ll_inbound_carton_plan` (`plan_id`,`status`), + KEY `idx_ll_inbound_carton_pallet` (`plan_pallet_id`,`status`), + KEY `idx_ll_inbound_carton_order` (`order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜次冻结箱唛及海外入库箱级作业账'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan_line`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan_line` ( + `id` bigint NOT NULL COMMENT '入库计划明细ID(雪花ID)', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `plan_id` bigint NOT NULL COMMENT '入库计划ID', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '头程入仓码快照', + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + `volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '入仓码体积(立方米)', + `planned_pallet_count` int NOT NULL DEFAULT '0' COMMENT '该明细分配的托盘数量', + `required_package_count` int NOT NULL DEFAULT '0', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + `postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_line_order` (`plan_id`,`order_id`), + KEY `idx_ll_inbound_line_plan` (`plan_id`), + KEY `idx_ll_inbound_line_tenant_plan` (`customer_id`,`plan_id`), + KEY `idx_ll_inbound_line_customer_plan` (`customer_id`,`plan_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan_pallet` ( + `id` bigint NOT NULL COMMENT '计划托盘ID(雪花ID)', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `plan_id` bigint NOT NULL COMMENT '入库计划ID', + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓资源服务返回的真实托盘码', + `source_pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发不可变来源托盘号快照', + `pallet_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '人工计划托盘类型', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + `capacity_cbm` decimal(18,4) NOT NULL DEFAULT '2.0000' COMMENT '托盘容量,固定为2立方米', + `allocated_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '已分配体积(立方米)', + `sequence_no` int NOT NULL COMMENT '计划内托盘序号', + `warehouse_pallet_id` bigint DEFAULT NULL, + `warehouse_location_id` bigint DEFAULT NULL, + `warehouse_resource_lock_id` bigint DEFAULT NULL, + `assignment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNASSIGNED', + `locked_at` datetime(3) DEFAULT NULL, + `actual_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `actual_package_count` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '0' COMMENT '收货累计值乐观锁版本', + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + `postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_pallet_sequence` (`plan_id`,`sequence_no`), + UNIQUE KEY `uk_ll_inbound_pallet_no` (`customer_id`,`pallet_no`), + KEY `idx_ll_inbound_pallet_plan` (`plan_id`), + KEY `idx_ll_inbound_pallet_tenant_plan` (`customer_id`,`plan_id`), + KEY `idx_ll_inbound_pallet_customer_plan` (`customer_id`,`plan_id`), + KEY `idx_ll_inbound_pallet_no` (`pallet_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan_pallet_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan_pallet_allocation` ( + `id` bigint NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `plan_id` bigint NOT NULL COMMENT '入库计划ID', + `line_id` bigint NOT NULL COMMENT '入库计划明细ID', + `pallet_id` bigint NOT NULL COMMENT '计划托盘ID', + `allocated_volume_cbm` decimal(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)', + `planned_package_count` int NOT NULL DEFAULT '0', + `counted_package_count` int NOT NULL DEFAULT '0', + `picked_package_count` int NOT NULL DEFAULT '0', + `actual_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_COUNT', + `version` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_allocation` (`line_id`,`pallet_id`), + KEY `idx_ll_inbound_allocation_plan` (`plan_id`), + KEY `idx_ll_inbound_allocation_pallet` (`pallet_id`), + KEY `idx_ll_inbound_allocation_tenant_plan` (`customer_id`,`plan_id`), + KEY `idx_ll_inbound_allocation_customer_plan` (`customer_id`,`plan_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_stock_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_stock_receipt` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `plan_id` bigint NOT NULL, + `task_id` bigint NOT NULL, + `allocation_id` bigint NOT NULL, + `line_id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + `product_name_en` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名快照', + `sku_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_detail_ids` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `warehouse_pallet_id` bigint NOT NULL, + `pallet_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发不可变来源托盘号快照', + `warehouse_location_id` bigint DEFAULT NULL, + `warehouse_location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `batch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `received_packages` bigint NOT NULL, + `received_quantity` bigint DEFAULT '0', + `inventory_balance_id` bigint NOT NULL, + `inventory_position_id` bigint NOT NULL, + `inventory_operation_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `received_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_stock_receipt_task_allocation_sku` (`customer_id`,`task_id`,`allocation_id`,`sku_code`), + KEY `idx_ll_stock_receipt_plan` (`customer_id`,`plan_id`,`line_id`), + KEY `idx_ll_stock_receipt_inventory` (`inventory_balance_id`,`inventory_position_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable SKU-pallet PDA stock-in receipt snapshot'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inventory_balance`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inventory_balance` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Customer tenant', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Customer name snapshot', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Actual overseas warehouse ID', + `overseas_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Actual overseas warehouse code snapshot', + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Actual overseas warehouse name snapshot', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Customer destination warehouse ID snapshot', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT 'Customer destination warehouse code snapshot', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Customer destination warehouse name snapshot', + `inbound_order_id` bigint NOT NULL, + `inbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sku_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Chinese product name', + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + `product_name_en` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名快照', + `available_packages` bigint NOT NULL DEFAULT '0', + `hold_packages` bigint NOT NULL DEFAULT '0', + `allocated_packages` bigint NOT NULL DEFAULT '0', + `picked_packages` bigint NOT NULL DEFAULT '0', + `in_transit_packages` bigint NOT NULL DEFAULT '0', + `receiving_packages` bigint NOT NULL DEFAULT '0', + `procurement_packages` bigint NOT NULL DEFAULT '0', + `available_quantity` bigint NOT NULL DEFAULT '0', + `hold_quantity` bigint NOT NULL DEFAULT '0', + `allocated_quantity` bigint NOT NULL DEFAULT '0', + `picked_quantity` bigint NOT NULL DEFAULT '0', + `in_transit_quantity` bigint NOT NULL DEFAULT '0', + `receiving_quantity` bigint NOT NULL DEFAULT '0', + `procurement_quantity` bigint NOT NULL DEFAULT '0', + `version` bigint NOT NULL DEFAULT '0' COMMENT 'Optimistic lock version', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inventory_dimension` (`customer_id`,`overseas_warehouse_id`,`destination_warehouse_code`,`inbound_order_id`,`inbound_code`), + KEY `idx_ll_inventory_list` (`customer_id`,`overseas_warehouse_id`,`updated_at`), + KEY `idx_ll_inventory_inbound` (`customer_id`,`inbound_code`), + KEY `idx_ll_inventory_sku` (`customer_id`,`sku_code`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户入仓单与海外目的仓维度实时库存余额'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inventory_ledger`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inventory_ledger` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '瀹㈡埛鍚嶇О涓嶅彲鍙樺揩鐓�', + `balance_id` bigint NOT NULL, + `position_id` bigint DEFAULT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `inbound_order_id` bigint NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `carton_packages` bigint DEFAULT NULL, + `pallet_id` bigint DEFAULT NULL, + `pallet_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `batch_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `warehouse_location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `business_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `business_order_id` bigint DEFAULT NULL, + `business_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `outbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_event_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `status_from` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status_to` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `before_packages` bigint NOT NULL, + `delta_packages` bigint NOT NULL, + `after_packages` bigint NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `occurred_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inventory_ledger_idempotency` (`customer_id`,`idempotency_key`), + KEY `idx_ll_inventory_ledger_query` (`customer_id`,`overseas_warehouse_id`,`occurred_at`), + KEY `idx_ll_inventory_ledger_business` (`customer_id`,`business_type`,`business_order_no`), + KEY `idx_ll_inventory_ledger_inbound` (`customer_id`,`inbound_code`,`occurred_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable inventory package and quantity ledger'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inventory_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inventory_operation` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PROCESSING', + `result_json` longtext COLLATE utf8mb4_unicode_ci, + `last_error` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inventory_operation_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_inventory_operation_status` (`customer_id`,`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Inventory command idempotency record'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inventory_position`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inventory_position` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `balance_id` bigint NOT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pallet_id` bigint NOT NULL, + `pallet_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `batch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `warehouse_location_id` bigint DEFAULT NULL, + `warehouse_location_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Informational default location code', + `available_packages` bigint NOT NULL DEFAULT '0', + `hold_packages` bigint NOT NULL DEFAULT '0', + `allocated_packages` bigint NOT NULL DEFAULT '0', + `picked_packages` bigint NOT NULL DEFAULT '0', + `receiving_packages` bigint NOT NULL DEFAULT '0', + `product_quantity` bigint NOT NULL DEFAULT '0', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inventory_position` (`balance_id`,`pallet_id`,`batch_no`), + KEY `idx_ll_inventory_position_pallet` (`customer_id`,`pallet_id`), + KEY `idx_ll_inventory_position_location` (`customer_id`,`overseas_warehouse_id`,`warehouse_location_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SKU package stock by pallet, default location and batch'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_loading_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_loading_event_inbox` ( + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局唯一标识', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + `event_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + `event_version` int NOT NULL COMMENT '事件契约版本', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + `payload_json` json NOT NULL COMMENT '事件冻结负载 JSON', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + `received_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间', + `last_error` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次处理错误', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`event_id`), + UNIQUE KEY `uk_ll_loading_inbox_tenant_key` (`customer_id`,`event_type`,`event_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程装柜完成事件接收幂等箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_loading_order_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_loading_order_status_history` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单 ID', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '触发事件 ID', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '状态变更备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + KEY `idx_ll_loading_history_order` (`order_id`,`created_at`), + KEY `idx_ll_loading_history_tenant_order` (`customer_id`,`order_id`), + KEY `idx_ll_loading_history_customer_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB AUTO_INCREMENT=2104482219476070737 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单状态变更历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_local_looseboard_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_local_looseboard_history` ( + `id` bigint NOT NULL COMMENT '主键ID', + `order_id` bigint NOT NULL COMMENT '本土散板订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + `change_detail` json DEFAULT NULL COMMENT '字段变更前后值JSON', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_local_looseboard_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板操作流水'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_local_looseboard_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_local_looseboard_operation` ( + `id` bigint NOT NULL COMMENT '操作记录ID', + `order_id` bigint NOT NULL COMMENT '本土散板订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作请求内容哈希', + `result_version` bigint NOT NULL COMMENT '操作完成后的订单版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_local_looseboard_operation_key` (`customer_id`,`order_id`,`idempotency_key`), + KEY `idx_ll_local_looseboard_operation_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单幂等操作账'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_local_looseboard_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_local_looseboard_order` ( + `id` bigint NOT NULL COMMENT '主键ID', + `lastleg_order_id` bigint DEFAULT NULL COMMENT '关联尾程主订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `order_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', + `transport_mode` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载', + `pallet_required` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '是否需要打盘:YES或NO', + `transport_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', + `origin` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', + `origin_address` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址', + `destination` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', + `destination_address` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号', + `container_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '柜型', + `package_count` int NOT NULL DEFAULT '0' COMMENT '货物件数', + `total_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)', + `total_volume_cbm` decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', + `budget_amount` decimal(18,2) DEFAULT NULL COMMENT '预算总价', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名', + `contact_info` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式', + `requested_date` date DEFAULT NULL COMMENT '要求服务日期', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求内容哈希', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + `version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '商品名称', + `estimated_pallet_count` int DEFAULT NULL COMMENT '客户申报预计托数,不代表实际收货', + `origin_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地联系人', + `origin_contact_phone` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地联系电话', + `destination_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地联系人', + `destination_contact_phone` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地联系电话', + `pickup_at` datetime(3) DEFAULT NULL COMMENT '取货时间(当地时间,不转换时区)', + `delivery_at` datetime(3) DEFAULT NULL COMMENT '送货时间(当地时间,不转换时区)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_local_looseboard_no` (`customer_id`,`order_no`), + UNIQUE KEY `uk_ll_local_looseboard_key` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_local_looseboard_lastleg` (`lastleg_order_id`), + KEY `idx_ll_local_looseboard_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_local_looseboard_date` (`customer_id`,`requested_date`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_monthly_exchange_rate`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_monthly_exchange_rate` ( + `id` bigint NOT NULL COMMENT '月度汇率ID', + `rate_month` char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份,格式yyyy-MM', + `base_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '基准币种', + `quote_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + `usd_to_cny_average` decimal(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更新人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_exchange_rate_month` (`rate_month`), + KEY `idx_ll_exchange_rate_page` (`status`,`rate_month`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美元兑人民币月度平均汇率'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_monthly_exchange_rate_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_monthly_exchange_rate_history` ( + `id` bigint NOT NULL COMMENT '汇率变更历史ID', + `exchange_rate_id` bigint NOT NULL COMMENT '月度汇率ID', + `before_rate` decimal(18,8) DEFAULT NULL COMMENT '变更前汇率', + `after_rate` decimal(18,8) NOT NULL COMMENT '变更后汇率', + `before_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + `after_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更说明', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_exchange_rate_history` (`exchange_rate_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order` ( + `id` bigint NOT NULL COMMENT '雪花 ID', + `order_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + `order_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + `lx_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发领星入库单代码', + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空', + `source_region` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域/系统', + `source_inbound_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + `root_inbound_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根入仓单 ID', + `source_order_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单 ID', + `source_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单号', + `root_order_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根订单 ID', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货主名称', + `source_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源国内装柜仓 ID 快照', + `source_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源国内装柜仓名称快照', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓ID;新建尾程订单由业务服务预分配', + `overseas_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓业务编码快照', + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓名称快照', + `receiving_warehouse_version` int NOT NULL DEFAULT '0' COMMENT '柜级接柜仓分配乐观锁版本', + `transfer_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓ID', + `transfer_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓编码', + `transfer_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓名称', + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA 仓库编码', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '集装箱号', + `container_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT '待转柜' COMMENT '货柜状态', + `abnormal` tinyint NOT NULL DEFAULT '0' COMMENT '是否异常:0否1是', + `detained` tinyint NOT NULL DEFAULT '0' COMMENT '是否暂扣:0否1是', + `hold_chargeable` tinyint NOT NULL DEFAULT '0' COMMENT '当前暂扣是否计费:0否1是', + `hold_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前暂扣原因;解除后为空', + `manual_node_override` tinyint NOT NULL DEFAULT '0' COMMENT '是否启用单票人工计划节点覆盖:0否1是', + `version` int NOT NULL DEFAULT '0' COMMENT '尾程订单命令乐观锁版本', + `pallet_count` int NOT NULL DEFAULT '0' COMMENT '托数', + `package_count` int NOT NULL DEFAULT '0' COMMENT '箱数', + `received_package_count` int DEFAULT NULL COMMENT 'PDA实际入库箱数', + `picked_package_count` int NOT NULL DEFAULT '0', + `shipped_package_count` int NOT NULL DEFAULT '0', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', + `product_quantity` int DEFAULT NULL COMMENT '货物数量', + `volume_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '体积重量(千克)', + `estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到港时间', + `actual_arrival_at` datetime(3) DEFAULT NULL COMMENT '实际到港时间', + `estimated_warehouse_at` datetime(3) DEFAULT NULL COMMENT '预计到仓时间', + `arrival_time_version` int NOT NULL DEFAULT '0' COMMENT '柜级预计到仓时间乐观锁版本', + `inbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入库单号', + `outbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库单号', + `delivery_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送单号', + `relabel_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标单号', + `relabel_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + `pallet_merge_flag` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合托标记', + `pallet_merge_batch_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '平台预标记合托分组号', + `current_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + `private_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(仅自己)', + `customer_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(客户)', + `warehouse_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(仓库)', + `source_event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源事件 ID', + `source_loading_plan_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源装柜计划 ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源头程货柜预订ID', + `source_loading_plan_version` bigint DEFAULT NULL COMMENT '来源装柜计划版本', + `source_handover_version` bigint DEFAULT NULL COMMENT '交接版本', + `source_payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源事件负载摘要', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `sub_order_status` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '子单据状态汇总', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_source` (`customer_id`,`source_region`,`source_inbound_id`), + KEY `idx_ll_order_list` (`customer_id`,`current_status`,`created_at`), + KEY `idx_ll_order_group` (`customer_id`,`order_no`), + KEY `idx_ll_order_container` (`customer_id`,`container_no`), + KEY `idx_ll_order_container_progress` (`source_container_booking_id`,`estimated_warehouse_at`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单主表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_command_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_command_record` ( + `id` bigint NOT NULL COMMENT '尾程订单命令幂等记录ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人ID', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化请求SHA-256摘要', + `result_ref` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '结果业务引用', + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '首次成功响应JSON快照', + `result_version` int NOT NULL COMMENT '命令成功后的订单版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_command_key` (`customer_id`,`order_id`,`action`,`operator_id`,`idempotency_key`), + KEY `idx_ll_order_command_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单写命令幂等与结果审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_detail` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单 ID', + `source_inbound_detail_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓明细 ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU 编码', + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `product_name_en` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名', + `package_count` int NOT NULL DEFAULT '0' COMMENT '箱数', + `product_quantity` int DEFAULT NULL COMMENT '数量', + `pallet_count` int DEFAULT NULL COMMENT '托数', + `gross_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '毛重(千克)', + `net_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '净重(千克)', + `dimensional_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '体积重量(千克)', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓 ID', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + `fba_tracking_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA跟踪号', + `shipment_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号', + `shipping_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '唛头', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_detail_source` (`order_id`,`source_inbound_detail_id`), + KEY `idx_ll_detail_order` (`order_id`), + KEY `idx_ll_detail_tenant_order` (`customer_id`,`order_id`), + KEY `idx_ll_detail_customer_order` (`customer_id`,`order_id`,`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2291500000000010433 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单实装商品冻结明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_fee` ( + `id` bigint NOT NULL COMMENT '费用明细主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `fee_item` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目', + `currency` varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '币种代码', + `amount` decimal(18,2) NOT NULL COMMENT '费用金额', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回', + `supplier_description` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商或费用说明', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_ll_order_fee_customer_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单费用明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_hold_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_hold_history` ( + `id` bigint NOT NULL COMMENT '尾程订单暂扣历史ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `held` tinyint NOT NULL COMMENT '操作后是否暂扣:0否1是', + `chargeable` tinyint NOT NULL COMMENT '本次暂扣是否计费:0否1是', + `reason` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '暂扣或解除原因', + `order_version` int NOT NULL COMMENT '操作成功后的订单版本', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + `created_at` datetime(3) NOT NULL COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_hold_key` (`customer_id`,`order_id`,`operator_id`,`idempotency_key`), + KEY `idx_ll_order_hold_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单暂扣与解除不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_manual_node`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_manual_node` ( + `id` bigint NOT NULL COMMENT '尾程订单人工节点ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `node_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离', + `node_at` datetime(3) DEFAULT NULL COMMENT '人工填写节点时间,允许为空', + `source` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', + `order_version` int NOT NULL COMMENT '最近编辑后的订单版本', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最近操作人ID', + `updated_at` datetime(3) NOT NULL COMMENT '最近编辑时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_manual_node` (`customer_id`,`order_id`,`node_code`), + KEY `idx_ll_order_manual_node_order` (`customer_id`,`order_id`,`node_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单当前人工节点覆盖事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_manual_node_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_manual_node_history` ( + `id` bigint NOT NULL COMMENT '尾程订单人工节点历史ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `nodes_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次全量人工节点JSON快照', + `order_version` int NOT NULL COMMENT '操作成功后的订单版本', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + `created_at` datetime(3) NOT NULL COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_manual_node_history_key` (`customer_id`,`order_id`,`operator_id`,`idempotency_key`), + KEY `idx_ll_order_manual_node_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_no_daily_sequence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_no_daily_sequence` ( + `business_date` date NOT NULL COMMENT '业务日期(Asia/Shanghai)', + `last_sequence` int NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY (`business_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_operation_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_operation_log` ( + `id` bigint NOT NULL COMMENT '操作记录主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `operation_node` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作节点', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + `result` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理结果', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_ll_order_operation_customer_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单操作记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_source_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_source_pallet` ( + `id` bigint NOT NULL COMMENT '来源托盘快照ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `source_pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '头程冻结托盘码', + `sequence_no` int NOT NULL COMMENT '订单内稳定顺序', + `source_event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源装柜完成事件ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_source_pallet_no` (`source_pallet_no`), + UNIQUE KEY `uk_ll_source_pallet_sequence` (`customer_id`,`order_id`,`sequence_no`), + KEY `idx_ll_source_pallet_order` (`customer_id`,`order_id`), + KEY `fk_ll_source_pallet_order` (`order_id`), + CONSTRAINT `fk_ll_source_pallet_order` FOREIGN KEY (`order_id`) REFERENCES `ll_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程交接的不可变托盘码快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_status_history` ( + `id` bigint NOT NULL COMMENT '订单状态历史主键', + `customer_id` bigint NOT NULL COMMENT '租户 ID', + `order_id` bigint NOT NULL COMMENT '订单 ID', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + `reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更原因', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单状态历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_outbound_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_outbound_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `outbound_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `dispatch_order_id` bigint DEFAULT NULL COMMENT '派车单ID,一件代发为空', + `dispatch_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派车单号,一件代发为空', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_OUTBOUND', + `outbound_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型', + `lx_codes` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联一件代发领星入库单代码汇总', + `recipient_info` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发收件信息', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', + `package_count` int NOT NULL DEFAULT '0', + `pallet_count` int NOT NULL DEFAULT '0', + `gross_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + `volume_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米),来源未确认时为空', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `fee_coverage_json` json DEFAULT NULL COMMENT '发运原费用及服务覆盖确认快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_outbound_no` (`customer_id`,`outbound_no`), + UNIQUE KEY `uk_ll_outbound_idempotency` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_outbound_dispatch` (`customer_id`,`dispatch_order_id`), + KEY `idx_ll_outbound_list` (`customer_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Outbound document generated from a dispatch'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_outbound_pallet_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_outbound_pallet_item` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `outbound_order_id` bigint NOT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL COMMENT '浠撳簱瀹炵墿鎵樼洏ID', + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鎵樼洏鍙凤紝绠辩淮搴﹀嚭搴撲负绌�', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `package_count` int NOT NULL DEFAULT '0', + `gross_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + `volume_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米),来源未确认时为空', + `picked_package_count` int NOT NULL DEFAULT '0' COMMENT '已拣箱数', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', + `order_refs` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发SKU', + `return_sku_quantity` bigint DEFAULT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', + `carton_marks` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `dispatch_item_id` bigint DEFAULT NULL COMMENT '本次整车排车作业明细ID', + `source_attribution_id` bigint DEFAULT NULL COMMENT '不可变PDA真实拣货归属ID', + `source_order_id` bigint DEFAULT NULL COMMENT '本行真实来源尾程订单ID', + `source_inventory_position_id` bigint DEFAULT NULL COMMENT '本行实际拣货库存仓位ID', + `source_return_receipt_id` bigint DEFAULT NULL COMMENT '本行实际退回再派实收记录ID', + `source_return_carton_id` bigint DEFAULT NULL COMMENT '本行实际退货换标来源箱ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_outbound_pallet` (`customer_id`,`outbound_order_id`,`warehouse_pallet_id`), + UNIQUE KEY `uk_outbound_source_attribution` (`customer_id`,`source_attribution_id`), + KEY `idx_ll_outbound_pallet_list` (`customer_id`,`outbound_order_id`), + KEY `idx_ll_outbound_pallet_destination` (`customer_id`,`destination_warehouse_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Pallet detail snapshot for an outbound document'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_outbound_pda_pick`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_outbound_pda_pick` ( + `id` bigint NOT NULL COMMENT '主键ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint NOT NULL COMMENT 'PDA任务ID', + `outbound_pallet_item_id` bigint NOT NULL COMMENT '出库托盘明细ID', + `inventory_position_id` bigint DEFAULT NULL COMMENT '库存仓位ID,退货箱唛出库为空', + `packages` bigint NOT NULL COMMENT '拣货箱数', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `picked_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_outbound_pda_pick_key` (`customer_id`,`idempotency_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA托盘拣货记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_outbound_pda_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_outbound_pda_task` ( + `id` bigint NOT NULL COMMENT '主键ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `outbound_order_id` bigint NOT NULL COMMENT '出库单ID', + `task_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA任务号', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成', + `completion_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完成命令幂等键', + `pushed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_outbound_pda_task_order` (`customer_id`,`outbound_order_id`), + UNIQUE KEY `uk_ll_outbound_pda_task_no` (`customer_id`,`task_no`), + UNIQUE KEY `uk_ll_outbound_pda_complete_key` (`customer_id`,`completion_idempotency_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_fee` ( + `id` bigint NOT NULL COMMENT '主键ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + `fee_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用业务号', + `fee_item_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项编码', + `fee_item_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称', + `amount` decimal(20,2) NOT NULL COMMENT 'CNY金额', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + `voucher_file_id` bigint NOT NULL COMMENT '凭证文件ID', + `voucher_file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '凭证文件名', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明', + `wallet_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态', + `wallet_error_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', + `wallet_error_message` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明', + `wallet_command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `purpose` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用业务用途;由履约节点确定', + `confirmation_origin` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BUSINESS' COMMENT '核定入口:BUSINESS业务节点、FINANCIAL财务操作', + `fee_version` int DEFAULT NULL COMMENT '同用途费用确认版本', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否当前有效版本', + `determination` varchar(24) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'POSITIVE正额、ZERO零元、NOT_APPLICABLE不适用', + `original_currency` char(3) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '核定原币CNY或USD', + `original_amount` decimal(20,2) DEFAULT NULL COMMENT '核定原币金额', + `exchange_rate` decimal(20,6) DEFAULT NULL COMMENT '已确认汇率快照', + `exchange_rate_month` char(7) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '已确认汇率所属月份', + `business_date` date DEFAULT NULL COMMENT '实际费用业务日期', + `wallet_business_id` bigint DEFAULT NULL COMMENT '资金冻结对应采购费用记录', + `wallet_business_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金冻结业务号;实际国际运费引用原预估', + `wallet_hold_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Finance冻结引用', + `wallet_ledger_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Finance实际处理流水引用', + `settled_command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '成功实扣或退款的资金命令事件', + `source_fee_id` bigint DEFAULT NULL COMMENT '已实扣费用来源ID', + `parent_fee_id` bigint DEFAULT NULL COMMENT '原费用或预估引用', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '确认请求摘要', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_procurement_fee_no` (`fee_no`), + UNIQUE KEY `uk_ll_procurement_fee_version` (`order_id`,`purpose`,`fee_item_code`,`fee_version`), + KEY `idx_ll_procurement_fee_order` (`customer_id`,`order_id`,`created_at`), + CONSTRAINT `chk_ll_procurement_fee_amount` CHECK ((`amount` > 0)), + CONSTRAINT `chk_ll_procurement_fee_currency` CHECK ((`currency` = _utf8mb4'CNY')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_file`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_file` ( + `id` bigint NOT NULL COMMENT '主键ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + `category` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', + `file_id` bigint NOT NULL COMMENT 'File服务文件ID', + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名快照', + `content_type` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '媒体类型', + `size_bytes` bigint NOT NULL DEFAULT '0' COMMENT '文件大小', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上传人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_procurement_file` (`order_id`,`file_id`), + KEY `idx_ll_procurement_file_order` (`customer_id`,`order_id`,`category`,`created_at`), + CONSTRAINT `chk_ll_procurement_file_category` CHECK ((`category` in (_utf8mb4'RECEIPT',_utf8mb4'VOUCHER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_order` ( + `id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `order_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `purchase_category` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_specification` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_url` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `purchase_quantity` bigint NOT NULL, + `budget_unit_price` decimal(18,2) DEFAULT NULL, + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD', + `receiving_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `purchase_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `purchase_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_quantity` bigint NOT NULL DEFAULT '0', + `outbound_details` json DEFAULT NULL COMMENT '出库明细', + `outbound_quantity` bigint NOT NULL DEFAULT '0', + `domestic_carrier` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `domestic_tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_carrier` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `estimated_arrival_at` datetime(3) DEFAULT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `review_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `review_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', + `review_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', + `reviewed_at` datetime(3) DEFAULT NULL, + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_overseas_procurement_order_no` (`customer_id`,`order_no`), + UNIQUE KEY `uk_ll_overseas_procurement_order_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_overseas_procurement_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_overseas_procurement_warehouse` (`customer_id`,`purchase_warehouse_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Overseas procurement order awaiting business approval'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_outbound`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_outbound` ( + `id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `quantity` bigint NOT NULL, + `outbound_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `carrier` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `outbound_at` datetime(3) NOT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_overseas_procurement_outbound_no` (`customer_id`,`order_id`,`outbound_no`), + KEY `idx_ll_overseas_procurement_outbound_order` (`customer_id`,`order_id`,`outbound_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Manual outbound records for overseas procurement inventory'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_status_history` ( + `id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `operation_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作类型:CREATE、REVIEW或UPDATE', + `change_detail` json DEFAULT NULL COMMENT '字段变更前后值JSON', + PRIMARY KEY (`id`), + KEY `idx_ll_overseas_procurement_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable overseas procurement review history'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_quote`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_quote` ( + `id` bigint NOT NULL COMMENT '报价ID', + `name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价名称', + `warehouse_id` bigint NOT NULL COMMENT '国内平台海外合作仓ID', + `warehouse_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合作仓编码快照', + `warehouse_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合作仓名称快照', + `country_code` varchar(2) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国家二字码快照', + `provider_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价方', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '币种', + `effective_start` date NOT NULL COMMENT '生效开始日期', + `effective_end` date NOT NULL COMMENT '生效结束日期', + `version_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务版本号', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更新人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_quote_warehouse_provider_version` (`warehouse_id`,`provider_name`,`version_code`), + KEY `idx_ll_quote_page` (`status`,`updated_at`,`id`), + KEY `idx_ll_quote_effective` (`warehouse_id`,`provider_name`,`currency`,`effective_start`,`effective_end`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价主表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_quote_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_quote_fee` ( + `id` bigint NOT NULL COMMENT '费用明细ID', + `quote_id` bigint NOT NULL COMMENT '报价ID', + `sequence_no` int NOT NULL COMMENT '显示顺序', + `item` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目', + `billing_unit` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '计费单位', + `unit_price` decimal(18,4) NOT NULL COMMENT '单价', + `minimum_charge` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '最低收费', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_quote_fee_sequence` (`quote_id`,`sequence_no`), + KEY `idx_ll_quote_fee_quote` (`quote_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价仓库费用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_quote_transit_price`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_quote_transit_price` ( + `id` bigint NOT NULL COMMENT '中转价格ID', + `quote_id` bigint NOT NULL COMMENT '报价ID', + `sequence_no` int NOT NULL COMMENT '显示顺序', + `fba_warehouse_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'FBA仓编码', + `delivery_method` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价运输方式', + `estimated_miles` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '预估英里数', + `price` decimal(18,4) NOT NULL COMMENT '报价金额', + `minimum_charge` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '最低收费', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_quote_transit_sequence` (`quote_id`,`sequence_no`), + KEY `idx_ll_quote_transit_quote` (`quote_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价中转价格明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_inventory_move`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_inventory_move` ( + `id` bigint NOT NULL COMMENT '移库操作ID', + `move_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库操作单号', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `pallet_id` bigint NOT NULL COMMENT '整托移库的实物托盘ID', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实物托盘码快照', + `source_location_id` bigint NOT NULL COMMENT '来源库位ID', + `source_location_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源库位码快照', + `target_location_id` bigint NOT NULL COMMENT '目标库位ID', + `target_location_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标库位码快照', + `customer_count` int NOT NULL COMMENT '托盘涉及客户数', + `position_count` int NOT NULL COMMENT '迁移库存位置数', + `package_count` bigint NOT NULL COMMENT '迁移总箱数', + `pallet_version_before` int NOT NULL COMMENT '迁移前托盘版本', + `pallet_version_after` int DEFAULT NULL COMMENT '迁移后托盘版本', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + `reason` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库原因', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库幂等键', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库请求哈希', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库处理状态', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '成功响应JSON', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_inventory_move_no` (`move_no`), + UNIQUE KEY `uk_ll_pda_inventory_move_key` (`overseas_warehouse_id`,`operator_key`,`idempotency_key`), + KEY `idx_ll_pda_inventory_move_pallet` (`overseas_warehouse_id`,`pallet_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA同仓整托移库记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_operation_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_operation_history` ( + `id` bigint NOT NULL COMMENT 'PDA作业历史ID', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '作业涉及的客户标识', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + `operation_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业类型', + `business_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业业务键', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业结果状态', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '写操作幂等键', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '写请求内容哈希', + `summary` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '作业结果摘要', + `occurred_at` datetime(3) NOT NULL COMMENT '作业发生时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_history_idempotency` (`operator_key`,`idempotency_key`), + KEY `idx_ll_pda_history_operator` (`overseas_warehouse_id`,`operator_key`,`occurred_at`), + KEY `idx_ll_pda_history_business` (`overseas_warehouse_id`,`operation_type`,`business_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA真实作业历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_pallet_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_pallet_operation` ( + `id` bigint NOT NULL COMMENT 'PDA托盘作业ID', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备幂等键', + `operation_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘作业类型', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容摘要', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '成功响应快照', + `remote_transitions_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '实物托盘状态变更回执', + `last_error` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后失败原因', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_pallet_idempotency` (`operator_key`,`idempotency_key`), + KEY `idx_ll_pda_pallet_warehouse` (`overseas_warehouse_id`,`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_stocktake_action`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_stocktake_action` ( + `id` bigint NOT NULL COMMENT '盘点动作ID', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作幂等键', + `action_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点动作类型', + `task_id` bigint DEFAULT NULL COMMENT '盘点任务ID', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作请求哈希', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作处理状态', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '成功响应JSON', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_stocktake_action` (`overseas_warehouse_id`,`operator_key`,`idempotency_key`), + KEY `idx_ll_pda_stocktake_action_task` (`task_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA盘点动作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_stocktake_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_stocktake_item` ( + `id` bigint NOT NULL COMMENT '盘点明细ID', + `task_id` bigint NOT NULL COMMENT '盘点任务ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户标识', + `balance_id` bigint NOT NULL COMMENT '库存余额ID', + `position_id` bigint NOT NULL COMMENT '库存位置ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU快照', + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '品名快照', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓码快照', + `pallet_id` bigint NOT NULL COMMENT '实物托盘ID', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实物托盘码快照', + `location_id` bigint NOT NULL COMMENT '库位ID快照', + `location_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库位码快照', + `book_packages` bigint NOT NULL COMMENT '任务创建时账面总箱数', + `actual_packages` bigint DEFAULT NULL COMMENT '现场实盘总箱数', + `difference_packages` bigint DEFAULT NULL COMMENT '实盘与账面箱数差异', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点明细状态', + `position_version` bigint NOT NULL COMMENT '任务创建时库存位置版本', + `counted_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '盘点操作账号标识', + `version` bigint NOT NULL DEFAULT '0' COMMENT '明细乐观锁版本', + `counted_at` datetime(3) DEFAULT NULL COMMENT '盘点时间', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_stocktake_item` (`task_id`,`position_id`), + KEY `idx_ll_pda_stocktake_item_task` (`task_id`,`status`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点位置明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_stocktake_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_stocktake_task` ( + `id` bigint NOT NULL COMMENT '盘点任务ID', + `task_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点任务号', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `scope_type` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + `scope_resource_id` bigint NOT NULL COMMENT '盘点库位或托盘资源ID', + `scope_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点库位码或托盘码快照', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点任务状态', + `position_count` int NOT NULL COMMENT '库存位置总数', + `counted_count` int NOT NULL DEFAULT '0' COMMENT '已盘库存位置数', + `difference_count` int NOT NULL DEFAULT '0' COMMENT '存在差异的库存位置数', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建操作账号标识', + `submitted_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提交操作账号标识', + `submit_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '盘点差异提交原因', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求哈希', + `version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + `submitted_at` datetime(3) DEFAULT NULL COMMENT '提交完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_stocktake_no` (`task_no`), + UNIQUE KEY `uk_ll_pda_stocktake_create` (`overseas_warehouse_id`,`created_by`,`idempotency_key`), + KEY `idx_ll_pda_stocktake_list` (`overseas_warehouse_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_budget_authorization`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_budget_authorization` ( + `id` bigint NOT NULL COMMENT '预算授权ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `previous_amount` decimal(20,2) DEFAULT NULL COMMENT '原货款预算总上限;未授权保持空', + `previous_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原预算币种', + `authorized_amount` decimal(20,2) NOT NULL COMMENT '客户授权的新货款总上限', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户授权币种', + `voucher_file_id` bigint NOT NULL COMMENT '客户确认书面或聊天凭证', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '授权依据', + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '核验人员', + `reviewed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核验时间', + PRIMARY KEY (`id`), + KEY `idx_procurement_budget_order` (`customer_id`,`order_id`,`id`), + CONSTRAINT `chk_procurement_budget_amount` CHECK (((`authorized_amount` >= 0) and (`previous_amount` >= 0))), + CONSTRAINT `chk_procurement_budget_currency` CHECK (((`currency` in (_utf8mb4'CNY',_utf8mb4'USD')) and (`previous_currency` in (_utf8mb4'CNY',_utf8mb4'USD')))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购货款预算授权核验历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_carton` ( + `id` bigint NOT NULL COMMENT '版本内箱记录ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `manifest_id` bigint NOT NULL COMMENT '装箱清单版本', + `carton_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购订单内业务箱号', + `length_cm` decimal(12,2) NOT NULL COMMENT '外箱长厘米', + `width_cm` decimal(12,2) NOT NULL COMMENT '外箱宽厘米', + `height_cm` decimal(12,2) NOT NULL COMMENT '外箱高厘米', + `gross_weight_kg` decimal(12,3) NOT NULL COMMENT '封箱毛重千克', + `quantity` bigint NOT NULL COMMENT '本箱合格商品总件数', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_carton_version` (`manifest_id`,`carton_no`), + KEY `idx_procurement_carton_order` (`order_id`,`manifest_id`), + CONSTRAINT `chk_procurement_carton_measurement` CHECK (((`length_cm` > 0) and (`width_cm` > 0) and (`height_cm` > 0) and (`gross_weight_kg` > 0) and (`quantity` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购版本化逐箱实测与件数'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_carton_source`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_carton_source` ( + `carton_id` bigint NOT NULL COMMENT '版本内箱ID', + `receipt_id` bigint NOT NULL COMMENT '海外实收明细来源', + `quantity` bigint NOT NULL COMMENT '从该实收装入本箱的件数', + PRIMARY KEY (`carton_id`,`receipt_id`), + KEY `idx_procurement_carton_receipt` (`receipt_id`), + CONSTRAINT `chk_procurement_carton_source_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购箱内货物的实收来源与数量'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_closure`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_closure` ( + `id` bigint NOT NULL COMMENT '采购异常关闭ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `closure_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '异常关闭业务号', + `customer_voucher_file_id` bigint NOT NULL COMMENT '客户同意停止履约凭证', + `financial_conclusion` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '已知费用及后续无需收费的处理结论,不改变资金事实', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '内部关闭原因', + `client_summary` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '人工确认的对客关闭摘要', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购管理确认人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关闭确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_closure_order` (`order_id`), + UNIQUE KEY `uk_procurement_closure_no` (`closure_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购实物资金收口后的异常关闭事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_closure_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_closure_item` ( + `id` bigint NOT NULL COMMENT '关闭货物处置ID', + `closure_id` bigint NOT NULL COMMENT '采购关闭ID', + `source_kind` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '购买未达、在途未达、合格、破损或多货来源', + `source_id` bigint DEFAULT NULL COMMENT '原实收或国际交寄箱,购买未达为空', + `quantity` bigint NOT NULL COMMENT '本来源最终明确去向件数', + `decision` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '供应商保管、退还、报失、客户授权保管或处置', + `destination` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际去向及接收方说明', + `voucher_file_id` bigint NOT NULL COMMENT '本单实物处置证明', + PRIMARY KEY (`id`), + KEY `idx_procurement_closure_source` (`closure_id`,`source_kind`,`source_id`), + CONSTRAINT `chk_procurement_closure_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常关闭逐来源剩余货物最终去向,不产生新发运'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_command` ( + `id` bigint NOT NULL COMMENT '操作记录ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `action` varchar(48) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购节点动作', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求摘要', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '执行人', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作依据', + `detail_json` json NOT NULL COMMENT '内部操作及更正依据快照,不直接对客展示', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '执行时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_command` (`order_id`,`action`,`idempotency_key`), + KEY `idx_procurement_command_customer` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购节点幂等与不可变审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_control_note`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_control_note` ( + `id` bigint NOT NULL COMMENT '采购更正说明ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `note_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更正说明业务号', + `target_type` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '购买、仓库、实收或交寄事实类型', + `target_id` bigint DEFAULT NULL COMMENT '本单事实对象引用,购买为空', + `blocking` tinyint(1) NOT NULL COMMENT '是否阻断后续计划履约', + `voucher_file_id` bigint NOT NULL COMMENT '本单内部事实更正凭证', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '内部更正说明', + `client_summary` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '人工确认的对客摘要,不复制内部原始内容', + `original_value_json` json DEFAULT NULL COMMENT '服务端记录的原事实快照', + `corrected_value_json` json DEFAULT NULL COMMENT '服务端记录的更正事实快照', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购管理操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + `resolved_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理确认人', + `resolved_at` datetime(3) DEFAULT NULL COMMENT '阻断解除时间', + `resolution_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '内部处理结果', + `resolution_voucher_id` bigint DEFAULT NULL COMMENT '本单处理凭证', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_note_no` (`note_no`), + KEY `idx_procurement_note_block` (`order_id`,`blocking`,`resolved_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购不可覆盖事实的更正说明及阻断异常'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_daily_sequence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_daily_sequence` ( + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + `business_date` date NOT NULL COMMENT '北京时间业务日期', + `sequence_value` int NOT NULL COMMENT '客户当日已分配最大序号', + PRIMARY KEY (`customer_id`,`business_date`), + CONSTRAINT `chk_ll_procurement_sequence` CHECK ((`sequence_value` between 1 and 9999)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_disposition`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_disposition` ( + `id` bigint NOT NULL COMMENT '差异处理ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `leg` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + `source_carton_id` bigint DEFAULT NULL COMMENT '国内原交寄箱来源', + `receipt_id` bigint DEFAULT NULL COMMENT '破损或多货对应实收记录,短少为空', + `kind` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SHORTAGE短少、DAMAGED破损、EXTRA多货', + `quantity` bigint NOT NULL COMMENT '已批准处置件数', + `decision` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACCEPT_SHORTAGE确认短少、DISPOSE_DAMAGED拒收或处置、RETURN_EXTRA退回多货', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '当前有效处置;撤销只改变有效性并保留原事实', + `voucher_file_id` bigint NOT NULL COMMENT '本单客户确认或实际处置凭证', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处置依据', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购管理人员', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY (`id`), + KEY `idx_procurement_disposition_source` (`order_id`,`leg`,`source_carton_id`,`receipt_id`,`kind`), + CONSTRAINT `chk_procurement_disposition_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单内差异处置,不自动退款'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_estimate_review`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_estimate_review` ( + `id` bigint NOT NULL COMMENT '预估核对ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `label_set_id` bigint NOT NULL COMMENT '已核对国际面单版本', + `fee_id` bigint NOT NULL COMMENT '唯一有效预估费用版本', + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '核价人员', + `reviewed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '核对时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_estimate_review` (`label_set_id`,`fee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='面单清单与预估冻结的显式核对关联'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_fee_correction`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_fee_correction` ( + `id` bigint NOT NULL COMMENT '费用链更正ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `correction_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '财务更正业务号', + `root_fee_id` bigint NOT NULL COMMENT '原实际费用链根', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本单更正请求幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更正目标及凭证请求摘要', + `target_original_amount` decimal(20,2) NOT NULL COMMENT '本次正确原币总额', + `original_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '沿用原费用币种', + `exchange_rate` decimal(20,6) NOT NULL COMMENT '沿用原费用汇率快照', + `exchange_rate_month` char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原汇率月份,不取本次操作月份', + `target_amount` decimal(20,2) NOT NULL COMMENT '正确总额折算CNY', + `previous_net_amount` decimal(20,2) NOT NULL COMMENT '确认前整链净实扣CNY', + `difference_amount` decimal(20,2) NOT NULL COMMENT 'CNY正差额追加、负差额退款、零差额仅留核对记录', + `voucher_file_id` bigint NOT NULL COMMENT '本订单财务更正凭证', + `reason` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更正依据', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '授权财务操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_correction_no` (`correction_no`), + UNIQUE KEY `uk_procurement_correction_request` (`order_id`,`idempotency_key`), + KEY `idx_procurement_correction_root` (`order_id`,`root_fee_id`,`id`), + CONSTRAINT `chk_procurement_correction_amount` CHECK (((`target_original_amount` >= 0) and (`target_amount` >= 0) and (`previous_net_amount` >= 0) and (`exchange_rate` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用链更正目标与原汇率快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_fee_correction_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_fee_correction_item` ( + `correction_id` bigint NOT NULL COMMENT '本次费用链更正ID', + `fee_id` bigint NOT NULL COMMENT '本次产生的独立正差额或退款费用ID', + PRIMARY KEY (`correction_id`,`fee_id`), + UNIQUE KEY `uk_procurement_correction_fee` (`fee_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用目标更正与逐笔资金事实关联'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_flow`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_flow` ( + `order_id` bigint NOT NULL COMMENT '采购订单ID,一单一个履约批次', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `purchased_at` datetime(3) DEFAULT NULL COMMENT '真实购买时间', + `purchased_product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际购买商品', + `purchased_specification` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际购买规格', + `purchased_quantity` bigint DEFAULT NULL COMMENT '实际购买件数', + `purchase_amount` decimal(20,2) DEFAULT NULL COMMENT '供应商实付原币金额', + `purchase_currency` char(3) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商实付原币CNY或USD', + `purchase_voucher_id` bigint DEFAULT NULL COMMENT '购买凭证引用', + `purchase_issue` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '购买未核定或异常说明', + `purchase_confirmed_at` datetime(3) DEFAULT NULL COMMENT '货款核定时间,不代表扣费成功', + `actual_warehouse_id` bigint DEFAULT NULL COMMENT '首次实际收货冻结的仓库ID', + `actual_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际收货仓编码快照', + `actual_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实际收货仓名称快照', + `overseas_received_at` datetime(3) DEFAULT NULL COMMENT '海外整批收货完成时间', + `packing_version` int DEFAULT NULL COMMENT '当前确认装箱清单版本', + `overseas_shipped_at` datetime(3) DEFAULT NULL COMMENT '国际段整批实际交寄时间', + `domestic_received_at` datetime(3) DEFAULT NULL COMMENT '国内整批收货完成时间', + `charges_confirmed_at` datetime(3) DEFAULT NULL COMMENT '国内整单费用核定时间', + `customer_shipped_at` datetime(3) DEFAULT NULL COMMENT '国内寄客户实际交寄时间', + `domestic_receiver_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '国际段国内收件人', + `domestic_receiver_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '国际段国内收件电话', + `domestic_receiver_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '国际段国内接收地址', + `cancelled_at` datetime(3) DEFAULT NULL COMMENT '购买前取消时间', + `closed_at` datetime(3) DEFAULT NULL COMMENT '异常关闭时间', + `control_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '取消或关闭原因', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`order_id`), + KEY `idx_procurement_flow_customer` (`customer_id`,`order_id`), + CONSTRAINT `chk_procurement_purchase_amount` CHECK (((`purchase_amount` is null) or (`purchase_amount` >= 0))), + CONSTRAINT `chk_procurement_purchase_quantity` CHECK (((`purchased_quantity` is null) or (`purchased_quantity` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购单一批次履约事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_label_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_label_item` ( + `label_set_id` bigint NOT NULL COMMENT '面单批版本ID', + `carton_id` bigint NOT NULL COMMENT '原海外清单箱ID', + `file_id` bigint NOT NULL COMMENT '本采购订单面单文件', + `page_no` int NOT NULL COMMENT '该箱对应文件页码,从1开始', + `carrier` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '承运商', + `tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱主或子运单号', + `applied_at` datetime(3) DEFAULT NULL COMMENT '真实贴单时间', + `applied_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '贴单确认人', + PRIMARY KEY (`label_set_id`,`carton_id`), + CONSTRAINT `chk_procurement_label_page` CHECK ((`page_no` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购逐箱文件页关联及贴单事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_label_set`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_label_set` ( + `id` bigint NOT NULL COMMENT '面单批版本ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `leg` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OVERSEAS国际段或DOMESTIC寄客户段', + `version` int NOT NULL COMMENT '订单段内面单版本', + `manifest_id` bigint NOT NULL COMMENT '原海外确认装箱版本', + `cargo_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱规及有效货物来源摘要', + `sender_snapshot` json NOT NULL COMMENT '寄件人姓名电话地址快照', + `receiver_snapshot` json NOT NULL COMMENT '收件人姓名电话地址快照', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '制单操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_label_version` (`order_id`,`leg`,`version`), + CONSTRAINT `chk_procurement_label_leg` CHECK ((`leg` in (_utf8mb4'OVERSEAS',_utf8mb4'DOMESTIC'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段面单不可变版本和地址快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_manifest`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_manifest` ( + `id` bigint NOT NULL COMMENT '装箱清单ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `version` int NOT NULL COMMENT '订单内装箱版本', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'DRAFT草稿、CONFIRMED已确认、SUPERSEDED已替代', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '确认时间', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '制单人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '版本保存时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_manifest_version` (`order_id`,`version`), + CONSTRAINT `chk_procurement_manifest_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'CONFIRMED',_utf8mb4'SUPERSEDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购装箱清单不可变版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_receipt` ( + `id` bigint NOT NULL COMMENT '实收明细ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `receipt_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '采购收货业务编号', + `leg` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OVERSEAS海外或DOMESTIC国内', + `source_carton_id` bigint DEFAULT NULL COMMENT '国内实收关联原国际交寄箱,海外为空', + `warehouse_id` bigint DEFAULT NULL COMMENT '海外实际收货仓', + `good_quantity` bigint NOT NULL COMMENT '可发合格件数', + `damaged_quantity` bigint NOT NULL COMMENT '破损待处置件数', + `extra_quantity` bigint NOT NULL COMMENT '多到件数,不增加可发数量', + `received_at` datetime(3) NOT NULL COMMENT '实际收货时间', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '当前有效实收事实', + `supersedes_id` bigint DEFAULT NULL COMMENT '受控更正所替代的原实收记录', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '登记人员', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_receipt_no` (`receipt_no`), + KEY `idx_procurement_receipt_source` (`order_id`,`leg`,`source_carton_id`,`active`), + CONSTRAINT `chk_procurement_receipt_leg` CHECK ((((`leg` = _utf8mb4'OVERSEAS') and (`source_carton_id` is null)) or ((`leg` = _utf8mb4'DOMESTIC') and (`source_carton_id` is not null)))), + CONSTRAINT `chk_procurement_receipt_quantity` CHECK (((`good_quantity` >= 0) and (`damaged_quantity` >= 0) and (`extra_quantity` >= 0) and (((`good_quantity` + `damaged_quantity`) + `extra_quantity`) > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购分次实收与受控更正事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_repack`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_repack` ( + `id` bigint NOT NULL COMMENT '换包装版本ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `carton_id` bigint NOT NULL COMMENT '原国际交寄箱ID,保持一对一来源', + `length_cm` decimal(12,2) NOT NULL COMMENT '新外箱长厘米', + `width_cm` decimal(12,2) NOT NULL COMMENT '新外箱宽厘米', + `height_cm` decimal(12,2) NOT NULL COMMENT '新外箱高厘米', + `gross_weight_kg` decimal(12,3) NOT NULL COMMENT '新封箱毛重千克', + `voucher_file_id` bigint NOT NULL COMMENT '本单换包装证据', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换包装依据', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内作业人员', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '登记时间', + PRIMARY KEY (`id`), + KEY `idx_procurement_repack_carton` (`order_id`,`carton_id`,`id`), + CONSTRAINT `chk_procurement_repack_measurement` CHECK (((`length_cm` > 0) and (`width_cm` > 0) and (`height_cm` > 0) and (`gross_weight_kg` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购国内原箱一对一换包装不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_shipment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_shipment` ( + `id` bigint NOT NULL COMMENT '采购交寄ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `shipment_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '交寄业务编号', + `leg` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'OVERSEAS国际交寄或DOMESTIC寄客户', + `manifest_id` bigint NOT NULL COMMENT '原海外装箱清单版本', + `shipped_at` datetime(3) NOT NULL COMMENT '整批真实交承运方时间', + `address_snapshot` json NOT NULL COMMENT '本段交寄时的收寄姓名电话地址快照', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '交寄确认人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_shipment_leg` (`order_id`,`leg`), + UNIQUE KEY `uk_procurement_shipment_no` (`shipment_no`), + CONSTRAINT `chk_procurement_shipment_leg` CHECK ((`leg` in (_utf8mb4'OVERSEAS',_utf8mb4'DOMESTIC'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购两段各一次整批真实交寄'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_shipment_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_shipment_item` ( + `shipment_id` bigint NOT NULL COMMENT '采购交寄ID', + `carton_id` bigint NOT NULL COMMENT '原海外装箱版本内箱ID', + `quantity` bigint NOT NULL COMMENT '本次实际交寄件数', + `carrier` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '承运商快照', + `tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运单号快照', + `label_snapshot` json NOT NULL COMMENT '当次箱规、面单及货物来源快照', + PRIMARY KEY (`shipment_id`,`carton_id`), + CONSTRAINT `chk_procurement_shipment_item_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购整批交寄逐箱事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_wallet_attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_wallet_attempt` ( + `id` bigint NOT NULL COMMENT '请求尝试ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `fee_id` bigint NOT NULL COMMENT '不可变费用版本ID', + `attempt_no` bigint NOT NULL COMMENT '费用版本内请求序号', + `operation` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金操作:FREEZE冻结、SETTLE结算、SETTLE_ACTUAL实际结算、RELEASE释放、REFUND退款', + `business_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Finance资金业务号', + `amount` decimal(20,2) DEFAULT NULL COMMENT '请求金额;全额结算或释放可空', + `command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金指令事件唯一引用', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金请求结果:PENDING未决、SUCCEEDED成功、FAILED明确失败', + `result_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原业务回执摘要', + `wallet_hold_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Finance冻结引用', + `wallet_ledger_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Finance处理流水引用', + `error_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '业务失败码', + `error_message` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '内部失败诊断,不直接对客展示', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '请求时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '最终结果时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_procurement_attempt_event` (`command_event_id`), + UNIQUE KEY `uk_procurement_attempt_number` (`fee_id`,`attempt_no`), + KEY `idx_procurement_attempt_order` (`customer_id`,`order_id`,`status`), + CONSTRAINT `chk_procurement_attempt_status` CHECK ((`status` in (_utf8mb4'PENDING',_utf8mb4'SUCCEEDED',_utf8mb4'FAILED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='采购费用资金命令尝试与不可变结果'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_container_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_container_operation` ( + `id` bigint NOT NULL COMMENT '柜号收货操作ID', + `session_id` bigint NOT NULL COMMENT '柜号签收会话ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号唯一标识', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作动作', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + `request_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容快照', + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '响应内容快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_operation` (`operator_key`,`idempotency_key`), + KEY `idx_ll_receiving_operation_session` (`session_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号收货幂等审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_container_session`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_container_session` ( + `id` bigint NOT NULL COMMENT '柜号签收会话ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所选柜解析出的客户标识', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + `overseas_warehouse_id` bigint NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号唯一标识', + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作账号名称', + `active_operator_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + `active_container_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '未完成会话柜号全局唯一锁键', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + `sign_photos` json DEFAULT NULL COMMENT '签收卸柜照片角色与文件ID列表', + `prepared_at` datetime(3) DEFAULT NULL COMMENT '选择柜号时间', + `signed_at` datetime(3) DEFAULT NULL COMMENT '签收时间', + `receiving_at` datetime(3) DEFAULT NULL COMMENT '首次收货时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `source_container_booking_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '稳定来源柜预订ID', + `container_fact_hash` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '领取时整柜订单与计划事实摘要', + `overseas_warehouse_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓业务编码快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_active_operator` (`active_operator_key`), + UNIQUE KEY `uk_ll_receiving_active_container` (`active_container_key`), + KEY `idx_ll_receiving_container` (`customer_id`,`container_no`,`status`), + KEY `idx_ll_receiving_operator` (`customer_id`,`operator_key`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号签收收货会话'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_fba_pallet_count`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_fba_pallet_count` ( + `id` bigint NOT NULL COMMENT 'FBA托盘现场累计箱数ID', + `session_id` bigint NOT NULL COMMENT '柜号签收会话ID', + `group_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '柜号与FBA仓组成的收货组键', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '现有FBA托盘码', + `actual_packages` int NOT NULL COMMENT '该托现场累计实物箱数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_fba_count` (`session_id`,`group_key`,`pallet_code`), + KEY `idx_ll_receiving_fba_group` (`session_id`,`group_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA普通FBA托盘现场累计与超收标记'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_sku_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_sku_receipt` ( + `id` bigint NOT NULL COMMENT 'FBA SKU实收进度ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `session_id` bigint NOT NULL COMMENT '柜号签收会话ID', + `plan_id` bigint NOT NULL COMMENT '入仓计划ID', + `line_id` bigint NOT NULL COMMENT '入仓计划明细ID', + `pallet_id` bigint NOT NULL COMMENT '计划托盘ID', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓码快照', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码快照', + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称快照', + `received_packages` int NOT NULL COMMENT 'SKU实收箱数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_sku_pallet` (`customer_id`,`line_id`,`pallet_id`,`sku_code`), + KEY `idx_ll_receiving_sku_session` (`customer_id`,`session_id`,`line_id`), + KEY `idx_ll_receiving_sku_plan` (`customer_id`,`plan_id`,`line_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号FBA SKU实收进度'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_warehouse_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_warehouse_operation` ( + `id` bigint NOT NULL COMMENT '柜级接柜仓修改操作ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台请求幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化修改请求SHA-256摘要', + `result_version` int NOT NULL COMMENT '修改成功后的柜级分配版本', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人名称', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '接柜仓修改备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_warehouse_idempotency` (`idempotency_key`), + KEY `idx_ll_receiving_warehouse_container` (`source_container_booking_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级海外接柜仓修改幂等审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_application`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_application` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `lastleg_order_id` bigint NOT NULL, + `customer_order_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `label_deadline` datetime(3) NOT NULL, + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `delivery_method` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填', + `print_shipping_label` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要打印面单', + `shipping_label_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件ID', + `shipping_label_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件名', + `shipping_label_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单下载地址', + `fulfillment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态', + `outbound_order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '自提或快递生成的换标出库单号', + `customer_confirmation_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户确认完成幂等键', + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认换标完成时间', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `total_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF文件ID', + `total_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF文件名', + `total_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF预览地址', + `carton_total_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件ID', + `carton_total_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件名', + `carton_total_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件地址', + `sku_total_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件ID', + `sku_total_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件名', + `sku_total_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件地址', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_PROCESSING', + `item_count` int NOT NULL DEFAULT '0', + `total_package_count` int NOT NULL DEFAULT '0', + `notified_package_count` int NOT NULL DEFAULT '0', + `charge_amount` decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '换标费用CNY', + `charge_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + `wallet_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'FREEZE_PENDING' COMMENT '资金状态', + `wallet_error_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', + `wallet_error_message` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明', + `wallet_command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_relabel_application_no` (`customer_id`,`relabel_no`), + UNIQUE KEY `uk_ll_relabel_application_idempotency` (`customer_id`,`idempotency_key`), + KEY `idx_ll_relabel_application_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_relabel_application_customer` (`customer_id`,`customer_order_no`), + KEY `idx_ll_relabel_application_warehouse` (`customer_id`,`overseas_warehouse_id`), + KEY `idx_ll_relabel_application_order` (`lastleg_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Customer relabel application header'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_item` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `application_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码(选填)', + `product_image_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_image_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_image_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `total_package_count` int NOT NULL, + `notified_package_count` int NOT NULL, + `shipment_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号(无FBA去向时为空)', + `tracking_code` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `new_sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后SKU编码', + `label_requirement` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货物明细备注', + `relabel_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `relabel_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `relabel_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '瀹㈡埛鑷湁绠卞敍', + `return_carton_id` bigint DEFAULT NULL COMMENT '閫�璐ф崲绠卞敍鏉ユ簮閫�璐у簱瀛樼ID', + `source_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '閫�璐ф崲绠卞敍鍘熺鍞涘揩鐓�', + `new_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '閫�璐ф崲绠卞敍鏂扮鍞�', + `fba_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鍏宠仈FBA绠卞敍', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '该箱目的海外仓ID', + `destination_fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标', + `sku_relabel_items_json` json DEFAULT NULL COMMENT '同箱SKU换标子明细快照(数量按件)', + `source_inventory_balance_id` bigint DEFAULT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)', + `source_inventory_position_id` bigint DEFAULT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)', + `source_inbound_order_id` bigint DEFAULT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)', + `source_inbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_ll_relabel_item_application` (`customer_id`,`application_id`), + KEY `idx_ll_relabel_item_sku` (`customer_id`,`sku_code`), + KEY `idx_ll_relabel_item_destination` (`customer_id`,`destination_warehouse_id`,`application_id`), + KEY `idx_ll_relabel_item_source_position` (`customer_id`,`source_inventory_position_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Frozen customer relabel cargo items'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_pda_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_pda_history` ( + `id` bigint NOT NULL COMMENT '换标PDA任务历史ID', + `task_id` bigint NOT NULL COMMENT '换标PDA任务ID', + `application_id` bigint NOT NULL COMMENT '换标申请单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '现场动作类型', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '动作前任务状态', + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作后任务状态', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识', + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照', + `detail_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '动作详情JSON', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_relabel_pda_history_task` (`customer_id`,`task_id`,`created_at`,`id`), + KEY `idx_ll_relabel_pda_history_application` (`customer_id`,`application_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标任务不可变操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_pda_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_pda_operation` ( + `id` bigint NOT NULL COMMENT '换标PDA幂等操作ID', + `task_id` bigint NOT NULL COMMENT '换标PDA任务ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求操作账号标识', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '幂等操作动作', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备生成的幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '成功响应快照JSON', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_relabel_pda_operation_key` (`operator_key`,`idempotency_key`), + KEY `idx_ll_relabel_pda_operation_task` (`customer_id`,`task_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标写操作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_pda_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_pda_task` ( + `id` bigint NOT NULL COMMENT '换标PDA任务ID', + `application_id` bigint NOT NULL COMMENT '换标申请单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标申请单号快照', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '认领操作账号标识', + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '认领操作员名称快照', + `reviewer_key` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '复核账号标识', + `reviewer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '复核员名称快照', + `expected_packages` int NOT NULL COMMENT '申请单通知的应换标箱数', + `relabelled_packages` int NOT NULL DEFAULT '0' COMMENT '已正常换标箱数', + `skipped_packages` int NOT NULL DEFAULT '0' COMMENT '已说明原因跳过箱数', + `exception_packages` int NOT NULL DEFAULT '0' COMMENT '已登记异常箱数', + `completion_photo_file_ids` json DEFAULT NULL COMMENT '换标完成现场照片文件ID列表', + `completion_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完成提交备注', + `review_decision` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近复核结论:APPROVE、REWORK或CUSTOMER_CONFIRMED', + `review_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近复核备注', + `claimed_at` datetime(3) NOT NULL COMMENT '任务认领时间', + `submitted_at` datetime(3) DEFAULT NULL COMMENT '提交复核时间', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '最近复核时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_relabel_pda_task_application` (`customer_id`,`application_id`), + UNIQUE KEY `uk_ll_relabel_pda_task_no` (`relabel_no`), + KEY `idx_ll_relabel_pda_task_warehouse` (`overseas_warehouse_id`,`status`,`updated_at`), + KEY `idx_ll_relabel_pda_task_operator` (`operator_key`,`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标现场作业任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_pda_unit_fact`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_pda_unit_fact` ( + `id` bigint NOT NULL COMMENT '逐件换标事实ID', + `task_id` bigint NOT NULL COMMENT '换标PDA任务ID', + `application_id` bigint NOT NULL COMMENT '换标申请单ID', + `item_id` bigint NOT NULL COMMENT '换标申请明细ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `old_label` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫描的实物旧标签或箱标', + `new_label` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫描的换标后新标签', + `result` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + `exception_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常编码', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '跳过或异常原因', + `quantity` int NOT NULL DEFAULT '1' COMMENT '本次事实处理件数,箱级作业固定为1', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识', + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照', + `occurred_at` datetime(3) NOT NULL COMMENT '逐件事实发生时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_relabel_pda_old_label` (`old_label`), + KEY `idx_ll_relabel_pda_unit_task` (`customer_id`,`task_id`,`occurred_at`,`id`), + KEY `idx_ll_relabel_pda_unit_item` (`customer_id`,`application_id`,`item_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA逐件换标扫码与异常事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_status_history` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `application_id` bigint NOT NULL, + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + KEY `idx_ll_relabel_status_history_application` (`customer_id`,`application_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable customer relabel process history'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_carton_count_session`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_carton_count_session` ( + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台PDA操作员标识', + `warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '认领时绑定海外仓标识', + `receipt_id` bigint DEFAULT NULL COMMENT '正在清点的签收批次,完成后释放为NULL', + `started_at` datetime(3) DEFAULT NULL COMMENT '本次开始清点时间', + `created_at` datetime(3) NOT NULL COMMENT '会话创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '会话更新时间', + PRIMARY KEY (`operator_id`), + UNIQUE KEY `uk_ll_return_count_session_receipt` (`receipt_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货箱内清点单人单任务持久会话'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_carton_stock_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_carton_stock_receipt` ( + `id` bigint NOT NULL COMMENT '原箱回仓凭据明细标识', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `receipt_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱回仓实收业务号', + `exception_order_id` bigint NOT NULL COMMENT '本次异常退回订单', + `exception_return_item_id` bigint NOT NULL COMMENT '本次冻结的原箱来源明细', + `delivery_order_id` bigint NOT NULL COMMENT '原运输派送单', + `source_pick_id` bigint NOT NULL COMMENT '原箱实际出库拣货凭据', + `source_outbound_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库单业务号', + `source_dispatch_item_id` bigint NOT NULL COMMENT '原排车货物明细', + `source_outbound_pallet_item_id` bigint NOT NULL COMMENT '原出库托盘明细', + `return_carton_id` bigint NOT NULL COMMENT '真实退货原箱标识', + `carton_mark` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原箱业务箱码快照', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际回仓海外仓标识', + `target_pallet_id` bigint NOT NULL COMMENT '实际回仓目标实物托盘', + `target_pallet_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标托盘业务码', + `target_location_id` bigint NOT NULL COMMENT '实际回仓目标库位', + `target_location_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际目标库位业务码', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原出库快照中的商品编码', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库快照中的中文品名', + `quantity` bigint NOT NULL COMMENT '本次原SKU实际恢复件数', + `packages` bigint NOT NULL COMMENT '本箱仅首条SKU记录计一箱其余为零', + `evidence_file_ids_json` json NOT NULL COMMENT '本次实收证据文件引用', + `received_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次原箱实收确认操作人', + `received_at` datetime(3) NOT NULL COMMENT '实际原箱回仓入账时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_return_carton_receipt_source` (`customer_id`,`source_pick_id`,`sku_code`), + KEY `idx_return_carton_receipt_exception` (`customer_id`,`exception_order_id`,`received_at`), + KEY `idx_return_carton_receipt_delivery` (`customer_id`,`delivery_order_id`), + CONSTRAINT `chk_return_carton_receipt_quantity` CHECK (((`quantity` > 0) and (`packages` in (0,1)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='原生退货箱按原出库SKU实际回仓的不可变凭据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_balance`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_balance` ( + `id` bigint NOT NULL COMMENT '退货库存余额ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `total_quantity` bigint NOT NULL DEFAULT '0' COMMENT '实物总库存,仅实际出库扣减', + `available_quantity` bigint NOT NULL COMMENT '可用上架数量', + `carton_quantity` bigint NOT NULL COMMENT '包含该SKU的可用箱数', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_inventory_balance` (`customer_id`,`overseas_warehouse_id`,`sku_code`), + CONSTRAINT `chk_ll_return_inventory_balance_quantity` CHECK (((`available_quantity` >= 0) and (`carton_quantity` >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_carton` ( + `id` bigint NOT NULL COMMENT '退货库存箱ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + `source_receipt_id` bigint DEFAULT NULL COMMENT '来源PDA签收单ID', + `source_batch_no` text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', + `source_return_no` text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔', + `source_unit_type` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源实物类型', + `source_unit_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源实物编码', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码', + `carton_mark` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码', + `fba_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + `relabel_application_id` bigint DEFAULT NULL COMMENT '绑定换标申请单ID', + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '绑定换标单号', + `relabel_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + `relabel_completed_at` datetime(3) DEFAULT NULL COMMENT '换标完成时间', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', + `sku_types` int NOT NULL COMMENT '箱内SKU种类数', + `total_quantity` bigint NOT NULL COMMENT '箱内总件数', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + `merged_at` datetime(3) DEFAULT NULL COMMENT '合箱完成时间', + `length` decimal(12,3) DEFAULT NULL COMMENT '绠遍暱CM', + `width` decimal(12,3) DEFAULT NULL COMMENT '绠卞CM', + `height` decimal(12,3) DEFAULT NULL COMMENT '绠遍珮CM', + `weight` decimal(12,3) DEFAULT NULL COMMENT '绠遍噸KG', + `original_packaging` tinyint(1) DEFAULT NULL COMMENT '是否使用原箱原包装', + `new_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鎹㈡爣鍚庢柊绠卞敍', + `location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '退货箱当前库位', + `putaway_at` datetime(3) DEFAULT NULL COMMENT '退货箱上架时间', + `count_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CONFIRMED' COMMENT '箱内清点状态 PENDING待清点 CONFIRMED已确认', + `counted_at` datetime(3) DEFAULT NULL COMMENT '箱内清点确认时间', + `counted_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱内清点确认操作人', + `label_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'WAIT_LABEL寰呰创鏍� CONFIRMED宸叉壂鐮佺‘璁ゆ爣绛�', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_inventory_carton_mark` (`carton_mark`), + KEY `idx_ll_return_inventory_carton_customer` (`customer_id`,`overseas_warehouse_id`,`status`,`created_at`), + KEY `idx_ll_return_inventory_carton_receipt` (`source_receipt_id`), + KEY `idx_ll_return_count` (`overseas_warehouse_id`,`count_status`,`status`,`source_receipt_id`), + CONSTRAINT `chk_ll_return_inventory_carton_quantity` CHECK ((((`status` in (_utf8mb4'AVAILABLE',_utf8mb4'PACKING_RESERVED',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUND_PENDING',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING')) and (`sku_types` > 0) and (`total_quantity` > 0)) or ((`status` in (_utf8mb4'MERGED',_utf8mb4'DISCARDED')) and (`sku_types` = 0) and (`total_quantity` = 0)))), + CONSTRAINT `chk_ll_return_inventory_carton_status` CHECK ((`status` in (_utf8mb4'AVAILABLE',_utf8mb4'PACKING_RESERVED',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUND_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING',_utf8mb4'DISCARDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_carton_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_carton_item` ( + `id` bigint NOT NULL COMMENT '退货库存箱SKU明细ID', + `carton_id` bigint NOT NULL COMMENT '退货库存箱ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `quantity` bigint NOT NULL COMMENT '该SKU箱内数量', + `packing_reserved_quantity` bigint NOT NULL DEFAULT '0' COMMENT '合箱计划预占件数', + `relabelled_quantity` bigint NOT NULL DEFAULT '0' COMMENT '当前SKU已经换标的实物件数,不可二次换标', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_inventory_carton_item` (`carton_id`,`sku_code`), + KEY `idx_ll_return_inventory_carton_item_customer` (`customer_id`,`sku_code`), + CONSTRAINT `chk_ll_return_inventory_carton_item_quantity` CHECK ((`quantity` >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_operation` ( + `id` bigint NOT NULL COMMENT '退货库存PDA操作ID', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作人ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型,当前MERGE', + `result_carton_id` bigint DEFAULT NULL COMMENT '合箱结果退货库存箱ID', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + `result_json` json DEFAULT NULL COMMENT '幂等操作不可变返回快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_inventory_operation_operator_key` (`operator_id`,`idempotency_key`), + KEY `idx_ll_return_inventory_operation_result` (`result_carton_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存PDA合箱幂等操作审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_outbound_movement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_outbound_movement` ( + `id` bigint NOT NULL COMMENT '退货出库SKU流水ID', + `outbound_pick_id` bigint NOT NULL COMMENT '同事务PDA出库拣货记录ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID', + `carton_id` bigint NOT NULL COMMENT '真实退货库存箱ID', + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冻结的真实退货箱业务码', + `outbound_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + `quantity` bigint NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正', + `total_before` bigint NOT NULL COMMENT '客户仓SKU出库前实物总数量', + `total_after` bigint NOT NULL COMMENT '客户仓SKU出库后实物总数量', + `available_before` bigint NOT NULL COMMENT '客户仓SKU出库前可用数量', + `available_after` bigint NOT NULL COMMENT '客户仓SKU出库后可用数量', + `cartons_before` bigint NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数', + `cartons_after` bigint NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数', + `occurred_at` datetime(3) NOT NULL COMMENT '出库记账时间', + `return_sku_quantity` bigint DEFAULT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_outbound_pick_sku` (`outbound_pick_id`,`sku_code`), + KEY `idx_ll_return_outbound_movement_query` (`customer_id`,`overseas_warehouse_id`,`sku_code`,`occurred_at`), + KEY `idx_ll_return_outbound_carton_sku` (`customer_id`,`carton_id`,`sku_code`), + CONSTRAINT `chk_ll_return_outbound_movement_delta` CHECK (((`quantity` > 0) and (`total_after` >= 0) and (`available_after` >= 0) and ((`total_before` - `total_after`) = `quantity`) and ((`available_before` - `available_after`) = `quantity`) and (`cartons_after` >= 0) and ((`cartons_before` - `cartons_after`) in (0,1)) and (`total_after` >= `available_after`))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='不可变退货出库SKU库存流水'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_order` ( + `id` bigint NOT NULL COMMENT '退货订单ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `return_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货订单号', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + `warehouse_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系人快照', + `warehouse_contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系电话快照', + `customer_reference_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户参考号', + `lastleg_order_id` bigint DEFAULT NULL COMMENT '鍏宠仈灏剧▼涓昏鍗旾D', + `lastleg_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'linked lastleg order no', + `need_relabel` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要换标', + `relabel_application_id` bigint DEFAULT NULL COMMENT '绑定换标申请单ID', + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '绑定换标单号', + `relabel_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', + `expected_sku_types` int NOT NULL COMMENT '预报SKU种类数', + `expected_quantity` bigint NOT NULL COMMENT '预报总数量', + `received_quantity` bigint NOT NULL DEFAULT '0' COMMENT '累计实收总数量', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户备注', + `discrepancy_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手动完结差异原因', + `manually_completed_at` datetime(3) DEFAULT NULL COMMENT '手动差异完结时间', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求SHA-256', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建操作人ID', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_order_customer_no` (`customer_id`,`return_no`), + UNIQUE KEY `uk_ll_return_order_customer_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_return_order_warehouse_status` (`overseas_warehouse_id`,`status`,`created_at`), + CONSTRAINT `chk_ll_return_order_quantity` CHECK (((`expected_sku_types` > 0) and (`expected_quantity` > 0) and (`received_quantity` >= 0) and (`received_quantity` <= `expected_quantity`))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户退货订单及SKU实收汇总'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_order_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_order_item` ( + `id` bigint NOT NULL COMMENT '退货预报明细ID', + `return_order_id` bigint NOT NULL COMMENT '退货订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `expected_quantity` bigint NOT NULL COMMENT '该快递单SKU预报数量', + `received_quantity` bigint NOT NULL DEFAULT '0' COMMENT '该快递单SKU累计实收数量', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_item_order_sku` (`return_order_id`,`sku_code`), + KEY `idx_ll_return_item_sku` (`sku_code`,`received_quantity`), + CONSTRAINT `chk_ll_return_item_quantity` CHECK (((`expected_quantity` > 0) and (`received_quantity` >= 0) and (`received_quantity` <= `expected_quantity`))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户退货订单的快递单SKU预报与实收明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_packing_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_packing_plan` ( + `id` bigint NOT NULL, + `plan_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `return_nos_json` json NOT NULL, + `boxes_json` json NOT NULL, + `mode` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL, + `updated_at` datetime(3) NOT NULL, + `pushed_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `source_items_json` json DEFAULT NULL COMMENT '精确来源箱和SKU锁定数量', + `result_marks_json` json DEFAULT NULL COMMENT '完成生成或并入的目标箱唛', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建载荷摘要', + `completion_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完成载荷摘要', + `result_labels_json` json DEFAULT NULL COMMENT '计划完工标签快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_packing_plan_no` (`plan_no`), + UNIQUE KEY `uk_ll_return_packing_plan_key` (`idempotency_key`), + KEY `idx_ll_return_packing_plan_warehouse_status` (`overseas_warehouse_id`,`status`,`created_at`), + KEY `idx_ll_return_packing_plan_warehouse` (`overseas_warehouse_id`,`status`,`pushed_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货合箱计划'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_packing_trace`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_packing_trace` ( + `id` bigint NOT NULL COMMENT '合箱追溯ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原客户隔离键', + `operation_id` bigint NOT NULL COMMENT '合箱操作ID', + `source_carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照', + `target_carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标箱唛', + `return_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始退货单号', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商品SKU编码', + `quantity` bigint NOT NULL COMMENT '本次移动件数', + `created_at` datetime(3) NOT NULL COMMENT '发生时间', + PRIMARY KEY (`id`), + KEY `idx_return_packing_trace_order` (`customer_id`,`return_no`,`created_at`), + KEY `idx_return_packing_trace_target` (`customer_id`,`target_carton_mark`,`sku_code`), + KEY `idx_return_packing_trace_source` (`customer_id`,`source_carton_mark`,`sku_code`), + CONSTRAINT `chk_return_packing_trace_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货合箱逐SKU原始退货单不可变追溯'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_receipt` ( + `id` bigint NOT NULL COMMENT 'PDA退货签收单ID', + `return_order_id` bigint NOT NULL COMMENT '退货订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `receipt_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号', + `receipt_batch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', + `unit_type` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收实物类型 PALLET/BOX/PARCEL', + `unit_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收实物编码', + `total_box_count` bigint DEFAULT NULL COMMENT '本次实物总箱数,托盘独立于SKU箱数', + `length` decimal(12,3) DEFAULT NULL COMMENT '托盘长度CM', + `width` decimal(12,3) DEFAULT NULL COMMENT '托盘宽度CM', + `height` decimal(12,3) DEFAULT NULL COMMENT '托盘高度CM', + `weight` decimal(12,3) DEFAULT NULL COMMENT '托盘重量KG', + `original_packaging` tinyint(1) DEFAULT NULL COMMENT '是否使用原箱原包装', + `measurement_source` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '尺寸重量来源 PALLET_MEASURED托盘实测 UNIT_SUM子箱汇总', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码', + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号', + `tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收包裹的可选快递单号', + `bound_by` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收状态,当前SIGNED', + `received_quantity` bigint NOT NULL COMMENT '本次实收数量', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作人ID', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA操作人名称', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA确认幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA确认请求SHA-256', + `signed_at` datetime(3) NOT NULL COMMENT '签收时间', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_receipt_no` (`receipt_no`), + UNIQUE KEY `uk_ll_return_receipt_batch` (`receipt_batch_no`), + UNIQUE KEY `uk_ll_return_receipt_operator_key` (`operator_id`,`idempotency_key`), + KEY `idx_ll_return_receipt_order` (`return_order_id`,`signed_at`), + CONSTRAINT `chk_ll_return_receipt_bound` CHECK ((`bound_by` in (_utf8mb4'RETURN_NO',_utf8mb4'SKU'))), + CONSTRAINT `chk_ll_return_receipt_quantity` CHECK ((`received_quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_receipt_batch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_receipt_batch` ( + `id` bigint NOT NULL COMMENT '退货仓储计费批次ID', + `return_order_id` bigint NOT NULL COMMENT '退货订单ID', + `receipt_id` bigint NOT NULL COMMENT 'PDA退货签收单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `batch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + `storage_billing_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', + `storage_started_at` datetime(3) NOT NULL COMMENT '仓储计费起算时间', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_batch_no` (`batch_no`), + UNIQUE KEY `uk_ll_return_batch_receipt` (`receipt_id`), + KEY `idx_ll_return_batch_billing` (`customer_id`,`overseas_warehouse_id`,`storage_billing_status`,`storage_started_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每次退货签收生成的仓储费起算批次'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_receipt_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_receipt_item` ( + `id` bigint NOT NULL COMMENT '签收SKU明细ID', + `receipt_id` bigint NOT NULL COMMENT 'PDA退货签收单ID', + `return_order_item_id` bigint NOT NULL COMMENT '退货预报明细ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + `received_quantity` bigint NOT NULL COMMENT '本次SKU实收数量', + `box_count` bigint DEFAULT NULL COMMENT '本次 SKU 对应箱数', + PRIMARY KEY (`id`), + KEY `idx_ll_return_receipt_item_receipt` (`receipt_id`), + KEY `idx_ll_return_receipt_item_order_item` (`return_order_item_id`), + CONSTRAINT `chk_ll_return_receipt_item_quantity` CHECK ((`received_quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_sku_conversion`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_sku_conversion` ( + `id` bigint NOT NULL COMMENT 'SKU库存转换记录ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + `application_id` bigint NOT NULL COMMENT '换标申请ID', + `relabel_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号', + `item_id` bigint NOT NULL COMMENT '换标箱级明细ID', + `round_key` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '作业轮次,退回重做使用新轮次', + `carton_id` bigint NOT NULL COMMENT '实物来源包裹或箱ID', + `carton_mark` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '完工时仓库识别码', + `old_sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU', + `new_sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + `quantity` bigint NOT NULL COMMENT '实际完成转换件数', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回', + `ownership_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回', + `completed_at` datetime(3) NOT NULL COMMENT '实物SKU转换时间', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认时间', + `reversed_at` datetime(3) DEFAULT NULL COMMENT '退回冲回时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_return_sku_conversion_round` (`item_id`,`round_key`,`old_sku_code`,`new_sku_code`), + KEY `idx_return_sku_conversion_app` (`customer_id`,`application_id`,`status`), + KEY `idx_return_sku_conversion_carton` (`customer_id`,`carton_id`), + CONSTRAINT `chk_return_sku_conversion_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_stock_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_stock_receipt` ( + `id` bigint NOT NULL COMMENT '退回入仓实收凭据ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据归属', + `receipt_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退回实收业务编号', + `inventory_ledger_id` bigint NOT NULL COMMENT '唯一实际退回入账流水', + `exception_order_id` bigint NOT NULL COMMENT '退回异常订单', + `order_id` bigint NOT NULL COMMENT '原尾程订单', + `inbound_order_id` bigint NOT NULL COMMENT '原入仓单', + `source_outbound_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原发运出库业务编号', + `source_outbound_pallet_item_id` bigint NOT NULL COMMENT '原出库托盘明细', + `source_inventory_position_id` bigint NOT NULL COMMENT '原拣货库存位置', + `inventory_balance_id` bigint NOT NULL COMMENT '实际退回库存余额', + `inventory_position_id` bigint NOT NULL COMMENT '实际退回库存位置', + `received_packages` bigint NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + `received_at` datetime(3) NOT NULL COMMENT '实际退回入账时间', + `created_at` datetime(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_return_stock_receipt_no` (`customer_id`,`receipt_no`), + UNIQUE KEY `uk_return_stock_receipt_ledger` (`customer_id`,`inventory_ledger_id`), + KEY `idx_return_stock_receipt_order` (`customer_id`,`order_id`), + KEY `idx_return_stock_receipt_position` (`customer_id`,`inventory_balance_id`,`inventory_position_id`), + CONSTRAINT `chk_return_stock_receipt_packages` CHECK ((`received_packages` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_security_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_security_event_inbox` ( + `event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨区安全事件ID', + `event_type` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'IAM安全事件类型', + `payload_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256哈希', + `payload_json` json NOT NULL COMMENT '冻结事件载荷JSON', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + `result` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + `received_at` datetime(3) NOT NULL COMMENT '接收时间', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (`event_id`), + KEY `idx_security_inbox_type_time` (`event_type`,`received_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全事件消费者收件箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_security_projection`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_security_projection` ( + `aggregate_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + `aggregate_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户或客户聚合ID', + `tenant_id` bigint NOT NULL COMMENT '平台0或客户ID', + `security_status` varchar(24) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + `permission_version` bigint DEFAULT NULL COMMENT '当前单调权限版本', + `roles_json` json NOT NULL COMMENT 'IAM投影的当前角色快照', + `authorities_json` json NOT NULL COMMENT 'IAM投影的当前海外权限快照', + `aggregate_version` bigint NOT NULL COMMENT '事件聚合单调版本', + `payload_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '当前版本载荷哈希', + `last_event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后应用的事件ID', + `changed_at` datetime(3) NOT NULL COMMENT '国内IAM事实发生时间', + `updated_at` datetime(3) NOT NULL COMMENT '投影更新时间', + PRIMARY KEY (`aggregate_type`,`aggregate_id`), + KEY `idx_security_projection_tenant` (`tenant_id`), + KEY `idx_security_projection_permission` (`aggregate_id`,`permission_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全状态与权限版本投影'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_inventory_daily`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_inventory_daily` ( + `day_version_id` bigint NOT NULL COMMENT '所属历史日版本', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源货主编码', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '来源商品编码', + `product_quality` int NOT NULL COMMENT '来源库存品质', + `physical_quantity` decimal(20,6) DEFAULT NULL COMMENT '日末实物库存,未知为空,已确认零为零', + `quality_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '日末资格,有效或未知', + `quality_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '缺覆盖或锚点等未知原因', + `anchor_reference` bigint DEFAULT NULL COMMENT '独立库存锚点版本', + `evidence_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '该日计算证据摘要', + `source` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '库存来源标记,流水还原', + PRIMARY KEY (`day_version_id`,`customer_code`,`sku_code`,`product_quality`), + KEY `idx_selfop_daily_identity` (`customer_code`,`sku_code`,`day_version_id`), + CONSTRAINT `chk_selfop_daily_quantity` CHECK ((((`quality_status` = _utf8mb4'VALID') and (`physical_quantity` is not null) and (`physical_quantity` >= 0)) or ((`quality_status` = _utf8mb4'UNKNOWN') and (`physical_quantity` is null)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日末分货主商品品质实物库存'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_inventory_day_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_inventory_day_version` ( + `id` bigint NOT NULL COMMENT '不可变历史日版本标识', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库编码', + `business_date` date NOT NULL COMMENT '北京时间日末所属日期', + `scope_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '业务范围摘要', + `rule_version` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '日末库存还原规则版本', + `build_run_id` bigint NOT NULL COMMENT '历史重建执行尝试', + `publication_group_id` bigint NOT NULL COMMENT '必须一起切换的发布组标识', + `sales_run_id` bigint DEFAULT NULL COMMENT '构建时固定的销量证据版本', + `anchor_run_id` bigint NOT NULL COMMENT '固定的独立库存锚点版本', + `input_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '冻结流水修订与覆盖及锚点输入摘要', + `evidence_json` json NOT NULL COMMENT '固定输入清单及不可变证据引用', + `status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '构建中、就绪、已发布、已替代或失败', + `head_slot` tinyint DEFAULT NULL COMMENT '当前日头为一,非当前版本为空', + `valid_sku_count` bigint NOT NULL DEFAULT '0' COMMENT '合格商品数', + `unknown_sku_count` bigint NOT NULL DEFAULT '0' COMMENT '待核验商品数', + `created_at` datetime(3) NOT NULL COMMENT '版本创建UTC时刻', + `published_at` datetime(3) DEFAULT NULL COMMENT '发布组提交UTC时刻', + `superseded_at` datetime(3) DEFAULT NULL COMMENT '被新组替代的UTC时刻', + `invalidated_at` datetime(3) DEFAULT NULL COMMENT '已确认输入失效的UTC时刻', + `invalidation_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '旧版本失效原因', + `rebuild_run_id` bigint DEFAULT NULL COMMENT '处理失效版本的重建尝试', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_history_build_day` (`build_run_id`,`business_date`), + UNIQUE KEY `uk_selfop_history_head` (`source_account`,`warehouse_code`,`business_date`,`head_slot`), + KEY `idx_selfop_history_group` (`publication_group_id`,`status`), + CONSTRAINT `chk_selfop_history_head` CHECK (((`head_slot` is null) or ((`head_slot` = 1) and (`status` = _utf8mb4'PUBLISHED')))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='历史日库存不可变版本及原子发布日头'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_inventory_flow`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_inventory_flow` ( + `id` bigint NOT NULL COMMENT '应用生成的流水记录标识', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库编码', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源货主编码', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '来源商品编码', + `source_flow_id` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源流水字符串标识', + `product_quality` int NOT NULL COMMENT '来源品质,零正品一次品,其他未知', + `cell_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源库位编码', + `inner_batch_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源内部批次号', + `bill_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源业务单号', + `source_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源关联单号', + `bill_type` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源业务类型,未验证类型不得参与正式还原', + `book_qty` decimal(20,6) DEFAULT NULL COMMENT '来源记账增减量,未知为空', + `book_change_qty_raw` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源带符号变动量原文', + `delta_quantity` decimal(20,6) DEFAULT NULL COMMENT '解析后的有符号实物变动量', + `close_quantity` decimal(20,6) DEFAULT NULL COMMENT '对应库位批次品质链的结存', + `source_create_time` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源北京时间原文', + `event_at_utc` datetime(3) DEFAULT NULL COMMENT '流水发生UTC时刻,无法解析时为空', + `business_date` date DEFAULT NULL COMMENT '北京时间业务日期', + `quantity_unit` varchar(16) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '已确认数量单位,未知为空', + `content_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '当前事实内容摘要', + `revision_no` bigint NOT NULL COMMENT '同来源流水单调增加的修订号', + `stage_id` bigint NOT NULL COMMENT '当前事实对应不可变暂存证据', + `first_run_id` bigint NOT NULL COMMENT '首次观察执行尝试', + `last_run_id` bigint NOT NULL COMMENT '最近确认执行尝试', + `last_observed_at` datetime(3) NOT NULL COMMENT '最近观察UTC时刻', + `validation_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '字段资格,有效或未知', + `validation_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '数量或身份待核验原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_flow_source` (`source_account`,`warehouse_code`,`source_flow_id`), + KEY `idx_selfop_flow_sku` (`source_account`,`warehouse_code`,`customer_code`,`sku_code`,`event_at_utc`), + KEY `idx_selfop_flow_day` (`source_account`,`warehouse_code`,`business_date`), + CONSTRAINT `chk_selfop_flow_revision` CHECK ((`revision_no` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='自营历史库存流水最新事实,旧版本保留于暂存证据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_inventory_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_inventory_snapshot` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `snapshot_run_id` bigint NOT NULL COMMENT 'INVENTORY 执行尝试;发布后该批明细不可修改', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '原始产品 SKU', + `product_quality` smallint NOT NULL COMMENT '来源品质,当前 0 正品、1 次品;全量均保存,不套用整箱库存', + `product_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源名称', + `physical_quantity` decimal(20,6) DEFAULT NULL COMMENT '来源stkQty实物库存,未知为空', + `available_quantity` decimal(20,6) DEFAULT NULL COMMENT '来源useStkQty可用库存,未知为空,不再次扣冻结', + `frozen_quantity` decimal(20,6) DEFAULT NULL COMMENT '来源freezeQty冻结库存,未知为空', + `transit_quantity` decimal(20,6) DEFAULT NULL COMMENT '来源transitQty在途数量,未知为空,不计可售库存', + `source_unique_key` varchar(512) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '原 uniqueKey4tenant,只作来源证据', + `observed_at_utc` datetime(3) NOT NULL COMMENT '所在页实际采集时间;缺行占位取本批采集完成时间', + `quality_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'VALID、UNKNOWN、INVALID;STALE 按批次年龄实时派生', + `quality_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'SOURCE_ROW_MISSING、FIELD_MISSING、NEGATIVE_QUANTITY 等', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_stock_snapshot` (`snapshot_run_id`,`source_account`,`customer_code`,`warehouse_code`,`sku_code`,`product_quality`), + KEY `idx_selfop_stock_lookup` (`source_account`,`customer_code`,`warehouse_code`,`sku_code`,`product_quality`,`snapshot_run_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='产品库存全量批次明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_outbound_discovery_scope`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_outbound_discovery_scope` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `catalog_from_utc` datetime(3) DEFAULT NULL COMMENT '账号完整目录连续覆盖UTC下界,不等同销量覆盖', + `catalog_until_utc` datetime(3) DEFAULT NULL COMMENT '账号完整目录连续覆盖UTC上界,不等同销量覆盖', + `next_sequence` bigint NOT NULL DEFAULT '1' COMMENT '新发现任务的单调调度序号;重试沿用,只安排任务,不判断对象内容的新旧', + `next_observation_sequence` bigint NOT NULL DEFAULT '1' COMMENT '每次实际出库详情请求前分配的单调采集代次;与 task 调度序号独立,重试请求也分配新代次', + `active_task_id` bigint DEFAULT NULL COMMENT '当前共享发现/详情补齐任务', + `lease_owner` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '账号发现执行者', + `lease_until_utc` datetime(3) DEFAULT NULL COMMENT '账号发现租约到期时刻', + `fence_token` bigint NOT NULL DEFAULT '0' COMMENT '防止过期发现进程继续写共享暂存或派发任务', + `request_budget` int NOT NULL COMMENT '一次调度允许的目录、详情请求总预算,必须为正', + `time_budget_seconds` int NOT NULL COMMENT '一次调度允许占用的执行时长,必须为正', + `min_request_interval_ms` int NOT NULL COMMENT '同账号请求最小间隔,按已核准配额配置', + `api_budget_json` json DEFAULT NULL COMMENT '账号所有采集族共用的预算、统计日、保守预留及确认消耗、尝试许可和独占交接依据', + `api_cooldown_until_utc` datetime(3) DEFAULT NULL COMMENT '账号全部真实请求共用冷却UTC截止,重启与跨日不清除', + `api_pause_reason` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '账号停止或等待的固定原因码,不保存供应商原始错误或凭据', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_discovery_account` (`source_account`), + CONSTRAINT `chk_selfop_outbound_discovery_scope` CHECK (((`request_budget` > 0) and (`time_budget_seconds` > 0) and (`min_request_interval_ms` > 0) and (`fence_token` >= 0) and (`next_sequence` > 0) and (`next_observation_sequence` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='账号共享出库目录与详情发现协调作用域'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_outbound_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_outbound_item` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `order_id` bigint NOT NULL COMMENT '对应外部出库事实', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '原始 productSku', + `product_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源商品名称,仅作展示', + `actual_quantity` decimal(20,6) NOT NULL COMMENT '详情 actualDeliveryQty,必须为非负有效数值', + `quantity_unit` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '已核准业务单位;未确认单位不发布正式数量统计', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_order_sku` (`order_id`,`sku_code`), + KEY `idx_selfop_item_sku` (`sku_code`,`order_id`), + CONSTRAINT `chk_selfop_outbound_item` CHECK ((`actual_quantity` >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='整单替换的实际出库明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_outbound_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_outbound_order` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `delivery_no` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT 'OMP 发货单号,采用列表及详情同一身份', + `source_no` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT 'WMS 来源出库单号,缺失不以内部 ID 回退展示', + `source_status` int NOT NULL COMMENT '原始状态,100 已出库、99 已取消;不改造成内部状态码', + `source_created_time` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源创建时间原始字符串,不使用采集时间替代', + `source_outbound_time` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源实际出库时间原始字符串,不使用采集时间替代', + `source_timezone` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '解析采用的源时区', + `outbound_at_utc` datetime(3) DEFAULT NULL COMMENT '已核准的实际出库时间点;非出库状态可空', + `business_date` date DEFAULT NULL COMMENT '已核准业务时区下的出库日', + `business_timezone` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '该事实当前归属仓所用业务时区,迁仓后重新确定业务日', + `sales_eligible` tinyint NOT NULL DEFAULT '0' COMMENT '仅已出库、时间与全部数量有效时为 1', + `revision_no` bigint NOT NULL DEFAULT '1' COMMENT '该单内容变更次数,不等于拉取次数', + `content_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '订单头及完整 SKU 明细规范化摘要', + `last_run_id` bigint NOT NULL COMMENT '最后一次确认或修订的执行尝试', + `last_observation_sequence` bigint NOT NULL COMMENT '最后接受的完整详情采集代次;仅防旧观测重放,不按 task 调度序号拒绝较晚实际读取的事实,不冒充领星源版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_order` (`source_account`,`customer_code`,`delivery_no`), + KEY `idx_selfop_order_daily` (`source_account`,`warehouse_code`,`sales_eligible`,`business_date`,`id`), + KEY `idx_selfop_order_run` (`last_run_id`), + CONSTRAINT `chk_selfop_outbound_order` CHECK (((`revision_no` > 0) and (`last_observation_sequence` > 0) and (`sales_eligible` in (0,1)) and ((`sales_eligible` = 0) or ((`source_status` = 100) and (`source_timezone` is not null) and (`outbound_at_utc` is not null) and (`business_date` is not null))))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='最后确认的单据事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_owner`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_owner` ( + `id` bigint NOT NULL COMMENT '主键', + `run_id` bigint NOT NULL COMMENT '完整资料采集版本', + `source_account` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号', + `customer_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码', + `customer_name` varchar(255) DEFAULT NULL COMMENT '领星货主名称', + `source_status` varchar(32) DEFAULT NULL COMMENT '来源启停状态', + `observed_at_utc` datetime(6) NOT NULL COMMENT '资料观测时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_owner_version` (`run_id`,`source_account`,`customer_code`), + KEY `idx_selfop_owner_lookup` (`source_account`,`customer_code`,`run_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='自营领星货主资料版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_product`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_product` ( + `id` bigint NOT NULL COMMENT '主键', + `run_id` bigint NOT NULL COMMENT '完整资料采集版本', + `source_account` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '来源账号', + `customer_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主编码', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL COMMENT '货主商品编码', + `product_name` varchar(255) DEFAULT NULL COMMENT '商品名称', + `declared_length` decimal(24,6) DEFAULT NULL COMMENT '商品资料长度', + `declared_width` decimal(24,6) DEFAULT NULL COMMENT '商品资料宽度', + `declared_height` decimal(24,6) DEFAULT NULL COMMENT '商品资料高度', + `declared_size_unit` varchar(32) DEFAULT NULL COMMENT '商品资料尺寸单位', + `measured_length` decimal(24,6) DEFAULT NULL COMMENT '仓库实测长度', + `measured_width` decimal(24,6) DEFAULT NULL COMMENT '仓库实测宽度', + `measured_height` decimal(24,6) DEFAULT NULL COMMENT '仓库实测高度', + `measured_size_unit` varchar(32) DEFAULT NULL COMMENT '仓库实测尺寸单位', + `observed_at_utc` datetime(6) NOT NULL COMMENT '资料观测时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_product_version` (`run_id`,`source_account`,`customer_code`,`sku_code`), + KEY `idx_selfop_product_lookup` (`source_account`,`customer_code`,`sku_code`,`run_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='自营领星商品资料版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_sku_daily_sales`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_sku_daily_sales` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `sales_run_id` bigint NOT NULL COMMENT 'OUTBOUND 执行尝试,同时是计算版本', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '原始产品 SKU', + `business_date` date NOT NULL COMMENT '已核准业务日', + `outbound_quantity` decimal(20,6) NOT NULL COMMENT '有效出库实际件数之和,非负', + `outbound_order_count` bigint NOT NULL COMMENT '该维度去重发货单数,非负', + `quantity_unit` varchar(32) COLLATE utf8mb4_bin NOT NULL COMMENT '同一统计维度内一致的业务单位', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_daily_version` (`sales_run_id`,`source_account`,`customer_code`,`warehouse_code`,`sku_code`,`business_date`), + KEY `idx_selfop_daily_window_cover` (`sales_run_id`,`source_account`,`warehouse_code`,`business_date`,`customer_code`,`sku_code`,`outbound_quantity`,`outbound_order_count`,`quantity_unit`), + KEY `idx_selfop_daily_sku_cover` (`sales_run_id`,`source_account`,`warehouse_code`,`customer_code`,`sku_code`,`business_date`,`outbound_quantity`,`outbound_order_count`,`quantity_unit`), + CONSTRAINT `chk_selfop_sku_daily_sales` CHECK (((`outbound_quantity` >= 0) and (`outbound_order_count` >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本的稀疏日事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_sku_profile`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_sku_profile` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `profile_run_id` bigint NOT NULL COMMENT '本仓销量或库存候选版本;与对应 daily/snapshot 使用同一 run,发布后该版本全部维度行不可修改', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `customer_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '领星货主原始编码,不等同平台客户标识', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `sku_code` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '原始产品 SKU', + `product_name` varchar(255) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '本版本确认的名称,不实时连接其他版本名称', + `quantity_unit` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '本版本已核准单位;未知或两版本单位冲突时不计算混合指标', + `first_seen_at_utc` datetime(3) DEFAULT NULL COMMENT '本证据族真实观测到本仓商品的最早接收时刻;缺行占位不能生成观察证据', + `earliest_evidence_at_utc` datetime(3) DEFAULT NULL COMMENT '库存版取真实库存首次观察时刻;销量版从该版有效事实重新求最早已核准出库时间,可随改期、取消或迁仓撤回/后移', + `evidence_type` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT 'INVENTORY_OBSERVATION、VALID_OUTBOUND、NONE;不得将销量证据复制成库存真实观察证据', + `evidence_run_id` bigint DEFAULT NULL COMMENT '本证据族真实依据的采集批次,无证据占位为空', + `last_seen_run_id` bigint DEFAULT NULL COMMENT '本证据族最近真实观测批次,无证据占位为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_profile_version` (`profile_run_id`,`source_account`,`customer_code`,`warehouse_code`,`sku_code`), + KEY `idx_selfop_profile_evidence` (`profile_run_id`,`earliest_evidence_at_utc`,`customer_code`,`sku_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='随发布版本固定的 SKU 维度与观察依据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_sync_run`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_sync_run` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `task_id` bigint NOT NULL COMMENT '本次执行请求', + `execution_purpose` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '从逻辑任务冻结继承,验证尝试不能作为正式统计版本', + `scope_id` bigint DEFAULT NULL COMMENT '仅账号共享发现为空;分仓执行必须有作用域', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `run_role` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + `upstream_task_id` bigint DEFAULT NULL COMMENT '分仓出库计算引用的共享切片及其稳定暂存集合', + `attempt_no` int NOT NULL COMMENT '同一请求从 1 起递增', + `scope_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '本版本的业务范围摘要', + `scope_snapshot_json` json NOT NULL COMMENT '分仓版只含本仓货主/品质、固定首采 [B,E0)、时区、调度与过期阈值;账号发现保存待路由仓集合,不冒充业务版', + `observed_dimensions_json` json DEFAULT NULL COMMENT '本次实际出现的仓库/货主集合及数量摘要,仅作采集证据;例如已观测 70 货主不等于固定授权范围', + `fence_token` bigint NOT NULL COMMENT '本尝试持有的栅栏版本', + `base_published_run_id` bigint DEFAULT NULL COMMENT '执行开始时的上一可见版本', + `reference_sales_run_id` bigint DEFAULT NULL COMMENT '库存构建开始时固定的本仓已发布销量版,用其不可变 SKU 维度生成出库已知商品占位;没有已发布销量版时为空,不读取半归并订单事实', + `inventory_daily_sample_date` date DEFAULT NULL COMMENT '北京时间当日首个合格计划库存发布代表日期,非代表为空', + `inventory_sample_rule_version` varchar(32) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '日库存代表选择规则 DAILY_SCHEDULED_FIRST_V1,与日标记原子保存', + `status` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT 'FETCHING、VALIDATED、ROUTED、FACTS_APPLIED、BUILDING、READY、PUBLISHED、REUSED、YIELDED、FAILED、ABORTED、SAMPLE_DONE;复用和验证不是版本头', + `last_phase` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT '失败/中止前所在阶段,支持判断是否已经归并事实', + `fetch_started_at_utc` datetime(3) DEFAULT NULL COMMENT '实际采集开始UTC时间', + `fetch_finished_at_utc` datetime(3) DEFAULT NULL COMMENT '实际采集完成UTC时间,未完成为空', + `build_started_at_utc` datetime(3) DEFAULT NULL COMMENT '实际开始有界候选构建UTC时间,用于构建耗时及首采合并间隔', + `warehouse_daily_ready` tinyint NOT NULL DEFAULT '0' COMMENT '仓库日汇总已完整构建并校验,含完整空版本', + `finished_at_utc` datetime(3) DEFAULT NULL COMMENT '本次尝试终止UTC时间', + `published_at_utc` datetime(3) DEFAULT NULL COMMENT '实际原子切换发布指针UTC时间', + `superseded_at_utc` datetime(3) DEFAULT NULL COMMENT '被新发布版替代的首次UTC时间,同事务填写且重试不重置', + `page_count` int NOT NULL DEFAULT '0' COMMENT '已完整获取页数', + `source_total_count` bigint DEFAULT NULL COMMENT '上游可靠返回的总数;未返回则为空', + `unique_object_count` bigint NOT NULL DEFAULT '0' COMMENT '规范化去重后对象数', + `progress_snapshot_json` json DEFAULT NULL COMMENT '尝试终止时冻结的有界可靠进度摘要,历史尝试不随逻辑任务改写', + `heartbeat_at_utc` datetime(3) DEFAULT NULL COMMENT '有效执行者最新UTC心跳,不代表业务检查点推进', + `coverage_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'UNKNOWN、PARTIAL、COMPLETE;分页完整与数值有效性分别判断', + `covered_from_utc` datetime(3) DEFAULT NULL COMMENT '本仓销量版本实际连续覆盖UTC下界', + `covered_until_utc` datetime(3) DEFAULT NULL COMMENT '本仓销量版本实际连续覆盖UTC上界,不含端点', + `complete_day_from` date DEFAULT NULL COMMENT '本仓完整自然日区间首日,包含', + `complete_day_until` date DEFAULT NULL COMMENT '本仓完整自然日区间末日,不含', + `applied_fact_revision` bigint DEFAULT NULL COMMENT '本次尝试归并后的事实修订号,库存为空', + `calculated_fact_revision` bigint DEFAULT NULL COMMENT '候选销量版本计算所依据的固定事实修订号', + `error_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '内部可诊断错误分类,不保存认证信息', + `error_message` varchar(500) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '脱敏后的固定错误摘要', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_run_attempt` (`task_id`,`attempt_no`), + UNIQUE KEY `uk_selfop_inventory_daily` (`source_account`,`warehouse_code`,`inventory_daily_sample_date`), + KEY `idx_selfop_run_scope` (`scope_id`,`id`), + KEY `idx_selfop_run_status` (`status`,`updated_at`), + CONSTRAINT `chk_selfop_inventory_daily` CHECK ((((`inventory_daily_sample_date` is null) and (`inventory_sample_rule_version` is null)) or ((`inventory_daily_sample_date` is not null) and (`inventory_sample_rule_version` is not null) and (`inventory_sample_rule_version` = _utf8mb4'DAILY_SCHEDULED_FIRST_V1') and (`execution_purpose` = _utf8mb4'BUSINESS') and (`run_role` = _utf8mb4'WAREHOUSE_SYNC') and (`status` = _utf8mb4'PUBLISHED') and (`coverage_status` = _utf8mb4'COMPLETE')))), + CONSTRAINT `chk_selfop_sync_run` CHECK (((`attempt_no` > 0) and (`page_count` >= 0) and (`unique_object_count` >= 0) and (`execution_purpose` in (_utf8mb4'BUSINESS',_utf8mb4'VALIDATION')) and ((`execution_purpose` <> _utf8mb4'VALIDATION') or (`status` not in (_utf8mb4'PUBLISHED',_utf8mb4'REUSED'))) and ((`source_total_count` is null) or (`source_total_count` >= 0)) and (((`run_role` = _utf8mb4'ACCOUNT_DISCOVERY') and (`scope_id` is null) and (`warehouse_code` is null)) or ((`run_role` = _utf8mb4'WAREHOUSE_SYNC') and (`scope_id` is not null) and (`warehouse_code` is not null))))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='执行尝试、覆盖证明和版本头'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_sync_scope`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_sync_scope` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `data_family` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'OUTBOUND 或 INVENTORY;按账号+仓库+任务族互斥', + `scope_json` json NOT NULL COMMENT '本仓范围规则,customerMode=ALL_AUTHORIZED,库存为全部品质;当前账号全部授权货主均自营,不将当天出现的货主列表当白名单', + `scope_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '仓库、货主范围规则、品质、时区及单位口径的规范化摘要;不包含当天观测到的客户/SKU 集合', + `business_timezone` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '本仓报表及调度时区,初始建议值为 Asia/Shanghai,各仓独立核准', + `source_timezone` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '本仓已核准的出库源时间时区', + `schedule_cron` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '本仓、本任务族的调度表达式,按 business_timezone 执行', + `stale_after_seconds` int NOT NULL COMMENT '本仓、本任务族数据过期阈值,必须为正;库存初始可采用 26 小时,后续按仓调整', + `warehouse_policy_ref` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '未来按仓告警/补货策略的稳定引用;一期为空,不建告警规则表', + `initial_from_utc` datetime(3) DEFAULT NULL COMMENT '首采固定UTC下界,按本仓业务时区减一个月', + `initial_until_utc` datetime(3) DEFAULT NULL COMMENT '首采固定UTC截止,重试不重新取当前时间', + `discovery_from_utc` datetime(3) DEFAULT NULL COMMENT '本仓已发布的连续销量覆盖UTC下界', + `discovery_until_utc` datetime(3) DEFAULT NULL COMMENT '本仓已发布的连续销量覆盖UTC上界,不含端点', + `fact_revision` bigint NOT NULL DEFAULT '0' COMMENT '出库事实归并修订号;内容实际变化才增加', + `published_fact_revision` bigint NOT NULL DEFAULT '0' COMMENT '本仓最后已发布销量版所使用的事实修订号', + `rebuild_required` tinyint NOT NULL DEFAULT '0' COMMENT '事实变化待重建标记;跨仓修订同时置旧、新仓为 1', + `invalidated_at_utc` datetime(3) DEFAULT NULL COMMENT '最近一次因事实修订使当前版待重建的时刻', + `published_run_id` bigint DEFAULT NULL COMMENT '唯一对外可见版本,指向本任务族已发布执行批次', + `lease_owner` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '当前执行者令牌,不使用仅进程内锁', + `lease_until_utc` datetime(3) DEFAULT NULL COMMENT '租约到期时刻', + `fence_token` bigint NOT NULL DEFAULT '0' COMMENT '每次取得租约递增,事实提交和发布均校验', + `known_order_cursor_id` bigint DEFAULT NULL COMMENT '本仓已可靠处理的稳定订单内部标识游标', + `known_order_round_max_id` bigint DEFAULT NULL COMMENT '本轮已知单复查固定最大内部标识', + `known_order_round_started_at_utc` datetime(3) DEFAULT NULL COMMENT '本仓已知单复查轮次开始UTC时间', + `known_order_round_finished_at_utc` datetime(3) DEFAULT NULL COMMENT '本仓已知单完整轮转完成UTC时间', + `history_round_from_utc` datetime(3) DEFAULT NULL COMMENT '本仓历史核对固定UTC下界', + `history_round_until_utc` datetime(3) DEFAULT NULL COMMENT '本仓历史核对固定UTC上界', + `history_slice_cursor_utc` datetime(3) DEFAULT NULL COMMENT '本仓下一待核对历史切片UTC位置', + `history_round_finished_at_utc` datetime(3) DEFAULT NULL COMMENT '上一轮历史时间片全部完成时刻', + `review_request_budget` int NOT NULL COMMENT '本仓单次复查最大请求数,必须为正', + `review_time_budget_seconds` int NOT NULL COMMENT '本仓单次复查最大执行秒数,必须为正', + `review_target_days` int NOT NULL DEFAULT '7' COMMENT '建议每 7 天轮转一遍,属于待配额验证的目标,不保证首次大量详情在单次调度内完成', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_scope` (`source_account`,`warehouse_code`,`data_family`), + KEY `idx_selfop_scope_lease` (`lease_until_utc`), + CONSTRAINT `chk_selfop_sync_scope` CHECK (((`data_family` in (_utf8mb4'OUTBOUND',_utf8mb4'INVENTORY')) and (`stale_after_seconds` > 0) and (`review_request_budget` > 0) and (`review_time_budget_seconds` > 0) and (`review_target_days` > 0) and (`fact_revision` >= 0) and (`published_fact_revision` >= 0) and (`fence_token` >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='分仓任务族租约及发布指针'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_sync_stage`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_sync_stage` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `run_id` bigint NOT NULL COMMENT '首次写入该暂存对象的执行尝试', + `stage_batch_id` bigint NOT NULL COMMENT '出库取共享发现 task.id,便于断点续取;库存取本次 run.id,严禁跨尝试混页', + `last_fetch_run_id` bigint NOT NULL COMMENT '最后一次补齐该对象的执行尝试,保留重试来源', + `observation_sequence` bigint DEFAULT NULL COMMENT '该完整出库详情实际请求的采集代次;有效出库对象必填,目录待补对象和库存为空;不能填 task 调度序号', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `previous_warehouse_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '路由时从已有单据识别的原仓库;归并前再校验,跨仓时通知两仓', + `object_type` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'OUTBOUND_ORDER 或 PRODUCT_STOCK', + `object_key_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '规范化业务键摘要;原业务键保留在载荷中并核对', + `first_page_no` int NOT NULL COMMENT '首次发现页码', + `observed_at_utc` datetime(3) NOT NULL COMMENT '实际接收该对象的时刻', + `content_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '完整规范化对象摘要', + `payload_json` json NOT NULL COMMENT '目录阶段可先存订单头,详情齐后补完整 SKU;VALID 后不再修改;库存保存产品库存字段', + `before_payload_json` json DEFAULT NULL COMMENT '订单归并事务内记录归并前的完整规范化事实;新增单为空', + `validation_status` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT '暂存校验状态:PENDING待核验、VALID有效、INVALID异常', + `validation_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '字段缺失、冲突、未知时区等原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_stage_object` (`stage_batch_id`,`object_type`,`object_key_hash`), + KEY `idx_selfop_stage_history` (`object_type`,`object_key_hash`,`stage_batch_id`), + KEY `idx_selfop_stage_observation` (`object_type`,`object_key_hash`,`validation_status`,`observation_sequence`), + KEY `idx_selfop_stage_fact_observation` (`observation_sequence`,`warehouse_code`,`object_type`,`validation_status`,`run_id`,`observed_at_utc`), + KEY `idx_selfop_stage_route` (`stage_batch_id`,`warehouse_code`,`validation_status`), + CONSTRAINT `chk_selfop_sync_stage` CHECK (((`first_page_no` > 0) and ((`object_type` <> _utf8mb4'OUTBOUND_ORDER') or (`validation_status` <> _utf8mb4'VALID') or (`observation_sequence` is not null)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='待验证事实与修订证据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_sync_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_sync_task` ( + `id` bigint NOT NULL COMMENT '应用生成的记录标识', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + `task_no` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '稳定采集业务任务号,重试沿用,不展示内部数据库标识', + `execution_purpose` varchar(16) COLLATE utf8mb4_bin NOT NULL COMMENT 'BUSINESS或VALIDATION;由服务端冻结用途,子任务继承', + `validation_root_task_id` bigint DEFAULT NULL COMMENT '验证会话唯一预算根任务,根引用自身,正式任务为空', + `validation_budget_json` json DEFAULT NULL COMMENT '验证根冻结硬预算、累计消耗、库存分仓页数、截止时刻及停止原因', + `business_root_task_id` bigint DEFAULT NULL COMMENT '测试人工正式采集共享预算根任务;生产任务为空', + `business_budget_json` json DEFAULT NULL COMMENT '仅测试正式采集根保存冻结请求上限、截止时间、消耗及停止原因', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `task_role` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT 'ACCOUNT_DISCOVERY 或 WAREHOUSE_SYNC', + `scope_id` bigint DEFAULT NULL COMMENT '分仓任务必须引用分仓作用域;仅账号共享发现为空', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '来源仓库原始编码,按仓独立统计', + `discovery_scope_id` bigint DEFAULT NULL COMMENT '共享发现必填,引用账号发现协调器', + `upstream_task_id` bigint DEFAULT NULL COMMENT '出库分仓任务引用已持久化目录的共享发现切片;库存无此上游', + `discovery_sequence` bigint DEFAULT NULL COMMENT '共享任务及分仓子任务继承的调度顺序;重试保持不变,不用于事实覆盖判定', + `observation_baseline` bigint DEFAULT NULL COMMENT '共享任务建立时已分配的最大详情采集代次,分仓子任务继承;用于识别旧任务恢复期间出现的其他任务新观测', + `request_key` varchar(128) COLLATE utf8mb4_bin NOT NULL COMMENT '共享发现包含切片/计划槽位;分仓任务包含仓库、任务族及切片或计划槽位;人工附请求幂等键', + `request_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '请求类型、查询范围、范围摘要的指纹;同键不同请求拒绝', + `task_type` varchar(24) COLLATE utf8mb4_bin NOT NULL COMMENT '计划、补采、复核、重建与验证;INVENTORY_FLOW_HISTORY为流水片,INVENTORY_HISTORY_BUILD为本地历史还原', + `planned_at_utc` datetime(3) DEFAULT NULL COMMENT '计划时点;人工请求可空', + `query_from_utc` datetime(3) DEFAULT NULL COMMENT '出库查询区间UTC下界,库存不填', + `query_until_utc` datetime(3) DEFAULT NULL COMMENT '出库查询区间UTC上界,不含端点,库存不填', + `query_scope_json` json NOT NULL COMMENT '不变的账号、货主、仓库、品质和查询条件快照;按单补查在此记录单号', + `routing_manifest_json` json DEFAULT NULL COMMENT '共享切片目录完整标记、每仓对象键集合/数量和详情就绪状态;各对象记录 effectiveStageId,需新代次复查时记录 refreshTaskId,完成后指向新有效暂存;不改写旧载荷', + `scope_hash` char(64) COLLATE utf8mb4_bin NOT NULL COMMENT '建任务时的业务范围摘要', + `status` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT 'WAITING_SOURCE、READY、RUNNING、RETRY_WAIT、SUCCEEDED、DEAD、SAMPLE_DONE;样本结束不等于正式成功', + `apply_state` varchar(20) COLLATE utf8mb4_bin NOT NULL COMMENT '正式出库归并NOT_STARTED、APPLYING、BLOCKED、APPLIED;共享发现、库存、重建及验证为NOT_APPLICABLE', + `affected_warehouse_codes_json` json DEFAULT NULL COMMENT '本次归并可能改变的仓集合,包含原仓与新仓;首段事实写入前登记,扩大集合须先取得新增仓租约并锁 scope,不能写完再补', + `apply_started_at_utc` datetime(3) DEFAULT NULL COMMENT '首段事实提交UTC时间', + `apply_completed_at_utc` datetime(3) DEFAULT NULL COMMENT '全部必需事实归并完成UTC时间,失败不填', + `attempt_count` int NOT NULL DEFAULT '0' COMMENT '已创建的执行尝试数', + `next_retry_at_utc` datetime(3) DEFAULT NULL COMMENT '有限退避后下一次可执行时刻', + `requested_by` varchar(128) COLLATE utf8mb4_bin DEFAULT NULL COMMENT '人工请求的平台操作人,仅内部审计', + `result_published_run_id` bigint DEFAULT NULL COMMENT '正式成功任务实际发布或复用的版本;验证和共享发现为空', + `progress_json` json DEFAULT NULL COMMENT '与可靠检查点一起提交的有界阶段、单位、尝试和质量进度,不保存单据集合', + `progress_updated_at_utc` datetime(3) DEFAULT NULL COMMENT '最后可靠业务检查点UTC时间,与执行心跳分开', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_selfop_task_no` (`task_no`), + UNIQUE KEY `uk_selfop_task_request` (`source_account`,`request_key`), + KEY `idx_selfop_task_poll` (`status`,`next_retry_at_utc`,`id`), + KEY `idx_selfop_task_upstream` (`upstream_task_id`,`warehouse_code`), + KEY `idx_selfop_task_apply` (`apply_state`,`id`), + KEY `idx_selfop_task_scope_list` (`scope_id`,`created_at`,`id`), + KEY `idx_selfop_task_account_list` (`source_account`,`task_role`,`created_at`,`id`), + KEY `idx_selfop_task_business_root` (`business_root_task_id`,`status`,`id`), + CONSTRAINT `chk_selfop_business_budget` CHECK ((((`business_root_task_id` is null) and (`business_budget_json` is null)) or ((`execution_purpose` = _utf8mb4'BUSINESS') and (`business_root_task_id` is not null) and (((`business_root_task_id` = `id`) and (`business_budget_json` is not null)) or ((`business_root_task_id` <> `id`) and (`business_budget_json` is null)))))), + CONSTRAINT `chk_selfop_sync_task` CHECK (((`attempt_count` >= 0) and (`execution_purpose` in (_utf8mb4'BUSINESS',_utf8mb4'VALIDATION')) and (((`execution_purpose` = _utf8mb4'BUSINESS') and (`validation_root_task_id` is null) and (`validation_budget_json` is null)) or ((`execution_purpose` = _utf8mb4'VALIDATION') and (`validation_root_task_id` is not null) and (`apply_state` = _utf8mb4'NOT_APPLICABLE') and (`result_published_run_id` is null) and (`status` <> _utf8mb4'SUCCEEDED'))) and (((`task_role` = _utf8mb4'ACCOUNT_DISCOVERY') and (`scope_id` is null) and (`warehouse_code` is null) and (`discovery_scope_id` is not null)) or ((`task_role` = _utf8mb4'WAREHOUSE_SYNC') and (`scope_id` is not null) and (`warehouse_code` is not null))))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='一次计划或人工请求'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_selfop_warehouse_daily_sales`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_selfop_warehouse_daily_sales` ( + `sales_run_id` bigint NOT NULL COMMENT '完整销量计算版本标识', + `source_account` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '受控来源账号别名,不保存密钥', + `warehouse_code` varchar(64) COLLATE utf8mb4_bin NOT NULL COMMENT '来源仓库原始编码', + `business_date` date NOT NULL COMMENT '已核准业务日', + `observed_quantity` decimal(38,6) DEFAULT NULL COMMENT '该日PCS非负有效出库量之和,无有效行时为空', + `invalid_row_count` bigint NOT NULL COMMENT '该日数量为空或负数或单位非PCS的日事实行数', + `source_row_count` bigint NOT NULL COMMENT '该日参与仓库汇总的SKU日事实总行数', + `created_at` datetime(3) NOT NULL COMMENT 'UTC创建时间', + `updated_at` datetime(3) NOT NULL COMMENT 'UTC更新时间', + PRIMARY KEY (`sales_run_id`,`source_account`,`warehouse_code`,`business_date`), + CONSTRAINT `chk_selfop_warehouse_daily_sales` CHECK (((`source_row_count` > 0) and (`invalid_row_count` >= 0) and (`invalid_row_count` <= `source_row_count`) and ((`observed_quantity` is null) or (`observed_quantity` >= 0)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='完整销量版本按仓按日汇总,随SKU日事实批次原子构建'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_us_truck_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_us_truck_order` ( + `id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `transport_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `origin` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `origin_address` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_address` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `container_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pallet_count` int NOT NULL DEFAULT '1', + `package_count` int NOT NULL DEFAULT '0', + `received_pallet_count` int NOT NULL DEFAULT '0', + `allocated_pallet_count` int NOT NULL DEFAULT '0', + `shipped_pallet_count` int NOT NULL DEFAULT '0', + `total_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000', + `total_volume_cbm` decimal(18,6) NOT NULL DEFAULT '0.000000', + `budget_amount` decimal(18,2) DEFAULT NULL, + `currency` varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `contact_info` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `requested_date` date NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `receipt_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `receipt_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `vehicle_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车辆类型:PALLET_15为15拖,PALLET_30为30拖;拼车未提供时为空', + `origin_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地联系人', + `origin_contact_phone` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运地联系电话', + `destination_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地联系人', + `destination_contact_phone` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地联系电话', + `pickup_at` datetime DEFAULT NULL COMMENT '取货时间,按填写的日期时分保存,不转换时区', + `delivery_at` datetime DEFAULT NULL COMMENT '送货时间,按填写的日期时分保存;拼车来源未提供时为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_us_truck_order_no` (`customer_id`,`order_no`), + UNIQUE KEY `uk_ll_us_truck_order_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_us_truck_order_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_us_truck_order_date` (`customer_id`,`requested_date`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='US domestic trucking customer order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_us_truck_order_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_us_truck_order_operation` ( + `id` bigint NOT NULL COMMENT '操作记录ID', + `order_id` bigint NOT NULL COMMENT '美国卡车订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `operation_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '具名操作类型', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作请求内容哈希', + `result_version` bigint NOT NULL COMMENT '操作完成后的订单版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_us_truck_operation_key` (`customer_id`,`order_id`,`idempotency_key`), + KEY `idx_ll_us_truck_operation_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车订单幂等操作账'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_us_truck_order_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_us_truck_order_pallet` ( + `id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sequence_no` int NOT NULL, + `source` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `package_count` int DEFAULT NULL, + `received_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_us_truck_order_pallet_no` (`customer_id`,`order_id`,`pallet_no`), + UNIQUE KEY `uk_ll_us_truck_order_pallet_seq` (`customer_id`,`order_id`,`sequence_no`), + KEY `idx_ll_us_truck_order_pallet_list` (`customer_id`,`order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_us_truck_order_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_us_truck_order_status_history` ( + `id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `operation_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UPDATE' COMMENT '具名操作类型', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + KEY `idx_ll_us_truck_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable US trucking order status history'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_vehicle`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_vehicle` ( + `id` bigint NOT NULL COMMENT '车辆档案ID', + `plate_number` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车牌号', + `model` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车型', + `fuel_consumption_per100_km` decimal(10,2) NOT NULL COMMENT '百公里油耗,单位升', + `mileage_km` bigint NOT NULL COMMENT '当前公里码,单位公里', + `driver_or_dispatch_company` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机姓名或外派单位', + `phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机或外派单位联系电话', + `region` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + `ownership` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更新人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + `max_pallet_spaces` int DEFAULT NULL COMMENT '最大标准托位,未知时为空', + `max_load_kg` decimal(12,3) DEFAULT NULL COMMENT '最大载重,千克', + `max_volume_cbm` decimal(12,3) DEFAULT NULL COMMENT '可用容积,立方米', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_vehicle_plate` (`plate_number`), + KEY `idx_ll_vehicle_page` (`ownership`,`region`,`status`,`updated_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台车辆主数据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_wallet_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_wallet_event_inbox` ( + `event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内Finance资金结果事件ID', + `command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外资金指令事件ID', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256,包含sha256:前缀', + `payload_json` json NOT NULL COMMENT '冻结事件信封', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVED或PROCESSED', + `received_at` datetime(3) NOT NULL COMMENT '接收时间', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (`event_id`), + UNIQUE KEY `uk_ll_wallet_command_result` (`command_event_id`), + KEY `idx_ll_wallet_event_status` (`status`,`received_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区客户资金结果幂等收件箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_carton` ( + `id` bigint NOT NULL COMMENT '预报箱主键', + `transfer_order_id` bigint NOT NULL COMMENT '转运单ID', + `transfer_item_id` bigint DEFAULT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛', + `customer_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定', + `carton_index` int NOT NULL COMMENT '全单箱序号', + `total_cartons` int NOT NULL COMMENT '首次生成时全单总箱数', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `length_cm` decimal(12,2) DEFAULT NULL COMMENT '箱子实测长度厘米', + `width_cm` decimal(12,2) DEFAULT NULL COMMENT '箱子实测宽度厘米', + `height_cm` decimal(12,2) DEFAULT NULL COMMENT '箱子实测高度厘米', + `gross_weight_kg` decimal(12,2) DEFAULT NULL COMMENT '箱子实测重量千克', + `counted_quantity` bigint DEFAULT NULL COMMENT '箱内SKU实点件数', + `count_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱内清点幂等键', + `count_request_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱内清点请求摘要', + `counted_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱内清点操作人', + `counted_at` datetime(3) DEFAULT NULL COMMENT '箱内清点完成时间', + `discrepancy_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实收与预报箱数差异原因', + `transfer_pallet_id` bigint DEFAULT NULL COMMENT '签收箱所属转运托盘事实ID', + `sku_items_json` json DEFAULT NULL COMMENT '实收箱内SKU及逐项件数明细', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_carton_mark` (`customer_id`,`carton_mark`), + UNIQUE KEY `uk_ll_wh_transfer_carton_index` (`transfer_order_id`,`carton_index`), + KEY `idx_ll_wh_transfer_carton_list` (`customer_id`,`transfer_order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运系统箱唛预报'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_inventory_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_inventory_allocation` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `transfer_order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `inventory_position_id` bigint NOT NULL, + `inventory_balance_id` bigint NOT NULL, + `inbound_order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `pallet_id` bigint NOT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `package_count` int NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RESERVED', + `active_position_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `allocated_at` datetime(3) NOT NULL, + `shipped_at` datetime(3) DEFAULT NULL, + `received_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次来源分配的实际SKU快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_allocation_position` (`transfer_order_id`,`inventory_position_id`), + UNIQUE KEY `uk_ll_wh_transfer_active_position` (`customer_id`,`active_position_key`), + KEY `idx_ll_wh_transfer_allocation_list` (`customer_id`,`transfer_order_id`,`status`), + KEY `idx_ll_wh_transfer_allocation_position` (`transfer_order_id`,`inventory_position_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Exclusive source inventory positions allocated to a warehouse transfer'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_item` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `transfer_order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `package_count` int NOT NULL, + `shipping_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `declared_quantity` bigint DEFAULT NULL COMMENT 'SKU鐢虫姤浠舵暟锛岄潪棰勮鎴栧疄鏀剁鏁�', + PRIMARY KEY (`id`), + KEY `idx_ll_wh_transfer_item_list` (`customer_id`,`transfer_order_id`), + KEY `idx_ll_wh_transfer_item_sku` (`customer_id`,`sku_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Frozen warehouse transfer cargo items'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_measurement_photo`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_measurement_photo` ( + `id` bigint NOT NULL COMMENT '测量照片引用ID', + `transfer_order_id` bigint NOT NULL COMMENT '仓储转运单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `file_id` bigint NOT NULL COMMENT '文件服务文件ID', + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照', + `content_type` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照', + `size_bytes` bigint NOT NULL DEFAULT '0' COMMENT '文件字节数快照', + `uploaded_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人', + `uploaded_at` datetime(3) NOT NULL COMMENT '上传时间', + `submitted_at` datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`), + KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `transfer_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `inbound_order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `lastleg_order_id` bigint NOT NULL, + `lastleg_order_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operating_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提货地址或中转仓收货地址', + `pickup_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系人', + `pickup_contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话', + `package_count` int NOT NULL, + `delivery_method` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', + `estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计入仓时间,库存转运不填写', + `estimated_ship_at` datetime(3) DEFAULT NULL COMMENT '预计发货时间', + `fba_shipment_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号', + `tracking_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fba_box_nos` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `need_relabel` tinyint NOT NULL DEFAULT '0', + `relabel_application_id` bigint DEFAULT NULL, + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `relabel_completed_at` datetime(3) DEFAULT NULL COMMENT '鍏宠仈璐存爣瀹屾垚鏃堕棿', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW', + `review_decision` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核决定', + `review_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注或驳回原因', + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间', + `allocation_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配幂等键', + `allocation_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配请求哈希', + `outbound_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库幂等键', + `outbound_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库请求哈希', + `outbound_at` datetime(3) DEFAULT NULL COMMENT '出库时间', + `measured_at` datetime(3) DEFAULT NULL COMMENT 'PDA测量完成时间', + `actual_ship_at` datetime(3) DEFAULT NULL COMMENT '实际发货时间', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `label_deadline` datetime(3) DEFAULT NULL COMMENT '换标截止时间', + `relabel_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标联系人', + `relabel_contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标联系电话', + `relabel_charge_amount` decimal(20,2) DEFAULT NULL COMMENT '换标费用CNY', + `relabel_delivery_method` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后派送方式', + `palletize_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA打托幂等键', + `palletize_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA打托请求哈希', + `confirmation_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认幂等键', + `confirmation_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认请求哈希', + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认测量时间', + `destination_type` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + `private_destination_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓完整地址', + `private_destination_postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + `private_destination_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + `private_destination_contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', + `destination_binding_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定幂等键', + `destination_binding_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定请求哈希', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_no` (`customer_id`,`transfer_no`), + UNIQUE KEY `uk_ll_wh_transfer_key` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_wh_transfer_tail_order` (`lastleg_order_id`), + KEY `idx_ll_wh_transfer_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_wh_transfer_inbound` (`customer_id`,`inbound_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Customer warehouse transfer order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_pallet` ( + `id` bigint NOT NULL COMMENT '打托事实ID', + `transfer_order_id` bigint NOT NULL COMMENT '转运单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `sequence_no` int NOT NULL COMMENT '托序号', + `warehouse_pallet_id` bigint DEFAULT NULL COMMENT '鐢熸垚鍚庣殑娴峰?浠撶湡瀹炴墭鐩業D', + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鐢熸垚鍚庣殑鐪熷疄鎵樼洏鐮', + `warehouse_location_id` bigint DEFAULT NULL COMMENT '鐢熸垚鍚庣殑鐪熷疄搴撲綅ID', + `warehouse_location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实库位码', + `warehouse_resource_lock_id` bigint DEFAULT NULL COMMENT '鐢熸垚鍚庣殑浠撳偍璧勬簮閿両D', + `carton_count` int NOT NULL COMMENT '本托实收箱数', + `length_cm` decimal(12,2) NOT NULL COMMENT '实测长cm', + `width_cm` decimal(12,2) NOT NULL COMMENT '实测宽cm', + `height_cm` decimal(12,2) NOT NULL COMMENT '实测高cm', + `gross_weight_kg` decimal(12,3) NOT NULL COMMENT '实测毛重kg', + `volume_cbm` decimal(18,6) NOT NULL COMMENT '实测体积CBM', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '打托状态', + `draft_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鑽夌?淇濆瓨骞傜瓑閿', + `draft_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鑽夌?璇锋眰鎽樿?', + `generate_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鏂版墭鐩樼敓鎴愬箓绛夐敭', + `bind_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鎵?爜缁戝畾骞傜瓑閿', + `bound_at` datetime(3) DEFAULT NULL COMMENT '鎵?爜纭??缁戝畾鏃堕棿', + `completed_at` datetime(3) DEFAULT NULL COMMENT '鏁村崟鎵撴墭瀹屾垚鏃堕棿', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_seq` (`transfer_order_id`,`sequence_no`), + UNIQUE KEY `uk_ll_transfer_pallet_physical` (`customer_id`,`warehouse_pallet_id`), + KEY `idx_ll_transfer_pallet_order` (`customer_id`,`transfer_order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA实测打托事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_pallet_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_pallet_item` ( + `id` bigint NOT NULL COMMENT '主键ID', + `transfer_order_id` bigint NOT NULL COMMENT '转运单ID', + `transfer_pallet_id` bigint NOT NULL COMMENT '转运托盘事实ID', + `transfer_item_id` bigint NOT NULL COMMENT '转运申报明细ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `package_count` int NOT NULL COMMENT '本托该SKU实收箱数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_item` (`transfer_pallet_id`,`transfer_item_id`), + KEY `idx_ll_transfer_pallet_item_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运每托SKU箱数回填'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_receipt` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `transfer_order_id` bigint NOT NULL, + `allocation_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_inventory_position_id` bigint NOT NULL, + `destination_inventory_balance_id` bigint NOT NULL, + `destination_inventory_position_id` bigint NOT NULL, + `package_count` int NOT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `inventory_operation_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `received_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_receipt_source` (`transfer_order_id`,`source_inventory_position_id`), + UNIQUE KEY `uk_ll_wh_transfer_receipt_key` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_wh_transfer_receipt_operation` (`customer_id`,`inventory_operation_key`), + KEY `idx_ll_wh_transfer_receipt_list` (`customer_id`,`transfer_order_id`,`received_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Idempotent destination stock receipt for one transfer source position'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_receipt_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_receipt_command` ( + `id` bigint NOT NULL COMMENT '收货命令主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', + `transfer_order_id` bigint NOT NULL COMMENT '转运单标识', + `source_inventory_position_id` bigint NOT NULL COMMENT '本次收货来源库存位置', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始收货幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '完整原始收货请求摘要', + `request_json` json NOT NULL COMMENT '完整原始收货请求快照', + `target_warehouse_id` bigint NOT NULL COMMENT '实际目标海外收货仓', + `target_pallet_id` bigint NOT NULL COMMENT '目标实物托盘', + `target_location_id` bigint NOT NULL COMMENT '目标实际库位', + `target_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '共享目标资源绑定摘要', + `target_json` json NOT NULL COMMENT '权威目标仓托盘库位及目的仓快照', + `reservation_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '同转运同目标托盘共享资源占用键', + `resource_lock_id` bigint DEFAULT NULL COMMENT '已确认有效的海外资源锁标识', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令状态PREPARED准备完成RESERVED资源占用COMPLETED收货完成', + `created_at` datetime(3) NOT NULL COMMENT '命令创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '命令最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_receipt_cmd_source` (`transfer_order_id`,`source_inventory_position_id`), + UNIQUE KEY `uk_ll_wh_receipt_cmd_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_wh_receipt_cmd_target` (`customer_id`,`transfer_order_id`,`target_pallet_id`), + CONSTRAINT `ck_ll_wh_receipt_cmd_status` CHECK ((`status` in (_utf8mb4'PREPARED',_utf8mb4'RESERVED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_relabel_binding`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_relabel_binding` ( + `id` bigint NOT NULL COMMENT '主键ID', + `transfer_order_id` bigint NOT NULL COMMENT '转运单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标类型', + `customer_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户原箱唛', + `original_sku` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '旧SKU', + `new_sku` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新SKU', + `quantity` int NOT NULL COMMENT '换标件数', + `fba_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新FBA箱唛', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA目的仓', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `source_allocation_id` bigint DEFAULT NULL COMMENT '已有库存转运的原始来源分配ID', + `relabel_item_id` bigint DEFAULT NULL COMMENT '本票关联换标明细ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_relabel_mark` (`transfer_order_id`,`customer_carton_mark`), + KEY `idx_ll_transfer_relabel_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运换标预绑定明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_source_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_source_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `transfer_order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_lastleg_order_id` bigint NOT NULL, + `source_lastleg_order_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `inbound_order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_source` (`transfer_order_id`,`source_lastleg_order_id`), + KEY `idx_ll_wh_transfer_source_list` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Selected historical customer orders and inbound bindings'; +/*!40101 SET character_set_client = @saved_cs_client */; + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `baiyue_firstleg` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; + +USE `baiyue_firstleg`; +DROP TABLE IF EXISTS `event_delivery_attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `event_delivery_attempt` ( + `attempt_id` varchar(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + `service_name` varchar(32) NOT NULL COMMENT '本库所属生产服务', + `event_type` varchar(96) NOT NULL COMMENT '白名单业务事件类型', + `entry_id` varchar(128) NOT NULL COMMENT '生产者发件箱主键', + `event_id` varchar(128) DEFAULT NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + `target_name` varchar(64) NOT NULL COMMENT '固定目标业务服务', + `started_at` datetime(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + `finished_at` datetime(3) DEFAULT NULL COMMENT '尝试完成或租约过期恢复时间', + `duration_ms` bigint DEFAULT NULL COMMENT '尝试耗时毫秒', + `outcome` varchar(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + `error_code` varchar(64) DEFAULT NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (`attempt_id`), + KEY `idx_delivery_attempt_entry` (`service_name`,`entry_id`,`started_at`), + KEY `idx_delivery_attempt_outcome` (`outcome`,`started_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本服务业务事件逐次投递审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `event_delivery_retry_audit`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `event_delivery_retry_audit` ( + `request_id` varchar(36) NOT NULL COMMENT '人工重试请求UUID和幂等键', + `producer_table` varchar(64) NOT NULL COMMENT '本服务发件箱表名', + `entry_id` varchar(64) NOT NULL COMMENT '发件箱记录主键', + `event_id` varchar(128) NOT NULL COMMENT '原事件全局标识', + `event_type` varchar(64) NOT NULL COMMENT '原事件类型', + `payload_hash` varchar(71) NOT NULL COMMENT '原事件不可变负载摘要', + `operator_name` varchar(128) NOT NULL COMMENT '持有运维凭据的操作人标识', + `reason` varchar(500) NOT NULL COMMENT '人工重试原因', + `previous_failures` int NOT NULL COMMENT '重试前累计失败次数', + `previous_error` varchar(1000) DEFAULT NULL COMMENT '重试前最后一次脱敏错误', + `requested_at` datetime(3) NOT NULL COMMENT '重试申请提交时间', + PRIMARY KEY (`request_id`), + KEY `idx_event_retry_entry` (`producer_table`,`entry_id`,`requested_at`), + KEY `idx_event_retry_event` (`event_id`,`requested_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='业务事件人工重试审计,与恢复待发送状态同事务提交'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_assignment_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_assignment_history` ( + `id` bigint unsigned NOT NULL COMMENT '指派历史ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `task_id` bigint unsigned NOT NULL COMMENT '所属履约任务ID', + `previous_assignee_id` bigint unsigned DEFAULT NULL COMMENT '原负责人ID', + `assignee_id` bigint unsigned NOT NULL COMMENT '新负责人ID', + `assignee_name` varchar(64) NOT NULL COMMENT '新负责人名称快照', + `reason` varchar(500) DEFAULT NULL COMMENT '指派或改派原因', + `operator_id` bigint unsigned NOT NULL COMMENT '执行指派的操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '指派发生时间', + PRIMARY KEY (`id`), + KEY `idx_assignment_task_time` (`tenant_id`,`task_id`,`created_at`), + KEY `fk_assignment_task` (`task_id`), + CONSTRAINT `fk_assignment_task` FOREIGN KEY (`task_id`) REFERENCES `fl_fulfillment_task` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_fba_rate`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_fba_rate` ( + `id` bigint unsigned NOT NULL COMMENT '船司FBA价格ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0', + `price_book_version_id` bigint unsigned DEFAULT NULL COMMENT '整表价格版本ID;历史兼容价格可为空', + `rate_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台公共价格编码', + `rate_version` int unsigned NOT NULL COMMENT '价格版本号', + `origin_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID', + `origin_warehouse_code` varchar(64) DEFAULT NULL COMMENT '起运仓稳定编码', + `origin_warehouse_name` varchar(128) NOT NULL COMMENT '起运仓名称快照', + `business_type` varchar(32) NOT NULL COMMENT '业务或计费类型', + `channel_code` varchar(64) DEFAULT NULL COMMENT '渠道编码', + `price_model` varchar(32) NOT NULL DEFAULT 'FBA_WAREHOUSE_RATE' COMMENT '价格模型', + `carrier_id` bigint unsigned DEFAULT NULL COMMENT '船司逻辑ID', + `carrier_name` varchar(128) DEFAULT NULL COMMENT '历史承运商名称;渠道价格可为空', + `destination_type` varchar(16) DEFAULT NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空', + `destination_code` varchar(32) DEFAULT NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空', + `destination_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '平台海外仓稳定ID;不存环境专属ID', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `region_code` varchar(16) DEFAULT NULL COMMENT '区域阶梯模型区域编码', + `pricing_tax_mode` varchar(16) DEFAULT NULL COMMENT '报价包税维度;独立于订单tax_mode', + `weight_from_kg` decimal(18,6) DEFAULT NULL COMMENT '区域阶梯起始计费重量KG', + `charge_unit` varchar(16) NOT NULL COMMENT '计费单位:KG或CBM', + `unit_price` decimal(18,6) NOT NULL COMMENT '基础单价', + `currency` char(3) NOT NULL COMMENT '币种', + `additional_fee` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '固定附加费', + `source_sheet_name` varchar(128) DEFAULT NULL COMMENT '原始Excel Sheet', + `source_row_number` int unsigned DEFAULT NULL COMMENT '原始Excel行号', + `source_destination_text` varchar(1000) DEFAULT NULL COMMENT '原始组合仓库或区域文本', + `effective_from` datetime(3) NOT NULL COMMENT '生效开始时间', + `effective_to` datetime(3) DEFAULT NULL COMMENT '生效结束时间', + `status` varchar(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED', + `remark` varchar(500) DEFAULT NULL COMMENT '价格备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fba_rate_version` (`tenant_id`,`rate_code`,`rate_version`), + KEY `idx_fba_rate_unique_match` (`tenant_id`,`origin_warehouse_id`,`business_type`,`channel_code`,`carrier_id`,`destination_code`,`charge_unit`,`status`,`effective_from`,`effective_to`), + KEY `idx_fba_rate_match` (`tenant_id`,`origin_warehouse_id`,`business_type`,`channel_code`,`carrier_id`,`destination_type`,`destination_code`,`destination_warehouse_id`,`charge_unit`,`status`,`effective_from`,`effective_to`), + KEY `idx_fba_rate_book` (`price_book_version_id`,`channel_code`,`origin_warehouse_id`,`price_model`,`status`), + KEY `idx_fba_rate_destination` (`price_book_version_id`,`channel_code`,`origin_warehouse_id`,`destination_type`,`destination_warehouse_id`,`destination_code`), + KEY `idx_fba_rate_region` (`price_book_version_id`,`channel_code`,`origin_warehouse_id`,`region_code`,`weight_from_kg`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司FBA目的仓价格版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_exception_fact`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_exception_fact` ( + `id` bigint unsigned NOT NULL COMMENT '船节点异常事实ID', + `query_id` bigint unsigned NOT NULL COMMENT '所属查询任务ID', + `event_key` varchar(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + `provider_code` varchar(32) NOT NULL COMMENT '供应商编码', + `provider_event_id` varchar(128) NOT NULL COMMENT '供应商稳定事件ID', + `provider_status` varchar(128) NOT NULL COMMENT '供应商原始异常状态', + `exception_code` varchar(32) NOT NULL COMMENT '平台异常编码', + `active` tinyint(1) NOT NULL COMMENT '1异常发生;0异常解除', + `observed_at` datetime(3) NOT NULL COMMENT '供应商观测时间', + `location_code` varchar(32) DEFAULT NULL COMMENT '地点编码', + `location_name` varchar(128) DEFAULT NULL COMMENT '地点名称', + `source_reference` varchar(500) DEFAULT NULL COMMENT '可审计来源引用', + `raw_payload_hash` char(64) NOT NULL COMMENT '原始响应SHA-256', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carrier_schedule_exception_event` (`query_id`,`event_key`), + KEY `idx_carrier_schedule_exception_timeline` (`query_id`,`observed_at`,`id`), + CONSTRAINT `fk_carrier_schedule_exception_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_exception_active` CHECK ((`active` in (0,1))), + CONSTRAINT `chk_carrier_schedule_exception_code` CHECK ((`exception_code` in (_utf8mb4'CUSTOMS_HOLD',_utf8mb4'TERMINAL_HOLD',_utf8mb4'DOCUMENT_HOLD',_utf8mb4'UNKNOWN_HOLD'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船节点异常发生与解除不可变事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_node_fact`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_node_fact` ( + `id` bigint unsigned NOT NULL COMMENT '标准船节点事实ID', + `query_id` bigint unsigned NOT NULL COMMENT '所属查询任务ID', + `event_key` varchar(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + `provider_code` varchar(32) NOT NULL COMMENT '轨迹供应商编码', + `provider_event_id` varchar(128) NOT NULL COMMENT '供应商稳定事件ID', + `provider_node_code` varchar(64) NOT NULL COMMENT '供应商原始节点编码', + `standard_node` varchar(32) NOT NULL COMMENT '标准船节点', + `node_sequence` int unsigned NOT NULL COMMENT '标准节点单调序号', + `observed_at` datetime(3) NOT NULL COMMENT '供应商观测时间', + `estimated_at` datetime(3) DEFAULT NULL COMMENT '供应商预计发生时间', + `actual_at` datetime(3) DEFAULT NULL COMMENT '供应商确认实际发生时间', + `location_code` varchar(32) DEFAULT NULL COMMENT '节点地点编码', + `location_name` varchar(128) DEFAULT NULL COMMENT '节点地点名称', + `source_reference` varchar(500) DEFAULT NULL COMMENT '可审计来源引用', + `raw_payload_hash` char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + `container_booking_id` bigint unsigned NOT NULL COMMENT '所属真实货柜ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carrier_schedule_node_event` (`query_id`,`event_key`), + KEY `idx_carrier_schedule_node_timeline` (`query_id`,`node_sequence`,`observed_at`,`id`), + KEY `idx_container_shipping_node_timeline` (`container_booking_id`,`observed_at`,`id`), + CONSTRAINT `fk_carrier_schedule_node_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_node_sequence` CHECK ((`node_sequence` in (10,20,30,40,50,60,70,80,90,100))), + CONSTRAINT `chk_carrier_schedule_node_standard` CHECK ((`standard_node` in (_utf8mb4'DEPARTED_WAREHOUSE',_utf8mb4'AT_ORIGIN_PORT',_utf8mb4'LOADED_ON_VESSEL',_utf8mb4'DEPARTED_ORIGIN_PORT',_utf8mb4'IN_TRANSIT',_utf8mb4'ARRIVED_DESTINATION_PORT',_utf8mb4'DISCHARGED_FROM_VESSEL',_utf8mb4'PICKUP_READY',_utf8mb4'PICKED_UP',_utf8mb4'EMPTY_RETURNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='供应商标准船节点不可变事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_query`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_query` ( + `id` bigint unsigned NOT NULL COMMENT '船司官网查询任务ID', + `query_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船司官网查询任务编号', + `customer_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '关联海运订舱ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', + `container_scope_id` bigint unsigned GENERATED ALWAYS AS (coalesce(`container_booking_id`,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', + `carrier_code` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + `tracking_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', + `tracking_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '提交至船公司官网的跟踪号码', + `official_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船公司官网查询入口地址快照', + `access_mode` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果', + `window_started_at` datetime(3) NOT NULL COMMENT '当前三天查询窗口起始时间', + `attempt_count` int unsigned NOT NULL DEFAULT '0' COMMENT '当前查询窗口已执行次数;最多3次', + `next_allowed_at` datetime(3) NOT NULL COMMENT '频控规则允许的下次查询时间', + `last_attempt_at` datetime(3) DEFAULT NULL COMMENT '最近一次查询尝试时间', + `last_success_at` datetime(3) DEFAULT NULL COMMENT '最近一次取得有效结果时间', + `source_updated_at` datetime(3) DEFAULT NULL COMMENT '船公司官网展示的结果更新时间', + `expires_at` datetime(3) DEFAULT NULL COMMENT '当前最近有效结果过期时间;到期后仍保留但标记过期', + `result_port_schedule_id` bigint unsigned DEFAULT NULL COMMENT '最近有效结果对应的船期记录ID', + `last_attempt_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近查询尝试状态快照', + `manual_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '需要人工查询或录入的原因', + `last_error_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近查询失败错误码', + `last_error_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近查询失败错误说明', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '平台内部备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `consecutive_failures` int unsigned NOT NULL DEFAULT '0' COMMENT '当前正常查询槽位内的连续技术失败次数', + `latest_node_observed_at` datetime(3) DEFAULT NULL COMMENT '最近实际标准节点发生时间', + `latest_standard_node` varchar(32) DEFAULT NULL COMMENT '最近实际标准船节点;预计事实不推进', + `lease_owner` varchar(64) DEFAULT NULL COMMENT '当前调度租约持有者', + `lease_until` datetime(3) DEFAULT NULL COMMENT '当前调度租约过期时间', + `stop_reason` varchar(500) DEFAULT NULL COMMENT '任务停止原因', + `stopped_at` datetime(3) DEFAULT NULL COMMENT '任务停止时间', + `trigger_source` varchar(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carrier_schedule_query_no` (`query_no`), + UNIQUE KEY `uk_carrier_schedule_query_scope` (`customer_id`,`ocean_booking_id`,`container_scope_id`,`carrier_code`,`tracking_type`,`tracking_no`), + KEY `idx_carrier_schedule_query_due` (`status`,`next_allowed_at`,`id`), + KEY `idx_carrier_schedule_query_customer` (`customer_id`,`updated_at`,`id`), + KEY `idx_carrier_schedule_query_expiry` (`expires_at`,`id`), + KEY `fk_carrier_schedule_query_booking` (`ocean_booking_id`), + KEY `fk_carrier_schedule_query_container` (`container_booking_id`), + KEY `fk_carrier_schedule_query_result` (`result_port_schedule_id`), + CONSTRAINT `fk_carrier_schedule_query_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `fk_carrier_schedule_query_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `fk_carrier_schedule_query_result` FOREIGN KEY (`result_port_schedule_id`) REFERENCES `fl_port_schedule` (`id`), + CONSTRAINT `chk_carrier_schedule_query_attempt` CHECK ((`attempt_count` between 0 and 3)), + CONSTRAINT `chk_carrier_schedule_query_customer` CHECK ((`customer_id` > 0)), + CONSTRAINT `chk_carrier_schedule_query_tracking` CHECK ((`tracking_type` in (_utf8mb4'BOOKING_NO',_utf8mb4'BILL_OF_LADING',_utf8mb4'CONTAINER_NO'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='六家船公司官网查询任务与三天窗口频控'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_query_attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_query_attempt` ( + `id` bigint unsigned NOT NULL COMMENT '船司官网查询尝试ID', + `query_id` bigint unsigned NOT NULL COMMENT '所属船司官网查询任务ID', + `window_attempt_no` int unsigned NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3', + `carrier_code` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次尝试的船公司编码快照', + `access_mode` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次尝试的官网访问方式快照', + `official_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次尝试使用的官网地址快照', + `attempt_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败', + `coverage_gap` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否存在自动化覆盖缺口:0否、1是', + `coverage_gap_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '自动化覆盖缺口编码', + `manual_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '转人工处理原因', + `error_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '查询失败错误码', + `error_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '查询失败错误说明', + `operator_id` bigint unsigned NOT NULL COMMENT '执行本次尝试的操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '尝试记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_carrier_schedule_attempt_query` (`query_id`,`created_at`,`id`), + CONSTRAINT `fk_carrier_schedule_attempt_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_attempt_no` CHECK ((`window_attempt_no` between 1 and 3)), + CONSTRAINT `chk_carrier_schedule_attempt_status` CHECK ((`attempt_status` in (_utf8mb4'MANUAL_REQUIRED',_utf8mb4'SUCCESS',_utf8mb4'FAILED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司官网查询尝试不可变审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_query_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_query_history` ( + `id` bigint unsigned NOT NULL COMMENT '船司查询任务历史ID', + `query_id` bigint unsigned NOT NULL COMMENT '所属船司官网查询任务ID', + `action` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果', + `from_version` int unsigned DEFAULT NULL COMMENT '变更前任务版本;创建时为空', + `to_version` int unsigned NOT NULL COMMENT '变更后任务版本', + `reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作原因或说明', + `before_snapshot` json DEFAULT NULL COMMENT '变更前任务数据快照;创建时为空', + `after_snapshot` json NOT NULL COMMENT '变更后任务数据快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_carrier_schedule_history_query` (`query_id`,`created_at`,`id`), + CONSTRAINT `fk_carrier_schedule_history_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_history_action` CHECK ((`action` in (_utf8mb4'CREATE',_utf8mb4'ATTEMPT',_utf8mb4'MANUAL_RESULT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司查询任务不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_query_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_query_operation` ( + `id` bigint unsigned NOT NULL COMMENT '船司查询写操作幂等记录ID', + `query_id` bigint unsigned DEFAULT NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', + `operator_id` bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + `operation` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端写操作幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + `result_json` json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carrier_schedule_operation` (`operator_id`,`idempotency_key`), + KEY `idx_carrier_schedule_operation_query` (`query_id`,`created_at`,`id`), + CONSTRAINT `fk_carrier_schedule_operation_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司查询写操作幂等与请求摘要'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_chargeable_weight_confirmation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_chargeable_weight_confirmation` ( + `id` bigint NOT NULL COMMENT '确认记录ID', + `tenant_id` bigint NOT NULL COMMENT '客户隔离ID', + `inbound_id` bigint NOT NULL COMMENT '独立来源入仓单ID', + `operator_id` bigint NOT NULL COMMENT '确认客户成员ID', + `command_key` varchar(128) NOT NULL COMMENT '成员确认幂等键', + `fingerprint` char(64) NOT NULL COMMENT '计费重及测量轮次摘要', + `chargeable_weight_kg` decimal(18,4) NOT NULL COMMENT '客户确认计费重千克', + `confirmed_at` datetime(3) NOT NULL COMMENT '客户确认时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_weight_confirmation_command` (`tenant_id`,`operator_id`,`command_key`), + KEY `idx_weight_confirmation_inbound` (`tenant_id`,`inbound_id`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户计费重独立确认历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_coload_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_coload_command` ( + `id` bigint unsigned NOT NULL COMMENT '幂等命令ID', + `actor_id` bigint unsigned NOT NULL COMMENT '平台或内部操作人ID', + `action_type` varchar(16) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `request_hash` char(64) NOT NULL, + `command_status` varchar(16) NOT NULL COMMENT 'PROCESSING、SUCCESS', + `result_group_id` bigint unsigned DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `completed_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_coload_command` (`actor_id`,`action_type`,`idempotency_key`), + KEY `fk_coload_command_group` (`result_group_id`), + CONSTRAINT `fk_coload_command_group` FOREIGN KEY (`result_group_id`) REFERENCES `fl_coload_group` (`id`), + CONSTRAINT `chk_coload_command_status` CHECK ((`command_status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组幂等写命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_coload_group`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_coload_group` ( + `id` bigint unsigned NOT NULL COMMENT '同柜不可拆组ID', + `group_no` varchar(40) NOT NULL COMMENT '稳定组号CLG-{id}', + `group_status` varchar(16) NOT NULL COMMENT 'ACTIVE、LOCKED、CONSUMED、CANCELLED', + `warehouse_id` bigint unsigned NOT NULL COMMENT '国内收货仓ID快照', + `channel_code` varchar(64) NOT NULL COMMENT '物流渠道编码快照', + `locked_plan_id` bigint unsigned DEFAULT NULL COMMENT 'LOCKED/CONSUMED绑定装柜计划ID', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观版本', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `locked_by` bigint unsigned DEFAULT NULL, + `locked_by_name` varchar(64) DEFAULT NULL, + `locked_at` datetime(3) DEFAULT NULL, + `consumed_by` bigint unsigned DEFAULT NULL, + `consumed_by_name` varchar(64) DEFAULT NULL, + `consumed_at` datetime(3) DEFAULT NULL, + `cancelled_by` bigint unsigned DEFAULT NULL, + `cancelled_by_name` varchar(64) DEFAULT NULL, + `cancelled_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_coload_group_no` (`group_no`), + KEY `idx_coload_group_status` (`group_status`,`updated_at`,`id`), + KEY `idx_coload_group_plan` (`locked_plan_id`,`group_status`), + CONSTRAINT `fk_coload_group_plan` FOREIGN KEY (`locked_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `chk_coload_group_lock` CHECK ((((`group_status` in (_utf8mb4'ACTIVE',_utf8mb4'CANCELLED')) and (`locked_plan_id` is null)) or ((`group_status` in (_utf8mb4'LOCKED',_utf8mb4'CONSUMED')) and (`locked_plan_id` is not null)))), + CONSTRAINT `chk_coload_group_status` CHECK ((`group_status` in (_utf8mb4'ACTIVE',_utf8mb4'LOCKED',_utf8mb4'CONSUMED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜且不可拆业务组聚合根'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_coload_group_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_coload_group_history` ( + `id` bigint unsigned NOT NULL COMMENT '状态历史ID', + `group_id` bigint unsigned NOT NULL, + `action_type` varchar(16) NOT NULL COMMENT 'CREATE、CANCEL、LOCK、UNLOCK、CONSUME', + `from_status` varchar(16) DEFAULT NULL, + `to_status` varchar(16) NOT NULL, + `plan_id` bigint unsigned DEFAULT NULL, + `operation_id` varchar(128) NOT NULL COMMENT '稳定幂等操作ID', + `remark` varchar(500) DEFAULT NULL, + `operator_id` bigint unsigned NOT NULL, + `operator_name` varchar(64) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_coload_history_operation` (`group_id`,`action_type`,`operation_id`), + KEY `idx_coload_history_group` (`group_id`,`created_at`,`id`), + CONSTRAINT `fk_coload_history_group` FOREIGN KEY (`group_id`) REFERENCES `fl_coload_group` (`id`), + CONSTRAINT `chk_coload_history_action` CHECK ((`action_type` in (_utf8mb4'CREATE',_utf8mb4'CANCEL',_utf8mb4'LOCK',_utf8mb4'UNLOCK',_utf8mb4'CONSUME'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组不可变状态历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_coload_group_member`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_coload_group_member` ( + `id` bigint unsigned NOT NULL COMMENT '组成员ID', + `group_id` bigint unsigned NOT NULL COMMENT '同柜不可拆组ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '成员客户ID;允许跨客户成组', + `order_id` bigint unsigned NOT NULL COMMENT '订单ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '创建时权威入库单ID', + `full_package_count` int unsigned NOT NULL COMMENT '必须整组分配的满箱数快照', + `active_flag` tinyint unsigned DEFAULT '1' COMMENT '当前约束唯一标识;取消或消费后置NULL', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `closed_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_coload_group_member` (`group_id`,`inbound_id`), + UNIQUE KEY `uk_coload_active_inbound` (`tenant_id`,`inbound_id`,`active_flag`), + KEY `idx_coload_member_order` (`order_id`,`created_at`,`group_id`), + KEY `fk_coload_member_inbound` (`inbound_id`), + CONSTRAINT `fk_coload_member_group` FOREIGN KEY (`group_id`) REFERENCES `fl_coload_group` (`id`), + CONSTRAINT `fk_coload_member_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_coload_member_active` CHECK (((`active_flag` is null) or (`active_flag` = 1))), + CONSTRAINT `chk_coload_member_packages` CHECK ((`full_package_count` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组成员与满箱数快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_consumed_event`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_consumed_event` ( + `event_id` bigint unsigned NOT NULL COMMENT '已消费领域事件ID,与生产端Outbox事件ID一致', + `event_type` varchar(64) NOT NULL COMMENT '已消费事件类型', + `consumed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功消费时间', + PRIMARY KEY (`event_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_booking`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_booking` ( + `id` bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)', + `booking_no` varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号', + `container_no` varchar(64) DEFAULT NULL COMMENT '集装箱号;确认前允许修订', + `cabinet_no` varchar(64) NOT NULL COMMENT '平台唯一内部柜号', + `channel_code` varchar(64) DEFAULT NULL COMMENT '订舱渠道编码快照', + `channel_name` varchar(128) DEFAULT NULL COMMENT '订舱渠道名称快照', + `ocean_booking_id` bigint unsigned DEFAULT NULL COMMENT '来源订舱ID', + `ocean_booking_requirement_id` bigint unsigned DEFAULT NULL COMMENT '来源订舱箱型需求ID', + `warehouse_id` bigint unsigned NOT NULL COMMENT '装柜仓库ID;平台公共资源', + `warehouse_name` varchar(128) NOT NULL COMMENT '装柜仓库名称快照', + `bill_of_lading_no` varchar(128) DEFAULT NULL COMMENT '提单号', + `vessel_name` varchar(128) DEFAULT NULL COMMENT '船名快照', + `voyage_no` varchar(64) DEFAULT NULL COMMENT '航次;确认前允许修订', + `container_type` varchar(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ', + `shipping_company_id` bigint unsigned DEFAULT NULL COMMENT '船司ID快照', + `shipping_company_name` varchar(128) DEFAULT NULL COMMENT '船司名称快照', + `departure_port_code` varchar(32) DEFAULT NULL COMMENT '起运港编码快照', + `departure_port_name` varchar(128) DEFAULT NULL COMMENT '起运港名称快照', + `destination_port_code` varchar(32) DEFAULT NULL COMMENT '目的港编码快照', + `destination_port_name` varchar(128) DEFAULT NULL COMMENT '目的港名称快照', + `destination_warehouse_code` varchar(64) DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `capacity_cbm` decimal(18,4) DEFAULT NULL COMMENT '货柜容量m3', + `max_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '货柜最大承重kg', + `terminal_trucking_company` varchar(128) DEFAULT NULL COMMENT '码头拖车公司', + `loading_at` datetime(3) DEFAULT NULL COMMENT '计划或实际装柜时间', + `estimated_sailing_at` datetime(3) DEFAULT NULL COMMENT '预计开船时间', + `actual_sailing_at` datetime(3) DEFAULT NULL COMMENT '实际开船时间', + `estimated_port_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到港时间', + `actual_port_arrival_at` datetime(3) DEFAULT NULL COMMENT '实际到港时间', + `estimated_warehouse_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到仓时间', + `actual_warehouse_arrival_at` datetime(3) DEFAULT NULL COMMENT '实际到仓时间', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_by_name` varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `source_type` varchar(24) NOT NULL DEFAULT 'SIMULATION', + `record_status` varchar(16) NOT NULL DEFAULT 'ACTIVE', + `merged_into_container_booking_id` bigint unsigned DEFAULT NULL, + `customer_id` bigint unsigned DEFAULT NULL, + `customer_name` varchar(128) DEFAULT NULL, + `terminal_trucking_company_id` bigint unsigned DEFAULT NULL, + `unloading_completed_at` datetime(3) DEFAULT NULL, + `unloading_photo_uploaded_at` datetime(3) DEFAULT NULL, + `estimated_empty_reported_at` datetime(3) DEFAULT NULL, + `actual_empty_reported_at` datetime(3) DEFAULT NULL, + `empty_report_source_version` bigint DEFAULT NULL, + `channel_group_code` varchar(64) DEFAULT NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留', + `pickup_code` varchar(64) DEFAULT NULL COMMENT 'PU数字码;按字符串保存以保留前导零', + `pickup_code_updated_by` bigint unsigned DEFAULT NULL COMMENT 'PU码最后填写人ID', + `pickup_code_updated_by_name` varchar(64) DEFAULT NULL COMMENT 'PU码最后填写人名称快照', + `pickup_code_updated_at` datetime(3) DEFAULT NULL COMMENT 'PU码最后填写时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_booking_no` (`booking_no`), + UNIQUE KEY `uk_container_booking_cabinet` (`cabinet_no`), + UNIQUE KEY `uk_container_booking_ocean_container` (`ocean_booking_id`,`container_no`), + KEY `idx_container_booking_container` (`container_no`), + KEY `idx_container_booking_warehouse` (`warehouse_id`,`updated_at`,`id`), + KEY `idx_container_booking_voyage` (`voyage_no`,`updated_at`), + KEY `idx_container_booking_container_no` (`container_no`,`updated_at`,`id`), + KEY `idx_container_booking_merge` (`record_status`,`merged_into_container_booking_id`), + KEY `idx_container_booking_transit_summary` (`actual_sailing_at`,`actual_warehouse_arrival_at`,`ocean_booking_id`,`container_type`), + CONSTRAINT `chk_container_booking_capacity` CHECK (((`capacity_cbm` is null) or (`capacity_cbm` > 0))), + CONSTRAINT `chk_container_booking_weight` CHECK (((`max_weight_kg` is null) or (`max_weight_kg` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_document` ( + `id` bigint unsigned NOT NULL COMMENT '货柜文件关联ID(雪花ID)', + `container_booking_id` bigint unsigned NOT NULL COMMENT '货柜预订ID', + `document_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件类型:CUSTOMS_CLEARANCE清关文件、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', + `file_name` varchar(255) NOT NULL COMMENT '文件名称快照', + `file_hash` varchar(64) DEFAULT NULL, + `file_url` varchar(1000) DEFAULT NULL COMMENT '文件访问地址快照', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传操作人ID', + `uploaded_by_name` varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', + `completed_by` bigint unsigned DEFAULT NULL, + `completed_by_name` varchar(64) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间', + `completed_at` datetime(3) DEFAULT NULL, + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_document_type` (`container_booking_id`,`document_type`), + KEY `idx_container_document_file` (`file_id`), + CONSTRAINT `fk_container_document_booking` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_container_document_type` CHECK ((`document_type` in (_utf8mb4'DESTINATION_CLEARANCE',_utf8mb4'CUSTOMS_DECLARATION',_utf8mb4'DELIVERY_ORDER',_utf8mb4'PICKUP_ORDER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台货柜清关、DO与PU文件关联'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_event_inbox` ( + `event_id` varchar(64) NOT NULL COMMENT '跨区事件全局唯一ID', + `event_type` varchar(64) NOT NULL COMMENT '事件类型', + `event_version` int unsigned NOT NULL COMMENT '事件契约版本', + `aggregate_id` varchar(64) NOT NULL COMMENT '货柜聚合ID', + `aggregate_version` bigint unsigned NOT NULL COMMENT '货柜聚合版本', + `payload_hash` varchar(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + `payload_json` json NOT NULL COMMENT '原始事件负载', + `status` varchar(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + `received_at` datetime(3) NOT NULL COMMENT '接收时间(UTC)', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (`event_id`), + KEY `idx_container_event_aggregate` (`aggregate_id`,`aggregate_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海外货柜事件消费Inbox'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_exception_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_exception_history` ( + `id` bigint unsigned NOT NULL COMMENT '异常历史ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '稳定货柜预订ID', + `action` varchar(16) NOT NULL COMMENT 'MARK标记异常/CANCEL取消异常', + `reason` varchar(500) NOT NULL COMMENT '标记或取消原因', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operation_id` varchar(128) NOT NULL COMMENT '同一操作人幂等键', + `request_hash` char(64) NOT NULL COMMENT '规范化请求摘要', + `occurred_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_exception_operation` (`container_booking_id`,`operator_id`,`operation_id`), + KEY `idx_container_exception_history` (`container_booking_id`,`occurred_at`,`id`), + CONSTRAINT `fk_container_exception_booking` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_container_exception_action` CHECK ((`action` in (_utf8mb4'MARK',_utf8mb4'CANCEL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='货柜异常标记及取消原因历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_fee_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_fee_detail` ( + `id` bigint unsigned NOT NULL COMMENT '柜次费用来源ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '订舱柜ID快照', + `container_no` varchar(64) DEFAULT NULL COMMENT '柜号快照', + `fee_item_id` bigint unsigned NOT NULL COMMENT '平台公共费用项目ID', + `fee_item_code_snapshot` varchar(50) NOT NULL COMMENT '费用项目稳定编码快照', + `fee_item_name_snapshot` varchar(128) NOT NULL COMMENT '费用项目名称快照', + `direction` varchar(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', + `business_date` date NOT NULL COMMENT '费用业务日期', + `amount` decimal(18,2) NOT NULL COMMENT 'CNY费用金额', + `currency` char(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', + `settlement_included` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否参与财务结算来源池', + `review_status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回', + `source_status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废', + `source_version` int unsigned NOT NULL DEFAULT '1' COMMENT '费用来源版本;调整递增,作废携带当前版本', + `remark` varchar(500) DEFAULT NULL COMMENT '人工备注', + `reviewer_id` bigint unsigned DEFAULT NULL COMMENT '审核人ID', + `reviewer_name` varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间', + `review_reason` varchar(500) DEFAULT NULL COMMENT '审核驳回原因', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后更新人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + `paid_at` datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + `payment_method` varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + `payment_reference_no` varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + `payment_voucher_file_ids` json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + `settlement_mode` varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + `supplier_category` varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + `supplier_id` bigint unsigned DEFAULT NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + `supplier_name` varchar(200) DEFAULT NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + `allocation_group_id` bigint unsigned DEFAULT NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空', + `allocation_order_id` bigint unsigned DEFAULT NULL COMMENT '本分摊行业务订单ID', + `allocation_order_no` varchar(64) DEFAULT NULL COMMENT '本分摊行业务订单号', + `allocation_inbound_id` bigint unsigned DEFAULT NULL COMMENT '本分摊行对应入仓单ID', + `allocation_inbound_code` varchar(64) DEFAULT NULL COMMENT '本分摊行对应入仓码', + `allocation_group_amount` decimal(18,2) DEFAULT NULL COMMENT '柜级原始费用总额快照', + PRIMARY KEY (`id`), + KEY `idx_container_fee_customer_plan` (`tenant_id`,`loading_plan_id`,`source_status`), + KEY `idx_container_fee_settlement` (`tenant_id`,`review_status`,`settlement_included`,`source_status`), + KEY `idx_container_fee_review` (`tenant_id`,`review_status`,`source_status`), + CONSTRAINT `chk_container_fee_amount` CHECK ((`amount` >= 0)), + CONSTRAINT `chk_container_fee_currency` CHECK ((`currency` = _utf8mb4'CNY')), + CONSTRAINT `chk_container_fee_direction` CHECK ((`direction` in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), + CONSTRAINT `chk_container_fee_review` CHECK ((`review_status` in (_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))), + CONSTRAINT `chk_container_fee_source` CHECK ((`source_status` in (_utf8mb4'ACTIVE',_utf8mb4'VOIDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_fee_source_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_fee_source_snapshot` ( + `id` bigint unsigned NOT NULL COMMENT '柜次费用快照ID', + `source_fee_id` bigint unsigned NOT NULL COMMENT '柜次费用来源ID', + `source_version` int unsigned NOT NULL COMMENT '费用来源版本', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '柜次ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '订舱柜ID快照', + `container_no` varchar(64) DEFAULT NULL COMMENT '柜号快照', + `fee_item_id` bigint unsigned NOT NULL COMMENT '费用项目ID快照', + `fee_item_code` varchar(50) NOT NULL COMMENT '费用项目编码快照', + `fee_item_name` varchar(128) NOT NULL COMMENT '费用项目名称快照', + `direction` varchar(16) NOT NULL COMMENT '费用方向', + `supplier_id` bigint unsigned DEFAULT NULL COMMENT 'Warehouse供应商ID快照', + `supplier_name` varchar(200) DEFAULT NULL COMMENT 'Warehouse供应商名称快照', + `supplier_category` varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + `settlement_mode` varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + `paid_at` datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + `payment_method` varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + `payment_reference_no` varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + `payment_voucher_file_ids` json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + `business_date` date NOT NULL COMMENT '费用业务日期', + `amount` decimal(18,2) NOT NULL COMMENT 'CNY费用金额', + `currency` char(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', + `review_status` varchar(16) NOT NULL COMMENT '审核状态快照', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间', + `allocation_group_id` bigint unsigned DEFAULT NULL COMMENT '柜级公共费用分摊组ID', + `allocation_order_id` bigint unsigned DEFAULT NULL COMMENT '分摊业务订单ID快照', + `allocation_order_no` varchar(64) DEFAULT NULL COMMENT '分摊业务订单号快照', + `allocation_inbound_id` bigint unsigned DEFAULT NULL COMMENT '分摊入仓单ID快照', + `allocation_inbound_code` varchar(64) DEFAULT NULL COMMENT '分摊入仓码快照', + `allocation_group_amount` decimal(18,2) DEFAULT NULL COMMENT '柜级原始费用总额快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_fee_source_version` (`loading_plan_id`,`source_fee_id`,`source_version`), + KEY `idx_container_fee_snapshot_tenant` (`tenant_id`,`loading_plan_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次费用不可变版本快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_transport_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_transport_history` ( + `id` bigint unsigned NOT NULL COMMENT '运输字段变更历史ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '稳定柜次ID', + `before_container_no` varchar(64) DEFAULT NULL COMMENT '变更前真实集装箱号', + `after_container_no` varchar(64) DEFAULT NULL COMMENT '变更后真实集装箱号', + `before_voyage_no` varchar(64) DEFAULT NULL COMMENT '变更前航次号', + `after_voyage_no` varchar(64) DEFAULT NULL COMMENT '变更后航次号', + `reason` varchar(500) DEFAULT NULL COMMENT '运输字段变更原因', + `operator_id` bigint unsigned NOT NULL COMMENT '变更操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '变更操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '变更记录创建时间', + `operation_id` varchar(64) NOT NULL COMMENT '运输字段操作幂等ID', + `request_hash` char(64) NOT NULL COMMENT '运输字段请求哈希', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_transport_history_operation` (`container_booking_id`,`operator_id`,`operation_id`), + KEY `idx_container_transport_history` (`container_booking_id`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次真实运输字段不可变变更审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customer_charge_rule`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customer_charge_rule` ( + `id` bigint unsigned NOT NULL COMMENT '客户计费规则ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `rule_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户内唯一计费规则编码', + `rule_version` int unsigned NOT NULL COMMENT '规则版本号', + `customer_id` bigint unsigned NOT NULL COMMENT '客户逻辑ID', + `customer_name` varchar(128) NOT NULL COMMENT '客户名称快照', + `business_type` varchar(32) NOT NULL COMMENT '业务类型', + `charge_mode` varchar(16) NOT NULL COMMENT '计费口径:KG或CBM', + `tax_mode` varchar(24) DEFAULT NULL COMMENT '包税或不包税类型', + `conversion_factor` decimal(18,6) NOT NULL COMMENT '重量体积换算系数', + `length_factor` decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT 'length factor', + `width_factor` decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT 'width factor', + `height_factor` decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT 'height factor', + `rounding_scale` tinyint unsigned NOT NULL DEFAULT '3' COMMENT '结果保留小数位数', + `rounding_mode` varchar(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式', + `currency` char(3) NOT NULL COMMENT '默认币种', + `effective_from` datetime(3) NOT NULL COMMENT '生效开始时间', + `effective_to` datetime(3) DEFAULT NULL COMMENT '生效结束时间', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', + `default_rule` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'default customer rule', + `remark` varchar(500) DEFAULT NULL COMMENT '规则备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `initialization_key` varchar(128) DEFAULT NULL COMMENT '客户创建执行器幂等键及补偿归属', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_charge_rule_version` (`tenant_id`,`rule_code`,`rule_version`), + KEY `idx_charge_rule_match` (`tenant_id`,`customer_id`,`business_type`,`status`,`effective_from`,`effective_to`), + KEY `idx_charge_rule_initialization` (`tenant_id`,`initialization_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户头程计费规则版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customer_dimension_config`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customer_dimension_config` ( + `id` bigint unsigned NOT NULL COMMENT '配置ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `box_type` varchar(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE', + `inner_length_cm` decimal(9,3) NOT NULL COMMENT '箱规内长(厘米)', + `inner_width_cm` decimal(9,3) NOT NULL COMMENT '箱规内宽(厘米)', + `inner_height_cm` decimal(9,3) NOT NULL COMMENT '箱规内高(厘米)', + `edge_addition_cm` decimal(6,3) NOT NULL DEFAULT '2.000' COMMENT '每个尺寸方向的单侧加宽厘米数', + `default_calculation_mode` varchar(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `initialization_key` varchar(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customer_dimension_config_tenant_box` (`tenant_id`,`box_type`), + KEY `idx_customer_dimension_config_initialization` (`tenant_id`,`initialization_key`), + CONSTRAINT `chk_customer_dimension_box_type` CHECK ((`box_type` in (_utf8mb4'SMALL',_utf8mb4'MEDIUM',_utf8mb4'LARGE',_utf8mb4'EXTRA_LARGE'))), + CONSTRAINT `chk_customer_dimension_edge_addition` CHECK (((`edge_addition_cm` >= 0.001) and (`edge_addition_cm` <= 100.000))), + CONSTRAINT `chk_customer_dimension_inner_size` CHECK (((`inner_length_cm` >= 0.001) and (`inner_width_cm` >= 0.001) and (`inner_height_cm` >= 0.001))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户固定四箱规尺寸配置'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_broker_submission`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_broker_submission` ( + `id` bigint unsigned NOT NULL COMMENT '首次发送报关行批次ID', + `customs_container_id` bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + `batch_no` varchar(64) NOT NULL COMMENT '首次发送报关行批次号', + `status` varchar(16) NOT NULL COMMENT '发送状态:SENT已发送', + `source_version` int unsigned NOT NULL COMMENT '发送时整柜报关聚合版本', + `source_snapshot_json` json NOT NULL COMMENT '发送时冻结资料版本与覆盖范围快照', + `sent_by` bigint unsigned NOT NULL COMMENT '首次发送操作人ID', + `sent_by_name` varchar(64) DEFAULT NULL COMMENT '首次发送操作人名称快照', + `sent_at` datetime(3) NOT NULL COMMENT '首次发送时间', + `version` int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_broker_submission_container` (`customs_container_id`), + UNIQUE KEY `uk_customs_broker_submission_batch` (`batch_no`), + KEY `idx_customs_broker_submission_plan` (`loading_plan_id`,`sent_at`), + CONSTRAINT `fk_customs_broker_submission_container` FOREIGN KEY (`customs_container_id`) REFERENCES `fl_customs_container` (`id`), + CONSTRAINT `chk_customs_broker_submission_status` CHECK ((`status` = _utf8mb4'SENT')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='冻结退税报关资料首次发送报关行批次事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_case`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_case` ( + `id` bigint unsigned NOT NULL COMMENT '报关协同单逻辑ID', + `customs_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '全局唯一报关协同单号', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `order_id` bigint unsigned NOT NULL COMMENT 'Order服务订单逻辑ID;跨库不建立外键', + `inbound_id` bigint unsigned NOT NULL COMMENT 'Firstleg入库单逻辑ID', + `container_booking_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '关联柜次逻辑ID;0表示创建时尚未关联柜次', + `checklist_version_id` bigint unsigned NOT NULL COMMENT '本单冻结使用的报关资料清单版本ID', + `checklist_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '创建协同单时的清单编码快照;源清单变化不回写', + `checklist_version_no` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '创建协同单时的清单版本号快照;源清单变化不回写', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'DOCUMENTS_PENDING' COMMENT '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '报关协同单内部业务备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建协同单的平台操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建协同单时的平台操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '协同单创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改协同单的平台操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改时的平台操作人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '协同单最后修改时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号;每次状态变更递增', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_no` (`customs_no`), + UNIQUE KEY `uk_customs_case_scope` (`tenant_id`,`order_id`,`inbound_id`,`container_booking_id`), + KEY `idx_customs_case_status` (`status`,`updated_at`), + KEY `idx_customs_case_container` (`container_booking_id`), + KEY `fk_customs_case_checklist` (`checklist_version_id`), + CONSTRAINT `fk_customs_case_checklist` FOREIGN KEY (`checklist_version_id`) REFERENCES `fl_customs_checklist_version` (`id`), + CONSTRAINT `chk_customs_case_status` CHECK ((`status` in (_utf8mb4'DOCUMENTS_PENDING',_utf8mb4'REVIEWING',_utf8mb4'SUPPLEMENT_REQUIRED',_utf8mb4'READY_TO_SUBMIT',_utf8mb4'RECEIPT_PENDING',_utf8mb4'RECEIPT_AVAILABLE',_utf8mb4'CLEARANCE_COMPLETED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期订单报关协同聚合'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_checklist_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_checklist_version` ( + `id` bigint unsigned NOT NULL COMMENT '报关资料清单版本逻辑ID', + `checklist_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '报关资料清单编码;同一清单的多个版本共用', + `version_no` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '清单业务版本号', + `country_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '清单适用的目的国家或地区编码', + `channel_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '清单适用的运输渠道编码', + `cargo_type` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '清单适用的货物类型编码', + `items_json` json NOT NULL COMMENT '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '清单版本状态:ACTIVE启用、RETIRED停用', + `created_by` bigint unsigned NOT NULL COMMENT '创建清单版本的平台操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建清单版本时的平台操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清单版本创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_checklist_version` (`checklist_code`,`version_no`), + KEY `idx_customs_checklist_match` (`country_code`,`channel_code`,`cargo_type`,`status`), + CONSTRAINT `chk_customs_checklist_status` CHECK ((`status` in (_utf8mb4'ACTIVE',_utf8mb4'RETIRED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期报关资料清单不可变版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_clearance_result`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_clearance_result` ( + `id` bigint unsigned NOT NULL COMMENT '海关结果事实ID', + `declaration_batch_id` bigint unsigned NOT NULL COMMENT '所属正式申报批次ID', + `customs_container_id` bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + `result_type` varchar(32) NOT NULL COMMENT '结果类型:INSPECTION/SUPPLEMENT_REQUIRED/RELEASED/DETAINED/OTHER', + `result_status` varchar(16) NOT NULL DEFAULT 'OPEN' COMMENT '结果状态:OPEN/RESOLVED/CANCELLED', + `customs_entry_no` varchar(64) DEFAULT NULL COMMENT '报关单号快照', + `occurred_at` datetime(3) NOT NULL COMMENT '海关事实发生时间', + `resolution` varchar(1000) DEFAULT NULL COMMENT '处理结果或异常说明', + `evidence_file_id` bigint unsigned DEFAULT NULL COMMENT '结果凭证File文件ID', + `recorded_by` bigint unsigned NOT NULL COMMENT '平台录入人', + `recorded_by_name` varchar(64) DEFAULT NULL COMMENT '平台录入人名称快照', + `recorded_at` datetime(3) NOT NULL COMMENT '平台录入时间', + `previous_result_id` bigint unsigned DEFAULT NULL COMMENT '前序结果事实ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '结果创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '结果更新时间', + PRIMARY KEY (`id`), + KEY `idx_customs_clearance_result_batch` (`declaration_batch_id`,`occurred_at`,`id`), + KEY `idx_customs_clearance_result_container` (`customs_container_id`,`result_type`,`result_status`), + CONSTRAINT `fk_customs_clearance_result_batch` FOREIGN KEY (`declaration_batch_id`) REFERENCES `fl_customs_declaration_batch` (`id`), + CONSTRAINT `chk_customs_clearance_result_status` CHECK ((`result_status` in (_utf8mb4'OPEN',_utf8mb4'RESOLVED',_utf8mb4'CANCELLED'))), + CONSTRAINT `chk_customs_clearance_result_type` CHECK ((`result_type` in (_utf8mb4'INSPECTION',_utf8mb4'SUPPLEMENT_REQUIRED',_utf8mb4'RELEASED',_utf8mb4'DETAINED',_utf8mb4'OTHER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='正式申报后的海关结果不可变事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_command` ( + `id` bigint unsigned NOT NULL, + `actor_id` bigint unsigned NOT NULL, + `action` varchar(80) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `request_hash` char(64) NOT NULL, + `status` varchar(16) NOT NULL, + `result_id` bigint unsigned DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `completed_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_command` (`actor_id`,`action`,`idempotency_key`), + CONSTRAINT `chk_customs_command_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜报关写命令幂等事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_completion_action_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_completion_action_history` ( + `id` bigint unsigned NOT NULL COMMENT '客户回执动作历史ID', + `completion_document_id` bigint unsigned NOT NULL COMMENT '报关行回执当前版本逻辑根ID', + `receipt_version` int unsigned NOT NULL COMMENT '动作对应的回执文件版本', + `action` varchar(16) NOT NULL COMMENT '客户动作:CONFIRM确认、REJECT打回', + `reason` varchar(500) DEFAULT NULL COMMENT '客户打回原因;确认动作为空', + `operator_id` bigint unsigned NOT NULL COMMENT '客户操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '客户操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '客户动作时间', + PRIMARY KEY (`id`), + KEY `idx_customs_completion_action_history` (`completion_document_id`,`receipt_version`,`created_at`,`id`), + CONSTRAINT `fk_customs_completion_action_history_document` FOREIGN KEY (`completion_document_id`) REFERENCES `fl_customs_completion_document` (`id`), + CONSTRAINT `chk_customs_completion_action_history_action` CHECK ((`action` in (_utf8mb4'CONFIRM',_utf8mb4'REJECT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报关行回执确认与打回不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_completion_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_completion_document` ( + `id` bigint unsigned NOT NULL, + `source_document_id` bigint unsigned NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', + `file_id` bigint unsigned NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_hash` char(64) NOT NULL, + `content_type` varchar(128) DEFAULT NULL, + `size_bytes` bigint unsigned NOT NULL, + `uploaded_by` bigint unsigned NOT NULL, + `uploaded_by_name` varchar(64) DEFAULT NULL, + `uploaded_at` datetime(3) NOT NULL, + `version` int unsigned NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `customer_confirm_status` varchar(20) NOT NULL DEFAULT 'PENDING' COMMENT '客户回执确认状态:PENDING/CONFIRMED/REJECTED', + `customer_confirmed_by` bigint unsigned DEFAULT NULL COMMENT '客户确认人ID', + `customer_confirmed_by_name` varchar(64) DEFAULT NULL COMMENT '客户确认人名称快照', + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认时间', + `customer_reject_reason` varchar(500) DEFAULT NULL COMMENT '客户打回原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_completion_source` (`source_document_id`), + CONSTRAINT `fk_customs_completion_source` FOREIGN KEY (`source_document_id`) REFERENCES `fl_customs_customer_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件当前版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_completion_document_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_completion_document_version` ( + `id` bigint unsigned NOT NULL, + `completion_document_id` bigint unsigned NOT NULL, + `document_version` int unsigned NOT NULL, + `file_id` bigint unsigned NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_hash` char(64) NOT NULL, + `content_type` varchar(128) DEFAULT NULL, + `size_bytes` bigint unsigned NOT NULL, + `operator_id` bigint unsigned NOT NULL, + `operator_name` varchar(64) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_completion_version` (`completion_document_id`,`document_version`), + CONSTRAINT `fk_customs_completion_version` FOREIGN KEY (`completion_document_id`) REFERENCES `fl_customs_completion_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件不可变覆盖版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_container`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_container` ( + `id` bigint unsigned NOT NULL, + `loading_plan_id` bigint unsigned NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键', + `container_booking_id` bigint unsigned NOT NULL COMMENT '物理货柜ID', + `status` varchar(40) NOT NULL COMMENT 'WAITING_CUSTOMER_DOCUMENTS、READY_TO_START、DECLARING、COMPLETED', + `started_by` bigint unsigned DEFAULT NULL, + `started_by_name` varchar(64) DEFAULT NULL, + `started_at` datetime(3) DEFAULT NULL, + `completed_by` bigint unsigned DEFAULT NULL, + `completed_by_name` varchar(64) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `version` int unsigned NOT NULL DEFAULT '0', + `broker_submitted_by` bigint unsigned DEFAULT NULL COMMENT '发送报关行执行的操作人ID', + `broker_submitted_by_name` varchar(64) DEFAULT NULL COMMENT '发送报关行执行的操作人名称', + `broker_submitted_at` datetime(3) DEFAULT NULL COMMENT '发送报关行执行时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_container_plan` (`loading_plan_id`), + UNIQUE KEY `uk_customs_container_booking` (`container_booking_id`), + KEY `idx_customs_container_status` (`status`,`updated_at`), + CONSTRAINT `chk_customs_container_status` CHECK ((`status` in (_utf8mb4'WAITING_CUSTOMER_DOCUMENTS',_utf8mb4'READY_TO_START',_utf8mb4'DECLARING',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='装柜完成后的整柜出口报关聚合'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_container_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_container_history` ( + `id` bigint unsigned NOT NULL, + `customs_container_id` bigint unsigned NOT NULL, + `action` varchar(40) NOT NULL, + `from_status` varchar(40) DEFAULT NULL, + `to_status` varchar(40) DEFAULT NULL, + `remark` varchar(500) DEFAULT NULL, + `operator_id` bigint unsigned NOT NULL, + `operator_name` varchar(64) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_customs_container_history` (`customs_container_id`,`created_at`,`id`), + CONSTRAINT `fk_customs_container_history` FOREIGN KEY (`customs_container_id`) REFERENCES `fl_customs_container` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜报关状态与系统触发历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_customer_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_customer_document` ( + `id` bigint unsigned NOT NULL, + `customs_container_id` bigint unsigned NOT NULL, + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票', + `file_id` bigint unsigned NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_hash` char(64) NOT NULL, + `content_type` varchar(128) DEFAULT NULL, + `size_bytes` bigint unsigned NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'ACTIVE', + `uploaded_by` bigint unsigned NOT NULL, + `uploaded_at` datetime(3) NOT NULL, + `frozen_version` int unsigned DEFAULT NULL, + `frozen_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint unsigned DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `version` int unsigned NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_customs_customer_document_container` (`customs_container_id`,`status`,`tenant_id`), + CONSTRAINT `fk_customs_customer_document_container` FOREIGN KEY (`customs_container_id`) REFERENCES `fl_customs_container` (`id`), + CONSTRAINT `chk_customs_customer_document_freeze` CHECK ((((`frozen_at` is null) and (`frozen_version` is null)) or ((`frozen_at` is not null) and (`frozen_version` is not null)))), + CONSTRAINT `chk_customs_customer_document_status` CHECK ((`status` in (_utf8mb4'ACTIVE',_utf8mb4'DELETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户整份退税报送资料逻辑根'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_customer_document_inbound`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_customer_document_inbound` ( + `document_id` bigint unsigned NOT NULL, + `inbound_id` bigint unsigned NOT NULL, + `tenant_id` bigint unsigned NOT NULL, + `order_id` bigint unsigned NOT NULL, + `inbound_code` varchar(40) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`document_id`,`inbound_id`), + KEY `idx_customs_document_inbound` (`inbound_id`,`tenant_id`), + CONSTRAINT `fk_customs_document_inbound_document` FOREIGN KEY (`document_id`) REFERENCES `fl_customs_customer_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报送资料与当前柜退税入仓码多对多关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_customer_document_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_customer_document_version` ( + `id` bigint unsigned NOT NULL, + `document_id` bigint unsigned NOT NULL, + `document_version` int unsigned NOT NULL, + `file_id` bigint unsigned NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_hash` char(64) NOT NULL, + `content_type` varchar(128) DEFAULT NULL, + `size_bytes` bigint unsigned NOT NULL, + `scope_snapshot_json` json NOT NULL, + `action` varchar(24) NOT NULL COMMENT 'UPLOAD、REPLACE、SCOPE_CHANGE、DELETE', + `operator_id` bigint unsigned NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_customer_document_version` (`document_id`,`document_version`), + CONSTRAINT `fk_customs_customer_document_version` FOREIGN KEY (`document_id`) REFERENCES `fl_customs_customer_document` (`id`), + CONSTRAINT `chk_customs_customer_document_version_action` CHECK ((`action` in (_utf8mb4'UPLOAD',_utf8mb4'REPLACE',_utf8mb4'SCOPE_CHANGE',_utf8mb4'DELETE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报送资料文件与覆盖范围不可变版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_declaration_batch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_declaration_batch` ( + `id` bigint unsigned NOT NULL COMMENT '正式申报批次ID', + `customs_container_id` bigint unsigned NOT NULL COMMENT '所属整柜报关聚合ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + `batch_no` varchar(64) DEFAULT NULL COMMENT '平台正式申报批次号', + `status` varchar(32) NOT NULL COMMENT '批次状态:READY/SUBMITTED/ACKNOWLEDGED/RESULT_PENDING/CLOSED/CANCELLED', + `source_version` int unsigned NOT NULL COMMENT '创建时整柜报关聚合版本', + `source_snapshot_json` json NOT NULL COMMENT '提交时资料、回执和确认版本快照', + `customs_entry_no` varchar(64) DEFAULT NULL COMMENT '报关单号', + `submitted_by` bigint unsigned DEFAULT NULL COMMENT '正式申报提交人', + `submitted_by_name` varchar(64) DEFAULT NULL COMMENT '正式申报提交人名称快照', + `submitted_at` datetime(3) DEFAULT NULL COMMENT '正式申报提交时间', + `acknowledged_by` bigint unsigned DEFAULT NULL COMMENT '报关行确认受理人', + `acknowledged_by_name` varchar(64) DEFAULT NULL COMMENT '报关行确认受理人名称快照', + `acknowledged_at` datetime(3) DEFAULT NULL COMMENT '报关行确认受理时间', + `remark` varchar(500) DEFAULT NULL COMMENT '批次说明', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '批次创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '批次更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_declaration_batch_container` (`customs_container_id`,`id`), + KEY `idx_customs_declaration_batch_plan` (`loading_plan_id`,`status`,`created_at`), + CONSTRAINT `fk_customs_declaration_batch_container` FOREIGN KEY (`customs_container_id`) REFERENCES `fl_customs_container` (`id`), + CONSTRAINT `chk_customs_declaration_batch_status` CHECK ((`status` in (_utf8mb4'READY',_utf8mb4'SUBMITTED',_utf8mb4'ACKNOWLEDGED',_utf8mb4'RESULT_PENDING',_utf8mb4'CLOSED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜正式申报批次事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_document` ( + `id` bigint unsigned NOT NULL COMMENT '报关资料文件版本逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `checklist_item_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '对应冻结清单中的资料项目编码', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件逻辑ID;跨库不建立外键', + `document_version` int unsigned NOT NULL COMMENT '同一协同单和清单项目下的文件版本序号', + `replaces_document_id` bigint unsigned DEFAULT NULL COMMENT '被本版本替换的上一资料版本ID;首个版本为空', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '上传或引用时的文件名称快照', + `file_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件内容SHA-256摘要;用于版本审计和幂等校验', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'UPLOADED' COMMENT '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结', + `visibility` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'CUSTOMER' COMMENT '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传或引用资料的操作人ID', + `uploaded_by_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料上传或引用时间', + `reviewed_by` bigint unsigned DEFAULT NULL COMMENT '审核资料的平台操作人ID;未审核时为空', + `reviewed_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '审核时的平台操作人名称快照', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '资料审核时间', + `review_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '审核退回原因或审核备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_document_version` (`customs_case_id`,`checklist_item_code`,`document_version`), + UNIQUE KEY `uk_customs_document_file` (`customs_case_id`,`file_id`), + KEY `idx_customs_document_case` (`customs_case_id`,`status`), + CONSTRAINT `fk_customs_document_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_document_actor` CHECK ((`uploaded_by_type` in (_utf8mb4'PLATFORM',_utf8mb4'MERCHANT'))), + CONSTRAINT `chk_customs_document_status` CHECK ((`status` in (_utf8mb4'UPLOADED',_utf8mb4'APPROVED',_utf8mb4'RETURNED',_utf8mb4'SUPERSEDED',_utf8mb4'FROZEN'))), + CONSTRAINT `chk_customs_document_visibility` CHECK ((`visibility` in (_utf8mb4'PLATFORM',_utf8mb4'CUSTOMER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关客户文件版本引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_final_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_final_document` ( + `id` bigint unsigned NOT NULL COMMENT '真实报关完税文件ID', + `source_document_id` bigint unsigned NOT NULL COMMENT '对应冻结客户资料ID', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件ID', + `file_name` varchar(255) NOT NULL COMMENT '真实报关完税文件名称快照', + `file_hash` char(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + `content_type` varchar(128) DEFAULT NULL COMMENT '真实报关完税文件媒体类型', + `size_bytes` bigint unsigned NOT NULL COMMENT '真实报关完税文件大小(字节)', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传操作人ID', + `uploaded_by_name` varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', + `uploaded_at` datetime(3) NOT NULL COMMENT '上传时间', + `version` int unsigned NOT NULL DEFAULT '1' COMMENT '完税文件版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_final_source` (`source_document_id`), + CONSTRAINT `fk_customs_final_source` FOREIGN KEY (`source_document_id`) REFERENCES `fl_customs_customer_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='真实报关完税文件当前版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_frozen_document_inbound`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_frozen_document_inbound` ( + `document_id` bigint unsigned NOT NULL, + `inbound_id` bigint unsigned NOT NULL, + `tenant_id` bigint unsigned NOT NULL, + `order_id` bigint unsigned NOT NULL, + `inbound_code` varchar(40) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`document_id`,`inbound_id`), + CONSTRAINT `fk_customs_frozen_document` FOREIGN KEY (`document_id`) REFERENCES `fl_customs_customer_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开始报关时冻结的客户资料覆盖范围'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_history` ( + `id` bigint unsigned NOT NULL COMMENT '报关协同历史逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `action` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT', + `from_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '动作发生前的协同状态;创建动作时为空', + `to_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '动作完成后的协同状态', + `resource_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执', + `resource_id` bigint unsigned NOT NULL COMMENT '动作关联资源逻辑ID', + `note` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '动作原因、资料项目编码或业务备注快照', + `operator_id` bigint unsigned NOT NULL COMMENT '执行业务动作的操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行动作时的操作人名称快照', + `operator_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_customs_history_case` (`customs_case_id`,`created_at`,`id`), + CONSTRAINT `fk_customs_history_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_history_actor` CHECK ((`operator_type` in (_utf8mb4'PLATFORM',_utf8mb4'MERCHANT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关协同稳定历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_operation` ( + `id` bigint unsigned NOT NULL COMMENT '报关写命令幂等记录逻辑ID', + `operator_id` bigint unsigned NOT NULL COMMENT '发起写命令的操作人ID;与动作和幂等键共同限定重放范围', + `action` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端提供的幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS处理成功', + `result_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT', + `result_id` bigint unsigned DEFAULT NULL COMMENT '成功结果资源逻辑ID;处理中为空', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '写命令成功完成时间;处理中为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_operation` (`operator_id`,`action`,`idempotency_key`), + CONSTRAINT `chk_customs_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关协同命令幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_receipt` ( + `id` bigint unsigned NOT NULL COMMENT '报关回执文件版本逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `submission_batch_id` bigint unsigned NOT NULL COMMENT '产生本回执的报关代理提交批次ID', + `receipt_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键', + `receipt_version` int unsigned NOT NULL COMMENT '同一提交批次和回执类型下的版本序号', + `replaces_receipt_id` bigint unsigned DEFAULT NULL COMMENT '被本版本替换的上一回执版本ID;首个版本为空', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '上传或引用时的回执文件名称快照', + `file_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '回执文件内容SHA-256摘要;用于版本审计和幂等校验', + `visibility` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PLATFORM' COMMENT '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载', + `note` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '回执业务备注', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传或引用回执的平台操作人ID', + `uploaded_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上传回执时的平台操作人名称快照', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回执上传或引用时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_receipt_version` (`submission_batch_id`,`receipt_type`,`receipt_version`), + UNIQUE KEY `uk_customs_receipt_file` (`submission_batch_id`,`file_id`), + KEY `idx_customs_receipt_case` (`customs_case_id`,`visibility`,`uploaded_at`), + CONSTRAINT `fk_customs_receipt_batch` FOREIGN KEY (`submission_batch_id`) REFERENCES `fl_customs_submission_batch` (`id`), + CONSTRAINT `fk_customs_receipt_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_receipt_type` CHECK ((`receipt_type` in (_utf8mb4'RECEIVED',_utf8mb4'DECLARED',_utf8mb4'REJECTED',_utf8mb4'INSPECTION',_utf8mb4'RELEASED',_utf8mb4'OTHER'))), + CONSTRAINT `chk_customs_receipt_visibility` CHECK ((`visibility` in (_utf8mb4'PLATFORM',_utf8mb4'CUSTOMER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关代理回执版本与客户可见范围'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_submission_batch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_submission_batch` ( + `id` bigint unsigned NOT NULL COMMENT '报关代理提交批次逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `batch_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '全局唯一代理提交批次号', + `batch_version` int unsigned NOT NULL COMMENT '同一报关协同单内的提交批次版本序号', + `customs_agent_id` bigint unsigned DEFAULT NULL, + `customs_agent_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照', + `external_reference` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '报关代理或外部系统返回的业务参考号', + `file_snapshot_json` json NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成', + `note` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '提交报关代理时的平台备注', + `submitted_by` bigint unsigned NOT NULL COMMENT '提交报关代理的平台操作人ID', + `submitted_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '提交时的平台操作人名称快照', + `submitted_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_batch_no` (`batch_no`), + UNIQUE KEY `uk_customs_batch_version` (`customs_case_id`,`batch_version`), + CONSTRAINT `fk_customs_batch_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_batch_status` CHECK ((`status` in (_utf8mb4'SUBMITTED',_utf8mb4'ACKNOWLEDGED',_utf8mb4'REJECTED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关代理提交批次及冻结文件版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_supplement_request`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_supplement_request` ( + `id` bigint unsigned NOT NULL COMMENT '报关补件请求逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `source_document_id` bigint unsigned NOT NULL COMMENT '触发本次补件的被退回资料版本ID', + `resolved_document_id` bigint unsigned DEFAULT NULL COMMENT '解决本次补件的新资料版本ID;未解决时为空', + `checklist_item_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '需要补充的报关资料项目编码', + `reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台审核人员填写的补件原因', + `due_at` datetime(3) DEFAULT NULL COMMENT '期望完成补件时间;未设置时为空', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'OPEN' COMMENT '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消', + `requested_by` bigint unsigned NOT NULL COMMENT '发起补件的平台操作人ID', + `requested_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '发起补件时的平台操作人名称快照', + `requested_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '补件请求发起时间', + `resolved_by` bigint unsigned DEFAULT NULL COMMENT '提交解决资料的操作人ID;未解决时为空', + `resolved_at` datetime(3) DEFAULT NULL COMMENT '补件请求解决时间', + PRIMARY KEY (`id`), + KEY `idx_customs_supplement_case` (`customs_case_id`,`status`,`due_at`), + CONSTRAINT `fk_customs_supplement_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_supplement_status` CHECK ((`status` in (_utf8mb4'OPEN',_utf8mb4'RESOLVED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关补件请求'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_direct_delivery_unloading`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_direct_delivery_unloading` ( + `container_booking_id` bigint unsigned NOT NULL, + `inbound_id` bigint unsigned NOT NULL COMMENT 'Immutable physically loaded inbound source', + `actual_at` datetime(3) NOT NULL COMMENT 'Actual destination local time', + `timezone` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Explicit destination IANA timezone', + `received_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Actual recipient', + `evidence_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Actual delivery receipt reference', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` bigint unsigned NOT NULL, + `created_by_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`container_booking_id`,`inbound_id`), + UNIQUE KEY `uk_direct_unloading_command` (`container_booking_id`,`idempotency_key`), + CONSTRAINT `fk_direct_unloading_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_document_sequence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_document_sequence` ( + `sequence_key` varchar(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818', + `current_value` int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', + PRIMARY KEY (`sequence_key`), + CONSTRAINT `chk_fl_document_sequence_value` CHECK ((`current_value` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_dropship_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_dropship_pallet` ( + `id` bigint unsigned NOT NULL COMMENT '合托托盘ID', + `task_id` bigint unsigned NOT NULL COMMENT '所属合托任务ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + `warehouse_id` bigint unsigned NOT NULL COMMENT '作业仓库ID', + `pallet_no` varchar(32) NOT NULL COMMENT '现场扫描的系统DP托盘码', + `pallet_status` varchar(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', + `open_operation_id` varchar(128) NOT NULL COMMENT '创建托盘幂等操作ID', + `seal_operation_id` varchar(128) DEFAULT NULL COMMENT '封托幂等操作ID', + `created_by` bigint unsigned NOT NULL COMMENT '托盘创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '托盘创建人姓名快照', + `completed_by` bigint unsigned DEFAULT NULL COMMENT '封托操作人ID', + `completed_by_name` varchar(64) DEFAULT NULL COMMENT '封托操作人姓名快照', + `completed_at` datetime(3) DEFAULT NULL COMMENT '封托完成时间', + `version` int NOT NULL DEFAULT '1' COMMENT '托盘乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '托盘创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '托盘更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dropship_pallet_open_op` (`open_operation_id`), + UNIQUE KEY `uk_dropship_pallet_no` (`pallet_no`), + UNIQUE KEY `uk_dropship_pallet_seal_op` (`seal_operation_id`), + KEY `idx_dropship_pallet_inbound` (`tenant_id`,`inbound_id`,`pallet_status`), + KEY `fk_dropship_pallet_task` (`task_id`), + CONSTRAINT `fk_dropship_pallet_task` FOREIGN KEY (`task_id`) REFERENCES `fl_dropship_pallet_task` (`id`), + CONSTRAINT `chk_dropship_pallet_status` CHECK ((`pallet_status` in (_utf8mb4'OPEN',_utf8mb4'SEALED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发合托形成的可打印托盘'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_dropship_pallet_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_dropship_pallet_carton` ( + `id` bigint unsigned NOT NULL COMMENT '托盘箱明细ID', + `task_id` bigint unsigned NOT NULL COMMENT '所属合托任务ID', + `pallet_id` bigint unsigned DEFAULT NULL COMMENT '所属组托托盘ID;已清点待绑托时为空', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '入仓货物明细ID', + `carton_label_id` bigint unsigned NOT NULL COMMENT '稳定箱唛记录ID', + `sku_code` varchar(128) NOT NULL COMMENT '箱唛冻结SKU编码', + `cargo_name` varchar(255) DEFAULT NULL COMMENT '货物名称快照', + `box_code` varchar(80) NOT NULL COMMENT '稳定箱码', + `expected_sku_quantity` int unsigned NOT NULL COMMENT '箱唛冻结的应收SKU数量', + `actual_sku_quantity` int unsigned NOT NULL COMMENT 'PDA确认的实收SKU数量', + `discrepancy_status` varchar(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE', + `discrepancy_remark` varchar(500) DEFAULT NULL COMMENT '差异接受说明', + `count_operation_id` varchar(128) NOT NULL COMMENT '逐箱清点幂等操作ID', + `bind_operation_id` varchar(128) DEFAULT NULL COMMENT '批量绑托幂等操作ID', + `last_operation_id` varchar(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号', + `counted_by` bigint unsigned NOT NULL COMMENT '清点操作人ID', + `counted_by_name` varchar(64) DEFAULT NULL COMMENT '清点操作人姓名快照', + `counted_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间', + `bound_by` bigint unsigned DEFAULT NULL COMMENT '上托操作人ID', + `bound_by_name` varchar(64) DEFAULT NULL COMMENT '上托操作人姓名快照', + `bound_at` datetime(3) DEFAULT NULL COMMENT '上托时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dropship_carton_task_label` (`task_id`,`carton_label_id`), + UNIQUE KEY `uk_dropship_carton_count_operation` (`count_operation_id`), + KEY `idx_dropship_carton_pallet_sku` (`pallet_id`,`sku_code`,`id`), + KEY `idx_dropship_carton_inbound` (`tenant_id`,`inbound_id`,`pallet_id`), + KEY `idx_dropship_carton_operation` (`count_operation_id`), + KEY `fk_dropship_carton_label` (`carton_label_id`), + KEY `idx_dropship_carton_bind_operation` (`task_id`,`bind_operation_id`), + CONSTRAINT `fk_dropship_carton_label` FOREIGN KEY (`carton_label_id`) REFERENCES `fl_inbound_carton_label` (`id`), + CONSTRAINT `fk_dropship_carton_pallet` FOREIGN KEY (`pallet_id`) REFERENCES `fl_dropship_pallet` (`id`), + CONSTRAINT `fk_dropship_carton_task` FOREIGN KEY (`task_id`) REFERENCES `fl_dropship_pallet_task` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发托盘内逐箱SKU和入仓单明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_dropship_pallet_carton_sku`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_dropship_pallet_carton_sku` ( + `id` bigint unsigned NOT NULL COMMENT '清点箱SKU明细ID', + `carton_id` bigint unsigned NOT NULL COMMENT '清点物理箱ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '入仓货物明细ID', + `sku_code` varchar(128) NOT NULL COMMENT 'SKU编码快照', + `cargo_name` varchar(255) DEFAULT NULL COMMENT '货物名称快照', + `expected_quantity` int unsigned NOT NULL COMMENT '应收数量', + `actual_quantity` int unsigned NOT NULL COMMENT '实收数量', + `discrepancy_status` varchar(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE', + `discrepancy_remark` varchar(500) DEFAULT NULL COMMENT '差异处理说明', + `line_no` int unsigned NOT NULL COMMENT '箱内SKU顺序', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dropship_carton_sku_detail` (`carton_id`,`inbound_detail_id`), + KEY `idx_dropship_carton_sku_code` (`sku_code`,`carton_id`), + CONSTRAINT `fk_dropship_carton_sku_carton` FOREIGN KEY (`carton_id`) REFERENCES `fl_dropship_pallet_carton` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发物理箱内逐SKU清点事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_dropship_pallet_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_dropship_pallet_task` ( + `id` bigint unsigned NOT NULL COMMENT '合托任务ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + `warehouse_id` bigint unsigned NOT NULL COMMENT '作业仓库ID', + `task_status` varchar(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', + `total_cartons` int unsigned NOT NULL COMMENT '测量确认总箱数', + `counted_cartons` int unsigned NOT NULL DEFAULT '0' COMMENT '已逐箱确认实收数量的箱数', + `allocated_cartons` int unsigned NOT NULL DEFAULT '0' COMMENT '已上托箱数', + `discrepancy_cartons` int unsigned NOT NULL DEFAULT '0' COMMENT '已确认差异箱数', + `assignee_id` bigint unsigned DEFAULT NULL COMMENT '任务领取人ID', + `assignee_name` varchar(64) DEFAULT NULL COMMENT '任务领取人姓名快照', + `claimed_at` datetime(3) DEFAULT NULL COMMENT '任务领取时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '任务完成时间', + `version` int NOT NULL DEFAULT '1' COMMENT '任务乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dropship_task_inbound` (`tenant_id`,`inbound_id`), + KEY `idx_dropship_task_operator` (`task_status`,`assignee_id`,`warehouse_id`,`created_at`), + KEY `fk_dropship_task_inbound` (`inbound_id`), + CONSTRAINT `fk_dropship_task_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_dropship_task_cartons` CHECK (((`allocated_cartons` <= `counted_cartons`) and (`counted_cartons` <= `total_cartons`))), + CONSTRAINT `chk_dropship_task_status` CHECK ((`task_status` in (_utf8mb4'PENDING',_utf8mb4'CLAIMED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发测量后PDA合托任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_fee_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_fee_item` ( + `id` bigint unsigned NOT NULL COMMENT '费用项目ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id', + `category` varchar(64) NOT NULL COMMENT '费用分类', + `trade_scope` varchar(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', + `fee_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码', + `fee_type_name` varchar(128) NOT NULL COMMENT '费用类型名称', + `pickup_payable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应付是否参与', + `pickup_receivable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应收是否参与', + `warehouse_payable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应付是否参与', + `warehouse_receivable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应收是否参与', + `allocation_fee` tinyint(1) NOT NULL DEFAULT '0' COMMENT '平摊费用是否参与', + `description` varchar(500) DEFAULT NULL COMMENT '说明', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + `created_by` bigint DEFAULT NULL COMMENT '创建人ID;系统初始化时可为空', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint DEFAULT NULL COMMENT '最后修改人ID;系统初始化时可为空', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0未删除、1已删除', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `supplier_category` varchar(16) DEFAULT NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商', + `default_price` decimal(12,2) DEFAULT NULL COMMENT '默认价格CNY;仅选费带入,实际费用可修改', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fee_item_code` (`fee_code`), + KEY `idx_fee_item_query` (`tenant_id`,`trade_scope`,`category`,`status`,`deleted`), + KEY `idx_fee_item_name` (`tenant_id`,`fee_type_name`,`deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共费用项目主数据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_finance_fee_source_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_finance_fee_source_command` ( + `id` bigint unsigned NOT NULL COMMENT 'Finance费用来源命令ID', + `customer_id` bigint unsigned NOT NULL COMMENT '客户隔离键,由Firstleg费用事实再次校验', + `operator_id` bigint unsigned NOT NULL COMMENT 'Finance操作人ID', + `operator_name` varchar(64) NOT NULL COMMENT 'Finance操作人名称快照', + `action` varchar(24) NOT NULL COMMENT '命令动作;当前仅VOID', + `idempotency_key` varchar(128) NOT NULL COMMENT 'Finance透传的幂等键', + `request_hash` char(64) NOT NULL COMMENT '规范化请求SHA-256', + `source_type` varchar(24) NOT NULL COMMENT 'INBOUND_FEE或CONTAINER_FEE', + `source_fee_id` bigint unsigned NOT NULL COMMENT '稳定费用来源ID', + `source_version` int unsigned NOT NULL COMMENT '精确费用来源版本', + `inbound_id` bigint unsigned DEFAULT NULL COMMENT '入库费用所属入库单ID', + `container_id` bigint unsigned DEFAULT NULL COMMENT '柜次费用所属柜次ID', + `reason` varchar(500) NOT NULL COMMENT '操作原因', + `status` varchar(16) NOT NULL COMMENT 'PROCESSING或SUCCEEDED', + `result_status` varchar(16) DEFAULT NULL COMMENT '成功业务结果;当前为VOIDED', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '命令成功完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_finance_fee_command_idem` (`customer_id`,`operator_id`,`action`,`idempotency_key`), + KEY `idx_finance_fee_command_source` (`source_type`,`source_fee_id`,`source_version`,`created_at`), + CONSTRAINT `chk_finance_fee_command_action` CHECK ((`action` = _utf8mb4'VOID')), + CONSTRAINT `chk_finance_fee_command_source` CHECK ((`source_type` in (_utf8mb4'INBOUND_FEE',_utf8mb4'CONTAINER_FEE'))), + CONSTRAINT `chk_finance_fee_command_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCEEDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance发起、Firstleg执行业务费用命令的幂等审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_fulfillment_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_fulfillment_task` ( + `id` bigint unsigned NOT NULL COMMENT '履约任务ID(雪花ID)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `order_id` bigint unsigned NOT NULL COMMENT '订单服务中的订单逻辑ID,不建立跨库外键', + `order_no` varchar(32) NOT NULL COMMENT '订单号快照', + `order_snapshot_json` json NOT NULL COMMENT '创建履约任务时所需的订单业务快照', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约状态:PENDING_ASSIGNMENT待指派、ASSIGNED已指派、PROCESSING处理中、COMPLETED已完成、CANCELLED已取消', + `assignee_id` bigint unsigned DEFAULT NULL COMMENT '当前负责人ID', + `assignee_name` varchar(64) DEFAULT NULL COMMENT '当前负责人名称快照', + `assigned_at` datetime(3) DEFAULT NULL COMMENT '最近指派时间', + `started_at` datetime(3) DEFAULT NULL COMMENT '开始履约时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '履约完成时间', + `created_by` bigint unsigned NOT NULL COMMENT '创建人或系统主体ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_tenant_order` (`tenant_id`,`order_id`), + KEY `idx_task_assignee_status` (`tenant_id`,`assignee_id`,`status`,`created_at`), + KEY `idx_task_tenant_created` (`tenant_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_carton_label`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_carton_label` ( + `id` bigint unsigned NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `order_id` bigint unsigned NOT NULL COMMENT '订单服务订单ID快照', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '订单货物明细ID,不建立跨库外键', + `cargo_item_no` int unsigned NOT NULL COMMENT '订单货物明细序号快照', + `cargo_name` varchar(255) DEFAULT NULL COMMENT '货物名称快照', + `sku_code` varchar(128) DEFAULT NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + `units_per_carton` int unsigned DEFAULT NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', + `detail_package_count` int unsigned NOT NULL COMMENT '该订单货物明细箱数快照', + `inbound_code` varchar(40) NOT NULL COMMENT '入仓码快照', + `box_code` varchar(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', + `total_carton_count` int unsigned NOT NULL COMMENT '首次分配时整单总箱数', + `carton_index` int unsigned NOT NULL COMMENT '整单从1开始的稳定箱下标', + `label_version` int unsigned NOT NULL DEFAULT '1' COMMENT '箱唛批次版本,从1递增', + `batch_status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', + `cargo_fingerprint` char(64) DEFAULT NULL COMMENT '影响标签字段的SHA-256指纹', + `voided_at` datetime(3) DEFAULT NULL COMMENT '批次作废时间', + `box_specification` varchar(255) NOT NULL COMMENT '订单箱规或货物明细描述快照', + `length_cm` decimal(12,3) DEFAULT NULL COMMENT '下单箱规长度cm快照', + `width_cm` decimal(12,3) DEFAULT NULL COMMENT '下单箱规宽度cm快照', + `height_cm` decimal(12,3) DEFAULT NULL COMMENT '下单箱规高度cm快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carton_label_box_code` (`tenant_id`,`box_code`), + UNIQUE KEY `uk_carton_label_index` (`tenant_id`,`inbound_id`,`label_version`,`carton_index`), + KEY `idx_carton_label_detail` (`tenant_id`,`inbound_id`,`inbound_detail_id`), + KEY `fk_carton_label_inbound` (`inbound_id`), + KEY `idx_carton_label_active` (`tenant_id`,`inbound_id`,`batch_status`,`label_version`), + CONSTRAINT `fk_carton_label_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_carton_label_sku`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_carton_label_sku` ( + `id` bigint unsigned NOT NULL COMMENT '箱唛SKU快照ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `carton_label_id` bigint unsigned NOT NULL COMMENT '物理箱唛ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '订单货物明细ID', + `sku_code` varchar(128) NOT NULL COMMENT 'SKU编码快照', + `cargo_name` varchar(255) DEFAULT NULL COMMENT '货物名称快照', + `expected_quantity` int unsigned NOT NULL COMMENT '该物理箱内应收SKU数量', + `line_no` int unsigned NOT NULL COMMENT '箱内SKU展示顺序', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carton_label_sku_detail` (`carton_label_id`,`inbound_detail_id`), + KEY `idx_carton_label_sku_code` (`tenant_id`,`inbound_id`,`sku_code`), + CONSTRAINT `fk_carton_label_sku_label` FOREIGN KEY (`carton_label_id`) REFERENCES `fl_inbound_carton_label` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='物理箱唛内的多SKU冻结快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_destination_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_destination_snapshot` ( + `id` bigint unsigned NOT NULL COMMENT '目的仓地址快照ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `destination_type` varchar(24) DEFAULT NULL COMMENT '目的仓类型', + `destination_code` varchar(32) DEFAULT NULL COMMENT '目的仓编码', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称', + `country_code` char(2) DEFAULT NULL COMMENT '国家二字码', + `state_province` varchar(100) DEFAULT NULL COMMENT '省或州', + `city` varchar(100) DEFAULT NULL COMMENT '城市', + `address_line` varchar(512) DEFAULT NULL COMMENT '详细地址', + `postal_code` varchar(20) DEFAULT NULL COMMENT '邮编', + `contact_name` varchar(64) DEFAULT NULL COMMENT '联系人', + `contact_phone` varchar(32) DEFAULT NULL COMMENT '联系人电话', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_destination_inbound` (`tenant_id`,`inbound_id`), + KEY `fk_destination_inbound` (`inbound_id`), + CONSTRAINT `fk_destination_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单目的仓地址和联系人快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_fee` ( + `id` bigint unsigned NOT NULL COMMENT '入库费用ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `measurement_id` bigint unsigned DEFAULT NULL COMMENT '璐圭敤缁戝畾鐨勬祴閲忔壒娆D蹇収', + `measurement_source` varchar(32) DEFAULT NULL COMMENT '璁¤垂灏哄鏉ユ簮蹇収锛歄UTER涓哄绠辫鏁版嵁', + `fee_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户内唯一费用单号', + `fee_version` int unsigned NOT NULL DEFAULT '1' COMMENT '费用快照版本', + `charge_rule_id` bigint unsigned DEFAULT NULL COMMENT '历史客户计费规则ID;渠道价格可为空', + `charge_rule_version` int unsigned DEFAULT NULL COMMENT '历史客户规则版本快照', + `carrier_rate_id` bigint unsigned DEFAULT NULL COMMENT '船司FBA价格ID;人工报价为空', + `carrier_rate_version` int unsigned DEFAULT NULL COMMENT '船司价格版本快照;人工报价为空', + `price_book_version_id` bigint unsigned DEFAULT NULL COMMENT '整表价格版本ID快照', + `rule_code` varchar(50) DEFAULT NULL COMMENT '历史客户规则编码快照', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', + `customer_name` varchar(128) NOT NULL COMMENT '客户名称快照', + `business_type` varchar(32) NOT NULL COMMENT '业务类型快照', + `tax_mode` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '订单买单或退税模式快照', + `pricing_tax_mode` varchar(16) DEFAULT NULL COMMENT '报价包税维度快照', + `rounding_scale` tinyint unsigned DEFAULT NULL COMMENT '历史客户规则计费量保留位数快照', + `rounding_mode` varchar(24) DEFAULT NULL COMMENT '历史客户规则舍入方式快照', + `rule_effective_from` datetime(3) DEFAULT NULL COMMENT '历史客户规则生效开始快照', + `rule_effective_to` datetime(3) DEFAULT NULL COMMENT '客户规则生效结束快照', + `rate_code` varchar(50) NOT NULL COMMENT '船司价格编码快照', + `origin_warehouse_id` bigint unsigned NOT NULL COMMENT '起运仓ID快照', + `origin_warehouse_name` varchar(128) NOT NULL COMMENT '起运仓名称快照', + `channel_code` varchar(64) NOT NULL COMMENT '渠道编码快照', + `carrier_id` bigint unsigned DEFAULT NULL COMMENT '历史承运商ID;渠道价格可为空', + `carrier_name` varchar(128) DEFAULT NULL COMMENT '历史承运商名称;渠道价格可为空', + `destination_type` varchar(16) DEFAULT NULL COMMENT '目的地类型快照:FBA或OVERSEAS', + `destination_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '海外仓或FBA目的仓ID快照', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `destination_code` varchar(32) DEFAULT NULL COMMENT 'FBA仓编码快照;海外仓可为空', + `rate_effective_from` datetime(3) DEFAULT NULL COMMENT '价格生效开始快照;人工报价为空', + `rate_effective_to` datetime(3) DEFAULT NULL COMMENT '价格生效结束快照', + `charge_mode` varchar(16) DEFAULT NULL COMMENT '计费口径:KG或CBM', + `conversion_factor` decimal(18,6) DEFAULT NULL COMMENT '历史客户规则使用的换算系数快照', + `actual_weight_kg` decimal(18,3) NOT NULL COMMENT '试算时实际重量kg', + `actual_volume_cbm` decimal(18,4) NOT NULL COMMENT '试算时实际体积m3', + `converted_quantity` decimal(18,4) NOT NULL COMMENT '换算后的重量或体积', + `chargeable_quantity` decimal(18,4) NOT NULL COMMENT '最终计费量', + `charge_unit` varchar(16) NOT NULL COMMENT '计费单位', + `currency` char(3) NOT NULL COMMENT '币种', + `subtotal_amount` decimal(18,2) NOT NULL COMMENT '基础费用金额', + `additional_amount` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '附加费金额', + `total_amount` decimal(18,2) NOT NULL COMMENT '总金额', + `fee_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、SUPERSEDED已被新版本替代', + `review_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认', + `exception_release` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否基于测量异常放行生成', + `calculated_by` bigint unsigned NOT NULL COMMENT '试算人ID', + `calculated_at` datetime(3) NOT NULL COMMENT '试算时间', + `confirmed_by` bigint unsigned DEFAULT NULL COMMENT '确认人ID', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '确认时间', + `sent_by` bigint unsigned DEFAULT NULL COMMENT '发送人ID', + `sent_at` datetime(3) DEFAULT NULL COMMENT '发送客户时间', + `customer_confirmed_by` bigint unsigned DEFAULT NULL COMMENT '瀹㈡埛纭浜虹敤鎴稩D', + `customer_confirmed_by_name` varchar(64) DEFAULT NULL COMMENT '瀹㈡埛纭浜哄悕绉板揩鐓�', + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '瀹㈡埛纭璐圭敤鏃堕棿锛涢潪绌哄悗璐圭敤鐗堟湰閿佸畾', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `baseline_chargeable_quantity` decimal(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量', + `tolerance_percent` decimal(5,2) NOT NULL DEFAULT '10.00' COMMENT '人工调整允许的最大偏差百分比,边界包含', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_fee_no` (`tenant_id`,`fee_no`), + UNIQUE KEY `uk_inbound_fee_version` (`tenant_id`,`inbound_id`,`fee_version`), + KEY `idx_inbound_fee_status` (`tenant_id`,`inbound_id`,`fee_status`,`review_status`), + KEY `fk_fee_inbound` (`inbound_id`), + KEY `fk_fee_charge_rule` (`charge_rule_id`), + KEY `fk_fee_carrier_rate` (`carrier_rate_id`), + CONSTRAINT `fk_fee_carrier_rate` FOREIGN KEY (`carrier_rate_id`) REFERENCES `fl_carrier_fba_rate` (`id`), + CONSTRAINT `fk_fee_charge_rule` FOREIGN KEY (`charge_rule_id`) REFERENCES `fl_customer_charge_rule` (`id`), + CONSTRAINT `fk_fee_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用计算和确认快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_fee_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_fee_detail` ( + `id` bigint unsigned NOT NULL COMMENT '入库费用明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID(客户隔离键);费用明细引用平台公共费用项目', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `fee_item_id` bigint unsigned NOT NULL COMMENT '绑定费用项目ID', + `source_type` varchar(32) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL或FBA_PRICING', + `source_fee_id` bigint unsigned DEFAULT NULL COMMENT '来源计费快照ID', + `source_fee_version` int unsigned DEFAULT NULL COMMENT '来源计费快照版本', + `source_fee_no` varchar(40) DEFAULT NULL COMMENT '来源费用单号', + `inbound_code` varchar(40) NOT NULL COMMENT '入仓码快照', + `direction` varchar(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付', + `business_date` date NOT NULL COMMENT '业务发生日', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '客户ID快照', + `customer_name` varchar(128) DEFAULT NULL COMMENT '客户公司名称快照', + `invoice_title` varchar(256) DEFAULT NULL COMMENT '发票抬头快照', + `fee_category_snapshot` varchar(64) NOT NULL COMMENT '费用分类快照', + `trade_scope_snapshot` varchar(20) NOT NULL COMMENT '国内国际快照', + `fee_type_name_snapshot` varchar(128) NOT NULL COMMENT '费用类型名称快照', + `channel_code` varchar(64) DEFAULT NULL COMMENT '渠道编码快照', + `channel_name` varchar(128) DEFAULT NULL COMMENT '渠道名称快照', + `destination_code` varchar(32) DEFAULT NULL COMMENT '目的仓编码快照', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `package_count` int DEFAULT NULL COMMENT '件数快照', + `actual_gross_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '仓库实测毛重快照,单位千克', + `actual_volume_cbm` decimal(18,6) DEFAULT NULL COMMENT '实际体积快照,单位立方米', + `volume_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '材积重快照,单位千克', + `chargeable_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '费用计算采用的计费重量,单位千克', + `chargeable_volume_cbm` decimal(18,6) DEFAULT NULL COMMENT '费用计算采用的计费体积,单位立方米', + `chargeable_quantity` decimal(18,4) DEFAULT NULL COMMENT '本费用最终计费量', + `charge_unit` varchar(16) DEFAULT NULL COMMENT 'KG或CBM', + `unit_price` decimal(18,4) NOT NULL COMMENT '费用单价;计价单位由对应费用项目规则确定', + `currency` char(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '金额币种三字码,如CNY', + `amount` decimal(18,2) NOT NULL COMMENT '该费用明细金额,币种由currency字段指定', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `review_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', + `reviewer_id` bigint DEFAULT NULL COMMENT '审核人ID', + `reviewer_name` varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间', + `review_reason` varchar(500) DEFAULT NULL COMMENT '审核驳回原因', + `publication_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'DRAFT' COMMENT '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代', + `published_by` bigint DEFAULT NULL COMMENT '发布人ID', + `published_by_name` varchar(64) DEFAULT NULL COMMENT '发布人名称快照', + `published_at` datetime(3) DEFAULT NULL COMMENT '发布时间', + `inbounded_at` datetime(3) DEFAULT NULL COMMENT '入库完成时间快照', + `settlement_included` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否参与结算', + `created_by` bigint DEFAULT NULL COMMENT '创建人或系统主体ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint DEFAULT NULL COMMENT '最后修改人或系统主体ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0未删除、1已删除', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `paid_at` datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + `payment_method` varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + `payment_reference_no` varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + `payment_voucher_file_ids` json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + `settlement_mode` varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID', + `supplier_category` varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + `supplier_id` bigint unsigned DEFAULT NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + `supplier_name` varchar(200) DEFAULT NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`,`source_type`,`source_fee_id`), + KEY `idx_inbound_fee_detail` (`tenant_id`,`inbound_id`,`business_date`,`deleted`), + KEY `idx_inbound_fee_review` (`tenant_id`,`review_status`,`business_date`,`deleted`), + KEY `idx_inbound_fee_item` (`tenant_id`,`fee_item_id`,`deleted`), + KEY `fk_inbound_fee_detail_order` (`inbound_id`), + KEY `fk_inbound_fee_detail_item` (`fee_item_id`), + KEY `idx_inbound_fee_published` (`tenant_id`,`inbound_id`,`publication_status`,`source_fee_version`), + CONSTRAINT `fk_inbound_fee_detail_item` FOREIGN KEY (`fee_item_id`) REFERENCES `fl_fee_item` (`id`), + CONSTRAINT `fk_inbound_fee_detail_order` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单应收应付费用明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_fee_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_fee_item` ( + `id` bigint unsigned NOT NULL COMMENT '费用明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `fee_id` bigint unsigned NOT NULL COMMENT '费用头ID', + `item_no` int unsigned NOT NULL COMMENT '费用单内序号', + `fee_code` varchar(50) NOT NULL COMMENT '费用项目编码', + `fee_name` varchar(128) NOT NULL COMMENT '费用项目名称快照', + `quantity` decimal(18,4) NOT NULL COMMENT '计费数量', + `unit` varchar(16) NOT NULL COMMENT '计费单位', + `unit_price` decimal(18,6) NOT NULL COMMENT '单价', + `amount` decimal(18,2) NOT NULL COMMENT '金额', + `currency` char(3) NOT NULL COMMENT '币种', + `automatic_flag` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否自动计算', + `remark` varchar(500) DEFAULT NULL COMMENT '费用备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fee_item_no` (`tenant_id`,`fee_id`,`item_no`), + KEY `fk_fee_item_header` (`fee_id`), + CONSTRAINT `fk_fee_item_header` FOREIGN KEY (`fee_id`) REFERENCES `fl_inbound_fee` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用项目明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_file`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_file` ( + `id` bigint unsigned NOT NULL COMMENT '入库单文件关系ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `file_id` bigint unsigned NOT NULL COMMENT '文件服务文件ID', + `file_type` varchar(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT', + `file_name_snapshot` varchar(255) NOT NULL COMMENT '文件名快照', + `visibility` varchar(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围', + `file_version` int unsigned NOT NULL DEFAULT '1' COMMENT '业务文件版本', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传人ID', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_file_relation` (`tenant_id`,`inbound_id`,`file_id`,`file_type`), + KEY `idx_inbound_file_type` (`tenant_id`,`inbound_id`,`file_type`,`deleted`), + KEY `fk_file_inbound` (`inbound_id`), + CONSTRAINT `fk_file_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单与文件中心对象的分类关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_hold_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_hold_record` ( + `id` bigint unsigned NOT NULL COMMENT '扣货记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `hold_action` varchar(16) NOT NULL COMMENT '动作:HOLD或RELEASE', + `reason` varchar(1000) NOT NULL COMMENT '扣货或解除原因', + `evidence_file_id` bigint unsigned DEFAULT NULL COMMENT '证据文件ID', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_hold_inbound_time` (`tenant_id`,`inbound_id`,`operated_at`), + KEY `fk_hold_inbound` (`inbound_id`), + CONSTRAINT `fk_hold_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单扣货和解除扣货不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_idempotency`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_idempotency` ( + `id` bigint unsigned NOT NULL COMMENT '入库写操作幂等记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `operation` varchar(40) NOT NULL COMMENT '操作类型', + `operator_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '操作者或外部应用ID', + `inbound_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '入库单ID,创建前可为0', + `idempotency_key` varchar(128) NOT NULL COMMENT '幂等键', + `request_hash` char(64) NOT NULL COMMENT '请求内容摘要', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED已成功、FAILED已失败', + `result_json` json DEFAULT NULL COMMENT '成功结果摘要', + `result_resource_id` bigint unsigned DEFAULT NULL COMMENT '成功结果资源ID', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_idempotency` (`tenant_id`,`operation`,`operator_id`,`idempotency_key`), + KEY `idx_inbound_idempotency_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_internal_note`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_internal_note` ( + `id` bigint unsigned NOT NULL COMMENT '客服内部备注ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `content` varchar(2000) NOT NULL COMMENT '仅内部可见备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_internal_note_time` (`tenant_id`,`inbound_id`,`created_at`), + KEY `fk_internal_note_inbound` (`inbound_id`), + CONSTRAINT `fk_internal_note_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单客服内部备注历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_loading_pick_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_loading_pick_task` ( + `id` bigint unsigned NOT NULL COMMENT '入库单级PDA拣货任务ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:PENDING待拣货、IN_PROGRESS拣货中、COMPLETED已完成', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `expected_count` int unsigned DEFAULT NULL COMMENT '计划应拣箱数', + `picked_count` int unsigned DEFAULT NULL COMMENT '实际已拣箱数', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '拣货结果或异常补充说明', + `updated_by` bigint unsigned NOT NULL COMMENT '最后操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_tenant_inbound` (`tenant_id`,`inbound_id`), + KEY `idx_loading_pick_status` (`tenant_id`,`status`,`updated_at`), + KEY `fk_loading_pick_inbound` (`inbound_id`), + CONSTRAINT `fk_loading_pick_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货任务及结果'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_measurement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_measurement` ( + `id` bigint unsigned NOT NULL COMMENT '测量批次ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `split_batch_id` bigint unsigned DEFAULT NULL COMMENT '产生该测量批次的入仓拆分批次ID', + `source_measurement_id` bigint unsigned DEFAULT NULL COMMENT '子单继承或复核时的来源测量批次ID', + `source_type` varchar(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL', + `source_code` varchar(64) DEFAULT NULL COMMENT '第三方应用或来源编码', + `external_event_no` varchar(128) DEFAULT NULL COMMENT '第三方事件号', + `external_request_hash` char(64) DEFAULT NULL COMMENT '第三方data原文和图片内容联合请求哈希', + `device_code` varchar(64) DEFAULT NULL COMMENT '测量设备编码', + `warehouse_code` varchar(50) DEFAULT NULL COMMENT '第三方回传仓库编码', + `measured_at` datetime(3) DEFAULT NULL COMMENT '设备测量时间', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态:DRAFT草稿、RECEIVED已接收、SUBMITTED已提交、APPROVED已通过、REJECTED已驳回', + `raw_payload_json` json DEFAULT NULL COMMENT '第三方原始报文', + `original_document_no` varchar(64) DEFAULT NULL COMMENT '原单据号码快照', + `original_gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '原单据毛重kg', + `original_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '原单据体积或材积m3', + `actual_goods_name` varchar(255) DEFAULT NULL COMMENT '实际品名', + `total_package_count` int unsigned DEFAULT NULL COMMENT '本批次总箱数', + `total_volume_raw_cbm` decimal(18,6) DEFAULT NULL COMMENT '本批次原始总体积m3', + `total_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '本批次业务总体积m3', + `volume_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本批次总材积重kg', + `created_by` bigint unsigned DEFAULT NULL COMMENT '创建人或系统主体ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_measure_external_event` (`tenant_id`,`source_code`,`external_event_no`), + KEY `idx_measure_inbound_time` (`tenant_id`,`inbound_id`,`created_at`), + KEY `fk_measure_inbound` (`inbound_id`), + KEY `fk_measure_split_batch` (`split_batch_id`), + KEY `fk_measure_source_batch` (`source_measurement_id`), + KEY `idx_measure_split_source` (`tenant_id`,`split_batch_id`,`source_measurement_id`,`created_at`), + KEY `idx_measure_inbound_status_id` (`tenant_id`,`inbound_id`,`status`,`id`), + CONSTRAINT `fk_measure_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_measure_source_batch` FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`), + CONSTRAINT `fk_measure_split_batch` FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库长宽高测量批次和第三方原始数据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_measurement_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_measurement_item` ( + `id` bigint unsigned NOT NULL COMMENT '测量明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `measurement_id` bigint unsigned NOT NULL COMMENT '测量批次ID', + `split_item_id` bigint unsigned DEFAULT NULL COMMENT '产生该子单测量明细的拆分台账ID', + `source_measurement_item_id` bigint unsigned DEFAULT NULL COMMENT '被拆分继承的来源测量箱规明细ID', + `inbound_detail_id` bigint unsigned DEFAULT NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空', + `external_detail_no` varchar(128) DEFAULT NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空', + `box_code` varchar(128) DEFAULT NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一', + `scanned_shipment_id` varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', + `item_no` varchar(32) NOT NULL COMMENT '批次内明细号', + `box_specification` varchar(64) NOT NULL COMMENT '箱规标识', + `package_count` int unsigned NOT NULL COMMENT '相同箱规箱数', + `length_cm` decimal(12,3) NOT NULL COMMENT '原始长度cm', + `width_cm` decimal(12,3) NOT NULL COMMENT '原始宽度cm', + `height_cm` decimal(12,3) NOT NULL COMMENT '原始高度cm', + `girth_cm` decimal(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)', + `unit_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空', + `unit_volume_cbm` decimal(18,4) NOT NULL COMMENT '单箱业务体积m3', + `volume_raw_cbm` decimal(18,6) NOT NULL COMMENT '原始体积m3', + `volume_cbm` decimal(18,4) NOT NULL COMMENT '业务体积m3', + `total_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本箱规总重量kg', + `total_volume_cbm` decimal(18,4) NOT NULL COMMENT '本箱规总体积m3', + `volume_weight_kg` decimal(18,3) NOT NULL COMMENT '本箱规材积重kg', + `charge_rule_id` bigint unsigned DEFAULT NULL COMMENT 'customer charge rule id', + `coefficient_mode` varchar(16) DEFAULT NULL COMMENT '系数模式快照:ADD或MULTIPLY', + `calculation_rule_version` varchar(64) NOT NULL COMMENT '体积计算和舍入规则版本', + `source_image_file_id` bigint unsigned DEFAULT NULL COMMENT '测量设备图片文件ID', + `machine_no` varchar(64) DEFAULT NULL COMMENT '本次测量设备编号', + `device_measured_at` datetime(3) DEFAULT NULL COMMENT '本次设备测量时间', + `over_limit_warning` varchar(500) DEFAULT NULL COMMENT '超线提醒快照', + `outer_length_cm` decimal(12,3) DEFAULT NULL COMMENT '外箱规长度cm', + `outer_width_cm` decimal(12,3) DEFAULT NULL COMMENT '外箱规宽度cm', + `outer_height_cm` decimal(12,3) DEFAULT NULL COMMENT '外箱规高度cm', + `manual_outer_length_cm` decimal(12,3) DEFAULT NULL COMMENT '浠撳簱浜哄伐澶栫瑙勯暱搴﹁绠楀熀鏁癱m', + `manual_outer_width_cm` decimal(12,3) DEFAULT NULL COMMENT '浠撳簱浜哄伐澶栫瑙勫搴﹁绠楀熀鏁癱m', + `manual_outer_height_cm` decimal(12,3) DEFAULT NULL COMMENT '浠撳簱浜哄伐澶栫瑙勯珮搴﹁绠楀熀鏁癱m', + `remark` varchar(500) DEFAULT NULL COMMENT '测量明细备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_measure_item_no` (`tenant_id`,`measurement_id`,`item_no`), + UNIQUE KEY `uk_measure_external_detail` (`tenant_id`,`inbound_detail_id`,`external_detail_no`), + UNIQUE KEY `uk_measure_box_code` (`tenant_id`,`box_code`), + KEY `fk_measure_item_batch` (`measurement_id`), + KEY `fk_measure_item_split_item` (`split_item_id`), + KEY `fk_measure_item_source_item` (`source_measurement_item_id`), + KEY `idx_measure_item_split_source` (`tenant_id`,`split_item_id`,`source_measurement_item_id`), + KEY `idx_measure_item_candidate` (`tenant_id`,`measurement_id`,`inbound_detail_id`), + CONSTRAINT `fk_measure_item_batch` FOREIGN KEY (`measurement_id`) REFERENCES `fl_inbound_measurement` (`id`), + CONSTRAINT `fk_measure_item_source_item` FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`), + CONSTRAINT `fk_measure_item_split_item` FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库长宽高和箱数测量明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_order` ( + `id` bigint unsigned NOT NULL COMMENT '入库单ID(雪花ID)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `task_id` bigint unsigned DEFAULT NULL COMMENT '头程履约任务ID', + `order_id` bigint unsigned NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', + `order_no` varchar(32) NOT NULL COMMENT '订单号快照', + `inbound_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户内唯一入仓码', + `inbound_role` varchar(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单', + `parent_inbound_id` bigint unsigned DEFAULT NULL COMMENT '直属主入仓单ID;主单为空', + `root_inbound_id` bigint unsigned DEFAULT NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释', + `split_sequence` int unsigned DEFAULT NULL COMMENT '同一主单下的子单顺序号', + `split_status` varchar(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT', + `process_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜', + `order_status_snapshot` varchar(24) NOT NULL COMMENT '订单状态快照', + `measure_review_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', + `fee_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废', + `fee_review_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认', + `notification_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态:NOT_CREATED未创建、PENDING待通知', + `expected_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到达时间', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '客户逻辑ID', + `customer_name` varchar(128) NOT NULL COMMENT '客户名称快照', + `company_id` bigint unsigned DEFAULT NULL COMMENT '运营公司逻辑ID', + `company_name` varchar(128) DEFAULT NULL COMMENT '运营公司名称快照', + `warehouse_id` bigint unsigned NOT NULL COMMENT '实际作业仓库ID', + `warehouse_name` varchar(128) NOT NULL COMMENT '实际作业仓库名称快照', + `delivery_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '交货仓逻辑ID', + `delivery_warehouse_name` varchar(128) DEFAULT NULL COMMENT '交货仓名称快照', + `business_type` varchar(32) NOT NULL COMMENT '业务类型', + `fulfillment_type` varchar(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', + `lx_code` varchar(128) DEFAULT NULL COMMENT '一件代发领星入库单代码', + `channel_code` varchar(64) DEFAULT NULL COMMENT '渠道编码', + `channel_name` varchar(128) DEFAULT NULL COMMENT '渠道名称快照', + `carrier_id` bigint unsigned DEFAULT NULL COMMENT '计费船司逻辑ID', + `carrier_name` varchar(128) DEFAULT NULL COMMENT '计费船司名称快照', + `tax_mode` varchar(24) DEFAULT NULL COMMENT '买单或退税模式', + `pricing_tax_mode` varchar(16) DEFAULT NULL COMMENT '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED', + `container_mode` varchar(24) DEFAULT NULL COMMENT '整柜或散货模式', + `customs_inspection` tinyint(1) DEFAULT NULL COMMENT '是否商检', + `destination_type` varchar(24) DEFAULT NULL COMMENT '目的仓类型', + `destination_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '目的仓逻辑ID', + `destination_code` varchar(32) DEFAULT NULL COMMENT '目的仓编码,例如FBA仓编码', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `hold_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '当前是否扣货', + `current_hold_reason` varchar(500) DEFAULT NULL COMMENT '当前扣货理由快照', + `last_overseas_hold_version` bigint unsigned DEFAULT NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖', + `total_package_count` int unsigned DEFAULT NULL COMMENT '确认后的总箱数', + `package_count` int unsigned DEFAULT NULL COMMENT '入仓单箱数;三期唯一数量口径', + `split_purpose` varchar(16) NOT NULL DEFAULT 'MASTER' COMMENT '履约分片用途:MASTER、LOADED、REMAINDER', + `actual_gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认后的实际总毛重kg', + `chargeable_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '当前费用快照的计费重量kg', + `actual_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '确认后的实际总体积m3', + `chargeable_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '当前费用快照的计费体积m3', + `pallet_count` int unsigned DEFAULT NULL COMMENT '已分配总托盘数快照', + `volume_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '总材积重kg', + `actual_net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '总净重kg', + `measure_completed_at` datetime(3) DEFAULT NULL COMMENT '测量完成时间', + `inbounded_at` datetime(3) DEFAULT NULL COMMENT '入库完成时间', + `express_company_code` varchar(64) DEFAULT NULL COMMENT '快递公司编码快照', + `express_company_name` varchar(128) DEFAULT NULL COMMENT '快递公司名称快照', + `express_tracking_no` varchar(128) DEFAULT NULL COMMENT '快递单号快照', + `public_remark` varchar(1000) DEFAULT NULL COMMENT '客户可见备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `master_order_id` bigint unsigned GENERATED ALWAYS AS ((case when ((`inbound_role` = _utf8mb4'MASTER') and (`deleted` = 0)) then `order_id` else NULL end)) STORED COMMENT '仅用于约束有效主单订单唯一性', + `active_remainder_root_id` bigint unsigned GENERATED ALWAYS AS ((case when ((`inbound_role` = _utf8mb4'CHILD') and (`split_purpose` = _utf8mb4'REMAINDER') and (`split_status` = _utf8mb4'NOT_SPLIT') and (`deleted` = 0)) then `root_inbound_id` else NULL end)) STORED COMMENT '有效余量唯一约束键;历史已完成分片为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_code` (`inbound_code`), + UNIQUE KEY `uk_inbound_master_order` (`tenant_id`,`master_order_id`), + UNIQUE KEY `uk_inbound_parent_split_sequence` (`tenant_id`,`parent_inbound_id`,`split_sequence`), + UNIQUE KEY `uk_inbound_one_active_remainder` (`tenant_id`,`active_remainder_root_id`), + KEY `idx_inbound_process_time` (`tenant_id`,`process_status`,`created_at`,`id`), + KEY `idx_inbound_measure_time` (`tenant_id`,`measure_review_status`,`created_at`,`id`), + KEY `idx_inbound_fee_time` (`tenant_id`,`fee_review_status`,`created_at`,`id`), + KEY `idx_inbound_warehouse_status` (`tenant_id`,`warehouse_id`,`process_status`,`created_at`), + KEY `idx_inbound_customer_time` (`tenant_id`,`customer_id`,`created_at`), + KEY `idx_inbound_company_time` (`tenant_id`,`company_id`,`created_at`), + KEY `idx_inbound_arrival` (`tenant_id`,`expected_arrival_at`), + KEY `idx_inbound_destination` (`tenant_id`,`destination_code`,`created_at`), + KEY `idx_inbound_channel` (`tenant_id`,`channel_code`,`created_at`), + KEY `fk_inbound_task` (`task_id`), + KEY `idx_inbound_root_role` (`tenant_id`,`root_inbound_id`,`inbound_role`,`created_at`,`id`), + KEY `fk_inbound_parent_order` (`parent_inbound_id`), + KEY `fk_inbound_root_order` (`root_inbound_id`), + KEY `idx_inbound_smart_loading` (`warehouse_id`,`process_status`,`channel_code`,`expected_arrival_at`,`id`), + CONSTRAINT `fk_inbound_parent_order` FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_root_order` FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_task` FOREIGN KEY (`task_id`) REFERENCES `fl_fulfillment_task` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='二期头程入库单聚合根和列表查询快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_outbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_outbox` ( + `id` bigint unsigned NOT NULL COMMENT '领域事件全局ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '事件所属客户ID和数据隔离键', + `aggregate_type` varchar(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', + `aggregate_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `event_type` varchar(64) NOT NULL COMMENT '事件类型', + `event_key` varchar(160) DEFAULT NULL COMMENT '费用来源事件幂等键;历史事件允许为空', + `event_version` int unsigned NOT NULL DEFAULT '1' COMMENT '事件信封版本', + `aggregate_sequence` int unsigned DEFAULT NULL COMMENT '同一费用来源的单调版本序号', + `payload_hash` varchar(71) DEFAULT NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀', + `payload_json` longtext NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法', + `publish_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '事件发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败', + `retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '下次重试时间', + `published_at` datetime(3) DEFAULT NULL COMMENT '发布时间', + `locked_at` datetime(3) DEFAULT NULL COMMENT 'Relay抢占租约时间', + `locked_by` varchar(64) DEFAULT NULL COMMENT 'Relay抢占实例ID', + `last_error` varchar(500) DEFAULT NULL COMMENT '最近一次投递失败摘要,不记录令牌', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_outbox_event_key` (`event_key`), + KEY `idx_inbound_outbox_publish` (`publish_status`,`next_retry_at`,`created_at`), + KEY `idx_inbound_outbox_finance_relay` (`publish_status`,`next_retry_at`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库事务Outbox事件表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_pallet_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_pallet_allocation` ( + `id` bigint unsigned NOT NULL COMMENT '头程托盘分配引用ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `allocation_no` int unsigned NOT NULL COMMENT '入库单内分配序号', + `block_id` bigint unsigned NOT NULL COMMENT 'Warehouse整票连续库位块ID', + `slot_no` int unsigned NOT NULL COMMENT '连续块内稳定槽位序号', + `weight_result_id` bigint unsigned DEFAULT NULL COMMENT '已绑定称重结果ID;预留空槽为空', + `pallet_sequence` int unsigned DEFAULT NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空', + `warehouse_id` bigint unsigned NOT NULL COMMENT '仓库服务仓库ID', + `warehouse_resource_tenant_id` bigint unsigned NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键', + `pallet_id` bigint unsigned NOT NULL COMMENT '仓库服务托盘ID', + `pallet_code` varchar(50) NOT NULL COMMENT '托盘编码快照', + `pallet_capacity_cbm` decimal(18,4) NOT NULL COMMENT '托盘可用容积快照', + `allocated_volume_cbm` decimal(18,4) NOT NULL COMMENT '本托盘分配体积', + `allocated_package_count` int unsigned DEFAULT NULL COMMENT '本托盘分配箱数', + `picked_package_count` int unsigned NOT NULL DEFAULT '0' COMMENT '已拣货箱数', + `location_id` bigint unsigned NOT NULL COMMENT '仓库服务库位ID', + `location_code` varchar(50) NOT NULL COMMENT '库位编码快照', + `shared_overflow` tinyint(1) NOT NULL DEFAULT '0' COMMENT '普通连续位不足时是否使用共享A0', + `reservation_id` bigint unsigned DEFAULT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘', + `slot_status` varchar(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED', + `reservation_no` varchar(64) NOT NULL COMMENT '仓库库位预占单号', + `allocation_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pallet_allocation_no` (`tenant_id`,`inbound_id`,`allocation_no`), + UNIQUE KEY `uk_pallet_allocation_slot` (`tenant_id`,`block_id`,`slot_no`), + UNIQUE KEY `uk_pallet_allocation_weight` (`tenant_id`,`weight_result_id`), + KEY `idx_allocation_location` (`tenant_id`,`warehouse_id`,`location_id`,`allocation_status`), + KEY `fk_allocation_inbound` (`inbound_id`), + KEY `idx_allocation_reservation` (`reservation_id`), + CONSTRAINT `fk_allocation_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程侧托盘、库位和仓库预占结果引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_pickup_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_pickup_snapshot` ( + `id` bigint unsigned NOT NULL COMMENT '提货信息快照ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `pickup_method` varchar(24) DEFAULT NULL COMMENT '提货方式', + `pickup_at` datetime(3) DEFAULT NULL COMMENT '提货时间', + `contact_name` varchar(64) DEFAULT NULL COMMENT '提货联系人', + `contact_phone` varchar(32) DEFAULT NULL COMMENT '提货联系人电话', + `contact_email` varchar(128) DEFAULT NULL COMMENT '提货联系人邮箱', + `country_code` char(2) DEFAULT NULL COMMENT '国家二字码', + `province` varchar(64) DEFAULT NULL COMMENT '省或州', + `city` varchar(64) DEFAULT NULL COMMENT '城市', + `district` varchar(64) DEFAULT NULL COMMENT '区县', + `address_line` varchar(512) DEFAULT NULL COMMENT '提货详细地址', + `postal_code` varchar(20) DEFAULT NULL COMMENT '提货邮编', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pickup_inbound` (`tenant_id`,`inbound_id`), + KEY `fk_pickup_inbound` (`inbound_id`), + CONSTRAINT `fk_pickup_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单提货信息不可变快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_split_batch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_split_batch` ( + `id` bigint unsigned NOT NULL COMMENT '拆分批次ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `parent_inbound_id` bigint unsigned NOT NULL COMMENT '主入仓单ID', + `source_inbound_id` bigint unsigned DEFAULT NULL COMMENT '本批次实际消耗的MASTER或REMAINDER分片ID', + `operation_id` varchar(64) NOT NULL COMMENT '业务操作幂等号', + `idempotency_key` varchar(128) NOT NULL COMMENT '请求幂等键', + `expected_parent_version` int NOT NULL COMMENT '操作时主单乐观锁版本', + `package_count` int unsigned NOT NULL COMMENT '本批次拆分箱数', + `loaded_child_inbound_id` bigint unsigned DEFAULT NULL COMMENT '本批次实装子单ID', + `remainder_child_inbound_id` bigint unsigned DEFAULT NULL COMMENT '本批次余量子单ID', + `loaded_package_count` int unsigned NOT NULL DEFAULT '0' COMMENT '本批次实装箱数', + `remaining_package_count` int unsigned NOT NULL DEFAULT '0' COMMENT '本批次未装箱数', + `loading_plan_id` bigint unsigned DEFAULT NULL COMMENT '关联装柜计划/柜次', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联柜次标识(兼容查询)', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PREPARED' COMMENT '拆分批次状态:PREPARED已准备、APPLIED已生效、CANCELLED已取消', + `remark` varchar(500) DEFAULT NULL COMMENT '拆分原因或备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分批次创建时间', + `applied_at` datetime(3) DEFAULT NULL COMMENT '批次生效时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_split_operation` (`tenant_id`,`operation_id`), + UNIQUE KEY `uk_inbound_split_idempotency` (`tenant_id`,`parent_inbound_id`,`idempotency_key`), + UNIQUE KEY `uk_inbound_split_source_once` (`tenant_id`,`source_inbound_id`), + KEY `idx_inbound_split_parent` (`tenant_id`,`parent_inbound_id`,`created_at`,`id`), + KEY `fk_inbound_split_parent` (`parent_inbound_id`), + KEY `fk_inbound_split_loaded_child` (`loaded_child_inbound_id`), + KEY `fk_inbound_split_source` (`source_inbound_id`), + KEY `fk_inbound_split_remainder_child` (`remainder_child_inbound_id`), + KEY `idx_inbound_split_loading_plan` (`tenant_id`,`loading_plan_id`,`container_booking_id`,`status`,`id`), + CONSTRAINT `fk_inbound_split_loaded_child` FOREIGN KEY (`loaded_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_parent` FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_remainder_child` FOREIGN KEY (`remainder_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_source` FOREIGN KEY (`source_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_inbound_split_package` CHECK ((`package_count` > 0)), + CONSTRAINT `chk_inbound_split_status` CHECK ((`status` in (_utf8mb4'PREPARED',_utf8mb4'APPLIED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主入仓单拆分批次;数量口径仅为箱数'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_split_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_split_detail` ( + `id` bigint unsigned NOT NULL COMMENT '部分锁定明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `parent_inbound_id` bigint unsigned NOT NULL COMMENT '主入库单ID', + `child_inbound_id` bigint unsigned DEFAULT NULL COMMENT '完成拆单后生成的二级子入库单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', + `source_package_count` int unsigned NOT NULL COMMENT '拆分前该明细总箱数快照', + `locked_package_count` int unsigned NOT NULL COMMENT '本次装柜锁定箱数', + `remaining_package_count` int unsigned GENERATED ALWAYS AS ((`source_package_count` - `locked_package_count`)) STORED COMMENT '锁定后剩余箱数,由来源箱数减锁定箱数生成', + `status` varchar(16) NOT NULL DEFAULT 'LOCKED' COMMENT '分柜明细状态:LOCKED已锁定、COMPLETED已完成拆分', + `operation_id` varchar(128) NOT NULL COMMENT '部分锁定操作幂等号', + `created_by` bigint unsigned NOT NULL COMMENT '创建部分锁定明细的操作人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', + `completed_by` bigint unsigned DEFAULT NULL COMMENT '完成拆分的操作人ID', + `completed_by_name` varchar(64) DEFAULT NULL COMMENT '完成操作人姓名快照', + `completed_at` datetime(3) DEFAULT NULL COMMENT '部分拆分完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '部分锁定明细创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '部分锁定明细最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_split_parent_detail` (`tenant_id`,`parent_inbound_id`,`inbound_detail_id`), + UNIQUE KEY `uk_inbound_split_operation` (`operation_id`), + UNIQUE KEY `uk_inbound_split_child_detail` (`tenant_id`,`child_inbound_id`,`inbound_detail_id`), + KEY `idx_inbound_split_status` (`tenant_id`,`parent_inbound_id`,`status`), + KEY `fk_inbound_split_detail_parent` (`parent_inbound_id`), + KEY `fk_inbound_split_detail_child` (`child_inbound_id`), + CONSTRAINT `fk_inbound_split_detail_child` FOREIGN KEY (`child_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_detail_parent` FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_inbound_split_completion` CHECK ((((`status` = _utf8mb4'LOCKED') and (`child_inbound_id` is null) and (`completed_at` is null)) or ((`status` = _utf8mb4'COMPLETED') and (`child_inbound_id` is not null) and (`completed_at` is not null)))), + CONSTRAINT `chk_inbound_split_partial` CHECK (((`source_package_count` > 1) and (`locked_package_count` > 0) and (`locked_package_count` < `source_package_count`))), + CONSTRAINT `chk_legacy_inbound_split_status` CHECK ((`status` in (_utf8mb4'LOCKED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_split_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_split_history` ( + `id` bigint unsigned NOT NULL COMMENT '拆分历史ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `split_batch_id` bigint unsigned NOT NULL COMMENT '拆分批次ID', + `operation_id` varchar(64) NOT NULL COMMENT '历史操作幂等号', + `action_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '拆分动作:CREATE创建批次、APPLY生效、CANCEL取消', + `before_snapshot` json DEFAULT NULL COMMENT '操作前快照', + `after_snapshot` json NOT NULL COMMENT '操作后快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operation_reason` varchar(500) DEFAULT NULL COMMENT '操作原因', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拆分历史创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_split_history_operation` (`tenant_id`,`operation_id`), + KEY `idx_inbound_split_history` (`tenant_id`,`split_batch_id`,`created_at`,`id`), + KEY `fk_inbound_split_history_batch` (`split_batch_id`), + CONSTRAINT `fk_inbound_split_history_batch` FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`), + CONSTRAINT `chk_inbound_split_action` CHECK ((`action_type` in (_utf8mb4'CREATE',_utf8mb4'APPLY',_utf8mb4'CANCEL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='不可变主子入仓单拆分审计历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_split_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_split_item` ( + `id` bigint unsigned NOT NULL COMMENT '拆分明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `split_batch_id` bigint unsigned NOT NULL COMMENT '拆分批次ID', + `parent_inbound_id` bigint unsigned NOT NULL COMMENT '主入仓单ID', + `child_inbound_id` bigint unsigned NOT NULL COMMENT '子入仓单ID', + `line_no` int unsigned NOT NULL COMMENT '同一拆分批次内的行号', + `source_inbound_detail_id` bigint unsigned DEFAULT NULL COMMENT '订单货物明细逻辑ID', + `source_measurement_id` bigint unsigned NOT NULL COMMENT '来源测量批次ID', + `source_measurement_item_id` bigint unsigned NOT NULL COMMENT '来源测量箱规明细ID', + `package_count` int unsigned NOT NULL COMMENT '分配给子单的箱数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分数量台账创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`,`split_batch_id`,`line_no`), + UNIQUE KEY `uk_inbound_split_batch_source_child` (`tenant_id`,`split_batch_id`,`source_measurement_item_id`,`child_inbound_id`), + KEY `idx_inbound_split_detail` (`tenant_id`,`source_inbound_detail_id`,`created_at`), + KEY `fk_inbound_split_item_batch` (`split_batch_id`), + KEY `fk_inbound_split_item_parent` (`parent_inbound_id`), + KEY `fk_inbound_split_item_child` (`child_inbound_id`), + KEY `fk_inbound_split_item_measurement` (`source_measurement_id`), + KEY `fk_inbound_split_item_measure_item` (`source_measurement_item_id`), + KEY `idx_inbound_split_candidate` (`tenant_id`,`parent_inbound_id`,`source_inbound_detail_id`), + CONSTRAINT `fk_inbound_split_item_batch` FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`), + CONSTRAINT `fk_inbound_split_item_child` FOREIGN KEY (`child_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_item_measure_item` FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`), + CONSTRAINT `fk_inbound_split_item_measurement` FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`), + CONSTRAINT `fk_inbound_split_item_parent` FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_inbound_split_item_package` CHECK ((`package_count` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主子入仓单拆分数量台账'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_status_history` ( + `id` bigint unsigned NOT NULL COMMENT '入库状态历史ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `from_status` varchar(24) DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(24) NOT NULL COMMENT '变更后状态', + `action` varchar(32) NOT NULL COMMENT '触发动作', + `reason` varchar(1000) DEFAULT NULL COMMENT '变更原因', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `occurred_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', + PRIMARY KEY (`id`), + KEY `idx_inbound_history_time` (`tenant_id`,`inbound_id`,`occurred_at`), + KEY `fk_history_inbound` (`inbound_id`), + CONSTRAINT `fk_history_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单流程状态不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_transport_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_transport_snapshot` ( + `id` bigint unsigned NOT NULL COMMENT '运输信息快照ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `loading_status` varchar(24) DEFAULT NULL COMMENT '配载状态快照', + `container_no` varchar(64) DEFAULT NULL COMMENT '集装箱号', + `cabinet_no` varchar(64) DEFAULT NULL COMMENT '柜号或内部柜单号', + `vessel_name` varchar(128) DEFAULT NULL COMMENT '船名', + `voyage_no` varchar(64) DEFAULT NULL COMMENT '航次', + `sailing_at` datetime(3) DEFAULT NULL COMMENT '开船时间', + `port_arrival_at` datetime(3) DEFAULT NULL COMMENT '到港时间', + `source_updated_at` datetime(3) DEFAULT NULL COMMENT '上游数据更新时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_transport_inbound` (`tenant_id`,`inbound_id`), + KEY `idx_transport_container` (`tenant_id`,`container_no`), + KEY `idx_transport_cabinet` (`tenant_id`,`cabinet_no`), + KEY `idx_transport_loading` (`tenant_id`,`loading_status`,`sailing_at`), + KEY `fk_transport_inbound` (`inbound_id`), + CONSTRAINT `fk_transport_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单关联运输信息只读快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_weight_result`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_weight_result` ( + `id` bigint unsigned NOT NULL COMMENT 'OCR重量结果ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `file_id` bigint unsigned NOT NULL COMMENT '称重图片文件ID', + `pallet_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Warehouse托盘ID', + `pallet_code` varchar(50) NOT NULL DEFAULT '' COMMENT '托盘编码快照', + `pallet_type` varchar(32) NOT NULL DEFAULT '' COMMENT '托盘类型快照', + `pallet_capacity_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '托盘容量CBM快照', + `pallet_tare_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '托盘皮重KG快照', + `ocr_provider` varchar(64) DEFAULT NULL COMMENT 'OCR服务商编码', + `ocr_raw_text` varchar(1000) DEFAULT NULL COMMENT 'OCR原始文本', + `ocr_weight_value` decimal(18,3) DEFAULT NULL COMMENT 'OCR识别原始重量值', + `ocr_weight_unit` varchar(8) DEFAULT NULL COMMENT 'OCR识别原始单位', + `confidence` decimal(6,5) DEFAULT NULL COMMENT '识别置信度', + `adjusted_weight_value` decimal(18,3) DEFAULT NULL COMMENT '人工修正后的原单位重量', + `adjusted_weight_unit` varchar(8) DEFAULT NULL COMMENT '人工修正后的单位', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认时统一换算后的含托盘毛重kg', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', + `recognition_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', + `adjustment_reason` varchar(500) DEFAULT NULL COMMENT '人工修正原因', + `adjusted_by` bigint unsigned DEFAULT NULL COMMENT '修正人ID', + `adjusted_at` datetime(3) DEFAULT NULL COMMENT '修正时间', + `confirmed_by` bigint unsigned DEFAULT NULL COMMENT '确认人ID', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '确认时间', + `created_by` bigint unsigned NOT NULL DEFAULT '0' COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL DEFAULT '0' COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_weight_inbound_file` (`tenant_id`,`inbound_id`,`file_id`), + KEY `idx_weight_inbound_status` (`tenant_id`,`inbound_id`,`recognition_status`), + KEY `fk_weight_inbound` (`inbound_id`), + KEY `idx_weight_inbound_pallet` (`tenant_id`,`inbound_id`,`pallet_id`), + CONSTRAINT `fk_weight_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='称重图片OCR原值、人工修正值和确认重量'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_weight_revision`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_weight_revision` ( + `id` bigint unsigned NOT NULL COMMENT 'OCR重量修订记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `weight_result_id` bigint unsigned NOT NULL COMMENT 'OCR重量结果ID', + `revision_no` int unsigned NOT NULL COMMENT '结果版本号,与乐观锁版本一致', + `action` varchar(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', + `before_weight_value` decimal(18,3) DEFAULT NULL COMMENT '变更前原单位重量', + `before_weight_unit` varchar(8) DEFAULT NULL COMMENT '变更前单位', + `after_weight_value` decimal(18,3) DEFAULT NULL COMMENT '变更后原单位重量', + `after_weight_unit` varchar(8) DEFAULT NULL COMMENT '变更后单位', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认时统一换算后的含托盘毛重kg', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', + `reason` varchar(500) DEFAULT NULL COMMENT '人工修正原因', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_weight_revision_no` (`tenant_id`,`weight_result_id`,`revision_no`), + KEY `idx_weight_revision_time` (`tenant_id`,`inbound_id`,`operated_at`), + KEY `fk_weight_revision_inbound` (`inbound_id`), + KEY `fk_weight_revision_result` (`weight_result_id`), + CONSTRAINT `fk_weight_revision_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_weight_revision_result` FOREIGN KEY (`weight_result_id`) REFERENCES `fl_inbound_weight_result` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='OCR重量人工修正和确认的不可变版本历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_cabinet_evidence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_cabinet_evidence` ( + `id` bigint unsigned NOT NULL COMMENT '柜次证据记录ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '证据唯一归属柜次ID', + `source_task_id` bigint unsigned NOT NULL COMMENT '上传来源PDA任务ID,仅用于审计', + `evidence_type` varchar(32) NOT NULL COMMENT '四步照片或带锁码照类型', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件ID', + `file_name` varchar(255) NOT NULL COMMENT '文件名称快照', + `seal_no` varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码', + `remark` varchar(1000) DEFAULT NULL COMMENT '证据备注', + `status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废', + `active_flag` tinyint unsigned DEFAULT '1' COMMENT '有效唯一标识;作废后置NULL', + `operation_key` varchar(128) NOT NULL COMMENT '任务范围内幂等操作键', + `request_hash` char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + `void_reason` varchar(500) DEFAULT NULL COMMENT '作废原因', + `voided_by` bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + `voided_by_name` varchar(128) DEFAULT NULL COMMENT '作废操作人名称快照', + `voided_at` datetime(3) DEFAULT NULL COMMENT '作废时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(128) DEFAULT NULL COMMENT '创建操作人名称快照', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(128) DEFAULT NULL COMMENT '最后修改操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cabinet_evidence_operation` (`source_task_id`,`operation_key`), + UNIQUE KEY `uk_cabinet_evidence_active` (`container_booking_id`,`evidence_type`,`active_flag`), + KEY `idx_cabinet_evidence_container` (`container_booking_id`,`status`), + KEY `idx_cabinet_evidence_seal` (`seal_no`,`active_flag`), + CONSTRAINT `fk_cabinet_evidence_booking` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `fk_cabinet_evidence_task` FOREIGN KEY (`source_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_cabinet_evidence_status` CHECK ((`status` in (_utf8mb4'ACTIVE',_utf8mb4'VOID'))), + CONSTRAINT `chk_cabinet_evidence_type` CHECK ((`evidence_type` in (_utf8mb4'PHOTO_STEP_1',_utf8mb4'PHOTO_STEP_2',_utf8mb4'PHOTO_STEP_3',_utf8mb4'PHOTO_STEP_4',_utf8mb4'SEAL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次物理装柜证据;task仅记录来源审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_completion_checkpoint`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_completion_checkpoint` ( + `command_id` bigint unsigned NOT NULL COMMENT '可靠装柜完成命令ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '入库单客户ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `owner_sequence` int unsigned NOT NULL COMMENT '稳定处理顺序', + `checkpoint_stage` varchar(24) NOT NULL COMMENT '入库单处理检查点', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '检查点创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '检查点更新时间', + PRIMARY KEY (`command_id`,`tenant_id`,`inbound_id`), + KEY `idx_loading_completion_owner` (`command_id`,`owner_sequence`), + CONSTRAINT `fk_loading_completion_checkpoint_command` FOREIGN KEY (`command_id`) REFERENCES `fl_loading_completion_command` (`id`), + CONSTRAINT `chk_loading_completion_checkpoint` CHECK ((`checkpoint_stage` in (_utf8mb4'READY',_utf8mb4'SETTLED',_utf8mb4'INBOUND_COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜完成按入库单恢复检查点'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_completion_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_completion_command` ( + `id` bigint unsigned NOT NULL COMMENT '可靠装柜完成命令ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA任务ID', + `operation_id` varchar(128) NOT NULL COMMENT '客户端全局幂等操作ID', + `request_hash` char(64) NOT NULL COMMENT '规范化请求摘要', + `command_status` varchar(16) NOT NULL COMMENT 'PROCESSING、RETRYABLE、FAILED、COMPLETED', + `expected_task_version` int unsigned NOT NULL COMMENT '受理时客户端任务版本', + `remark` varchar(500) DEFAULT NULL COMMENT '完成备注', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `attempt_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'worker认领次数', + `next_retry_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间', + `lease_owner` varchar(128) DEFAULT NULL COMMENT 'worker租约持有者', + `lease_until` datetime(3) DEFAULT NULL COMMENT 'worker租约截止时间', + `failure_stage` varchar(40) DEFAULT NULL COMMENT '脱敏失败阶段', + `failure_code` varchar(32) DEFAULT NULL COMMENT '下游或业务错误码', + `failure_request_id` varchar(64) DEFAULT NULL COMMENT '下游请求ID', + `failure_trace_id` varchar(64) DEFAULT NULL COMMENT '下游链路ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `completed_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_completion_operation` (`operation_id`), + KEY `idx_loading_completion_dispatch` (`command_status`,`next_retry_at`,`lease_until`), + KEY `fk_loading_completion_task` (`pick_task_id`), + CONSTRAINT `fk_loading_completion_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_completion_status` CHECK ((`command_status` in (_utf8mb4'PROCESSING',_utf8mb4'RETRYABLE',_utf8mb4'FAILED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜完成可靠异步命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_evidence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_evidence` ( + `id` bigint unsigned NOT NULL COMMENT '装柜证据记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + `evidence_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'PHOTO_STEP_1空仓/PHOTO_STEP_2半仓/PHOTO_STEP_3满仓/PHOTO_STEP_4锁仓/SEAL带锁码照', + `file_id` bigint unsigned DEFAULT NULL COMMENT 'File 服务逻辑文件ID', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上传时文件名称快照', + `seal_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '锁码文本;带锁码照必填,客户不可见', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '证据补充说明', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `created_by` bigint unsigned NOT NULL COMMENT '上传证据的操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上传证据的操作人姓名快照', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '证据记录创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '证据记录最后更新时间', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID已作废;错误照片保留记录不硬删除', + `void_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '照片作废原因', + `voided_by` bigint unsigned DEFAULT NULL COMMENT '执行作废的平台操作人ID', + `voided_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行作废的平台操作人姓名快照', + `voided_at` datetime(3) DEFAULT NULL COMMENT '照片作废时间', + PRIMARY KEY (`id`), + KEY `idx_loading_evidence_time` (`tenant_id`,`inbound_id`,`updated_at`), + KEY `fk_loading_evidence_inbound` (`inbound_id`), + KEY `idx_loading_evidence_type` (`tenant_id`,`inbound_id`,`evidence_type`,`status`), + KEY `idx_loading_evidence_seal` (`tenant_id`,`seal_no`,`status`), + CONSTRAINT `fk_loading_evidence_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单装柜空仓/半仓/满仓/锁仓/带锁码照证据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_operation` ( + `id` bigint unsigned NOT NULL COMMENT '装柜写操作记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '操作目标入库单逻辑ID', + `operator_id` bigint unsigned NOT NULL COMMENT '执行写操作的平台用户ID', + `operation` varchar(48) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '装柜或证据写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功、FAILED失败', + `result_json` json DEFAULT NULL COMMENT '成功结果或失败信息JSON快照,用于幂等重放', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作声明创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '操作完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_operation` (`tenant_id`,`inbound_id`,`operator_id`,`operation`,`idempotency_key`), + KEY `idx_loading_operation_time` (`tenant_id`,`inbound_id`,`created_at`), + KEY `fk_loading_operation_inbound` (`inbound_id`), + CONSTRAINT `fk_loading_operation_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='装柜与派送写操作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pallet_label`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pallet_label` ( + `id` bigint unsigned NOT NULL COMMENT '散货托盘标签ID(雪花ID)', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID', + `loading_plan_item_id` bigint unsigned NOT NULL COMMENT '标签归属装柜明细ID', + `account_id` bigint unsigned NOT NULL COMMENT '客户ID快照', + `inbound_id` bigint unsigned NOT NULL COMMENT '入仓单ID快照', + `inbound_code` varchar(40) NOT NULL COMMENT '入仓码快照', + `container_no` varchar(64) NOT NULL COMMENT '真实集装箱号快照', + `destination_warehouse_code` varchar(64) NOT NULL COMMENT 'FBA仓编码快照', + `destination_warehouse_name` varchar(128) DEFAULT NULL COMMENT 'FBA仓名称快照', + `pallet_index` int unsigned NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号', + `pallet_code` varchar(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pallet_code` (`pallet_code`), + UNIQUE KEY `uk_loading_pallet_index` (`loading_plan_id`,`destination_warehouse_code`,`pallet_index`), + KEY `idx_loading_pallet_item` (`loading_plan_item_id`,`pallet_index`), + KEY `idx_loading_pallet_inbound` (`account_id`,`inbound_id`), + CONSTRAINT `fk_loading_pallet_item` FOREIGN KEY (`loading_plan_item_id`) REFERENCES `fl_loading_plan_item` (`id`), + CONSTRAINT `fk_loading_pallet_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `chk_loading_pallet_index` CHECK ((`pallet_index` between 1 and 999)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='散货按柜号和FBA仓冻结的可打印托盘码'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_assignment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_assignment` ( + `pick_task_id` bigint unsigned NOT NULL COMMENT '柜次PDA拣货任务ID;同一任务仅保留一名作业员', + `assignee_id` bigint unsigned NOT NULL COMMENT '当前唯一作业员ID', + `assignee_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '当前作业员姓名快照', + `assigned_by` bigint unsigned NOT NULL COMMENT '领取任务的作业员或执行分配的平台用户ID', + `assigned_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '领取或分配操作人姓名快照', + `assignment_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分配方式:CLAIMED作业员领取、ASSIGNED平台指派', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,防止多人并发领取或覆盖', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务领取或分配时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '作业员分配最后更新时间', + PRIMARY KEY (`pick_task_id`), + KEY `idx_loading_pick_assignee` (`assignee_id`,`updated_at`), + CONSTRAINT `fk_loading_pick_assignment_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_pick_assignment_type` CHECK ((`assignment_type` in (_utf8mb4'CLAIMED',_utf8mb4'ASSIGNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务领取与作业员分配'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_history` ( + `id` bigint unsigned NOT NULL COMMENT 'PDA拣货操作历史ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + `pick_item_id` bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID', + `operation_id` varchar(128) NOT NULL COMMENT '客户端幂等操作号', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '完整批量请求内容SHA-256摘要', + `action_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PICK' COMMENT '拣货动作:PICK正常拣货、PARTIAL部分拣货、SKIP跳拣', + `picked_package_count` int unsigned NOT NULL COMMENT '本次拣货箱数', + `operation_reason` varchar(500) DEFAULT NULL COMMENT '部分拣货或跳拣原因', + `operator_id` bigint unsigned NOT NULL COMMENT '执行PDA拣货操作的作业员ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '作业员姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拣货操作历史创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_history_operation` (`operation_id`), + KEY `idx_loading_pick_history_item` (`pick_item_id`,`created_at`), + KEY `fk_loading_pick_history_task` (`pick_task_id`), + CONSTRAINT `fk_loading_pick_history_item` FOREIGN KEY (`pick_item_id`) REFERENCES `fl_loading_pick_item` (`id`), + CONSTRAINT `fk_loading_pick_history_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货幂等和操作审计历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_item` ( + `id` bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + `loading_plan_item_id` bigint unsigned NOT NULL COMMENT '装柜计划明细ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '父入库单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '订单货物明细ID', + `planned_package_count` int unsigned NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', + `picked_package_count` int unsigned NOT NULL DEFAULT '0' COMMENT '已拣货箱数', + `item_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '拣货明细状态:PENDING待拣、PICKING拣货中、PICKED已拣完、PARTIAL部分拣货、SKIPPED跳拣', + `release_mode` varchar(10) DEFAULT NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放', + `release_location_ids` json DEFAULT NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_item_plan_item` (`loading_plan_item_id`), + KEY `idx_loading_pick_item_task` (`pick_task_id`,`item_status`,`id`), + CONSTRAINT `fk_loading_pick_item_plan_item` FOREIGN KEY (`loading_plan_item_id`) REFERENCES `fl_loading_plan_item` (`id`), + CONSTRAINT `fk_loading_pick_item_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_pick_item_count` CHECK ((`picked_package_count` <= `planned_package_count`)), + CONSTRAINT `chk_loading_pick_item_planned` CHECK ((`planned_package_count` > 0)), + CONSTRAINT `chk_loading_pick_item_status` CHECK ((`item_status` in (_utf8mb4'PENDING',_utf8mb4'PICKING',_utf8mb4'PICKED',_utf8mb4'PARTIAL',_utf8mb4'SKIPPED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜拣货明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_item_assignment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_item_assignment` ( + `pick_item_id` bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID,同时作为本表主键', + `assignee_id` bigint unsigned NOT NULL COMMENT '当前领取或被分配的作业员ID', + `assignee_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '当前作业员姓名快照', + `assigned_by` bigint unsigned NOT NULL COMMENT '执行领取或分配操作的用户ID', + `assigned_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行领取或分配操作的用户姓名快照', + `assignment_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分配类型:CLAIMED作业员领取、ASSIGNED管理员分配', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细分配记录创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '明细分配记录最后更新时间', + PRIMARY KEY (`pick_item_id`), + KEY `idx_loading_pick_item_assignee` (`assignee_id`,`updated_at`), + CONSTRAINT `fk_loading_pick_item_assignment_item` FOREIGN KEY (`pick_item_id`) REFERENCES `fl_loading_pick_item` (`id`), + CONSTRAINT `chk_loading_pick_item_assignment_type` CHECK ((`assignment_type` in (_utf8mb4'CLAIMED',_utf8mb4'ASSIGNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货明细领取与作业员分配'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_operation` ( + `id` bigint unsigned NOT NULL COMMENT 'PDA任务操作审计记录ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT '关联柜次PDA拣货任务ID', + `operation_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '全局唯一操作ID,用于幂等重放', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '完整批量请求或任务操作内容SHA-256摘要', + `action_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '动作类型:CLAIM领取、ASSIGN分配、REVIEW复核、BATCH批量提交', + `operator_id` bigint unsigned NOT NULL COMMENT '执行PDA任务操作的用户ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变操作审计创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_operation_id` (`operation_id`), + KEY `idx_loading_pick_operation_task` (`pick_task_id`,`created_at`), + CONSTRAINT `fk_loading_pick_operation_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_pick_operation_action` CHECK ((`action_type` in (_gbk'CLAIM',_gbk'ASSIGN',_gbk'ITEM_CLAIM',_gbk'ITEM_ASSIGN',_gbk'REVIEW',_gbk'BATCH',_gbk'COMPLETE_LOADING'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务级幂等审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_review`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_review` ( + `pick_task_id` bigint unsigned NOT NULL COMMENT '已完成的柜次PDA拣货任务ID', + `review_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'APPROVED' COMMENT '复核状态:APPROVED已通过;记录创建后不可修改或删除', + `reviewed_by` bigint unsigned NOT NULL COMMENT '执行完成复核的平台用户ID', + `reviewed_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '复核人姓名快照', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'PDA任务复核备注', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '复核记录版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '复核通过时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '复核记录最后更新时间;正常业务不允许更新', + PRIMARY KEY (`pick_task_id`), + KEY `idx_loading_pick_reviewer` (`reviewed_by`,`created_at`), + CONSTRAINT `fk_loading_pick_review_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_pick_review_status` CHECK ((`review_status` = _utf8mb4'APPROVED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务完成复核'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_task` ( + `id` bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '稳定柜次ID', + `cabinet_no` varchar(64) DEFAULT NULL COMMENT '稳定内部柜次号快照', + `container_no` varchar(64) DEFAULT NULL COMMENT '真实集装箱号;生成PDA任务前允许为空', + `task_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成', + `started_by` bigint unsigned NOT NULL COMMENT '开始装柜人', + `started_by_name` varchar(64) DEFAULT NULL COMMENT '开始装柜人名称', + `started_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', + `completed_by` bigint unsigned DEFAULT NULL COMMENT '完成装柜人', + `completed_by_name` varchar(64) DEFAULT NULL COMMENT '完成装柜人名称', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_task_plan` (`loading_plan_id`), + UNIQUE KEY `uk_loading_pick_task_booking` (`container_booking_id`), + KEY `idx_loading_pick_container` (`container_no`,`task_status`,`started_at`), + CONSTRAINT `fk_loading_pick_task_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `chk_loading_pick_task_status` CHECK ((`task_status` in (_utf8mb4'LOADING',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜拣货任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!50003 SET @saved_cs_client = @@character_set_client */ ; +/*!50003 SET @saved_cs_results = @@character_set_results */ ; +/*!50003 SET @saved_col_connection = @@collation_connection */ ; +/*!50003 SET character_set_client = utf8mb4 */ ; +/*!50003 SET character_set_results = utf8mb4 */ ; +/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ; +/*!50003 SET @saved_sql_mode = @@sql_mode */ ; +/*!50003 SET sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' */ ; +DELIMITER ;; +/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg_loading_pick_task_before_update` BEFORE UPDATE ON `fl_loading_pick_task` FOR EACH ROW BEGIN IF OLD.task_status = 'COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改'; END IF; IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_booking_id <=> OLD.container_booking_id) OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; END IF; IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; END IF; END */;; +DELIMITER ; +/*!50003 SET sql_mode = @saved_sql_mode */ ; +/*!50003 SET character_set_client = @saved_cs_client */ ; +/*!50003 SET character_set_results = @saved_cs_results */ ; +/*!50003 SET collation_connection = @saved_col_connection */ ; +DROP TABLE IF EXISTS `fl_loading_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_plan` ( + `id` bigint unsigned NOT NULL COMMENT '智能装柜计划ID(雪花ID)', + `plan_no` varchar(40) NOT NULL COMMENT '平台唯一装柜计划号', + `container_booking_id` bigint unsigned NOT NULL COMMENT '货柜预订ID', + `plan_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认', + `push_idempotency_key` varchar(128) DEFAULT NULL COMMENT '推送预装幂等键', + `preload_pushed_by` bigint unsigned DEFAULT NULL COMMENT '推送预装操作人ID', + `preload_pushed_by_name` varchar(64) DEFAULT NULL COMMENT '推送预装操作人名称快照', + `preload_pushed_at` datetime(3) DEFAULT NULL COMMENT '开始装柜并进入LOADING的时间', + `preload_completed_by` bigint unsigned DEFAULT NULL COMMENT '预装完成操作人ID', + `preload_completed_by_name` varchar(64) DEFAULT NULL COMMENT '预装完成操作人名称快照', + `preload_completed_at` datetime(3) DEFAULT NULL COMMENT '预装完成时间', + `confirmed_by` bigint unsigned DEFAULT NULL COMMENT '确认操作人ID', + `confirmed_by_name` varchar(64) DEFAULT NULL COMMENT '确认操作人名称快照', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '确认时间', + `remark` varchar(500) DEFAULT NULL COMMENT '平台装柜计划备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_by_name` varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜计划最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_plan_no` (`plan_no`), + UNIQUE KEY `uk_loading_plan_booking` (`container_booking_id`), + UNIQUE KEY `uk_loading_plan_push_key` (`push_idempotency_key`), + KEY `idx_loading_plan_status` (`plan_status`,`updated_at`,`id`), + CONSTRAINT `fk_loading_plan_booking` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_loading_plan_status` CHECK ((`plan_status` in (_utf8mb4'EDITING',_utf8mb4'LOADING',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台智能装柜计划与唯一预装状态源'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!50003 SET @saved_cs_client = @@character_set_client */ ; +/*!50003 SET @saved_cs_results = @@character_set_results */ ; +/*!50003 SET @saved_col_connection = @@collation_connection */ ; +/*!50003 SET character_set_client = utf8mb4 */ ; +/*!50003 SET character_set_results = utf8mb4 */ ; +/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ; +/*!50003 SET @saved_sql_mode = @@sql_mode */ ; +/*!50003 SET sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' */ ; +DELIMITER ;; +/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg_loading_plan_after_update` AFTER UPDATE ON `fl_loading_plan` FOR EACH ROW BEGIN IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN UPDATE fl_loading_plan_item SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)), frozen_by = COALESCE(frozen_by, NEW.updated_by), updated_by = NEW.updated_by, updated_by_name = NEW.updated_by_name, version = version + 1 WHERE loading_plan_id = NEW.id AND deleted = 0; UPDATE fl_loading_pick_task SET task_status = 'COMPLETED', completed_by = NEW.updated_by, completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3), version = version + 1 WHERE loading_plan_id = NEW.id AND task_status = 'LOADING'; END IF; END */;; +DELIMITER ; +/*!50003 SET sql_mode = @saved_sql_mode */ ; +/*!50003 SET character_set_client = @saved_cs_client */ ; +/*!50003 SET character_set_results = @saved_cs_results */ ; +/*!50003 SET collation_connection = @saved_col_connection */ ; +DROP TABLE IF EXISTS `fl_loading_plan_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_plan_history` ( + `id` bigint unsigned NOT NULL COMMENT '装柜操作历史ID(雪花ID)', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '智能装柜计划ID', + `loading_plan_item_id` bigint unsigned DEFAULT NULL COMMENT '装柜计划明细ID', + `operation_id` varchar(128) NOT NULL COMMENT '操作幂等ID', + `action_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货', + `from_status` varchar(24) DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(24) DEFAULT NULL COMMENT '变更后状态', + `before_snapshot` json DEFAULT NULL COMMENT '变更前快照', + `after_snapshot` json DEFAULT NULL COMMENT '变更后快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operation_reason` varchar(500) DEFAULT NULL COMMENT '操作原因', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_history_operation` (`operation_id`), + KEY `idx_loading_history_plan` (`loading_plan_id`,`created_at`,`id`), + KEY `idx_loading_history_item` (`loading_plan_item_id`,`created_at`,`id`), + CONSTRAINT `fk_loading_history_item` FOREIGN KEY (`loading_plan_item_id`) REFERENCES `fl_loading_plan_item` (`id`), + CONSTRAINT `fk_loading_history_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='智能装柜追加式不可变操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_plan_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_plan_item` ( + `id` bigint unsigned NOT NULL COMMENT '装柜计划明细ID(雪花ID)', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '智能装柜计划ID', + `account_id` bigint unsigned NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段', + `customer_name` varchar(128) NOT NULL COMMENT '客户名称快照', + `inbound_id` bigint unsigned NOT NULL COMMENT 'Firstleg入库单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键', + `order_id` bigint unsigned NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键', + `order_no` varchar(32) NOT NULL COMMENT '订单号快照', + `inbound_code` varchar(40) NOT NULL COMMENT '入仓码快照', + `destination_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(64) DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `sku_code` varchar(128) DEFAULT NULL COMMENT 'SKU编码快照', + `product_name_zh` varchar(255) DEFAULT NULL COMMENT '中文品名快照', + `product_name_en` varchar(255) DEFAULT NULL COMMENT '英文品名快照', + `product_image_file_id` bigint unsigned DEFAULT NULL COMMENT '产品图片File对象ID', + `product_image_url` varchar(1000) DEFAULT NULL COMMENT '产品图片访问地址快照', + `package_count` int unsigned NOT NULL COMMENT '本次装柜箱数', + `product_quantity` int unsigned DEFAULT NULL COMMENT '产品总数量', + `units_per_carton` int unsigned DEFAULT NULL COMMENT '每箱产品数', + `pallet_count` int unsigned DEFAULT NULL COMMENT '托盘数', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本次装柜毛重kg', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本次装柜净重kg', + `dimensional_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本次装柜材积重kg', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '本次装柜体积m3', + `fba_tracking_no` varchar(128) DEFAULT NULL COMMENT 'FBA追踪号快照', + `shipment_no` varchar(128) DEFAULT NULL COMMENT '货件编号快照', + `shipping_mark` varchar(500) DEFAULT NULL COMMENT '唛头快照', + `load_sequence` int unsigned NOT NULL DEFAULT '0' COMMENT '装柜顺序', + `position_snapshot` json DEFAULT NULL COMMENT '智能装柜位置与摆放结果快照', + `internal_remark` varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + `customer_remark` varchar(1000) DEFAULT NULL COMMENT '客户备注快照', + `warehouse_remark` varchar(1000) DEFAULT NULL COMMENT '仓库备注快照', + `frozen_at` datetime(3) DEFAULT NULL COMMENT '装柜完成后的永久冻结时间', + `frozen_by` bigint unsigned DEFAULT NULL COMMENT '冻结操作人ID', + `removed_by` bigint unsigned DEFAULT NULL COMMENT '推送前逻辑删除操作人ID', + `removed_by_name` varchar(64) DEFAULT NULL COMMENT '推送前逻辑删除操作人名称快照', + `removed_at` datetime(3) DEFAULT NULL COMMENT '推送前逻辑删除时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '推送前逻辑删除标记;冻结后不可修改', + `active_inbound_detail_id` bigint unsigned GENERATED ALWAYS AS ((case when (`deleted` = 0) then `inbound_detail_id` else NULL end)) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_by_name` varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `unit_type` varchar(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托', + `pallet_id` bigint unsigned DEFAULT NULL COMMENT '一件代发封托ID;普通箱为空', + `pallet_no` varchar(32) DEFAULT NULL COMMENT '一件代发系统托盘码快照', + `indivisible` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为不可拆整托单元', + `customer_service_remark` varchar(1000) DEFAULT NULL COMMENT '客服备注快照', + `active_assignment_key` varchar(96) GENERATED ALWAYS AS ((case when (`deleted` = 0) then concat((case when (`pallet_id` is null) then _utf8mb4'D:0' else concat(_utf8mb4'P:',`pallet_id`) end),_utf8mb4':',`inbound_detail_id`) else NULL end)) STORED COMMENT '普通明细或已封托托盘明细的有效唯一锁', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_item_plan_detail` (`loading_plan_id`,`active_inbound_detail_id`), + UNIQUE KEY `uk_loading_item_plan_assignment` (`loading_plan_id`,`active_assignment_key`), + KEY `idx_loading_item_active_detail` (`account_id`,`active_inbound_detail_id`), + KEY `idx_loading_item_plan` (`loading_plan_id`,`deleted`,`load_sequence`,`id`), + KEY `idx_loading_item_account` (`account_id`,`created_at`,`id`), + KEY `idx_loading_item_inbound` (`account_id`,`inbound_id`,`deleted`), + KEY `idx_loading_item_order` (`account_id`,`order_id`,`deleted`), + KEY `idx_loading_item_candidate` (`account_id`,`inbound_id`,`inbound_detail_id`,`deleted`), + KEY `idx_loading_item_pallet` (`pallet_id`,`deleted`), + CONSTRAINT `fk_loading_item_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `chk_loading_item_account` CHECK ((`account_id` > 0)), + CONSTRAINT `chk_loading_item_deleted` CHECK ((`deleted` in (0,1))), + CONSTRAINT `chk_loading_item_package` CHECK ((`package_count` > 0)), + CONSTRAINT `chk_loading_item_pallet` CHECK (((`pallet_count` is null) or (`pallet_count` >= 0))), + CONSTRAINT `chk_loading_item_product_quantity` CHECK (((`product_quantity` is null) or (`product_quantity` > 0))), + CONSTRAINT `chk_loading_item_units` CHECK (((`units_per_carton` is null) or (`units_per_carton` > 0))), + CONSTRAINT `chk_loading_item_volume` CHECK (((`volume_cbm` is null) or (`volume_cbm` >= 0))), + CONSTRAINT `chk_loading_item_weight` CHECK ((((`gross_weight_kg` is null) or (`gross_weight_kg` >= 0)) and ((`net_weight_kg` is null) or (`net_weight_kg` >= 0)) and ((`dimensional_weight_kg` is null) or (`dimensional_weight_kg` >= 0)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_work_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_work_pallet` ( + `id` bigint unsigned NOT NULL COMMENT '一件代发现场作业托盘ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA装柜拣货任务ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + `warehouse_id` bigint unsigned NOT NULL COMMENT '作业仓库ID', + `warehouse_pallet_id` bigint unsigned DEFAULT NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空', + `pallet_code` varchar(64) NOT NULL COMMENT '现场托盘号', + `open_operation_id` varchar(128) NOT NULL COMMENT '开托幂等操作号', + `seal_operation_id` varchar(128) DEFAULT NULL COMMENT '封托幂等操作号', + `pallet_status` varchar(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建操作人名称快照', + `sealed_by` bigint unsigned DEFAULT NULL COMMENT '封托操作人ID', + `sealed_by_name` varchar(64) DEFAULT NULL COMMENT '封托操作人名称快照', + `sealed_at` datetime(3) DEFAULT NULL COMMENT '封托时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_work_pallet_code` (`pick_task_id`,`pallet_code`), + UNIQUE KEY `uk_loading_work_pallet_open_operation` (`open_operation_id`), + UNIQUE KEY `uk_loading_work_pallet_seal_operation` (`seal_operation_id`), + KEY `idx_loading_work_pallet_plan` (`loading_plan_id`,`pallet_status`,`id`), + CONSTRAINT `fk_loading_work_pallet_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `fk_loading_work_pallet_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_work_pallet_status` CHECK ((`pallet_status` in (_utf8mb4'OPEN',_utf8mb4'SEALED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_work_pallet_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_work_pallet_carton` ( + `id` bigint unsigned NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA装柜拣货任务ID', + `work_pallet_id` bigint unsigned NOT NULL COMMENT '现场作业托盘ID', + `loading_plan_item_id` bigint unsigned NOT NULL COMMENT '装柜计划货物明细ID', + `pick_item_id` bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '箱唛所属客户ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '来源入仓单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '来源入仓货物明细ID', + `carton_label_id` bigint unsigned NOT NULL COMMENT '稳定箱唛记录ID', + `box_code` varchar(80) NOT NULL COMMENT '稳定箱唛箱码快照', + `sku_code` varchar(128) NOT NULL COMMENT 'SKU编码快照', + `units_per_carton` int unsigned DEFAULT NULL COMMENT '每箱产品数量快照', + `bound_by` bigint unsigned NOT NULL COMMENT '最后绑定操作人ID', + `bound_by_name` varchar(64) DEFAULT NULL COMMENT '最后绑定操作人名称快照', + `operation_id` varchar(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_work_carton_task_label` (`pick_task_id`,`carton_label_id`), + UNIQUE KEY `uk_loading_work_carton_operation` (`operation_id`), + KEY `idx_loading_work_carton_pallet` (`work_pallet_id`,`sku_code`,`id`), + KEY `idx_loading_work_carton_plan_item` (`loading_plan_item_id`,`id`), + KEY `fk_loading_work_carton_pick_item` (`pick_item_id`), + KEY `fk_loading_work_carton_label` (`carton_label_id`), + CONSTRAINT `fk_loading_work_carton_label` FOREIGN KEY (`carton_label_id`) REFERENCES `fl_inbound_carton_label` (`id`), + CONSTRAINT `fk_loading_work_carton_pallet` FOREIGN KEY (`work_pallet_id`) REFERENCES `fl_loading_work_pallet` (`id`), + CONSTRAINT `fk_loading_work_carton_pick_item` FOREIGN KEY (`pick_item_id`) REFERENCES `fl_loading_pick_item` (`id`), + CONSTRAINT `fk_loading_work_carton_plan_item` FOREIGN KEY (`loading_plan_item_id`) REFERENCES `fl_loading_plan_item` (`id`), + CONSTRAINT `fk_loading_work_carton_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_work_carton_units` CHECK (((`units_per_carton` is null) or (`units_per_carton` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_measure_review_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_measure_review_record` ( + `id` bigint unsigned NOT NULL COMMENT '测量审核记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `measurement_id` bigint unsigned DEFAULT NULL COMMENT '被审核的测量批次ID', + `review_result` varchar(24) NOT NULL COMMENT '审核结果', + `review_reason` varchar(1000) DEFAULT NULL COMMENT '审核或驳回原因', + `exception_release` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否异常放行', + `release_reason` varchar(1000) DEFAULT NULL COMMENT '异常放行原因', + `allow_rejected_config` tinyint(1) NOT NULL DEFAULT '0' COMMENT '操作时Nacos异常放行开关值', + `reviewer_id` bigint unsigned NOT NULL COMMENT '审核人ID', + `reviewer_name` varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + `reviewed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间', + PRIMARY KEY (`id`), + KEY `idx_review_inbound_time` (`tenant_id`,`inbound_id`,`reviewed_at`), + KEY `idx_review_exception_release` (`tenant_id`,`inbound_id`,`exception_release`,`reviewed_at`), + KEY `fk_review_inbound` (`inbound_id`), + KEY `fk_review_measurement` (`measurement_id`), + CONSTRAINT `fk_review_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_review_measurement` FOREIGN KEY (`measurement_id`) REFERENCES `fl_inbound_measurement` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测量审核、驳回和异常放行不可变记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_mojia_measurement_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_mojia_measurement_receipt` ( + `id` bigint unsigned NOT NULL COMMENT '墨家联调收件记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `subject_id` bigint unsigned NOT NULL COMMENT '设备应用操作主体ID', + `box_code` varchar(128) NOT NULL COMMENT '设备扫描的原始箱码或不带#的SKU', + `inbound_code` varchar(64) DEFAULT NULL COMMENT '箱码命中后的权威入仓码;SKU记录为空', + `measurement_kind` varchar(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU', + `scanned_shipment_id` varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', + `length_cm` decimal(12,3) NOT NULL COMMENT '设备实测长度cm', + `width_cm` decimal(12,3) NOT NULL COMMENT '设备实测宽度cm', + `height_cm` decimal(12,3) NOT NULL COMMENT '设备实测高度cm', + `weight_kg` decimal(18,3) NOT NULL COMMENT '设备实测重量kg', + `machine_no` varchar(64) NOT NULL COMMENT '设备编号', + `measured_at` datetime(3) NOT NULL COMMENT '设备测量时间或服务接收时间', + `image_file_id` bigint unsigned NOT NULL COMMENT 'File服务中的墨家联调图片ID', + `status` varchar(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '原始收件状态;SKU记录使用SKU_PENDING_SYNC', + `sync_status` varchar(24) DEFAULT NULL COMMENT 'SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED', + `sync_failure_code` varchar(64) DEFAULT NULL COMMENT 'SKU同步失败业务码', + `sync_failure_reason` varchar(500) DEFAULT NULL COMMENT '脱敏后的SKU同步失败原因', + `lingxing_sku_id` varchar(128) DEFAULT NULL COMMENT '领星SKU内部标识', + `revision` int NOT NULL DEFAULT '1' COMMENT '当前测量版本号', + `match_failure_code` varchar(16) DEFAULT NULL COMMENT '正式箱唛业务匹配失败码', + `match_failure_reason` varchar(500) DEFAULT NULL COMMENT '脱敏后的箱唛匹配失败原因', + `request_id` varchar(128) DEFAULT NULL COMMENT '请求链路ID', + `trace_id` varchar(128) DEFAULT NULL COMMENT '调用链ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_mojia_receipt_status_time` (`tenant_id`,`status`,`created_at`), + KEY `idx_mojia_receipt_box_time` (`tenant_id`,`box_code`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='墨家模拟或未匹配入库单的原始测量收件箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking` ( + `id` bigint unsigned NOT NULL COMMENT '海运订舱ID', + `booking_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台海运订舱编号', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '可选客户ID快照', + `customer_name` varchar(128) DEFAULT NULL COMMENT '可选客户名称快照', + `channel_code` varchar(64) DEFAULT NULL, + `channel_name` varchar(128) DEFAULT NULL, + `warehouse_id` bigint unsigned DEFAULT NULL, + `warehouse_name` varchar(128) DEFAULT NULL, + `bill_of_lading_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '提单号', + `shipping_company_id` bigint unsigned DEFAULT NULL COMMENT '船公司逻辑ID快照', + `shipping_company_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '船公司名称快照', + `carrier_code` varchar(16) DEFAULT NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', + `booking_agent_id` bigint unsigned DEFAULT NULL COMMENT '订舱代理逻辑ID快照', + `booking_agent_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '订舱代理名称快照', + `vessel_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前确认船名快照', + `voyage_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前确认航次号快照', + `departure_port_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '起运港编码快照', + `departure_port_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '起运港名称快照', + `destination_port_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '目的港编码快照', + `destination_port_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '目的港名称快照', + `place_of_delivery` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最终交货地快照', + `service_term` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '运输服务条款快照', + `freight_term` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '海运费支付条款快照', + `estimated_sailing_at` datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间', + `estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间', + `pending_vessel_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '待确认变更船名', + `pending_voyage_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '待确认变更航次号', + `pending_estimated_sailing_at` datetime(3) DEFAULT NULL COMMENT '待确认变更预计开船时间', + `pending_estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '待确认变更预计到港时间', + `change_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '船期变更原因', + `cancellation_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '取消订舱原因', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '平台内部备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '订舱记录最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ocean_booking_no` (`booking_no`), + UNIQUE KEY `uk_ocean_booking_bill_of_lading` (`bill_of_lading_no`), + KEY `idx_ocean_booking_customer` (`customer_id`,`status`,`updated_at`,`id`), + KEY `idx_ocean_booking_voyage` (`vessel_name`,`voyage_no`,`updated_at`), + CONSTRAINT `chk_ocean_booking_carrier_code` CHECK (((`carrier_code` is null) or (`carrier_code` in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))), + CONSTRAINT `chk_ocean_booking_customer` CHECK ((`customer_id` > 0)), + CONSTRAINT `chk_ocean_booking_pending_schedule` CHECK (((`pending_estimated_sailing_at` is null) or (`pending_estimated_arrival_at` is null) or (`pending_estimated_arrival_at` >= `pending_estimated_sailing_at`))), + CONSTRAINT `chk_ocean_booking_schedule` CHECK (((`estimated_sailing_at` is null) or (`estimated_arrival_at` is null) or (`estimated_arrival_at` >= `estimated_sailing_at`))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_container_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_container_allocation` ( + `id` bigint unsigned NOT NULL COMMENT '订舱柜次分配关系ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `requirement_id` bigint unsigned NOT NULL COMMENT '对应订舱箱型需求ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '明确引用现有柜次ID', + `allocated_by` bigint unsigned NOT NULL COMMENT '分配操作人ID', + `allocated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '分配操作人姓名快照', + `allocated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '柜次分配时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ocean_allocation_container` (`container_booking_id`), + KEY `idx_ocean_allocation_booking` (`ocean_booking_id`,`requirement_id`,`allocated_at`), + KEY `fk_ocean_allocation_requirement` (`requirement_id`), + CONSTRAINT `fk_ocean_allocation_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `fk_ocean_allocation_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `fk_ocean_allocation_requirement` FOREIGN KEY (`requirement_id`) REFERENCES `fl_ocean_booking_container_requirement` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱需求与现有柜次的唯一分配关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_container_requirement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_container_requirement` ( + `id` bigint unsigned NOT NULL COMMENT '订舱箱型需求ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `container_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '所需集装箱尺寸类型编码', + `container_kind` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '所需集装箱箱种编码', + `capacity_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `max_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000', + `quantity` int unsigned NOT NULL COMMENT '该箱型箱种所需柜数', + `sort_no` int unsigned NOT NULL DEFAULT '0' COMMENT '订舱需求展示顺序', + PRIMARY KEY (`id`), + KEY `idx_ocean_requirement_booking` (`ocean_booking_id`,`sort_no`,`id`), + CONSTRAINT `fk_ocean_requirement_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `chk_ocean_requirement_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱箱型箱种及数量需求'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_document` ( + `id` bigint unsigned NOT NULL COMMENT '订舱文件关联ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件逻辑ID', + `document_type` varchar(32) NOT NULL COMMENT '文件类型:BOOKING_CONFIRMATION订舱确认、BILL_OF_LADING提单', + `file_name` varchar(255) NOT NULL COMMENT 'File返回的原始文件名快照', + `file_hash` char(64) DEFAULT NULL COMMENT 'File返回的SHA-256快照', + `size_bytes` bigint unsigned DEFAULT NULL COMMENT 'File返回的文件大小快照', + `content_type` varchar(128) DEFAULT NULL COMMENT 'File返回的内容类型快照', + `valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否为有效文件关联', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传或关联操作人ID', + `uploaded_by_name` varchar(64) DEFAULT NULL COMMENT '上传或关联操作人姓名快照', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传或关联时间', + `invalidated_by` bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + `invalidated_by_name` varchar(64) DEFAULT NULL COMMENT '作废操作人姓名快照', + `invalidated_at` datetime(3) DEFAULT NULL COMMENT '作废时间', + PRIMARY KEY (`id`), + KEY `idx_ocean_document_booking` (`ocean_booking_id`,`valid`,`uploaded_at`,`id`), + KEY `idx_ocean_document_file` (`file_id`), + CONSTRAINT `fk_ocean_document_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`) ON DELETE CASCADE, + CONSTRAINT `chk_ocean_document_type` CHECK ((`document_type` in (_utf8mb4'BOOKING_CONFIRMATION',_utf8mb4'BILL_OF_LADING'))), + CONSTRAINT `chk_ocean_document_valid` CHECK ((`valid` in (0,1))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱聚合拥有的订舱确认与提单文件关联'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_operation` ( + `id` bigint unsigned NOT NULL COMMENT '订舱写操作幂等记录ID', + `ocean_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联海运订舱ID;创建请求完成前允许为空', + `operator_id` bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + `operation` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端写操作幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + `result_json` json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ocean_operation` (`operator_id`,`operation`,`idempotency_key`), + KEY `idx_ocean_operation_booking` (`ocean_booking_id`,`created_at`), + CONSTRAINT `fk_ocean_operation_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `chk_ocean_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海运订舱写操作幂等与请求哈希'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_party`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_party` ( + `id` bigint unsigned NOT NULL COMMENT '订舱提单关系方ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `party_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '关系方类型:SHIPPER发货人、CONSIGNEE收货人、NOTIFY_PARTY通知人', + `party_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '关系方名称快照', + `address` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关系方地址快照', + `contact_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关系方联系人快照', + `contact_phone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关系方联系电话快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ocean_booking_party` (`ocean_booking_id`,`party_type`), + CONSTRAINT `fk_ocean_booking_party` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `chk_ocean_booking_party_type` CHECK ((`party_type` in (_utf8mb4'SHIPPER',_utf8mb4'CONSIGNEE',_utf8mb4'NOTIFY_PARTY'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱提单关系方快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_status_history` ( + `id` bigint unsigned NOT NULL COMMENT '订舱状态历史ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `from_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更前订舱状态;创建时为空', + `to_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '变更后订舱状态', + `action` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '触发状态变更的业务动作', + `reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '状态变更原因', + `before_snapshot` json DEFAULT NULL COMMENT '状态变更前订舱数据快照;创建时为空', + `after_snapshot` json NOT NULL COMMENT '状态变更后订舱数据快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间', + PRIMARY KEY (`id`), + KEY `idx_ocean_history_booking` (`ocean_booking_id`,`created_at`,`id`), + CONSTRAINT `fk_ocean_history_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海运订舱不可变状态历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_order_declaration_event`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_order_declaration_event` ( + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + `event_id` varchar(128) NOT NULL COMMENT '永久保留的Order Outbox事件ID', + `order_id` bigint unsigned NOT NULL COMMENT 'Order订单ID', + `order_version` int NOT NULL COMMENT '事件携带的Order聚合版本', + `request_hash` char(64) NOT NULL COMMENT '完整事件SHA-256,用于异参重放拒绝', + `consumed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`tenant_id`,`event_id`), + KEY `idx_order_declaration_event_order` (`tenant_id`,`order_id`,`order_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Order客户申报事件永久幂等账本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_order_declaration_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_order_declaration_snapshot` ( + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + `order_id` bigint unsigned NOT NULL COMMENT 'Order订单ID', + `event_id` varchar(128) NOT NULL COMMENT 'Order Outbox事件ID', + `order_version` int NOT NULL COMMENT 'Order聚合版本,严格单调消费', + `request_hash` char(64) NOT NULL COMMENT '完整事件SHA-256', + `customer_remark` varchar(1000) DEFAULT NULL COMMENT '客户备注快照', + `expected_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到仓时间快照', + `channel_code` varchar(64) NOT NULL COMMENT '渠道编码快照', + `channel_name` varchar(128) NOT NULL COMMENT '渠道名称快照', + `cargoes_json` json NOT NULL COMMENT '客户申报货物行完整快照,不覆盖实测事实', + `changes_json` json DEFAULT NULL COMMENT '本版本申报字段变化摘要', + `occurred_at` datetime(3) NOT NULL COMMENT 'Order事件发生时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`tenant_id`,`order_id`), + UNIQUE KEY `uk_order_declaration_event` (`tenant_id`,`event_id`), + KEY `idx_order_declaration_version` (`tenant_id`,`order_id`,`order_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_outbound_dispatch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_outbound_dispatch` ( + `id` bigint unsigned NOT NULL COMMENT '国内出仓调度记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `order_id` bigint unsigned NOT NULL COMMENT 'Order服务订单逻辑ID,不复制订单主数据', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '关联柜次逻辑ID', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'READY_FOR_DISPATCH' COMMENT '出仓状态:READY_FOR_DISPATCH待确认出仓、WAITING_FOR_LOADING等待装柜', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `confirmed_by` bigint unsigned DEFAULT NULL COMMENT '确认国内出仓的操作人ID', + `confirmed_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '确认国内出仓的操作人姓名快照', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '国内出仓确认时间', + `reversed_by` bigint unsigned DEFAULT NULL COMMENT '撤销国内出仓的操作人ID', + `reversed_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '撤销国内出仓的操作人姓名快照', + `reversed_at` datetime(3) DEFAULT NULL COMMENT '国内出仓撤销时间', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '国内出仓调度备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '出仓调度记录创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '出仓调度记录最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outbound_dispatch_owner` (`tenant_id`,`inbound_id`,`container_booking_id`), + KEY `idx_outbound_dispatch_order` (`tenant_id`,`order_id`,`updated_at`), + KEY `idx_outbound_dispatch_container` (`container_booking_id`,`status`,`updated_at`), + KEY `fk_outbound_dispatch_inbound` (`inbound_id`), + CONSTRAINT `fk_outbound_dispatch_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `fk_outbound_dispatch_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_outbound_dispatch_status` CHECK ((`status` in (_utf8mb4'READY_FOR_DISPATCH',_utf8mb4'WAITING_FOR_LOADING'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期国内出仓派车状态,不重复创建出仓单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_outbound_dispatch_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_outbound_dispatch_history` ( + `id` bigint unsigned NOT NULL COMMENT '出仓状态历史记录ID', + `dispatch_id` bigint unsigned NOT NULL COMMENT '关联国内出仓调度记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID快照', + `container_booking_id` bigint unsigned NOT NULL COMMENT '关联柜次逻辑ID快照', + `action` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '出仓状态变更动作', + `from_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更前出仓状态;首次创建时为空', + `to_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '变更后出仓状态', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '状态变更原因或备注', + `operator_id` bigint unsigned NOT NULL COMMENT '执行状态变更的平台用户ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行状态变更的平台用户姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间', + PRIMARY KEY (`id`), + KEY `idx_outbound_dispatch_history` (`tenant_id`,`inbound_id`,`container_booking_id`,`created_at`,`id`), + KEY `fk_outbound_dispatch_history` (`dispatch_id`), + CONSTRAINT `fk_outbound_dispatch_history` FOREIGN KEY (`dispatch_id`) REFERENCES `fl_outbound_dispatch` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内出仓追加式审计历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_outbound_dispatch_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_outbound_dispatch_operation` ( + `id` bigint unsigned NOT NULL COMMENT '出仓幂等操作记录ID', + `dispatch_id` bigint unsigned DEFAULT NULL COMMENT '首次声明幂等键时允许为空', + `operator_id` bigint unsigned NOT NULL COMMENT '执行写操作的平台用户ID', + `operation` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '国内出仓写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等声明创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '幂等操作完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outbound_dispatch_operation` (`operator_id`,`operation`,`idempotency_key`), + KEY `idx_outbound_dispatch_operation_resource` (`dispatch_id`,`created_at`), + CONSTRAINT `fk_outbound_dispatch_operation` FOREIGN KEY (`dispatch_id`) REFERENCES `fl_outbound_dispatch` (`id`), + CONSTRAINT `chk_outbound_dispatch_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内出仓写操作幂等声明'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_port_schedule`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_port_schedule` ( + `id` bigint unsigned NOT NULL COMMENT '港务船期记录ID', + `schedule_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '港务船期记录编号', + `customer_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '关联海运订舱ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联柜次ID;订舱整票船期时为空', + `source_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船期来源类型:MANUAL人工录入、CARRIER船公司、TERMINAL码头、API外部接口', + `source_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船期来源名称', + `source_ref` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '来源侧记录唯一引用或查询凭据摘要', + `vessel_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船名快照', + `voyage_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '航次号快照', + `departure_port_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '起运港编码快照', + `departure_port_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '起运港名称快照', + `destination_port_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '目的港编码快照', + `destination_port_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '目的港名称快照', + `etd_at` datetime(3) DEFAULT NULL COMMENT '预计离港时间', + `eta_at` datetime(3) DEFAULT NULL COMMENT '预计到港时间', + `booking_cutoff_at` datetime(3) DEFAULT NULL COMMENT '截单时间', + `customs_cutoff_at` datetime(3) DEFAULT NULL COMMENT '截关时间', + `port_cutoff_at` datetime(3) DEFAULT NULL COMMENT '截港时间', + `observed_at` datetime(3) NOT NULL COMMENT '来源数据观测时间', + `expires_at` datetime(3) NOT NULL COMMENT '超过该时间即由API计算为过期', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '平台内部备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '船期记录创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '船期记录最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_port_schedule_no` (`schedule_no`), + UNIQUE KEY `uk_port_schedule_source` (`source_type`,`source_name`,`source_ref`), + KEY `idx_port_schedule_customer` (`customer_id`,`observed_at`,`id`), + KEY `idx_port_schedule_booking` (`ocean_booking_id`,`observed_at`,`id`), + KEY `idx_port_schedule_container` (`container_booking_id`,`observed_at`,`id`), + KEY `idx_port_schedule_expiry` (`expires_at`,`id`), + CONSTRAINT `fk_port_schedule_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `fk_port_schedule_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_port_schedule_booking_cutoff` CHECK (((`booking_cutoff_at` is null) or (`etd_at` is null) or (`booking_cutoff_at` <= `etd_at`))), + CONSTRAINT `chk_port_schedule_customer` CHECK ((`customer_id` > 0)), + CONSTRAINT `chk_port_schedule_customs_cutoff` CHECK (((`customs_cutoff_at` is null) or (`etd_at` is null) or (`customs_cutoff_at` <= `etd_at`))), + CONSTRAINT `chk_port_schedule_eta` CHECK (((`etd_at` is null) or (`eta_at` is null) or (`eta_at` >= `etd_at`))), + CONSTRAINT `chk_port_schedule_expiry` CHECK ((`expires_at` > `observed_at`)), + CONSTRAINT `chk_port_schedule_port_cutoff` CHECK (((`port_cutoff_at` is null) or (`etd_at` is null) or (`port_cutoff_at` <= `etd_at`))), + CONSTRAINT `chk_port_schedule_source_type` CHECK ((`source_type` in (_utf8mb4'MANUAL',_utf8mb4'CARRIER',_utf8mb4'TERMINAL',_utf8mb4'API'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立港务船期来源聚合,不替代订舱快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_port_schedule_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_port_schedule_history` ( + `id` bigint unsigned NOT NULL COMMENT '港务船期变更历史ID', + `port_schedule_id` bigint unsigned NOT NULL COMMENT '所属港务船期记录ID', + `action` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '变更动作:CREATE创建、UPDATE更新', + `from_version` int unsigned DEFAULT NULL COMMENT '变更前版本;创建时为空', + `to_version` int unsigned NOT NULL COMMENT '变更后版本', + `reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更原因或说明', + `before_snapshot` json DEFAULT NULL COMMENT '变更前船期数据快照;创建时为空', + `after_snapshot` json NOT NULL COMMENT '变更后船期数据快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_port_schedule_history` (`port_schedule_id`,`created_at`,`id`), + CONSTRAINT `fk_port_schedule_history` FOREIGN KEY (`port_schedule_id`) REFERENCES `fl_port_schedule` (`id`), + CONSTRAINT `chk_port_schedule_history_action` CHECK ((`action` in (_utf8mb4'CREATE',_utf8mb4'UPDATE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='港务船期不可变变更历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_port_schedule_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_port_schedule_operation` ( + `id` bigint unsigned NOT NULL COMMENT '港务船期写操作幂等记录ID', + `port_schedule_id` bigint unsigned DEFAULT NULL COMMENT '关联港务船期ID;创建请求完成前允许为空', + `operator_id` bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + `operation` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端写操作幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + `result_json` json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_port_schedule_operation` (`operator_id`,`idempotency_key`), + KEY `idx_port_schedule_operation_resource` (`port_schedule_id`,`created_at`), + CONSTRAINT `fk_port_schedule_operation` FOREIGN KEY (`port_schedule_id`) REFERENCES `fl_port_schedule` (`id`), + CONSTRAINT `chk_port_schedule_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='港务船期写操作幂等与请求哈希'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_preload_loading_plan_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_preload_loading_plan_link` ( + `id` bigint unsigned NOT NULL COMMENT '关联ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '入库单客户隔离ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `preload_plan_id` bigint unsigned NOT NULL COMMENT '入库单级预装计划ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '柜次级装柜计划ID', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '建立关联幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '建立关联请求SHA-256', + `created_by` bigint unsigned NOT NULL COMMENT '建立预装与装柜计划关联的操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '建立关联的操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关联记录创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_preload_loading_pair` (`tenant_id`,`inbound_id`,`loading_plan_id`), + UNIQUE KEY `uk_preload_loading_idempotency` (`created_by`,`idempotency_key`), + KEY `idx_preload_loading_preload` (`preload_plan_id`,`loading_plan_id`), + KEY `idx_preload_loading_plan` (`loading_plan_id`,`tenant_id`,`inbound_id`), + KEY `fk_preload_loading_inbound` (`inbound_id`), + CONSTRAINT `fk_preload_loading_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_preload_loading_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `fk_preload_loading_preload` FOREIGN KEY (`preload_plan_id`) REFERENCES `fl_preload_plan` (`id`), + CONSTRAINT `chk_preload_loading_tenant` CHECK ((`tenant_id` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单预装与柜次装柜计划多对多关联'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_preload_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_preload_plan` ( + `id` bigint unsigned NOT NULL COMMENT '预装清单ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '预装状态:PENDING待确认、CONFIRMING确认中、CONFIRMED已确认', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `items_json` json NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', + `remark` varchar(1000) DEFAULT NULL COMMENT '预装备注', + `confirmed_by` bigint unsigned DEFAULT NULL, + `confirmed_by_name` varchar(64) DEFAULT NULL, + `confirmed_at` datetime(3) DEFAULT NULL, + `created_by` bigint unsigned NOT NULL COMMENT '创建预装清单的操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建操作人姓名快照', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '预装清单创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '预装清单最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_preload_tenant_inbound` (`tenant_id`,`inbound_id`), + KEY `idx_preload_status` (`tenant_id`,`status`,`updated_at`), + KEY `fk_preload_inbound` (`inbound_id`), + CONSTRAINT `fk_preload_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单分柜预装清单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_book_channel`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_book_channel` ( + `id` bigint unsigned NOT NULL COMMENT '版本渠道快照ID', + `price_book_version_id` bigint unsigned NOT NULL COMMENT '整表价格版本ID', + `channel_code` varchar(64) NOT NULL COMMENT '固定渠道编码', + `channel_name` varchar(128) NOT NULL COMMENT '渠道名称快照', + `price_model` varchar(32) NOT NULL COMMENT '价格模型快照', + `source_sheet_name` varchar(128) NOT NULL COMMENT '原始Excel Sheet', + `estimated_departure_date` date DEFAULT NULL COMMENT '标准预计开船日期', + `estimated_departure_text` varchar(128) DEFAULT NULL COMMENT '原始预计开船日期文本', + `latest_warehouse_entry` varchar(128) DEFAULT NULL COMMENT '最晚进仓时间', + `vessel_name` varchar(128) DEFAULT NULL COMMENT '船名', + `voyage_no` varchar(64) DEFAULT NULL COMMENT '航次', + `reference_transit` varchar(128) DEFAULT NULL COMMENT '参考时效', + `surcharge_description` varchar(2000) DEFAULT NULL COMMENT '附加费说明,仅展示', + `remark` varchar(2000) DEFAULT NULL COMMENT '渠道备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '渠道快照创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '渠道快照最后更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_book_channel` (`price_book_version_id`,`channel_code`), + KEY `idx_price_book_channel_code` (`channel_code`,`price_book_version_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格版本渠道及船期快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_book_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_book_version` ( + `id` bigint unsigned NOT NULL COMMENT '整表价格版本ID', + `tenant_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共价格作用域固定为0', + `version_no` int unsigned NOT NULL COMMENT '整表版本号', + `original_file_name` varchar(255) NOT NULL COMMENT '原始Excel文件名', + `source_file_id` bigint unsigned DEFAULT NULL COMMENT 'File服务原始文件ID', + `source_sha256` char(64) DEFAULT NULL COMMENT '原始文件SHA-256', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败', + `blocking_error_count` int unsigned NOT NULL DEFAULT '0' COMMENT '阻断错误数', + `warning_count` int unsigned NOT NULL DEFAULT '0' COMMENT '警告数', + `parse_issues_json` json DEFAULT NULL COMMENT '上传解析问题及待确认重新校验结果', + `channel_count` int unsigned NOT NULL DEFAULT '0' COMMENT '解析渠道数', + `rate_count` int unsigned NOT NULL DEFAULT '0' COMMENT '解析价格数', + `created_by` bigint unsigned NOT NULL COMMENT '上传价格表的操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传记录创建时间', + `published_by` bigint unsigned DEFAULT NULL COMMENT '确认并发布价格版本的操作人ID', + `published_at` datetime(3) DEFAULT NULL COMMENT '确认并生效时间', + `superseded_at` datetime(3) DEFAULT NULL COMMENT '被下一版本替换时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0未删除、1已删除', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `voided_by` bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + `voided_at` datetime(3) DEFAULT NULL COMMENT '作废时间', + `void_reason` varchar(500) DEFAULT NULL COMMENT '作废原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_book_version` (`tenant_id`,`version_no`), + KEY `idx_price_book_status` (`tenant_id`,`status`,`published_at`), + KEY `idx_price_book_source_hash` (`tenant_id`,`source_sha256`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共船期价格整表版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_channel_catalog`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_channel_catalog` ( + `id` bigint unsigned NOT NULL COMMENT '渠道目录ID', + `channel_code` varchar(64) NOT NULL COMMENT '稳定渠道编码', + `display_name` varchar(128) NOT NULL COMMENT '平台渠道名称', + `price_model` varchar(32) NOT NULL COMMENT '默认价格模型', + `source_sheet_name` varchar(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', + `aliases_json` json NOT NULL COMMENT '上传名称别名数组', + `inbound_code_prefix` varchar(6) DEFAULT NULL, + `overseas_inbound_code_prefix` varchar(6) DEFAULT NULL, + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_channel_code` (`channel_code`), + KEY `idx_price_channel_status` (`status`,`price_model`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共船期价格固定渠道目录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_origin_alias`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_origin_alias` ( + `id` bigint unsigned NOT NULL COMMENT '起运仓别名ID', + `warehouse_code` varchar(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID', + `alias_name` varchar(128) NOT NULL COMMENT 'Excel起运仓名称别名', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_origin_alias` (`warehouse_code`,`alias_name`), + KEY `idx_price_origin_alias_name` (`alias_name`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格表起运仓名称别名映射'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_warehouse_mapping`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_warehouse_mapping` ( + `id` bigint unsigned NOT NULL COMMENT '价格仓映射ID', + `source_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '来源维度:ORIGIN_CODE或OVERSEAS_NAME', + `source_value` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '解析后的来源仓编码或海外仓名称', + `warehouse_id` bigint unsigned NOT NULL COMMENT '固定平台公共仓库ID', + `warehouse_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台仓库稳定编码快照', + `warehouse_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'DOMESTIC或OVERSEAS', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_warehouse_mapping_source` (`source_type`,`source_value`), + KEY `idx_price_warehouse_mapping_target` (`warehouse_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格表来源仓与固定平台公共仓映射'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_sailing`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_sailing` ( + `id` bigint NOT NULL, + `sailing_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `template_id` bigint DEFAULT NULL, + `channel_id` bigint NOT NULL, + `channel_code_snapshot` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `channel_name_snapshot` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `warehouse_id` bigint NOT NULL, + `warehouse_name_snapshot` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `departure_port_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_port_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `vessel_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `voyage_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `etd_at` datetime(3) NOT NULL, + `warehouse_cutoff_at` datetime(3) NOT NULL, + `eta_at` datetime(3) DEFAULT NULL, + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'OPEN', + `source_type` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANUAL', + `generated_key` varchar(192) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `planned_capacity_cbm` decimal(14,3) DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` bigint NOT NULL, + `created_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL, + `updated_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `version` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fl_sailing_no` (`sailing_no`), + UNIQUE KEY `uk_fl_sailing_generated_key` (`generated_key`), + UNIQUE KEY `uk_fl_sailing_business` (`warehouse_id`,`channel_code_snapshot`,`departure_port_code`,`destination_port_code`,`etd_at`,`voyage_no`), + KEY `idx_fl_sailing_calendar` (`etd_at`,`warehouse_id`,`channel_id`,`status`), + KEY `idx_fl_sailing_route` (`departure_port_code`,`destination_port_code`), + CONSTRAINT `ck_fl_sailing_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'OPEN',_utf8mb4'CLOSED',_utf8mb4'DEPARTED',_utf8mb4'CANCELLED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_sailing_assignment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_sailing_assignment` ( + `id` bigint unsigned NOT NULL COMMENT '归班事实ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键', + `sailing_id` bigint unsigned DEFAULT NULL COMMENT '船期实例ID,UNMATCHED为空', + `origin_sailing_id` bigint unsigned DEFAULT NULL COMMENT '异常重排/取消前的原船期实例ID', + `inbound_id` bigint unsigned DEFAULT NULL COMMENT '归班对象入库单ID', + `inbound_code` varchar(64) DEFAULT NULL COMMENT '入仓码快照,仅用于展示', + `receipt_batch_id` bigint unsigned DEFAULT NULL COMMENT '收货批次事实ID', + `forecast_id` bigint unsigned DEFAULT NULL COMMENT '货量预报事实ID', + `assignment_source` varchar(24) NOT NULL DEFAULT 'AUTO' COMMENT '归班来源:AUTO/MANUAL/ROLLOVER', + `assignment_version` int unsigned NOT NULL COMMENT '同一对象的归班版本', + `match_status` varchar(24) NOT NULL COMMENT 'FORECAST_MATCHED/RECEIVED/LATE/UNMATCHED/SUPERSEDED', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '已收实测体积', + `forecast_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '未收有效预计体积', + `route_source` varchar(32) DEFAULT NULL COMMENT '航线来源:INBOUND_FACT/FORECAST_FACT/CHANNEL_DEFAULT', + `volume_source` varchar(32) DEFAULT NULL COMMENT 'MEASURED_ACTUAL或预报来源', + `rule_version` varchar(64) NOT NULL COMMENT '自动归班规则版本', + `matched_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `superseded_at` datetime(3) DEFAULT NULL, + `locked_at` datetime(3) DEFAULT NULL COMMENT '截止/订舱/关闭后冻结时间', + `lock_reason` varchar(32) DEFAULT NULL COMMENT 'CUTOFF/BOOKING_CONFIRMED/CLOSED', + `operator_id` bigint unsigned DEFAULT NULL, + `reason` varchar(500) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_sailing_assignment_current` (`tenant_id`,`sailing_id`,`match_status`,`matched_at`,`id`), + KEY `idx_sailing_assignment_origin` (`tenant_id`,`origin_sailing_id`,`match_status`,`id`), + KEY `idx_sailing_assignment_receipt` (`tenant_id`,`receipt_batch_id`,`assignment_version`,`id`), + KEY `idx_sailing_assignment_forecast` (`tenant_id`,`forecast_id`,`assignment_version`,`id`), + KEY `idx_sailing_assignment_inbound` (`tenant_id`,`inbound_id`,`match_status`,`id`), + CONSTRAINT `chk_sailing_assignment_source` CHECK ((`assignment_source` in (_utf8mb4'AUTO',_utf8mb4'MANUAL',_utf8mb4'ROLLOVER'))), + CONSTRAINT `chk_sailing_assignment_status` CHECK ((`match_status` in (_utf8mb4'FORECAST_MATCHED',_utf8mb4'RECEIVED',_utf8mb4'LATE',_utf8mb4'UNMATCHED',_utf8mb4'SUPERSEDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期日历自动归班事实及不可变版本历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_sailing_booking_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_sailing_booking_link` ( + `id` bigint unsigned NOT NULL COMMENT '船期-订舱关联ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '平台关联隔离键', + `sailing_id` bigint unsigned NOT NULL COMMENT '计划船期实例ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '海运订舱ID', + `link_status` varchar(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '关联状态:ACTIVE/REVOKED', + `active_booking_guard` tinyint DEFAULT NULL COMMENT 'ACTIVE为1,REVOKED为空,用于唯一约束', + `booking_status_snapshot` varchar(24) NOT NULL COMMENT '关联时订舱状态快照;只有RELEASED可建立', + `warehouse_id` bigint unsigned DEFAULT NULL COMMENT '关联仓库快照', + `channel_code` varchar(64) DEFAULT NULL COMMENT '关联渠道快照', + `departure_port_code` varchar(32) DEFAULT NULL COMMENT '关联起运港快照', + `destination_port_code` varchar(32) DEFAULT NULL COMMENT '关联目的港快照', + `linked_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `revoked_at` datetime(3) DEFAULT NULL, + `operator_id` bigint unsigned DEFAULT NULL, + `operator_name` varchar(64) DEFAULT NULL, + `reason` varchar(500) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sailing_booking_active` (`ocean_booking_id`,`active_booking_guard`), + KEY `idx_sailing_booking_link` (`sailing_id`,`link_status`,`ocean_booking_id`), + KEY `idx_sailing_booking_booking` (`ocean_booking_id`,`link_status`,`id`), + CONSTRAINT `chk_sailing_booking_link_guard` CHECK ((((`link_status` = _utf8mb4'ACTIVE') and (`active_booking_guard` = 1)) or ((`link_status` = _utf8mb4'REVOKED') and (`active_booking_guard` is null)))), + CONSTRAINT `chk_sailing_booking_link_status` CHECK ((`link_status` in (_utf8mb4'ACTIVE',_utf8mb4'REVOKED'))), + CONSTRAINT `chk_sailing_booking_released` CHECK ((`booking_status_snapshot` = _utf8mb4'RELEASED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期计划与已放舱订舱的最小关联及历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_sailing_channel`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_sailing_channel` ( + `id` bigint NOT NULL, + `channel_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `channel_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_type` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL, + `mapped_channel_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `default_departure_port_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `default_destination_port_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `default_cutoff_days` tinyint NOT NULL DEFAULT '3', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` bigint NOT NULL, + `created_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL, + `updated_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `version` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fl_sailing_channel_code` (`channel_code`), + KEY `idx_fl_sailing_channel_status` (`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_sailing_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_sailing_history` ( + `id` bigint NOT NULL, + `sailing_id` bigint NOT NULL, + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `from_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `operator_id` bigint DEFAULT NULL, + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_fl_sailing_history_sailing` (`sailing_id`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_sailing_inbound_volume_forecast`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_sailing_inbound_volume_forecast` ( + `id` bigint unsigned NOT NULL COMMENT '货量预报事实ID(forecastId)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键,继承入库事实', + `forecast_key` varchar(64) NOT NULL COMMENT '同一未收货对象的不可变业务键', + `fact_version` int unsigned NOT NULL DEFAULT '1' COMMENT '预报修订版本', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `receiving_warehouse_id` bigint unsigned NOT NULL COMMENT '预计收货仓ID', + `channel_code` varchar(64) DEFAULT NULL COMMENT '预计渠道编码快照', + `channel_name` varchar(128) DEFAULT NULL COMMENT '预计渠道名称快照', + `departure_port_code` varchar(32) DEFAULT NULL COMMENT '预计起运港编码快照', + `destination_port_code` varchar(32) DEFAULT NULL COMMENT '预计目的港编码快照', + `expected_arrival_at` datetime(3) NOT NULL COMMENT '预计到仓时间', + `forecast_volume_cbm` decimal(18,4) NOT NULL COMMENT '未收货预计体积m3,不得使用申报体积代替', + `source_type` varchar(32) NOT NULL COMMENT '预报来源,例如OPERATIONS/UPSTREAM_MEASUREMENT;不得使用客户申报体积冒充预报', + `fact_status` varchar(24) NOT NULL DEFAULT 'VALID' COMMENT '事实状态:VALID/SUPERSEDED/VOIDED/CONSUMED', + `effective_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '本版本生效时间', + `created_by` bigint unsigned DEFAULT NULL COMMENT '写入人或系统主体', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sailing_forecast_version` (`tenant_id`,`forecast_key`,`fact_version`), + KEY `idx_sailing_forecast_current` (`tenant_id`,`forecast_key`,`fact_status`,`fact_version`), + KEY `idx_sailing_forecast_match` (`tenant_id`,`receiving_warehouse_id`,`channel_code`,`expected_arrival_at`,`id`), + KEY `idx_sailing_forecast_inbound` (`tenant_id`,`inbound_id`,`expected_arrival_at`,`id`), + CONSTRAINT `chk_sailing_forecast_status` CHECK ((`fact_status` in (_utf8mb4'VALID',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED',_utf8mb4'CONSUMED'))), + CONSTRAINT `chk_sailing_forecast_volume` CHECK ((`forecast_volume_cbm` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期日历未收货量预报事实及版本历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_sailing_receipt_batch_fact`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_sailing_receipt_batch_fact` ( + `id` bigint unsigned NOT NULL COMMENT '稳定收货批次事实ID(receiptBatchId)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键,继承入库事实', + `receipt_batch_key` varchar(64) NOT NULL COMMENT '同一物理收货批次的不可变业务键', + `fact_version` int unsigned NOT NULL DEFAULT '1' COMMENT '同一批次复测/冲正版本', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `measurement_id` bigint unsigned DEFAULT NULL COMMENT '最终审核测量批次ID', + `receiving_warehouse_id` bigint unsigned NOT NULL COMMENT '实际收货仓ID', + `channel_code` varchar(64) DEFAULT NULL COMMENT '收货渠道编码快照', + `channel_name` varchar(128) DEFAULT NULL COMMENT '收货渠道名称快照', + `departure_port_code` varchar(32) DEFAULT NULL COMMENT '起运港编码快照', + `destination_port_code` varchar(32) DEFAULT NULL COMMENT '目的港编码快照', + `received_at` datetime(3) NOT NULL COMMENT '实际收货完成时间,不使用入库单inbounded_at替代', + `actual_volume_cbm` decimal(18,4) NOT NULL COMMENT '最终审核实测体积m3', + `fact_status` varchar(24) NOT NULL DEFAULT 'APPROVED' COMMENT '事实状态:APPROVED/REVERSED/VOIDED/SUPERSEDED', + `reversal_of_fact_id` bigint unsigned DEFAULT NULL COMMENT '冲正所替代的事实ID', + `source_type` varchar(32) NOT NULL DEFAULT 'FIRSTLEG_RECEIPT' COMMENT '事实来源', + `created_by` bigint unsigned DEFAULT NULL COMMENT '写入人或系统主体', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_sailing_receipt_batch_version` (`tenant_id`,`receipt_batch_key`,`fact_version`), + KEY `idx_sailing_receipt_batch_current` (`tenant_id`,`receipt_batch_key`,`fact_status`,`fact_version`), + KEY `idx_sailing_receipt_match` (`tenant_id`,`receiving_warehouse_id`,`channel_code`,`received_at`,`id`), + KEY `idx_sailing_receipt_inbound` (`tenant_id`,`inbound_id`,`received_at`,`id`), + CONSTRAINT `chk_sailing_receipt_status` CHECK ((`fact_status` in (_utf8mb4'APPROVED',_utf8mb4'REVERSED',_utf8mb4'VOIDED',_utf8mb4'SUPERSEDED'))), + CONSTRAINT `chk_sailing_receipt_volume` CHECK ((`actual_volume_cbm` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船期日历稳定收货批次实测事实及版本历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_sailing_template`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_sailing_template` ( + `id` bigint NOT NULL, + `template_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `template_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `channel_id` bigint NOT NULL, + `channel_code_snapshot` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `channel_name_snapshot` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `warehouse_id` bigint NOT NULL, + `warehouse_name_snapshot` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `departure_port_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_port_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `weekday` tinyint NOT NULL, + `weekday_mask` tinyint NOT NULL DEFAULT '1', + `etd_local_time` time NOT NULL, + `cutoff_days` tinyint NOT NULL DEFAULT '3', + `cutoff_local_time` time NOT NULL DEFAULT '18:00:00', + `valid_from` date NOT NULL, + `valid_to` date DEFAULT NULL, + `voyage_rule` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `vessel_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` bigint NOT NULL, + `created_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_by` bigint NOT NULL, + `updated_by_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `version` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fl_sailing_template_no` (`template_no`), + KEY `idx_fl_sailing_template_filter` (`warehouse_id`,`channel_id`,`status`,`weekday`), + CONSTRAINT `ck_fl_sailing_template_cutoff_days` CHECK ((`cutoff_days` between 0 and 30)), + CONSTRAINT `ck_fl_sailing_template_weekday` CHECK ((`weekday` between 1 and 7)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_split_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_split_operation` ( + `id` bigint unsigned NOT NULL COMMENT '幂等记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '操作目标入库单逻辑ID', + `operator_id` bigint unsigned NOT NULL COMMENT '执行分柜或预装写操作的平台用户ID', + `operation` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分柜或预装写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功', + `result_json` json DEFAULT NULL COMMENT '操作结果JSON快照,用于幂等重放', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等操作声明创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_split_operation` (`tenant_id`,`inbound_id`,`operator_id`,`operation`,`idempotency_key`), + KEY `idx_split_operation_time` (`tenant_id`,`inbound_id`,`created_at`), + KEY `fk_split_operation_inbound` (`inbound_id`), + CONSTRAINT `fk_split_operation_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分柜操作幂等与审计索引'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_terminal_drayage`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_terminal_drayage` ( + `id` bigint unsigned NOT NULL COMMENT '目的港码头拖车聚合ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '唯一归属物理货柜ID', + `fleet_supplier_id` bigint unsigned DEFAULT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + `fleet_supplier_name` varchar(128) DEFAULT NULL COMMENT 'Warehouse供应商名称快照', + `pickup_appointment_at` datetime(3) DEFAULT NULL COMMENT '预约提柜时间', + `actual_pickup_at` datetime(3) DEFAULT NULL COMMENT '实际提柜时间', + `estimated_empty_return_at` datetime(3) DEFAULT NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', + `actual_empty_return_at` datetime(3) DEFAULT NULL COMMENT '实际还空柜时间', + `version` int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后更新人ID', + `updated_by_name` varchar(64) DEFAULT NULL COMMENT '最后更新人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_terminal_drayage_container` (`container_booking_id`), + KEY `idx_terminal_drayage_warning` (`actual_empty_return_at`,`estimated_empty_return_at`), + CONSTRAINT `fk_terminal_drayage_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_terminal_drayage_times` CHECK ((((`actual_pickup_at` is null) or (`pickup_appointment_at` is not null)) and ((`actual_empty_return_at` is null) or (`actual_pickup_at` is null) or (`actual_empty_return_at` >= `actual_pickup_at`)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港货柜级码头拖车与还空事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_terminal_drayage_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_terminal_drayage_history` ( + `id` bigint unsigned NOT NULL COMMENT '历史ID', + `drayage_id` bigint unsigned NOT NULL COMMENT '码头拖车聚合ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '货柜ID快照', + `action` varchar(32) NOT NULL COMMENT '业务动作', + `from_status` varchar(32) NOT NULL COMMENT '操作前状态', + `to_status` varchar(32) NOT NULL COMMENT '操作后状态', + `from_version` int unsigned NOT NULL COMMENT '操作前版本', + `to_version` int unsigned NOT NULL COMMENT '操作后版本', + `reason` varchar(500) DEFAULT NULL COMMENT '操作说明', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_terminal_drayage_history` (`container_booking_id`,`created_at`,`id`), + KEY `fk_terminal_drayage_history` (`drayage_id`), + CONSTRAINT `fk_terminal_drayage_history` FOREIGN KEY (`drayage_id`) REFERENCES `fl_terminal_drayage` (`id`), + CONSTRAINT `chk_terminal_drayage_history_action` CHECK ((`action` in (_utf8mb4'SELECT_FLEET',_utf8mb4'SEND_PICKUP_EMAIL',_utf8mb4'SCHEDULE_PICKUP',_utf8mb4'RECORD_PICKUP',_utf8mb4'RECORD_EMPTY_RETURN'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港码头拖车不可变操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_terminal_drayage_mail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_terminal_drayage_mail` ( + `id` bigint unsigned NOT NULL COMMENT '邮件任务ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '归属物理货柜ID', + `fleet_supplier_id` bigint unsigned NOT NULL COMMENT '车队供应商ID快照', + `fleet_name` varchar(128) NOT NULL COMMENT '车队名称快照', + `recipient` varchar(254) NOT NULL COMMENT '收件人邮箱', + `kind` varchar(32) NOT NULL COMMENT '邮件类型:REQUEST_PICKUP提柜请求、CANCEL_PICKUP取消提柜', + `subject` varchar(255) NOT NULL COMMENT '邮件主题', + `body` text NOT NULL COMMENT '邮件正文', + `status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、SENDING发送中、SENT已发送、FAILED失败', + `attempts` int NOT NULL DEFAULT '0' COMMENT '发送尝试次数', + `failure_reason` varchar(500) DEFAULT NULL COMMENT '发送失败原因', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `updated_by` bigint unsigned NOT NULL COMMENT '最后更新人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `sent_at` datetime(3) DEFAULT NULL COMMENT '发送成功时间', + PRIMARY KEY (`id`), + KEY `idx_terminal_mail_dispatch` (`status`,`id`), + KEY `idx_terminal_mail_container` (`container_booking_id`,`id`,`status`), + CONSTRAINT `fk_terminal_mail_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_terminal_mail_kind` CHECK ((`kind` in (_utf8mb4'REQUEST_PICKUP',_utf8mb4'CANCEL_PICKUP'))), + CONSTRAINT `chk_terminal_mail_status` CHECK ((`status` in (_utf8mb4'PENDING',_utf8mb4'SENDING',_utf8mb4'SENT',_utf8mb4'FAILED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='码头车队邮件任务与投递记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_terminal_drayage_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_terminal_drayage_operation` ( + `id` bigint unsigned NOT NULL COMMENT '幂等操作ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '货柜ID', + `operation` varchar(32) NOT NULL COMMENT '业务动作', + `idempotency_key` varchar(255) NOT NULL COMMENT '客户端幂等键', + `request_hash` char(64) NOT NULL COMMENT '请求载荷SHA-256', + `result_version` int unsigned NOT NULL COMMENT '成功结果版本', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_terminal_drayage_operation` (`container_booking_id`,`idempotency_key`), + KEY `idx_terminal_drayage_operation` (`container_booking_id`,`created_at`), + CONSTRAINT `fk_terminal_drayage_operation_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港码头拖车成功幂等操作'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_third_party_call_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_third_party_call_log` ( + `id` bigint unsigned NOT NULL COMMENT '第三方调用日志ID(雪花ID)', + `tenant_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权', + `app_key` varchar(128) DEFAULT NULL COMMENT '第三方应用标识', + `signature_subject` varchar(128) DEFAULT NULL COMMENT '签名主体快照', + `request_method` varchar(8) NOT NULL COMMENT 'HTTP方法', + `request_path` varchar(255) NOT NULL COMMENT '签名使用的规范路径', + `request_timestamp` varchar(32) DEFAULT NULL COMMENT '请求头Unix毫秒时间戳原值', + `nonce` varchar(128) DEFAULT NULL COMMENT '请求Nonce', + `idempotency_key` varchar(128) DEFAULT NULL COMMENT 'HTTP幂等键,查询接口为空', + `idempotency_anchor` tinyint unsigned DEFAULT NULL COMMENT '签名通过后首个请求为1,重复调用为空', + `signature_value` varchar(128) DEFAULT NULL COMMENT '第三方签名原值', + `request_hash` char(64) NOT NULL COMMENT '原始请求体SHA-256小写十六进制', + `raw_payload` longtext NOT NULL COMMENT '未经重新序列化的UTF-8原始请求体', + `processing_status` varchar(16) NOT NULL COMMENT 'PROCESSING、ACCEPTED或REJECTED', + `result_code` varchar(32) DEFAULT NULL COMMENT '统一响应业务码', + `result_message` varchar(1000) DEFAULT NULL COMMENT '处理结果或失败原因', + `result_json` json DEFAULT NULL COMMENT '首次成功结果快照', + `requested_at` datetime(3) NOT NULL COMMENT '服务端接收时间', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_call_idempotency_owner` (`tenant_id`,`app_key`,`idempotency_key`,`idempotency_anchor`), + KEY `idx_third_call_app_time` (`tenant_id`,`app_key`,`requested_at`), + KEY `idx_third_call_idempotency` (`tenant_id`,`app_key`,`idempotency_key`), + KEY `idx_third_call_nonce` (`tenant_id`,`app_key`,`nonce`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_truck_appointment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_truck_appointment` ( + `id` bigint unsigned NOT NULL COMMENT '拖车预约ID', + `appointment_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台拖车预约编号', + `outbound_order_id` bigint unsigned DEFAULT NULL COMMENT '关联头程订单逻辑ID;不跨库建外键', + `ocean_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联海运订舱逻辑ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验', + `loading_plan_id` bigint unsigned DEFAULT NULL COMMENT '关联装柜计划逻辑ID', + `warehouse_id` bigint unsigned DEFAULT NULL COMMENT '预约到仓的国内收货仓逻辑ID', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键', + `customer_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照', + `carrier_id` bigint unsigned DEFAULT NULL COMMENT '拖车服务商逻辑ID', + `carrier_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '拖车服务商名称快照', + `bill_of_lading_no` varchar(128) DEFAULT NULL, + `fleet_contact` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '车队联系人', + `fleet_phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '车队联系电话', + `driver_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '司机姓名', + `driver_phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '司机联系电话', + `driver_id_card` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '司机身份证号;平台内部敏感信息', + `vehicle_no` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '车辆车牌号', + `store_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓装柜地点名称快照', + `store_contact` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓地点联系人快照', + `store_phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓地点联系电话快照', + `store_address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓装柜地点地址快照', + `store_city` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓地点城市快照', + `store_zipcode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓地点邮编快照', + `package_count` int unsigned DEFAULT NULL COMMENT '预约货物包装件数快照', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '预约货物毛重,单位千克', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '预约货物体积,单位立方米', + `pallet_count` int unsigned DEFAULT NULL COMMENT '预约托盘数量', + `po_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关联采购单号或客户PO号', + `planned_loading_at` datetime(3) DEFAULT NULL COMMENT '预约到仓装柜时间', + `actual_arrived_warehouse_at` datetime(3) DEFAULT NULL COMMENT '车辆实际到达收货仓时间', + `actual_loading_at` datetime(3) DEFAULT NULL COMMENT '实际开始装柜时间', + `actual_departed_at` datetime(3) DEFAULT NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', + `transit_minutes` int unsigned DEFAULT NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', + `payable_amount` decimal(18,2) DEFAULT NULL COMMENT '应付拖车服务商金额;币种由currency指定', + `receivable_amount` decimal(18,2) DEFAULT NULL COMMENT '向客户收取的拖车金额;币种由currency指定', + `currency` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', + `revision` int unsigned NOT NULL DEFAULT '0' COMMENT '拖车预约业务修订号;每次状态或改约递增', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '平台内部备注或异常说明', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', + `active_container_booking_id` bigint unsigned GENERATED ALWAYS AS ((case when (`status` not in (_utf8mb4'COMPLETED',_utf8mb4'CANCELLED',_utf8mb4'EXCEPTION')) then `container_booking_id` else NULL end)) STORED COMMENT '非终态预约的柜次唯一键', + `active_ocean_booking_id` bigint unsigned GENERATED ALWAYS AS ((case when (`status` not in (_utf8mb4'COMPLETED',_utf8mb4'CANCELLED',_utf8mb4'EXCEPTION')) then `ocean_booking_id` else NULL end)) STORED COMMENT '非终态预约的订舱唯一键', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_truck_appointment_no` (`appointment_no`), + UNIQUE KEY `uk_truck_active_ocean_booking` (`active_ocean_booking_id`), + KEY `idx_truck_appointment_status` (`status`,`updated_at`), + KEY `idx_truck_appointment_container` (`container_booking_id`), + KEY `idx_truck_appointment_customer` (`customer_id`), + KEY `idx_truck_appointment_ocean` (`ocean_booking_id`), + CONSTRAINT `chk_truck_appointment_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'PENDING_DISPATCH',_utf8mb4'DISPATCHED',_utf8mb4'PICKUP_IN_PROGRESS',_utf8mb4'ARRIVED_WAREHOUSE',_utf8mb4'LOADING',_utf8mb4'DEPARTED',_utf8mb4'COMPLETED',_utf8mb4'RESCHEDULED',_utf8mb4'EXCEPTION',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期拖车预约派车聚合'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_truck_appointment_attachment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_truck_appointment_attachment` ( + `id` bigint unsigned NOT NULL COMMENT '拖车附件关系ID', + `appointment_id` bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件逻辑ID', + `attachment_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '附件类型编码;由业务动作约定', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上传时文件名快照', + `file_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件内容SHA-256摘要', + `visibility` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传操作人ID', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_truck_attachment` (`appointment_id`,`file_id`), + KEY `idx_truck_attachment` (`appointment_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车附件文件引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_truck_appointment_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_truck_appointment_history` ( + `id` bigint unsigned NOT NULL COMMENT '拖车预约历史ID', + `appointment_id` bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + `revision` int unsigned NOT NULL COMMENT '该预约业务修订号', + `action` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '触发状态或改约的业务动作', + `from_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更前拖车状态;创建时为空', + `to_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更后拖车状态;改约时可保持原状态', + `reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '状态或改约原因', + `operator_id` bigint unsigned NOT NULL COMMENT '执行操作的平台用户ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `request_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '请求链路中的请求ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_truck_history` (`appointment_id`,`revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车状态与改约历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_truck_appointment_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_truck_appointment_operation` ( + `id` bigint unsigned NOT NULL COMMENT '拖车写操作幂等记录ID', + `appointment_id` bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端写操作幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + `action` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '执行的拖车业务动作', + `response_snapshot` json DEFAULT NULL COMMENT '成功响应JSON快照;供相同幂等请求重放', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_truck_operation` (`idempotency_key`), + KEY `idx_truck_operation_appointment` (`appointment_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车操作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_audit_outbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_audit_outbox` ( + `event_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件全局唯一ID', + `event_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件类型', + `resource_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '被审计业务资源类型', + `resource_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '被审计业务资源ID;无法确定时为空', + `service_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '产生审计事件的服务名称', + `payload_json` json NOT NULL COMMENT '待发送的审计事件完整负载快照', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD已转死信', + `attempt_count` int NOT NULL DEFAULT '0' COMMENT '正常发送累计尝试次数', + `compensation_count` int NOT NULL DEFAULT '0' COMMENT '人工或自动补偿累计次数', + `next_retry_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '允许下次重试的最早时间', + `lease_owner` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前发送任务租约持有者标识', + `lease_until` datetime(3) DEFAULT NULL COMMENT '当前发送任务租约到期时间', + `last_error` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近一次发送失败原因', + `dead_at` datetime(3) DEFAULT NULL COMMENT '转入死信状态的时间', + `sent_at` datetime(3) DEFAULT NULL COMMENT '首次成功发送时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录最后更新时间', + PRIMARY KEY (`event_id`), + KEY `idx_audit_outbox_publish` (`status`,`next_retry_at`,`lease_until`,`created_at`), + KEY `idx_audit_outbox_retention` (`status`,`sent_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/output/local-schema-repair/schema-before.sql b/output/local-schema-repair/schema-before.sql new file mode 100644 index 000000000..832c16b7d --- /dev/null +++ b/output/local-schema-repair/schema-before.sql @@ -0,0 +1,7980 @@ + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `baiyue_lastleg` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; + +USE `baiyue_lastleg`; +DROP TABLE IF EXISTS `backup_fba_appointment_item_20261013`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `backup_fba_appointment_item_20261013` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '绉熸埛/瀹㈡埛璐﹀彿鏍囪瘑', + `appointment_id` bigint NOT NULL, + `plan_id` bigint NOT NULL, + `line_id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '取消或失败后释放货源占用', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_fba_appointment_item_line` (`appointment_id`,`line_id`), + KEY `idx_ll_fba_appointment_item_plan` (`plan_id`), + KEY `idx_ll_fba_appointment_item_order` (`order_id`), + KEY `idx_ll_fba_item_tenant_appointment` (`customer_id`,`appointment_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='FBA appointment line snapshot'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `event_delivery_attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `event_delivery_attempt` ( + `attempt_id` varchar(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + `service_name` varchar(32) NOT NULL COMMENT '本库所属生产服务', + `event_type` varchar(96) NOT NULL COMMENT '白名单业务事件类型', + `entry_id` varchar(128) NOT NULL COMMENT '生产者发件箱主键', + `event_id` varchar(128) DEFAULT NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + `target_name` varchar(64) NOT NULL COMMENT '固定目标业务服务', + `started_at` datetime(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + `finished_at` datetime(3) DEFAULT NULL COMMENT '尝试完成或租约过期恢复时间', + `duration_ms` bigint DEFAULT NULL COMMENT '尝试耗时毫秒', + `outcome` varchar(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + `error_code` varchar(64) DEFAULT NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (`attempt_id`), + KEY `idx_delivery_attempt_entry` (`service_name`,`entry_id`,`started_at`), + KEY `idx_delivery_attempt_outcome` (`outcome`,`started_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本服务业务事件逐次投递审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_work`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_work` ( + `work_item_key` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '不可变事项键,业务身份确定性生成', + `container_booking_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源柜次内部关联标识,不作展示编号', + `version` int NOT NULL DEFAULT '0' COMMENT '工作命令乐观锁版本', + `retired` tinyint(1) NOT NULL DEFAULT '0' COMMENT '已替代或已完成路线需求,不计活动工作', + `state_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '工作关系责任跟进及影响基线,不保存可编辑业务执行事实', + PRIMARY KEY (`work_item_key`), + KEY `idx_appointment_work_container` (`container_booking_id`,`retired`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项需求与关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_work_commitment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_work_commitment` ( + `entity_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原业务实体稳定键', + `state_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原业务命令产生的只读语义影响基线,不是第二份可编辑计划', + `impact_version` bigint NOT NULL DEFAULT '0' COMMENT '承诺影响单调递增版本', + PRIMARY KEY (`entity_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='工作事项承诺影响审计基线'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_work_guard`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_work_guard` ( + `id` int NOT NULL COMMENT '工作事项协调锁,固定1', + `scan_cursor` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '已开船来源同步游标', + `initialized` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否完成首次完整来源扫描', + `scope_version` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完整同步已确认的权威来源集合版本', + `scan_scope_version` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前扫描来源集合版本,防跨页漂移', + `completed_at` datetime(3) DEFAULT NULL COMMENT '最近完整同步UTC时间', + `requirement_version` bigint NOT NULL DEFAULT '0' COMMENT '原域命令在同一事务推进的需求版本', + `synchronized_requirement_version` bigint NOT NULL DEFAULT '0' COMMENT '完整扫描已消费需求版本', + `scan_requirement_version` bigint NOT NULL DEFAULT '0' COMMENT '当前扫描需求版本,防跨页漂移', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项同步与命令串行守卫'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_appointment_work_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_appointment_work_history` ( + `id` varchar(36) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '历史记录标识', + `work_item_key` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事项稳定键', + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '真实操作人员标识', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人显示名称', + `action` varchar(48) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '工作命令或观察动作', + `summary` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理或责任变更依据', + `command_key` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '请求幂等键,观察记录为空', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '包含操作者的规范请求摘要', + `response_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '原命令响应快照,重放不随当前状态改变', + `occurred_at` datetime(3) NOT NULL COMMENT '历史发生UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_appointment_work_command` (`work_item_key`,`command_key`), + KEY `idx_appointment_work_history` (`work_item_key`,`occurred_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_application`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_application` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `discard_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `outbound_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '废弃出库单号', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_codes` varchar(2000) COLLATE utf8mb4_unicode_ci NOT NULL, + `total_packages` int NOT NULL, + `fee_per_package` decimal(18,2) NOT NULL, + `total_fee` decimal(18,2) NOT NULL, + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `review_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', + `review_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `review_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reviewed_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL COMMENT '废弃完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_cargo_discard_no` (`customer_id`,`discard_no`), + UNIQUE KEY `uk_ll_cargo_discard_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_cargo_discard_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_cargo_discard_warehouse` (`customer_id`,`overseas_warehouse_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Cargo discard application requiring warehouse approval'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_carton` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `application_id` bigint NOT NULL, + `item_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `carton_id` bigint NOT NULL, + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_cargo_discard_carton` (`application_id`,`carton_id`), + KEY `idx_ll_cargo_discard_carton_application` (`customer_id`,`application_id`,`item_id`), + KEY `idx_ll_cargo_discard_carton_identity` (`customer_id`,`carton_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Cartons reserved while a cargo discard application awaits review'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_item` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `application_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint DEFAULT NULL COMMENT '一件代发订单ID', + `source_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DROPSHIP' COMMENT '库存来源:一件代发DROPSHIP、退货箱RETURN_CARTON', + `return_carton_id` bigint DEFAULT NULL COMMENT '退货库存箱ID', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `profile_id` bigint DEFAULT NULL COMMENT '托盘档案ID', + `sku_inventory_id` bigint DEFAULT NULL COMMENT 'SKU库存记录ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `available_packages_snapshot` int NOT NULL, + `discard_packages` int NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `actual_discard_packages` int DEFAULT NULL, + `pda_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `pda_operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pda_completed_at` datetime(3) DEFAULT NULL, + `completion_photo_file_ids` text COLLATE utf8mb4_unicode_ci COMMENT 'PDA完工照片文件ID JSON数组', + `inventory_position_id` bigint DEFAULT NULL COMMENT '废弃来源库存仓位ID', + `carton_marks_json` text COLLATE utf8mb4_unicode_ci COMMENT '本次废弃所选真实箱唛快照JSON数组', + `carton_scan_required` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要逐箱核对本次所选箱子', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_cargo_discard_item` (`application_id`,`profile_id`,`sku_code`), + KEY `idx_ll_cargo_discard_item_list` (`customer_id`,`application_id`), + KEY `idx_ll_cargo_discard_item_inbound` (`customer_id`,`inbound_code`,`sku_code`), + KEY `idx_ll_cargo_discard_return_carton` (`customer_id`,`return_carton_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Frozen SKU quantities in a cargo discard application'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_photo`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_photo` ( + `id` bigint NOT NULL, + `customer_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `application_id` bigint NOT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `file_id` bigint NOT NULL, + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `content_type` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `size_bytes` bigint NOT NULL, + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(6) NOT NULL, + `submitted_at` datetime(6) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cargo_discard_photo_file` (`file_id`), + KEY `idx_cargo_discard_photo_application` (`customer_id`,`application_id`,`pallet_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_cargo_discard_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_cargo_discard_status_history` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `application_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_ll_cargo_discard_history` (`customer_id`,`application_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable cargo discard approval history'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_compact_number_clock`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_compact_number_clock` ( + `number_key` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务前缀和客户名称缩写', + `last_second` bigint NOT NULL COMMENT '最后分配的Unix逻辑秒', + PRIMARY KEY (`number_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_container_arrival_time_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_container_arrival_time_operation` ( + `id` bigint NOT NULL COMMENT '柜级预计到仓时间修改操作ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台请求幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化修改请求SHA-256摘要', + `result_version` int NOT NULL COMMENT '修改成功后的柜级时间版本', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人名称', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '预计到仓时间修改备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_container_arrival_time_idempotency` (`idempotency_key`), + KEY `idx_ll_container_arrival_time_container` (`source_container_booking_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级预计到仓时间修改幂等审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_container_loading_evidence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_container_loading_evidence` ( + `id` bigint NOT NULL COMMENT '海外整柜装柜照片投影ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内稳定来源柜预订ID', + `evidence_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PHOTO_STEP_1至PHOTO_STEP_4或SEAL', + `source_file_id` bigint NOT NULL COMMENT '国内File服务文件ID', + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内文件名快照', + `created_at` datetime(3) NOT NULL COMMENT '投影创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_container_loading_evidence_type` (`source_container_booking_id`,`evidence_type`), + UNIQUE KEY `uk_ll_container_loading_evidence_file` (`source_container_booking_id`,`source_file_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外拆柜只读国内装柜照片引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_container_plan_conversion_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_container_plan_conversion_command` ( + `id` bigint NOT NULL COMMENT '柜级转换命令ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '调用方幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '确认请求SHA-256摘要', + `snapshot_version` bigint NOT NULL COMMENT '确认的柜级快照版本', + `snapshot_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '确认的柜级快照SHA-256', + `warehouse_id` bigint NOT NULL COMMENT '海外接柜仓ID', + `batch_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓托盘批次幂等键', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令状态:PREPARED/APPLYING/FAILED/LOCAL_COMMITTED/COMPLETED', + `lease_token` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前执行租约令牌,用于陈旧执行者隔离', + `preparation_json` json NOT NULL COMMENT '稳定计划号与托盘业务键快照', + `result_json` json DEFAULT NULL COMMENT '本地计划提交结果', + `last_error` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次跨服务错误', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '命令状态或租约更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_container_conversion_command` (`source_container_booking_id`), + UNIQUE KEY `uk_ll_container_conversion_batch` (`warehouse_id`,`batch_key`), + KEY `idx_ll_container_conversion_status` (`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级入库计划转换Saga与幂等命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_container_shipping_gate`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_container_shipping_gate` ( + `container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源头程柜预订ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', + `ocean_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源海运订舱ID', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际集装箱号', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'AWAITING_PICKUP_READY' COMMENT '海外收货门禁状态:AWAITING_PICKUP_READY、RECEIVING_AVAILABLE', + `standard_node` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最新有效标准船节点', + `node_sequence` int NOT NULL DEFAULT '0' COMMENT '最新有效节点单调序号', + `observed_at` datetime(3) DEFAULT NULL COMMENT '供应商节点观测时间', + `actual_at` datetime(3) DEFAULT NULL COMMENT '节点实际发生时间', + `location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '节点地点编码', + `location_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '节点地点名称', + `provider_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '船节点供应商编码', + `provider_event_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商事件ID', + `provider_node_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商原始节点编码', + `source_reference` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商来源引用', + `raw_payload_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商原始负载SHA-256', + `aggregate_version` bigint NOT NULL DEFAULT '0' COMMENT '来源查询聚合版本', + `handover_version` bigint NOT NULL DEFAULT '1' COMMENT '跨区交接版本', + `last_event_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近采纳的跨区事件ID', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近采纳事件规范化负载摘要', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`container_booking_id`), + KEY `idx_ll_shipping_gate_candidate` (`customer_id`,`container_no`,`status`), + KEY `idx_ll_shipping_gate_version` (`container_booking_id`,`aggregate_version`,`node_sequence`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号船节点收货资格快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_customer_visit`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_customer_visit` ( + `id` bigint NOT NULL COMMENT '客户回访记录ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `delivery_order_id` bigint NOT NULL COMMENT '关联派送单ID', + `delivery_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联派送单号', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户联系人姓名快照', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户联系人电话快照', + `destination` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送目的仓编码汇总快照', + `delivery_completed_at` datetime(3) NOT NULL COMMENT '派送完成时间', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '回访状态:PENDING待回访或COMPLETED已回访', + `selected_last_mile_order_id` bigint DEFAULT NULL COMMENT '本次回访选择的尾程订单ID', + `selected_last_mile_order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次回访选择的尾程订单号', + `selected_inbound_order_id` bigint DEFAULT NULL COMMENT '本次回访选择的入仓单ID', + `selected_inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次回访选择的入仓码', + `visit_result` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访结果', + `customer_feedback` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次客户反馈', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访备注', + `attachment_json` json DEFAULT NULL COMMENT '当前回访资料文件元数据列表', + `visited_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次回访操作人ID', + `visited_at` datetime(3) DEFAULT NULL COMMENT '最近一次回访时间', + `visit_count` int NOT NULL DEFAULT '0' COMMENT '累计保存回访次数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_customer_visit_delivery` (`customer_id`,`delivery_order_id`), + KEY `idx_ll_customer_visit_list` (`customer_id`,`status`,`delivery_completed_at`), + KEY `idx_ll_customer_visit_delivery_no` (`customer_id`,`delivery_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送单维度客户回访记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_document_revision`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_document_revision` ( + `id` bigint NOT NULL COMMENT '派送资料历史ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '派送所属客户隔离键', + `delivery_order_id` bigint NOT NULL COMMENT '派送单ID', + `version` bigint NOT NULL COMMENT '保存成功时派送单版本', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资料保存幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '完整保存请求哈希', + `snapshot_json` json NOT NULL COMMENT '不可变三类资料及BOL号结果快照', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '保存操作人ID', + `created_at` datetime(3) NOT NULL COMMENT '资料保存时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_delivery_document_command` (`customer_id`,`delivery_order_id`,`idempotency_key`), + UNIQUE KEY `uk_delivery_document_version` (`customer_id`,`delivery_order_id`,`version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送资料版本历史与幂等保存记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_fee` ( + `id` bigint NOT NULL COMMENT '派送费用主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `delivery_order_id` bigint NOT NULL COMMENT '派送单ID', + `fee_direction` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PAYABLE' COMMENT '应收应付方向', + `fee_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用类型', + `currency` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '币种', + `amount` decimal(18,2) NOT NULL COMMENT '费用金额', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '费用备注', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_delivery_fee_order` (`customer_id`,`delivery_order_id`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送单费用明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_operation` ( + `id` bigint NOT NULL COMMENT '派送操作主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `delivery_order_id` bigint NOT NULL COMMENT '派送单ID', + `operation_node` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作节点', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人名称', + `result` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理结果', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_ll_delivery_operation_order` (`customer_id`,`delivery_order_id`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='派送单操作记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_delivery_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_delivery_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `delivery_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `outbound_order_id` bigint NOT NULL, + `outbound_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `dispatch_order_id` bigint DEFAULT NULL COMMENT '关联派车单ID,仓储转运直接出库时为空', + `dispatch_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联派车单号,仓储转运直接出库时为空', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外仓名称快照', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓编码快照,私人仓为空', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本派送单目的FBA仓名称快照', + `destination_type` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地类型:FBA或PRIVATE', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓派送地址', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', + `delivery_group_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '同一出库单内按目的FBA仓拆单的稳定分组键', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_DELIVERY', + `seller_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '卖家姓名', + `package_count` int NOT NULL DEFAULT '0', + `pallet_count` int NOT NULL DEFAULT '0', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送备注', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` bigint NOT NULL DEFAULT '0' COMMENT '派送单乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `waybill_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '承运运单号', + `delivery_method` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式', + `carrier_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送承运商', + `pickup_method` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货方式', + `loading_type` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '装车类型', + `vehicle_type` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车辆类型', + `vehicle_size` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车辆大小', + `vehicle_plate` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车牌号', + `driver_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机或车队名称', + `driver_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '车队联系电话', + `appointment_account` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '预约账号', + `total_weight_kg` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '总重量(千克)', + `total_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米),来源未确认时为空', + `planned_delivery_at` datetime(3) DEFAULT NULL COMMENT '预计派送时间', + `dispatched_at` datetime(3) DEFAULT NULL COMMENT '实际派送时间', + `expected_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到达时间', + `signed_at` datetime(3) DEFAULT NULL COMMENT '签收时间', + `bol_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送资料BOL号,不作为文件归属标识', + `documents_json` json DEFAULT NULL COMMENT '娲鹃�佷笁绫昏祫鏂欏綋鍓嶇粦瀹氬強鏉冨▉鏂囦欢鍏冩暟鎹揩鐓�', + `postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + `signed_packages` int DEFAULT NULL COMMENT '保存签收资料时冻结的实际签收箱数,不含此前已退回量', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_delivery_no` (`customer_id`,`delivery_no`), + UNIQUE KEY `uk_ll_delivery_idempotency` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_delivery_outbound_destination` (`customer_id`,`outbound_order_id`,`delivery_group_key`), + KEY `idx_ll_delivery_list` (`customer_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Delivery document linked to an outbound document'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_destuff_pallet_cost`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_destuff_pallet_cost` ( + `id` bigint NOT NULL COMMENT '主键ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源柜次业务ID', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + `warehouse_id` bigint DEFAULT NULL COMMENT '海外仓ID', + `pallet_count` int NOT NULL COMMENT '实际打托数', + `total_actual_volume_cbm` decimal(20,6) NOT NULL COMMENT '客户实收总体积', + `total_cost` decimal(20,2) NOT NULL COMMENT '打托总成本', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '成本币种', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '生成状态', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_destuff_cost_booking` (`source_container_booking_id`), + CONSTRAINT `chk_ll_destuff_cost` CHECK (((`pallet_count` > 0) and (`total_actual_volume_cbm` > 0) and (`total_cost` > 0) and (`currency` = _utf8mb4'CNY'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='拆柜打托实际成本汇总'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_destuff_pallet_cost_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_destuff_pallet_cost_allocation` ( + `id` bigint NOT NULL COMMENT '主键ID', + `cost_id` bigint NOT NULL COMMENT '打托成本汇总ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + `actual_volume_cbm` decimal(20,6) NOT NULL COMMENT '客户实收体积', + `allocated_amount` decimal(20,2) NOT NULL COMMENT '客户分摊金额', + `fee_source_id` bigint NOT NULL COMMENT '海外费用来源ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_destuff_customer` (`cost_id`,`customer_id`), + UNIQUE KEY `uk_ll_destuff_fee_source` (`fee_source_id`), + CONSTRAINT `chk_ll_destuff_allocation` CHECK (((`actual_volume_cbm` > 0) and (`allocated_amount` >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='拆柜打托成本按客户实收体积分摊'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_appointment_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_appointment_allocation` ( + `dispatch_id` bigint NOT NULL COMMENT '排车单标识', + `dispatch_item_id` bigint NOT NULL COMMENT '排车明细标识', + `plan_pallet_id` bigint NOT NULL COMMENT '入仓计划托盘标识', + `batch_key` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '预约稳定批次键', + `customer_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所属客户标识', + `source_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '货物来源稳定键', + `destination_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地稳定键', + `package_count` int NOT NULL COMMENT '本票分配实物箱数', + PRIMARY KEY (`dispatch_item_id`,`batch_key`,`source_key`,`destination_key`), + KEY `idx_dispatch_appointment_batch` (`batch_key`,`customer_id`), + KEY `idx_dispatch_appointment_dispatch` (`dispatch_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车实物与预约批次逐票分配,原执行域事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_draft`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_draft` ( + `id` bigint NOT NULL, + `draft_token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `plan_ids_json` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `containers_json` longtext COLLATE utf8mb4_unicode_ci, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `expires_at` datetime(3) DEFAULT NULL, + `released_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_draft_token` (`draft_token`), + KEY `idx_ll_dispatch_draft_operator` (`operator_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_draft_container`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_draft_container` ( + `id` bigint NOT NULL, + `draft_id` bigint NOT NULL, + `draft_token` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `sequence_no` int NOT NULL, + `capacity` int NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_ll_dispatch_draft_container_token` (`draft_token`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='智能排车草稿装车托盘'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_item` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `dispatch_order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `plan_id` bigint DEFAULT NULL, + `plan_pallet_id` bigint DEFAULT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `order_id` bigint DEFAULT NULL, + `order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `order_refs` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `package_count` int NOT NULL DEFAULT '0', + `gross_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + `volume_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', + `volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `source_stage` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PLAN or RECEIVED', + `carton_marks` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标箱唛映射快照', + `source_business_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务类型', + `source_business_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源业务单号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + `postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', + `return_receipt_id` bigint DEFAULT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_item_source` (`dispatch_order_id`,`plan_pallet_id`,`warehouse_pallet_id`), + KEY `idx_ll_dispatch_item_order` (`customer_id`,`order_id`), + KEY `idx_ll_dispatch_item_inbound` (`customer_id`,`inbound_code`), + KEY `idx_ll_dispatch_item_pallet` (`customer_id`,`plan_pallet_id`,`warehouse_pallet_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Frozen pallet and cargo snapshot for a dispatch order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `dispatch_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Dispatch order number', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `vehicle_plate` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Editable vehicle plate; required before PDA picking', + `driver_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `driver_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `carrier_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `route` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `depart_at` datetime(3) DEFAULT NULL, + `fba_appointment_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fba_appointment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `vehicle_appointment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `estimated_arrival_at` datetime(3) DEFAULT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_DEPARTURE', + `item_count` int NOT NULL DEFAULT '0', + `pallet_count` int NOT NULL DEFAULT '0', + `package_count` int NOT NULL DEFAULT '0', + `picked_package_count` int NOT NULL DEFAULT '0', + `shipped_package_count` int NOT NULL DEFAULT '0', + `partial_completion_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_order_no` (`customer_id`,`dispatch_no`), + KEY `idx_ll_dispatch_order_list` (`customer_id`,`status`,`depart_at`), + KEY `idx_ll_dispatch_order_vehicle` (`customer_id`,`vehicle_plate`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Last-leg pre-dispatch order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pallet_lock`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pallet_lock` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_pallet_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PLAN:{planPalletId} or RECEIVED:{warehousePalletId}', + `active_key` varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Equals source key only while lock is active', + `plan_id` bigint DEFAULT NULL, + `plan_pallet_id` bigint DEFAULT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `dispatch_order_id` bigint DEFAULT NULL COMMENT '正式派车单ID,草稿锁阶段为空', + `draft_container_id` bigint DEFAULT NULL COMMENT '智能排车草稿装车托盘ID', + `draft_token` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '智能排车草稿随机标识', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '草稿锁操作人ID', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `locked_at` datetime(3) NOT NULL, + `expires_at` datetime(3) DEFAULT NULL COMMENT '草稿锁失效时间,正式派车锁为空', + `released_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `return_receipt_id` bigint DEFAULT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_active_pallet` (`customer_id`,`active_key`), + KEY `idx_ll_dispatch_pallet_lock_order` (`customer_id`,`dispatch_order_id`,`status`), + KEY `idx_ll_dispatch_pallet_lock_source` (`customer_id`,`source_pallet_key`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Active dispatch pallet exclusion lock'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pda_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pda_operation` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint DEFAULT NULL, + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `result_json` longtext COLLATE utf8mb4_unicode_ci, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_pda_operation_key` (`customer_id`,`idempotency_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Idempotent dispatch PDA operations'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pda_pick`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pda_pick` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint NOT NULL, + `dispatch_item_id` bigint NOT NULL, + `inventory_position_id` bigint DEFAULT NULL COMMENT '普通库存仓位记录ID', + `packages` bigint NOT NULL, + `product_quantity` bigint NOT NULL DEFAULT '0', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `picked_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `return_carton_id` bigint DEFAULT NULL COMMENT '独立退货库存箱ID,与普通仓位互斥', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_pda_pick_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_dispatch_pda_pick_item` (`customer_id`,`task_id`,`dispatch_item_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable dispatch PDA pallet package picks'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pda_pick_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pda_pick_order` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `pick_id` bigint NOT NULL, + `receipt_id` bigint DEFAULT NULL COMMENT '原入仓实收来源,与退回来源或原生退货箱互斥', + `order_id` bigint NOT NULL, + `packages` bigint NOT NULL, + `product_quantity` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `return_carton_id` bigint DEFAULT NULL COMMENT '原生退货箱来源ID,与实收来源互斥', + `return_receipt_id` bigint DEFAULT NULL COMMENT '明确选用的真实退回入仓来源', + PRIMARY KEY (`id`), + KEY `idx_ll_dispatch_pick_order_pick` (`customer_id`,`pick_id`), + KEY `idx_ll_dispatch_pick_order_receipt` (`customer_id`,`receipt_id`), + CONSTRAINT `chk_dispatch_pick_receipt_source` CHECK (((((`receipt_id` is not null) + (`return_receipt_id` is not null)) + (`return_carton_id` is not null)) = 1)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA pick attribution to frozen inbound receipt and tail order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dispatch_pda_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dispatch_pda_task` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `dispatch_order_id` bigint NOT NULL, + `task_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pushed_at` datetime(3) NOT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dispatch_pda_task_dispatch` (`customer_id`,`dispatch_order_id`), + UNIQUE KEY `uk_ll_dispatch_pda_task_no` (`customer_id`,`task_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA task for a pre-arranged dispatch'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_carton` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint NOT NULL, + `profile_id` bigint NOT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `carton_sequence` int NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'AVAILABLE', + `outbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_carton` (`customer_id`,`order_id`,`carton_mark`), + KEY `idx_ll_dropship_carton_status` (`customer_id`,`status`), + KEY `idx_ll_dropship_carton_sku` (`customer_id`,`sku_code`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_carton_ledger`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_carton_ledger` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint NOT NULL, + `carton_id` bigint NOT NULL, + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `before_packages` bigint NOT NULL, + `delta_packages` bigint NOT NULL, + `after_packages` bigint NOT NULL, + `business_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `occurred_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_ledger_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_dropship_ledger_sku` (`customer_id`,`sku_code`,`carton_mark`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_carton_sku`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_carton_sku` ( + `id` bigint NOT NULL COMMENT '主键ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `profile_id` bigint NOT NULL COMMENT '一件代发托盘档案ID', + `carton_id` bigint NOT NULL COMMENT '一件代发物理箱ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱内SKU编码', + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称快照', + `product_quantity` bigint NOT NULL COMMENT '该物理箱内此SKU实收件数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_carton_sku` (`carton_id`,`sku_code`), + KEY `idx_ll_dropship_carton_sku_order` (`customer_id`,`order_id`,`sku_code`), + KEY `idx_ll_dropship_carton_sku_profile` (`customer_id`,`profile_id`,`sku_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发物理箱内SKU明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_order_profile`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_order_profile` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint NOT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Pallet number snapshot supplied by first-leg handover', + `inventory_position_id` bigint DEFAULT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_profile_key` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_dropship_profile_order_pallet` (`customer_id`,`order_id`,`source_pallet_no`), + KEY `idx_ll_dropship_profile_order` (`customer_id`,`order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_dropship_sku_inventory`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_dropship_sku_inventory` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_id` bigint NOT NULL, + `profile_id` bigint NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `package_count` bigint NOT NULL, + `product_quantity` bigint NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_dropship_sku_profile` (`profile_id`,`sku_code`), + KEY `idx_ll_dropship_sku_order` (`customer_id`,`order_id`,`sku_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_event_conflict`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_event_conflict` ( + `id` bigint NOT NULL COMMENT '冲突记录主键', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冲突事件 ID', + `customer_id` bigint NOT NULL COMMENT '租户 ID', + `conflict_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冲突类型', + `conflict_message` varchar(512) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冲突说明', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_conflict_event` (`event_id`,`customer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='事件冲突记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_event_inbox` ( + `id` bigint NOT NULL COMMENT '事件接收记录主键', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局唯一 ID', + `customer_id` bigint NOT NULL COMMENT '租户 ID', + `event_type` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + `aggregate_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + `aggregate_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合 ID', + `aggregate_version` bigint NOT NULL COMMENT '聚合版本', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '负载摘要', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态', + `error_message` varchar(512) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '错误信息', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbox_event` (`event_id`), + KEY `idx_ll_inbox_source` (`customer_id`,`aggregate_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='通用事件接收幂等箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_event_outbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_event_outbox` ( + `id` bigint NOT NULL COMMENT '事件主键ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `aggregate_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型', + `aggregate_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合标识', + `event_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + `event_version` int NOT NULL COMMENT '事件版本', + `event_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务幂等键', + `payload_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件载荷JSON', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '发布状态', + `retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '下次重试时间', + `published_at` datetime(3) DEFAULT NULL COMMENT '发布时间', + `locked_at` datetime(3) DEFAULT NULL COMMENT '投递租约获取时间', + `locked_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '投递租约持有者', + `last_error` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后一次发布错误', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_event_outbox_key` (`event_key`), + KEY `idx_ll_event_outbox_publish` (`status`,`next_retry_at`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程跨区业务事件发件箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_exception_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_exception_order` ( + `id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `exception_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `exception_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL, + `last_mile_order_id` bigint DEFAULT NULL, + `last_mile_order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_order_id` bigint DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `delivery_order_id` bigint DEFAULT NULL, + `delivery_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `outbound_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `package_count` int DEFAULT NULL, + `return_direction` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `return_logistics_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `handling_action` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `handling_result` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常处理结果', + `evidence_file_ids_json` json DEFAULT NULL COMMENT '异常处理证据文件ID列表', + `inventory_adjustment_id` bigint DEFAULT NULL COMMENT '退回库存恢复操作ID', + `handler_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `handler_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANUAL', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `exception_at` datetime(3) NOT NULL, + `completed_at` datetime(3) DEFAULT NULL COMMENT '异常处理完成时间', + `version` bigint NOT NULL DEFAULT '0' COMMENT '异常订单乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_exception_no` (`customer_id`,`exception_no`), + UNIQUE KEY `uk_ll_exception_delivery_return` (`customer_id`,`delivery_order_id`,`exception_type`), + KEY `idx_ll_exception_list` (`customer_id`,`status`,`exception_at`), + KEY `idx_ll_exception_tail` (`customer_id`,`last_mile_order_no`), + KEY `idx_ll_exception_inbound` (`customer_id`,`inbound_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓异常订单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_exception_order_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_exception_order_command` ( + `id` bigint NOT NULL COMMENT '异常处理命令ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `exception_order_id` bigint NOT NULL COMMENT '异常订单ID', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '异常处理动作', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA256哈希', + `result_json` json NOT NULL COMMENT '命令结果快照', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_exception_command_key` (`customer_id`,`action`,`idempotency_key`), + KEY `idx_ll_exception_command_order` (`customer_id`,`exception_order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常处理命令幂等审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_exception_return_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_exception_return_item` ( + `id` bigint NOT NULL COMMENT '异常退回明细ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `exception_order_id` bigint NOT NULL COMMENT '异常订单ID', + `outbound_pallet_item_id` bigint NOT NULL COMMENT '原出库托盘明细ID', + `inventory_position_id` bigint NOT NULL COMMENT '原出库库存位置ID', + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库托盘码快照', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '原出库SKU快照', + `max_return_packages` bigint NOT NULL COMMENT '原出库可退箱数上限', + `approved_return_packages` bigint NOT NULL COMMENT '批准退回箱数', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_exception_return_source` (`customer_id`,`exception_order_id`,`outbound_pallet_item_id`,`inventory_position_id`), + KEY `idx_ll_exception_return_order` (`customer_id`,`exception_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='异常退回原出库库存冻结明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_finance_fee_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_finance_fee_command` ( + `id` bigint NOT NULL COMMENT '幂等命令ID', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + `action` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'CREATE、SUBMIT、REVIEW、ADJUST或VOID', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256', + `source_fee_id` bigint NOT NULL COMMENT '稳定费用来源ID', + `source_version` int NOT NULL COMMENT '命令结果版本', + `created_at` datetime(3) NOT NULL COMMENT '命令创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_finance_fee_command` (`operator_id`,`action`,`idempotency_key`), + KEY `idx_ll_finance_fee_command_source` (`source_fee_id`,`source_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外费用来源写操作幂等审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_finance_fee_source`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_finance_fee_source` ( + `id` bigint NOT NULL COMMENT '费用版本记录ID', + `source_fee_id` bigint NOT NULL COMMENT '跨版本稳定费用来源ID', + `source_version` int NOT NULL COMMENT '费用来源版本,从1递增', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `customer_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户名称快照', + `source_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外费用来源类型', + `source_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务编号或入仓码快照', + `business_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务类型', + `business_id` bigint DEFAULT NULL COMMENT '来源业务记录ID', + `business_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源业务编号', + `order_id` bigint DEFAULT NULL COMMENT '关联订单ID', + `inbound_id` bigint DEFAULT NULL COMMENT '关联海外入仓计划或入仓记录ID', + `inbound_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联入仓码;并非所有费用必填', + `container_id` bigint DEFAULT NULL COMMENT '关联柜次ID', + `container_booking_id` bigint DEFAULT NULL COMMENT '来源订舱柜ID', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实集装箱号快照', + `warehouse_id` bigint DEFAULT NULL COMMENT '关联海外仓ID', + `fee_item_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目编码快照', + `fee_item_name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目名称快照', + `direction` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVABLE应收或PAYABLE应付', + `supplier_id` bigint DEFAULT NULL COMMENT '应付供应商ID', + `supplier_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商名称快照', + `supplier_category` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '应付供应商类别约束', + `settlement_mode` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'ON_ACCOUNT账期应付或DIRECT_PAID业务已付', + `paid_at` datetime(3) DEFAULT NULL COMMENT '直接已付时间', + `payment_method` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付方式', + `payment_reference_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '直接已付流水号', + `payment_voucher_file_ids` json DEFAULT NULL COMMENT '直接已付凭证文件ID快照', + `wallet_settlement` json DEFAULT NULL COMMENT '已实扣应收的Finance资金命令、冻结和消费流水引用', + `business_date` date NOT NULL COMMENT '费用业务日期', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '财务结算币种,当前固定CNY', + `amount` decimal(20,6) NOT NULL COMMENT '费用金额', + `review_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'DRAFT、PENDING、APPROVED或REJECTED', + `publication_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'UNPUBLISHED或PUBLISHED', + `source_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'ACTIVE、SUPERSEDED或VOIDED', + `review_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核或作废原因', + `reviewer_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人ID', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后更新人ID', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + `original_currency` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '原币币种', + `original_amount` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '原币金额', + `exchange_rate` decimal(18,8) NOT NULL DEFAULT '1.00000000' COMMENT '原币折算CNY汇率', + `exchange_rate_month` char(7) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '汇率月份YYYY-MM', + `chargeable_quantity` decimal(18,4) DEFAULT NULL COMMENT '计费数量', + `quantity_unit` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '计费单位', + `unit_price` decimal(18,4) DEFAULT NULL COMMENT '原币单价', + `period_start` date DEFAULT NULL COMMENT '周期费用开始日期', + `period_end` date DEFAULT NULL COMMENT '周期费用结束日期', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_finance_fee_version` (`source_fee_id`,`source_version`), + KEY `idx_ll_finance_fee_customer` (`customer_id`,`source_status`,`created_at`), + KEY `idx_ll_finance_fee_business` (`business_type`,`business_no`,`created_at`), + KEY `idx_ll_finance_fee_container_customer` (`container_booking_id`,`customer_id`,`source_status`,`created_at`), + CONSTRAINT `chk_ll_finance_fee_amount` CHECK ((`amount` > 0)), + CONSTRAINT `chk_ll_finance_fee_currency` CHECK ((`currency` = _utf8mb4'CNY')), + CONSTRAINT `chk_ll_finance_fee_direction` CHECK ((`direction` in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), + CONSTRAINT `chk_ll_finance_fee_publication` CHECK ((`publication_status` in (_utf8mb4'UNPUBLISHED',_utf8mb4'PUBLISHED'))), + CONSTRAINT `chk_ll_finance_fee_review` CHECK ((`review_status` in (_utf8mb4'DRAFT',_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))), + CONSTRAINT `chk_ll_finance_fee_status` CHECK ((`source_status` in (_utf8mb4'ACTIVE',_utf8mb4'SUPERSEDED',_utf8mb4'VOIDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务不可变费用来源版本;发布后经跨区事件进入Finance'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_capacity_offer`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_capacity_offer` ( + `id` bigint NOT NULL COMMENT '内部主键', + `offer_no` varchar(64) NOT NULL COMMENT '拼车运力发布业务编号', + `trip_id` bigint NOT NULL COMMENT '唯一所属车次', + `trip_no` varchar(64) NOT NULL COMMENT '车次业务编号', + `status` varchar(16) NOT NULL COMMENT '发布状态:草稿、开放、暂停、关闭', + `cutoff_at` datetime(3) NOT NULL COMMENT '最晚交货及新询价截止UTC时间', + `contact_name` varchar(128) DEFAULT NULL COMMENT '对内部销售公开的调度联系人', + `contact_phone` varchar(128) DEFAULT NULL COMMENT '对内部销售公开的调度联系电话', + `remark` varchar(1000) DEFAULT NULL COMMENT '公开承运说明', + `published_fingerprint` char(64) DEFAULT NULL COMMENT '已核对路线车辆及容量计划事实摘要', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(64) NOT NULL COMMENT '创建人', + `created_at` datetime(3) NOT NULL COMMENT '创建UTC时间', + `updated_by` varchar(64) NOT NULL COMMENT '修改人', + `updated_at` datetime(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_capacity_offer_no` (`offer_no`), + UNIQUE KEY `uk_fleet_capacity_offer_trip` (`trip_id`), + KEY `idx_fleet_capacity_offer_status` (`status`,`cutoff_at`,`id`), + CONSTRAINT `fk_fleet_capacity_offer_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`), + CONSTRAINT `chk_fleet_capacity_offer_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'OPEN',_utf8mb4'PAUSED',_utf8mb4'CLOSED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='内部拼车车次余位发布;容量实时计算不在此另存'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_capacity_offer_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_capacity_offer_command` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_id` bigint NOT NULL COMMENT '所属车次锁作用域', + `operator_id` varchar(64) NOT NULL COMMENT '原请求操作者', + `action` varchar(32) NOT NULL COMMENT '业务命令类型', + `scope_no` varchar(64) NOT NULL COMMENT '发布或预订业务编号作用域', + `idempotency_key` varchar(128) NOT NULL COMMENT '原请求幂等键', + `request_hash` char(64) NOT NULL COMMENT '原请求业务内容摘要', + `result_json` json NOT NULL COMMENT '成功命令原始结果用于精确重放', + `created_at` datetime(3) NOT NULL COMMENT '命令完成UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_capacity_offer_command` (`trip_id`,`operator_id`,`action`,`scope_no`,`idempotency_key`), + CONSTRAINT `fk_fleet_capacity_command_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拼车发布及销售意向持久幂等结果'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_driver`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_driver` ( + `id` bigint NOT NULL COMMENT '内部主键', + `driver_no` varchar(32) NOT NULL COMMENT '司机业务编号', + `warehouse_id` bigint DEFAULT NULL COMMENT '所属公共仓库引用', + `warehouse_code` varchar(64) DEFAULT NULL COMMENT '所属仓库编码快照', + `warehouse_name` varchar(256) DEFAULT NULL COMMENT '所属仓库名称快照', + `vehicle_models` varchar(128) DEFAULT NULL COMMENT '可驾驶车型描述', + `languages_and_area` varchar(256) DEFAULT NULL COMMENT '语言及服务区域', + `medical_expires_on` date DEFAULT NULL COMMENT '体检到期日,当前不作为必需资质', + `training_expires_on` date DEFAULT NULL COMMENT '培训安全到期日,当前不作为必需资质', + `insurance_expires_on` date DEFAULT NULL COMMENT '保险到期日,当前不作为必需资质', + `name` varchar(128) NOT NULL COMMENT '姓名', + `phone` varchar(64) NOT NULL COMMENT '联系电话', + `ownership` varchar(16) NOT NULL COMMENT '归属 OWNED自有 DISPATCHED外派', + `region` varchar(16) NOT NULL COMMENT '区域 DOMESTIC国内 OVERSEAS海外', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `review_status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '证照审核 PENDING待审 APPROVED通过 REJECTED打回', + `license_number` varchar(128) DEFAULT NULL COMMENT '驾驶证编号', + `license_type` varchar(64) DEFAULT NULL COMMENT '驾驶证类型', + `license_issued_on` date DEFAULT NULL COMMENT '驾驶证签发日期', + `license_expires_on` date DEFAULT NULL COMMENT '驾驶证有效截止日', + `license_zone` varchar(64) NOT NULL COMMENT '证照有效日期的IANA时区', + `license_file_id` bigint DEFAULT NULL COMMENT '平台驾驶证文件引用', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '并发版本', + `created_by` varchar(64) NOT NULL COMMENT '创建人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间UTC', + `updated_by` varchar(64) NOT NULL COMMENT '修改人', + `updated_at` datetime(3) NOT NULL COMMENT '修改时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_driver_no` (`driver_no`), + KEY `idx_fleet_driver_page` (`active`,`review_status`,`updated_at`,`id`), + KEY `idx_fleet_driver_expiry` (`license_expires_on`), + CONSTRAINT `chk_fleet_driver_dates` CHECK (((`license_issued_on` is null) or (`license_expires_on` is null) or (`license_issued_on` <= `license_expires_on`))), + CONSTRAINT `chk_fleet_driver_ownership` CHECK ((`ownership` in (_utf8mb4'OWNED',_utf8mb4'DISPATCHED'))), + CONSTRAINT `chk_fleet_driver_region` CHECK ((`region` in (_utf8mb4'DOMESTIC',_utf8mb4'OVERSEAS'))), + CONSTRAINT `chk_fleet_driver_review` CHECK ((`review_status` in (_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台司机档案'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_driver_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_driver_history` ( + `id` bigint NOT NULL COMMENT '内部主键', + `driver_id` bigint NOT NULL COMMENT '司机内部引用', + `record_version` bigint NOT NULL COMMENT '操作后的档案版本', + `action` varchar(32) NOT NULL COMMENT '创建编辑启停证照上传及审核动作', + `reason` varchar(1000) DEFAULT NULL COMMENT '操作原因或审核意见', + `license_file_id` bigint DEFAULT NULL COMMENT '当时驾驶证文件引用', + `snapshot_json` json NOT NULL COMMENT '操作后不可变档案快照', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime(3) NOT NULL COMMENT '操作时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_driver_history_version` (`driver_id`,`record_version`), + KEY `idx_fleet_driver_history_file` (`driver_id`,`license_file_id`), + CONSTRAINT `fk_fleet_driver_history_driver` FOREIGN KEY (`driver_id`) REFERENCES `ll_fleet_driver` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='司机档案及审核历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_external_booking`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_external_booking` ( + `id` bigint NOT NULL COMMENT '内部主键', + `booking_no` varchar(32) NOT NULL COMMENT '外接预订业务编号', + `trip_id` bigint NOT NULL COMMENT '所属车次', + `trip_no` varchar(32) NOT NULL COMMENT '车次业务编号', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '委托客户及付款归属', + `customer_code` varchar(64) NOT NULL COMMENT '客户编号快照', + `customer_name` varchar(255) NOT NULL COMMENT '客户名称快照', + `status` varchar(16) NOT NULL COMMENT 'DRAFT询价/CONFIRMED占位/RECEIVED确认实收/BOUND关联发运/CANCELLED取消', + `cargo_json` json NOT NULL COMMENT '货物、计划载荷、联系人及约定报价快照', + `quote_source_json` json DEFAULT NULL COMMENT '选用销售报价的可信版本快照,与实际成交金额分开保存', + `destination_key` varchar(160) NOT NULL COMMENT '已有路线卸货站匹配键', + `destination_label` varchar(255) NOT NULL COMMENT '约定卸货站名称', + `agreed_amount` decimal(16,2) DEFAULT NULL COMMENT '约定金额,非已入账应收或实收', + `idempotency_key` varchar(128) NOT NULL COMMENT '登记请求键', + `request_hash` char(64) NOT NULL COMMENT '登记内容摘要', + `confirm_key` varchar(128) DEFAULT NULL COMMENT '承运确认请求键', + `confirm_hash` char(64) DEFAULT NULL COMMENT '承运确认摘要', + `cancel_key` varchar(128) DEFAULT NULL COMMENT '取消请求键', + `cancel_hash` char(64) DEFAULT NULL COMMENT '取消摘要', + `incoming_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '正式来货单业务编号', + `dispatch_no` varchar(64) DEFAULT NULL COMMENT '已关联客户排车业务编号', + `receipt_json` json DEFAULT NULL COMMENT '实测来源及运营确认的托位和运费依据', + `incoming_key` varchar(128) DEFAULT NULL COMMENT '来货登记请求键', + `incoming_hash` char(64) DEFAULT NULL COMMENT '来货登记摘要', + `receipt_key` varchar(128) DEFAULT NULL COMMENT '实收确认请求键', + `receipt_hash` char(64) DEFAULT NULL COMMENT '实收确认摘要', + `binding_key` varchar(128) DEFAULT NULL COMMENT '关联发运请求键', + `binding_hash` char(64) DEFAULT NULL COMMENT '关联发运摘要', + `offer_id` bigint DEFAULT NULL COMMENT '来源运力发布,普通车次预订为空', + `sales_owner_user_id` varchar(64) DEFAULT NULL COMMENT '登录身份冻结的销售负责人', + `business_type` varchar(32) DEFAULT NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK', + `delivery_mode` varchar(32) DEFAULT NULL COMMENT '客户送仓或人工安排提货回仓', + `estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计实际到我仓UTC时间', + `expected_pallet_count` int DEFAULT NULL COMMENT '预计实托数,与托位分开且未知为空', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '记录版本', + `created_by` varchar(64) NOT NULL COMMENT '登记人', + `created_at` datetime(3) NOT NULL COMMENT '登记UTC时间', + `updated_by` varchar(64) NOT NULL COMMENT '修改人', + `updated_at` datetime(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_booking_no` (`booking_no`), + UNIQUE KEY `uk_fleet_booking_create` (`trip_id`,`created_by`,`idempotency_key`), + UNIQUE KEY `uk_fleet_booking_incoming` (`incoming_no`), + KEY `idx_fleet_booking_offer` (`offer_id`,`status`,`id`), + KEY `idx_fleet_booking_sales` (`sales_owner_user_id`,`offer_id`,`id`), + KEY `idx_fleet_booking_trip` (`trip_id`,`status`,`id`), + KEY `idx_fleet_booking_dispatch` (`dispatch_no`), + CONSTRAINT `fk_fleet_booking_offer` FOREIGN KEY (`offer_id`) REFERENCES `ll_fleet_capacity_offer` (`id`), + CONSTRAINT `fk_fleet_booking_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`), + CONSTRAINT `chk_fleet_booking_expected_pallet` CHECK (((`expected_pallet_count` is null) or (`expected_pallet_count` > 0))), + CONSTRAINT `chk_fleet_booking_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'CONFIRMED',_utf8mb4'RECEIVED',_utf8mb4'BOUND',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='外接询价及确认承运占位;货物收发和费用入账分别引用正式事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_finance_source`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_finance_source` ( + `id` bigint unsigned NOT NULL COMMENT '来源提交主键', + `record_id` bigint unsigned NOT NULL COMMENT '车辆运维记录主键', + `source_version` bigint unsigned NOT NULL COMMENT '独立财务来源版本,从1递增', + `record_version` bigint unsigned NOT NULL COMMENT '提交时运维记录版本', + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提交操作人标识', + `request_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原请求幂等键', + `request_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范请求摘要,含sha256前缀', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '关联公共Outbox事件ID', + `payload_json` json NOT NULL COMMENT '不可变公司成本来源快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_finance_source_version` (`record_id`,`source_version`), + UNIQUE KEY `uk_fleet_finance_source_request` (`record_id`,`operator_id`,`request_key`), + UNIQUE KEY `uk_fleet_finance_source_event` (`event_id`), + CONSTRAINT `chk_fleet_finance_source_version` CHECK ((`source_version` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='维修救援公司成本提交来源,不保存付款推定'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_command` ( + `trip_no` varchar(64) NOT NULL COMMENT '取货车次号', + `request_key` varchar(128) NOT NULL COMMENT '成功命令幂等键', + `request_hash` varchar(64) NOT NULL COMMENT '命令动作与内容摘要', + PRIMARY KEY (`trip_no`,`request_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='取货命令幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_history` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '操作流水', + `customer_id` varchar(64) NOT NULL COMMENT '客户隔离键', + `transfer_no` varchar(64) NOT NULL COMMENT '原转运单号', + `trip_no` varchar(64) NOT NULL COMMENT '当时取货车次号', + `action` varchar(32) NOT NULL COMMENT '安排解除取货到仓动作', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime NOT NULL COMMENT '操作UTC时间', + `detail` varchar(2000) DEFAULT NULL COMMENT '操作说明', + PRIMARY KEY (`id`), + KEY `idx_pickup_history_source` (`customer_id`,`transfer_no`,`id`), + KEY `idx_pickup_history_trip` (`trip_no`,`id`) +) ENGINE=InnoDB AUTO_INCREMENT=20 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='取货不可变操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_plan` ( + `trip_no` varchar(64) NOT NULL COMMENT '现有车辆车次号', + `request_hash` varchar(64) NOT NULL COMMENT '创建来源与计划摘要', + PRIMARY KEY (`trip_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运取货计划'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_task` ( + `transfer_order_id` bigint NOT NULL COMMENT '原仓储转运单ID,一票唯一', + `customer_id` varchar(64) NOT NULL COMMENT '客户隔离键', + `transfer_no` varchar(64) NOT NULL COMMENT '原仓储转运单号', + `trip_no` varchar(64) DEFAULT NULL COMMENT '当前取货车次,解除后为空', + `status` varchar(32) NOT NULL COMMENT 'WAITING待安排 ASSIGNED已安排 PICKED已取货 ARRIVED已送仓', + `actual_packages` int DEFAULT NULL COMMENT '司机实提箱数,非仓库实收', + `picked_at` datetime DEFAULT NULL COMMENT '实际确认取货UTC时间', + `arrived_at` datetime DEFAULT NULL COMMENT '实际确认送仓UTC时间', + `evidence_json` longtext COMMENT '本票已确认凭证元数据', + `remark` varchar(1000) DEFAULT NULL COMMENT '取货备注', + `updated_at` datetime NOT NULL COMMENT '最近修改UTC时间', + PRIMARY KEY (`transfer_order_id`), + UNIQUE KEY `uk_pickup_source` (`customer_id`,`transfer_no`), + KEY `idx_pickup_trip` (`trip_no`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='仓储转运逐票取货执行'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pickup_upload`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pickup_upload` ( + `transfer_order_id` bigint NOT NULL COMMENT '凭证所属原单', + `trip_no` varchar(64) NOT NULL COMMENT '凭证所属车次', + `file_id` bigint NOT NULL COMMENT '平台归属文件ID', + PRIMARY KEY (`file_id`), + KEY `idx_pickup_upload_source` (`transfer_order_id`,`trip_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='取货上传凭证精确单据归属'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_pool_order_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_pool_order_link` ( + `id` bigint NOT NULL COMMENT '内部主键', + `booking_id` bigint NOT NULL COMMENT '唯一外接预订', + `booking_no` varchar(64) NOT NULL COMMENT '预订业务编号', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商业客户隔离范围', + `business_type` varchar(32) NOT NULL COMMENT '本土散板或本土卡车商业类型', + `business_order_id` bigint NOT NULL COMMENT '对应商业订单内部关联', + `business_order_no` varchar(64) NOT NULL COMMENT '本土商业订单业务编号', + `warehouse_id` varchar(128) NOT NULL COMMENT '真实我仓上车仓库', + `destination_key` varchar(160) NOT NULL COMMENT '冻结卸货站匹配键', + `delivery_mode` varchar(32) NOT NULL COMMENT '客户送仓或人工提货回仓', + `delivery_instructions` varchar(1000) DEFAULT NULL COMMENT '实际到仓交货说明', + `incoming_no` varchar(64) DEFAULT NULL COMMENT '真实来货业务编号,未登记为空', + `lastleg_order_id` bigint DEFAULT NULL COMMENT '唯一真实尾程来源,未发生为空', + `dispatch_no` varchar(64) DEFAULT NULL COMMENT '已关联客户排车编号', + `fulfillment_stage` varchar(32) NOT NULL COMMENT '实际履约阶段,不能手工冒充完成', + `stage_occurred_at` datetime(3) NOT NULL COMMENT '实际阶段发生UTC时间', + `actual_pallet_count` int DEFAULT NULL COMMENT '实际去重托盘数,未知为空', + `actual_package_count` int DEFAULT NULL COMMENT '实际箱数,未知为空', + `actual_pallet_spaces` int DEFAULT NULL COMMENT '实际核准占用托位,未知为空', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '桥记录版本', + `creation_request_hash` char(64) NOT NULL COMMENT '商业单创建身份和预报事实摘要', + `created_by` varchar(64) NOT NULL COMMENT '确认承运操作者', + `created_at` datetime(3) NOT NULL COMMENT '创建UTC时间', + `updated_at` datetime(3) NOT NULL COMMENT '修改UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_pool_order_booking` (`booking_id`), + UNIQUE KEY `uk_fleet_pool_order_booking_no` (`booking_no`), + UNIQUE KEY `uk_fleet_pool_order_business` (`business_type`,`business_order_id`), + UNIQUE KEY `uk_fleet_pool_order_incoming` (`incoming_no`), + UNIQUE KEY `uk_fleet_pool_order_lastleg` (`lastleg_order_id`), + KEY `idx_fleet_pool_order_customer` (`customer_id`,`business_type`,`business_order_no`), + KEY `idx_fleet_pool_order_dispatch` (`customer_id`,`dispatch_no`), + CONSTRAINT `fk_fleet_pool_order_booking` FOREIGN KEY (`booking_id`) REFERENCES `ll_fleet_external_booking` (`id`), + CONSTRAINT `chk_fleet_pool_order_actual` CHECK ((((`actual_pallet_count` is null) or (`actual_pallet_count` >= 0)) and ((`actual_package_count` is null) or (`actual_package_count` >= 0)) and ((`actual_pallet_spaces` is null) or (`actual_pallet_spaces` >= 0)))), + CONSTRAINT `chk_fleet_pool_order_type` CHECK ((`business_type` in (_utf8mb4'LOCAL_LOOSEBOARD',_utf8mb4'US_TRUCK'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拼车预订与本土商业单唯一真实履约关联,不重复记运费'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_quote`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_quote` ( + `id` bigint NOT NULL COMMENT '报价记录主键', + `quote_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价业务编号', + `kind` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价用途采购或销售', + `party_id` bigint NOT NULL COMMENT '供应商或客户主档标识', + `party_name` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '往来单位名称快照', + `origin_city` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '发货城市', + `destination_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的仓业务编码', + `valid_from` date NOT NULL COMMENT '报价生效日期', + `valid_to` date NOT NULL COMMENT '报价失效日期含当天', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价启停或删除状态', + `natural_key` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价组合去重摘要', + `details_json` json NOT NULL COMMENT '完整报价明细快照', + `record_version` bigint NOT NULL COMMENT '报价记录版本', + `create_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + `create_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求含导入批次内容摘要', + `created_by` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建操作人标识', + `created_at` datetime(3) NOT NULL COMMENT '创建时间UTC', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_quote_no` (`quote_no`), + UNIQUE KEY `uk_fleet_quote_natural` (`natural_key`), + UNIQUE KEY `uk_fleet_quote_create` (`created_by`,`create_key`), + KEY `idx_fleet_quote_search` (`kind`,`status`,`valid_from`,`valid_to`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆采购与外接销售报价'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_quote_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_quote_history` ( + `id` bigint NOT NULL COMMENT '报价历史主键', + `quote_id` bigint NOT NULL COMMENT '报价记录标识', + `record_version` bigint NOT NULL COMMENT '报价记录版本', + `action` varchar(24) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价操作类型', + `operator_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人标识', + `occurred_at` datetime(3) NOT NULL COMMENT '操作时间UTC', + `snapshot_json` json NOT NULL COMMENT '报价完整版本快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_quote_history_version` (`quote_id`,`record_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='车辆报价不可变版本历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_resource_assignment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_resource_assignment` ( + `resource_kind` varchar(16) NOT NULL COMMENT '资源种类 VEHICLE或DRIVER', + `resource_id` bigint NOT NULL COMMENT '对应车辆或司机档案引用', + `trip_id` bigint NOT NULL COMMENT '占用车次', + `trip_no` varchar(32) NOT NULL COMMENT '占用车次业务编号', + PRIMARY KEY (`resource_kind`,`resource_id`), + KEY `idx_fleet_resource_trip` (`trip_id`), + CONSTRAINT `fk_fleet_resource_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`), + CONSTRAINT `chk_fleet_resource_kind` CHECK ((`resource_kind` in (_utf8mb4'VEHICLE',_utf8mb4'DRIVER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车辆及司机当前车次占用,释放后删除锁行,历史保留在车次'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_no` varchar(32) NOT NULL COMMENT '车次业务编号', + `idempotency_key` varchar(128) NOT NULL COMMENT '创建请求幂等键', + `request_hash` varchar(64) NOT NULL COMMENT '创建请求SHA256', + `status` varchar(16) NOT NULL COMMENT '车次状态', + `warehouse_id` bigint NOT NULL COMMENT '起运公共仓库引用', + `warehouse_code` varchar(64) NOT NULL COMMENT '起运仓编码快照', + `warehouse_name` varchar(256) NOT NULL COMMENT '起运仓名称快照', + `vehicle_id` bigint DEFAULT NULL COMMENT '车辆档案引用', + `vehicle_plate` varchar(64) DEFAULT NULL COMMENT '分配时车牌快照', + `driver_id` bigint DEFAULT NULL COMMENT '司机档案引用', + `driver_name` varchar(128) DEFAULT NULL COMMENT '分配时司机姓名', + `driver_phone` varchar(64) DEFAULT NULL COMMENT '分配时司机电话', + `max_pallet_spaces` int DEFAULT NULL COMMENT '分配时托位容量快照', + `max_load_kg` decimal(12,3) DEFAULT NULL COMMENT '分配时载重千克', + `max_volume_cbm` decimal(12,3) DEFAULT NULL COMMENT '分配时可用容积立方米', + `planned_departure_at` datetime(3) NOT NULL COMMENT '计划发车UTC', + `expected_driver_end_at` datetime(3) NOT NULL COMMENT '司机预计任务结束UTC,含承担的返程转场', + `departed_at` datetime(3) DEFAULT NULL COMMENT '实际发车UTC', + `completed_at` datetime(3) DEFAULT NULL COMMENT '实际运输完成UTC', + `stops_json` json NOT NULL COMMENT '按顺序保存的路线站点', + `planned_load_json` json DEFAULT NULL COMMENT '拣货前自有货计划载荷与逐段剩余容量', + `load_json` json DEFAULT NULL COMMENT '已确认实物装载及客户货物来源快照', + `loading_review_json` longtext COMMENT '整车理货审核快照,空值为待审核', + `departure_key` varchar(128) DEFAULT NULL COMMENT '整车发车幂等标识', + `departure_hash` char(64) DEFAULT NULL COMMENT '整车发车请求摘要', + `departure_result_json` json DEFAULT NULL COMMENT '整车发车结果及各客户出库结果', + `completion_json` json DEFAULT NULL COMMENT '货物结案来源及幂等结果快照', + `vehicle_release_json` json DEFAULT NULL COMMENT '车辆任务结束地点状态与幂等记录', + `driver_release_json` json DEFAULT NULL COMMENT '司机任务结束地点与幂等记录', + `remark` varchar(1000) DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '并发版本', + `created_by` varchar(64) NOT NULL COMMENT '创建人', + `created_at` datetime(3) NOT NULL COMMENT '创建UTC时间', + `updated_by` varchar(64) NOT NULL COMMENT '更新人', + `updated_at` datetime(3) NOT NULL COMMENT '更新UTC时间', + `trip_purpose` varchar(16) NOT NULL DEFAULT 'DELIVERY' COMMENT 'DELIVERY出库派送 PICKUP上门取货入仓', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_trip_no` (`trip_no`), + UNIQUE KEY `uk_fleet_trip_create` (`created_by`,`idempotency_key`), + KEY `idx_fleet_trip_page` (`warehouse_id`,`status`,`updated_at`,`id`), + KEY `fk_fleet_trip_vehicle` (`vehicle_id`), + KEY `fk_fleet_trip_driver` (`driver_id`), + CONSTRAINT `fk_fleet_trip_driver` FOREIGN KEY (`driver_id`) REFERENCES `ll_fleet_driver` (`id`), + CONSTRAINT `fk_fleet_trip_vehicle` FOREIGN KEY (`vehicle_id`) REFERENCES `ll_vehicle` (`id`), + CONSTRAINT `chk_fleet_trip_status` CHECK ((`status` in (_utf8mb4'PLANNED',_utf8mb4'DEPARTING',_utf8mb4'IN_TRANSIT',_utf8mb4'COMPLETED',_utf8mb4'CANCELLED'))), + CONSTRAINT `chk_fleet_trip_time` CHECK ((`expected_driver_end_at` > `planned_departure_at`)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台运输车次'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_cost`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_cost` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_id` bigint NOT NULL COMMENT '车次内部引用', + `cost_no` varchar(32) NOT NULL COMMENT '车次成本业务编号', + `category` varchar(16) NOT NULL COMMENT '直接成本类别FUEL/DRIVER/TOLL/PARKING/OTHER', + `currency` varchar(3) NOT NULL COMMENT '原始成本币种CNY/USD,不混币汇总', + `amount` decimal(14,2) NOT NULL COMMENT '运营登记金额,不代表付款', + `incurred_at` datetime(3) NOT NULL COMMENT '实际费用发生UTC时间', + `remark` varchar(1000) DEFAULT NULL COMMENT '费用备注', + `record_version` bigint NOT NULL COMMENT '成本记录版本', + `request_key` varchar(128) NOT NULL COMMENT '创建幂等键', + `request_hash` char(64) NOT NULL COMMENT '创建请求摘要', + `created_by` varchar(64) NOT NULL COMMENT '登记人', + `updated_by` varchar(64) NOT NULL COMMENT '最近更正人', + `created_at` datetime(3) NOT NULL COMMENT '登记UTC时间', + `updated_at` datetime(3) NOT NULL COMMENT '更正UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_trip_cost_no` (`cost_no`), + UNIQUE KEY `uk_fleet_trip_cost_request` (`trip_id`,`created_by`,`request_key`), + KEY `idx_fleet_trip_cost_page` (`trip_id`,`incurred_at`,`id`), + CONSTRAINT `fk_fleet_trip_cost_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`), + CONSTRAINT `ck_fleet_trip_cost_amount` CHECK ((`amount` >= 0)), + CONSTRAINT `ck_fleet_trip_cost_category` CHECK ((`category` in (_utf8mb4'FUEL',_utf8mb4'DRIVER',_utf8mb4'TOLL',_utf8mb4'PARKING',_utf8mb4'OTHER'))), + CONSTRAINT `ck_fleet_trip_cost_currency` CHECK ((`currency` in (_utf8mb4'CNY',_utf8mb4'USD'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车次直接运营成本,不生成客户账单或付款'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_cost_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_cost_history` ( + `id` bigint NOT NULL COMMENT '内部主键', + `cost_id` bigint NOT NULL COMMENT '成本记录内部引用', + `record_version` bigint NOT NULL COMMENT '成本更正后版本', + `action` varchar(16) NOT NULL COMMENT '成本操作CREATE/UPDATE', + `reason` varchar(1000) DEFAULT NULL COMMENT '登记或更正说明', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime(3) NOT NULL COMMENT '操作UTC时间', + `snapshot_json` json NOT NULL COMMENT '成本与当时整趟汇总及分摊快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_trip_cost_history` (`cost_id`,`record_version`), + CONSTRAINT `fk_fleet_trip_cost_history` FOREIGN KEY (`cost_id`) REFERENCES `ll_fleet_trip_cost` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车次成本不可变更正历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_dispatch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_dispatch` ( + `dispatch_id` bigint NOT NULL COMMENT '客户排车单内部引用,唯一有效车次关联', + `dispatch_no` varchar(64) NOT NULL COMMENT '客户排车业务单号', + `customer_id` varchar(128) NOT NULL COMMENT '排车单自身客户归属', + `trip_id` bigint NOT NULL COMMENT '共享运输车次内部引用', + `trip_no` varchar(32) NOT NULL COMMENT '共享运输车次业务编号', + `package_count` int NOT NULL COMMENT '关联时待发运箱数快照', + `bound_by` varchar(64) NOT NULL COMMENT '关联操作人', + `bound_at` datetime(3) NOT NULL COMMENT '关联UTC时间', + `review_json` longtext COMMENT '客户排车调度审核快照,空值为待审核', + PRIMARY KEY (`dispatch_id`), + UNIQUE KEY `uk_fleet_trip_dispatch_no` (`dispatch_no`), + KEY `idx_fleet_trip_dispatch_trip` (`trip_id`,`dispatch_id`), + CONSTRAINT `fk_fleet_trip_dispatch_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车次与各客户排车单的有效关联,解除前快照保留在车次历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_handover`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_handover` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_id` bigint NOT NULL COMMENT '所属车次', + `trip_version` bigint NOT NULL COMMENT '交接对应车次历史版本', + `trip_no` varchar(32) NOT NULL COMMENT '车次业务编号', + `handover_no` varchar(32) NOT NULL COMMENT '交接业务编号', + `request_key` varchar(128) NOT NULL COMMENT '交接请求幂等键', + `request_hash` char(64) NOT NULL COMMENT '交接请求摘要', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '资源结束并发版本', + `handed_over_at` datetime(3) NOT NULL COMMENT '实际交接UTC时间', + `snapshot_json` json NOT NULL COMMENT '不可变资源及剩余货物交接快照', + `vehicle_release_json` json DEFAULT NULL COMMENT '原车辆任务结束及幂等结果', + `driver_release_json` json DEFAULT NULL COMMENT '原司机任务结束及幂等结果', + `created_by` varchar(64) NOT NULL COMMENT '记录操作人', + `created_at` datetime(3) NOT NULL COMMENT '记录UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_handover_no` (`handover_no`), + UNIQUE KEY `uk_fleet_handover_request` (`trip_id`,`request_key`), + UNIQUE KEY `uk_fleet_handover_version` (`trip_id`,`trip_version`), + KEY `idx_fleet_handover_page` (`trip_id`,`id`), + CONSTRAINT `fk_fleet_handover_trip` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='在途车辆司机交接'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_history` ( + `id` bigint NOT NULL COMMENT '内部主键', + `trip_id` bigint NOT NULL COMMENT '车次内部引用', + `record_version` bigint NOT NULL COMMENT '操作后版本', + `action` varchar(32) NOT NULL COMMENT '车次操作', + `reason` varchar(1000) DEFAULT NULL COMMENT '操作原因', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime(3) NOT NULL COMMENT '操作UTC时间', + `snapshot_json` json NOT NULL COMMENT '操作后不可变车次快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_trip_history_version` (`trip_id`,`record_version`), + CONSTRAINT `fk_fleet_trip_history` FOREIGN KEY (`trip_id`) REFERENCES `ll_fleet_trip` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='运输车次操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_trip_operations_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_trip_operations_record` ( + `id` bigint NOT NULL COMMENT '出车实测历史主键', + `trip_no` varchar(64) NOT NULL COMMENT '车次业务编号', + `record_version` bigint NOT NULL COMMENT '实测记录版本从一递增', + `details_json` json NOT NULL COMMENT '实际里程英里油耗升与备注快照', + `reason` varchar(1000) NOT NULL COMMENT '实测更正说明', + `operator_id` varchar(64) NOT NULL COMMENT '实测操作人标识', + `request_key` varchar(128) NOT NULL COMMENT '实测保存幂等键', + `request_hash` char(64) NOT NULL COMMENT '实测请求内容摘要', + `occurred_at` datetime(3) NOT NULL COMMENT '实测保存时间UTC', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fleet_operations_version` (`trip_no`,`record_version`), + UNIQUE KEY `uk_fleet_operations_request` (`trip_no`,`operator_id`,`request_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车次里程油耗不可变更正记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_vehicle_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_vehicle_record` ( + `id` bigint NOT NULL COMMENT '记录内部主键', + `record_no` varchar(32) NOT NULL COMMENT '车辆运维业务编号', + `vehicle_id` bigint NOT NULL COMMENT '所属车辆主键', + `vehicle_plate` varchar(64) NOT NULL COMMENT '建档时车牌快照', + `record_type` varchar(32) NOT NULL COMMENT '运维类型:保养、加油、维修、故障、救援、异常', + `occurred_at` datetime(3) NOT NULL COMMENT '记录发生时间(UTC)', + `details_json` json NOT NULL COMMENT '带明确单位的业务字段快照', + `files_json` json NOT NULL COMMENT '所属记录照片与上传幂等摘要', + `idempotency_key` varchar(128) NOT NULL COMMENT '创建请求幂等键', + `request_hash` char(64) NOT NULL COMMENT '原创建业务内容摘要', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(64) NOT NULL COMMENT '创建操作人', + `updated_by` varchar(64) NOT NULL COMMENT '最后操作人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间(UTC)', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间(UTC)', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vehicle_record_no` (`record_no`), + UNIQUE KEY `uk_vehicle_record_creation` (`vehicle_id`,`created_by`,`idempotency_key`), + KEY `idx_vehicle_record_page` (`vehicle_id`,`record_type`,`occurred_at`,`id`), + CONSTRAINT `fk_vehicle_record_vehicle` FOREIGN KEY (`vehicle_id`) REFERENCES `ll_vehicle` (`id`), + CONSTRAINT `ck_vehicle_record_type` CHECK ((`record_type` in (_utf8mb4'MAINTENANCE',_utf8mb4'FUEL',_utf8mb4'REPAIR',_utf8mb4'BREAKDOWN',_utf8mb4'RESCUE',_utf8mb4'EXCEPTION'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车辆运维记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_fleet_vehicle_record_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_fleet_vehicle_record_history` ( + `id` bigint NOT NULL COMMENT '历史内部主键', + `record_id` bigint NOT NULL COMMENT '运维记录主键', + `record_version` bigint NOT NULL COMMENT '对应业务版本', + `action` varchar(32) NOT NULL COMMENT '操作类型', + `reason` varchar(1000) DEFAULT NULL COMMENT '更正原因或附件名称', + `operator_id` varchar(64) NOT NULL COMMENT '操作人', + `occurred_at` datetime(3) NOT NULL COMMENT '操作时间(UTC)', + `snapshot_json` json NOT NULL COMMENT '当时完整业务快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_vehicle_record_revision` (`record_id`,`record_version`), + CONSTRAINT `fk_vehicle_record_history` FOREIGN KEY (`record_id`) REFERENCES `ll_fleet_vehicle_record` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='车辆运维记录不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_fba_appointment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_fba_appointment` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `appointment_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '预约单号', + `appointment_at` datetime(3) NOT NULL COMMENT '预约时间', + `appointment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `appointment_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `selected_count` int NOT NULL DEFAULT '0', + `replacement_nos_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '分批或合并后的新预约业务号列表,非空时原批次只读', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `expected_loading_at` datetime(3) NOT NULL, + `appointment_end_at` datetime(3) DEFAULT NULL COMMENT '预约结束时间,未明确时为空', + `version` int NOT NULL DEFAULT '0' COMMENT '并发版本', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_fba_appointment_no` (`customer_id`,`appointment_no`), + KEY `idx_ll_fba_appointment_list` (`customer_id`,`appointment_status`,`fba_warehouse_code`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Batch FBA appointment'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_fba_appointment_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_fba_appointment_item` ( + `id` bigint NOT NULL COMMENT '记录主键', + `appointment_id` bigint NOT NULL COMMENT '预约主记录标识', + `source_key` varchar(160) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定ORDER或BOOKING货源键', + `source_version` varchar(160) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '保存时核实的来源版本', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本票所属客户', + `order_id` bigint DEFAULT NULL COMMENT '尚未形成尾程订单时为空', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实入仓码,不伪造', + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的FBA仓编码', + `package_count` int DEFAULT NULL COMMENT '所选整票预报箱数,未知保持空', + `active` tinyint(1) NOT NULL DEFAULT '1' COMMENT '取消或失败后释放货源占用', + `active_source_key` varchar(160) COLLATE utf8mb4_unicode_ci GENERATED ALWAYS AS ((case when (`active` = 1) then `source_key` else NULL end)) STORED COMMENT '有效货源唯一占用键', + `active_order_id` bigint GENERATED ALWAYS AS ((case when (`active` = 1) then `order_id` else NULL end)) STORED COMMENT '有效订单唯一占用键', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建UTC时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_fba_item_source` (`appointment_id`,`source_key`), + UNIQUE KEY `uk_ll_fba_active_source` (`active_source_key`), + UNIQUE KEY `uk_ll_fba_active_order` (`active_order_id`), + KEY `idx_ll_fba_item_source_lookup` (`source_key`,`customer_id`), + KEY `idx_ll_fba_item_customer_order` (`customer_id`,`order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='入仓前后共用的预约真实货源'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_order` ( + `id` bigint NOT NULL COMMENT '入仓单主键', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` bigint NOT NULL COMMENT '租户 ID', + `source_region` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域', + `source_inbound_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓分片 ID', + `root_inbound_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根入仓单 ID', + `source_order_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单 ID', + `source_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单号', + `root_order_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根订单 ID', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓码', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓 ID', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + `package_count` int NOT NULL DEFAULT '0' COMMENT '箱数', + `product_quantity` int DEFAULT NULL COMMENT '数量', + `pallet_count` int DEFAULT NULL COMMENT '托数', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '毛重(千克)', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '净重(千克)', + `volume_cbm` decimal(18,6) DEFAULT NULL COMMENT '体积(立方米)', + `source_event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源事件 ID', + `source_loading_plan_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '装柜计划 ID', + `source_loading_plan_version` bigint NOT NULL COMMENT '装柜计划版本', + `handover_version` bigint NOT NULL COMMENT '交接版本', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '负载摘要', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓单状态', + `exception_flag` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'NONE' COMMENT '异常标记', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `lx_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发领星入库单代码', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_source` (`customer_id`,`source_region`,`source_inbound_id`), + UNIQUE KEY `uk_ll_order_event` (`source_event_id`,`source_inbound_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入仓单快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_order_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_order_item` ( + `id` bigint NOT NULL COMMENT '入仓明细主键', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` bigint NOT NULL COMMENT '租户ID', + `order_id` bigint NOT NULL COMMENT '入仓单ID', + `source_inbound_detail_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓明细ID', + `package_count` int NOT NULL COMMENT '箱数', + `product_quantity` int DEFAULT NULL COMMENT '数量', + `pallet_count` int DEFAULT NULL COMMENT '托数', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '毛重(千克)', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '净重(千克)', + `dimensional_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '体积重量(千克)', + `volume_cbm` decimal(18,6) DEFAULT NULL COMMENT '体积(立方米)', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + `fba_tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA跟踪号', + `shipment_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号', + `shipping_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '唛头', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码', + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `product_name_en` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_item_order` (`customer_id`,`order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入仓商品明细快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_pallet_merge`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_pallet_merge` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint NOT NULL, + `merge_group_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_pallet_id` bigint NOT NULL, + `target_pallet_id` bigint NOT NULL, + `source_allocation_id` bigint NOT NULL, + `target_allocation_id` bigint NOT NULL, + `source_line_id` bigint NOT NULL, + `target_line_id` bigint NOT NULL, + `source_order_id` bigint NOT NULL, + `target_order_id` bigint NOT NULL, + `moved_package_count` int NOT NULL, + `moved_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `source_pallet_scrapped` tinyint NOT NULL DEFAULT '0', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_ll_inbound_merge_task` (`customer_id`,`task_id`), + KEY `idx_ll_inbound_merge_group` (`merge_group_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_pda_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_pda_operation` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint NOT NULL, + `allocation_id` bigint DEFAULT NULL, + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_pda_operation_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_inbound_pda_operation_task` (`task_id`), + KEY `idx_ll_inbound_pda_operation_allocation` (`allocation_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_pda_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_pda_task` ( + `id` bigint NOT NULL, + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `plan_id` bigint NOT NULL, + `task_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pushed_at` datetime(3) DEFAULT NULL, + `started_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `unload_photo_file_ids` json DEFAULT NULL COMMENT '卸柜照片文件ID列表', + `unload_photo_uploaded_at` datetime(3) DEFAULT NULL COMMENT '卸柜照片上传成功时间', + `estimated_empty_return_at` datetime(3) DEFAULT NULL COMMENT '预计报空时间(卸柜照片上传成功后两天)', + `version` int NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_pda_task_plan` (`customer_id`,`plan_id`), + UNIQUE KEY `uk_ll_inbound_pda_task_no` (`task_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan` ( + `id` bigint NOT NULL COMMENT '入库计划ID(雪花ID)', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `plan_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入库单号', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + `source_order_id` bigint NOT NULL COMMENT '来源尾程订单ID,一张入仓单对应一张入库计划', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + `overseas_warehouse_id` bigint DEFAULT NULL COMMENT '海外实际收货共享物理仓ID,由PDA认领柜号时冻结', + `planned_arrival_date` date DEFAULT NULL COMMENT '人工计划预计到仓日期', + `pallet_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '人工计划托盘类型', + `assigned_flag` tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否已分配:0否1是', + `merge_required` tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否要求合托:0否1是', + `urgent_flag` tinyint NOT NULL DEFAULT '0' COMMENT '人工计划是否加急:0否1是', + `creation_mode` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'AUTO' COMMENT '计划生成方式:AUTO柜级自动、MANUAL单票人工、WAREHOUSE_TRANSFER仓储转运', + `plan_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓计划备注', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '入库计划状态:PENDING_ASSIGNMENT待分配及后续正式状态', + `line_count` int NOT NULL DEFAULT '0' COMMENT '入仓码明细数量', + `pallet_count` int NOT NULL DEFAULT '0' COMMENT '系统自动分配托盘数量', + `total_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '集装箱总体积(立方米)', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `version` int NOT NULL DEFAULT '0' COMMENT '入库计划乐观锁版本', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_plan_no` (`plan_no`), + KEY `idx_ll_inbound_plan_container` (`customer_id`,`container_no`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划表:每张来源入仓单生成一张入库计划,同柜计划共享集装箱号'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan_carton` ( + `id` bigint NOT NULL COMMENT '箱唛作业主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `order_id` bigint DEFAULT NULL COMMENT '海外首扫确权后的尾程订单ID', + `source_inbound_detail_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入库明细ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码快照', + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内冻结箱唛', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + `plan_id` bigint DEFAULT NULL COMMENT '海外入库计划ID', + `line_id` bigint DEFAULT NULL COMMENT '入库计划明细ID', + `plan_pallet_id` bigint DEFAULT NULL COMMENT '海外计划托盘ID', + `warehouse_pallet_id` bigint DEFAULT NULL COMMENT '海外实物托盘ID', + `warehouse_location_id` bigint DEFAULT NULL COMMENT '海外库位ID', + `pda_task_id` bigint DEFAULT NULL COMMENT '首次扫码PDA任务ID', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '箱唛状态:UNASSIGNED、ON_PALLET、AVAILABLE', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `bound_at` datetime(3) DEFAULT NULL COMMENT '绑定托盘时间', + `received_at` datetime(3) DEFAULT NULL COMMENT '完成入库时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_carton_mark` (`customer_id`,`carton_mark`), + KEY `idx_ll_inbound_carton_plan` (`plan_id`,`status`), + KEY `idx_ll_inbound_carton_pallet` (`plan_pallet_id`,`status`), + KEY `idx_ll_inbound_carton_order` (`order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜次冻结箱唛及海外入库箱级作业账'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan_line`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan_line` ( + `id` bigint NOT NULL COMMENT '入库计划明细ID(雪花ID)', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `plan_id` bigint NOT NULL COMMENT '入库计划ID', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '头程入仓码快照', + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'OVERSEAS' COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA仓库编码', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + `volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '入仓码体积(立方米)', + `planned_pallet_count` int NOT NULL DEFAULT '0' COMMENT '该明细分配的托盘数量', + `required_package_count` int NOT NULL DEFAULT '0', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + `postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_line_order` (`plan_id`,`order_id`), + KEY `idx_ll_inbound_line_plan` (`plan_id`), + KEY `idx_ll_inbound_line_tenant_plan` (`customer_id`,`plan_id`), + KEY `idx_ll_inbound_line_customer_plan` (`customer_id`,`plan_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划明细表:按入仓码和尾程订单维度记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan_pallet` ( + `id` bigint NOT NULL COMMENT '计划托盘ID(雪花ID)', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `plan_id` bigint NOT NULL COMMENT '入库计划ID', + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓资源服务返回的真实托盘码', + `source_pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发不可变来源托盘号快照', + `pallet_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '人工计划托盘类型', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + `capacity_cbm` decimal(18,4) NOT NULL DEFAULT '2.0000' COMMENT '托盘容量,固定为2立方米', + `allocated_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '已分配体积(立方米)', + `sequence_no` int NOT NULL COMMENT '计划内托盘序号', + `warehouse_pallet_id` bigint DEFAULT NULL, + `warehouse_location_id` bigint DEFAULT NULL, + `warehouse_resource_lock_id` bigint DEFAULT NULL, + `assignment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UNASSIGNED', + `locked_at` datetime(3) DEFAULT NULL, + `actual_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `actual_package_count` int NOT NULL DEFAULT '0', + `version` int NOT NULL DEFAULT '0' COMMENT '收货累计值乐观锁版本', + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓目的地址快照', + `postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编快照', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人快照', + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_pallet_sequence` (`plan_id`,`sequence_no`), + UNIQUE KEY `uk_ll_inbound_pallet_no` (`customer_id`,`pallet_no`), + KEY `idx_ll_inbound_pallet_plan` (`plan_id`), + KEY `idx_ll_inbound_pallet_tenant_plan` (`customer_id`,`plan_id`), + KEY `idx_ll_inbound_pallet_customer_plan` (`customer_id`,`plan_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划托盘表:按目的仓隔离合托'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_plan_pallet_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_plan_pallet_allocation` ( + `id` bigint NOT NULL COMMENT '托盘分配记录ID(雪花ID)', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `plan_id` bigint NOT NULL COMMENT '入库计划ID', + `line_id` bigint NOT NULL COMMENT '入库计划明细ID', + `pallet_id` bigint NOT NULL COMMENT '计划托盘ID', + `allocated_volume_cbm` decimal(18,4) NOT NULL COMMENT '该明细分配到托盘的体积(立方米)', + `planned_package_count` int NOT NULL DEFAULT '0', + `counted_package_count` int NOT NULL DEFAULT '0', + `picked_package_count` int NOT NULL DEFAULT '0', + `actual_volume_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_COUNT', + `version` int NOT NULL DEFAULT '0', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inbound_allocation` (`line_id`,`pallet_id`), + KEY `idx_ll_inbound_allocation_plan` (`plan_id`), + KEY `idx_ll_inbound_allocation_pallet` (`pallet_id`), + KEY `idx_ll_inbound_allocation_tenant_plan` (`customer_id`,`plan_id`), + KEY `idx_ll_inbound_allocation_customer_plan` (`customer_id`,`plan_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程入库计划托盘分配表:记录入仓码明细与计划托盘的分配关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inbound_stock_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inbound_stock_receipt` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `plan_id` bigint NOT NULL, + `task_id` bigint NOT NULL, + `allocation_id` bigint NOT NULL, + `line_id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + `product_name_en` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名快照', + `sku_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_detail_ids` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `warehouse_pallet_id` bigint NOT NULL, + `pallet_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发不可变来源托盘号快照', + `warehouse_location_id` bigint DEFAULT NULL, + `warehouse_location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `batch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `received_packages` bigint NOT NULL, + `received_quantity` bigint DEFAULT '0', + `inventory_balance_id` bigint NOT NULL, + `inventory_position_id` bigint NOT NULL, + `inventory_operation_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `received_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_stock_receipt_task_allocation_sku` (`customer_id`,`task_id`,`allocation_id`,`sku_code`), + KEY `idx_ll_stock_receipt_plan` (`customer_id`,`plan_id`,`line_id`), + KEY `idx_ll_stock_receipt_inventory` (`inventory_balance_id`,`inventory_position_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable SKU-pallet PDA stock-in receipt snapshot'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inventory_balance`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inventory_balance` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Customer tenant', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Customer name snapshot', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Actual overseas warehouse ID', + `overseas_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Actual overseas warehouse code snapshot', + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Actual overseas warehouse name snapshot', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Customer destination warehouse ID snapshot', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '' COMMENT 'Customer destination warehouse code snapshot', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Customer destination warehouse name snapshot', + `inbound_order_id` bigint NOT NULL, + `inbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `sku_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Chinese product name', + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + `product_name_en` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名快照', + `available_packages` bigint NOT NULL DEFAULT '0', + `hold_packages` bigint NOT NULL DEFAULT '0', + `allocated_packages` bigint NOT NULL DEFAULT '0', + `picked_packages` bigint NOT NULL DEFAULT '0', + `in_transit_packages` bigint NOT NULL DEFAULT '0', + `receiving_packages` bigint NOT NULL DEFAULT '0', + `procurement_packages` bigint NOT NULL DEFAULT '0', + `available_quantity` bigint NOT NULL DEFAULT '0', + `hold_quantity` bigint NOT NULL DEFAULT '0', + `allocated_quantity` bigint NOT NULL DEFAULT '0', + `picked_quantity` bigint NOT NULL DEFAULT '0', + `in_transit_quantity` bigint NOT NULL DEFAULT '0', + `receiving_quantity` bigint NOT NULL DEFAULT '0', + `procurement_quantity` bigint NOT NULL DEFAULT '0', + `version` bigint NOT NULL DEFAULT '0' COMMENT 'Optimistic lock version', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inventory_dimension` (`customer_id`,`overseas_warehouse_id`,`destination_warehouse_code`,`inbound_order_id`,`inbound_code`), + KEY `idx_ll_inventory_list` (`customer_id`,`overseas_warehouse_id`,`updated_at`), + KEY `idx_ll_inventory_inbound` (`customer_id`,`inbound_code`), + KEY `idx_ll_inventory_sku` (`customer_id`,`sku_code`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户入仓单与海外目的仓维度实时库存余额'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inventory_ledger`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inventory_ledger` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '瀹㈡埛鍚嶇О涓嶅彲鍙樺揩鐓�', + `balance_id` bigint NOT NULL, + `position_id` bigint DEFAULT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `inbound_order_id` bigint NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `carton_packages` bigint DEFAULT NULL, + `pallet_id` bigint DEFAULT NULL, + `pallet_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `batch_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `warehouse_location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `business_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `business_order_id` bigint DEFAULT NULL, + `business_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `outbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_event_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `status_from` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status_to` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `before_packages` bigint NOT NULL, + `delta_packages` bigint NOT NULL, + `after_packages` bigint NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `occurred_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inventory_ledger_idempotency` (`customer_id`,`idempotency_key`), + KEY `idx_ll_inventory_ledger_query` (`customer_id`,`overseas_warehouse_id`,`occurred_at`), + KEY `idx_ll_inventory_ledger_business` (`customer_id`,`business_type`,`business_order_no`), + KEY `idx_ll_inventory_ledger_inbound` (`customer_id`,`inbound_code`,`occurred_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable inventory package and quantity ledger'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inventory_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inventory_operation` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PROCESSING', + `result_json` longtext COLLATE utf8mb4_unicode_ci, + `last_error` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inventory_operation_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_inventory_operation_status` (`customer_id`,`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Inventory command idempotency record'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_inventory_position`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_inventory_position` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `balance_id` bigint NOT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pallet_id` bigint NOT NULL, + `pallet_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `batch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `warehouse_location_id` bigint DEFAULT NULL, + `warehouse_location_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Informational default location code', + `available_packages` bigint NOT NULL DEFAULT '0', + `hold_packages` bigint NOT NULL DEFAULT '0', + `allocated_packages` bigint NOT NULL DEFAULT '0', + `picked_packages` bigint NOT NULL DEFAULT '0', + `receiving_packages` bigint NOT NULL DEFAULT '0', + `product_quantity` bigint NOT NULL DEFAULT '0', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE', + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_inventory_position` (`balance_id`,`pallet_id`,`batch_no`), + KEY `idx_ll_inventory_position_pallet` (`customer_id`,`pallet_id`), + KEY `idx_ll_inventory_position_location` (`customer_id`,`overseas_warehouse_id`,`warehouse_location_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='SKU package stock by pallet, default location and batch'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_loading_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_loading_event_inbox` ( + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件全局唯一标识', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + `event_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '事件类型', + `event_version` int NOT NULL COMMENT '事件契约版本', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化事件负载 SHA-256 摘要', + `payload_json` json NOT NULL COMMENT '事件冻结负载 JSON', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '消费状态:RECEIVED/PROCESSED', + `received_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '接收时间', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间', + `last_error` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近一次处理错误', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`event_id`), + UNIQUE KEY `uk_ll_loading_inbox_tenant_key` (`customer_id`,`event_type`,`event_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程装柜完成事件接收幂等箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_loading_order_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_loading_order_status_history` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '状态历史主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单 ID', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + `event_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '触发事件 ID', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '状态变更备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '记录时间', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + KEY `idx_ll_loading_history_order` (`order_id`,`created_at`), + KEY `idx_ll_loading_history_tenant_order` (`customer_id`,`order_id`), + KEY `idx_ll_loading_history_customer_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB AUTO_INCREMENT=2104482219476070737 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单状态变更历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_local_looseboard_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_local_looseboard_history` ( + `id` bigint NOT NULL COMMENT '主键ID', + `order_id` bigint NOT NULL COMMENT '本土散板订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型:CREATE、UPDATE或STATUS', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + `change_detail` json DEFAULT NULL COMMENT '字段变更前后值JSON', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_local_looseboard_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板操作流水'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_local_looseboard_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_local_looseboard_operation` ( + `id` bigint NOT NULL COMMENT '操作记录ID', + `order_id` bigint NOT NULL COMMENT '本土散板订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作请求内容哈希', + `result_version` bigint NOT NULL COMMENT '操作完成后的订单版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_local_looseboard_operation_key` (`customer_id`,`order_id`,`idempotency_key`), + KEY `idx_ll_local_looseboard_operation_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单幂等操作账'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_local_looseboard_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_local_looseboard_order` ( + `id` bigint NOT NULL COMMENT '主键ID', + `lastleg_order_id` bigint DEFAULT NULL COMMENT '关联尾程主订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `order_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本土散板订单号', + `transport_mode` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输方式:拼车或优先拼载', + `pallet_required` varchar(8) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '是否需要打盘:YES或NO', + `transport_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '运输类型', + `origin` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '起运地', + `origin_address` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '起运详细地址', + `destination` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目的地', + `destination_address` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的详细地址、码头或仓号', + `container_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '柜型', + `package_count` int NOT NULL DEFAULT '0' COMMENT '货物件数', + `total_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '总重量(千克)', + `total_volume_cbm` decimal(18,6) NOT NULL DEFAULT '0.000000' COMMENT '总体积(立方米)', + `budget_amount` decimal(18,2) DEFAULT NULL COMMENT '预算总价', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '预算币种', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人姓名', + `contact_info` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '联系人联系方式', + `requested_date` date DEFAULT NULL COMMENT '要求服务日期', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ACCEPTANCE' COMMENT '本土散板订单状态', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求内容哈希', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建人ID', + `version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_local_looseboard_no` (`customer_id`,`order_no`), + UNIQUE KEY `uk_ll_local_looseboard_key` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_local_looseboard_lastleg` (`lastleg_order_id`), + KEY `idx_ll_local_looseboard_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_local_looseboard_date` (`customer_id`,`requested_date`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土散板订单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_monthly_exchange_rate`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_monthly_exchange_rate` ( + `id` bigint NOT NULL COMMENT '月度汇率ID', + `rate_month` char(7) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '汇率月份,格式yyyy-MM', + `base_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD' COMMENT '基准币种', + `quote_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '报价币种', + `usd_to_cny_average` decimal(18,8) NOT NULL COMMENT '美元兑人民币月平均汇率', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更新人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_exchange_rate_month` (`rate_month`), + KEY `idx_ll_exchange_rate_page` (`status`,`rate_month`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美元兑人民币月度平均汇率'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_monthly_exchange_rate_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_monthly_exchange_rate_history` ( + `id` bigint NOT NULL COMMENT '汇率变更历史ID', + `exchange_rate_id` bigint NOT NULL COMMENT '月度汇率ID', + `before_rate` decimal(18,8) DEFAULT NULL COMMENT '变更前汇率', + `after_rate` decimal(18,8) NOT NULL COMMENT '变更后汇率', + `before_status` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + `after_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更说明', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_exchange_rate_history` (`exchange_rate_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='月度汇率变更历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order` ( + `id` bigint NOT NULL COMMENT '雪花 ID', + `order_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '尾程单号', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '租户/客户账号标识', + `order_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'FIRST_LEG' COMMENT '订单类型', + `lx_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发领星入库单代码', + `destination_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA按箱数接收、OVERSEAS逐箱扫码', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实来源入仓码;无入仓关系的海外本土业务为空', + `source_region` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源区域/系统', + `source_inbound_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最终实装 LOADED 分片 ID,建单幂等键', + `root_inbound_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根入仓单 ID', + `source_order_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单 ID', + `source_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源订单号', + `root_order_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '根订单 ID', + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货主名称', + `source_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源国内装柜仓 ID 快照', + `source_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源国内装柜仓名称快照', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓ID;新建尾程订单由业务服务预分配', + `overseas_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓业务编码快照', + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '海外接柜仓名称快照', + `receiving_warehouse_version` int NOT NULL DEFAULT '0' COMMENT '柜级接柜仓分配乐观锁版本', + `transfer_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓ID', + `transfer_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓编码', + `transfer_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '转入目的仓名称', + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA 仓库编码', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '集装箱号', + `container_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT '待转柜' COMMENT '货柜状态', + `abnormal` tinyint NOT NULL DEFAULT '0' COMMENT '是否异常:0否1是', + `detained` tinyint NOT NULL DEFAULT '0' COMMENT '是否暂扣:0否1是', + `hold_chargeable` tinyint NOT NULL DEFAULT '0' COMMENT '当前暂扣是否计费:0否1是', + `hold_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前暂扣原因;解除后为空', + `manual_node_override` tinyint NOT NULL DEFAULT '0' COMMENT '是否启用单票人工计划节点覆盖:0否1是', + `version` int NOT NULL DEFAULT '0' COMMENT '尾程订单命令乐观锁版本', + `pallet_count` int NOT NULL DEFAULT '0' COMMENT '托数', + `package_count` int NOT NULL DEFAULT '0' COMMENT '箱数', + `received_package_count` int DEFAULT NULL COMMENT 'PDA实际入库箱数', + `picked_package_count` int NOT NULL DEFAULT '0', + `shipped_package_count` int NOT NULL DEFAULT '0', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', + `product_quantity` int DEFAULT NULL COMMENT '货物数量', + `volume_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '体积重量(千克)', + `estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到港时间', + `actual_arrival_at` datetime(3) DEFAULT NULL COMMENT '实际到港时间', + `estimated_warehouse_at` datetime(3) DEFAULT NULL COMMENT '预计到仓时间', + `arrival_time_version` int NOT NULL DEFAULT '0' COMMENT '柜级预计到仓时间乐观锁版本', + `inbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入库单号', + `outbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库单号', + `delivery_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送单号', + `relabel_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标单号', + `relabel_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + `pallet_merge_flag` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '合托标记', + `pallet_merge_batch_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '平台预标记合托分组号', + `current_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '当前业务状态', + `private_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(仅自己)', + `customer_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(客户)', + `warehouse_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注(仓库)', + `source_event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源事件 ID', + `source_loading_plan_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源装柜计划 ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源头程货柜预订ID', + `source_loading_plan_version` bigint DEFAULT NULL COMMENT '来源装柜计划版本', + `source_handover_version` bigint DEFAULT NULL COMMENT '交接版本', + `source_payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源事件负载摘要', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `sub_order_status` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '子单据状态汇总', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_source` (`customer_id`,`source_region`,`source_inbound_id`), + KEY `idx_ll_order_list` (`customer_id`,`current_status`,`created_at`), + KEY `idx_ll_order_group` (`customer_id`,`order_no`), + KEY `idx_ll_order_container` (`customer_id`,`container_no`), + KEY `idx_ll_order_container_progress` (`source_container_booking_id`,`estimated_warehouse_at`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单主表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_command_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_command_record` ( + `id` bigint NOT NULL COMMENT '尾程订单命令幂等记录ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令动作:MANUAL_PLAN、HOLD、MANUAL_NODES', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人ID', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化请求SHA-256摘要', + `result_ref` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '结果业务引用', + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '首次成功响应JSON快照', + `result_version` int NOT NULL COMMENT '命令成功后的订单版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_command_key` (`customer_id`,`order_id`,`action`,`operator_id`,`idempotency_key`), + KEY `idx_ll_order_command_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单写命令幂等与结果审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_detail` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '订单明细主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单 ID', + `source_inbound_detail_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源入仓明细 ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU 编码', + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `product_name_en` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '英文品名', + `package_count` int NOT NULL DEFAULT '0' COMMENT '箱数', + `product_quantity` int DEFAULT NULL COMMENT '数量', + `pallet_count` int DEFAULT NULL COMMENT '托数', + `gross_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '毛重(千克)', + `net_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '净重(千克)', + `dimensional_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '体积重量(千克)', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米)', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓 ID', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + `fba_tracking_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA跟踪号', + `shipment_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号', + `shipping_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '唛头', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_detail_source` (`order_id`,`source_inbound_detail_id`), + KEY `idx_ll_detail_order` (`order_id`), + KEY `idx_ll_detail_tenant_order` (`customer_id`,`order_id`), + KEY `idx_ll_detail_customer_order` (`customer_id`,`order_id`,`id`) +) ENGINE=InnoDB AUTO_INCREMENT=2291500000000010433 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单实装商品冻结明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_fee` ( + `id` bigint NOT NULL COMMENT '费用明细主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `fee_item` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目', + `currency` varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '币种代码', + `amount` decimal(18,2) NOT NULL COMMENT '费用金额', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW' COMMENT '费用状态:PENDING_REVIEW待审核、APPROVED审核通过、REJECTED已驳回', + `supplier_description` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '供应商或费用说明', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_ll_order_fee_customer_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单费用明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_hold_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_hold_history` ( + `id` bigint NOT NULL COMMENT '尾程订单暂扣历史ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `held` tinyint NOT NULL COMMENT '操作后是否暂扣:0否1是', + `chargeable` tinyint NOT NULL COMMENT '本次暂扣是否计费:0否1是', + `reason` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '暂扣或解除原因', + `order_version` int NOT NULL COMMENT '操作成功后的订单版本', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + `created_at` datetime(3) NOT NULL COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_hold_key` (`customer_id`,`order_id`,`operator_id`,`idempotency_key`), + KEY `idx_ll_order_hold_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单暂扣与解除不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_manual_node`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_manual_node` ( + `id` bigint NOT NULL COMMENT '尾程订单人工节点ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `node_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '八行人工展示节点稳定代码,与Carrier标准节点隔离', + `node_at` datetime(3) DEFAULT NULL COMMENT '人工填写节点时间,允许为空', + `source` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'MANUAL' COMMENT '节点来源,固定MANUAL', + `order_version` int NOT NULL COMMENT '最近编辑后的订单版本', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最近操作人ID', + `updated_at` datetime(3) NOT NULL COMMENT '最近编辑时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_manual_node` (`customer_id`,`order_id`,`node_code`), + KEY `idx_ll_order_manual_node_order` (`customer_id`,`order_id`,`node_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单当前人工节点覆盖事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_manual_node_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_manual_node_history` ( + `id` bigint NOT NULL COMMENT '尾程订单人工节点历史ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `nodes_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次全量人工节点JSON快照', + `order_version` int NOT NULL COMMENT '操作成功后的订单版本', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人ID', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + `created_at` datetime(3) NOT NULL COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_order_manual_node_history_key` (`customer_id`,`order_id`,`operator_id`,`idempotency_key`), + KEY `idx_ll_order_manual_node_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_no_daily_sequence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_no_daily_sequence` ( + `business_date` date NOT NULL COMMENT '业务日期(Asia/Shanghai)', + `last_sequence` int NOT NULL COMMENT '当日已分配尾程单流水号', + PRIMARY KEY (`business_date`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_operation_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_operation_log` ( + `id` bigint NOT NULL COMMENT '操作记录主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `operation_node` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作节点', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作人名称', + `result` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理结果', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_ll_order_operation_customer_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单操作记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_source_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_source_pallet` ( + `id` bigint NOT NULL COMMENT '来源托盘快照ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `source_pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '头程冻结托盘码', + `sequence_no` int NOT NULL COMMENT '订单内稳定顺序', + `source_event_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源装柜完成事件ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_source_pallet_no` (`source_pallet_no`), + UNIQUE KEY `uk_ll_source_pallet_sequence` (`customer_id`,`order_id`,`sequence_no`), + KEY `idx_ll_source_pallet_order` (`customer_id`,`order_id`), + KEY `fk_ll_source_pallet_order` (`order_id`), + CONSTRAINT `fk_ll_source_pallet_order` FOREIGN KEY (`order_id`) REFERENCES `ll_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='头程交接的不可变托盘码快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_order_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_order_status_history` ( + `id` bigint NOT NULL COMMENT '订单状态历史主键', + `customer_id` bigint NOT NULL COMMENT '租户 ID', + `order_id` bigint NOT NULL COMMENT '订单 ID', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '变更后状态', + `reason` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '变更原因', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '记录时间', + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单状态历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_outbound_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_outbound_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `outbound_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `dispatch_order_id` bigint DEFAULT NULL COMMENT '派车单ID,一件代发为空', + `dispatch_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派车单号,一件代发为空', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_OUTBOUND', + `outbound_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'DISPATCH_DELIVERY' COMMENT '出库类型', + `lx_codes` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '关联一件代发领星入库单代码汇总', + `recipient_info` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发收件信息', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发创建请求摘要,其他出库类型为空', + `package_count` int NOT NULL DEFAULT '0', + `pallet_count` int NOT NULL DEFAULT '0', + `gross_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + `volume_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米),来源未确认时为空', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `fee_coverage_json` json DEFAULT NULL COMMENT '发运原费用及服务覆盖确认快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_outbound_no` (`customer_id`,`outbound_no`), + UNIQUE KEY `uk_ll_outbound_idempotency` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_outbound_dispatch` (`customer_id`,`dispatch_order_id`), + KEY `idx_ll_outbound_list` (`customer_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Outbound document generated from a dispatch'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_outbound_pallet_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_outbound_pallet_item` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `outbound_order_id` bigint NOT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL COMMENT '浠撳簱瀹炵墿鎵樼洏ID', + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鎵樼洏鍙凤紝绠辩淮搴﹀嚭搴撲负绌�', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `package_count` int NOT NULL DEFAULT '0', + `gross_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '毛重(千克)', + `volume_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '材积重(千克)', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '体积(立方米),来源未确认时为空', + `picked_package_count` int NOT NULL DEFAULT '0' COMMENT '已拣箱数', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_PICK' COMMENT '拣货状态', + `order_refs` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '一件代发SKU', + `return_sku_quantity` bigint DEFAULT NULL COMMENT '退货单SKU本来源计划出库件数快照,NULL为历史整箱出库', + `carton_marks` varchar(4000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_outbound_pallet` (`customer_id`,`outbound_order_id`,`warehouse_pallet_id`), + KEY `idx_ll_outbound_pallet_list` (`customer_id`,`outbound_order_id`), + KEY `idx_ll_outbound_pallet_destination` (`customer_id`,`destination_warehouse_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Pallet detail snapshot for an outbound document'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_outbound_pda_pick`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_outbound_pda_pick` ( + `id` bigint NOT NULL COMMENT '主键ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `task_id` bigint NOT NULL COMMENT 'PDA任务ID', + `outbound_pallet_item_id` bigint NOT NULL COMMENT '出库托盘明细ID', + `inventory_position_id` bigint DEFAULT NULL COMMENT '库存仓位ID,退货箱唛出库为空', + `packages` bigint NOT NULL COMMENT '拣货箱数', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `picked_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_outbound_pda_pick_key` (`customer_id`,`idempotency_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA托盘拣货记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_outbound_pda_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_outbound_pda_task` ( + `id` bigint NOT NULL COMMENT '主键ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `outbound_order_id` bigint NOT NULL COMMENT '出库单ID', + `task_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA任务号', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:待拣货、拣货中、已完成', + `completion_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完成命令幂等键', + `pushed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '推送时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_outbound_pda_task_order` (`customer_id`,`outbound_order_id`), + UNIQUE KEY `uk_ll_outbound_pda_task_no` (`customer_id`,`task_no`), + UNIQUE KEY `uk_ll_outbound_pda_complete_key` (`customer_id`,`completion_idempotency_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='一件代发出库PDA拣货任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_fee` ( + `id` bigint NOT NULL COMMENT '主键ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + `fee_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用业务号', + `fee_item_code` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项编码', + `fee_item_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项名称', + `amount` decimal(20,2) NOT NULL COMMENT 'CNY金额', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + `voucher_file_id` bigint NOT NULL COMMENT '凭证文件ID', + `voucher_file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '凭证文件名', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '说明', + `wallet_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资金状态', + `wallet_error_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', + `wallet_error_message` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明', + `wallet_command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '登记人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_procurement_fee_no` (`fee_no`), + KEY `idx_ll_procurement_fee_order` (`customer_id`,`order_id`,`created_at`), + CONSTRAINT `chk_ll_procurement_fee_amount` CHECK ((`amount` > 0)), + CONSTRAINT `chk_ll_procurement_fee_currency` CHECK ((`currency` = _utf8mb4'CNY')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购费用明细及资金生命周期'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_file`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_file` ( + `id` bigint NOT NULL COMMENT '主键ID', + `order_id` bigint NOT NULL COMMENT '采购订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + `category` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIPT签收文件或VOUCHER费用凭证', + `file_id` bigint NOT NULL COMMENT 'File服务文件ID', + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件名快照', + `content_type` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '媒体类型', + `size_bytes` bigint NOT NULL DEFAULT '0' COMMENT '文件大小', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '上传人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_procurement_file` (`order_id`,`file_id`), + KEY `idx_ll_procurement_file_order` (`customer_id`,`order_id`,`category`,`created_at`), + CONSTRAINT `chk_ll_procurement_file_category` CHECK ((`category` in (_utf8mb4'RECEIPT',_utf8mb4'VOUCHER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购签收文件及费用凭证'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_order` ( + `id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `customer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `order_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `purchase_category` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_specification` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_url` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `purchase_quantity` bigint NOT NULL, + `budget_unit_price` decimal(18,2) DEFAULT NULL, + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD', + `receiving_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `purchase_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `purchase_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `inbound_quantity` bigint NOT NULL DEFAULT '0', + `outbound_details` json DEFAULT NULL COMMENT '出库明细', + `outbound_quantity` bigint NOT NULL DEFAULT '0', + `domestic_carrier` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `domestic_tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_carrier` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `overseas_tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `estimated_arrival_at` datetime(3) DEFAULT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `review_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `review_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核幂等键', + `review_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核请求内容哈希', + `reviewed_at` datetime(3) DEFAULT NULL, + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_overseas_procurement_order_no` (`customer_id`,`order_no`), + UNIQUE KEY `uk_ll_overseas_procurement_order_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_overseas_procurement_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_overseas_procurement_warehouse` (`customer_id`,`purchase_warehouse_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Overseas procurement order awaiting business approval'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_outbound`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_outbound` ( + `id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `quantity` bigint NOT NULL, + `outbound_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `carrier` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `outbound_at` datetime(3) NOT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_overseas_procurement_outbound_no` (`customer_id`,`order_id`,`outbound_no`), + KEY `idx_ll_overseas_procurement_outbound_order` (`customer_id`,`order_id`,`outbound_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Manual outbound records for overseas procurement inventory'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_procurement_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_procurement_status_history` ( + `id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + KEY `idx_ll_overseas_procurement_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable overseas procurement review history'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_quote`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_quote` ( + `id` bigint NOT NULL COMMENT '报价ID', + `name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价名称', + `warehouse_id` bigint NOT NULL COMMENT '国内平台海外合作仓ID', + `warehouse_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合作仓编码快照', + `warehouse_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '合作仓名称快照', + `country_code` varchar(2) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国家二字码快照', + `provider_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价方', + `currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '币种', + `effective_start` date NOT NULL COMMENT '生效开始日期', + `effective_end` date NOT NULL COMMENT '生效结束日期', + `version_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '业务版本号', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'INACTIVE' COMMENT '状态:ACTIVE/INACTIVE', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更新人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_quote_warehouse_provider_version` (`warehouse_id`,`provider_name`,`version_code`), + KEY `idx_ll_quote_page` (`status`,`updated_at`,`id`), + KEY `idx_ll_quote_effective` (`warehouse_id`,`provider_name`,`currency`,`effective_start`,`effective_end`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价主表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_quote_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_quote_fee` ( + `id` bigint NOT NULL COMMENT '费用明细ID', + `quote_id` bigint NOT NULL COMMENT '报价ID', + `sequence_no` int NOT NULL COMMENT '显示顺序', + `item` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '费用项目', + `billing_unit` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '计费单位', + `unit_price` decimal(18,4) NOT NULL COMMENT '单价', + `minimum_charge` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '最低收费', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_quote_fee_sequence` (`quote_id`,`sequence_no`), + KEY `idx_ll_quote_fee_quote` (`quote_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价仓库费用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_overseas_quote_transit_price`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_overseas_quote_transit_price` ( + `id` bigint NOT NULL COMMENT '中转价格ID', + `quote_id` bigint NOT NULL COMMENT '报价ID', + `sequence_no` int NOT NULL COMMENT '显示顺序', + `fba_warehouse_code` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'FBA仓编码', + `delivery_method` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '报价运输方式', + `estimated_miles` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '预估英里数', + `price` decimal(18,4) NOT NULL COMMENT '报价金额', + `minimum_charge` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '最低收费', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_quote_transit_sequence` (`quote_id`,`sequence_no`), + KEY `idx_ll_quote_transit_quote` (`quote_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外仓报价中转价格明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_inventory_move`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_inventory_move` ( + `id` bigint NOT NULL COMMENT '移库操作ID', + `move_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库操作单号', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `pallet_id` bigint NOT NULL COMMENT '整托移库的实物托盘ID', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实物托盘码快照', + `source_location_id` bigint NOT NULL COMMENT '来源库位ID', + `source_location_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源库位码快照', + `target_location_id` bigint NOT NULL COMMENT '目标库位ID', + `target_location_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标库位码快照', + `customer_count` int NOT NULL COMMENT '托盘涉及客户数', + `position_count` int NOT NULL COMMENT '迁移库存位置数', + `package_count` bigint NOT NULL COMMENT '迁移总箱数', + `pallet_version_before` int NOT NULL COMMENT '迁移前托盘版本', + `pallet_version_after` int DEFAULT NULL COMMENT '迁移后托盘版本', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + `reason` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库原因', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库幂等键', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库请求哈希', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '移库处理状态', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '成功响应JSON', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_inventory_move_no` (`move_no`), + UNIQUE KEY `uk_ll_pda_inventory_move_key` (`overseas_warehouse_id`,`operator_key`,`idempotency_key`), + KEY `idx_ll_pda_inventory_move_pallet` (`overseas_warehouse_id`,`pallet_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA同仓整托移库记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_operation_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_operation_history` ( + `id` bigint NOT NULL COMMENT 'PDA作业历史ID', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '作业涉及的客户标识', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + `operation_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业类型', + `business_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业业务键', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '作业结果状态', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '写操作幂等键', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '写请求内容哈希', + `summary` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '作业结果摘要', + `occurred_at` datetime(3) NOT NULL COMMENT '作业发生时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_history_idempotency` (`operator_key`,`idempotency_key`), + KEY `idx_ll_pda_history_operator` (`overseas_warehouse_id`,`operator_key`,`occurred_at`), + KEY `idx_ll_pda_history_business` (`overseas_warehouse_id`,`operation_type`,`business_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA真实作业历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_pallet_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_pallet_operation` ( + `id` bigint NOT NULL COMMENT 'PDA托盘作业ID', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备幂等键', + `operation_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '托盘作业类型', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容摘要', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '处理状态:PROCESSING、COMPLETED、FAILED_COMPENSATED、COMPENSATION_REQUIRED', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '成功响应快照', + `remote_transitions_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '实物托盘状态变更回执', + `last_error` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最后失败原因', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_pallet_idempotency` (`operator_key`,`idempotency_key`), + KEY `idx_ll_pda_pallet_warehouse` (`overseas_warehouse_id`,`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA托盘作业幂等、补偿与结果记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_stocktake_action`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_stocktake_action` ( + `id` bigint NOT NULL COMMENT '盘点动作ID', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作账号标识', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作幂等键', + `action_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点动作类型', + `task_id` bigint DEFAULT NULL COMMENT '盘点任务ID', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作请求哈希', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作处理状态', + `result_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '成功响应JSON', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_stocktake_action` (`overseas_warehouse_id`,`operator_key`,`idempotency_key`), + KEY `idx_ll_pda_stocktake_action_task` (`task_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA盘点动作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_stocktake_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_stocktake_item` ( + `id` bigint NOT NULL COMMENT '盘点明细ID', + `task_id` bigint NOT NULL COMMENT '盘点任务ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户标识', + `balance_id` bigint NOT NULL COMMENT '库存余额ID', + `position_id` bigint NOT NULL COMMENT '库存位置ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU快照', + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '品名快照', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '入仓码快照', + `pallet_id` bigint NOT NULL COMMENT '实物托盘ID', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实物托盘码快照', + `location_id` bigint NOT NULL COMMENT '库位ID快照', + `location_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库位码快照', + `book_packages` bigint NOT NULL COMMENT '任务创建时账面总箱数', + `actual_packages` bigint DEFAULT NULL COMMENT '现场实盘总箱数', + `difference_packages` bigint DEFAULT NULL COMMENT '实盘与账面箱数差异', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点明细状态', + `position_version` bigint NOT NULL COMMENT '任务创建时库存位置版本', + `counted_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '盘点操作账号标识', + `version` bigint NOT NULL DEFAULT '0' COMMENT '明细乐观锁版本', + `counted_at` datetime(3) DEFAULT NULL COMMENT '盘点时间', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_stocktake_item` (`task_id`,`position_id`), + KEY `idx_ll_pda_stocktake_item_task` (`task_id`,`status`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点位置明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_pda_stocktake_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_pda_stocktake_task` ( + `id` bigint NOT NULL COMMENT '盘点任务ID', + `task_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点任务号', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'Token冻结的海外实际仓ID', + `scope_type` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点范围类型LOCATION或PALLET', + `scope_resource_id` bigint NOT NULL COMMENT '盘点库位或托盘资源ID', + `scope_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点库位码或托盘码快照', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '盘点任务状态', + `position_count` int NOT NULL COMMENT '库存位置总数', + `counted_count` int NOT NULL DEFAULT '0' COMMENT '已盘库存位置数', + `difference_count` int NOT NULL DEFAULT '0' COMMENT '存在差异的库存位置数', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建操作账号标识', + `submitted_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提交操作账号标识', + `submit_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '盘点差异提交原因', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求哈希', + `version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + `submitted_at` datetime(3) DEFAULT NULL COMMENT '提交完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_pda_stocktake_no` (`task_no`), + UNIQUE KEY `uk_ll_pda_stocktake_create` (`overseas_warehouse_id`,`created_by`,`idempotency_key`), + KEY `idx_ll_pda_stocktake_list` (`overseas_warehouse_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_procurement_daily_sequence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_procurement_daily_sequence` ( + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', + `business_date` date NOT NULL COMMENT '北京时间业务日期', + `sequence_value` int NOT NULL COMMENT '客户当日已分配最大序号', + PRIMARY KEY (`customer_id`,`business_date`), + CONSTRAINT `chk_ll_procurement_sequence` CHECK ((`sequence_value` between 1 and 9999)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_container_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_container_operation` ( + `id` bigint NOT NULL COMMENT '柜号收货操作ID', + `session_id` bigint NOT NULL COMMENT '柜号签收会话ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号唯一标识', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作动作', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户端幂等键', + `request_hash` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + `request_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容快照', + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '响应内容快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_operation` (`operator_key`,`idempotency_key`), + KEY `idx_ll_receiving_operation_session` (`session_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号收货幂等审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_container_session`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_container_session` ( + `id` bigint NOT NULL COMMENT '柜号签收会话ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '所选柜解析出的客户标识', + `container_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '集装箱号', + `overseas_warehouse_id` bigint NOT NULL COMMENT '操作员Token绑定并冻结的海外实际收货共享物理仓ID', + `overseas_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓库服务校验后的海外实际收货共享物理仓名称快照', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号唯一标识', + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作账号名称', + `active_operator_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '未完成会话平台操作账号全局唯一锁键', + `active_container_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '未完成会话柜号全局唯一锁键', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_SIGN' COMMENT '会话状态:WAIT_SIGN、SIGNED、RECEIVING、COMPLETED', + `sign_photos` json DEFAULT NULL COMMENT '签收卸柜照片角色与文件ID列表', + `prepared_at` datetime(3) DEFAULT NULL COMMENT '选择柜号时间', + `signed_at` datetime(3) DEFAULT NULL COMMENT '签收时间', + `receiving_at` datetime(3) DEFAULT NULL COMMENT '首次收货时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_active_operator` (`active_operator_key`), + UNIQUE KEY `uk_ll_receiving_active_container` (`active_container_key`), + KEY `idx_ll_receiving_container` (`customer_id`,`container_no`,`status`), + KEY `idx_ll_receiving_operator` (`customer_id`,`operator_key`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号签收收货会话'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_fba_pallet_count`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_fba_pallet_count` ( + `id` bigint NOT NULL COMMENT 'FBA托盘现场累计箱数ID', + `session_id` bigint NOT NULL COMMENT '柜号签收会话ID', + `group_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '柜号与FBA仓组成的收货组键', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '现有FBA托盘码', + `actual_packages` int NOT NULL COMMENT '该托现场累计实物箱数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_fba_count` (`session_id`,`group_key`,`pallet_code`), + KEY `idx_ll_receiving_fba_group` (`session_id`,`group_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA普通FBA托盘现场累计与超收标记'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_sku_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_sku_receipt` ( + `id` bigint NOT NULL COMMENT 'FBA SKU实收进度ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户账号标识', + `session_id` bigint NOT NULL COMMENT '柜号签收会话ID', + `plan_id` bigint NOT NULL COMMENT '入仓计划ID', + `line_id` bigint NOT NULL COMMENT '入仓计划明细ID', + `pallet_id` bigint NOT NULL COMMENT '计划托盘ID', + `order_id` bigint NOT NULL COMMENT '尾程订单ID', + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓码快照', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码快照', + `product_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '产品名称快照', + `received_packages` int NOT NULL COMMENT 'SKU实收箱数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_sku_pallet` (`customer_id`,`line_id`,`pallet_id`,`sku_code`), + KEY `idx_ll_receiving_sku_session` (`customer_id`,`session_id`,`line_id`), + KEY `idx_ll_receiving_sku_plan` (`customer_id`,`plan_id`,`line_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA柜号FBA SKU实收进度'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_receiving_warehouse_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_receiving_warehouse_operation` ( + `id` bigint NOT NULL COMMENT '柜级接柜仓修改操作ID', + `source_container_booking_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '稳定来源柜预订ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台请求幂等键', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化修改请求SHA-256摘要', + `result_version` int NOT NULL COMMENT '修改成功后的柜级分配版本', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台操作人名称', + `remark` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '接柜仓修改备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_receiving_warehouse_idempotency` (`idempotency_key`), + KEY `idx_ll_receiving_warehouse_container` (`source_container_booking_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='柜级海外接柜仓修改幂等审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_application`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_application` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `lastleg_order_id` bigint NOT NULL, + `customer_order_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `label_deadline` datetime(3) NOT NULL, + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `delivery_method` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '派送方式:TRUCK卡车、PICKUP自提、EXPRESS快递;仅涉及箱唛换标时必填', + `print_shipping_label` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要打印面单', + `shipping_label_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件ID', + `shipping_label_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单文件名', + `shipping_label_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户上传面单下载地址', + `fulfillment_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'WAIT_RELABEL' COMMENT '换标后履约状态', + `outbound_order_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '自提或快递生成的换标出库单号', + `customer_confirmation_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户确认完成幂等键', + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认换标完成时间', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `total_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF文件ID', + `total_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF文件名', + `total_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '总换标PDF预览地址', + `carton_total_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件ID', + `carton_total_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件名', + `carton_total_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱唛总文件地址', + `sku_total_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件ID', + `sku_total_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件名', + `sku_total_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU总文件地址', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_PROCESSING', + `item_count` int NOT NULL DEFAULT '0', + `total_package_count` int NOT NULL DEFAULT '0', + `notified_package_count` int NOT NULL DEFAULT '0', + `charge_amount` decimal(20,2) NOT NULL DEFAULT '0.00' COMMENT '换标费用CNY', + `charge_currency` char(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CNY' COMMENT '费用币种', + `wallet_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'FREEZE_PENDING' COMMENT '资金状态', + `wallet_error_code` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误码', + `wallet_error_message` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '资金错误说明', + `wallet_command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前资金指令事件ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_relabel_application_no` (`customer_id`,`relabel_no`), + UNIQUE KEY `uk_ll_relabel_application_idempotency` (`customer_id`,`idempotency_key`), + KEY `idx_ll_relabel_application_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_relabel_application_customer` (`customer_id`,`customer_order_no`), + KEY `idx_ll_relabel_application_warehouse` (`customer_id`,`overseas_warehouse_id`), + KEY `idx_ll_relabel_application_order` (`lastleg_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Customer relabel application header'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_item` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `application_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'SKU编码(选填)', + `product_image_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_image_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_image_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `total_package_count` int NOT NULL, + `notified_package_count` int NOT NULL, + `shipment_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货件编号(无FBA去向时为空)', + `tracking_code` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `new_sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后SKU编码', + `label_requirement` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '货物明细备注', + `relabel_file_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `relabel_file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `relabel_file_url` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '瀹㈡埛鑷湁绠卞敍', + `return_carton_id` bigint DEFAULT NULL COMMENT '閫�璐ф崲绠卞敍鏉ユ簮閫�璐у簱瀛樼ID', + `source_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '閫�璐ф崲绠卞敍鍘熺鍞涘揩鐓�', + `new_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '閫�璐ф崲绠卞敍鏂扮鍞�', + `fba_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鍏宠仈FBA绠卞敍', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '该箱目的海外仓ID', + `destination_fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码(与海外操作仓分离)', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CARTON_LABEL' COMMENT '换标模式:箱唛、SKU或组合换标', + `sku_relabel_items_json` json DEFAULT NULL COMMENT '同箱SKU换标子明细快照(数量按件)', + `source_inventory_balance_id` bigint DEFAULT NULL COMMENT '原库存余额ID(预入仓换标任务可为空)', + `source_inventory_position_id` bigint DEFAULT NULL COMMENT '原库存库位ID(预入仓换标任务可为空)', + `source_inbound_order_id` bigint DEFAULT NULL COMMENT '原入仓单ID(预入仓换标任务可为空)', + `source_inbound_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `source_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + PRIMARY KEY (`id`), + KEY `idx_ll_relabel_item_application` (`customer_id`,`application_id`), + KEY `idx_ll_relabel_item_sku` (`customer_id`,`sku_code`), + KEY `idx_ll_relabel_item_destination` (`customer_id`,`destination_warehouse_id`,`application_id`), + KEY `idx_ll_relabel_item_source_position` (`customer_id`,`source_inventory_position_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Frozen customer relabel cargo items'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_pda_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_pda_history` ( + `id` bigint NOT NULL COMMENT '换标PDA任务历史ID', + `task_id` bigint NOT NULL COMMENT '换标PDA任务ID', + `application_id` bigint NOT NULL COMMENT '换标申请单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '现场动作类型', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '动作前任务状态', + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '动作后任务状态', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识', + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照', + `detail_json` longtext COLLATE utf8mb4_unicode_ci COMMENT '动作详情JSON', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_ll_relabel_pda_history_task` (`customer_id`,`task_id`,`created_at`,`id`), + KEY `idx_ll_relabel_pda_history_application` (`customer_id`,`application_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标任务不可变操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_pda_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_pda_operation` ( + `id` bigint NOT NULL COMMENT '换标PDA幂等操作ID', + `task_id` bigint NOT NULL COMMENT '换标PDA任务ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求操作账号标识', + `action` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '幂等操作动作', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '设备生成的幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容哈希', + `result_json` longtext COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '成功响应快照JSON', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_relabel_pda_operation_key` (`operator_key`,`idempotency_key`), + KEY `idx_ll_relabel_pda_operation_task` (`customer_id`,`task_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标写操作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_pda_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_pda_task` ( + `id` bigint NOT NULL COMMENT '换标PDA任务ID', + `application_id` bigint NOT NULL COMMENT '换标申请单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标申请单号快照', + `overseas_warehouse_id` bigint NOT NULL COMMENT 'PDA令牌冻结的海外实际作业仓ID', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '任务状态:PROCESSING、WAIT_REVIEW或COMPLETED', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '认领操作账号标识', + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '认领操作员名称快照', + `reviewer_key` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '复核账号标识', + `reviewer_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '复核员名称快照', + `expected_packages` int NOT NULL COMMENT '申请单通知的应换标箱数', + `relabelled_packages` int NOT NULL DEFAULT '0' COMMENT '已正常换标箱数', + `skipped_packages` int NOT NULL DEFAULT '0' COMMENT '已说明原因跳过箱数', + `exception_packages` int NOT NULL DEFAULT '0' COMMENT '已登记异常箱数', + `completion_photo_file_ids` json DEFAULT NULL COMMENT '换标完成现场照片文件ID列表', + `completion_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完成提交备注', + `review_decision` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近复核结论:APPROVE、REWORK或CUSTOMER_CONFIRMED', + `review_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '最近复核备注', + `claimed_at` datetime(3) NOT NULL COMMENT '任务认领时间', + `submitted_at` datetime(3) DEFAULT NULL COMMENT '提交复核时间', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '最近复核时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_relabel_pda_task_application` (`customer_id`,`application_id`), + UNIQUE KEY `uk_ll_relabel_pda_task_no` (`relabel_no`), + KEY `idx_ll_relabel_pda_task_warehouse` (`overseas_warehouse_id`,`status`,`updated_at`), + KEY `idx_ll_relabel_pda_task_operator` (`operator_key`,`status`,`updated_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA换标现场作业任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_pda_unit_fact`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_pda_unit_fact` ( + `id` bigint NOT NULL COMMENT '逐件换标事实ID', + `task_id` bigint NOT NULL COMMENT '换标PDA任务ID', + `application_id` bigint NOT NULL COMMENT '换标申请单ID', + `item_id` bigint NOT NULL COMMENT '换标申请明细ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', + `old_label` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫描的实物旧标签或箱标', + `new_label` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '扫描的换标后新标签', + `result` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '逐件结果:RELABELLED、SKIPPED或EXCEPTION', + `exception_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '异常编码', + `reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '跳过或异常原因', + `quantity` int NOT NULL DEFAULT '1' COMMENT '本次事实处理件数,箱级作业固定为1', + `operator_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作账号标识', + `operator_name` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作员名称快照', + `occurred_at` datetime(3) NOT NULL COMMENT '逐件事实发生时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_relabel_pda_old_label` (`old_label`), + KEY `idx_ll_relabel_pda_unit_task` (`customer_id`,`task_id`,`occurred_at`,`id`), + KEY `idx_ll_relabel_pda_unit_item` (`customer_id`,`application_id`,`item_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA逐件换标扫码与异常事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_relabel_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_relabel_status_history` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `application_id` bigint NOT NULL, + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + KEY `idx_ll_relabel_status_history_application` (`customer_id`,`application_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable customer relabel process history'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_carton_count_session`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_carton_count_session` ( + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '平台PDA操作员标识', + `warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '认领时绑定海外仓标识', + `receipt_id` bigint DEFAULT NULL COMMENT '正在清点的签收批次,完成后释放为NULL', + `started_at` datetime(3) DEFAULT NULL COMMENT '本次开始清点时间', + `created_at` datetime(3) NOT NULL COMMENT '会话创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '会话更新时间', + PRIMARY KEY (`operator_id`), + UNIQUE KEY `uk_ll_return_count_session_receipt` (`receipt_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货箱内清点单人单任务持久会话'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_balance`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_balance` ( + `id` bigint NOT NULL COMMENT '退货库存余额ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `total_quantity` bigint NOT NULL DEFAULT '0' COMMENT '实物总库存,仅实际出库扣减', + `available_quantity` bigint NOT NULL COMMENT '可用上架数量', + `carton_quantity` bigint NOT NULL COMMENT '包含该SKU的可用箱数', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_inventory_balance` (`customer_id`,`overseas_warehouse_id`,`sku_code`), + CONSTRAINT `chk_ll_return_inventory_balance_quantity` CHECK (((`available_quantity` >= 0) and (`carton_quantity` >= 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存按客户海外仓SKU汇总余额'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_carton` ( + `id` bigint NOT NULL COMMENT '退货库存箱ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '海外仓ID', + `source_receipt_id` bigint DEFAULT NULL COMMENT '来源PDA签收单ID', + `source_batch_no` text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源签收批次号,多个批次逗号分隔', + `source_return_no` text COLLATE utf8mb4_unicode_ci COMMENT '实际消耗来源退货单号,多个单号逗号分隔', + `source_unit_type` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源实物类型', + `source_unit_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '来源实物编码', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收时绑定的托盘码', + `carton_mark` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货箱号和箱唛编码', + `fba_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA箱唛', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称', + `relabel_application_id` bigint DEFAULT NULL COMMENT '绑定换标申请单ID', + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '绑定换标单号', + `relabel_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + `relabel_completed_at` datetime(3) DEFAULT NULL COMMENT '换标完成时间', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'AVAILABLE可用或MERGED已合箱', + `sku_types` int NOT NULL COMMENT '箱内SKU种类数', + `total_quantity` bigint NOT NULL COMMENT '箱内总件数', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + `merged_at` datetime(3) DEFAULT NULL COMMENT '合箱完成时间', + `length` decimal(12,3) DEFAULT NULL COMMENT '绠遍暱CM', + `width` decimal(12,3) DEFAULT NULL COMMENT '绠卞CM', + `height` decimal(12,3) DEFAULT NULL COMMENT '绠遍珮CM', + `weight` decimal(12,3) DEFAULT NULL COMMENT '绠遍噸KG', + `original_packaging` tinyint(1) DEFAULT NULL COMMENT '是否使用原箱原包装', + `new_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鎹㈡爣鍚庢柊绠卞敍', + `location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '退货箱当前库位', + `putaway_at` datetime(3) DEFAULT NULL COMMENT '退货箱上架时间', + `count_status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'CONFIRMED' COMMENT '箱内清点状态 PENDING待清点 CONFIRMED已确认', + `counted_at` datetime(3) DEFAULT NULL COMMENT '箱内清点确认时间', + `counted_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱内清点确认操作人', + `label_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'WAIT_LABEL寰呰创鏍� CONFIRMED宸叉壂鐮佺‘璁ゆ爣绛�', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_inventory_carton_mark` (`carton_mark`), + KEY `idx_ll_return_inventory_carton_customer` (`customer_id`,`overseas_warehouse_id`,`status`,`created_at`), + KEY `idx_ll_return_inventory_carton_receipt` (`source_receipt_id`), + KEY `idx_ll_return_count` (`overseas_warehouse_id`,`count_status`,`status`,`source_receipt_id`), + CONSTRAINT `chk_ll_return_inventory_carton_quantity` CHECK ((((`status` in (_utf8mb4'AVAILABLE',_utf8mb4'PACKING_RESERVED',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUND_PENDING',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING')) and (`sku_types` > 0) and (`total_quantity` > 0)) or ((`status` in (_utf8mb4'MERGED',_utf8mb4'DISCARDED')) and (`sku_types` = 0) and (`total_quantity` = 0)))), + CONSTRAINT `chk_ll_return_inventory_carton_status` CHECK ((`status` in (_utf8mb4'AVAILABLE',_utf8mb4'PACKING_RESERVED',_utf8mb4'RELABEL_PENDING',_utf8mb4'OUTBOUND_PENDING',_utf8mb4'MERGED',_utf8mb4'OUTBOUNDED',_utf8mb4'DISCARD_PENDING',_utf8mb4'DISCARDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物库存箱及箱唛'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_carton_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_carton_item` ( + `id` bigint NOT NULL COMMENT '退货库存箱SKU明细ID', + `carton_id` bigint NOT NULL COMMENT '退货库存箱ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `quantity` bigint NOT NULL COMMENT '该SKU箱内数量', + `packing_reserved_quantity` bigint NOT NULL DEFAULT '0' COMMENT '合箱计划预占件数', + `relabelled_quantity` bigint NOT NULL DEFAULT '0' COMMENT '当前SKU已经换标的实物件数,不可二次换标', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_inventory_carton_item` (`carton_id`,`sku_code`), + KEY `idx_ll_return_inventory_carton_item_customer` (`customer_id`,`sku_code`), + CONSTRAINT `chk_ll_return_inventory_carton_item_quantity` CHECK ((`quantity` >= 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存箱内SKU和数量'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_operation` ( + `id` bigint NOT NULL COMMENT '退货库存PDA操作ID', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作人ID', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '请求内容SHA-256', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作类型,当前MERGE', + `result_carton_id` bigint DEFAULT NULL COMMENT '合箱结果退货库存箱ID', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + `result_json` json DEFAULT NULL COMMENT '幂等操作不可变返回快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_inventory_operation_operator_key` (`operator_id`,`idempotency_key`), + KEY `idx_ll_return_inventory_operation_result` (`result_carton_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货库存PDA合箱幂等操作审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_inventory_outbound_movement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_inventory_outbound_movement` ( + `id` bigint NOT NULL COMMENT '退货出库SKU流水ID', + `outbound_pick_id` bigint NOT NULL COMMENT '同事务PDA出库拣货记录ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '库存所属客户ID和隔离键', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '实际出库海外仓ID', + `carton_id` bigint NOT NULL COMMENT '真实退货库存箱ID', + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '冻结的真实退货箱业务码', + `outbound_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次退货出库业务单号', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '本次出库SKU编码快照', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + `quantity` bigint NOT NULL COMMENT '本箱该SKU实际出库件数,始终为正', + `total_before` bigint NOT NULL COMMENT '客户仓SKU出库前实物总数量', + `total_after` bigint NOT NULL COMMENT '客户仓SKU出库后实物总数量', + `available_before` bigint NOT NULL COMMENT '客户仓SKU出库前可用数量', + `available_after` bigint NOT NULL COMMENT '客户仓SKU出库后可用数量', + `cartons_before` bigint NOT NULL COMMENT '客户仓包含该SKU的出库前可用箱数', + `cartons_after` bigint NOT NULL COMMENT '客户仓包含该SKU的出库后可用箱数', + `occurred_at` datetime(3) NOT NULL COMMENT '出库记账时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_outbound_pick_sku` (`outbound_pick_id`,`sku_code`), + KEY `idx_ll_return_outbound_movement_query` (`customer_id`,`overseas_warehouse_id`,`sku_code`,`occurred_at`), + KEY `idx_ll_return_outbound_carton_sku` (`customer_id`,`carton_id`,`sku_code`), + CONSTRAINT `chk_ll_return_outbound_movement_delta` CHECK (((`quantity` > 0) and (`total_after` >= 0) and (`available_after` >= 0) and ((`total_before` - `total_after`) = `quantity`) and ((`available_before` - `available_after`) = `quantity`) and (`cartons_after` >= 0) and ((`cartons_before` - `cartons_after`) in (0,1)) and (`total_after` >= `available_after`))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='不可变退货出库SKU库存流水'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_order` ( + `id` bigint NOT NULL COMMENT '退货订单ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `return_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退货订单号', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + `warehouse_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系人快照', + `warehouse_contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中转仓联系电话快照', + `customer_reference_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户参考号', + `lastleg_order_id` bigint DEFAULT NULL COMMENT '鍏宠仈灏剧▼涓昏鍗旾D', + `lastleg_order_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'linked lastleg order no', + `need_relabel` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否需要换标', + `relabel_application_id` bigint DEFAULT NULL COMMENT '绑定换标申请单ID', + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '绑定换标单号', + `relabel_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标状态', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PENDING_RECEIPT、PARTIALLY_RECEIVED或COMPLETED', + `expected_sku_types` int NOT NULL COMMENT '预报SKU种类数', + `expected_quantity` bigint NOT NULL COMMENT '预报总数量', + `received_quantity` bigint NOT NULL DEFAULT '0' COMMENT '累计实收总数量', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户备注', + `discrepancy_reason` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手动完结差异原因', + `manually_completed_at` datetime(3) DEFAULT NULL COMMENT '手动差异完结时间', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建请求SHA-256', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建操作人ID', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_order_customer_no` (`customer_id`,`return_no`), + UNIQUE KEY `uk_ll_return_order_customer_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_return_order_warehouse_status` (`overseas_warehouse_id`,`status`,`created_at`), + CONSTRAINT `chk_ll_return_order_quantity` CHECK (((`expected_sku_types` > 0) and (`expected_quantity` > 0) and (`received_quantity` >= 0) and (`received_quantity` <= `expected_quantity`))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户退货订单及SKU实收汇总'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_order_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_order_item` ( + `id` bigint NOT NULL COMMENT '退货预报明细ID', + `return_order_id` bigint NOT NULL COMMENT '退货订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名', + `expected_quantity` bigint NOT NULL COMMENT '该快递单SKU预报数量', + `received_quantity` bigint NOT NULL DEFAULT '0' COMMENT '该快递单SKU累计实收数量', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_item_order_sku` (`return_order_id`,`sku_code`), + KEY `idx_ll_return_item_sku` (`sku_code`,`received_quantity`), + CONSTRAINT `chk_ll_return_item_quantity` CHECK (((`expected_quantity` > 0) and (`received_quantity` >= 0) and (`received_quantity` <= `expected_quantity`))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='客户退货订单的快递单SKU预报与实收明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_packing_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_packing_plan` ( + `id` bigint NOT NULL, + `plan_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `return_nos_json` json NOT NULL, + `boxes_json` json NOT NULL, + `mode` varchar(40) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL, + `updated_at` datetime(3) NOT NULL, + `pushed_at` datetime(3) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `source_items_json` json DEFAULT NULL COMMENT '精确来源箱和SKU锁定数量', + `result_marks_json` json DEFAULT NULL COMMENT '完成生成或并入的目标箱唛', + `request_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '创建载荷摘要', + `completion_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '完成载荷摘要', + `result_labels_json` json DEFAULT NULL COMMENT '计划完工标签快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_packing_plan_no` (`plan_no`), + UNIQUE KEY `uk_ll_return_packing_plan_key` (`idempotency_key`), + KEY `idx_ll_return_packing_plan_warehouse_status` (`overseas_warehouse_id`,`status`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货合箱计划'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_packing_trace`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_packing_trace` ( + `id` bigint NOT NULL COMMENT '合箱追溯ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原客户隔离键', + `operation_id` bigint NOT NULL COMMENT '合箱操作ID', + `source_carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '来源箱唛 空值表示目标箱原有库存快照', + `target_carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '目标箱唛', + `return_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始退货单号', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '商品SKU编码', + `quantity` bigint NOT NULL COMMENT '本次移动件数', + `created_at` datetime(3) NOT NULL COMMENT '发生时间', + PRIMARY KEY (`id`), + KEY `idx_return_packing_trace_order` (`customer_id`,`return_no`,`created_at`), + KEY `idx_return_packing_trace_target` (`customer_id`,`target_carton_mark`,`sku_code`), + KEY `idx_return_packing_trace_source` (`customer_id`,`source_carton_mark`,`sku_code`), + CONSTRAINT `chk_return_packing_trace_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货合箱逐SKU原始退货单不可变追溯'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_receipt` ( + `id` bigint NOT NULL COMMENT 'PDA退货签收单ID', + `return_order_id` bigint NOT NULL COMMENT '退货订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `receipt_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收单号', + `receipt_batch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', + `unit_type` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收实物类型 PALLET/BOX/PARCEL', + `unit_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收实物编码', + `total_box_count` bigint DEFAULT NULL COMMENT '本次实物总箱数,托盘独立于SKU箱数', + `length` decimal(12,3) DEFAULT NULL COMMENT '托盘长度CM', + `width` decimal(12,3) DEFAULT NULL COMMENT '托盘宽度CM', + `height` decimal(12,3) DEFAULT NULL COMMENT '托盘高度CM', + `weight` decimal(12,3) DEFAULT NULL COMMENT '托盘重量KG', + `original_packaging` tinyint(1) DEFAULT NULL COMMENT '是否使用原箱原包装', + `measurement_source` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '尺寸重量来源 PALLET_MEASURED托盘实测 UNIT_SUM子箱汇总', + `pallet_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收绑定的托盘码', + `carton_mark` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '签收生成或绑定的退货箱号', + `tracking_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次签收包裹的可选快递单号', + `bound_by` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '开始签收方式,RETURN_NO退货单号或SKU候选', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收状态,当前SIGNED', + `received_quantity` bigint NOT NULL COMMENT '本次实收数量', + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA操作人ID', + `operator_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA操作人名称', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA确认幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'PDA确认请求SHA-256', + `signed_at` datetime(3) NOT NULL COMMENT '签收时间', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_receipt_no` (`receipt_no`), + UNIQUE KEY `uk_ll_return_receipt_batch` (`receipt_batch_no`), + UNIQUE KEY `uk_ll_return_receipt_operator_key` (`operator_id`,`idempotency_key`), + KEY `idx_ll_return_receipt_order` (`return_order_id`,`signed_at`), + CONSTRAINT `chk_ll_return_receipt_bound` CHECK ((`bound_by` in (_utf8mb4'RETURN_NO',_utf8mb4'SKU'))), + CONSTRAINT `chk_ll_return_receipt_quantity` CHECK ((`received_quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货实收签收事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_receipt_batch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_receipt_batch` ( + `id` bigint NOT NULL COMMENT '退货仓储计费批次ID', + `return_order_id` bigint NOT NULL COMMENT '退货订单ID', + `receipt_id` bigint NOT NULL COMMENT 'PDA退货签收单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `batch_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '签收批次号', + `overseas_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + `storage_billing_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '仓储计费状态,初始PENDING_PRICING', + `storage_started_at` datetime(3) NOT NULL COMMENT '仓储计费起算时间', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_return_batch_no` (`batch_no`), + UNIQUE KEY `uk_ll_return_batch_receipt` (`receipt_id`), + KEY `idx_ll_return_batch_billing` (`customer_id`,`overseas_warehouse_id`,`storage_billing_status`,`storage_started_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='每次退货签收生成的仓储费起算批次'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_receipt_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_receipt_item` ( + `id` bigint NOT NULL COMMENT '签收SKU明细ID', + `receipt_id` bigint NOT NULL COMMENT 'PDA退货签收单ID', + `return_order_item_id` bigint NOT NULL COMMENT '退货预报明细ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID和隔离键', + `sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'SKU编码', + `received_quantity` bigint NOT NULL COMMENT '本次SKU实收数量', + `box_count` bigint DEFAULT NULL COMMENT '本次 SKU 对应箱数', + PRIMARY KEY (`id`), + KEY `idx_ll_return_receipt_item_receipt` (`receipt_id`), + KEY `idx_ll_return_receipt_item_order_item` (`return_order_item_id`), + CONSTRAINT `chk_ll_return_receipt_item_quantity` CHECK ((`received_quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='PDA退货签收SKU实际数量明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_sku_conversion`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_sku_conversion` ( + `id` bigint NOT NULL COMMENT 'SKU库存转换记录ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户隔离键', + `overseas_warehouse_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作海外仓ID', + `application_id` bigint NOT NULL COMMENT '换标申请ID', + `relabel_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标单号', + `item_id` bigint NOT NULL COMMENT '换标箱级明细ID', + `round_key` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '作业轮次,退回重做使用新轮次', + `carton_id` bigint NOT NULL COMMENT '实物来源包裹或箱ID', + `carton_mark` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '完工时仓库识别码', + `old_sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标前SKU', + `new_sku_code` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标后SKU', + `product_name_zh` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '中文品名快照', + `quantity` bigint NOT NULL COMMENT '实际完成转换件数', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'APPLIED待客户确认/CONFIRMED已确认/REVERSED退回冲回', + `ownership_json` longtext CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次转换件数的来源退货单快照,供精确冲回', + `completed_at` datetime(3) NOT NULL COMMENT '实物SKU转换时间', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认时间', + `reversed_at` datetime(3) DEFAULT NULL COMMENT '退回冲回时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_return_sku_conversion_round` (`item_id`,`round_key`,`old_sku_code`,`new_sku_code`), + KEY `idx_return_sku_conversion_app` (`customer_id`,`application_id`,`status`), + KEY `idx_return_sku_conversion_carton` (`customer_id`,`carton_id`), + CONSTRAINT `chk_return_sku_conversion_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='退货实物SKU换标库存转换和冲回记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_return_stock_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_return_stock_receipt` ( + `id` bigint NOT NULL COMMENT '退回入仓实收凭据ID', + `customer_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据归属', + `receipt_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '退回实收业务编号', + `inventory_ledger_id` bigint NOT NULL COMMENT '唯一实际退回入账流水', + `exception_order_id` bigint NOT NULL COMMENT '退回异常订单', + `order_id` bigint NOT NULL COMMENT '原尾程订单', + `inbound_order_id` bigint NOT NULL COMMENT '原入仓单', + `source_outbound_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原发运出库业务编号', + `source_outbound_pallet_item_id` bigint NOT NULL COMMENT '原出库托盘明细', + `source_inventory_position_id` bigint NOT NULL COMMENT '原拣货库存位置', + `inventory_balance_id` bigint NOT NULL COMMENT '实际退回库存余额', + `inventory_position_id` bigint NOT NULL COMMENT '实际退回库存位置', + `received_packages` bigint NOT NULL COMMENT '实际退回箱数,不增加原订单箱数', + `received_at` datetime(3) NOT NULL COMMENT '实际退回入账时间', + `created_at` datetime(3) NOT NULL COMMENT '凭据记录时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_return_stock_receipt_no` (`customer_id`,`receipt_no`), + UNIQUE KEY `uk_return_stock_receipt_ledger` (`customer_id`,`inventory_ledger_id`), + KEY `idx_return_stock_receipt_order` (`customer_id`,`order_id`), + KEY `idx_return_stock_receipt_position` (`customer_id`,`inventory_balance_id`,`inventory_position_id`), + CONSTRAINT `chk_return_stock_receipt_packages` CHECK ((`received_packages` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='真实退回入仓及再次发运来源凭据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_security_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_security_event_inbox` ( + `event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '跨区安全事件ID', + `event_type` varchar(96) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'IAM安全事件类型', + `payload_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256哈希', + `payload_json` json NOT NULL COMMENT '冻结事件载荷JSON', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消费状态:RECEIVED或PROCESSED', + `result` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '处理结果:APPLIED、STALE、DUPLICATE或DUPLICATE_VERSION', + `received_at` datetime(3) NOT NULL COMMENT '接收时间', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (`event_id`), + KEY `idx_security_inbox_type_time` (`event_type`,`received_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全事件消费者收件箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_security_projection`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_security_projection` ( + `aggregate_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '聚合类型:USER、TENANT或AUTHORIZATION', + `aggregate_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户或客户聚合ID', + `tenant_id` bigint NOT NULL COMMENT '平台0或客户ID', + `security_status` varchar(24) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '当前安全状态:ACTIVE或DISABLED', + `permission_version` bigint DEFAULT NULL COMMENT '当前单调权限版本', + `roles_json` json NOT NULL COMMENT 'IAM投影的当前角色快照', + `authorities_json` json NOT NULL COMMENT 'IAM投影的当前海外权限快照', + `aggregate_version` bigint NOT NULL COMMENT '事件聚合单调版本', + `payload_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '当前版本载荷哈希', + `last_event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后应用的事件ID', + `changed_at` datetime(3) NOT NULL COMMENT '国内IAM事实发生时间', + `updated_at` datetime(3) NOT NULL COMMENT '投影更新时间', + PRIMARY KEY (`aggregate_type`,`aggregate_id`), + KEY `idx_security_projection_tenant` (`tenant_id`), + KEY `idx_security_projection_permission` (`aggregate_id`,`permission_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外IAM安全状态与权限版本投影'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_us_truck_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_us_truck_order` ( + `id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `order_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `transport_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `origin` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `origin_address` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `destination` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `destination_address` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `container_type` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `pallet_count` int NOT NULL DEFAULT '1', + `package_count` int NOT NULL DEFAULT '0', + `received_pallet_count` int NOT NULL DEFAULT '0', + `allocated_pallet_count` int NOT NULL DEFAULT '0', + `shipped_pallet_count` int NOT NULL DEFAULT '0', + `total_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000', + `total_volume_cbm` decimal(18,6) NOT NULL DEFAULT '0.000000', + `budget_amount` decimal(18,2) DEFAULT NULL, + `currency` varchar(3) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'USD', + `contact_name` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `contact_info` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `requested_date` date NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `receipt_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `receipt_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` bigint NOT NULL DEFAULT '0', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_us_truck_order_no` (`customer_id`,`order_no`), + UNIQUE KEY `uk_ll_us_truck_order_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_us_truck_order_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_us_truck_order_date` (`customer_id`,`requested_date`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='US domestic trucking customer order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_us_truck_order_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_us_truck_order_operation` ( + `id` bigint NOT NULL COMMENT '操作记录ID', + `order_id` bigint NOT NULL COMMENT '美国卡车订单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `operation_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '具名操作类型', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '操作请求内容哈希', + `result_version` bigint NOT NULL COMMENT '操作完成后的订单版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_us_truck_operation_key` (`customer_id`,`order_id`,`idempotency_key`), + KEY `idx_ll_us_truck_operation_order` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='美国卡车订单幂等操作账'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_us_truck_order_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_us_truck_order_pallet` ( + `id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sequence_no` int NOT NULL, + `source` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `warehouse_pallet_id` bigint DEFAULT NULL, + `package_count` int DEFAULT NULL, + `received_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_us_truck_order_pallet_no` (`customer_id`,`order_id`,`pallet_no`), + UNIQUE KEY `uk_ll_us_truck_order_pallet_seq` (`customer_id`,`order_id`,`sequence_no`), + KEY `idx_ll_us_truck_order_pallet_list` (`customer_id`,`order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_us_truck_order_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_us_truck_order_status_history` ( + `id` bigint NOT NULL, + `order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `operation_type` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'UPDATE' COMMENT '具名操作类型', + `from_status` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `to_status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operator_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '操作幂等键', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + KEY `idx_ll_us_truck_history` (`customer_id`,`order_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Immutable US trucking order status history'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_vehicle`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_vehicle` ( + `id` bigint NOT NULL COMMENT '车辆档案ID', + `plate_number` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车牌号', + `model` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车型', + `fuel_consumption_per100_km` decimal(10,2) NOT NULL COMMENT '百公里油耗,单位升', + `mileage_km` bigint NOT NULL COMMENT '当前公里码,单位公里', + `driver_or_dispatch_company` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机姓名或外派单位', + `phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '司机或外派单位联系电话', + `region` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '区域:DOMESTIC/OVERSEAS', + `ownership` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '车辆归属:OWNED/DISPATCHED', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '状态:IN_SERVICE/DISPATCHED/MAINTENANCE/INACTIVE/RETURNED', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `record_version` bigint NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '创建人', + `updated_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '更新人', + `created_at` datetime(3) NOT NULL COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '更新时间', + `max_pallet_spaces` int DEFAULT NULL COMMENT '最大标准托位,未知时为空', + `max_load_kg` decimal(12,3) DEFAULT NULL COMMENT '最大载重,千克', + `max_volume_cbm` decimal(12,3) DEFAULT NULL COMMENT '可用容积,立方米', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_vehicle_plate` (`plate_number`), + KEY `idx_ll_vehicle_page` (`ownership`,`region`,`status`,`updated_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台车辆主数据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_wallet_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_wallet_event_inbox` ( + `event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '国内Finance资金结果事件ID', + `command_event_id` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原海外资金指令事件ID', + `payload_hash` varchar(71) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '规范化载荷SHA-256,包含sha256:前缀', + `payload_json` json NOT NULL COMMENT '冻结事件信封', + `status` varchar(16) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'RECEIVED或PROCESSED', + `received_at` datetime(3) NOT NULL COMMENT '接收时间', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (`event_id`), + UNIQUE KEY `uk_ll_wallet_command_result` (`command_event_id`), + KEY `idx_ll_wallet_event_status` (`status`,`received_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='跨区客户资金结果幂等收件箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_carton` ( + `id` bigint NOT NULL COMMENT '预报箱主键', + `transfer_order_id` bigint NOT NULL COMMENT '转运单ID', + `transfer_item_id` bigint DEFAULT NULL COMMENT '实际箱内首项SKU明细ID,预报补箱可为空', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '系统生成的一箱一码箱唛', + `customer_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户原箱唛,用于换标现场绑定', + `carton_index` int NOT NULL COMMENT '全单箱序号', + `total_cartons` int NOT NULL COMMENT '首次生成时全单总箱数', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'EXPECTED' COMMENT '箱状态:EXPECTED待实收', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `length_cm` decimal(12,2) DEFAULT NULL COMMENT '箱子实测长度厘米', + `width_cm` decimal(12,2) DEFAULT NULL COMMENT '箱子实测宽度厘米', + `height_cm` decimal(12,2) DEFAULT NULL COMMENT '箱子实测高度厘米', + `gross_weight_kg` decimal(12,2) DEFAULT NULL COMMENT '箱子实测重量千克', + `counted_quantity` bigint DEFAULT NULL COMMENT '箱内SKU实点件数', + `count_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱内清点幂等键', + `count_request_hash` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱内清点请求摘要', + `counted_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '箱内清点操作人', + `counted_at` datetime(3) DEFAULT NULL COMMENT '箱内清点完成时间', + `discrepancy_reason` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '实收与预报箱数差异原因', + `transfer_pallet_id` bigint DEFAULT NULL COMMENT '签收箱所属转运托盘事实ID', + `sku_items_json` json DEFAULT NULL COMMENT '实收箱内SKU及逐项件数明细', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_carton_mark` (`customer_id`,`carton_mark`), + UNIQUE KEY `uk_ll_wh_transfer_carton_index` (`transfer_order_id`,`carton_index`), + KEY `idx_ll_wh_transfer_carton_list` (`customer_id`,`transfer_order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运系统箱唛预报'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_inventory_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_inventory_allocation` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `transfer_order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `inventory_position_id` bigint NOT NULL, + `inventory_balance_id` bigint NOT NULL, + `inbound_order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `pallet_id` bigint NOT NULL, + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `package_count` int NOT NULL, + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'RESERVED', + `active_position_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `allocated_at` datetime(3) NOT NULL, + `shipped_at` datetime(3) DEFAULT NULL, + `received_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '本次来源分配的实际SKU快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_allocation_position` (`transfer_order_id`,`inventory_position_id`), + UNIQUE KEY `uk_ll_wh_transfer_active_position` (`customer_id`,`active_position_key`), + KEY `idx_ll_wh_transfer_allocation_list` (`customer_id`,`transfer_order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Exclusive source inventory positions allocated to a warehouse transfer'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_item` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `transfer_order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `sku_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `product_name_zh` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `package_count` int NOT NULL, + `shipping_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `declared_quantity` bigint DEFAULT NULL COMMENT 'SKU鐢虫姤浠舵暟锛岄潪棰勮鎴栧疄鏀剁鏁�', + PRIMARY KEY (`id`), + KEY `idx_ll_wh_transfer_item_list` (`customer_id`,`transfer_order_id`), + KEY `idx_ll_wh_transfer_item_sku` (`customer_id`,`sku_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Frozen warehouse transfer cargo items'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_measurement_photo`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_measurement_photo` ( + `id` bigint NOT NULL COMMENT '测量照片引用ID', + `transfer_order_id` bigint NOT NULL COMMENT '仓储转运单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `file_id` bigint NOT NULL COMMENT '文件服务文件ID', + `file_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件名快照', + `content_type` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '文件媒体类型快照', + `size_bytes` bigint NOT NULL DEFAULT '0' COMMENT '文件字节数快照', + `uploaded_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA上传操作人', + `uploaded_at` datetime(3) NOT NULL COMMENT '上传时间', + `submitted_at` datetime(3) DEFAULT NULL COMMENT '随测量打托提交时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_measurement_photo_file` (`customer_id`,`transfer_order_id`,`file_id`), + KEY `idx_ll_transfer_measurement_photo_order` (`customer_id`,`transfer_order_id`,`uploaded_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA测量现场照片引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `transfer_no` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `inbound_order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `lastleg_order_id` bigint NOT NULL, + `lastleg_order_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `operating_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `fba_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA目的仓编码', + `delivery_address` varchar(1000) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '提货地址或中转仓收货地址', + `pickup_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系人', + `pickup_contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '提货联系电话', + `package_count` int NOT NULL, + `delivery_method` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '入仓方式', + `estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计入仓时间,库存转运不填写', + `estimated_ship_at` datetime(3) DEFAULT NULL COMMENT '预计发货时间', + `fba_shipment_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '可选FBA货件编号', + `tracking_no` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `fba_box_nos` varchar(2000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `need_relabel` tinyint NOT NULL DEFAULT '0', + `relabel_application_id` bigint DEFAULT NULL, + `relabel_no` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `relabel_completed_at` datetime(3) DEFAULT NULL COMMENT '鍏宠仈璐存爣瀹屾垚鏃堕棿', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'PENDING_REVIEW', + `review_decision` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核决定', + `review_remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核备注或驳回原因', + `reviewed_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '审核人ID', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间', + `allocation_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配幂等键', + `allocation_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '库存分配请求哈希', + `outbound_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库幂等键', + `outbound_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '出库请求哈希', + `outbound_at` datetime(3) DEFAULT NULL COMMENT '出库时间', + `measured_at` datetime(3) DEFAULT NULL COMMENT 'PDA测量完成时间', + `actual_ship_at` datetime(3) DEFAULT NULL COMMENT '实际发货时间', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + `label_deadline` datetime(3) DEFAULT NULL COMMENT '换标截止时间', + `relabel_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标联系人', + `relabel_contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标联系电话', + `relabel_charge_amount` decimal(20,2) DEFAULT NULL COMMENT '换标费用CNY', + `relabel_delivery_method` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '换标后派送方式', + `palletize_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA打托幂等键', + `palletize_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'PDA打托请求哈希', + `confirmation_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认幂等键', + `confirmation_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '客户测量确认请求哈希', + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '客户确认测量时间', + `destination_type` varchar(16) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的类型:FBA或PRIVATE', + `destination_warehouse_id` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的仓名称快照', + `private_destination_address` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓完整地址', + `private_destination_postal_code` varchar(32) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓邮编', + `private_destination_contact_name` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系人', + `private_destination_contact_phone` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '私人仓联系电话', + `destination_binding_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定幂等键', + `destination_binding_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目的地绑定请求哈希', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_no` (`customer_id`,`transfer_no`), + UNIQUE KEY `uk_ll_wh_transfer_key` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_wh_transfer_tail_order` (`lastleg_order_id`), + KEY `idx_ll_wh_transfer_list` (`customer_id`,`status`,`created_at`), + KEY `idx_ll_wh_transfer_inbound` (`customer_id`,`inbound_code`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Customer warehouse transfer order'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_pallet` ( + `id` bigint NOT NULL COMMENT '打托事实ID', + `transfer_order_id` bigint NOT NULL COMMENT '转运单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `sequence_no` int NOT NULL COMMENT '托序号', + `warehouse_pallet_id` bigint DEFAULT NULL COMMENT '鐢熸垚鍚庣殑娴峰?浠撶湡瀹炴墭鐩業D', + `pallet_no` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鐢熸垚鍚庣殑鐪熷疄鎵樼洏鐮', + `warehouse_location_id` bigint DEFAULT NULL COMMENT '鐢熸垚鍚庣殑鐪熷疄搴撲綅ID', + `warehouse_location_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实库位码', + `warehouse_resource_lock_id` bigint DEFAULT NULL COMMENT '鐢熸垚鍚庣殑浠撳偍璧勬簮閿両D', + `carton_count` int NOT NULL COMMENT '本托实收箱数', + `length_cm` decimal(12,2) NOT NULL COMMENT '实测长cm', + `width_cm` decimal(12,2) NOT NULL COMMENT '实测宽cm', + `height_cm` decimal(12,2) NOT NULL COMMENT '实测高cm', + `gross_weight_kg` decimal(12,3) NOT NULL COMMENT '实测毛重kg', + `volume_cbm` decimal(18,6) NOT NULL COMMENT '实测体积CBM', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '打托状态', + `draft_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鑽夌?淇濆瓨骞傜瓑閿', + `draft_request_hash` char(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鑽夌?璇锋眰鎽樿?', + `generate_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鏂版墭鐩樼敓鎴愬箓绛夐敭', + `bind_idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '鎵?爜缁戝畾骞傜瓑閿', + `bound_at` datetime(3) DEFAULT NULL COMMENT '鎵?爜纭??缁戝畾鏃堕棿', + `completed_at` datetime(3) DEFAULT NULL COMMENT '鏁村崟鎵撴墭瀹屾垚鏃堕棿', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_seq` (`transfer_order_id`,`sequence_no`), + UNIQUE KEY `uk_ll_transfer_pallet_physical` (`customer_id`,`warehouse_pallet_id`), + KEY `idx_ll_transfer_pallet_order` (`customer_id`,`transfer_order_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运PDA实测打托事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_pallet_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_pallet_item` ( + `id` bigint NOT NULL COMMENT '主键ID', + `transfer_order_id` bigint NOT NULL COMMENT '转运单ID', + `transfer_pallet_id` bigint NOT NULL COMMENT '转运托盘事实ID', + `transfer_item_id` bigint NOT NULL COMMENT '转运申报明细ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `package_count` int NOT NULL COMMENT '本托该SKU实收箱数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_pallet_item` (`transfer_pallet_id`,`transfer_item_id`), + KEY `idx_ll_transfer_pallet_item_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运每托SKU箱数回填'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_receipt` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `transfer_order_id` bigint NOT NULL, + `allocation_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_inventory_position_id` bigint NOT NULL, + `destination_inventory_balance_id` bigint NOT NULL, + `destination_inventory_position_id` bigint NOT NULL, + `package_count` int NOT NULL, + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `inventory_operation_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `received_at` datetime(3) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_receipt_source` (`transfer_order_id`,`source_inventory_position_id`), + UNIQUE KEY `uk_ll_wh_transfer_receipt_key` (`customer_id`,`idempotency_key`), + UNIQUE KEY `uk_ll_wh_transfer_receipt_operation` (`customer_id`,`inventory_operation_key`), + KEY `idx_ll_wh_transfer_receipt_list` (`customer_id`,`transfer_order_id`,`received_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Idempotent destination stock receipt for one transfer source position'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_receipt_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_receipt_command` ( + `id` bigint NOT NULL COMMENT '收货命令主键', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户标识', + `transfer_order_id` bigint NOT NULL COMMENT '转运单标识', + `source_inventory_position_id` bigint NOT NULL COMMENT '本次收货来源库存位置', + `idempotency_key` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '原始收货幂等键', + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '完整原始收货请求摘要', + `request_json` json NOT NULL COMMENT '完整原始收货请求快照', + `target_warehouse_id` bigint NOT NULL COMMENT '实际目标海外收货仓', + `target_pallet_id` bigint NOT NULL COMMENT '目标实物托盘', + `target_location_id` bigint NOT NULL COMMENT '目标实际库位', + `target_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '共享目标资源绑定摘要', + `target_json` json NOT NULL COMMENT '权威目标仓托盘库位及目的仓快照', + `reservation_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '同转运同目标托盘共享资源占用键', + `resource_lock_id` bigint DEFAULT NULL COMMENT '已确认有效的海外资源锁标识', + `status` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '命令状态PREPARED准备完成RESERVED资源占用COMPLETED收货完成', + `created_at` datetime(3) NOT NULL COMMENT '命令创建时间', + `updated_at` datetime(3) NOT NULL COMMENT '命令最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_receipt_cmd_source` (`transfer_order_id`,`source_inventory_position_id`), + UNIQUE KEY `uk_ll_wh_receipt_cmd_key` (`customer_id`,`idempotency_key`), + KEY `idx_ll_wh_receipt_cmd_target` (`customer_id`,`transfer_order_id`,`target_pallet_id`), + CONSTRAINT `ck_ll_wh_receipt_cmd_status` CHECK ((`status` in (_utf8mb4'PREPARED',_utf8mb4'RESERVED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='转运逐来源收货原请求与共享目标资源占用命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_relabel_binding`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_relabel_binding` ( + `id` bigint NOT NULL COMMENT '主键ID', + `transfer_order_id` bigint NOT NULL COMMENT '转运单ID', + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户租户ID', + `operation_type` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '换标类型', + `customer_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户原箱唛', + `original_sku` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '旧SKU', + `new_sku` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新SKU', + `quantity` int NOT NULL COMMENT '换标件数', + `fba_carton_mark` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '新FBA箱唛', + `destination_warehouse_code` varchar(128) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'FBA目的仓', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `source_allocation_id` bigint DEFAULT NULL COMMENT '已有库存转运的原始来源分配ID', + `relabel_item_id` bigint DEFAULT NULL COMMENT '本票关联换标明细ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_transfer_relabel_mark` (`transfer_order_id`,`customer_carton_mark`), + KEY `idx_ll_transfer_relabel_order` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='仓储转运换标预绑定明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `ll_warehouse_transfer_source_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `ll_warehouse_transfer_source_order` ( + `id` bigint NOT NULL COMMENT 'Snowflake ID', + `transfer_order_id` bigint NOT NULL, + `customer_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `source_lastleg_order_id` bigint NOT NULL, + `source_lastleg_order_no` varchar(32) COLLATE utf8mb4_unicode_ci NOT NULL, + `inbound_order_id` bigint NOT NULL, + `inbound_code` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `tenant_id` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'BAIYUE' COMMENT 'SaaS租户标识,当前单租户固定为BAIYUE', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ll_wh_transfer_source` (`transfer_order_id`,`source_lastleg_order_id`), + KEY `idx_ll_wh_transfer_source_list` (`customer_id`,`transfer_order_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='Selected historical customer orders and inbound bindings'; +/*!40101 SET character_set_client = @saved_cs_client */; + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `baiyue_firstleg` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; + +USE `baiyue_firstleg`; +DROP TABLE IF EXISTS `event_delivery_attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `event_delivery_attempt` ( + `attempt_id` varchar(36) NOT NULL COMMENT '本次投递租约UUID和审计主键', + `service_name` varchar(32) NOT NULL COMMENT '本库所属生产服务', + `event_type` varchar(96) NOT NULL COMMENT '白名单业务事件类型', + `entry_id` varchar(128) NOT NULL COMMENT '生产者发件箱主键', + `event_id` varchar(128) DEFAULT NULL COMMENT '合法原事件UUID或ULID;格式错误时为空', + `target_name` varchar(64) NOT NULL COMMENT '固定目标业务服务', + `started_at` datetime(3) NOT NULL COMMENT '领取及审计事务提交前的开始时间', + `finished_at` datetime(3) DEFAULT NULL COMMENT '尝试完成或租约过期恢复时间', + `duration_ms` bigint DEFAULT NULL COMMENT '尝试耗时毫秒', + `outcome` varchar(24) NOT NULL COMMENT 'STARTED/APPLIED/DUPLICATE/STALE/RETRY/DEAD/LEASE_EXPIRED', + `error_code` varchar(64) DEFAULT NULL COMMENT '有限错误类别或HTTP状态;不含载荷与令牌', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '本次失败后下次计划投递时间', + PRIMARY KEY (`attempt_id`), + KEY `idx_delivery_attempt_entry` (`service_name`,`entry_id`,`started_at`), + KEY `idx_delivery_attempt_outcome` (`outcome`,`started_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='本服务业务事件逐次投递审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_assignment_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_assignment_history` ( + `id` bigint unsigned NOT NULL COMMENT '指派历史ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `task_id` bigint unsigned NOT NULL COMMENT '所属履约任务ID', + `previous_assignee_id` bigint unsigned DEFAULT NULL COMMENT '原负责人ID', + `assignee_id` bigint unsigned NOT NULL COMMENT '新负责人ID', + `assignee_name` varchar(64) NOT NULL COMMENT '新负责人名称快照', + `reason` varchar(500) DEFAULT NULL COMMENT '指派或改派原因', + `operator_id` bigint unsigned NOT NULL COMMENT '执行指派的操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '指派发生时间', + PRIMARY KEY (`id`), + KEY `idx_assignment_task_time` (`tenant_id`,`task_id`,`created_at`), + KEY `fk_assignment_task` (`task_id`), + CONSTRAINT `fk_assignment_task` FOREIGN KEY (`task_id`) REFERENCES `fl_fulfillment_task` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程任务指派历史表:不可变记录每次负责人变更及原因,用于追踪和审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_fba_rate`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_fba_rate` ( + `id` bigint unsigned NOT NULL COMMENT '船司FBA价格ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '平台价格作用域ID,船司价格固定为0', + `price_book_version_id` bigint unsigned DEFAULT NULL COMMENT '整表价格版本ID;历史兼容价格可为空', + `rate_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台公共价格编码', + `rate_version` int unsigned NOT NULL COMMENT '价格版本号', + `origin_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '确认前可为空;确认后绑定平台公共起运仓逻辑ID', + `origin_warehouse_code` varchar(64) DEFAULT NULL COMMENT '起运仓稳定编码', + `origin_warehouse_name` varchar(128) NOT NULL COMMENT '起运仓名称快照', + `business_type` varchar(32) NOT NULL COMMENT '业务或计费类型', + `channel_code` varchar(64) DEFAULT NULL COMMENT '渠道编码', + `price_model` varchar(32) NOT NULL DEFAULT 'FBA_WAREHOUSE_RATE' COMMENT '价格模型', + `carrier_id` bigint unsigned DEFAULT NULL COMMENT '船司逻辑ID', + `carrier_name` varchar(128) DEFAULT NULL COMMENT '历史承运商名称;渠道价格可为空', + `destination_type` varchar(16) DEFAULT NULL COMMENT '目的地类型:FBA或OVERSEAS;REGION_WEIGHT_TIER区域阶梯行为空', + `destination_code` varchar(32) DEFAULT NULL COMMENT 'FBA目的仓编码;区域阶梯模型为空', + `destination_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '平台海外仓稳定ID;不存环境专属ID', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `region_code` varchar(16) DEFAULT NULL COMMENT '区域阶梯模型区域编码', + `pricing_tax_mode` varchar(16) DEFAULT NULL COMMENT '报价包税维度;独立于订单tax_mode', + `weight_from_kg` decimal(18,6) DEFAULT NULL COMMENT '区域阶梯起始计费重量KG', + `charge_unit` varchar(16) NOT NULL COMMENT '计费单位:KG或CBM', + `unit_price` decimal(18,6) NOT NULL COMMENT '基础单价', + `currency` char(3) NOT NULL COMMENT '币种', + `additional_fee` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '固定附加费', + `source_sheet_name` varchar(128) DEFAULT NULL COMMENT '原始Excel Sheet', + `source_row_number` int unsigned DEFAULT NULL COMMENT '原始Excel行号', + `source_destination_text` varchar(1000) DEFAULT NULL COMMENT '原始组合仓库或区域文本', + `effective_from` datetime(3) NOT NULL COMMENT '生效开始时间', + `effective_to` datetime(3) DEFAULT NULL COMMENT '生效结束时间', + `status` varchar(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '价格状态:PENDING_CONFIRMATION、ACTIVE或SUPERSEDED', + `remark` varchar(500) DEFAULT NULL COMMENT '价格备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fba_rate_version` (`tenant_id`,`rate_code`,`rate_version`), + KEY `idx_fba_rate_unique_match` (`tenant_id`,`origin_warehouse_id`,`business_type`,`channel_code`,`carrier_id`,`destination_code`,`charge_unit`,`status`,`effective_from`,`effective_to`), + KEY `idx_fba_rate_match` (`tenant_id`,`origin_warehouse_id`,`business_type`,`channel_code`,`carrier_id`,`destination_type`,`destination_code`,`destination_warehouse_id`,`charge_unit`,`status`,`effective_from`,`effective_to`), + KEY `idx_fba_rate_book` (`price_book_version_id`,`channel_code`,`origin_warehouse_id`,`price_model`,`status`), + KEY `idx_fba_rate_destination` (`price_book_version_id`,`channel_code`,`origin_warehouse_id`,`destination_type`,`destination_warehouse_id`,`destination_code`), + KEY `idx_fba_rate_region` (`price_book_version_id`,`channel_code`,`origin_warehouse_id`,`region_code`,`weight_from_kg`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司FBA目的仓价格版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_exception_fact`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_exception_fact` ( + `id` bigint unsigned NOT NULL COMMENT '船节点异常事实ID', + `query_id` bigint unsigned NOT NULL COMMENT '所属查询任务ID', + `event_key` varchar(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + `provider_code` varchar(32) NOT NULL COMMENT '供应商编码', + `provider_event_id` varchar(128) NOT NULL COMMENT '供应商稳定事件ID', + `provider_status` varchar(128) NOT NULL COMMENT '供应商原始异常状态', + `exception_code` varchar(32) NOT NULL COMMENT '平台异常编码', + `active` tinyint(1) NOT NULL COMMENT '1异常发生;0异常解除', + `observed_at` datetime(3) NOT NULL COMMENT '供应商观测时间', + `location_code` varchar(32) DEFAULT NULL COMMENT '地点编码', + `location_name` varchar(128) DEFAULT NULL COMMENT '地点名称', + `source_reference` varchar(500) DEFAULT NULL COMMENT '可审计来源引用', + `raw_payload_hash` char(64) NOT NULL COMMENT '原始响应SHA-256', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carrier_schedule_exception_event` (`query_id`,`event_key`), + KEY `idx_carrier_schedule_exception_timeline` (`query_id`,`observed_at`,`id`), + CONSTRAINT `fk_carrier_schedule_exception_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_exception_active` CHECK ((`active` in (0,1))), + CONSTRAINT `chk_carrier_schedule_exception_code` CHECK ((`exception_code` in (_utf8mb4'CUSTOMS_HOLD',_utf8mb4'TERMINAL_HOLD',_utf8mb4'DOCUMENT_HOLD',_utf8mb4'UNKNOWN_HOLD'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船节点异常发生与解除不可变事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_node_fact`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_node_fact` ( + `id` bigint unsigned NOT NULL COMMENT '标准船节点事实ID', + `query_id` bigint unsigned NOT NULL COMMENT '所属查询任务ID', + `event_key` varchar(192) NOT NULL COMMENT '供应商编码与供应商事件ID组成的幂等键', + `provider_code` varchar(32) NOT NULL COMMENT '轨迹供应商编码', + `provider_event_id` varchar(128) NOT NULL COMMENT '供应商稳定事件ID', + `provider_node_code` varchar(64) NOT NULL COMMENT '供应商原始节点编码', + `standard_node` varchar(32) NOT NULL COMMENT '标准船节点', + `node_sequence` int unsigned NOT NULL COMMENT '标准节点单调序号', + `observed_at` datetime(3) NOT NULL COMMENT '供应商观测时间', + `estimated_at` datetime(3) DEFAULT NULL COMMENT '供应商预计发生时间', + `actual_at` datetime(3) DEFAULT NULL COMMENT '供应商确认实际发生时间', + `location_code` varchar(32) DEFAULT NULL COMMENT '节点地点编码', + `location_name` varchar(128) DEFAULT NULL COMMENT '节点地点名称', + `source_reference` varchar(500) DEFAULT NULL COMMENT '可审计来源引用', + `raw_payload_hash` char(64) NOT NULL COMMENT '原始响应SHA-256;不保存敏感原文', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事实入库时间', + `container_booking_id` bigint unsigned NOT NULL COMMENT '所属真实货柜ID', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carrier_schedule_node_event` (`query_id`,`event_key`), + KEY `idx_carrier_schedule_node_timeline` (`query_id`,`node_sequence`,`observed_at`,`id`), + CONSTRAINT `fk_carrier_schedule_node_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_node_sequence` CHECK ((`node_sequence` in (10,20,30,40,50,60,70,80,90,100))), + CONSTRAINT `chk_carrier_schedule_node_standard` CHECK ((`standard_node` in (_utf8mb4'DEPARTED_WAREHOUSE',_utf8mb4'AT_ORIGIN_PORT',_utf8mb4'LOADED_ON_VESSEL',_utf8mb4'DEPARTED_ORIGIN_PORT',_utf8mb4'IN_TRANSIT',_utf8mb4'ARRIVED_DESTINATION_PORT',_utf8mb4'DISCHARGED_FROM_VESSEL',_utf8mb4'PICKUP_READY',_utf8mb4'PICKED_UP',_utf8mb4'EMPTY_RETURNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='供应商标准船节点不可变事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_query`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_query` ( + `id` bigint unsigned NOT NULL COMMENT '船司官网查询任务ID', + `query_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船司官网查询任务编号', + `customer_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '关联海运订舱ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '指定查询的柜次ID;整票订舱查询时为空', + `container_scope_id` bigint unsigned GENERATED ALWAYS AS (coalesce(`container_booking_id`,0)) STORED COMMENT '柜次唯一作用域计算值;整票订舱查询固定为0', + `carrier_code` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船公司编码:MATSON美森、SML森罗、ZIM以星、COSCO中远海运、EMC长荣、HEDE合德', + `tracking_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '官网跟踪类型:BOOKING_NO订舱号、BILL_OF_LADING提单号、CONTAINER_NO箱号', + `tracking_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '提交至船公司官网的跟踪号码', + `official_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船公司官网查询入口地址快照', + `access_mode` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '官网访问方式:PUBLIC_FORM公开表单、HCAPTCHA人机校验、DYNAMIC_IFRAME动态页面', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '任务状态:READY可查询、MANUAL_REQUIRED需人工处理、SUCCESS已取得有效结果', + `window_started_at` datetime(3) NOT NULL COMMENT '当前三天查询窗口起始时间', + `attempt_count` int unsigned NOT NULL DEFAULT '0' COMMENT '当前查询窗口已执行次数;最多3次', + `next_allowed_at` datetime(3) NOT NULL COMMENT '频控规则允许的下次查询时间', + `last_attempt_at` datetime(3) DEFAULT NULL COMMENT '最近一次查询尝试时间', + `last_success_at` datetime(3) DEFAULT NULL COMMENT '最近一次取得有效结果时间', + `source_updated_at` datetime(3) DEFAULT NULL COMMENT '船公司官网展示的结果更新时间', + `expires_at` datetime(3) DEFAULT NULL COMMENT '当前最近有效结果过期时间;到期后仍保留但标记过期', + `result_port_schedule_id` bigint unsigned DEFAULT NULL COMMENT '最近有效结果对应的船期记录ID', + `last_attempt_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近查询尝试状态快照', + `manual_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '需要人工查询或录入的原因', + `last_error_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近查询失败错误码', + `last_error_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近查询失败错误说明', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '平台内部备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `consecutive_failures` int unsigned NOT NULL DEFAULT '0' COMMENT '当前正常查询槽位内的连续技术失败次数', + `latest_node_observed_at` datetime(3) DEFAULT NULL COMMENT '最近实际标准节点发生时间', + `latest_standard_node` varchar(32) DEFAULT NULL COMMENT '最近实际标准船节点;预计事实不推进', + `lease_owner` varchar(64) DEFAULT NULL COMMENT '当前调度租约持有者', + `lease_until` datetime(3) DEFAULT NULL COMMENT '当前调度租约过期时间', + `stop_reason` varchar(500) DEFAULT NULL COMMENT '任务停止原因', + `stopped_at` datetime(3) DEFAULT NULL COMMENT '任务停止时间', + `trigger_source` varchar(32) NOT NULL DEFAULT 'MANUAL' COMMENT '任务触发来源:OUTBOUND_CONFIRM或MANUAL', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carrier_schedule_query_no` (`query_no`), + UNIQUE KEY `uk_carrier_schedule_query_scope` (`customer_id`,`ocean_booking_id`,`container_scope_id`,`carrier_code`,`tracking_type`,`tracking_no`), + KEY `idx_carrier_schedule_query_due` (`status`,`next_allowed_at`,`id`), + KEY `idx_carrier_schedule_query_customer` (`customer_id`,`updated_at`,`id`), + KEY `idx_carrier_schedule_query_expiry` (`expires_at`,`id`), + KEY `fk_carrier_schedule_query_booking` (`ocean_booking_id`), + KEY `fk_carrier_schedule_query_container` (`container_booking_id`), + KEY `fk_carrier_schedule_query_result` (`result_port_schedule_id`), + CONSTRAINT `fk_carrier_schedule_query_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `fk_carrier_schedule_query_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `fk_carrier_schedule_query_result` FOREIGN KEY (`result_port_schedule_id`) REFERENCES `fl_port_schedule` (`id`), + CONSTRAINT `chk_carrier_schedule_query_attempt` CHECK ((`attempt_count` between 0 and 3)), + CONSTRAINT `chk_carrier_schedule_query_customer` CHECK ((`customer_id` > 0)), + CONSTRAINT `chk_carrier_schedule_query_tracking` CHECK ((`tracking_type` in (_utf8mb4'BOOKING_NO',_utf8mb4'BILL_OF_LADING',_utf8mb4'CONTAINER_NO'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='六家船公司官网查询任务与三天窗口频控'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_query_attempt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_query_attempt` ( + `id` bigint unsigned NOT NULL COMMENT '船司官网查询尝试ID', + `query_id` bigint unsigned NOT NULL COMMENT '所属船司官网查询任务ID', + `window_attempt_no` int unsigned NOT NULL COMMENT '当前三天窗口内的尝试序号;取值1至3', + `carrier_code` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次尝试的船公司编码快照', + `access_mode` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次尝试的官网访问方式快照', + `official_url` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本次尝试使用的官网地址快照', + `attempt_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '尝试结果:MANUAL_REQUIRED需人工处理、SUCCESS成功、FAILED失败', + `coverage_gap` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否存在自动化覆盖缺口:0否、1是', + `coverage_gap_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '自动化覆盖缺口编码', + `manual_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '转人工处理原因', + `error_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '查询失败错误码', + `error_message` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '查询失败错误说明', + `operator_id` bigint unsigned NOT NULL COMMENT '执行本次尝试的操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '尝试记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_carrier_schedule_attempt_query` (`query_id`,`created_at`,`id`), + CONSTRAINT `fk_carrier_schedule_attempt_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_attempt_no` CHECK ((`window_attempt_no` between 1 and 3)), + CONSTRAINT `chk_carrier_schedule_attempt_status` CHECK ((`attempt_status` in (_utf8mb4'MANUAL_REQUIRED',_utf8mb4'SUCCESS',_utf8mb4'FAILED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司官网查询尝试不可变审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_query_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_query_history` ( + `id` bigint unsigned NOT NULL COMMENT '船司查询任务历史ID', + `query_id` bigint unsigned NOT NULL COMMENT '所属船司官网查询任务ID', + `action` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '历史动作:CREATE创建、ATTEMPT执行查询、MANUAL_RESULT录入人工结果', + `from_version` int unsigned DEFAULT NULL COMMENT '变更前任务版本;创建时为空', + `to_version` int unsigned NOT NULL COMMENT '变更后任务版本', + `reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作原因或说明', + `before_snapshot` json DEFAULT NULL COMMENT '变更前任务数据快照;创建时为空', + `after_snapshot` json NOT NULL COMMENT '变更后任务数据快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_carrier_schedule_history_query` (`query_id`,`created_at`,`id`), + CONSTRAINT `fk_carrier_schedule_history_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_history_action` CHECK ((`action` in (_utf8mb4'CREATE',_utf8mb4'ATTEMPT',_utf8mb4'MANUAL_RESULT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司查询任务不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_carrier_schedule_query_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_carrier_schedule_query_operation` ( + `id` bigint unsigned NOT NULL COMMENT '船司查询写操作幂等记录ID', + `query_id` bigint unsigned DEFAULT NULL COMMENT '关联查询任务ID;创建任务落库前允许为空', + `operator_id` bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + `operation` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端写操作幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + `result_json` json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carrier_schedule_operation` (`operator_id`,`idempotency_key`), + KEY `idx_carrier_schedule_operation_query` (`query_id`,`created_at`,`id`), + CONSTRAINT `fk_carrier_schedule_operation_query` FOREIGN KEY (`query_id`) REFERENCES `fl_carrier_schedule_query` (`id`), + CONSTRAINT `chk_carrier_schedule_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='船司查询写操作幂等与请求摘要'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_coload_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_coload_command` ( + `id` bigint unsigned NOT NULL COMMENT '幂等命令ID', + `actor_id` bigint unsigned NOT NULL COMMENT '平台或内部操作人ID', + `action_type` varchar(16) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `request_hash` char(64) NOT NULL, + `command_status` varchar(16) NOT NULL COMMENT 'PROCESSING、SUCCESS', + `result_group_id` bigint unsigned DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `completed_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_coload_command` (`actor_id`,`action_type`,`idempotency_key`), + KEY `fk_coload_command_group` (`result_group_id`), + CONSTRAINT `fk_coload_command_group` FOREIGN KEY (`result_group_id`) REFERENCES `fl_coload_group` (`id`), + CONSTRAINT `chk_coload_command_status` CHECK ((`command_status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组幂等写命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_coload_group`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_coload_group` ( + `id` bigint unsigned NOT NULL COMMENT '同柜不可拆组ID', + `group_no` varchar(40) NOT NULL COMMENT '稳定组号CLG-{id}', + `group_status` varchar(16) NOT NULL COMMENT 'ACTIVE、LOCKED、CONSUMED、CANCELLED', + `warehouse_id` bigint unsigned NOT NULL COMMENT '国内收货仓ID快照', + `channel_code` varchar(64) NOT NULL COMMENT '物流渠道编码快照', + `locked_plan_id` bigint unsigned DEFAULT NULL COMMENT 'LOCKED/CONSUMED绑定装柜计划ID', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观版本', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `locked_by` bigint unsigned DEFAULT NULL, + `locked_by_name` varchar(64) DEFAULT NULL, + `locked_at` datetime(3) DEFAULT NULL, + `consumed_by` bigint unsigned DEFAULT NULL, + `consumed_by_name` varchar(64) DEFAULT NULL, + `consumed_at` datetime(3) DEFAULT NULL, + `cancelled_by` bigint unsigned DEFAULT NULL, + `cancelled_by_name` varchar(64) DEFAULT NULL, + `cancelled_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_coload_group_no` (`group_no`), + KEY `idx_coload_group_status` (`group_status`,`updated_at`,`id`), + KEY `idx_coload_group_plan` (`locked_plan_id`,`group_status`), + CONSTRAINT `fk_coload_group_plan` FOREIGN KEY (`locked_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `chk_coload_group_lock` CHECK ((((`group_status` in (_utf8mb4'ACTIVE',_utf8mb4'CANCELLED')) and (`locked_plan_id` is null)) or ((`group_status` in (_utf8mb4'LOCKED',_utf8mb4'CONSUMED')) and (`locked_plan_id` is not null)))), + CONSTRAINT `chk_coload_group_status` CHECK ((`group_status` in (_utf8mb4'ACTIVE',_utf8mb4'LOCKED',_utf8mb4'CONSUMED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜且不可拆业务组聚合根'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_coload_group_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_coload_group_history` ( + `id` bigint unsigned NOT NULL COMMENT '状态历史ID', + `group_id` bigint unsigned NOT NULL, + `action_type` varchar(16) NOT NULL COMMENT 'CREATE、CANCEL、LOCK、UNLOCK、CONSUME', + `from_status` varchar(16) DEFAULT NULL, + `to_status` varchar(16) NOT NULL, + `plan_id` bigint unsigned DEFAULT NULL, + `operation_id` varchar(128) NOT NULL COMMENT '稳定幂等操作ID', + `remark` varchar(500) DEFAULT NULL, + `operator_id` bigint unsigned NOT NULL, + `operator_name` varchar(64) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_coload_history_operation` (`group_id`,`action_type`,`operation_id`), + KEY `idx_coload_history_group` (`group_id`,`created_at`,`id`), + CONSTRAINT `fk_coload_history_group` FOREIGN KEY (`group_id`) REFERENCES `fl_coload_group` (`id`), + CONSTRAINT `chk_coload_history_action` CHECK ((`action_type` in (_utf8mb4'CREATE',_utf8mb4'CANCEL',_utf8mb4'LOCK',_utf8mb4'UNLOCK',_utf8mb4'CONSUME'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组不可变状态历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_coload_group_member`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_coload_group_member` ( + `id` bigint unsigned NOT NULL COMMENT '组成员ID', + `group_id` bigint unsigned NOT NULL COMMENT '同柜不可拆组ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '成员客户ID;允许跨客户成组', + `order_id` bigint unsigned NOT NULL COMMENT '订单ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '创建时权威入库单ID', + `full_package_count` int unsigned NOT NULL COMMENT '必须整组分配的满箱数快照', + `active_flag` tinyint unsigned DEFAULT '1' COMMENT '当前约束唯一标识;取消或消费后置NULL', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `closed_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_coload_group_member` (`group_id`,`inbound_id`), + UNIQUE KEY `uk_coload_active_inbound` (`tenant_id`,`inbound_id`,`active_flag`), + KEY `idx_coload_member_order` (`order_id`,`created_at`,`group_id`), + KEY `fk_coload_member_inbound` (`inbound_id`), + CONSTRAINT `fk_coload_member_group` FOREIGN KEY (`group_id`) REFERENCES `fl_coload_group` (`id`), + CONSTRAINT `fk_coload_member_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_coload_member_active` CHECK (((`active_flag` is null) or (`active_flag` = 1))), + CONSTRAINT `chk_coload_member_packages` CHECK ((`full_package_count` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='同柜不可拆组成员与满箱数快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_consumed_event`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_consumed_event` ( + `event_id` bigint unsigned NOT NULL COMMENT '已消费领域事件ID,与生产端Outbox事件ID一致', + `event_type` varchar(64) NOT NULL COMMENT '已消费事件类型', + `consumed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次成功消费时间', + PRIMARY KEY (`event_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程消息消费幂等表:记录已处理事件并按事件ID去重,防止RocketMQ重复投递造成重复建单;不是消息发布表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_booking`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_booking` ( + `id` bigint unsigned NOT NULL COMMENT '货柜预订ID(雪花ID)', + `booking_no` varchar(40) NOT NULL COMMENT '平台唯一货柜预订单号', + `container_no` varchar(64) DEFAULT NULL COMMENT '集装箱号;确认前允许修订', + `cabinet_no` varchar(64) NOT NULL COMMENT '平台唯一内部柜号', + `channel_code` varchar(64) DEFAULT NULL COMMENT '订舱渠道编码快照', + `channel_name` varchar(128) DEFAULT NULL COMMENT '订舱渠道名称快照', + `ocean_booking_id` bigint unsigned DEFAULT NULL COMMENT '来源订舱ID', + `ocean_booking_requirement_id` bigint unsigned DEFAULT NULL COMMENT '来源订舱箱型需求ID', + `warehouse_id` bigint unsigned NOT NULL COMMENT '装柜仓库ID;平台公共资源', + `warehouse_name` varchar(128) NOT NULL COMMENT '装柜仓库名称快照', + `bill_of_lading_no` varchar(128) DEFAULT NULL COMMENT '提单号', + `vessel_name` varchar(128) DEFAULT NULL COMMENT '船名快照', + `voyage_no` varchar(64) DEFAULT NULL COMMENT '航次;确认前允许修订', + `container_type` varchar(32) NOT NULL COMMENT '箱型,例如20GP、40GP、40HQ', + `shipping_company_id` bigint unsigned DEFAULT NULL COMMENT '船司ID快照', + `shipping_company_name` varchar(128) DEFAULT NULL COMMENT '船司名称快照', + `departure_port_code` varchar(32) DEFAULT NULL COMMENT '起运港编码快照', + `departure_port_name` varchar(128) DEFAULT NULL COMMENT '起运港名称快照', + `destination_port_code` varchar(32) DEFAULT NULL COMMENT '目的港编码快照', + `destination_port_name` varchar(128) DEFAULT NULL COMMENT '目的港名称快照', + `destination_warehouse_code` varchar(64) DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `capacity_cbm` decimal(18,4) DEFAULT NULL COMMENT '货柜容量m3', + `max_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '货柜最大承重kg', + `terminal_trucking_company` varchar(128) DEFAULT NULL COMMENT '码头拖车公司', + `loading_at` datetime(3) DEFAULT NULL COMMENT '计划或实际装柜时间', + `estimated_sailing_at` datetime(3) DEFAULT NULL COMMENT '预计开船时间', + `actual_sailing_at` datetime(3) DEFAULT NULL COMMENT '实际开船时间', + `estimated_port_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到港时间', + `actual_port_arrival_at` datetime(3) DEFAULT NULL COMMENT '实际到港时间', + `estimated_warehouse_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到仓时间', + `actual_warehouse_arrival_at` datetime(3) DEFAULT NULL COMMENT '实际到仓时间', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_by_name` varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜资料最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `source_type` varchar(24) NOT NULL DEFAULT 'SIMULATION', + `record_status` varchar(16) NOT NULL DEFAULT 'ACTIVE', + `merged_into_container_booking_id` bigint unsigned DEFAULT NULL, + `customer_id` bigint unsigned DEFAULT NULL, + `customer_name` varchar(128) DEFAULT NULL, + `terminal_trucking_company_id` bigint unsigned DEFAULT NULL, + `unloading_completed_at` datetime(3) DEFAULT NULL, + `unloading_photo_uploaded_at` datetime(3) DEFAULT NULL, + `estimated_empty_reported_at` datetime(3) DEFAULT NULL, + `actual_empty_reported_at` datetime(3) DEFAULT NULL, + `empty_report_source_version` bigint DEFAULT NULL, + `channel_group_code` varchar(64) DEFAULT NULL COMMENT '模拟柜计划渠道分组;关联真实订舱后继续保留', + `pickup_code` varchar(64) DEFAULT NULL COMMENT 'PU数字码;按字符串保存以保留前导零', + `pickup_code_updated_by` bigint unsigned DEFAULT NULL COMMENT 'PU码最后填写人ID', + `pickup_code_updated_by_name` varchar(64) DEFAULT NULL COMMENT 'PU码最后填写人名称快照', + `pickup_code_updated_at` datetime(3) DEFAULT NULL COMMENT 'PU码最后填写时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_booking_no` (`booking_no`), + UNIQUE KEY `uk_container_booking_cabinet` (`cabinet_no`), + KEY `idx_container_booking_container` (`container_no`), + KEY `idx_container_booking_warehouse` (`warehouse_id`,`updated_at`,`id`), + KEY `idx_container_booking_voyage` (`voyage_no`,`updated_at`), + CONSTRAINT `chk_container_booking_capacity` CHECK (((`capacity_cbm` is null) or (`capacity_cbm` > 0))), + CONSTRAINT `chk_container_booking_weight` CHECK (((`max_weight_kg` is null) or (`max_weight_kg` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台货柜预订资料;预装状态唯一来源为装柜计划'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_document` ( + `id` bigint unsigned NOT NULL COMMENT '货柜文件关联ID(雪花ID)', + `container_booking_id` bigint unsigned NOT NULL COMMENT '货柜预订ID', + `document_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件类型:CUSTOMS_CLEARANCE清关文件、DELIVERY_ORDER提货单、PICKUP_ORDER取货单', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件ID,不建立跨库外键', + `file_name` varchar(255) NOT NULL COMMENT '文件名称快照', + `file_hash` varchar(64) DEFAULT NULL, + `file_url` varchar(1000) DEFAULT NULL COMMENT '文件访问地址快照', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传操作人ID', + `uploaded_by_name` varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', + `completed_by` bigint unsigned DEFAULT NULL, + `completed_by_name` varchar(64) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '货柜文件关联最后更新时间', + `completed_at` datetime(3) DEFAULT NULL, + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_document_type` (`container_booking_id`,`document_type`), + KEY `idx_container_document_file` (`file_id`), + CONSTRAINT `fk_container_document_booking` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_container_document_type` CHECK ((`document_type` in (_utf8mb4'DESTINATION_CLEARANCE',_utf8mb4'CUSTOMS_DECLARATION',_utf8mb4'DELIVERY_ORDER',_utf8mb4'PICKUP_ORDER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台货柜清关、DO与PU文件关联'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_event_inbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_event_inbox` ( + `event_id` varchar(64) NOT NULL COMMENT '跨区事件全局唯一ID', + `event_type` varchar(64) NOT NULL COMMENT '事件类型', + `event_version` int unsigned NOT NULL COMMENT '事件契约版本', + `aggregate_id` varchar(64) NOT NULL COMMENT '货柜聚合ID', + `aggregate_version` bigint unsigned NOT NULL COMMENT '货柜聚合版本', + `payload_hash` varchar(80) NOT NULL COMMENT '规范化负载SHA-256摘要', + `payload_json` json NOT NULL COMMENT '原始事件负载', + `status` varchar(16) NOT NULL COMMENT 'RECEIVED或PROCESSED', + `received_at` datetime(3) NOT NULL COMMENT '接收时间(UTC)', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间(UTC)', + PRIMARY KEY (`event_id`), + KEY `idx_container_event_aggregate` (`aggregate_id`,`aggregate_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海外货柜事件消费Inbox'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_exception_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_exception_history` ( + `id` bigint unsigned NOT NULL COMMENT '异常历史ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '稳定货柜预订ID', + `action` varchar(16) NOT NULL COMMENT 'MARK标记异常/CANCEL取消异常', + `reason` varchar(500) NOT NULL COMMENT '标记或取消原因', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operation_id` varchar(128) NOT NULL COMMENT '同一操作人幂等键', + `request_hash` char(64) NOT NULL COMMENT '规范化请求摘要', + `occurred_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_exception_operation` (`container_booking_id`,`operator_id`,`operation_id`), + KEY `idx_container_exception_history` (`container_booking_id`,`occurred_at`,`id`), + CONSTRAINT `fk_container_exception_booking` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_container_exception_action` CHECK ((`action` in (_utf8mb4'MARK',_utf8mb4'CANCEL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='货柜异常标记及取消原因历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_fee_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_fee_detail` ( + `id` bigint unsigned NOT NULL COMMENT '柜次费用来源ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '柜次ID,对应fl_loading_plan.id', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '订舱柜ID快照', + `container_no` varchar(64) DEFAULT NULL COMMENT '柜号快照', + `fee_item_id` bigint unsigned NOT NULL COMMENT '平台公共费用项目ID', + `fee_item_code_snapshot` varchar(50) NOT NULL COMMENT '费用项目稳定编码快照', + `fee_item_name_snapshot` varchar(128) NOT NULL COMMENT '费用项目名称快照', + `direction` varchar(16) NOT NULL COMMENT '费用方向:RECEIVABLE应收、PAYABLE应付', + `business_date` date NOT NULL COMMENT '费用业务日期', + `amount` decimal(18,2) NOT NULL COMMENT 'CNY费用金额', + `currency` char(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', + `settlement_included` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否参与财务结算来源池', + `review_status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED通过、REJECTED驳回', + `source_status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '来源状态:ACTIVE有效、VOIDED作废', + `source_version` int unsigned NOT NULL DEFAULT '1' COMMENT '费用来源版本;调整递增,作废携带当前版本', + `remark` varchar(500) DEFAULT NULL COMMENT '人工备注', + `reviewer_id` bigint unsigned DEFAULT NULL COMMENT '审核人ID', + `reviewer_name` varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间', + `review_reason` varchar(500) DEFAULT NULL COMMENT '审核驳回原因', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后更新人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + `paid_at` datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + `payment_method` varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + `payment_reference_no` varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + `payment_voucher_file_ids` json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + `settlement_mode` varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + `supplier_category` varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + `supplier_id` bigint unsigned DEFAULT NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + `supplier_name` varchar(200) DEFAULT NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + `allocation_group_id` bigint unsigned DEFAULT NULL COMMENT '柜级公共费用分摊组ID;单入仓直接费用为空', + `allocation_order_id` bigint unsigned DEFAULT NULL COMMENT '本分摊行业务订单ID', + `allocation_order_no` varchar(64) DEFAULT NULL COMMENT '本分摊行业务订单号', + `allocation_inbound_id` bigint unsigned DEFAULT NULL COMMENT '本分摊行对应入仓单ID', + `allocation_inbound_code` varchar(64) DEFAULT NULL COMMENT '本分摊行对应入仓码', + `allocation_group_amount` decimal(18,2) DEFAULT NULL COMMENT '柜级原始费用总额快照', + PRIMARY KEY (`id`), + KEY `idx_container_fee_customer_plan` (`tenant_id`,`loading_plan_id`,`source_status`), + KEY `idx_container_fee_settlement` (`tenant_id`,`review_status`,`settlement_included`,`source_status`), + CONSTRAINT `chk_container_fee_amount` CHECK ((`amount` >= 0)), + CONSTRAINT `chk_container_fee_currency` CHECK ((`currency` = _utf8mb4'CNY')), + CONSTRAINT `chk_container_fee_direction` CHECK ((`direction` in (_utf8mb4'RECEIVABLE',_utf8mb4'PAYABLE'))), + CONSTRAINT `chk_container_fee_review` CHECK ((`review_status` in (_utf8mb4'PENDING',_utf8mb4'APPROVED',_utf8mb4'REJECTED'))), + CONSTRAINT `chk_container_fee_source` CHECK ((`source_status` in (_utf8mb4'ACTIVE',_utf8mb4'VOIDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次级人工应收应付费用来源;审核通过且参与结算后同步Finance'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_fee_source_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_fee_source_snapshot` ( + `id` bigint unsigned NOT NULL COMMENT '柜次费用快照ID', + `source_fee_id` bigint unsigned NOT NULL COMMENT '柜次费用来源ID', + `source_version` int unsigned NOT NULL COMMENT '费用来源版本', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '柜次ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '订舱柜ID快照', + `container_no` varchar(64) DEFAULT NULL COMMENT '柜号快照', + `fee_item_id` bigint unsigned NOT NULL COMMENT '费用项目ID快照', + `fee_item_code` varchar(50) NOT NULL COMMENT '费用项目编码快照', + `fee_item_name` varchar(128) NOT NULL COMMENT '费用项目名称快照', + `direction` varchar(16) NOT NULL COMMENT '费用方向', + `supplier_id` bigint unsigned DEFAULT NULL COMMENT 'Warehouse供应商ID快照', + `supplier_name` varchar(200) DEFAULT NULL COMMENT 'Warehouse供应商名称快照', + `supplier_category` varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + `settlement_mode` varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT账期应付、DIRECT_PAID业务已付;RECEIVABLE为空', + `paid_at` datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + `payment_method` varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + `payment_reference_no` varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + `payment_voucher_file_ids` json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + `business_date` date NOT NULL COMMENT '费用业务日期', + `amount` decimal(18,2) NOT NULL COMMENT 'CNY费用金额', + `currency` char(3) NOT NULL DEFAULT 'CNY' COMMENT '币种,首期固定CNY', + `review_status` varchar(16) NOT NULL COMMENT '审核状态快照', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '快照创建时间', + `allocation_group_id` bigint unsigned DEFAULT NULL COMMENT '柜级公共费用分摊组ID', + `allocation_order_id` bigint unsigned DEFAULT NULL COMMENT '分摊业务订单ID快照', + `allocation_order_no` varchar(64) DEFAULT NULL COMMENT '分摊业务订单号快照', + `allocation_inbound_id` bigint unsigned DEFAULT NULL COMMENT '分摊入仓单ID快照', + `allocation_inbound_code` varchar(64) DEFAULT NULL COMMENT '分摊入仓码快照', + `allocation_group_amount` decimal(18,2) DEFAULT NULL COMMENT '柜级原始费用总额快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_container_fee_source_version` (`loading_plan_id`,`source_fee_id`,`source_version`), + KEY `idx_container_fee_snapshot_tenant` (`tenant_id`,`loading_plan_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次费用不可变版本快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_container_transport_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_container_transport_history` ( + `id` bigint unsigned NOT NULL COMMENT '运输字段变更历史ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '稳定柜次ID', + `before_container_no` varchar(64) DEFAULT NULL COMMENT '变更前真实集装箱号', + `after_container_no` varchar(64) DEFAULT NULL COMMENT '变更后真实集装箱号', + `before_voyage_no` varchar(64) DEFAULT NULL COMMENT '变更前航次号', + `after_voyage_no` varchar(64) DEFAULT NULL COMMENT '变更后航次号', + `reason` varchar(500) DEFAULT NULL COMMENT '运输字段变更原因', + `operator_id` bigint unsigned NOT NULL COMMENT '变更操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '变更操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '变更记录创建时间', + `operation_id` varchar(64) NOT NULL COMMENT '运输字段操作幂等ID', + `request_hash` char(64) NOT NULL COMMENT '运输字段请求哈希', + PRIMARY KEY (`id`), + KEY `idx_container_transport_history` (`container_booking_id`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次真实运输字段不可变变更审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customer_charge_rule`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customer_charge_rule` ( + `id` bigint unsigned NOT NULL COMMENT '客户计费规则ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `rule_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户内唯一计费规则编码', + `rule_version` int unsigned NOT NULL COMMENT '规则版本号', + `customer_id` bigint unsigned NOT NULL COMMENT '客户逻辑ID', + `customer_name` varchar(128) NOT NULL COMMENT '客户名称快照', + `business_type` varchar(32) NOT NULL COMMENT '业务类型', + `charge_mode` varchar(16) NOT NULL COMMENT '计费口径:KG或CBM', + `tax_mode` varchar(24) DEFAULT NULL COMMENT '包税或不包税类型', + `conversion_factor` decimal(18,6) NOT NULL COMMENT '重量体积换算系数', + `length_factor` decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT 'length factor', + `width_factor` decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT 'width factor', + `height_factor` decimal(18,6) NOT NULL DEFAULT '1.000000' COMMENT 'height factor', + `rounding_scale` tinyint unsigned NOT NULL DEFAULT '3' COMMENT '结果保留小数位数', + `rounding_mode` varchar(24) NOT NULL DEFAULT 'CEILING' COMMENT '舍入方式', + `currency` char(3) NOT NULL COMMENT '默认币种', + `effective_from` datetime(3) NOT NULL COMMENT '生效开始时间', + `effective_to` datetime(3) DEFAULT NULL COMMENT '生效结束时间', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '规则状态:ACTIVE启用、DISABLED停用', + `default_rule` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'default customer rule', + `remark` varchar(500) DEFAULT NULL COMMENT '规则备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `initialization_key` varchar(128) DEFAULT NULL COMMENT '客户创建执行器幂等键及补偿归属', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_charge_rule_version` (`tenant_id`,`rule_code`,`rule_version`), + KEY `idx_charge_rule_match` (`tenant_id`,`customer_id`,`business_type`,`status`,`effective_from`,`effective_to`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户头程计费规则版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customer_dimension_config`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customer_dimension_config` ( + `id` bigint unsigned NOT NULL COMMENT '配置ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `box_type` varchar(24) NOT NULL COMMENT '固定箱规类型:SMALL/MEDIUM/LARGE/EXTRA_LARGE', + `inner_length_cm` decimal(9,3) NOT NULL COMMENT '箱规内长(厘米)', + `inner_width_cm` decimal(9,3) NOT NULL COMMENT '箱规内宽(厘米)', + `inner_height_cm` decimal(9,3) NOT NULL COMMENT '箱规内高(厘米)', + `edge_addition_cm` decimal(6,3) NOT NULL DEFAULT '2.000' COMMENT '每个尺寸方向的单侧加宽厘米数', + `default_calculation_mode` varchar(16) NOT NULL DEFAULT 'ADD' COMMENT '订单外箱规默认计算方式:ADD或MULTIPLY', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `initialization_key` varchar(128) NOT NULL COMMENT '创建流程幂等键及补偿归属', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customer_dimension_config_tenant_box` (`tenant_id`,`box_type`), + KEY `idx_customer_dimension_config_initialization` (`tenant_id`,`initialization_key`), + CONSTRAINT `chk_customer_dimension_box_type` CHECK ((`box_type` in (_utf8mb4'SMALL',_utf8mb4'MEDIUM',_utf8mb4'LARGE',_utf8mb4'EXTRA_LARGE'))), + CONSTRAINT `chk_customer_dimension_edge_addition` CHECK (((`edge_addition_cm` >= 0.001) and (`edge_addition_cm` <= 100.000))), + CONSTRAINT `chk_customer_dimension_inner_size` CHECK (((`inner_length_cm` >= 0.001) and (`inner_width_cm` >= 0.001) and (`inner_height_cm` >= 0.001))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户固定四箱规尺寸配置'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_case`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_case` ( + `id` bigint unsigned NOT NULL COMMENT '报关协同单逻辑ID', + `customs_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '全局唯一报关协同单号', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `order_id` bigint unsigned NOT NULL COMMENT 'Order服务订单逻辑ID;跨库不建立外键', + `inbound_id` bigint unsigned NOT NULL COMMENT 'Firstleg入库单逻辑ID', + `container_booking_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '关联柜次逻辑ID;0表示创建时尚未关联柜次', + `checklist_version_id` bigint unsigned NOT NULL COMMENT '本单冻结使用的报关资料清单版本ID', + `checklist_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '创建协同单时的清单编码快照;源清单变化不回写', + `checklist_version_no` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '创建协同单时的清单版本号快照;源清单变化不回写', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'DOCUMENTS_PENDING' COMMENT '协同状态:DOCUMENTS_PENDING待补资料、REVIEWING审核中、SUPPLEMENT_REQUIRED需补件、READY_TO_SUBMIT待提交代理、RECEIPT_PENDING待回执、RECEIPT_AVAILABLE已有回执、CLEARANCE_COMPLETED已放行、CANCELLED已取消', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '报关协同单内部业务备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建协同单的平台操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建协同单时的平台操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '协同单创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改协同单的平台操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改时的平台操作人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '协同单最后修改时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号;每次状态变更递增', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_no` (`customs_no`), + UNIQUE KEY `uk_customs_case_scope` (`tenant_id`,`order_id`,`inbound_id`,`container_booking_id`), + KEY `idx_customs_case_status` (`status`,`updated_at`), + KEY `idx_customs_case_container` (`container_booking_id`), + KEY `fk_customs_case_checklist` (`checklist_version_id`), + CONSTRAINT `fk_customs_case_checklist` FOREIGN KEY (`checklist_version_id`) REFERENCES `fl_customs_checklist_version` (`id`), + CONSTRAINT `chk_customs_case_status` CHECK ((`status` in (_utf8mb4'DOCUMENTS_PENDING',_utf8mb4'REVIEWING',_utf8mb4'SUPPLEMENT_REQUIRED',_utf8mb4'READY_TO_SUBMIT',_utf8mb4'RECEIPT_PENDING',_utf8mb4'RECEIPT_AVAILABLE',_utf8mb4'CLEARANCE_COMPLETED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期订单报关协同聚合'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_checklist_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_checklist_version` ( + `id` bigint unsigned NOT NULL COMMENT '报关资料清单版本逻辑ID', + `checklist_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '报关资料清单编码;同一清单的多个版本共用', + `version_no` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '清单业务版本号', + `country_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '清单适用的目的国家或地区编码', + `channel_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '清单适用的运输渠道编码', + `cargo_type` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '清单适用的货物类型编码', + `items_json` json NOT NULL COMMENT '不可变清单项目数组;元素包含code项目编码、name项目名称和required是否必填', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '清单版本状态:ACTIVE启用、RETIRED停用', + `created_by` bigint unsigned NOT NULL COMMENT '创建清单版本的平台操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建清单版本时的平台操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清单版本创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_checklist_version` (`checklist_code`,`version_no`), + KEY `idx_customs_checklist_match` (`country_code`,`channel_code`,`cargo_type`,`status`), + CONSTRAINT `chk_customs_checklist_status` CHECK ((`status` in (_utf8mb4'ACTIVE',_utf8mb4'RETIRED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期报关资料清单不可变版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_command` ( + `id` bigint unsigned NOT NULL, + `actor_id` bigint unsigned NOT NULL, + `action` varchar(80) NOT NULL, + `idempotency_key` varchar(128) NOT NULL, + `request_hash` char(64) NOT NULL, + `status` varchar(16) NOT NULL, + `result_id` bigint unsigned DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `completed_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_command` (`actor_id`,`action`,`idempotency_key`), + CONSTRAINT `chk_customs_command_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜报关写命令幂等事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_completion_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_completion_document` ( + `id` bigint unsigned NOT NULL, + `source_document_id` bigint unsigned NOT NULL COMMENT '一份冻结客户资料严格对应一份完成文件逻辑根', + `file_id` bigint unsigned NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_hash` char(64) NOT NULL, + `content_type` varchar(128) DEFAULT NULL, + `size_bytes` bigint unsigned NOT NULL, + `uploaded_by` bigint unsigned NOT NULL, + `uploaded_by_name` varchar(64) DEFAULT NULL, + `uploaded_at` datetime(3) NOT NULL, + `version` int unsigned NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_completion_source` (`source_document_id`), + CONSTRAINT `fk_customs_completion_source` FOREIGN KEY (`source_document_id`) REFERENCES `fl_customs_customer_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件当前版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_completion_document_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_completion_document_version` ( + `id` bigint unsigned NOT NULL, + `completion_document_id` bigint unsigned NOT NULL, + `document_version` int unsigned NOT NULL, + `file_id` bigint unsigned NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_hash` char(64) NOT NULL, + `content_type` varchar(128) DEFAULT NULL, + `size_bytes` bigint unsigned NOT NULL, + `operator_id` bigint unsigned NOT NULL, + `operator_name` varchar(64) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_completion_version` (`completion_document_id`,`document_version`), + CONSTRAINT `fk_customs_completion_version` FOREIGN KEY (`completion_document_id`) REFERENCES `fl_customs_completion_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关完成文件不可变覆盖版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_container`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_container` ( + `id` bigint unsigned NOT NULL, + `loading_plan_id` bigint unsigned NOT NULL COMMENT '已完成装柜计划ID;报关柜级聚合唯一键', + `container_booking_id` bigint unsigned NOT NULL COMMENT '物理货柜ID', + `status` varchar(40) NOT NULL COMMENT 'WAITING_CUSTOMER_DOCUMENTS、READY_TO_START、DECLARING、COMPLETED', + `started_by` bigint unsigned DEFAULT NULL, + `started_by_name` varchar(64) DEFAULT NULL, + `started_at` datetime(3) DEFAULT NULL, + `completed_by` bigint unsigned DEFAULT NULL, + `completed_by_name` varchar(64) DEFAULT NULL, + `completed_at` datetime(3) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `version` int unsigned NOT NULL DEFAULT '0', + `broker_submitted_by` bigint unsigned DEFAULT NULL COMMENT '发送报关行执行的操作人ID', + `broker_submitted_by_name` varchar(64) DEFAULT NULL COMMENT '发送报关行执行的操作人名称', + `broker_submitted_at` datetime(3) DEFAULT NULL COMMENT '发送报关行执行时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_container_plan` (`loading_plan_id`), + UNIQUE KEY `uk_customs_container_booking` (`container_booking_id`), + KEY `idx_customs_container_status` (`status`,`updated_at`), + CONSTRAINT `chk_customs_container_status` CHECK ((`status` in (_utf8mb4'WAITING_CUSTOMER_DOCUMENTS',_utf8mb4'READY_TO_START',_utf8mb4'DECLARING',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='装柜完成后的整柜出口报关聚合'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_container_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_container_history` ( + `id` bigint unsigned NOT NULL, + `customs_container_id` bigint unsigned NOT NULL, + `action` varchar(40) NOT NULL, + `from_status` varchar(40) DEFAULT NULL, + `to_status` varchar(40) DEFAULT NULL, + `remark` varchar(500) DEFAULT NULL, + `operator_id` bigint unsigned NOT NULL, + `operator_name` varchar(64) DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_customs_container_history` (`customs_container_id`,`created_at`,`id`), + CONSTRAINT `fk_customs_container_history` FOREIGN KEY (`customs_container_id`) REFERENCES `fl_customs_container` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='整柜报关状态与系统触发历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_customer_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_customer_document` ( + `id` bigint unsigned NOT NULL, + `customs_container_id` bigint unsigned NOT NULL, + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键;只允许关联同客户退税票', + `file_id` bigint unsigned NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_hash` char(64) NOT NULL, + `content_type` varchar(128) DEFAULT NULL, + `size_bytes` bigint unsigned NOT NULL, + `status` varchar(16) NOT NULL DEFAULT 'ACTIVE', + `uploaded_by` bigint unsigned NOT NULL, + `uploaded_at` datetime(3) NOT NULL, + `frozen_version` int unsigned DEFAULT NULL, + `frozen_at` datetime(3) DEFAULT NULL, + `deleted_by` bigint unsigned DEFAULT NULL, + `deleted_at` datetime(3) DEFAULT NULL, + `version` int unsigned NOT NULL DEFAULT '1', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + KEY `idx_customs_customer_document_container` (`customs_container_id`,`status`,`tenant_id`), + CONSTRAINT `fk_customs_customer_document_container` FOREIGN KEY (`customs_container_id`) REFERENCES `fl_customs_container` (`id`), + CONSTRAINT `chk_customs_customer_document_freeze` CHECK ((((`frozen_at` is null) and (`frozen_version` is null)) or ((`frozen_at` is not null) and (`frozen_version` is not null)))), + CONSTRAINT `chk_customs_customer_document_status` CHECK ((`status` in (_utf8mb4'ACTIVE',_utf8mb4'DELETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户整份退税报送资料逻辑根'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_customer_document_inbound`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_customer_document_inbound` ( + `document_id` bigint unsigned NOT NULL, + `inbound_id` bigint unsigned NOT NULL, + `tenant_id` bigint unsigned NOT NULL, + `order_id` bigint unsigned NOT NULL, + `inbound_code` varchar(40) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`document_id`,`inbound_id`), + KEY `idx_customs_document_inbound` (`inbound_id`,`tenant_id`), + CONSTRAINT `fk_customs_document_inbound_document` FOREIGN KEY (`document_id`) REFERENCES `fl_customs_customer_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报送资料与当前柜退税入仓码多对多关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_customer_document_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_customer_document_version` ( + `id` bigint unsigned NOT NULL, + `document_id` bigint unsigned NOT NULL, + `document_version` int unsigned NOT NULL, + `file_id` bigint unsigned NOT NULL, + `file_name` varchar(255) NOT NULL, + `file_hash` char(64) NOT NULL, + `content_type` varchar(128) DEFAULT NULL, + `size_bytes` bigint unsigned NOT NULL, + `scope_snapshot_json` json NOT NULL, + `action` varchar(24) NOT NULL COMMENT 'UPLOAD、REPLACE、SCOPE_CHANGE、DELETE', + `operator_id` bigint unsigned NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_customer_document_version` (`document_id`,`document_version`), + CONSTRAINT `fk_customs_customer_document_version` FOREIGN KEY (`document_id`) REFERENCES `fl_customs_customer_document` (`id`), + CONSTRAINT `chk_customs_customer_document_version_action` CHECK ((`action` in (_utf8mb4'UPLOAD',_utf8mb4'REPLACE',_utf8mb4'SCOPE_CHANGE',_utf8mb4'DELETE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='客户报送资料文件与覆盖范围不可变版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_document` ( + `id` bigint unsigned NOT NULL COMMENT '报关资料文件版本逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `checklist_item_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '对应冻结清单中的资料项目编码', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件逻辑ID;跨库不建立外键', + `document_version` int unsigned NOT NULL COMMENT '同一协同单和清单项目下的文件版本序号', + `replaces_document_id` bigint unsigned DEFAULT NULL COMMENT '被本版本替换的上一资料版本ID;首个版本为空', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '上传或引用时的文件名称快照', + `file_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '文件内容SHA-256摘要;用于版本审计和幂等校验', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'UPLOADED' COMMENT '资料状态:UPLOADED待审核、APPROVED审核通过、RETURNED已退回、SUPERSEDED已被替换、FROZEN已随代理提交批次冻结', + `visibility` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'CUSTOMER' COMMENT '资料可见范围:PLATFORM仅平台可见、CUSTOMER客户可见', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传或引用资料的操作人ID', + `uploaded_by_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '上传主体类型:PLATFORM平台操作人、MERCHANT客户操作人', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '资料上传或引用时间', + `reviewed_by` bigint unsigned DEFAULT NULL COMMENT '审核资料的平台操作人ID;未审核时为空', + `reviewed_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '审核时的平台操作人名称快照', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '资料审核时间', + `review_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '审核退回原因或审核备注', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_document_version` (`customs_case_id`,`checklist_item_code`,`document_version`), + UNIQUE KEY `uk_customs_document_file` (`customs_case_id`,`file_id`), + KEY `idx_customs_document_case` (`customs_case_id`,`status`), + CONSTRAINT `fk_customs_document_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_document_actor` CHECK ((`uploaded_by_type` in (_utf8mb4'PLATFORM',_utf8mb4'MERCHANT'))), + CONSTRAINT `chk_customs_document_status` CHECK ((`status` in (_utf8mb4'UPLOADED',_utf8mb4'APPROVED',_utf8mb4'RETURNED',_utf8mb4'SUPERSEDED',_utf8mb4'FROZEN'))), + CONSTRAINT `chk_customs_document_visibility` CHECK ((`visibility` in (_utf8mb4'PLATFORM',_utf8mb4'CUSTOMER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关客户文件版本引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_final_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_final_document` ( + `id` bigint unsigned NOT NULL COMMENT '真实报关完税文件ID', + `source_document_id` bigint unsigned NOT NULL COMMENT '对应冻结客户资料ID', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件ID', + `file_name` varchar(255) NOT NULL COMMENT '真实报关完税文件名称快照', + `file_hash` char(64) NOT NULL COMMENT '真实报关完税文件SHA-256摘要', + `content_type` varchar(128) DEFAULT NULL COMMENT '真实报关完税文件媒体类型', + `size_bytes` bigint unsigned NOT NULL COMMENT '真实报关完税文件大小(字节)', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传操作人ID', + `uploaded_by_name` varchar(64) DEFAULT NULL COMMENT '上传操作人名称快照', + `uploaded_at` datetime(3) NOT NULL COMMENT '上传时间', + `version` int unsigned NOT NULL DEFAULT '1' COMMENT '完税文件版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_final_source` (`source_document_id`), + CONSTRAINT `fk_customs_final_source` FOREIGN KEY (`source_document_id`) REFERENCES `fl_customs_customer_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='真实报关完税文件当前版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_frozen_document_inbound`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_frozen_document_inbound` ( + `document_id` bigint unsigned NOT NULL, + `inbound_id` bigint unsigned NOT NULL, + `tenant_id` bigint unsigned NOT NULL, + `order_id` bigint unsigned NOT NULL, + `inbound_code` varchar(40) NOT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`document_id`,`inbound_id`), + CONSTRAINT `fk_customs_frozen_document` FOREIGN KEY (`document_id`) REFERENCES `fl_customs_customer_document` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='开始报关时冻结的客户资料覆盖范围'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_history` ( + `id` bigint unsigned NOT NULL COMMENT '报关协同历史逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `action` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '业务动作编码,如CREATE、UPLOAD_DOCUMENT、REPLACE_DOCUMENT、REVIEW_DOCUMENT_APPROVE、REVIEW_DOCUMENT_RETURN、SUBMIT_AGENT_BATCH或UPLOAD_RECEIPT', + `from_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '动作发生前的协同状态;创建动作时为空', + `to_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '动作完成后的协同状态', + `resource_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '动作关联资源类型:CASE协同单、DOCUMENT资料、SUBMISSION_BATCH提交批次或RECEIPT回执', + `resource_id` bigint unsigned NOT NULL COMMENT '动作关联资源逻辑ID', + `note` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '动作原因、资料项目编码或业务备注快照', + `operator_id` bigint unsigned NOT NULL COMMENT '执行业务动作的操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行动作时的操作人名称快照', + `operator_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '操作主体类型:PLATFORM平台操作人、MERCHANT客户操作人', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_customs_history_case` (`customs_case_id`,`created_at`,`id`), + CONSTRAINT `fk_customs_history_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_history_actor` CHECK ((`operator_type` in (_utf8mb4'PLATFORM',_utf8mb4'MERCHANT'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关协同稳定历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_operation` ( + `id` bigint unsigned NOT NULL COMMENT '报关写命令幂等记录逻辑ID', + `operator_id` bigint unsigned NOT NULL COMMENT '发起写命令的操作人ID;与动作和幂等键共同限定重放范围', + `action` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等业务动作编码,包含平台或客户动作及资料、审核、提交、回执类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端提供的幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化完整请求内容的SHA-256摘要;同键不同请求时拒绝', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS处理成功', + `result_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '成功结果资源类型:CHECKLIST、CASE、DOCUMENT、BATCH或RECEIPT', + `result_id` bigint unsigned DEFAULT NULL COMMENT '成功结果资源逻辑ID;处理中为空', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '写命令成功完成时间;处理中为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_operation` (`operator_id`,`action`,`idempotency_key`), + CONSTRAINT `chk_customs_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关协同命令幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_receipt` ( + `id` bigint unsigned NOT NULL COMMENT '报关回执文件版本逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `submission_batch_id` bigint unsigned NOT NULL COMMENT '产生本回执的报关代理提交批次ID', + `receipt_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '回执类型:RECEIVED已收件、DECLARED已申报、REJECTED已拒绝、INSPECTION查验通知、RELEASED放行回执、OTHER其他回执', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务回执文件逻辑ID;跨库不建立外键', + `receipt_version` int unsigned NOT NULL COMMENT '同一提交批次和回执类型下的版本序号', + `replaces_receipt_id` bigint unsigned DEFAULT NULL COMMENT '被本版本替换的上一回执版本ID;首个版本为空', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '上传或引用时的回执文件名称快照', + `file_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '回执文件内容SHA-256摘要;用于版本审计和幂等校验', + `visibility` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PLATFORM' COMMENT '回执可见范围:PLATFORM仅平台可见、CUSTOMER客户可查询下载', + `note` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '回执业务备注', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传或引用回执的平台操作人ID', + `uploaded_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上传回执时的平台操作人名称快照', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '回执上传或引用时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_receipt_version` (`submission_batch_id`,`receipt_type`,`receipt_version`), + UNIQUE KEY `uk_customs_receipt_file` (`submission_batch_id`,`file_id`), + KEY `idx_customs_receipt_case` (`customs_case_id`,`visibility`,`uploaded_at`), + CONSTRAINT `fk_customs_receipt_batch` FOREIGN KEY (`submission_batch_id`) REFERENCES `fl_customs_submission_batch` (`id`), + CONSTRAINT `fk_customs_receipt_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_receipt_type` CHECK ((`receipt_type` in (_utf8mb4'RECEIVED',_utf8mb4'DECLARED',_utf8mb4'REJECTED',_utf8mb4'INSPECTION',_utf8mb4'RELEASED',_utf8mb4'OTHER'))), + CONSTRAINT `chk_customs_receipt_visibility` CHECK ((`visibility` in (_utf8mb4'PLATFORM',_utf8mb4'CUSTOMER'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关代理回执版本与客户可见范围'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_submission_batch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_submission_batch` ( + `id` bigint unsigned NOT NULL COMMENT '报关代理提交批次逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `batch_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '全局唯一代理提交批次号', + `batch_version` int unsigned NOT NULL COMMENT '同一报关协同单内的提交批次版本序号', + `customs_agent_id` bigint unsigned DEFAULT NULL, + `customs_agent_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '本批次接收报关资料的报关代理名称快照', + `external_reference` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '报关代理或外部系统返回的业务参考号', + `file_snapshot_json` json NOT NULL COMMENT '提交时冻结的已审核资料数组;元素包含documentId、fileId、itemCode、documentVersion和fileHash', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'SUBMITTED' COMMENT '代理提交状态:SUBMITTED已提交、ACKNOWLEDGED代理已确认、REJECTED代理已拒绝、COMPLETED代理处理完成', + `note` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '提交报关代理时的平台备注', + `submitted_by` bigint unsigned NOT NULL COMMENT '提交报关代理的平台操作人ID', + `submitted_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '提交时的平台操作人名称快照', + `submitted_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '提交报关代理时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_customs_batch_no` (`batch_no`), + UNIQUE KEY `uk_customs_batch_version` (`customs_case_id`,`batch_version`), + CONSTRAINT `fk_customs_batch_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_batch_status` CHECK ((`status` in (_utf8mb4'SUBMITTED',_utf8mb4'ACKNOWLEDGED',_utf8mb4'REJECTED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关代理提交批次及冻结文件版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_customs_supplement_request`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_customs_supplement_request` ( + `id` bigint unsigned NOT NULL COMMENT '报关补件请求逻辑ID', + `customs_case_id` bigint unsigned NOT NULL COMMENT '所属报关协同单ID', + `source_document_id` bigint unsigned NOT NULL COMMENT '触发本次补件的被退回资料版本ID', + `resolved_document_id` bigint unsigned DEFAULT NULL COMMENT '解决本次补件的新资料版本ID;未解决时为空', + `checklist_item_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '需要补充的报关资料项目编码', + `reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台审核人员填写的补件原因', + `due_at` datetime(3) DEFAULT NULL COMMENT '期望完成补件时间;未设置时为空', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'OPEN' COMMENT '补件状态:OPEN待补件、RESOLVED已由新资料解决、CANCELLED已取消', + `requested_by` bigint unsigned NOT NULL COMMENT '发起补件的平台操作人ID', + `requested_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '发起补件时的平台操作人名称快照', + `requested_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '补件请求发起时间', + `resolved_by` bigint unsigned DEFAULT NULL COMMENT '提交解决资料的操作人ID;未解决时为空', + `resolved_at` datetime(3) DEFAULT NULL COMMENT '补件请求解决时间', + PRIMARY KEY (`id`), + KEY `idx_customs_supplement_case` (`customs_case_id`,`status`,`due_at`), + CONSTRAINT `fk_customs_supplement_case` FOREIGN KEY (`customs_case_id`) REFERENCES `fl_customs_case` (`id`), + CONSTRAINT `chk_customs_supplement_status` CHECK ((`status` in (_utf8mb4'OPEN',_utf8mb4'RESOLVED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='报关补件请求'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_direct_delivery_unloading`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_direct_delivery_unloading` ( + `container_booking_id` bigint unsigned NOT NULL, + `inbound_id` bigint unsigned NOT NULL COMMENT 'Immutable physically loaded inbound source', + `actual_at` datetime(3) NOT NULL COMMENT 'Actual destination local time', + `timezone` varchar(64) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Explicit destination IANA timezone', + `received_by` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Actual recipient', + `evidence_no` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT 'Actual delivery receipt reference', + `remark` varchar(1000) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `version` int NOT NULL DEFAULT '1', + `idempotency_key` varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL, + `request_hash` char(64) COLLATE utf8mb4_unicode_ci NOT NULL, + `created_by` bigint unsigned NOT NULL, + `created_by_name` varchar(64) COLLATE utf8mb4_unicode_ci DEFAULT NULL, + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`container_booking_id`,`inbound_id`), + UNIQUE KEY `uk_direct_unloading_command` (`container_booking_id`,`idempotency_key`), + CONSTRAINT `fk_direct_unloading_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='直送目的仓真实卸货凭据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_document_sequence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_document_sequence` ( + `sequence_key` varchar(32) NOT NULL COMMENT '单据类型与北京时间日期组合键,例如CAB:20260818', + `current_value` int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', + PRIMARY KEY (`sequence_key`), + CONSTRAINT `chk_fl_document_sequence_value` CHECK ((`current_value` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_dropship_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_dropship_pallet` ( + `id` bigint unsigned NOT NULL COMMENT '合托托盘ID', + `task_id` bigint unsigned NOT NULL COMMENT '所属合托任务ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + `warehouse_id` bigint unsigned NOT NULL COMMENT '作业仓库ID', + `pallet_no` varchar(32) NOT NULL COMMENT '现场扫描的系统DP托盘码', + `pallet_status` varchar(16) NOT NULL COMMENT '托盘状态:OPEN合托中、COMPLETED已封托', + `open_operation_id` varchar(128) NOT NULL COMMENT '创建托盘幂等操作ID', + `seal_operation_id` varchar(128) DEFAULT NULL COMMENT '封托幂等操作ID', + `created_by` bigint unsigned NOT NULL COMMENT '托盘创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '托盘创建人姓名快照', + `completed_by` bigint unsigned DEFAULT NULL COMMENT '封托操作人ID', + `completed_by_name` varchar(64) DEFAULT NULL COMMENT '封托操作人姓名快照', + `completed_at` datetime(3) DEFAULT NULL COMMENT '封托完成时间', + `version` int NOT NULL DEFAULT '1' COMMENT '托盘乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '托盘创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '托盘更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dropship_pallet_open_op` (`open_operation_id`), + UNIQUE KEY `uk_dropship_pallet_no` (`pallet_no`), + UNIQUE KEY `uk_dropship_pallet_seal_op` (`seal_operation_id`), + KEY `idx_dropship_pallet_inbound` (`tenant_id`,`inbound_id`,`pallet_status`), + KEY `fk_dropship_pallet_task` (`task_id`), + CONSTRAINT `fk_dropship_pallet_task` FOREIGN KEY (`task_id`) REFERENCES `fl_dropship_pallet_task` (`id`), + CONSTRAINT `chk_dropship_pallet_status` CHECK ((`pallet_status` in (_utf8mb4'OPEN',_utf8mb4'SEALED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发合托形成的可打印托盘'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_dropship_pallet_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_dropship_pallet_carton` ( + `id` bigint unsigned NOT NULL COMMENT '托盘箱明细ID', + `task_id` bigint unsigned NOT NULL COMMENT '所属合托任务ID', + `pallet_id` bigint unsigned DEFAULT NULL COMMENT '所属组托托盘ID;已清点待绑托时为空', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '入仓货物明细ID', + `carton_label_id` bigint unsigned NOT NULL COMMENT '稳定箱唛记录ID', + `sku_code` varchar(128) NOT NULL COMMENT '箱唛冻结SKU编码', + `cargo_name` varchar(255) DEFAULT NULL COMMENT '货物名称快照', + `box_code` varchar(80) NOT NULL COMMENT '稳定箱码', + `expected_sku_quantity` int unsigned NOT NULL COMMENT '箱唛冻结的应收SKU数量', + `actual_sku_quantity` int unsigned NOT NULL COMMENT 'PDA确认的实收SKU数量', + `discrepancy_status` varchar(24) NOT NULL COMMENT '差异状态:MATCH或ACCEPTED_VARIANCE', + `discrepancy_remark` varchar(500) DEFAULT NULL COMMENT '差异接受说明', + `count_operation_id` varchar(128) NOT NULL COMMENT '逐箱清点幂等操作ID', + `bind_operation_id` varchar(128) DEFAULT NULL COMMENT '批量绑托幂等操作ID', + `last_operation_id` varchar(128) NOT NULL COMMENT '最后一次绑托或换托操作幂等号', + `counted_by` bigint unsigned NOT NULL COMMENT '清点操作人ID', + `counted_by_name` varchar(64) DEFAULT NULL COMMENT '清点操作人姓名快照', + `counted_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '清点时间', + `bound_by` bigint unsigned DEFAULT NULL COMMENT '上托操作人ID', + `bound_by_name` varchar(64) DEFAULT NULL COMMENT '上托操作人姓名快照', + `bound_at` datetime(3) DEFAULT NULL COMMENT '上托时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dropship_carton_task_label` (`task_id`,`carton_label_id`), + UNIQUE KEY `uk_dropship_carton_count_operation` (`count_operation_id`), + KEY `idx_dropship_carton_pallet_sku` (`pallet_id`,`sku_code`,`id`), + KEY `idx_dropship_carton_inbound` (`tenant_id`,`inbound_id`,`pallet_id`), + KEY `idx_dropship_carton_operation` (`count_operation_id`), + KEY `fk_dropship_carton_label` (`carton_label_id`), + KEY `idx_dropship_carton_bind_operation` (`task_id`,`bind_operation_id`), + CONSTRAINT `fk_dropship_carton_label` FOREIGN KEY (`carton_label_id`) REFERENCES `fl_inbound_carton_label` (`id`), + CONSTRAINT `fk_dropship_carton_pallet` FOREIGN KEY (`pallet_id`) REFERENCES `fl_dropship_pallet` (`id`), + CONSTRAINT `fk_dropship_carton_task` FOREIGN KEY (`task_id`) REFERENCES `fl_dropship_pallet_task` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发托盘内逐箱SKU和入仓单明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_dropship_pallet_carton_sku`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_dropship_pallet_carton_sku` ( + `id` bigint unsigned NOT NULL COMMENT '清点箱SKU明细ID', + `carton_id` bigint unsigned NOT NULL COMMENT '清点物理箱ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '入仓货物明细ID', + `sku_code` varchar(128) NOT NULL COMMENT 'SKU编码快照', + `cargo_name` varchar(255) DEFAULT NULL COMMENT '货物名称快照', + `expected_quantity` int unsigned NOT NULL COMMENT '应收数量', + `actual_quantity` int unsigned NOT NULL COMMENT '实收数量', + `discrepancy_status` varchar(24) NOT NULL COMMENT 'MATCH或ACCEPTED_VARIANCE', + `discrepancy_remark` varchar(500) DEFAULT NULL COMMENT '差异处理说明', + `line_no` int unsigned NOT NULL COMMENT '箱内SKU顺序', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dropship_carton_sku_detail` (`carton_id`,`inbound_detail_id`), + KEY `idx_dropship_carton_sku_code` (`sku_code`,`carton_id`), + CONSTRAINT `fk_dropship_carton_sku_carton` FOREIGN KEY (`carton_id`) REFERENCES `fl_dropship_pallet_carton` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发物理箱内逐SKU清点事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_dropship_pallet_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_dropship_pallet_task` ( + `id` bigint unsigned NOT NULL COMMENT '合托任务ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '一件代发入仓单ID', + `warehouse_id` bigint unsigned NOT NULL COMMENT '作业仓库ID', + `task_status` varchar(24) NOT NULL COMMENT '任务状态:PENDING待领取、CLAIMED已领取、COMPLETED已完成', + `total_cartons` int unsigned NOT NULL COMMENT '测量确认总箱数', + `counted_cartons` int unsigned NOT NULL DEFAULT '0' COMMENT '已逐箱确认实收数量的箱数', + `allocated_cartons` int unsigned NOT NULL DEFAULT '0' COMMENT '已上托箱数', + `discrepancy_cartons` int unsigned NOT NULL DEFAULT '0' COMMENT '已确认差异箱数', + `assignee_id` bigint unsigned DEFAULT NULL COMMENT '任务领取人ID', + `assignee_name` varchar(64) DEFAULT NULL COMMENT '任务领取人姓名快照', + `claimed_at` datetime(3) DEFAULT NULL COMMENT '任务领取时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '任务完成时间', + `version` int NOT NULL DEFAULT '1' COMMENT '任务乐观锁版本', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_dropship_task_inbound` (`tenant_id`,`inbound_id`), + KEY `idx_dropship_task_operator` (`task_status`,`assignee_id`,`warehouse_id`,`created_at`), + KEY `fk_dropship_task_inbound` (`inbound_id`), + CONSTRAINT `fk_dropship_task_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_dropship_task_cartons` CHECK (((`allocated_cartons` <= `counted_cartons`) and (`counted_cartons` <= `total_cartons`))), + CONSTRAINT `chk_dropship_task_status` CHECK ((`task_status` in (_utf8mb4'PENDING',_utf8mb4'CLAIMED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发测量后PDA合托任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_fee_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_fee_item` ( + `id` bigint unsigned NOT NULL COMMENT '费用项目ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '平台公共费用项目作用域,固定为0;物理字段名沿用 tenant_id', + `category` varchar(64) NOT NULL COMMENT '费用分类', + `trade_scope` varchar(20) NOT NULL COMMENT 'DOMESTIC国内、INTERNATIONAL国际', + `fee_code` varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '稳定费用编码;系统费用使用固定编码', + `fee_type_name` varchar(128) NOT NULL COMMENT '费用类型名称', + `pickup_payable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应付是否参与', + `pickup_receivable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '提柜应收是否参与', + `warehouse_payable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应付是否参与', + `warehouse_receivable` tinyint(1) NOT NULL DEFAULT '0' COMMENT '仓储应收是否参与', + `allocation_fee` tinyint(1) NOT NULL DEFAULT '0' COMMENT '平摊费用是否参与', + `description` varchar(500) DEFAULT NULL COMMENT '说明', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + `created_by` bigint DEFAULT NULL COMMENT '创建人ID;系统初始化时可为空', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint DEFAULT NULL COMMENT '最后修改人ID;系统初始化时可为空', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0未删除、1已删除', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `supplier_category` varchar(16) DEFAULT NULL COMMENT 'PAYABLE供应商类别约束;为空表示任意启用供应商', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fee_item_code` (`fee_code`), + KEY `idx_fee_item_query` (`tenant_id`,`trade_scope`,`category`,`status`,`deleted`), + KEY `idx_fee_item_name` (`tenant_id`,`fee_type_name`,`deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共费用项目主数据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_finance_fee_source_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_finance_fee_source_command` ( + `id` bigint unsigned NOT NULL COMMENT 'Finance费用来源命令ID', + `customer_id` bigint unsigned NOT NULL COMMENT '客户隔离键,由Firstleg费用事实再次校验', + `operator_id` bigint unsigned NOT NULL COMMENT 'Finance操作人ID', + `operator_name` varchar(64) NOT NULL COMMENT 'Finance操作人名称快照', + `action` varchar(24) NOT NULL COMMENT '命令动作;当前仅VOID', + `idempotency_key` varchar(128) NOT NULL COMMENT 'Finance透传的幂等键', + `request_hash` char(64) NOT NULL COMMENT '规范化请求SHA-256', + `source_type` varchar(24) NOT NULL COMMENT 'INBOUND_FEE或CONTAINER_FEE', + `source_fee_id` bigint unsigned NOT NULL COMMENT '稳定费用来源ID', + `source_version` int unsigned NOT NULL COMMENT '精确费用来源版本', + `inbound_id` bigint unsigned DEFAULT NULL COMMENT '入库费用所属入库单ID', + `container_id` bigint unsigned DEFAULT NULL COMMENT '柜次费用所属柜次ID', + `reason` varchar(500) NOT NULL COMMENT '操作原因', + `status` varchar(16) NOT NULL COMMENT 'PROCESSING或SUCCEEDED', + `result_status` varchar(16) DEFAULT NULL COMMENT '成功业务结果;当前为VOIDED', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '命令创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '命令成功完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_finance_fee_command_idem` (`customer_id`,`operator_id`,`action`,`idempotency_key`), + KEY `idx_finance_fee_command_source` (`source_type`,`source_fee_id`,`source_version`,`created_at`), + CONSTRAINT `chk_finance_fee_command_action` CHECK ((`action` = _utf8mb4'VOID')), + CONSTRAINT `chk_finance_fee_command_source` CHECK ((`source_type` in (_utf8mb4'INBOUND_FEE',_utf8mb4'CONTAINER_FEE'))), + CONSTRAINT `chk_finance_fee_command_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCEEDED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance发起、Firstleg执行业务费用命令的幂等审计记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_fulfillment_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_fulfillment_task` ( + `id` bigint unsigned NOT NULL COMMENT '履约任务ID(雪花ID)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `order_id` bigint unsigned NOT NULL COMMENT '订单服务中的订单逻辑ID,不建立跨库外键', + `order_no` varchar(32) NOT NULL COMMENT '订单号快照', + `order_snapshot_json` json NOT NULL COMMENT '创建履约任务时所需的订单业务快照', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING_ASSIGNMENT' COMMENT '履约状态:PENDING_ASSIGNMENT待指派、ASSIGNED已指派、PROCESSING处理中、COMPLETED已完成、CANCELLED已取消', + `assignee_id` bigint unsigned DEFAULT NULL COMMENT '当前负责人ID', + `assignee_name` varchar(64) DEFAULT NULL COMMENT '当前负责人名称快照', + `assigned_at` datetime(3) DEFAULT NULL COMMENT '最近指派时间', + `started_at` datetime(3) DEFAULT NULL COMMENT '开始履约时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '履约完成时间', + `created_by` bigint unsigned NOT NULL COMMENT '创建人或系统主体ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_task_tenant_order` (`tenant_id`,`order_id`), + KEY `idx_task_assignee_status` (`tenant_id`,`assignee_id`,`status`,`created_at`), + KEY `idx_task_tenant_created` (`tenant_id`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约任务表:接收订单事件后形成独立履约任务,管理指派、执行和完成状态'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_carton_label`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_carton_label` ( + `id` bigint unsigned NOT NULL COMMENT '实际箱唛头映射ID(雪花ID)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `order_id` bigint unsigned NOT NULL COMMENT '订单服务订单ID快照', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '订单货物明细ID,不建立跨库外键', + `cargo_item_no` int unsigned NOT NULL COMMENT '订单货物明细序号快照', + `cargo_name` varchar(255) DEFAULT NULL COMMENT '货物名称快照', + `sku_code` varchar(128) DEFAULT NULL COMMENT '箱唛绑定的SKU快照;二维码内容不变', + `units_per_carton` int unsigned DEFAULT NULL COMMENT '每箱SKU数量快照;一件代发必须为正数', + `detail_package_count` int unsigned NOT NULL COMMENT '该订单货物明细箱数快照', + `inbound_code` varchar(40) NOT NULL COMMENT '入仓码快照', + `box_code` varchar(80) NOT NULL COMMENT '固定二维码内容:入仓码#总箱数-箱下标', + `total_carton_count` int unsigned NOT NULL COMMENT '首次分配时整单总箱数', + `carton_index` int unsigned NOT NULL COMMENT '整单从1开始的稳定箱下标', + `label_version` int unsigned NOT NULL DEFAULT '1' COMMENT '箱唛批次版本,从1递增', + `batch_status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '批次状态:ACTIVE有效、VOID已作废', + `cargo_fingerprint` char(64) DEFAULT NULL COMMENT '影响标签字段的SHA-256指纹', + `voided_at` datetime(3) DEFAULT NULL COMMENT '批次作废时间', + `box_specification` varchar(255) NOT NULL COMMENT '订单箱规或货物明细描述快照', + `length_cm` decimal(12,3) DEFAULT NULL COMMENT '下单箱规长度cm快照', + `width_cm` decimal(12,3) DEFAULT NULL COMMENT '下单箱规宽度cm快照', + `height_cm` decimal(12,3) DEFAULT NULL COMMENT '下单箱规高度cm快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次分配时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carton_label_box_code` (`tenant_id`,`box_code`), + UNIQUE KEY `uk_carton_label_index` (`tenant_id`,`inbound_id`,`label_version`,`carton_index`), + KEY `idx_carton_label_detail` (`tenant_id`,`inbound_id`,`inbound_detail_id`), + KEY `fk_carton_label_inbound` (`inbound_id`), + KEY `idx_carton_label_active` (`tenant_id`,`inbound_id`,`batch_status`,`label_version`), + CONSTRAINT `fk_carton_label_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='首次打印前冻结的实际箱号与订单货物明细映射'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_carton_label_sku`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_carton_label_sku` ( + `id` bigint unsigned NOT NULL COMMENT '箱唛SKU快照ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `carton_label_id` bigint unsigned NOT NULL COMMENT '物理箱唛ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '订单货物明细ID', + `sku_code` varchar(128) NOT NULL COMMENT 'SKU编码快照', + `cargo_name` varchar(255) DEFAULT NULL COMMENT '货物名称快照', + `expected_quantity` int unsigned NOT NULL COMMENT '该物理箱内应收SKU数量', + `line_no` int unsigned NOT NULL COMMENT '箱内SKU展示顺序', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_carton_label_sku_detail` (`carton_label_id`,`inbound_detail_id`), + KEY `idx_carton_label_sku_code` (`tenant_id`,`inbound_id`,`sku_code`), + CONSTRAINT `fk_carton_label_sku_label` FOREIGN KEY (`carton_label_id`) REFERENCES `fl_inbound_carton_label` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='物理箱唛内的多SKU冻结快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_destination_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_destination_snapshot` ( + `id` bigint unsigned NOT NULL COMMENT '目的仓地址快照ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `destination_type` varchar(24) DEFAULT NULL COMMENT '目的仓类型', + `destination_code` varchar(32) DEFAULT NULL COMMENT '目的仓编码', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称', + `country_code` char(2) DEFAULT NULL COMMENT '国家二字码', + `state_province` varchar(100) DEFAULT NULL COMMENT '省或州', + `city` varchar(100) DEFAULT NULL COMMENT '城市', + `address_line` varchar(512) DEFAULT NULL COMMENT '详细地址', + `postal_code` varchar(20) DEFAULT NULL COMMENT '邮编', + `contact_name` varchar(64) DEFAULT NULL COMMENT '联系人', + `contact_phone` varchar(32) DEFAULT NULL COMMENT '联系人电话', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_destination_inbound` (`tenant_id`,`inbound_id`), + KEY `fk_destination_inbound` (`inbound_id`), + CONSTRAINT `fk_destination_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单目的仓地址和联系人快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_fee`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_fee` ( + `id` bigint unsigned NOT NULL COMMENT '入库费用ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `measurement_id` bigint unsigned DEFAULT NULL COMMENT '璐圭敤缁戝畾鐨勬祴閲忔壒娆D蹇収', + `measurement_source` varchar(32) DEFAULT NULL COMMENT '璁¤垂灏哄鏉ユ簮蹇収锛歄UTER涓哄绠辫鏁版嵁', + `fee_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户内唯一费用单号', + `fee_version` int unsigned NOT NULL DEFAULT '1' COMMENT '费用快照版本', + `charge_rule_id` bigint unsigned DEFAULT NULL COMMENT '历史客户计费规则ID;渠道价格可为空', + `charge_rule_version` int unsigned DEFAULT NULL COMMENT '历史客户规则版本快照', + `carrier_rate_id` bigint unsigned DEFAULT NULL COMMENT '船司FBA价格ID;人工报价为空', + `carrier_rate_version` int unsigned DEFAULT NULL COMMENT '船司价格版本快照;人工报价为空', + `price_book_version_id` bigint unsigned DEFAULT NULL COMMENT '整表价格版本ID快照', + `rule_code` varchar(50) DEFAULT NULL COMMENT '历史客户规则编码快照', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '可选业务客户快照;平台公共渠道计费不依赖该字段', + `customer_name` varchar(128) NOT NULL COMMENT '客户名称快照', + `business_type` varchar(32) NOT NULL COMMENT '业务类型快照', + `tax_mode` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '订单买单或退税模式快照', + `pricing_tax_mode` varchar(16) DEFAULT NULL COMMENT '报价包税维度快照', + `rounding_scale` tinyint unsigned DEFAULT NULL COMMENT '历史客户规则计费量保留位数快照', + `rounding_mode` varchar(24) DEFAULT NULL COMMENT '历史客户规则舍入方式快照', + `rule_effective_from` datetime(3) DEFAULT NULL COMMENT '历史客户规则生效开始快照', + `rule_effective_to` datetime(3) DEFAULT NULL COMMENT '客户规则生效结束快照', + `rate_code` varchar(50) NOT NULL COMMENT '船司价格编码快照', + `origin_warehouse_id` bigint unsigned NOT NULL COMMENT '起运仓ID快照', + `origin_warehouse_name` varchar(128) NOT NULL COMMENT '起运仓名称快照', + `channel_code` varchar(64) NOT NULL COMMENT '渠道编码快照', + `carrier_id` bigint unsigned DEFAULT NULL COMMENT '历史承运商ID;渠道价格可为空', + `carrier_name` varchar(128) DEFAULT NULL COMMENT '历史承运商名称;渠道价格可为空', + `destination_type` varchar(16) DEFAULT NULL COMMENT '目的地类型快照:FBA或OVERSEAS', + `destination_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '海外仓或FBA目的仓ID快照', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `destination_code` varchar(32) DEFAULT NULL COMMENT 'FBA仓编码快照;海外仓可为空', + `rate_effective_from` datetime(3) DEFAULT NULL COMMENT '价格生效开始快照;人工报价为空', + `rate_effective_to` datetime(3) DEFAULT NULL COMMENT '价格生效结束快照', + `charge_mode` varchar(16) DEFAULT NULL COMMENT '计费口径:KG或CBM', + `conversion_factor` decimal(18,6) DEFAULT NULL COMMENT '历史客户规则使用的换算系数快照', + `actual_weight_kg` decimal(18,3) NOT NULL COMMENT '试算时实际重量kg', + `actual_volume_cbm` decimal(18,4) NOT NULL COMMENT '试算时实际体积m3', + `converted_quantity` decimal(18,4) NOT NULL COMMENT '换算后的重量或体积', + `chargeable_quantity` decimal(18,4) NOT NULL COMMENT '最终计费量', + `charge_unit` varchar(16) NOT NULL COMMENT '计费单位', + `currency` char(3) NOT NULL COMMENT '币种', + `subtotal_amount` decimal(18,2) NOT NULL COMMENT '基础费用金额', + `additional_amount` decimal(18,2) NOT NULL DEFAULT '0.00' COMMENT '附加费金额', + `total_amount` decimal(18,2) NOT NULL COMMENT '总金额', + `fee_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'CALCULATED' COMMENT '费用状态:CALCULATED已计算、SUPERSEDED已被新版本替代', + `review_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认', + `exception_release` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否基于测量异常放行生成', + `calculated_by` bigint unsigned NOT NULL COMMENT '试算人ID', + `calculated_at` datetime(3) NOT NULL COMMENT '试算时间', + `confirmed_by` bigint unsigned DEFAULT NULL COMMENT '确认人ID', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '确认时间', + `sent_by` bigint unsigned DEFAULT NULL COMMENT '发送人ID', + `sent_at` datetime(3) DEFAULT NULL COMMENT '发送客户时间', + `customer_confirmed_by` bigint unsigned DEFAULT NULL COMMENT '瀹㈡埛纭浜虹敤鎴稩D', + `customer_confirmed_by_name` varchar(64) DEFAULT NULL COMMENT '瀹㈡埛纭浜哄悕绉板揩鐓�', + `customer_confirmed_at` datetime(3) DEFAULT NULL COMMENT '瀹㈡埛纭璐圭敤鏃堕棿锛涢潪绌哄悗璐圭敤鐗堟湰閿佸畾', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `baseline_chargeable_quantity` decimal(18,6) NOT NULL COMMENT '系统按权威测量与生效报价计算的原始计费量', + `tolerance_percent` decimal(5,2) NOT NULL DEFAULT '10.00' COMMENT '人工调整允许的最大偏差百分比,边界包含', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_fee_no` (`tenant_id`,`fee_no`), + UNIQUE KEY `uk_inbound_fee_version` (`tenant_id`,`inbound_id`,`fee_version`), + KEY `idx_inbound_fee_status` (`tenant_id`,`inbound_id`,`fee_status`,`review_status`), + KEY `fk_fee_inbound` (`inbound_id`), + KEY `fk_fee_charge_rule` (`charge_rule_id`), + KEY `fk_fee_carrier_rate` (`carrier_rate_id`), + CONSTRAINT `fk_fee_carrier_rate` FOREIGN KEY (`carrier_rate_id`) REFERENCES `fl_carrier_fba_rate` (`id`), + CONSTRAINT `fk_fee_charge_rule` FOREIGN KEY (`charge_rule_id`) REFERENCES `fl_customer_charge_rule` (`id`), + CONSTRAINT `fk_fee_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用计算和确认快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_fee_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_fee_detail` ( + `id` bigint unsigned NOT NULL COMMENT '入库费用明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID(客户隔离键);费用明细引用平台公共费用项目', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `fee_item_id` bigint unsigned NOT NULL COMMENT '绑定费用项目ID', + `source_type` varchar(32) NOT NULL DEFAULT 'MANUAL' COMMENT 'MANUAL或FBA_PRICING', + `source_fee_id` bigint unsigned DEFAULT NULL COMMENT '来源计费快照ID', + `source_fee_version` int unsigned DEFAULT NULL COMMENT '来源计费快照版本', + `source_fee_no` varchar(40) DEFAULT NULL COMMENT '来源费用单号', + `inbound_code` varchar(40) NOT NULL COMMENT '入仓码快照', + `direction` varchar(16) NOT NULL COMMENT 'RECEIVABLE应收、PAYABLE应付', + `business_date` date NOT NULL COMMENT '业务发生日', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '客户ID快照', + `customer_name` varchar(128) DEFAULT NULL COMMENT '客户公司名称快照', + `invoice_title` varchar(256) DEFAULT NULL COMMENT '发票抬头快照', + `fee_category_snapshot` varchar(64) NOT NULL COMMENT '费用分类快照', + `trade_scope_snapshot` varchar(20) NOT NULL COMMENT '国内国际快照', + `fee_type_name_snapshot` varchar(128) NOT NULL COMMENT '费用类型名称快照', + `channel_code` varchar(64) DEFAULT NULL COMMENT '渠道编码快照', + `channel_name` varchar(128) DEFAULT NULL COMMENT '渠道名称快照', + `destination_code` varchar(32) DEFAULT NULL COMMENT '目的仓编码快照', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `package_count` int DEFAULT NULL COMMENT '件数快照', + `actual_gross_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '仓库实测毛重快照,单位千克', + `actual_volume_cbm` decimal(18,6) DEFAULT NULL COMMENT '实际体积快照,单位立方米', + `volume_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '材积重快照,单位千克', + `chargeable_weight_kg` decimal(18,4) DEFAULT NULL COMMENT '费用计算采用的计费重量,单位千克', + `chargeable_volume_cbm` decimal(18,6) DEFAULT NULL COMMENT '费用计算采用的计费体积,单位立方米', + `chargeable_quantity` decimal(18,4) DEFAULT NULL COMMENT '本费用最终计费量', + `charge_unit` varchar(16) DEFAULT NULL COMMENT 'KG或CBM', + `unit_price` decimal(18,4) NOT NULL COMMENT '费用单价;计价单位由对应费用项目规则确定', + `currency` char(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '金额币种三字码,如CNY', + `amount` decimal(18,2) NOT NULL COMMENT '该费用明细金额,币种由currency字段指定', + `remark` varchar(500) DEFAULT NULL COMMENT '备注', + `review_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回', + `reviewer_id` bigint DEFAULT NULL COMMENT '审核人ID', + `reviewer_name` varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + `reviewed_at` datetime(3) DEFAULT NULL COMMENT '审核时间', + `review_reason` varchar(500) DEFAULT NULL COMMENT '审核驳回原因', + `publication_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'DRAFT' COMMENT '费用发布状态:DRAFT草稿、PUBLISHED已发布、SUPERSEDED已替代', + `published_by` bigint DEFAULT NULL COMMENT '发布人ID', + `published_by_name` varchar(64) DEFAULT NULL COMMENT '发布人名称快照', + `published_at` datetime(3) DEFAULT NULL COMMENT '发布时间', + `inbounded_at` datetime(3) DEFAULT NULL COMMENT '入库完成时间快照', + `settlement_included` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否参与结算', + `created_by` bigint DEFAULT NULL COMMENT '创建人或系统主体ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint DEFAULT NULL COMMENT '最后修改人或系统主体ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0未删除、1已删除', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `paid_at` datetime(3) DEFAULT NULL COMMENT 'DIRECT_PAID业务已付时间', + `payment_method` varchar(32) DEFAULT NULL COMMENT 'DIRECT_PAID付款方式', + `payment_reference_no` varchar(128) DEFAULT NULL COMMENT 'DIRECT_PAID付款流水号', + `payment_voucher_file_ids` json DEFAULT NULL COMMENT 'DIRECT_PAID付款凭证File ID数组', + `settlement_mode` varchar(16) DEFAULT NULL COMMENT '仅PAYABLE:ON_ACCOUNT或DIRECT_PAID', + `supplier_category` varchar(16) DEFAULT NULL COMMENT '费用项目供应商类别约束快照;为空表示任意启用供应商', + `supplier_id` bigint unsigned DEFAULT NULL COMMENT '应付Warehouse供应商ID快照;应收为空', + `supplier_name` varchar(200) DEFAULT NULL COMMENT '应付Warehouse供应商名称快照;应收为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_fee_source` (`tenant_id`,`source_type`,`source_fee_id`), + KEY `idx_inbound_fee_detail` (`tenant_id`,`inbound_id`,`business_date`,`deleted`), + KEY `idx_inbound_fee_review` (`tenant_id`,`review_status`,`business_date`,`deleted`), + KEY `idx_inbound_fee_item` (`tenant_id`,`fee_item_id`,`deleted`), + KEY `fk_inbound_fee_detail_order` (`inbound_id`), + KEY `fk_inbound_fee_detail_item` (`fee_item_id`), + KEY `idx_inbound_fee_published` (`tenant_id`,`inbound_id`,`publication_status`,`source_fee_version`), + CONSTRAINT `fk_inbound_fee_detail_item` FOREIGN KEY (`fee_item_id`) REFERENCES `fl_fee_item` (`id`), + CONSTRAINT `fk_inbound_fee_detail_order` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单应收应付费用明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_fee_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_fee_item` ( + `id` bigint unsigned NOT NULL COMMENT '费用明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `fee_id` bigint unsigned NOT NULL COMMENT '费用头ID', + `item_no` int unsigned NOT NULL COMMENT '费用单内序号', + `fee_code` varchar(50) NOT NULL COMMENT '费用项目编码', + `fee_name` varchar(128) NOT NULL COMMENT '费用项目名称快照', + `quantity` decimal(18,4) NOT NULL COMMENT '计费数量', + `unit` varchar(16) NOT NULL COMMENT '计费单位', + `unit_price` decimal(18,6) NOT NULL COMMENT '单价', + `amount` decimal(18,2) NOT NULL COMMENT '金额', + `currency` char(3) NOT NULL COMMENT '币种', + `automatic_flag` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否自动计算', + `remark` varchar(500) DEFAULT NULL COMMENT '费用备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_fee_item_no` (`tenant_id`,`fee_id`,`item_no`), + KEY `fk_fee_item_header` (`fee_id`), + CONSTRAINT `fk_fee_item_header` FOREIGN KEY (`fee_id`) REFERENCES `fl_inbound_fee` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库头程费用项目明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_file`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_file` ( + `id` bigint unsigned NOT NULL COMMENT '入库单文件关系ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `file_id` bigint unsigned NOT NULL COMMENT '文件服务文件ID', + `file_type` varchar(24) NOT NULL COMMENT '文件类型:RELATED、MARK、PC_ORDER、MEASURE或WEIGHT', + `file_name_snapshot` varchar(255) NOT NULL COMMENT '文件名快照', + `visibility` varchar(16) NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围', + `file_version` int unsigned NOT NULL DEFAULT '1' COMMENT '业务文件版本', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传人ID', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_file_relation` (`tenant_id`,`inbound_id`,`file_id`,`file_type`), + KEY `idx_inbound_file_type` (`tenant_id`,`inbound_id`,`file_type`,`deleted`), + KEY `fk_file_inbound` (`inbound_id`), + CONSTRAINT `fk_file_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单与文件中心对象的分类关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_hold_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_hold_record` ( + `id` bigint unsigned NOT NULL COMMENT '扣货记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `hold_action` varchar(16) NOT NULL COMMENT '动作:HOLD或RELEASE', + `reason` varchar(1000) NOT NULL COMMENT '扣货或解除原因', + `evidence_file_id` bigint unsigned DEFAULT NULL COMMENT '证据文件ID', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_hold_inbound_time` (`tenant_id`,`inbound_id`,`operated_at`), + KEY `fk_hold_inbound` (`inbound_id`), + CONSTRAINT `fk_hold_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单扣货和解除扣货不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_idempotency`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_idempotency` ( + `id` bigint unsigned NOT NULL COMMENT '入库写操作幂等记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `operation` varchar(40) NOT NULL COMMENT '操作类型', + `operator_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '操作者或外部应用ID', + `inbound_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '入库单ID,创建前可为0', + `idempotency_key` varchar(128) NOT NULL COMMENT '幂等键', + `request_hash` char(64) NOT NULL COMMENT '请求内容摘要', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等处理状态:PROCESSING处理中、SUCCEEDED已成功、FAILED已失败', + `result_json` json DEFAULT NULL COMMENT '成功结果摘要', + `result_resource_id` bigint unsigned DEFAULT NULL COMMENT '成功结果资源ID', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_idempotency` (`tenant_id`,`operation`,`operator_id`,`idempotency_key`), + KEY `idx_inbound_idempotency_created` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方测量、审核、费用发送和入库写操作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_internal_note`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_internal_note` ( + `id` bigint unsigned NOT NULL COMMENT '客服内部备注ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `content` varchar(2000) NOT NULL COMMENT '仅内部可见备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + KEY `idx_internal_note_time` (`tenant_id`,`inbound_id`,`created_at`), + KEY `fk_internal_note_inbound` (`inbound_id`), + CONSTRAINT `fk_internal_note_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单客服内部备注历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_loading_pick_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_loading_pick_task` ( + `id` bigint unsigned NOT NULL COMMENT '入库单级PDA拣货任务ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '任务状态:PENDING待拣货、IN_PROGRESS拣货中、COMPLETED已完成', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `expected_count` int unsigned DEFAULT NULL COMMENT '计划应拣箱数', + `picked_count` int unsigned DEFAULT NULL COMMENT '实际已拣箱数', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '拣货结果或异常补充说明', + `updated_by` bigint unsigned NOT NULL COMMENT '最后操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货任务最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_tenant_inbound` (`tenant_id`,`inbound_id`), + KEY `idx_loading_pick_status` (`tenant_id`,`status`,`updated_at`), + KEY `fk_loading_pick_inbound` (`inbound_id`), + CONSTRAINT `fk_loading_pick_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货任务及结果'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_measurement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_measurement` ( + `id` bigint unsigned NOT NULL COMMENT '测量批次ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `split_batch_id` bigint unsigned DEFAULT NULL COMMENT '产生该测量批次的入仓拆分批次ID', + `source_measurement_id` bigint unsigned DEFAULT NULL COMMENT '子单继承或复核时的来源测量批次ID', + `source_type` varchar(24) NOT NULL COMMENT '数据来源:THIRD_PARTY或MANUAL', + `source_code` varchar(64) DEFAULT NULL COMMENT '第三方应用或来源编码', + `external_event_no` varchar(128) DEFAULT NULL COMMENT '第三方事件号', + `external_request_hash` char(64) DEFAULT NULL COMMENT '第三方data原文和图片内容联合请求哈希', + `device_code` varchar(64) DEFAULT NULL COMMENT '测量设备编码', + `warehouse_code` varchar(50) DEFAULT NULL COMMENT '第三方回传仓库编码', + `measured_at` datetime(3) DEFAULT NULL COMMENT '设备测量时间', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'RECEIVED' COMMENT '测量批次状态:DRAFT草稿、RECEIVED已接收、SUBMITTED已提交、APPROVED已通过、REJECTED已驳回', + `raw_payload_json` json DEFAULT NULL COMMENT '第三方原始报文', + `original_document_no` varchar(64) DEFAULT NULL COMMENT '原单据号码快照', + `original_gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '原单据毛重kg', + `original_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '原单据体积或材积m3', + `actual_goods_name` varchar(255) DEFAULT NULL COMMENT '实际品名', + `total_package_count` int unsigned DEFAULT NULL COMMENT '本批次总箱数', + `total_volume_raw_cbm` decimal(18,6) DEFAULT NULL COMMENT '本批次原始总体积m3', + `total_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '本批次业务总体积m3', + `volume_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本批次总材积重kg', + `created_by` bigint unsigned DEFAULT NULL COMMENT '创建人或系统主体ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_measure_external_event` (`tenant_id`,`source_code`,`external_event_no`), + KEY `idx_measure_inbound_time` (`tenant_id`,`inbound_id`,`created_at`), + KEY `fk_measure_inbound` (`inbound_id`), + KEY `fk_measure_split_batch` (`split_batch_id`), + KEY `fk_measure_source_batch` (`source_measurement_id`), + KEY `idx_measure_split_source` (`tenant_id`,`split_batch_id`,`source_measurement_id`,`created_at`), + CONSTRAINT `fk_measure_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_measure_source_batch` FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`), + CONSTRAINT `fk_measure_split_batch` FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库长宽高测量批次和第三方原始数据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_measurement_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_measurement_item` ( + `id` bigint unsigned NOT NULL COMMENT '测量明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `measurement_id` bigint unsigned NOT NULL COMMENT '测量批次ID', + `split_item_id` bigint unsigned DEFAULT NULL COMMENT '产生该子单测量明细的拆分台账ID', + `source_measurement_item_id` bigint unsigned DEFAULT NULL COMMENT '被拆分继承的来源测量箱规明细ID', + `inbound_detail_id` bigint unsigned DEFAULT NULL COMMENT '绑定的订单货物明细ID,平台手工明细可为空', + `external_detail_no` varchar(128) DEFAULT NULL COMMENT '第三方测量明细唯一号,平台手工明细可为空', + `box_code` varchar(128) DEFAULT NULL COMMENT '柏悦唛头箱号;设备单箱测量时同一客户内唯一', + `scanned_shipment_id` varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', + `item_no` varchar(32) NOT NULL COMMENT '批次内明细号', + `box_specification` varchar(64) NOT NULL COMMENT '箱规标识', + `package_count` int unsigned NOT NULL COMMENT '相同箱规箱数', + `length_cm` decimal(12,3) NOT NULL COMMENT '原始长度cm', + `width_cm` decimal(12,3) NOT NULL COMMENT '原始宽度cm', + `height_cm` decimal(12,3) NOT NULL COMMENT '原始高度cm', + `girth_cm` decimal(12,3) NOT NULL COMMENT '周长cm,规则为长+2*(宽+高)', + `unit_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '单箱重量kg,第三方仅回传尺寸时可为空', + `unit_volume_cbm` decimal(18,4) NOT NULL COMMENT '单箱业务体积m3', + `volume_raw_cbm` decimal(18,6) NOT NULL COMMENT '原始体积m3', + `volume_cbm` decimal(18,4) NOT NULL COMMENT '业务体积m3', + `total_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本箱规总重量kg', + `total_volume_cbm` decimal(18,4) NOT NULL COMMENT '本箱规总体积m3', + `volume_weight_kg` decimal(18,3) NOT NULL COMMENT '本箱规材积重kg', + `charge_rule_id` bigint unsigned DEFAULT NULL COMMENT 'customer charge rule id', + `coefficient_mode` varchar(16) DEFAULT NULL COMMENT '系数模式快照:ADD或MULTIPLY', + `calculation_rule_version` varchar(64) NOT NULL COMMENT '体积计算和舍入规则版本', + `source_image_file_id` bigint unsigned DEFAULT NULL COMMENT '测量设备图片文件ID', + `machine_no` varchar(64) DEFAULT NULL COMMENT '本次测量设备编号', + `device_measured_at` datetime(3) DEFAULT NULL COMMENT '本次设备测量时间', + `over_limit_warning` varchar(500) DEFAULT NULL COMMENT '超线提醒快照', + `outer_length_cm` decimal(12,3) DEFAULT NULL COMMENT '外箱规长度cm', + `outer_width_cm` decimal(12,3) DEFAULT NULL COMMENT '外箱规宽度cm', + `outer_height_cm` decimal(12,3) DEFAULT NULL COMMENT '外箱规高度cm', + `manual_outer_length_cm` decimal(12,3) DEFAULT NULL COMMENT '浠撳簱浜哄伐澶栫瑙勯暱搴﹁绠楀熀鏁癱m', + `manual_outer_width_cm` decimal(12,3) DEFAULT NULL COMMENT '浠撳簱浜哄伐澶栫瑙勫搴﹁绠楀熀鏁癱m', + `manual_outer_height_cm` decimal(12,3) DEFAULT NULL COMMENT '浠撳簱浜哄伐澶栫瑙勯珮搴﹁绠楀熀鏁癱m', + `remark` varchar(500) DEFAULT NULL COMMENT '测量明细备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_measure_item_no` (`tenant_id`,`measurement_id`,`item_no`), + UNIQUE KEY `uk_measure_external_detail` (`tenant_id`,`inbound_detail_id`,`external_detail_no`), + UNIQUE KEY `uk_measure_box_code` (`tenant_id`,`box_code`), + KEY `fk_measure_item_batch` (`measurement_id`), + KEY `fk_measure_item_split_item` (`split_item_id`), + KEY `fk_measure_item_source_item` (`source_measurement_item_id`), + KEY `idx_measure_item_split_source` (`tenant_id`,`split_item_id`,`source_measurement_item_id`), + CONSTRAINT `fk_measure_item_batch` FOREIGN KEY (`measurement_id`) REFERENCES `fl_inbound_measurement` (`id`), + CONSTRAINT `fk_measure_item_source_item` FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`), + CONSTRAINT `fk_measure_item_split_item` FOREIGN KEY (`split_item_id`) REFERENCES `fl_inbound_split_item` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库长宽高和箱数测量明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_order`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_order` ( + `id` bigint unsigned NOT NULL COMMENT '入库单ID(雪花ID)', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `task_id` bigint unsigned DEFAULT NULL COMMENT '头程履约任务ID', + `order_id` bigint unsigned NOT NULL COMMENT '订单服务订单ID,不建立跨库外键', + `order_no` varchar(32) NOT NULL COMMENT '订单号快照', + `inbound_code` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户内唯一入仓码', + `inbound_role` varchar(16) NOT NULL DEFAULT 'MASTER' COMMENT '入仓单层级:MASTER主单、CHILD子单', + `parent_inbound_id` bigint unsigned DEFAULT NULL COMMENT '直属主入仓单ID;主单为空', + `root_inbound_id` bigint unsigned DEFAULT NULL COMMENT '根主入仓单ID;存量主单可为空,按自身ID解释', + `split_sequence` int unsigned DEFAULT NULL COMMENT '同一主单下的子单顺序号', + `split_status` varchar(24) NOT NULL DEFAULT 'NOT_SPLIT' COMMENT '拆分状态:NOT_SPLIT、PARTIALLY_SPLIT、FULLY_SPLIT', + `process_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'WAIT_MEASURE' COMMENT '入库流程状态:WAIT_MEASURE待测量、MEASURING测量中、MEASURE_PENDING待审核、MEASURE_REJECTED测量驳回、WAIT_PRICING待计费、FEE_CONFIRMING费用确认中、INBOUNDING入库收口中、INBOUNDED已入库、WAIT_SPLIT历史待分柜', + `order_status_snapshot` varchar(24) NOT NULL COMMENT '订单状态快照', + `measure_review_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '测量审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回', + `fee_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'UNPRICED' COMMENT '费用状态:UNPRICED未计费、CALCULATED已计算、CONFIRMED已确认、SENT已发送、VOIDED已作废', + `fee_review_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NOT_SUBMITTED' COMMENT '费用审核状态:NOT_SUBMITTED未提交、PENDING待审核、APPROVED已通过、REJECTED已驳回、CONFIRMED已确认', + `notification_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'NOT_CREATED' COMMENT '客户通知状态:NOT_CREATED未创建、PENDING待通知', + `expected_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到达时间', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '客户逻辑ID', + `customer_name` varchar(128) NOT NULL COMMENT '客户名称快照', + `company_id` bigint unsigned DEFAULT NULL COMMENT '运营公司逻辑ID', + `company_name` varchar(128) DEFAULT NULL COMMENT '运营公司名称快照', + `warehouse_id` bigint unsigned NOT NULL COMMENT '实际作业仓库ID', + `warehouse_name` varchar(128) NOT NULL COMMENT '实际作业仓库名称快照', + `delivery_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '交货仓逻辑ID', + `delivery_warehouse_name` varchar(128) DEFAULT NULL COMMENT '交货仓名称快照', + `business_type` varchar(32) NOT NULL COMMENT '业务类型', + `fulfillment_type` varchar(32) NOT NULL DEFAULT 'STANDARD' COMMENT '履约类型:STANDARD普通头程、ONE_CLICK_DROPSHIP一键代发', + `lx_code` varchar(128) DEFAULT NULL COMMENT '一件代发领星入库单代码', + `channel_code` varchar(64) DEFAULT NULL COMMENT '渠道编码', + `channel_name` varchar(128) DEFAULT NULL COMMENT '渠道名称快照', + `carrier_id` bigint unsigned DEFAULT NULL COMMENT '计费船司逻辑ID', + `carrier_name` varchar(128) DEFAULT NULL COMMENT '计费船司名称快照', + `tax_mode` varchar(24) DEFAULT NULL COMMENT '买单或退税模式', + `pricing_tax_mode` varchar(16) DEFAULT NULL COMMENT '报价包税维度:TAX_INCLUDED/TAX_EXCLUDED', + `container_mode` varchar(24) DEFAULT NULL COMMENT '整柜或散货模式', + `customs_inspection` tinyint(1) DEFAULT NULL COMMENT '是否商检', + `destination_type` varchar(24) DEFAULT NULL COMMENT '目的仓类型', + `destination_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '目的仓逻辑ID', + `destination_code` varchar(32) DEFAULT NULL COMMENT '目的仓编码,例如FBA仓编码', + `destination_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `hold_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT '当前是否扣货', + `current_hold_reason` varchar(500) DEFAULT NULL COMMENT '当前扣货理由快照', + `last_overseas_hold_version` bigint unsigned DEFAULT NULL COMMENT '海外暂扣事件最新聚合版本,用于阻止乱序覆盖', + `total_package_count` int unsigned DEFAULT NULL COMMENT '确认后的总箱数', + `package_count` int unsigned DEFAULT NULL COMMENT '入仓单箱数;三期唯一数量口径', + `split_purpose` varchar(16) NOT NULL DEFAULT 'MASTER' COMMENT '履约分片用途:MASTER、LOADED、REMAINDER', + `actual_gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认后的实际总毛重kg', + `chargeable_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '当前费用快照的计费重量kg', + `actual_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '确认后的实际总体积m3', + `chargeable_volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '当前费用快照的计费体积m3', + `pallet_count` int unsigned DEFAULT NULL COMMENT '已分配总托盘数快照', + `volume_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '总材积重kg', + `actual_net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '总净重kg', + `measure_completed_at` datetime(3) DEFAULT NULL COMMENT '测量完成时间', + `inbounded_at` datetime(3) DEFAULT NULL COMMENT '入库完成时间', + `express_company_code` varchar(64) DEFAULT NULL COMMENT '快递公司编码快照', + `express_company_name` varchar(128) DEFAULT NULL COMMENT '快递公司名称快照', + `express_tracking_no` varchar(128) DEFAULT NULL COMMENT '快递单号快照', + `public_remark` varchar(1000) DEFAULT NULL COMMENT '客户可见备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `master_order_id` bigint unsigned GENERATED ALWAYS AS ((case when ((`inbound_role` = _utf8mb4'MASTER') and (`deleted` = 0)) then `order_id` else NULL end)) STORED COMMENT '仅用于约束有效主单订单唯一性', + `active_remainder_root_id` bigint unsigned GENERATED ALWAYS AS ((case when ((`inbound_role` = _utf8mb4'CHILD') and (`split_purpose` = _utf8mb4'REMAINDER') and (`split_status` = _utf8mb4'NOT_SPLIT') and (`deleted` = 0)) then `root_inbound_id` else NULL end)) STORED COMMENT '有效余量唯一约束键;历史已完成分片为空', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_code` (`inbound_code`), + UNIQUE KEY `uk_inbound_master_order` (`tenant_id`,`master_order_id`), + UNIQUE KEY `uk_inbound_parent_split_sequence` (`tenant_id`,`parent_inbound_id`,`split_sequence`), + UNIQUE KEY `uk_inbound_one_active_remainder` (`tenant_id`,`active_remainder_root_id`), + KEY `idx_inbound_process_time` (`tenant_id`,`process_status`,`created_at`,`id`), + KEY `idx_inbound_measure_time` (`tenant_id`,`measure_review_status`,`created_at`,`id`), + KEY `idx_inbound_fee_time` (`tenant_id`,`fee_review_status`,`created_at`,`id`), + KEY `idx_inbound_warehouse_status` (`tenant_id`,`warehouse_id`,`process_status`,`created_at`), + KEY `idx_inbound_customer_time` (`tenant_id`,`customer_id`,`created_at`), + KEY `idx_inbound_company_time` (`tenant_id`,`company_id`,`created_at`), + KEY `idx_inbound_arrival` (`tenant_id`,`expected_arrival_at`), + KEY `idx_inbound_destination` (`tenant_id`,`destination_code`,`created_at`), + KEY `idx_inbound_channel` (`tenant_id`,`channel_code`,`created_at`), + KEY `fk_inbound_task` (`task_id`), + KEY `idx_inbound_root_role` (`tenant_id`,`root_inbound_id`,`inbound_role`,`created_at`,`id`), + KEY `fk_inbound_parent_order` (`parent_inbound_id`), + KEY `fk_inbound_root_order` (`root_inbound_id`), + CONSTRAINT `fk_inbound_parent_order` FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_root_order` FOREIGN KEY (`root_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_task` FOREIGN KEY (`task_id`) REFERENCES `fl_fulfillment_task` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='二期头程入库单聚合根和列表查询快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_outbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_outbox` ( + `id` bigint unsigned NOT NULL COMMENT '领域事件全局ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '事件所属客户ID和数据隔离键', + `aggregate_type` varchar(32) NOT NULL DEFAULT 'INBOUND' COMMENT '聚合类型', + `aggregate_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `event_type` varchar(64) NOT NULL COMMENT '事件类型', + `event_key` varchar(160) DEFAULT NULL COMMENT '费用来源事件幂等键;历史事件允许为空', + `event_version` int unsigned NOT NULL DEFAULT '1' COMMENT '事件信封版本', + `aggregate_sequence` int unsigned DEFAULT NULL COMMENT '同一费用来源的单调版本序号', + `payload_hash` varchar(71) DEFAULT NULL COMMENT '事件规范化负载SHA-256;标准跨区事件含sha256:前缀', + `payload_json` longtext NOT NULL COMMENT '事件负载快照;文本类型保留跨区事件摘要所依赖的数值词法', + `publish_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '事件发布状态:PENDING待发布、PUBLISHED已发布、FAILED发布失败', + `retry_count` int NOT NULL DEFAULT '0' COMMENT '重试次数', + `next_retry_at` datetime(3) DEFAULT NULL COMMENT '下次重试时间', + `published_at` datetime(3) DEFAULT NULL COMMENT '发布时间', + `locked_at` datetime(3) DEFAULT NULL COMMENT 'Relay抢占租约时间', + `locked_by` varchar(64) DEFAULT NULL COMMENT 'Relay抢占实例ID', + `last_error` varchar(500) DEFAULT NULL COMMENT '最近一次投递失败摘要,不记录令牌', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_outbox_event_key` (`event_key`), + KEY `idx_inbound_outbox_publish` (`publish_status`,`next_retry_at`,`created_at`), + KEY `idx_inbound_outbox_finance_relay` (`publish_status`,`next_retry_at`,`created_at`,`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库事务Outbox事件表'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_pallet_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_pallet_allocation` ( + `id` bigint unsigned NOT NULL COMMENT '头程托盘分配引用ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `allocation_no` int unsigned NOT NULL COMMENT '入库单内分配序号', + `block_id` bigint unsigned NOT NULL COMMENT 'Warehouse整票连续库位块ID', + `slot_no` int unsigned NOT NULL COMMENT '连续块内稳定槽位序号', + `weight_result_id` bigint unsigned DEFAULT NULL COMMENT '已绑定称重结果ID;预留空槽为空', + `pallet_sequence` int unsigned DEFAULT NULL COMMENT '已绑定称重托盘在整票内的稳定顺序;空槽为空', + `warehouse_id` bigint unsigned NOT NULL COMMENT '仓库服务仓库ID', + `warehouse_resource_tenant_id` bigint unsigned NOT NULL COMMENT '平台公共仓储资源固定使用 0;tenant_id 仍是客户隔离键', + `pallet_id` bigint unsigned NOT NULL COMMENT '仓库服务托盘ID', + `pallet_code` varchar(50) NOT NULL COMMENT '托盘编码快照', + `pallet_capacity_cbm` decimal(18,4) NOT NULL COMMENT '托盘可用容积快照', + `allocated_volume_cbm` decimal(18,4) NOT NULL COMMENT '本托盘分配体积', + `allocated_package_count` int unsigned DEFAULT NULL COMMENT '本托盘分配箱数', + `picked_package_count` int unsigned NOT NULL DEFAULT '0' COMMENT '已拣货箱数', + `location_id` bigint unsigned NOT NULL COMMENT '仓库服务库位ID', + `location_code` varchar(50) NOT NULL COMMENT '库位编码快照', + `shared_overflow` tinyint(1) NOT NULL DEFAULT '0' COMMENT '普通连续位不足时是否使用共享A0', + `reservation_id` bigint unsigned DEFAULT NULL COMMENT '仓库预占资源唯一ID,A0共享场景用于区分托盘', + `slot_status` varchar(24) NOT NULL DEFAULT 'RESERVED_EMPTY' COMMENT '槽位状态:RESERVED_EMPTY、BOUND、TRANSFERRED、RELEASED', + `reservation_no` varchar(64) NOT NULL COMMENT '仓库库位预占单号', + `allocation_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'RESERVED' COMMENT '托盘库位分配状态:RESERVED已预占、CONFIRMED已确认、TRANSFERRED已转移、RELEASED已释放', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pallet_allocation_no` (`tenant_id`,`inbound_id`,`allocation_no`), + UNIQUE KEY `uk_pallet_allocation_slot` (`tenant_id`,`block_id`,`slot_no`), + UNIQUE KEY `uk_pallet_allocation_weight` (`tenant_id`,`weight_result_id`), + KEY `idx_allocation_location` (`tenant_id`,`warehouse_id`,`location_id`,`allocation_status`), + KEY `fk_allocation_inbound` (`inbound_id`), + KEY `idx_allocation_reservation` (`reservation_id`), + CONSTRAINT `fk_allocation_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程侧托盘、库位和仓库预占结果引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_pickup_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_pickup_snapshot` ( + `id` bigint unsigned NOT NULL COMMENT '提货信息快照ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `pickup_method` varchar(24) DEFAULT NULL COMMENT '提货方式', + `pickup_at` datetime(3) DEFAULT NULL COMMENT '提货时间', + `contact_name` varchar(64) DEFAULT NULL COMMENT '提货联系人', + `contact_phone` varchar(32) DEFAULT NULL COMMENT '提货联系人电话', + `contact_email` varchar(128) DEFAULT NULL COMMENT '提货联系人邮箱', + `country_code` char(2) DEFAULT NULL COMMENT '国家二字码', + `province` varchar(64) DEFAULT NULL COMMENT '省或州', + `city` varchar(64) DEFAULT NULL COMMENT '城市', + `district` varchar(64) DEFAULT NULL COMMENT '区县', + `address_line` varchar(512) DEFAULT NULL COMMENT '提货详细地址', + `postal_code` varchar(20) DEFAULT NULL COMMENT '提货邮编', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_pickup_inbound` (`tenant_id`,`inbound_id`), + KEY `fk_pickup_inbound` (`inbound_id`), + CONSTRAINT `fk_pickup_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单提货信息不可变快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_split_batch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_split_batch` ( + `id` bigint unsigned NOT NULL COMMENT '拆分批次ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `parent_inbound_id` bigint unsigned NOT NULL COMMENT '主入仓单ID', + `source_inbound_id` bigint unsigned DEFAULT NULL COMMENT '本批次实际消耗的MASTER或REMAINDER分片ID', + `operation_id` varchar(64) NOT NULL COMMENT '业务操作幂等号', + `idempotency_key` varchar(128) NOT NULL COMMENT '请求幂等键', + `expected_parent_version` int NOT NULL COMMENT '操作时主单乐观锁版本', + `package_count` int unsigned NOT NULL COMMENT '本批次拆分箱数', + `loaded_child_inbound_id` bigint unsigned DEFAULT NULL COMMENT '本批次实装子单ID', + `remainder_child_inbound_id` bigint unsigned DEFAULT NULL COMMENT '本批次余量子单ID', + `loaded_package_count` int unsigned NOT NULL DEFAULT '0' COMMENT '本批次实装箱数', + `remaining_package_count` int unsigned NOT NULL DEFAULT '0' COMMENT '本批次未装箱数', + `loading_plan_id` bigint unsigned DEFAULT NULL COMMENT '关联装柜计划/柜次', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联柜次标识(兼容查询)', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PREPARED' COMMENT '拆分批次状态:PREPARED已准备、APPLIED已生效、CANCELLED已取消', + `remark` varchar(500) DEFAULT NULL COMMENT '拆分原因或备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分批次创建时间', + `applied_at` datetime(3) DEFAULT NULL COMMENT '批次生效时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_split_operation` (`tenant_id`,`operation_id`), + UNIQUE KEY `uk_inbound_split_idempotency` (`tenant_id`,`parent_inbound_id`,`idempotency_key`), + UNIQUE KEY `uk_inbound_split_source_once` (`tenant_id`,`source_inbound_id`), + KEY `idx_inbound_split_parent` (`tenant_id`,`parent_inbound_id`,`created_at`,`id`), + KEY `fk_inbound_split_parent` (`parent_inbound_id`), + KEY `fk_inbound_split_loaded_child` (`loaded_child_inbound_id`), + KEY `fk_inbound_split_source` (`source_inbound_id`), + KEY `fk_inbound_split_remainder_child` (`remainder_child_inbound_id`), + KEY `idx_inbound_split_loading_plan` (`tenant_id`,`loading_plan_id`,`container_booking_id`,`status`,`id`), + CONSTRAINT `fk_inbound_split_loaded_child` FOREIGN KEY (`loaded_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_parent` FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_remainder_child` FOREIGN KEY (`remainder_child_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_source` FOREIGN KEY (`source_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_inbound_split_package` CHECK ((`package_count` > 0)), + CONSTRAINT `chk_inbound_split_status` CHECK ((`status` in (_utf8mb4'PREPARED',_utf8mb4'APPLIED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主入仓单拆分批次;数量口径仅为箱数'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_split_detail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_split_detail` ( + `id` bigint unsigned NOT NULL COMMENT '部分锁定明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `parent_inbound_id` bigint unsigned NOT NULL COMMENT '主入库单ID', + `child_inbound_id` bigint unsigned DEFAULT NULL COMMENT '完成拆单后生成的二级子入库单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '原订单货物明细ID,不建立跨库外键', + `source_package_count` int unsigned NOT NULL COMMENT '拆分前该明细总箱数快照', + `locked_package_count` int unsigned NOT NULL COMMENT '本次装柜锁定箱数', + `remaining_package_count` int unsigned GENERATED ALWAYS AS ((`source_package_count` - `locked_package_count`)) STORED COMMENT '锁定后剩余箱数,由来源箱数减锁定箱数生成', + `status` varchar(16) NOT NULL DEFAULT 'LOCKED' COMMENT '分柜明细状态:LOCKED已锁定、COMPLETED已完成拆分', + `operation_id` varchar(128) NOT NULL COMMENT '部分锁定操作幂等号', + `created_by` bigint unsigned NOT NULL COMMENT '创建部分锁定明细的操作人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', + `completed_by` bigint unsigned DEFAULT NULL COMMENT '完成拆分的操作人ID', + `completed_by_name` varchar(64) DEFAULT NULL COMMENT '完成操作人姓名快照', + `completed_at` datetime(3) DEFAULT NULL COMMENT '部分拆分完成时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '部分锁定明细创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '部分锁定明细最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_split_parent_detail` (`tenant_id`,`parent_inbound_id`,`inbound_detail_id`), + UNIQUE KEY `uk_inbound_split_operation` (`operation_id`), + UNIQUE KEY `uk_inbound_split_child_detail` (`tenant_id`,`child_inbound_id`,`inbound_detail_id`), + KEY `idx_inbound_split_status` (`tenant_id`,`parent_inbound_id`,`status`), + KEY `fk_inbound_split_detail_parent` (`parent_inbound_id`), + KEY `fk_inbound_split_detail_child` (`child_inbound_id`), + CONSTRAINT `fk_inbound_split_detail_child` FOREIGN KEY (`child_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_detail_parent` FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_inbound_split_completion` CHECK ((((`status` = _utf8mb4'LOCKED') and (`child_inbound_id` is null) and (`completed_at` is null)) or ((`status` = _utf8mb4'COMPLETED') and (`child_inbound_id` is not null) and (`completed_at` is not null)))), + CONSTRAINT `chk_inbound_split_partial` CHECK (((`source_package_count` > 1) and (`locked_package_count` > 0) and (`locked_package_count` < `source_package_count`))), + CONSTRAINT `chk_legacy_inbound_split_status` CHECK ((`status` in (_utf8mb4'LOCKED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='按箱数部分锁定及剩余箱数生成二级子入库单的不可变契约'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_split_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_split_history` ( + `id` bigint unsigned NOT NULL COMMENT '拆分历史ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `split_batch_id` bigint unsigned NOT NULL COMMENT '拆分批次ID', + `operation_id` varchar(64) NOT NULL COMMENT '历史操作幂等号', + `action_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '拆分动作:CREATE创建批次、APPLY生效、CANCEL取消', + `before_snapshot` json DEFAULT NULL COMMENT '操作前快照', + `after_snapshot` json NOT NULL COMMENT '操作后快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operation_reason` varchar(500) DEFAULT NULL COMMENT '操作原因', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拆分历史创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_split_history_operation` (`tenant_id`,`operation_id`), + KEY `idx_inbound_split_history` (`tenant_id`,`split_batch_id`,`created_at`,`id`), + KEY `fk_inbound_split_history_batch` (`split_batch_id`), + CONSTRAINT `fk_inbound_split_history_batch` FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`), + CONSTRAINT `chk_inbound_split_action` CHECK ((`action_type` in (_utf8mb4'CREATE',_utf8mb4'APPLY',_utf8mb4'CANCEL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='不可变主子入仓单拆分审计历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_split_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_split_item` ( + `id` bigint unsigned NOT NULL COMMENT '拆分明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `split_batch_id` bigint unsigned NOT NULL COMMENT '拆分批次ID', + `parent_inbound_id` bigint unsigned NOT NULL COMMENT '主入仓单ID', + `child_inbound_id` bigint unsigned NOT NULL COMMENT '子入仓单ID', + `line_no` int unsigned NOT NULL COMMENT '同一拆分批次内的行号', + `source_inbound_detail_id` bigint unsigned DEFAULT NULL COMMENT '订单货物明细逻辑ID', + `source_measurement_id` bigint unsigned NOT NULL COMMENT '来源测量批次ID', + `source_measurement_item_id` bigint unsigned NOT NULL COMMENT '来源测量箱规明细ID', + `package_count` int unsigned NOT NULL COMMENT '分配给子单的箱数', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拆分数量台账创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_inbound_split_batch_line` (`tenant_id`,`split_batch_id`,`line_no`), + UNIQUE KEY `uk_inbound_split_batch_source_child` (`tenant_id`,`split_batch_id`,`source_measurement_item_id`,`child_inbound_id`), + KEY `idx_inbound_split_detail` (`tenant_id`,`source_inbound_detail_id`,`created_at`), + KEY `fk_inbound_split_item_batch` (`split_batch_id`), + KEY `fk_inbound_split_item_parent` (`parent_inbound_id`), + KEY `fk_inbound_split_item_child` (`child_inbound_id`), + KEY `fk_inbound_split_item_measurement` (`source_measurement_id`), + KEY `fk_inbound_split_item_measure_item` (`source_measurement_item_id`), + CONSTRAINT `fk_inbound_split_item_batch` FOREIGN KEY (`split_batch_id`) REFERENCES `fl_inbound_split_batch` (`id`), + CONSTRAINT `fk_inbound_split_item_child` FOREIGN KEY (`child_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_inbound_split_item_measure_item` FOREIGN KEY (`source_measurement_item_id`) REFERENCES `fl_inbound_measurement_item` (`id`), + CONSTRAINT `fk_inbound_split_item_measurement` FOREIGN KEY (`source_measurement_id`) REFERENCES `fl_inbound_measurement` (`id`), + CONSTRAINT `fk_inbound_split_item_parent` FOREIGN KEY (`parent_inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_inbound_split_item_package` CHECK ((`package_count` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='主子入仓单拆分数量台账'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_status_history` ( + `id` bigint unsigned NOT NULL COMMENT '入库状态历史ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `from_status` varchar(24) DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(24) NOT NULL COMMENT '变更后状态', + `action` varchar(32) NOT NULL COMMENT '触发动作', + `reason` varchar(1000) DEFAULT NULL COMMENT '变更原因', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `occurred_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间', + PRIMARY KEY (`id`), + KEY `idx_inbound_history_time` (`tenant_id`,`inbound_id`,`occurred_at`), + KEY `fk_history_inbound` (`inbound_id`), + CONSTRAINT `fk_history_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单流程状态不可变历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_transport_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_transport_snapshot` ( + `id` bigint unsigned NOT NULL COMMENT '运输信息快照ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `loading_status` varchar(24) DEFAULT NULL COMMENT '配载状态快照', + `container_no` varchar(64) DEFAULT NULL COMMENT '集装箱号', + `cabinet_no` varchar(64) DEFAULT NULL COMMENT '柜号或内部柜单号', + `vessel_name` varchar(128) DEFAULT NULL COMMENT '船名', + `voyage_no` varchar(64) DEFAULT NULL COMMENT '航次', + `sailing_at` datetime(3) DEFAULT NULL COMMENT '开船时间', + `port_arrival_at` datetime(3) DEFAULT NULL COMMENT '到港时间', + `source_updated_at` datetime(3) DEFAULT NULL COMMENT '上游数据更新时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_transport_inbound` (`tenant_id`,`inbound_id`), + KEY `idx_transport_container` (`tenant_id`,`container_no`), + KEY `idx_transport_cabinet` (`tenant_id`,`cabinet_no`), + KEY `idx_transport_loading` (`tenant_id`,`loading_status`,`sailing_at`), + KEY `fk_transport_inbound` (`inbound_id`), + CONSTRAINT `fk_transport_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单关联运输信息只读快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_weight_result`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_weight_result` ( + `id` bigint unsigned NOT NULL COMMENT 'OCR重量结果ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `file_id` bigint unsigned NOT NULL COMMENT '称重图片文件ID', + `pallet_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'Warehouse托盘ID', + `pallet_code` varchar(50) NOT NULL DEFAULT '' COMMENT '托盘编码快照', + `pallet_type` varchar(32) NOT NULL DEFAULT '' COMMENT '托盘类型快照', + `pallet_capacity_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000' COMMENT '托盘容量CBM快照', + `pallet_tare_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000' COMMENT '托盘皮重KG快照', + `ocr_provider` varchar(64) DEFAULT NULL COMMENT 'OCR服务商编码', + `ocr_raw_text` varchar(1000) DEFAULT NULL COMMENT 'OCR原始文本', + `ocr_weight_value` decimal(18,3) DEFAULT NULL COMMENT 'OCR识别原始重量值', + `ocr_weight_unit` varchar(8) DEFAULT NULL COMMENT 'OCR识别原始单位', + `confidence` decimal(6,5) DEFAULT NULL COMMENT '识别置信度', + `adjusted_weight_value` decimal(18,3) DEFAULT NULL COMMENT '人工修正后的原单位重量', + `adjusted_weight_unit` varchar(8) DEFAULT NULL COMMENT '人工修正后的单位', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认时统一换算后的含托盘毛重kg', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', + `recognition_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '重量识别状态:PENDING待识别、PENDING_CONFIRM待确认、NEEDS_MANUAL需人工录入、CONFIRMED已确认', + `adjustment_reason` varchar(500) DEFAULT NULL COMMENT '人工修正原因', + `adjusted_by` bigint unsigned DEFAULT NULL COMMENT '修正人ID', + `adjusted_at` datetime(3) DEFAULT NULL COMMENT '修正时间', + `confirmed_by` bigint unsigned DEFAULT NULL COMMENT '确认人ID', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '确认时间', + `created_by` bigint unsigned NOT NULL DEFAULT '0' COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL DEFAULT '0' COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_weight_inbound_file` (`tenant_id`,`inbound_id`,`file_id`), + KEY `idx_weight_inbound_status` (`tenant_id`,`inbound_id`,`recognition_status`), + KEY `fk_weight_inbound` (`inbound_id`), + KEY `idx_weight_inbound_pallet` (`tenant_id`,`inbound_id`,`pallet_id`), + CONSTRAINT `fk_weight_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='称重图片OCR原值、人工修正值和确认重量'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_inbound_weight_revision`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_inbound_weight_revision` ( + `id` bigint unsigned NOT NULL COMMENT 'OCR重量修订记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `weight_result_id` bigint unsigned NOT NULL COMMENT 'OCR重量结果ID', + `revision_no` int unsigned NOT NULL COMMENT '结果版本号,与乐观锁版本一致', + `action` varchar(24) NOT NULL COMMENT '动作:CORRECT或CONFIRM', + `before_weight_value` decimal(18,3) DEFAULT NULL COMMENT '变更前原单位重量', + `before_weight_unit` varchar(8) DEFAULT NULL COMMENT '变更前单位', + `after_weight_value` decimal(18,3) DEFAULT NULL COMMENT '变更后原单位重量', + `after_weight_unit` varchar(8) DEFAULT NULL COMMENT '变更后单位', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认时统一换算后的含托盘毛重kg', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '确认毛重扣除托盘皮重后的货物净重kg', + `reason` varchar(500) DEFAULT NULL COMMENT '人工修正原因', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_weight_revision_no` (`tenant_id`,`weight_result_id`,`revision_no`), + KEY `idx_weight_revision_time` (`tenant_id`,`inbound_id`,`operated_at`), + KEY `fk_weight_revision_inbound` (`inbound_id`), + KEY `fk_weight_revision_result` (`weight_result_id`), + CONSTRAINT `fk_weight_revision_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_weight_revision_result` FOREIGN KEY (`weight_result_id`) REFERENCES `fl_inbound_weight_result` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='OCR重量人工修正和确认的不可变版本历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_cabinet_evidence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_cabinet_evidence` ( + `id` bigint unsigned NOT NULL COMMENT '柜次证据记录ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '证据唯一归属柜次ID', + `source_task_id` bigint unsigned NOT NULL COMMENT '上传来源PDA任务ID,仅用于审计', + `evidence_type` varchar(32) NOT NULL COMMENT '四步照片或带锁码照类型', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件ID', + `file_name` varchar(255) NOT NULL COMMENT '文件名称快照', + `seal_no` varchar(64) DEFAULT NULL COMMENT '带锁码照对应锁码', + `remark` varchar(1000) DEFAULT NULL COMMENT '证据备注', + `status` varchar(16) NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID作废', + `active_flag` tinyint unsigned DEFAULT '1' COMMENT '有效唯一标识;作废后置NULL', + `operation_key` varchar(128) NOT NULL COMMENT '任务范围内幂等操作键', + `request_hash` char(64) NOT NULL COMMENT '规范化请求SHA-256摘要', + `void_reason` varchar(500) DEFAULT NULL COMMENT '作废原因', + `voided_by` bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + `voided_by_name` varchar(128) DEFAULT NULL COMMENT '作废操作人名称快照', + `voided_at` datetime(3) DEFAULT NULL COMMENT '作废时间', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(128) DEFAULT NULL COMMENT '创建操作人名称快照', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(128) DEFAULT NULL COMMENT '最后修改操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_cabinet_evidence_operation` (`source_task_id`,`operation_key`), + UNIQUE KEY `uk_cabinet_evidence_active` (`container_booking_id`,`evidence_type`,`active_flag`), + KEY `idx_cabinet_evidence_container` (`container_booking_id`,`status`), + KEY `idx_cabinet_evidence_seal` (`seal_no`,`active_flag`), + CONSTRAINT `fk_cabinet_evidence_booking` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `fk_cabinet_evidence_task` FOREIGN KEY (`source_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_cabinet_evidence_status` CHECK ((`status` in (_utf8mb4'ACTIVE',_utf8mb4'VOID'))), + CONSTRAINT `chk_cabinet_evidence_type` CHECK ((`evidence_type` in (_utf8mb4'PHOTO_STEP_1',_utf8mb4'PHOTO_STEP_2',_utf8mb4'PHOTO_STEP_3',_utf8mb4'PHOTO_STEP_4',_utf8mb4'SEAL'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次物理装柜证据;task仅记录来源审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_completion_checkpoint`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_completion_checkpoint` ( + `command_id` bigint unsigned NOT NULL COMMENT '可靠装柜完成命令ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '入库单客户ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `owner_sequence` int unsigned NOT NULL COMMENT '稳定处理顺序', + `checkpoint_stage` varchar(24) NOT NULL COMMENT '入库单处理检查点', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '检查点创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '检查点更新时间', + PRIMARY KEY (`command_id`,`tenant_id`,`inbound_id`), + KEY `idx_loading_completion_owner` (`command_id`,`owner_sequence`), + CONSTRAINT `fk_loading_completion_checkpoint_command` FOREIGN KEY (`command_id`) REFERENCES `fl_loading_completion_command` (`id`), + CONSTRAINT `chk_loading_completion_checkpoint` CHECK ((`checkpoint_stage` in (_utf8mb4'READY',_utf8mb4'SETTLED',_utf8mb4'INBOUND_COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜完成按入库单恢复检查点'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_completion_command`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_completion_command` ( + `id` bigint unsigned NOT NULL COMMENT '可靠装柜完成命令ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA任务ID', + `operation_id` varchar(128) NOT NULL COMMENT '客户端全局幂等操作ID', + `request_hash` char(64) NOT NULL COMMENT '规范化请求摘要', + `command_status` varchar(16) NOT NULL COMMENT 'PROCESSING、RETRYABLE、FAILED、COMPLETED', + `expected_task_version` int unsigned NOT NULL COMMENT '受理时客户端任务版本', + `remark` varchar(500) DEFAULT NULL COMMENT '完成备注', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `attempt_count` int unsigned NOT NULL DEFAULT '0' COMMENT 'worker认领次数', + `next_retry_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次处理时间', + `lease_owner` varchar(128) DEFAULT NULL COMMENT 'worker租约持有者', + `lease_until` datetime(3) DEFAULT NULL COMMENT 'worker租约截止时间', + `failure_stage` varchar(40) DEFAULT NULL COMMENT '脱敏失败阶段', + `failure_code` varchar(32) DEFAULT NULL COMMENT '下游或业务错误码', + `failure_request_id` varchar(64) DEFAULT NULL COMMENT '下游请求ID', + `failure_trace_id` varchar(64) DEFAULT NULL COMMENT '下游链路ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + `completed_at` datetime(3) DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_completion_operation` (`operation_id`), + KEY `idx_loading_completion_dispatch` (`command_status`,`next_retry_at`,`lease_until`), + KEY `fk_loading_completion_task` (`pick_task_id`), + CONSTRAINT `fk_loading_completion_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_completion_status` CHECK ((`command_status` in (_utf8mb4'PROCESSING',_utf8mb4'RETRYABLE',_utf8mb4'FAILED',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜完成可靠异步命令'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_evidence`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_evidence` ( + `id` bigint unsigned NOT NULL COMMENT '装柜证据记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + `evidence_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'PHOTO_STEP_1空仓/PHOTO_STEP_2半仓/PHOTO_STEP_3满仓/PHOTO_STEP_4锁仓/SEAL带锁码照', + `file_id` bigint unsigned DEFAULT NULL COMMENT 'File 服务逻辑文件ID', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上传时文件名称快照', + `seal_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '锁码文本;带锁码照必填,客户不可见', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '证据补充说明', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `created_by` bigint unsigned NOT NULL COMMENT '上传证据的操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上传证据的操作人姓名快照', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '证据记录创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '证据记录最后更新时间', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '证据状态:ACTIVE有效、VOID已作废;错误照片保留记录不硬删除', + `void_reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '照片作废原因', + `voided_by` bigint unsigned DEFAULT NULL COMMENT '执行作废的平台操作人ID', + `voided_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行作废的平台操作人姓名快照', + `voided_at` datetime(3) DEFAULT NULL COMMENT '照片作废时间', + PRIMARY KEY (`id`), + KEY `idx_loading_evidence_time` (`tenant_id`,`inbound_id`,`updated_at`), + KEY `fk_loading_evidence_inbound` (`inbound_id`), + KEY `idx_loading_evidence_type` (`tenant_id`,`inbound_id`,`evidence_type`,`status`), + KEY `idx_loading_evidence_seal` (`tenant_id`,`seal_no`,`status`), + CONSTRAINT `fk_loading_evidence_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单装柜空仓/半仓/满仓/锁仓/带锁码照证据'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_operation` ( + `id` bigint unsigned NOT NULL COMMENT '装柜写操作记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '操作目标入库单逻辑ID', + `operator_id` bigint unsigned NOT NULL COMMENT '执行写操作的平台用户ID', + `operation` varchar(48) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '装柜或证据写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功、FAILED失败', + `result_json` json DEFAULT NULL COMMENT '成功结果或失败信息JSON快照,用于幂等重放', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作声明创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '操作完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_operation` (`tenant_id`,`inbound_id`,`operator_id`,`operation`,`idempotency_key`), + KEY `idx_loading_operation_time` (`tenant_id`,`inbound_id`,`created_at`), + KEY `fk_loading_operation_inbound` (`inbound_id`), + CONSTRAINT `fk_loading_operation_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='装柜与派送写操作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pallet_label`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pallet_label` ( + `id` bigint unsigned NOT NULL COMMENT '散货托盘标签ID(雪花ID)', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID', + `loading_plan_item_id` bigint unsigned NOT NULL COMMENT '标签归属装柜明细ID', + `account_id` bigint unsigned NOT NULL COMMENT '客户ID快照', + `inbound_id` bigint unsigned NOT NULL COMMENT '入仓单ID快照', + `inbound_code` varchar(40) NOT NULL COMMENT '入仓码快照', + `container_no` varchar(64) NOT NULL COMMENT '真实集装箱号快照', + `destination_warehouse_code` varchar(64) NOT NULL COMMENT 'FBA仓编码快照', + `destination_warehouse_name` varchar(128) DEFAULT NULL COMMENT 'FBA仓名称快照', + `pallet_index` int unsigned NOT NULL COMMENT '当前柜当前FBA仓内从1开始的序号', + `pallet_code` varchar(128) NOT NULL COMMENT '托盘码:集装箱号-FBA仓编码-三位序号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '生成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pallet_code` (`pallet_code`), + UNIQUE KEY `uk_loading_pallet_index` (`loading_plan_id`,`destination_warehouse_code`,`pallet_index`), + KEY `idx_loading_pallet_item` (`loading_plan_item_id`,`pallet_index`), + KEY `idx_loading_pallet_inbound` (`account_id`,`inbound_id`), + CONSTRAINT `fk_loading_pallet_item` FOREIGN KEY (`loading_plan_item_id`) REFERENCES `fl_loading_plan_item` (`id`), + CONSTRAINT `fk_loading_pallet_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `chk_loading_pallet_index` CHECK ((`pallet_index` between 1 and 999)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='散货按柜号和FBA仓冻结的可打印托盘码'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_assignment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_assignment` ( + `pick_task_id` bigint unsigned NOT NULL COMMENT '柜次PDA拣货任务ID;同一任务仅保留一名作业员', + `assignee_id` bigint unsigned NOT NULL COMMENT '当前唯一作业员ID', + `assignee_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '当前作业员姓名快照', + `assigned_by` bigint unsigned NOT NULL COMMENT '领取任务的作业员或执行分配的平台用户ID', + `assigned_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '领取或分配操作人姓名快照', + `assignment_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分配方式:CLAIMED作业员领取、ASSIGNED平台指派', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,防止多人并发领取或覆盖', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务领取或分配时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '作业员分配最后更新时间', + PRIMARY KEY (`pick_task_id`), + KEY `idx_loading_pick_assignee` (`assignee_id`,`updated_at`), + CONSTRAINT `fk_loading_pick_assignment_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_pick_assignment_type` CHECK ((`assignment_type` in (_utf8mb4'CLAIMED',_utf8mb4'ASSIGNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务领取与作业员分配'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_history` ( + `id` bigint unsigned NOT NULL COMMENT 'PDA拣货操作历史ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + `pick_item_id` bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID', + `operation_id` varchar(128) NOT NULL COMMENT '客户端幂等操作号', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '完整批量请求内容SHA-256摘要', + `action_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PICK' COMMENT '拣货动作:PICK正常拣货、PARTIAL部分拣货、SKIP跳拣', + `picked_package_count` int unsigned NOT NULL COMMENT '本次拣货箱数', + `operation_reason` varchar(500) DEFAULT NULL COMMENT '部分拣货或跳拣原因', + `operator_id` bigint unsigned NOT NULL COMMENT '执行PDA拣货操作的作业员ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '作业员姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变拣货操作历史创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_history_operation` (`operation_id`), + KEY `idx_loading_pick_history_item` (`pick_item_id`,`created_at`), + KEY `fk_loading_pick_history_task` (`pick_task_id`), + CONSTRAINT `fk_loading_pick_history_item` FOREIGN KEY (`pick_item_id`) REFERENCES `fl_loading_pick_item` (`id`), + CONSTRAINT `fk_loading_pick_history_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货幂等和操作审计历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_item` ( + `id` bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + `loading_plan_item_id` bigint unsigned NOT NULL COMMENT '装柜计划明细ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '父入库单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '订单货物明细ID', + `planned_package_count` int unsigned NOT NULL COMMENT '计划拣货箱数;上层拆分按业务规则向上取整', + `picked_package_count` int unsigned NOT NULL DEFAULT '0' COMMENT '已拣货箱数', + `item_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '拣货明细状态:PENDING待拣、PICKING拣货中、PICKED已拣完、PARTIAL部分拣货、SKIPPED跳拣', + `release_mode` varchar(10) DEFAULT NULL COMMENT 'AUTO按比例释放,MANUAL按人工选择释放', + `release_location_ids` json DEFAULT NULL COMMENT '人工选择释放库位ID;空数组表示明确释放0个', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT 'PDA拣货明细最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_item_plan_item` (`loading_plan_item_id`), + KEY `idx_loading_pick_item_task` (`pick_task_id`,`item_status`,`id`), + CONSTRAINT `fk_loading_pick_item_plan_item` FOREIGN KEY (`loading_plan_item_id`) REFERENCES `fl_loading_plan_item` (`id`), + CONSTRAINT `fk_loading_pick_item_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_pick_item_count` CHECK ((`picked_package_count` <= `planned_package_count`)), + CONSTRAINT `chk_loading_pick_item_planned` CHECK ((`planned_package_count` > 0)), + CONSTRAINT `chk_loading_pick_item_status` CHECK ((`item_status` in (_utf8mb4'PENDING',_utf8mb4'PICKING',_utf8mb4'PICKED',_utf8mb4'PARTIAL',_utf8mb4'SKIPPED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜拣货明细'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_item_assignment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_item_assignment` ( + `pick_item_id` bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID,同时作为本表主键', + `assignee_id` bigint unsigned NOT NULL COMMENT '当前领取或被分配的作业员ID', + `assignee_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '当前作业员姓名快照', + `assigned_by` bigint unsigned NOT NULL COMMENT '执行领取或分配操作的用户ID', + `assigned_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行领取或分配操作的用户姓名快照', + `assignment_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分配类型:CLAIMED作业员领取、ASSIGNED管理员分配', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '明细分配记录创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '明细分配记录最后更新时间', + PRIMARY KEY (`pick_item_id`), + KEY `idx_loading_pick_item_assignee` (`assignee_id`,`updated_at`), + CONSTRAINT `fk_loading_pick_item_assignment_item` FOREIGN KEY (`pick_item_id`) REFERENCES `fl_loading_pick_item` (`id`), + CONSTRAINT `chk_loading_pick_item_assignment_type` CHECK ((`assignment_type` in (_utf8mb4'CLAIMED',_utf8mb4'ASSIGNED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA拣货明细领取与作业员分配'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_operation` ( + `id` bigint unsigned NOT NULL COMMENT 'PDA任务操作审计记录ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT '关联柜次PDA拣货任务ID', + `operation_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '全局唯一操作ID,用于幂等重放', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '完整批量请求或任务操作内容SHA-256摘要', + `action_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '动作类型:CLAIM领取、ASSIGN分配、REVIEW复核、BATCH批量提交', + `operator_id` bigint unsigned NOT NULL COMMENT '执行PDA任务操作的用户ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '不可变操作审计创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_operation_id` (`operation_id`), + KEY `idx_loading_pick_operation_task` (`pick_task_id`,`created_at`), + CONSTRAINT `fk_loading_pick_operation_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_pick_operation_action` CHECK ((`action_type` in (_gbk'CLAIM',_gbk'ASSIGN',_gbk'ITEM_CLAIM',_gbk'ITEM_ASSIGN',_gbk'REVIEW',_gbk'BATCH',_gbk'COMPLETE_LOADING'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务级幂等审计'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_review`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_review` ( + `pick_task_id` bigint unsigned NOT NULL COMMENT '已完成的柜次PDA拣货任务ID', + `review_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'APPROVED' COMMENT '复核状态:APPROVED已通过;记录创建后不可修改或删除', + `reviewed_by` bigint unsigned NOT NULL COMMENT '执行完成复核的平台用户ID', + `reviewed_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '复核人姓名快照', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT 'PDA任务复核备注', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '复核记录版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '复核通过时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '复核记录最后更新时间;正常业务不允许更新', + PRIMARY KEY (`pick_task_id`), + KEY `idx_loading_pick_reviewer` (`reviewed_by`,`created_at`), + CONSTRAINT `fk_loading_pick_review_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_pick_review_status` CHECK ((`review_status` = _utf8mb4'APPROVED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='柜次PDA任务完成复核'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_pick_task`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_pick_task` ( + `id` bigint unsigned NOT NULL COMMENT 'PDA拣货任务ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '稳定柜次ID', + `cabinet_no` varchar(64) DEFAULT NULL COMMENT '稳定内部柜次号快照', + `container_no` varchar(64) DEFAULT NULL COMMENT '真实集装箱号;生成PDA任务前允许为空', + `task_status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'LOADING' COMMENT 'PDA任务状态:LOADING拣货装柜中、COMPLETED已完成', + `started_by` bigint unsigned NOT NULL COMMENT '开始装柜人', + `started_by_name` varchar(64) DEFAULT NULL COMMENT '开始装柜人名称', + `started_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '开始时间', + `completed_by` bigint unsigned DEFAULT NULL COMMENT '完成装柜人', + `completed_by_name` varchar(64) DEFAULT NULL COMMENT '完成装柜人名称', + `completed_at` datetime(3) DEFAULT NULL COMMENT '完成时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_pick_task_plan` (`loading_plan_id`), + UNIQUE KEY `uk_loading_pick_task_booking` (`container_booking_id`), + KEY `idx_loading_pick_container` (`container_no`,`task_status`,`started_at`), + CONSTRAINT `fk_loading_pick_task_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `chk_loading_pick_task_status` CHECK ((`task_status` in (_utf8mb4'LOADING',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='PDA装柜拣货任务'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!50003 SET @saved_cs_client = @@character_set_client */ ; +/*!50003 SET @saved_cs_results = @@character_set_results */ ; +/*!50003 SET @saved_col_connection = @@collation_connection */ ; +/*!50003 SET character_set_client = utf8mb4 */ ; +/*!50003 SET character_set_results = utf8mb4 */ ; +/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ; +/*!50003 SET @saved_sql_mode = @@sql_mode */ ; +/*!50003 SET sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' */ ; +DELIMITER ;; +/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg_loading_pick_task_before_update` BEFORE UPDATE ON `fl_loading_pick_task` FOR EACH ROW BEGIN IF OLD.task_status = 'COMPLETED' THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '已完成PDA拣货任务不可修改'; END IF; IF NEW.loading_plan_id <> OLD.loading_plan_id OR NOT (NEW.container_booking_id <=> OLD.container_booking_id) OR NOT (NEW.cabinet_no <=> OLD.cabinet_no) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务稳定归属不可修改'; END IF; IF NOT (OLD.task_status = 'LOADING' AND NEW.task_status IN ('LOADING', 'COMPLETED')) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'PDA拣货任务状态只允许完成'; END IF; END */;; +DELIMITER ; +/*!50003 SET sql_mode = @saved_sql_mode */ ; +/*!50003 SET character_set_client = @saved_cs_client */ ; +/*!50003 SET character_set_results = @saved_cs_results */ ; +/*!50003 SET collation_connection = @saved_col_connection */ ; +DROP TABLE IF EXISTS `fl_loading_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_plan` ( + `id` bigint unsigned NOT NULL COMMENT '智能装柜计划ID(雪花ID)', + `plan_no` varchar(40) NOT NULL COMMENT '平台唯一装柜计划号', + `container_booking_id` bigint unsigned NOT NULL COMMENT '货柜预订ID', + `plan_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'EDITING' COMMENT '装柜计划状态:EDITING编辑中、LOADING装柜中、COMPLETED装柜完成;建议须经人工确认', + `push_idempotency_key` varchar(128) DEFAULT NULL COMMENT '推送预装幂等键', + `preload_pushed_by` bigint unsigned DEFAULT NULL COMMENT '推送预装操作人ID', + `preload_pushed_by_name` varchar(64) DEFAULT NULL COMMENT '推送预装操作人名称快照', + `preload_pushed_at` datetime(3) DEFAULT NULL COMMENT '开始装柜并进入LOADING的时间', + `preload_completed_by` bigint unsigned DEFAULT NULL COMMENT '预装完成操作人ID', + `preload_completed_by_name` varchar(64) DEFAULT NULL COMMENT '预装完成操作人名称快照', + `preload_completed_at` datetime(3) DEFAULT NULL COMMENT '预装完成时间', + `confirmed_by` bigint unsigned DEFAULT NULL COMMENT '确认操作人ID', + `confirmed_by_name` varchar(64) DEFAULT NULL COMMENT '确认操作人名称快照', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '确认时间', + `remark` varchar(500) DEFAULT NULL COMMENT '平台装柜计划备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_by_name` varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜计划最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_plan_no` (`plan_no`), + UNIQUE KEY `uk_loading_plan_booking` (`container_booking_id`), + UNIQUE KEY `uk_loading_plan_push_key` (`push_idempotency_key`), + KEY `idx_loading_plan_status` (`plan_status`,`updated_at`,`id`), + CONSTRAINT `fk_loading_plan_booking` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_loading_plan_status` CHECK ((`plan_status` in (_utf8mb4'EDITING',_utf8mb4'LOADING',_utf8mb4'COMPLETED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台智能装柜计划与唯一预装状态源'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!50003 SET @saved_cs_client = @@character_set_client */ ; +/*!50003 SET @saved_cs_results = @@character_set_results */ ; +/*!50003 SET @saved_col_connection = @@collation_connection */ ; +/*!50003 SET character_set_client = utf8mb4 */ ; +/*!50003 SET character_set_results = utf8mb4 */ ; +/*!50003 SET collation_connection = utf8mb4_0900_ai_ci */ ; +/*!50003 SET @saved_sql_mode = @@sql_mode */ ; +/*!50003 SET sql_mode = 'ONLY_FULL_GROUP_BY,STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION' */ ; +DELIMITER ;; +/*!50003 CREATE*/ /*!50017 DEFINER=`root`@`localhost`*/ /*!50003 TRIGGER `trg_loading_plan_after_update` AFTER UPDATE ON `fl_loading_plan` FOR EACH ROW BEGIN IF OLD.plan_status = 'LOADING' AND NEW.plan_status = 'COMPLETED' THEN UPDATE fl_loading_plan_item SET frozen_at = COALESCE(frozen_at, NEW.confirmed_at, CURRENT_TIMESTAMP(3)), frozen_by = COALESCE(frozen_by, NEW.updated_by), updated_by = NEW.updated_by, updated_by_name = NEW.updated_by_name, version = version + 1 WHERE loading_plan_id = NEW.id AND deleted = 0; UPDATE fl_loading_pick_task SET task_status = 'COMPLETED', completed_by = NEW.updated_by, completed_by_name = NEW.updated_by_name, completed_at = CURRENT_TIMESTAMP(3), version = version + 1 WHERE loading_plan_id = NEW.id AND task_status = 'LOADING'; END IF; END */;; +DELIMITER ; +/*!50003 SET sql_mode = @saved_sql_mode */ ; +/*!50003 SET character_set_client = @saved_cs_client */ ; +/*!50003 SET character_set_results = @saved_cs_results */ ; +/*!50003 SET collation_connection = @saved_col_connection */ ; +DROP TABLE IF EXISTS `fl_loading_plan_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_plan_history` ( + `id` bigint unsigned NOT NULL COMMENT '装柜操作历史ID(雪花ID)', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '智能装柜计划ID', + `loading_plan_item_id` bigint unsigned DEFAULT NULL COMMENT '装柜计划明细ID', + `operation_id` varchar(128) NOT NULL COMMENT '操作幂等ID', + `action_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '动作类型:CREATE_PLAN创建计划、ADD_ITEM新增明细、ADJUST_ITEM调整明细、REMOVE_ITEM移除明细、START_LOADING开始装柜、COMPLETE_LOADING完成装柜、PICK_ITEM拣货', + `from_status` varchar(24) DEFAULT NULL COMMENT '变更前状态', + `to_status` varchar(24) DEFAULT NULL COMMENT '变更后状态', + `before_snapshot` json DEFAULT NULL COMMENT '变更前快照', + `after_snapshot` json DEFAULT NULL COMMENT '变更后快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `operation_reason` varchar(500) DEFAULT NULL COMMENT '操作原因', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_history_operation` (`operation_id`), + KEY `idx_loading_history_plan` (`loading_plan_id`,`created_at`,`id`), + KEY `idx_loading_history_item` (`loading_plan_item_id`,`created_at`,`id`), + CONSTRAINT `fk_loading_history_item` FOREIGN KEY (`loading_plan_item_id`) REFERENCES `fl_loading_plan_item` (`id`), + CONSTRAINT `fk_loading_history_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='智能装柜追加式不可变操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_plan_item`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_plan_item` ( + `id` bigint unsigned NOT NULL COMMENT '装柜计划明细ID(雪花ID)', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '智能装柜计划ID', + `account_id` bigint unsigned NOT NULL COMMENT '客户账号ID;装柜业务唯一客户归属字段', + `customer_name` varchar(128) NOT NULL COMMENT '客户名称快照', + `inbound_id` bigint unsigned NOT NULL COMMENT 'Firstleg入库单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '订单货物或入库货物明细ID,不建立跨库外键', + `order_id` bigint unsigned NOT NULL COMMENT '订单服务订单ID快照,不建立跨库外键', + `order_no` varchar(32) NOT NULL COMMENT '订单号快照', + `inbound_code` varchar(40) NOT NULL COMMENT '入仓码快照', + `destination_warehouse_id` bigint unsigned DEFAULT NULL COMMENT '目的仓ID快照', + `destination_warehouse_code` varchar(64) DEFAULT NULL COMMENT '目的仓编码快照', + `destination_warehouse_name` varchar(128) DEFAULT NULL COMMENT '目的仓名称快照', + `sku_code` varchar(128) DEFAULT NULL COMMENT 'SKU编码快照', + `product_name_zh` varchar(255) DEFAULT NULL COMMENT '中文品名快照', + `product_name_en` varchar(255) DEFAULT NULL COMMENT '英文品名快照', + `product_image_file_id` bigint unsigned DEFAULT NULL COMMENT '产品图片File对象ID', + `product_image_url` varchar(1000) DEFAULT NULL COMMENT '产品图片访问地址快照', + `package_count` int unsigned NOT NULL COMMENT '本次装柜箱数', + `product_quantity` int unsigned DEFAULT NULL COMMENT '产品总数量', + `units_per_carton` int unsigned DEFAULT NULL COMMENT '每箱产品数', + `pallet_count` int unsigned DEFAULT NULL COMMENT '托盘数', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本次装柜毛重kg', + `net_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本次装柜净重kg', + `dimensional_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '本次装柜材积重kg', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '本次装柜体积m3', + `fba_tracking_no` varchar(128) DEFAULT NULL COMMENT 'FBA追踪号快照', + `shipment_no` varchar(128) DEFAULT NULL COMMENT '货件编号快照', + `shipping_mark` varchar(500) DEFAULT NULL COMMENT '唛头快照', + `load_sequence` int unsigned NOT NULL DEFAULT '0' COMMENT '装柜顺序', + `position_snapshot` json DEFAULT NULL COMMENT '智能装柜位置与摆放结果快照', + `internal_remark` varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + `customer_remark` varchar(1000) DEFAULT NULL COMMENT '客户备注快照', + `warehouse_remark` varchar(1000) DEFAULT NULL COMMENT '仓库备注快照', + `frozen_at` datetime(3) DEFAULT NULL COMMENT '装柜完成后的永久冻结时间', + `frozen_by` bigint unsigned DEFAULT NULL COMMENT '冻结操作人ID', + `removed_by` bigint unsigned DEFAULT NULL COMMENT '推送前逻辑删除操作人ID', + `removed_by_name` varchar(64) DEFAULT NULL COMMENT '推送前逻辑删除操作人名称快照', + `removed_at` datetime(3) DEFAULT NULL COMMENT '推送前逻辑删除时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '推送前逻辑删除标记;冻结后不可修改', + `active_inbound_detail_id` bigint unsigned GENERATED ALWAYS AS ((case when (`deleted` = 0) then `inbound_detail_id` else NULL end)) STORED COMMENT '未删除明细唯一锁;删除后返回NULL释放分配资格', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_by_name` varchar(64) DEFAULT NULL COMMENT '最后修改人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '装柜明细最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `unit_type` varchar(24) NOT NULL DEFAULT 'PACKAGE' COMMENT '分配单元:PACKAGE普通箱、SEALED_PALLET一件代发封托', + `pallet_id` bigint unsigned DEFAULT NULL COMMENT '一件代发封托ID;普通箱为空', + `pallet_no` varchar(32) DEFAULT NULL COMMENT '一件代发系统托盘码快照', + `indivisible` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否为不可拆整托单元', + `customer_service_remark` varchar(1000) DEFAULT NULL COMMENT '客服备注快照', + `active_assignment_key` varchar(96) GENERATED ALWAYS AS ((case when (`deleted` = 0) then concat((case when (`pallet_id` is null) then _utf8mb4'D:0' else concat(_utf8mb4'P:',`pallet_id`) end),_utf8mb4':',`inbound_detail_id`) else NULL end)) STORED COMMENT '普通明细或已封托托盘明细的有效唯一锁', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_item_plan_detail` (`loading_plan_id`,`active_inbound_detail_id`), + KEY `idx_loading_item_active_detail` (`account_id`,`active_inbound_detail_id`), + KEY `idx_loading_item_plan` (`loading_plan_id`,`deleted`,`load_sequence`,`id`), + KEY `idx_loading_item_account` (`account_id`,`created_at`,`id`), + KEY `idx_loading_item_inbound` (`account_id`,`inbound_id`,`deleted`), + KEY `idx_loading_item_order` (`account_id`,`order_id`,`deleted`), + CONSTRAINT `fk_loading_item_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `chk_loading_item_account` CHECK ((`account_id` > 0)), + CONSTRAINT `chk_loading_item_deleted` CHECK ((`deleted` in (0,1))), + CONSTRAINT `chk_loading_item_package` CHECK ((`package_count` > 0)), + CONSTRAINT `chk_loading_item_pallet` CHECK (((`pallet_count` is null) or (`pallet_count` >= 0))), + CONSTRAINT `chk_loading_item_product_quantity` CHECK (((`product_quantity` is null) or (`product_quantity` > 0))), + CONSTRAINT `chk_loading_item_units` CHECK (((`units_per_carton` is null) or (`units_per_carton` > 0))), + CONSTRAINT `chk_loading_item_volume` CHECK (((`volume_cbm` is null) or (`volume_cbm` >= 0))), + CONSTRAINT `chk_loading_item_weight` CHECK ((((`gross_weight_kg` is null) or (`gross_weight_kg` >= 0)) and ((`net_weight_kg` is null) or (`net_weight_kg` >= 0)) and ((`dimensional_weight_kg` is null) or (`dimensional_weight_kg` >= 0)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='智能装柜客户货物明细;客户归属仅在明细层'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_work_pallet`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_work_pallet` ( + `id` bigint unsigned NOT NULL COMMENT '一件代发现场作业托盘ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA装柜拣货任务ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '装柜计划ID快照', + `warehouse_id` bigint unsigned NOT NULL COMMENT '作业仓库ID', + `warehouse_pallet_id` bigint unsigned DEFAULT NULL COMMENT '仓库实物托盘ID;未绑定仓库资源时为空', + `pallet_code` varchar(64) NOT NULL COMMENT '现场托盘号', + `open_operation_id` varchar(128) NOT NULL COMMENT '开托幂等操作号', + `seal_operation_id` varchar(128) DEFAULT NULL COMMENT '封托幂等操作号', + `pallet_status` varchar(16) NOT NULL DEFAULT 'OPEN' COMMENT '托盘状态:OPEN打托中、SEALED已封托、CANCELLED已取消', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建操作人名称快照', + `sealed_by` bigint unsigned DEFAULT NULL COMMENT '封托操作人ID', + `sealed_by_name` varchar(64) DEFAULT NULL COMMENT '封托操作人名称快照', + `sealed_at` datetime(3) DEFAULT NULL COMMENT '封托时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_work_pallet_code` (`pick_task_id`,`pallet_code`), + UNIQUE KEY `uk_loading_work_pallet_open_operation` (`open_operation_id`), + UNIQUE KEY `uk_loading_work_pallet_seal_operation` (`seal_operation_id`), + KEY `idx_loading_work_pallet_plan` (`loading_plan_id`,`pallet_status`,`id`), + CONSTRAINT `fk_loading_work_pallet_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `fk_loading_work_pallet_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_work_pallet_status` CHECK ((`pallet_status` in (_utf8mb4'OPEN',_utf8mb4'SEALED',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发PDA现场实际托盘;允许同托盘承载多个客户订单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_loading_work_pallet_carton`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_loading_work_pallet_carton` ( + `id` bigint unsigned NOT NULL COMMENT '一件代发现场托盘箱唛绑定ID', + `pick_task_id` bigint unsigned NOT NULL COMMENT 'PDA装柜拣货任务ID', + `work_pallet_id` bigint unsigned NOT NULL COMMENT '现场作业托盘ID', + `loading_plan_item_id` bigint unsigned NOT NULL COMMENT '装柜计划货物明细ID', + `pick_item_id` bigint unsigned NOT NULL COMMENT 'PDA拣货明细ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '箱唛所属客户ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '来源入仓单ID', + `inbound_detail_id` bigint unsigned NOT NULL COMMENT '来源入仓货物明细ID', + `carton_label_id` bigint unsigned NOT NULL COMMENT '稳定箱唛记录ID', + `box_code` varchar(80) NOT NULL COMMENT '稳定箱唛箱码快照', + `sku_code` varchar(128) NOT NULL COMMENT 'SKU编码快照', + `units_per_carton` int unsigned DEFAULT NULL COMMENT '每箱产品数量快照', + `bound_by` bigint unsigned NOT NULL COMMENT '最后绑定操作人ID', + `bound_by_name` varchar(64) DEFAULT NULL COMMENT '最后绑定操作人名称快照', + `operation_id` varchar(128) NOT NULL COMMENT '最近一次绑定或移托幂等操作号', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次上托时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后移托时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_loading_work_carton_task_label` (`pick_task_id`,`carton_label_id`), + UNIQUE KEY `uk_loading_work_carton_operation` (`operation_id`), + KEY `idx_loading_work_carton_pallet` (`work_pallet_id`,`sku_code`,`id`), + KEY `idx_loading_work_carton_plan_item` (`loading_plan_item_id`,`id`), + KEY `fk_loading_work_carton_pick_item` (`pick_item_id`), + KEY `fk_loading_work_carton_label` (`carton_label_id`), + CONSTRAINT `fk_loading_work_carton_label` FOREIGN KEY (`carton_label_id`) REFERENCES `fl_inbound_carton_label` (`id`), + CONSTRAINT `fk_loading_work_carton_pallet` FOREIGN KEY (`work_pallet_id`) REFERENCES `fl_loading_work_pallet` (`id`), + CONSTRAINT `fk_loading_work_carton_pick_item` FOREIGN KEY (`pick_item_id`) REFERENCES `fl_loading_pick_item` (`id`), + CONSTRAINT `fk_loading_work_carton_plan_item` FOREIGN KEY (`loading_plan_item_id`) REFERENCES `fl_loading_plan_item` (`id`), + CONSTRAINT `fk_loading_work_carton_task` FOREIGN KEY (`pick_task_id`) REFERENCES `fl_loading_pick_task` (`id`), + CONSTRAINT `chk_loading_work_carton_units` CHECK (((`units_per_carton` is null) or (`units_per_carton` > 0))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='一件代发PDA逐箱上托事实;客户订单不合并但实际托盘可混托'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_measure_review_record`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_measure_review_record` ( + `id` bigint unsigned NOT NULL COMMENT '测量审核记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `measurement_id` bigint unsigned DEFAULT NULL COMMENT '被审核的测量批次ID', + `review_result` varchar(24) NOT NULL COMMENT '审核结果', + `review_reason` varchar(1000) DEFAULT NULL COMMENT '审核或驳回原因', + `exception_release` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否异常放行', + `release_reason` varchar(1000) DEFAULT NULL COMMENT '异常放行原因', + `allow_rejected_config` tinyint(1) NOT NULL DEFAULT '0' COMMENT '操作时Nacos异常放行开关值', + `reviewer_id` bigint unsigned NOT NULL COMMENT '审核人ID', + `reviewer_name` varchar(64) DEFAULT NULL COMMENT '审核人名称快照', + `reviewed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '审核时间', + PRIMARY KEY (`id`), + KEY `idx_review_inbound_time` (`tenant_id`,`inbound_id`,`reviewed_at`), + KEY `idx_review_exception_release` (`tenant_id`,`inbound_id`,`exception_release`,`reviewed_at`), + KEY `fk_review_inbound` (`inbound_id`), + KEY `fk_review_measurement` (`measurement_id`), + CONSTRAINT `fk_review_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_review_measurement` FOREIGN KEY (`measurement_id`) REFERENCES `fl_inbound_measurement` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='测量审核、驳回和异常放行不可变记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_mojia_measurement_receipt`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_mojia_measurement_receipt` ( + `id` bigint unsigned NOT NULL COMMENT '墨家联调收件记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `subject_id` bigint unsigned NOT NULL COMMENT '设备应用操作主体ID', + `box_code` varchar(128) NOT NULL COMMENT '设备扫描的原始箱码或不带#的SKU', + `inbound_code` varchar(64) DEFAULT NULL COMMENT '箱码命中后的权威入仓码;SKU记录为空', + `measurement_kind` varchar(16) NOT NULL DEFAULT 'BOX_LABEL' COMMENT '测量分流:BOX_LABEL箱唛;SKU一件代发SKU', + `scanned_shipment_id` varchar(64) DEFAULT NULL COMMENT '设备从Amazon FBA箱标实扫的Shipment ID', + `length_cm` decimal(12,3) NOT NULL COMMENT '设备实测长度cm', + `width_cm` decimal(12,3) NOT NULL COMMENT '设备实测宽度cm', + `height_cm` decimal(12,3) NOT NULL COMMENT '设备实测高度cm', + `weight_kg` decimal(18,3) NOT NULL COMMENT '设备实测重量kg', + `machine_no` varchar(64) NOT NULL COMMENT '设备编号', + `measured_at` datetime(3) NOT NULL COMMENT '设备测量时间或服务接收时间', + `image_file_id` bigint unsigned NOT NULL COMMENT 'File服务中的墨家联调图片ID', + `status` varchar(24) NOT NULL DEFAULT 'UNMATCHED' COMMENT '原始收件状态;SKU记录使用SKU_PENDING_SYNC', + `sync_status` varchar(24) DEFAULT NULL COMMENT 'SKU同步状态:PENDING/SYNCING/SUCCESS/FAILED', + `sync_failure_code` varchar(64) DEFAULT NULL COMMENT 'SKU同步失败业务码', + `sync_failure_reason` varchar(500) DEFAULT NULL COMMENT '脱敏后的SKU同步失败原因', + `lingxing_sku_id` varchar(128) DEFAULT NULL COMMENT '领星SKU内部标识', + `revision` int NOT NULL DEFAULT '1' COMMENT '当前测量版本号', + `match_failure_code` varchar(16) DEFAULT NULL COMMENT '正式箱唛业务匹配失败码', + `match_failure_reason` varchar(500) DEFAULT NULL COMMENT '脱敏后的箱唛匹配失败原因', + `request_id` varchar(128) DEFAULT NULL COMMENT '请求链路ID', + `trace_id` varchar(128) DEFAULT NULL COMMENT '调用链ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + KEY `idx_mojia_receipt_status_time` (`tenant_id`,`status`,`created_at`), + KEY `idx_mojia_receipt_box_time` (`tenant_id`,`box_code`,`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='墨家模拟或未匹配入库单的原始测量收件箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking` ( + `id` bigint unsigned NOT NULL COMMENT '海运订舱ID', + `booking_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台海运订舱编号', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、SUBMITTED已提交、CONFIRMED已确认、RELEASED已放舱、FAILED失败、CHANGE_PENDING船期待变更、CANCELLED已取消', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '可选客户ID快照', + `customer_name` varchar(128) DEFAULT NULL COMMENT '可选客户名称快照', + `channel_code` varchar(64) DEFAULT NULL, + `channel_name` varchar(128) DEFAULT NULL, + `warehouse_id` bigint unsigned DEFAULT NULL, + `warehouse_name` varchar(128) DEFAULT NULL, + `bill_of_lading_no` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '提单号', + `shipping_company_id` bigint unsigned DEFAULT NULL COMMENT '船公司逻辑ID快照', + `shipping_company_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '船公司名称快照', + `carrier_code` varchar(16) DEFAULT NULL COMMENT '船司标准编码:MATSON/SML/ZIM/COSCO/EMC/HEDE/OTHER;发布前必填', + `booking_agent_id` bigint unsigned DEFAULT NULL COMMENT '订舱代理逻辑ID快照', + `booking_agent_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '订舱代理名称快照', + `vessel_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前确认船名快照', + `voyage_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前确认航次号快照', + `departure_port_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '起运港编码快照', + `departure_port_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '起运港名称快照', + `destination_port_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '目的港编码快照', + `destination_port_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '目的港名称快照', + `place_of_delivery` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最终交货地快照', + `service_term` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '运输服务条款快照', + `freight_term` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '海运费支付条款快照', + `estimated_sailing_at` datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间', + `estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间', + `pending_vessel_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '待确认变更船名', + `pending_voyage_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '待确认变更航次号', + `pending_estimated_sailing_at` datetime(3) DEFAULT NULL COMMENT '待确认变更预计开船时间', + `pending_estimated_arrival_at` datetime(3) DEFAULT NULL COMMENT '待确认变更预计到港时间', + `change_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '船期变更原因', + `cancellation_reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '取消订舱原因', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '平台内部备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '订舱记录最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ocean_booking_no` (`booking_no`), + KEY `idx_ocean_booking_customer` (`customer_id`,`status`,`updated_at`,`id`), + KEY `idx_ocean_booking_voyage` (`vessel_name`,`voyage_no`,`updated_at`), + CONSTRAINT `chk_ocean_booking_carrier_code` CHECK (((`carrier_code` is null) or (`carrier_code` in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))), + CONSTRAINT `chk_ocean_booking_customer` CHECK ((`customer_id` > 0)), + CONSTRAINT `chk_ocean_booking_pending_schedule` CHECK (((`pending_estimated_sailing_at` is null) or (`pending_estimated_arrival_at` is null) or (`pending_estimated_arrival_at` >= `pending_estimated_sailing_at`))), + CONSTRAINT `chk_ocean_booking_schedule` CHECK (((`estimated_sailing_at` is null) or (`estimated_arrival_at` is null) or (`estimated_arrival_at` >= `estimated_sailing_at`))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_container_allocation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_container_allocation` ( + `id` bigint unsigned NOT NULL COMMENT '订舱柜次分配关系ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `requirement_id` bigint unsigned NOT NULL COMMENT '对应订舱箱型需求ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '明确引用现有柜次ID', + `allocated_by` bigint unsigned NOT NULL COMMENT '分配操作人ID', + `allocated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '分配操作人姓名快照', + `allocated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '柜次分配时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ocean_allocation_container` (`container_booking_id`), + KEY `idx_ocean_allocation_booking` (`ocean_booking_id`,`requirement_id`,`allocated_at`), + KEY `fk_ocean_allocation_requirement` (`requirement_id`), + CONSTRAINT `fk_ocean_allocation_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `fk_ocean_allocation_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `fk_ocean_allocation_requirement` FOREIGN KEY (`requirement_id`) REFERENCES `fl_ocean_booking_container_requirement` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱需求与现有柜次的唯一分配关系'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_container_requirement`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_container_requirement` ( + `id` bigint unsigned NOT NULL COMMENT '订舱箱型需求ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `container_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '所需集装箱尺寸类型编码', + `container_kind` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '所需集装箱箱种编码', + `capacity_cbm` decimal(18,4) NOT NULL DEFAULT '0.0000', + `max_weight_kg` decimal(18,3) NOT NULL DEFAULT '0.000', + `quantity` int unsigned NOT NULL COMMENT '该箱型箱种所需柜数', + `sort_no` int unsigned NOT NULL DEFAULT '0' COMMENT '订舱需求展示顺序', + PRIMARY KEY (`id`), + KEY `idx_ocean_requirement_booking` (`ocean_booking_id`,`sort_no`,`id`), + CONSTRAINT `fk_ocean_requirement_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `chk_ocean_requirement_quantity` CHECK ((`quantity` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱箱型箱种及数量需求'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_document`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_document` ( + `id` bigint unsigned NOT NULL COMMENT '订舱文件关联ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件逻辑ID', + `document_type` varchar(32) NOT NULL COMMENT '文件类型:BOOKING_CONFIRMATION订舱确认、BILL_OF_LADING提单', + `file_name` varchar(255) NOT NULL COMMENT 'File返回的原始文件名快照', + `file_hash` char(64) DEFAULT NULL COMMENT 'File返回的SHA-256快照', + `size_bytes` bigint unsigned DEFAULT NULL COMMENT 'File返回的文件大小快照', + `content_type` varchar(128) DEFAULT NULL COMMENT 'File返回的内容类型快照', + `valid` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否为有效文件关联', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传或关联操作人ID', + `uploaded_by_name` varchar(64) DEFAULT NULL COMMENT '上传或关联操作人姓名快照', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传或关联时间', + `invalidated_by` bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + `invalidated_by_name` varchar(64) DEFAULT NULL COMMENT '作废操作人姓名快照', + `invalidated_at` datetime(3) DEFAULT NULL COMMENT '作废时间', + PRIMARY KEY (`id`), + KEY `idx_ocean_document_booking` (`ocean_booking_id`,`valid`,`uploaded_at`,`id`), + KEY `idx_ocean_document_file` (`file_id`), + CONSTRAINT `fk_ocean_document_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`) ON DELETE CASCADE, + CONSTRAINT `chk_ocean_document_type` CHECK ((`document_type` in (_utf8mb4'BOOKING_CONFIRMATION',_utf8mb4'BILL_OF_LADING'))), + CONSTRAINT `chk_ocean_document_valid` CHECK ((`valid` in (0,1))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱聚合拥有的订舱确认与提单文件关联'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_operation` ( + `id` bigint unsigned NOT NULL COMMENT '订舱写操作幂等记录ID', + `ocean_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联海运订舱ID;创建请求完成前允许为空', + `operator_id` bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + `operation` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端写操作幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + `result_json` json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ocean_operation` (`operator_id`,`operation`,`idempotency_key`), + KEY `idx_ocean_operation_booking` (`ocean_booking_id`,`created_at`), + CONSTRAINT `fk_ocean_operation_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `chk_ocean_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海运订舱写操作幂等与请求哈希'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_party`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_party` ( + `id` bigint unsigned NOT NULL COMMENT '订舱提单关系方ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `party_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '关系方类型:SHIPPER发货人、CONSIGNEE收货人、NOTIFY_PARTY通知人', + `party_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '关系方名称快照', + `address` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关系方地址快照', + `contact_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关系方联系人快照', + `contact_phone` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关系方联系电话快照', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_ocean_booking_party` (`ocean_booking_id`,`party_type`), + CONSTRAINT `fk_ocean_booking_party` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `chk_ocean_booking_party_type` CHECK ((`party_type` in (_utf8mb4'SHIPPER',_utf8mb4'CONSIGNEE',_utf8mb4'NOTIFY_PARTY'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订舱提单关系方快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_ocean_booking_status_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_ocean_booking_status_history` ( + `id` bigint unsigned NOT NULL COMMENT '订舱状态历史ID', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '所属海运订舱ID', + `from_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更前订舱状态;创建时为空', + `to_status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '变更后订舱状态', + `action` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '触发状态变更的业务动作', + `reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '状态变更原因', + `before_snapshot` json DEFAULT NULL COMMENT '状态变更前订舱数据快照;创建时为空', + `after_snapshot` json NOT NULL COMMENT '状态变更后订舱数据快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间', + PRIMARY KEY (`id`), + KEY `idx_ocean_history_booking` (`ocean_booking_id`,`created_at`,`id`), + CONSTRAINT `fk_ocean_history_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='海运订舱不可变状态历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_order_declaration_event`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_order_declaration_event` ( + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + `event_id` varchar(128) NOT NULL COMMENT '永久保留的Order Outbox事件ID', + `order_id` bigint unsigned NOT NULL COMMENT 'Order订单ID', + `order_version` int NOT NULL COMMENT '事件携带的Order聚合版本', + `request_hash` char(64) NOT NULL COMMENT '完整事件SHA-256,用于异参重放拒绝', + `consumed_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + PRIMARY KEY (`tenant_id`,`event_id`), + KEY `idx_order_declaration_event_order` (`tenant_id`,`order_id`,`order_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Order客户申报事件永久幂等账本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_order_declaration_snapshot`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_order_declaration_snapshot` ( + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和隔离键', + `order_id` bigint unsigned NOT NULL COMMENT 'Order订单ID', + `event_id` varchar(128) NOT NULL COMMENT 'Order Outbox事件ID', + `order_version` int NOT NULL COMMENT 'Order聚合版本,严格单调消费', + `request_hash` char(64) NOT NULL COMMENT '完整事件SHA-256', + `customer_remark` varchar(1000) DEFAULT NULL COMMENT '客户备注快照', + `expected_arrival_at` datetime(3) DEFAULT NULL COMMENT '预计到仓时间快照', + `channel_code` varchar(64) NOT NULL COMMENT '渠道编码快照', + `channel_name` varchar(128) NOT NULL COMMENT '渠道名称快照', + `cargoes_json` json NOT NULL COMMENT '客户申报货物行完整快照,不覆盖实测事实', + `changes_json` json DEFAULT NULL COMMENT '本版本申报字段变化摘要', + `occurred_at` datetime(3) NOT NULL COMMENT 'Order事件发生时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3), + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3), + PRIMARY KEY (`tenant_id`,`order_id`), + UNIQUE KEY `uk_order_declaration_event` (`tenant_id`,`event_id`), + KEY `idx_order_declaration_version` (`tenant_id`,`order_id`,`order_version`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Order客户申报版本快照;与箱码、实测、托盘、PDA和计费事实隔离'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_outbound_dispatch`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_outbound_dispatch` ( + `id` bigint unsigned NOT NULL COMMENT '国内出仓调度记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `order_id` bigint unsigned NOT NULL COMMENT 'Order服务订单逻辑ID,不复制订单主数据', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '关联柜次逻辑ID', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'READY_FOR_DISPATCH' COMMENT '出仓状态:READY_FOR_DISPATCH待确认出仓、WAITING_FOR_LOADING等待装柜', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `confirmed_by` bigint unsigned DEFAULT NULL COMMENT '确认国内出仓的操作人ID', + `confirmed_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '确认国内出仓的操作人姓名快照', + `confirmed_at` datetime(3) DEFAULT NULL COMMENT '国内出仓确认时间', + `reversed_by` bigint unsigned DEFAULT NULL COMMENT '撤销国内出仓的操作人ID', + `reversed_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '撤销国内出仓的操作人姓名快照', + `reversed_at` datetime(3) DEFAULT NULL COMMENT '国内出仓撤销时间', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '国内出仓调度备注', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '出仓调度记录创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '出仓调度记录最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outbound_dispatch_owner` (`tenant_id`,`inbound_id`,`container_booking_id`), + KEY `idx_outbound_dispatch_order` (`tenant_id`,`order_id`,`updated_at`), + KEY `idx_outbound_dispatch_container` (`container_booking_id`,`status`,`updated_at`), + KEY `fk_outbound_dispatch_inbound` (`inbound_id`), + CONSTRAINT `fk_outbound_dispatch_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `fk_outbound_dispatch_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `chk_outbound_dispatch_status` CHECK ((`status` in (_utf8mb4'READY_FOR_DISPATCH',_utf8mb4'WAITING_FOR_LOADING'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期国内出仓派车状态,不重复创建出仓单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_outbound_dispatch_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_outbound_dispatch_history` ( + `id` bigint unsigned NOT NULL COMMENT '出仓状态历史记录ID', + `dispatch_id` bigint unsigned NOT NULL COMMENT '关联国内出仓调度记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID快照', + `container_booking_id` bigint unsigned NOT NULL COMMENT '关联柜次逻辑ID快照', + `action` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '出仓状态变更动作', + `from_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更前出仓状态;首次创建时为空', + `to_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '变更后出仓状态', + `remark` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '状态变更原因或备注', + `operator_id` bigint unsigned NOT NULL COMMENT '执行状态变更的平台用户ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '执行状态变更的平台用户姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '状态历史创建时间', + PRIMARY KEY (`id`), + KEY `idx_outbound_dispatch_history` (`tenant_id`,`inbound_id`,`container_booking_id`,`created_at`,`id`), + KEY `fk_outbound_dispatch_history` (`dispatch_id`), + CONSTRAINT `fk_outbound_dispatch_history` FOREIGN KEY (`dispatch_id`) REFERENCES `fl_outbound_dispatch` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内出仓追加式审计历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_outbound_dispatch_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_outbound_dispatch_operation` ( + `id` bigint unsigned NOT NULL COMMENT '出仓幂等操作记录ID', + `dispatch_id` bigint unsigned DEFAULT NULL COMMENT '首次声明幂等键时允许为空', + `operator_id` bigint unsigned NOT NULL COMMENT '执行写操作的平台用户ID', + `operation` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '国内出仓写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等声明创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '幂等操作完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_outbound_dispatch_operation` (`operator_id`,`operation`,`idempotency_key`), + KEY `idx_outbound_dispatch_operation_resource` (`dispatch_id`,`created_at`), + CONSTRAINT `fk_outbound_dispatch_operation` FOREIGN KEY (`dispatch_id`) REFERENCES `fl_outbound_dispatch` (`id`), + CONSTRAINT `chk_outbound_dispatch_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='国内出仓写操作幂等声明'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_port_schedule`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_port_schedule` ( + `id` bigint unsigned NOT NULL COMMENT '港务船期记录ID', + `schedule_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '港务船期记录编号', + `customer_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `ocean_booking_id` bigint unsigned NOT NULL COMMENT '关联海运订舱ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联柜次ID;订舱整票船期时为空', + `source_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船期来源类型:MANUAL人工录入、CARRIER船公司、TERMINAL码头、API外部接口', + `source_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船期来源名称', + `source_ref` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '来源侧记录唯一引用或查询凭据摘要', + `vessel_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '船名快照', + `voyage_no` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '航次号快照', + `departure_port_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '起运港编码快照', + `departure_port_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '起运港名称快照', + `destination_port_code` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '目的港编码快照', + `destination_port_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '目的港名称快照', + `etd_at` datetime(3) DEFAULT NULL COMMENT '预计离港时间', + `eta_at` datetime(3) DEFAULT NULL COMMENT '预计到港时间', + `booking_cutoff_at` datetime(3) DEFAULT NULL COMMENT '截单时间', + `customs_cutoff_at` datetime(3) DEFAULT NULL COMMENT '截关时间', + `port_cutoff_at` datetime(3) DEFAULT NULL COMMENT '截港时间', + `observed_at` datetime(3) NOT NULL COMMENT '来源数据观测时间', + `expires_at` datetime(3) NOT NULL COMMENT '超过该时间即由API计算为过期', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '平台内部备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '船期记录创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '船期记录最后更新时间', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_port_schedule_no` (`schedule_no`), + UNIQUE KEY `uk_port_schedule_source` (`source_type`,`source_name`,`source_ref`), + KEY `idx_port_schedule_customer` (`customer_id`,`observed_at`,`id`), + KEY `idx_port_schedule_booking` (`ocean_booking_id`,`observed_at`,`id`), + KEY `idx_port_schedule_container` (`container_booking_id`,`observed_at`,`id`), + KEY `idx_port_schedule_expiry` (`expires_at`,`id`), + CONSTRAINT `fk_port_schedule_booking` FOREIGN KEY (`ocean_booking_id`) REFERENCES `fl_ocean_booking` (`id`), + CONSTRAINT `fk_port_schedule_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_port_schedule_booking_cutoff` CHECK (((`booking_cutoff_at` is null) or (`etd_at` is null) or (`booking_cutoff_at` <= `etd_at`))), + CONSTRAINT `chk_port_schedule_customer` CHECK ((`customer_id` > 0)), + CONSTRAINT `chk_port_schedule_customs_cutoff` CHECK (((`customs_cutoff_at` is null) or (`etd_at` is null) or (`customs_cutoff_at` <= `etd_at`))), + CONSTRAINT `chk_port_schedule_eta` CHECK (((`etd_at` is null) or (`eta_at` is null) or (`eta_at` >= `etd_at`))), + CONSTRAINT `chk_port_schedule_expiry` CHECK ((`expires_at` > `observed_at`)), + CONSTRAINT `chk_port_schedule_port_cutoff` CHECK (((`port_cutoff_at` is null) or (`etd_at` is null) or (`port_cutoff_at` <= `etd_at`))), + CONSTRAINT `chk_port_schedule_source_type` CHECK ((`source_type` in (_utf8mb4'MANUAL',_utf8mb4'CARRIER',_utf8mb4'TERMINAL',_utf8mb4'API'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立港务船期来源聚合,不替代订舱快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_port_schedule_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_port_schedule_history` ( + `id` bigint unsigned NOT NULL COMMENT '港务船期变更历史ID', + `port_schedule_id` bigint unsigned NOT NULL COMMENT '所属港务船期记录ID', + `action` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '变更动作:CREATE创建、UPDATE更新', + `from_version` int unsigned DEFAULT NULL COMMENT '变更前版本;创建时为空', + `to_version` int unsigned NOT NULL COMMENT '变更后版本', + `reason` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更原因或说明', + `before_snapshot` json DEFAULT NULL COMMENT '变更前船期数据快照;创建时为空', + `after_snapshot` json NOT NULL COMMENT '变更后船期数据快照', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_port_schedule_history` (`port_schedule_id`,`created_at`,`id`), + CONSTRAINT `fk_port_schedule_history` FOREIGN KEY (`port_schedule_id`) REFERENCES `fl_port_schedule` (`id`), + CONSTRAINT `chk_port_schedule_history_action` CHECK ((`action` in (_utf8mb4'CREATE',_utf8mb4'UPDATE'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='港务船期不可变变更历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_port_schedule_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_port_schedule_operation` ( + `id` bigint unsigned NOT NULL COMMENT '港务船期写操作幂等记录ID', + `port_schedule_id` bigint unsigned DEFAULT NULL COMMENT '关联港务船期ID;创建请求完成前允许为空', + `operator_id` bigint unsigned NOT NULL COMMENT '发起写操作的平台操作人ID', + `operation` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '幂等写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端写操作幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '处理状态:PROCESSING处理中、SUCCESS成功', + `result_json` json DEFAULT NULL COMMENT '成功结果JSON快照;供幂等重放返回', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + `completed_at` datetime(3) DEFAULT NULL COMMENT '写操作成功完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_port_schedule_operation` (`operator_id`,`idempotency_key`), + KEY `idx_port_schedule_operation_resource` (`port_schedule_id`,`created_at`), + CONSTRAINT `fk_port_schedule_operation` FOREIGN KEY (`port_schedule_id`) REFERENCES `fl_port_schedule` (`id`), + CONSTRAINT `chk_port_schedule_operation_status` CHECK ((`status` in (_utf8mb4'PROCESSING',_utf8mb4'SUCCESS'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='港务船期写操作幂等与请求哈希'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_preload_loading_plan_link`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_preload_loading_plan_link` ( + `id` bigint unsigned NOT NULL COMMENT '关联ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '入库单客户隔离ID', + `inbound_id` bigint unsigned NOT NULL COMMENT '入库单ID', + `preload_plan_id` bigint unsigned NOT NULL COMMENT '入库单级预装计划ID', + `loading_plan_id` bigint unsigned NOT NULL COMMENT '柜次级装柜计划ID', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '建立关联幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '建立关联请求SHA-256', + `created_by` bigint unsigned NOT NULL COMMENT '建立预装与装柜计划关联的操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '建立关联的操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '关联记录创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_preload_loading_pair` (`tenant_id`,`inbound_id`,`loading_plan_id`), + UNIQUE KEY `uk_preload_loading_idempotency` (`created_by`,`idempotency_key`), + KEY `idx_preload_loading_preload` (`preload_plan_id`,`loading_plan_id`), + KEY `idx_preload_loading_plan` (`loading_plan_id`,`tenant_id`,`inbound_id`), + KEY `fk_preload_loading_inbound` (`inbound_id`), + CONSTRAINT `fk_preload_loading_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`), + CONSTRAINT `fk_preload_loading_plan` FOREIGN KEY (`loading_plan_id`) REFERENCES `fl_loading_plan` (`id`), + CONSTRAINT `fk_preload_loading_preload` FOREIGN KEY (`preload_plan_id`) REFERENCES `fl_preload_plan` (`id`), + CONSTRAINT `chk_preload_loading_tenant` CHECK ((`tenant_id` > 0)) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='入库单预装与柜次装柜计划多对多关联'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_preload_plan`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_preload_plan` ( + `id` bigint unsigned NOT NULL COMMENT '预装清单ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '关联入库单逻辑ID', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '预装状态:PENDING待确认、CONFIRMING确认中、CONFIRMED已确认', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号,每次更新递增', + `items_json` json NOT NULL COMMENT '可编辑分柜明细JSON快照;人工确认后作为预装依据', + `remark` varchar(1000) DEFAULT NULL COMMENT '预装备注', + `confirmed_by` bigint unsigned DEFAULT NULL, + `confirmed_by_name` varchar(64) DEFAULT NULL, + `confirmed_at` datetime(3) DEFAULT NULL, + `created_by` bigint unsigned NOT NULL COMMENT '创建预装清单的操作人ID', + `created_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '创建操作人姓名快照', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改操作人ID', + `updated_by_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最后修改操作人姓名快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '预装清单创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '预装清单最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_preload_tenant_inbound` (`tenant_id`,`inbound_id`), + KEY `idx_preload_status` (`tenant_id`,`status`,`updated_at`), + KEY `fk_preload_inbound` (`inbound_id`), + CONSTRAINT `fk_preload_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='订单分柜预装清单'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_book_channel`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_book_channel` ( + `id` bigint unsigned NOT NULL COMMENT '版本渠道快照ID', + `price_book_version_id` bigint unsigned NOT NULL COMMENT '整表价格版本ID', + `channel_code` varchar(64) NOT NULL COMMENT '固定渠道编码', + `channel_name` varchar(128) NOT NULL COMMENT '渠道名称快照', + `price_model` varchar(32) NOT NULL COMMENT '价格模型快照', + `source_sheet_name` varchar(128) NOT NULL COMMENT '原始Excel Sheet', + `estimated_departure_date` date DEFAULT NULL COMMENT '标准预计开船日期', + `estimated_departure_text` varchar(128) DEFAULT NULL COMMENT '原始预计开船日期文本', + `latest_warehouse_entry` varchar(128) DEFAULT NULL COMMENT '最晚进仓时间', + `vessel_name` varchar(128) DEFAULT NULL COMMENT '船名', + `voyage_no` varchar(64) DEFAULT NULL COMMENT '航次', + `reference_transit` varchar(128) DEFAULT NULL COMMENT '参考时效', + `surcharge_description` varchar(2000) DEFAULT NULL COMMENT '附加费说明,仅展示', + `remark` varchar(2000) DEFAULT NULL COMMENT '渠道备注', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '渠道快照创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '渠道快照最后更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_book_channel` (`price_book_version_id`,`channel_code`), + KEY `idx_price_book_channel_code` (`channel_code`,`price_book_version_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格版本渠道及船期快照'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_book_version`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_book_version` ( + `id` bigint unsigned NOT NULL COMMENT '整表价格版本ID', + `tenant_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '平台公共价格作用域固定为0', + `version_no` int unsigned NOT NULL COMMENT '整表版本号', + `original_file_name` varchar(255) NOT NULL COMMENT '原始Excel文件名', + `source_file_id` bigint unsigned DEFAULT NULL COMMENT 'File服务原始文件ID', + `source_sha256` char(64) DEFAULT NULL COMMENT '原始文件SHA-256', + `status` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING_CONFIRMATION' COMMENT '价格版本状态:PENDING_CONFIRMATION待确认、ACTIVE已生效、SUPERSEDED已被替代、FAILED解析失败', + `blocking_error_count` int unsigned NOT NULL DEFAULT '0' COMMENT '阻断错误数', + `warning_count` int unsigned NOT NULL DEFAULT '0' COMMENT '警告数', + `parse_issues_json` json DEFAULT NULL COMMENT '上传解析问题及待确认重新校验结果', + `channel_count` int unsigned NOT NULL DEFAULT '0' COMMENT '解析渠道数', + `rate_count` int unsigned NOT NULL DEFAULT '0' COMMENT '解析价格数', + `created_by` bigint unsigned NOT NULL COMMENT '上传价格表的操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '上传记录创建时间', + `published_by` bigint unsigned DEFAULT NULL COMMENT '确认并发布价格版本的操作人ID', + `published_at` datetime(3) DEFAULT NULL COMMENT '确认并生效时间', + `superseded_at` datetime(3) DEFAULT NULL COMMENT '被下一版本替换时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后修改人ID', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + `deleted` tinyint(1) NOT NULL DEFAULT '0' COMMENT '逻辑删除标记:0未删除、1已删除', + `version` int NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `voided_by` bigint unsigned DEFAULT NULL COMMENT '作废操作人ID', + `voided_at` datetime(3) DEFAULT NULL COMMENT '作废时间', + `void_reason` varchar(500) DEFAULT NULL COMMENT '作废原因', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_book_version` (`tenant_id`,`version_no`), + KEY `idx_price_book_status` (`tenant_id`,`status`,`published_at`), + KEY `idx_price_book_source_hash` (`tenant_id`,`source_sha256`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共船期价格整表版本'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_channel_catalog`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_channel_catalog` ( + `id` bigint unsigned NOT NULL COMMENT '渠道目录ID', + `channel_code` varchar(64) NOT NULL COMMENT '稳定渠道编码', + `display_name` varchar(128) NOT NULL COMMENT '平台渠道名称', + `price_model` varchar(32) NOT NULL COMMENT '默认价格模型', + `source_sheet_name` varchar(128) NOT NULL COMMENT '来源Sheet名称;海派为同一Sheet', + `aliases_json` json NOT NULL COMMENT '上传名称别名数组', + `inbound_code_prefix` varchar(6) DEFAULT NULL, + `overseas_inbound_code_prefix` varchar(6) DEFAULT NULL, + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '目录状态:ACTIVE启用、DISABLED停用', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_channel_code` (`channel_code`), + KEY `idx_price_channel_status` (`status`,`price_model`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='平台公共船期价格固定渠道目录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_origin_alias`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_origin_alias` ( + `id` bigint unsigned NOT NULL COMMENT '起运仓别名ID', + `warehouse_code` varchar(64) NOT NULL COMMENT '平台仓库稳定编码,不存环境专属ID', + `alias_name` varchar(128) NOT NULL COMMENT 'Excel起运仓名称别名', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_origin_alias` (`warehouse_code`,`alias_name`), + KEY `idx_price_origin_alias_name` (`alias_name`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格表起运仓名称别名映射'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_price_warehouse_mapping`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_price_warehouse_mapping` ( + `id` bigint unsigned NOT NULL COMMENT '价格仓映射ID', + `source_type` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '来源维度:ORIGIN_CODE或OVERSEAS_NAME', + `source_value` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '解析后的来源仓编码或海外仓名称', + `warehouse_id` bigint unsigned NOT NULL COMMENT '固定平台公共仓库ID', + `warehouse_code` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台仓库稳定编码快照', + `warehouse_type` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT 'DOMESTIC或OVERSEAS', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'ACTIVE' COMMENT '状态:ACTIVE启用、DISABLED停用', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '最后更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_price_warehouse_mapping_source` (`source_type`,`source_value`), + KEY `idx_price_warehouse_mapping_target` (`warehouse_id`,`status`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='价格表来源仓与固定平台公共仓映射'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_split_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_split_operation` ( + `id` bigint unsigned NOT NULL COMMENT '幂等记录ID', + `tenant_id` bigint unsigned NOT NULL COMMENT '客户ID和数据隔离键', + `inbound_id` bigint unsigned NOT NULL COMMENT '操作目标入库单逻辑ID', + `operator_id` bigint unsigned NOT NULL COMMENT '执行分柜或预装写操作的平台用户ID', + `operation` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '分柜或预装写操作类型', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端幂等键,同一操作重试复用', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PROCESSING' COMMENT '幂等处理状态:PROCESSING处理中、SUCCESS成功', + `result_json` json DEFAULT NULL COMMENT '操作结果JSON快照,用于幂等重放', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等操作声明创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_split_operation` (`tenant_id`,`inbound_id`,`operator_id`,`operation`,`idempotency_key`), + KEY `idx_split_operation_time` (`tenant_id`,`inbound_id`,`created_at`), + KEY `fk_split_operation_inbound` (`inbound_id`), + CONSTRAINT `fk_split_operation_inbound` FOREIGN KEY (`inbound_id`) REFERENCES `fl_inbound_order` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='分柜操作幂等与审计索引'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_terminal_drayage`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_terminal_drayage` ( + `id` bigint unsigned NOT NULL COMMENT '目的港码头拖车聚合ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '唯一归属物理货柜ID', + `fleet_supplier_id` bigint unsigned DEFAULT NULL COMMENT 'Warehouse ACTIVE/TRUCKING供应商ID快照', + `fleet_supplier_name` varchar(128) DEFAULT NULL COMMENT 'Warehouse供应商名称快照', + `pickup_appointment_at` datetime(3) DEFAULT NULL COMMENT '预约提柜时间', + `actual_pickup_at` datetime(3) DEFAULT NULL COMMENT '实际提柜时间', + `estimated_empty_return_at` datetime(3) DEFAULT NULL COMMENT '服务端按卸柜完成时间加72小时计算的预计还柜时间', + `actual_empty_return_at` datetime(3) DEFAULT NULL COMMENT '实际还空柜时间', + `version` int unsigned NOT NULL DEFAULT '1' COMMENT '乐观锁版本号', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `created_by_name` varchar(64) DEFAULT NULL COMMENT '创建人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_by` bigint unsigned NOT NULL COMMENT '最后更新人ID', + `updated_by_name` varchar(64) DEFAULT NULL COMMENT '最后更新人名称快照', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_terminal_drayage_container` (`container_booking_id`), + KEY `idx_terminal_drayage_warning` (`actual_empty_return_at`,`estimated_empty_return_at`), + CONSTRAINT `fk_terminal_drayage_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_terminal_drayage_times` CHECK ((((`actual_pickup_at` is null) or (`pickup_appointment_at` is not null)) and ((`actual_empty_return_at` is null) or (`actual_pickup_at` is null) or (`actual_empty_return_at` >= `actual_pickup_at`)))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港货柜级码头拖车与还空事实'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_terminal_drayage_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_terminal_drayage_history` ( + `id` bigint unsigned NOT NULL COMMENT '历史ID', + `drayage_id` bigint unsigned NOT NULL COMMENT '码头拖车聚合ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '货柜ID快照', + `action` varchar(32) NOT NULL COMMENT '业务动作', + `from_status` varchar(32) NOT NULL COMMENT '操作前状态', + `to_status` varchar(32) NOT NULL COMMENT '操作后状态', + `from_version` int unsigned NOT NULL COMMENT '操作前版本', + `to_version` int unsigned NOT NULL COMMENT '操作后版本', + `reason` varchar(500) DEFAULT NULL COMMENT '操作说明', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `operator_name` varchar(64) DEFAULT NULL COMMENT '操作人名称快照', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '操作时间', + PRIMARY KEY (`id`), + KEY `idx_terminal_drayage_history` (`container_booking_id`,`created_at`,`id`), + KEY `fk_terminal_drayage_history` (`drayage_id`), + CONSTRAINT `fk_terminal_drayage_history` FOREIGN KEY (`drayage_id`) REFERENCES `fl_terminal_drayage` (`id`), + CONSTRAINT `chk_terminal_drayage_history_action` CHECK ((`action` in (_utf8mb4'SELECT_FLEET',_utf8mb4'SEND_PICKUP_EMAIL',_utf8mb4'SCHEDULE_PICKUP',_utf8mb4'RECORD_PICKUP',_utf8mb4'RECORD_EMPTY_RETURN'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港码头拖车不可变操作历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_terminal_drayage_mail`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_terminal_drayage_mail` ( + `id` bigint unsigned NOT NULL COMMENT '邮件任务ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '归属物理货柜ID', + `fleet_supplier_id` bigint unsigned NOT NULL COMMENT '车队供应商ID快照', + `fleet_name` varchar(128) NOT NULL COMMENT '车队名称快照', + `recipient` varchar(254) NOT NULL COMMENT '收件人邮箱', + `kind` varchar(32) NOT NULL COMMENT '邮件类型:REQUEST_PICKUP提柜请求、CANCEL_PICKUP取消提柜', + `subject` varchar(255) NOT NULL COMMENT '邮件主题', + `body` text NOT NULL COMMENT '邮件正文', + `status` varchar(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING待发送、SENDING发送中、SENT已发送、FAILED失败', + `attempts` int NOT NULL DEFAULT '0' COMMENT '发送尝试次数', + `failure_reason` varchar(500) DEFAULT NULL COMMENT '发送失败原因', + `created_by` bigint unsigned NOT NULL COMMENT '创建人ID', + `updated_by` bigint unsigned NOT NULL COMMENT '最后更新人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + `sent_at` datetime(3) DEFAULT NULL COMMENT '发送成功时间', + PRIMARY KEY (`id`), + KEY `idx_terminal_mail_dispatch` (`status`,`id`), + KEY `idx_terminal_mail_container` (`container_booking_id`,`id`,`status`), + CONSTRAINT `fk_terminal_mail_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`), + CONSTRAINT `chk_terminal_mail_kind` CHECK ((`kind` in (_utf8mb4'REQUEST_PICKUP',_utf8mb4'CANCEL_PICKUP'))), + CONSTRAINT `chk_terminal_mail_status` CHECK ((`status` in (_utf8mb4'PENDING',_utf8mb4'SENDING',_utf8mb4'SENT',_utf8mb4'FAILED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='码头车队邮件任务与投递记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_terminal_drayage_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_terminal_drayage_operation` ( + `id` bigint unsigned NOT NULL COMMENT '幂等操作ID', + `container_booking_id` bigint unsigned NOT NULL COMMENT '货柜ID', + `operation` varchar(32) NOT NULL COMMENT '业务动作', + `idempotency_key` varchar(255) NOT NULL COMMENT '客户端幂等键', + `request_hash` char(64) NOT NULL COMMENT '请求载荷SHA-256', + `result_version` int unsigned NOT NULL COMMENT '成功结果版本', + `operator_id` bigint unsigned NOT NULL COMMENT '操作人ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_terminal_drayage_operation` (`container_booking_id`,`idempotency_key`), + KEY `idx_terminal_drayage_operation` (`container_booking_id`,`created_at`), + CONSTRAINT `fk_terminal_drayage_operation_container` FOREIGN KEY (`container_booking_id`) REFERENCES `fl_container_booking` (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='目的港码头拖车成功幂等操作'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_third_party_call_log`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_third_party_call_log` ( + `id` bigint unsigned NOT NULL COMMENT '第三方调用日志ID(雪花ID)', + `tenant_id` bigint unsigned NOT NULL DEFAULT '0' COMMENT '第三方未知/未解析主体哨兵值;不是平台客户记录或平台作用域,不得用于业务数据授权', + `app_key` varchar(128) DEFAULT NULL COMMENT '第三方应用标识', + `signature_subject` varchar(128) DEFAULT NULL COMMENT '签名主体快照', + `request_method` varchar(8) NOT NULL COMMENT 'HTTP方法', + `request_path` varchar(255) NOT NULL COMMENT '签名使用的规范路径', + `request_timestamp` varchar(32) DEFAULT NULL COMMENT '请求头Unix毫秒时间戳原值', + `nonce` varchar(128) DEFAULT NULL COMMENT '请求Nonce', + `idempotency_key` varchar(128) DEFAULT NULL COMMENT 'HTTP幂等键,查询接口为空', + `idempotency_anchor` tinyint unsigned DEFAULT NULL COMMENT '签名通过后首个请求为1,重复调用为空', + `signature_value` varchar(128) DEFAULT NULL COMMENT '第三方签名原值', + `request_hash` char(64) NOT NULL COMMENT '原始请求体SHA-256小写十六进制', + `raw_payload` longtext NOT NULL COMMENT '未经重新序列化的UTF-8原始请求体', + `processing_status` varchar(16) NOT NULL COMMENT 'PROCESSING、ACCEPTED或REJECTED', + `result_code` varchar(32) DEFAULT NULL COMMENT '统一响应业务码', + `result_message` varchar(1000) DEFAULT NULL COMMENT '处理结果或失败原因', + `result_json` json DEFAULT NULL COMMENT '首次成功结果快照', + `requested_at` datetime(3) NOT NULL COMMENT '服务端接收时间', + `processed_at` datetime(3) DEFAULT NULL COMMENT '处理完成时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_third_call_idempotency_owner` (`tenant_id`,`app_key`,`idempotency_key`,`idempotency_anchor`), + KEY `idx_third_call_app_time` (`tenant_id`,`app_key`,`requested_at`), + KEY `idx_third_call_idempotency` (`tenant_id`,`app_key`,`idempotency_key`), + KEY `idx_third_call_nonce` (`tenant_id`,`app_key`,`nonce`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='第三方测量开放接口原始请求、签名主体和处理结果留档'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_truck_appointment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_truck_appointment` ( + `id` bigint unsigned NOT NULL COMMENT '拖车预约ID', + `appointment_no` varchar(40) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '平台拖车预约编号', + `outbound_order_id` bigint unsigned DEFAULT NULL COMMENT '关联头程订单逻辑ID;不跨库建外键', + `ocean_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联海运订舱逻辑ID', + `container_booking_id` bigint unsigned DEFAULT NULL COMMENT '关联柜次逻辑ID;拖车完成供装柜门禁校验', + `loading_plan_id` bigint unsigned DEFAULT NULL COMMENT '关联装柜计划逻辑ID', + `warehouse_id` bigint unsigned DEFAULT NULL COMMENT '预约到仓的国内收货仓逻辑ID', + `customer_id` bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键', + `customer_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照', + `carrier_id` bigint unsigned DEFAULT NULL COMMENT '拖车服务商逻辑ID', + `carrier_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '拖车服务商名称快照', + `bill_of_lading_no` varchar(128) DEFAULT NULL, + `fleet_contact` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '车队联系人', + `fleet_phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '车队联系电话', + `driver_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '司机姓名', + `driver_phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '司机联系电话', + `driver_id_card` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '司机身份证号;平台内部敏感信息', + `vehicle_no` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '车辆车牌号', + `store_name` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓装柜地点名称快照', + `store_contact` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓地点联系人快照', + `store_phone` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓地点联系电话快照', + `store_address` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓装柜地点地址快照', + `store_city` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓地点城市快照', + `store_zipcode` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '到仓地点邮编快照', + `package_count` int unsigned DEFAULT NULL COMMENT '预约货物包装件数快照', + `gross_weight_kg` decimal(18,3) DEFAULT NULL COMMENT '预约货物毛重,单位千克', + `volume_cbm` decimal(18,4) DEFAULT NULL COMMENT '预约货物体积,单位立方米', + `pallet_count` int unsigned DEFAULT NULL COMMENT '预约托盘数量', + `po_no` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '关联采购单号或客户PO号', + `planned_loading_at` datetime(3) DEFAULT NULL COMMENT '预约到仓装柜时间', + `actual_arrived_warehouse_at` datetime(3) DEFAULT NULL COMMENT '车辆实际到达收货仓时间', + `actual_loading_at` datetime(3) DEFAULT NULL COMMENT '实际开始装柜时间', + `actual_departed_at` datetime(3) DEFAULT NULL COMMENT '车辆实际离仓时间;DEPARTED后供LOADED门禁使用', + `transit_minutes` int unsigned DEFAULT NULL COMMENT '从到仓到离仓的运输作业耗时,单位分钟', + `payable_amount` decimal(18,2) DEFAULT NULL COMMENT '应付拖车服务商金额;币种由currency指定', + `receivable_amount` decimal(18,2) DEFAULT NULL COMMENT '向客户收取的拖车金额;币种由currency指定', + `currency` varchar(8) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'RMB' COMMENT '金额币种;首期默认RMB', + `status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'DRAFT' COMMENT '拖车预约状态:DRAFT草稿、PENDING_DISPATCH待派车、DISPATCHED已派车、PICKUP_IN_PROGRESS提货中、ARRIVED_WAREHOUSE已到仓、LOADING装柜中、DEPARTED已离仓、COMPLETED已完成、RESCHEDULED已改约、EXCEPTION异常、CANCELLED已取消', + `revision` int unsigned NOT NULL DEFAULT '0' COMMENT '拖车预约业务修订号;每次状态或改约递增', + `version` int unsigned NOT NULL DEFAULT '0' COMMENT '乐观锁版本号', + `remark` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '平台内部备注或异常说明', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '拖车预约创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '拖车预约最后更新时间', + `active_container_booking_id` bigint unsigned GENERATED ALWAYS AS ((case when (`status` not in (_utf8mb4'COMPLETED',_utf8mb4'CANCELLED',_utf8mb4'EXCEPTION')) then `container_booking_id` else NULL end)) STORED COMMENT '非终态预约的柜次唯一键', + `active_ocean_booking_id` bigint unsigned GENERATED ALWAYS AS ((case when (`status` not in (_utf8mb4'COMPLETED',_utf8mb4'CANCELLED',_utf8mb4'EXCEPTION')) then `ocean_booking_id` else NULL end)) STORED COMMENT '非终态预约的订舱唯一键', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_truck_appointment_no` (`appointment_no`), + KEY `idx_truck_appointment_status` (`status`,`updated_at`), + KEY `idx_truck_appointment_container` (`container_booking_id`), + KEY `idx_truck_appointment_customer` (`customer_id`), + CONSTRAINT `chk_truck_appointment_status` CHECK ((`status` in (_utf8mb4'DRAFT',_utf8mb4'PENDING_DISPATCH',_utf8mb4'DISPATCHED',_utf8mb4'PICKUP_IN_PROGRESS',_utf8mb4'ARRIVED_WAREHOUSE',_utf8mb4'LOADING',_utf8mb4'DEPARTED',_utf8mb4'COMPLETED',_utf8mb4'RESCHEDULED',_utf8mb4'EXCEPTION',_utf8mb4'CANCELLED'))) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='三期拖车预约派车聚合'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_truck_appointment_attachment`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_truck_appointment_attachment` ( + `id` bigint unsigned NOT NULL COMMENT '拖车附件关系ID', + `appointment_id` bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + `file_id` bigint unsigned NOT NULL COMMENT 'File服务文件逻辑ID', + `attachment_type` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '附件类型编码;由业务动作约定', + `file_name` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '上传时文件名快照', + `file_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '文件内容SHA-256摘要', + `visibility` varchar(24) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'INTERNAL' COMMENT '可见范围:INTERNAL仅平台内部、CLIENT客户可见', + `uploaded_by` bigint unsigned NOT NULL COMMENT '上传操作人ID', + `uploaded_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '附件上传时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_truck_attachment` (`appointment_id`,`file_id`), + KEY `idx_truck_attachment` (`appointment_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车附件文件引用'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_truck_appointment_history`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_truck_appointment_history` ( + `id` bigint unsigned NOT NULL COMMENT '拖车预约历史ID', + `appointment_id` bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + `revision` int unsigned NOT NULL COMMENT '该预约业务修订号', + `action` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '触发状态或改约的业务动作', + `from_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更前拖车状态;创建时为空', + `to_status` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '变更后拖车状态;改约时可保持原状态', + `reason` varchar(500) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '状态或改约原因', + `operator_id` bigint unsigned NOT NULL COMMENT '执行操作的平台用户ID', + `operator_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '操作人姓名快照', + `request_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '请求链路中的请求ID', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '历史记录创建时间', + PRIMARY KEY (`id`), + KEY `idx_truck_history` (`appointment_id`,`revision`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车状态与改约历史'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `fl_truck_appointment_operation`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `fl_truck_appointment_operation` ( + `id` bigint unsigned NOT NULL COMMENT '拖车写操作幂等记录ID', + `appointment_id` bigint unsigned NOT NULL COMMENT '所属拖车预约ID', + `idempotency_key` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '客户端写操作幂等键', + `request_hash` char(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '规范化请求内容SHA-256摘要;用于识别同键不同请求', + `action` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '执行的拖车业务动作', + `response_snapshot` json DEFAULT NULL COMMENT '成功响应JSON快照;供相同幂等请求重放', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '幂等记录创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_truck_operation` (`idempotency_key`), + KEY `idx_truck_operation_appointment` (`appointment_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='拖车操作幂等记录'; +/*!40101 SET character_set_client = @saved_cs_client */; +DROP TABLE IF EXISTS `sys_audit_outbox`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `sys_audit_outbox` ( + `event_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件全局唯一ID', + `event_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '审计事件类型', + `resource_type` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL COMMENT '被审计业务资源类型', + `resource_id` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '被审计业务资源ID;无法确定时为空', + `service_name` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '产生审计事件的服务名称', + `payload_json` json NOT NULL COMMENT '待发送的审计事件完整负载快照', + `status` varchar(16) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci NOT NULL DEFAULT 'PENDING' COMMENT '发送状态:PENDING待发送、SENDING发送中、SENT已发送、DEAD已转死信', + `attempt_count` int NOT NULL DEFAULT '0' COMMENT '正常发送累计尝试次数', + `compensation_count` int NOT NULL DEFAULT '0' COMMENT '人工或自动补偿累计次数', + `next_retry_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '允许下次重试的最早时间', + `lease_owner` varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '当前发送任务租约持有者标识', + `lease_until` datetime(3) DEFAULT NULL COMMENT '当前发送任务租约到期时间', + `last_error` varchar(1000) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '最近一次发送失败原因', + `dead_at` datetime(3) DEFAULT NULL COMMENT '转入死信状态的时间', + `sent_at` datetime(3) DEFAULT NULL COMMENT '首次成功发送时间', + `created_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '事件创建时间', + `updated_at` datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '记录最后更新时间', + PRIMARY KEY (`event_id`), + KEY `idx_audit_outbox_publish` (`status`,`next_retry_at`,`lease_until`,`created_at`), + KEY `idx_audit_outbox_retention` (`status`,`sent_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='可靠审计事件本地发送箱'; +/*!40101 SET character_set_client = @saved_cs_client */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + diff --git a/output/local-schema-repair/schema-verification.json b/output/local-schema-repair/schema-verification.json new file mode 100644 index 000000000..b664715bb --- /dev/null +++ b/output/local-schema-repair/schema-verification.json @@ -0,0 +1,20 @@ +[ + { + "db": "baiyue_firstleg", + "baselineTables": 131, + "missingColumns": [], + "missingIndexes": [], + "appliedIndexes": [], + "blockedIndexes": [] + }, + { + "db": "baiyue_lastleg", + "baselineTables": 218, + "missingColumns": [], + "missingIndexes": [ + "ll_inbound_plan.uk_ll_inbound_plan_source_order" + ], + "appliedIndexes": [], + "blockedIndexes": [] + } +] \ No newline at end of file diff --git a/output/local-schema-repair/summarize.mjs b/output/local-schema-repair/summarize.mjs new file mode 100644 index 000000000..897aa1e4d --- /dev/null +++ b/output/local-schema-repair/summarize.mjs @@ -0,0 +1,12 @@ +import fs from 'node:fs'; +const dir=new URL('./',import.meta.url); +function parse(file){let db='';const result=new Map();for(const m of fs.readFileSync(new URL(file,dir),'utf8').matchAll(/USE `(\w+)`;|CREATE TABLE `(\w+)` \(([\s\S]*?)\n\) ENGINE=[^;]+;/g)){if(m[1])db=m[1];else result.set(db+'.'+m[2],{db,name:m[2],sql:m[0],defs:m[3].split(/\r?\n/).map(s=>s.trim().replace(/,$/,''))});}return result;} +const before=parse('schema-before.sql'),after=parse('schema-after.sql'),report={},ddl=['-- 本次本机结构修复的实际增量,仅用于审阅与原始快照对照。已执行,禁止在当前库重复执行。','SET NAMES utf8mb4;']; +for(const [key,t] of after){const r=report[t.db]??={createdTables:[],addedColumns:[],addedIndexes:[]};const old=before.get(key);ddl.push('USE `'+t.db+'`;'); + if(!old){r.createdTables.push(t.name);ddl.push(t.sql);continue;} + const oldCols=new Set(old.defs.map(d=>d.match(/^`(\w+)`/)?.[1]).filter(Boolean)); + const oldKeys=new Set(old.defs.map(d=>d.match(/^(?:UNIQUE )?KEY `(\w+)`/)?.[1]).filter(Boolean)); + for(const d of t.defs){const c=d.match(/^`(\w+)`/),i=d.match(/^(?:UNIQUE )?KEY `(\w+)`/);if(c&&!oldCols.has(c[1])){r.addedColumns.push(t.name+'.'+c[1]);ddl.push('ALTER TABLE `'+t.name+'` ADD COLUMN '+d+';');}else if(i&&!oldKeys.has(i[1])){r.addedIndexes.push(t.name+'.'+i[1]);ddl.push('ALTER TABLE `'+t.name+'` ADD '+d+';');}} +} +fs.writeFileSync(new URL('actual-changes.json',dir),JSON.stringify(report,null,2));fs.writeFileSync(new URL('actual-incremental-review.sql',dir),ddl.join('\n\n')); +for(const [db,r] of Object.entries(report))console.log(db,{tables:r.createdTables.length,columns:r.addedColumns.length,indexes:r.addedIndexes.length}); diff --git a/output/local-schema-repair/tab-count-consistency.json b/output/local-schema-repair/tab-count-consistency.json new file mode 100644 index 000000000..a9938d370 --- /dev/null +++ b/output/local-schema-repair/tab-count-consistency.json @@ -0,0 +1,116 @@ +[ + { + "status": "PENDING_REVIEW", + "count": 127, + "total": 127, + "passed": true + }, + { + "status": "WAIT_MEASURE", + "count": 195, + "total": 195, + "passed": true + }, + { + "status": "WAIT_WEIGHT_CONFIRM", + "count": 125, + "total": 125, + "passed": true + }, + { + "status": "WEIGHT_CONFIRMED", + "count": 1, + "total": 1, + "passed": true + }, + { + "status": "WAIT_CUSTOMS_DOCS", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "WAIT_PALLETIZE", + "count": 6, + "total": 6, + "passed": true + }, + { + "status": "WAIT_SPLIT", + "count": 126, + "total": 126, + "passed": true + }, + { + "status": "LOADING", + "count": 9, + "total": 9, + "passed": true + }, + { + "status": "LOADED", + "count": 73, + "total": 73, + "passed": true + }, + { + "status": "EXPORT_CUSTOMS", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "SAILING_CLEARANCE", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "WAIT_UNLOAD", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "WAIT_DISPATCH", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "OUTBOUND_DOCUMENT", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "DELIVERING", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "SIGNED", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "DELIVERED", + "count": 0, + "total": 0, + "passed": true + }, + { + "status": "COMPLETED", + "count": 2, + "total": 2, + "passed": true + }, + { + "status": "MEASUREMENT_REVIEW", + "count": 120, + "total": 120, + "passed": true + } +] diff --git a/output/local-schema-repair/tab-smoke.json b/output/local-schema-repair/tab-smoke.json new file mode 100644 index 000000000..22b841365 --- /dev/null +++ b/output/local-schema-repair/tab-smoke.json @@ -0,0 +1,537 @@ +[ + { + "path": "/api/auth/login", + "http": 200, + "code": "200", + "requestId": "5a80dd52-e63b-4750-9176-2d6d97a5e3c8, dddd7757-a5d2-4185-839b-04b34ab2837c", + "passed": true + }, + { + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200", + "requestId": "1da4d48a-e0bb-41a3-ae43-1ddf12d5a14b, 741bf672-5db1-4914-81d0-d0a46745f67c", + "passed": true, + "counts": { + "PENDING_REVIEW": 127, + "WAIT_MEASURE": 195, + "WAIT_PALLETIZE": 6, + "WAIT_SPLIT": 127, + "LOADING": 8, + "LOADED": 74, + "DELIVERED": 0, + "COMPLETED": 2, + "WAIT_WEIGHT_CONFIRM": 124, + "WEIGHT_CONFIRMED": 1, + "WAIT_CUSTOMS_DOCS": 0, + "EXPORT_CUSTOMS": 55, + "SAILING_CLEARANCE": 0, + "WAIT_UNLOAD": 1, + "WAIT_DISPATCH": 1, + "OUTBOUND_DOCUMENT": 0, + "DELIVERING": 0, + "SIGNED": 0, + "MEASUREMENT_REVIEW": 120 + } + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=PENDING_REVIEW", + "http": 200, + "code": "200", + "requestId": "3bbf7448-f7e1-40a3-bea6-8e7007bb5c23, 10fd5748-ea6f-413f-9d96-f67cc930b664", + "passed": true, + "total": "127", + "records": [ + { + "orderId": "2112105953787518978", + "tenantId": "2112105501490544641", + "inboundId": "2112105957231161346", + "mainStatus": "PENDING_REVIEW" + }, + { + "orderId": "2112105886309556225", + "tenantId": "2112105195373461506", + "inboundId": "2112105890017439745", + "mainStatus": "PENDING_REVIEW" + }, + { + "orderId": "2112105822484832258", + "tenantId": "2112105195373461506", + "inboundId": "2112105825983000577", + "mainStatus": "PENDING_REVIEW" + }, + { + "orderId": "2112105768302813186", + "tenantId": "2112105195373461506", + "inboundId": "2112105772081999873", + "mainStatus": "PENDING_REVIEW" + }, + { + "orderId": "2112105682319581185", + "tenantId": "2112105501490544641", + "inboundId": "2112105687122178049", + "mainStatus": "PENDING_REVIEW" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_MEASURE", + "http": 200, + "code": "200", + "requestId": "71a6b016-4d23-448d-aa30-ed7c954e29ee, 60c5e10b-faf1-4e61-b16f-35306513a7f1", + "passed": true, + "total": "195", + "records": [ + { + "orderId": "2112102981498183681", + "tenantId": "2112102958765047809", + "inboundId": "2112103003577118721", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2098325342631428097", + "tenantId": "2098324852501774338", + "inboundId": "2098325343935848449", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2098325190034259969", + "tenantId": "2098324852501774338", + "inboundId": "2098325191338680322", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2098325037621641217", + "tenantId": "2098324852501774338", + "inboundId": "2098325038921867265", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2098324887905959938", + "tenantId": "2098324852501774338", + "inboundId": "2098324891550801921", + "mainStatus": "WAIT_MEASURE" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_WEIGHT_CONFIRM", + "http": 200, + "code": "200", + "requestId": "ac639d03-1d4f-4140-bd19-a50e15e211ab, 8d6dfa2b-85c5-4cd8-bb8a-7bd112d2fe0d", + "passed": true, + "total": "124", + "records": [ + { + "orderId": "2085355345216114689", + "tenantId": "2085308606829346818", + "inboundId": "2085355347941904385", + "mainStatus": null + }, + { + "orderId": "2085355345975283713", + "tenantId": "2085308606829346818", + "inboundId": "2085355347992236034", + "mainStatus": null + }, + { + "orderId": "2085355346734452738", + "tenantId": "2085308606829346818", + "inboundId": "2085355360826806273", + "mainStatus": null + }, + { + "orderId": "2085355347493621762", + "tenantId": "2085308606829346818", + "inboundId": "2085355360881332226", + "mainStatus": null + }, + { + "orderId": "2085355348282150913", + "tenantId": "2085308606829346818", + "inboundId": "2085355360931663873", + "mainStatus": null + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WEIGHT_CONFIRMED", + "http": 200, + "code": "200", + "requestId": "c47772c4-8188-47d5-9a8f-6e9aaabb2d7e, 52eabd98-7666-47da-b8e0-3447cdace665", + "passed": true, + "total": "1", + "records": [ + { + "orderId": "2085341741574098946", + "tenantId": "2085308606829346818", + "inboundId": "2085341749429522434", + "mainStatus": null + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_CUSTOMS_DOCS", + "http": 200, + "code": "200", + "requestId": "febd1b16-fdea-4c4b-8cb8-69aecb5e1ff6, 82627b67-04a6-47b7-b168-e653d4eeb5e3", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_PALLETIZE", + "http": 200, + "code": "200", + "requestId": "8f84977d-97f1-4acf-b9e5-0e062a901490, 03707bbf-cb42-4b34-ba5f-a924f9bae410", + "passed": true, + "total": "6", + "records": [ + { + "orderId": "2112105882580819970", + "tenantId": "2112105195373461506", + "inboundId": "2112105899853082626", + "mainStatus": "WAIT_PALLETIZE" + }, + { + "orderId": "2112105818881925122", + "tenantId": "2112105195373461506", + "inboundId": "2112105836158382082", + "mainStatus": "WAIT_PALLETIZE" + }, + { + "orderId": "2112105764741849089", + "tenantId": "2112105195373461506", + "inboundId": "2112105781263331330", + "mainStatus": "WAIT_PALLETIZE" + }, + { + "orderId": "2112104925457424385", + "tenantId": "2112104631512203266", + "inboundId": "2112104942167650305", + "mainStatus": "WAIT_PALLETIZE" + }, + { + "orderId": "2112104871636115457", + "tenantId": "2112104551203864578", + "inboundId": "2112104887486509058", + "mainStatus": "WAIT_PALLETIZE" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_SPLIT", + "http": 200, + "code": "200", + "requestId": "6fed641c-6076-41ca-b4e4-9567d1a843f5, 7199357b-7616-43c9-922f-74e2fa900477", + "passed": true, + "total": "127", + "records": [ + { + "orderId": "2112374338811326466", + "tenantId": "2092814835845664769", + "inboundId": "2112376564246077441", + "mainStatus": "WAIT_SPLIT" + }, + { + "orderId": "2112105949454802945", + "tenantId": "2112105501490544641", + "inboundId": "2112105974222286850", + "mainStatus": "WAIT_SPLIT" + }, + { + "orderId": "2112105677974282242", + "tenantId": "2112105501490544641", + "inboundId": "2112105696567750658", + "mainStatus": "WAIT_SPLIT" + }, + { + "orderId": "2112105550538743809", + "tenantId": "2112105501490544641", + "inboundId": "2112105615210835970", + "mainStatus": "WAIT_SPLIT" + }, + { + "orderId": "2112105269298077697", + "tenantId": "2112105195373461506", + "inboundId": "2112105371026845697", + "mainStatus": "WAIT_SPLIT" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=LOADING", + "http": 200, + "code": "200", + "requestId": "acabdb6d-f822-46e4-92ae-30bc8efa7537, 8d45744a-682e-41a9-b738-9ade63926b28", + "passed": true, + "total": "8", + "records": [ + { + "orderId": "2092816910277038081", + "tenantId": "2092816814294667266", + "inboundId": "2092816913754202115", + "mainStatus": "LOADING" + }, + { + "orderId": "2092816871253233665", + "tenantId": "2092816814294667266", + "inboundId": "2092816875699281923", + "mainStatus": "LOADING" + }, + { + "orderId": "2092816836365012994", + "tenantId": "2092816814294667266", + "inboundId": "2092816850256633858", + "mainStatus": "LOADING" + }, + { + "orderId": "2091000814000001101", + "tenantId": "10001", + "inboundId": "2091000814000001301", + "mainStatus": "LOADING" + }, + { + "orderId": "2091000814000000101", + "tenantId": "10001", + "inboundId": "2091000814000000301", + "mainStatus": "LOADING" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=LOADED", + "http": 200, + "code": "200", + "requestId": "0e6536aa-a82a-4c1b-a41c-7d7520511eeb, 03629346-096b-465c-956f-c9326328dcee", + "passed": true, + "total": "74", + "records": [ + { + "orderId": "2093269574496440322", + "tenantId": "2093269089353961474", + "inboundId": "2093269587792433154", + "mainStatus": "LOADED" + }, + { + "orderId": "2093269527184691202", + "tenantId": "2093269089353961474", + "inboundId": "2093269537020383234", + "mainStatus": "LOADED" + }, + { + "orderId": "2093269460814024706", + "tenantId": "2093269089353961474", + "inboundId": "2093269473657032707", + "mainStatus": "LOADED" + }, + { + "orderId": "2093269413967843329", + "tenantId": "2093269089353961474", + "inboundId": "2093269422700433411", + "mainStatus": "LOADED" + }, + { + "orderId": "2093269346426966017", + "tenantId": "2093269089353961474", + "inboundId": "2093269359261585410", + "mainStatus": "LOADED" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=EXPORT_CUSTOMS", + "http": 200, + "code": "200", + "requestId": "b0568387-1d75-4d6e-8755-8fb49623a224, ba4bf212-2ea9-4909-b816-f1650265211a", + "passed": true, + "total": "55", + "records": [ + { + "orderId": "2086081433932726274", + "tenantId": "10001", + "inboundId": "2086081437650956290", + "mainStatus": null + }, + { + "orderId": "2086085225151528961", + "tenantId": "10001", + "inboundId": "2086085239259049985", + "mainStatus": null + }, + { + "orderId": "2086085487601713154", + "tenantId": "10001", + "inboundId": "2086085491298971650", + "mainStatus": null + }, + { + "orderId": "2086086464165707778", + "tenantId": "10001", + "inboundId": "2086086473193926658", + "mainStatus": null + }, + { + "orderId": "2086088193535967234", + "tenantId": "10001", + "inboundId": "2086088197887537154", + "mainStatus": null + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=SAILING_CLEARANCE", + "http": 200, + "code": "200", + "requestId": "6de0f6f1-db74-43bd-a2df-1356dd2621dc, 836088e2-6974-4d24-86b5-3381f988cee4", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_UNLOAD", + "http": 200, + "code": "200", + "requestId": "4134d8d3-dd4d-4fd6-8950-83e3697d3f74, aae63083-913c-4453-82d3-7f8c7c97ef3f", + "passed": true, + "total": "1", + "records": [ + { + "orderId": "2092814878690398210", + "tenantId": "2092814835845664769", + "inboundId": "2092814880129130499", + "mainStatus": "LOADED" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_DISPATCH", + "http": 200, + "code": "200", + "requestId": "cbb986f8-a8db-4057-a4ce-f81973b9f167, bf7e93c6-dfce-435f-a1fa-ee8a5801defd", + "passed": true, + "total": "1", + "records": [ + { + "orderId": "2092814878690398210", + "tenantId": "2092814835845664769", + "inboundId": "2092814880129130499", + "mainStatus": "LOADED" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=OUTBOUND_DOCUMENT", + "http": 200, + "code": "200", + "requestId": "95e78822-1d56-4699-b90b-87322f972067, db5f9b65-6f7b-4cf6-a875-1c90e45d8f79", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=DELIVERING", + "http": 200, + "code": "200", + "requestId": "a98b495a-db94-471e-bc59-a3aeba7d2997, f409423a-5e9f-4f46-b2ec-203f987aa39a", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=SIGNED", + "http": 200, + "code": "200", + "requestId": "06260f7b-84cd-49f4-a03c-fa699a4e1a86, 3c3879e7-869d-4e9b-a725-868a466f8fb2", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=DELIVERED", + "http": 200, + "code": "200", + "requestId": "c6451658-5d84-4fa6-b2e5-2fee098c50c1, 05eb6078-71c4-4e98-b205-c643d69647b9", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=COMPLETED", + "http": 200, + "code": "200", + "requestId": "d46153fb-bfdd-4493-b789-a3a5261eed6d, 2b71ece4-b561-47f6-8978-68f71c9f62b3", + "passed": true, + "total": "2", + "records": [ + { + "orderId": "2084515377635758081", + "tenantId": "2084513933705150465", + "inboundId": "2085623586303365121", + "mainStatus": "COMPLETED" + }, + { + "orderId": "2084210113483173890", + "tenantId": "10001", + "inboundId": "2085623585678413826", + "mainStatus": "COMPLETED" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=MEASUREMENT_REVIEW", + "http": 200, + "code": "200", + "requestId": "16fe4770-d066-42f0-b9a0-3fb9e5ab7403, b51983ce-cf48-42b3-b003-0156ca953e44", + "passed": true, + "total": "120", + "records": [ + { + "orderId": "2085355294326624258", + "tenantId": "2085308606829346818", + "inboundId": "2085355307252961281", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2085355295115153410", + "tenantId": "2085308606829346818", + "inboundId": "2085355307303292930", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2085355295895293953", + "tenantId": "2085308606829346818", + "inboundId": "2085355307357818882", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2085355296650268673", + "tenantId": "2085308606829346818", + "inboundId": "2085355307408150530", + "mainStatus": "WAIT_MEASURE" + }, + { + "orderId": "2085355297438797826", + "tenantId": "2085308606829346818", + "inboundId": "2085355307462676481", + "mainStatus": "WAIT_MEASURE" + } + ] + }, + { + "path": "9202/actuator/health", + "http": 200, + "body": { + "status": "UP" + }, + "passed": true + }, + { + "path": "9303/actuator/health", + "http": 200, + "body": { + "status": "UP" + }, + "passed": true + } +] \ No newline at end of file diff --git a/output/local-schema-repair/tab-smoke.mjs b/output/local-schema-repair/tab-smoke.mjs new file mode 100644 index 000000000..1afc8a491 --- /dev/null +++ b/output/local-schema-repair/tab-smoke.mjs @@ -0,0 +1,17 @@ +import fs from 'node:fs'; +const base=process.env.ADMIN_TEST_GATEWAY; +if(!base||!['127.0.0.1','localhost'].includes(new URL(base).hostname))throw new Error('仅允许本机'); +let token='';const checks=[]; +async function call(path,body) { + const r=await fetch(base+path,{method:body?'POST':'GET',headers:{...(token?{Authorization:'Bearer '+token}:{}),...(body?{'Content-Type':'application/json'}:{})},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(45000)}); + const value=await r.json().catch(()=>null); + checks.push({path,http:r.status,code:value?.code,message:String(value?.code)==='200'?undefined:value?.message,requestId:r.headers.get('x-request-id'),passed:r.status===200&&String(value?.code)==='200',total:value?.data?.total,counts:value?.data?.counts,records:value?.data?.records?.map(x=>({orderId:x.orderId,tenantId:x.tenantId,inboundId:x.inboundId,mainStatus:x.mainStatus}))}); + fs.writeFileSync(new URL('tab-smoke.json',import.meta.url),JSON.stringify(checks,null,2)); + return value; +} +const login=await call('/api/auth/login',{loginId:process.env.ADMIN_TEST_USERNAME,password:process.env.ADMIN_TEST_PASSWORD}); +if(!login?.data?.accessToken)throw new Error('本地登录失败');token=login.data.accessToken; +for(const path of ['/api/platform/v1/orders/status-counts',...['PENDING_REVIEW','WAIT_MEASURE','WAIT_WEIGHT_CONFIRM','WEIGHT_CONFIRMED','WAIT_CUSTOMS_DOCS','WAIT_PALLETIZE','WAIT_SPLIT','LOADING','LOADED','EXPORT_CUSTOMS','SAILING_CLEARANCE','WAIT_UNLOAD','WAIT_DISPATCH','OUTBOUND_DOCUMENT','DELIVERING','SIGNED','DELIVERED','COMPLETED','MEASUREMENT_REVIEW'].map(s=>'/api/platform/v1/orders?page=1&size=5&mainStatus='+s)])await call(path); +for(const port of [9202,9303]){const r=await fetch(`http://127.0.0.1:${port}/actuator/health`);checks.push({path:`${port}/actuator/health`,http:r.status,body:await r.json(),passed:r.ok});} +fs.writeFileSync(new URL('tab-smoke.json',import.meta.url),JSON.stringify(checks,null,2));console.log(JSON.stringify(checks,null,2)); + diff --git a/output/local-schema-repair/tail-final-consistency.json b/output/local-schema-repair/tail-final-consistency.json new file mode 100644 index 000000000..a2dce8a8d --- /dev/null +++ b/output/local-schema-repair/tail-final-consistency.json @@ -0,0 +1,67 @@ +{ + "at": "2026-10-20T03:20:01.182Z", + "checks": 23, + "failed": [], + "mismatches": [], + "tail": [ + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=SAILING_CLEARANCE", + "http": 200, + "code": "200", + "requestId": "6de0f6f1-db74-43bd-a2df-1356dd2621dc, 836088e2-6974-4d24-86b5-3381f988cee4", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_UNLOAD", + "http": 200, + "code": "200", + "requestId": "4134d8d3-dd4d-4fd6-8950-83e3697d3f74, aae63083-913c-4453-82d3-7f8c7c97ef3f", + "passed": true, + "total": "1", + "records": [ + { + "orderId": "2092814878690398210", + "tenantId": "2092814835845664769", + "inboundId": "2092814880129130499", + "mainStatus": "LOADED" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=WAIT_DISPATCH", + "http": 200, + "code": "200", + "requestId": "cbb986f8-a8db-4057-a4ce-f81973b9f167, bf7e93c6-dfce-435f-a1fa-ee8a5801defd", + "passed": true, + "total": "1", + "records": [ + { + "orderId": "2092814878690398210", + "tenantId": "2092814835845664769", + "inboundId": "2092814880129130499", + "mainStatus": "LOADED" + } + ] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=DELIVERING", + "http": 200, + "code": "200", + "requestId": "a98b495a-db94-471e-bc59-a3aeba7d2997, f409423a-5e9f-4f46-b2ec-203f987aa39a", + "passed": true, + "total": "0", + "records": [] + }, + { + "path": "/api/platform/v1/orders?page=1&size=5&mainStatus=SIGNED", + "http": 200, + "code": "200", + "requestId": "06260f7b-84cd-49f4-a03c-fa699a4e1a86, 3c3879e7-869d-4e9b-a725-868a466f8fb2", + "passed": true, + "total": "0", + "records": [] + } + ] +} \ No newline at end of file diff --git a/output/local-schema-repair/tail-flow-inspect.json b/output/local-schema-repair/tail-flow-inspect.json new file mode 100644 index 000000000..1aa585f0f --- /dev/null +++ b/output/local-schema-repair/tail-flow-inspect.json @@ -0,0 +1,189 @@ +[ + { + "path": "/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=193000000000200001", + "code": "200", + "message": "success", + "data": [ + { + "userId": "1790048228520288513", + "displayName": "张三" + }, + { + "userId": "1790048228520288516", + "displayName": "李四" + } + ], + "requestId": null, + "traceId": null, + "timestamp": "2026-10-20T02:33:57.852782300Z" + }, + { + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "code": "200", + "message": "success", + "data": { + "id": "2102273311474982914", + "loadingPlanId": "2092814929479311362", + "containerBookingId": "2092814929307344898", + "inboundId": "2092814880129130499", + "inboundVersion": 6, + "loadingVersion": 6, + "cabinetNo": "CAB-260827-0019", + "currentContainerNo": "CSQU3054383", + "sourceType": "SIMULATION", + "recordStatus": "ACTIVE", + "mergedIntoContainerBookingId": null, + "taskStatus": "LOADING", + "workflowStatus": "IN_PROGRESS", + "version": 0, + "assignment": { + "assigneeId": "1790048228520288513", + "assigneeName": "pda_zhangsan", + "assignedBy": "1790048228520288513", + "assignedByName": "pda_zhangsan", + "assignmentType": "CLAIMED", + "version": 0 + }, + "review": null, + "completionCommand": null, + "items": [ + { + "id": "2102273311542091778", + "loadingPlanItemId": "2092814929680637954", + "tenantId": "2092814835845664769", + "inboundId": "2092814880129130499", + "inboundDetailId": "2092814878690398211", + "unitType": "PACKAGE", + "palletId": null, + "palletNo": null, + "indivisible": false, + "orderNo": "FL2092814878690398210", + "inboundCode": "NYEXX2608270051", + "cargoName": "SMALL 测试产品", + "boxCodes": [ + "NYEXX2608270051#4-1" + ], + "inventoryResources": [ + { + "blockId": "2092814880129130499", + "slotNo": 1, + "reservationId": "2092814900999888899", + "weightResultId": "2092814900605722626", + "palletSequence": 1, + "palletId": "2092814900211359746", + "locationId": "193000000000200202", + "locationCode": "A0", + "sharedOverflow": true, + "slotStatus": "BOUND", + "availablePackageCount": 4 + } + ], + "plannedPackageCount": 1, + "pickedPackageCount": 0, + "itemStatus": "PENDING", + "version": 0 + } + ] + }, + "requestId": null, + "traceId": null, + "timestamp": "2026-10-20T02:33:57.981150700Z" + }, + { + "path": "/api/platform/v1/containers/2092814929307344898/overview", + "code": "200", + "message": "success", + "data": { + "container": { + "id": "2092814929307344898", + "cabinetNo": "CAB-260827-0019", + "containerNo": "CSQU3054383", + "status": "LOADING", + "warehouseId": "193000000000200001", + "warehouseName": "义乌仓", + "voyageNo": "P3V-32153799-AA35DF75", + "vesselName": "P3VESSEL-32153799-AA35DF75", + "billOfLadingNo": "P3BL-32153799-AA35DF75", + "shippingCompanyId": "2092808875341127681", + "shippingCompanyName": "MATSON 开发联调船司", + "departurePortCode": "CNSZX", + "departurePortName": "深圳", + "destinationPortCode": "USLAX", + "destinationPortName": "洛杉矶", + "estimatedSailingAt": "2026-09-01T08:00:00", + "actualSailingAt": null, + "estimatedPortArrivalAt": "2026-09-20T08:00:00", + "actualPortArrivalAt": null, + "estimatedWarehouseArrivalAt": null, + "actualWarehouseArrivalAt": null, + "unloadingCompletedAt": null, + "unloadingPhotoUploadedAt": null, + "estimatedEmptyReportedAt": null, + "actualEmptyReportedAt": null, + "emptyReportSourceVersion": null, + "pickupCode": null, + "pickupCodeUpdatedBy": null, + "pickupCodeUpdatedByName": null, + "pickupCodeUpdatedAt": null, + "version": 2 + }, + "loadingPlanId": "2092814929479311362", + "pda": { + "taskId": "2102273311474982914", + "loadingPlanId": "2092814929479311362", + "containerBookingId": "2092814929307344898", + "taskStatus": "LOADING", + "workflowStatus": "IN_PROGRESS", + "taskVersion": 0, + "itemCount": 1, + "completedItemCount": 0, + "unresolvedItemCount": 1, + "photoCounts": [], + "photos": [], + "exceptionReasons": [] + }, + "documents": [], + "exception": { + "active": false, + "reason": null, + "markedBy": null, + "markedByName": null, + "markedAt": null + }, + "exceptionHistory": [], + "timeline": [ + { + "eventType": "TRANSPORT_UPDATE", + "detail": "按业务测试要求绑定已通过 ISO 6346 校验的集装箱号,暂不推送 PDA", + "operatorId": "9000000001", + "operatorName": "platform_custom_admin", + "occurredAt": "2026-09-22T11:32:55.69" + }, + { + "eventType": "PDA_CLAIM", + "detail": "claim-1790048228520288513-2102273311474982914-2092814929479311362-2092814929307344898", + "operatorId": "1790048228520288513", + "operatorName": "pda_zhangsan", + "occurredAt": "2026-09-22T14:27:48.707" + } + ] + }, + "requestId": null, + "traceId": null, + "timestamp": "2026-10-20T02:33:58.064086600Z" + }, + { + "path": "/api/platform/v1/container-shipping-nodes/2092814929307344898", + "code": "200", + "message": "success", + "data": { + "containerBookingId": "2092814929307344898", + "currentNode": null, + "currentNodeAt": null, + "nodes": [] + }, + "requestId": null, + "traceId": null, + "timestamp": "2026-10-20T02:33:58.128845700Z" + } +] \ No newline at end of file diff --git a/output/local-schema-repair/tail-flow-inspect.mjs b/output/local-schema-repair/tail-flow-inspect.mjs new file mode 100644 index 000000000..60bb7d4c3 --- /dev/null +++ b/output/local-schema-repair/tail-flow-inspect.mjs @@ -0,0 +1,16 @@ +import fs from 'node:fs'; +const base=process.env.ADMIN_TEST_GATEWAY; +if(!base || !['127.0.0.1','localhost'].includes(new URL(base).hostname))throw Error('仅允许本机'); +let token=''; +const login=await fetch(base+'/api/auth/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({loginId:process.env.ADMIN_TEST_USERNAME,password:process.env.ADMIN_TEST_PASSWORD})}).then(r=>r.json()); +if(String(login.code)!=='200'||!login.data?.accessToken)throw Error('登录失败'); +token=login.data.accessToken; +const paths=[ +'/api/platform/v1/iam/users/domestic-pda-operators?warehouseId=193000000000200001', +'/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001', +'/api/platform/v1/containers/2092814929307344898/overview', +'/api/platform/v1/container-shipping-nodes/2092814929307344898', +]; +const out=[]; +for(const path of paths){const r=await fetch(base+path,{headers:{Authorization:'Bearer '+token}}); const v=await r.json(); out.push({path,...v});console.log(path,v.code,String(v.message||''),v.data?Object.keys(v.data):'');} +fs.writeFileSync(new URL('tail-flow-inspect.json',import.meta.url),JSON.stringify(out,null,2)); diff --git a/output/local-schema-repair/tail-flow-state.json b/output/local-schema-repair/tail-flow-state.json new file mode 100644 index 000000000..893940890 --- /dev/null +++ b/output/local-schema-repair/tail-flow-state.json @@ -0,0 +1,1233 @@ +{ + "marker": "LOCAL-TAIL-20261020", + "environment": "LOCAL", + "photoEvidence": "SYNTHETIC_TEST_PNG", + "checks": [ + { + "method": "GET", + "path": "/api/platform/v1/iam/roles?page=1&size=200", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/iam/roles?page=1&size=200", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/iam/users", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/iam/users?page=1&size=20&keyword=local_tail_pda_20261020", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/assign", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/items/2102273311542091778/pick", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001&expectedVersion=0&evidenceType=PHOTO_STEP_1&remark=LOCAL-TAIL-20261020+%E5%90%88%E6%88%90%E6%B5%8B%E8%AF%95%E5%9B%BE%E7%89%87%EF%BC%9B%E4%BB%85%E6%9C%AC%E5%9C%B0%E6%B5%81%E8%BD%AC%E9%AA%8C%E6%94%B6", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001&expectedVersion=0&evidenceType=PHOTO_STEP_2&remark=LOCAL-TAIL-20261020+%E5%90%88%E6%88%90%E6%B5%8B%E8%AF%95%E5%9B%BE%E7%89%87%EF%BC%9B%E4%BB%85%E6%9C%AC%E5%9C%B0%E6%B5%81%E8%BD%AC%E9%AA%8C%E6%94%B6", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001&expectedVersion=0&evidenceType=PHOTO_STEP_3&remark=LOCAL-TAIL-20261020+%E5%90%88%E6%88%90%E6%B5%8B%E8%AF%95%E5%9B%BE%E7%89%87%EF%BC%9B%E4%BB%85%E6%9C%AC%E5%9C%B0%E6%B5%81%E8%BD%AC%E9%AA%8C%E6%94%B6", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001&expectedVersion=0&evidenceType=PHOTO_STEP_4&remark=LOCAL-TAIL-20261020+%E5%90%88%E6%88%90%E6%B5%8B%E8%AF%95%E5%9B%BE%E7%89%87%EF%BC%9B%E4%BB%85%E6%9C%AC%E5%9C%B0%E6%B5%81%E8%BD%AC%E9%AA%8C%E6%94%B6", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001&expectedVersion=0&evidenceType=SEAL&remark=LOCAL-TAIL-20261020+%E5%90%88%E6%88%90%E6%B5%8B%E8%AF%95%E5%9B%BE%E7%89%87%EF%BC%9B%E4%BB%85%E6%9C%AC%E5%9C%B0%E6%B5%81%E8%BD%AC%E9%AA%8C%E6%94%B6&sealNo=LOCALTEST20261020", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "S0500", + "message": "Internal server error" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "S0500", + "message": "Internal server error" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "S0500", + "message": "Internal server error" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/evidence?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914/complete", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/container-shipping-nodes/2092814929307344898", + "http": 200, + "code": "S0500", + "message": "Internal server error" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/container-shipping-nodes/2092814929307344898", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/containers/2092814929307344898/clearance", + "http": 200, + "code": "A0403", + "message": "Access denied" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "S0503", + "message": "统一订单状态查询响应无效" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "S0503", + "message": "统一订单状态查询响应无效" + }, + { + "method": "GET", + "path": "/api/platform/v1/containers/2092814929307344898/clearance", + "http": 200, + "code": "A0404", + "message": "国外工作台货柜不存在" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/outbound-dispatches?containerBookingId=2092814929307344898", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/outbound-dispatches/2092814929307344898/gate", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "S0503", + "message": "统一订单状态查询响应无效" + }, + { + "method": "GET", + "path": "/api/platform/v1/first-leg/pda-loading/tasks/2102273311474982914?warehouseId=193000000000200001", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/containers/2092814929307344898/clearance", + "http": 200, + "code": "200" + }, + { + "method": "POST", + "path": "/api/platform/v1/containers/2092814929307344898/clearance/document/upload?expectedVersion=0&operationReason=%E6%9C%AC%E5%9C%B0%E6%B5%81%E8%BD%AC%E6%B5%8B%E8%AF%95%E8%B5%84%E6%96%99", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders/status-counts", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/containers/2092814929307344898/clearance", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders?page=1&size=200&mainStatus=SAILING_CLEARANCE", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders?page=1&size=200&mainStatus=WAIT_UNLOAD", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders?page=1&size=200&mainStatus=WAIT_DISPATCH", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/containers/2092814929307344898/clearance", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders?page=1&size=200&mainStatus=SAILING_CLEARANCE", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders?page=1&size=200&mainStatus=WAIT_UNLOAD", + "http": 200, + "code": "200" + }, + { + "method": "GET", + "path": "/api/platform/v1/orders?page=1&size=200&mainStatus=WAIT_DISPATCH", + "http": 200, + "code": "200" + } + ], + "taskId": "2102273311474982914", + "containerId": "2092814929307344898", + "inboundId": "2092814880129130499", + "orderId": "2092814878690398210", + "warehouseId": "193000000000200001", + "lastError": "GET /api/platform/v1/orders/status-counts: S0503 统一订单状态查询响应无效", + "pdaUsername": "local_tail_pda_20261020", + "pdaUserId": "2112372466490671105", + "before": { + "counts": { + "PENDING_REVIEW": 127, + "WAIT_MEASURE": 195, + "WAIT_PALLETIZE": 6, + "WAIT_SPLIT": 126, + "LOADING": 9, + "LOADED": 73, + "DELIVERED": 0, + "COMPLETED": 2, + "WAIT_WEIGHT_CONFIRM": 125, + "WEIGHT_CONFIRMED": 1, + "WAIT_CUSTOMS_DOCS": 0, + "EXPORT_CUSTOMS": 12, + "SAILING_CLEARANCE": 0, + "WAIT_UNLOAD": 0, + "WAIT_DISPATCH": 0, + "OUTBOUND_DOCUMENT": 0, + "DELIVERING": 0, + "SIGNED": 0, + "MEASUREMENT_REVIEW": 120 + } + }, + "evidence": [ + "PHOTO_STEP_1", + "PHOTO_STEP_2", + "PHOTO_STEP_3", + "PHOTO_STEP_4", + "SEAL" + ], + "completion": { + "id": "2102273311474982914", + "loadingPlanId": "2092814929479311362", + "containerBookingId": "2092814929307344898", + "inboundId": "2092814880129130499", + "inboundVersion": 6, + "loadingVersion": 6, + "cabinetNo": "CAB-260827-0019", + "currentContainerNo": "CSQU3054383", + "sourceType": "SIMULATION", + "recordStatus": "ACTIVE", + "mergedIntoContainerBookingId": null, + "taskStatus": "COMPLETING", + "workflowStatus": "PROCESSING", + "version": 14, + "assignment": { + "assigneeId": "2112372466490671105", + "assigneeName": "本地尾程流转验证PDA", + "assignedBy": "1000001", + "assignedByName": "platform_admin", + "assignmentType": "ASSIGNED", + "version": 1 + }, + "review": null, + "completionCommand": { + "id": "2112373676283224065", + "operationId": "LOCAL-TAIL-20261020-complete", + "status": "RETRYABLE", + "expectedTaskVersion": 1, + "remark": "LOCAL-TAIL-20261020 本地测试装柜交接验证", + "attemptCount": 8, + "nextRetryAt": "2026-10-20T10:53:11.922", + "failureStage": "WAREHOUSE_SETTLEMENT", + "failureCode": "S0503", + "requestId": null, + "traceId": null, + "completedAt": null + }, + "items": [ + { + "id": "2102273311542091778", + "loadingPlanItemId": "2092814929680637954", + "tenantId": "2092814835845664769", + "inboundId": "2092814880129130499", + "inboundDetailId": "2092814878690398211", + "unitType": "PACKAGE", + "palletId": null, + "palletNo": null, + "indivisible": false, + "orderNo": "FL2092814878690398210", + "inboundCode": "NYEXX2608270051", + "cargoName": "SMALL 测试产品", + "boxCodes": [ + "NYEXX2608270051#4-1" + ], + "inventoryResources": [ + { + "blockId": "2092814880129130499", + "slotNo": 1, + "reservationId": "2092814900999888899", + "weightResultId": "2092814900605722626", + "palletSequence": 1, + "palletId": "2092814900211359746", + "locationId": "193000000000200202", + "locationCode": "A0", + "sharedOverflow": true, + "slotStatus": "BOUND", + "availablePackageCount": 4 + } + ], + "plannedPackageCount": 1, + "pickedPackageCount": 1, + "itemStatus": "PICKED", + "version": 1 + } + ] + }, + "task": { + "id": "2102273311474982914", + "loadingPlanId": "2092814929479311362", + "containerBookingId": "2092814929307344898", + "inboundId": "2092814880129130499", + "inboundVersion": 9, + "loadingVersion": 9, + "cabinetNo": "CAB-260827-0019", + "currentContainerNo": "CSQU3054383", + "sourceType": "SIMULATION", + "recordStatus": "ACTIVE", + "mergedIntoContainerBookingId": null, + "taskStatus": "COMPLETED", + "workflowStatus": "COMPLETED", + "version": 15, + "assignment": { + "assigneeId": "2112372466490671105", + "assigneeName": "本地尾程流转验证PDA", + "assignedBy": "1000001", + "assignedByName": "platform_admin", + "assignmentType": "ASSIGNED", + "version": 1 + }, + "review": { + "status": "APPROVED", + "reviewedBy": "2112372466490671105", + "reviewedByName": "local_tail_pda_20261020", + "remark": "LOCAL-TAIL-20261020 本地测试装柜交接验证", + "version": 0 + }, + "completionCommand": { + "id": "2112373676283224065", + "operationId": "LOCAL-TAIL-20261020-complete", + "status": "COMPLETED", + "expectedTaskVersion": 1, + "remark": "LOCAL-TAIL-20261020 本地测试装柜交接验证", + "attemptCount": 9, + "nextRetryAt": "2026-10-20T10:53:11.922", + "failureStage": null, + "failureCode": null, + "requestId": null, + "traceId": null, + "completedAt": "2026-10-20T10:53:13.826" + }, + "items": [ + { + "id": "2102273311542091778", + "loadingPlanItemId": "2092814929680637954", + "tenantId": "2092814835845664769", + "inboundId": "2092814880129130499", + "inboundDetailId": "2092814878690398211", + "unitType": "PACKAGE", + "palletId": null, + "palletNo": null, + "indivisible": false, + "orderNo": "FL2092814878690398210", + "inboundCode": "NYEXX2608270051", + "cargoName": "SMALL 测试产品", + "boxCodes": [ + "NYEXX2608270051#4-1" + ], + "inventoryResources": [], + "plannedPackageCount": 1, + "pickedPackageCount": 1, + "itemStatus": "PICKED", + "version": 1 + } + ] + }, + "counts": { + "counts": { + "PENDING_REVIEW": 127, + "WAIT_MEASURE": 195, + "WAIT_PALLETIZE": 6, + "WAIT_SPLIT": 127, + "LOADING": 8, + "LOADED": 74, + "DELIVERED": 0, + "COMPLETED": 2, + "WAIT_WEIGHT_CONFIRM": 124, + "WEIGHT_CONFIRMED": 1, + "WAIT_CUSTOMS_DOCS": 0, + "EXPORT_CUSTOMS": 55, + "SAILING_CLEARANCE": 1, + "WAIT_UNLOAD": 0, + "WAIT_DISPATCH": 1, + "OUTBOUND_DOCUMENT": 0, + "DELIVERING": 0, + "SIGNED": 0, + "MEASUREMENT_REVIEW": 120 + } + }, + "nodeTime": "2026-10-20T10:54:20", + "departure": { + "containerBookingId": "2092814929307344898", + "currentNode": "DEPARTED_ORIGIN_PORT", + "currentNodeAt": "2026-10-20T10:54:20", + "nodes": [ + { + "id": "2112377029255979010", + "providerCode": "MANUAL", + "providerEventId": "39f3f09e55b61fc0e0beba2cc50bf167b060cc1b11862d2af2f25713d1d239ce", + "providerNodeCode": "DEPARTED_ORIGIN_PORT", + "standardNode": "DEPARTED_ORIGIN_PORT", + "nodeSequence": 40, + "observedAt": "2026-10-20T10:54:20", + "estimatedAt": null, + "actualAt": "2026-10-20T10:54:20", + "locationCode": null, + "locationName": "本地手动开船流转验收", + "sourceReference": "MANUAL:1000001", + "rawPayloadHash": "6435970b1711830d6f5ee86cb844346f655fba2b7056c009bce285b8505df4c5", + "createdAt": "2026-10-20T10:55:04.513" + } + ] + }, + "afterDeparture": { + "counts": { + "PENDING_REVIEW": 127, + "WAIT_MEASURE": 195, + "WAIT_PALLETIZE": 6, + "WAIT_SPLIT": 127, + "LOADING": 8, + "LOADED": 74, + "DELIVERED": 0, + "COMPLETED": 2, + "WAIT_WEIGHT_CONFIRM": 124, + "WEIGHT_CONFIRMED": 1, + "WAIT_CUSTOMS_DOCS": 0, + "EXPORT_CUSTOMS": 12, + "SAILING_CLEARANCE": 0, + "WAIT_UNLOAD": 0, + "WAIT_DISPATCH": 0, + "OUTBOUND_DOCUMENT": 0, + "DELIVERING": 0, + "SIGNED": 0, + "MEASUREMENT_REVIEW": 120 + } + }, + "clearanceBefore": null, + "clearance": { + "id": "2112381005493927938", + "documentType": "DESTINATION_CLEARANCE", + "fileId": "2112381005254864897", + "fileName": "LOCAL-TAIL-20261020-clearance.txt", + "fileHash": "95e01269c0ae1de50bb60315d5b7a0ec5c82dba5d2bd6911566ede581e8e69e2", + "uploadedBy": "1000001", + "uploadedByName": "platform_admin", + "uploadedAt": "2026-10-20T11:10:52.514", + "completedBy": null, + "completedByName": null, + "completedAt": null, + "version": 0 + }, + "afterClearance": { + "counts": { + "PENDING_REVIEW": 127, + "WAIT_MEASURE": 195, + "WAIT_PALLETIZE": 6, + "WAIT_SPLIT": 127, + "LOADING": 8, + "LOADED": 74, + "DELIVERED": 0, + "COMPLETED": 2, + "WAIT_WEIGHT_CONFIRM": 124, + "WEIGHT_CONFIRMED": 1, + "WAIT_CUSTOMS_DOCS": 0, + "EXPORT_CUSTOMS": 55, + "SAILING_CLEARANCE": 0, + "WAIT_UNLOAD": 1, + "WAIT_DISPATCH": 1, + "OUTBOUND_DOCUMENT": 0, + "DELIVERING": 0, + "SIGNED": 0, + "MEASUREMENT_REVIEW": 120 + } + }, + "verification": { + "at": "2026-10-20T03:20:03.315Z", + "document": { + "id": "2112381005493927938", + "documentType": "DESTINATION_CLEARANCE", + "fileId": "2112381005254864897", + "fileName": "LOCAL-TAIL-20261020-clearance.txt", + "fileHash": "95e01269c0ae1de50bb60315d5b7a0ec5c82dba5d2bd6911566ede581e8e69e2", + "uploadedBy": "1000001", + "uploadedByName": "platform_admin", + "uploadedAt": "2026-10-20T11:10:52.514", + "completedBy": null, + "completedByName": null, + "completedAt": null, + "version": 0 + }, + "rows": { + "SAILING_CLEARANCE": { + "total": "0", + "matching": [] + }, + "WAIT_UNLOAD": { + "total": "1", + "matching": [ + { + "orderId": "2092814878690398210", + "orderVersion": 5, + "tenantId": "2092814835845664769", + "orderNo": "FL2092814878690398210", + "mainStatus": "LOADED", + "nextAction": null, + "customerFeeConfirmationRequired": true, + "relation": { + "role": "PARENT", + "rootOrderId": "2092814878690398210", + "parentOrderId": null, + "splitSequence": null, + "childCount": 1 + }, + "internalOrderStatus": "APPROVED", + "customerId": "2092814835845664769", + "customerName": "全量验收下单客户-7032153799", + "channelCode": "EXX_RAIL_NY", + "channelName": "纽约EXX海铁", + "channelChanged": false, + "receivingWarehouseId": "193000000000200001", + "receivingWarehouseName": "义乌仓", + "destinationWarehouseId": "193000000000000244", + "destinationWarehouseName": "XLX1", + "inboundId": "2092814880129130499", + "inboundVersion": 9, + "inboundCode": "NYEXX2608270051", + "expectedShipDate": null, + "holdFlag": false, + "currentHoldReason": null, + "totalPackageCount": 4, + "recommendedPalletType": null, + "locationCodes": "A0", + "estimatedVolumeCbm": null, + "expectedVolumeCbm": 0.278, + "actualGrossWeightKg": 100, + "actualVolumeCbm": 0.187, + "expectedArrivalAt": "2026-09-26T00:00:00", + "feeReviewStatus": "NOT_SUBMITTED", + "companyName": null, + "businessType": "FBA_SEA", + "fulfillmentType": "STANDARD", + "lxCode": null, + "pickupMethod": "PICKUP", + "taxMode": "BUY_DECLARATION", + "containerMode": "LCL", + "customsInspection": false, + "destinationType": "FBA", + "destinationCode": "XLX1", + "totalPalletCount": 1, + "chargeableWeightKg": null, + "chargeableVolumeCbm": null, + "volumetricWeightKg": 31.229, + "totalNetWeightKg": 92, + "measurementCompletedAt": "2026-08-27T11:22:09.659", + "inboundCompletedAt": "2026-08-27T11:22:09.815", + "expressCompany": null, + "expressTrackingNo": null, + "createdBy": null, + "skuNos": [ + "SKU-SMALL-ADMIN-API-20260827032153799-LOAD", + "SKU-MEDIUM-ADMIN-API-20260827032153799-LOAD", + "SKU-LARGE-ADMIN-API-20260827032153799-LOAD", + "SKU-EXTRA_LARGE-ADMIN-API-20260827032153799-LOAD" + ], + "totalQuantity": 46, + "shipmentId": "SHIP-ADMIN-API-20260827032153799-LOAD", + "referenceId": "REF-ADMIN-API-20260827032153799-LOAD", + "measurement": { + "reviewStatus": "APPROVED", + "actualGrossWeightKg": 100, + "actualVolumeCbm": 0.187, + "chargeableWeightKg": null, + "completedAt": "2026-08-27T11:22:09.659" + }, + "preload": { + "status": "CONFIRMED", + "version": 2, + "itemCount": 0, + "updatedAt": "2026-09-22T13:46:30.454" + }, + "loading": { + "status": "COMPLETED", + "loadingVersion": 2, + "pickingVersion": 2, + "expectedCount": 1, + "pickedCount": 1, + "evidenceCount": 5, + "sealNo": "LOCALTEST20261020", + "updatedAt": "2026-10-20T10:53:13.825" + }, + "delivery": { + "status": "PENDING", + "completedAt": null, + "trackingNo": null + }, + "transport": { + "containerNo": "CSQU3054383", + "cabinetNo": "CAB-260827-0019", + "vesselName": "P3VESSEL-32153799-AA35DF75", + "voyageNo": "P3V-32153799-AA35DF75", + "estimatedSailingAt": "2026-09-01T08:00:00", + "actualSailingAt": null, + "effectiveSailingAt": "2026-09-01T08:00:00", + "effectiveSailingAtEstimated": true, + "estimatedPortArrivalAt": "2026-09-20T08:00:00", + "actualPortArrivalAt": null, + "effectivePortArrivalAt": "2026-09-20T08:00:00", + "effectivePortArrivalAtEstimated": true + }, + "coLoadGroup": null, + "customerRemark": "ADMIN-API-20260827032153799-LOAD Client 创建隔离订单", + "latestInternalNote": null, + "warehouseRemark": null, + "customerServiceRemark": null, + "createdAt": "2026-08-27T11:22:04.702", + "updatedAt": "2026-10-20T10:53:13.725", + "requestedPickupAt": "2026-09-03T03:22:04" + } + ] + }, + "WAIT_DISPATCH": { + "total": "1", + "matching": [ + { + "orderId": "2092814878690398210", + "orderVersion": 5, + "tenantId": "2092814835845664769", + "orderNo": "FL2092814878690398210", + "mainStatus": "LOADED", + "nextAction": null, + "customerFeeConfirmationRequired": true, + "relation": { + "role": "PARENT", + "rootOrderId": "2092814878690398210", + "parentOrderId": null, + "splitSequence": null, + "childCount": 1 + }, + "internalOrderStatus": "APPROVED", + "customerId": "2092814835845664769", + "customerName": "全量验收下单客户-7032153799", + "channelCode": "EXX_RAIL_NY", + "channelName": "纽约EXX海铁", + "channelChanged": false, + "receivingWarehouseId": "193000000000200001", + "receivingWarehouseName": "义乌仓", + "destinationWarehouseId": "193000000000000244", + "destinationWarehouseName": "XLX1", + "inboundId": "2092814880129130499", + "inboundVersion": 9, + "inboundCode": "NYEXX2608270051", + "expectedShipDate": null, + "holdFlag": false, + "currentHoldReason": null, + "totalPackageCount": 4, + "recommendedPalletType": null, + "locationCodes": "A0", + "estimatedVolumeCbm": null, + "expectedVolumeCbm": 0.278, + "actualGrossWeightKg": 100, + "actualVolumeCbm": 0.187, + "expectedArrivalAt": "2026-09-26T00:00:00", + "feeReviewStatus": "NOT_SUBMITTED", + "companyName": null, + "businessType": "FBA_SEA", + "fulfillmentType": "STANDARD", + "lxCode": null, + "pickupMethod": "PICKUP", + "taxMode": "BUY_DECLARATION", + "containerMode": "LCL", + "customsInspection": false, + "destinationType": "FBA", + "destinationCode": "XLX1", + "totalPalletCount": 1, + "chargeableWeightKg": null, + "chargeableVolumeCbm": null, + "volumetricWeightKg": 31.229, + "totalNetWeightKg": 92, + "measurementCompletedAt": "2026-08-27T11:22:09.659", + "inboundCompletedAt": "2026-08-27T11:22:09.815", + "expressCompany": null, + "expressTrackingNo": null, + "createdBy": null, + "skuNos": [ + "SKU-SMALL-ADMIN-API-20260827032153799-LOAD", + "SKU-MEDIUM-ADMIN-API-20260827032153799-LOAD", + "SKU-LARGE-ADMIN-API-20260827032153799-LOAD", + "SKU-EXTRA_LARGE-ADMIN-API-20260827032153799-LOAD" + ], + "totalQuantity": 46, + "shipmentId": "SHIP-ADMIN-API-20260827032153799-LOAD", + "referenceId": "REF-ADMIN-API-20260827032153799-LOAD", + "measurement": { + "reviewStatus": "APPROVED", + "actualGrossWeightKg": 100, + "actualVolumeCbm": 0.187, + "chargeableWeightKg": null, + "completedAt": "2026-08-27T11:22:09.659" + }, + "preload": { + "status": "CONFIRMED", + "version": 2, + "itemCount": 0, + "updatedAt": "2026-09-22T13:46:30.454" + }, + "loading": { + "status": "COMPLETED", + "loadingVersion": 2, + "pickingVersion": 2, + "expectedCount": 1, + "pickedCount": 1, + "evidenceCount": 5, + "sealNo": "LOCALTEST20261020", + "updatedAt": "2026-10-20T10:53:13.825" + }, + "delivery": { + "status": "PENDING", + "completedAt": null, + "trackingNo": null + }, + "transport": { + "containerNo": "CSQU3054383", + "cabinetNo": "CAB-260827-0019", + "vesselName": "P3VESSEL-32153799-AA35DF75", + "voyageNo": "P3V-32153799-AA35DF75", + "estimatedSailingAt": "2026-09-01T08:00:00", + "actualSailingAt": null, + "effectiveSailingAt": "2026-09-01T08:00:00", + "effectiveSailingAtEstimated": true, + "estimatedPortArrivalAt": "2026-09-20T08:00:00", + "actualPortArrivalAt": null, + "effectivePortArrivalAt": "2026-09-20T08:00:00", + "effectivePortArrivalAtEstimated": true + }, + "coLoadGroup": null, + "customerRemark": "ADMIN-API-20260827032153799-LOAD Client 创建隔离订单", + "latestInternalNote": null, + "warehouseRemark": null, + "customerServiceRemark": null, + "createdAt": "2026-08-27T11:22:04.702", + "updatedAt": "2026-10-20T10:53:13.725", + "requestedPickupAt": "2026-09-03T03:22:04" + } + ] + } + }, + "download": { + "http": 200, + "containsLocalTestMarker": true + } + } +} \ No newline at end of file diff --git a/output/local-schema-repair/tail-flow-warehouse-before.tsv b/output/local-schema-repair/tail-flow-warehouse-before.tsv new file mode 100644 index 000000000..dda43ca7a --- /dev/null +++ b/output/local-schema-repair/tail-flow-warehouse-before.tsv @@ -0,0 +1,2 @@ +id warehouse_code internal_code version +193000000000000244 XLX1 NULL 0 diff --git a/output/local-schema-repair/tail-flow.mjs b/output/local-schema-repair/tail-flow.mjs new file mode 100644 index 000000000..6ac18214e --- /dev/null +++ b/output/local-schema-repair/tail-flow.mjs @@ -0,0 +1,84 @@ +import fs from 'node:fs'; +const base=process.env.ADMIN_TEST_GATEWAY; +if(!base||!['127.0.0.1','localhost'].includes(new URL(base).hostname))throw Error('仅允许本机'); +const marker='LOCAL-TAIL-20261020'; +const file=new URL('tail-flow-state.json',import.meta.url); +const state=fs.existsSync(file)?JSON.parse(fs.readFileSync(file,'utf8')):{marker,environment:'LOCAL',photoEvidence:'SYNTHETIC_TEST_PNG',checks:[],taskId:'2102273311474982914',containerId:'2092814929307344898',inboundId:'2092814880129130499',orderId:'2092814878690398210',warehouseId:'193000000000200001'}; +const save=()=>fs.writeFileSync(file,JSON.stringify(state,null,2)); +async function api(token,method,path,body,key){ + const form=body instanceof FormData; + const r=await fetch(base+path,{method,headers:{...(token?{Authorization:'Bearer '+token}:{}),...(!form&&body!==undefined?{'Content-Type':'application/json'}:{}),...(key?{'Idempotency-Key':key}:{})},body:body===undefined?undefined:form?body:JSON.stringify(body),signal:AbortSignal.timeout(60000)}); + const v=await r.json(); + if(!path.includes('/auth/')){state.checks.push({method,path,http:r.status,code:v.code,message:String(v.code)==='200'?undefined:v.message});save();console.log(method,path,v.code,String(v.code)==='200'?'':v.message);} + if(!r.ok||String(v.code)!=='200')throw Error(`${method} ${path}: ${v.code} ${v.message}`); + return v.data; +} +const admin=(await api('','POST','/api/auth/login',{loginId:process.env.ADMIN_TEST_USERNAME,password:process.env.ADMIN_TEST_PASSWORD})).accessToken; +const root='/api/platform/v1/first-leg/pda-loading/tasks/'+state.taskId; +const task=()=>api(admin,'GET',root+'?warehouseId='+state.warehouseId); +const action=process.argv[2]||'inspect'; +try { + if(action==='gate'){ + for(const suffix of ['?containerBookingId='+state.containerId,'/'+state.containerId+'/gate']){ + console.log(JSON.stringify(await api(admin,'GET','/api/platform/v1/first-leg/outbound-dispatches'+suffix))); + } + } + if(action==='verify'){ + const doc=await api(admin,'GET','/api/platform/v1/containers/'+state.containerId+'/clearance'); + const rows={}; + for(const status of ['SAILING_CLEARANCE','WAIT_UNLOAD','WAIT_DISPATCH']){ + const page=await api(admin,'GET','/api/platform/v1/orders?page=1&size=200&mainStatus='+status); + rows[status]={total:page.total,matching:page.records.filter(r=>r.inboundId===state.inboundId)}; + } + if(rows.SAILING_CLEARANCE.matching.length||rows.WAIT_UNLOAD.matching.length!==1||rows.WAIT_DISPATCH.matching.length!==1)throw Error('测试来源页签流转不符合预期'); + const r=await fetch(base+'/api/platform/v1/containers/'+state.containerId+'/clearance/document/'+doc.fileId+'/download',{headers:{Authorization:'Bearer '+admin},signal:AbortSignal.timeout(30000)}); + const downloaded=await r.text(); + if(!r.ok||!downloaded.includes(marker))throw Error('测试清关附件下载不一致'); + state.verification={at:new Date().toISOString(),document:doc,rows,download:{http:r.status,containsLocalTestMarker:true}};save(); + console.log(JSON.stringify({document:doc,rows:Object.fromEntries(Object.entries(rows).map(([s,p])=>[s,{total:p.total,matching:p.matching.map(r=>r.inboundId)}])),downloadHttp:r.status})); + } + if(action==='prepare'){ + if(!state.pdaUserId && state.checks.some(c=>c.method==='POST'&&c.path==='/api/platform/v1/iam/users'&&String(c.code)==='200')){ const a=await api('','POST','/api/auth/login',{loginId:state.pdaUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD}); state.pdaUserId=JSON.parse(Buffer.from(a.accessToken.split('.')[1],'base64url')).sub;save(); } + if(!state.pdaUserId){ + const roles=await api(admin,'GET','/api/platform/v1/iam/roles?page=1&size=200'); + const role=(roles.records||roles).find(r=>r.roleCode==='CN_FIRSTLEG_PDA_OPERATOR'); + if(!role)throw Error('缺少国内PDA角色'); + state.pdaUsername='local_tail_pda_20261020';save(); + await api(admin,'POST','/api/platform/v1/iam/users',{username:state.pdaUsername,displayName:'本地尾程流转验证PDA',phone:'18'+String(Date.now()).slice(-9),tenantId:'0',roleIds:[role.id],domesticWarehouseId:state.warehouseId,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,status:'ACTIVE'}); + const users=await api(admin,'GET','/api/platform/v1/iam/users?page=1&size=20&keyword='+state.pdaUsername); + const user=users.records.find(r=>r.username===state.pdaUsername); + if(!user)throw Error('PDA创建后未查到');state.pdaUserId=user.userId;save(); + } + const pda=(await api('','POST','/api/auth/login',{loginId:state.pdaUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken; + let current=await task(); + if(current.assignment?.assigneeId!==state.pdaUserId)current=await api(admin,'POST',root+'/assign',{assigneeId:state.pdaUserId,assigneeName:'本地尾程流转验证PDA',expectedAssignmentVersion:current.assignment?.version||0,operationId:marker+'-assign'}); + if(!state.before){state.before=await api(admin,'GET','/api/platform/v1/orders/status-counts');save();} + for(const item of current.items){if(item.itemStatus!=='PICKED')await api(pda,'POST',root+'/items/'+item.id+'/pick',{warehouseId:state.warehouseId,pickedPackageCount:item.plannedPackageCount,version:item.version,operationId:marker+'-pick-'+item.id});} + const evidence=await api(pda,'GET',root+'/evidence?warehouseId='+state.warehouseId); + for(const type of ['PHOTO_STEP_1','PHOTO_STEP_2','PHOTO_STEP_3','PHOTO_STEP_4','SEAL']){ + if(evidence.evidences?.some(e=>e.evidenceType===type&&e.status==='ACTIVE'))continue; + if(state.evidence?.includes(type))continue; + const q=new URLSearchParams({warehouseId:state.warehouseId,expectedVersion:'0',evidenceType:type,remark:marker+' 合成测试图片;仅本地流转验收'}); + if(type==='SEAL')q.set('sealNo','LOCALTEST20261020'); + const bytes=Uint8Array.from([137,80,78,71,13,10,26,10,0,0,0,13,73,72,68,82,0,0,0,1,0,0,0,1,8,6,0,0,0,31,21,196,137,0,0,0,13,73,68,65,84,8,215,99,248,207,192,240,31,0,5,0,1,255,137,153,61,29,0,0,0,0,73,69,78,68,174,66,96,130]); + const body=new FormData();body.append('file',new Blob([bytes],{type:'image/png'}),marker+'-'+type+'.png'); + await api(pda,'POST',root+'/evidence?'+q,body,marker+'-'+type);(state.evidence??=[]).push(type);save(); + } + current=await task(); + if(current.taskStatus==='LOADING'||current.taskStatus==='COMPLETION_FAILED')state.completion=await api(pda,'POST',root+'/complete',{expectedTaskVersion:state.completion?.completionCommand?.expectedTaskVersion??current.version,operationId:marker+'-complete',remark:marker+' 本地测试装柜交接验证'}); + save(); + } + if(action==='inspect'||action==='prepare') {state.task=await task();state.counts=await api(admin,'GET','/api/platform/v1/orders/status-counts');save();console.log(JSON.stringify({taskStatus:state.task.taskStatus,completion:state.task.completionCommand,counts:state.counts}));} + if(action==='depart'){ + if((await task()).taskStatus!=='COMPLETED')throw Error('必须先装柜完成'); + state.nodeTime??=new Date(Date.now()+8*3600000).toISOString().slice(0,19);save(); + state.departure=await api(admin,'POST','/api/platform/v1/container-shipping-nodes/'+state.containerId,{standardNode:'DEPARTED_ORIGIN_PORT',observedAt:state.nodeTime,actualAt:state.nodeTime,locationName:'本地手动开船流转验收',reason:marker+' 用户授权本地手动节点验证'},marker+'-depart'); + state.afterDeparture=await api(admin,'GET','/api/platform/v1/orders/status-counts');save();console.log(JSON.stringify(state.afterDeparture)); + } + if(action==='clearance'){ + state.clearanceBefore=await api(admin,'GET','/api/platform/v1/containers/'+state.containerId+'/clearance');save(); + const form=new FormData();form.append('file',new Blob([marker+' LOCAL TEST ONLY 清关资料上传流转测试\n'],{type:'text/plain'}),marker+'-clearance.txt'); + state.clearance=await api(admin,'POST','/api/platform/v1/containers/'+state.containerId+'/clearance/document/upload?expectedVersion='+(state.clearanceBefore?.version||0)+'&operationReason='+encodeURIComponent('本地流转测试资料'),form,marker+'-clearance'); + state.afterClearance=await api(admin,'GET','/api/platform/v1/orders/status-counts');save();console.log(JSON.stringify(state.afterClearance)); + } +}catch(e){state.lastError=e.message;save();console.error(e.message);process.exitCode=1;} diff --git a/output/local-schema-repair/verify-schema.mjs b/output/local-schema-repair/verify-schema.mjs new file mode 100644 index 000000000..c1c90f0bf --- /dev/null +++ b/output/local-schema-repair/verify-schema.mjs @@ -0,0 +1,26 @@ +import fs from 'node:fs'; +import {spawnSync} from 'node:child_process'; +const dir=new URL('./',import.meta.url), results=[]; +function sql(db,query){const r=spawnSync('D:/baiyue-runtime/mysql/server/bin/mysql.exe',['--host=127.0.0.1','--user=root','--database='+db,'-N','-B','--default-character-set=utf8mb4'],{input:query,encoding:'utf8',env:process.env,maxBuffer:20e6});if(r.status)throw new Error(r.stderr);return r.stdout.trim();} +function parts(s){let q='',d=0,start=0;const out=[];for(let i=0;ix.inboundId); +if(!target)throw new Error('测试客户没有可确认来源'); +const ticket=await api(admin,'POST','/api/platform/v1/iam/tenants/2085308606829346818/impersonation',{reason:'本地新增订单页签验收:独立确认测试客户计费重并核对队列流转'}); +merchant=(await api('','POST','/api/auth/impersonations/exchange',{code:ticket.code})).accessToken; +const weights=await api(merchant,'GET',`/api/merchant/v1/first-leg/orders/${target.orderId}/chargeable-weights`); +const weight=weights.find(x=>String(x.inboundId)===String(target.inboundId)&&!x.confirmed); +if(!weight)throw new Error('测试来源没有当前待确认计费重'); +const key=randomUUID(); +const result=await api(merchant,'POST',`/api/merchant/v1/first-leg/chargeable-weights/${weight.inboundId}/confirm`,{expectedFingerprint:weight.fingerprint},{'Idempotency-Key':key}); +if(!result.confirmed)throw new Error('未读回已确认事实'); +await api(merchant,'POST',`/api/merchant/v1/first-leg/chargeable-weights/${weight.inboundId}/confirm`,{expectedFingerprint:weight.fingerprint},{'Idempotency-Key':key}); +const confirmed=await api(admin,'GET',`/api/platform/v1/orders?tenantId=2085308606829346818&mainStatus=WEIGHT_CONFIRMED&inboundCode=${encodeURIComponent(target.inboundCode)}&page=1&size=5`); +const pending=await api(admin,'GET',`/api/platform/v1/orders?tenantId=2085308606829346818&mainStatus=WAIT_WEIGHT_CONFIRM&inboundCode=${encodeURIComponent(target.inboundCode)}&page=1&size=5`); +if(!confirmed.records.some(x=>String(x.orderId)===String(target.orderId))||pending.records.some(x=>String(x.orderId)===String(target.orderId)))throw new Error('确认后页签流转不一致'); +checks.push({label:'同一测试订单确认后进入已确认、离开待确认;同幂等命令重放成功',passed:true,orderId:target.orderId,inboundId:weight.inboundId}); +fs.writeFileSync(new URL('weight-flow.json',import.meta.url),JSON.stringify(checks,null,2));console.log(JSON.stringify(checks,null,2)); + diff --git a/output/local-schema-repair/新增页签验收记录.md b/output/local-schema-repair/新增页签验收记录.md new file mode 100644 index 000000000..a6ed15394 --- /dev/null +++ b/output/local-schema-repair/新增页签验收记录.md @@ -0,0 +1,127 @@ +# 新增订单页签本地验收 + +2026-10-19,目标仅为本机 Gateway/MySQL/Nacos。此前已执行的结构补齐见同目录《验收记录.md》。本轮没有通过直接写业务表生成任何物流事实,没有远程部署。 + +## 本轮修复 + +复现发现,运行中的 Order 仍为旧版代码:新增 mainStatus 参数返回“统一订单主状态不合法”,status-counts 未生效。当前代码已支持这些状态,因此重新编译并重启 Order、Firstleg、Lastleg,并为本地跨服务调用配置 localhost 地址。 + +编译通过。当前后台服务为 Order PID 54860(9204)、Firstleg PID 33804(9202)、Lastleg PID 48016(9303)。进程由本轮启动;以后使用 IDE 启动前先核对端口占用,避免双启动。 + +本轮没有发现需要额外补齐的页签 SQL 表或字段;此前新建的 fl_chargeable_weight_confirmation、ll_delivery_facts_changed 等结构已被实际查询使用。 + +## 真实 HTTP 读取 + +通过既有本地平台账号登录统一 Gateway,19 个状态列表、status-counts 均 HTTP 200 / code 200。19 个状态列表 total 均与汇总对应数量一致,包括所有零数据状态。原有页签继续可读取。 + +| 页签 | 本轮最后核对数量 | +|---|---:| +| 待审核 | 127 | +| 待测量数据 | 195 | +| 测量审核 | 120 | +| 待客户确认计费重 | 125 | +| 已确认数据 | 1 | +| 待上传资料 | 0 | +| 一件代发打托 | 6 | +| 待分柜 | 126 | +| 装柜中 | 9 | +| 装柜完成 | 73 | +| 出口报关 | 0 | +| 已开船/待清关 | 0 | +| 待拆柜 | 0 | +| 待排车 | 0 | +| 出库单 | 0 | +| 派送 | 0 | +| 签收 | 0 | +| 派送完成 | 0 | +| 已完成 | 2 | + +证据:tab-smoke.json、tab-count-consistency.json。数量是检查时刻的本地事实,不是固定产品配置。此次未完成全部浏览器页面的人工视觉验收。 + +## 真实本地业务流转 + +通过正式 IAM 代登录与 Auth 交换接口取得既有 ACTIVE 测试企业会话;仅确认一条该测试企业既有待确认计费重,没有改费用金额或物流主状态。 + +测试订单 2085341741574098946、入仓单 2085341749429522434: + +1. 通过客户 API 读取当前审核通过的计费重及 fingerprint。 +2. 使用当前 fingerprint 与独立幂等键提交确认。 +3. 读回 confirmed=true;同一命令重放仍成功。 +4. 平台按同入仓码查询:进入 WEIGHT_CONFIRMED,退出 WAIT_WEIGHT_CONFIRM。 +5. 全量数量从待确认 126 / 已确认 0,变为待确认 125 / 已确认 1。 + +证据:weight-flow.json。测试确认历史予以保留。其他客户未被修改。 + +## 定向回归 + +Order / Firstleg 共 96 项通过;尾程独立定向编译与两项测试通过,共 98 项。覆盖当前来源的确认、重复命令、旧版本拒绝、重测失效、分柜父子来源、资料客户归属、清关上传、报关、卸柜事实、建出库单、取消回显、实际出库、派送及客户隔离。 + +尾程为临时 POM 限定 workflow 测试编译及独立测试输出目录,未视作尾程完整测试套件通过。临时 POM 已删除,正常构建配置没有修改。证据:tab-regression.log、lastleg-tab-regression.log。 + +Mapper 扫描合同通过:10 个持久化模块、396 个 Mapper。 + +截至 21:08 运行日志未发现 ERROR、BadSqlGrammarException、SQLSyntaxErrorException 或预约同步失败。两业务服务健康检查均为 UP。 + +## 后半程现场数据边界 + +本地 fl_carrier_schedule_node_fact 当前为 0 行,fl_customs_container 当前无 DECLARING 记录,因此待排车等后半程页签没有符合前置事实的订单。清关资料已有记录,不能单凭资料存在假设已实际开船。历史尾程来源主要为旧 CN 等标识,不能直接改成当前 FIRSTLEG 或伪造离港记录来证明流转。 + +后半程已通过隔离库真实 Mapper 和领域定向回归;尚未使用新生成的一票业务单,在本机通过完整装柜交接、实际离港录入、卸柜、出库和签收命令完成整链路真实 HTTP 验收。因此不能将本报告称为全部真实业务流验收通过。 + +此前保留的历史重复来源唯一索引冲突、历史必填事实为空及其他页面权限问题仍见《验收记录.md》,本轮未扩大账号授权。 + +## 2026-10-19 页签数量接口修复补验 + +纠正此前验证缺口:旧的统计接口验收请求额外带了 page/size,未覆盖浏览器实际不带分页的调用。此次修复前,以不带参数的 `/api/platform/v1/orders/status-counts` 成功复现 A0422(page 的 null 无法绑定 long),见 tab-smoke-before.log。旧验收结论不能证明该请求正常。 + +修复:统计入口使用独立的无分页筛选 DTO,转换为共用查询条件;保留筛选校验、平台权限及客户范围,忽略页签和分页,不改业务数量口径。前端已有“名称(数量)”显示,包括零数量,全部不显示数量,本轮无需改动布局。 + +验证:Order 定向 69 项、Admin 数量 API/页面定向 27 项全部通过;新增 MVC 用例覆盖不传分页、false 筛选、日期、客户范围、忽略额外分页/状态和非法税务类型。证据:status-count-binding-test.log、status-count-frontend-test.log。 + +本地仅重启 Order,PID 15788。实际经过 Gateway 的无分页统计请求成功(业务码200),19个状态列表均成功,逐项统计数量与各列表总数一致,见 tab-smoke.json、tab-count-consistency.json。当前待客户确认125、已确认数据1、测量数据审核120,无符合业务事实的状态返回0。Firstleg/Lastleg健康检查正常;Order本次启动日志未发现ERROR或SQL语法错误。 + +本轮未取得刷新后人工页面截图,未宣称完整视觉验收或后半程真实全链路验收通过。 + +## 2026-10-19 报关与开船链路口径补充 + +用户明确:待上传资料为报关资料;出口报关汇总本票资料已上传且装柜完成的订单。实际开船(目前人工节点,后续拉取节点)后进入已开船/待清关,头程上传清关资料后进入待拆柜,持续到实际卸柜完成。 + +已调整 WorkflowFactMapper 与 WorkflowTabService:出口报关不再仅匹配 DECLARING,也不要求整柜全部客户资料齐全或点击开始报关;按当前独立来源、本客户、本柜的有效报关资料与真实装柜证据汇总。买单票沿用既有无需客户退税资料的业务规则。实际开船后移出报关前序页签,按清关资料事实分类;预计日期不能替代实际离港。资料已上传但尚未实际开船时,不提前进入待拆柜。现有报关资料上传入口按完成装柜后的柜次关联,本轮未扩大到装柜前上传。原报关四态和实际报关命令保持原有规则。 + +手动及外部船节点均由 CarrierScheduleQueryService.addNode 保存到 fl_carrier_schedule_node_fact,统一按 DEPARTED_ORIGIN_PORT 且 actual_at 非空判断;不增加另一套人工开船标志。 + +自动回归:Order 69项、Firstleg 28项,共97项通过(customs-tab-flow-test.log)。真实 Mapper + H2 隔离库验证资料上传即进入、删除后回显、其他客户及其他柜资料不能覆盖、装柜未完成或无实拣数量不能进入、报关状态等待/就绪/申报/完成均不替代资料事实,以及买单资料豁免;领域测试覆盖开船、清关上传、实际卸柜退出及独立分柜来源。隔离库测试未写本机业务事实。 + +本机生效补验:仅重启 Firstleg(PID45432),启动成功,本次日志未见ERROR或SQL语法异常。通过Gateway重新读取无分页数量及19个状态列表,全部成功且数量逐项一致(customs-tab-live.log、customs-tab-consistency.json)。出口报关从0变为12,待上传报关资料0。 + +只读核对说明:当前满足独立实装关联的来源为BUY_DECLARATION 12条、历史tax_mode空值2条,无TAX_REFUND来源;因此12条是沿用买单免客户退税资料规则,并不表示伪造了12次资料上传。历史空税务模式没有自行补成买单。实际离港节点0条,后半程无数据符合进入条件。本轮没有为填充列表修改业务资料、装柜或船节点事实;真实本机完整开船到签收链路仍未验收。 + +## 2026-10-20 本地尾程节点与清关资料流转补验 + +当前显示口径:派送改为“派送中”,签收改为“签收完成”,签收完成仍显示括号数量;移除“派送完成”“已完成”页签,历史状态及接口保留。 + +修复实际阻塞: + +- 当前部分装柜模型为来源单承接实装、REMAINDER 子单承接余量。统一页签不再把这种来源单当作纯父汇总排除;真正由 LOADED 子票承接的旧父汇总仍不重复计票。 +- 尾程来源按客户、来源入仓单精确字符串匹配,避免 MySQL 连接默认排序规则冲突和大整数隐式比较;record 参数使用 MyBatis 可读取的 record 访问器。 +- 目的港清关资料读取、上传、下载及完成入口接受有效货柜已确认离仓或已有 DEPARTED_ORIGIN_PORT 实际节点;预计节点不放行。保留国内出仓、国外工作台和 DO 原有门禁,未创建虚假的国内出仓确认。 +- 本地补齐 Warehouse 的 wh_location_block / wh_location_block_slot;仅对本轮既有测试票按真实历史预占和占用恢复缺失块表示,保留原预占与占用。部分拣货由正式结算接口转移至余量票。 +- 本地手动节点表 query_id 改为允许空,唯一键使用 container_booking_id/event_key,按当前正式基线支持无拉取任务的手动节点。 +- 本地执行 IAM 正式 container-workspace-permissions.sql 补齐清关入口权限,并更新默认角色相关账号权限版本;旧登录建议重新登录。 + +测试来源:NYEXX2608270051(入仓单 2092814880129130499),柜 CSQU3054383;本轮正式 PDA 装柜完成后尾程新单 2112376565948878849 创建成功,装柜事件与手动开船事件均已处理。来源票实装1箱、独立余量票 NYEXX2608270051-1 为3箱,余量未被错误带入开船页签。 + +经本机 Gateway 的正式业务命令实测: + +1. 装柜完成并保存手动实际开船节点后,已开船/待清关=1、待拆柜=0、待排车=1。 +2. 上传 LOCAL-TAIL-20261020-clearance.txt 后,已开船/待清关=0、待拆柜=1、待排车=1;未点击“清关完成”,读回 completedAt 为空,证明按上传事实流转。 +3. 待拆柜和待排车列表均精确返回本票来源;待清关列表不再返回。清关附件通过正式下载接口校验内容,HTTP200。 +4. 无分页统计、19个状态列表、Firstleg/Lastleg 两个健康检查共23项均成功,各列表 total(字符串)与统计数值规范化后逐项一致,未用失败伪造零值。当前派送中和签收完成均为0,本轮没有真实发车或全签样本。 + +证据:tail-flow-state.json、tail-final-consistency.json、tab-smoke.json、tail-final-smoke.log。头程30项定向测试通过(tail-clearance-access-test.log);此前来源分柜/取消回显/派送/全签分类等12项通过(tail-flow-source-test.log)。Admin 405文件/3375测试及构建类型检查通过(tail-tabs-frontend-test.log、tail-tabs-frontend-build.log)。Gateway入口5项与Mapper扫描397个均通过。 + +边界:PDA照片是明确命名的合成测试图片,清关文件为本机测试文本;不能作为现场装柜或真实报关证据。本轮没有实际卸柜、出库发车和全签命令,后续环节仍以隔离库领域/Mapper测试覆盖;不能宣称真实物流全链路验收完成。浏览器临时页打开后处于登录页,未获得登录后截图,因此不宣称人工视觉验收通过。尾程常规测试编译仍被其他在途模块旧签名阻断,单独回归结果另记,不等于全量通过。 + +尾程补验:tail-workflow-isolated-test.log 最终 BUILD SUCCESS,完整生产源码编译及2项 workflow 隔离测试通过(含相邻长整型ID精确隔离、出库取消、实际派送/卸柜事实、内部令牌);读出键直接由JDBC按long映射,避免 H2 SIGNED 被解释为32位整数。临时模块/聚合POM已删除,正常构建配置未修改。 + +最终生效复核:Firstleg PID56348、Lastleg PID17412。尾程最终修复重启后,重新通过23项HTTP/健康检查且19项数量与列表total一致;清关文件下载及精确来源页签验证再次通过。两个本次启动日志未发现ERROR、BadSqlGrammarException或SQLSyntaxErrorException。头程没有新增代码变更,因此复用已通过的30项及此前12项回归,不重复占用其他任务的共享构建槽。 diff --git a/output/local-schema-repair/验收记录.md b/output/local-schema-repair/验收记录.md new file mode 100644 index 000000000..201a8968e --- /dev/null +++ b/output/local-schema-repair/验收记录.md @@ -0,0 +1,34 @@ +# 本地头程与尾程结构修复 + +时间:2026-10-19;目标:本机 MySQL 127.0.0.1:3306。 + +## 实际改动 + +| 数据库 | 新建表 | 已有表新增字段 | 已有表新增索引 | +|---|---:|---:|---:| +| baiyue_firstleg | 14 | 6 | 18 | +| baiyue_lastleg | 53 | 97 | 18 | + +包含报错表 `ll_delivery_facts_changed`,头程计费重确认、报关/清关和船期表,以及尾程排车、派送事实、采购和自营统计等缺失结构。 + +对照当前统一初始化 SQL 的建表定义复查:头程 131 张基线表、尾程 218 张基线表均已存在,基线字段无缺失。保留本地额外表;未执行整库初始化、删除、业务数据回填或远程部署。 + +执行前后的结构快照为 `schema-before.sql`、`schema-after.sql`。`actual-changes.json` 是实际结构差异;`actual-incremental-review.sql` 是实际增量审阅记录,已执行,不能在当前库重复执行。DDL 隐式提交,前快照仅用于审阅和恢复设计,不应直接覆盖现有库。 + +## 运行验证 + +重启时沿用原 Java 可执行文件、应用主类和类路径,并加载本地 Nacos、Redis、数据库与服务地址配置。原 IDE 启动的两服务进程已停止,新进程在后台运行:头程 PID 48892(9202)、尾程 PID 64060(9303)。后续需要 IDE 调试时,先停止对应后台进程,避免端口冲突。 + +两服务均成功输出 Started;健康检查均为 HTTP 200、status UP。从约 20:36 启动至 20:40 的观察窗口内,未发现 ERROR、BadSqlGrammarException、SQLSyntaxErrorException 或预约同步失败;尾程曾出现一次同步耗时告警,complete=true、failed=false。 + +通过本地统一 Gateway,既有本地验收账号登录成功。尾程订单、整车排车、排车草稿、头程入库单、指定有效客户的履约任务列表五个只读业务接口均返回 HTTP 200 / code 200。未创建、修改或删除业务单据。 + +这次验证为结构、服务运行和 HTTP 读取验证,未完成所有页面的人工视觉验收,也未重新执行全量业务流。 + +## 保留事项 + +1. 已有尾程历史单据的 16 个新必填业务字段无法从结构本身推导真实值,按可空字段补齐,历史行保留 NULL,未伪造来源快照、清单摘要或实收数量。后续推进这些历史单据前需核对真实资料;这部分并非完整 NOT NULL 约束对齐。 +2. `ll_inbound_plan.uk_ll_inbound_plan_source_order` 唯一索引因历史重复来源建索引失败(Duplicate entry)。原记录完整保留;其他缺失索引已建立。对应错误与复查见 `index-repair.json`、`schema-verification.json`。 +3. 当前本地验收账号读取预约工作台汇总、船期渠道、船期模板返回 A0403 / Access denied。这三项是权限阻断,未改动账号授权,不能记作页面验收通过。详细结果见 `http-smoke.json`。 + +服务日志:`firstleg-runtime.log`、`lastleg-runtime.log`;两个标准错误日志均为空。 From e829519044b84f8647be47b616c71c3c9cd7da95 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 11:36:30 +0800 Subject: [PATCH 033/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/PdaPalletOperationService.java | 3 ++- .../MerchantRelabelApplicationController.java | 4 ++-- .../RelabelApplicationController.java | 4 ++-- .../lastleg/relabel/dto/RelabelRequests.java | 9 +++++++++ .../service/PdaPalletOperationServiceTest.java | 17 +++++++++++++++++ .../WarehouseTransferCartonCountTest.java | 2 +- 6 files changed, 33 insertions(+), 6 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java index fb8030484..ff85c70c7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationService.java @@ -126,7 +126,8 @@ public class PdaPalletOperationService { long quantity = entry.getValue().stream().anyMatch(item -> "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) ? value(carton.getTotalQuantity()) : 1; result.add(new PalletOperationResponses.RelabelSource(carton.getId(), 0L, - pallet == null ? null : pallet.palletId(), palletCode, null, carton.getSourceBatchNo(), quantity, quantity, + // 未组托退货箱使用 UNBOUND 和零身份,不对 null 执行基本类型拆箱。 + pallet == null ? 0L : pallet.palletId(), palletCode, null, carton.getSourceBatchNo(), quantity, quantity, entry.getValue().stream().map(RelabelItem::getCartonMark).filter(Objects::nonNull).toList())); } return result; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java index 0ddf2936c..3a797c9b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/MerchantRelabelApplicationController.java @@ -31,8 +31,8 @@ public class MerchantRelabelApplicationController { @GetMapping("/quantity-summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") public ApiResponse quantities( - @AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) { - return ApiResponse.success(quantities.totals(customer(user), query)); + @AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.QuantityQuery query) { + return ApiResponse.success(quantities.totals(customer(user), query.asQuery())); } @GetMapping("/inventory-candidates") @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_RELABEL_LIST + "')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java index f0b77d159..ec3a5b2a8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/controller/RelabelApplicationController.java @@ -31,9 +31,9 @@ public class RelabelApplicationController { @GetMapping("/quantity-summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.RELABEL_ORDER_LIST + "')") public ApiResponse quantities( - @AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.Query query) { + @AuthenticationPrincipal AuthenticatedUser user, @Valid RelabelRequests.QuantityQuery query) { requirePlatform(user); - return ApiResponse.success(quantities.totals(null, query)); + return ApiResponse.success(quantities.totals(null, query.asQuery())); } @Resource private com.baiyue.lastleg.relabel.pda.service.PdaRelabelService pdaService; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java index 4595244e2..b895e2918 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/dto/RelabelRequests.java @@ -126,6 +126,15 @@ public final class RelabelRequests { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } + /** 数量汇总不需要分页参数,避免缺省原始数值绑定失败。 */ + public record QuantityQuery(String relabelNo, String customerOrderNo, String skuCode, String status, + String overseasWarehouseId, LocalDateTime createdFrom, LocalDateTime createdTo) { + public Query asQuery() { + return new Query(1, 200, relabelNo, customerOrderNo, skuCode, status, + overseasWarehouseId, createdFrom, createdTo); + } + } + public record ChangeStatus(@NotBlank @Size(max = 32) String status, @Size(max = 1000) String remark) {} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index cdc8e8b34..ada74b54a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -576,6 +576,23 @@ class PdaPalletOperationServiceTest { assertEquals(java.util.Set.of(), ReflectionTestUtils.invokeMethod(service, "boundRelabelMarks", app)); } + @Test void unboundReturnRelabelSourceDoesNotUnboxNullPalletIdentity() { + RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); + app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND"); + when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); + RelabelItem item = relabelItem(701L, "BOX-1", 100L); item.setReturnCartonId(801L); + item.setOperationType("RETURN_BULK_SKU_LABEL"); + when(relabelItems.selectList(any())).thenReturn(List.of(item)); + ReturnInventoryCarton carton = new ReturnInventoryCarton(); carton.setId(801L); + carton.setCustomerId("100"); carton.setOverseasWarehouseId("88"); carton.setStatus("RELABEL_PENDING"); + carton.setTotalQuantity(8L); + when(returnCartons.selectById(801L)).thenReturn(carton); + var result = service.relabelSources(identity(), new PalletOperationRequests.RelabelSources("RL-1")); + assertEquals(0L, result.get(0).palletId()); + assertEquals("UNBOUND", result.get(0).palletCode()); + assertEquals(8L, result.get(0).stockPackages()); + } + @Test void relabelSourcesExposeAuthoritativePalletId() { var tasks = mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class); ReflectionTestUtils.setField(service, "relabelWorkTasks", tasks); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index 3beaf15b2..e0de96786 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -175,7 +175,7 @@ class WarehouseTransferCartonCountTest { ia.setDeclaredQuantity(4L); assertTrue(service.hasSkuReceiptDifference(List.of(a),List.of(ia,ib))); } - /** 新箱必须先补客户预报,PDA不能绕过预报生成另一套箱号。 */ + /** 缺失申报保持未知,已知SKU差异及真实零申报仍按实际值校验。 */ @Test void missingDeclarationIsUnknownButKnownSkuDifferenceStillRequiresReason() { var unknown = new WarehouseTransferItem(); unknown.setId(10L); unknown.setSkuCode("A"); var known = new WarehouseTransferItem(); known.setId(11L); known.setSkuCode("B"); known.setDeclaredQuantity(7L); From 19745e46c1bbb5aeea451015cd206ad470da8f83 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 12:01:52 +0800 Subject: [PATCH 034/113] docs(agents): pointerize duplicated DB rules and add index width guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 18 个服务与公共模块重复复制的 314 字符数据库访问约束改为指向后端根唯一定义,并给索引门禁增加防退化校验。 - 18 处重复段落合并为一句指针,规则正文只在后端根 AGENTS.md 维护 - 门禁新增路由表行宽上限(根/Admin/Client/后端根/海外 PDA),防止业务规则重新回流表格 - 同步包含此前未提交的链接与锚点、表格存在性校验 - 仅索引文档与门禁脚本,未改动业务代码 --- audit-service/AGENTS.md | 3 +- auth/AGENTS.md | 3 +- common-audit/AGENTS.md | 3 +- common-core/AGENTS.md | 3 +- common-event-contract/AGENTS.md | 3 +- common-openapi/AGENTS.md | 3 +- common-security/AGENTS.md | 3 +- common-web/AGENTS.md | 3 +- file/AGENTS.md | 3 +- finance/AGENTS.md | 3 +- gateway/AGENTS.md | 3 +- iam/AGENTS.md | 3 +- integration-service/AGENTS.md | 3 +- order/AGENTS.md | 3 +- platform-bom/AGENTS.md | 3 +- print/AGENTS.md | 3 +- scripts/agents-index-contract.ps1 | 67 +++++++++++++++++++++++++++- warehouse-overseas-service/AGENTS.md | 3 +- warehouse/AGENTS.md | 3 +- 19 files changed, 84 insertions(+), 37 deletions(-) diff --git a/audit-service/AGENTS.md b/audit-service/AGENTS.md index d266c053d..e8e79e014 100644 --- a/audit-service/AGENTS.md +++ b/audit-service/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/auth/AGENTS.md b/auth/AGENTS.md index f6ff8ce7e..4f064521e 100644 --- a/auth/AGENTS.md +++ b/auth/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责与边界 diff --git a/common-audit/AGENTS.md b/common-audit/AGENTS.md index 6298f658b..8c89d0204 100644 --- a/common-audit/AGENTS.md +++ b/common-audit/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/common-core/AGENTS.md b/common-core/AGENTS.md index 6bb66fff6..6ea619008 100644 --- a/common-core/AGENTS.md +++ b/common-core/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/common-event-contract/AGENTS.md b/common-event-contract/AGENTS.md index 46745428d..bf616d0ee 100644 --- a/common-event-contract/AGENTS.md +++ b/common-event-contract/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/common-openapi/AGENTS.md b/common-openapi/AGENTS.md index fdffb1d7f..67d43e42b 100644 --- a/common-openapi/AGENTS.md +++ b/common-openapi/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/common-security/AGENTS.md b/common-security/AGENTS.md index e9bd2a2da..7b74bdf86 100644 --- a/common-security/AGENTS.md +++ b/common-security/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 16a878e89..b98b5dcc7 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/file/AGENTS.md b/file/AGENTS.md index cdfc798e0..cfbc64dcb 100644 --- a/file/AGENTS.md +++ b/file/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责与边界 diff --git a/finance/AGENTS.md b/finance/AGENTS.md index 8f055025b..b39c580e0 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 模块定位 diff --git a/gateway/AGENTS.md b/gateway/AGENTS.md index 116d80555..9297dbe04 100644 --- a/gateway/AGENTS.md +++ b/gateway/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责与边界 diff --git a/iam/AGENTS.md b/iam/AGENTS.md index d59414bd2..ca5634ca4 100644 --- a/iam/AGENTS.md +++ b/iam/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责与边界 diff --git a/integration-service/AGENTS.md b/integration-service/AGENTS.md index 34361a2c5..9d35717fb 100644 --- a/integration-service/AGENTS.md +++ b/integration-service/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责与边界 diff --git a/order/AGENTS.md b/order/AGENTS.md index 490fde935..3ae38db61 100644 --- a/order/AGENTS.md +++ b/order/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责与边界 diff --git a/platform-bom/AGENTS.md b/platform-bom/AGENTS.md index 4b20ebd94..cda5e0b43 100644 --- a/platform-bom/AGENTS.md +++ b/platform-bom/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/print/AGENTS.md b/print/AGENTS.md index 912e3852f..ddfcdfdfa 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责边界 diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 063cb6d41..dafc6ce68 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -74,11 +74,63 @@ function Assert-MarkdownTableUnique([string]$path, [string]$heading, [int]$keyCo if ($keyColumn -ge $cells.Count -or $cells[$keyColumn] -match '^:?-{3,}:?$') { continue } $keys.Add($cells[$keyColumn]) } + if (-not $inside) { + Add-Failure "$scope 缺少章节: $heading" + return + } + if ($keys.Count -lt 2) { + Add-Failure "$scope 缺少表头或入口行: $heading" + } $keys | Group-Object | Where-Object Count -gt 1 | ForEach-Object { Add-Failure "$scope 存在重复索引键: $($_.Name)($($_.Count) 次)" } } +# 只检查指定文档的本地 Markdown 链接与标题锚点,不递归读取目录或把代码片段当路径。 +function Assert-MarkdownLinks([string]$path, [string]$text, [string]$scope) { + foreach ($link in [regex]::Matches($text, '(?<[^>\r\n]+>|[^\s)]+)\)')) { + $target = $link.Groups['target'].Value.Trim([char[]]'<>') + if ($target -match '^[a-zA-Z][a-zA-Z0-9+.-]*:') { continue } + $parts = $target -split '#', 2 + $linkedPath = if ($parts[0]) { Join-Path (Split-Path -Parent $path) ([Uri]::UnescapeDataString($parts[0])) } else { $path } + $script:checked++ + if (-not (Test-Path -LiteralPath $linkedPath -PathType Leaf)) { + Add-Failure "$scope 链接目标不存在: $target" + continue + } + if ($parts.Count -lt 2 -or -not $parts[1] -or [IO.Path]::GetExtension($linkedPath) -ne '.md') { continue } + $script:checked++ + $linkedText = if ($linkedPath -eq $path) { $text } else { Get-Content -LiteralPath $linkedPath -Raw } + $headingIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::Ordinal) + foreach ($heading in [regex]::Matches($linkedText, '(?m)^#{1,6}[ \t]+(.+?)\r?$')) { + $title = $heading.Groups[1].Value -replace '[ \t]+#+[ \t]*$', '' + $slug = [regex]::Replace($title.Trim().ToLowerInvariant(), '[^\p{L}\p{M}\p{N}_\s-]', '') -replace '\s+', '-' + $headingId = $slug + $suffix = 0 + while ($headingIds.Contains($headingId)) { $suffix++; $headingId = "$slug-$suffix" } + [void]$headingIds.Add($headingId) + } + if (-not $headingIds.Contains([Uri]::UnescapeDataString($parts[1]))) { + Add-Failure "$scope 标题锚点不存在: $target" + } + } +} + +# 路由表只放定位,不放规则;单行过宽意味着业务规则又回流到表格。 +function Assert-TableRowWidth([string]$path, [string]$heading, [int]$maxLength, [string]$scope) { + $script:checked++ + $inside = $false + foreach ($line in Get-Content -LiteralPath $path) { + if ($line -eq $heading) { $inside = $true; continue } + if ($inside -and $line.StartsWith('## ')) { break } + if (-not $inside -or -not $line.StartsWith('|')) { continue } + if ($line -match '^\|[\s\-:|]+\|$') { continue } + if ($line.Length -gt $maxLength) { + Add-Failure "$scope 路由行过宽($($line.Length) > $maxLength):$($line.Substring(0, 40))…" + } + } +} + $rootAgentsPath = Join-Path $workspaceRoot 'AGENTS.md' $adminAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/AGENTS.md' $clientAgentsPath = Join-Path $workspaceRoot 'apps/client-web/AGENTS.md' @@ -90,6 +142,7 @@ $rootAgents = Read-RequiredText $rootAgentsPath $businessIndexPath = Join-Path $workspaceRoot 'docs/业务快速索引.md' $businessIndex = Read-RequiredText $businessIndexPath Assert-Contains $rootAgents 'docs/业务快速索引.md' '根按需业务索引入口' +Assert-MarkdownLinks $rootAgentsPath $rootAgents '根导航与文档链接' Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详细业务路由表' Assert-MarkdownTableUnique $businessIndexPath '## 常用说法与歧义分流' 0 '业务歧义分流表' Assert-Contains $businessIndex '## 业务快速路由表' '详细业务表存在' @@ -138,6 +191,13 @@ foreach ($applicationDetailIndex in @( Assert-Path "$($applicationDetailIndex.Base)/docs/../AGENTS.md" } Assert-MarkdownTableUnique $adminAgentsPath '## 模块快速路由' 0 'Admin 短入口表' +Assert-TableRowWidth $rootAgentsPath '## 业务快速路由表' 260 '根业务快速路由表' +Assert-TableRowWidth $rootAgentsPath '## 项目快速入口' 260 '根项目快速入口' +Assert-TableRowWidth $rootAgentsPath '## 专项约束按需入口' 200 '根专项约束表' +Assert-TableRowWidth $adminAgentsPath '## 模块快速路由' 200 'Admin 短入口表' +Assert-TableRowWidth $clientAgentsPath '## 模块快速查询' 160 'Client 短入口表' +Assert-TableRowWidth $backendAgentsPath '## 服务路由(按关键词定位)' 160 '后端根服务路由表' +Assert-TableRowWidth $overseasPdaAgentsPath '## 页面与能力快速索引' 200 '海外 PDA 入口表' Assert-MarkdownTableUnique $adminIndexPath '## 模块快速路由' 0 'Admin 详细索引表' Assert-MarkdownTableUnique $clientAgentsPath '## 模块快速查询' 0 'Client 短入口表' Assert-MarkdownTableUnique $clientIndexPath '## 模块快速查询' 0 'Client 详细索引表' @@ -615,7 +675,12 @@ Assert-Contains $rootAgents 'baiyue-firstleg/' '根 AGENTS.md' Assert-MarkdownTableUnique $rootAgentsPath '## 项目快速入口' 0 '根项目表' Assert-MarkdownTableUnique $rootAgentsPath '## 项目快速入口' 1 '根项目目录' Assert-MarkdownTableUnique $rootAgentsPath '## 业务快速路由表' 0 '根业务路由表' -Assert-MarkdownTableUnique $rootAgentsPath '## 原型入口索引' 0 '根原型表' +Assert-Contains $rootAgents 'docs/业务快速索引.md#原型入口索引' '根按需原型入口' +Assert-MarkdownTableUnique $businessIndexPath '## 原型入口索引' 0 '按需原型入口表' +$prototypeRoutes = (($businessIndex -split '(?m)^## 原型入口索引\r?$', 2)[1] -split '(?m)^## ', 2)[0] +$prototypeFiles = [regex]::Matches($prototypeRoutes, '`([^`]+\.(?:md|html|json|js|vue))`') | + ForEach-Object { $_.Groups[1].Value } | Sort-Object -Unique +foreach ($prototypeFile in $prototypeFiles) { Assert-Path $prototypeFile } # 前端当前全部业务模块必须能从应用级索引命中。 $applications = @( diff --git a/warehouse-overseas-service/AGENTS.md b/warehouse-overseas-service/AGENTS.md index a85755f74..a6f374b1f 100644 --- a/warehouse-overseas-service/AGENTS.md +++ b/warehouse-overseas-service/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责与边界 diff --git a/warehouse/AGENTS.md b/warehouse/AGENTS.md index 51f8a6b40..9163d4a68 100644 --- a/warehouse/AGENTS.md +++ b/warehouse/AGENTS.md @@ -2,8 +2,7 @@ ## 数据库访问与中文注释约束 -- 强制遵守根 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则:正常业务访问统一为 `Controller → Service → Repository → Mapper`,不得省略 Repository 或跨层直调;生产 SQL 由 Mapper 执行,Repository 不得直接使用 JdbcTemplate 或原生 JDBC。非 HTTP 入口从 Service 进入;无数据库模块不引入数据库访问。 -- 每个手写方法及构造器须有简短中文说明;核心业务规则、复杂 SQL、事务、锁、幂等、并发及失败处理须补充适量中文注释,重点说明原因和边界。新增、修改方法立即执行,触及的存量代码同步整改。 +- 强制继承 `../AGENTS.md` 的“数据库访问与中文代码注释”全部规则;该节是分层、SQL 执行和方法/核心逻辑中文注释的唯一维护处,本模块不另设例外。 ## 职责与边界 From a870b920fe358ec13e793596b96305ea4b813b16 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 14:22:31 +0800 Subject: [PATCH 035/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/20261016-数据库变更汇总.md | 2 +- .../V8__chargeable_weight_confirmation.sql | 2 +- ...ansfer_measurement_and_compact_numbers.sql | 4 +- .../V31__align_physical_pallet_guards.sql | 49 +++++ .../controller/LastlegOrderController.java | 9 + .../service/DeliveryTransferService.java | 63 +++++- .../discard/dto/CargoDiscardResponses.java | 8 +- .../mapper/CargoDiscardDimensionMapper.java | 11 ++ .../mapper/CargoDiscardInventoryMapper.java | 8 +- .../discard/model/CargoDiscardQuantities.java | 9 +- .../model/CargoDiscardStockCartonRow.java | 1 + .../discard/pda/dto/PdaCargoDiscardDtos.java | 3 +- .../pda/service/PdaCargoDiscardService.java | 79 ++++---- .../CargoDiscardDimensionRepository.java | 21 ++ .../repository/CargoDiscardPdaRepository.java | 46 +++++ .../CargoDiscardQuantityRepository.java | 5 +- .../CargoDiscardStockRepository.java | 47 +++++ .../service/CargoDiscardOutboundService.java | 15 +- .../discard/service/CargoDiscardService.java | 18 +- .../CargoDiscardStatisticsService.java | 10 +- .../service/CargoDiscardStockService.java | 184 ++++++++++++++---- .../dispatch/mapper/DispatchBolMapper.java | 5 - .../DispatchPickOrderAttributionService.java | 4 +- .../port/DispatchSourceAllocation.java | 4 + .../service/DispatchItemSourceService.java | 12 +- .../service/DispatchPickingService.java | 2 +- .../lastleg/dto/LastlegOrderSummary.java | 4 +- .../fleet/pickup/FleetPickupMapper.java | 14 +- .../fleet/pickup/FleetPickupRepository.java | 5 +- .../fleet/pickup/FleetPickupService.java | 23 +-- .../fleet/pickup/FleetPickupSource.java | 20 ++ .../controller/InventoryQueryController.java | 10 + .../query/dto/InventoryPieceProjection.java | 14 ++ .../query/mapper/InventoryPieceMapper.java | 73 +++++++ .../query/mapper/InventoryQueryMapper.java | 4 +- .../repository/InventoryPieceRepository.java | 15 ++ .../query/service/InventoryPieceService.java | 71 +++++++ ...formPalletOperationRecoveryController.java | 24 +++ .../mapper/PalletOperationRecoveryMapper.java | 25 +++ .../mapper/PdaPalletOperationMapper.java | 10 +- .../PalletOperationRecoveryRepository.java | 24 +++ .../PalletOperationRecoveryService.java | 58 ++++++ .../service/PdaPalletOperationService.java | 44 ++++- .../lastleg/query/LastlegOrderFilter.java | 52 +++++ .../RelabelApplicationController.java | 10 + .../pda/service/PdaRelabelService.java | 9 +- .../repository/RelabelQuantityRepository.java | 31 +++ .../service/RelabelPalletDimensions.java | 65 +++++++ .../service/RelabelQuantityService.java | 24 ++- .../MerchantReturnOrderController.java | 7 + ...atformReturnOrderStatisticsController.java | 23 +++ .../dto/ReturnOrderStatistics.java | 13 ++ .../mapper/ReturnOrderStatisticsMapper.java | 43 ++++ .../ReturnOrderStatisticsRepository.java | 27 +++ .../service/ReturnOrderStatisticsService.java | 21 ++ .../lastleg/service/LastlegOrderService.java | 53 ++--- .../MerchantWarehouseTransferController.java | 8 + .../transfer/dto/TransferQuantityTotals.java | 8 + .../transfer/dto/TransferQuantityView.java | 6 + .../dto/WarehouseTransferRequests.java | 5 +- .../mapper/TransferQuantityMapper.java | 78 ++++++++ .../TransferQuantityRepository.java | 28 +++ .../service/TransferQuantityService.java | 87 +++++++++ .../service/WarehouseTransferService.java | 61 ++++-- .../openapi/cargo-discard-photos-v1.yaml | 3 +- .../openapi/cargo-discard-statistics-v1.yaml | 94 +++++++++ .../openapi/relabel-quantities-v1.yaml | 40 +++- .../openapi/return-order-statistics-v1.yaml | 60 ++++++ .../warehouse-transfer-quantities-v1.yaml | 120 ++++++++++++ .../service/RelabelOutboundPickingTest.java | 118 +++++++++++ .../CargoDiscardTransferCartonMapperTest.java | 50 ++++- .../CargoDiscardQueryBindingTest.java | 43 ++++ .../service/PdaCargoDiscardServiceTest.java | 39 +++- .../CargoDiscardDimensionRepositoryTest.java | 43 ++++ .../CargoDiscardQuantityRepositoryTest.java | 27 +++ .../CargoDiscardStatisticsServiceTest.java | 26 ++- .../service/CargoDiscardStockServiceTest.java | 92 ++++++++- .../DispatchAggregatePersistenceTest.java | 2 +- .../DispatchItemSourcePersistenceTest.java | 8 +- .../fleet/pickup/FleetPickupMapperTest.java | 21 ++ .../fleet/pickup/FleetPickupServiceTest.java | 24 +-- .../query/InventoryPieceServiceTest.java | 49 +++++ .../PalletMutationOrderMapperTest.java | 54 +++++ .../PalletOperationRecoveryServiceTest.java | 31 +++ ...daPalletDispatchFreezePersistenceTest.java | 27 ++- .../PdaPalletOperationServiceTest.java | 5 + .../pda/service/PdaRelabelServiceTest.java | 3 + .../service/RelabelQuantityServiceTest.java | 49 +++++ .../ReturnInventoryMovementSqlTest.java | 4 +- .../service/LastlegOrderServiceTest.java | 4 +- .../transfer/TransferQuantityMapperTest.java | 63 ++++++ ...rchantWarehouseTransferControllerTest.java | 9 +- .../service/TransferQuantityServiceTest.java | 57 ++++++ .../WarehouseTransferCartonCountTest.java | 1 - .../service/WarehouseTransferServiceTest.java | 7 +- 95 files changed, 2616 insertions(+), 275 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V31__align_physical_pallet_guards.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardPdaRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSourceAllocation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupSource.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPieceProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryPieceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/repository/InventoryPieceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryPieceService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PlatformPalletOperationRecoveryController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PalletOperationRecoveryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/repository/PalletOperationRecoveryRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/query/LastlegOrderFilter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelPalletDimensions.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/controller/PlatformReturnOrderStatisticsController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/dto/ReturnOrderStatistics.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/mapper/ReturnOrderStatisticsMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/repository/ReturnOrderStatisticsRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnOrderStatisticsService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityTotals.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityView.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferQuantityMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferQuantityRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferQuantityService.java create mode 100644 lastleg-service/src/main/resources/openapi/cargo-discard-statistics-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/return-order-statistics-v1.yaml create mode 100644 lastleg-service/src/main/resources/openapi/warehouse-transfer-quantities-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardQueryBindingTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepositoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepositoryTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryPieceServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletMutationOrderMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/TransferQuantityMapperTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferQuantityServiceTest.java diff --git a/deploy/sql/20261016-数据库变更汇总.md b/deploy/sql/20261016-数据库变更汇总.md index eb2172ede..815838580 100644 --- a/deploy/sql/20261016-数据库变更汇总.md +++ b/deploy/sql/20261016-数据库变更汇总.md @@ -10,7 +10,7 @@ | warehouse-overseas-service | `baiyue_lastlag_warehouse` | 仅业务代码变化,无新增表或字段,无需 SQL;数据库名称保留正式的 lastlag 拼写 | | 其他服务 | 各服务原数据库 | 本次比较范围内无 SQL 结构新增,无需 SQL | -已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。文件显式指定 `USE baiyue_lastleg`,不访问其他服务数据库。 +已有尾程库使用:[V28 可重复执行 SQL](migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql)。由单 schema 执行器选定 `baiyue_lastleg`,文件内不切库,不访问其他服务数据库。2026-10-20 按尚未执行候选的边界移除内部切库语句,其余结构内容不变;本次未执行此候选。 ## 新表与字段 diff --git a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql index 47620cd03..481e45442 100644 --- a/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql +++ b/deploy/sql/migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql @@ -1,5 +1,5 @@ -- 仅为已初始化的头程库新增独立确认历史;不回填费用确认,不修改订单事实。 -USE baiyue_firstleg; +-- 由单 schema 执行器选择 baiyue_firstleg,不在迁移内部切库。 -- 客户独立确认计费重,不回填既有费用确认。 CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( id BIGINT NOT NULL COMMENT '确认记录ID', diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql index 54d74c3c1..19c90b39e 100644 --- a/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql @@ -2,7 +2,7 @@ -- 目标:lastleg-service / baiyue_lastleg;前置:已有业务表及此前迁移。 -- 只调整结构,不回填、删除或重算业务数据;执行失败应停止,修复后可重跑。 SET NAMES utf8mb4; -USE baiyue_lastleg; +-- 由单 schema 执行器选择 baiyue_lastleg,不在迁移内部切库。 CREATE TABLE IF NOT EXISTS ll_compact_number_clock ( number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', @@ -11,7 +11,6 @@ CREATE TABLE IF NOT EXISTS ll_compact_number_clock ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; -- 仓储转运逐箱测量与清点;仅增加字段,不修改业务数据。 -USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='length_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN length_cm DECIMAL(12,2) NULL COMMENT ''箱子实测长度厘米''')); PREPARE transfer_count_stmt FROM @ddl; EXECUTE transfer_count_stmt; DEALLOCATE PREPARE transfer_count_stmt; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='width_cm'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN width_cm DECIMAL(12,2) NULL COMMENT ''箱子实测宽度厘米''')); @@ -42,7 +41,6 @@ SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE tab PREPARE transfer_quantity_stmt FROM @ddl; EXECUTE transfer_quantity_stmt; DEALLOCATE PREPARE transfer_quantity_stmt; -- 仓储转运一箱多SKU清点;仅增加结构,不回填历史业务数据。 -USE baiyue_lastleg; SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_carton' AND column_name='sku_items_json'), 'SELECT 1', 'ALTER TABLE ll_warehouse_transfer_carton ADD COLUMN sku_items_json JSON NULL COMMENT ''实收箱内SKU及逐项件数明细''')); PREPARE transfer_sku_stmt FROM @ddl; EXECUTE transfer_sku_stmt; DEALLOCATE PREPARE transfer_sku_stmt; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V31__align_physical_pallet_guards.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V31__align_physical_pallet_guards.sql new file mode 100644 index 000000000..90bb0919b --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V31__align_physical_pallet_guards.sql @@ -0,0 +1,49 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 排车物理托盘串行点、物理占用及逻辑占用关联结构补齐。 +-- Preconditions: MySQL 8;已选中目标schema;ll_dispatch_pallet_lock已有业务基线存在。 +-- Postconditions: 两张实托表和physical_lock_id列/idx_dispatch_logical_physical索引存在。 +-- 只增加缺失结构,不删除/回填/重算历史数据。重复执行已存在对象时跳过。 +-- 与当前lastleg.sql最终定义一致;已有异构对象须人工核对,不能以跳过证明一致。 + +CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard ( + warehouse_pallet_id BIGINT NOT NULL COMMENT '真实全局物理托盘ID', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '首次建立串行点时间', + PRIMARY KEY (warehouse_pallet_id) +) ENGINE=InnoDB COMMENT='排车冻结与仓内实托组成修改的共同串行点'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_physical_pallet_lock ( + id BIGINT NOT NULL COMMENT '物理实托占用主键', + warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运仓ID', + warehouse_pallet_id BIGINT NOT NULL COMMENT '全局真实物理托盘ID', + active_warehouse_pallet_id BIGINT NULL COMMENT '活动物理排他键,释放后为空', + dispatch_order_id BIGINT NULL COMMENT '正式排车主单ID,与草稿归属互斥', + draft_token VARCHAR(64) NULL COMMENT '草稿拥有者令牌,与正式排车归属互斥', + draft_container_id BIGINT NULL COMMENT '草稿内唯一占用该实托的容器ID', + operator_id VARCHAR(128) NULL COMMENT '占用操作人ID', + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE' COMMENT '物理占用状态', + expires_at DATETIME(3) NULL COMMENT '草稿失效时间,正式占用为空', + locked_at DATETIME(3) NOT NULL COMMENT '锁定时间', + released_at DATETIME(3) NULL COMMENT '释放时间', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', + updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', + PRIMARY KEY (id), + UNIQUE KEY uk_dispatch_physical_active_pallet (active_warehouse_pallet_id), + KEY idx_dispatch_physical_order (dispatch_order_id,status), + KEY idx_dispatch_physical_draft (draft_token,draft_container_id,status), + CONSTRAINT chk_dispatch_physical_active_key CHECK((status='ACTIVE' AND active_warehouse_pallet_id IS NOT NULL AND active_warehouse_pallet_id=warehouse_pallet_id) OR (status<>'ACTIVE' AND active_warehouse_pallet_id IS NULL)), + CONSTRAINT chk_dispatch_physical_owner CHECK((dispatch_order_id IS NOT NULL AND draft_token IS NULL AND draft_container_id IS NULL) OR (dispatch_order_id IS NULL AND draft_token IS NOT NULL AND draft_container_id IS NOT NULL)) +) ENGINE=InnoDB COMMENT='同车共享且跨车排他的物理实托占用'; + +-- 已只读确认本地缺失:客户逻辑锁到物理锁的可空关联列及查询索引。 +-- 仅补结构,不根据历史单据推测或回填关联。 +SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND column_name='physical_lock_id'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD COLUMN physical_lock_id BIGINT NULL COMMENT ''实际物理实托占用ID,非物理来源为空'''); +PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl; +EXECUTE overseas_dimension_stmt; +DEALLOCATE PREPARE overseas_dimension_stmt; +SET @overseas_dimension_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_dispatch_pallet_lock' AND index_name='idx_dispatch_logical_physical'), 'SELECT 1', 'ALTER TABLE ll_dispatch_pallet_lock ADD KEY idx_dispatch_logical_physical (physical_lock_id,status)'); +PREPARE overseas_dimension_stmt FROM @overseas_dimension_ddl; +EXECUTE overseas_dimension_stmt; +DEALLOCATE PREPARE overseas_dimension_stmt; +SET @overseas_dimension_ddl = NULL; + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 5255b933e..592a75301 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -91,6 +91,15 @@ public class LastlegOrderController { return ApiResponse.success(orderService.page(platformScope(user, customerId), query, relabelNo)); } + /** 按主列表完整筛选读取仓储转运数量,保持同一平台客户边界和列表权限。 */ + @GetMapping("/api/platform/v1/last-leg/orders/warehouse-transfer-quantities") + @PreAuthorize("hasAuthority('" + OverseasPermissions.LAST_MILE_ORDER_LIST + "')") + public ApiResponse transferQuantities( + @AuthenticationPrincipal AuthenticatedUser user, @RequestParam(required=false) String customerId, + @RequestParam(required=false) String relabelNo, @Valid LastlegOrderRequests.Query query) { + return ApiResponse.success(orderService.transferQuantityTotals(platformScope(user,customerId),query,relabelNo)); + } + /** Compatibility overload used by non-Spring callers and existing contract tests. */ public ApiResponse> list( AuthenticatedUser user, String customerId, LastlegOrderRequests.Query query) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index 35762deed..fb6f930eb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -597,12 +597,19 @@ public class DeliveryTransferService { if (application == null || !RelabelApplicationService.READY_FOR_OUTBOUND.equals(application.getStatus())) { throw conflict("换标单尚未完成客户确认或已不可出库"); } + if (relabelSnapshotMarks(item, selectedPosition.getId(), application.getRelabelNo()).isEmpty()) + throw conflict("换标出库指定位置或箱唛快照不完整,请核对原申请"); List sourceItems = relabelItems.selectList(new LambdaQueryWrapper() .eq(RelabelItem::getApplicationId, application.getId()) .eq(RelabelItem::getCustomerId, customerId) .eq(RelabelItem::getSourceInventoryPositionId, request.inventoryPositionId())); long sourcePackages = sourceItems.stream().mapToLong(source -> value(source.getNotifiedPackageCount())).sum(); - if (sourcePackages != request.packages()) throw conflict("换标出库来源库存快照与任务箱数不一致"); + List sourceMarks = sourceItems.stream().map(RelabelItem::getCartonMark) + .filter(StringUtils::hasText).distinct().sorted().toList(); + Set sourceIds = sourceItems.stream().map(source -> String.valueOf(source.getId())).collect(Collectors.toSet()); + if (sourcePackages != request.packages() || !sourceMarks.equals(expectedMarks) + || !sourceIds.equals(relabelSnapshotItemIds(item))) + throw conflict("换标出库来源库存快照与任务箱数或箱唛不一致"); int releaseIndex = 0; for (RelabelItem source : sourceItems) { inventoryCommands.release(customerId, operatorId, new InventoryCommandRequests.Move( @@ -720,6 +727,7 @@ public class DeliveryTransferService { return taskResult(outbound, task); } + /** 扫描整托前核平全部指定来源,任一不足时不执行部分拣货。 */ @Transactional public DeliveryResponses.PdaTask pdaPickPallet(String warehouseId, String operatorId, String taskNo, String palletNo, DeliveryRequests.PdaPalletPick request) { @@ -769,7 +777,7 @@ public class DeliveryTransferService { long remaining = value(item.getPackageCount()) - value(item.getPickedPackageCount()); if (remaining <= 0) continue; Map> positionsById = pdaPickPositions( - task.getCustomerId(), boundWarehouseId, item).stream().collect(Collectors.groupingBy( + task.getCustomerId(), boundWarehouseId, item, outbound.getOutboundType()).stream().collect(Collectors.groupingBy( DeliveryResponses.PdaPickPosition::inventoryPositionId, LinkedHashMap::new, Collectors.toList())); for (Map.Entry> entry : positionsById.entrySet()) { if (remaining <= 0) break; @@ -1049,6 +1057,7 @@ public class DeliveryTransferService { .map(task -> taskResult(requireOutboundInWarehouse(task, boundWarehouseId), task)).toList(); } + /** PDA明细展示与整托拣货使用相同的客户、仓库及来源投影。 */ public DeliveryResponses.PdaTaskDetail pdaTaskDetail(String warehouseId, String taskNo) { String boundWarehouseId = required(warehouseId, "PDA warehouse binding is required"); OutboundPdaTask task = pdaTasks.findByNoAndWarehouse(required(taskNo, "PDA task number is required"), @@ -1061,7 +1070,7 @@ public class DeliveryTransferService { value(item.getPickedPackageCount()), item.getStatus(), ("RETURN_OUTBOUND".equals(outbound.getOutboundType()) || ("RELABEL_OUTBOUND".equals(outbound.getOutboundType()) && item.getWarehousePalletId() == null)) ? returnPickPositions(outbound, task, item) - : pdaPickPositions(task.getCustomerId(), boundWarehouseId, item), item.getReturnSkuQuantity())).toList(); + : pdaPickPositions(task.getCustomerId(), boundWarehouseId, item, outbound.getOutboundType()), item.getReturnSkuQuantity())).toList(); return new DeliveryResponses.PdaTaskDetail(taskResult(outbound, task), items); } @@ -1119,13 +1128,28 @@ public class DeliveryTransferService { && value(item.getPickedPackageCount()) == 0 && "OUTBOUND_PENDING".equals(carton.getStatus()) ? 1 : 0, value(item.getPickedPackageCount()))); } + /** 换标使用指定实物箱及仍归本单的冻结库存;普通出库继续按可用库存和SKU匹配。 */ private List pdaPickPositions(String customerId, String warehouseId, - OutboundPalletItem item) { + OutboundPalletItem item, String outboundType) { if (item.getWarehousePalletId() == null) return List.of(); List result = new ArrayList<>(); Set frozenMarks = new HashSet<>(Arrays.asList( Optional.ofNullable(item.getCartonMarks()).orElse("").split(","))); for (InventoryPosition position : inventoryPositions.listPallet(customerId, item.getWarehousePalletId())) { + if ("RELABEL_OUTBOUND".equals(outboundType)) { + List marks = relabelSnapshotMarks(item, position.getId(), null); + InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); + if (marks.isEmpty() || !customerId.equals(position.getCustomerId()) + || !warehouseId.equals(position.getOverseasWarehouseId()) + || !Objects.equals(item.getWarehousePalletId(), position.getPalletId()) + || balance == null || !customerId.equals(balance.getCustomerId()) + || !warehouseId.equals(balance.getOverseasWarehouseId()) + || !Objects.equals(item.getInboundCode(), balance.getInboundCode()) + || value(position.getHoldPackages()) < marks.size()) continue; + // 本单冻结箱先在写路径校验原申请和来源任务,再释放、分配、拣货、出库;不把其他可用箱混入。 + for (String mark : marks) result.add(pdaPosition(position, item, balance, mark, 1, 0)); + continue; + } long available = value(position.getAvailablePackages()); if (available <= 0 || !warehouseId.equals(position.getOverseasWarehouseId())) continue; InventoryBalance balance = inventoryBalances.selectById(position.getBalanceId()); @@ -1147,6 +1171,37 @@ public class DeliveryTransferService { return result; } + /** 仅接受完整的换标出库快照;空SKU不是通配符,必须精确匹配返工后冻结的位置和全部箱唛。 */ + private List relabelSnapshotMarks(OutboundPalletItem item, Long positionId, String expectedRelabelNo) { + try { + var refs = objectMapper.readTree(item.getOrderRefs()); + if (!"RELABEL_ORDER".equals(refs.path("sourceType").asText()) + || !StringUtils.hasText(refs.path("relabelNo").asText()) + || expectedRelabelNo != null && !expectedRelabelNo.equals(refs.path("relabelNo").asText()) + || positionId == null || !String.valueOf(positionId).equals(refs.path("sourceInventoryPositionId").asText()) + || !refs.path("items").isArray()) return List.of(); + var marks = new TreeSet(); var ids = new HashSet(); long packages = 0; + for (var line : refs.path("items")) { + String mark = line.path("cartonMark").asText().trim(), id = line.path("itemId").asText(); + long count = line.path("packages").asLong(); + if (!StringUtils.hasText(mark) || !StringUtils.hasText(id) || !ids.add(id) || !marks.add(mark) || count != 1) return List.of(); + packages += count; + } + var frozen = Arrays.stream(Objects.toString(item.getCartonMarks(), "").split(",")) + .map(String::trim).filter(StringUtils::hasText).collect(Collectors.toCollection(TreeSet::new)); + return packages == value(item.getPackageCount()) && marks.equals(frozen) ? List.copyOf(marks) : List.of(); + } catch (Exception ignored) { return List.of(); } + } + + /** 释放冻结前核对快照任务主键与本申请当前位置的真实任务集合完全相同。 */ + private Set relabelSnapshotItemIds(OutboundPalletItem item) { + try { + var ids = new HashSet(); + for (var line : objectMapper.readTree(item.getOrderRefs()).path("items")) ids.add(line.path("itemId").asText()); + return ids; + } catch (Exception ignored) { return Set.of(); } + } + private DeliveryResponses.PdaPickPosition pdaPosition(InventoryPosition position, OutboundPalletItem item, InventoryBalance balance, String cartonMark, long available, long picked) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java index c62094d6d..3b9ac1a1b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/dto/CargoDiscardResponses.java @@ -29,12 +29,16 @@ public final class CargoDiscardResponses { } } public record Box(Long cartonId, String cartonMark, String palletNo, List skus) {} - public record CandidateSku(String skuCode, String productName, long availablePackages) {} + public record CandidateSku(String skuCode, String productName, long availablePackages, Long availablePieces) { + /** 无件数事实时保留未知,不按箱规推算。 */ + public CandidateSku(String skuCode, String productName, long availablePackages) { this(skuCode, productName, availablePackages, null); } + } public record Summary(Long id, String customerId, String discardNo, String inboundCodes, String overseasWarehouseId, String overseasWarehouseName, int totalPackages, BigDecimal totalFee, String currency, String status, - String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt) {} + String createdBy, LocalDateTime createdAt, LocalDateTime reviewedAt, String outboundNo, LocalDateTime customerConfirmedAt, + List skuCodes, int skuCount, int requestedPackages, Integer actualPackages) {} public record Item(Long id, Long orderId, String inboundCode, String skuCode, String productName, int availablePackagesSnapshot, int discardPackages, List cartonMarks, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java index 5f62485bc..972282487 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardDimensionMapper.java @@ -19,4 +19,15 @@ public interface CargoDiscardDimensionMapper { WHERE i.customer_id=#{customerId} AND i.application_id=#{applicationId} """) List palletNos(@Param("customerId") String customerId, @Param("applicationId") Long applicationId); + + /** 仅读取所选转运实收箱的真实件数,其他来源保留未知。 */ + @Select(""" + SELECT CASE WHEN l.source_type='TRANSFER' THEN c.sku_items_json ELSE NULL END + FROM ll_cargo_discard_item i + JOIN ll_cargo_discard_carton l ON l.customer_id=i.customer_id AND l.application_id=i.application_id + AND (l.item_id=i.id OR (i.source_type='INVENTORY' AND JSON_CONTAINS(i.carton_marks_json,JSON_QUOTE(l.carton_mark)))) + LEFT JOIN ll_warehouse_transfer_carton c ON c.id=l.carton_id AND c.customer_id=l.customer_id + WHERE i.customer_id=#{customerId} AND i.id=#{itemId} + """) + List selectedCartonPieces(@Param("customerId") String customerId, @Param("itemId") Long itemId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java index ba4238a99..1b9cc17d0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/mapper/CargoDiscardInventoryMapper.java @@ -25,16 +25,16 @@ public interface CargoDiscardInventoryMapper { @Select("SELECT p.* FROM ll_inventory_position p WHERE p.customer_id=#{customerId} AND p.id=#{positionId} FOR UPDATE") com.baiyue.lastleg.inventory.entity.InventoryPosition lockGenericPosition(@Param("customerId") String customerId, @Param("positionId") Long positionId); - /** 按实收托盘及 SKU 关联已清点箱,避免漏箱或混入其他托盘的箱号。 */ + /** 按实收托盘关联已清点箱;混装物理余额允许箱内不同 SKU,仍严格限定客户、批次、仓和托盘。 */ @Select(""" - SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type + SELECT c.id,c.carton_mark,c.sku_code,'INBOUND' source_type,NULL sku_items_json FROM ll_inbound_plan_carton c JOIN ll_inventory_balance b ON b.customer_id=c.customer_id AND b.inbound_order_id=c.order_id JOIN ll_inventory_position p ON p.customer_id=b.customer_id AND p.balance_id=b.id AND p.pallet_id=c.warehouse_pallet_id WHERE p.customer_id=#{customerId} AND p.id=#{positionId} AND c.status='AVAILABLE' AND c.received_at IS NOT NULL AND COALESCE(b.destination_warehouse_code,'')=COALESCE(c.destination_warehouse_code,'') UNION ALL - SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type + SELECT c.id,c.carton_mark,i.sku_code,'TRANSFER' source_type,c.sku_items_json FROM ll_inventory_position p JOIN ll_inventory_balance b ON b.customer_id=p.customer_id AND b.id=p.balance_id JOIN ll_warehouse_transfer_order t ON t.customer_id=p.customer_id AND t.inbound_order_id=b.inbound_order_id @@ -44,7 +44,7 @@ public interface CargoDiscardInventoryMapper { JOIN ll_warehouse_transfer_carton c ON c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND c.transfer_pallet_id=tp.id AND c.status='COUNTED' JOIN ll_warehouse_transfer_item i ON i.customer_id=c.customer_id AND i.id=c.transfer_item_id - AND i.transfer_order_id=t.id AND COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'') + AND i.transfer_order_id=t.id AND (b.sku_code='MIXED' OR COALESCE(i.sku_code,'')=COALESCE(b.sku_code,'')) WHERE p.customer_id=#{customerId} AND p.id=#{positionId} ORDER BY id """) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java index 7c4e06f51..1aa4f166c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardQuantities.java @@ -12,15 +12,22 @@ public final class CargoDiscardQuantities { /** 退货混装箱按箱 ID 去重,其余来源累加已选择的物理箱数。 */ public static int boxes(List lines, boolean actual) { var returnBoxes = new HashSet(); + var inventoryBoxes = new HashSet(); int total = 0; for (var line : lines) { int quantity = actual ? java.util.Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) : line.getDiscardPackages(); if (line.getReturnCartonId() != null) { if (quantity > 0) returnBoxes.add(line.getReturnCartonId()); + } else if ("INVENTORY".equals(line.getSourceType()) && line.getCartonMarksJson() != null && quantity > 0) { + try { + var marks = new com.fasterxml.jackson.databind.ObjectMapper().readTree(line.getCartonMarksJson()); + if (!marks.isArray() || marks.size() != quantity) throw new IllegalStateException("废弃箱唛快照与数量不一致"); + for (var mark : marks) inventoryBoxes.add(line.getInboundCode() + ":" + mark.asText()); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw new IllegalStateException("废弃箱唛快照无法读取", e); } } else { total = Math.addExact(total, quantity); } } - return Math.addExact(total, returnBoxes.size()); + return Math.addExact(Math.addExact(total, returnBoxes.size()), inventoryBoxes.size()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java index 3a3f8d15e..d413b83bb 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/model/CargoDiscardStockCartonRow.java @@ -9,4 +9,5 @@ public class CargoDiscardStockCartonRow { private String cartonMark; private String skuCode; private String sourceType; + private String skuItemsJson; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java index 7ac9e9f50..db4793900 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/dto/PdaCargoDiscardDtos.java @@ -21,7 +21,8 @@ public final class PdaCargoDiscardDtos { } /** 明细数量按来源标识单位:退货库存为件,其余箱级库存为箱。 */ public record Sku(Long itemId, String skuCode, String productName, int plannedPackages, - Integer actualPackages, String status, String quantityUnit) { } + Integer actualPackages, String status, String quantityUnit, + boolean actualQuantityEditable) { } public record PalletTask(String discardNo, String customerId, String overseasWarehouseId, String palletNo, Long profileId, String status, List skus, String outboundNo, String scanMode, List cartonMarks) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java index ad828118b..96d5ce1e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardService.java @@ -1,17 +1,11 @@ package com.baiyue.lastleg.discard.pda.service; -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; import com.baiyue.lastleg.discard.dto.CargoDiscardResponses; import com.baiyue.lastleg.discard.entity.*; -import com.baiyue.lastleg.discard.mapper.*; import com.baiyue.lastleg.discard.pda.dto.PdaCargoDiscardDtos; import com.baiyue.lastleg.discard.service.CargoDiscardInventoryAdjustmentService; import com.baiyue.lastleg.discard.service.CargoDiscardService; -import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; -import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; -import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; -import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; @@ -24,28 +18,24 @@ import java.util.stream.Collectors; @Service public class PdaCargoDiscardService { @Resource private com.baiyue.lastleg.discard.service.CargoDiscardPhotoService photoService; - @Resource private CargoDiscardApplicationMapper applications; + @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository repository; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardOutboundService outboundService; - @Resource private CargoDiscardItemMapper items; - @Resource private CargoDiscardCartonMapper cartons; - @Resource private DropshipOrderProfileMapper profiles; @Resource private CargoDiscardInventoryAdjustmentService adjustments; @Resource private CargoDiscardService discardService; - @Resource private ReturnInventoryCartonMapper returnCartons; @Resource private com.baiyue.lastleg.discard.service.CargoDiscardStockService stockService; + /** 只读取绑定仓已审核申请的实际托盘任务。 */ public List tasks(PdaWarehouseIdentity identity) { - return applications.selectList(new LambdaQueryWrapper() - .eq(CargoDiscardApplication::getOverseasWarehouseId, String.valueOf(identity.overseasWarehouseId())) - .eq(CargoDiscardApplication::getStatus, CargoDiscardService.APPROVED) - .orderByAsc(CargoDiscardApplication::getCreatedAt)) + return repository.approved(String.valueOf(identity.overseasWarehouseId())) .stream().flatMap(a -> palletTasks(a).stream()).toList(); } + /** 扫码读取当前仓库唯一可操作任务。 */ public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo) { return resolve(identity, palletNo, null); } + /** 扫码时限定已选择申请,防止同托盘多任务误操作。 */ public PdaCargoDiscardDtos.PalletTask resolve(PdaWarehouseIdentity identity, String palletNo, String discardNo) { var matches = tasks(identity).stream().filter(t -> t.palletNo().equals(palletNo.trim()) || t.cartonMarks().contains(palletNo.trim())) .filter(t -> discardNo == null || discardNo.isBlank() || t.discardNo().equals(discardNo.trim())).toList(); @@ -54,10 +44,11 @@ public class PdaCargoDiscardService { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "该仓库没有此托盘的待废弃任务")); } + /** 仓库按无箱唛对应任务的实点数结算,其余任务继续核平所选箱。 */ @Transactional public PdaCargoDiscardDtos.PalletTask confirm(PdaWarehouseIdentity identity, String discardNo, String palletNo, PdaCargoDiscardDtos.Confirm request) { - CargoDiscardApplication application = applications.findNoByWarehouse(discardNo, + CargoDiscardApplication application = repository.lockApplication(discardNo, String.valueOf(identity.overseasWarehouseId())); if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); boolean terminal = CargoDiscardService.PDA_COMPLETED.equals(application.getStatus()) @@ -71,8 +62,12 @@ public class PdaCargoDiscardService { if (!actuals.keySet().equals(task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).collect(Collectors.toSet()))) { throw conflict("必须提交该托盘全部 SKU 的实际废弃数量"); } - if (task.skus().stream().anyMatch(line -> !Objects.equals(actuals.get(line.itemId()), line.plannedPackages()))) - throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); + for (var line : task.skus()) { + int actual = actuals.get(line.itemId()); + if (actual < 0 || actual > line.plannedPackages()) throw conflict("实际废弃数量不能超过申请预留数量"); + if (!line.actualQuantityEditable() && actual != line.plannedPackages()) + throw conflict("必须完成本次废弃单所选全部箱子,不能漏出"); + } if ("CARTON".equals(task.scanMode()) && !Boolean.TRUE.equals(request.confirmAllCartons())) { var scanned = request.scannedCartonMarks() == null ? List.of() : request.scannedCartonMarks(); if (scanned.size() != task.cartonMarks().size() || !new HashSet<>(scanned).equals(new HashSet<>(task.cartonMarks()))) @@ -80,7 +75,7 @@ public class PdaCargoDiscardService { } List palletItems = new ArrayList<>(); for (PdaCargoDiscardDtos.Sku line : task.skus()) { - CargoDiscardItem item = items.selectById(line.itemId()); + CargoDiscardItem item = repository.item(line.itemId()); if (item == null || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()) || !physicalCode(application, item).equals(task.palletNo())) @@ -90,14 +85,14 @@ public class PdaCargoDiscardService { throw conflict("已完成托盘的实际数量不能修改"); palletItems.add(item); } - if (terminal && items.listApplication(application.getCustomerId(), application.getId()).stream() + if (terminal && repository.items(application.getCustomerId(), application.getId()).stream() .anyMatch(item -> !"COMPLETED".equals(item.getPdaStatus()) || !application.getCustomerId().equals(item.getCustomerId()) || !Objects.equals(application.getId(), item.getApplicationId()))) throw conflict("废弃申请终态存在未完成明细,不能恢复作业结果"); photoService.confirm(application, task.palletNo(), request.photoFileIds(), palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))); - // A completed pallet is an immutable result, including the final pallet after application completion. + // 已完成托盘保持原始实点结果,终态重放不得再次扣减或释放库存。 if (palletItems.stream().allMatch(item -> "COMPLETED".equals(item.getPdaStatus()))) return task; if (terminal) throw conflict("废弃终态不能执行未完成托盘"); LocalDateTime now = LocalDateTime.now(); @@ -105,20 +100,25 @@ public class PdaCargoDiscardService { if ("COMPLETED".equals(item.getPdaStatus())) { continue; } - List links = cartons.listApplication(application.getCustomerId(), application.getId()).stream() + List links = repository.cartons(application.getCustomerId(), application.getId()).stream() .filter(c -> c.getItemId().equals(item.getId())).toList(); - adjustments.discardPalletItem(application.getCustomerId(), application, item, links, - actuals.get(item.getId()), identity.operatorKey(), now); + // 未知箱唛对应关系时只按当前仓位冻结桶结算,不猜测被废弃的具体箱唛。 + if ("INVENTORY".equals(item.getSourceType()) && stockService.actualQuantityEditable(item)) { + stockService.discard(application.getCustomerId(), identity.operatorKey(), application, item, actuals.get(item.getId())); + } else { + adjustments.discardPalletItem(application.getCustomerId(), application, item, links, + actuals.get(item.getId()), identity.operatorKey(), now); + } item.setActualDiscardPackages(actuals.get(item.getId())); item.setPdaStatus("COMPLETED"); - item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); items.updateById(item); + item.setPdaOperatorId(identity.operatorKey()); item.setPdaCompletedAt(now); repository.saveItem(item); } - task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(items::selectById) + task.skus().stream().map(PdaCargoDiscardDtos.Sku::itemId).map(repository::item) .filter(this::isReturnItem).map(CargoDiscardItem::getReturnCartonId).filter(Objects::nonNull).distinct() .forEach(cartonId -> adjustments.finishReturnCarton(application.getCustomerId(), cartonId)); - if (items.listApplication(application.getCustomerId(), application.getId()).stream() + if (repository.items(application.getCustomerId(), application.getId()).stream() .allMatch(i -> "COMPLETED".equals(i.getPdaStatus()))) { int actualTotal = com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes( - items.listApplication(application.getCustomerId(), application.getId()), true); + repository.items(application.getCustomerId(), application.getId()), true); application.setTotalPackages(actualTotal); application.setTotalFee(application.getFeePerPackage().multiply(java.math.BigDecimal.valueOf(actualTotal))); discardService.updateLinkedStatus(application.getCustomerId(), application, CargoDiscardService.APPROVED, @@ -130,6 +130,7 @@ public class PdaCargoDiscardService { } @Transactional + /** 当前申请行锁内保存现场照片,已完成托盘禁止补写。 */ public CargoDiscardResponses.Photo uploadPhoto(PdaWarehouseIdentity identity, String discardNo, String palletNo, long operatorUserId, org.springframework.web.multipart.MultipartFile file) { CargoDiscardApplication application = photoApplication(identity, discardNo, palletNo, true); @@ -140,19 +141,22 @@ public class PdaCargoDiscardService { } @Transactional(readOnly = true) + /** 读取绑定仓当前托盘的照片。 */ public List photos(PdaWarehouseIdentity identity, String discardNo, String palletNo) { return photoService.list(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), false); } @Transactional(readOnly = true) + /** 下载当前申请和托盘拥有的照片。 */ public com.baiyue.lastleg.discard.gateway.CargoDiscardPhotoFileGateway.DownloadedFile downloadPhoto( PdaWarehouseIdentity identity, String discardNo, String palletNo, long fileId) { return photoService.download(photoApplication(identity, discardNo, palletNo, false), palletNo.trim(), fileId, false); } + /** 照片访问必须同时满足绑定仓和申请托盘范围。 */ private CargoDiscardApplication photoApplication(PdaWarehouseIdentity identity, String discardNo, String palletNo, boolean lock) { var warehouseId = String.valueOf(identity.overseasWarehouseId()); - var application = lock ? applications.findNoByWarehouse(discardNo, warehouseId) : applications.readNoByWarehouse(discardNo, warehouseId); + var application = lock ? repository.lockApplication(discardNo, warehouseId) : repository.readApplication(discardNo, warehouseId); if (application == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "废弃任务不存在或不属于当前仓库"); if (palletTasks(application).stream().noneMatch(t -> t.palletNo().equals(palletNo.trim()))) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "托盘不属于该废弃单"); @@ -162,13 +166,13 @@ public class PdaCargoDiscardService { private static final String LOOSE = "散箱"; /** 按实托汇总本单所选箱唛,退货件数与物理箱数分别呈现。 */ private List palletTasks(CargoDiscardApplication application) { - var allItems = items.listApplication(application.getCustomerId(), application.getId()); + var allItems = repository.items(application.getCustomerId(), application.getId()); var grouped = allItems.stream().collect(Collectors.groupingBy(i -> physicalCode(application, i), LinkedHashMap::new, Collectors.toList())); return grouped.entrySet().stream().map(entry -> { CargoDiscardItem first = entry.getValue().get(0); var skus = entry.getValue().stream().map(i -> new PdaCargoDiscardDtos.Sku(i.getId(), i.getSkuCode(), i.getProductName(), i.getDiscardPackages(), i.getActualDiscardPackages(), i.getPdaStatus() == null ? "PENDING" : i.getPdaStatus(), - isReturnItem(i) ? "件" : "箱")).toList(); + isReturnItem(i) ? "件" : "箱", "INVENTORY".equals(i.getSourceType()) && stockService.actualQuantityEditable(i))).toList(); String status = skus.stream().allMatch(i -> "COMPLETED".equals(i.status())) ? "COMPLETED" : "PENDING"; var marks = entry.getValue().stream().flatMap(i -> cartonMarks(application, i).stream()).distinct().sorted().toList(); // 退货箱只代表本单选择的箱子,不能因为有托盘号就当成整托废弃。 @@ -180,6 +184,7 @@ public class PdaCargoDiscardService { status, skus, outboundService.outboundNo(application), cartonScan ? "CARTON" : "PALLET", marks); }).toList(); } + /** 相关托盘只从客户库存事实读取,不从请求推断。 */ private String physicalCode(CargoDiscardApplication application, CargoDiscardItem item) { String code; if ("INVENTORY".equals(item.getSourceType())) { @@ -187,31 +192,35 @@ public class PdaCargoDiscardService { if (!application.getOverseasWarehouseId().equals(position.getOverseasWarehouseId())) throw conflict("废弃库存不属于当前仓库"); code = position.getPalletNo(); } else if (isReturnItem(item)) { - var carton = returnCartons.selectById(item.getReturnCartonId()); + var carton = repository.returnCarton(item.getReturnCartonId()); if (carton == null || !application.getCustomerId().equals(carton.getCustomerId())) throw conflict("废弃明细关联退货箱不存在"); code = carton.getPalletCode(); } else { - var profile = profiles.selectById(item.getProfileId()); + var profile = repository.profile(item.getProfileId()); if (profile == null || !application.getCustomerId().equals(profile.getCustomerId())) throw conflict("废弃明细关联托盘不存在"); code = profile.getPalletNo(); } return code == null || code.isBlank() ? LOOSE : code.trim(); } + /** 仅返回保存的真实箱唛,未知对应关系不补造箱码。 */ private List cartonMarks(CargoDiscardApplication application, CargoDiscardItem item) { if ("INVENTORY".equals(item.getSourceType())) return stockService.cartonMarks(item); if (isReturnItem(item)) { - var carton = returnCartons.selectById(item.getReturnCartonId()); + var carton = repository.returnCarton(item.getReturnCartonId()); String mark = com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton); return mark == null ? List.of() : List.of(mark); } - return cartons.listApplication(application.getCustomerId(), application.getId()).stream() + return repository.cartons(application.getCustomerId(), application.getId()).stream() .filter(c -> Objects.equals(c.getItemId(), item.getId())).map(CargoDiscardCarton::getCartonMark).filter(Objects::nonNull).toList(); } + /** 退货库存沿用独立按件结算规则。 */ private boolean isReturnItem(CargoDiscardItem item) { return "RETURN_CARTON".equals(item.getSourceType()) || item.getReturnCartonId() != null; } + /** 取首个有效业务展示值。 */ private static String firstNonBlank(String first, String second) { return first != null && !first.isBlank() ? first : second; } + /** 返回可恢复业务冲突。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java index b375f8202..8201293b6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepository.java @@ -12,8 +12,29 @@ import java.util.List; public class CargoDiscardDimensionRepository { @Resource private CargoDiscardItemMapper items; @Resource private CargoDiscardDimensionMapper dimensions; + @Resource private com.fasterxml.jackson.databind.ObjectMapper objectMapper; /** 读取一票客户隔离明细。 */ public List items(String customer, Long application) { return items.listApplication(customer, application); } /** 读取一票托盘身份,保留无事实行。 */ public List pallets(String customer, Long application) { return dimensions.palletNos(customer, application); } + /** 完整箱级事实才可提供件数,缺箱或未知件数不得固定换算。 */ + public Long pieces(String customer, CargoDiscardItem item) { + var values = dimensions.selectedCartonPieces(customer, item.getId()); + if (values.isEmpty() || values.size() != item.getDiscardPackages()) return null; + long total = 0; + for (var value : values) { + if (value == null || value.isBlank()) return null; + try { + var contents = objectMapper.readTree(value); + if (!contents.isArray()) return null; + long selected = 0; + for (var content : contents) { + if (item.getSkuCode().equals(content.path("skuCode").asText())) selected += content.path("quantity").asLong(); + } + if (selected <= 0) return null; + total += selected; + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { return null; } + } + return total; + } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardPdaRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardPdaRepository.java new file mode 100644 index 000000000..1d176def8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardPdaRepository.java @@ -0,0 +1,46 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.discard.entity.*; +import com.baiyue.lastleg.discard.mapper.*; +import com.baiyue.lastleg.dropship.entity.DropshipOrderProfile; +import com.baiyue.lastleg.dropship.mapper.DropshipOrderProfileMapper; +import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 封装 PDA 废弃任务读取和作业快照持久化,保留绑定仓与申请行锁。 */ +@Repository +public class CargoDiscardPdaRepository { + @Resource private CargoDiscardApplicationMapper applications; + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardCartonMapper cartons; + @Resource private DropshipOrderProfileMapper profiles; + @Resource private ReturnInventoryCartonMapper returnCartons; + + /** 按已验证绑定仓读取已审核作业,客户归属由申请事实保留。 */ + public List approved(String warehouseId) { + return applications.selectList(new LambdaQueryWrapper() + .eq(CargoDiscardApplication::getOverseasWarehouseId, warehouseId) + .eq(CargoDiscardApplication::getStatus, "APPROVED") + .orderByAsc(CargoDiscardApplication::getCreatedAt)); + } + /** 写操作按申请串行,保证扣减、释放、实点结果及终态同事务。 */ + public CargoDiscardApplication lockApplication(String no, String warehouse) { return applications.findNoByWarehouse(no, warehouse); } + /** 照片只读入口仍限制绑定仓。 */ + public CargoDiscardApplication readApplication(String no, String warehouse) { return applications.readNoByWarehouse(no, warehouse); } + /** 按申请所属客户读取全部明细。 */ + public List items(String customer, Long application) { return items.listApplication(customer, application); } + /** 读取明细后由服务复验客户与申请。 */ + public CargoDiscardItem item(Long id) { return items.selectById(id); } + /** 保存一次实际作业结果。 */ + public void saveItem(CargoDiscardItem item) { items.updateById(item); } + /** 按客户和申请读取已选箱快照。 */ + public List cartons(String customer, Long application) { return cartons.listApplication(customer, application); } + /** 读取原托盘档案供服务核验归属。 */ + public DropshipOrderProfile profile(Long id) { return profiles.selectById(id); } + /** 读取退货箱档案供服务核验归属。 */ + public ReturnInventoryCarton returnCarton(Long id) { return returnCartons.selectById(id); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java index 64acc82c5..dbfc3265b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepository.java @@ -11,9 +11,10 @@ import org.springframework.stereotype.Repository; public class CargoDiscardQuantityRepository { @Resource private CargoDiscardItemMapper items; - /** 列表和详情均按所选实物箱展示;空明细保留申请自身快照。 */ + /** 完工后列表和详情按实际箱数展示,作业前保留申请预留数量。 */ public int boxes(CargoDiscardApplication application) { var lines = items.listApplication(application.getCustomerId(), application.getId()); - return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, false); + boolean completed = "PDA_COMPLETED".equals(application.getStatus()) || "COMPLETED".equals(application.getStatus()); + return lines.isEmpty() ? application.getTotalPackages() : CargoDiscardQuantities.boxes(lines, completed); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java new file mode 100644 index 000000000..a5ae4173a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/repository/CargoDiscardStockRepository.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.*; +import com.baiyue.lastleg.discard.mapper.*; +import com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow; +import com.baiyue.lastleg.inventory.entity.*; +import com.baiyue.lastleg.inventory.mapper.*; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 封装普通库存废弃的客户隔离读取、锁箱和申请快照保存。 */ +@Repository +public class CargoDiscardStockRepository { + @Resource private CargoDiscardInventoryMapper stock; + @Resource private InventoryPositionMapper positions; + @Resource private InventoryBalanceMapper balances; + @Resource private CargoDiscardItemMapper items; + @Resource private CargoDiscardCartonMapper links; + + /** 读取客户当前可用且没有转运占用的库存仓位。 */ + public List candidates(String customer, String keyword) { return stock.genericPositions(customer, keyword); } + /** 读取余额,服务继续校验其客户与仓库归属。 */ + public InventoryBalance balance(Long id) { return balances.selectById(id); } + /** 读取客户指定仓位。 */ + public InventoryPosition position(String customer, Long id) { return positions.findTenantPosition(customer, id); } + /** 锁定源仓位以串行创建预留。 */ + public InventoryPosition lockPosition(String customer, Long id) { return stock.lockGenericPosition(customer, id); } + /** 读取已有的真实箱唛与托盘对应事实。 */ + public List cartons(String customer, Long position) { return stock.genericCartons(customer, position); } + /** 排除须走一件代发逐箱预留的仓位。 */ + public boolean dropshipPosition(String customer, Long position) { return stock.isDropshipPosition(customer, position) > 0; } + /** 读取散箱实收流水中的箱码快照。 */ + public List looseMarks(String customer, Long position) { return stock.looseCartonMarks(customer, position); } + /** 保存废弃申请的库存来源明细。 */ + public void saveItem(CargoDiscardItem item) { items.insert(item); } + /** 保存申请所选实物箱快照。 */ + public void saveCarton(CargoDiscardCarton carton) { links.insert(carton); } + /** 读取申请全部所选箱,服务按当前明细筛选。 */ + public List selectedCartons(String customer, Long application) { return links.listApplication(customer, application); } + /** 读取同申请各 SKU 的仓位和箱唛快照,用于按物理仓位消除重复库存命令。 */ + public List applicationItems(String customer, Long application) { return items.listApplication(customer, application); } + /** 普通实收箱用原状态条件更新,防止并发占用。 */ + public int changeInboundCarton(String customer, Long id, String from, String to) { return stock.changeGenericCartonStatus(customer, id, from, to); } + /** 转运实收箱保留已清点状态语义。 */ + public int changeTransferCarton(String customer, Long id, String from, String to) { return stock.changeTransferCartonStatus(customer, id, from, to); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index 84d361e9f..d9ef4c468 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -58,6 +58,7 @@ public class CargoDiscardOutboundService { var snapshots = new java.util.ArrayList(); var palletCodes = new java.util.HashSet(); var returnBoxIds = new java.util.HashSet(); + var inventoryBoxMarks = new java.util.HashSet(); for (var line : lines) { boolean returned = line.getReturnCartonId() != null || "RETURN_CARTON".equals(line.getSourceType()); String palletNo; @@ -88,7 +89,7 @@ public class CargoDiscardOutboundService { snapshot.setId(line.getId()); snapshot.setCustomerId(application.getCustomerId()); snapshot.setOutboundOrderId(outbound.getId()); snapshot.setPalletNo(palletNo); snapshot.setSkuCode(line.getSkuCode()); snapshot.setInboundCode(line.getInboundCode()); snapshot.setOrderRefs(application.getDiscardNo()); snapshot.setCartonMarks(cartonMarks); - snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages()); + snapshot.setPackageCount(returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : physicalBoxes(line, inventoryBoxMarks)); snapshot.setPickedPackageCount(0); snapshot.setStatus("WAIT_PICK"); if (returned) snapshot.setReturnSkuQuantity((long) line.getDiscardPackages()); snapshot.setVolumeWeightKg(BigDecimal.ZERO); snapshot.setVolumeCbm(BigDecimal.ZERO); snapshot.setCreatedAt(at); @@ -99,11 +100,13 @@ public class CargoDiscardOutboundService { } /** 同步实物箱的拣货进度,退货 SKU 件数不进入箱数汇总。 */ + /** 同步废弃实物出库投影,混装 SKU 明细不能重复累计物理箱。 */ public void sync(CargoDiscardApplication application) { OutboundOrder outbound = outbounds.lockByIdempotency(application.getCustomerId(), key(application)); if (outbound == null) throw conflict("废弃出库单不存在,请联系平台核对审核结果"); var lines = items.listApplication(application.getCustomerId(), application.getId()); var returnBoxIds = new java.util.HashSet(); + var inventoryBoxMarks = new java.util.HashSet(); for (var snapshot : pallets.findByOutbound(application.getCustomerId(), outbound.getId())) { var line = lines.stream().filter(i -> Objects.equals(i.getId(), snapshot.getId())).findFirst() .orElseThrow(() -> conflict("废弃出库明细关联失效")); @@ -114,11 +117,11 @@ public class CargoDiscardOutboundService { throw conflict("废弃明细关联退货箱不存在"); snapshot.setCartonMarks(com.baiyue.lastleg.discard.model.CargoDiscardReturnMark.of(carton)); } - int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : line.getDiscardPackages(); + int boxes = returned ? (returnBoxIds.add(line.getReturnCartonId()) ? 1 : 0) : physicalBoxes(line, inventoryBoxMarks); snapshot.setPackageCount(boxes); if ("COMPLETED".equals(line.getPdaStatus())) { snapshot.setPickedPackageCount(returned ? (Objects.requireNonNullElse(line.getActualDiscardPackages(), 0) > 0 ? boxes : 0) - : Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); + : "INVENTORY".equals(line.getSourceType()) && !stockService.cartonMarks(line).isEmpty() ? boxes : Objects.requireNonNullElse(line.getActualDiscardPackages(), 0)); snapshot.setStatus("PICKED"); } if (CargoDiscardService.CANCELLED.equals(application.getStatus())) snapshot.setStatus("CANCELLED"); @@ -134,5 +137,11 @@ public class CargoDiscardOutboundService { outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, "OUTBOUNDED".equals(status))); outbound.setUpdatedAt(LocalDateTime.now()); outbounds.updateById(outbound); } + /** 保留逐 SKU 来源行,已投影过的同一实箱在后续行记零箱。 */ + private int physicalBoxes(com.baiyue.lastleg.discard.entity.CargoDiscardItem line, java.util.Set seen) { + if (!"INVENTORY".equals(line.getSourceType())) return line.getDiscardPackages(); + var marks = stockService.cartonMarks(line); + return marks.isEmpty() ? line.getDiscardPackages() : (int)marks.stream().filter(mark -> seen.add(line.getInboundCode() + ":" + mark)).count(); + } private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 81640a372..4b9e3900b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -165,7 +165,10 @@ public class CargoDiscardService { if (input.cartonIds() != null) { if (input.cartonIds().isEmpty() || input.cartonIds().size() != input.discardPackages()) throw badRequest("请选择具体废弃箱子,箱数须与选择一致"); String source = input.inventoryPositionId() != null ? "INVENTORY" : "DROPSHIP"; - for (Long cartonId : input.cartonIds()) if (!requestedBoxes.add(source + ":" + cartonId)) throw badRequest("同一个箱子不能重复废弃"); + for (Long cartonId : input.cartonIds()) { + boolean added = requestedBoxes.add(source + ":" + cartonId); + if (!added && input.inventoryPositionId() == null) throw badRequest("同一个箱子不能重复废弃"); + } } int sources = (returnSource ? 1 : 0) + (input.orderId() != null ? 1 : 0) + (input.inventoryPositionId() != null ? 1 : 0); if (sources != 1) { @@ -202,7 +205,7 @@ public class CargoDiscardService { throw conflict("One cargo discard application cannot span operating warehouses"); } // 退货明细为件数,整箱只在全部 SKU 校验完成后按箱 ID 计一次。 - if (!returnSource) total = Math.addExact(total, input.discardPackages()); + if (!returnSource && input.cartonIds() == null) total = Math.addExact(total, input.discardPackages()); } for (Long cartonId : returnCartonCache.keySet()) { @@ -214,6 +217,7 @@ public class CargoDiscardService { if (!availableSkus.equals(requestedSkus)) throw conflict("目前不支持部分废弃,必须选择退货箱内全部 SKU 库存"); } total = Math.addExact(total, returnCartonCache.size()); + total = Math.addExact(total, requestedBoxes.size()); LocalDateTime now = LocalDateTime.now(); if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); CargoDiscardApplication application = new CargoDiscardApplication(); @@ -236,9 +240,10 @@ public class CargoDiscardService { // 废弃申请独立承载废弃流程,不创建尾程订单。 Set reservedReturnCartons = new LinkedHashSet<>(); + stockService.reserveAll(customerId, operatorId, application, request.items(), now); for (CargoDiscardRequests.Item input : request.items()) { if (input.inventoryPositionId() != null) { - stockService.reserve(customerId, operatorId, application, input, now); + // 普通库存已按完整混装箱批量预留。 } else if (input.returnCartonId() != null) { reserveReturn(customerId, application, returnCartonCache.get(input.returnCartonId()), input, now, reservedReturnCartons); @@ -513,10 +518,15 @@ public class CargoDiscardService { /** 废弃列表与详情按物理箱汇总,并按每箱单价计算应展示费用。 */ private CargoDiscardResponses.Summary summary(CargoDiscardApplication value, Map names) { int boxes = quantities.boxes(value); + var lines = items.listApplication(value.getCustomerId(), value.getId()); + var skuCodes = lines.stream().map(CargoDiscardItem::getSkuCode).filter(s -> s != null && !s.isBlank()).distinct().sorted().toList(); + int requested = lines.isEmpty() ? value.getTotalPackages() : com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false); + Integer actual = lines.stream().anyMatch(i -> i.getActualDiscardPackages() != null) + ? com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, true) : null; return new CargoDiscardResponses.Summary(value.getId(), value.getCustomerId(), value.getDiscardNo(), value.getInboundCodes(), value.getOverseasWarehouseId(), StringUtils.hasText(value.getOverseasWarehouseName()) ? value.getOverseasWarehouseName() : names.get(value.getOverseasWarehouseId()), boxes, value.getFeePerPackage() == null ? value.getTotalFee() : value.getFeePerPackage().multiply(BigDecimal.valueOf(boxes)), value.getCurrency(), value.getStatus(), value.getCreatedBy(), value.getCreatedAt(), - value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt()); + value.getReviewedAt(), outboundService.outboundNo(value), value.getCustomerConfirmedAt(), skuCodes, skuCodes.size(), requested, actual); } private void history(String customerId, Long applicationId, String from, String to, String operatorId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java index 76508b8e8..7441a8de5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsService.java @@ -32,8 +32,14 @@ public class CargoDiscardStatisticsService { for (var line : lines) { if (line.getSkuCode() != null && !line.getSkuCode().isBlank()) skus.add(line.getSkuCode()); if (line.getReturnCartonId() == null) { - piecesKnown = false; - if (line.getActualDiscardPackages() != null) actualPiecesKnown = false; + var selectedPieces = dimensions.pieces(application.getCustomerId(), line); + if (selectedPieces == null) piecesKnown = false; + else pieces += selectedPieces; + if (line.getActualDiscardPackages() != null) { + if (line.getActualDiscardPackages() == 0) { /* 实际未废弃商品为零,不推测箱规。 */ } + else if (selectedPieces != null && line.getActualDiscardPackages() == line.getDiscardPackages()) actualPieces += selectedPieces; + else actualPiecesKnown = false; + } } else { pieces += line.getDiscardPackages(); if (line.getActualDiscardPackages() != null) actualPieces += line.getActualDiscardPackages(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java index 081158224..4ef53aa2f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardStockService.java @@ -2,11 +2,9 @@ package com.baiyue.lastleg.discard.service; import com.baiyue.lastleg.discard.dto.*; import com.baiyue.lastleg.discard.entity.*; -import com.baiyue.lastleg.discard.mapper.*; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.baiyue.lastleg.inventory.entity.*; -import com.baiyue.lastleg.inventory.mapper.*; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.Resource; @@ -16,129 +14,233 @@ import org.springframework.web.server.ResponseStatusException; import java.util.*; import java.time.LocalDateTime; -/** Stock eligibility is based on physical available positions, independent of the source document type. */ +/** 根据实际可用仓位校验废弃库存,精确选箱与无对应关系实点分别结算。 */ @Service public class CargoDiscardStockService { - @Resource private CargoDiscardInventoryMapper stock; - @Resource private InventoryPositionMapper positions; - @Resource private InventoryBalanceMapper balances; + @Resource private com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository repository; @Resource private InventoryCommandService commands; - @Resource private CargoDiscardItemMapper items; - @Resource private CargoDiscardCartonMapper links; @Resource private ObjectMapper objectMapper; + /** 展示可用库存及相关托盘,箱唛完整时才提供逐箱选择。 */ public List candidates(String customer, String keyword) { - return stock.genericPositions(customer, keyword).stream().map(position -> { - var balance = balances.selectById(position.getBalanceId()); + return repository.candidates(customer, keyword).stream().map(position -> { + var balance = repository.balance(position.getBalanceId()); if (balance == null || !customer.equals(balance.getCustomerId())) throw conflict("库存余额不存在"); long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); - var cartons = stock.genericCartons(customer, position.getId()); - boolean complete = cartons.size() == available && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank()) - && cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size(); + var cartons = repository.cartons(customer, position.getId()); + boolean complete = completeCartons(cartons, available); var boxes = complete ? cartons.stream().map(c -> new CargoDiscardResponses.Box(c.getId(), c.getCartonMark(), position.getPalletNo(), - List.of(new CargoDiscardResponses.CandidateSku(c.getSkuCode(), balance.getProductNameZh(), 1)))).toList() : List.of(); + contents(c, balance.getProductNameZh()))).toList() : List.of(); return new CargoDiscardResponses.Candidate(null, "INVENTORY", null, null, balance.getInboundCode(), balance.getInboundOrderNo(), balance.getOverseasWarehouseId(), balance.getOverseasWarehouseName(), position.getCreatedAt(), available, - List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)), + complete ? summarizeSkus(cartons, balance) : List.of(new CargoDiscardResponses.CandidateSku(sku(balance), balance.getProductNameZh(), available)), position.getId(), position.getPalletNo(), boxes, complete, - complete ? null : "当前库存缺少完整箱唛与托盘对应记录,可按全部库存废弃"); + complete ? null : "缺少完整箱唛与托盘对应记录,将预留所选库存,由仓库核对相关托盘并填写实际废弃箱数,未废弃部分释放"); }).filter(c -> c.availablePackages() > 0).toList(); } + /** 校验客户、仓库、SKU、预留上限与具体箱子的归属。 */ public InventoryBalance validate(String customer, CargoDiscardRequests.Item request) { var position = position(customer, request.inventoryPositionId()); - var balance = balances.selectById(position.getBalanceId()); + var balance = repository.balance(position.getBalanceId()); if (balance == null || !customer.equals(balance.getCustomerId()) || !Objects.equals(position.getOverseasWarehouseId(), balance.getOverseasWarehouseId())) throw conflict("库存不属于当前客户或仓库"); - if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); long available = Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())); if (available <= 0 || request.discardPackages() <= 0 || request.discardPackages() > available) throw conflict("所选废弃库存数量不足"); if (request.cartonIds() != null) { if (request.cartonIds().size() != request.discardPackages() || new HashSet<>(request.cartonIds()).size() != request.cartonIds().size()) throw conflict("废弃箱子数量与所选箱子不一致或重复"); - var cartons = stock.genericCartons(customer, position.getId()); + var cartons = repository.cartons(customer, position.getId()); var ids = cartons.stream().map(c -> c.getId()).collect(java.util.stream.Collectors.toSet()); if (cartons.size() != available || cartons.stream().anyMatch(c -> c.getCartonMark() == null || c.getCartonMark().isBlank()) || cartons.stream().map(c -> c.getCartonMark()).distinct().count() != cartons.size() || !ids.containsAll(request.cartonIds())) throw conflict("箱唛库存已变化或不属于所选托盘,请刷新后重选"); - } else if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); - if (stock.isDropshipPosition(customer, position.getId()) > 0) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留"); + if (cartons.stream().filter(c -> request.cartonIds().contains(c.getId())).anyMatch(c -> contents(c, null).stream().noneMatch(s -> request.skuCode().trim().equals(s.skuCode())))) throw conflict("SKU 不属于所选箱子"); + } else { + if (!sku(balance).equals(request.skuCode().trim())) throw conflict("SKU 不属于所选库存"); + if (request.discardPackages() != available) throw conflict("部分废弃必须选择具体箱子,不能只填写数量"); + } + if (repository.dropshipPosition(customer, position.getId())) throw conflict("一件代发库存必须按真实箱唛选择,不能绕过箱级预留"); return balance; } /** 预留所选实收箱并保存箱号快照,转运箱从已清点状态进入待废弃。 */ public void reserve(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at) { - if (stock.lockGenericPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); + if (repository.lockPosition(customer, request.inventoryPositionId()) == null) throw conflict("库存仓位不存在"); var balance = validate(customer, request); + reserveValidated(customer, operator, application, request, at, balance, repository.cartons(customer, request.inventoryPositionId()), new HashSet<>(), new HashSet<>()); + } + + /** 整箱混装必须包含全部 SKU 仓位;先锁仓位并校验全部事实,再按实物箱仅锁定一次。 */ + public void reserveAll(String customer, String operator, CargoDiscardApplication application, List requests, LocalDateTime at) { + var stocks = requests.stream().filter(r -> r.inventoryPositionId() != null).toList(); + stocks.stream().map(CargoDiscardRequests.Item::inventoryPositionId).distinct().sorted().forEach(id -> { + if (repository.lockPosition(customer, id) == null) throw conflict("库存仓位不存在"); + }); + var balances = new HashMap(); + var cartons = new HashMap>(); + var selectedSkus = new HashMap>(); + for (var request : stocks) { + balances.put(request.inventoryPositionId(), validate(customer, request)); + var facts = repository.cartons(customer, request.inventoryPositionId()); + cartons.put(request.inventoryPositionId(), facts); + for (var carton : facts) if (request.cartonIds() == null || request.cartonIds().contains(carton.getId())) + selectedSkus.computeIfAbsent(carton.getSourceType() + ":" + carton.getId(), k -> new HashSet<>()).add(request.skuCode().trim()); + } + for (var request : stocks) for (var carton : cartons.get(request.inventoryPositionId())) { + if (request.cartonIds() != null && !request.cartonIds().contains(carton.getId())) continue; + var required = contents(carton, null).stream().map(CargoDiscardResponses.CandidateSku::skuCode).collect(java.util.stream.Collectors.toSet()); + if (required.size() > 1 && request.cartonIds() == null) throw conflict("混装箱必须选择具体箱子及箱内全部 SKU 库存"); + if (!selectedSkus.get(carton.getSourceType() + ":" + carton.getId()).containsAll(required)) throw conflict("混装箱必须选择箱内全部 SKU 库存"); + } + var lockedBoxes = new HashSet(); + var heldBoxes = new HashSet(); + for (var request : stocks) reserveValidated(customer, operator, application, request, at, + balances.get(request.inventoryPositionId()), cartons.get(request.inventoryPositionId()), lockedBoxes, heldBoxes); + } + + /** 使用本事务变更箱状态前的完整事实,为每个 SKU 分别预留并保存相同箱唛快照。 */ + private void reserveValidated(String customer, String operator, CargoDiscardApplication application, CargoDiscardRequests.Item request, LocalDateTime at, + InventoryBalance balance, List availableCartons, Set lockedBoxes, Set heldBoxes) { var position = position(customer, request.inventoryPositionId()); var line = new CargoDiscardItem(); line.setId(IdWorker.getId()); line.setApplicationId(application.getId()); line.setCustomerId(customer); line.setSourceType("INVENTORY"); line.setInventoryPositionId(position.getId()); - line.setInboundCode(balance.getInboundCode()); line.setSkuCode(sku(balance)); line.setProductName(balance.getProductNameZh()); - line.setAvailablePackagesSnapshot(Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages())))); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at); - var availableCartons = stock.genericCartons(customer, position.getId()); + line.setInboundCode(balance.getInboundCode()); line.setSkuCode(request.skuCode().trim()); line.setProductName(sku(balance).equals(line.getSkuCode()) ? balance.getProductNameZh() : null); + line.setAvailablePackagesSnapshot(availableCartons.isEmpty() ? Math.toIntExact(Math.min(value(position.getAvailablePackages()), value(balance.getAvailablePackages()))) : availableCartons.size()); line.setDiscardPackages(request.discardPackages()); line.setCreatedAt(at); var selected = request.cartonIds() == null - ? (availableCartons.size() == request.discardPackages() ? availableCartons : List.of()) + ? (completeCartons(availableCartons, request.discardPackages()) ? availableCartons : List.of()) : availableCartons.stream().filter(c -> request.cartonIds().contains(c.getId())).toList(); if (!selected.isEmpty()) { for (var carton : selected) { + if (!lockedBoxes.add(carton.getSourceType() + ":" + carton.getId())) continue; if (changeBox(customer, carton.getId(), carton.getSourceType(), "TRANSFER".equals(carton.getSourceType()) ? "COUNTED" : "AVAILABLE", "DISCARD_PENDING") != 1) throw conflict("箱子已被其他作业占用,请刷新后重选"); var link = new CargoDiscardCarton(); link.setId(IdWorker.getId()); link.setCustomerId(customer); link.setApplicationId(application.getId()); - link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); links.insert(link); + link.setItemId(line.getId()); link.setSourceType(carton.getSourceType()); link.setCartonId(carton.getId()); link.setCartonMark(carton.getCartonMark()); link.setCreatedAt(at); repository.saveCarton(link); } snapshotMarks(line, selected.stream().map(c -> c.getCartonMark()).toList()); line.setCartonScanRequired(request.discardPackages() < value(position.getAvailablePackages()) + value(position.getHoldPackages()) + value(position.getAllocatedPackages()) + value(position.getPickedPackages())); } else if (position.getPalletNo() == null || position.getPalletNo().isBlank()) { - var marks = stock.looseCartonMarks(customer, position.getId()); + var marks = repository.looseMarks(customer, position.getId()); if (marks.size() != request.discardPackages() || marks.stream().distinct().count() != marks.size()) throw conflict("散箱库存缺少完整箱唛,请先核对实物箱级入库记录"); snapshotMarks(line, marks); } - commands.hold(customer, operator, move(application, line, request.discardPackages(), "HOLD")); - items.insert(line); + int physical = selected.isEmpty() ? request.discardPackages() : (int)selected.stream().filter(c -> heldBoxes.add(position.getId() + ":" + c.getSourceType() + ":" + c.getId())).count(); + if (physical > 0) commands.hold(customer, operator, move(application, line, physical, "HOLD")); + repository.saveItem(line); } + /** 同一仓位可能承载混装箱,完整 SKU 候选来自实箱而不是库存余额的首 SKU。 */ + private List summarizeSkus(List cartons, InventoryBalance balance) { + var boxes = new LinkedHashMap(); var pieces = new HashMap(); var unknown = new HashSet(); + for (var carton : cartons) for (var content : contents(carton, null)) { + boxes.merge(content.skuCode(), 1L, Math::addExact); + if (content.availablePieces() == null) unknown.add(content.skuCode()); else pieces.merge(content.skuCode(), content.availablePieces(), Math::addExact); + } + return boxes.entrySet().stream().map(e -> new CargoDiscardResponses.CandidateSku(e.getKey(), sku(balance).equals(e.getKey()) ? balance.getProductNameZh() : null, + e.getValue(), unknown.contains(e.getKey()) ? null : pieces.get(e.getKey()))).toList(); + } + + /** 箱数、箱唛和唯一性均完整时,才允许将库存对应到具体箱子。 */ + private boolean completeCartons(List cartons, long count) { + return cartons.size() == count && cartons.stream().allMatch(c -> c.getCartonMark() != null && !c.getCartonMark().isBlank()) + && cartons.stream().map(c -> c.getCartonMark()).distinct().count() == cartons.size(); + } + /** 转运实收 JSON 提供完整 SKU 与件数;无 JSON 的单 SKU 来源保留箱数单位。 */ + private List contents(com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow carton, String productName) { + if (carton.getSkuItemsJson() == null || carton.getSkuItemsJson().isBlank()) + return List.of(new CargoDiscardResponses.CandidateSku(carton.getSkuCode(), productName, 1)); + try { + var values = objectMapper.readTree(carton.getSkuItemsJson()); + if (!values.isArray()) throw conflict("箱内 SKU 事实不完整"); + var quantities = new LinkedHashMap(); + for (var value : values) { + String code = value.path("skuCode").asText().trim(); long pieces = value.path("quantity").asLong(); + if (code.isEmpty() || pieces <= 0) throw conflict("箱内 SKU 事实不完整"); + quantities.merge(code, pieces, Math::addExact); + } + if (quantities.isEmpty()) throw conflict("箱内 SKU 事实不完整"); + return quantities.entrySet().stream().map(e -> new CargoDiscardResponses.CandidateSku(e.getKey(), Objects.equals(e.getKey(), carton.getSkuCode()) ? productName : null, 1, e.getValue())).toList(); + } catch (com.fasterxml.jackson.core.JsonProcessingException e) { throw conflict("箱内 SKU 事实无法读取"); } + } + + /** 驳回或取消时恢复所选箱状态并释放本次冻结数量。 */ public void release(String customer, CargoDiscardApplication application, CargoDiscardItem line) { changeSelectedCartons(customer, application, line, "AVAILABLE"); - commands.release(customer, application.getCreatedBy(), move(application, line, line.getDiscardPackages(), "RELEASE")); + int physical = physicalPackages(customer, application, line); + if (physical > 0) commands.release(customer, application.getCreatedBy(), move(application, line, physical, "RELEASE")); } + /** 无逐箱对应关系时按仓库实点扣冻结库存,未废弃余量在同一作业事务释放。 */ + @org.springframework.transaction.annotation.Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public void discard(String customer, String operator, CargoDiscardApplication application, CargoDiscardItem line, int actual) { - if (actual != line.getDiscardPackages()) throw conflict("必须完成本次申请所选全部箱子,不能漏出"); - changeSelectedCartons(customer, application, line, "DISCARDED"); - commands.discardHeld(customer, operator, move(application, line, actual, "DISCARD")); + if (actual < 0 || actual > line.getDiscardPackages()) throw conflict("实际废弃箱数不能超过申请预留箱数"); + if (!actualQuantityEditable(line) && actual != line.getDiscardPackages()) + throw conflict("必须完成本次申请所选全部箱子,不能漏出"); + // 已选择具体箱子的作业仍按箱唛结算,不能按数量猜测哪些箱已被废弃。 + if (!actualQuantityEditable(line)) changeSelectedCartons(customer, application, line, "DISCARDED"); + int physical = actualQuantityEditable(line) ? actual : physicalPackages(customer, application, line); + if (physical > 0) commands.discardHeld(customer, operator, move(application, line, physical, "DISCARD")); + int remaining = line.getDiscardPackages() - actual; + if (remaining > 0) commands.release(customer, operator, move(application, line, remaining, "ACTUAL_RELEASE")); } - private int changeBox(String customer, Long cartonId, String source, String from, String to) { - if ("TRANSFER".equals(source)) return stock.changeTransferCartonStatus(customer, cartonId, from, to); - if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); - return stock.changeGenericCartonStatus(customer, cartonId, from, to); + /** 同仓位同箱只由首个 SKU 明细执行库存命令;其他 SKU 保存件数事实,不重复扣物理箱桶。 */ + private int physicalPackages(String customer, CargoDiscardApplication application, CargoDiscardItem line) { + var marks = cartonMarks(line); + if (marks.isEmpty()) return line.getDiscardPackages(); + var previous = new HashSet(); + for (var other : repository.applicationItems(customer, application.getId())) { + if (Objects.equals(other.getInventoryPositionId(), line.getInventoryPositionId()) && other.getId() != null && line.getId() != null && other.getId() < line.getId()) previous.addAll(cartonMarks(other)); + } + return (int)marks.stream().filter(mark -> !previous.contains(mark)).count(); } + + /** 只有未保存具体箱唛的普通仓位库存允许仓库按实点箱数完成。 */ + public boolean actualQuantityEditable(CargoDiscardItem line) { + return "INVENTORY".equals(line.getSourceType()) && line.getInventoryPositionId() != null + && cartonMarks(line).isEmpty() && !Boolean.TRUE.equals(line.getCartonScanRequired()); + } + + /** 按箱来源执行带原状态条件的变更。 */ + private int changeBox(String customer, Long cartonId, String source, String from, String to) { + if ("TRANSFER".equals(source)) return repository.changeTransferCarton(customer, cartonId, from, to); + if (!"INBOUND".equals(source)) throw conflict("箱子库存来源不正确"); + return repository.changeInboundCarton(customer, cartonId, from, to); + } + /** 保存本次选箱事实,后续作业不得按数量猜测箱唛。 */ private void snapshotMarks(CargoDiscardItem line, List marks) { try { line.setCartonMarksJson(objectMapper.writeValueAsString(marks)); } catch (Exception e) { throw new IllegalStateException("Cannot snapshot carton marks", e); } } - /** 按真实箱来源完成废弃或释放,转运箱释放后恢复已清点状态。 */ + /** 仅变更当前申请明细保存的箱子,转运箱释放后恢复已清点状态。 */ private void changeSelectedCartons(String customer, CargoDiscardApplication application, CargoDiscardItem line, String status) { - for (var link : links.listApplication(customer, application.getId())) { + for (var link : repository.selectedCartons(customer, application.getId())) { if (Objects.equals(link.getItemId(), line.getId()) && changeBox(customer, link.getCartonId(), link.getSourceType(), "DISCARD_PENDING", "TRANSFER".equals(link.getSourceType()) && "AVAILABLE".equals(status) ? "COUNTED" : status) != 1) throw conflict("预留箱子的库存状态发生变化"); } } + /** 库存命令使用申请和明细构成的稳定来源与幂等键。 */ private InventoryCommandRequests.Move move(CargoDiscardApplication application, CargoDiscardItem line, int count, String action) { String cycle = "CARGO_DISCARD:" + application.getId() + ":" + line.getId(); return new InventoryCommandRequests.Move(line.getInventoryPositionId(), count, application.getDiscardNo(), null, cycle, cycle + ":" + action, "货物废弃 " + action); } + /** 读取并核对客户指定库存仓位。 */ public InventoryPosition position(String customer, Long id) { - var position = positions.findTenantPosition(customer, id); + var position = repository.position(customer, id); if (position == null) throw conflict("废弃库存仓位不存在或不属于当前客户"); return position; } + /** 只解析申请保存的箱唛,缺失对应关系保留为空。 */ public List cartonMarks(CargoDiscardItem line) { if (line.getCartonMarksJson() == null) return List.of(); try { return objectMapper.readValue(line.getCartonMarksJson(), new com.fasterxml.jackson.core.type.TypeReference>() {}); } catch (Exception e) { throw new IllegalStateException("Cannot read carton marks", e); } } + /** 无 SKU 来源保留未指定标记,不猜测商品。 */ private static String sku(InventoryBalance balance) { return balance.getSkuCode() == null || balance.getSkuCode().isBlank() ? "UNSPECIFIED" : balance.getSkuCode(); } + /** 空库存桶按零处理。 */ private static long value(Long value) { return value == null ? 0 : value; } + /** 返回可恢复的业务冲突。 */ private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index ede9b6852..a150fdfd0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -5,11 +5,6 @@ import java.util.List; /** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ @Mapper public interface DispatchBolMapper { - /** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */ - default List findByDispatchOrder(String customerId, Long dispatchOrderId) { - return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList(); - } - @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java index 45b9a0687..97f1b33a4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/service/DispatchPickOrderAttributionService.java @@ -20,7 +20,7 @@ import java.util.List; import java.util.Map; import java.util.Objects; import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; -import com.baiyue.lastleg.dispatch.service.DispatchItemSourceService; +import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation; @Service public class DispatchPickOrderAttributionService { @@ -39,7 +39,7 @@ public class DispatchPickOrderAttributionService { /** 第四来源分支链接真实冻结贡献,全部归属、履约计数和库存跟随原扫码事务提交。 */ @org.springframework.transaction.annotation.Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) - public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List allocations) { + public void attributeRelabel(String customerId,DispatchPdaPick pick,Long orderId,List allocations) { long packages=0,quantity=0;var seen=new java.util.HashSet(); for(var allocation:allocations) { var source=allocation.dispatchItemSourceId()==null?null:relabelSources.find(allocation.dispatchItemSourceId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSourceAllocation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSourceAllocation.java new file mode 100644 index 000000000..1b79200f6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchSourceAllocation.java @@ -0,0 +1,4 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 一次真实拣货消费的冻结来源额度;件数未知时保留空,不接受请求伪造来源身份。 */ +public record DispatchSourceAllocation(Long dispatchItemSourceId, long packages, Long productQuantity) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java index 9aeb1e526..5c58a2348 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourceService.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.entity.*; import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; import com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository; +import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation; import com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; @@ -23,9 +24,6 @@ public class DispatchItemSourceService { /** 来源事实经提供者读取,冻结贡献及已消费数经本域仓储读取。 */ public DispatchItemSourceService(DispatchItemSourceRepository repository,RelabelDispatchSourcePort relabel,ObjectMapper json){this.repository=repository;this.relabel=relabel;this.json=json;} - /** packages是本次实际消费量,件数未知保留空;不是请求可伪造的来源身份。 */ - public record Allocation(Long dispatchItemSourceId,long packages,Long productQuantity) { } - /** 主单原事务已锁齐全部来源与库存;所有当前成员一起核对,仅为新增明细追加不可变快照。 */ @Transactional(propagation=Propagation.MANDATORY) public void capture(DispatchAggregate root,List all,List added,LocalDateTime at) { @@ -108,7 +106,7 @@ public class DispatchItemSourceService { /** 本客户本实托的全部剩余贡献必须累计核对HOLD,逐item单独足够不能证明共享position总量足够。 */ @Transactional(propagation=Propagation.MANDATORY) - public List allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List actualStock) { + public List allocate(long dispatch,DispatchItem item,long positionId,long packages,long productQuantity,String cartonMark,List actualStock) { var all=repository.all(dispatch);var consumed=repository.pickedAll(List.of(dispatch)).stream().collect(Collectors.toMap(p->p.sourceId(),p->p)); var expected=new HashMap(); for(var source:all)if(Objects.equals(item.getCustomerId(),source.getCustomerId()) && Objects.equals(item.getWarehousePalletId(),source.getWarehousePalletId())) { @@ -127,14 +125,14 @@ public class DispatchItemSourceService { for(var row:rows){long left=row.getPackageCount()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).packages():0);remaining.put(row.getId(),left);total=Math.addExact(total,left);} if(packages<=0 || packages>total)throw conflict("实际扫码超过本换标来源本位置的剩余箱数"); if(rows.size()>1 && packages!=total)throw conflict("本位置含多条换标贡献,请扫描具体箱唛后确认部分箱数"); - var result=new ArrayList();long knownQuantity=0; + var result=new ArrayList();long knownQuantity=0; for(var row:rows)if(row.getProductQuantity()!=null){long used=consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0;long left=row.getProductQuantity()-used;if(left<0)throw conflict("换标实际件数超过来源贡献");knownQuantity=Math.addExact(knownQuantity,left);} if(rows.size()>1) { if(productQuantity!=knownQuantity)throw conflict("多来源件数不能推算,请扫描明确箱唛并确认实际件数"); - for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new Allocation(row.getId(),remaining.get(row.getId()),quantity));} + for(var row:rows){Long quantity=row.getProductQuantity()==null?null:row.getProductQuantity()-(consumed.containsKey(row.getId())?consumed.get(row.getId()).productQuantity():0);result.add(new DispatchSourceAllocation(row.getId(),remaining.get(row.getId()),quantity));} } else { var row=rows.get(0);if(row.getProductQuantity()!=null && (productQuantity>knownQuantity || packages==total && productQuantity!=knownQuantity))throw conflict("实际扫码件数与本来源剩余件数不一致"); - result.add(new Allocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity)); + result.add(new DispatchSourceAllocation(row.getId(),packages,row.getProductQuantity()==null && productQuantity==0?null:productQuantity)); } return List.copyOf(result); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 4f5fc7da3..5e157632c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -65,7 +65,7 @@ public class DispatchPickingService { boolean nativeReturn="RETURN_RELABEL".equals(item.getSourceStage()); if(nativeReturn?request.positionId()!=null:request.positionId()==null)throw bad(nativeReturn?"退货换标请扫描来源箱唛":"普通入仓请选本托实际库存仓位"); long pickId=IdWorker.getId();Long returnCartonId=null;boolean ordinaryRelabel=!nativeReturn && "RELABEL_ORDER".equals(item.getSourceBusinessType()); - List relabelAllocations=List.of(); + List relabelAllocations=List.of(); if(nativeReturn) { attribution.prepareNativeReturn(item.getCustomerId(),item.getOrderId()); returnCartonId=returns.pick(item,request.cartonMark(),request.packages(),request.productQuantity(),pickId,number).getId(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java index d640aad0b..1691d5933 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/LastlegOrderSummary.java @@ -75,7 +75,9 @@ public record LastlegOrderSummary( LocalDateTime updatedAt, @Schema(description = "同一主单关联的全部换标子单及各自状态,按创建时间排序") List relabelApplications, - BusinessSource businessSource + BusinessSource businessSource, + @Schema(description = "仓储转运专属预报、历史实收及当前箱托数量;其他业务为空") + com.baiyue.lastleg.transfer.dto.TransferQuantityView warehouseTransferQuantity ) { @Schema(description = "从正式单据关联读取的商业来源;与履约类型 orderType 独立") public record BusinessSource(String businessType, String label, String businessOrderNo, String externalBookingNo) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java index 161a4281d..18d1c5813 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java @@ -1,13 +1,14 @@ package com.baiyue.lastleg.fleet.pickup; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import org.apache.ibatis.annotations.*; import java.util.List; /** 取货持久化只访问 Lastleg 自有库,客户与原业务号始终成对使用。 */ @Mapper public interface FleetPickupMapper extends BaseMapper { + String SOURCE_COLUMNS = "id,customer_id,transfer_no,inbound_code,lastleg_order_no,operating_warehouse_id," + + "delivery_address,pickup_contact_name,pickup_contact_phone,package_count,status,delivery_method"; String ELIGIBLE = " FROM ll_warehouse_transfer_order w LEFT JOIN ll_fleet_pickup_task p ON p.transfer_order_id=w.id " + "WHERE w.delivery_method='WAREHOUSE_PICKUP' AND w.transfer_no=w.inbound_code " + "AND w.status='WAIT_PDA_RECEIVING' AND (p.transfer_order_id IS NULL OR p.trip_no IS NULL) " @@ -21,14 +22,11 @@ public interface FleetPickupMapper extends BaseMapper { @Select("SELECT COUNT(*)" + ELIGIBLE) long countCandidates(@Param("keyword") String keyword); /** 锁定源订单,串行化补箱、清点与取货任务关联。 */ - @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE") - WarehouseTransferOrder lockSource(@Param("customer") String customer,@Param("number") String number); + @Select("SELECT " + SOURCE_COLUMNS + " FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE") + FleetPickupSource lockSource(@Param("customer") String customer,@Param("number") String number); /** 读取明确客户下的源订单,不接收来自客户端的客户范围推断。 */ - @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}") - WarehouseTransferOrder source(@Param("customer") String customer,@Param("number") String number); - /** PDA 作业仍按已绑定海外仓锁定原单。 */ - @Select("SELECT * FROM ll_warehouse_transfer_order WHERE operating_warehouse_id=#{warehouse} AND transfer_no=#{number} FOR UPDATE") - WarehouseTransferOrder lockPdaSource(@Param("warehouse") String warehouse,@Param("number") String number); + @Select("SELECT " + SOURCE_COLUMNS + " FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number}") + FleetPickupSource source(@Param("customer") String customer,@Param("number") String number); /** 读取取货任务,原订单锁负责同票并发互斥。 */ @Select("SELECT * FROM ll_fleet_pickup_task WHERE transfer_order_id=#{id}") FleetPickupTask task(@Param("id") long id); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java index 70eb1ebd5..ceb88b69c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java @@ -2,7 +2,6 @@ package com.baiyue.lastleg.fleet.pickup; import com.baiyue.lastleg.fleet.trip.FleetTripEntity; import com.baiyue.lastleg.fleet.trip.FleetTripMapper; -import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import org.springframework.stereotype.Repository; import java.util.List; @@ -18,9 +17,7 @@ public class FleetPickupRepository { /** 查询匹配需求总数。 */ public long count(String keyword) { return mapper.countCandidates(keyword); } /** 读取或锁定客户原单。 */ - public WarehouseTransferOrder source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); } - /** 按 PDA 仓库范围锁定原单。 */ - public WarehouseTransferOrder pdaSource(String warehouse,String number) { return mapper.lockPdaSource(warehouse,number); } + public FleetPickupSource source(String customer,String number,boolean lock) { return lock?mapper.lockSource(customer,number):mapper.source(customer,number); } /** 读取原单唯一取货记录。 */ public FleetPickupTask task(long id) { return mapper.task(id); } /** 读取车次全部取货成员。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java index f454ebfb4..50002ec18 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java @@ -6,7 +6,6 @@ import com.baiyue.lastleg.fleet.driver.FleetWarehouseDirectory; import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; import com.baiyue.lastleg.fleet.trip.*; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; -import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.http.HttpStatus; @@ -59,7 +58,7 @@ public class FleetPickupService { var sources=request.sources().stream().sorted(Comparator.comparing(FleetPickupModels.Source::customerId) .thenComparing(FleetPickupModels.Source::transferNo)).toList(); if(new HashSet<>(sources).size()!=sources.size())throw bad("同一取货单不能重复选择"); - var orders=new ArrayList(); + var orders=new ArrayList(); for(var source:sources) { var order=source(source.customerId(),source.transferNo(),true);eligible(order); // 相同请求并发等待原单锁后,允许返回第一次已经成功的车次。 @@ -107,18 +106,10 @@ public class FleetPickupService { if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !source.getTransferNo().equals(source.getInboundCode()))return null; return project(source,repository.task(source.getId())); } - /** PDA 开始作业前校验已送仓,防止把司机取货冒充仓库实收。 */ - public WarehouseTransferOrder requireMeasuringSource(String warehouse,String number) { - var source=repository.pdaSource(warehouse,number); - if(source==null)throw missing("仓储转运单不存在"); - if(!"WAIT_PDA_RECEIVING".equals(source.getStatus()))throw conflict("当前转运单不允许测量"); - requireArrived(source); - return source; - } /** 已有原单行锁的清点、照片和完成入口复用相同到仓门禁。 */ - public void requireArrived(WarehouseTransferOrder source) { - if("WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) && source.getTransferNo().equals(source.getInboundCode())) { - var task=repository.task(source.getId()); + public void requireArrived(Long sourceId,String deliveryMethod,String transferNo,String inboundCode) { + if("WAREHOUSE_PICKUP".equals(deliveryMethod) && transferNo.equals(inboundCode)) { + var task=repository.task(sourceId); if(task==null || !"ARRIVED".equals(task.getStatus()))throw conflict("该单为仓库上门取货,请先完成车辆取货送仓"); } } @@ -198,7 +189,7 @@ public class FleetPickupService { var trip=pickupTrip(task.getTripNo(),false);return files.download(trip.getId(),fileId); } /** 组装运输进度,车次资源修改后自然回显最新分配。 */ - private FleetPickupModels.Task project(WarehouseTransferOrder source,FleetPickupTask task) { + private FleetPickupModels.Task project(FleetPickupSource source,FleetPickupTask task) { var trip=task==null || task.getTripNo()==null?null:repository.trip(task.getTripNo(),false); String status=task==null || task.getTripNo()==null?("PENDING_REVIEW".equals(source.getStatus()) || "DRAFT".equals(source.getStatus())?"PENDING_REVIEW":"WAITING"):task.getStatus(); return new FleetPickupModels.Task(source.getCustomerId(),source.getTransferNo(),source.getLastlegOrderNo(),source.getOperatingWarehouseId(), @@ -215,11 +206,11 @@ public class FleetPickupService { if(task==null || !trip.getTripNo().equals(task.getTripNo()))throw missing("当前车次没有该取货订单");return task; } /** 按客户与单号读取原订单。 */ - private WarehouseTransferOrder source(String customer,String number,boolean lock) { + private FleetPickupSource source(String customer,String number,boolean lock) { var source=repository.source(customer,number,lock);if(source==null)throw missing("仓储转运单不存在");return source; } /** 原业务资格每次执行复核,禁止未审、自送、库存转运和已完成测量单进入。 */ - private static void eligible(WarehouseTransferOrder order) { + private static void eligible(FleetPickupSource order) { if(!"WAREHOUSE_PICKUP".equals(order.getDeliveryMethod()) || !order.getTransferNo().equals(order.getInboundCode()) || !"WAIT_PDA_RECEIVING".equals(order.getStatus()))throw conflict("该单不是已审核待收货的仓库上门取货单"); if(blank(order.getDeliveryAddress())==null || blank(order.getPickupContactName())==null || blank(order.getPickupContactPhone())==null) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupSource.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupSource.java new file mode 100644 index 000000000..3fa120ccc --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupSource.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.fleet.pickup; + +import lombok.Data; + +/** 车队只读取货资格和展示所需来源摘要,不共享转运领域实体或写入原订单。 */ +@Data +public class FleetPickupSource { + private Long id; + private String customerId; + private String transferNo; + private String inboundCode; + private String lastlegOrderNo; + private String operatingWarehouseId; + private String deliveryAddress; + private String pickupContactName; + private String pickupContactPhone; + private Integer packageCount; + private String status; + private String deliveryMethod; +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java index 05c721b56..37a5a5a17 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/controller/InventoryQueryController.java @@ -35,6 +35,16 @@ public class InventoryQueryController { private static final String BASE_PATH = "/api/platform/v1/last-leg/inventory"; @Resource private InventoryQueryService service; + @Resource private com.baiyue.lastleg.inventory.query.service.InventoryPieceService pieces; + + /** 完整筛选读取当前可用件数、箱内全部SKU及未知箱数,不改变原有箱数契约。 */ + @GetMapping(BASE_PATH + "/piece-projection") + @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") + public ApiResponse pieceProjection( + @AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String customerId, + @Valid InventoryQueryRequests.BalanceQuery query) { + return ApiResponse.success(pieces.project(platformScope(user,customerId),query)); + } @GetMapping(BASE_PATH + "/summary") @PreAuthorize("hasAuthority('" + OverseasPermissions.INVENTORY_LIST + "')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPieceProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPieceProjection.java new file mode 100644 index 000000000..7ac33301c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/dto/InventoryPieceProjection.java @@ -0,0 +1,14 @@ +package com.baiyue.lastleg.inventory.query.dto; +import java.util.List; +/** 当前严格可用件数与完整混箱SKU内容;不完整映射保持未知,不覆盖原有箱数API。 */ +public record InventoryPieceProjection(Long availablePieceCount,long knownAvailablePieceCount,long unknownCartonCount, + long availableCartonCount,long availablePalletCount,List groups) { + /** 与通用库存按客户、仓、目的仓、记账SKU分组对齐,箱内SKU不重复累计物理箱。 */ + public record Group(String customerId,String warehouseId,String destinationCode,String anchorSkuCode, + Long availablePieceCount,long knownAvailablePieceCount,long unknownCartonCount,long availableCartonCount, + long availablePalletCount,List skuContents,List cartons) { } + /** 箱内实际SKU件数。 */ + public record Sku(String skuCode,long quantity) { } + /** 保留箱唛及所在仓位,混箱SKU共享同一物理箱。 */ + public record Carton(String cartonMark,Long inventoryPositionId,Long palletId,Long quantity,List items) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryPieceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryPieceMapper.java new file mode 100644 index 000000000..2563379a2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryPieceMapper.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.inventory.query.mapper; + +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 从原箱清点事实及实际目的仓签收定位箱内SKU,不按首SKU或箱规换算件数。 */ +@Mapper +public interface InventoryPieceMapper { + String POSITION_CTE = "WITH balances AS (SELECT * FROM ll_inventory_balance " + InventoryQueryMapper.BALANCE_FILTER + """ + ), occupied AS ( + SELECT DISTINCT t.customer_id,t.operating_warehouse_id warehouse_id,t.inbound_code, + COALESCE(NULLIF(c.customer_carton_mark,''),c.carton_mark) carton_mark + FROM ll_warehouse_transfer_order t JOIN ll_warehouse_transfer_carton c + ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id + -- 出库已扣减来源箱,后续签收生成目的仓库存,不再把同一原箱当作待出库预留。 + WHERE t.transfer_no<>t.inbound_code AND t.status NOT IN + ('DRAFT','REJECTED','CANCELLED','OUTBOUNDED','PARTIALLY_RECEIVED','RECEIVED') + ), raw_positions AS (SELECT p.* FROM ll_inventory_position p JOIN balances b + ON b.id=p.balance_id AND b.customer_id=p.customer_id WHERE p.available_packages>0), + located AS ( + SELECT p.id position_id,c.id carton_id,c.carton_mark,c.counted_quantity,c.sku_items_json + FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id + JOIN ll_warehouse_transfer_order t ON t.customer_id=b.customer_id AND t.inbound_order_id=b.inbound_order_id + AND t.operating_warehouse_id=b.overseas_warehouse_id AND t.transfer_no=t.inbound_code + JOIN ll_warehouse_transfer_pallet tp ON tp.transfer_order_id=t.id AND tp.customer_id=t.customer_id AND tp.warehouse_pallet_id=p.pallet_id + JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=t.id AND c.customer_id=t.customer_id + AND c.transfer_pallet_id=tp.id AND c.status='COUNTED' + JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND (b.sku_code='MIXED' OR i.sku_code=b.sku_code) + UNION + SELECT p.id,c.id,c.carton_mark,c.counted_quantity,c.sku_items_json + FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id + JOIN ll_warehouse_transfer_receipt r ON r.destination_inventory_position_id=p.id AND r.customer_id=p.customer_id + JOIN ll_warehouse_transfer_inventory_allocation a ON a.id=r.allocation_id AND a.customer_id=r.customer_id AND a.status='RECEIVED' + JOIN ll_inventory_position sp ON sp.id=a.inventory_position_id AND sp.customer_id=a.customer_id + JOIN ll_warehouse_transfer_order outgoing ON outgoing.id=a.transfer_order_id AND outgoing.customer_id=a.customer_id + JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=outgoing.id AND chosen.customer_id=outgoing.customer_id + JOIN ll_warehouse_transfer_order origin ON origin.customer_id=outgoing.customer_id AND origin.inbound_code=outgoing.inbound_code + AND origin.operating_warehouse_id=outgoing.operating_warehouse_id AND origin.transfer_no=origin.inbound_code + JOIN ll_warehouse_transfer_carton c ON c.transfer_order_id=origin.id AND c.customer_id=origin.customer_id + AND c.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND c.status='SHIPPED' + JOIN ll_warehouse_transfer_pallet tp ON tp.id=c.transfer_pallet_id AND tp.customer_id=c.customer_id AND tp.warehouse_pallet_id=sp.pallet_id + JOIN ll_warehouse_transfer_item i ON i.id=c.transfer_item_id AND i.customer_id=c.customer_id AND (b.sku_code='MIXED' OR i.sku_code=b.sku_code) + ), reservations AS ( + SELECT p.id position_id,COUNT(DISTINCT l.carton_id) reserved_count + FROM raw_positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id + JOIN located l ON l.position_id=p.id JOIN occupied o ON o.customer_id=b.customer_id + AND o.warehouse_id=b.overseas_warehouse_id AND o.inbound_code=b.inbound_code AND o.carton_mark=l.carton_mark + GROUP BY p.id + ), positions AS ( + SELECT p.*,GREATEST(0,p.available_packages-COALESCE(r.reserved_count,0)) free_packages + FROM raw_positions p LEFT JOIN reservations r ON r.position_id=p.id + ) + SELECT p.id positionId,p.balance_id balanceId,b.customer_id customerId,b.overseas_warehouse_id warehouseId, + b.destination_warehouse_code destinationCode,b.sku_code anchorSkuCode,p.pallet_id palletId, + p.free_packages availablePackages,l.carton_id cartonId,l.carton_mark cartonMark, + l.counted_quantity countedQuantity,l.sku_items_json skuItemsJson + FROM positions p JOIN balances b ON b.id=p.balance_id AND b.customer_id=p.customer_id + LEFT JOIN located l ON l.position_id=p.id AND NOT EXISTS ( + SELECT 1 FROM occupied o WHERE o.customer_id=b.customer_id AND o.warehouse_id=b.overseas_warehouse_id + AND o.inbound_code=b.inbound_code AND o.carton_mark=l.carton_mark) + WHERE p.free_packages>0 + ORDER BY p.id,l.carton_id + """; + + /** 完整筛选取箱级投影;页码/每页大小不参与查询。 */ + @Select("") + List rows(@Param("customerId") String customerId,@Param("q") InventoryQueryRequests.BalanceQuery query); + + /** 每个当前可用仓位及其匹配箱事实;未知箱以空cartonId表达。 */ + record PieceRow(Long positionId,Long balanceId,String customerId,String warehouseId,String destinationCode, + String anchorSkuCode,Long palletId,long availablePackages,Long cartonId,String cartonMark,Long countedQuantity,String skuItemsJson) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java index 03702fad3..1aa57889d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/mapper/InventoryQueryMapper.java @@ -24,7 +24,7 @@ public interface InventoryQueryMapper { " AND inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + " AND inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=ll_inventory_balance.id AND l.customer_id=ll_inventory_balance.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + - " AND sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND (sku_code LIKE CONCAT('%',#{q.skuCode},'%') OR EXISTS (SELECT 1 FROM ll_warehouse_transfer_order tq JOIN ll_warehouse_transfer_carton cq ON cq.transfer_order_id=tq.id AND cq.customer_id=tq.customer_id WHERE tq.customer_id=ll_inventory_balance.customer_id AND tq.inbound_order_id=ll_inventory_balance.inbound_order_id AND tq.operating_warehouse_id=ll_inventory_balance.overseas_warehouse_id AND tq.transfer_no=tq.inbound_code AND cq.status IN ('COUNTED','DISCARD_PENDING') AND EXISTS (SELECT 1 FROM ll_warehouse_transfer_item iq WHERE iq.id=cq.transfer_item_id AND iq.customer_id=cq.customer_id AND (ll_inventory_balance.sku_code='MIXED' OR iq.sku_code=ll_inventory_balance.sku_code)) AND JSON_SEARCH(cq.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL) OR EXISTS (SELECT 1 FROM ll_inventory_position dp JOIN ll_warehouse_transfer_receipt rr ON rr.destination_inventory_position_id=dp.id AND rr.customer_id=dp.customer_id JOIN ll_warehouse_transfer_inventory_allocation aa ON aa.id=rr.allocation_id AND aa.customer_id=rr.customer_id AND aa.status='RECEIVED' JOIN ll_inventory_position sp ON sp.id=aa.inventory_position_id AND sp.customer_id=aa.customer_id JOIN ll_warehouse_transfer_order ot ON ot.id=aa.transfer_order_id AND ot.customer_id=aa.customer_id JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=ot.id AND chosen.customer_id=ot.customer_id JOIN ll_warehouse_transfer_order origin ON origin.customer_id=ot.customer_id AND origin.inbound_code=ot.inbound_code AND origin.operating_warehouse_id=ot.operating_warehouse_id AND origin.transfer_no=origin.inbound_code JOIN ll_warehouse_transfer_carton cc ON cc.transfer_order_id=origin.id AND cc.customer_id=origin.customer_id AND cc.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND cc.status='SHIPPED' JOIN ll_warehouse_transfer_pallet pp ON pp.id=cc.transfer_pallet_id AND pp.customer_id=cc.customer_id AND pp.warehouse_pallet_id=sp.pallet_id JOIN ll_warehouse_transfer_item ii ON ii.id=cc.transfer_item_id AND ii.customer_id=cc.customer_id WHERE dp.balance_id=ll_inventory_balance.id AND dp.customer_id=ll_inventory_balance.customer_id AND dp.available_packages>0 AND (ll_inventory_balance.sku_code='MIXED' OR ii.sku_code=ll_inventory_balance.sku_code) AND JSON_SEARCH(cc.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL))" + " AND (product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR product_name_en LIKE CONCAT('%',#{q.productName},'%'))" + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + " AND EXISTS (SELECT 1 FROM ll_inventory_position p WHERE p.balance_id=ll_inventory_balance.id AND p.customer_id=ll_inventory_balance.customer_id AND p.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + @@ -48,7 +48,7 @@ public interface InventoryQueryMapper { " AND b.inbound_order_no LIKE CONCAT('%',#{q.inboundOrderNo},'%')" + " AND b.inbound_code LIKE CONCAT('%',#{q.inboundCode},'%')" + " AND EXISTS (SELECT 1 FROM ll_inventory_ledger l WHERE l.balance_id=b.id AND l.customer_id=b.customer_id AND l.business_order_no LIKE CONCAT('%',#{q.businessOrderNo},'%'))" + - " AND b.sku_code LIKE CONCAT('%',#{q.skuCode},'%')" + + " AND (b.sku_code LIKE CONCAT('%',#{q.skuCode},'%') OR EXISTS (SELECT 1 FROM ll_warehouse_transfer_order tq JOIN ll_warehouse_transfer_carton cq ON cq.transfer_order_id=tq.id AND cq.customer_id=tq.customer_id WHERE tq.customer_id=b.customer_id AND tq.inbound_order_id=b.inbound_order_id AND tq.operating_warehouse_id=b.overseas_warehouse_id AND tq.transfer_no=tq.inbound_code AND cq.status IN ('COUNTED','DISCARD_PENDING') AND EXISTS (SELECT 1 FROM ll_warehouse_transfer_item iq WHERE iq.id=cq.transfer_item_id AND iq.customer_id=cq.customer_id AND (b.sku_code='MIXED' OR iq.sku_code=b.sku_code)) AND JSON_SEARCH(cq.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL) OR EXISTS (SELECT 1 FROM ll_inventory_position dp JOIN ll_warehouse_transfer_receipt rr ON rr.destination_inventory_position_id=dp.id AND rr.customer_id=dp.customer_id JOIN ll_warehouse_transfer_inventory_allocation aa ON aa.id=rr.allocation_id AND aa.customer_id=rr.customer_id AND aa.status='RECEIVED' JOIN ll_inventory_position sp ON sp.id=aa.inventory_position_id AND sp.customer_id=aa.customer_id JOIN ll_warehouse_transfer_order ot ON ot.id=aa.transfer_order_id AND ot.customer_id=aa.customer_id JOIN ll_warehouse_transfer_carton chosen ON chosen.transfer_order_id=ot.id AND chosen.customer_id=ot.customer_id JOIN ll_warehouse_transfer_order origin ON origin.customer_id=ot.customer_id AND origin.inbound_code=ot.inbound_code AND origin.operating_warehouse_id=ot.operating_warehouse_id AND origin.transfer_no=origin.inbound_code JOIN ll_warehouse_transfer_carton cc ON cc.transfer_order_id=origin.id AND cc.customer_id=origin.customer_id AND cc.carton_mark=COALESCE(NULLIF(chosen.customer_carton_mark,''),chosen.carton_mark) AND cc.status='SHIPPED' JOIN ll_warehouse_transfer_pallet pp ON pp.id=cc.transfer_pallet_id AND pp.customer_id=cc.customer_id AND pp.warehouse_pallet_id=sp.pallet_id JOIN ll_warehouse_transfer_item ii ON ii.id=cc.transfer_item_id AND ii.customer_id=cc.customer_id WHERE dp.balance_id=b.id AND dp.customer_id=b.customer_id AND dp.available_packages>0 AND (b.sku_code='MIXED' OR ii.sku_code=b.sku_code) AND JSON_SEARCH(cc.sku_items_json,'one',CONCAT('%',#{q.skuCode},'%'),NULL,'$[*].skuCode') IS NOT NULL))" + " AND (b.product_name_zh LIKE CONCAT('%',#{q.productName},'%') OR b.product_name_en LIKE CONCAT('%',#{q.productName},'%'))" + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.batch_no LIKE CONCAT('%',#{q.batchNo},'%'))" + " AND EXISTS (SELECT 1 FROM ll_inventory_position p2 WHERE p2.balance_id=b.id AND p2.customer_id=b.customer_id AND p2.warehouse_location_code LIKE CONCAT('%',#{q.warehouseLocationCode},'%'))" + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/repository/InventoryPieceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/repository/InventoryPieceRepository.java new file mode 100644 index 000000000..3cdef3329 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/repository/InventoryPieceRepository.java @@ -0,0 +1,15 @@ +package com.baiyue.lastleg.inventory.query.repository; +import com.baiyue.lastleg.inventory.query.dto.InventoryQueryRequests; +import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; +/** 统一库存混箱事实查询入口。 */ +@Repository +public class InventoryPieceRepository { + @Resource private InventoryPieceMapper mapper; + /** 读取相同筛选范围的全部当前可用箱。 */ + public List rows(String customerId,InventoryQueryRequests.BalanceQuery query) { + return mapper.rows(customerId,query); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryPieceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryPieceService.java new file mode 100644 index 000000000..c2877f4de --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inventory/query/service/InventoryPieceService.java @@ -0,0 +1,71 @@ +package com.baiyue.lastleg.inventory.query.service; +import com.baiyue.lastleg.inventory.query.dto.*; +import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper.PieceRow; +import com.baiyue.lastleg.inventory.query.repository.InventoryPieceRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import java.util.stream.Collectors; + +/** 用完整箱内清点投影当前可用件数,对缺失或不完整映射显式返回未知。 */ +@Service +public class InventoryPieceService { + @Resource private InventoryPieceRepository repository; + @Resource private ObjectMapper objectMapper; + /** 完整筛选的多分组汇总,同一物理托盘按客户及仓去重而非相减。 */ + @Transactional(readOnly=true) + public InventoryPieceProjection project(String customerId,InventoryQueryRequests.BalanceQuery query) { + var rows=repository.rows(customerId,query); + var groups=rows.stream().collect(Collectors.groupingBy(r -> Arrays.asList(r.customerId(),r.warehouseId(),r.destinationCode(),r.anchorSkuCode()),LinkedHashMap::new,Collectors.toList())); + var result=new ArrayList(); var pallets=new HashSet(); + for(var group:groups.values()) { + result.add(group(group)); + for(var row:group) if(row.palletId()!=null) pallets.add(row.customerId()+":"+row.warehouseId()+":"+row.palletId()); + } + long known=0,unknown=0,cartons=0; + for(var group:result) { known=Math.addExact(known,group.knownAvailablePieceCount()); unknown+=group.unknownCartonCount(); cartons+=group.availableCartonCount(); } + return new InventoryPieceProjection(unknown==0?known:null,known,unknown,cartons,pallets.size(),result); + } + + /** 每个仓位只有精确箱映射完整时才计入已知件数,不能从多余箱中截取或按比例推算。 */ + private InventoryPieceProjection.Group group(List rows) { + var positions=rows.stream().collect(Collectors.groupingBy(PieceRow::positionId,LinkedHashMap::new,Collectors.toList())); + var contents=new TreeMap(); var cartons=new ArrayList(); + var pallets=new HashSet(); long available=0,unknown=0,known=0; + for(var position:positions.values()) { + var first=position.get(0); available+=first.availablePackages(); if(first.palletId()!=null) pallets.add(first.palletId()); + var boxes=position.stream().filter(r -> r.cartonId()!=null).collect(Collectors.toMap(PieceRow::cartonId,r -> r,(a,b)->a,LinkedHashMap::new)); + var parsed=new ArrayList(); + for(var row:boxes.values()) parsed.add(carton(row)); + if(boxes.size()!=first.availablePackages() || parsed.stream().anyMatch(c -> c.quantity()==null)) { unknown+=first.availablePackages(); continue; } + for(var box:parsed) { + cartons.add(box); known=Math.addExact(known,box.quantity()); + for(var sku:box.items()) contents.merge(sku.skuCode(),sku.quantity(),Math::addExact); + } + } + var first=rows.get(0); + return new InventoryPieceProjection.Group(first.customerId(),first.warehouseId(),first.destinationCode(),first.anchorSkuCode(), + unknown==0?known:null,known,unknown,available,pallets.size(),contents.entrySet().stream().map(e -> new InventoryPieceProjection.Sku(e.getKey(),e.getValue())).toList(),cartons); + } + + /** 清点JSON与箱总件数必须一致;缺失信息保持未知,不拿首SKU替代混箱。 */ + private InventoryPieceProjection.Carton carton(PieceRow row) { + var items=new ArrayList(); Long quantity=null; + try { + var lines=objectMapper.readTree(row.skuItemsJson()); long sum=0; + if(lines.isArray() && !lines.isEmpty()) { + for(var line:lines) { + String sku=line.path("skuCode").asText(""); var count=line.path("quantity"); + if(sku.isBlank() || !count.asText().matches("[0-9]+")) throw new IllegalArgumentException("无效箱内SKU事实"); + long pieces=Long.parseLong(count.asText()); + if(pieces<=0) throw new IllegalArgumentException("箱内件数必须为正整数"); + items.add(new InventoryPieceProjection.Sku(sku,pieces)); sum=Math.addExact(sum,pieces); + } + if(row.countedQuantity()!=null && sum==row.countedQuantity()) quantity=sum; + } + } catch(Exception invalid) { items.clear(); } + return new InventoryPieceProjection.Carton(row.cartonMark(),row.positionId(),row.palletId(),quantity,items); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PlatformPalletOperationRecoveryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PlatformPalletOperationRecoveryController.java new file mode 100644 index 000000000..6ea260164 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/controller/PlatformPalletOperationRecoveryController.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.palletops.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.palletops.service.PalletOperationRecoveryService; +import org.springframework.web.bind.annotation.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import jakarta.validation.Valid; + +/** 受库存管理权限保护的人工恢复入口,不允许PDA自行批准技术状态关闭。 */ +@RestController +@RequestMapping("/api/platform/v1/last-leg/pallet-operation-recoveries") +@PreAuthorize("hasAuthority('lastleg:inventory:manage')") +public class PlatformPalletOperationRecoveryController { + private final PalletOperationRecoveryService service; + /** 注入恢复业务服务。 */ + public PlatformPalletOperationRecoveryController(PalletOperationRecoveryService service){this.service=service;} + /** 管理员读取原作业证据。 */ + @GetMapping("/{id}") public ApiResponse inspect(@PathVariable long id){return ApiResponse.success(service.inspect(id));} + /** 以认证管理员身份审计关闭。 */ + @PostMapping("/{id}/close-no-effect") + public ApiResponse close(@PathVariable long id,@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody PalletOperationRecoveryService.Close request){return ApiResponse.success(service.close(id,String.valueOf(user.userId()),request));} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PalletOperationRecoveryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PalletOperationRecoveryMapper.java new file mode 100644 index 000000000..1b2518fff --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PalletOperationRecoveryMapper.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.palletops.mapper; + +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import org.apache.ibatis.annotations.*; +import java.time.LocalDateTime; + +/** 恢复与远端意图使用同一行CAS,恢复关闭后旧执行线程不能继续发起远端写入。 */ +@Mapper +public interface PalletOperationRecoveryMapper { + /** 读取原作业技术状态。 */ + @Select("SELECT * FROM ll_pda_pallet_operation WHERE id=#{id}") + PdaPalletOperation find(@Param("id") long id); + /** 恢复审批与远端意图按原行串行。 */ + @Select("SELECT * FROM ll_pda_pallet_operation WHERE id=#{id} FOR UPDATE") + PdaPalletOperation lock(@Param("id") long id); + /** 恢复关闭后拒绝继续持久化远端意图。 */ + @Update("UPDATE ll_pda_pallet_operation SET remote_transitions_json=#{json},updated_at=#{at} WHERE id=#{id} AND status='PROCESSING'") + int journal(@Param("id") long id,@Param("json") String json,@Param("at") LocalDateTime at); + /** 条件关闭原作业并保存恢复审计。 */ + @Update("UPDATE ll_pda_pallet_operation SET status='RECOVERED_NO_EFFECT',result_json=#{audit},updated_at=#{at} WHERE id=#{id} AND status IN ('PROCESSING','FAILED_COMPENSATED')") + int close(@Param("id") long id,@Param("audit") String audit,@Param("at") LocalDateTime at); + /** 旧执行线程不能用失败结果覆盖已恢复或已完成终态。 */ + @Update("UPDATE ll_pda_pallet_operation SET status=#{status},last_error=#{error},updated_at=#{at} WHERE id=#{id} AND status NOT IN ('RECOVERED_NO_EFFECT','COMPLETED')") + int fail(@Param("id") long id,@Param("status") String status,@Param("error") String error,@Param("at") LocalDateTime at); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java index 72d0d164f..a3bf3a31e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/palletops/mapper/PdaPalletOperationMapper.java @@ -53,11 +53,15 @@ public interface PdaPalletOperationMapper extends BaseMapper @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) List mutationPallets(@Param("warehouseId") String warehouseId, @Param("applicationNos") List applicationNos); - /** 收齐原入仓、退回实收、计划及原生退货业务订单,不使用MIN或其他客户代表行。 */ + /** 收齐真实尾程订单;入仓快照ID经同客户来源分片解析,缺失关联保留空值以拒绝改绑。 */ @Select("") + ReturnOrderStatistics.Totals merchantTotals(@Param("customerId") String customerId, @Param("q") com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Query query); + @Select("") + List merchantDimensions(@Param("customerId") String customerId, @Param("q") com.baiyue.lastleg.returnreceipt.dto.ReturnOrderRequests.Query query, @Param("size") long size, @Param("offset") long offset); + @Select("") + ReturnOrderStatistics.Totals platformTotals(@Param("ew") Wrapper wrapper); + @Select("") + List platformDimensions(@Param("ew") Wrapper wrapper, @Param("size") long size, @Param("offset") long offset); +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/repository/ReturnOrderStatisticsRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/repository/ReturnOrderStatisticsRepository.java new file mode 100644 index 000000000..af1321982 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/repository/ReturnOrderStatisticsRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.returnreceipt.repository; + +import com.baiyue.lastleg.returnreceipt.dto.*; +import com.baiyue.lastleg.returnreceipt.mapper.ReturnOrderStatisticsMapper; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import com.baiyue.lastleg.query.LastlegOrderFilter; +import com.baiyue.lastleg.entity.LastlegOrder; +import org.springframework.stereotype.Repository; + +/** 列表统计只经本域Mapper读取正式订单与签收事实。 */ +@Repository +public class ReturnOrderStatisticsRepository { + private final ReturnOrderStatisticsMapper mapper; + /** 注入订单统计映射。 */ + public ReturnOrderStatisticsRepository(ReturnOrderStatisticsMapper mapper) { this.mapper=mapper; } + /** 客户范围来自认证身份,分页不进入总计查询。 */ + public ReturnOrderStatistics merchant(String customerId, ReturnOrderRequests.Query query) { + return new ReturnOrderStatistics(mapper.merchantTotals(customerId,query), + mapper.merchantDimensions(customerId,query,query.size(),Math.multiplyExact(query.page()-1,query.size()))); + } + /** 与平台尾程列表使用同一筛选构造器,只统计其中原生退货单。 */ + public ReturnOrderStatistics platform(LastlegOrderRequests.Query query, String relabelNo) { + var filter=LastlegOrderFilter.create(null,query,relabelNo); + return new ReturnOrderStatistics(mapper.platformTotals(filter), + mapper.platformDimensions(filter,query.size(),Math.multiplyExact(query.page()-1,query.size()))); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnOrderStatisticsService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnOrderStatisticsService.java new file mode 100644 index 000000000..04e333026 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnOrderStatisticsService.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.returnreceipt.service; + +import com.baiyue.lastleg.returnreceipt.dto.*; +import com.baiyue.lastleg.returnreceipt.repository.ReturnOrderStatisticsRepository; +import com.baiyue.lastleg.dto.LastlegOrderRequests; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** 汇总和当前页维度在同一只读快照内返回,避免签收并发时前后口径不一致。 */ +@Service +public class ReturnOrderStatisticsService { + private final ReturnOrderStatisticsRepository repository; + /** 注入只读统计仓储。 */ + public ReturnOrderStatisticsService(ReturnOrderStatisticsRepository repository) { this.repository=repository; } + /** 当前客户的总计与页内维度使用同一只读快照。 */ + @Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public ReturnOrderStatistics merchant(String customerId, ReturnOrderRequests.Query query) { return repository.merchant(customerId,query); } + /** 平台筛选范围中的退货总计与混合列表页内维度。 */ + @Transactional(readOnly=true, isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) + public ReturnOrderStatistics platform(LastlegOrderRequests.Query query, String relabelNo) { return repository.platform(query,relabelNo); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 6ae026a6d..6e556f464 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -86,6 +86,7 @@ public class LastlegOrderService { @Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ReturnOrderReferenceResolver returnOrderReferences; + @Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; public PageResult page(String customerId, LastlegOrderRequests.Query query) { return page(customerId, query, null); @@ -102,11 +103,12 @@ public class LastlegOrderService { Map weights = orderWeights(page.getRecords()); Map> relabels = relabelSubOrders(page.getRecords()); Map businessSources = businessSources(page.getRecords()); + var quantities = transferQuantities.forOrders(page.getRecords()); return new PageResult<>(page.getRecords().stream().map(order -> summary(order, mergeRelations.get(order.getId()), StringUtils.hasText(order.getSourceContainerBookingId()) ? containerNodes.get(order.getSourceContainerBookingId()) : null, dispatchStatuses.getOrDefault(order.getId(), "UNSCHEDULED"), weights.get(order.getId()), - relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()))).toList(), + relabels.getOrDefault(order.getId(), List.of()), businessSources.get(order.getId()), quantities.get(order.getId()))).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } @@ -363,7 +365,7 @@ public class LastlegOrderService { return new LastlegOrderDetailResponse(summary(order, palletMergeRelations(List.of(order)).get(order.getId()), containerNode(order), dispatchStatuses(List.of(order)).getOrDefault(order.getId(), "UNSCHEDULED"), orderWeights(List.of(order)).get(order.getId()), relabelSubOrders(List.of(order)).getOrDefault(order.getId(), List.of()), - businessSources(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() + businessSources(List.of(order)).get(order.getId()), transferQuantities.forOrders(List.of(order)).get(order.getId())), manualNodes, cargo, history.stream() .map(value -> new LastlegOrderDetailResponse.History(value.getId(), value.getFromStatus(), value.getToStatus(), value.getRemark(), value.getCreatedAt())).toList(), fees, operations.stream() .map(value -> new LastlegOrderDetailResponse.Operation(value.getId(), value.getOperationNode(), @@ -532,45 +534,18 @@ public class LastlegOrderService { return orders.size(); } + /** 与主列表复用同一筛选,汇总只纳入仓储转运且不使用分页参数。 */ + public com.baiyue.lastleg.transfer.dto.TransferQuantityTotals transferQuantityTotals( + String customerId, LastlegOrderRequests.Query query, String relabelNo) { + return transferQuantities.totals(filter(customerId, query, relabelNo).eq(LastlegOrder::getOrderType,"WAREHOUSE_TRANSFER")); + } + private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query) { return filter(customerId, query, null); } private LambdaQueryWrapper filter(String customerId, LastlegOrderRequests.Query query, String relabelNo) { - if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "createdFrom must not be after createdTo"); - } - String currentStatus = StringUtils.hasText(query.currentStatus()) ? query.currentStatus() : query.status(); - String warehouse = StringUtils.hasText(query.warehouse()) ? query.warehouse().trim() : null; - return new LambdaQueryWrapper() - .ne(LastlegOrder::getOrderType, "CARGO_DISCARD") - .eq(StringUtils.hasText(customerId), LastlegOrder::getCustomerId, customerId) - .and(StringUtils.hasText(query.orderNo()), nested -> nested - .like(LastlegOrder::getOrderNo, query.orderNo()) - .or() - .like(LastlegOrder::getSourceOrderNo, query.orderNo())) - .and(StringUtils.hasText(relabelNo), nested -> nested - .like(LastlegOrder::getRelabelOrderNo, relabelNo) - .or().apply("EXISTS (SELECT 1 FROM ll_relabel_application r WHERE r.lastleg_order_id=ll_order.id " + - "AND r.customer_id=ll_order.customer_id AND r.relabel_no LIKE CONCAT('%',{0},'%'))", relabelNo)) - .eq(StringUtils.hasText(query.orderType()), LastlegOrder::getOrderType, query.orderType()) - .apply(StringUtils.hasText(query.businessType()), LastlegOrderMapper.BUSINESS_TYPE_FILTER_SQL, query.businessType()) - .apply(StringUtils.hasText(query.businessOrderNo()), LastlegOrderMapper.BUSINESS_ORDER_NO_FILTER_SQL, query.businessOrderNo()) - .apply(StringUtils.hasText(query.externalBookingNo()), LastlegOrderMapper.EXTERNAL_BOOKING_NO_SQL + " LIKE CONCAT('%',{0},'%')", query.externalBookingNo()) - .apply(query.sailingStage() != null, LastlegOrderMapper.SAILING_STAGE_FILTER_SQL, - query.sailingStage() == LastlegOrderRequests.SailingStage.AFTER_DEPARTURE ? 1 : 0) - .like(StringUtils.hasText(query.inboundCode()), LastlegOrder::getInboundCode, query.inboundCode()) - .like(StringUtils.hasText(query.lxCode()), LastlegOrder::getLxCode, query.lxCode()) - .like(StringUtils.hasText(query.sourceOrderNo()), LastlegOrder::getSourceOrderNo, query.sourceOrderNo()) - .like(StringUtils.hasText(query.containerNo()), LastlegOrder::getContainerNo, query.containerNo()) - .eq(StringUtils.hasText(currentStatus), LastlegOrder::getCurrentStatus, currentStatus) - .apply(StringUtils.hasText(warehouse), LastlegOrderMapper.WAREHOUSE_FILTER_SQL, warehouse) - .eq(StringUtils.hasText(query.transferWarehouseCode()), LastlegOrder::getTransferWarehouseCode, - query.transferWarehouseCode()) - .eq(query.abnormal() != null, LastlegOrder::getAbnormal, query.abnormal()) - .eq(query.detained() != null, LastlegOrder::getDetained, query.detained()) - .ge(query.createdFrom() != null, LastlegOrder::getCreatedAt, query.createdFrom()) - .le(query.createdTo() != null, LastlegOrder::getCreatedAt, query.createdTo()); + return com.baiyue.lastleg.query.LastlegOrderFilter.create(customerId, query, relabelNo); } private LastlegOrder findOwned(String customerId, long orderId) { @@ -647,7 +622,8 @@ public class LastlegOrderService { String containerNode, String dispatchStatus, LastlegOrderMapper.OrderWeightRow weight, List relabelApplications, - LastlegOrderSummary.BusinessSource businessSource) { + LastlegOrderSummary.BusinessSource businessSource, + com.baiyue.lastleg.transfer.dto.TransferQuantityView transferQuantity) { List related = relations == null ? List.of() : relations; return new LastlegOrderSummary(o.getId(), displayOrderNo(o), o.getOrderType(), LastlegOrderType.labelOf(o.getOrderType()), o.getDestinationType(), o.getSourceInboundId(), @@ -665,7 +641,7 @@ public class LastlegOrderService { o.getPalletMergeFlag(), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::mergeGroupNo).distinct().findFirst().orElse(null), related.stream().map(LastlegOrderMapper.PalletMergeRelationRow::relatedInboundNo).filter(Objects::nonNull).distinct().toList(), o.getCurrentStatus(), o.getSubOrderStatus(), o.getPrivateRemark(), - o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource); + o.getCustomerRemark(), o.getWarehouseRemark(), o.getCreatedAt(), o.getUpdatedAt(), relabelApplications, businessSource, transferQuantity); } private static Integer packageDifference(LastlegOrder order) { @@ -722,3 +698,4 @@ public class LastlegOrderService { } } + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java index 35ba560dd..193265be0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferController.java @@ -77,6 +77,14 @@ public class MerchantWarehouseTransferController { return ApiResponse.success(service.submit(customer(user), transferNo, operator(user))); } + /** 客户当前筛选的全部转运数量,不受页码影响。 */ + @GetMapping("/quantities") + @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") + public ApiResponse quantities( + @AuthenticationPrincipal AuthenticatedUser user,@Valid WarehouseTransferRequests.Query query) { + return ApiResponse.success(service.quantityTotals(customer(user),query)); + } + @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.MERCHANT_TRANSFER_LIST + "')") public ApiResponse> page(@AuthenticationPrincipal AuthenticatedUser user, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityTotals.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityTotals.java new file mode 100644 index 000000000..1eec2f53f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityTotals.java @@ -0,0 +1,8 @@ +package com.baiyue.lastleg.transfer.dto; + +/** 全筛选结果汇总,不受分页影响;已知小计与未知票数同时返回。 */ +public record TransferQuantityTotals(long orderCount, Long declaredPieceCount, long knownDeclaredPieceCount, + long unknownDeclaredOrderCount, Long receivedPieceCount, long knownReceivedPieceCount, + long unknownReceivedOrderCount, long declaredCartonCount, long receivedCartonCount, + long receivedPalletCount, long availableCartonCount, long shippedCartonCount, + long remainingPalletCount, long shippedPalletCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityView.java new file mode 100644 index 000000000..5776cc3d2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/TransferQuantityView.java @@ -0,0 +1,6 @@ +package com.baiyue.lastleg.transfer.dto; + +/** 分离客户预报、历史实收和当前箱状态;未知件数保持空值。 */ +public record TransferQuantityView(Long lastlegOrderId, Long declaredPieceCount, Long receivedPieceCount, + long declaredCartonCount, long receivedCartonCount, long receivedPalletCount, + long availableCartonCount, long shippedCartonCount, long remainingPalletCount, long shippedPalletCount) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java index 21f4fc5b5..f7b1a01dc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/dto/WarehouseTransferRequests.java @@ -212,7 +212,10 @@ public final class WarehouseTransferRequests { public record Query(@Min(1) long page, @Min(1) @Max(200) long size, String transferNo, String inboundCode, String operatingWarehouseId, String fbaWarehouseCode, String status, Boolean needRelabel, - LocalDateTime createdFrom, LocalDateTime createdTo) { + LocalDateTime createdFrom, LocalDateTime createdTo, + LocalDateTime reviewedFrom, LocalDateTime reviewedTo, + LocalDateTime measuredFrom, LocalDateTime measuredTo, + LocalDateTime outboundFrom, LocalDateTime outboundTo) { public Query { if (page == 0) page = 1; if (size == 0) size = 20; } } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferQuantityMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferQuantityMapper.java new file mode 100644 index 000000000..2c5fe9502 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferQuantityMapper.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.transfer.dto.TransferQuantityView; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 使用主订单相同筛选读取转运数量事实,不把历史实收与剩余库存相减。 */ +@Mapper +public interface TransferQuantityMapper { + String SELECTED = "SELECT id,customer_id FROM ll_order ${ew.customSqlSegment}"; + String RECEIVED = "c.counted_at IS NOT NULL AND c.status IN ('COUNTED','DISCARD_PENDING','DISCARDED','SHIPPED')"; + String FACTS = """ + SELECT t.lastleg_order_id lastlegOrderId, + (SELECT CASE WHEN COUNT(*)=0 OR COUNT(*)<>COUNT(i.declared_quantity) THEN NULL ELSE SUM(i.declared_quantity) END + FROM ll_warehouse_transfer_item i WHERE i.customer_id=t.customer_id AND i.transfer_order_id=t.id) declaredPieceCount, + (SELECT CASE WHEN COUNT(*)=0 OR COUNT(*)<>COUNT(c.counted_quantity) THEN NULL ELSE SUM(c.counted_quantity) END + FROM ll_warehouse_transfer_carton c WHERE c.customer_id=t.customer_id AND c.transfer_order_id=t.id AND + """ + RECEIVED + """ + ) receivedPieceCount, + t.package_count declaredCartonCount, + COALESCE(f.receivedCartonCount,0) receivedCartonCount, COALESCE(f.receivedPalletCount,0) receivedPalletCount, + COALESCE(f.availableCartonCount,0) availableCartonCount, COALESCE(f.shippedCartonCount,0) shippedCartonCount, + COALESCE(f.remainingPalletCount,0) remainingPalletCount, COALESCE(f.shippedPalletCount,0) shippedPalletCount + FROM ll_warehouse_transfer_order t JOIN ( + """ + SELECTED + """ + ) selected ON selected.id=t.lastleg_order_id AND selected.customer_id=t.customer_id + LEFT JOIN ( + SELECT c.customer_id,c.transfer_order_id, + """ + "SUM(" + RECEIVED + ") receivedCartonCount," + + "COUNT(DISTINCT CASE WHEN " + RECEIVED + " THEN p.warehouse_pallet_id END) receivedPalletCount," + """ + SUM(c.status='COUNTED') availableCartonCount, SUM(c.status='SHIPPED') shippedCartonCount, + COUNT(DISTINCT CASE WHEN c.status IN ('COUNTED','DISCARD_PENDING') THEN p.warehouse_pallet_id END) remainingPalletCount, + COUNT(DISTINCT CASE WHEN c.status='SHIPPED' THEN p.warehouse_pallet_id END) shippedPalletCount + FROM ll_warehouse_transfer_carton c LEFT JOIN ll_warehouse_transfer_pallet p + ON p.id=c.transfer_pallet_id AND p.customer_id=c.customer_id + GROUP BY c.customer_id,c.transfer_order_id + ) f ON f.transfer_order_id=t.id AND f.customer_id=t.customer_id + """; + + /** 一次读取筛选范围或本页订单的预报及实收事实。 */ + @Select("") + List rows(@Param("ew") Wrapper filter); + + /** 按真实托盘身份在完整筛选集合去重,同托部分出库可同时属于实收、剩余、出库集合。 */ + @Select("") + List pallets(@Param("ew") Wrapper filter); + + /** 物理托盘事实,保留客户隔离。 */ + record PalletFact(String customerId, Long palletId, String status) { } + + /** 库存转运目的仓实收依正式签收凭据;件数只取精确选箱对应原清点事实。 */ + @Select("") + List movements(@Param("ew") Wrapper filter); + + /** 每个分配来源的实际出库与目的仓签收事实。 */ + record MovementFact(Long lastlegOrderId,String customerId,long cartonCount,Long sourcePalletId,String status, + Long receiptId,Long destinationPalletId,Long pieceCount) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferQuantityRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferQuantityRepository.java new file mode 100644 index 000000000..4015236ee --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferQuantityRepository.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.transfer.repository; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.transfer.dto.TransferQuantityView; +import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 转运数量只读持久化入口。 */ +@Repository +public class TransferQuantityRepository { + @Resource private TransferQuantityMapper mapper; + @Resource private com.baiyue.lastleg.transfer.mapper.WarehouseTransferOrderMapper transfers; + /** 以客户列表相同筛选读取关联主订单,不读取分页参数。 */ + public List selectedOrderIds(Wrapper filter) { + return transfers.selectList(filter).stream() + .map(com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder::getLastlegOrderId) + .filter(java.util.Objects::nonNull).distinct().toList(); + } + /** 读取完整筛选集合的事实,分页由主列表单独处理。 */ + public List rows(Wrapper filter) { return mapper.rows(filter); } + /** 读取完整筛选集合的物理托盘。 */ + public List pallets(Wrapper filter) { return mapper.pallets(filter); } + /** 读取库存转运实际出库及目的仓签收,按来源分配去重。 */ + public List movements(Wrapper filter) { return mapper.movements(filter); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferQuantityService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferQuantityService.java new file mode 100644 index 000000000..c37617cea --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferQuantityService.java @@ -0,0 +1,87 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.conditions.Wrapper; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.transfer.dto.*; +import com.baiyue.lastleg.transfer.repository.TransferQuantityRepository; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.util.*; +import java.util.function.ToLongFunction; +import java.util.stream.Collectors; + +/** 预报、实收、当前剩余分别汇总,空件数不会被零值掩盖。 */ +@Service +public class TransferQuantityService { + @Resource private TransferQuantityRepository repository; + + /** 客户列表共享完整筛选,主订单查询仍显式限定客户。 */ + @Transactional(readOnly=true) + public TransferQuantityTotals merchantTotals(Wrapper filter, String customerId) { + var ids = repository.selectedOrderIds(filter); + var orders = new LambdaQueryWrapper().eq(LastlegOrder::getCustomerId, customerId); + if (ids.isEmpty()) orders.apply("1=0"); else orders.in(LastlegOrder::getId, ids); + return totals(orders); + } + + /** 主列表批量加载本页转运,其他业务不改变字段语义。 */ + public Map forOrders(List orders) { + var ids=orders.stream().filter(o -> "WAREHOUSE_TRANSFER".equals(o.getOrderType())).map(LastlegOrder::getId).toList(); + if(ids.isEmpty()) return Map.of(); + var filter = new LambdaQueryWrapper().in(LastlegOrder::getId,ids); + return withMovements(repository.rows(filter),repository.movements(filter)) + .stream().collect(Collectors.toMap(TransferQuantityView::lastlegOrderId, v -> v)); + } + + /** 同一只读事务按完整筛选汇总,page/size不参与数量计算。 */ + @Transactional(readOnly=true) + public TransferQuantityTotals totals(Wrapper filter) { + var movements=repository.movements(filter); + var rows=withMovements(repository.rows(filter),movements); var pallets=repository.pallets(filter); + long unknownDeclared=rows.stream().filter(v -> v.declaredPieceCount()==null).count(); + long unknownReceived=rows.stream().filter(v -> v.receivedPieceCount()==null).count(); + long declared=sum(rows,v -> v.declaredPieceCount()==null?0:v.declaredPieceCount()); + long received=sum(rows,v -> v.receivedPieceCount()==null?0:v.receivedPieceCount()); + var all=new HashSet(); var remaining=new HashSet(); var shipped=new HashSet(); + for(var pallet:pallets) { + String key=pallet.customerId()+":"+pallet.palletId(); all.add(key); + if(Set.of("COUNTED","DISCARD_PENDING").contains(pallet.status())) remaining.add(key); + if("SHIPPED".equals(pallet.status())) shipped.add(key); + } + for (var move:movements) { + if (move.receiptId()!=null && move.destinationPalletId()!=null) all.add(move.customerId()+":"+move.destinationPalletId()); + if (Set.of("SHIPPED","RECEIVED").contains(move.status()) && move.sourcePalletId()!=null) shipped.add(move.customerId()+":"+move.sourcePalletId()); + } + return new TransferQuantityTotals(rows.size(),unknownDeclared==0?declared:null,declared,unknownDeclared, + unknownReceived==0?received:null,received,unknownReceived,sum(rows,TransferQuantityView::declaredCartonCount), + sum(rows,TransferQuantityView::receivedCartonCount),all.size(),sum(rows,TransferQuantityView::availableCartonCount), + sum(rows,TransferQuantityView::shippedCartonCount),remaining.size(),shipped.size()); + } + + /** 库存转运的实收来自目的仓凭据,原仓出库事实保留独立维度。 */ + private List withMovements(List rows, + List movements) { + var grouped=movements.stream().collect(Collectors.groupingBy(com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact::lastlegOrderId)); + return rows.stream().map(row -> { + var facts=grouped.get(row.lastlegOrderId()); if(facts==null) return row; + long received=0,pieces=0,shipped=0; boolean known=true; var targets=new HashSet(); var sources=new HashSet(); + for(var fact:facts) { + if(fact.receiptId()!=null) { + received=Math.addExact(received,fact.cartonCount()); + if(fact.pieceCount()==null) known=false; else pieces=Math.addExact(pieces,fact.pieceCount()); + if(fact.destinationPalletId()!=null) targets.add(fact.destinationPalletId()); + } + if(Set.of("SHIPPED","RECEIVED").contains(fact.status())) { shipped=Math.addExact(shipped,fact.cartonCount()); if(fact.sourcePalletId()!=null) sources.add(fact.sourcePalletId()); } + } + return new TransferQuantityView(row.lastlegOrderId(),row.declaredPieceCount(),received>0&&known?pieces:null, + row.declaredCartonCount(),received,targets.size(),0,shipped,0,sources.size()); + }).toList(); + } + + /** 使用精确整数累加,溢出不能静默返回错误汇总。 */ + private long sum(List rows,ToLongFunction measure) { + long value=0; for(var row:rows) value=Math.addExact(value,measure.applyAsLong(row)); return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index ff0b99069..8a790f977 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -92,6 +92,7 @@ import java.util.stream.Collectors; @Service public class WarehouseTransferService { + @Resource private TransferQuantityService transferQuantityService; @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; @@ -551,19 +552,15 @@ public class WarehouseTransferService { transferCartons.updateById(carton); } } - @Transactional(readOnly = true) - public PageResult page(String customerId, WarehouseTransferRequests.Query query) { - return page(customerId, query, true); - } - - @Transactional(readOnly = true) - public PageResult page(String customerId, WarehouseTransferRequests.Query query, - boolean includeDrafts) { - if (query.createdFrom() != null && query.createdTo() != null && query.createdFrom().isAfter(query.createdTo())) { - throw badRequest("createdFrom must not be after createdTo"); - } + /** 列表和全筛选数量共享条件,避免翻页或状态分组后口径漂移。 */ + private LambdaQueryWrapper transferPageFilter(String customerId, + WarehouseTransferRequests.Query query, boolean includeDrafts) { + validateQuantityRange(query.createdFrom(), query.createdTo(), "创建"); + validateQuantityRange(query.reviewedFrom(), query.reviewedTo(), "审核"); + validateQuantityRange(query.measuredFrom(), query.measuredTo(), "清点完成"); + validateQuantityRange(query.outboundFrom(), query.outboundTo(), "出库"); List groupedStatuses = customerMainStatuses(query.status()); - LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + return new LambdaQueryWrapper() .eq(StringUtils.hasText(customerId), WarehouseTransferOrder::getCustomerId, customerId) .like(StringUtils.hasText(query.transferNo()), WarehouseTransferOrder::getTransferNo, query.transferNo()) .like(StringUtils.hasText(query.inboundCode()), WarehouseTransferOrder::getInboundCode, query.inboundCode()) @@ -577,7 +574,35 @@ public class WarehouseTransferService { .eq(query.needRelabel() != null, WarehouseTransferOrder::getNeedRelabel, query.needRelabel()) .ge(query.createdFrom() != null, WarehouseTransferOrder::getCreatedAt, query.createdFrom()) .le(query.createdTo() != null, WarehouseTransferOrder::getCreatedAt, query.createdTo()) + .ge(query.reviewedFrom() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedFrom()) + .le(query.reviewedTo() != null, WarehouseTransferOrder::getReviewedAt, query.reviewedTo()) + .ge(query.outboundFrom() != null, WarehouseTransferOrder::getOutboundAt, query.outboundFrom()) + .le(query.outboundTo() != null, WarehouseTransferOrder::getOutboundAt, query.outboundTo()) + .apply(query.measuredFrom() != null, "(SELECT MAX(qp.completed_at) FROM ll_warehouse_transfer_pallet qp WHERE qp.transfer_order_id=ll_warehouse_transfer_order.id AND qp.customer_id=ll_warehouse_transfer_order.customer_id) >= {0}", query.measuredFrom()) + .apply(query.measuredTo() != null, "(SELECT MAX(qp.completed_at) FROM ll_warehouse_transfer_pallet qp WHERE qp.transfer_order_id=ll_warehouse_transfer_order.id AND qp.customer_id=ll_warehouse_transfer_order.customer_id) <= {0}", query.measuredTo()) .orderByDesc(WarehouseTransferOrder::getCreatedAt).orderByDesc(WarehouseTransferOrder::getId); + } + + /** 日期区间统一校验,列表与数量汇总均拒绝反向区间。 */ + private void validateQuantityRange(LocalDateTime from, LocalDateTime to, String name) { + if (from != null && to != null && from.isAfter(to)) throw badRequest(name + "开始时间不能晚于结束时间"); + } + + /** 客户侧完整筛选数量,不读取其他客户且不使用页码。 */ + @Transactional(readOnly = true) + public com.baiyue.lastleg.transfer.dto.TransferQuantityTotals quantityTotals(String customerId, WarehouseTransferRequests.Query query) { + return transferQuantityService.merchantTotals(transferPageFilter(customerId, query, true), customerId); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, WarehouseTransferRequests.Query query) { + return page(customerId, query, true); + } + + @Transactional(readOnly = true) + public PageResult page(String customerId, WarehouseTransferRequests.Query query, + boolean includeDrafts) { + var wrapper = transferPageFilter(customerId, query, includeDrafts); Page result = transfers.selectPage(Page.of(query.page(), query.size()), wrapper); Map relabelApplicationsByNo = relabelApplications(result.getRecords()); Map measurementTimes = measurementTimes(result.getRecords()); @@ -961,7 +986,7 @@ public class WarehouseTransferService { WarehouseTransferRequests.CountCartons request) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); if (transfer == null) throw notFound("当前仓没有该转运单"); - if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); String customerId = transfer.getCustomerId(); List cartons = transferCartons.listOrder(customerId, transfer.getId()); // 客户补箱后旧设备快照不可确认短收,必须刷新取得新增的预报箱唛。 @@ -1096,7 +1121,11 @@ public class WarehouseTransferService { /** 上门取货必须先送仓;按 PDA 绑定仓锁定后继续使用原测量流程。 */ private WarehouseTransferOrder requirePdaMeasuringTransfer(long warehouseId, String transferNo) { - return pickupService.requireMeasuringSource(String.valueOf(warehouseId), transferNo); + WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(warehouseId)); + if (transfer == null) throw notFound("仓储转运单不存在"); + if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) throw conflict("当前转运单不允许测量"); + pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); + return transfer; } private void validatePalletMeasurement(BigDecimal length, BigDecimal width, BigDecimal height, BigDecimal volume) { @@ -1114,7 +1143,7 @@ public class WarehouseTransferService { WarehouseTransferRequests.Palletize request) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); if (transfer == null) throw notFound("Warehouse transfer order not found"); - if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); if (!String.valueOf(overseasWarehouseId).equals(transfer.getOperatingWarehouseId())) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Warehouse transfer belongs to another warehouse"); } @@ -1683,7 +1712,7 @@ public class WarehouseTransferService { String transferNo, MultipartFile file) { WarehouseTransferOrder transfer = transfers.lockForPda(transferNo, String.valueOf(overseasWarehouseId)); if (transfer == null) throw notFound("Warehouse transfer order not found"); - if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer); + if ("WAREHOUSE_PICKUP".equals(transfer.getDeliveryMethod())) pickupService.requireArrived(transfer.getId(), transfer.getDeliveryMethod(), transfer.getTransferNo(), transfer.getInboundCode()); if (!WAIT_PDA_RECEIVING.equals(transfer.getStatus())) { throw conflict("Measurement photos can only be uploaded before palletization completes"); } diff --git a/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml b/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml index 07d151e04..83fd0bfba 100644 --- a/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/cargo-discard-photos-v1.yaml @@ -86,6 +86,7 @@ paths: - $ref: '#/components/parameters/PalletNo' post: summary: Confirm actual disposal with owned pallet photos + description: PalletTask.skus返回actualQuantityEditable布尔值。仅普通库存未保存具体箱唛时为true,PDA展示相关托盘并要求填写实际废弃箱数;库存和费用按实际数结算,未废弃预留释放。已完成任务相同数量重放不重复扣减,修改数量拒绝。 x-permissions: [lastleg:pda:operate, ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR] requestBody: required: true @@ -103,7 +104,7 @@ paths: required: [itemId, actualPackages] properties: itemId: { type: string, pattern: '^[1-9][0-9]*$' } - actualPackages: { type: integer, minimum: 1, description: Must equal plannedPackages; must finish the application selection } + actualPackages: { type: integer, minimum: 0, description: 无精确箱唛对应关系的普通库存允许0至plannedPackages的实际箱数,剩余预留释放;有具体选箱的明细必须等于plannedPackages } scannedCartonMarks: type: array uniqueItems: true diff --git a/lastleg-service/src/main/resources/openapi/cargo-discard-statistics-v1.yaml b/lastleg-service/src/main/resources/openapi/cargo-discard-statistics-v1.yaml new file mode 100644 index 000000000..456c15e53 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/cargo-discard-statistics-v1.yaml @@ -0,0 +1,94 @@ +openapi: 3.0.3 +info: + title: 货物废弃全部筛选数量统计 + version: 1.1.0 +paths: + /api/platform/v1/last-leg/cargo-discards/statistics: + get: + summary: 按平台允许的客户范围汇总全部筛选结果 + description: 与废弃列表共用筛选条件,page和size不限制统计范围。未记录的实际数量、商品件数或托盘事实返回null。实际数取PDA确认事实,不从申请数或状态推算。 + parameters: + - {name: customerId, in: query, schema: {type: string}} + - {$ref: '#/components/parameters/DiscardNo'} + - {$ref: '#/components/parameters/InboundCode'} + - {$ref: '#/components/parameters/Warehouse'} + - {$ref: '#/components/parameters/Status'} + - {$ref: '#/components/parameters/CreatedFrom'} + - {$ref: '#/components/parameters/CreatedTo'} + - {$ref: '#/components/parameters/CompletedFrom'} + - {$ref: '#/components/parameters/CompletedTo'} + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + responses: + '200': + description: 标准ApiResponse,data为全部筛选数量统计 + content: + application/json: + schema: {$ref: '#/components/schemas/Envelope'} + /api/merchant/v1/last-leg/cargo-discards/statistics: + get: + summary: 按当前认证客户汇总全部筛选结果 + description: 需要merchant:overseas-warehouse:discard:list权限;客户归属只取认证身份。完成时间筛选同时适用于列表。 + parameters: + - {$ref: '#/components/parameters/DiscardNo'} + - {$ref: '#/components/parameters/InboundCode'} + - {$ref: '#/components/parameters/Warehouse'} + - {$ref: '#/components/parameters/Status'} + - {$ref: '#/components/parameters/CreatedFrom'} + - {$ref: '#/components/parameters/CreatedTo'} + - {$ref: '#/components/parameters/CompletedFrom'} + - {$ref: '#/components/parameters/CompletedTo'} + - {$ref: '#/components/parameters/Page'} + - {$ref: '#/components/parameters/Size'} + responses: + '200': + description: 标准ApiResponse,data为当前客户全部筛选统计 + content: + application/json: + schema: {$ref: '#/components/schemas/Envelope'} +components: + parameters: + DiscardNo: {name: discardNo, in: query, schema: {type: string}} + InboundCode: {name: inboundCode, in: query, schema: {type: string}} + Warehouse: {name: overseasWarehouseId, in: query, schema: {type: string}} + Status: {name: status, in: query, schema: {type: string}} + CreatedFrom: {name: createdFrom, in: query, schema: {type: string, format: date-time}} + CreatedTo: {name: createdTo, in: query, schema: {type: string, format: date-time}} + CompletedFrom: {name: completedFrom, in: query, description: 客户确认完成时间下界,包含边界, schema: {type: string, format: date-time}} + CompletedTo: {name: completedTo, in: query, description: 客户确认完成时间上界,包含边界, schema: {type: string, format: date-time}} + Page: {name: page, in: query, required: true, schema: {type: integer, minimum: 1}} + Size: {name: size, in: query, required: true, schema: {type: integer, minimum: 1, maximum: 200}} + schemas: + SummaryDimensions: + type: object + description: 平台及客户废弃列表records与详情summary均返回这些维度;totalPackages保留作业阶段展示箱数,申请箱数须读取requestedPackages。 + required: [skuCodes, skuCount, requestedPackages, actualPackages] + properties: + skuCodes: {type: array, items: {type: string}, description: 申请明细去重并排序后的全部SKU} + skuCount: {type: integer, description: 本单SKU种数} + requestedPackages: {type: integer, description: 申请物理箱数,同一混装箱跨SKU仅计一次} + actualPackages: {type: integer, nullable: true, description: PDA已确认实际物理箱数,未作业为null} + CandidateSku: + type: object + description: 库存仓位按物理箱计量,箱内全部SKU从真实实收JSON投影;完整混箱必须携带全部SKU明细,库存冻结和扣减按仓位及箱身份去重。 + properties: + skuCode: {type: string} + productName: {type: string, nullable: true} + availablePackages: {type: string, description: 包含本SKU的可选物理箱数} + availablePieces: {type: string, nullable: true, description: 真实箱内本SKU件数,缺失时null,不按箱规推算} + Envelope: + type: object + properties: + code: {type: string} + data: {$ref: '#/components/schemas/Statistics'} + Statistics: + type: object + description: Long值遵循全局序列化配置,可能以十进制字符串返回;退货SKU按件汇总并按实物箱去重,转运件数只取所选箱实点SKU事实,其他缺失事实保留null。 + properties: + orderCount: {type: string, description: 筛选订单票数} + skuCount: {type: integer, description: 筛选内去重SKU种数} + requestedPackages: {type: string, description: 申请物理箱数} + actualPackages: {type: string, nullable: true, description: 已确认实际物理箱数} + requestedPieces: {type: string, nullable: true, description: 申请商品件数} + actualPieces: {type: string, nullable: true, description: 已确认实际商品件数} + palletCount: {type: integer, nullable: true, description: 客户和仓范围内去重真实来源托盘数,缺失或散箱返回null} diff --git a/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml b/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml index 4777b1eda..ff0b85426 100644 --- a/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/relabel-quantities-v1.yaml @@ -4,6 +4,17 @@ info: version: 1.0.0 description: 全部筛选结果汇总,不受 page/size 影响。件、箱、SKU分别统计;未知不能解释为零,禁止固定箱规换算。 paths: + /api/platform/v1/last-leg/relabel-applications/inventory-candidates: + get: + operationId: platformRelabelInventoryCandidates + description: 平台代客创建候选;权限platform:overseas-warehouse:relabel:create。客户和公共作业仓均必须显式选择,返回与客户创建端相同的InventoryCandidate结构。 + parameters: + - {name: customerId, in: query, required: true, schema: {type: string}} + - {name: operationWarehouseId, in: query, required: true, schema: {type: string}} + - {name: inboundCode, in: query, schema: {type: string}} + responses: + '200': + description: 标准ApiResponse,data为正式库存候选数组;普通库存包含inventoryBalanceId/inboundOrderId/positions,退货包含returnCartonId/cartonMark/cartonSkuItems/cartonAvailablePackages。所有Long ID为十进制字符串。 /api/platform/v1/last-leg/relabel-applications/quantity-summary: get: operationId: platformRelabelQuantitySummary @@ -61,12 +72,16 @@ components: type: object required: [orderCount, skuCount, knownLabelPieceCount, unknownPieceOrders, knownCartonCount, unknownCartonOrders, orders] properties: - orderCount: {type: integer, format: int64, minimum: 0} - skuCount: {type: integer, format: int64, minimum: 0, description: 全部筛选结果的不同旧SKU数} - knownLabelPieceCount: {type: integer, format: int64, minimum: 0} - unknownPieceOrders: {type: integer, format: int64, minimum: 0} - knownCartonCount: {type: integer, format: int64, minimum: 0} - unknownCartonOrders: {type: integer, format: int64, minimum: 0} + orderCount: {type: string, pattern: '^[0-9]+$'} + skuCount: {type: string, pattern: '^[0-9]+$', description: 全部筛选结果的不同旧SKU数} + knownLabelPieceCount: {type: string, pattern: '^[0-9]+$'} + unknownPieceOrders: {type: string, pattern: '^[0-9]+$'} + knownCartonCount: {type: string, pattern: '^[0-9]+$'} + unknownCartonOrders: {type: string, pattern: '^[0-9]+$'} + knownSourcePalletCount: {type: string, pattern: '^[0-9]+$', description: 已知完整原托快照按仓库和托盘码在全部筛选单据间去重} + unknownSourcePalletOrders: {type: string, pattern: '^[0-9]+$'} + knownTargetPalletCount: {type: string, pattern: '^[0-9]+$', description: 本轮完整目标快照按仓库和托盘码去重;返工旧轮目标不计} + unknownTargetPalletOrders: {type: string, pattern: '^[0-9]+$'} orders: type: object description: 以换标业务单号为键,覆盖全部筛选单据 @@ -74,10 +89,15 @@ components: Quantity: type: object properties: - labelPieceCount: {type: integer, format: int64, nullable: true, description: 按件任务采用通知件数;箱任务采用SKU子项件数;重复关联或缺失为null} - cartonCount: {type: integer, format: int64, nullable: true, description: 箱级任务按箱唛去重;按件来源标识不作为箱数} + labelPieceCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 按件任务采用通知件数;箱任务采用SKU子项件数;重复关联或缺失为null} + cartonCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 箱级任务按箱唛去重;按件任务仅在全部精确来源快照都是BOX时按来源主键去重,PALLET/PARCEL不冒充箱数} skuCount: {type: integer, minimum: 0} duplicateCarton: {type: boolean, description: 历史重复任务关联提示,不将同一来源不同SKU按件任务视为重复} quantityUnit: {type: string, enum: [PIECE, CARTON, UNKNOWN]} - sourcePalletCount: {type: integer, format: int64, nullable: true, description: 缺少不可变原托快照时为null} - targetPalletCount: {type: integer, format: int64, nullable: true, description: 未提供汇总,详情以本轮有效绑定历史为准} + sourcePalletCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 缺少不可变原托快照时为null} + targetPalletCount: {type: string, pattern: '^[0-9]+$', nullable: true, description: 每个任务本轮最后一次可信绑定的不同目标托盘数;未绑定完整为null} + sourceUnitCounts: + type: object + nullable: true + description: 首次认领时已清点且精确关联的退货来源主键按BOX/PARCEL/PALLET分类去重;任一来源缺失时为null,不按标识外形猜测 + additionalProperties: {type: string, pattern: '^[0-9]+$'} diff --git a/lastleg-service/src/main/resources/openapi/return-order-statistics-v1.yaml b/lastleg-service/src/main/resources/openapi/return-order-statistics-v1.yaml new file mode 100644 index 000000000..df36d8e24 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/return-order-statistics-v1.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.3 +info: + title: 退货全筛选统计与托盘无效果恢复 + version: 1.0.0 +paths: + /api/merchant/v1/last-leg/return-orders/statistics: + get: + summary: 当前客户退货全筛选统计 + description: 使用与退货分页列表一致的 returnNo、status、skuCode、trackingNo 条件。客户由认证身份确定,忽略客户端归属输入。totals 不受 page/size 影响;pageDimensions 仅对应当前页。SKU 跨订单按去空白大写后的编码去重。件、箱、包裹、托独立统计;未提供的预报载体数不推算为零。需要 MERCHANT_RETURN_ORDER_LIST 对应正式权限。 + operationId: merchantReturnOrderStatistics + responses: + '200': + description: ApiResponse,data 为 totals 与 pageDimensions;失败按统一响应契约返回。 + /api/platform/v1/last-leg/return-orders/statistics: + get: + summary: 尾程筛选集合中原生退货订单的统计 + description: 需要 platform:overseas-warehouse:last-mile-order:list。完整复用尾程列表筛选;totals 只聚合其中有原生退货记录的订单。pageDimensions 对应混合尾程列表当前页中的退货行,不单独分页退货子集。实收载体来自签收事实,出库不扣减历史签收数量。 + operationId: platformReturnOrderStatistics + responses: + '200': + description: ApiResponse,data 为 totals 与 pageDimensions。 + /api/platform/v1/last-leg/pallet-operation-recoveries/{id}: + parameters: + - in: path + name: id + required: true + schema: { type: integer, format: int64 } + get: + summary: 管理员只读核对原托盘作业 + description: 需要 lastleg:inventory:manage,返回原状态、请求哈希、远端意图、原错误和更新时间,不返回会话凭据。 + operationId: inspectPalletOperationRecovery + responses: + '200': { description: 原作业技术审计状态 } + /api/platform/v1/last-leg/pallet-operation-recoveries/{id}/close-no-effect: + parameters: + - in: path + name: id + required: true + schema: { type: integer, format: int64 } + post: + summary: 审计关闭无远端效果的超时换标组托 + description: 需要 lastleg:inventory:manage。仅 RELABEL_PALLET_DISASSEMBLE、PROCESSING 或 FAILED_COMPENSATED、超过15分钟、版本一致、无结果且无任何远端意图允许关闭。未知/已确认/损坏远端记录均拒绝。原行行锁与远端意图条件写互斥;旧线程失败写不能覆盖恢复终态。原请求哈希及原错误不变,恢复管理员、原因、幂等键、原状态和时间写入原 result_json。相同管理员/键/原因重放返回同一审计,不另造业务成功结果。无需新增表,不写库存或远端实托。 + operationId: closePalletOperationNoEffect + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [warehouseId, expectedUpdatedAt, idempotencyKey, reason] + properties: + warehouseId: { type: integer, format: int64, minimum: 1 } + expectedUpdatedAt: { type: string, format: date-time } + idempotencyKey: { type: string, minLength: 1, maxLength: 255 } + reason: { type: string, minLength: 1, maxLength: 1000 } + responses: + '200': { description: ApiResponse 恢复审计或正式业务拒绝 } + '403': { description: 权限不足 } + '404': { description: 原作业不存在 } + '409': { description: 原状态、版本、归属或远端效果不满足恢复条件 } diff --git a/lastleg-service/src/main/resources/openapi/warehouse-transfer-quantities-v1.yaml b/lastleg-service/src/main/resources/openapi/warehouse-transfer-quantities-v1.yaml new file mode 100644 index 000000000..42036dae5 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/warehouse-transfer-quantities-v1.yaml @@ -0,0 +1,120 @@ +openapi: 3.0.3 +info: + title: 仓储转运与混箱库存数量 + version: 1.0.0 + description: 件、箱、托分开记录。所有Long数量序列化为十进制字符串;未知件数保持null,空筛选返回零。同托部分出库可同时属于历史实收、剩余、出库集合,禁止托数相减。 +paths: + /api/platform/v1/last-leg/orders/warehouse-transfer-quantities: + get: + operationId: platformWarehouseTransferQuantityTotals + description: 权限platform:overseas-warehouse:last-mile-order:list,平台访问域与主订单列表一致。复用主订单全部Query筛选及relabelNo,额外限定WAREHOUSE_TRANSFER;page/size不参与汇总。 + responses: + '200': + description: ApiResponse.data为TransferQuantityTotals。 + content: + application/json: + schema: { $ref: '#/components/schemas/TotalsEnvelope' } + /api/merchant/v1/last-leg/warehouse-transfers/quantities: + get: + operationId: merchantWarehouseTransferQuantityTotals + description: 权限merchant:overseas-warehouse:transfer:list,客户范围只从会话获取。复用转运列表完整筛选,包括transferNo/inboundCode/operatingWarehouseId/fbaWarehouseCode/status/needRelabel和创建、审核、清点完成、出库日期区间;日期区间上界包含端点。page/size不参与汇总。 + responses: + '200': + description: ApiResponse.data为TransferQuantityTotals。 + content: + application/json: + schema: { $ref: '#/components/schemas/TotalsEnvelope' } + /api/platform/v1/last-leg/inventory/piece-projection: + get: + operationId: platformInventoryPieceProjection + description: 权限platform:overseas-warehouse:inventory:list,与库存列表相同平台客户访问域及BalanceQuery完整筛选,不分页。按原箱清点及正式目的仓签收凭据投影当前未占用可用库存。每仓位精确箱映射数量须等于available_packages,箱内SKU件数须等于箱总清点件数;缺失、不一致、无法追溯的非转运库存均为未知,不按箱规或首SKU推算。通用余额MIXED也逐箱读取全部SKU。SKU查询可命中混箱第二SKU。 + responses: + '200': + description: ApiResponse.data为InventoryPieceProjection。 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: { $ref: '#/components/schemas/InventoryPieceProjection' } +components: + schemas: + Count: + type: string + pattern: '^[0-9]+$' + NullableCount: + type: string + nullable: true + pattern: '^[0-9]+$' + TransferQuantityView: + type: object + description: 主订单列表与详情summary.warehouseTransferQuantity,仅转运订单提供。新到仓历史实收取counted_at非空且COUNTED/DISCARD_PENDING/DISCARDED/SHIPPED原箱;库存转运实收取正式目的仓签收凭据,件数取精确原箱清点。 + properties: + lastlegOrderId: { $ref: '#/components/schemas/Count' } + declaredPieceCount: { $ref: '#/components/schemas/NullableCount' } + receivedPieceCount: { $ref: '#/components/schemas/NullableCount' } + declaredCartonCount: { $ref: '#/components/schemas/Count' } + receivedCartonCount: { $ref: '#/components/schemas/Count' } + receivedPalletCount: { $ref: '#/components/schemas/Count' } + availableCartonCount: { type: string, description: 原箱当前COUNTED数,非扣除其他业务预留后的可用库存。真实可用数读取库存投影。 } + shippedCartonCount: { $ref: '#/components/schemas/Count' } + remainingPalletCount: { $ref: '#/components/schemas/Count' } + shippedPalletCount: { $ref: '#/components/schemas/Count' } + TransferQuantityTotals: + allOf: + - { $ref: '#/components/schemas/TransferQuantityView' } + - type: object + properties: + orderCount: { $ref: '#/components/schemas/Count' } + knownDeclaredPieceCount: { $ref: '#/components/schemas/Count' } + unknownDeclaredOrderCount: { $ref: '#/components/schemas/Count' } + knownReceivedPieceCount: { $ref: '#/components/schemas/Count' } + unknownReceivedOrderCount: { $ref: '#/components/schemas/Count' } + TotalsEnvelope: + type: object + properties: + code: { type: string } + data: { $ref: '#/components/schemas/TransferQuantityTotals' } + Sku: + type: object + properties: + skuCode: { type: string } + quantity: { $ref: '#/components/schemas/Count' } + InventoryPieceProjection: + type: object + properties: + availablePieceCount: { $ref: '#/components/schemas/NullableCount' } + knownAvailablePieceCount: { $ref: '#/components/schemas/Count' } + unknownCartonCount: { $ref: '#/components/schemas/Count' } + availableCartonCount: { $ref: '#/components/schemas/Count' } + availablePalletCount: { $ref: '#/components/schemas/Count' } + groups: + type: array + items: + type: object + properties: + customerId: { type: string } + warehouseId: { type: string } + destinationCode: { type: string, nullable: true } + anchorSkuCode: { type: string, nullable: true, description: 原余额记账SKU,可能为MIXED。完整SKU在skuContents中。 } + availablePieceCount: { $ref: '#/components/schemas/NullableCount' } + knownAvailablePieceCount: { $ref: '#/components/schemas/Count' } + unknownCartonCount: { $ref: '#/components/schemas/Count' } + availableCartonCount: { $ref: '#/components/schemas/Count' } + availablePalletCount: { $ref: '#/components/schemas/Count' } + skuContents: + type: array + items: { $ref: '#/components/schemas/Sku' } + cartons: + type: array + items: + type: object + properties: + cartonMark: { type: string } + inventoryPositionId: { type: string } + palletId: { type: string, nullable: true } + quantity: { $ref: '#/components/schemas/NullableCount' } + items: + type: array + items: { $ref: '#/components/schemas/Sku' } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java new file mode 100644 index 000000000..c0c0d96eb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/RelabelOutboundPickingTest.java @@ -0,0 +1,118 @@ +package com.baiyue.lastleg.delivery.service; + +import com.baiyue.lastleg.delivery.dto.DeliveryRequests; +import com.baiyue.lastleg.delivery.entity.*; +import com.baiyue.lastleg.delivery.mapper.*; +import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; +import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; +import com.baiyue.lastleg.inventory.entity.*; +import com.baiyue.lastleg.inventory.mapper.*; +import com.baiyue.lastleg.relabel.entity.*; +import com.baiyue.lastleg.relabel.mapper.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.*; +import org.junit.jupiter.api.*; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class RelabelOutboundPickingTest { + private DeliveryTransferService service; + private InventoryCommandService commands; + private InventoryPosition position; + private InventoryBalance balance; + private OutboundPalletItem item; + private OutboundPdaPickMapper picks; + private OutboundPdaTaskMapper tasks; + private OutboundOrderMapper orders; + private OutboundPalletItemMapper pallets; + private InventoryPositionMapper positions; + private InventoryBalanceMapper balances; + private RelabelApplicationMapper applications; + private RelabelItemMapper items; + + /** 构造返工后新目标位:两箱冻结,账面混合SKU,与空出库SKU不作通配匹配。 */ + @BeforeEach void prepare() { + service = new DeliveryTransferService(); commands = mock(InventoryCommandService.class); + picks=mock(OutboundPdaPickMapper.class); tasks=mock(OutboundPdaTaskMapper.class); + orders=mock(OutboundOrderMapper.class); pallets=mock(OutboundPalletItemMapper.class); + positions=mock(InventoryPositionMapper.class); balances=mock(InventoryBalanceMapper.class); + applications=mock(RelabelApplicationMapper.class); items=mock(RelabelItemMapper.class); + Map.of("inventoryCommands",commands,"pdaPicks",picks,"pdaTasks",tasks,"outboundOrders",orders, + "outboundPallets",pallets,"inventoryPositions",positions,"inventoryBalances",balances, + "relabelApplications",applications,"relabelItems",items,"objectMapper",new ObjectMapper()) + .forEach((name,value)->ReflectionTestUtils.setField(service,name,value)); + var outbound=new OutboundOrder(); outbound.setId(1L); outbound.setCustomerId("C"); + outbound.setOverseasWarehouseId("W"); outbound.setOutboundNo("OUT"); outbound.setOutboundType("RELABEL_OUTBOUND"); + var task=new OutboundPdaTask(); task.setId(2L); task.setCustomerId("C"); task.setOutboundOrderId(1L); + task.setTaskNo("TASK"); task.setStatus("PENDING"); + item=new OutboundPalletItem(); item.setId(3L); item.setCustomerId("C"); item.setOutboundOrderId(1L); + item.setWarehousePalletId(4L); item.setPalletNo("NEW-TARGET"); item.setInboundCode("IN"); + item.setPackageCount(2); item.setPickedPackageCount(0); item.setCartonMarks("BOX-A,BOX-B"); + item.setOrderRefs(""" + {"sourceType":"RELABEL_ORDER","relabelNo":"RL","sourceInventoryPositionId":9, + "items":[{"itemId":11,"cartonMark":"BOX-A","packages":1},{"itemId":12,"cartonMark":"BOX-B","packages":1}]} + """); + position=new InventoryPosition(); position.setId(9L); position.setCustomerId("C"); position.setBalanceId(8L); + position.setOverseasWarehouseId("W"); position.setPalletId(4L); position.setPalletNo("NEW-TARGET"); + position.setAvailablePackages(0L); position.setHoldPackages(2L); + balance=new InventoryBalance(); balance.setId(8L); balance.setCustomerId("C"); + balance.setOverseasWarehouseId("W"); balance.setInboundCode("IN"); balance.setSkuCode("MIXED"); + when(tasks.lockByNoAndWarehouse("TASK","W")).thenReturn(task); + when(tasks.findByNoAndWarehouse("TASK","W")).thenReturn(task); + when(orders.selectById(1L)).thenReturn(outbound); when(orders.lockByNo("C","OUT")).thenReturn(outbound); + when(pallets.findByOutbound("C",1L)).thenReturn(List.of(item)); when(pallets.selectById(3L)).thenReturn(item); + when(positions.listPallet("C",4L)).thenReturn(List.of(position)); when(positions.selectById(9L)).thenReturn(position); + when(balances.selectById(8L)).thenReturn(balance); + var app=new RelabelApplication(); app.setId(10L); app.setRelabelNo("RL"); app.setStatus("READY_FOR_OUTBOUND"); + when(applications.lockByOutbound("C","OUT")).thenReturn(app); + var first=new RelabelItem(); first.setId(11L); first.setCartonMark("BOX-A"); first.setNotifiedPackageCount(1); + var second=new RelabelItem(); second.setId(12L); second.setCartonMark("BOX-B"); second.setNotifiedPackageCount(1); + when(items.selectList(any())).thenReturn(List.of(first,second)); + } + + /** 指定箱从冻结到出库数量守恒;整托重扫不重复释放或出库。 */ + @Test void picksExactFrozenBoxesAndReplayDoesNotShipAgain() { + assertEquals(List.of("BOX-A","BOX-B"),service.pdaTaskDetail("W","TASK").items().get(0).positions() + .stream().map(value->value.cartonMark()).toList()); + service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY")); + service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY")); + var release=org.mockito.ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(commands,times(2)).release(eq("C"),eq("U"),release.capture()); + assertEquals(2,release.getAllValues().stream().mapToLong(InventoryCommandRequests.Move::packages).sum()); + assertTrue(release.getAllValues().stream().allMatch(move->move.positionId()==9L&&"RELABEL:RL".equals(move.sourceEventId()))); + var shipped=org.mockito.ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(commands).ship(eq("C"),eq("U"),shipped.capture()); + assertEquals(2,shipped.getValue().packages()); assertEquals(9L,shipped.getValue().positionId()); + verify(commands).allocate(eq("C"),eq("U"),any()); verify(commands).pick(eq("C"),eq("U"),any()); + verify(picks).insert(any(OutboundPdaPick.class)); assertEquals(2,item.getPickedPackageCount()); + } + + /** 其他位置、客户、仓、入仓或不足冻结均拒绝,不能用空SKU放宽范围。 */ + @ParameterizedTest @ValueSource(strings={"POSITION","CUSTOMER","WAREHOUSE","INBOUND","HOLD","SNAPSHOT"}) + void rejectsUnrelatedOrIncompleteSourcesWithoutInventoryEffects(String invalid) { + switch(invalid) { + case "POSITION" -> position.setId(99L); + case "CUSTOMER" -> balance.setCustomerId("OTHER"); + case "WAREHOUSE" -> position.setOverseasWarehouseId("OTHER"); + case "INBOUND" -> balance.setInboundCode("OTHER"); + case "HOLD" -> position.setHoldPackages(1L); + case "SNAPSHOT" -> item.setOrderRefs("{\"sourceType\":\"RELABEL_ORDER\"}"); + } + assertTrue(service.pdaTaskDetail("W","TASK").items().get(0).positions().isEmpty()); + assertThrows(ResponseStatusException.class,()->service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY"))); + verifyNoInteractions(commands,picks); + } + + /** 同箱唛也不能代替来源任务主键,损坏ID必须在释放冻结前拒绝。 */ + @Test void rejectsWrongSnapshotItemIdBeforeReleasingAnyStock() { + item.setOrderRefs(item.getOrderRefs().replace("\"itemId\":11", "\"itemId\":999")); + assertEquals(2,service.pdaTaskDetail("W","TASK").items().get(0).positions().size()); + assertThrows(ResponseStatusException.class,()->service.pdaPickPallet("W","U","TASK","NEW-TARGET",new DeliveryRequests.PdaPalletPick("KEY"))); + verifyNoInteractions(commands); verify(picks,never()).insert(any(OutboundPdaPick.class)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java index 3df612690..095c47413 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/CargoDiscardTransferCartonMapperTest.java @@ -16,6 +16,50 @@ import static org.junit.jupiter.api.Assertions.*; /** 使用真实 SQL 核对转运实收箱与库存仓位的关联及废弃状态流转。 */ class CargoDiscardTransferCartonMapperTest { + /** MIXED 是整托物理余额标记;精确返回剩余实箱及真实 SKU,不能降级成整仓位或混入别的来源。 */ + @Test + void mixedPhysicalBalanceKeepsOnlyItsRemainingCountedCartons() { + var source = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var db = new JdbcTemplate(source); + db.execute("CREATE TABLE ll_inbound_plan_carton(id BIGINT,customer_id VARCHAR(20),order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),received_at TIMESTAMP,destination_warehouse_code VARCHAR(20),carton_mark VARCHAR(80),sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,destination_warehouse_code VARCHAR(20),sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(20),balance_id BIGINT,pallet_id BIGINT,batch_no VARCHAR(80),overseas_warehouse_id VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))"); + db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP,sku_items_json VARCHAR(2000))"); + db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','MIXED')"); + db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'TRANSFER','W'),(42,'C',1,100,'OTHER-BATCH','W'),(43,'C',1,100,'TRANSFER','OTHER-WAREHOUSE')"); + db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'TRANSFER','W'),(20,'C',10,'OTHER-ORDER','W')"); + db.update("INSERT INTO ll_warehouse_transfer_pallet VALUES(11,'C',10,100,'COMPLETED'),(12,'C',10,200,'COMPLETED'),(13,'C',10,100,'BOUND'),(14,'C',20,100,'COMPLETED')"); + db.update("INSERT INTO ll_warehouse_transfer_item VALUES(21,'C',10,'A'),(22,'C',10,'B'),(23,'C',20,'FOREIGN-ORDER')"); + db.update("INSERT INTO ll_warehouse_transfer_carton(id,customer_id,transfer_order_id,transfer_item_id,transfer_pallet_id,status,carton_mark,sku_items_json) VALUES " + + "(1,'C',10,21,11,'COUNTED','REMAINING-A','[{\"skuCode\":\"A\",\"quantity\":2}]')," + + "(2,'C',10,22,11,'DISPATCHED','MOVED-B','[{\"skuCode\":\"B\",\"quantity\":8}]')," + + "(3,'C',10,21,12,'COUNTED','OTHER-PALLET',NULL),(4,'OTHER',10,21,11,'COUNTED','OTHER-CUSTOMER',NULL)," + + "(5,'C',10,21,11,'EXPECTED','FORECAST',NULL),(6,'C',10,21,11,'DISCARD_PENDING','HELD',NULL)," + + "(7,'C',10,21,13,'COUNTED','NOT-STOCKED',NULL),(8,'C',20,23,14,'COUNTED','OTHER-ORDER',NULL)," + + "(9,'C',10,23,11,'COUNTED','ITEM-ORDER-MISMATCH',NULL)"); + var config = new Configuration(new Environment("test", new JdbcTransactionFactory(), source)); + config.setMapUnderscoreToCamelCase(true); + config.addMapper(CargoDiscardInventoryMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(config).openSession(true)) { + var mapper = session.getMapper(CargoDiscardInventoryMapper.class); + var rows = mapper.genericCartons("C", 41L); + assertEquals(List.of("REMAINING-A"), rows.stream().map(c -> c.getCartonMark()).toList()); + assertEquals("A", rows.get(0).getSkuCode()); + assertEquals("[{\"skuCode\":\"A\",\"quantity\":2}]", rows.get(0).getSkuItemsJson()); + assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty()); + assertTrue(mapper.genericCartons("C", 42L).isEmpty()); + assertTrue(mapper.genericCartons("C", 43L).isEmpty()); + db.update("UPDATE ll_warehouse_transfer_carton SET status='COUNTED',sku_items_json='[{\"skuCode\":\"A\",\"quantity\":3},{\"skuCode\":\"B\",\"quantity\":5}]' WHERE id=2"); + session.clearCache(); + assertEquals(List.of("REMAINING-A", "MOVED-B"), mapper.genericCartons("C", 41L).stream().map(c -> c.getCartonMark()).toList()); + assertEquals("B", mapper.genericCartons("C", 41L).get(1).getSkuCode()); + assertTrue(mapper.genericCartons("C", 41L).get(1).getSkuItemsJson().contains("\"quantity\":5")); + } + } + /** 同批多托盘、多 SKU 时仅返回本仓位实收箱,排除预报、短收、占用及其他客户的箱。 */ @Test void receivedBoxesFollowTheirActualPalletAndSku() { @@ -27,7 +71,7 @@ class CargoDiscardTransferCartonMapperTest { db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR(20),inbound_order_id BIGINT,transfer_no VARCHAR(80),operating_warehouse_id VARCHAR(20))"); db.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30))"); db.execute("CREATE TABLE ll_warehouse_transfer_item(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,sku_code VARCHAR(20))"); - db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(id BIGINT,customer_id VARCHAR(20),transfer_order_id BIGINT,transfer_item_id BIGINT,transfer_pallet_id BIGINT,status VARCHAR(30),carton_mark VARCHAR(80),updated_at TIMESTAMP,sku_items_json VARCHAR(2000))"); db.update("INSERT INTO ll_inventory_balance VALUES(1,'C',10,'FBA','SKU'),(2,'C',10,'FBA','OTHER')"); db.update("INSERT INTO ll_inventory_position VALUES(41,'C',1,100,'ZY-BYWL-261019194301','W'),(42,'C',1,200,'ZY-BYWL-261019194301','W'),(43,'C',2,100,'ZY-BYWL-261019194301','W')"); db.update("INSERT INTO ll_warehouse_transfer_order VALUES(10,'C',10,'ZY-BYWL-261019194301','W')"); @@ -48,6 +92,10 @@ class CargoDiscardTransferCartonMapperTest { assertEquals(List.of("OTHER-PALLET"), mapper.genericCartons("C", 42L).stream().map(c -> c.getCartonMark()).toList()); assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList()); assertTrue(mapper.genericCartons("OTHER", 41L).isEmpty()); + db.update("UPDATE ll_warehouse_transfer_carton SET sku_items_json=? WHERE id=1", "[{\"skuCode\":\"SKU\",\"quantity\":5},{\"skuCode\":\"OTHER\",\"quantity\":7}]"); + session.clearCache(); + assertEquals(List.of("OTHER-SKU"), mapper.genericCartons("C", 43L).stream().map(c -> c.getCartonMark()).toList()); + assertTrue(mapper.genericCartons("C", 41L).get(0).getSkuItemsJson().contains("OTHER")); assertEquals(1, mapper.changeTransferCartonStatus("C", 2L, "COUNTED", "DISCARD_PENDING")); assertEquals(1, mapper.genericCartons("C", 41L).size()); assertEquals(0, mapper.changeTransferCartonStatus("OTHER", 2L, "DISCARD_PENDING", "COUNTED")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardQueryBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardQueryBindingTest.java new file mode 100644 index 000000000..2a8f67b0a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/controller/CargoDiscardQueryBindingTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.discard.controller; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.discard.dto.CargoDiscardRequests; +import com.baiyue.lastleg.discard.service.CargoDiscardService; +import org.junit.jupiter.api.Test; +import org.springframework.core.MethodParameter; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.bind.support.WebDataBinderFactory; +import java.time.LocalDateTime; +import java.util.Set; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** 使用真实 MVC 参数绑定防止查询构造器重载导致列表和统计全部失败。 */ +class CargoDiscardQueryBindingTest { + /** 客户归属取认证身份,完成时间同时传入分页和全筛选统计。 */ + @Test void mvcBindsCompletionDatesForPageAndStatistics() throws Exception { + var service = mock(CargoDiscardService.class); + var controller = new MerchantCargoDiscardController(); + ReflectionTestUtils.setField(controller, "service", service); + var mvc = MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new HandlerMethodArgumentResolver() { + @Override public boolean supportsParameter(MethodParameter parameter) { return parameter.getParameterType() == AuthenticatedUser.class; } + @Override public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer container, NativeWebRequest request, WebDataBinderFactory binder) { + return new AuthenticatedUser(1L, 100L, 100L, 9L, "fixture", "MERCHANT", "WEB", Set.of()); + } + }).build(); + for (String suffix : new String[]{"", "/statistics"}) { + mvc.perform(get("/api/merchant/v1/last-leg/cargo-discards" + suffix) + .param("page", "1").param("size", "1").param("customerId", "OTHER") + .param("completedFrom", "2099-01-01T00:00:00")).andExpect(status().isOk()); + } + var query = new CargoDiscardRequests.Query(1, 1, null, null, null, null, null, null, + LocalDateTime.of(2099, 1, 1, 0, 0), null); + verify(service).page("100", query); + verify(service).statistics("100", query); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java index 1a310889a..7980ea411 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/pda/service/PdaCargoDiscardServiceTest.java @@ -42,15 +42,17 @@ class PdaCargoDiscardServiceTest { @BeforeEach void setup() { service = new PdaCargoDiscardService(); + var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardPdaRepository(); + ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "outboundService", org.mockito.Mockito.mock(com.baiyue.lastleg.discard.service.CargoDiscardOutboundService.class)); ReflectionTestUtils.setField(service, "photoService", photoService); - ReflectionTestUtils.setField(service, "applications", applications); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "cartons", cartons); - ReflectionTestUtils.setField(service, "profiles", profiles); + ReflectionTestUtils.setField(repository, "applications", applications); + ReflectionTestUtils.setField(repository, "items", items); + ReflectionTestUtils.setField(repository, "cartons", cartons); + ReflectionTestUtils.setField(repository, "profiles", profiles); ReflectionTestUtils.setField(service, "adjustments", adjustments); ReflectionTestUtils.setField(service, "discardService", discardService); - ReflectionTestUtils.setField(service, "returnCartons", returnCartons); + ReflectionTestUtils.setField(repository, "returnCartons", returnCartons); application = new CargoDiscardApplication(); application.setId(1L); application.setDiscardNo("CD-1"); application.setCustomerId("CUSTOMER"); application.setOverseasWarehouseId("99"); application.setStatus(CargoDiscardService.APPROVED); application.setFeePerPackage(new BigDecimal("5.00")); @@ -171,6 +173,33 @@ class PdaCargoDiscardServiceTest { private PdaCargoDiscardDtos.Confirm looseRequest(List marks, boolean bulk) { return new PdaCargoDiscardDtos.Confirm(List.of(new PdaCargoDiscardDtos.ActualLine(11L, 2)), List.of(101L), marks, bulk); } + + /** 无箱唛对应的仓位允许实际少弃或未弃,完工后同载荷重放不再结算。 */ + @ParameterizedTest @ValueSource(ints = {0, 1, 2}) + void unboundPalletUsesActualQuantityForInventoryFeeAndReplay(int actual) { + var stockService = mock(com.baiyue.lastleg.discard.service.CargoDiscardStockService.class); + ReflectionTestUtils.setField(service, "stockService", stockService); + item.setSourceType("INVENTORY"); item.setInventoryPositionId(42L); item.setDiscardPackages(3); + var position = new com.baiyue.lastleg.inventory.entity.InventoryPosition(); + position.setOverseasWarehouseId("99"); position.setPalletNo("PALLET-1"); + when(stockService.position("CUSTOMER", 42L)).thenReturn(position); + when(stockService.cartonMarks(item)).thenReturn(List.of()); + when(stockService.actualQuantityEditable(item)).thenReturn(true); + when(applications.selectList(any())).thenReturn(List.of(application)); + var task = service.resolve(identity, "PALLET-1", "CD-1"); + assertTrue(task.skus().get(0).actualQuantityEditable()); assertEquals("PALLET", task.scanMode()); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(4))); + verifyNoInteractions(photoService); + var completed = service.confirm(identity, "CD-1", "PALLET-1", request(actual)); + assertEquals(actual, completed.skus().get(0).actualPackages()); + assertEquals(actual, application.getTotalPackages()); + assertEquals(new BigDecimal("5.00").multiply(BigDecimal.valueOf(actual)), application.getTotalFee()); + service.confirm(identity, "CD-1", "PALLET-1", request(actual)); + verify(stockService, times(1)).discard("CUSTOMER", "OP", application, item, actual); + verifyNoInteractions(adjustments); + assertThrows(ResponseStatusException.class, () -> service.confirm(identity, "CD-1", "PALLET-1", request(3))); + verify(items, times(1)).updateById(item); + } @Test void looseTaskExposesAllRealMarksAndRejectsMissingDuplicateAndUnknownScans() { looseTask(); when(applications.selectList(any())).thenReturn(List.of(application)); var task = service.resolve(identity, "BOX-1", "CD-1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepositoryTest.java new file mode 100644 index 000000000..c9de169d0 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardDimensionRepositoryTest.java @@ -0,0 +1,43 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardDimensionMapper; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实箱内混装SKU按各自件数统计,缺失事实不得用整箱件数替代。 */ +class CargoDiscardDimensionRepositoryTest { + private final CargoDiscardDimensionMapper mapper = mock(CargoDiscardDimensionMapper.class); + private final CargoDiscardDimensionRepository repository = new CargoDiscardDimensionRepository(); + /** 注入JSON解析和隔离Mapper。 */ + CargoDiscardDimensionRepositoryTest() { + ReflectionTestUtils.setField(repository, "dimensions", mapper); + ReflectionTestUtils.setField(repository, "objectMapper", new ObjectMapper()); + } + /** 一箱混装A五件和B七件,不能把十二件分别计给两个SKU。 */ + @Test void mixedCartonUsesMatchingSkuOnly() { + var line = line(1, "A"); + when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5},{\"skuCode\":\"B\",\"quantity\":7}]")); + assertEquals(5L, repository.pieces("C", line)); + line.setSkuCode("B"); assertEquals(7L, repository.pieces("C", line)); + line.setSkuCode("MISSING"); assertNull(repository.pieces("C", line)); + } + /** 两箱五件和七件累计十二件,箱数量不足或JSON损坏时保留未知。 */ + @Test void everySelectedCartonNeedsKnownPositiveContents() { + var line = line(2, "A"); + when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5}]", "[{\"skuCode\":\"A\",\"quantity\":7}]")); + assertEquals(12L, repository.pieces("C", line)); + when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[{\"skuCode\":\"A\",\"quantity\":5}]")); + assertNull(repository.pieces("C", line)); + when(mapper.selectedCartonPieces("C", 1L)).thenReturn(List.of("[]", "broken")); + assertNull(repository.pieces("C", line)); + } + /** 指定申请箱数和当前SKU,不创建库存。 */ + private static CargoDiscardItem line(int boxes, String sku) { + var line = new CargoDiscardItem(); line.setId(1L); line.setDiscardPackages(boxes); line.setSkuCode(sku); return line; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepositoryTest.java new file mode 100644 index 000000000..20dc378fa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/repository/CargoDiscardQuantityRepositoryTest.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.discard.repository; + +import com.baiyue.lastleg.discard.entity.CargoDiscardApplication; +import com.baiyue.lastleg.discard.entity.CargoDiscardItem; +import com.baiyue.lastleg.discard.mapper.CargoDiscardItemMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.*; + +class CargoDiscardQuantityRepositoryTest { + /** 完工前展示申请十箱,完工后费用及列表使用实弃六箱或零箱。 */ + @Test void completedSummaryUsesActualWithoutChangingRequestedSnapshot() { + var mapper = mock(CargoDiscardItemMapper.class); + var repository = new CargoDiscardQuantityRepository(); + ReflectionTestUtils.setField(repository, "items", mapper); + var application = new CargoDiscardApplication(); application.setCustomerId("C1"); application.setId(1L); + var line = new CargoDiscardItem(); line.setDiscardPackages(10); line.setActualDiscardPackages(6); + when(mapper.listApplication("C1", 1L)).thenReturn(List.of(line)); + application.setStatus("APPROVED"); assertEquals(10, repository.boxes(application)); + application.setStatus("PDA_COMPLETED"); assertEquals(6, repository.boxes(application)); + application.setStatus("COMPLETED"); assertEquals(6, repository.boxes(application)); + line.setActualDiscardPackages(0); assertEquals(0, repository.boxes(application)); + assertEquals(10, line.getDiscardPackages()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java index 6081879d2..a8a858bf3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStatisticsServiceTest.java @@ -40,10 +40,11 @@ class CargoDiscardStatisticsServiceTest { @Test void unknownPiecesAndLoosePalletNeverBecomeZero() { when(queries.list("C", query)).thenReturn(List.of(application(1L))); when(dimensions.items("C", 1L)).thenReturn(List.of(item(null, "A", 2, 0))); + when(dimensions.pieces(eq("C"), any())).thenReturn(null); when(dimensions.pallets("C", 1L)).thenReturn(List.of("")); var result = service.statistics("C", query); assertEquals(2, result.requestedPackages()); assertEquals(0L, result.actualPackages()); - assertNull(result.requestedPieces()); assertNull(result.actualPieces()); assertNull(result.palletCount()); + assertNull(result.requestedPieces()); assertEquals(0L, result.actualPieces()); assertNull(result.palletCount()); } /** 空筛选有零票零申请箱,但不能伪造实际作业或商品、托盘事实。 */ @Test void emptySelectionHasNoInventedActualFacts() { @@ -53,6 +54,29 @@ class CargoDiscardStatisticsServiceTest { assertNull(result.actualPackages()); assertNull(result.requestedPieces()); assertNull(result.palletCount()); verifyNoInteractions(dimensions); } + /** 两箱实收五件与七件,实际确认后件数仍由箱级事实汇总。 */ + @Test void selectedTransferCartonsProvideRealPieces() { + var line = item(null, "TRANSFER-SKU", 2, 2); + when(queries.list("C", query)).thenReturn(List.of(application(1L))); + when(dimensions.items("C", 1L)).thenReturn(List.of(line)); + when(dimensions.pieces("C", line)).thenReturn(12L); + when(dimensions.pallets("C", 1L)).thenReturn(List.of("P")); + var result = service.statistics("C", query); + assertEquals(2, result.requestedPackages()); assertEquals(2L, result.actualPackages()); + assertEquals(12L, result.requestedPieces()); assertEquals(12L, result.actualPieces()); + } + /** 创建一票同客户同仓的隔离单据。 */ + @Test void mixedTransferSkuLinesDeduplicatePhysicalBoxesWithoutLosingPieces() { + var a = item(null, "A", 2, 2); var b = item(null, "B", 2, 2); + for (var line : List.of(a, b)) { line.setSourceType("INVENTORY"); line.setInboundCode("ZY-MIX"); line.setCartonMarksJson("[\"BOX-1\",\"BOX-2\"]"); } + when(queries.list("C", query)).thenReturn(List.of(application(1L))); + when(dimensions.items("C", 1L)).thenReturn(List.of(a, b)); + when(dimensions.pieces("C", a)).thenReturn(8L); when(dimensions.pieces("C", b)).thenReturn(18L); + when(dimensions.pallets("C", 1L)).thenReturn(List.of("P", "P")); + var result = service.statistics("C", query); + assertEquals(2, result.skuCount()); assertEquals(2, result.requestedPackages()); assertEquals(2L, result.actualPackages()); + assertEquals(26L, result.requestedPieces()); assertEquals(26L, result.actualPieces()); assertEquals(1, result.palletCount()); + } /** 创建一票同客户同仓的隔离单据。 */ private static CargoDiscardApplication application(long id) { var a = new CargoDiscardApplication(); a.setId(id); a.setCustomerId("C"); a.setOverseasWarehouseId("W"); return a; } /** 明确退货件数和实际值,不引入固定箱规。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java index 777b3d501..8af617bac 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardStockServiceTest.java @@ -32,12 +32,14 @@ class CargoDiscardStockServiceTest { final CargoDiscardApplication application = new CargoDiscardApplication(); CargoDiscardRequests.Item request(int count) { return new CargoDiscardRequests.Item(null, null, "SKU", count, 42L); } @BeforeEach void setup() { - ReflectionTestUtils.setField(service, "stock", stock); - ReflectionTestUtils.setField(service, "positions", positions); - ReflectionTestUtils.setField(service, "balances", balances); + var repository = new com.baiyue.lastleg.discard.repository.CargoDiscardStockRepository(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(repository, "stock", stock); + ReflectionTestUtils.setField(repository, "positions", positions); + ReflectionTestUtils.setField(repository, "balances", balances); ReflectionTestUtils.setField(service, "commands", commands); - ReflectionTestUtils.setField(service, "items", items); - ReflectionTestUtils.setField(service, "links", links); + ReflectionTestUtils.setField(repository, "items", items); + ReflectionTestUtils.setField(repository, "links", links); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); position.setId(42L); position.setCustomerId("C1"); position.setBalanceId(51L); position.setOverseasWarehouseId("99"); position.setPalletNo("P-42"); position.setAvailablePackages(100L); @@ -83,6 +85,33 @@ class CargoDiscardStockServiceTest { var carton = new com.baiyue.lastleg.discard.model.CargoDiscardStockCartonRow(); carton.setSourceType("INBOUND"); carton.setId(id); carton.setCartonMark(mark); carton.setSkuCode("SKU"); return carton; } + /** 同一混装实箱的两个 SKU 保留完整快照,物理仓位只冻结一次,实箱只锁定和保存一次,漏 SKU 时不得产生任何冻结。 */ + @Test void mixedTransferCartonRequiresAllSkusAndLocksPhysicalBoxOnce() { + position.setAvailablePackages(1L); balance.setAvailablePackages(1L); + var box = carton(1L, "MIXED-1"); box.setSourceType("TRANSFER"); + box.setSkuItemsJson("[{\"skuCode\":\"SKU\",\"quantity\":5},{\"skuCode\":\"SECOND\",\"quantity\":7}]"); + when(stock.genericCartons("C1", 42L)).thenReturn(List.of(box)); + when(stock.changeTransferCartonStatus("C1", 1L, "COUNTED", "DISCARD_PENDING")).thenReturn(1); + var a = new CargoDiscardRequests.Item(null, null, "SKU", 1, 42L, List.of(1L)); + var b = new CargoDiscardRequests.Item(null, null, "SECOND", 1, 42L, List.of(1L)); + assertThrows(ResponseStatusException.class, () -> service.reserveAll("C1", "OP", application, List.of(a), LocalDateTime.now())); + verifyNoInteractions(commands, items, links); + service.reserveAll("C1", "OP", application, List.of(a, b), LocalDateTime.now()); + verify(stock, times(1)).changeTransferCartonStatus("C1", 1L, "COUNTED", "DISCARD_PENDING"); + verify(links, times(1)).insert(any(CargoDiscardCarton.class)); + verify(commands, times(1)).hold(eq("C1"), eq("OP"), any()); + var saved = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items, times(2)).insert(saved.capture()); + assertTrue(saved.getAllValues().stream().allMatch(i -> service.cartonMarks(i).equals(List.of("MIXED-1")))); + assertEquals(1, com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(saved.getAllValues(), false)); + when(items.listApplication("C1", 11L)).thenReturn(saved.getAllValues()); + var physical = ArgumentCaptor.forClass(CargoDiscardCarton.class); verify(links).insert(physical.capture()); + when(links.listApplication("C1", 11L)).thenReturn(List.of(physical.getValue())); + when(stock.changeTransferCartonStatus("C1", 1L, "DISCARD_PENDING", "DISCARDED")).thenReturn(1); + for (var line : saved.getAllValues()) { service.discard("C1", "OP", application, line, 1); line.setActualDiscardPackages(1); } + verify(stock, times(1)).changeTransferCartonStatus("C1", 1L, "DISCARD_PENDING", "DISCARDED"); + verify(commands, times(1)).discardHeld(eq("C1"), eq("OP"), any()); + assertEquals(1, com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(saved.getAllValues(), true)); + } @Test void partialDisposalFreezesExactlySelectedBoxesAndLeavesTheOthersUntouched() { position.setAvailablePackages(3L); balance.setAvailablePackages(3L); when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, "BOX-1"), carton(2L, "BOX-2"), carton(3L, "BOX-3"))); @@ -133,4 +162,57 @@ class CargoDiscardStockServiceTest { verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString()); } + /** 即使有同数量箱记录,缺失箱唛时仍应展示相关托盘并允许仓库实点。 */ + @Test void incompleteCartonMarksUseActualQuantityWithoutGuessingBoxes() { + position.setAvailablePackages(1L); balance.setAvailablePackages(1L); + when(stock.genericPositions("C1", "WT")).thenReturn(List.of(position)); + when(stock.genericCartons("C1", 42L)).thenReturn(List.of(carton(1L, null))); + var candidate = service.candidates("C1", "WT").get(0); + assertFalse(candidate.cartonSelectionSupported()); assertEquals("P-42", candidate.palletNo()); + service.reserve("C1", "OP", application, request(1), LocalDateTime.now()); + var saved = ArgumentCaptor.forClass(CargoDiscardItem.class); verify(items).insert(saved.capture()); + assertTrue(service.actualQuantityEditable(saved.getValue())); + assertTrue(service.cartonMarks(saved.getValue()).isEmpty()); + verifyNoInteractions(links); + verify(stock, never()).changeGenericCartonStatus(anyString(), anyLong(), anyString(), anyString()); + } + /** 预留十箱、实弃六箱,扣减与释放分别使用稳定幂等键。 */ + @Test void unboundPalletDiscardsActualAndReleasesUnfinishedReservation() { + var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY"); + line.setInventoryPositionId(42L); line.setDiscardPackages(10); + assertTrue(service.actualQuantityEditable(line)); + service.discard("C1", "OP", application, line, 6); + var deduction = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + var release = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(commands).discardHeld(eq("C1"), eq("OP"), deduction.capture()); + verify(commands).release(eq("C1"), eq("OP"), release.capture()); + assertEquals(6, deduction.getValue().packages()); assertEquals(4, release.getValue().packages()); + assertEquals(deduction.getValue().sourceEventId(), release.getValue().sourceEventId()); + assertNotEquals(deduction.getValue().idempotencyKey(), release.getValue().idempotencyKey()); + verifyNoInteractions(stock, links); + } + + /** 实际未废弃时只释放全部预留,不产生零数量扣减。 */ + @Test void unboundPalletZeroDisposalReleasesAllHeldStock() { + var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY"); + line.setInventoryPositionId(42L); line.setDiscardPackages(10); + service.discard("C1", "OP", application, line, 0); + verify(commands, never()).discardHeld(anyString(), anyString(), any()); + var release = ArgumentCaptor.forClass(InventoryCommandRequests.Move.class); + verify(commands).release(eq("C1"), eq("OP"), release.capture()); + assertEquals(10, release.getValue().packages()); + } + + /** 已选具体箱子不能通过实际数量绕过核对,超量和负数同样不得写库存。 */ + @Test void exactBoxesAndInvalidActualsCannotUseQuantityOnlyCompletion() { + var line = new CargoDiscardItem(); line.setId(71L); line.setSourceType("INVENTORY"); + line.setInventoryPositionId(42L); line.setDiscardPackages(2); + assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, -1)); + assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, 3)); + line.setCartonMarksJson("[\"BOX-1\",\"BOX-2\"]"); + assertFalse(service.actualQuantityEditable(line)); + assertThrows(ResponseStatusException.class, () -> service.discard("C1", "OP", application, line, 1)); + verifyNoInteractions(commands, stock, links); + } + } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 3f4290dcf..29a1a83d0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -140,7 +140,7 @@ public class DispatchAggregatePersistenceTest { /** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */ DriverEvidenceFixture() throws Exception { f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver); - var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class)); + var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class)); org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers); dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence"); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java index 515f73550..4ac5c08d0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchItemSourcePersistenceTest.java @@ -1,5 +1,7 @@ package com.baiyue.lastleg.dispatch.service; +import com.baiyue.lastleg.dispatch.port.DispatchSourceAllocation; + import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import com.baiyue.common.core.exception.BizException; @@ -326,7 +328,7 @@ class DispatchItemSourcePersistenceTest { capture(); var allocated = allocate(first, 101, 3, 10, null, 6); assertEquals(2, allocated.size()); - assertEquals(3, allocated.stream().mapToLong(DispatchItemSourceService.Allocation::packages).sum()); + assertEquals(3, allocated.stream().mapToLong(DispatchSourceAllocation::packages).sum()); assertEquals(10, allocated.stream().filter(a -> a.productQuantity() != null).mapToLong(a -> a.productQuantity()).sum()); assertEquals(1, allocated.stream().filter(a -> a.productQuantity() == null).count()); assertThrows(BizException.class, () -> allocate(first, 101, 3, 20, null, 6)); @@ -368,7 +370,7 @@ class DispatchItemSourcePersistenceTest { } /** 本次输入件数明确传入,库存数量取调用方锁后的受控真实位置快照。 */ - private List allocate(DispatchItem item, long position, long packages, + private List allocate(DispatchItem item, long position, long packages, long quantity, String mark, long hold) { long balance = position == 102 ? 602 : 601; return transaction.execute(status -> service.allocate(DISPATCH, item, position, packages, quantity, mark, @@ -376,7 +378,7 @@ class DispatchItemSourcePersistenceTest { } /** 模拟已提交的不可变真实拣货账本供实际 Mapper 读取消费额度,不冒充 PDA 全链。 */ - private void pick(long id, String customer, long fulfillment, DispatchItemSourceService.Allocation allocation) { + private void pick(long id, String customer, long fulfillment, DispatchSourceAllocation allocation) { jdbc.update(""" INSERT INTO ll_dispatch_pda_pick_order (id,customer_id,pick_id,dispatch_item_source_id,order_id,packages,product_quantity,created_at) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java index 1a6a7af91..11db2b00a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java @@ -10,6 +10,7 @@ import static org.assertj.core.api.Assertions.*; class FleetPickupMapperTest { JdbcTemplate db;FleetPickupMapper mapper; + /** 每个用例建立独立数据库,通过真实 Mapper 验证取货只读来源投影。 */ @BeforeEach void setup() throws Exception { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds); db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_no VARCHAR(64),operating_warehouse_id VARCHAR(64),delivery_address VARCHAR(500),pickup_contact_name VARCHAR(100),pickup_contact_phone VARCHAR(40),package_count INT,estimated_arrival_at TIMESTAMP,status VARCHAR(32),delivery_method VARCHAR(40),created_at TIMESTAMP)"); @@ -23,6 +24,26 @@ class FleetPickupMapperTest { var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetPickupMapper.class); } + /** 释放隔离库,避免完整回归累积 H2 内存。 */ + @AfterEach void close() { if(db!=null)db.execute("SHUTDOWN"); } + /** 显式列投影保留车队全部资格和展示字段,不依赖转运实体映射。 */ + @Test void sourceProjectionPreservesPickupFactsAndCustomerScope() { + var source=mapper.lockSource("C1","WT-1"); + assertThat(source.getId()).isEqualTo(1L); + assertThat(source.getCustomerId()).isEqualTo("C1"); + assertThat(source.getTransferNo()).isEqualTo("WT-1"); + assertThat(source.getInboundCode()).isEqualTo("WT-1"); + assertThat(source.getLastlegOrderNo()).isEqualTo("LL-1"); + assertThat(source.getOperatingWarehouseId()).isEqualTo("100"); + assertThat(source.getDeliveryAddress()).isEqualTo("Ontario Test Address"); + assertThat(source.getPickupContactName()).isEqualTo("Contact"); + assertThat(source.getPickupContactPhone()).isEqualTo("0000000000"); + assertThat(source.getPackageCount()).isEqualTo(5); + assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + assertThat(source.getDeliveryMethod()).isEqualTo("WAREHOUSE_PICKUP"); + assertThat(mapper.lockSource("C2","WT-1")).isNull(); + assertThat(mapper.source("C1","WT-1").getId()).isEqualTo(source.getId()); + } @Test void candidateQualificationPaginationAndCustomerScopeUseActualSql() { assertThat(mapper.countCandidates(null)).isEqualTo(2); assertThat(mapper.candidates(null,1,0)).extracting(FleetPickupModels.Candidate::transferNo).containsExactly("WT-1"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java index f48e8ff79..8fceea32d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java @@ -4,7 +4,6 @@ import com.baiyue.lastleg.fleet.driver.*; import com.baiyue.lastleg.fleet.reporting.*; import com.baiyue.lastleg.fleet.trip.*; import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; -import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.*; import org.springframework.web.server.ResponseStatusException; @@ -16,7 +15,7 @@ import static org.mockito.ArgumentMatchers.*; class FleetPickupServiceTest { FleetPickupRepository repository;FleetTripService trips;FleetPickupService service; - TripOperationsEvidenceGateway files;FleetTripEntity trip;WarehouseTransferOrder source;FleetPickupTask task; + TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task; final Map commands=new HashMap<>(); @BeforeEach void setup() { repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class); @@ -30,15 +29,18 @@ class FleetPickupServiceTest { doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString()); doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); } - @Test void pickupMustArriveBeforePdaAndWrongWarehouseCannotRead() { - when(repository.pdaSource("100","WT-1")).thenReturn(source); - assertThatThrownBy(()->service.requireMeasuringSource("100","WT-1")).hasMessageContaining("先完成车辆取货送仓"); - task.setStatus("ARRIVED");assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source); - assertThatThrownBy(()->service.requireMeasuringSource("200","WT-1")).isInstanceOf(ResponseStatusException.class); + /** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */ + @Test void pickupMustArriveBeforePda() { + assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓"); + task.setStatus("ARRIVED"); + assertThatCode(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).doesNotThrowAnyException(); + assertThatThrownBy(()->service.requireArrived(2L,"WAREHOUSE_PICKUP","WT-2","WT-2")).hasMessageContaining("先完成车辆取货送仓"); } + /** 客户自送和已有库存转运沿本域原测量资格,不要求不存在的取货任务。 */ @Test void selfDeliveryKeepsOriginalMeasurementPath() { - source.setDeliveryMethod("CUSTOMER_DELIVERY");when(repository.pdaSource("100","WT-1")).thenReturn(source); - assertThat(service.requireMeasuringSource("100","WT-1")).isSameAs(source);verify(repository,never()).task(anyLong()); + assertThatCode(()->service.requireArrived(1L,"CUSTOMER_DELIVERY","WT-1","WT-1")).doesNotThrowAnyException(); + assertThatCode(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","ORIGINAL-INBOUND")).doesNotThrowAnyException(); + verify(repository,never()).task(anyLong()); } @Test void cannotScheduleSelfDeliveryOrAnotherWarehouseOrDuplicateSource() { var plan=new FleetPickupModels.Create(List.of(new FleetPickupModels.Source("C1","WT-1")),Instant.now(),Instant.now().plusSeconds(7200),null,"create-key-1"); @@ -98,8 +100,8 @@ class FleetPickupServiceTest { verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); } /** 构造有业务含义的原转运单,箱唛与库存由原链路持有。 */ - static WarehouseTransferOrder source(long id,String customer,String number,String warehouse) { - var row=new WarehouseTransferOrder();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number); + static FleetPickupSource source(long id,String customer,String number,String warehouse) { + var row=new FleetPickupSource();row.setId(id);row.setCustomerId(customer);row.setTransferNo(number);row.setInboundCode(number); row.setOperatingWarehouseId(warehouse);row.setDeliveryMethod("WAREHOUSE_PICKUP");row.setStatus("WAIT_PDA_RECEIVING");row.setPackageCount(5); row.setDeliveryAddress("1580 S Grove Ave, Ontario, CA 91761");row.setPickupContactName("取货测试联系人");row.setPickupContactPhone("0000000000");return row; } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryPieceServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryPieceServiceTest.java new file mode 100644 index 000000000..115573d88 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inventory/query/InventoryPieceServiceTest.java @@ -0,0 +1,49 @@ +package com.baiyue.lastleg.inventory.query; + +import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper.PieceRow; +import com.baiyue.lastleg.inventory.query.repository.InventoryPieceRepository; +import com.baiyue.lastleg.inventory.query.service.InventoryPieceService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class InventoryPieceServiceTest { + /** 混箱全部SKU与数字字符串正确投影,多组共享物理托盘只累计一次。 */ + @Test void projectsAllSkusWithoutMultiplyingCartonsOrPallets() { + var repository=mock(InventoryPieceRepository.class); + when(repository.rows("A",null)).thenReturn(List.of( + row(1,10,100,2,101,10L,"[{\"skuCode\":\"A\",\"quantity\":3},{\"skuCode\":\"B\",\"quantity\":\"7\"}]"), + row(1,10,100,2,102,2L,"[{\"skuCode\":\"A\",\"quantity\":2}]"), + row(2,11,100,1,103,4L,"[{\"skuCode\":\"C\",\"quantity\":4}]"))); + var result=service(repository).project("A",null); + assertEquals(16L,result.availablePieceCount()); assertEquals(3,result.availableCartonCount()); assertEquals(1,result.availablePalletCount()); + assertEquals(2,result.groups().size()); assertEquals(2,result.groups().get(0).skuContents().size()); + assertEquals(7,result.groups().get(0).skuContents().get(1).quantity()); + } + + /** 箱映射不全、JSON缺失或件数冲突均保留未知,不拿已知部分冒充总数。 */ + @Test void incompleteAndInconsistentPositionsRemainUnknown() { + for (var rows:List.of( + List.of(row(1,10,100,2,101,10L,"[{\"skuCode\":\"A\",\"quantity\":10}]")), + List.of(row(1,10,100,1,101,10L,null)), + List.of(row(1,10,100,1,101,10L,"[{\"skuCode\":\"A\",\"quantity\":9}]")))) { + var repository=mock(InventoryPieceRepository.class); when(repository.rows("A",null)).thenReturn(rows); + var result=service(repository).project("A",null); + assertNull(result.availablePieceCount()); assertEquals(0,result.knownAvailablePieceCount()); + assertEquals(rows.get(0).availablePackages(),result.unknownCartonCount()); assertTrue(result.groups().get(0).skuContents().isEmpty()); + } + } + + /** 手工注入只读仓储及JSON解析器,测试不依赖运行服务。 */ + private InventoryPieceService service(InventoryPieceRepository repository) { + var service=new InventoryPieceService(); ReflectionTestUtils.setField(service,"repository",repository); + ReflectionTestUtils.setField(service,"objectMapper",new ObjectMapper()); return service; + } + /** 保留每个仓位所属记账分组及真实箱身份。 */ + private PieceRow row(long position,long balance,long pallet,long boxes,long carton,Long quantity,String json) { + return new PieceRow(position,balance,"A","W","DEST",balance==10?"A":"C",pallet,boxes,carton,"BOX-"+carton,quantity,json); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletMutationOrderMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletMutationOrderMapperTest.java new file mode 100644 index 000000000..277f8ff70 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletMutationOrderMapperTest.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.*; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; +import java.util.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.*; + +class PalletMutationOrderMapperTest { + /** 执行真实Mapper,证明入仓ID不同于尾程ID且同源分片跨客户不会串锁。 */ + @Test void resolvesSnapshotIdsToRealOrdersAndKeepsMissingSourcesFailClosed() { + var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new JdbcTemplate(ds); + try { + db.execute("CREATE TABLE ll_relabel_application(id BIGINT,customer_id VARCHAR,relabel_no VARCHAR,lastleg_order_id BIGINT)"); + db.execute("CREATE TABLE ll_relabel_item(customer_id VARCHAR,application_id BIGINT,source_inbound_order_id BIGINT,return_carton_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_order(id BIGINT,customer_id VARCHAR,source_inbound_id VARCHAR)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_inbound_id VARCHAR)"); + db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR,balance_id BIGINT,overseas_warehouse_id VARCHAR,pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_inventory_balance(id BIGINT,customer_id VARCHAR,inbound_order_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_stock_receipt(customer_id VARCHAR,order_id BIGINT,inventory_position_id BIGINT,warehouse_pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_return_stock_receipt(customer_id VARCHAR,order_id BIGINT,inventory_position_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,plan_id BIGINT,customer_id VARCHAR,line_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,plan_id BIGINT,customer_id VARCHAR,order_id BIGINT)"); + db.execute("CREATE TABLE ll_return_inventory_carton(id BIGINT,customer_id VARCHAR,source_return_no VARCHAR,overseas_warehouse_id VARCHAR,total_quantity BIGINT,pallet_code VARCHAR)"); + db.execute("CREATE TABLE ll_return_order(customer_id VARCHAR,return_no VARCHAR,lastleg_order_id BIGINT)"); + db.update("INSERT INTO ll_relabel_application VALUES(1,'C','RL',900)"); + db.update("INSERT INTO ll_relabel_item VALUES('C',1,100,NULL)"); + db.update("INSERT INTO ll_inbound_order VALUES(100,'C','WAREHOUSE_TRANSFER:ZY'),(101,'C','MISSING')"); + db.update("INSERT INTO ll_order VALUES(200,'C','WAREHOUSE_TRANSFER:ZY'),(201,'OTHER','WAREHOUSE_TRANSFER:ZY')"); + db.update("INSERT INTO ll_inventory_balance VALUES(10,'C',100),(11,'C',101)"); + db.update("INSERT INTO ll_inventory_position VALUES(20,'C',10,'WH',500),(21,'C',11,'WH',501)"); + var configuration=new Configuration(new Environment("test",new JdbcTransactionFactory(),ds)); + configuration.addMapper(PdaPalletOperationMapper.class); + try(var session=new SqlSessionFactoryBuilder().build(configuration).openSession()){ + var mapper=session.getMapper(PdaPalletOperationMapper.class); + var rows=mapper.mutationOrders("WH",List.of(500L),List.of("P500"),List.of("RL")); + assertThat(rows).extracting(PdaPalletOperationMapper.MutationOrder::orderId).containsExactlyInAnyOrder(900L,200L); + assertThat(rows).allSatisfy(r->assertThat(r.customerId()).isEqualTo("C")); + assertThat(mapper.mutationOrders("WH",List.of(501L),List.of("P501"),List.of())).singleElement().satisfies(r->{assertThat(r.customerId()).isEqualTo("C");assertThat(r.orderId()).isNull();}); + } + } finally { + // Mapper会话先关闭,再销毁本用例独有的H2数据库,不把内存库留给全量回归。 + db.execute("SHUTDOWN"); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryServiceTest.java new file mode 100644 index 000000000..455d5b954 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PalletOperationRecoveryServiceTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.palletops.service; + +import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository; +import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDateTime; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.junit.jupiter.api.Assertions.*; + +class PalletOperationRecoveryServiceTest { + private final PalletOperationRecoveryRepository repository=mock(PalletOperationRecoveryRepository.class); + private final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + private final PalletOperationRecoveryService service=new PalletOperationRecoveryService(repository,json); + private PdaPalletOperation row(){var r=new PdaPalletOperation();r.setId(9L);r.setOverseasWarehouseId(88L);r.setOperationType("RELABEL_PALLET_DISASSEMBLE");r.setStatus("PROCESSING");r.setUpdatedAt(LocalDateTime.now().minusHours(1));r.setRequestHash("original-hash");return r;} + private PalletOperationRecoveryService.Close request(PdaPalletOperation r){return new PalletOperationRecoveryService.Close(88L,r.getUpdatedAt(),"recovery-1","核实无远端意图且无业务提交");} + @Test void closesOnlyNoEffectAndReplaysImmutableAudit() throws Exception { + var row=row();row.setLastError("original failure");when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(true); + var result=service.close(9L,"admin",request(row));assertEquals("RECOVERED_NO_EFFECT",result.status());assertEquals("original failure",result.previousError());assertFalse(result.replayed()); + row.setStatus(result.status());row.setResultJson(json.writeValueAsString(result)); + var replay=service.close(9L,"admin",request(row));assertTrue(replay.replayed());assertEquals(result.closedAt(),replay.closedAt());verify(repository,times(1)).close(eq(9L),anyString(),any());assertEquals("original-hash",row.getRequestHash()); + assertThrows(ResponseStatusException.class,()->service.close(9L,"different-admin",request(row))); + } + @Test void unknownOrConfirmedRemoteAttemptsCannotBeClosed(){for(String journal:new String[]{"[{\"operationKey\":\"x\",\"receipt\":null}]","[{\"receipt\":{\"operationStatus\":\"APPLIED\"}}]","{}","corrupt"}){var row=row();row.setRemoteTransitionsJson(journal);when(repository.lock(9L)).thenReturn(row);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));}verify(repository,never()).close(anyLong(),anyString(),any());} + @Test void activeChangedCompletedAndWrongWarehouseReject(){var row=row();when(repository.lock(9L)).thenReturn(row);var old=request(row);row.setUpdatedAt(LocalDateTime.now());assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",old));assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));row.setStatus("COMPLETED");assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));row.setStatus("PROCESSING");row.setOverseasWarehouseId(99L);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));verify(repository,never()).close(anyLong(),anyString(),any());} + @Test void conditionalWriteLossIsNotSuccess(){var row=row();when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(false);assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));} + /** 已补偿失败也须逐条确认无意图,审计保留真实前态。 */ + @Test void failedCompensatedRequiresTheSameNoRemoteProof(){var row=row();row.setStatus("FAILED_COMPENSATED");when(repository.lock(9L)).thenReturn(row);when(repository.close(eq(9L),anyString(),any())).thenReturn(true);assertEquals("FAILED_COMPENSATED",service.close(9L,"admin",request(row)).previousStatus());row.setRemoteTransitionsJson("[{\"receipt\":null}]");assertThrows(ResponseStatusException.class,()->service.close(9L,"admin",request(row)));} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java index 2e50caf6a..45a58d936 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletDispatchFreezePersistenceTest.java @@ -19,7 +19,9 @@ import com.baiyue.lastleg.palletops.dto.PalletOperationRequests; import com.baiyue.lastleg.palletops.entity.PdaPalletOperation; import com.baiyue.lastleg.palletops.gateway.PalletPhysicalGateway; import com.baiyue.lastleg.palletops.mapper.PdaPalletOperationMapper; +import com.baiyue.lastleg.palletops.mapper.PalletOperationRecoveryMapper; import com.baiyue.lastleg.palletops.repository.PdaPalletOperationRepository; +import com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository; import com.baiyue.lastleg.pda.security.PdaWarehouseIdentity; import com.baiyue.lastleg.pda.warehouse.entity.PdaOperationHistory; import com.baiyue.lastleg.pda.warehouse.mapper.PdaWarehouseMapper; @@ -36,6 +38,7 @@ import com.baiyue.lastleg.returnreceipt.entity.ReturnInventoryCarton; import com.baiyue.lastleg.returnreceipt.mapper.ReturnInventoryCartonMapper; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DataSourceTransactionManager; @@ -65,6 +68,13 @@ import static org.mockito.Mockito.*; /** 实际MyBatis、H2和Spring事务证明仓内调用端与运输使用同一组成冻结边界。 */ class PdaPalletDispatchFreezePersistenceTest { + private final List fixtures = new ArrayList<>(); + + /** 每个用例结束都销毁其独立H2库,避免DB_CLOSE_DELAY=-1在全套测试中累计保留。 */ + @AfterEach void closeFixtures() { + for (var fixture : fixtures) fixture.db.execute("SHUTDOWN"); + fixtures.clear(); + } private static final LocalDateTime AT = LocalDateTime.of(2026,10,19,12,0); private static final PdaWarehouseIdentity ACTOR = new PdaWarehouseIdentity("7","picker",88L); @@ -343,7 +353,7 @@ class PdaPalletDispatchFreezePersistenceTest { } /** 隔离数据库保留实际Mapper、事务和共享串行提供者,仅远端资源使用受控替身。 */ - static class Fixture { + class Fixture { final JdbcTemplate db; final PdaPalletOperationService service = new PdaPalletOperationService(); final PalletPhysicalGateway physical = mock(PalletPhysicalGateway.class); @@ -366,9 +376,12 @@ class PdaPalletDispatchFreezePersistenceTest { } }; db = new JdbcTemplate(ds); + // SqlSessionTemplate每次调用自行释放会话;用例最后显式关闭仅本夹具拥有的数据库。 + fixtures.add(this); baseline = Files.readString(Path.of("../deploy/sql/海外区域统一增量-MySQL8.sql")); for (var type : List.of(RelabelApplication.class,RelabelItem.class,ReturnInventoryCarton.class,InventoryPosition.class,InventoryBalance.class,InventoryLedger.class,PdaPalletOperation.class,PdaRelabelTask.class,PdaRelabelHistory.class,PdaOperationHistory.class)) createEntity(type); - createColumns("ll_order",List.of("id","customer_id","detained")); + createColumns("ll_order",List.of("id","customer_id","detained","source_inbound_id")); + createColumns("ll_inbound_order",List.of("id","customer_id","source_inbound_id")); createColumns("ll_inbound_stock_receipt",List.of("id","customer_id","order_id","warehouse_pallet_id","inventory_position_id")); createColumns("ll_return_stock_receipt",List.of("id","customer_id","order_id","inventory_position_id")); createColumns("ll_inbound_plan",List.of("id","customer_id","overseas_warehouse_id")); @@ -381,6 +394,7 @@ class PdaPalletDispatchFreezePersistenceTest { createColumns("ll_physical_pallet_guard",List.of("warehouse_pallet_id","created_at")); createColumns("ll_warehouse_transfer_inventory_allocation",List.of("customer_id","inventory_position_id","transfer_order_id","package_count","status")); var configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(PalletOperationRecoveryMapper.class); for (var type : List.of(PdaPalletOperationMapper.class,PhysicalPalletSerialMapper.class,RelabelApplicationMapper.class,RelabelItemMapper.class,ReturnInventoryCartonMapper.class,PdaRelabelTaskMapper.class,PdaRelabelHistoryMapper.class,InventoryPositionMapper.class,InventoryBalanceMapper.class,InventoryLedgerMapper.class,InventoryCommandMapper.class,PdaWarehouseMapper.class)) configuration.addMapper(type); var factory = new MybatisSqlSessionFactoryBean(); factory.setDataSource(ds); factory.setConfiguration(configuration); var sessions = new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); @@ -401,6 +415,7 @@ class PdaPalletDispatchFreezePersistenceTest { operationHistory = spy(sessions.getMapper(PdaWarehouseMapper.class)); var historyService = new PdaWarehouseService(); ReflectionTestUtils.setField(historyService,"mapper",operationHistory); ReflectionTestUtils.setField(service,"mapper",traced); ReflectionTestUtils.setField(service,"mutationRepository",new PdaPalletOperationRepository(traced)); + ReflectionTestUtils.setField(service,"recoveryRepository",new PalletOperationRecoveryRepository(sessions.getMapper(PalletOperationRecoveryMapper.class))); ReflectionTestUtils.setField(service,"physicalPalletSerial",observedSerial); ReflectionTestUtils.setField(service,"physical",physical); ReflectionTestUtils.setField(service,"relabelApplications",applications); ReflectionTestUtils.setField(service,"relabelItems",sessions.getMapper(RelabelItemMapper.class)); ReflectionTestUtils.setField(service,"returnCartons",sessions.getMapper(ReturnInventoryCartonMapper.class)); @@ -480,8 +495,12 @@ class PdaPalletDispatchFreezePersistenceTest { /** 真实库存余额保留其原始订单,跨客户位置各有独立行。 */ void position(long id,String customer,long balance,long pallet,long order,long held) { - db.update("INSERT INTO ll_order(id,customer_id) VALUES(?,?)",order,customer); - db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,order); + // 入仓快照和尾程订单是不同主键,经同客户的来源身份解析,禁止借相同ID掩盖映射错误。 + String source = "TEST-SOURCE-"+order; + long inbound = order+100000; + db.update("INSERT INTO ll_order(id,customer_id,source_inbound_id) VALUES(?,?,?)",order,customer,source); + db.update("INSERT INTO ll_inbound_order(id,customer_id,source_inbound_id) VALUES(?,?,?)",inbound,customer,source); + db.update("INSERT INTO ll_inventory_balance(id,customer_id,inbound_order_id,overseas_warehouse_id,destination_warehouse_code) VALUES(?,?,?,'88','SCK8')",balance,customer,inbound); String code = pallet==40?"TARGET":pallet==30?"SOURCE":"OTHER"; db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,overseas_warehouse_id,pallet_id,pallet_no,batch_no,destination_warehouse_code,available_packages,hold_packages,allocated_packages,picked_packages,receiving_packages,version) VALUES(?,?,?,'88',?,?,'TEST','SCK8',?,?,?,?,?,0)",id,customer,balance,pallet,code,held==0?1:0,held,0,0,0); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java index ada74b54a..c92b53c33 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/palletops/service/PdaPalletOperationServiceTest.java @@ -62,6 +62,7 @@ class PdaPalletOperationServiceTest { } @Mock private PdaPalletOperationMapper mapper; @Mock private PdaPalletOperationRepository mutationRepository; + @Mock private com.baiyue.lastleg.palletops.repository.PalletOperationRecoveryRepository recoveryRepository; @Mock private PhysicalPalletSerialPort physicalPalletSerial; @Mock private com.baiyue.lastleg.inventory.command.mapper.InventoryCommandMapper inventoryCommands; @Mock private InventoryPositionMapper positions; @@ -82,6 +83,8 @@ class PdaPalletOperationServiceTest { ReflectionTestUtils.setField(service, "cartonMeasurements", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.RelabelCartonMeasurementService.class)); ReflectionTestUtils.setField(service, "mapper", mapper); ReflectionTestUtils.setField(service, "mutationRepository", mutationRepository); + ReflectionTestUtils.setField(service, "recoveryRepository", recoveryRepository); + lenient().when(recoveryRepository.journal(anyLong(),anyString(),any())).thenReturn(true); ReflectionTestUtils.setField(service, "physicalPalletSerial", physicalPalletSerial); // 既有单元测试只模拟本申请;真实全客户组成、SQL锁及竞争由持久化专项证明。 lenient().when(mutationRepository.composition(anyString(), anyMap(), any())).thenAnswer(call -> { @@ -577,6 +580,8 @@ class PdaPalletOperationServiceTest { } @Test void unboundReturnRelabelSourceDoesNotUnboxNullPalletIdentity() { + ReflectionTestUtils.setField(service, "relabelWorkTasks", + mock(com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper.class)); RelabelApplication app = new RelabelApplication(); app.setId(600L); app.setCustomerId("100"); app.setRelabelNo("RL-1"); app.setOverseasWarehouseId("88"); app.setStatus("READY_FOR_OUTBOUND"); when(relabelApplications.findAnyByRelabelNo("RL-1")).thenReturn(app); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java index 0b27bde80..e023838a5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/pda/service/PdaRelabelServiceTest.java @@ -76,6 +76,9 @@ class PdaRelabelServiceTest { @BeforeEach void setUp() { service = new PdaRelabelService(); + var quantityRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.repository.RelabelQuantityRepository.class); + lenient().when(quantityRepository.sourceSnapshot(any())).thenReturn(java.util.Map.of("itemBindings", List.of())); + ReflectionTestUtils.setField(service, "quantityRepository", quantityRepository); ReflectionTestUtils.setField(service, "skuConversions", skuConversions); ReflectionTestUtils.setField(service, "palletMeasurements", palletMeasurements); ReflectionTestUtils.setField(service, "applications", applications); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java index f2411c501..083a21cc1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelQuantityServiceTest.java @@ -12,6 +12,55 @@ import static org.junit.jupiter.api.Assertions.*; import static org.mockito.Mockito.*; class RelabelQuantityServiceTest { + /** 构造不可变历史供多轮和多来源回归使用。 */ + private com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory history(String action, String json) { + var event = new com.baiyue.lastleg.relabel.pda.entity.PdaRelabelHistory(); + event.setAction(action); event.setDetailJson(json); return event; + } + @Test void exactBindingsKeepOriginalAndReplaceTargetsAcrossRebindAndRework() { + var a = item("CARTON_AND_SKU", "A", 1); a.setId(1L); + var b = item("CARTON_AND_SKU", "B", 1); b.setId(2L); + var first = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"P1\",\"targetPalletCode\":\"T1\"},{\"itemId\":\"2\",\"sourceKnown\":true,\"sourcePalletCode\":\"P2\",\"targetPalletCode\":\"T1\"}]}"); + var second = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"T1\",\"targetPalletCode\":\"T2\"}]}"); + var result = RelabelPalletDimensions.measure(List.of(a,b), List.of(first, second), new ObjectMapper()); + assertEquals(java.util.Set.of("P1", "P2"), result.sources()); + assertEquals(java.util.Set.of("T1", "T2"), result.targets()); + result = RelabelPalletDimensions.measure(List.of(a,b), List.of(first, history("REWORK_ARCHIVE", "{}"), second), new ObjectMapper()); + assertEquals(java.util.Set.of("P1", "P2"), result.sources()); assertNull(result.targets()); + } + @Test void ambiguousLegacySourcesAndCurrentPositionsCannotInventOriginalPallets() { + var a = item("SKU_LABEL", "A", 1); a.setId(1L); a.setSourceInventoryPositionId(999L); + var result = RelabelPalletDimensions.measure(List.of(a), List.of(history("PALLET_BIND", "{\"cartonMarks\":[\"A\"],\"sourcePallets\":[\"P1\",\"P2\"],\"targetPallets\":[\"T1\"]}")), new ObjectMapper()); + assertNull(result.sources()); assertEquals(java.util.Set.of("T1"), result.targets()); + } + @Test void bulkSourceTypesUseExactIdsAndNeverTreatPalletOrParcelAsBoxes() { + var a = item("RETURN_BULK_SKU_LABEL", "M", 2); a.setId(1L); + var b = item("RETURN_BULK_SKU_LABEL", "M", 3); b.setId(2L); + var box = history("SOURCE_SNAPSHOT", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceUnitId\":\"9\",\"sourceUnitType\":\"BOX\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\"},{\"itemId\":\"2\",\"sourceUnitId\":\"9\",\"sourceUnitType\":\"BOX\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\"}]}"); + var result = RelabelPalletDimensions.measure(List.of(a,b), List.of(box), new ObjectMapper()); + assertEquals(1L, result.cartons()); assertEquals(java.util.Map.of("BOX",1L), result.sourceUnits()); + for (var type : List.of("PALLET", "PARCEL")) { + box.setDetailJson(box.getDetailJson().replace("BOX", type)); + result = RelabelPalletDimensions.measure(List.of(a,b), List.of(box), new ObjectMapper()); + assertNull(result.cartons()); assertEquals(1L, result.sourceUnits().get(type)); + box.setDetailJson(box.getDetailJson().replace(type, "BOX")); + } + } + @Test void allFilterPalletTotalsDeduplicateSameWarehouseAcrossOrders() { + var service = service(); var repo = mock(RelabelQuantityRepository.class); + ReflectionTestUtils.setField(service,"repository",repo); + var a = new RelabelApplication(); a.setRelabelNo("A"); a.setOverseasWarehouseId("W"); + var b = new RelabelApplication(); b.setRelabelNo("B"); b.setOverseasWarehouseId("W"); + var item = item("SKU_LABEL", "A", 1); item.setId(1L); + var event = history("PALLET_BIND", "{\"itemBindings\":[{\"itemId\":\"1\",\"sourceKnown\":true,\"sourcePalletCode\":\"P\",\"targetPalletCode\":\"T\"}]}"); + var query = new RelabelRequests.Query(2,1,null,null,null,null,null,null,null); + when(repo.filtered(null,query)).thenReturn(List.of(a,b)); + when(repo.items(a)).thenReturn(List.of(item)); when(repo.items(b)).thenReturn(List.of(item)); + when(repo.history(a)).thenReturn(List.of(event)); when(repo.history(b)).thenReturn(List.of(event)); + var result = service.totals(null,query); + assertEquals(1L,result.knownSourcePalletCount()); assertEquals(1L,result.knownTargetPalletCount()); + assertEquals(0L,result.unknownSourcePalletOrders()); assertEquals(2,result.orderCount()); + } /** 以真实对象测试件数、箱数的不同业务口径。 */ private RelabelQuantityService service() { var service = new RelabelQuantityService(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java index 478c9da19..25f3efb88 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryMovementSqlTest.java @@ -54,7 +54,9 @@ class ReturnInventoryMovementSqlTest { var boundSql = configuration.getMappedStatement(ReturnInventoryMovementMapper.class.getName() + "." + method) .getBoundSql(params); String sql = boundSql.getSql(); - assertEquals(8, sql.split("UNION ALL", -1).length); + // 原有八种库存流水加上正式废弃扣减分支,完整保留投影和客户过滤合同。 + assertEquals(9, sql.split("UNION ALL", -1).length); + assertTrue(sql.contains("'DISCARD'")); assertTrue(sql.contains("ll_return_carton_stock_receipt")); assertTrue(!sql.contains("CONVERT(")); assertTrue(sql.contains("m.customerId=?")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index c5bacefba..61b4a81dd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -77,6 +77,7 @@ class LastlegOrderServiceTest { @Mock ReturnOrderReferenceResolver returnOrderReferences; @Mock com.baiyue.lastleg.returnreceipt.service.ReturnReceiptService returnReceipts; LastlegOrderService service; + @Mock com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; @Test void exposesWarehouseTransferNumberAsTailOrderNumber() { @@ -104,7 +105,7 @@ class LastlegOrderServiceTest { LastlegOrderSummary summary = ReflectionTestUtils.invokeMethod( service, "summary", order, List.of(), null, "UNSCHEDULED", - new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null); + new LastlegOrderMapper.OrderWeightRow(11L, new java.math.BigDecimal("30"), new java.math.BigDecimal("22")), List.of(), null, null); assertNotNull(summary); assertEquals("BOOKING-1", summary.sourceContainerBookingId()); @@ -117,6 +118,7 @@ class LastlegOrderServiceTest { com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class); service = new LastlegOrderService(); + ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities); ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences); ReflectionTestUtils.setField(service, "orderMapper", orders); ReflectionTestUtils.setField(service, "detailMapper", details); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/TransferQuantityMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/TransferQuantityMapperTest.java new file mode 100644 index 000000000..a09d69a15 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/TransferQuantityMapperTest.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.transfer; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.inventory.query.mapper.InventoryPieceMapper; +import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class TransferQuantityMapperTest { + /** 使用真实MyBatis注册解析动态XML,避免仅javac放过未转义比较符。 */ + @Test void registersBothDynamicQuantityMappers() { + var configuration = new MybatisConfiguration(); + assertDoesNotThrow(() -> configuration.addMapper(TransferQuantityMapper.class)); + assertDoesNotThrow(() -> configuration.addMapper(InventoryPieceMapper.class)); + assertTrue(configuration.hasStatement(TransferQuantityMapper.class.getName() + ".rows")); + assertTrue(configuration.hasStatement(InventoryPieceMapper.class.getName() + ".rows")); + } + + /** 真正执行SQL核对未知件数、出库后历史实收、同托重叠及客户隔离。 */ + @Test void executesQuantityFactsWithoutLosingUnknownsOrShippedHistory() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:transfer-quantity-" + System.nanoTime() + ";MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var connection = ds.getConnection(); var sql = connection.createStatement()) { + sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR)"); + sql.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT,customer_id VARCHAR,lastleg_order_id BIGINT,package_count BIGINT)"); + sql.execute("CREATE TABLE ll_warehouse_transfer_item(customer_id VARCHAR,transfer_order_id BIGINT,declared_quantity BIGINT)"); + sql.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR,transfer_order_id BIGINT,transfer_pallet_id BIGINT,counted_at TIMESTAMP,status VARCHAR,counted_quantity BIGINT)"); + sql.execute("CREATE TABLE ll_warehouse_transfer_pallet(id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT)"); + sql.execute("INSERT INTO ll_order VALUES(1,'A'),(2,'A'),(3,'B')"); + sql.execute("INSERT INTO ll_warehouse_transfer_order VALUES(11,'A',1,3),(12,'A',2,1),(13,'B',3,1)"); + sql.execute("INSERT INTO ll_warehouse_transfer_item VALUES('A',11,10),('A',11,NULL),('A',12,4),('B',13,999)"); + sql.execute("INSERT INTO ll_warehouse_transfer_pallet VALUES(21,'A',100),(22,'B',100)"); + sql.execute("INSERT INTO ll_warehouse_transfer_carton VALUES('A',11,21,CURRENT_TIMESTAMP,'COUNTED',5),('A',11,21,CURRENT_TIMESTAMP,'SHIPPED',7),('A',11,NULL,NULL,'NOT_RECEIVED',NULL),('A',12,NULL,CURRENT_TIMESTAMP,'DISCARDED',NULL),('B',13,22,CURRENT_TIMESTAMP,'COUNTED',999)"); + } + var configuration = new MybatisConfiguration(); + configuration.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); + configuration.addMapper(TransferQuantityMapper.class); + try (var session = new MybatisSqlSessionFactoryBuilder().build(configuration).openSession()) { + var mapper = session.getMapper(TransferQuantityMapper.class); + var filter = new QueryWrapper().eq("customer_id", "A").orderByAsc("id"); + var rows = mapper.rows(filter); + assertEquals(2, rows.size()); + var first = rows.get(0); + assertNull(first.declaredPieceCount()); + assertEquals(12L, first.receivedPieceCount()); + assertEquals(2L, first.receivedCartonCount()); + assertEquals(1L, first.receivedPalletCount()); + assertEquals(1L, first.remainingPalletCount()); + assertEquals(1L, first.shippedPalletCount()); + assertNull(rows.get(1).receivedPieceCount()); + var pallets = mapper.pallets(filter); + assertEquals(2, pallets.size()); + assertTrue(pallets.stream().allMatch(pallet -> pallet.customerId().equals("A"))); + assertTrue(mapper.rows(new QueryWrapper().eq("customer_id", "NONE")).isEmpty()); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java index bebe9059a..97abc0c3d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/controller/MerchantWarehouseTransferControllerTest.java @@ -18,7 +18,7 @@ class MerchantWarehouseTransferControllerTest { private final com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService forecastService = mock(com.baiyue.lastleg.transfer.service.WarehouseTransferForecastService.class); private final MerchantWarehouseTransferController controller = controller(); private final WarehouseTransferRequests.Query query = new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null); @Test void merchantPageUsesOnlyAuthenticatedTenant() { @@ -26,6 +26,13 @@ class MerchantWarehouseTransferControllerTest { verify(service).page("100", query); } + /** 汇总只使用已认证客户,平台身份不可借客户入口读取数量。 */ + @Test void quantityTotalsUsesOnlyAuthenticatedCustomer() { + controller.quantities(user(100L,"MERCHANT"),query); + verify(service).quantityTotals("100",query); + assertThrows(ResponseStatusException.class,() -> controller.quantities(user(0L,"PLATFORM"),query)); + } + @Test void merchantDetailUsesOnlyAuthenticatedTenant() { controller.detail(user(100L, "MERCHANT"), "TR001"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferQuantityServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferQuantityServiceTest.java new file mode 100644 index 000000000..ccfd686c5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferQuantityServiceTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.baiyue.lastleg.entity.LastlegOrder; +import com.baiyue.lastleg.transfer.dto.TransferQuantityView; +import com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.PalletFact; +import com.baiyue.lastleg.transfer.repository.TransferQuantityRepository; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TransferQuantityServiceTest { + /** 库存转运签收使用目的仓实收凭据,同时保留原仓已出库托盘。 */ + @Test void destinationReceiptUsesExactOriginalPiecesAndPhysicalTargetPallets() { + var repository=mock(TransferQuantityRepository.class); + var service=new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository); + var filter=new QueryWrapper().eq("id",3); + when(repository.rows(filter)).thenReturn(List.of(new TransferQuantityView(3L,14L,null,2,0,0,0,0,0,0))); + when(repository.movements(filter)).thenReturn(List.of( + new com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact(3L,"A",1,100L,"RECEIVED",1L,200L,10L), + new com.baiyue.lastleg.transfer.mapper.TransferQuantityMapper.MovementFact(3L,"A",1,101L,"RECEIVED",2L,201L,4L))); + var result=service.totals(filter); + assertEquals(14L,result.receivedPieceCount()); assertEquals(2,result.receivedCartonCount()); + assertEquals(2,result.receivedPalletCount()); assertEquals(2,result.shippedPalletCount()); + assertEquals(2,result.shippedCartonCount()); + } + + /** 完整筛选总量保留未知,托盘按客户去重且剩余与出库集合允许重叠。 */ + @Test void keepsUnknownsAndDeduplicatesOverlappingPalletsAcrossCustomers() { + var repository = mock(TransferQuantityRepository.class); + var service = new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository); + var filter = new QueryWrapper().eq("order_type","WAREHOUSE_TRANSFER"); + when(repository.rows(filter)).thenReturn(List.of( + new TransferQuantityView(1L,null,12L,3,2,1,1,1,1,1), + new TransferQuantityView(2L,4L,null,1,1,1,1,0,1,0))); + when(repository.pallets(filter)).thenReturn(List.of(new PalletFact("A",100L,"COUNTED"), + new PalletFact("A",100L,"SHIPPED"),new PalletFact("A",100L,"COUNTED"),new PalletFact("B",100L,"COUNTED"))); + var totals = service.totals(filter); + assertEquals(2,totals.orderCount()); assertNull(totals.declaredPieceCount()); assertEquals(4,totals.knownDeclaredPieceCount()); + assertEquals(1,totals.unknownDeclaredOrderCount()); assertNull(totals.receivedPieceCount()); + assertEquals(12,totals.knownReceivedPieceCount()); assertEquals(1,totals.unknownReceivedOrderCount()); + assertEquals(2,totals.receivedPalletCount()); assertEquals(2,totals.remainingPalletCount()); assertEquals(1,totals.shippedPalletCount()); + verify(repository).rows(filter); verify(repository).pallets(filter); + } + + /** 空筛选返回准确零值,非转运订单无需额外数量读取。 */ + @Test void emptyFilterIsZeroAndNonTransferPageSkipsRepository() { + var repository = mock(TransferQuantityRepository.class); + var service = new TransferQuantityService(); ReflectionTestUtils.setField(service,"repository",repository); + var order = new LastlegOrder(); order.setOrderType("RETURN_RELABEL"); + assertTrue(service.forOrders(List.of(order)).isEmpty()); verifyNoInteractions(repository); + var totals = service.totals(new QueryWrapper().apply("1=0")); + assertEquals(0L,totals.declaredPieceCount()); assertEquals(0L,totals.receivedPieceCount()); assertEquals(0,totals.receivedPalletCount()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index e0de96786..1ce61ed87 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -27,7 +27,6 @@ class WarehouseTransferCartonCountTest { service = spy(new WarehouseTransferService()); ReflectionTestUtils.setField(service, "transfers", orders); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); - org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> orders.lockForPda(call.getArgument(1), call.getArgument(0))); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "transferCartons", cartons); ReflectionTestUtils.setField(service, "transferItems", items); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index c3ec0bb4e..860ffb446 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -143,7 +143,7 @@ class WarehouseTransferServiceTest { when(relabelApplications.selectList(any())).thenReturn(List.of(relabel)); var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null)); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null)); assertEquals("RL-20260923-000001", result.records().get(0).relabelNo()); assertEquals("COMPLETED", result.records().get(0).relabelStatus()); @@ -162,7 +162,7 @@ class WarehouseTransferServiceTest { when(transfers.selectPage(any(), any())).thenReturn(resultPage); var result = service.page("TENANT-A", new WarehouseTransferRequests.Query( - 1, 20, null, null, null, null, null, null, null, null)); + 1, 20, null, null, null, null, null, null, null, null, null, null, null, null, null, null)); assertEquals(1, result.records().size()); assertNull(result.records().get(0).relabelStatus()); @@ -233,7 +233,6 @@ class WarehouseTransferServiceTest { }); ReflectionTestUtils.setField(service, "transfers", transfers); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); - org.mockito.Mockito.lenient().when(pickupRepository.pdaSource(anyString(), anyString())).thenAnswer(call -> transfers.lockForPda(call.getArgument(1), call.getArgument(0))); ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); ReflectionTestUtils.setField(service, "transferItems", transferItems); ReflectionTestUtils.setField(service, "transferCartons", transferCartons); @@ -521,8 +520,6 @@ class WarehouseTransferServiceTest { transfer.setInboundCode("IN-1"); transfer.setNeedRelabel(true); transfer.setPackageCount(1); - var selected = new WarehouseTransferCarton(); - selected.setId(51L); selected.setCustomerCartonMark("CURRENT-BOX"); when(inboundSnapshots.sourceCartons("TENANT-A", "WH-1", "IN-1")) .thenReturn(List.of(new InboundSnapshotMapper.SourceCarton("CURRENT-BOX", "SKU-1", null, null, null, null, null))); when(transfers.lockNo("TENANT-A", "WTR-1")).thenReturn(transfer); From bf2508b6d6c712aa87cf0f58ca34183d6fe97248 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 14:22:53 +0800 Subject: [PATCH 036/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatch/mapper/DispatchBolMapper.java | 86 ++- .../dispatch/model/DispatchBolData.java | 14 +- .../service/DispatchAggregateService.java | 11 +- .../service/DispatchBolPrintService.java | 4 +- .../service/DispatchBolReadService.java | 81 ++- .../openapi/overseas-business-v1.yaml | 2 +- .../DispatchAggregatePersistenceTest.java | 60 +- .../service/DispatchBolPrintServiceTest.java | 100 +++- .../DispatchBolReadPersistenceTest.java | 201 ++++++- .../print/service/DispatchBolPdfService.java | 12 +- .../DispatchBolPdfServiceGenerationTest.java | 40 ++ scripts/README.md | 2 +- scripts/admin-test-env-acceptance.mjs | 3 +- scripts/admin-test-env-modules/README.md | 39 ++ .../finance-downloads-contract.test.mjs | 6 + .../finance-downloads.mjs | 9 +- .../grouped-dispatch-acceptance.mjs | 2 + .../phase3-producer-chain-contract.test.mjs | 2 +- .../phase4-carrier-nodes-contract.test.mjs | 8 + .../phase4-carrier-nodes.mjs | 17 +- scripts/backend-full-regression.ps1 | 8 +- ...ient-test-env-acceptance-contract.test.mjs | 74 +++ scripts/client-test-env-acceptance.mjs | 519 ++++++++++++++++++ .../full-regression-entry-contract.test.mjs | 10 + 24 files changed, 1222 insertions(+), 88 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java index ede9b6852..15000acf6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchBolMapper.java @@ -1,35 +1,65 @@ package com.baiyue.lastleg.dispatch.mapper; -import com.baiyue.lastleg.dispatch.model.DispatchBolData; + import org.apache.ibatis.annotations.*; import java.util.List; -/** BOL按真实明细客户逐行读取整车,不依赖已移除的主单客户头。 */ + +/** BOL读取原调度逐来源数量及真实预约分配,扫码进度和订单商品JOIN均不放大或缩小箱数。 */ @Mapper public interface DispatchBolMapper { - /** 旧客户详情入口沿用整车查询,但只返回该客户自己的BOL明细。 */ - default List findByDispatchOrder(String customerId, Long dispatchOrderId) { - return findWholeDispatch(dispatchOrderId).stream().filter(line -> java.util.Objects.equals(customerId,line.customerId())).map(line -> new com.baiyue.lastleg.dispatch.dto.DispatchResponse.BolLine(line.containerNo(),line.palletNo(),line.fbaWarehouseCode(),line.destinationWarehouseName(),line.warehouseNo(),line.fbaShipmentId(),line.poReferenceNo(),line.packageCount())).toList(); - } + record Cargo(Long dispatchItemId,Long orderId,Long planPalletId,String customerId,String destinationType, + String deliveryAddress,String postalCode,String containerNo,String palletNo,String fbaWarehouseCode, + String destinationWarehouseName,String warehouseNo,String fbaShipmentId,String poReferenceNo, + Integer packageCount,Integer itemPackageCount,boolean originalPlan) { } + record Allocation(Long dispatchItemId,Long planPalletId,String batchKey,String customerId,String sourceKey, + String destinationKey,Integer packageCount,Long appointmentId,Long orderId,String confirmationNo) { } - @Select("SELECT di.customer_id AS customerId,di.destination_type AS destinationType,di.delivery_address AS deliveryAddress,di.postal_code AS postalCode," + - "COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo," + - "MAX(NULLIF(di.pallet_no,'')) AS palletNo," + - "COALESCE(MAX(NULLIF(l.destination_warehouse_code,'')),MAX(NULLIF(di.fba_warehouse_code,''))," + - "MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode," + - "COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName," + - "COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo," + - "GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId," + - "GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo," + - "CASE WHEN MAX(l.id) IS NULL THEN COALESCE(MAX(di.package_count),0) " + - "WHEN COALESCE(MAX(a.picked_package_count),0)>0 THEN MAX(a.picked_package_count) " + - "ELSE COALESCE(MAX(a.planned_package_count),0) END AS packageCount " + - "FROM ll_dispatch_item di " + - "LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id " + - "LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id " + - "LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' " + - "LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id " + - "LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id " + - "LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=di.customer_id " + - "WHERE di.dispatch_order_id=#{dispatchOrderId} " + - "GROUP BY di.id,di.customer_id,di.destination_type,di.delivery_address,di.postal_code,l.id ORDER BY di.id,l.id") - List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); + /** 一行对应本车一个真实计划来源行;商品明细只聚合展示字段,原调度数量不取部分已拣数。 */ + @Select(""" + SELECT di.id AS dispatchItemId, + CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL + AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN l.order_id ELSE di.order_id END AS orderId, + di.plan_pallet_id AS planPalletId,di.customer_id AS customerId,di.destination_type AS destinationType, + di.delivery_address AS deliveryAddress,di.postal_code AS postalCode, + COALESCE(MAX(NULLIF(o.container_no,'')),MAX(NULLIF(ip.container_no,''))) AS containerNo, + MAX(NULLIF(di.pallet_no,'')) AS palletNo, + COALESCE(MAX(NULLIF(l.fba_warehouse_code,'')),MAX(NULLIF(l.destination_warehouse_code,'')), + MAX(NULLIF(di.fba_warehouse_code,'')),MAX(NULLIF(di.destination_warehouse_code,''))) AS fbaWarehouseCode, + COALESCE(MAX(NULLIF(l.destination_warehouse_name,'')),MAX(NULLIF(di.destination_warehouse_name,''))) AS destinationWarehouseName, + COALESCE(MAX(NULLIF(l.inbound_code,'')),MAX(NULLIF(di.inbound_code,''))) AS warehouseNo, + GROUP_CONCAT(DISTINCT NULLIF(od.shipment_no,'') ORDER BY od.shipment_no SEPARATOR ', ') AS fbaShipmentId, + GROUP_CONCAT(DISTINCT NULLIF(od.fba_tracking_no,'') ORDER BY od.fba_tracking_no SEPARATOR ', ') AS poReferenceNo, + CASE WHEN l.id IS NULL THEN MAX(di.package_count) + WHEN di.source_stage='RECEIVED' THEN MAX(a.counted_package_count) + ELSE MAX(a.planned_package_count) END AS packageCount, + MAX(di.package_count) AS itemPackageCount, + CASE WHEN di.plan_pallet_id IS NOT NULL AND di.return_receipt_id IS NULL + AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' THEN TRUE ELSE FALSE END AS originalPlan + FROM ll_dispatch_item di + LEFT JOIN ll_inbound_plan_pallet p ON p.id=di.plan_pallet_id + LEFT JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=di.customer_id + LEFT JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=di.customer_id + AND di.return_receipt_id IS NULL AND COALESCE(di.source_business_type,'')<>'RELABEL_ORDER' + LEFT JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=di.customer_id AND l.plan_id=p.plan_id + LEFT JOIN ll_order o ON o.id=l.order_id AND o.customer_id=di.customer_id + LEFT JOIN ll_order_detail od ON od.order_id=l.order_id AND od.customer_id=l.customer_id + WHERE di.dispatch_order_id=#{dispatchOrderId} + GROUP BY di.id,di.order_id,di.plan_pallet_id,di.customer_id,di.destination_type,di.delivery_address, + di.postal_code,di.source_stage,di.return_receipt_id,di.source_business_type,l.id,l.order_id,a.id + ORDER BY di.id,l.id,a.id + """) + List findWholeDispatch(@Param("dispatchOrderId") long dispatchOrderId); + + /** 原batch+客户+稳定来源+目的地定位预约货物;BOOKING使用持久化order_id,绝不自行拼ORDER键。 */ + @Select(""" + SELECT a.dispatch_item_id AS dispatchItemId,a.plan_pallet_id AS planPalletId,a.batch_key AS batchKey, + a.customer_id AS customerId,a.source_key AS sourceKey,a.destination_key AS destinationKey, + a.package_count AS packageCount,p.id AS appointmentId,i.order_id AS orderId,p.confirmation_no AS confirmationNo + FROM ll_dispatch_appointment_allocation a + LEFT JOIN ll_inbound_fba_appointment p ON a.batch_key=CONCAT('FBA_APPOINTMENT:',p.id) + LEFT JOIN ll_inbound_fba_appointment_item i ON i.appointment_id=p.id AND i.customer_id=a.customer_id + AND i.source_key=a.source_key AND CONCAT('FBA:',i.fba_warehouse_code)=a.destination_key + WHERE a.dispatch_id=#{dispatchOrderId} AND a.batch_key LIKE 'FBA_APPOINTMENT:%' + ORDER BY a.dispatch_item_id,a.customer_id,a.source_key,a.destination_key,a.batch_key + """) + List findAppointmentAllocations(@Param("dispatchOrderId") long dispatchOrderId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java index 869c23a97..016a13577 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/model/DispatchBolData.java @@ -3,13 +3,17 @@ import java.time.LocalDateTime; import java.util.*; /** 打印服务内部一致快照,不恢复旧排车JSON协议。 */ public record DispatchBolData(String dispatchNo,String overseasWarehouseId,String overseasWarehouseName,String carrierName, - String vehiclePlate,LocalDateTime estimatedArrivalAt,List bolLines,Map appointmentNumbers) { - public record Line(String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo, + String vehiclePlate,LocalDateTime estimatedArrivalAt,List bolLines) { + public record Line(Long dispatchItemId,Long orderId,String customerId,String destinationType,String deliveryAddress,String postalCode,String containerNo, String palletNo,String fbaWarehouseCode,String destinationWarehouseName,String warehouseNo,String fbaShipmentId, - String poReferenceNo,int packageCount) { + String poReferenceNo,int packageCount,String appointmentNo) { + /** 外部确认号只去首尾空白,未取得真实约号时保留空。 */ + public Line { appointmentNo=appointmentNo==null || appointmentNo.isBlank()?null:appointmentNo.strip(); } + /** 目的地身份不使用客户作为分组条件。 */ public String destinationKey(){return com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(destinationType,fbaWarehouseCode,null,deliveryAddress,postalCode);} + /** 打印展示使用业务名称或真实地址。 */ public String destinationLabel(){return "PRIVATE".equals(destinationType)?deliveryAddress:destinationWarehouseName;} - public String groupKey(){return customerId+"\u0000"+destinationKey();} + /** 同目的地同约号跨客户合并,无号也可组成同目的地文档。 */ + public String groupKey(){return destinationKey()+"\u0000"+Objects.requireNonNullElse(appointmentNo,"");} } - public String appointmentFor(Line line){return appointmentNumbers.get(line.groupKey());} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index b283e69fb..8c52fba4b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -369,7 +369,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var row=lockPlatform(actor,number);var identity=identity(actor,number,Action.SUBMIT_REVIEW,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); requireEditing(row,request.recordVersion()); - if(!Set.of(ReviewState.DRAFT.name(),ReviewState.DISPATCH_REJECTED.name(),ReviewState.LOADING_REJECTED.name()).contains(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); + if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); verifyReviewResources(row); if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); long round=row.getReviewRound()+1;String basis=basis(row); @@ -739,6 +739,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(!Status.ACTIVE.name().equals(row.getStatus()) || !Stage.BEFORE_LOADING.name().equals(row.getStage()) || n(row.getPickedPackageCount())>0 || n(row.getShippedPackageCount())>0) throw conflict("仅装车前且未扫码的有效排车可以调整货物"); } + /** 提交资格和写入共用可重提状态,已进入两级待审的整车不得再次显示提交入口。 */ + private static boolean canSubmitReview(String reviewState) { + return ReviewState.DRAFT.name().equals(reviewState) || ReviewState.DISPATCH_REJECTED.name().equals(reviewState) + || ReviewState.LOADING_REJECTED.name().equals(reviewState); + } + /** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */ Map actions(Actor actor,DispatchAggregate row) { var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); @@ -746,7 +752,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var action:Action.values()) { boolean state=switch(action) { case CREATE -> actor.mode()==ActorMode.PLATFORM; - case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL,SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; + case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState()); case DISPATCH_REVIEW -> active && review==ReviewState.DISPATCH_PENDING; case LOADING_REVIEW -> active && review==ReviewState.LOADING_PENDING; case RETURN_TO_REVIEW -> active && stage==Stage.LOADING && n(row.getPickedPackageCount())==0; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java index ff5af7f79..341ab0d03 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintService.java @@ -32,6 +32,7 @@ public class DispatchBolPrintService { @Resource private DomesticWarehouseDirectoryClient warehouses; @Value("${baiyue.security.internal-service-token:}") private String internalToken; + /** 按本车一致快照组装全部BOL,一次调用打印服务并保持一个PDF下载。 */ public GeneratedPdf generate(String dispatchNo, String requestId) { DispatchBolData detail = dispatches.read(dispatchNo); if (detail.bolLines() == null || detail.bolLines().isEmpty()) { @@ -54,6 +55,7 @@ public class DispatchBolPrintService { } } + /** 将同目的地同预约号的跨客户货物组装为独立分页BOL,无预约号时ISA保持空白。 */ private List documents(DispatchBolData detail) { String shipFromAddress = warehouseAddress(detail.overseasWarehouseId()); Map> groups = new LinkedHashMap<>(); @@ -72,7 +74,7 @@ public class DispatchBolPrintService { .map(line -> new PrintPdfClient.CargoRow(text(line.containerNo()), text(line.palletNo()), text(line.fbaWarehouseCode()), text(line.warehouseNo()), text(line.fbaShipmentId()), text(line.poReferenceNo()), line.packageCount())).toList(); documents.add(new PrintPdfClient.Document(text(detail.overseasWarehouseName()), shipFromAddress, text(detail.carrierName()), - text(detail.appointmentFor(lines.get(0))), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", + lines.get(0).appointmentNo() == null ? "" : lines.get(0).appointmentNo().strip(), dateTime(detail.estimatedArrivalAt()), text(detail.vehiclePlate()), "—", text(lines.get(0).fbaWarehouseCode()), destination, "—", text(detail.dispatchNo()), unique(lines, true), unique(lines, false), total, page + 1, pages, rows)); offset = end; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java index 00c2c2d28..4d361fde2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadService.java @@ -1,31 +1,92 @@ package com.baiyue.lastleg.dispatch.service; + import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.model.DispatchBolData; import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository; import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.*; import java.time.*; import java.util.*; -import java.util.stream.Collectors; -/** 先在本地一致事务读全车打印事实,事务结束后才调用远程打印。 */ + +/** 同一只读事务内核实本车逐来源数量,再按真实预约份额拆行;事务结束后才调用远程打印。 */ @Service public class DispatchBolReadService { - private final DispatchAggregateRepository roots;private final DispatchBolMapper lines;private final ObjectMapper json; - public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolMapper lines,ObjectMapper json){this.roots=roots;this.lines=lines;this.json=json;} + private final DispatchAggregateRepository roots;private final DispatchBolRepository facts;private final ObjectMapper json; + private record Source(Long itemId,String customerId,Long orderId,String destinationKey) { } + private record AllocationKey(Long itemId,String batchKey,String customerId,String sourceKey,String destinationKey) { } + /** 打印读取只经Repository,不写预约、调度或库存事实。 */ + public DispatchBolReadService(DispatchAggregateRepository roots,DispatchBolRepository facts,ObjectMapper json){this.roots=roots;this.facts=facts;this.json=json;} + + /** 保留原调度总量,未约部分以空约号打印,不按部分拣货进度缩量。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchBolData read(String number) { var row=roots.find(number);if(row==null)throw new BizException(ResultCode.NOT_FOUND,"排车单不存在"); Resources resources;try{resources=json.readValue(row.getResourcesJson(),Resources.class);}catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("本车资源快照损坏",e);} - var cargo=lines.findWholeDispatch(row.getId()); - if(cargo.stream().anyMatch(l->l.customerId()==null || l.destinationKey()==null || l.packageCount()<=0))throw new BizException(ResultCode.VALIDATION_FAILED,"BOL货物客户、目的地或箱数尚未核实"); - var confirmations=new LinkedHashMap>(); - for(var batch:roots.batches(row.getId()))if(batch.confirmationNo()!=null && !batch.confirmationNo().isBlank())confirmations.computeIfAbsent(batch.customerId()+"\u0000"+batch.destinationKey(),k->new LinkedHashSet<>()).add(batch.confirmationNo()); - var numbers=confirmations.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey,e->String.join(", ",e.getValue()))); + var cargo=new LinkedHashMap();var totals=new HashMap();var pallets=new HashMap(); + for(var raw:facts.cargo(row.getId())) { + int packages=nonnegative(raw.packageCount());var line=line(raw,null,packages); + if(raw.dispatchItemId()==null || blank(raw.customerId()) || line.destinationKey()==null + || raw.originalPlan() && raw.orderId()==null)throw invalid(); + int total=positive(raw.itemPackageCount());var prior=totals.putIfAbsent(raw.dispatchItemId(),total); + if(prior!=null && prior!=total)throw invalid(); + if(pallets.containsKey(raw.dispatchItemId()) && !Objects.equals(pallets.get(raw.dispatchItemId()),raw.planPalletId()))throw invalid(); + pallets.put(raw.dispatchItemId(),raw.planPalletId()); + if(packages==0)continue; + var key=new Source(raw.dispatchItemId(),raw.customerId(),raw.orderId(),line.destinationKey()); + cargo.merge(key,line,DispatchBolReadService::merge); + } + var sourceTotals=new HashMap(); + cargo.forEach((key,line)->sourceTotals.merge(key.itemId(),line.packageCount(),Math::addExact)); + if(!sourceTotals.equals(totals))throw invalid(); + var remaining=new LinkedHashMap();cargo.forEach((key,line)->remaining.put(key,line.packageCount())); + var seen=new HashSet();var result=new ArrayList(); + for(var allocation:facts.allocations(row.getId())) { + if(allocation.appointmentId()==null || allocation.orderId()==null || blank(allocation.sourceKey()) + || !Objects.equals(pallets.get(allocation.dispatchItemId()),allocation.planPalletId()))throw invalid(); + var key=new Source(allocation.dispatchItemId(),allocation.customerId(),allocation.orderId(),allocation.destinationKey()); + var source=cargo.get(key);int count=positive(allocation.packageCount()); + if(source==null || !seen.add(new AllocationKey(allocation.dispatchItemId(),allocation.batchKey(),allocation.customerId(),allocation.sourceKey(),allocation.destinationKey())) + || count>remaining.get(key))throw invalid(); + remaining.put(key,remaining.get(key)-count);result.add(copy(source,count,allocation.confirmationNo())); + } + // 每个来源只消费实际分配份额,余额有明确实物依据,保持空ISA而非重复整托。 + remaining.forEach((key,count)->{if(count>0)result.add(copy(cargo.get(key),count,null));}); + var printedTotals=new HashMap();result.forEach(line->printedTotals.merge(line.dispatchItemId(),line.packageCount(),Math::addExact)); + if(!printedTotals.equals(totals))throw invalid(); return new DispatchBolData(row.getDispatchNo(),row.getOverseasWarehouseId(),row.getOverseasWarehouseName(),resources.vehicle()==null?null:resources.vehicle().carrierName(),resources.vehicle()==null?null:resources.vehicle().plate(), - row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(cargo),Map.copyOf(numbers)); + row.getEstimatedArrivalAt()==null?null:LocalDateTime.ofInstant(row.getEstimatedArrivalAt().toInstant(ZoneOffset.UTC),ZoneId.systemDefault()),List.copyOf(result)); } + /** 将数据库原始来源行转换为带真实身份的内部打印行。 */ + private static DispatchBolData.Line line(DispatchBolMapper.Cargo c,String appointment,int packages) { + return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(), + c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment); + } + /** 拆份只改变份额和预约号,真实客户、源订单和排车明细身份不变。 */ + private static DispatchBolData.Line copy(DispatchBolData.Line c,int packages,String appointment) { + return new DispatchBolData.Line(c.dispatchItemId(),c.orderId(),c.customerId(),c.destinationType(),c.deliveryAddress(),c.postalCode(),c.containerNo(),c.palletNo(), + c.fbaWarehouseCode(),c.destinationWarehouseName(),c.warehouseNo(),c.fbaShipmentId(),c.poReferenceNo(),packages,appointment); + } + /** 同订单多来源行先守恒合并,再让每条预约分配消费一次;展示字符串只去重合并。 */ + private static DispatchBolData.Line merge(DispatchBolData.Line a,DispatchBolData.Line b) { + return new DispatchBolData.Line(a.dispatchItemId(),a.orderId(),a.customerId(),a.destinationType(),a.deliveryAddress(),a.postalCode(),join(a.containerNo(),b.containerNo()),a.palletNo(), + a.fbaWarehouseCode(),a.destinationWarehouseName(),join(a.warehouseNo(),b.warehouseNo()),join(a.fbaShipmentId(),b.fbaShipmentId()),join(a.poReferenceNo(),b.poReferenceNo()),Math.addExact(a.packageCount(),b.packageCount()),null); + } + /** 仅打印展示字段去重,不用逗号字符串表示预约身份。 */ + private static String join(String a,String b) { + var values=new LinkedHashSet();for(var text:Arrays.asList(a,b))if(text!=null)for(var part:text.split(","))if(!part.isBlank())values.add(part.strip()); + return values.isEmpty()?null:String.join(", ",values); + } + /** 数量未知或非正不得伪造打印份额。 */ + private static int positive(Integer value){if(value==null || value<=0)throw invalid();return value;} + /** 已核实为零的源行没有待打印货物,但不以零替代未知数量。 */ + private static int nonnegative(Integer value){if(value==null || value<0)throw invalid();return value;} + /** 来源身份必须来自真实持久事实。 */ + private static boolean blank(String value){return value==null || value.isBlank();} + /** 不完整、跨客户或超额分配不能生成看似守恒的BOL。 */ + private static BizException invalid(){return new BizException(ResultCode.VALIDATION_FAILED,"BOL货物来源、预约归属或分配箱数尚未核实");} } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 8245d27b3..86c396041 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -874,7 +874,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/prints/bol: get: summary: 生成排车 BOL PDF - description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的 FBA 仓分组并分页生成固定 A4 PDF,浏览器不直接访问内部打印服务。 + description: 权限 platform:overseas-warehouse:dispatch:list;服务端按目的地与预约号归集BOL,同目的地同预约号跨客户合并,不同预约号分别生成BOL并独立分页;无预约号也能打印且预约栏留空,同目的地空号合并。本次仍返回包含各份BOL的一个固定A4 PDF,浏览器不直接访问内部打印服务,打印不改变货物客户归属。 security: [{ bearerAuth: [] }] parameters: [{ name: dispatchNo, in: path, required: true, schema: { type: string } }] responses: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 3f4290dcf..356a5c095 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -140,7 +140,7 @@ public class DispatchAggregatePersistenceTest { /** 真实司机Service继续执行当前/历史附件关联检查,不由排车测试替身伪装通过。 */ DriverEvidenceFixture() throws Exception { f=new Fixture();driver.setId(71L);driver.setLicenseFileId(81L);when(driverRows.selectById(71L)).thenReturn(driver); - var drivers=new DriverService(driverRows,history,files,f.json,Clock.systemUTC(),mock(FleetWarehouseDirectory.class)); + var drivers=new DriverService(driverRows,history,files,f.json,mock(FleetWarehouseDirectory.class)); org.springframework.test.util.ReflectionTestUtils.setField(f.fleet,"drivers",drivers); dispatchFiles=(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway)org.springframework.test.util.ReflectionTestUtils.getField(f.service,"evidence"); } @@ -287,6 +287,64 @@ public class DispatchAggregatePersistenceTest { assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平"); assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); } + /** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */ + @Test void pendingSubmissionGateRejectsNewKeyWithoutMutationAndRestoresOriginalCommand() throws Exception { + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","", + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit")); + try { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-gate-create",List.of(source)))); + var request=new DispatchCommands.SubmitReview(created.recordVersion(),"submission-gate-original",null); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request)); + var detail=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo())); + assertThat(detail.reviewState()).isEqualTo(ReviewState.DISPATCH_PENDING); + assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isFalse(); + var before=f.json.writeValueAsString(f.repository.find(created.dispatchNo())); + var commands=f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id"); + assertThatThrownBy(()->f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(), + new DispatchCommands.SubmitReview(detail.recordVersion(),"submission-gate-duplicate",null)))) + .isInstanceOf(BizException.class).hasMessageContaining("当前审核已提交"); + assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before); + assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands); + var replay=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),request)); + assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(submitted.recordVersion()); + assertThat(replay.result()).isEqualTo(submitted.result()); + assertThat(f.json.writeValueAsString(f.repository.find(created.dispatchNo()))).isEqualTo(before); + assertThat(f.db.queryForList("SELECT id,idempotency_key,record_version,command_hash FROM ll_dispatch_command ORDER BY id")).isEqualTo(commands); + } finally { security.setAuthentication(previous); } + } + + /** 真实两级审核流证明三种可重提状态开放,而两种待审状态均关闭提交资格。 */ + @Test void submissionActionMatchesDraftRejectedAndPendingReviewStates() throws Exception { + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator-test","", + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit", + "platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review")); + try { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("submission-states-create",List.of(source)))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度"))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true); + var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false); + var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载"))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true); + f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null))); + assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false); + } finally { security.setAuthentication(previous); } + } + + /** 从真实详情DTO读取动作资格,避免只测内部枚举或权限缺失导致的假阴性。 */ + private static void assertSubmissionGate(Fixture f,String number,ReviewState state,boolean allowed) { + var detail=f.tx.execute(s->f.service.detail(f.actor,number)); + assertThat(detail.reviewState()).isEqualTo(state);assertThat(detail.actions().get(Action.SUBMIT_REVIEW).allowed()).isEqualTo(allowed); + } + /** 两级顺序、驳回重提、原结果恢复和零扫码退回均实际提交到同一整车。 */ @Test void reviewsUseActualRootRoundAndImmutableCommandsBeforeAndAfterLoading() throws Exception { var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java index 488b56e2f..2d3587142 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolPrintServiceTest.java @@ -10,7 +10,7 @@ import com.baiyue.common.core.api.ApiResponse; import com.baiyue.lastleg.client.DomesticWarehouseDirectoryClient; import com.baiyue.lastleg.client.PrintPdfClient; import com.baiyue.lastleg.dispatch.model.DispatchBolData; -import java.util.Map; +import java.util.ArrayList; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; import java.util.List; @@ -33,11 +33,13 @@ class DispatchBolPrintServiceTest { @InjectMocks private DispatchBolPrintService service; @BeforeEach + /** 设置本次受控打印调用的内部身份。 */ void setUp() { ReflectionTestUtils.setField(service, "internalToken", "internal-test-token"); } @Test + /** 核对首页十八行及续页二十六行的全部分页边界。 */ void usesDenseFirstAndContinuationPageCapacities() { assertThat(DispatchBolPrintService.pageCount(1)).isEqualTo(1); assertThat(DispatchBolPrintService.pageCount(18)).isEqualTo(1); @@ -49,10 +51,11 @@ class DispatchBolPrintServiceTest { } @Test + /** 保留从实际起运仓主档解析营业地址的打印行为。 */ void resolvesTheOperatingWarehouseAddressIntoTheBolRequest() { String warehouseId = "193000000000200003"; DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, - LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1)),Map.of()); + LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2",1))); var address = new DomesticWarehouseDirectoryClient.Address("OPERATING", "US", "California", "Rancho Cucamonga", null, "91730", "10767 7th Street", null); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, @@ -74,10 +77,11 @@ class DispatchBolPrintServiceTest { } @Test + /** 无预约号时不同目的仓仍分别生成各自BOL。 */ void createsOneBolDocumentPerFbaWarehouse() { String warehouseId = "193000000000200003"; DispatchBolData detail = new DispatchBolData("DS-WH-20260911-624449",warehouseId,"洛杉矶仓","五月开发验收车队",null, - LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1)),Map.of()); + LocalDateTime.of(2026,9,12,2,40,20),List.of(line("101","ABE2","ABE2 Warehouse",1),line("102","ONT8","ONT8 Warehouse",1))); var warehouse = new DomesticWarehouseDirectoryClient.Warehouse(Long.parseLong(warehouseId), 0L, "US-LAX", "洛杉矶仓", "OVERSEAS", "US", "ACTIVE", null, null); when(dispatches.read(detail.dispatchNo())).thenReturn(detail); @@ -96,21 +100,91 @@ class DispatchBolPrintServiceTest { .containsExactly("ABE2", "ONT8"); } @Test - void keepsTwoCustomersAtTheSameDestinationInSeparateBolDocuments() { - var first=line("101","ABE2","ABE2 Warehouse",3);var second=line("102","ABE2","ABE2 Warehouse",5); - var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,List.of(first,second), - Map.of(first.groupKey(),"CONF-A",second.groupKey(),"CONF-B")); + /** 同仓同号合并客户,同时保留各自入仓码、托盘与箱数。 */ + void mergesCustomersWithTheSameDestinationAndAppointmentWithoutLosingTheirCargo() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-SHARED"),cargo(12,"102","ABE2","IN-B",5,"CONF-SHARED")),1); + assertThat(request.documents()).singleElement().satisfies(document->{ + assertThat(document.isaNumber()).isEqualTo("CONF-SHARED");assertThat(document.totalPackages()).isEqualTo(8); + assertThat(document.pageNumber()).isEqualTo(1);assertThat(document.pageCount()).isEqualTo(1); + assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A","IN-B"); + assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::packageCount).containsExactly(3,5); + assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::palletNo).containsExactly("PALLET-11","PALLET-12"); + }); + } + @Test + /** 同目的仓的不同预约号不得合成同一份BOL。 */ + void keepsDifferentAppointmentsAtTheSameDestinationInSeparateDocuments() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-A"),cargo(12,"102","ABE2","IN-B",5,"CONF-B")),2); + assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A","CONF-B"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(3,5); + assertThat(request.documents().get(0).cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A"); + assertThat(request.documents().get(1).cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-B"); + } + @Test + /** 相同预约号不能将不同目的仓的货物混入同一份BOL。 */ + void keepsTheSameAppointmentAtDifferentDestinationsInSeparateDocuments() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,"CONF-SHARED"),cargo(12,"102","ONT8","IN-B",5,"CONF-SHARED")),2); + assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-SHARED","CONF-SHARED"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::fbaWarehouseCode).containsExactly("ABE2","ONT8"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(3,5); + } + @Test + /** 无号及空白号合并同仓货物,并把ISA作为空串传递。 */ + void mergesMissingAppointmentsAtTheSameDestinationAndLeavesIsaEmpty() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A",3,null),cargo(12,"102","ABE2","IN-B",5," ")),1); + assertThat(request.documents()).singleElement().satisfies(document->{ + assertThat(document.isaNumber()).isEmpty();assertThat(document.totalPackages()).isEqualTo(8); + assertThat(document.cargoRows()).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactly("IN-A","IN-B"); + }); + } + @Test + /** 两仓各两个预约在一次PDF请求中保留四份独立BOL。 */ + void keepsTwoAppointmentsForEachOfTwoWarehousesInOnePdfRequest() { + var request=render(List.of(cargo(11,"101","ABE2","IN-A1",2,"CONF-A1"),cargo(12,"102","ABE2","IN-A2",3,"CONF-A2"), + cargo(13,"101","ONT8","IN-B1",4,"CONF-B1"),cargo(14,"102","ONT8","IN-B2",5,"CONF-B2")),4); + assertThat(request.documents()).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A1","CONF-A2","CONF-B1","CONF-B2"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::fbaWarehouseCode).containsExactly("ABE2","ABE2","ONT8","ONT8"); + assertThat(request.documents()).extracting(PrintPdfClient.Document::totalPackages).containsExactly(2,3,4,5); + assertThat(request.downloadFileName()).isEqualTo("DS-ONE-TRUCK-BOL.pdf"); + } + @Test + /** 每份BOL重置页码,跨页全部原始货物仅累计一次。 */ + void restartsPaginationPerBolAndCountsEveryCargoRowOnlyOnce() { + var lines=new ArrayList(); + for(int i=0;i<45;i++)lines.add(cargo(100+i,i%2==0?"101":"102","ABE2","IN-A-"+i,i%3+1,"CONF-A")); + for(int i=0;i<19;i++)lines.add(cargo(200+i,"103","ABE2","IN-B-"+i,2,"CONF-B")); + var documents=render(lines,5).documents(); + assertThat(documents).extracting(PrintPdfClient.Document::pageNumber).containsExactly(1,2,3,1,2); + assertThat(documents).extracting(PrintPdfClient.Document::pageCount).containsExactly(3,3,3,2,2); + assertThat(documents.stream().map(d->d.cargoRows().size()).toList()).containsExactly(18,26,1,18,1); + assertThat(documents).extracting(PrintPdfClient.Document::totalPackages).containsExactly(90,90,90,38,38); + assertThat(documents).extracting(PrintPdfClient.Document::isaNumber).containsExactly("CONF-A","CONF-A","CONF-A","CONF-B","CONF-B"); + var rows=documents.stream().flatMap(d->d.cargoRows().stream()).toList();assertThat(rows).hasSize(64); + assertThat(rows.stream().mapToInt(PrintPdfClient.CargoRow::packageCount).sum()).isEqualTo(128); + assertThat(rows).extracting(PrintPdfClient.CargoRow::warehouseNo).containsExactlyElementsOf(lines.stream().map(DispatchBolData.Line::warehouseNo).toList()); + } + /** 所有分组仍通过一次远程调用生成同一个PDF,并保留原文件名。 */ + private PrintPdfClient.Request render(List lines,int pages) { + var detail=new DispatchBolData("DS-ONE-TRUCK","9","Operating Warehouse",null,null,null,lines); when(dispatches.read(detail.dispatchNo())).thenReturn(detail); var warehouse=new DomesticWarehouseDirectoryClient.Warehouse(9L,0L,"WH9","Operating Warehouse","OVERSEAS","US","ACTIVE",null,null); when(warehouses.activeWarehouse(9L,"internal-test-token")).thenReturn(ApiResponse.success(new DomesticWarehouseDirectoryClient.Detail(warehouse,List.of()))); - when(print.generateDispatchBol(eq("internal-test-token"),eq("two-customers"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body("%PDF-".getBytes(StandardCharsets.US_ASCII))); - service.generate(detail.dispatchNo(),"two-customers"); + when(print.generateDispatchBol(eq("internal-test-token"),eq("grouped-bol"),any())).thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF) + .header("X-Print-Page-Count",Integer.toString(pages)).body("%PDF-".getBytes(StandardCharsets.US_ASCII))); + var generated=service.generate(detail.dispatchNo(),"grouped-bol"); + assertThat(generated.fileName()).isEqualTo("DS-ONE-TRUCK-BOL.pdf");assertThat(generated.pageCount()).isEqualTo(Integer.toString(pages)); + assertThat(generated.content()).isEqualTo("%PDF-".getBytes(StandardCharsets.US_ASCII)); var request=ArgumentCaptor.forClass(PrintPdfClient.Request.class); - verify(print).generateDispatchBol(eq("internal-test-token"),eq("two-customers"),request.capture()); - assertThat(request.getValue().documents()).hasSize(2); - assertThat(request.getValue().documents().stream().map(PrintPdfClient.Document::isaNumber).toList()).containsExactly("CONF-A","CONF-B"); + verify(print).generateDispatchBol(eq("internal-test-token"),eq("grouped-bol"),request.capture()); + return request.getValue(); } + /** 新快照携带真实明细及原订单身份,不恢复旧预约号映射。 */ + private static DispatchBolData.Line cargo(long item,String customer,String code,String incoming,int packages,String appointment) { + return new DispatchBolData.Line(item,item+1000,customer,"FBA",null,null,"TSTU3539519","PALLET-"+item,code,code+" Warehouse",incoming, + "SHIP-"+item,"REF-"+item,packages,appointment); + } + /** 为既有仓地址与目的地测试构造没有预约号的真实身份快照。 */ private static DispatchBolData.Line line(String customer,String code,String name,int packages) { - return new DispatchBolData.Line(customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages); + return new DispatchBolData.Line(Long.valueOf(customer),Long.parseLong(customer)+1000,customer,"FBA",null,null,"TSTU3539519","DP-001",code,name,"NYEXX2609110214",null,null,packages,null); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java index 098768f5a..1e2e0e22b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java @@ -1,41 +1,214 @@ package com.baiyue.lastleg.dispatch.service; + import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.model.DispatchBolData; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.repository.DispatchBolRepository; import com.baiyue.lastleg.dispatch.port.DispatchContracts; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.mybatis.spring.SqlSessionTemplate; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.DriverManagerDataSource; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.support.TransactionTemplate; import java.util.*; import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; + +/** 真实H2/MyBatis读取来源与预约分配;不通过mock返回新模型自证数量守恒。 */ class DispatchBolReadPersistenceTest { + /** 同仓同目的地无号货物跨客户合并文档,逐行客户和来源仍独立。 */ @Test void wholeTruckBolReadsBothTrueOwnersAndPreservesTheirOriginalLineComposition() throws Exception { - var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10),(2,20)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A'),(20,'102','CN-B')"); - f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','IN-A'),(200,'102',2000,'ONT8','ONT8','IN-B')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,0,3),(2,200,0,5)"); - f.db.update("INSERT INTO ll_order VALUES(1000,'101','CN-A'),(2000,'102','CN-B')");f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')"); - f.item(11,"101",1,null,"FBA",null,3);f.item(12,"102",2,null,"FBA",null,5); - var result=f.service.read("DP-ONE");assertThat(result.bolLines()).extracting(l->l.customerId()).containsExactly("101","102");assertThat(result.bolLines()).extracting(l->l.packageCount()).containsExactly(3,5);assertThat(result.bolLines()).extracting(l->l.fbaShipmentId()).containsExactly("SHIP-A","SHIP-B"); - assertThat(result.bolLines().get(0).groupKey()).isNotEqualTo(result.bolLines().get(1).groupKey()); + var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0); + f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(2000,'102','SHIP-B','REF-B')"); + var result=f.read();assertThat(result.bolLines()).extracting(DispatchBolData.Line::customerId).containsExactly("101","102"); + assertThat(result.bolLines()).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5); + assertThat(result.bolLines()).extracting(DispatchBolData.Line::fbaShipmentId).containsExactly("SHIP-A","SHIP-B"); + assertThat(result.bolLines().get(0).groupKey()).isEqualTo(result.bolLines().get(1).groupKey()); + assertThat(result.bolLines()).allSatisfy(line->assertThat(line.appointmentNo()).isNull()); } + + /** 部分退回仅打印所选箱数,不借原计划已清点/已拣总数放大。 */ @Test void partialReturnReceiptPrintsOnlySelectedQuantityAndPrivateActualAddress() throws Exception { - var f=new Fixture();f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(1,10)");f.db.update("INSERT INTO ll_inbound_plan VALUES(10,'101','CN-A')");f.db.update("INSERT INTO ll_inbound_plan_line VALUES(100,'101',1000,'ONT8','ONT8','ORIGINAL-IN')");f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(1,100,8,8)"); - f.item(11,"101",1,99L,"PRIVATE","Actual receiving street",2); - var result=f.service.read("DP-ONE");assertThat(result.bolLines()).singleElement().satisfies(l->{assertThat(l.packageCount()).isEqualTo(2);assertThat(l.destinationLabel()).isEqualTo("Actual receiving street");assertThat(l.warehouseNo()).isEqualTo("RETURN-IN");}); + var f=new Fixture();f.source(11,"101",1,1000,8,"ONT8","RECEIVED",8); + f.db.update("UPDATE ll_dispatch_item SET return_receipt_id=99,destination_type='PRIVATE',delivery_address='Actual receiving street',inbound_code='RETURN-IN',package_count=2 WHERE id=11"); + var result=f.read();assertThat(result.bolLines()).singleElement().satisfies(line->{ + assertThat(line.packageCount()).isEqualTo(2);assertThat(line.destinationLabel()).isEqualTo("Actual receiving street"); + assertThat(line.warehouseNo()).isEqualTo("RETURN-IN");assertThat(line.appointmentNo()).isNull(); + }); } + + /** 换标履约仅采用该逻辑明细,不能复用原计划所有货物。 */ + @Test void relabelSourceDoesNotBorrowOriginalPlanQuantity() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,8,"ONT8","RECEIVED",8); + f.db.update("UPDATE ll_dispatch_item SET source_business_type='RELABEL_ORDER',source_stage='RELABEL',package_count=2,inbound_code='RELABEL-IN' WHERE id=11"); + assertThat(f.read().bolLines()).singleElement().satisfies(line->{assertThat(line.packageCount()).isEqualTo(2);assertThat(line.warehouseNo()).isEqualTo("RELABEL-IN");}); + } + + /** 同实物分成两个预约时只消费各自份额,多条订单商品资料不得重复箱数。 */ + @Test void twoAppointmentsConsumeOnePhysicalSourceExactlyOncePerAllocation() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1); + f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A','REF-A'),(1000,'101','SHIP-B','REF-B'),(1000,'101','SHIP-A','REF-A')"); + f.appointment(1," ISA-A ","101","ORDER:1000",1000,"ONT8");f.appointment(2,"ISA-B","101","ORDER:1000",1000,"ONT8"); + f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",2);f.allocate(11,1,2,"101","ORDER:1000","FBA:ONT8",3); + var lines=f.read().bolLines();assertThat(lines).hasSize(2); + assertThat(lines).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-A","ISA-B"); + assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(2,3); + assertThat(lines).allSatisfy(line->{assertThat(line.dispatchItemId()).isEqualTo(11L);assertThat(line.orderId()).isEqualTo(1000L);assertThat(line.fbaShipmentId()).isEqualTo("SHIP-A, SHIP-B");}); + assertThat(lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum()).isEqualTo(5); + } + + /** 同一明细同计划实托的两个订单各取自己的预约份额,商品JOIN不能将另一订单或整托复制过去。 */ + @Test void oneDispatchItemWithTwoOrdersAllocatesEachOriginalSourceToItsOwnAppointment() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",1); + f.db.update("UPDATE ll_dispatch_item SET package_count=5 WHERE id=11"); + f.db.update("INSERT INTO ll_inbound_plan_line VALUES(120,'101',10,2000,'ONT8','ONT8','ONT8','IN-2000')"); + f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(121,'101',10,1,120,0,2,2)"); + f.db.update("INSERT INTO ll_order VALUES(2000,'101','CN-2000')"); + f.db.update("INSERT INTO ll_order_detail VALUES(1000,'101','SHIP-A1','REF-A1'),(1000,'101','SHIP-A2','REF-A2'),(1000,'101','SHIP-A1','REF-A1'),(2000,'101','SHIP-B1','REF-B1'),(2000,'101','SHIP-B2','REF-B2')"); + f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.appointment(2,"ISA-B","101","ORDER:2000",2000,"ONT8"); + f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(11,1,2,"101","ORDER:2000","FBA:ONT8",2); + var lines=f.read().bolLines();assertThat(lines).hasSize(2); + assertThat(lines).extracting(DispatchBolData.Line::dispatchItemId).containsExactly(11L,11L); + assertThat(lines).extracting(DispatchBolData.Line::orderId).containsExactly(1000L,2000L); + assertThat(lines).extracting(DispatchBolData.Line::appointmentNo).containsExactly("ISA-A","ISA-B"); + assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(3,2); + assertThat(lines).extracting(DispatchBolData.Line::warehouseNo).containsExactly("IN-1000","IN-2000"); + assertThat(lines).extracting(DispatchBolData.Line::fbaShipmentId).containsExactly("SHIP-A1, SHIP-A2","SHIP-B1, SHIP-B2"); + assertThat(lines).extracting(DispatchBolData.Line::poReferenceNo).containsExactly("REF-A1, REF-A2","REF-B1, REF-B2"); + assertThat(lines.stream().mapToInt(DispatchBolData.Line::packageCount).sum()).isEqualTo(5); + } + + /** BOOKING来源通过原预约item的持久order_id关联,不把真实稳定键改拼成ORDER。 */ + @Test void bookingSourceUsesPersistedAppointmentOrderMapping() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0); + f.appointment(1,"BOOKING-ISA","101","BOOKING:EXT-REAL",1000,"ONT8"); + f.allocate(11,1,1,"101","BOOKING:EXT-REAL","FBA:ONT8",5); + assertThat(f.read().bolLines()).singleElement().satisfies(line->{assertThat(line.orderId()).isEqualTo(1000L);assertThat(line.appointmentNo()).isEqualTo("BOOKING-ISA");assertThat(line.packageCount()).isEqualTo(5);}); + } + + /** 同外部约号、同目的地的不同客户保留各自份额,但共用文档键。 */ + @Test void sameAppointmentAcrossCustomersSharesDocumentWithoutLosingOwnership() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"ONT8","RECEIVED",0); + f.appointment(1,"SHARED-ISA","101","ORDER:1000",1000,"ONT8");f.appointment(2," SHARED-ISA ","102","ORDER:2000",2000,"ONT8"); + f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:ONT8",5); + var lines=f.read().bolLines();assertThat(lines).extracting(DispatchBolData.Line::customerId).containsExactly("101","102"); + assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(3,5);assertThat(lines.stream().map(DispatchBolData.Line::groupKey).distinct().count()).isOne(); + } + + /** 同约号不同目的地仍分文档;缺约号不以系统预约单号补充。 */ + @Test void destinationSeparatesSharedNumbersAndBlankConfirmationRemainsPrintable() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,3,"ONT8","RECEIVED",0);f.source(12,"102",2,2000,5,"LAX9","RECEIVED",0); + f.appointment(1,"SAME","101","ORDER:1000",1000,"ONT8");f.appointment(2,"SAME","102","ORDER:2000",2000,"LAX9"); + f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",3);f.allocate(12,2,2,"102","ORDER:2000","FBA:LAX9",5); + assertThat(f.read().bolLines().stream().map(DispatchBolData.Line::groupKey).distinct().count()).isEqualTo(2); + f.db.update("UPDATE ll_inbound_fba_appointment SET confirmation_no=' '"); + assertThat(f.read().bolLines()).allSatisfy(line->{assertThat(line.appointmentNo()).isNull();assertThat(line.packageCount()).isPositive();}); + } + + /** 未约余量有真实来源数量依据,空ISA保留;不能把全托复制到已有预约。 */ + @Test void unassignedRemainderKeepsItsOwnQuantityAndBlankAppointment() throws Exception { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1); + f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",2); + var lines=f.read().bolLines();assertThat(lines).extracting(DispatchBolData.Line::packageCount).containsExactly(2,3); + assertThat(lines.get(0).appointmentNo()).isEqualTo("ISA-A");assertThat(lines.get(1).appointmentNo()).isNull(); + } + + /** PLAN用计划箱数,RECEIVED用清点箱数;部分扫码量从不替代原调度份额。 */ + @Test void plannedAndReceivedCountsMatchTheirAuthoritativeSourceStage() throws Exception { + for(var stage:List.of("PLAN","RECEIVED")) { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8",stage,1); + if(stage.equals("PLAN"))f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=2"); + else f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET planned_package_count=9"); + assertThat(f.read().bolLines()).singleElement().satisfies(line->assertThat(line.packageCount()).isEqualTo(5)); + } + } + + /** 来源合计与本车选择量不一致或数量未知时失败,不以整托/扫码常数补齐。 */ + @Test void missingCountsAndChangedSelectedTotalsFailClosed() throws Exception { + for(var change:List.of("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=NULL", + "UPDATE ll_dispatch_item SET package_count=4")) { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",1);f.db.update(change); + assertThatThrownBy(f::read).isInstanceOf(BizException.class).hasMessageContaining("尚未核实"); + } + } + + /** 明细、客户、来源、目的地、原计划与预约item任一不匹配均不能取得打印份额。 */ + @Test void foreignOrIncompleteAllocationCannotBorrowAnotherSource() throws Exception { + for(var fault:List.of("customer","item","plan","source","destination","appointment-customer","order","missing-order","missing-batch")) { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0); + f.appointment(1,"ISA","101","BOOKING:REAL",1000,"ONT8");f.allocate(11,1,1,"101","BOOKING:REAL","FBA:ONT8",5); + f.db.update(switch(fault) { + case "customer" -> "UPDATE ll_dispatch_appointment_allocation SET customer_id='FOREIGN'"; + case "item" -> "UPDATE ll_dispatch_appointment_allocation SET dispatch_item_id=99"; + case "plan" -> "UPDATE ll_dispatch_appointment_allocation SET plan_pallet_id=99"; + case "source" -> "UPDATE ll_dispatch_appointment_allocation SET source_key='ORDER:1000'"; + case "destination" -> "UPDATE ll_dispatch_appointment_allocation SET destination_key='FBA:LAX9'"; + case "appointment-customer" -> "UPDATE ll_inbound_fba_appointment_item SET customer_id='FOREIGN'"; + case "order" -> "UPDATE ll_inbound_fba_appointment_item SET order_id=9999"; + case "missing-order" -> "UPDATE ll_inbound_fba_appointment_item SET order_id=NULL"; + default -> "DELETE FROM ll_inbound_fba_appointment"; + }); + assertThatThrownBy(f::read).as(fault).isInstanceOf(BizException.class).hasMessageContaining("尚未核实"); + } + } + + /** 单份超量和多份累计超量均失败,原货物和分配记录不被读取操作修复。 */ + @Test void individualAndCumulativeOverAllocationFailWithoutChangingFacts() throws Exception { + for(boolean cumulative:List.of(false,true)) { + var f=new Fixture();f.source(11,"101",1,1000,5,"ONT8","RECEIVED",0); + f.appointment(1,"ISA-A","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,1,"101","ORDER:1000","FBA:ONT8",cumulative?3:6); + if(cumulative){f.appointment(2,"ISA-B","101","ORDER:1000",1000,"ONT8");f.allocate(11,1,2,"101","ORDER:1000","FBA:ONT8",3);} + var before=f.db.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY batch_key"); + assertThatThrownBy(f::read).isInstanceOf(BizException.class).hasMessageContaining("尚未核实"); + assertThat(f.db.queryForList("SELECT * FROM ll_dispatch_appointment_allocation ORDER BY batch_key")).isEqualTo(before); + } + } + + /** 测试表仅取完整模块DDL中实际引用列,保留来源/分配唯一键,事务覆盖两个真实Mapper查询。 */ private static final class Fixture { - final JdbcTemplate db;final DispatchBolReadService service; + final JdbcTemplate db;final DispatchBolReadService service;final TransactionTemplate tx; + /** 根主单只提供身份,全部货物和预约事实均经实际Mapper读取。 */ Fixture() throws Exception { var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa","");db=new JdbcTemplate(ds); - db.execute("CREATE TABLE ll_dispatch_item(id BIGINT,dispatch_order_id BIGINT,customer_id VARCHAR(64),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),destination_type VARCHAR(32),delivery_address VARCHAR(128),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(64),destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64),package_count INT)"); - db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT)");db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR(64),order_id BIGINT,destination_warehouse_code VARCHAR(64),destination_warehouse_name VARCHAR(64),inbound_code VARCHAR(64))");db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(64),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(64),shipment_no VARCHAR(64),fba_tracking_no VARCHAR(64))"); + db.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(128),plan_pallet_id BIGINT,return_receipt_id BIGINT,source_business_type VARCHAR(32),source_stage VARCHAR(32),order_id BIGINT,destination_type VARCHAR(32),delivery_address VARCHAR(1000),postal_code VARCHAR(32),pallet_no VARCHAR(64),fba_warehouse_code VARCHAR(128),destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(255),inbound_code VARCHAR(128),package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,plan_id BIGINT)");db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),container_no VARCHAR(64))"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),plan_id BIGINT,pallet_id BIGINT,line_id BIGINT,picked_package_count INT,planned_package_count INT,counted_package_count INT,UNIQUE(line_id,pallet_id))"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),plan_id BIGINT,order_id BIGINT,fba_warehouse_code VARCHAR(128),destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(255),inbound_code VARCHAR(128),UNIQUE(plan_id,order_id))"); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),container_no VARCHAR(64))");db.execute("CREATE TABLE ll_order_detail(order_id BIGINT,customer_id VARCHAR(128),shipment_no VARCHAR(255),fba_tracking_no VARCHAR(255))"); + db.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT NOT NULL,dispatch_item_id BIGINT NOT NULL,plan_pallet_id BIGINT NOT NULL,batch_key VARCHAR(96) NOT NULL,customer_id VARCHAR(64) NOT NULL,source_key VARCHAR(160) NOT NULL,destination_key VARCHAR(160) NOT NULL,package_count INT NOT NULL,PRIMARY KEY(dispatch_item_id,batch_key,source_key,destination_key))"); + db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(64),confirmation_no VARCHAR(128))"); + db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT PRIMARY KEY,appointment_id BIGINT NOT NULL,source_key VARCHAR(160) NOT NULL,customer_id VARCHAR(128) NOT NULL,order_id BIGINT,fba_warehouse_code VARCHAR(128) NOT NULL,UNIQUE(appointment_id,source_key))"); var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class); - var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row);when(roots.batches(1)).thenReturn(List.of());service=new DispatchBolReadService(roots,mapper,new ObjectMapper()); + var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row); + service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper()); + tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); + } + /** 本地一致事务读取,不接触任何环境或远程文件。 */ + DispatchBolData read(){return tx.execute(status->service.read("DP-ONE"));} + /** 创建一票真实计划来源;picked与原调度/清点数量有意独立。 */ + void source(long item,String customer,long pallet,long order,int packages,String code,String stage,int picked) { + long plan=pallet*10,line=item*10; + db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?)",pallet,plan);db.update("INSERT INTO ll_inbound_plan VALUES(?,?,?)",plan,customer,"CN-"+order); + db.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?,?,?,?,?)",line,customer,plan,order,code,code,code,"IN-"+order); + db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?,?,?,?)",line+1,customer,plan,pallet,line,picked,packages,packages); + db.update("INSERT INTO ll_order VALUES(?,?,?)",order,customer,"CN-"+order); + db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,NULL,NULL,?,?, 'FBA',NULL,NULL,?,?,?,?,?,?)",item,customer,pallet,stage,order,"P-"+pallet,code,code,code,"IN-"+order,packages); + } + /** 系统预约业务号与对外确认号分开,稳定来源键原样保存。 */ + void appointment(long id,String confirmation,String customer,String source,long order,String code) { + db.update("INSERT INTO ll_inbound_fba_appointment VALUES(?,?,?)",id,"SYSTEM-APPOINTMENT-"+id,confirmation); + db.update("INSERT INTO ll_inbound_fba_appointment_item VALUES(?,?,?,?,?,?)",id,id,source,customer,order,code); + } + /** 按模块实际主键建立本车明细的真实逐票分配,不以客户目的地总量替代。 */ + void allocate(long item,long pallet,long appointment,String customer,String source,String destination,int packages) { + db.update("INSERT INTO ll_dispatch_appointment_allocation VALUES(1,?,?,?,?,?,?,?)",item,pallet,"FBA_APPOINTMENT:"+appointment,customer,source,destination,packages); } - void item(long id,String customer,long plan,Long returned,String type,String address,int packages){db.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,?,NULL,?,?,NULL,?,'ONT8','ONT8','ONT8',?,?)",id,customer,plan,returned,type,address,"P-"+id,returned==null?"IN-"+customer:"RETURN-IN",packages);} } } diff --git a/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java b/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java index 213c1a826..badf3f26e 100644 --- a/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java +++ b/print/src/main/java/com/baiyue/print/service/DispatchBolPdfService.java @@ -64,6 +64,7 @@ public class DispatchBolPdfService { return new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, request.pageSpec(), fileName, List.copyOf(documents)); } + /** 核对页面容量并规范化打印字段,预约号缺失时保持空白。 */ private DispatchBolPdfDtos.Document normalizeDocument(DispatchBolPdfDtos.Document value, int index) { int maxRows = value != null && value.pageNumber() == 1 ? MAX_FIRST_PAGE_ROWS : MAX_CONTINUATION_PAGE_ROWS; if (value == null || value.cargoRows() == null || value.cargoRows().isEmpty() || value.cargoRows().size() > maxRows) { @@ -77,15 +78,22 @@ public class DispatchBolPdfService { text(row.warehouseNo()), text(row.fbaShipmentId()), text(row.poReferenceNo()), row.packageCount())); } return new DispatchBolPdfDtos.Document(text(value.shipFromName()), text(value.shipFromAddress()), text(value.carrierName()), - text(value.isaNumber()), text(value.appointmentTime()), text(value.trailerNumber()), text(value.sealNumber()), + optionalText(value.isaNumber()), text(value.appointmentTime()), text(value.trailerNumber()), text(value.sealNumber()), text(value.fbaWarehouseCode()), text(value.shipToName()), text(value.shipToAddress()), text(value.dispatchNo()), text(value.fbaShipmentIds()), text(value.poReferenceNos()), value.totalPackages(), value.pageNumber(), value.pageCount(), List.copyOf(rows)); } + /** 普通缺失字段沿用占位符,预约号通过可空文本单独处理。 */ private String text(String value) { + String normalized = optionalText(value); + return normalized.isBlank() ? "—" : normalized; + } + + /** 保留空白语义,同时复用长度、规范化和控制字符校验。 */ + private String optionalText(String value) { String normalized = line(value); if (normalized.codePointCount(0, normalized.length()) > 300 || normalized.codePoints().anyMatch(c -> Character.isISOControl(c))) invalid("document text is invalid"); - return normalized.isBlank() ? "—" : normalized; + return normalized; } private String line(String value) { return value == null ? "" : Normalizer.normalize(value, Normalizer.Form.NFC).strip(); } private void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); } diff --git a/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java b/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java index 7e8aaf050..cc1f744bf 100644 --- a/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java +++ b/print/src/test/java/com/baiyue/print/service/DispatchBolPdfServiceGenerationTest.java @@ -1,6 +1,7 @@ package com.baiyue.print.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.baiyue.print.config.PrintPdfProperties; import com.baiyue.print.dto.DispatchBolPdfDtos; @@ -73,6 +74,45 @@ class DispatchBolPdfServiceGenerationTest { "FBA19TEST", "PO-TEST", 44, pageNumber, pageCount, rows); } + /** 未预约仍能生成真实PDF,预约栏不打印占位符或null。 */ + @Test + void leavesMissingAppointmentNumbersBlankInTheRenderedPdf() throws Exception { + for (String appointment : new String[] { null, "", " " }) { + var generated = service.generate(appointmentRequest(appointment)); + try (PDDocument pdf = Loader.loadPDF(generated.content())) { + assertThat(pdf.getNumberOfPages()).isEqualTo(1); + assertThat(new PDFTextStripper().getText(pdf)).contains("ISA #:", "BILL OF LADING", "PALLET-01") + .doesNotContain("ISA #: —", "ISA #: null"); + } + } + } + + /** 空值放开不削弱预约文本的长度和控制字符边界。 */ + @Test + void keepsAppointmentTextValidationAndTrimsPresentNumbers() throws Exception { + assertThatThrownBy(() -> service.generate(appointmentRequest("ISA\u0000BAD"))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + assertThatThrownBy(() -> service.generate(appointmentRequest("A".repeat(301)))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class); + var generated = service.generate(appointmentRequest(" ISA-ONE ")); + try (PDDocument pdf = Loader.loadPDF(generated.content())) { + assertThat(new PDFTextStripper().getText(pdf)).contains("ISA #: ISA-ONE"); + } + } + + /** 只替换预约字段,其他页面参数使用既有真实渲染夹具。 */ + private DispatchBolPdfDtos.Request appointmentRequest(String appointment) { + var original = page(1, 1, rows(1, 1)); + var document = new DispatchBolPdfDtos.Document(original.shipFromName(), original.shipFromAddress(), + original.carrierName(), appointment, original.appointmentTime(), original.trailerNumber(), + original.sealNumber(), original.fbaWarehouseCode(), original.shipToName(), original.shipToAddress(), + original.dispatchNo(), original.fbaShipmentIds(), original.poReferenceNos(), original.totalPackages(), + original.pageNumber(), original.pageCount(), original.cargoRows()); + return new DispatchBolPdfDtos.Request(DispatchBolPdfDtos.TEMPLATE, + new DispatchBolPdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), + "appointment-bol.pdf", List.of(document)); + } + private List rows(int start, int end) { List rows = new ArrayList<>(); for (int index = start; index <= end; index++) { diff --git a/scripts/README.md b/scripts/README.md index 08ccd5cd8..d149ea7a5 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -273,7 +273,7 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc |---|---|---| | 测试环境 HTTP | `admin-test-env-acceptance.mjs` | Admin 全模块真实 HTTP 验收;按模块创建、更新、停用测试数据 | | 测试环境 HTTP | `pallet-location-test-env-acceptance.mjs` | 订单测量与连续库位扩展矩阵;串行创建 5 票独立订单,覆盖 1/2/3 张称重图、体积跨档、删图释放、补传复用和跨票隔离,不主动停用库位制造 A0 | -| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据 | +| 测试环境 HTTP | `client-test-env-acceptance.mjs` | Client 完整真实 HTTP 链路;写入客户、订单、入库、权限和消息数据;客户账单、海外仓自助(退货预报写链路与贴标/转运/废弃/库存/箱号只读)、跨境采购/卡车/散板、入库与派送订单、船期价格和报关待办已纳入 | | 测试环境 HTTP | `client-quick-order-test-env-acceptance.mjs` | Client 快速下单定向真实 HTTP 验收;使用既有测试客户创建 FBA、海外仓和私人地址草稿,验证逐行失败与幂等重放,不提交订单 | | 测试环境 HTTP | `client-phase2-test-env-acceptance.mjs` | Client 入库、测量、费用和权限专项;写测试数据 | diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index c95e05789..a51961e09 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -5,7 +5,7 @@ import { dirname, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import process from 'node:process' -const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'dashboard', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] +const CONFIGURED_MODULES = ['merchant-qualification-fixture', 'customers', 'overseas-storage-fees', 'system', 'notifications', 'pda-releases', 'audit', 'dashboard', 'self-operated-analytics', 'master-data', 'suppliers', 'domestic-container', 'orders', 'inbound-hold', 'fee-items', 'pricing', 'downloads', 'inbound-weight-ocr', 'inbound-measurement', 'inbound-fees', 'phase3-container-loading', 'phase3-container-overview', 'phase3-loading', 'phase3-loading-model-bridge', 'phase3-pda-convergence', 'phase3-evidence-gate', 'supplemental', 'supplemental-boundaries', 'ocean-bookings', 'truck-appointments', 'service-center-flow', 'phase3-loading-finalize', 'phase3-container-documents', 'phase3-customs', 'phase3-container-customs', 'phase3-port-schedules', 'sailing-calendar', 'phase3-carrier-schedule-queries', 'phase3-outbound-dispatch', 'phase3-outbound-aggregate', 'phase3-dispatch-gate', 'phase3-waiting-for-loading', 'phase3-warehouse-rbac', 'phase3-order-splitting', 'manual-state-matrix', 'phase4-overseas-warehouse', 'phase4-carrier-pickup-ready', 'appointment-work', 'phase4-tail-order-controls', 'phase4-overseas-pda', 'phase4-overseas-pda-operations', 'phase4-overseas-return-receipt', 'phase4-overseas-inbound-operations', 'phase4-overseas-client-services', 'phase4-overseas-dispatch', 'phase5-overseas-fulfillment', 'phase5-terminal-drayage', 'phase4-cross-region-events', 'phase4-overseas-business', 'phase4-overseas-master-data', 'fleet-management', 'phase4-carrier-nodes', 'finance-full-fixtures', 'finance-reconciliation-full', 'finance-settlement-full', 'finance-direct-paid-isolation', 'finance-downloads', 'firstleg-container-fee-allocation', 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', 'customer-operating-statistics'] const OPTIONAL_MODULES = ['phase5-root-fulfillment', 'phase4-overseas-return-followups', 'manual-order-splitting-fixture', 'manual-order-splitting-verify', 'manual-preload-push-fixture', 'manual-preload-push-verify', 'manual-pda-ready-fixture', 'manual-pda-ready-verify', @@ -96,6 +96,7 @@ const MODULE_DEPENDENCIES = new Map([ ['manual-state-matrix', ['phase3-loading-finalize', 'phase3-customs', 'ocean-bookings', 'phase3-container-documents', 'phase5-overseas-fulfillment']], ['phase4-carrier-pickup-ready', ['phase3-carrier-schedule-queries', 'phase3-container-documents']], ['phase4-tail-order-controls', ['phase4-carrier-pickup-ready']], + ['appointment-work', ['phase4-carrier-pickup-ready']], ['phase4-overseas-pda', ['phase4-tail-order-controls']], ['phase4-overseas-pda-operations', ['phase4-overseas-pda']], ['phase4-overseas-return-receipt', ['merchant-qualification-fixture']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index e8d0c5e26..1df7aa5c5 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -273,7 +273,13 @@ export async function run(context) { | 订舱与拖车服务中心跨链路 | `service-center-flow.mjs`(依赖 `ocean-bookings,truck-appointments`;复用本轮 `RELEASED` 订舱创建订舱级拖车,验证派车单、离仓前状态链、历史、审计和关联零漂移) | 由入口依赖合同和拖车直接合同共同覆盖;真实 HTTP 缺本轮订舱或承运商时 fail-closed | | Admin 补充能力与保护边界 | `supplemental.mjs`、`supplemental-boundaries.mjs` | `supplemental-boundaries-contract.test.mjs`;只按本轮资源验证补充能力、负向和保护边界 | | 装柜清单三备注 | `orders.mjs`、`phase3-loading.mjs`(客户备注、最新内部备注、仓库备注;仓库备注 PATCH/清空/精确重放/旧版本零写) | `loading-list-remarks-contract.test.mjs` | +| 预约工作日历与工作事项 | `appointment-work.mjs` | `appointment-work-contract.test.mjs`;默认只读核对月历、汇总、分页、详情与不存在事项拒绝,正向命令待本轮可编辑工作项接入 | +| PDA 版本发布 | `pda-releases.mjs` | `pda-audit-sailing-contract.test.mjs`;只读双端发布列表与空上传/不存在版本拒绝,真实 APK/WGT 发布需人工准备安装包 | +| 平台操作审计 | `audit.mjs` | `pda-audit-sailing-contract.test.mjs`;分页查询、列表不返回快照、详情与不存在日志拒绝 | +| 船期日历 | `sailing-calendar.mjs` | `pda-audit-sailing-contract.test.mjs`;月历、渠道、模板与异常分配只读核对 | | 首页实时指标 | `dashboard.mjs` | `dashboard-contract.test.mjs`;默认只读核对实时柜量/清关指标、今日新运单及启用客户的服务端分页总数 | +| 自营经营分析 | `self-operated-analytics.mjs` | `fleet-analytics-contract.test.mjs`;空参探测看板/货主目录,缺采集账号上下文时记录非阻断缺口,不虚构指标 | +| 车队车次与出车记录 | `fleet-management.mjs` | `fleet-analytics-contract.test.mjs`;只读核对车次列表/详情、出车记录、车队报价与拼车需求,车次写命令待本轮排车资源接入 | | 消息中心管理 | `notifications.mjs` | `notifications-contract.test.mjs` | | Finance 真实前置、对账、结算与下载中心 | `finance-full-fixtures.mjs`、`finance-reconciliation-full.mjs`、`finance-settlement-full.mjs`、`finance-downloads.mjs` | `finance-full-fixtures-contract.test.mjs`、`finance-reconciliation-full-contract.test.mjs`、`finance-settlement-full-contract.test.mjs`、`finance-downloads-contract.test.mjs` | | Finance 扩展隔离与跨区费用 | `firstleg-container-fee-allocation.mjs`(柜次跨客户分摊)、`finance-direct-paid-isolation.mjs`(海外仓服务直接已付)、`phase4-overseas-finance-cross-region.mjs`(五类海外费用跨区进入 Finance) | 对应同名 `*-contract.test.mjs`;跨区事件持久化链在本轮获授权时另以只读数据库证据核对 | @@ -442,4 +448,37 @@ node --test scripts/admin-test-env-modules/merchant-qualification-fixture-contra 下载中心导出共用内部助手:`download-center.mjs` 的 `verifyDownloadCenterExport` 由尾程订单、海外库存及主数据模块调用,创建 File XLSX 任务、等待终态、下载并核对字段及本轮业务数据;环境和写入授权沿所属模块。离线 `download-center-fixture.mjs` 仅生成合同测试使用的 XLSX 字节;`download-center-contract.test.mjs` 校验失败不继续下载及文件内容验证,不访问环境。 +## 新增模块的授权后定向运行清单 + +以下模块已注册 `CONFIGURED_MODULES`,离线合同通过;真实 HTTP 仍需当前轮测试环境授权和对应业务前置。默认全量已包含它们,定向排查时按模块名运行,依赖仍由总入口展开。 + +| 模块 | 定向命令 | 关键前置 | 报告校验点 | +|---|---|---|---| +| `appointment-work` | `--modules phase4-carrier-pickup-ready,appointment-work` | 本轮柜、船节点或排车产生工作事项 | 日历/汇总/分页/详情结构;不存在事项命令拒绝 | +| `pda-releases` | `--modules pda-releases` | 平台 `platform:pda-release:list` | 双端发布列表结构;空上传与不存在版本拒绝 | +| `audit` | `--modules audit` | 平台 `platform:audit:list`,且本轮已有业务写入 | 分页、列表不返回快照、详情与不存在日志拒绝 | +| `sailing-calendar` | `--modules sailing-calendar` | 当月已有订舱或模板生成的船期 | 月历、渠道、模板、异常分配;无船期时记录非阻断缺口 | +| `self-operated-analytics` | `--modules self-operated-analytics` | 采集账号与仓库上下文 | 空参不可用时记录非阻断缺口,不虚构指标 | +| `fleet-management` | `--modules phase4-overseas-master-data,fleet-management` | 本轮车次或排车样本 | 车次、出车记录、报价、拼车需求只读;无车次时记录缺口 | + +Client 全量为单文件,新增的客户账单、海外仓自助、跨境采购/卡车/散板、入库与派送订单、船期价格和报关待办随默认全量执行,不能按模块定向。 + +上述模块与 Client 新增端点已按当前 Controller 静态核对存在,包括 `/api/merchant/v1/customs/pending-summary`、`/orders/{orderId}`、`/first-leg/pricing/channels`、`/api/merchant/v1/warehouses`、`/inbound-plans`、`/delivery-orders`、`/api/platform/v1/files/pda-releases` 和 `fleet/operations|quotes|capacity-offers`;真实响应结构仍以 HTTP 验收为准。所需平台权限(`platform:appointment:list`、`platform:pda-release:list`、`platform:audit:list`、`platform:firstleg:sailing-calendar:list`、`platform:self-operated-analytics:list`、`platform:fleet:*:list`)已在 IAM 与服务权限种子中定义并授予 `PLATFORM_ADMIN`,HTTP 403 不应被误判为脚本失败。 + +已知待验收缺口(不计作已覆盖通过): + +- `phase4-overseas-dispatch` 要求两个 FBA 目的地及同目的地双实托;默认主链当前只产生单目的地(9/23 证据 `destinations=ABE4`),前置不足按 `notRun` 阻断,报告中 `dispatchStatus=NOT_RUN`,并连带 `phase5-overseas-fulfillment` 阻断。补齐主链多目的仓样本后重跑。 +- `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID。 +- `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次。 +- `fleet-management` 车次写命令需要本轮排车、车辆与司机资源。 +- Client 海外仓贴标、仓储转运、货物废弃和退货换标的创建链路需要平台海外 PDA 链产出的库存;当前只覆盖客户范围列表和退货预报写链路。 +- `pda-releases` 真实 APK/WGT 上传、发布和停发需人工准备有效安装包。 + +## 本地离线合同运行边界 + +- 必须在 `baiyue-firstleg` 根运行批量合同:`auth-login-payload` 等合同按 `scripts/...` 相对路径读取,从工作区根运行会产生 `ENOENT` 假失败。 +- `appointment-work-test-env`、`agents-index-registry`、`ci-maven-resource`、`deploy-built-service-rollback` 会 `spawn` 子进程;受限沙箱下 `EPERM` 属于运行环境限制,不是合同失败。 +- `database-schema-diff`、`prod-release-*`、`fleet-*-test-env` 等需要数据库或测试环境的合同不进入本地批量扫描;按各自 README 单独授权执行。 +- 当前 134 个模块合同中有 4 个 `fleet-*` 合同失败(`fleet-cross-customer-fixture`、`fleet-external-dispatch-acceptance`、`fleet-fee-coverage-acceptance`、`fleet-return-redispatch-acceptance`),失败点在 fleet 业务模拟断言,与脚本新增模块和本次 `phase4-overseas-dispatch`/`finance-downloads` 修改无关,另行处理。 + 采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。 diff --git a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs index a202b246b..dbd698b8c 100644 --- a/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-downloads-contract.test.mjs @@ -65,3 +65,9 @@ test('未部署时能力探测后记录部署前置,不借历史业务数据', assert.match(gaps[0][3], /部署后重跑完整链/) assert.equal(steps.some((step) => step.name === '读取客户账户筛选事实'), false) }) + +test('同参导出只合并进行中任务,已完成任务允许按同参重新导出', () => { + assert.match(source, /60 秒同参请求合并/) + assert.match(source, /String\(originalStatus\) === 'SUCCEEDED'/) + assert.match(source, /File 只合并 PENDING\/RUNNING 的同参任务/) +}) diff --git a/scripts/admin-test-env-modules/finance-downloads.mjs b/scripts/admin-test-env-modules/finance-downloads.mjs index 2f45e3a45..71b91494f 100644 --- a/scripts/admin-test-env-modules/finance-downloads.mjs +++ b/scripts/admin-test-env-modules/finance-downloads.mjs @@ -127,8 +127,13 @@ async function createTask(context, type, parameters, suffix, expectedHeaders, ex const duplicateResponse = await context.request('POST', '/api/platform/v1/files/export-tasks', body, context.token, { 'Idempotency-Key': idempotencyKey }) const duplicate = context.apiSuccess(MODULE, `${type} 重复请求`, duplicateResponse, '', OPTIONS) - context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, id(duplicate?.id) === taskId, - `original=${taskId}, duplicate=${id(duplicate?.id) || 'missing'}`, OPTIONS) + const originalRead = await context.request('GET', `/api/platform/v1/files/export-tasks/${encodeURIComponent(taskId)}`) + const originalStatus = originalRead.body?.data?.status + context.assert(MODULE, `${type} 60 秒同参请求合并`, duplicateResponse, + // File 只合并 PENDING/RUNNING 的同参任务;已完成任务允许按同参重新导出。 + id(duplicate?.id) === taskId || String(originalStatus) === 'SUCCEEDED', + `original=${taskId}, originalStatus=${originalStatus || 'missing'}, duplicate=${id(duplicate?.id) || 'missing'}`, + OPTIONS) const listResponse = await context.request('GET', `/api/platform/v1/files/export-tasks?page=1&size=100&exportType=${type}`) const page = context.apiSuccess(MODULE, `${type} 下载中心列表可见`, listResponse, '', OPTIONS) diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs index 16ec5aa51..a98dc4500 100644 --- a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs @@ -101,6 +101,8 @@ export function markerCandidates(context, candidates) { } export function notRun(context, proof, reason) { proof.status = 'NOT_RUN'; proof.reason = reason + // 未运行时保留既有终态;否则显式标记 NOT_RUN,便于下游区分前置不足与代码失败。 + proof.finalStatus ??= 'NOT_RUN' context.addCoverageGap?.('phase4-overseas-dispatch', 'native-dispatch', '缺少当前生产前置', reason, 'fixture', { deploymentBlocker: true }) return proof } diff --git a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs index 6f9b19c34..36215c2d6 100644 --- a/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-producer-chain-contract.test.mjs @@ -706,7 +706,7 @@ test('container overview always follows phase3 loading in default and custom mod const entry = source('../admin-test-env-acceptance.mjs') assert.match(entry, /const DEFAULT_MODULES = normalizeModuleOrder\(CONFIGURED_MODULES\)/) assert.match(entry, /const modules = normalizeModuleOrder\(/) - assert.match(entry, /return \{[\s\S]{0,500}modules,/) + assert.match(entry, /return \{[\s\S]{0,1200}modules,/) }) test('overview does not fall back to a container fixture when loading evidence was requested', () => { diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs index 5603063c8..e2ddb9e05 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes-contract.test.mjs @@ -311,3 +311,11 @@ test('platform scope zero completes the same detail and manual-node lifecycle as assert.ok(fixture.assertions.every((check) => check.passed)) assert.ok(fixture.steps.every((check) => check.passed)) }) + +test('还柜终态拆分为节点覆盖与停止语义,人工节点下限扣除出仓自动节点和拖车回写节点', () => { + assert.match(source, /拖车业务回写后覆盖十标准实际节点/) + assert.match(source, /还柜终态停止语义与本轮货柜关联/) + assert.match(source, /STANDARD_NODES\.length - 2/) + assert.match(source, /stopReason=\$\{finalTask\.stopReason/) + assert.doesNotMatch(source, /覆盖十标准实际节点且EMPTY_RETURNED停止/) +}) diff --git a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs index fe9d53590..e1e7ad0d9 100644 --- a/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs +++ b/scripts/admin-test-env-modules/phase4-carrier-nodes.mjs @@ -348,16 +348,23 @@ export async function run(context) { // PICKED_UP; the immutable carrier history therefore contains the eight // pre-pickup nodes plus EMPTY_RETURNED. const carrierNodesWithoutInferredPickup = STANDARD_NODES.filter((node) => node !== 'PICKED_UP') - context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点且EMPTY_RETURNED停止', detailResponse, - carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)) && finalTask.status === 'STOPPED' - && finalTask.latestActualNode === 'EMPTY_RETURNED' && finalTask.stopReason === '已还柜' + context.assert(MODULE, '拖车业务回写后覆盖十标准实际节点', detailResponse, + carrierNodesWithoutInferredPickup.every((node) => actualNodes.has(node)), + `actualNodes=${[...actualNodes].join(',')}, missing=${carrierNodesWithoutInferredPickup.filter((node) => !actualNodes.has(node)).join(',')}`, + OPTIONS) + context.assert(MODULE, '还柜终态停止语义与本轮货柜关联', detailResponse, + finalTask.status === 'STOPPED' && finalTask.latestActualNode === 'EMPTY_RETURNED' + && finalTask.stopReason === '已还柜' && id(finalTask.containerBookingId) === id(drayage.containerBookingId), - `actualNodes=${[...actualNodes].join(',')}, status=${finalTask.status}`, OPTIONS) + `status=${finalTask.status}, latest=${finalTask.latestActualNode || 'none'}, ` + + `stopReason=${finalTask.stopReason || 'none'}, taskBooking=${id(finalTask.containerBookingId)}, ` + + `drayageBooking=${id(drayage.containerBookingId)}`, OPTIONS) const historyResponse = await context.request('GET', `${BASE}/${queryId}/history`) const history = context.apiSuccess(MODULE, '查询本轮任务不可变历史', historyResponse, `queryId=${queryId}`, OPTIONS) context.assert(MODULE, '历史包含码头拖车回写节点且结构完整', historyResponse, Array.isArray(history) && history.every(validHistory) - && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length - 1, + // 首个标准节点由国内出仓自动写入,PICKED_UP 由码头拖车业务回写,人工补录最多 8 条。 + && history.filter((item) => item.action === 'MANUAL_NODE').length >= STANDARD_NODES.length - 2, `history=${Array.isArray(history) ? history.length : 'missing'}`, OPTIONS) } diff --git a/scripts/backend-full-regression.ps1 b/scripts/backend-full-regression.ps1 index 49f14f071..0f2a739c1 100644 --- a/scripts/backend-full-regression.ps1 +++ b/scripts/backend-full-regression.ps1 @@ -41,7 +41,13 @@ $requiredTests = @( "firstleg/src/test/java/com/baiyue/firstleg/service/ContainerOverviewServiceTest.java", "firstleg/src/test/java/com/baiyue/firstleg/service/LoadingListServiceTest.java", "warehouse/src/test/java/com/baiyue/warehouse/repository/OverseasStorageFeeSqlContractTest.java", - "warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java" + "warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformOverseasStorageFeeControllerContractTest.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkContractTest.java", + "audit-service/src/test/java/com/baiyue/audit/controller/PlatformAuditLogControllerContractTest.java", + "file/src/test/java/com/baiyue/file/service/PdaReleaseServiceTest.java", + "firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/controller/SailingCalendarOpenApiContractTest.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/controller/PlatformAnalyticsControllerTest.java", + "lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java" ) $mavenArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true', "-f", $pom, "test") diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index 2b8daf794..6474dd4a3 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -504,3 +504,77 @@ test('F04 still rejects a numeric error row pointing to the successful row', asy const { r } = importHarness('wrong-error-row') await assert.rejects(() => r.orderExcelImportModule(), /actual Excel error row/) }) + +test('F01 client bills module covers customer-only read APIs and rejects unknown documents', async () => { + const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(source, /async function merchantFinanceBillsModule\(\)/) + assert.match(source, /await merchantFinanceBillsModule\(\)/) + const callOrder = [ + source.indexOf('await orderSubmissionChannelGateModule()'), + source.indexOf('await merchantFinanceBillsModule()'), + source.indexOf('await notificationInboxModule()'), + ] + assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1], + 'bills module must run after channel gate and before notifications') + assert.match(source, /const base = '\/api\/client\/finance\/v1'/) + for (const path of ['/bills', '/statements', '/receipts', '/receivable-settlements', '/confirm']) { + assert.ok(source.includes(path), path) + } + assert.match(source, /customerId=0&tenantId=0/) + assert.match(source, /customer bills ignore caller-supplied customer scope/) + assert.match(source, /cross-customer bill detail stays outside the other enterprise/) + assert.match(source, /unknown statement confirmation is rejected with idempotency key/) + assert.match(source, /customer receipts are confirmed-only and customer-scoped/) + assert.doesNotMatch(source, /jdbc|INSERT INTO/i) +}) + +test('F02 client overseas self-service covers customer-scoped lists and the return forecast write chain', async () => { + const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') + assert.match(source, /async function merchantOverseasWarehouseModule\(\)/) + assert.match(source, /await merchantOverseasWarehouseModule\(\)/) + const callOrder = [ + source.indexOf('await merchantFinanceBillsModule()'), + source.indexOf('await merchantOverseasWarehouseModule()'), + source.indexOf('await notificationInboxModule()'), + ] + assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1], + 'overseas self-service must run after bills and before notifications') + for (const path of ['/api/merchant/v1/warehouses', '/return-orders', '/relabel-applications', + '/warehouse-transfers', '/cargo-discards', '/return-inventory', '/return-inventory/summary', + '/return-inventory/cartons']) { + assert.ok(source.includes(path), path) + } + assert.match(source, /create return forecast with marker-owned payload/) + assert.match(source, /same idempotency key replays the same return order/) + assert.match(source, /return order appears in the customer list/) + assert.match(source, /cross-customer return order is rejected/) + assert.match(source, /overseasWarehouseReadOnlyGaps/) + assert.doesNotMatch(source, /jdbc|INSERT INTO/i) +}) + +test('F03 client cross-border, warehouse orders and pricing/customs cover current customer pages', async () => { + const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') + for (const name of ['merchantCrossBorderModule', 'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule']) { + assert.match(source, new RegExp(`async function ${name}\\(\\)`), name) + assert.match(source, new RegExp(`await ${name}\\(\\)`), name) + } + const order = ['merchantOverseasWarehouseModule', 'merchantCrossBorderModule', + 'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule', 'notificationInboxModule'] + .map((name) => source.indexOf(`await ${name}()`)) + assert.ok(order.every((index) => index >= 0) && order.every((index, position) => position === 0 || index > order[position - 1]), + 'new client modules must run in order before notifications') + for (const path of ['/us-truck-orders', '/local-looseboard-orders', '/overseas-procurements', + '/inbound-plans', '/delivery-orders', '/first-leg/pricing/channels', '/customs/pending-summary', + '/customs/orders/']) { + assert.ok(source.includes(path), path) + } + assert.match(source, /same idempotency key replays the same US truck order/) + assert.match(source, /same idempotency key replays the same looseboard order/) + assert.match(source, /same idempotency key replays the same procurement order/) + assert.match(source, /customer inbound plans are customer-scoped/) + assert.match(source, /customer delivery orders are customer-scoped/) + assert.match(source, /customer customs pending summary is readable/) + assert.match(source, /crossBorderLimitations/) + assert.doesNotMatch(source, /jdbc|INSERT INTO/i) +}) + diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 51fd75776..24090611b 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -108,6 +108,11 @@ try { await orderMutationEvidenceModule() await orderExcelImportModule() await orderSubmissionChannelGateModule() + await merchantFinanceBillsModule() + await merchantOverseasWarehouseModule() + await merchantCrossBorderModule() + await merchantWarehouseOrdersModule() + await merchantPricingCustomsModule() await notificationInboxModule() await accountSecurityModule() report.success = report.steps.every((step) => step.passed) @@ -1490,6 +1495,520 @@ async function isolationModule() { }, ({ json }) => ({ qualificationId: report.resources.businessLicenseQualificationId, rejectionCode: json?.code })) } +async function merchantFinanceBillsModule() { + const module = 'merchant-bills' + const base = '/api/client/finance/v1' + const customerId = String(report.resources.tenantId) + const requireArray = (json, label) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data), `${label} must be an array`) + return json.data + } + const bills = await apiStep(module, 'customer bills ignore caller-supplied customer scope', { + path: `${base}/bills?customerId=0&tenantId=0`, token: merchantToken, + }, ({ json }) => { + const rows = requireArray(json, 'bills') + for (const row of rows) { + assertEqual(String(row.customerId), customerId, 'bill owner must be the authenticated customer') + assertPresent(row.billNo, 'billNo') + assertPresent(row.customerName, 'customerName') + assertTrue(['ISSUED', 'PARTIALLY_PAID', 'PAID'].includes(row.status), `unexpected visible bill status ${row.status}`) + assertTrue(Number.isSafeInteger(Number(row.version)), 'bill version required') + assertTrue(['NOT_REQUIRED', 'PENDING_CONFIRMATION', 'CONFIRMED'].includes(row.customerConfirmationStatus), + 'customer confirmation status required') + } + }, ({ json }) => ({ customerId, count: json.data?.length ?? null, scope: 'authenticated-enterprise-only' })) + const statements = await apiStep(module, 'customer statements expose the same visible bills', { + path: `${base}/statements`, token: merchantToken, + }, ({ json }) => { + const rows = requireArray(json, 'statements') + assertEqual(rows.length, bills.json.data.length, 'bills and statements must expose the same visible set') + for (const row of rows) assertEqual(String(row.customerId), customerId, 'statement owner must be the authenticated customer') + }, ({ json }) => ({ count: json.data?.length ?? null })) + await apiStep(module, 'customer receipts are confirmed-only and customer-scoped', { + path: `${base}/receipts`, token: merchantToken, + }, ({ json }) => { + const rows = requireArray(json, 'receipts') + for (const row of rows) { + assertPresent(row.receiptNo, 'receiptNo') + assertTrue(Number.isFinite(Number(row.receivedAmount)), 'receipt amount required') + assertEqual(row.status, 'CONFIRMED', 'only confirmed receipts are customer-visible') + } + }, ({ json }) => ({ count: json.data?.length ?? null })) + await apiStep(module, 'customer receivable settlements are readable', { + path: `${base}/receivable-settlements`, token: merchantToken, + }, ({ json }) => { + const rows = requireArray(json, 'receivable settlements') + for (const row of rows) { + assertPresent(row.settlementNo, 'settlementNo') + assertEqual(String(row.customerId), customerId, 'settlement owner must be the authenticated customer') + assertTrue(Number.isFinite(Number(row.receivableAmount)), 'receivable amount required') + } + }, ({ json }) => ({ count: json.data?.length ?? null })) + await apiStep(module, 'unknown bill id is rejected without cross-customer disclosure', { + path: `${base}/bills/999999999999999`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown bill must not succeed, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + await apiStep(module, 'unknown statement confirmation is rejected with idempotency key', { + method: 'POST', path: `${base}/statements/999999999999999/confirm`, token: merchantToken, + headers: { 'Idempotency-Key': newIdempotencyKey('bill-confirm-negative') }, + body: { expectedVersion: 1 }, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown statement must not confirm, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + if (existingClientToken) { + await apiStep(module, 'cross-customer bill detail stays outside the other enterprise', { + path: `${base}/bills/999999999999999`, token: existingClientToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', 'another enterprise bill must not be readable') + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } else { + recordSkipped(module, 'cross-customer bill detail stays outside the other enterprise', + 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') + } + report.merchantFinanceBills = { + customerId, + bills: bills.json.data.length, + statements: statements.json.data.length, + scope: 'read-only; statement confirmation with real issued bills is covered when Admin finance chain runs first', + } +} + +async function merchantOverseasWarehouseModule() { + const module = 'merchant-overseas-warehouse' + const base = '/api/merchant/v1/last-leg' + const page = 'page=1&size=20' + const listStep = async (label, path, validateRow) => { + const response = await apiStep(module, label, { path, token: merchantToken }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), `${label} records must be an array`) + assertTrue(Number.isFinite(Number(json.data?.total)), `${label} total required`) + for (const row of json.data.records) validateRow?.(row) + }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) + return response.json.data + } + + const warehouseResponse = await apiStep(module, 'customer reads active overseas warehouses', { + path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE&warehouseType=OVERSEAS', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records) && json.data.records.length > 0, 'at least one active overseas warehouse required') + assertTrue(json.data.records.every((row) => row.status === 'ACTIVE' && row.warehouseType === 'OVERSEAS'), + 'customer warehouse list must contain only active overseas warehouses') + }, ({ json }) => ({ count: json.data?.records?.length ?? null })) + const warehouse = warehouseResponse.json.data.records[0] + + await listStep('return forecasts are customer-scoped', `${base}/return-orders?${page}`, (row) => { + assertPresent(row.returnNo, 'returnNo') + assertPresent(row.status, 'return status') + }) + await listStep('relabel applications are customer-scoped', `${base}/relabel-applications?${page}`, (row) => { + assertPresent(row.relabelNo, 'relabelNo') + assertTrue(Number.isFinite(Number(row.totalPackageCount)), 'relabel package count required') + }) + await listStep('warehouse transfers are customer-scoped', `${base}/warehouse-transfers?${page}`, (row) => { + assertPresent(row.transferNo, 'transferNo') + assertPresent(row.status, 'transfer status') + }) + await listStep('cargo discards are customer-scoped', `${base}/cargo-discards?${page}`, (row) => { + assertPresent(row.discardNo, 'discardNo') + assertTrue(Number.isFinite(Number(row.totalPackages)), 'discard package count required') + }) + await listStep('return inventory is customer-scoped', `${base}/return-inventory?${page}`, (row) => { + assertPresent(row.cartonMark, 'cartonMark') + assertTrue(Number.isFinite(Number(row.quantity)), 'return inventory quantity required') + }) + await listStep('return inventory summary is customer-scoped', `${base}/return-inventory/summary?${page}`, (row) => { + assertPresent(row.skuCode, 'summary skuCode') + assertTrue(Number.isFinite(Number(row.availableQuantity)), 'available quantity required') + }) + await listStep('return cartons are customer-scoped', `${base}/return-inventory/cartons?${page}`, (row) => { + assertPresent(row.cartonMark, 'cartonMark') + assertPresent(row.status, 'carton status') + }) + + const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-20) + const sku = `ACCEPT-${suffix}` + const createPayload = { + overseasWarehouseId: String(warehouse.id), + warehouseContactName: '验收联系人', + warehouseContactPhone: '13800000000', + customerReferenceNo: `RET-${suffix}`, + needRelabel: false, + remark: `${marker} client overseas self-service acceptance`, + idempotencyKey: newIdempotencyKey('return-order'), + items: [{ skuCode: sku, productNameZh: '验收测试货物', expectedQuantity: 1 }], + } + const created = await apiStep(module, 'create return forecast with marker-owned payload', { + method: 'POST', path: `${base}/return-orders`, token: merchantToken, body: createPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.order?.returnNo, 'returnNo') + assertEqual(String(json.data.order.overseasWarehouseId), String(warehouse.id), 'return order warehouse') + assertTrue(Array.isArray(json.data.items) && json.data.items.length === 1 + && json.data.items[0].skuCode === sku && Number(json.data.items[0].expectedQuantity) === 1, + 'return order items echo the marker payload') + }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null, status: json.data?.order?.status ?? null })) + const returnNo = created.json.data.order.returnNo + report.resources.returnOrderNos = [returnNo] + + await apiStep(module, 'same idempotency key replays the same return order', { + method: 'POST', path: `${base}/return-orders`, token: merchantToken, body: createPayload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.returnNo, returnNo, 'replayed returnNo must match the original') + }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null })) + + await apiStep(module, 'return order detail keeps the marker payload', { + path: `${base}/return-orders/${encodeURIComponent(returnNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.returnNo, returnNo, 'detail returnNo') + assertTrue(Array.isArray(json.data.items) && json.data.items.some((item) => item.skuCode === sku), + 'detail keeps the submitted sku') + }, ({ json }) => ({ returnNo: json.data?.order?.returnNo ?? null, items: json.data?.items?.length ?? null })) + + await apiStep(module, 'return order appears in the customer list', { + path: `${base}/return-orders?${page}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(json.data.records.some((row) => String(row.returnNo) === String(returnNo)), + 'new return order must appear in the first customer page') + }, ({ json }) => ({ total: json.data?.total ?? null, returnNo })) + + await apiStep(module, 'unknown return order is rejected', { + path: `${base}/return-orders/${encodeURIComponent(`RET-MISSING-${suffix}`)}`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown return order must not succeed, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + + if (existingClientToken) { + await apiStep(module, 'cross-customer return order is rejected', { + path: `${base}/return-orders/${encodeURIComponent(returnNo)}`, token: existingClientToken, + }, ({ json, status }) => { + assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', 'another enterprise return order must not be readable') + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } else { + recordSkipped(module, 'cross-customer return order is rejected', + 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') + } + + report.overseasWarehouseReadOnlyGaps = [ + 'Relabel, transfer and cargo-discard creation require inventory produced by the platform overseas PDA chain; this run only covers their customer-scoped lists and the return forecast write chain.', + 'Return relabel creation, completion confirmation and document downloads require received return cartons produced by the PDA return receipt chain.', + ] +} + +async function merchantCrossBorderModule() { + const module = 'merchant-cross-border' + const truckPath = '/api/merchant/v1/last-leg/us-truck-orders' + const looseboardPath = '/api/merchant/v1/last-leg/local-looseboard-orders' + const procurementPath = '/api/merchant/v1/last-leg/overseas-procurements' + const page = 'page=1&size=20' + const suffix = marker.replace(/[^0-9A-Za-z]/g, '').slice(-18) + const localTime = (days) => { + const date = new Date(Date.now() + days * 86_400_000) + return date.toISOString().slice(0, 19) + } + const listStep = async (label, path, validateRow) => apiStep(module, label, { path, token: merchantToken }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records), `${label} records must be an array`) + assertTrue(Number.isFinite(Number(json.data?.total)), `${label} total required`) + for (const row of json.data.records) validateRow?.(row) + }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) + + await listStep('customer procurement orders are customer-scoped', `${procurementPath}?${page}`, (row) => { + assertPresent(row.orderNo, 'procurement orderNo') + assertPresent(row.status, 'procurement status') + }) + await listStep('customer US truck orders are customer-scoped', `${truckPath}?${page}`, (row) => { + assertPresent(row.orderNo, 'truck orderNo') + assertPresent(row.status, 'truck status') + }) + await listStep('customer local looseboard orders are customer-scoped', `${looseboardPath}?${page}`, (row) => { + assertPresent(row.orderNo, 'looseboard orderNo') + assertPresent(row.status, 'looseboard status') + }) + + const truckPayload = { + vehicleType: 'PALLET_15', + origin: `验收起点-${suffix}`, + destination: `验收终点-${suffix}`, + packageCount: 2, + totalWeightKg: 12.5, + totalVolumeCbm: 0.8, + budgetAmount: 100, + currency: 'USD', + originContactName: '验收发货联系人', + originContactPhone: '13800000001', + destinationContactName: '验收收货联系人', + destinationContactPhone: '13800000002', + pickupAt: localTime(2), + deliveryAt: localTime(3), + remark: `${marker} client US truck acceptance`, + idempotencyKey: newIdempotencyKey('us-truck'), + } + const truck = await apiStep(module, 'customer creates a US truck order', { + method: 'POST', path: truckPath, token: merchantToken, body: truckPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.summary?.orderNo, 'truck orderNo') + assertTrue(['PENDING_REVIEW', 'SUBMITTED', 'DRAFT'].includes(json.data.summary.status), + `unexpected truck status ${json.data.summary.status}`) + assertTrue(Number(json.data.summary.version) >= 0, 'truck version required') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null, status: json.data?.summary?.status ?? null })) + const truckNo = truck.json.data.summary.orderNo + report.resources.crossBorderOrderNos = [truckNo] + + await apiStep(module, 'same idempotency key replays the same US truck order', { + method: 'POST', path: truckPath, token: merchantToken, body: truckPayload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.summary?.orderNo, truckNo, 'replayed truck orderNo') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) + + await apiStep(module, 'US truck order detail keeps the marker payload', { + path: `${truckPath}/${encodeURIComponent(truckNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.summary?.orderNo, truckNo, 'truck detail orderNo') + assertEqual(json.data?.summary?.vehicleType, 'PALLET_15', 'truck vehicle type') + assertEqual(Number(json.data?.summary?.packageCount), 2, 'truck package count') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) + + const looseboardPayload = { + origin: `散板起点-${suffix}`, + destination: `散板终点-${suffix}`, + productName: '验收散板货物', + estimatedPalletCount: 1, + packageCount: 3, + totalWeightKg: 20, + totalVolumeCbm: 1.2, + budgetAmount: 80, + currency: 'USD', + originContactName: '验收发货联系人', + originContactPhone: '13800000001', + destinationContactName: '验收收货联系人', + destinationContactPhone: '13800000002', + pickupAt: localTime(2), + deliveryAt: localTime(4), + remark: `${marker} client looseboard acceptance`, + idempotencyKey: newIdempotencyKey('looseboard'), + } + const looseboard = await apiStep(module, 'customer creates a local looseboard order', { + method: 'POST', path: looseboardPath, token: merchantToken, body: looseboardPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.summary?.orderNo, 'looseboard orderNo') + assertTrue(Number(json.data.summary.estimatedPalletCount) === 1, 'looseboard pallet count') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null, status: json.data?.summary?.status ?? null })) + const looseboardNo = looseboard.json.data.summary.orderNo + report.resources.crossBorderOrderNos.push(looseboardNo) + + await apiStep(module, 'same idempotency key replays the same looseboard order', { + method: 'POST', path: looseboardPath, token: merchantToken, body: looseboardPayload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.summary?.orderNo, looseboardNo, 'replayed looseboard orderNo') + }, ({ json }) => ({ orderNo: json.data?.summary?.orderNo ?? null })) + + const procurementPayload = { + customerName: companyName, + purchaseCategory: '验收测试', + productName: `验收采购样品-${suffix}`, + productSpecification: '1 件/箱', + purchaseQuantity: 1, + budgetUnitPrice: 10, + currency: 'CNY', + receivingAddress: '广东省深圳市南山区验收路 1 号', + contactName: '验收采购联系人', + contactPhone: '13800000003', + remark: `${marker} client procurement acceptance`, + idempotencyKey: newIdempotencyKey('procurement'), + } + const procurement = await apiStep(module, 'customer creates an overseas procurement order', { + method: 'POST', path: procurementPath, token: merchantToken, body: procurementPayload, + }, ({ json }) => { + requireSuccess(json) + assertPresent(json.data?.order?.orderNo, 'procurement orderNo') + assertTrue(Number(json.data.order.purchaseQuantity) === 1, 'procurement quantity') + }, ({ json }) => ({ orderNo: json.data?.order?.orderNo ?? null, status: json.data?.order?.status ?? null })) + const procurementNo = procurement.json.data.order.orderNo + report.resources.crossBorderOrderNos.push(procurementNo) + + await apiStep(module, 'same idempotency key replays the same procurement order', { + method: 'POST', path: procurementPath, token: merchantToken, body: procurementPayload, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.orderNo, procurementNo, 'replayed procurement orderNo') + }, ({ json }) => ({ orderNo: json.data?.order?.orderNo ?? null })) + + await apiStep(module, 'created cross-border orders appear in customer lists', { + path: `${truckPath}?${page}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(json.data.records.some((row) => String(row.orderNo) === String(truckNo)), + 'new truck order must appear in the customer list') + }, ({ json }) => ({ total: json.data?.total ?? null, truckNo })) + for (const [label, path] of [['truck', truckPath], ['looseboard', looseboardPath], ['procurement', procurementPath]]) { + await apiStep(module, `unknown ${label} order is rejected`, { + path: `${path}/${encodeURIComponent(`CB-MISSING-${suffix}`)}`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown ${label} order must not succeed, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } + + if (existingClientToken) { + await apiStep(module, 'cross-customer truck order is rejected', { + path: `${truckPath}/${encodeURIComponent(truckNo)}`, token: existingClientToken, + }, ({ json, status }) => { + assertTrue([200, 403, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', 'another enterprise truck order must not be readable') + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } else { + recordSkipped(module, 'cross-customer truck order is rejected', + 'Set MERCHANT_TEST_USERNAME and MERCHANT_TEST_PASSWORD to enable this check') + } + + report.crossBorderLimitations = [ + 'Customer creation stops at the submitted/pending platform review state; platform review, pricing, purchase execution, receipts and delivery are covered by Admin/fleet specialty entries.', + ] +} + +async function merchantWarehouseOrdersModule() { + const module = 'merchant-warehouse-orders' + const base = '/api/merchant/v1/last-leg' + const page = 'page=1&size=20' + const inbound = await apiStep(module, 'customer inbound plans are customer-scoped', { + path: `${base}/inbound-plans?${page}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records) && Number.isFinite(Number(json.data?.total)), + 'inbound plan page required') + for (const row of json.data.records) { + assertPresent(row.planNo, 'planNo') + assertPresent(row.inboundCode, 'inboundCode') + assertTrue(Number.isFinite(Number(row.packageCount)), 'inbound packageCount') + assertPresent(row.status, 'inbound status') + } + }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) + + const delivery = await apiStep(module, 'customer delivery orders are customer-scoped', { + path: `${base}/delivery-orders?${page}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records) && Number.isFinite(Number(json.data?.total)), + 'delivery page required') + for (const row of json.data.records) { + assertPresent(row.deliveryNo, 'deliveryNo') + assertPresent(row.dispatchNo, 'dispatchNo') + assertPresent(row.status, 'delivery status') + assertTrue(Number.isFinite(Number(row.packageCount)), 'delivery packageCount') + } + }, ({ json }) => ({ total: json.data?.total ?? null, count: json.data?.records?.length ?? null })) + + const planNo = inbound.json.data.records[0]?.planNo + if (planNo) { + await apiStep(module, 'inbound plan detail keeps plan identity and lines', { + path: `${base}/inbound-plans/${encodeURIComponent(planNo)}`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.planNo, planNo, 'planNo') + assertPresent(json.data?.status, 'plan status') + assertTrue(Array.isArray(json.data?.lines) && Array.isArray(json.data?.pallets), 'plan lines and pallets required') + }, ({ json }) => ({ planNo: json.data?.planNo ?? null, lines: json.data?.lines?.length ?? null })) + } else { + recordSkipped(module, 'inbound plan detail keeps plan identity and lines', + 'this run customer has no inbound plan yet') + } + + const deliveryNo = delivery.json.data.records[0]?.deliveryNo + if (deliveryNo) { + await apiStep(module, 'delivery order detail keeps customer-visible fees and skus', { + path: `${base}/delivery-orders/${encodeURIComponent(deliveryNo)}?page=1&size=200`, token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(json.data?.order?.deliveryNo, deliveryNo, 'deliveryNo') + assertTrue(Array.isArray(json.data?.skus) && Array.isArray(json.data?.fees), 'delivery skus and fees required') + assertPresent(json.data?.order?.status, 'delivery status') + }, ({ json }) => ({ deliveryNo: json.data?.order?.deliveryNo ?? null, skus: json.data?.skus?.length ?? null })) + } else { + recordSkipped(module, 'delivery order detail keeps customer-visible fees and skus', + 'this run customer has no delivery order yet') + } + + for (const [label, path] of [['inbound plan', `${base}/inbound-plans`], ['delivery order', `${base}/delivery-orders`]]) { + await apiStep(module, `unknown ${label} is rejected`, { + path: `${path}/${encodeURIComponent(`WO-MISSING-${marker}`)}`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + assertTrue(String(json?.code) !== '200', `unknown ${label} must not succeed, code=${json?.code}`) + }, ({ json, status }) => ({ http: status, code: json?.code ?? null })) + } +} + +async function merchantPricingCustomsModule() { + const module = 'merchant-pricing-customs' + const warehouseResponse = await apiStep(module, 'customer reads active warehouses for pricing', { + path: '/api/merchant/v1/warehouses?page=1&size=200&status=ACTIVE', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data?.records) && json.data.records.length > 0, 'active warehouse required') + assertTrue(json.data.records.every((row) => row.status === 'ACTIVE'), 'only active warehouses allowed') + }, ({ json }) => ({ count: json.data?.records?.length ?? null })) + const domestic = warehouseResponse.json.data.records.find((row) => row.warehouseType !== 'OVERSEAS') + if (domestic) { + await apiStep(module, 'customer reads active pricing channels', { + path: `/api/merchant/v1/first-leg/pricing/channels?originWarehouseId=${encodeURIComponent(String(domestic.id))}`, + token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Array.isArray(json.data), 'pricing channels must be an array') + for (const row of json.data) { + assertPresent(row.channelCode, 'channelCode') + assertPresent(row.priceModel, 'priceModel') + assertTrue(Array.isArray(row.pricingTaxModes), 'pricingTaxModes required') + assertTrue(Number.isFinite(Number(row.priceBookVersionId)), 'priceBookVersionId required') + } + }, ({ json }) => ({ count: json.data?.length ?? null, warehouseId: String(domestic.id) })) + } else { + recordSkipped(module, 'customer reads active pricing channels', + 'this customer has no domestic origin warehouse') + } + + await apiStep(module, 'customer customs pending summary is readable', { + path: '/api/merchant/v1/customs/pending-summary', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertTrue(Number.isFinite(Number(json.data?.pendingCount)), 'pendingCount required') + assertTrue(Number(json.data.pendingCount) >= 0, 'pendingCount must be non-negative') + }, ({ json }) => ({ pendingCount: json.data?.pendingCount ?? null })) + + const orderId = report.resources.orderIds[0] + if (orderId) { + await apiStep(module, 'order customs documents stay customer-scoped', { + path: `/api/merchant/v1/customs/orders/${encodeURIComponent(String(orderId))}`, token: merchantToken, + }, ({ json, status }) => { + assertTrue([200, 404].includes(status), `unexpected HTTP status ${status}`) + if (String(json?.code) === '200') { + assertTrue(json.data && typeof json.data === 'object', 'customs payload required') + } else { + assertTrue(['A0403', 'A0404'].includes(String(json?.code)), + `unexpected customs code ${json?.code}`) + } + }, ({ json, status }) => ({ http: status, code: json?.code ?? null, orderId: String(orderId) })) + } else { + recordSkipped(module, 'order customs documents stay customer-scoped', 'no current-run order id') + } +} + async function notificationInboxModule() { const module = 'notification-inbox' const templateBase = '/api/platform/v1/notification-templates' diff --git a/scripts/full-regression-entry-contract.test.mjs b/scripts/full-regression-entry-contract.test.mjs index ea0370761..4bb5e9958 100644 --- a/scripts/full-regression-entry-contract.test.mjs +++ b/scripts/full-regression-entry-contract.test.mjs @@ -190,3 +190,13 @@ test('Finance-only HTTP entry expands producer prerequisites and runs downloads 'finance-direct-paid-isolation', 'finance-downloads', ]) }) + +test('default Admin HTTP entry registers current-code appointment, PDA release, audit and sailing calendar modules', () => { + for (const module of ['appointment-work', 'pda-releases', 'audit', 'sailing-calendar', + 'self-operated-analytics', 'fleet-management']) { + assert.match(adminAcceptance, new RegExp(`CONFIGURED_MODULES\\s*=\\s*\\[[^\\]]*'${module}'`, 's'), + `${module} must be registered in CONFIGURED_MODULES`) + assert.ok(existsSync(resolve(scriptsDir, 'admin-test-env-modules', `${module}.mjs`)), + `${module} module file is missing`) + } +}) From e0c03164876158f677f2a5bf80ed5eb32da2b741 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 14:22:55 +0800 Subject: [PATCH 037/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../repository/DispatchBolRepository.java | 17 +++ scripts/admin-test-env-modules/README.md | 11 +- .../appointment-work-contract.test.mjs | 92 ++++++++++++++ .../appointment-work.mjs | 82 +++++++++++++ scripts/admin-test-env-modules/audit.mjs | 36 ++++++ .../fleet-analytics-contract.test.mjs | 90 ++++++++++++++ .../fleet-management.mjs | 79 ++++++++++++ .../pda-audit-sailing-contract.test.mjs | 114 ++++++++++++++++++ .../admin-test-env-modules/pda-releases.mjs | 46 +++++++ .../sailing-calendar.mjs | 80 ++++++++++++ .../self-operated-analytics.mjs | 39 ++++++ 11 files changed, 681 insertions(+), 5 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchBolRepository.java create mode 100644 scripts/admin-test-env-modules/appointment-work-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/appointment-work.mjs create mode 100644 scripts/admin-test-env-modules/audit.mjs create mode 100644 scripts/admin-test-env-modules/fleet-analytics-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/fleet-management.mjs create mode 100644 scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/pda-releases.mjs create mode 100644 scripts/admin-test-env-modules/sailing-calendar.mjs create mode 100644 scripts/admin-test-env-modules/self-operated-analytics.mjs diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchBolRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchBolRepository.java new file mode 100644 index 000000000..88969a8c8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchBolRepository.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.lastleg.dispatch.mapper.DispatchBolMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** BOL专用只读事实入口,应用服务不直接持有Mapper。 */ +@Repository +public class DispatchBolRepository { + private final DispatchBolMapper mapper; + /** 装配同一次事务使用的来源及分配读取。 */ + public DispatchBolRepository(DispatchBolMapper mapper){this.mapper=mapper;} + /** 返回完整明细身份、原调度箱数及逐来源打印资料。 */ + public List cargo(long dispatchId){return mapper.findWholeDispatch(dispatchId);} + /** 仅正式FBA预约分配有外部预约号;客户仓内部DISPATCH批次不伪造约号。 */ + public List allocations(long dispatchId){return mapper.findAppointmentAllocations(dispatchId);} +} diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 1df7aa5c5..b8d3b3505 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -467,12 +467,13 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采 已知待验收缺口(不计作已覆盖通过): -- `phase4-overseas-dispatch` 要求两个 FBA 目的地及同目的地双实托;默认主链当前只产生单目的地(9/23 证据 `destinations=ABE4`),前置不足按 `notRun` 阻断,报告中 `dispatchStatus=NOT_RUN`,并连带 `phase5-overseas-fulfillment` 阻断。补齐主链多目的仓样本后重跑。 -- `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID。 -- `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次。 -- `fleet-management` 车次写命令需要本轮排车、车辆与司机资源。 -- Client 海外仓贴标、仓储转运、货物废弃和退货换标的创建链路需要平台海外 PDA 链产出的库存;当前只覆盖客户范围列表和退货预报写链路。 +- `phase4-overseas-dispatch`:主链已内置 `FBA-SECOND`(`destinationIndex=1`)强制第二 FBA 目的地;真实 HTTP 仍需 PDA 作业员凭据才能展开该变体。未验证前按 `notRun` 阻断并标记 `dispatchStatus=NOT_RUN`,连带 `phase5-overseas-fulfillment`。 +- `appointment-work` 写命令(`SAVE_FOLLOWUP`、`CONFIRM_ROUTE`、`SAVE_REQUIREMENT`)需要本轮可编辑工作项与操作人 ID;真实 HTTP 依赖 PDA 链产生工作事项。 +- `self-operated-analytics` 数据采集任务与重试需要领星账号和真实采集批次;2026-10-20 空参 HTTP 返回 `B0001`,已按非阻断缺口记录。 +- `fleet-management` 只读(车次、出车记录、车队报价、拼车需求)已通过真实 HTTP;车次写命令仍需要本轮排车、车辆与司机资源。 +- Client 海外仓只读列表与退货预报写链路已通过真实 HTTP(2026-10-20 Client 全量 406 项通过、2 项跳过);贴标、仓储转运、货物废弃和退货换标的创建链路仍需要平台海外 PDA 链产出的库存。 - `pda-releases` 真实 APK/WGT 上传、发布和停发需人工准备有效安装包。 +- Client 入库计划与派送单详情在新建客户无数据时跳过,需先由平台海外链产出单据后定向复核。 ## 本地离线合同运行边界 diff --git a/scripts/admin-test-env-modules/appointment-work-contract.test.mjs b/scripts/admin-test-env-modules/appointment-work-contract.test.mjs new file mode 100644 index 000000000..2a9827277 --- /dev/null +++ b/scripts/admin-test-env-modules/appointment-work-contract.test.mjs @@ -0,0 +1,92 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { run } from './appointment-work.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') +const moduleSource = readFileSync(new URL('./appointment-work.mjs', import.meta.url), 'utf8') + +function fakeContext(overrides = {}) { + const calls = [] + const gaps = [] + const steps = [] + const context = { + marker: 'ADMIN-API-OFFLINE', + calls, + gaps, + steps, + async request(method, path, body) { + calls.push({ method, path, body }) + const url = new URL(path, 'https://offline.invalid') + if (url.pathname.endsWith('/calendar')) { + const from = url.searchParams.get('from') + const to = url.searchParams.get('to') + return { http: 200, body: { code: '200', data: { from, to, total: 2, days: [ + { date: from, total: 1, typeCounts: { DELIVERY: 1 } }, + { date: to, total: 1, typeCounts: { PICKUP: 1 } }, + ] } } } + } + if (url.pathname.endsWith('/summary')) { + return { http: 200, body: { code: '200', data: { all: 2, followToday: 1, overdue: 0, undated: 1, review: 0 } } } + } + if (url.pathname.endsWith('/items')) { + return { http: 200, body: { code: '200', data: overrides.emptyPage + ? { records: [], page: 1, size: 20, total: 0, pages: 0 } + : { records: [{ workItemKey: 'WORK-1', type: 'DELIVERY', flow: 'TRANSFER' }], page: 1, size: 20, total: 1, pages: 1 } } } + } + if (url.pathname.includes('/items/WORK-1')) { + return { http: 200, body: { code: '200', data: { + item: { workItemKey: 'WORK-1', type: 'DELIVERY', flow: 'TRANSFER', containers: [{ containerBookingId: 'C-1' }], allowedActions: [] }, + references: [], history: [], + } } } + } + if (url.pathname.endsWith('/commands')) { + return { http: 200, body: { code: 'A0404', message: '工作事项不存在' } } + } + throw new Error(`unexpected offline request ${method} ${path}`) + }, + apiSuccess(module, name, response) { + assert.equal(response.http, 200) + assert.equal(String(response.body?.code), '200') + steps.push({ module, name, passed: true }) + return response.body.data + }, + assert(module, name, response, condition, evidence) { + assert.equal(Boolean(condition), true, `${name}: ${evidence}`) + steps.push({ module, name, passed: true }) + return true + }, + addCoverageGap(module, scenario, name, reason, category, metadata) { + gaps.push({ module, scenario, name, reason, category, metadata }) + }, + } + return context +} + +test('预约工作模块在默认全量中注册且只读查询官方工作事项接口', () => { + assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'appointment-work'/) + assert.match(moduleSource, /\/api\/platform\/v1\/last-leg\/appointment-work/) + for (const path of ['/calendar', '/summary', '/items', '/commands']) assert.match(moduleSource, new RegExp(path.replace('/', '\\/'))) + assert.match(moduleSource, /command: 'SAVE_FOLLOWUP'/) + assert.doesNotMatch(moduleSource, /this\.addStep|writeFileSync|execSync/) +}) + +test('预约工作只读链按日历、汇总、分页、详情顺序调用,唯一写入是不存在事项的拒绝命令', async () => { + const context = fakeContext() + await run(context) + assert.deepEqual(context.calls.map((row) => row.method), ['GET', 'GET', 'GET', 'GET', 'POST']) + assert.ok(context.calls[0].path.includes('/calendar?')) + assert.ok(context.calls[1].path.endsWith('/summary')) + assert.ok(context.calls[2].path.includes('/items?view=ALL&page=1&size=20')) + assert.ok(context.calls[3].path.includes('/items/WORK-1')) + assert.ok(context.calls[4].path.includes('/items/FULL-ACCEPTANCE-MISSING-WORK-ITEM/commands')) + assert.equal(context.calls[4].body.command, 'SAVE_FOLLOWUP') + assert.ok(context.gaps.some((gap) => gap.name === '预约写命令正向链路' && gap.metadata.blocking === false)) +}) + +test('无本轮工作事项时记录非阻断覆盖缺口而不是虚构详情或命令结果', async () => { + const context = fakeContext({ emptyPage: true }) + await run(context) + assert.equal(context.calls.length, 3) + assert.ok(context.gaps.some((gap) => gap.name === '预约工作项详情与命令' && gap.metadata.blocking === false)) +}) diff --git a/scripts/admin-test-env-modules/appointment-work.mjs b/scripts/admin-test-env-modules/appointment-work.mjs new file mode 100644 index 000000000..321290252 --- /dev/null +++ b/scripts/admin-test-env-modules/appointment-work.mjs @@ -0,0 +1,82 @@ +function count(value) { + const number = Number(value) + return Number.isFinite(number) ? number : NaN +} + +function shanghaiDate(offsetDays) { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', + }).format(new Date(Date.now() + offsetDays * 86_400_000)) +} + +export async function run(context) { + const module = 'appointment-work' + const base = '/api/platform/v1/last-leg/appointment-work' + const from = shanghaiDate(-7) + const to = shanghaiDate(7) + + const calendarResponse = await context.request('GET', `${base}/calendar?from=${from}&to=${to}`) + const calendar = context.apiSuccess(module, '查询预约工作日历', calendarResponse) + const days = Array.isArray(calendar?.days) ? calendar.days : [] + const dayTotal = days.reduce((sum, day) => sum + Number(day?.total || 0), 0) + context.assert(module, '日历区间、日汇总与总数守恒', calendarResponse, + calendar?.from === from && calendar?.to === to && count(calendar?.total) === dayTotal, + `from=${calendar?.from || 'missing'},to=${calendar?.to || 'missing'},total=${calendar?.total ?? 'missing'},dayTotal=${dayTotal}`) + for (const day of days.slice(0, 3)) { + const typeTotal = Object.values(day?.typeCounts || {}).reduce((sum, value) => sum + Number(value || 0), 0) + context.assert(module, `日历日 ${day?.date} 类型汇总不超过当日总数`, calendarResponse, + Number(day?.total || 0) >= typeTotal, + `date=${day?.date || 'missing'},total=${day?.total ?? 'missing'},typeTotal=${typeTotal}`) + } + + const summaryResponse = await context.request('GET', `${base}/summary`) + const summary = context.apiSuccess(module, '查询预约工作汇总', summaryResponse) + const summaryFields = ['all', 'followToday', 'overdue', 'undated', 'review'] + context.assert(module, '工作汇总五项计数完整', summaryResponse, + summaryFields.every((field) => Number.isFinite(Number(summary?.[field]))), + summaryFields.map((field) => `${field}=${summary?.[field] ?? 'missing'}`).join(',')) + + const pageResponse = await context.request('GET', `${base}/items?view=ALL&page=1&size=20`) + const page = context.apiSuccess(module, '查询预约工作事项首页', pageResponse) + const records = Array.isArray(page?.records) ? page.records : [] + const total = count(page?.total) + context.assert(module, '工作事项分页完整且不截断', pageResponse, + count(page?.page) === 1 && count(page?.size) === 20 && Number.isFinite(total) + && records.length === Math.min(20, total) && count(page?.pages) === Math.ceil(total / 20), + `page=${page?.page},size=${page?.size},total=${page?.total},records=${records.length},pages=${page?.pages}`) + + const first = records[0] + if (!first) { + context.addCoverageGap(module, 'appointment-work', '预约工作项详情与命令', + '本轮主链未产生预约工作事项;需先完成订单、柜、船节点或排车链后重跑', 'not-implemented', + { blocking: false }) + return + } + + const detailResponse = await context.request('GET', `${base}/items/${encodeURIComponent(first.workItemKey)}`) + const detail = context.apiSuccess(module, '查询当前工作事项详情', detailResponse) + context.assert(module, '工作详情包含身份、来源与可执行动作', detailResponse, + detail?.item?.workItemKey === first.workItemKey + && ['ROUTE', 'PICKUP', 'WAREHOUSE_RECEIPT', 'DELIVERY', 'TRANSPORT', 'EMPTY_RETURN'].includes(detail?.item?.type) + && ['UNKNOWN', 'TRANSFER', 'DIRECT'].includes(detail?.item?.flow) + && Array.isArray(detail?.item?.containers) && detail.item.containers.length > 0 + && Array.isArray(detail?.item?.allowedActions) && Array.isArray(detail?.references) + && Array.isArray(detail?.history), + `key=${detail?.item?.workItemKey},type=${detail?.item?.type},flow=${detail?.item?.flow},` + + `actions=${detail?.item?.allowedActions?.length ?? 'missing'}`) + + const invalidKey = 'FULL-ACCEPTANCE-MISSING-WORK-ITEM' + const invalidResponse = await context.request('POST', `${base}/items/${encodeURIComponent(invalidKey)}/commands`, { + command: 'SAVE_FOLLOWUP', + payload: { ownerId: '1', followupDate: to, note: `missing-${context.marker}` }, + expectedVersion: 1, + idempotencyKey: `${context.marker}-missing-work`, + }) + context.assert(module, '不存在的工作事项命令被拒绝', invalidResponse, + invalidResponse.http === 200 && ['A0403', 'A0404'].includes(String(invalidResponse.body?.code)), + `HTTP=${invalidResponse.http},code=${invalidResponse.body?.code ?? 'missing'}`) + + context.addCoverageGap(module, 'appointment-work', '预约写命令正向链路', + '当前仅验证拒绝路径;SAVE_FOLLOWUP/CONFIRM_ROUTE/SAVE_REQUIREMENT 需要本轮可编辑工作项与操作人 ID,待下一批接入', + 'not-implemented', { blocking: false }) +} diff --git a/scripts/admin-test-env-modules/audit.mjs b/scripts/admin-test-env-modules/audit.mjs new file mode 100644 index 000000000..66acf24ad --- /dev/null +++ b/scripts/admin-test-env-modules/audit.mjs @@ -0,0 +1,36 @@ +function id(value) { return value == null ? '' : String(value) } + +export async function run(context) { + const module = 'audit' + const base = '/api/platform/v1/audit/logs' + + const pageResponse = await context.request('GET', `${base}?page=1&size=20`) + const page = context.apiSuccess(module, '查询平台操作日志首页', pageResponse) + const records = Array.isArray(page?.records) ? page.records : [] + const total = Number(page?.total) + context.assert(module, '日志分页完整且标识字段齐全', pageResponse, + Number(page?.page) === 1 && Number(page?.size) === 20 && Number.isFinite(total) + && records.length === Math.min(20, total) + && records.every((row) => id(row.id) && row.eventType && row.occurredAt), + `page=${page?.page},size=${page?.size},total=${page?.total},records=${records.length}`) + context.assert(module, '日志列表不返回变更前后快照', pageResponse, + records.every((row) => !Object.hasOwn(row, 'beforeData') && !Object.hasOwn(row, 'afterData')), + `records=${records.length}`) + + if (records[0]) { + const detailResponse = await context.request('GET', `${base}/${encodeURIComponent(records[0].id)}`) + const detail = context.apiSuccess(module, '查询当前日志详情', detailResponse) + context.assert(module, '日志详情与列表身份一致', detailResponse, + id(detail?.id) === id(records[0].id) && Object.hasOwn(detail || {}, 'httpMethod'), + `id=${detail?.id},eventType=${detail?.eventType}`) + } else { + context.addCoverageGap(module, 'audit', '日志详情', + '测试环境当前没有审计日志;先执行本轮业务写入后重跑', 'not-implemented', { blocking: false }) + } + + const missingResponse = await context.request('GET', `${base}/999999999999999`) + context.assert(module, '不存在的日志详情被拒绝', missingResponse, + (missingResponse.http === 200 && ['A0403', 'A0404'].includes(String(missingResponse.body?.code))) + || missingResponse.http === 404, + `HTTP=${missingResponse.http},code=${missingResponse.body?.code ?? 'missing'}`) +} diff --git a/scripts/admin-test-env-modules/fleet-analytics-contract.test.mjs b/scripts/admin-test-env-modules/fleet-analytics-contract.test.mjs new file mode 100644 index 000000000..83405f210 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-analytics-contract.test.mjs @@ -0,0 +1,90 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { run as runFleet } from './fleet-management.mjs' +import { run as runAnalytics } from './self-operated-analytics.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + +function context(handler) { + const calls = [] + const gaps = [] + return { + marker: 'ADMIN-API-OFFLINE', + calls, + gaps, + async request(method, path) { + calls.push({ method, path }) + return handler(method, path) + }, + apiSuccess(_module, _name, response) { + assert.equal(response.http, 200) + assert.equal(String(response.body?.code), '200') + return response.body.data + }, + assert(_module, name, _response, condition, evidence) { + assert.equal(Boolean(condition), true, `${name}: ${evidence}`) + return true + }, + addCoverageGap(module, scenario, name, reason, category, metadata) { + gaps.push({ module, scenario, name, reason, category, metadata }) + }, + } +} + +test('车队管理模块注册为默认模块并只读核对车次、出车、报价与拼车需求', async () => { + assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'fleet-management'/) + const tripRow = { tripNo: 'TRIP-1', status: 'PLANNED', recordVersion: 0 } + const ctx = context((method, path) => { + if (path.endsWith('/fleet/trips/FLEET-MISSING-TRIP')) { + return { http: 200, body: { code: 'A0404', message: '车次不存在' } } + } + if (path.includes('/fleet/trips/TRIP-1')) { + return { http: 200, body: { code: '200', data: { + trip: tripRow, dispatches: [], loadingReview: { recordVersion: null }, previousDispatches: [] } } } + } + if (path.includes('/fleet/trips')) { + return { http: 200, body: { code: '200', data: { records: [tripRow], page: 1, size: 20, total: 1, pages: 1 } } } + } + if (path.includes('/fleet/operations')) { + return { http: 200, body: { code: '200', data: { + page: { records: [], page: 1, size: 20, total: 0, pages: 0 }, + summary: { trips: 0, inTransit: 0, completed: 0, physicalPallets: 0, palletSpaces: 0, ownPackages: 0, + externalPackages: 0, externalAgreedAmounts: [], costs: { visible: true, unrecordedTrips: null, unallocatedTrips: null, totals: [] } }, + charts: { physicalPallets: [], destinationTrips: [], externalAgreedAmounts: [], externalPalletRates: [] }, + } } } + } + if (path.includes('/fleet/quotes')) { + return { http: 200, body: { code: '200', data: { records: [{ quoteNo: 'Q-1', status: 'ACTIVE', recordVersion: '0' }], page: 1, size: 20, total: 1, pages: 1 } } } + } + if (path.includes('/fleet/capacity-offers')) { + return { http: 200, body: { code: '200', data: { records: [{ status: 'OPEN' }], page: 1, size: 20, total: 1, pages: 1 } } } + } + if (path.endsWith('/fleet/trips/FLEET-MISSING-TRIP')) { + return { http: 200, body: { code: 'A0404', message: '车次不存在' } } + } + throw new Error(`unexpected ${method} ${path}`) + }) + await runFleet(ctx) + assert.deepEqual(ctx.calls.map((row) => row.method), ['GET', 'GET', 'GET', 'GET', 'GET', 'GET']) + assert.ok(ctx.calls[0].path.includes('/fleet/trips?page=1&size=20')) + assert.ok(ctx.calls[2].path.includes('/fleet/operations?page=1&size=20&from=')) + assert.ok(ctx.calls[4].path.includes('/fleet/capacity-offers')) + assert.ok(ctx.gaps.some((gap) => gap.name === '车次写命令' && gap.metadata.blocking === false)) +}) + +test('自营分析模块注册为默认模块,空参不可用时记录缺口而不是虚构看板', async () => { + assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'self-operated-analytics'/) + const unavailable = context(() => ({ http: 200, body: { code: 'A0400', message: '缺少采集账号' } })) + await runAnalytics(unavailable) + assert.equal(unavailable.calls.length, 2) + assert.ok(unavailable.gaps.some((gap) => gap.name === '经营看板' && gap.metadata.blocking === false)) + assert.ok(unavailable.gaps.some((gap) => gap.name === '货主目录' && gap.metadata.blocking === false)) + + const available = context((_method, path) => path.endsWith('/dashboard') + ? { http: 200, body: { code: '200', data: { metrics: [], trend: [] } } } + : { http: 200, body: { code: '200', data: { records: [], page: 1, size: 20, total: 0, pages: 0 } } }) + await runAnalytics(available) + assert.equal(available.gaps.some((gap) => gap.name === '经营看板'), false) + assert.ok(available.gaps.some((gap) => gap.category === 'external-third-party' && gap.metadata.blocking === false)) +}) diff --git a/scripts/admin-test-env-modules/fleet-management.mjs b/scripts/admin-test-env-modules/fleet-management.mjs new file mode 100644 index 000000000..76f79e756 --- /dev/null +++ b/scripts/admin-test-env-modules/fleet-management.mjs @@ -0,0 +1,79 @@ +function count(value) { + const number = Number(value) + return Number.isFinite(number) ? number : NaN +} + +function id(value) { return value == null ? '' : String(value) } + +function isoInstant(offsetDays, endOfDay = false) { + const day = new Date(Date.now() + offsetDays * 86_400_000).toISOString().slice(0, 10) + return `${day}T${endOfDay ? '23:59:59' : '00:00:00'}Z` +} + +function pageOf(value) { + return value && Array.isArray(value.records) && Number.isFinite(count(value.total)) +} + +export async function run(context) { + const module = 'fleet-management' + const base = '/api/platform/v1/last-leg/fleet' + + const tripsResponse = await context.request('GET', `${base}/trips?page=1&size=20`) + const trips = context.apiSuccess(module, '查询车次计划列表', tripsResponse) + context.assert(module, '车次分页与状态字段完整', tripsResponse, + pageOf(trips) && trips.records.every((row) => id(row.tripNo) && row.status && Number.isFinite(count(row.recordVersion))), + `total=${trips?.total ?? 'missing'},records=${trips?.records?.length ?? 'missing'}`) + + const tripNo = trips?.records?.[0]?.tripNo + if (tripNo) { + const detailResponse = await context.request('GET', `${base}/trips/${encodeURIComponent(tripNo)}`) + const detail = context.apiSuccess(module, '查询当前车次详情', detailResponse) + context.assert(module, '车次详情包含四页签所需事实', detailResponse, + id(detail?.trip?.tripNo) === id(tripNo) + && detail?.trip?.status + && Array.isArray(detail?.dispatches) + && detail?.loadingReview != null, + `tripNo=${detail?.trip?.tripNo},status=${detail?.trip?.status},dispatches=${detail?.dispatches?.length ?? 'missing'}`) + } else { + context.addCoverageGap(module, 'fleet-management', '车次详情', + '当前没有车次样本;先由海外排车/车队入口创建本轮车次后重跑', 'not-implemented', { blocking: false }) + } + + const operationsResponse = await context.request('GET', + `${base}/operations?page=1&size=20&from=${isoInstant(-30)}&to=${isoInstant(0, true)}`) + const operations = context.apiSuccess(module, '查询出车记录', operationsResponse) + context.assert(module, '出车记录分页、汇总与图表结构完整', operationsResponse, + pageOf(operations?.page) + && Number.isFinite(count(operations?.summary?.trips)) + && Array.isArray(operations?.summary?.externalAgreedAmounts) + && operations?.charts && Array.isArray(operations.charts.physicalPallets), + `trips=${operations?.summary?.trips ?? 'missing'},pageTotal=${operations?.page?.total ?? 'missing'}`) + context.assert(module, '未登记或未分摊成本不按零展示', operationsResponse, + operations?.summary?.costs == null + || (typeof operations.summary.costs.visible === 'boolean' + && (operations.summary.costs.unrecordedTrips == null || Number.isFinite(count(operations.summary.costs.unrecordedTrips)))), + JSON.stringify(operations?.summary?.costs || {}).slice(0, 200)) + + const quotesResponse = await context.request('GET', `${base}/quotes?page=1&size=20`) + const quotes = context.apiSuccess(module, '查询车队报价', quotesResponse) + context.assert(module, '车队报价分页字段完整', quotesResponse, + pageOf(quotes) && quotes.records.every((row) => id(row.quoteNo) && row.status != null + && (row.recordVersion == null || Number.isFinite(count(row.recordVersion)))), + `total=${quotes?.total ?? 'missing'},records=${quotes?.records?.length ?? 'missing'}`) + + const offersResponse = await context.request('GET', `${base}/capacity-offers?page=1&size=20`) + const offers = context.apiSuccess(module, '查询内部拼车需求池', offersResponse) + context.assert(module, '拼车需求分页字段完整', offersResponse, + pageOf(offers) && offers.records.every((row) => row.status != null), + `total=${offers?.total ?? 'missing'},records=${offers?.records?.length ?? 'missing'}`) + + const missingResponse = await context.request('GET', `${base}/trips/FLEET-MISSING-TRIP`) + context.assert(module, '不存在的车次详情被拒绝', missingResponse, + (missingResponse.http === 200 && ['A0403', 'A0404'].includes(String(missingResponse.body?.code))) + || missingResponse.http === 404, + `HTTP=${missingResponse.http},code=${missingResponse.body?.code ?? 'missing'}`) + + context.addCoverageGap(module, 'fleet-management', '车次写命令', + '车次计划/分配/审核/发车/结案依赖本轮排车与车辆资源;本模块只读核对列表、详情、出车记录、报价和拼车需求', + 'not-implemented', { blocking: false }) +} diff --git a/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs b/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs new file mode 100644 index 000000000..01e43a3a8 --- /dev/null +++ b/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { readFileSync } from 'node:fs' +import { run as runPdaReleases } from './pda-releases.mjs' +import { run as runAudit } from './audit.mjs' +import { run as runSailingCalendar } from './sailing-calendar.mjs' + +const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') + +function context(handler) { + const calls = [] + const gaps = [] + return { + marker: 'ADMIN-API-OFFLINE', + calls, + gaps, + async request(method, path, body) { + calls.push({ method, path, body }) + return handler(method, path, body) + }, + async requestMultipart(method, path, form) { + calls.push({ method, path, multipart: form }) + return handler(method, path, form) + }, + apiSuccess(_module, _name, response) { + assert.equal(response.http, 200) + assert.equal(String(response.body?.code), '200') + return response.body.data + }, + assert(_module, name, _response, condition, evidence) { + assert.equal(Boolean(condition), true, `${name}: ${evidence}`) + return true + }, + addCoverageGap(module, scenario, name, reason, category, metadata) { + gaps.push({ module, scenario, name, reason, category, metadata }) + }, + } +} + +test('PDA 版本模块注册为默认模块并只读核对双端发布列表', async () => { + assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'pda-releases'/) + const releases = (app) => [{ + id: '1', app, appid: `__UNI__${app}`, packageType: 'apk', versionName: '1.0.0', versionCode: 1, + minAppVersionCode: 1, mandatory: false, releaseNotes: 'offline', status: 'PUBLISHED', current: true, + sha256: 'a'.repeat(64), sizeBytes: 1024, downloadUrl: '/download', createdAt: '2026-10-01T00:00:00Z', + publishedAt: '2026-10-01T00:00:00Z', + }] + const ctx = context((method, path) => { + if (method === 'GET') return { http: 200, body: { code: '200', data: releases(path.endsWith('/domestic') ? 'domestic' : 'overseas') } } + if (method === 'POST' && path.includes('/publish')) return { http: 200, body: { code: 'A0404', message: '不存在' } } + if (method === 'POST' && path.includes('/withdraw')) return { http: 200, body: { code: 'A0404', message: '不存在' } } + return { http: 400, body: { code: 'A0400', message: '文件不能为空' } } + }) + await runPdaReleases(ctx) + assert.deepEqual(ctx.calls.map((row) => row.method), ['GET', 'GET', 'POST', 'POST', 'POST']) + assert.ok(ctx.calls[0].path.endsWith('/domestic')) + assert.ok(ctx.calls[1].path.endsWith('/overseas')) + assert.ok(ctx.gaps.some((gap) => gap.name === '真实 APK/WGT 上传、发布、停发与下载' && gap.metadata.blocking === false)) +}) + +test('平台审计模块注册为默认模块,列表不泄漏快照且详情身份一致', async () => { + assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'audit'/) + const row = { id: '7', eventId: 'e-7', eventType: 'ORDER_SUBMIT', resourceType: 'ORDER', resourceId: '1', + accountId: '0', operatorTenantId: '0', userId: '1', occurredAt: '2026-10-01T00:00:00Z', success: true } + const detailCtx = context((method, path) => { + if (path.endsWith('/999999999999999')) return { http: 200, body: { code: 'A0404', message: '不存在' } } + if (path.endsWith('/7')) return { http: 200, body: { code: '200', data: { ...row, httpMethod: 'POST' } } } + return { http: 200, body: { code: '200', data: { records: [row], page: 1, size: 20, total: 1, pages: 1 } } } + }) + const withSnapshot = context((method, path) => { + if (path.includes('/audit/logs/')) return { http: 200, body: { code: 'A0404', message: '不存在' } } + return { http: 200, body: { code: '200', data: { records: [{ ...row, beforeData: '{}' }], page: 1, size: 20, total: 1, pages: 1 } } } + }) + await runAudit(detailCtx) + assert.equal(detailCtx.calls[0].method, 'GET') + assert.ok(detailCtx.calls[0].path.includes('/audit/logs?page=1&size=20')) + assert.equal(detailCtx.calls[1].path, '/api/platform/v1/audit/logs/7') + assert.ok(detailCtx.calls[2].path.endsWith('/999999999999999')) + await assert.rejects(runAudit(withSnapshot), /不返回变更前后快照/) +}) + +test('船期日历模块注册为默认模块并核对月历、渠道、模板与异常分配', async () => { + assert.match(entry, /const CONFIGURED_MODULES = \[[^\n]*'sailing-calendar'/) + const summary = { + actualReceivedCbm: 1, catchableForecastCbm: 2, riskUnmatchedCbm: 0, bookedContainerCount: 1, + recommendedContainerCount: 1, pendingContainerCount: 0, lateBatchCount: 0, overCapacity: false, + planningCapacityCbm: 10, bookedPlanningCapacityCbm: 5, catchableReceivedCbm: 1, lateReceivedCbm: 0, + snapshotAt: null, sailingCount: 1, + } + const sailing = { + id: '11', sailingNo: 'SAIL-11', warehouseId: '1', channelCode: 'SEA', channelName: '海运', templateId: null, + channelId: null, etaAt: '2026-10-20T00:00:00', sourceType: 'MANUAL', plannedCapacityCbm: 10, + volumeSummary: summary, version: 0, + } + const ctx = context((method, path) => { + if (path.includes('/calendar')) return { http: 200, body: { code: '200', data: { + records: [sailing], summary, filterOptions: { warehouses: [], channels: [], departurePorts: [], destinationPorts: [] } } } } + if (path.includes('/sailing-channels')) return { http: 200, body: { code: '200', data: [{ id: '1', channelCode: 'SEA', defaultCutoffDays: 3, version: 0 }] } } + if (path.includes('/sailing-templates')) return { http: 200, body: { code: '200', data: { records: [{ id: '1', channelId: '1', warehouseId: '1', weekdays: [1, 2], cutoffDays: 3, version: 0 }] } } } + if (path.includes('/sailing-assignments/exceptions')) return { http: 200, body: { code: '200', data: { records: [{ assignmentId: '1', volumeCbm: 1 }], total: 1 } } } + if (path.endsWith('/11')) return { http: 200, body: { code: '200', data: { + sailing: { ...sailing, volumeSummary: summary }, histories: [] } } } + if (path.endsWith('/999999999999999')) return { http: 200, body: { code: 'A0404', message: '不存在' } } + throw new Error(`unexpected ${method} ${path}`) + }) + await runSailingCalendar(ctx) + assert.deepEqual(ctx.calls.map((row) => row.method), ['GET', 'GET', 'GET', 'GET', 'GET', 'GET']) + assert.ok(ctx.calls[0].path.includes('/sailings/calendar?month=')) + assert.ok(ctx.calls[1].path.endsWith('/sailing-channels')) + assert.ok(ctx.calls[2].path.includes('/sailing-templates?page=1&size=200')) + assert.ok(ctx.calls[3].path.includes('/sailing-assignments/exceptions')) + assert.ok(ctx.calls[4].path.endsWith('/11')) + assert.ok(ctx.calls[5].path.endsWith('/999999999999999')) +}) diff --git a/scripts/admin-test-env-modules/pda-releases.mjs b/scripts/admin-test-env-modules/pda-releases.mjs new file mode 100644 index 000000000..ce14a5a95 --- /dev/null +++ b/scripts/admin-test-env-modules/pda-releases.mjs @@ -0,0 +1,46 @@ +function id(value) { return value == null ? '' : String(value) } + +function validRelease(row) { + return row && typeof row === 'object' + && ['domestic', 'overseas'].includes(row.app) + && ['apk', 'wgt'].includes(row.packageType) + && typeof row.versionName === 'string' && row.versionName.length > 0 + && Number.isSafeInteger(Number(row.versionCode)) + && ['DRAFT', 'PUBLISHED', 'WITHDRAWN'].includes(row.status) + && typeof row.current === 'boolean' + && /^[a-f0-9]{64}$/i.test(String(row.sha256 || '')) + && Number.isSafeInteger(Number(row.sizeBytes)) && Number(row.sizeBytes) > 0 +} + +export async function run(context) { + const module = 'pda-releases' + const base = '/api/platform/v1/files/pda-releases' + + for (const app of ['domestic', 'overseas']) { + const response = await context.request('GET', `${base}/${app}`) + const releases = context.apiSuccess(module, `查询 ${app} PDA 发布列表`, response) + context.assert(module, `${app} 发布列表结构与版本字段完整`, response, + Array.isArray(releases) && releases.every(validRelease), + `count=${Array.isArray(releases) ? releases.length : 'not-array'}`) + const currentCount = Array.isArray(releases) ? releases.filter((row) => row.current === true).length : -1 + context.assert(module, `${app} 当前生效版本不超过一个发布类型`, response, + currentCount <= 2, `current=${currentCount}`) + } + + const emptyUpload = await context.requestMultipart('POST', `${base}/domestic`, new FormData()) + context.assert(module, '无安装包的上传被拒绝', emptyUpload, + emptyUpload.http >= 400 || String(emptyUpload.body?.code) !== '200', + `HTTP=${emptyUpload.http},code=${emptyUpload.body?.code ?? 'missing'}`) + + const missingId = '999999999999999' + for (const action of ['publish', 'withdraw']) { + const response = await context.request('POST', `${base}/${missingId}/${action}`, {}) + context.assert(module, `不存在版本 ${action} 被拒绝`, response, + response.http === 200 && ['A0403', 'A0404', 'A0409'].includes(String(response.body?.code)), + `HTTP=${response.http},code=${response.body?.code ?? 'missing'}`) + } + + context.addCoverageGap(module, 'pda-releases', '真实 APK/WGT 上传、发布、停发与下载', + '脚本不上传真实安装包;需人工准备有效 domestic/overseas APK 或 WGT 后定向执行,避免伪造签名与版本号', + 'not-implemented', { blocking: false }) +} diff --git a/scripts/admin-test-env-modules/sailing-calendar.mjs b/scripts/admin-test-env-modules/sailing-calendar.mjs new file mode 100644 index 000000000..cc4173f36 --- /dev/null +++ b/scripts/admin-test-env-modules/sailing-calendar.mjs @@ -0,0 +1,80 @@ +function count(value) { + const number = Number(value) + return Number.isFinite(number) ? number : NaN +} + +function currentMonth() { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', + }).format(new Date()).slice(0, 7) +} + +function id(value) { return value == null ? '' : String(value) } + +function listOf(value) { + if (Array.isArray(value)) return value + if (Array.isArray(value?.records)) return value.records + return null +} + +export async function run(context) { + const module = 'sailing-calendar' + const base = '/api/platform/v1/sailings' + const month = currentMonth() + + const calendarResponse = await context.request('GET', `${base}/calendar?month=${month}`) + const calendar = context.apiSuccess(module, '查询船期月历', calendarResponse) + const records = Array.isArray(calendar?.records) ? calendar.records : [] + const summary = calendar?.summary + const filterOptions = calendar?.filterOptions + context.assert(module, '月历记录、汇总与筛选选项结构完整', calendarResponse, + records.every((row) => id(row.id) && id(row.sailingNo) && 'etaAt' in row) + && summary && Number.isFinite(count(summary.sailingCount)) + && filterOptions && Array.isArray(filterOptions.warehouses) && Array.isArray(filterOptions.channels), + `month=${month},records=${records.length},sailingCount=${summary?.sailingCount ?? 'missing'}`) + context.assert(module, '月历容量汇总均为非负有限数', calendarResponse, + ['actualReceivedCbm', 'catchableForecastCbm', 'riskUnmatchedCbm'].every((field) => + Number.isFinite(count(summary?.[field])) && count(summary?.[field]) >= 0), + JSON.stringify(summary || {}).slice(0, 200)) + + const channelsResponse = await context.request('GET', '/api/platform/v1/sailing-channels') + const channels = context.apiSuccess(module, '查询船期渠道资源', channelsResponse) + const channelRows = listOf(channels) ?? [] + context.assert(module, '渠道列表字段完整', channelsResponse, + channelRows.every((row) => id(row.id) && id(row.channelCode) && Number.isFinite(count(row.defaultCutoffDays))), + `count=${channelRows.length}`) + + const templatesResponse = await context.request('GET', '/api/platform/v1/sailing-templates?page=1&size=200') + const templates = context.apiSuccess(module, '查询船期模板', templatesResponse) + const templateRows = listOf(templates) ?? [] + context.assert(module, '模板列表字段完整', templatesResponse, + templateRows.every((row) => id(row.id) && id(row.channelId) && id(row.warehouseId) && Array.isArray(row.weekdays)), + `count=${templateRows.length}`) + + const exceptionsResponse = await context.request('GET', '/api/platform/v1/sailing-assignments/exceptions?page=1&size=20') + const exceptions = context.apiSuccess(module, '查询船期异常分配', exceptionsResponse) + const exceptionRows = listOf(exceptions) ?? [] + context.assert(module, '异常分配列表字段完整', exceptionsResponse, + exceptionRows.every((row) => id(row.assignmentId) && Number.isFinite(count(row.volumeCbm))), + `count=${exceptionRows.length},total=${exceptions?.total ?? 'array'}`) + + if (records[0]) { + const detailResponse = await context.request('GET', `${base}/${encodeURIComponent(records[0].id)}`) + const detail = context.apiSuccess(module, '查询当前船期详情', detailResponse) + context.assert(module, '船期详情包含版本与货量摘要', detailResponse, + id(detail?.sailing?.id) === id(records[0].id) + && Number.isFinite(count(detail?.sailing?.version)) + && detail?.sailing?.volumeSummary != null + && Array.isArray(detail?.histories), + `id=${detail?.sailing?.id},version=${detail?.sailing?.version}`) + } else { + context.addCoverageGap(module, 'sailing-calendar', '船期详情与变更历史', + '当前月份没有船期记录;需先由订舱/模板生成本轮船期后重跑', 'not-implemented', { blocking: false }) + } + + const missingResponse = await context.request('GET', `${base}/999999999999999`) + context.assert(module, '不存在的船期详情被拒绝', missingResponse, + (missingResponse.http === 200 && ['A0403', 'A0404'].includes(String(missingResponse.body?.code))) + || missingResponse.http === 404, + `HTTP=${missingResponse.http},code=${missingResponse.body?.code ?? 'missing'}`) +} diff --git a/scripts/admin-test-env-modules/self-operated-analytics.mjs b/scripts/admin-test-env-modules/self-operated-analytics.mjs new file mode 100644 index 000000000..009d2c330 --- /dev/null +++ b/scripts/admin-test-env-modules/self-operated-analytics.mjs @@ -0,0 +1,39 @@ +function count(value) { + const number = Number(value) + return Number.isFinite(number) ? number : NaN +} + +export async function run(context) { + const module = 'self-operated-analytics' + const base = '/api/platform/v1/last-leg/self-operated-analytics' + + const dashboardResponse = await context.request('GET', `${base}/dashboard`) + if (dashboardResponse.http === 200 && String(dashboardResponse.body?.code) === '200') { + const dashboard = context.apiSuccess(module, '查询自营经营看板', dashboardResponse) + context.assert(module, '经营看板六项指标与趋势结构完整', dashboardResponse, + dashboard && typeof dashboard === 'object' + && (dashboard.metrics == null || Array.isArray(dashboard.metrics)) + && (dashboard.trend == null || Array.isArray(dashboard.trend)), + `keys=${Object.keys(dashboard || {}).length}`) + } else { + context.addCoverageGap(module, 'self-operated-analytics', '经营看板', + `需要本轮采集账号与仓库上下文;当前空参请求 HTTP=${dashboardResponse.http}, code=${dashboardResponse.body?.code ?? 'missing'}`, + 'not-implemented', { blocking: false }) + } + + const ownersResponse = await context.request('GET', `${base}/owners?page=1&size=20`) + if (ownersResponse.http === 200 && String(ownersResponse.body?.code) === '200') { + const owners = context.apiSuccess(module, '查询自营货主目录', ownersResponse) + context.assert(module, '货主目录分页字段完整', ownersResponse, + owners && Array.isArray(owners.records) && Number.isFinite(count(owners.total)), + `total=${owners?.total ?? 'missing'}`) + } else { + context.addCoverageGap(module, 'self-operated-analytics', '货主目录', + `需要本轮采集账号上下文;HTTP=${ownersResponse.http}, code=${ownersResponse.body?.code ?? 'missing'}`, + 'not-implemented', { blocking: false }) + } + + context.addCoverageGap(module, 'self-operated-analytics', '数据采集任务与重试', + '采集任务同步/重试依赖领星外部账号与真实采集批次;本模块只读核对看板与货主目录,缺账号时记录覆盖缺口', + 'external-third-party', { blocking: false }) +} From 0ff1eefff6f7521bc22a8ecd9479e72599d6b053 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 15:48:38 +0800 Subject: [PATCH 038/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + deploy/sql/头程数据库统一增量-MySQL8.sql | 6 + deploy/sql/海外区域统一增量-MySQL8.sql | 80 ++ .../file/service/BusinessFileCategory.java | 7 + .../fleet-owned-business-photos-v1.yaml | 14 + .../service/OwnedBusinessPhotoFileTest.java | 125 +++ .../main/resources/db/lastleg-permissions.sql | 5 +- .../service/DispatchAggregateService.java | 5 + .../controller/OwnedBusinessController.java | 78 ++ .../gateway/HttpOwnedBusinessFileGateway.java | 81 ++ .../gateway/OwnedBusinessFileGateway.java | 15 + .../mapper/OwnedBusinessMapper.java | 88 ++ .../mapper/OwnedBusinessQueryMapper.java | 55 + .../model/OwnedBusinessModels.java | 64 ++ .../model/OwnedBusinessRows.java | 47 + .../port/OwnedBusinessOwnershipCapture.java | 10 + .../repository/OwnedBusinessRepository.java | 54 + .../service/OwnedBusinessCommandService.java | 62 ++ .../OwnedBusinessOwnershipService.java | 51 + .../service/OwnedBusinessPhotoService.java | 95 ++ .../service/OwnedBusinessProjection.java | 77 ++ .../service/OwnedBusinessQueryService.java | 98 ++ .../service/OwnedBusinessRecordService.java | 110 ++ .../service/OwnedBusinessSupport.java | 89 ++ .../src/main/resources/db/lastleg.sql | 80 ++ .../openapi/fleet-owned-business-v1.yaml | 975 ++++++++++++++++++ .../DispatchAggregatePersistenceTest.java | 16 + .../OwnedBusinessCommandTest.java | 40 + .../OwnedBusinessFileGatewayTest.java | 30 + .../ownedbusiness/OwnedBusinessFixtures.java | 47 + .../OwnedBusinessOwnershipTest.java | 57 + .../OwnedBusinessPersistenceTest.java | 194 ++++ .../ownedbusiness/OwnedBusinessPhotoTest.java | 88 ++ .../OwnedBusinessProjectionTest.java | 45 + .../ownedbusiness/OwnedBusinessQueryTest.java | 73 ++ scripts/README.md | 10 + scripts/admin-test-env-modules/README.md | 6 + ...dispatch-workflow-extension-acceptance.mjs | 490 +++++++++ ...patch-workflow-extension-contract.test.mjs | 103 ++ ...t-cross-customer-fixture-contract.test.mjs | 33 +- .../fleet-cross-customer-fixture.mjs | 46 +- .../grouped-dispatch-acceptance.mjs | 20 +- .../inbound-measurement.mjs | 67 +- ...phase4-overseas-dispatch-contract.test.mjs | 2 +- .../phase4-overseas-dispatch.mjs | 10 +- .../phase4-overseas-pda-operations.mjs | 2 +- .../phase4-overseas-pda.mjs | 6 + .../dispatch-workflow-extensions-test-env.mjs | 172 +++ scripts/local-check.ps1 | 51 +- scripts/local-check.test.mjs | 30 +- 50 files changed, 3933 insertions(+), 78 deletions(-) create mode 100644 file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml create mode 100644 file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/OwnedBusinessFileGateway.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java create mode 100644 scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs create mode 100644 scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs create mode 100644 scripts/dispatch-workflow-extensions-test-env.mjs diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 336d17dea..90a27fb40 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。 + 客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。 费用项目默认价格结构:Firstleg 源基线 `phase2-fee-item-and-inbound-detail.sql` 与国内统一空库基线均增加可空的 `fl_fee_item.default_price DECIMAL(12,2)`。字段业务规则见工作区 `docs/入库测量与计费/README.md`;需与匹配的 Firstleg 和 Admin 一起发布。本次仅修改基线,不表示 SQL 已执行,也不能向已有测试库重放空库脚本。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ecb9eaca6..db18e9fdd 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6856,6 +6856,12 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107361,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107362,0,'PLATFORM','BUTTON',5107360,'platform:fleet:operations:edit','更正出车实测记录',NULL,'PUT',992,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107362,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107390,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index b821e6b70..6ce789b99 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4882,4 +4882,84 @@ CREATE TABLE ll_fleet_pickup_history ( KEY idx_pickup_history_trip(trip_no,id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; +-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。 +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership ( + dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键', + resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL', + vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空', + vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED', + vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本', + ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间', + loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要', + loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间', + PRIMARY KEY (dispatch_id), + KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id), + CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')), + CONSTRAINT ck_owned_business_resource_mode CHECK ( + (resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0) + OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( + id BIGINT NOT NULL COMMENT '附加运营记录内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本', + currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空', + total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用', + actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空', + fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里', + arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间', + photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照', + created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识', + updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id), + CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0), + CONSTRAINT ck_owned_business_money CHECK ( + (currency IS NULL AND total_amount IS NULL) + OR (currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)), + CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0), + CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command ( + id BIGINT NOT NULL COMMENT '附加运营命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识', + idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组', + action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作', + expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空', + request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要', + request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求', + status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT', + result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实', + error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码', + created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key), + CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0), + CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( + id BIGINT NOT NULL COMMENT '附加运营历史内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本', + action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作', + operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识', + reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明', + snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version), + CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; + SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 850060c90..ed10c57fe 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -11,6 +11,8 @@ enum BusinessFileCategory { FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_LOADING_PHOTO(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), DELIVERY_BOL(true, FormatPolicy.ARBITRARY, Limits.CUSTOMS, BusinessIdPolicy.POSITIVE_LONG), @@ -71,6 +73,11 @@ enum BusinessFileCategory { /** 校验文件归属;跨客户排车证据固定为平台所有,不随货物客户变化。 */ void validateOwner(long tenantId) { + // 整车运营照片归原排车单,不随同车客户变化,也不能串用装车和油耗类别。 + if ((this == OVERSEAS_DISPATCH_LOADING_PHOTO || this == OVERSEAS_DISPATCH_FUEL_PHOTO) + && tenantId != 0) { + throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); + } if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); diff --git a/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml new file mode 100644 index 000000000..f1c9fd9f9 --- /dev/null +++ b/file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml @@ -0,0 +1,14 @@ +openapi: 3.0.3 +info: + title: 自有业务排车运营照片归属契约 + version: 1.0.0 + description: >- + 自有业务消费原排车事实,仅补充运营照片,不创建另一套排车或装车状态。 + 复用InternalBusinessFileController,不增加公网File路径。 + businessType分为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO, + tenantId固定0,businessId固定原Lastleg排车内部主键,不接受车次主键、车牌或客户ID替代。 + 仅非空JPG/PNG,最大10MiB,同时校验MIME与真实内容签名;两类照片不能交叉引用。 + Lastleg按公开dispatchNo解析原排车,负责车辆归属、可见阶段、读写权限、附加记录版本、 + 分类附件引用和历史。文件上传不代表装车确认、签收、实到或运营信息保存。 + File不读取Lastleg表,只按客户范围、业务类别、业务ID和文件ID核对文件复合归属。 +paths: {} diff --git a/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java new file mode 100644 index 000000000..d608a51f3 --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/OwnedBusinessPhotoFileTest.java @@ -0,0 +1,125 @@ +package com.baiyue.file.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.entity.FileObjectEntity; +import com.baiyue.file.mapper.FileObjectMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.multipart.MultipartFile; + +import java.io.ByteArrayOutputStream; +import java.nio.file.Path; +import java.util.regex.Pattern; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +class OwnedBusinessPhotoFileTest { + private static final String LOADING = "OVERSEAS_DISPATCH_LOADING_PHOTO"; + private static final String FUEL = "OVERSEAS_DISPATCH_FUEL_PHOTO"; + @TempDir Path root; + + @BeforeAll static void columns() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "owned-business-photo"), FileObjectEntity.class); + } + + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void rejectsNonPlatformOrInvalidDispatchIdentityBeforeAnyStorage(String type) { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + var file = new MockMultipartFile("file", "photo.png", "image/png", new byte[]{1}); + for (long owner : new long[]{-1, 1, 900}) + assertThatThrownBy(() -> service.upload(owner, type, "71", 7, file)).isInstanceOf(BizException.class); + for (String businessId : new String[]{"0", "-1", "DS-71", "../71", ""}) + assertThatThrownBy(() -> service.upload(0, type, businessId, 7, file)).isInstanceOf(BizException.class); + verifyNoInteractions(files, storage); + } + + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void storesRealImagesButRejectsPdfSpoofedEmptyAndOversizedFiles(String type) throws Exception { + var storage = new BusinessFileStorage(); + ReflectionTestUtils.setField(storage, "configuredRoot", root); + storage.initializeRoot(); + var category = BusinessFileCategory.require(type); + for (String format : new String[]{"jpeg", "png"}) { + byte[] bytes = image(format); + var upload = new MockMultipartFile("file", "photo." + format, "image/" + format, bytes); + var saved = storage.store(0, "71", upload, category); + assertThat(storage.read(saved.objectKey())).isEqualTo(bytes); + } + for (var upload : new MockMultipartFile[]{ + new MockMultipartFile("file", "empty.png", "image/png", new byte[0]), + new MockMultipartFile("file", "spoof.png", "image/png", "not-image".getBytes()), + new MockMultipartFile("file", "receipt.pdf", "application/pdf", "%PDF-1.7\nreceipt".getBytes())}) + assertThatThrownBy(() -> storage.store(0, "71", upload, category)).isInstanceOf(BizException.class); + MultipartFile oversized = mock(MultipartFile.class); + when(oversized.isEmpty()).thenReturn(false); + when(oversized.getSize()).thenReturn(10L * 1024 * 1024 + 1); + assertThatThrownBy(() -> storage.store(0, "71", oversized, category)).isInstanceOf(BizException.class); + verify(oversized, never()).getInputStream(); + } + + /** 用实际Mapper查询条件核对四元组,错车、错类别和错文件必须先拒绝再读内容。 */ + @ParameterizedTest @ValueSource(strings = {LOADING, FUEL}) + void metadataAndDownloadRequireExactDispatchCategoryAndFile(String type) throws Exception { + var files = mock(FileObjectMapper.class); + var storage = mock(BusinessFileStorage.class); + var service = service(files, storage); + byte[] bytes = image("png"); + var row = new FileObjectEntity(); + row.setId(501L); row.setTenantId(0L); row.setBusinessType(type); row.setBusinessId("71"); + row.setOriginalName("photo.png"); row.setContentType("image/png"); row.setSizeBytes((long) bytes.length); + row.setObjectKey("owned-photo"); row.setSha256("a".repeat(64)); row.setCreatedAt(java.time.LocalDateTime.now()); + when(files.selectOne(any())).thenAnswer(call -> { + LambdaQueryWrapper query = call.getArgument(0); + assertThat(bound(query, "status")).isEqualTo("ACTIVE"); + assertThat(bound(query, "deleted")).isEqualTo(false); + return Long.valueOf(0).equals(bound(query, "tenant_id")) && type.equals(bound(query, "business_type")) + && "71".equals(bound(query, "business_id")) && Long.valueOf(501).equals(bound(query, "id")) ? row : null; + }); + when(storage.read("owned-photo")).thenReturn(bytes); + assertThat(service.metadata(0, type, "71", 501).businessType()).isEqualTo(type); + assertThat(service.download(0, type, "71", 501).content()).isEqualTo(bytes); + clearInvocations(storage); + for (String other : new String[]{LOADING.equals(type) ? FUEL : LOADING, + "FLEET_VEHICLE_RECORD_PHOTO", "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE", "DELIVERY_LOADING_PHOTO"}) { + assertThatThrownBy(() -> service.metadata(0, other, "71", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, other, "71", 501)).isInstanceOf(BizException.class); + } + assertThatThrownBy(() -> service.download(0, type, "72", 501)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(0, type, "71", 502)).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.download(1, type, "71", 501)).isInstanceOf(BizException.class); + verifyNoInteractions(storage); + } + + private static Object bound(LambdaQueryWrapper query, String column) { + var matcher = Pattern.compile("(?new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark()); var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at); row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name()); + // 同原装车事务保存车辆归属证据;后续档案变化不能改写本趟自有/外配分类。 + // 原命令重放已在方法入口返回,不重复捕获,也不在GET或旧单上补造历史。 + ownedBusinessOwnership.capture(row.getId(),context.resources().vehicle().vehicleId(), + context.resources().vehicle().mode().name(),row.getLoadingCompletionHash(),at); return saveCommand(row,actor,identity,request.remark(),new DispatchResults.Loaded(row.getLoadingCompletionHash(),at,confirmed)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java new file mode 100644 index 000000000..ff8898e86 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/OwnedBusinessController.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** 只读原排车装车后事实,仅附加登记和照片可写。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/owned-business") +public class OwnedBusinessController { + private final OwnedBusinessQueryService queries; + private final OwnedBusinessRecordService records; + private final OwnedBusinessPhotoService photos; + private final OwnedBusinessSupport access; + /** Controller不直调Repository/Mapper,身份只取会话。 */ + public OwnedBusinessController(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) { + this.queries=queries;this.records=records;this.photos=photos;this.access=access; + } + /** 列表和完整统计一次返回。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ApiResponse list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(queries.list(access.actor(user),query));} + /** list权限包含独立详情。 */ + @GetMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(queries.detail(access.actor(user),dispatchNo));} + /** 原排车和附加历史在同一快照分页,不逐条GET。 */ + @GetMapping("/{dispatchNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ApiResponse history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) { + return ApiResponse.success(queries.history(access.actor(user),dispatchNo,page,size)); + } + /** 金额可以更正/清空,但不推进主单或触发财务。 */ + @PutMapping("/{dispatchNo}/operations") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:edit')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) { + return ApiResponse.success(records.save(access.actor(user),dispatchNo,request)); + } + /** 上传仅准备File,关联保存另有独立原键。 */ + @PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(photos.upload(access.actor(user),dispatchNo,slot,idempotencyKey,file)); + } + /** 绑定失败保留fileId和原请求,不重新上传。 */ + @PutMapping("/{dispatchNo}/photos/{slot}") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:upload')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + return ApiResponse.success(photos.bind(access.actor(user),dispatchNo,slot,request)); + } + /** 附件只允许当前或精确历史引用读取。 */ + @GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable PhotoSlot slot,@PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) { + var result=photos.content(access.actor(user),dispatchNo,slot,fileId,revisionVersion); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType())) + .header("X-Content-Type-Options","nosniff").body(result.content()); + } + /** 不自动补写或向别的账号披露原命令。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + @PreAuthorize("hasAuthority('platform:fleet:owned-business:list')") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(photos.command(access.actor(user),dispatchNo,idempotencyKey)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java new file mode 100644 index 000000000..2d439889f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.gateway; + +import com.baiyue.lastleg.config.LastlegRestClientFactory; +import com.baiyue.lastleg.config.LastlegRestClientProperties; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.ByteArrayResource; +import org.springframework.http.*; +import org.springframework.http.client.MultipartBodyBuilder; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import java.time.LocalDateTime; +import java.util.Set; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** File保存照片,本域另行授权原排车及分类引用。 */ +@Component +public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway { + public static final long MAX_SIZE=10L*1024*1024; + private static final String PATH="/internal/file/v1/business-files/0/{type}/{dispatch}"; + private final RestClient client; + private final String token; + /** 沿既有内部File客户端,保持外部访问域封闭。 */ + public HttpOwnedBusinessFileGateway(RestClient.Builder builder,LastlegRestClientFactory factory,LastlegRestClientProperties properties, + @Value("${baiyue.clients.file.url:http://127.0.0.1:9205}") String baseUrl, + @Value("${baiyue.security.internal-service-token:}") String token) { + client=factory.build(builder,baseUrl,properties.getCustomerVisitFile());this.token=token; + } + /** MIME和字节签名一起校验,File仍执行其权威校验。 */ + public static void validate(Upload upload) { + if(upload==null || upload.fileName()==null || upload.fileName().isBlank() || upload.fileName().length()>255 + || upload.content()==null || upload.content().length==0 || upload.content().length>MAX_SIZE + || !image(upload.contentType()) || !signature(upload.contentType(),upload.content())) + throw bad("照片须为非空真实JPG/PNG,最大10MiB"); + } + /** 使用原图片字节,未知结果不能换键或自动重传。 */ + @Override public Evidence upload(long dispatchId,PhotoSlot slot,String operatorId,Upload upload) { + validate(upload);var body=new MultipartBodyBuilder(); + body.part("file",new ByteArrayResource(upload.content()){ + /** 保留本轮原文件名,不生成占位证据。 */ + @Override public String getFilename(){return upload.fileName();} + }).contentType(MediaType.parseMediaType(upload.contentType())); + var result=client.post().uri(PATH,slot.businessType(),dispatchId).header("X-Internal-Service-Token",token) + .header("X-Operator-Id",operatorId).contentType(MediaType.MULTIPART_FORM_DATA).body(body.build()).retrieve().body(Envelope.class); + return verified(result,dispatchId,slot,null); + } + /** 不能借另一客户、另一分类或另一排车的文件授权。 */ + @Override public Evidence metadata(long dispatchId,PhotoSlot slot,long fileId) { + return verified(client.get().uri(PATH+"/{file}",slot.businessType(),dispatchId,fileId) + .header("X-Internal-Service-Token",token).retrieve().body(Envelope.class),dispatchId,slot,fileId); + } + /** 防止把错误页或其他内容冒充图片返回。 */ + @Override public byte[] content(long dispatchId,PhotoSlot slot,long fileId) { + var info=metadata(dispatchId,slot,fileId); + byte[] bytes=client.get().uri(PATH+"/{file}/content",slot.businessType(),dispatchId,fileId) + .header("X-Internal-Service-Token",token).retrieve().body(byte[].class); + if(bytes==null || bytes.length!=info.sizeBytes() || !signature(info.contentType(),bytes))throw conflict("图片内容与元数据不一致"); + return bytes; + } + /** 所有归属及内容元数据必须严格匹配原dispatch内部ID。 */ + public static Evidence verified(Envelope response,long dispatchId,PhotoSlot slot,Long expected) { + if(response==null || !"200".equals(response.code()) || response.data()==null)throw conflict("照片服务未返回明确成功"); + var m=response.data(); + if(m.fileId()==null || m.fileId()<=0 || !Long.valueOf(0).equals(m.tenantId()) || !slot.businessType().equals(m.businessType()) + || !String.valueOf(dispatchId).equals(m.businessId()) || expected!=null && !expected.equals(m.fileId()) + || m.sizeBytes()==null || m.sizeBytes()<=0 || m.sizeBytes()>MAX_SIZE || !image(m.contentType()) + || m.fileName()==null || m.fileName().isBlank() || m.createdAt()==null)throw bad("照片不属于本排车或分类,或文件格式无效"); + return new Evidence(m.fileId(),m.fileName(),m.contentType(),m.sizeBytes(),m.createdAt().toString()); + } + /** 不放开PDF、SVG或任意image类型。 */ + private static boolean image(String type){return Set.of("image/jpeg","image/png").contains(type==null?"":type);} + /** 使用真实文件头,不转换或伪造证据图片。 */ + private static boolean signature(String type,byte[] bytes) { + if(bytes==null)return false; + if("image/jpeg".equals(type))return bytes.length>=3 && (bytes[0]&255)==255 && (bytes[1]&255)==216 && (bytes[2]&255)==255; + byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.lengthSELECT * FROM ll_fleet_owned_business_record WHERE dispatch_id IN #{id}") + List records(@Param("ids") List ids); + /** CAS仅修改运营附加字段,不占用主单recordVersion。 */ + @Update(""" + UPDATE ll_fleet_owned_business_record SET record_version=#{r.recordVersion},currency=#{r.currency}, + total_amount=#{r.totalAmount},actual_distance_miles=#{r.actualDistanceMiles}, + fuel_consumption_litres_per_100km=#{r.fuelConsumptionLitresPer100Km},arrivals_json=#{r.arrivalsJson}, + photos_json=#{r.photosJson},updated_by=#{r.updatedBy},updated_at=#{r.updatedAt} + WHERE dispatch_id=#{r.dispatchId} AND record_version=#{expected} + """) + int update(@Param("r") Record row,@Param("expected") long expected); + /** 命令键全车唯一,操作者在服务端另行核验。 */ + @Select("SELECT * FROM ll_fleet_owned_business_command WHERE dispatch_id=#{id} AND idempotency_key=#{key}") + Command command(@Param("id") long id,@Param("key") String key); + /** 完整意图在文件HTTP之前单独事务提交。 */ + @Insert(""" + INSERT INTO ll_fleet_owned_business_command(id,dispatch_id,operator_id,idempotency_key,action, + expected_version,request_hash,request_json,status,result_json,error_code,created_at,updated_at) + VALUES(#{id},#{dispatchId},#{operatorId},#{idempotencyKey},#{action},#{expectedVersion}, + #{requestHash},#{requestJson},#{status},#{resultJson},#{errorCode},#{createdAt},#{updatedAt}) + """) + int insertCommand(Command row); + /** 上传只完成原待定意图,不能把未知结果当新命令重做。 */ + @Update(""" + UPDATE ll_fleet_owned_business_command SET status=#{status},result_json=#{resultJson}, + error_code=#{errorCode},updated_at=#{updatedAt} WHERE id=#{id} AND status='PENDING' + """) + int finishUpload(Command row); + /** 附加写与不可变历史同事务,版本不能重复。 */ + @Insert(""" + INSERT INTO ll_fleet_owned_business_history(id,dispatch_id,record_version,action,operator_id, + reason,snapshot_json,occurred_at) VALUES(#{id},#{dispatchId},#{recordVersion},#{action},#{operatorId}, + #{reason},#{snapshotJson},#{occurredAt}) + """) + int insertHistory(History row); + /** 历史按本页版本顺序分页。 */ + @Select(""" + SELECT * FROM ( + SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source, + action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} + UNION ALL + SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source, + action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id} + ) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset} + """) + List history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit); + /** 历史总数不取当前页长度。 */ + @Select("SELECT (SELECT COUNT(*) FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id})+(SELECT COUNT(*) FROM ll_dispatch_history WHERE dispatch_id=#{id})") + int historyCount(long id); + /** 历史文件只能从指定真实历史快照读取。 */ + @Select("SELECT * FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} AND record_version=#{version}") + History revision(@Param("id") long id,@Param("version") long version); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java new file mode 100644 index 000000000..62986d7ad --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.mapper; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; +import org.apache.ibatis.annotations.*; +import java.util.List; + +/** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */ +@Mapper +public interface OwnedBusinessQueryMapper { + String SOURCE=""" + SELECT d.*,o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode, + o.vehicle_ownership AS owned_vehicle_ownership,o.vehicle_record_version AS owned_vehicle_record_version, + o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at + FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id + """; + String FILTER=""" + d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED') + AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL + AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash + AND d.planned_departure_at >= #{q.from} + AND d.planned_departure_at < #{q.to} + AND d.stage=#{q.stage} + AND d.overseas_warehouse_id=#{q.warehouseId} + AND (d.dispatch_no LIKE CONCAT('%',#{q.keyword},'%') + OR d.vehicle_plate LIKE CONCAT('%',#{q.keyword},'%') OR EXISTS(SELECT 1 FROM ll_dispatch_item i + WHERE i.dispatch_order_id=d.id AND (i.destination_warehouse_code LIKE CONCAT('%',#{q.keyword},'%') + OR i.fba_warehouse_code LIKE CONCAT('%',#{q.keyword},'%') + OR i.destination_warehouse_name LIKE CONCAT('%',#{q.keyword},'%')))) + """; + /** 只返回当前完整条件下的一车一行。 */ + @Select("") + List page(@Param("q") Search query); + /** 同一过滤片段不带分页,不能拿当前页长度冒充统计。 */ + @Select("") + List summary(@Param("q") Search query); + /** 先读原主单和可空归属,以明确区别无快照与不存在。 */ + @Select(SOURCE+" WHERE d.dispatch_no=#{number}") + Source find(String number); + /** 页内全部货物一次读取,各客户保持原归属。 */ + @Select("") + List cargo(@Param("ids") List ids); + /** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */ + @Select(""" + + """) + List batches(@Param("ids") List ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java new file mode 100644 index 000000000..25d841394 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessModels.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.model; + +import com.baiyue.lastleg.dispatch.port.DispatchViews; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; +import java.util.Set; + +/** 原排车只读投影和附加信息,不公开第二套履约命令。 */ +public final class OwnedBusinessModels { + /** 工具容器不允许实例化。 */ + private OwnedBusinessModels() {} + public static final String LIST="platform:fleet:owned-business:list"; + public static final String EDIT="platform:fleet:owned-business:edit"; + public static final String UPLOAD="platform:fleet:owned-business:upload"; + public enum Stage { AFTER_LOADING, DELIVERING, DELIVERED } + public enum PhotoSlot { + LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT, FUEL_START, FUEL_END; + /** 图片框分类不表示司机审核或真实派送签收。 */ + public String category() { return name().startsWith("FUEL_")?"FUEL":"LOADING"; } + /** 文件类型与租户归属由本域固定,浏览器不能指定。 */ + public String businessType() { return category().equals("FUEL")?"OVERSEAS_DISPATCH_FUEL_PHOTO":"OVERSEAS_DISPATCH_LOADING_PHOTO"; } + } + public record Actor(String operatorId,String operatorName,Set permissions) {} + public record Query(@Min(1) Integer page,@Min(1) @Max(200) Integer size, + String plannedDepartureFrom,String plannedDepartureTo,@Size(max=128) String keyword, + Stage stage,@Size(max=64) String warehouseId) {} + public record Money(@NotBlank @Pattern(regexp="CNY|USD") String currency, + @NotBlank @Pattern(regexp="(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?") String amount) {} + public record Arrival(@NotBlank @Size(max=256) String destinationKey,String actualArrivedAt) {} + public record Operations(@Valid Money money,String actualDistanceMiles, + String fuelConsumptionLitresPer100Km,@NotNull List<@NotNull @Valid Arrival> arrivals) {} + public record SaveOperations(@NotBlank String recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotNull @Valid Operations operations, + @Size(max=1000) String reason) {} + public record BindPhoto(@NotBlank String recordVersion, + @NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {} + public record Gate(boolean allowed,String blockReason) {} + public record Actions(Gate edit,Gate upload) {} + public record Origin(String warehouseId,String warehouseCode,String warehouseName) {} + public record Vehicle(String vehicleId,String plate,String ownership) {} + public record Driver(String name,String phone) {} + public record Appointment(String appointmentNo,String confirmationNo,String scheduledAt,String allocatedPackages) {} + public record Destination(String destinationKey,String warehouseCode,String warehouseName,String address, + List appointments,String actualArrivedAt) {} + public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType, + String sizeBytes,String uploadedAt,String savedAt) {} + public record PhotoSummary(int loadingCount,int fuelCount) {} + public record Row(String dispatchNo,Stage stage,String plannedDepartureAt,String loadingCompletedAt, + String departedAt,String deliveryCompletedAt,Origin origin,Vehicle vehicle,Driver driver, + List destinations,Integer actualPalletCount,Operations operations,String unitPrice, + String recordVersion,String dispatchRecordVersion,PhotoSummary photoSummary,Actions actions,List sourceIssues) {} + public record Summary(int dispatchCount,int afterLoadingCount,int deliveringCount,int deliveredCount) {} + public record ListResult(List records,int page,int size,int total,Summary summary) {} + public record Cargo(String sourceKey,String customerId,String customerName,String orderNo, + String inboundCode,String palletNo,String destinationKey,String packages) {} + public record Detail(Row row,DispatchViews.Detail dispatch,List cargo,List photos) {} + public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason, + Operations operations,List photos) {} + public record HistoryPage(List records,int page,int size,int total) {} + public record Command(String dispatchNo,String action,String idempotencyKey,String state, + String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {} + public record Snapshot(Operations operations,List photos,String operatorName) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java new file mode 100644 index 000000000..826b923a5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.model; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import lombok.Data; +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** 仅供本包持久化,不向浏览器输出内部主键。 */ +public final class OwnedBusinessRows { + /** 持久化容器不允许实例化。 */ + private OwnedBusinessRows() {} + @Data public static class Ownership { + private Long dispatchId,vehicleId,vehicleRecordVersion; + private String resourceMode,vehicleOwnership,loadingCompletionHash; + private LocalDateTime ownershipCapturedAt,loadingCompletedAt; + } + @Data public static class Source extends DispatchAggregate { + private String ownedResourceMode,ownedVehicleOwnership,ownedLoadingCompletionHash; + private Long ownedVehicleId,ownedVehicleRecordVersion; + private LocalDateTime ownedLoadingCompletedAt; + } + @Data public static class Record { + private Long id,dispatchId,recordVersion; + private String currency,arrivalsJson,photosJson,createdBy,updatedBy; + private BigDecimal totalAmount,actualDistanceMiles,fuelConsumptionLitresPer100Km; + private LocalDateTime createdAt,updatedAt; + } + @Data public static class Command { + private Long id,dispatchId,expectedVersion; + private String operatorId,idempotencyKey,action,requestHash,requestJson,status,resultJson,errorCode; + private LocalDateTime createdAt,updatedAt; + } + @Data public static class History { + private Long id,dispatchId,recordVersion,dispatchRecordVersion; + private String eventSource,action,operatorId,reason,snapshotJson; + private LocalDateTime occurredAt; + } + @Data public static class Batch { + private Long dispatchId; + private String destinationKey,appointmentNo,confirmationNo; + private LocalDateTime startUtc; + private Long allocatedPackages; + } + public record Search(int offset,int limit,String keyword,String warehouseId,String stage, + LocalDateTime from,LocalDateTime to) {} + @Data public static class StageCount { private String stage;private Long quantity; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java new file mode 100644 index 000000000..f69de8cf6 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/OwnedBusinessOwnershipCapture.java @@ -0,0 +1,10 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.port; + +import java.time.Instant; + +/** 装车主事务唯一捕获车辆归属,不创建第二套履约状态。 */ +public interface OwnedBusinessOwnershipCapture { + /** 主流程已稳定车辆资格,仅捕获原装车时点的不可变归属。 */ + void capture(long dispatchId, Long vehicleId, String resourceMode, + String loadingCompletionHash, Instant loadingCompletedAt); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java new file mode 100644 index 000000000..abb6ed487 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/repository/OwnedBusinessRepository.java @@ -0,0 +1,54 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.repository; + +import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessMapper; +import com.baiyue.lastleg.fleet.ownedbusiness.mapper.OwnedBusinessQueryMapper; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 只封装本页Mapper,关系库访问不使用JdbcTemplate或直连JDBC。 */ +@Repository +public class OwnedBusinessRepository { + private final OwnedBusinessMapper records; + private final OwnedBusinessQueryMapper queries; + /** 注入唯一SQL提供者,Service不能直接访问Mapper。 */ + public OwnedBusinessRepository(OwnedBusinessMapper records,OwnedBusinessQueryMapper queries){this.records=records;this.queries=queries;} + /** 原归属已捕获后不按当前车辆回算。 */ + public Ownership ownership(long id){return records.ownership(id);} + /** 归属与主装车事务共同提交。 */ + public void capture(Ownership row){records.capture(row);} + /** 服务端整车分页。 */ + public List page(Search query){return queries.page(query);} + /** 与分页相同过滤的完整统计。 */ + public List summary(Search query){return queries.summary(query);} + /** 读取唯一原排车身份及归属证据。 */ + public Source source(String number){return queries.find(number);} + /** 页内货物批量查询。 */ + public List cargo(List ids){return ids.isEmpty()?List.of():queries.cargo(ids);} + /** 页内实际预约分配批量查询。 */ + public List batches(List ids){return ids.isEmpty()?List.of():queries.batches(ids);} + /** GET只读,无记录保留虚拟0版本。 */ + public Record record(long id){return records.record(id);} + /** 页内附加信息批量读取。 */ + public List records(List ids){return ids.isEmpty()?List.of():records.records(ids);} + /** 首个写事务建立行,并按dispatch唯一键串行。 */ + public Record lock(Record seed){records.ensure(seed);return records.lockRecord(seed.getDispatchId());} + /** 只CAS修改附加信息。 */ + public int update(Record row,long expected){return records.update(row,expected);} + /** 命令先按全车请求键定位,再在服务端检查原操作者。 */ + public Command command(long id,String key){return records.command(id,key);} + /** 命令与附加版本同事务保存,或作为独立上传意图持久化。 */ + public void command(Command row){records.insertCommand(row);} + /** 上传结果只完成原待定意图。 */ + public int finishUpload(Command row){return records.finishUpload(row);} + /** 同一附加版本只记录一次不可变历史。 */ + public void history(History row){records.insertHistory(row);} + /** 历史总数只取原dispatch。 */ + public int historyCount(long id){return records.historyCount(id);} + /** 历史采用稳定版本倒序。 */ + public List history(long id,int offset,int size){return records.history(id,offset,size);} + /** 下载历史附件时精确定位原版本。 */ + public History revision(long id,long version){return records.revision(id,version);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java new file mode 100644 index 000000000..7b7174361 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessCommandService.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Command; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.server.ResponseStatusException; +import java.time.Instant; +import java.util.Objects; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** 附加操作与文件准备共用二元唯一账本,不允许异账号复用键。 */ +@Service +public class OwnedBusinessCommandService { + private final OwnedBusinessRepository repository; + private final OwnedBusinessSupport codec; + /** 文件意图独立短事务,不持业务行锁跨HTTP。 */ + public OwnedBusinessCommandService(OwnedBusinessRepository repository,OwnedBusinessSupport codec){this.repository=repository;this.codec=codec;} + public record Reservation(Command command,boolean created) {} + /** 查找原命令时先核操作者,不披露别人的原载荷。 */ + public Command existing(long dispatchId,String key,Actor actor) { + var row=repository.command(dispatchId,key); + if(row!=null && !Objects.equals(row.getOperatorId(),actor.operatorId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"该请求键不属于当前操作账号"); + return row; + } + /** 原action、版本和完整规范请求必须一致,不能用当前版本重造旧意图。 */ + public void same(Command row,String action,Long version,Object request) { + if(!Objects.equals(row.getAction(),action) || !Objects.equals(row.getExpectedVersion(),version) + || !Objects.equals(row.getRequestHash(),codec.hash(java.util.Arrays.asList(row.getOperatorId(),action,version,request))))throw conflict("相同请求键的操作、版本或内容不同"); + } + /** 完整命令实体只用于本页,不保存浏览器凭据。 */ + public Command create(long id,Actor actor,String key,String action,Long version,Object request,String status) { + var now=codec.utc(Instant.now());var row=new Command();row.setId(IdWorker.getId());row.setDispatchId(id); + row.setOperatorId(actor.operatorId());row.setIdempotencyKey(key);row.setAction(action);row.setExpectedVersion(version); + row.setRequestHash(codec.hash(java.util.Arrays.asList(actor.operatorId(),action,version,request)));row.setRequestJson(codec.write(request));row.setStatus(status);row.setCreatedAt(now);row.setUpdatedAt(now);return row; + } + /** 首次上传先持久化意图,竞争者只能读取原状态,不再次调用File。 */ + @Transactional(propagation=Propagation.REQUIRES_NEW,isolation=Isolation.READ_COMMITTED) + public Reservation reserveUpload(long id,Actor actor,String key,Object request) { + var previous=existing(id,key,actor); + if(previous!=null){same(previous,"UPLOAD_PHOTO",null,request);return new Reservation(previous,false);} + var row=create(id,actor,key,"UPLOAD_PHOTO",null,request,"PENDING"); + try{repository.command(row);return new Reservation(row,true);} + catch(DuplicateKeyException race){var original=existing(id,key,actor);if(original==null)throw race;same(original,"UPLOAD_PHOTO",null,request);return new Reservation(original,false);} + } + /** 已知上传回执或未知结果只完成原意图,不创建新业务记录。 */ + @Transactional(propagation=Propagation.REQUIRES_NEW) + public void finishUpload(Command command,String status,Object result,String errorCode) { + command.setStatus(status);command.setResultJson(result==null?null:codec.write(result));command.setErrorCode(errorCode); + command.setUpdatedAt(codec.utc(Instant.now())); + if(repository.finishUpload(command)!=1)throw conflict("上传命令结果已被其他操作确认,请核对原请求"); + } + /** 写命令与附加更新、历史在同一外部事务提交。 */ + public void success(long id,Actor actor,String key,String action,long version,Object request,Object result) { + var command=create(id,actor,key,action,version,request,"SUCCEEDED");command.setResultJson(codec.write(result));repository.command(command); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java new file mode 100644 index 000000000..5c3757fce --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessOwnershipService.java @@ -0,0 +1,51 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.*; +import java.util.Objects; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** 装车时车辆归属的唯一捕获实现,GET绝不补造历史。 */ +@Service +public class OwnedBusinessOwnershipService implements OwnedBusinessOwnershipCapture { + private final OwnedBusinessRepository repository; + private final VehicleRepository vehicles; + /** 使用真实车辆提供者,不对车辆主档增加反向写锁。 */ + public OwnedBusinessOwnershipService(OwnedBusinessRepository repository,VehicleRepository vehicles){this.repository=repository;this.vehicles=vehicles;} + /** 主流程原父事务已经稳定资源,捕获失败必须与装车一起回滚。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public void capture(long dispatchId,Long vehicleId,String resourceMode,String loadingCompletionHash,Instant loadingCompletedAt) { + if(dispatchId<=0 || loadingCompletionHash==null || loadingCompletionHash.isBlank() || loadingCompletionHash.length()>128 || loadingCompletedAt==null + || !loadingCompletedAt.equals(loadingCompletedAt.truncatedTo(java.time.temporal.ChronoUnit.MILLIS)))throw bad("装车归属证据不完整"); + if(!"MASTER".equals(resourceMode) && !"EXTERNAL".equals(resourceMode))throw bad("车辆资源模式不合法"); + if("MASTER".equals(resourceMode)?vehicleId==null || vehicleId<=0:vehicleId!=null)throw bad("车辆资源身份不一致"); + var existing=repository.ownership(dispatchId); + if(existing!=null){same(existing,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt);return;} + var row=new Ownership();row.setDispatchId(dispatchId);row.setVehicleId(vehicleId);row.setResourceMode(resourceMode); + if("MASTER".equals(resourceMode)) { + var vehicle=vehicles.findById(vehicleId).orElseThrow(()->conflict("原车辆主档不存在")); + if(vehicle.recordVersion()<0)throw conflict("原车辆档案版本无效"); + if(!"OWNED".equals(vehicle.ownership()) && !"DISPATCHED".equals(vehicle.ownership()))throw conflict("车辆归属未明确"); + row.setVehicleOwnership(vehicle.ownership());row.setVehicleRecordVersion(vehicle.recordVersion()); + } else {row.setVehicleOwnership("DISPATCHED");row.setVehicleRecordVersion(null);} + row.setLoadingCompletedAt(LocalDateTime.ofInstant(loadingCompletedAt,ZoneOffset.UTC)); + row.setOwnershipCapturedAt(row.getLoadingCompletedAt());row.setLoadingCompletionHash(loadingCompletionHash); + try{repository.capture(row);}catch(DuplicateKeyException duplicate){ + var raced=repository.ownership(dispatchId);if(raced==null)throw duplicate; + same(raced,vehicleId,resourceMode,loadingCompletionHash,loadingCompletedAt); + if(!Objects.equals(raced.getVehicleOwnership(),row.getVehicleOwnership()) || !Objects.equals(raced.getVehicleRecordVersion(),row.getVehicleRecordVersion()))throw conflict("装车车辆归属并发变化"); + } + } + /** 只允许同一原装车事实重放,不能以新归属重写旧证据。 */ + private void same(Ownership row,Long vehicleId,String mode,String hash,Instant at) { + if(!Objects.equals(row.getVehicleId(),vehicleId) || !Objects.equals(row.getResourceMode(),mode) + || !Objects.equals(row.getLoadingCompletionHash(),hash) || !Objects.equals(row.getLoadingCompletedAt(),LocalDateTime.ofInstant(at,ZoneOffset.UTC))) + throw conflict("原装车车辆归属证据不同"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java new file mode 100644 index 000000000..12cec0302 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java @@ -0,0 +1,95 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 只有绑定成功才保存照片,上传和绑定各自使用稳定键。 */ +@Service +public class OwnedBusinessPhotoService { + private final OwnedBusinessQueryService queries; + private final OwnedBusinessRecordService records; + private final OwnedBusinessRepository repository; + private final OwnedBusinessCommandService commands; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + private final OwnedBusinessFileGateway files; + /** HTTP远程请求不在附加行写事务内持锁等待。 */ + public OwnedBusinessPhotoService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records, + OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection, + OwnedBusinessSupport codec,OwnedBusinessFileGateway files) { + this.queries=queries;this.records=records;this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files; + } + public record UploadIdentity(PhotoSlot slot,String fileName,String contentType,long sizeBytes,String sha256) {} + public record Download(Photo photo,byte[] content) {} + /** 写前落意图,绑定失败须保留原fileId,不能再上传。 */ + public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) { + codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number); + if(slot==null || multipart==null)throw bad("请选择照片及分类"); + if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); + byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");} + var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); + HttpOwnedBusinessFileGateway.validate(payload); + var identity=new UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); + var reservation=commands.reserveUpload(source.getId(),actor,key,identity); + if(!reservation.created()) { + if(!"SUCCEEDED".equals(reservation.command().getStatus()))throw conflict("原上传结果尚未明确,请查询原请求,勿换键重传"); + return codec.read(reservation.command().getResultJson(),Photo.class); + } + try { + var result=photo(slot,files.upload(source.getId(),slot,actor.operatorId(),payload)); + commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result; + } catch(RuntimeException failure) { + // 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。 + try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");} + catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);} + throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传"); + } + } + /** 重放先于File复核,保证原成功结果可以精确恢复。 */ + public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) { + codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失"); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId()); + if(fileId<=0)throw bad("文件编号无效"); + var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(previous!=null) { + commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request)); + if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令"); + return codec.read(previous.getResultJson(),Detail.class); + } + return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId))); + } + /** 只有当前或指定真实历史版本中的引用允许代理读取。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) { + codec.require(actor,LIST);var source=queries.requireOwned(number);List bound; + if(revision==null)bound=projection.photos(repository.record(source.getId())); + else { + if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision); + if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos(); + } + var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing); + return new Download(photo,files.content(source.getId(),slot,fileId)); + } + /** 恢复先校验原操作者,不披露他人载荷。 */ + @Transactional(readOnly=true) + public Command command(Actor actor,String number,String key) { + codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor); + if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class); + String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1)); + return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false); + } + /** 上传回执不填写保存时间,避免假称已经关联成功。 */ + private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence evidence) { + return new Photo(slot,slot.category(),String.valueOf(evidence.fileId()),evidence.fileName(),evidence.contentType(), + String.valueOf(evidence.sizeBytes()),evidence.uploadedAt(),null); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java new file mode 100644 index 000000000..31bf7bd8d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -0,0 +1,77 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import org.springframework.stereotype.Component; +import java.math.*; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 组合原排车与附加信息;不复制、推进或重新归并订单。 */ +@Component +public class OwnedBusinessProjection { + private final OwnedBusinessSupport codec; + /** 所有数值与日期转换沿统一显式规则。 */ + public OwnedBusinessProjection(OwnedBusinessSupport codec){this.codec=codec;} + /** 未登记为null,不以默认油耗、金额或到达填充。 */ + public Operations operations(Record record) { + if(record==null)return new Operations(null,null,null,List.of()); + var money=record.getTotalAmount()==null?null:new Money(record.getCurrency(),record.getTotalAmount().setScale(2).toPlainString()); + return new Operations(money,decimal(record.getActualDistanceMiles()),decimal(record.getFuelConsumptionLitresPer100Km()), + codec.list(record.getArrivalsJson(),Arrival.class)); + } + /** 照片已绑定才出现在当前记录,无文件不生成占位图。 */ + public List photos(Record record){return record==null?List.of():codec.list(record.getPhotosJson(),Photo.class);} + /** 使用原货物确定目的地key,保持与排车及到达登记一致。 */ + public String destinationKey(DispatchItem item) { + String key=FleetDispatchGateway.destinationKey(item.getDestinationType(),item.getDestinationWarehouseCode(), + item.getFbaWarehouseCode(),item.getDeliveryAddress(),item.getPostalCode()); + if(key==null || key.isBlank())throw OwnedBusinessSupport.conflict("原货物目的地事实缺失");return key; + } + /** 真实物理托盘唯一计数,标准托位和来源条数不参与除数。 */ + public int palletCount(Source source) { + var confirmed=codec.read(source.getLoadingConfirmationJson(),ConfirmedLoad.class); + if(confirmed.pallets()==null)throw OwnedBusinessSupport.conflict("实际装车托盘事实缺失"); + var ids=new HashSet(); + for(var pallet:confirmed.pallets()) { + if(pallet==null || pallet.warehousePalletId()==null || pallet.warehousePalletId()<=0)throw OwnedBusinessSupport.conflict("实际装车托盘身份缺失"); + ids.add(pallet.warehousePalletId()); + } + return ids.size(); + } + /** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */ + public Row row(Source source,Record record,List cargo,List batches,Actor actor) { + var resources=codec.read(source.getResourcesJson(),Resources.class); + if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失"); + var ops=operations(record);var photos=photos(record);int pallets=palletCount(source); + var arrivals=new HashMap();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt()); + var destinations=new LinkedHashMap(); + for(var item:cargo) { + String key=destinationKey(item); + destinations.computeIfAbsent(key,k->new Destination(k,item.getDestinationWarehouseCode()==null || item.getDestinationWarehouseCode().isBlank()?item.getFbaWarehouseCode():item.getDestinationWarehouseCode(),item.getDestinationWarehouseName(), + item.getDeliveryAddress(),batches.stream().filter(b->k.equals(b.getDestinationKey())).map(b->new Appointment( + b.getAppointmentNo(),b.getConfirmationNo(),codec.iso(b.getStartUtc()),String.valueOf(b.getAllocatedPackages()))).toList(),arrivals.get(k))); + } + String price=ops.money()==null || pallets==0?null:new BigDecimal(ops.money().amount()).divide(BigDecimal.valueOf(pallets),2,RoundingMode.HALF_UP).toPlainString(); + var driver=resources.driver(); + return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()), + codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()), + new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()), + new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"), + new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price, + record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()), + new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()), + new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of()); + } + /** 附加权限独立于原排车写动作。 */ + private Gate gate(Actor actor,String permission) { + boolean allowed=actor!=null && actor.permissions()!=null && actor.permissions().contains(permission); + return new Gate(allowed,allowed?null:"没有该业务操作权限"); + } + /** 数据库十进制保持显式单位,不以空值替换0。 */ + private String decimal(BigDecimal value){return value==null?null:value.toPlainString();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java new file mode 100644 index 000000000..2c9c7c0a1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -0,0 +1,98 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.time.Instant; +import java.util.*; +import java.util.stream.Collectors; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */ +@Service +public class OwnedBusinessQueryService { + private final OwnedBusinessRepository repository; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + private final DispatchAggregateService dispatch; + /** 主单详情沿既有真实提供者,列表使用本域批量投影。 */ + public OwnedBusinessQueryService(OwnedBusinessRepository repository,OwnedBusinessProjection projection, + OwnedBusinessSupport codec,DispatchAggregateService dispatch){this.repository=repository;this.projection=projection;this.codec=codec;this.dispatch=dispatch;} + /** 四项统计与分页使用同一过滤和快照,GET不写库。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ListResult list(Actor actor,Query query) { + codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失"); + int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64); + Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo()); + if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效"); + int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");} + var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to)); + var counts=repository.summary(search);int after=0,delivering=0,delivered=0; + for(var c:counts) { + int n=Math.toIntExact(c.getQuantity()); + switch(c.getStage()){case "AFTER_LOADING"->after=n;case "DELIVERING"->delivering=n;case "DELIVERED"->delivered=n;default->throw conflict("阶段统计事实不合法");} + } + int total=Math.addExact(Math.addExact(after,delivering),delivered); + var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList(); + var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r)); + var cargo=repository.cargo(ids);var batches=repository.batches(ids); + var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()), + cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(), + batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList(); + return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)); + } + /** 对原排车详情做组合,不创建第二份主单或履约状态。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));} + /** 写入已经有独立权限;回执仍由相同真实来源组成。 */ + public Detail detailForWrite(Actor actor,Source source) { + eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids); + var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor); + var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo()); + return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(), + c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(), + projection.photos(repository.record(source.getId()))); + } + /** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */ + public Source requireOwned(String number) { + if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号"); + var source=repository.source(number);if(source==null)throw missing();eligible(source);return source; + } + /** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */ + private void eligible(Source source) { + if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务"); + if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing(); + if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage()) + || source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实"); + var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle(); + if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId()) + || !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash()) + || !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致"); + } + /** 操作记录只返回真实附加版本,不制造初始示例事件。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) { + codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize); + int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");} + return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{ + var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null; + return new History(h.getEventSource(),h.getRecordVersion()==null?null:h.getRecordVersion().toString(), + h.getDispatchRecordVersion()==null?null:h.getDispatchRecordVersion().toString(),h.getAction(),snapshot==null?null:snapshot.operatorName(),codec.iso(h.getOccurredAt()),h.getReason(), + snapshot==null?null:snapshot.operations(),snapshot==null?List.of():snapshot.photos()); + }).toList(),page,size,repository.historyCount(source.getId())); + } + /** 页码初始1,缺失与非法值不能混同。 */ + private int page(Integer value){if(value==null)return 1;if(value<1)throw bad("页码须大于0");return value;} + /** 列表和历史均限制单页读取量。 */ + private int size(Integer value){if(value==null)return 20;if(value<1 || value>200)throw bad("每页条数须为1至200");return value;} + /** 空白可选条件归一化,无限长输入明确拒绝。 */ + private String optional(String value,int max){if(value==null || value.isBlank())return null;if(value.length()>max)throw bad("筛选条件过长");return value.trim();} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java new file mode 100644 index 000000000..7648aa944 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java @@ -0,0 +1,110 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 运营信息和分类照片只写本页附加版本,绝不改主单版本或发布费用。 */ +@Service +public class OwnedBusinessRecordService { + private final OwnedBusinessRepository repository; + private final OwnedBusinessQueryService queries; + private final OwnedBusinessProjection projection; + private final OwnedBusinessCommandService commands; + private final OwnedBusinessSupport codec; + /** 业务规则经Repository读写,命令与历史一起提交。 */ + public OwnedBusinessRecordService(OwnedBusinessRepository repository,OwnedBusinessQueryService queries, + OwnedBusinessProjection projection,OwnedBusinessCommandService commands,OwnedBusinessSupport codec) { + this.repository=repository;this.queries=queries;this.projection=projection;this.commands=commands;this.codec=codec; + } + /** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail save(Actor actor,String number,SaveOperations request) { + codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失"); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()); + var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);} + if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长"); + var ops=normalize(source,request.operations());var row=lock(source,actor); + previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);} + requireVersion(row,version); + row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount())); + row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km())); + row.setArrivalsJson(codec.write(ops.arrivals())); + return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason()); + } + /** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) { + codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失"); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number); + Object identity=new PhotoBinding(slot,request); + var old=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);} + if(slot!=verified.slot() || !Objects.equals(slot.category(),verified.category()) || !Objects.equals(request.fileId(),verified.fileId()))throw bad("照片不属于本框"); + var row=lock(source,actor);old=commands.existing(source.getId(),request.idempotencyKey(),actor); + if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);} + requireVersion(row,version); + var photos=new EnumMap(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p); + photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(), + verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); + row.setPhotosJson(codec.write(List.copyOf(photos.values()))); + return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,"保存"+slot.name()+"照片"); + } + /** 照片框也参与摘要,不能换URL框位复用原键。 */ + public record PhotoBinding(PhotoSlot slot,BindPhoto request) {} + /** 先校验所有目的地及数值,再进入附加行写事务。 */ + private Operations normalize(Source source,Operations ops) { + Money money=null; + if(ops.money()!=null) { + if(!Set.of("CNY","USD").contains(ops.money().currency()==null?"":ops.money().currency()))throw bad("币种须为CNY或USD"); + String amount=codec.decimal(ops.money().amount(),16,2);if(amount==null)throw bad("金额与币种必须同时登记"); + money=new Money(ops.money().currency(),amount); + } + if(ops.arrivals()==null)throw bad("目的地到达登记不完整"); + var destinations=new HashSet();for(var item:repository.cargo(List.of(source.getId())))destinations.add(projection.destinationKey(item)); + var seen=new HashSet();var arrivals=new ArrayList(); + for(var arrival:ops.arrivals()) { + if(arrival==null || arrival.destinationKey()==null || !destinations.contains(arrival.destinationKey()) || !seen.add(arrival.destinationKey()))throw bad("到达目的地重复或不属于本车"); + var at=codec.instant(arrival.actualArrivedAt());arrivals.add(new Arrival(arrival.destinationKey(),at==null?null:at.toString())); + } + arrivals.sort(Comparator.comparing(Arrival::destinationKey)); + return new Operations(money,codec.decimal(ops.actualDistanceMiles(),9,3), + codec.decimal(ops.fuelConsumptionLitresPer100Km(),9,3),List.copyOf(arrivals)); + } + /** 首次写建立0版本附加行,仅在成功事务中最终提交。 */ + private Record lock(Source source,Actor actor) { + var seed=new Record();seed.setId(IdWorker.getId());seed.setDispatchId(source.getId());seed.setCreatedBy(actor.operatorId()); + seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); + var row=repository.lock(seed);if(row==null)throw conflict("附加记录初始化失败");return row; + } + /** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */ + private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");} + /** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */ + private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) { + row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now())); + if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败"); + var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion()); + history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt()); + history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history); + var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result; + } + /** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */ + private Detail result(OwnedBusinessRows.Command command) { + if(!"SUCCEEDED".equals(command.getStatus()))throw conflict("原命令结果尚未明确,请按原请求恢复"); + return codec.read(command.getResultJson(),Detail.class); + } + /** null是未登记,不以0代替。 */ + private BigDecimal decimal(String value){return value==null?null:new BigDecimal(value);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java new file mode 100644 index 000000000..048c651df --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.JavaType; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ResponseStatusException; +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.*; +import java.util.*; + +/** 只处理本页格式、身份和JSON,不读写主流程。 */ +@Component +public class OwnedBusinessSupport { + private final ObjectMapper json; + /** 复用服务统一Jackson,不建立第二套日期或版本协议。 */ + public OwnedBusinessSupport(ObjectMapper json){this.json=json;} + /** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */ + public Actor actor(AuthenticatedUser user) { + if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务"); + String operator=user.userId()==null?user.username():user.userId().toString(); + if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号"); + String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName(); + return new Actor(operator,name,user.permissions()==null?Set.of():Set.copyOf(user.permissions())); + } + /** 写权限独立校验,直接Service调用也不能绕过。 */ + public void require(Actor actor,String permission) { + if(actor==null || actor.operatorId()==null || actor.permissions()==null || !actor.permissions().contains(permission)) + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"没有该业务操作权限"); + } + /** JSON只从本页记录和已确认排车事实读取,损坏不静默恢复空值。 */ + public T read(String value,Class type) { + if(value==null || value.isBlank())throw conflict("缺少可靠业务事实"); + try{return json.readValue(value,type);}catch(JsonProcessingException error){throw conflict("业务事实读取失败,请核对原记录");} + } + /** 集合记录与声明的元素类型共同校验。 */ + public List list(String value,Class type) { + JavaType listType=json.getTypeFactory().constructCollectionType(List.class,type); + try{return json.readValue(value,listType);}catch(Exception error){throw conflict("业务集合事实读取失败");} + } + /** 原请求和原结果完整持久化,序列化失败必须中止命令。 */ + public String write(Object value) { + try{return json.writeValueAsString(value);}catch(JsonProcessingException error){throw new IllegalStateException("业务记录序列化失败",error);} + } + /** 请求摘要不包含版本漂移后的当前数据。 */ + public String hash(Object value){return digest(write(value).getBytes(StandardCharsets.UTF_8));} + /** 文件内容摘要用于识别相同上传原请求,不保存图片进数据库。 */ + public String digest(byte[] bytes) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes));} + catch(Exception error){throw new IllegalStateException("无法计算业务摘要",error);} + } + /** 版本0合法,但缺失、负数和超长版本不得降级为0。 */ + public long version(String value) { + try{if(value==null || !value.matches("0|[1-9][0-9]*"))throw new IllegalArgumentException();return Long.parseLong(value);} + catch(Exception error){throw bad("请提交有效的原记录版本");} + } + /** 请求键在页面恢复期间保持稳定,不能用空键创建命令。 */ + public void key(String key){if(key==null || key.length()<8 || key.length()>128)throw bad("请求键长度须为8至128");} + /** 只接受带时区的真实时间,统一存UTC。 */ + public Instant instant(String value) { + try{return value==null?null:OffsetDateTime.parse(value).toInstant();} + catch(Exception error){throw bad("时间须包含明确时区");} + } + /** 关系库时间只表示UTC,不取运行机默认时区。 */ + public LocalDateTime utc(Instant value){return value==null?null:LocalDateTime.ofInstant(value,ZoneOffset.UTC);} + /** 展示原真实时间,不根据签收或预计时间填充。 */ + public String iso(LocalDateTime value){return value==null?null:value.toInstant(ZoneOffset.UTC).toString();} + /** 规范十进制输入,空值与明确零分别保存。 */ + public String decimal(String value,int integers,int scale) { + if(value==null)return null; + if(!value.matches("(0|[1-9][0-9]{0,"+(integers-1)+"})(\\.[0-9]{1,"+scale+"})?"))throw bad("数量或金额须为非负规范十进制"); + return new BigDecimal(value).setScale(scale).toPlainString(); + } + /** 用户输入错误保持项目业务错误约定。 */ + public static BizException bad(String message){return new BizException(ResultCode.BAD_REQUEST,message);} + /** 业务并发或来源事实不符不能返回模拟成功。 */ + public static BizException conflict(String message){return new BizException(ResultCode.CONFLICT,message);} + /** 不存在只使用业务提示,不泄露内部主键。 */ + public static BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前自有业务");} +} diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index a0ba74737..c672a0de6 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -4553,3 +4553,83 @@ CREATE TABLE IF NOT EXISTS ll_fleet_pickup_history ( KEY idx_pickup_history_source(customer_id,transfer_no,id), KEY idx_pickup_history_trip(trip_no,id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; + +-- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。 +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership ( + dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键', + resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL', + vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空', + vehicle_ownership VARCHAR(16) NOT NULL COMMENT '装车完成时车辆归属OWNED或DISPATCHED', + vehicle_record_version BIGINT NULL COMMENT '捕获归属时真实车辆档案版本', + ownership_captured_at DATETIME(3) NOT NULL COMMENT '归属捕获UTC时间', + loading_completion_hash VARCHAR(128) NOT NULL COMMENT '原排车装车确认事实摘要', + loading_completed_at DATETIME(3) NOT NULL COMMENT '原排车实际装车完成UTC时间', + PRIMARY KEY (dispatch_id), + KEY idx_owned_business_vehicle (vehicle_ownership,vehicle_id), + CONSTRAINT fk_owned_business_ownership_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_ownership CHECK (vehicle_ownership IN ('OWNED','DISPATCHED')), + CONSTRAINT ck_owned_business_resource_mode CHECK ( + (resource_mode='MASTER' AND vehicle_id IS NOT NULL AND vehicle_record_version IS NOT NULL AND vehicle_record_version>=0) + OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( + id BIGINT NOT NULL COMMENT '附加运营记录内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本', + currency VARCHAR(3) NULL COMMENT '整车运营金额币种CNY或USD,未登记为空', + total_amount DECIMAL(18,2) NULL COMMENT '人工登记整车总金额,不自动形成账务费用', + actual_distance_miles DECIMAL(12,3) NULL COMMENT '实际行驶英里数,未登记为空', + fuel_consumption_litres_per_100km DECIMAL(12,3) NULL COMMENT '人工登记油耗率,单位升每百公里', + arrivals_json JSON NOT NULL COMMENT '按原目的地键记录的实际到达UTC时间', + photos_json JSON NOT NULL COMMENT '七个照片框当前已绑定文件的分类快照', + created_by VARCHAR(64) NOT NULL COMMENT '首次登记操作人标识', + updated_by VARCHAR(64) NOT NULL COMMENT '最近登记操作人标识', + created_at DATETIME(3) NOT NULL COMMENT '首次登记UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '最近登记UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_record_dispatch (dispatch_id), + CONSTRAINT fk_owned_business_record_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0), + CONSTRAINT ck_owned_business_money CHECK ( + (currency IS NULL AND total_amount IS NULL) + OR (currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)), + CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0), + CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command ( + id BIGINT NOT NULL COMMENT '附加运营命令内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识', + idempotency_key VARCHAR(128) COLLATE utf8mb4_bin NOT NULL COMMENT '同一原排车内唯一请求键,不按操作者分组', + action VARCHAR(32) NOT NULL COMMENT '运营登记或照片上传绑定动作', + expected_version BIGINT NULL COMMENT '原请求附加版本,纯文件上传可为空', + request_hash CHAR(64) NOT NULL COMMENT '原操作者动作版本与完整请求摘要', + request_json JSON NOT NULL COMMENT '不含文件内容或凭据的完整原请求', + status VARCHAR(24) NOT NULL COMMENT 'PENDING或SUCCEEDED或REJECTED或UNKNOWN_RESULT', + result_json JSON NULL COMMENT '原命令已知成功结果或明确拒绝事实', + error_code VARCHAR(64) NULL COMMENT '明确拒绝或未知结果诊断码', + created_at DATETIME(3) NOT NULL COMMENT '命令意图创建UTC时间', + updated_at DATETIME(3) NOT NULL COMMENT '命令结果更新UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_command_key (dispatch_id,idempotency_key), + CONSTRAINT fk_owned_business_command_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_command_version CHECK (expected_version IS NULL OR expected_version>=0), + CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果'; + +CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( + id BIGINT NOT NULL COMMENT '附加运营历史内部主键', + dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', + record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本', + action VARCHAR(32) NOT NULL COMMENT '运营登记或照片绑定动作', + operator_id VARCHAR(64) NOT NULL COMMENT '真实操作人标识', + reason VARCHAR(1000) NULL COMMENT '人工填写的登记或更正说明', + snapshot_json JSON NOT NULL COMMENT '操作后附加运营信息与分类照片不可变快照', + occurred_at DATETIME(3) NOT NULL COMMENT '操作UTC时间', + PRIMARY KEY (id), + UNIQUE KEY uk_owned_business_history_version (dispatch_id,record_version), + CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), + CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml new file mode 100644 index 000000000..2b2b9372d --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -0,0 +1,975 @@ +openapi: "3.0.3" +info: + title: "原排车自有车辆运营视图" + version: "1.0.0" + description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。" +security: + - + bearerAuth: [] +paths: + "/api/platform/v1/last-leg/fleet/owned-business": + get: + operationId: "listOwnedBusiness" + summary: "自有车辆装车后视图及同筛选四项统计" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/ListResult" + nullable: true + parameters: + - + name: "page" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + default: 1 + - + name: "size" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + maximum: 200 + default: 20 + - + name: "plannedDepartureFrom" + in: "query" + required: false + schema: + type: "string" + format: "date-time" + - + name: "plannedDepartureTo" + in: "query" + required: false + schema: + type: "string" + format: "date-time" + - + name: "keyword" + in: "query" + required: false + schema: + type: "string" + maxLength: 128 + - + name: "stage" + in: "query" + required: false + schema: + "$ref": "#/components/schemas/Stage" + - + name: "warehouseId" + in: "query" + required: false + schema: + type: "string" + maxLength: 64 + x-filter-contract: "plannedDepartureAt [from,to),统计同全部过滤、不带分页;无归属快照不降级按当前主档推断。" + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + get: + operationId: "getOwnedBusiness" + summary: "组合原排车详情及附加运营信息" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Detail" + nullable: true + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/history": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + get: + operationId: "historyOwnedBusiness" + summary: "原排车及附加真实流水聚合分页" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/HistoryPage" + nullable: true + parameters: + - + name: "page" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + default: 1 + - + name: "size" + in: "query" + required: false + schema: + type: "integer" + minimum: 1 + maximum: 200 + default: 20 + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/operations": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + put: + operationId: "saveOwnedBusinessOperations" + summary: "按附加版本登记、更正或清空运营信息" + description: "权限:platform:fleet:owned-business:edit。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:edit" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Detail" + nullable: true + requestBody: + required: true + content: + "application/json": + schema: + "$ref": "#/components/schemas/SaveOperations" + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/uploads": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + - + name: "slot" + in: "path" + required: true + schema: + "$ref": "#/components/schemas/PhotoSlot" + post: + operationId: "uploadOwnedBusinessPhoto" + summary: "逐框准备照片,尚未保存绑定" + description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:upload" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Photo" + nullable: true + parameters: + - + name: "idempotencyKey" + in: "query" + required: true + schema: + type: "string" + minLength: 8 + maxLength: 128 + requestBody: + required: true + content: + "multipart/form-data": + schema: + type: "object" + required: + - "file" + properties: + file: + type: "string" + format: "binary" + description: "真实JPG/PNG,非空,最大10MiB;与独立绑定命令使用不同请求键。" + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + - + name: "slot" + in: "path" + required: true + schema: + "$ref": "#/components/schemas/PhotoSlot" + put: + operationId: "bindOwnedBusinessPhoto" + summary: "保存原上传fileId;失败保留原键恢复,不重新上传" + description: "权限:platform:fleet:owned-business:upload。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:upload" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Detail" + nullable: true + requestBody: + required: true + content: + "application/json": + schema: + "$ref": "#/components/schemas/BindPhoto" + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/photos/{slot}/files/{fileId}/content": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + - + name: "slot" + in: "path" + required: true + schema: + "$ref": "#/components/schemas/PhotoSlot" + - + name: "fileId" + in: "path" + required: true + schema: + type: "string" + pattern: "^[1-9][0-9]*$" + get: + operationId: "readOwnedBusinessPhoto" + summary: "授权读取当前或原历史绑定图片" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "仅当前或指定历史引用的图片字节,Cache-Control:no-store,X-Content-Type-Options:nosniff" + content: + "image/jpeg": + schema: + type: "string" + format: "binary" + "image/png": + schema: + type: "string" + format: "binary" + parameters: + - + name: "revisionVersion" + in: "query" + required: false + schema: + type: "integer" + format: "int64" + minimum: 1 + "/api/platform/v1/last-leg/fleet/owned-business/{dispatchNo}/commands/{idempotencyKey}": + parameters: + - + name: "dispatchNo" + in: "path" + required: true + schema: + type: "string" + maxLength: 128 + - + name: "idempotencyKey" + in: "path" + required: true + schema: + type: "string" + minLength: 8 + maxLength: 128 + get: + operationId: "recoverOwnedBusinessCommand" + summary: "恢复本操作者原命令,未知结果不得换键重发" + description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" + x-permission-code: "platform:fleet:owned-business:list" + responses: + 200: + description: "统一ApiResponse:成功code=200;业务错误保留A0400/A0403/A0404/A0409及诊断requestId/traceId。" + content: + "application/json": + schema: + type: "object" + properties: + code: + type: "string" + message: + type: "string" + data: + allOf: + - + "$ref": "#/components/schemas/Command" + nullable: true +components: + securitySchemes: + bearerAuth: + type: "http" + scheme: "bearer" + bearerFormat: "JWT" + schemas: + Stage: + type: "string" + enum: + - "AFTER_LOADING" + - "DELIVERING" + - "DELIVERED" + PhotoSlot: + type: "string" + enum: + - "LOADING_SCENE" + - "LOADING_LIST" + - "PRE_DEPARTURE_CHECK" + - "DRIVER_LICENSE" + - "RECEIPT" + - "FUEL_START" + - "FUEL_END" + Money: + type: "object" + x-java-record: "OwnedBusinessModels.Money" + required: + - "currency" + - "amount" + properties: + currency: + type: "string" + enum: + - "CNY" + - "USD" + amount: + type: "string" + pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$" + Arrival: + type: "object" + x-java-record: "OwnedBusinessModels.Arrival" + required: + - "destinationKey" + - "actualArrivedAt" + properties: + destinationKey: + type: "string" + minLength: 1 + maxLength: 256 + actualArrivedAt: + type: "string" + nullable: true + format: "date-time" + Operations: + type: "object" + x-java-record: "OwnedBusinessModels.Operations" + required: + - "money" + - "actualDistanceMiles" + - "fuelConsumptionLitresPer100Km" + - "arrivals" + properties: + money: + allOf: + - + "$ref": "#/components/schemas/Money" + nullable: true + actualDistanceMiles: + type: "string" + nullable: true + pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$" + fuelConsumptionLitresPer100Km: + type: "string" + nullable: true + pattern: "^(0|[1-9][0-9]{0,8})(\\.[0-9]{1,3})?$" + arrivals: + type: "array" + items: + "$ref": "#/components/schemas/Arrival" + SaveOperations: + type: "object" + x-java-record: "OwnedBusinessModels.SaveOperations" + required: + - "recordVersion" + - "idempotencyKey" + - "operations" + properties: + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + idempotencyKey: + type: "string" + minLength: 8 + maxLength: 128 + operations: + "$ref": "#/components/schemas/Operations" + reason: + type: "string" + nullable: true + maxLength: 1000 + BindPhoto: + type: "object" + x-java-record: "OwnedBusinessModels.BindPhoto" + required: + - "recordVersion" + - "idempotencyKey" + - "fileId" + properties: + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + idempotencyKey: + type: "string" + minLength: 8 + maxLength: 128 + fileId: + type: "string" + pattern: "^[1-9][0-9]*$" + Gate: + type: "object" + x-java-record: "OwnedBusinessModels.Gate" + required: + - "allowed" + - "blockReason" + properties: + allowed: + type: "boolean" + blockReason: + type: "string" + nullable: true + Actions: + type: "object" + x-java-record: "OwnedBusinessModels.Actions" + required: + - "edit" + - "upload" + properties: + edit: + "$ref": "#/components/schemas/Gate" + upload: + "$ref": "#/components/schemas/Gate" + Origin: + type: "object" + x-java-record: "OwnedBusinessModels.Origin" + required: + - "warehouseId" + - "warehouseCode" + - "warehouseName" + properties: + warehouseId: + type: "string" + warehouseCode: + type: "string" + warehouseName: + type: "string" + Vehicle: + type: "object" + x-java-record: "OwnedBusinessModels.Vehicle" + required: + - "vehicleId" + - "plate" + - "ownership" + properties: + vehicleId: + type: "string" + plate: + type: "string" + ownership: + type: "string" + enum: + - "OWNED" + Driver: + type: "object" + x-java-record: "OwnedBusinessModels.Driver" + required: + - "name" + - "phone" + properties: + name: + type: "string" + nullable: true + phone: + type: "string" + nullable: true + Appointment: + type: "object" + x-java-record: "OwnedBusinessModels.Appointment" + required: + - "appointmentNo" + - "confirmationNo" + - "scheduledAt" + - "allocatedPackages" + properties: + appointmentNo: + type: "string" + confirmationNo: + type: "string" + nullable: true + scheduledAt: + type: "string" + nullable: true + format: "date-time" + allocatedPackages: + type: "string" + pattern: "^[1-9][0-9]*$" + Destination: + type: "object" + x-java-record: "OwnedBusinessModels.Destination" + required: + - "destinationKey" + - "warehouseCode" + - "warehouseName" + - "address" + - "appointments" + - "actualArrivedAt" + properties: + destinationKey: + type: "string" + warehouseCode: + type: "string" + nullable: true + warehouseName: + type: "string" + nullable: true + address: + type: "string" + nullable: true + appointments: + type: "array" + items: + "$ref": "#/components/schemas/Appointment" + actualArrivedAt: + type: "string" + nullable: true + format: "date-time" + Photo: + type: "object" + x-java-record: "OwnedBusinessModels.Photo" + required: + - "slot" + - "category" + - "fileId" + - "fileName" + - "contentType" + - "sizeBytes" + - "uploadedAt" + - "savedAt" + properties: + slot: + "$ref": "#/components/schemas/PhotoSlot" + category: + type: "string" + enum: + - "LOADING" + - "FUEL" + fileId: + type: "string" + fileName: + type: "string" + contentType: + type: "string" + enum: + - "image/jpeg" + - "image/png" + sizeBytes: + type: "string" + pattern: "^[1-9][0-9]*$" + uploadedAt: + type: "string" + savedAt: + type: "string" + nullable: true + format: "date-time" + description: "上传回执savedAt为空;绑定成功才填写savedAt。File类型分别为OVERSEAS_DISPATCH_LOADING_PHOTO和OVERSEAS_DISPATCH_FUEL_PHOTO,平台0、原dispatch内部ID,最多10MiB。" + PhotoSummary: + type: "object" + x-java-record: "OwnedBusinessModels.PhotoSummary" + required: + - "loadingCount" + - "fuelCount" + properties: + loadingCount: + type: "integer" + minimum: 0 + fuelCount: + type: "integer" + minimum: 0 + Row: + type: "object" + x-java-record: "OwnedBusinessModels.Row" + required: + - "dispatchNo" + - "stage" + - "plannedDepartureAt" + - "loadingCompletedAt" + - "departedAt" + - "deliveryCompletedAt" + - "origin" + - "vehicle" + - "driver" + - "destinations" + - "actualPalletCount" + - "operations" + - "unitPrice" + - "recordVersion" + - "dispatchRecordVersion" + - "photoSummary" + - "actions" + - "sourceIssues" + properties: + dispatchNo: + type: "string" + stage: + "$ref": "#/components/schemas/Stage" + plannedDepartureAt: + type: "string" + format: "date-time" + loadingCompletedAt: + type: "string" + format: "date-time" + departedAt: + type: "string" + nullable: true + format: "date-time" + deliveryCompletedAt: + type: "string" + nullable: true + format: "date-time" + origin: + "$ref": "#/components/schemas/Origin" + vehicle: + "$ref": "#/components/schemas/Vehicle" + driver: + "$ref": "#/components/schemas/Driver" + destinations: + type: "array" + items: + "$ref": "#/components/schemas/Destination" + actualPalletCount: + type: "integer" + minimum: 0 + nullable: true + operations: + "$ref": "#/components/schemas/Operations" + unitPrice: + type: "string" + nullable: true + pattern: "^(0|[1-9][0-9]{0,15})(\\.[0-9]{1,2})?$" + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + dispatchRecordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + photoSummary: + "$ref": "#/components/schemas/PhotoSummary" + actions: + "$ref": "#/components/schemas/Actions" + sourceIssues: + type: "array" + items: + type: "string" + Summary: + type: "object" + x-java-record: "OwnedBusinessModels.Summary" + required: + - "dispatchCount" + - "afterLoadingCount" + - "deliveringCount" + - "deliveredCount" + properties: + dispatchCount: + type: "integer" + minimum: 0 + afterLoadingCount: + type: "integer" + minimum: 0 + deliveringCount: + type: "integer" + minimum: 0 + deliveredCount: + type: "integer" + minimum: 0 + ListResult: + type: "object" + x-java-record: "OwnedBusinessModels.ListResult" + required: + - "records" + - "page" + - "size" + - "total" + - "summary" + properties: + records: + type: "array" + items: + "$ref": "#/components/schemas/Row" + page: + type: "integer" + minimum: 1 + size: + type: "integer" + minimum: 1 + maximum: 200 + total: + type: "integer" + minimum: 0 + summary: + "$ref": "#/components/schemas/Summary" + Cargo: + type: "object" + x-java-record: "OwnedBusinessModels.Cargo" + required: + - "sourceKey" + - "customerId" + - "customerName" + - "orderNo" + - "inboundCode" + - "palletNo" + - "destinationKey" + - "packages" + properties: + sourceKey: + type: "string" + customerId: + type: "string" + customerName: + type: "string" + nullable: true + orderNo: + type: "string" + nullable: true + inboundCode: + type: "string" + nullable: true + palletNo: + type: "string" + destinationKey: + type: "string" + packages: + type: "string" + Detail: + type: "object" + x-java-record: "OwnedBusinessModels.Detail" + required: + - "row" + - "dispatch" + - "cargo" + - "photos" + properties: + row: + "$ref": "#/components/schemas/Row" + dispatch: + "$ref": "./overseas-business-v1.yaml#/components/schemas/DispatchDetail" + cargo: + type: "array" + items: + "$ref": "#/components/schemas/Cargo" + photos: + type: "array" + items: + "$ref": "#/components/schemas/Photo" + History: + type: "object" + x-java-record: "OwnedBusinessModels.History" + required: + - "eventSource" + - "recordVersion" + - "dispatchRecordVersion" + - "action" + - "operatorName" + - "occurredAt" + - "reason" + - "operations" + - "photos" + properties: + eventSource: + type: "string" + enum: + - "DISPATCH" + - "OPERATIONS" + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + nullable: true + dispatchRecordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + nullable: true + action: + type: "string" + operatorName: + type: "string" + nullable: true + occurredAt: + type: "string" + format: "date-time" + reason: + type: "string" + nullable: true + operations: + allOf: + - + "$ref": "#/components/schemas/Operations" + nullable: true + photos: + type: "array" + items: + "$ref": "#/components/schemas/Photo" + description: "UNION ALL读取原排车真实流水与附加流水,一次排序分页;DISPATCH事件recordVersion=null、operations=null、photos=[];OPERATIONS事件dispatchRecordVersion=null。" + HistoryPage: + type: "object" + x-java-record: "OwnedBusinessModels.HistoryPage" + required: + - "records" + - "page" + - "size" + - "total" + properties: + records: + type: "array" + items: + "$ref": "#/components/schemas/History" + page: + type: "integer" + minimum: 1 + size: + type: "integer" + minimum: 1 + maximum: 200 + total: + type: "integer" + minimum: 0 + Command: + type: "object" + x-java-record: "OwnedBusinessModels.Command" + required: + - "dispatchNo" + - "action" + - "idempotencyKey" + - "state" + - "recordVersion" + - "replayed" + - "result" + - "errorCode" + - "retryAllowed" + properties: + dispatchNo: + type: "string" + action: + type: "string" + enum: + - "SAVE_OPERATIONS" + - "UPLOAD_PHOTO" + - "BIND_PHOTO" + idempotencyKey: + type: "string" + state: + type: "string" + enum: + - "SUCCEEDED" + - "PENDING" + - "UNKNOWN_RESULT" + - "REJECTED" + recordVersion: + type: "string" + pattern: "^(0|[1-9][0-9]*)$" + nullable: true + replayed: + type: "boolean" + result: + nullable: true + oneOf: + - + "$ref": "#/components/schemas/Detail" + - + "$ref": "#/components/schemas/Photo" + errorCode: + type: "string" + nullable: true + retryAllowed: + type: "boolean" + enum: + - false diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 356a5c095..d74c6ed17 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -268,12 +268,25 @@ public class DispatchAggregatePersistenceTest { var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1, p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList(); var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对"); + // 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。 + org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing() + .when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class), + anyString(),anyString(),any(java.time.Instant.class)); + assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request))) + .isInstanceOf(IllegalStateException.class).hasMessage("ownership-capture-failed"); + assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); + assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(approved.recordVersion()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero(); var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class); assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result()); assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); + org.mockito.Mockito.verify(f.ownedBusinessOwnership,org.mockito.Mockito.times(2)).capture(anyLong(), + eq(context.resources().vehicle().vehicleId()),eq(context.resources().vehicle().mode().name()), + anyString(),any(java.time.Instant.class)); + org.mockito.Mockito.verifyNoMoreInteractions(f.ownedBusinessOwnership); } /** 合计相等但某项漏拣或归属缺失时,公共端口不能报告已核平。 */ @@ -444,6 +457,8 @@ public class DispatchAggregatePersistenceTest { /** 实际MyBatis和Spring本机内存事务;不使用返回成功的仓储替身。 */ public static final class Fixture { + final com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture ownedBusinessOwnership= + mock(com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture.class); public final JdbcTemplate db; public final TransactionTemplate tx; public final DispatchAggregateService service; public final DataSourceTransactionManager transactions;public final ObjectMapper json;public final SqlSessionTemplate session; public final DispatchAggregateRepository repository;public final DispatchExecutionRepository executionReads;public final FleetTripExecutionService fleet; @@ -522,6 +537,7 @@ public class DispatchAggregatePersistenceTest { new DispatchSourceRepository(session.getMapper(DispatchCandidateMapper.class),session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)),rules,fleet, new DispatchCommandLedger(new DispatchCommandRepository(session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(json)),new DispatchCommandHasher(json),json); org.springframework.test.util.ReflectionTestUtils.setField(service,"evidence",mock(com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"ownedBusinessOwnership",ownedBusinessOwnership); org.springframework.test.util.ReflectionTestUtils.setField(gateway,"workflow",service); org.springframework.test.util.ReflectionTestUtils.setField(service,"appointments",new DispatchAppointmentAllocations(mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class))); executionReads=new DispatchExecutionRepository(session.getMapper(DispatchExecutionReadMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java new file mode 100644 index 000000000..b7337f776 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessCommandTest.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.util.Set; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 全车请求键唯一,原操作者、action、版本、载荷缺一不可。 */ +class OwnedBusinessCommandTest { + /** 已有键不能按另一个operator维度重新执行或读取结果。 */ + @Test void anotherActorCannotBorrowOriginalKey() { + var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec()); + var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED"); + when(repo.command(1,"original-key")).thenReturn(row); + var denied=assertThrows(ResponseStatusException.class,()->service.existing(1,"original-key",new Actor("other","其他测试账号",Set.of()))); + assertEquals(403,denied.getStatusCode().value());verify(repo,never()).command(any()); + } + /** 同一请求原版本0合法,改载荷或改版本不得借用原成功。 */ + @Test void fullOriginalIdentityIsRequiredForReplay() { + var service=new OwnedBusinessCommandService(mock(OwnedBusinessRepository.class),OwnedBusinessFixtures.codec()); + var row=service.create(1,OwnedBusinessFixtures.actor(),"original-key","SAVE_OPERATIONS",0L,"original","SUCCEEDED"); + assertDoesNotThrow(()->service.same(row,"SAVE_OPERATIONS",0L,"original")); + assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",1L,"original")); + assertThrows(BizException.class,()->service.same(row,"SAVE_OPERATIONS",0L,"changed")); + assertThrows(BizException.class,()->service.same(row,"BIND_PHOTO",0L,"original")); + } + /** 未知上传只有原状态,不创建第二个File调用意图。 */ + @Test void unknownUploadReservationDoesNotBecomeNewWork() { + var repo=mock(OwnedBusinessRepository.class);var service=new OwnedBusinessCommandService(repo,OwnedBusinessFixtures.codec()); + var row=service.create(1,OwnedBusinessFixtures.actor(),"upload-key","UPLOAD_PHOTO",null,"original","UNKNOWN_RESULT"); + when(repo.command(1,"upload-key")).thenReturn(row); + var reservation=service.reserveUpload(1,OwnedBusinessFixtures.actor(),"upload-key","original"); + assertFalse(reservation.created());assertEquals("UNKNOWN_RESULT",reservation.command().getStatus());verify(repo,never()).command(any()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java new file mode 100644 index 000000000..90b749a63 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFileGatewayTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import static org.junit.jupiter.api.Assertions.*; + +/** 本域消费者的文件归属及格式回归,不重复File提供者全量测试。 */ +class OwnedBusinessFileGatewayTest { + /** 图片文本伪装、PDF及空内容均须在File调用前拒绝。 */ + @Test void invalidImageSignaturesAndMimeAreRejected() { + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.png","image/png","not-image".getBytes()))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.pdf","application/pdf","%PDF".getBytes()))); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.validate(new OwnedBusinessFileGateway.Upload("x.jpg","image/jpeg",new byte[0]))); + } + /** 同一原dispatch内部ID、平台0与分类都匹配才接受元数据。 */ + @Test void metadataCannotCrossCustomerDispatchOrCategory() { + var good=metadata(0L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO"); + assertEquals(9,HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.LOADING_SCENE,9L).fileId()); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(101L,"1","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L)); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",metadata(0L,"2","OVERSEAS_DISPATCH_LOADING_PHOTO")),1,PhotoSlot.LOADING_SCENE,9L)); + assertThrows(BizException.class,()->HttpOwnedBusinessFileGateway.verified(new HttpOwnedBusinessFileGateway.Envelope("200",good),1,PhotoSlot.FUEL_START,9L)); + } + /** File所有权使用原dispatch而非trip或客户,照片框不能改变类型。 */ + private HttpOwnedBusinessFileGateway.Metadata metadata(Long tenant,String businessId,String type) { + return new HttpOwnedBusinessFileGateway.Metadata(9L,tenant,type,businessId,"actual.png","image/png",20L,LocalDateTime.parse("2026-10-20T07:30:00")); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java new file mode 100644 index 000000000..3653e0e87 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; + +/** 仅本地测试夹具,不操作业务环境或生成运行样本。 */ +final class OwnedBusinessFixtures { + /** 夹具容器不允许实例化。 */ + private OwnedBusinessFixtures() {} + /** Jackson包含实际时间类型,避免把日期当假字符串。 */ + static OwnedBusinessSupport codec(){return new OwnedBusinessSupport(new ObjectMapper().findAndRegisterModules());} + /** 平台列表、编辑、上传权限保持独立。 */ + static Actor actor(){return new Actor("operator-1","TEST操作员",Set.of(LIST,EDIT,UPLOAD));} + /** 装车完成但未发车,证明不依赖DEPART历史。 */ + static Source source() { + var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setTripId(2L);row.setStatus("ACTIVE");row.setStage("AFTER_LOADING"); + row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓"); + row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00")); + row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt()); + row.setOwnedResourceMode("MASTER");row.setOwnedVehicleOwnership("OWNED");row.setOwnedVehicleId(7L);row.setOwnedVehicleRecordVersion(3L); + row.setResourcesJson(""" + {"vehicle":{"mode":"MASTER","vehicleId":7,"plate":"TEST-OWN-7","carrierName":null,"vehicleStatus":"IN_SERVICE", + "capacity":{"palletSpaces":10,"maxLoadKg":1000,"maxVolumeCbm":20},"evidenceFileIds":[]}, + "driver":{"mode":"MASTER","driverId":8,"name":"测试司机","phone":null,"evidence":null}} + """); + row.setLoadingConfirmationJson(""" + {"manifestHash":"actual-manifest","pallets":[{"warehousePalletId":31,"palletSpaces":3,"weightKg":100,"volumeCbm":2,"deliveryIndex":1}], + "operatorId":"picker","confirmedAt":"2026-10-20T07:30:00Z"} + """);return row; + } + /** 两个客户逻辑来源共享一个真实物理托盘,不合并客户归属。 */ + static List cargo() { + var first=item(11L,"101","IN-A");var second=item(12L,"102","IN-B");return List.of(first,second); + } + /** 来源事实的目的地键与正式排车提供者一致。 */ + private static DispatchItem item(long id,String customer,String inbound) { + var i=new DispatchItem();i.setId(id);i.setDispatchOrderId(1L);i.setCustomerId(customer);i.setCustomerName("测试客户"+customer); + i.setSourceKey("SOURCE:"+inbound);i.setOrderNo("ORDER-"+inbound);i.setInboundCode(inbound);i.setWarehousePalletId(31L);i.setPalletNo("PAL-31"); + i.setDestinationType("FBA");i.setDestinationWarehouseCode("ONT8");i.setDestinationWarehouseName("ONT8仓");i.setPackageCount(1);return i; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java new file mode 100644 index 000000000..f004097ed --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessOwnershipTest.java @@ -0,0 +1,57 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessOwnershipService; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Ownership; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import java.time.*; +import java.util.Optional; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 归属来自真实装车时车辆,不从GET和当前主档补造历史。 */ +class OwnedBusinessOwnershipTest { + /** 自有车辆无论承运何种货源,都按真实主档归属捕获。 */ + @Test void capturesRealOwnedVehicleAndOriginalLoadTime() { + var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class); + when(vehicles.findById(7)).thenReturn(Optional.of(vehicle("OWNED",3))); + var service=new OwnedBusinessOwnershipService(repo,vehicles);var at=Instant.parse("2026-10-20T07:30:00Z"); + service.capture(1,7L,"MASTER","load-1",at); + var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture()); + assertEquals("OWNED",saved.getValue().getVehicleOwnership());assertEquals(3L,saved.getValue().getVehicleRecordVersion()); + assertEquals(LocalDateTime.parse("2026-10-20T07:30:00"),saved.getValue().getLoadingCompletedAt()); + verify(vehicles,never()).lockById(anyLong()); + } + /** 临时外配原模式EXTERNAL原样保存,不伪造车辆档案。 */ + @Test void externalHasNoMasterIdOrVersion() { + var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class); + new OwnedBusinessOwnershipService(repo,vehicles).capture(1,null,"EXTERNAL","load",Instant.parse("2026-10-20T07:30:00Z")); + var saved=ArgumentCaptor.forClass(Ownership.class);verify(repo).capture(saved.capture()); + assertEquals("EXTERNAL",saved.getValue().getResourceMode());assertEquals("DISPATCHED",saved.getValue().getVehicleOwnership()); + assertNull(saved.getValue().getVehicleRecordVersion());verifyNoInteractions(vehicles); + } + /** 重放沿原证据,不读取后来可能变化的车辆归属。 */ + @Test void matchingCaptureReplaysWithoutReadingCurrentVehicle() { + var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class);var owned=new Ownership(); + owned.setDispatchId(1L);owned.setVehicleId(7L);owned.setResourceMode("MASTER");owned.setVehicleOwnership("OWNED"); + owned.setLoadingCompletionHash("load");owned.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00")); + when(repo.ownership(1)).thenReturn(owned); + new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z")); + verifyNoInteractions(vehicles);verify(repo,never()).capture(any()); + assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","different",Instant.parse("2026-10-20T07:30:00Z"))); + } + /** MASTER没有真实车辆时阻断原装车事务,不退回外配。 */ + @Test void rejectsMissingVehicleInsteadOfInventingOwnership() { + var repo=mock(OwnedBusinessRepository.class);var vehicles=mock(VehicleRepository.class); + when(vehicles.findById(7)).thenReturn(Optional.empty()); + assertThrows(BizException.class,()->new OwnedBusinessOwnershipService(repo,vehicles).capture(1,7L,"MASTER","load",Instant.parse("2026-10-20T07:30:00Z"))); + verify(repo,never()).capture(any()); + } + /** 真实车辆快照由端口返回,测试不依赖车辆存储实现。 */ + private VehicleRepository.VehicleSnapshot vehicle(String ownership,long version) { + return new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US",ownership,"IN_SERVICE",null,version,null,LocalDateTime.parse("2026-10-20T07:30:00"),10,null,null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java new file mode 100644 index 000000000..565a71c45 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java @@ -0,0 +1,194 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.common.core.exception.BizException; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.nio.file.*; +import java.util.*; +import java.util.regex.Pattern; +import java.time.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 本机H2/MyBatis真实四表回归,不调用环境或复用外部数据库。 */ +class OwnedBusinessPersistenceTest { + JdbcTemplate jdbc; + OwnedBusinessRepository repository; + OwnedBusinessSupport codec; + OwnedBusinessProjection projection; + OwnedBusinessRecordService service; + OwnedBusinessQueryService queries; + OwnedBusinessCommandService commands; + DataSourceTransactionManager manager; + /** 精确读取根新增4表基线,测试只适配H2方言,不重写业务列或约束。 */ + @BeforeEach void setup() throws Exception { + var ds=new DriverManagerDataSource("jdbc:h2:mem:owned-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=5000","sa",""); + jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds); + jdbc.execute(""" + CREATE TABLE ll_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(128),trip_id BIGINT,status VARCHAR(32), + stage VARCHAR(32),record_version BIGINT,planned_departure_at DATETIME(3),loading_completed_at DATETIME(3), + loading_completion_hash VARCHAR(128),loading_confirmation_json LONGTEXT,resources_json LONGTEXT, + vehicle_plate VARCHAR(64),overseas_warehouse_id VARCHAR(64),overseas_warehouse_code VARCHAR(64),overseas_warehouse_name VARCHAR(128)) + """); + jdbc.execute(""" + CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64), + customer_name VARCHAR(128),source_key VARCHAR(128),order_no VARCHAR(128),inbound_code VARCHAR(128), + pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,destination_type VARCHAR(32), + destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128), + delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT) + """); + jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,customer_id VARCHAR(64),batch_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)"); + jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(64),confirmation_no VARCHAR(64),appointment_start_utc DATETIME(3))"); + jdbc.execute(""" + CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(32), + operator_id VARCHAR(64),reason VARCHAR(1000),snapshot_json LONGTEXT,occurred_at DATETIME(3)) + """); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String suffix:List.of("ownership","record","command","history")) { + var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema); + assertTrue(matcher.find(),"缺少根维护的"+suffix+"表"); + String ddl=matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";"); + jdbc.execute(ddl); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.addMapper(OwnedBusinessMapper.class);config.addMapper(OwnedBusinessQueryMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var session=new SqlSessionTemplate(Objects.requireNonNull(factory.getObject())); + repository=new OwnedBusinessRepository(session.getMapper(OwnedBusinessMapper.class),session.getMapper(OwnedBusinessQueryMapper.class)); + codec=OwnedBusinessFixtures.codec();projection=new OwnedBusinessProjection(codec); + commands=new OwnedBusinessCommandService(repository,codec);queries=mock(OwnedBusinessQueryService.class); + var source=OwnedBusinessFixtures.source(); + when(queries.requireOwned("DS-OWN-ONE")).thenReturn(source); + when(queries.detailForWrite(any(),any())).thenAnswer(call->new Detail( + projection.row(source,repository.record(1),repository.cargo(List.of(1L)),repository.batches(List.of(1L)),call.getArgument(0)), + null,List.of(),projection.photos(repository.record(1)))); + service=tx(new OwnedBusinessRecordService(repository,queries,projection,commands,codec)); + jdbc.update(""" + INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,status,stage,record_version,planned_departure_at, + loading_completed_at,loading_completion_hash,loading_confirmation_json,resources_json,vehicle_plate, + overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name) + VALUES(1,'DS-OWN-ONE',2,'ACTIVE','AFTER_LOADING',8,?,?,?,?,?,'TEST-OWN-7','9','WH-9','测试仓') + """,source.getPlannedDepartureAt(),source.getLoadingCompletedAt(),source.getLoadingCompletionHash(),source.getLoadingConfirmationJson(),source.getResourcesJson()); + for(var item:OwnedBusinessFixtures.cargo())jdbc.update(""" + INSERT INTO ll_dispatch_item(id,dispatch_order_id,customer_id,customer_name,source_key,order_no,inbound_code, + pallet_no,warehouse_pallet_id,destination_type,destination_warehouse_code,destination_warehouse_name,package_count) + VALUES(?,1,?,?,?,?,?,?,31,'FBA','ONT8','ONT8仓',1) + """,item.getId(),item.getCustomerId(),item.getCustomerName(),item.getSourceKey(),item.getOrderNo(),item.getInboundCode(),item.getPalletNo()); + jdbc.update("INSERT INTO ll_dispatch_history VALUES(99,1,8,'LOADING_COMPLETE','picker',NULL,'{}',?)",source.getLoadingCompletedAt()); + var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(1L);ownership.setVehicleId(7L); + ownership.setResourceMode("MASTER");ownership.setVehicleOwnership("OWNED");ownership.setVehicleRecordVersion(3L); + ownership.setOwnershipCapturedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletedAt(source.getLoadingCompletedAt());ownership.setLoadingCompletionHash("actual-load"); + repository.capture(ownership); + } + /** 明确NULL更新真实落库,原主单版本、货物和两个客户完全不改。 */ + @Test void savesThenClearsNullableValuesWithRealMapperCas() { + var first=request("save-money-first",0,new Money("CNY","30"),"12.5","18.6",List.of(new Arrival("FBA:ONT8","2026-10-20T16:00:00+08:00"))); + var saved=service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first); + assertEquals("1",saved.row().recordVersion());assertEquals("30.00",saved.row().unitPrice()); + assertEquals("18.600",repository.record(1).getFuelConsumptionLitresPer100Km().toPlainString()); + assertEquals("2026-10-20T08:00:00Z",codec.list(repository.record(1).getArrivalsJson(),Arrival.class).get(0).actualArrivedAt()); + service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("clear-money-now",1,null,null,null,List.of())); + var cleared=repository.record(1);assertNull(cleared.getCurrency());assertNull(cleared.getTotalAmount()); + assertNull(cleared.getActualDistanceMiles());assertNull(cleared.getFuelConsumptionLitresPer100Km());assertEquals(2L,cleared.getRecordVersion()); + assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class)); + assertEquals(2,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item",Integer.class)); + } + /** 原成功载荷重放返回原版本,不能用新版本或不同操作者另起命令。 */ + @Test void replayAndStaleVersionDoNotCreateExtraRecordsOrHistory() { + var first=request("first-operation",0,new Money("USD","0"),null,null,List.of()); + assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion()); + assertEquals("1",service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",first).row().recordVersion()); + assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("stale-operation",0,null,null,null,List.of()))); + assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("first-operation",0,new Money("USD","1"),null,null,List.of()))); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class)); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class)); + } + /** 非本车目的地和不规范单位输入不能产生附加行或命令。 */ + @Test void rejectsForeignDestinationBeforeAnyWrite() { + assertThrows(BizException.class,()->service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE", + request("foreign-arrival",0,null,null,null,List.of(new Arrival("FBA:OTHER","2026-10-20T08:00:00Z"))))); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class)); + } + /** 原流水与附加流水用一次真实SQL分页,版本域不混用。 */ + @Test void historyMergesOriginalDispatchWithoutDuplicatingIt() { + service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request("history-register",0,null,null,"0",List.of())); + var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchAggregateService.class)); + var page=realQueries.history(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",1,20); + assertEquals(2,page.total());assertEquals(2,page.records().size()); + var original=page.records().stream().filter(r->r.eventSource().equals("DISPATCH")).findFirst().orElseThrow(); + assertEquals("8",original.dispatchRecordVersion());assertNull(original.recordVersion());assertNull(original.operations());assertEquals(List.of(),original.photos()); + var added=page.records().stream().filter(r->r.eventSource().equals("OPERATIONS")).findFirst().orElseThrow(); + assertEquals("1",added.recordVersion());assertNull(added.dispatchRecordVersion()); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history",Integer.class)); + } + /** 当前页不是统计范围,装车后无DEPART也能入选;GET不建附加记录。 */ + @Test void listUsesFilteredSourceAndLeavesVirtualZeroUnwritten() { + var realQueries=new OwnedBusinessQueryService(repository,projection,codec,mock(DispatchAggregateService.class)); + var list=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T00:00:00Z","2026-10-21T00:00:00Z","ONT8",null,"9")); + assertEquals(1,list.total());assertEquals(1,list.summary().afterLoadingCount());assertEquals(1,list.summary().dispatchCount()); + assertEquals("0",list.records().get(0).recordVersion());assertEquals(1,list.records().get(0).actualPalletCount()); + assertEquals(0,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + var empty=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,"2026-10-20T08:00:00Z","2026-10-20T08:00:01Z",null,null,null)); + assertEquals(1,empty.total()); + var excluded=realQueries.list(OwnedBusinessFixtures.actor(),new Query(1,20,null,"2026-10-20T08:00:00Z",null,null,null)); + assertEquals(0,excluded.total()); + } + /** 原车辆归属捕获与调用方事务同回滚,不留下假的历史所有权。 */ + @Test void ownershipCaptureRollsBackWithParentTransaction() { + jdbc.update("DELETE FROM ll_fleet_owned_business_ownership"); + var vehicles=mock(VehicleRepository.class); + when(vehicles.findById(7)).thenReturn(Optional.of(new VehicleRepository.VehicleSnapshot(7,"TEST-7","TRUCK",null,0,null,null,"US","OWNED","IN_SERVICE",null,3,null,LocalDateTime.now(),10,null,null))); + var capture=tx(new OwnedBusinessOwnershipService(repository,vehicles)); + assertThrows(IllegalStateException.class,()->new TransactionTemplate(manager).execute(status->{ + capture.capture(1,7L,"MASTER","actual-load",Instant.parse("2026-10-20T07:30:00Z"));throw new IllegalStateException("模拟主装车回滚"); + })); + assertNull(repository.ownership(1));verify(vehicles,never()).lockById(anyLong()); + } + /** 两个真实数据库事务争用初始0版本,必须只有一个命令及历史成功。 */ + @Test void concurrentZeroVersionWritesHaveExactlyOneWinner() throws Exception { + var pool=java.util.concurrent.Executors.newFixedThreadPool(2); + var start=new java.util.concurrent.CountDownLatch(1); + try { + var first=pool.submit(()->compete(start,"concurrent-first")); + var second=pool.submit(()->compete(start,"concurrent-second")); + start.countDown(); + boolean a=first.get(10,java.util.concurrent.TimeUnit.SECONDS),b=second.get(10,java.util.concurrent.TimeUnit.SECONDS); + assertNotEquals(a,b);assertEquals(1L,repository.record(1).getRecordVersion()); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class)); + assertEquals(1,jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_history",Integer.class)); + assertEquals(8L,jdbc.queryForObject("SELECT record_version FROM ll_dispatch_order WHERE id=1",Long.class)); + } finally {pool.shutdownNow();} + } + /** 只把真实版本冲突判为竞争失败,SQL和基础设施异常必须让回归失败。 */ + private boolean compete(java.util.concurrent.CountDownLatch start,String key) throws Exception { + if(!start.await(5,java.util.concurrent.TimeUnit.SECONDS))throw new IllegalStateException("并发测试起点超时"); + try{service.save(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",request(key,0,new Money("CNY","1"),null,null,List.of()));return true;} + catch(BizException error){if(error.getResultCode()!=com.baiyue.common.core.api.ResultCode.CONFLICT)throw error;return false;} + } + /** 基于实际数据库事务代理调用,验证的不只是实现分支。 */ + @SuppressWarnings("unchecked") + private T tx(T value) { + var proxy=new ProxyFactory(value);proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy(); + } + /** 每个请求保留原附加版本和明确量纲。 */ + private SaveOperations request(String key,long version,Money money,String distance,String fuel,List arrivals) { + return new SaveOperations(String.valueOf(version),key,new Operations(money,distance,fuel,arrivals),"本机TEST合成记录"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java new file mode 100644 index 000000000..0e91f16fa --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPhotoTest.java @@ -0,0 +1,88 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import org.junit.jupiter.api.*; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 上传未知结果和绑定失败恢复回归,不发任何环境HTTP。 */ +class OwnedBusinessPhotoTest { + OwnedBusinessPhotoService service; + OwnedBusinessFileGateway files; + OwnedBusinessRecordService records; + OwnedBusinessQueryService queries; + OwnedBusinessRepository repository; + Map ledger; + /** 用独立命令集合记录原键,文件服务全部为明确的本机mock。 */ + @BeforeEach void setup() { + var codec=OwnedBusinessFixtures.codec();repository=mock(OwnedBusinessRepository.class);queries=mock(OwnedBusinessQueryService.class); + records=mock(OwnedBusinessRecordService.class);files=mock(OwnedBusinessFileGateway.class);ledger=new HashMap<>(); + when(queries.requireOwned("DS-OWN-ONE")).thenReturn(OwnedBusinessFixtures.source()); + when(repository.command(eq(1L),anyString())).thenAnswer(call->ledger.get(call.getArgument(1))); + doAnswer(call->{OwnedBusinessRows.Command row=call.getArgument(0);ledger.put(row.getIdempotencyKey(),row);return null;}).when(repository).command(any(OwnedBusinessRows.Command.class)); + when(repository.finishUpload(any())).thenReturn(1); + service=new OwnedBusinessPhotoService(queries,records,repository,new OwnedBusinessCommandService(repository,codec), + new OwnedBusinessProjection(codec),codec,files); + } + /** 相同原上传请求只执行一次File,回执未绑定不能声称已经保存。 */ + @Test void successfulUploadReplaysOriginalFileWithoutUploadingAgain() { + when(files.upload(eq(1L),eq(PhotoSlot.LOADING_SCENE),eq("operator-1"),any())).thenReturn(evidence()); + var first=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png()); + var replay=service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-original",png()); + assertEquals(first,replay);assertEquals("9",first.fileId());assertNull(first.savedAt()); + verify(files,times(1)).upload(anyLong(),any(),anyString(),any());verifyNoInteractions(records); + } + /** File响应未知必须保留原键并停写,原请求重试不能再次上传。 */ + @Test void unknownUploadStopsInsteadOfRepeatingFileWrite() { + when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("模拟连接中断")); + assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png())); + assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-unknown",png())); + var recovered=service.command(OwnedBusinessFixtures.actor(),"DS-OWN-ONE","upload-unknown"); + assertEquals("UNKNOWN_RESULT",recovered.state());assertFalse(recovered.retryAllowed());assertNull(recovered.result()); + verify(files,times(1)).upload(anyLong(),any(),anyString(),any()); + } + /** 相同键不能从装车框切换到油耗框,也不能由另一操作者复用。 */ + @Test void uploadIdentityIncludesActorSlotAndOriginalBytes() { + when(files.upload(anyLong(),any(),anyString(),any())).thenReturn(evidence()); + service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-identity",png()); + assertThrows(BizException.class,()->service.upload(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.FUEL_START,"upload-identity",png())); + var other=new Actor("other","其他测试账号",Set.of(LIST,UPLOAD)); + assertThrows(ResponseStatusException.class,()->service.command(other,"DS-OWN-ONE","upload-identity")); + verify(files,times(1)).upload(anyLong(),any(),anyString(),any()); + } + /** 绑定失败后仍消费原fileId/原绑定键,不触发新的上传。 */ + @Test void failedBindingRetainsOriginalFileForExplicitSameRequestRetry() { + when(files.metadata(1,PhotoSlot.LOADING_SCENE,9)).thenReturn(evidence()); + when(records.bind(any(),eq("DS-OWN-ONE"),eq(PhotoSlot.LOADING_SCENE),any(),any())) + .thenThrow(new BizException(ResultCode.CONFLICT,"模拟附加版本冲突")).thenReturn(new Detail(null,null,List.of(),List.of())); + var request=new BindPhoto("0","bind-original","9"); + assertThrows(BizException.class,()->service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request)); + assertNotNull(service.bind(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,request)); + verify(files,never()).upload(anyLong(),any(),anyString(),any()); + verify(files,times(2)).metadata(1,PhotoSlot.LOADING_SCENE,9); + } + /** 知道文件ID也不能越过当前/历史引用探测内容。 */ + @Test void unboundFileIdIsDeniedBeforeCallingFile() { + assertThrows(BizException.class,()->service.content(OwnedBusinessFixtures.actor(),"DS-OWN-ONE",PhotoSlot.RECEIPT,9,null)); + verifyNoInteractions(files); + } + /** 只读用户不能调用上传,File意图也不能落库。 */ + @Test void uploadPermissionIsIndependentFromList() { + assertThrows(ResponseStatusException.class,()->service.upload(new Actor("read-only","只读测试账号",Set.of(LIST)),"DS-OWN-ONE",PhotoSlot.LOADING_SCENE,"upload-denied",png())); + verifyNoInteractions(files);assertTrue(ledger.isEmpty()); + } + /** 本机原始PNG签名用于消费者格式检查,不声称外部真实照片。 */ + private MockMultipartFile png(){return new MockMultipartFile("file","TEST.png","image/png",new byte[]{(byte)137,80,78,71,13,10,26,10,0});} + /** 元数据归属在独立gateway测试核验,这里只验证命令生命周期。 */ + private OwnedBusinessFileGateway.Evidence evidence(){return new OwnedBusinessFileGateway.Evidence(9,"TEST.png","image/png",9,"2026-10-20T07:30:00");} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java new file mode 100644 index 000000000..ca91c14c5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessProjectionTest.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +/** 金额按物理托盘计算;原装车和来源客户事实不重新归并。 */ +class OwnedBusinessProjectionTest { + /** 同一实托两客户仍是一托,不按3个标准托位或2个来源除价。 */ + @Test void sharedPhysicalPalletIsCountedOnceAndUnregisteredMoneyStaysNull() { + var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var source=OwnedBusinessFixtures.source(); + var row=p.row(source,null,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()); + assertEquals(1,row.actualPalletCount());assertEquals("0",row.recordVersion());assertEquals("8",row.dispatchRecordVersion()); + assertNull(row.operations().money());assertNull(row.unitPrice());assertNull(row.operations().fuelConsumptionLitresPer100Km()); + assertEquals(1,row.destinations().size());assertNull(row.departedAt());assertTrue(row.actions().edit().allowed()); + assertEquals(8L,source.getRecordVersion()); + } + /** 明确0与未登记不同,单价只由服务端权威金额和实托计算。 */ + @Test void explicitZeroAndMoneyRoundingRemainAuthoritative() { + var p=new OwnedBusinessProjection(OwnedBusinessFixtures.codec());var record=record(); + record.setCurrency("CNY");record.setTotalAmount(BigDecimal.ZERO); + var zero=p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()); + assertEquals("0.00",zero.operations().money().amount());assertEquals("0.00",zero.unitPrice()); + record.setTotalAmount(new BigDecimal("10.01")); + assertEquals("10.01",p.row(OwnedBusinessFixtures.source(),record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()).unitPrice()); + } + /** 无实托不除零,不能用来源数或容量代替。 */ + @Test void emptyPhysicalManifestNeverDividesByZero() { + var source=OwnedBusinessFixtures.source();source.setLoadingConfirmationJson("{\"manifestHash\":\"m\",\"pallets\":[],\"operatorId\":\"p\",\"confirmedAt\":\"2026-10-20T07:30:00Z\"}"); + var record=record();record.setCurrency("USD");record.setTotalAmount(BigDecimal.ONE); + var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor()); + assertEquals(0,row.actualPalletCount());assertNull(row.unitPrice());assertFalse(row.sourceIssues().isEmpty()); + } + /** null与0在每百公里油耗中同样保持区分,不沿用原型18.6。 */ + @Test void explicitFuelRateIsNotDerivedAsTotalFuel() { + var record=record();record.setActualDistanceMiles(BigDecimal.ZERO);record.setFuelConsumptionLitresPer100Km(new BigDecimal("18.600")); + var ops=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).operations(record); + assertEquals("0",ops.actualDistanceMiles());assertEquals("18.600",ops.fuelConsumptionLitresPer100Km()); + } + /** 虚拟记录和空照片保持真实空态。 */ + private Record record(){var r=new Record();r.setRecordVersion(1L);r.setArrivalsJson("[]");r.setPhotosJson("[]");return r;} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java new file mode 100644 index 000000000..6e0d69a11 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import org.mockito.ArgumentCaptor; +import java.time.LocalDateTime; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 同筛选完整统计、旧单不回填和权限隔离的直接回归。 */ +class OwnedBusinessQueryTest { + /** 当前页只有一车时统计仍来自全量,日期边界转换为UTC且共用过滤。 */ + @Test void summaryDoesNotUseCurrentPageLengthAndDateUsesPlannedDeparture() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); + var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchAggregateService.class)); + var after=new StageCount();after.setStage("AFTER_LOADING");after.setQuantity(2L); + var completed=new StageCount();completed.setStage("DELIVERED");completed.setQuantity(1L); + when(repo.summary(any())).thenReturn(List.of(after,completed));when(repo.page(any())).thenReturn(List.of(OwnedBusinessFixtures.source())); + when(repo.records(any())).thenReturn(List.of());when(repo.cargo(any())).thenReturn(OwnedBusinessFixtures.cargo());when(repo.batches(any())).thenReturn(List.of()); + var result=service.list(OwnedBusinessFixtures.actor(),new Query(1,1,"2026-10-20T00:00:00+08:00","2026-10-21T00:00:00+08:00","ONT8",null,"9")); + assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(3,result.summary().dispatchCount());assertEquals(2,result.summary().afterLoadingCount()); + var search=ArgumentCaptor.forClass(Search.class);verify(repo).summary(search.capture());verify(repo).page(search.getValue()); + assertEquals(LocalDateTime.parse("2026-10-19T16:00:00"),search.getValue().from()); + assertEquals(LocalDateTime.parse("2026-10-20T16:00:00"),search.getValue().to()); + verify(repo,never()).lock(any());verify(repo,never()).capture(any());verify(repo,never()).update(any(),anyLong()); + } + /** 无归属快照不可用当前主档补造,自有页失败不修改原排车。 */ + @Test void oldLoadedDispatchWithoutOwnershipIsNotReclassified() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec();var source=OwnedBusinessFixtures.source(); + source.setOwnedVehicleOwnership(null);when(repo.source("DS-OWN-ONE")).thenReturn(source); + var dispatch=mock(DispatchAggregateService.class);var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,dispatch); + assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE")); + verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion()); + } + /** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */ + @Test void stageAndFrozenOwnershipFactsAreBothRequired() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); + var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchAggregateService.class)); + var source=OwnedBusinessFixtures.source();when(repo.source("DS-OWN-ONE")).thenReturn(source); + source.setStage("LOADING");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); + source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); + source.setOwnedVehicleOwnership("OWNED");source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); + } + /** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */ + @Test void readOnlyActorHasOnlyReadGates() { + var actor=new Actor("reader","只读账号",Set.of(LIST));var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()) + .row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor); + assertFalse(row.actions().edit().allowed());assertFalse(row.actions().upload().allowed()); + } + /** 客户Token即使携带同名权限也不成为平台运营身份。 */ + @Test void customerTokenCannotClaimPlatformThroughPermissions() { + var codec=OwnedBusinessFixtures.codec();var user=new AuthenticatedUser(11L,101L,101L,1L,"客户账号","MERCHANT","WEB",Set.of(LIST,EDIT,UPLOAD)); + assertThrows(ResponseStatusException.class,()->codec.actor(user)); + var platform=new AuthenticatedUser(12L,0L,0L,2L,"平台账号","PLATFORM","WEB",Set.of(LIST)); + assertEquals("平台账号",codec.actor(platform).operatorName());assertEquals("12",codec.actor(platform).operatorId()); + } + /** 缺版本和非规范值不能默认为合法0,超长Long也不能截断。 */ + @Test void versionsAndFuelRateNumbersAreStrict() { + var codec=OwnedBusinessFixtures.codec();assertEquals(0,codec.version("0"));assertEquals("0.000",codec.decimal("0",9,3)); + for(String version:List.of("","-1","00","90071992547409919999"))assertThrows(BizException.class,()->codec.version(version)); + for(String value:List.of("-1","18.6001","NaN","18e1","01"))assertThrows(BizException.class,()->codec.decimal(value,9,3)); + assertThrows(BizException.class,()->codec.instant("2026-10-20T08:00:00")); + } +} diff --git a/scripts/README.md b/scripts/README.md index d149ea7a5..86c71650e 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -267,6 +267,16 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc ## 仓库级入口清单 +排车扩展入口还支持独立新批次 `--scenario reviews`,真实覆盖两级驳回重提、零扫码退回改货改车及旧版本/幂等冲突,再接完整履约。`--scenario review-terminal --resume-report <本扩展原失败报告> --resume-report-sha256 <原SHA>` 只接收已完成双签收、请求全部明确RESPONSE、审核包VERIFIED但顺序只读快照跨过异步完成版本的精确停点;新报告先只读核齐原单DELIVERED及全量库存,再仅结案释放本批资源,不重拣、重发、重签或覆盖旧失败。其他未知写和原人工单不接受此模式。 + +`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、独立费用/单据/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、费用、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。 + +`--scenario competition` 同样仅使用本轮新样本,验证占用中的车辆/司机拒绝第二张空排车、装车后尚未安排派送的发车拒绝,以及已成功装车原键的两个 Admin/PDA 请求并发重放;原操作员恢复唯一结果,另一操作员被拒绝,完整库存与主单事实不变。该场景不宣称首次有效命令竞争或完整权限矩阵通过。第二次签收确定成功后增加最多30秒只读完结等待,再进入既有跨接口一致性快照;不重放签收写入,也不修改既有失败报告或共享验证器。 + +`--scenario bol-empty|bol-same|bol-different --pdf-python <已安装pypdf的Python绝对路径>` 分别创建同目的仓无预约号、同号、不同号的两客户新样本。预约场景通过原有来源预约入口登记明确标记TEST的确认资料及1箱真实份额,再按返回的batchKey/version/来源归属关联原生排车;不借旧预约,不新增日历功能。实际BOL PDF、独立只读二进制请求账本和提取文字保存在该批 `evidence/`;核对跨客户合并/分组、各托只打印一次、总计2箱、打印前后完整库存不变,再继续正常履约结案。JSON请求计数不含独立二进制GET,二者分别留证;不把文字检查作为视觉验收。 + +BOL长托盘码按现行模板省略,自动核验使用完整入仓码与本轮一箱一托来源映射守恒,不声称PDF完整展示托盘码。若本轮在该显示断言处停止且尚未开始履约,可显式传原 `--resume-report` 和 `--resume-report-sha256` 到同一BOL场景、新runId;仅接受原READY夹具、精确已知错误、全部请求确定回执及无履约命令的冻结检查点。保留原失败,重新打印并核对当前状态后首次推进履约,不重建预约/排车,不允许未知写重试。 + 下表覆盖 `scripts/` 下所有非 `*.test.mjs` 的可执行入口。`*.test.mjs` 统一为离线 Node 静态合同,不访问测试环境;`admin-test-env-modules/*.mjs` 的逐模块说明由该目录 `README.md` 和总入口注册表维护。 | 分类 | 入口 | 用途与副作用 | diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index b8d3b3505..e36917e77 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,11 @@ # Admin 测试环境 HTTP 模块 +`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。 + +其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。 + +其BOL扩展通过可选`shipmentReferences`和`prepareBatchAllocations`为本轮实收来源提供显式TEST货件参考及正式预约份额;分配准备后重新读取候选当前版本,默认夹具不创建预约。`bol-empty/same/different` 核验真实PDF页数、约号、逐托唯一出现及箱数守恒,打印前后原始库存和主单快照完全不变;实际二进制请求另存只读账本。 + `procurement-sample-fulfillment.mjs` 是采购样品完整正常路径的内部验收助手,钱包综合模块也调用同一流程,并将采购请求证据独占保存于 `reports/procurement-wallet-checkpoints/.json`;客户会话在 finally 退出。由 `../procurement-sample-test-env.mjs` 注入本轮独立已充值客户、客户会话、有效收货仓、受控上传及权威钱包读取。创建新样本,执行正额货款、两次海外收货、两箱装箱、预估冻结、国际交寄、逐箱国内收货、实际核费及寄客户,逐阶段核对钱包现金/冻结和费用来源,关键请求原键重放。每次请求前必须 checkpoint;未知结果停写保留样本,不自动新键重试或清理。已接入显式 TEST 入口;未执行时不计作真实 HTTP 通过,也不覆盖异常、退款或权限全部矩阵。离线合同 `procurement-sample-fulfillment-contract.test.mjs` 验证前置缺失、余额不足、请求前持久化及未知结果停写。 `phase5-root-fulfillment` 可由总入口显式 `--prepare-domestic-pda-h5 true` 仅准备本轮国内H5待拣任务。调用 `phase3-order-splitting.loadOneFragment` 到正式计划确认、待拣任务及箱唛就绪即返回,不领取、不拣货、不继续海外;结果固定 `PREPARED_NOT_VERIFIED`,不算全链验收。默认完整链不变,范围及授权见根脚本README。 diff --git a/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs new file mode 100644 index 000000000..ed651af09 --- /dev/null +++ b/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs @@ -0,0 +1,490 @@ +import assert from 'node:assert/strict' +import { verifyFleetCrossCustomer } from './fleet-cross-customer-acceptance.mjs' + +const ROOT = '/api/platform/v1/last-leg', MODULE = 'dispatch-workflow-extensions' +const OPTIONS = { scenario: MODULE, deploymentBlocker: true } +const clone = value => structuredClone(value) +const id = value => String(value ?? '') +export const extensionMatrix = Object.freeze([ + { scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; distinct fee inputs and isolated execution documents', readiness: 'READY' }, + { scenario: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' }, + { scenario: 'relabel', scope: 'Confirmed relabel cancellation/rearrangement and multiple applications sharing one physical pallet', readiness: 'REQUIRES_FORMAL_RELABEL_FIXTURE' }, + { scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources and unarranged departure gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' }, + { scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' }, +]) +export function assertDualDestinationFixture(fixture) { + assert.equal(fixture.members?.length, 2) + assert.equal(new Set(fixture.members.map(row => id(row.customerId))).size, 2) + assert.equal(new Set(fixture.members.map(row => row.destination?.key)).size, 2) + assert(fixture.members.every(row => row.destination?.key && row.receiptVerified === true)) + assert(fixture.tripProof?.stops?.length >= 3) + for (const member of fixture.members) + assert(fixture.tripProof.stops.some(stop => stop.destinationKey === member.destination.key)) +} +export function feeCents(value) { + assert(['string', 'number'].includes(typeof value) && /^(0|[1-9]\d*)(\.\d{1,2})?$/.test(String(value)), 'Non-canonical fee amount') + const [whole, fraction = ''] = String(value).split('.') + const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0')) + assert(Number.isSafeInteger(cents)); return cents +} +export function applyIndependentFees(body, groups, members, marker) { + assert.equal(groups?.length, 2); assert.equal(body.groups?.length, 2) + assert.equal(new Set(groups.map(row => row.groupKey)).size, 2) + assert.equal(new Set(groups.map(row => id(row.customerId))).size, 2) + const centsByCustomer = new Map(members.map((member, index) => [member.customerId, index === 0 ? 3125 : 4775])) + const expected = {} + for (const group of body.groups) { + const matches = groups.filter(row => row.groupKey === group.groupKey) + assert.equal(matches.length, 1) + const customerId = id(matches[0].customerId), cents = centsByCustomer.get(customerId) + assert(cents && group.fees?.length > 0, 'Explicit current-customer fee services required') + assert(!expected[customerId], 'Duplicated customer fee group') + for (const fee of group.fees) { fee.amount = cents / 100; fee.feeName = `${marker} TEST合成客户独立费用` } + expected[customerId] = group.fees.map(fee => ({ feeType: fee.feeType, currency: fee.currency, cents: feeCents(fee.amount) })) + } + return expected +} + +/** Public master-data reads only; no fixture writes until both independent US FBA masters are verified. */ +export async function discoverSecondDestination(context, primaryId) { + const get = async path => { + const response = await context.request('GET', path) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); return response.body.data + } + const primary = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(primaryId)}?tenantId=0`) + const country = primary.addresses?.find(row => row.addressLine1)?.countryCode + assert(country && primary.warehouse?.warehouseType === 'FBA' && primary.warehouse.status === 'ACTIVE') + let total, scanned = 0 + const seen = new Set() + for (let page = 1; page <= 100; page++) { + const data = await get(`/api/platform/v1/warehouse-admin?${new URLSearchParams({ tenantId: '0', page: String(page), size: '200', warehouseType: 'FBA', status: 'ACTIVE', countryCode: country })}`) + assert(Array.isArray(data.records)); assert.equal(Number(data.page), page); assert.equal(Number(data.size), 200) + assert(Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0) + total ??= Number(data.total); assert.equal(Number(data.total), total) + assert(data.records.length > 0 || scanned === total) + for (const row of data.records) { + assert(row.id && !seen.has(id(row.id))); seen.add(id(row.id)); scanned++ + if (id(row.id) === id(primaryId)) continue + const candidate = await get(`/api/platform/v1/warehouse-admin/${encodeURIComponent(row.id)}?tenantId=0`) + if (candidate.warehouse?.warehouseType !== 'FBA' || candidate.warehouse.status !== 'ACTIVE' + || id(candidate.warehouse.tenantId) !== '0' || candidate.warehouse.warehouseCode === primary.warehouse.warehouseCode) continue + if (candidate.addresses?.some(address => address.addressLine1 && address.countryCode === country)) return id(row.id) + } + if (scanned === total) break + } + throw Error('No second distinct public active FBA destination with a real same-country address; no fixture created') +} + +/** Reuse the formal full-chain verifier; only explicitly supplied synthetic fee amounts differ by customer. */ +export async function verifyDualDestination(context, fixture, options) { + if (!options.bol) assertDualDestinationFixture(fixture) + else { + assert.equal(fixture.members.length, 2); assert.equal(new Set(fixture.members.map(row => row.customerId)).size, 2) + assert.equal(new Set(fixture.members.map(row => row.destination.key)).size, 1) + assert(fixture.members.every(row => row.receiptVerified === true)) + } + const proof = context.createdResources.dispatchWorkflowExtensions = { scenario: options.bol || 'dual-destination', marker: context.marker, + dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false, + hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] } + const original = context.request + let arrangement, deferredFailure, raceDone = false, unarrangedDone = false + const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + context.request = async function (method, path, body, ...rest) { + if (deferredFailure) throw deferredFailure + if (method === 'POST' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/transfer-delivery`) { + proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker) + proof.arrangeRequest = clone(body); await context.checkpoint() + } + const response = await original.call(this, method, path, body, ...rest) + if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement` + && response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data) + // 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。 + if (response.http === 200 && String(response.body?.code) === '200') { + try { + if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone + && response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') { + raceDone = true + await verifyLoadingReplayRace(context, fixture, options, original.bind(this), body, response.body.data) + } + if (options.competition && method === 'GET' && path === `${mainPath}/delivery-arrangement` && !unarrangedDone + && response.body.data?.stage === 'AFTER_LOADING' && response.body.data?.arrangement == null) { + unarrangedDone = true + await verifyUnarrangedDeparture(context, fixture, options, original.bind(this), response.body.data) + } + if (method === 'PUT' && body?.idempotencyKey === `${context.marker}-CROSS-SIGN-1`) { + const fence = proof.completionReadFence = { status: 'READING', attempts: [], writeReceiptPreserved: true } + for (let attempt = 1; attempt <= 60; attempt++) { + const current = await original.call(this, 'GET', mainPath) + assert.equal(current.http, 200); assert.equal(String(current.body?.code), '200') + assert.equal(current.body.data?.dispatchNo, fixture.dispatchNo) + fence.attempts.push({ attempt, stage: current.body.data.stage, recordVersion: current.body.data.recordVersion, + requestId: current.requestId, traceId: current.traceId }) + if (current.body.data.stage === 'DELIVERED') { fence.status = 'DELIVERED'; break } + assert.equal(current.body.data.stage, 'DELIVERING') + await context.checkpoint(); await new Promise(resolve => setTimeout(resolve, 500)) + } + assert.equal(fence.status, 'DELIVERED', 'Automatic completion did not settle within 30 seconds') + await context.checkpoint() + } + } catch (error) { deferredFailure = error } + } + return response + } + let verified + try { verified = await verifyFleetCrossCustomer(context, fixture, options) } + finally { context.request = original } + assert.equal(verified.status, 'VERIFIED') + const final = verified.snapshots.FINAL + assert.equal(new Set(final.dispatch.documents.deliveries.map(row => row.deliveryNo)).size, 2) + assert.equal(new Set(final.dispatch.documents.outbounds.map(row => row.outboundNo)).size, 2) + const feeIds = new Set() + for (const [index, member] of fixture.members.entries()) { + const delivery = final.members[index].delivery + assert.equal(id(delivery.order.customerId), member.customerId) + assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) + assert.equal(Number(delivery.order.packageCount), 1); assert.equal(Number(delivery.order.palletCount), 1) + const expected = proof.expectedFees[member.customerId] + assert(expected?.length > 0); assert.equal(delivery.fees?.length, expected.length) + for (const fee of delivery.fees) { + assert(fee.id && !feeIds.has(id(fee.id))); feeIds.add(id(fee.id)) + assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) + assert.equal(fee.feeDirection, 'RECEIVABLE'); assert.equal(fee.sourceStatus, 'ACTIVE') + } + const other = fixture.members[1 - index] + const path = `${ROOT}/delivery-orders/${encodeURIComponent(delivery.order.deliveryNo)}?customerId=${encodeURIComponent(other.customerId)}` + const response = await context.request('GET', path) + const denied = [200, 403, 404].includes(response.http) && ['A0403', 'A0404'].includes(String(response.body?.code)) + context.assert(MODULE, 'Foreign customer cannot read this delivery or its independent fees', response, denied, '', OPTIONS) + assert(denied) + proof.isolation.push({ customerId: member.customerId, wrongCustomerId: other.customerId, deliveryNo: delivery.order.deliveryNo, + http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId }) + } + assert.notDeepEqual(proof.expectedFees[fixture.members[0].customerId], proof.expectedFees[fixture.members[1].customerId]) + proof.status = 'VERIFIED'; await context.checkpoint(); return verified +} + +const receipt = response => ({ http: response.http, code: response.body?.code, message: response.body?.message, + requestId: response.requestId, traceId: response.traceId }) +async function competitionSnapshot(context, fixture, { readInventory }, send) { + const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + const state = {} + for (const [name, target] of [['main', path], ['items', `${path}/items`], ['loading', `${path}/loading`], + ['trip', `${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`]]) { + const response = await send('GET', target) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); state[name] = response.body.data + } + state.stock = [] + for (const member of fixture.members) state.stock.push(await readInventory({ marker: context.marker, + customerId: member.customerId, warehouseId: fixture.warehouseId })) + return state +} + +/** 仅两个请求同时发送;使用已成功装车的原键,明确区别首次执行竞争。 */ +export async function verifyLoadingReplayRace(context, fixture, options, send, body, acknowledged) { + const proof = context.createdResources.dispatchWorkflowCompetition + assert(proof && proof.status === 'CHECKING') + const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete` + const pda = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/loading-complete` + const race = proof.loadingReplayRace = { status: 'INTENT', scope: 'ACKNOWLEDGED_COMMAND_TWO_ACTOR_REPLAY', + firstExecutionRace: 'NOT_TESTED', body: clone(body), before: await competitionSnapshot(context, fixture, options, send) } + await context.checkpoint() + const results = await Promise.allSettled([send('POST', pda, clone(body), fixture.pdaToken), send('POST', path, clone(body))]) + race.responses = results.map(result => result.status === 'fulfilled' ? receipt(result.value) : { outcome: 'UNKNOWN_RESULT' }) + race.status = results.every(result => result.status === 'fulfilled') ? 'RESPONDED' : 'UNKNOWN_RESULT' + await context.checkpoint() + for (const result of results) if (result.status === 'rejected') throw result.reason + const [pdaResult, adminResult] = results.map(result => result.value) + assert.equal(String(pdaResult.body?.code), '200'); assert.equal(pdaResult.body?.data?.replayed, true) + assert.deepEqual(pdaResult.body.data.result, acknowledged.result) + assert.equal(id(pdaResult.body.data.recordVersion), id(acknowledged.recordVersion)) + assert.equal(String(adminResult.body?.code), 'A0403'); assert.match(adminResult.body?.message || '', /操作员/) + race.after = await competitionSnapshot(context, fixture, options, send) + assert.deepEqual(race.after, race.before) + context.assert(MODULE, 'Concurrent Admin/PDA original-key replays preserve the one loading result and complete inventories', + pdaResult, true, '', OPTIONS) + race.status = 'VERIFIED'; await context.checkpoint() +} + +async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) { + const proof = context.createdResources.dispatchWorkflowCompetition + const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send), + body: { recordVersion: arrangement.recordVersion, idempotencyKey: `${context.marker}-EXT-NO-ARRANGEMENT-DEPART`, remark: 'TEST装车后未安排派送必须拒绝' } } + assert.equal(state.before.main.stage, 'AFTER_LOADING'); assert.equal(state.before.main.documents?.deliveries?.length, 0) + await context.checkpoint() + const response = await send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/depart`, state.body, fixture.pdaToken) + state.response = receipt(response); await context.checkpoint() + assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /派送|安排/) + state.after = await competitionSnapshot(context, fixture, options, send); assert.deepEqual(state.after, state.before) + context.assert(MODULE, 'Loaded but unarranged departure is rejected without inventory or execution mutation', response, true, '', OPTIONS) + state.status = 'VERIFIED'; await context.checkpoint() +} + +export async function verifyCompetitionExtension(context, fixture, options) { + const proof = context.createdResources.dispatchWorkflowCompetition = { marker: context.marker, dispatchNo: fixture.dispatchNo, + status: 'CHECKING', firstExecutionRace: 'NOT_TESTED', arbitraryPrivilegeMatrix: 'NOT_TESTED_NO_PERMISSION_MUTATION' } + const before = await competitionSnapshot(context, fixture, options, context.request.bind(context)) + const body = { idempotencyKey: `${context.marker}-EXT-OCCUPIED-RESOURCE`, warehouseId: fixture.warehouseId, + vehicle: { mode: 'MASTER', vehicleId: fixture.tripProof.vehicleId }, + driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } }, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, + remark: `${context.marker} TEST已占用资源不能分配另一空车`, cargo: [], batchAllocations: [] } + proof.resourceConflict = { status: 'INTENT', body: clone(body), before }; await context.checkpoint() + const response = await context.request('POST', `${ROOT}/dispatches`, body) + proof.resourceConflict.response = receipt(response); await context.checkpoint() + assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /车辆.*占用|司机.*占用/) + proof.resourceConflict.after = await competitionSnapshot(context, fixture, options, context.request.bind(context)) + assert.deepEqual(proof.resourceConflict.after, before) + context.assert(MODULE, 'Occupied vehicle/driver reject a second native main without changing the existing trip or inventory', response, true, '', OPTIONS) + proof.resourceConflict.status = 'VERIFIED' + const verified = await verifyDualDestination(context, fixture, { ...options, competition: true }) + assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED') + proof.status = 'VERIFIED_BOUNDED_SCOPE'; await context.checkpoint(); return verified +} + +/** 仅使用本轮PDA实收的一箱来源,正式创建预约并向原生建单返回实际份额。 */ +export async function prepareBolAppointments(context, members, scenario, warehouseId) { + assert(['bol-empty', 'bol-same', 'bol-different'].includes(scenario)) + const proof = context.createdResources.dispatchWorkflowBolAppointments = { scenario, status: 'PREPARING', appointments: [], allocations: [] } + if (scenario === 'bol-empty') { proof.status = 'NO_APPOINTMENT'; return [] } + const groups = scenario === 'bol-same' ? [members] : members.map(member => [member]) + for (const [index, group] of groups.entries()) { + for (const member of group) { + const response = await context.request('GET', `${ROOT}/warehouse-transfers/${encodeURIComponent(member.incomingNo)}?customerId=${encodeURIComponent(member.customerId)}`) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200') + const transfer = response.body.data + assert.equal(transfer.transferNo, member.incomingNo); assert.equal(transfer.inboundCode, member.incomingNo) + assert.equal(id(transfer.inboundOrderId), id(member.receipt.balance.inboundOrderId)) + assert.equal(id(transfer.operatingWarehouseId), warehouseId) + assert(transfer.lastlegOrderId && member.selection.planPalletId && member.receiptVerified) + member.appointmentSourceKey = `ORDER:${transfer.lastlegOrderId}` + } + const refs = group.map(member => { + return { customerId: member.customerId, sourceKey: member.appointmentSourceKey, destinationKey: member.destination.key } + }) + const confirmationNo = `TEST-ISA-${context.marker.slice(-12)}-${index + 1}` + const body = { sources: refs, originWarehouseId: warehouseId, fbaWarehouseCode: group[0].destination.warehouseCode, + details: { appointmentStatus: 'SUCCESS', confirmationNo, appointmentAt: new Date(Date.now() + 48 * 3600000).toISOString().slice(0, 19), + appointmentEndAt: new Date(Date.now() + 50 * 3600000).toISOString().slice(0, 19), appointmentRemark: `${context.marker} TEST合成预约确认资料` }, + idempotencyKey: `${context.marker}-BOL-AP-${index + 1}` } + const record = { status: 'INTENT', body: clone(body) }; proof.appointments.push(record); await context.checkpoint() + const response = await context.request('POST', `${ROOT}/appointment-planning/appointments`, body) + record.response = receipt(response); record.status = 'RESPONDED'; await context.checkpoint() + const data = context.apiSuccess(MODULE, 'Create own formal FBA appointment for exact received quantities', response, '', OPTIONS) + assert.equal(String(response.body?.code), '200'); assert(data?.batchKey && data.appointmentNo) + assert.equal(data.confirmationNo, confirmationNo); assert.equal(data.appointmentStatus, 'SUCCESS') + assert.equal(data.items.length, group.length); assert(Number.isSafeInteger(data.version) && data.version >= 0) + record.data = clone(data) + for (const member of group) { + const matches = data.items.filter(item => id(item.customerId) === member.customerId && item.sourceKey === member.appointmentSourceKey) + assert.equal(matches.length, 1); assert.equal(Number(matches[0].packageCount), 1) + assert.equal(matches[0].destinationKey, member.destination.key) + proof.allocations.push({ planPalletId: member.selection.planPalletId, batchKey: data.batchKey, customerId: member.customerId, + sourceKey: matches[0].sourceKey, destinationKey: matches[0].destinationKey, packageCount: 1, recordVersion: data.version }) + } + record.status = 'VERIFIED'; await context.checkpoint() + } + proof.status = 'VERIFIED'; return clone(proof.allocations) +} + +export function assertBolPages(pages, fixture, appointments, scenario) { + const expected = scenario === 'bol-different' ? 2 : 1 + assert.equal(pages.length, expected) + const counts = [], isas = [], flattened = pages.map(page => page.replace(/\s/g, '')) + for (const page of pages) { + assert.match(page, /BILL OF LADING/) + const total = page.match(/Total\s+CTNS:\s*(\d+)/) + assert(total, 'Printed total box count missing'); counts.push(Number(total[1])) + const isaLine = page.split(/\r?\n/).find(line => line.includes('ISA #:')) + assert(isaLine !== undefined); isas.push(isaLine.split('ISA #:')[1].trim()) + } + assert.equal(counts.reduce((sum, count) => sum + count, 0), 2) + // 现行窄托盘列会省略长托盘码;用完整入仓码核对原实收来源,每个来源在本夹具中严格对应一箱一实托。 + for (const member of fixture.members) { + assert(member.incomingNo && member.palletNo) + assert.equal(flattened.reduce((sum, page) => sum + page.split(member.incomingNo).length - 1, 0), 1) + } + const expectedIsas = appointments.appointments.map(row => row.data.confirmationNo) + assert.deepEqual([...isas].sort(), scenario === 'bol-empty' ? [''] : [...expectedIsas].sort()) + if (scenario !== 'bol-different') assert.deepEqual(counts, [2]) + else assert.deepEqual(counts, [1, 1]) + return { pageCount: pages.length, totalPackages: 2, packagesPerBol: counts, confirmationNos: isas, + eachSourcePrintedOnce: true, physicalPalletMapping: 'ONE_BOX_ONE_PALLET_PER_SOURCE', palletColumn: 'LONG_CODES_TRUNCATED_BY_EXISTING_TEMPLATE' } +} + +export async function verifyBolExtension(context, fixture, options) { + assert(typeof options.inspectBol === 'function') + const proof = context.createdResources.dispatchWorkflowBol = { scenario: options.bol, status: 'CHECKING', + physicalMeasurementVerified: false, hardwareVerified: false } + proof.before = await competitionSnapshot(context, fixture, options, context.request.bind(context)) + const pages = await options.inspectBol(context, fixture) + proof.printed = assertBolPages(pages, fixture, context.createdResources.dispatchWorkflowBolAppointments, options.bol) + proof.after = await competitionSnapshot(context, fixture, options, context.request.bind(context)) + assert.deepEqual(proof.after, proof.before) + context.assert(MODULE, 'Actual BOL PDF preserves appointment shares, cross-customer grouping and complete inventory facts', + { http: 200 }, true, '', OPTIONS) + proof.status = 'VERIFIED'; await context.checkpoint() + return verifyDualDestination(context, fixture, options) +} + +/** Exercise edits only on this fresh zero-picked main, then leave a new fully approved round for the formal chain. */ +export async function exerciseReviewLifecycle(context, fixture, { readInventory }) { + const path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + const proof = context.createdResources.dispatchWorkflowReviews = { marker: context.marker, dispatchNo: fixture.dispatchNo, + status: 'CHECKING', commands: [], rejections: [], snapshots: {}, syntheticTestMaterials: true } + const request = async (method, target, body, label, reject = false) => { + if (method !== 'GET') { proof.commands.push({ method, path: target, body: clone(body) }); await context.checkpoint() } + const response = await context.request(method, target, body) + if (reject) { + const denied = [200, 409].includes(response.http) && ['A0409', '409'].includes(String(response.body?.code)) + context.assert(MODULE, label, response, denied, '', OPTIONS); assert(denied) + proof.rejections.push({ label, http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId }) + return response + } + const data = context.apiSuccess(MODULE, label, response, '', OPTIONS) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data + } + const get = (target, label) => request('GET', target, undefined, label) + const snapshot = async label => { + const data = { main: await get(path, label), items: await get(`${path}/items`, label), route: await get(`${path}/route`, label), stock: [] } + for (const member of fixture.members) data.stock.push(await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId })) + proof.snapshots[label] = data; await context.checkpoint(); return data + } + const initial = await snapshot('INITIAL_APPROVED_ZERO_PICK') + assert.equal(initial.main.stage, 'LOADING'); assert.equal(initial.main.reviewState, 'APPROVED') + assert.equal(Number(initial.main.counts.pickedPackageCount), 0); assert.equal(Number(initial.main.counts.shippedPackageCount), 0) + const key = label => `${context.marker}-REV-${label}` + let result = await request('POST', `${path}/return-to-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('RETURN'), reason: 'TEST零扫码显式退回改货改车' }, 'Zero-pick explicit return') + assert.equal(result.stage, 'BEFORE_LOADING') + let before = await snapshot('RETURNED') + assert.equal(before.main.reviewState, 'DRAFT') + await request('POST', `${path}/submit-review`, { recordVersion: initial.main.recordVersion, idempotencyKey: key('STALE'), remark: context.marker }, 'Stale main version is rejected', true) + assert.deepEqual(await snapshot('AFTER_STALE'), before) + const submit = { recordVersion: before.main.recordVersion, idempotencyKey: key('SUBMIT-REJECT-ROUND'), remark: 'TEST第一重提轮次' } + result = await request('POST', `${path}/submit-review`, submit, 'Submit dispatch rejection round') + before = await snapshot('SUBMITTED') + const replay = await request('POST', `${path}/submit-review`, submit, 'Original submit key replays') + assert.equal(replay.replayed, true); assert.equal(String(replay.recordVersion), String(result.recordVersion)) + await request('POST', `${path}/submit-review`, { ...submit, remark: 'TEST同键异载荷不得复用' }, 'Changed payload with original key is rejected', true) + await request('POST', `${path}/submit-review`, { ...submit, recordVersion: before.main.recordVersion, idempotencyKey: key('DUPLICATE-SUBMIT') }, 'New-key duplicate submit is rejected', true) + assert.deepEqual(await snapshot('AFTER_DUPLICATE_REJECTIONS'), before) + result = await request('POST', `${path}/dispatch-review`, { recordVersion: before.main.recordVersion, idempotencyKey: key('DISPATCH-REJECT'), decision: 'REJECTED', reason: 'TEST调度驳回' }, 'Dispatch reviewer rejects') + let detail = await get(path, 'Read dispatch rejection') + assert.equal(detail.reviewState, 'DISPATCH_REJECTED'); assert.equal(detail.reviews.dispatchReview.status, 'REJECTED') + result = await request('POST', `${path}/submit-review`, { recordVersion: detail.recordVersion, idempotencyKey: key('SUBMIT-LOADING-REJECT'), remark: 'TEST驳回重提' }, 'Resubmit after dispatch rejection') + result = await request('POST', `${path}/dispatch-review`, { recordVersion: result.recordVersion, idempotencyKey: key('DISPATCH-APPROVE'), decision: 'APPROVED', reason: 'TEST调度通过' }, 'Dispatch approves new round') + result = await request('POST', `${path}/loading-review`, { recordVersion: result.recordVersion, idempotencyKey: key('LOADING-REJECT'), decision: 'REJECTED', reason: 'TEST理货驳回' }, 'Loading reviewer rejects') + detail = await get(path, 'Read loading rejection') + assert.equal(detail.reviewState, 'LOADING_REJECTED'); assert.equal(detail.reviews.loadingReview.status, 'REJECTED') + const second = fixture.members[1] + result = await request('POST', `${path}/items/${encodeURIComponent(second.dispatchItemId)}/remove`, { + recordVersion: detail.recordVersion, idempotencyKey: key('REMOVE-SECOND'), reason: 'TEST零扫码先移出一票再真实重选' }, 'Remove only unpicked second member') + const removed = await snapshot('SECOND_REMOVED') + assert.equal(Number(removed.main.counts.packageCount), 1); assert.equal(removed.items.items.length, 1) + assert(removed.route.stops.every(stop => stop.destinationKey !== second.destination.key)) + const candidates = await get(`${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: fixture.warehouseId, orderNo: second.incomingNo })}`, 'Read freshly released source candidate') + const choices = candidates.candidates?.filter(row => id(row.customerId) === second.customerId && id(row.selection?.warehousePalletId) === second.palletId && row.palletNo === second.palletNo) + assert.equal(choices?.length, 1); const choice = choices[0] + assert(choice.selection && choice.sourceVersion && choice.blockReasons?.length === 0 && Number(choice.availablePackages) === 1) + result = await request('POST', `${path}/items`, { recordVersion: result.recordVersion, idempotencyKey: key('ADD-SECOND'), + cargo: [{ ...choice.selection, warehousePalletId: second.palletId, sourceVersion: choice.sourceVersion }], batchAllocations: [] }, 'Re-add exact released current source') + const added = await snapshot('SECOND_RESTORED') + assert.equal(added.items.items.length, 2); assert.equal(Number(added.main.counts.packageCount), 2) + const replacement = added.items.items.filter(row => id(row.customerId) === second.customerId && id(row.warehousePalletId) === second.palletId) + assert.equal(replacement.length, 1); second.dispatchItemId = id(replacement[0].dispatchItemId) + const nextPlate = `FT-${context.marker.slice(-12)}-R2` + const vehicle = await request('POST', `${ROOT}/master-data/vehicles`, { plateNumber: nextPlate, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, + mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, + remark: `${context.marker} TEST零扫码改派自有车辆` }, 'Create owned replacement TEST vehicle') + const update = { recordVersion: result.recordVersion, idempotencyKey: key('CHANGE-VEHICLE'), vehicle: { mode: 'MASTER', vehicleId: id(vehicle.id) }, + driver: { mode: 'MASTER', driverId: fixture.tripProof.driverId, evidence: { expectedTaskEndAt: fixture.tripProof.expectedDriverEndAt } }, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: null, remark: `${context.marker} TEST零扫码改车需两级重审` } + result = await request('PUT', path, update, 'Change resources before re-review') + detail = await get(path, 'Resource edit invalidates old approvals') + assert.equal(detail.reviewState, 'DRAFT'); assert.equal(id(detail.vehicleInfo.vehicle.vehicleId), id(vehicle.id)) + assert.notEqual(id(vehicle.id), fixture.tripProof.vehicleId) + for (const [action, suffix] of [['submit-review', 'FINAL-SUBMIT'], ['dispatch-review', 'FINAL-DISPATCH'], ['loading-review', 'FINAL-LOADING']]) { + result = await request('POST', `${path}/${action}`, { recordVersion: result.recordVersion, idempotencyKey: key(suffix), + ...(action === 'submit-review' ? { remark: 'TEST变更后重审' } : { decision: 'APPROVED', reason: 'TEST新依据重审通过' }) }, `Final new round ${action}`) + } + const final = await snapshot('REAPPROVED_ZERO_PICK') + assert.equal(final.main.stage, 'LOADING'); assert.equal(final.main.reviewState, 'APPROVED') + assert(Number(final.main.reviews.dispatchReview.reviewRound) > Number(initial.main.reviews.dispatchReview.reviewRound)) + assert.equal(final.main.reviews.dispatchReview.reviewRound, final.main.reviews.loadingReview.reviewRound) + assert.deepEqual(final.stock, initial.stock, 'All zero-pick review and cargo/resource edits preserve complete inventory') + const fleet = await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`, 'Read same main updated trip resources and route') + assert.equal(fleet.trip.tripNo, fixture.tripNo); assert.equal(id(fleet.trip.vehicleId), id(vehicle.id)) + assert.equal(fleet.dispatches.length, 1); assert.equal(fleet.dispatches[0].dispatchNo, fixture.dispatchNo) + Object.assign(fixture.tripProof, { marker: nextPlate, vehicleId: id(vehicle.id), vehicle: final.main.vehicleInfo.vehicle, + vehicleInfo: final.main.vehicleInfo, driver: final.main.vehicleInfo.driver, updateRequest: update, trip: fleet.trip, + stops: fleet.trip.stops, fleetRecordVersion: fleet.trip.recordVersion, recordVersion: final.main.recordVersion }) + proof.membersAfterEdit = clone(fixture.members); proof.finalVehicleInfo = clone(final.main.vehicleInfo) + proof.status = 'VERIFIED'; await context.checkpoint(); return proof +} + +export async function verifyReviewExtension(context, fixture, options) { + await exerciseReviewLifecycle(context, fixture, options) + return verifyDualDestination(context, fixture, options) +} + +/** Read-only terminal reconciliation after the known sequential-read completion race; never replay fulfillment. */ +export async function reconcileReviewTerminal(context, fixture, { readInventory }, prior) { + assert.equal(prior.success, false) + assert.equal(prior.error, 'Native main/items/PDA retain both true customers and physical pallets') + assert.equal(prior.createdResources.dispatchWorkflowReviews.status, 'VERIFIED') + assert.equal(prior.createdResources.fleetWriteBoundary, undefined) + const old = prior.createdResources.fleetCrossCustomer, path = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` + assert.equal(old.dispatchNo, fixture.dispatchNo); assert.equal(old.marker, context.marker) + assert(old.requests.filter(row => row.label === 'Attach receipt to independently complete this delivery').length === 2) + assert(old.requests.every(row => row.status === 'RESPONDED')) + const get = async target => { + const response = await context.request('GET', target) + const data = context.apiSuccess(MODULE, 'Explicit terminal reconciliation read only', response, '', OPTIONS) + assert.equal(response.http, 200); assert.equal(String(response.body?.code), '200'); assert(data); return data + } + const current = { trip: await get(`${ROOT}/fleet/trips/${encodeURIComponent(fixture.tripNo)}`), dispatch: await get(path), + items: await get(`${path}/items`), task: null, members: [] } + const pda = await context.request('GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}`, undefined, fixture.pdaToken) + assert.equal(pda.http, 200); assert.equal(String(pda.body?.code), '200'); current.task = pda.body.data + assert.equal(current.dispatch.stage, 'DELIVERED'); assert.equal(current.dispatch.status, 'ACTIVE') + for (const value of [current.items, current.task]) { + assert.equal(value.dispatchNo, fixture.dispatchNo); assert.equal(value.stage, 'DELIVERED') + assert.equal(id(value.recordVersion), id(current.dispatch.recordVersion)); assert.equal(value.items.length, 2) + } + for (const field of ['packageCount', 'pickedPackageCount', 'shippedPackageCount']) assert.equal(Number(current.dispatch.counts[field]), 2) + assert.equal(current.trip.trip.tripNo, fixture.tripNo) + assert.equal(id(current.trip.trip.vehicleId), fixture.tripProof.vehicleId); assert.equal(id(current.trip.trip.driverId), fixture.tripProof.driverId) + assert.equal(current.trip.dispatches.length, 1); assert.equal(current.trip.dispatches[0].dispatchNo, fixture.dispatchNo) + const extension = context.createdResources.dispatchWorkflowExtensions = clone(prior.createdResources.dispatchWorkflowExtensions) + extension.status = 'CHECKING'; extension.explicitTerminalContinuation = true; extension.priorFailurePreserved = true + const allFeeIds = new Set() + for (const [index, member] of fixture.members.entries()) { + const oldMember = old.members[index] + assert.equal(member.customerId, oldMember.customerId); assert.equal(member.dispatchItemId, oldMember.dispatchItemId) + for (const rows of [current.items.items, current.task.items]) { + const matches = rows.filter(row => id(row.dispatchItemId) === member.dispatchItemId) + assert.equal(matches.length, 1); assert.equal(id(matches[0].customerId), member.customerId) + assert.equal(matches[0].palletNo, member.palletNo); assert.equal(Number(matches[0].pickedPackages), 1) + } + const suffix = new URLSearchParams({ customerId: member.customerId }) + const outbound = await get(`${ROOT}/outbound-orders/${encodeURIComponent(oldMember.outboundNo)}?${suffix}`) + const delivery = await get(`${ROOT}/delivery-orders/${encodeURIComponent(oldMember.deliveryNo)}?${suffix}`) + assert.equal(outbound.outbound.dispatchNo, fixture.dispatchNo); assert.equal(outbound.outbound.status, 'OUTBOUNDED') + assert.equal(delivery.order.dispatchNo, fixture.dispatchNo); assert.equal(delivery.order.status, 'DELIVERED') + assert.equal(id(delivery.order.customerId), member.customerId); assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) + const expected = extension.expectedFees[member.customerId]; assert.equal(delivery.fees.length, expected.length) + for (const fee of delivery.fees) { + assert(!allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id)) + assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) + } + const stock = await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId }) + assert.deepEqual(stock, old.snapshots.BEFORE_SIGN_1.members[index].stock, 'Terminal completion must not change already shipped inventory') + current.members.push({ outbound, delivery, stock }) + } + assert.deepEqual(await get(path), current.dispatch, 'Terminal state changed during reconciliation') + const verified = context.createdResources.fleetCrossCustomer = { marker: context.marker, dispatchNo: fixture.dispatchNo, + tripNo: fixture.tripNo, warehouseId: fixture.warehouseId, members: clone(old.members), status: 'VERIFIED', snapshots: { FINAL: current }, + continuation: 'TERMINAL_READONLY_AFTER_CONFIRMED_SIGNATURES', priorError: prior.error, + gateProof: old.gateProof, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' } + extension.status = 'VERIFIED'; await context.checkpoint(); return verified +} diff --git a/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs new file mode 100644 index 000000000..b41fd7f62 --- /dev/null +++ b/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs @@ -0,0 +1,103 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { applyIndependentFees, feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, assertBolPages } from './dispatch-workflow-extension-acceptance.mjs' +import { parseExtensionArguments, runDispatchExtensions } from '../dispatch-workflow-extensions-test-env.mjs' + +test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => { + const plan = await runDispatchExtensions(parseExtensionArguments([]), {}) + assert.equal(plan.execute, false); assert.equal(plan.matrix.length, 5) + assert.equal(extensionMatrix.find(row => row.scenario === 'relabel').readiness, 'REQUIRES_FORMAL_RELABEL_FIXTURE') +}) +test('execution requires fresh explicit TEST scope and declared synthetic load, never coerces a plan', () => { + const args = ['--execute', '--environment', 'TEST', '--run-id', 'EXT1', '--scenario', 'dual-destination', '--warehouse-id', '10', + '--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1'] + assert.equal(parseExtensionArguments(args).execute, true) + for (const [from, to] of [['TEST', 'DEV'], ['EXT1', 'plan'], ['dual-destination', 'relabel'], ['10', ''], ['1', '0']]) + assert.throws(() => parseExtensionArguments(args.map(value => value === from ? to : value))) + assert.throws(() => parseExtensionArguments([...args, '--execute'])) +}) +test('terminal continuation requires an explicit original report hash and is never an implicit replay', () => { + assert.throws(() => parseExtensionArguments(['--scenario', 'review-terminal'])) + assert.throws(() => parseExtensionArguments(['--resume-report', 'reports/old/report.json'])) + const config = parseExtensionArguments(['--execute', '--environment', 'TEST', '--run-id', 'EXTCLOSE1', '--scenario', 'review-terminal', + '--warehouse-id', '10', '--destination-warehouse-id', '20', '--pda-env-file', 'private.env', '--synthetic-pallet-spaces', '1', + '--resume-report', 'reports/current/report.json', '--resume-report-sha256', 'a'.repeat(64)]) + assert.equal(config.scenario, 'review-terminal'); assert.equal(config.resumeReportSha256, 'a'.repeat(64)) +}) +test('actual group owner controls distinct cents even when server group ordering differs', () => { + const groups = [{ groupKey: 'B-GROUP', customerId: 'B' }, { groupKey: 'A-GROUP', customerId: 'A' }] + const body = { recordVersion: '9007199254740993', idempotencyKey: 'ORIGINAL', groups: groups.map(row => ({ groupKey: row.groupKey, + fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30 }] })) } + const expected = applyIndependentFees(body, groups, [{ customerId: 'A' }, { customerId: 'B' }], 'CURRENT') + assert.deepEqual(body.groups.map(row => row.fees[0].amount), [47.75, 31.25]) + assert.equal(expected.A[0].cents, 3125); assert.equal(expected.B[0].cents, 4775) + assert.equal(body.recordVersion, '9007199254740993'); assert.equal(body.idempotencyKey, 'ORIGINAL') + assert(body.groups.every(row => row.fees[0].feeName.includes('TEST合成'))) +}) +test('unknown customers, repeated groups and missing real required fees fail before transport', () => { + const members = [{ customerId: 'A' }, { customerId: 'B' }] + for (const groups of [[{ groupKey: 'G', customerId: 'A' }, { groupKey: 'G', customerId: 'B' }], + [{ groupKey: 'A', customerId: 'A' }, { groupKey: 'X', customerId: 'FOREIGN' }]]) { + const body = { groups: groups.map(row => ({ groupKey: row.groupKey, fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY' }] })) } + assert.throws(() => applyIndependentFees(body, groups, members, 'CURRENT')) + } + assert.throws(() => applyIndependentFees({ groups: [{ groupKey: 'A', fees: [] }, { groupKey: 'B', fees: [] }] }, + [{ groupKey: 'A', customerId: 'A' }, { groupKey: 'B', customerId: 'B' }], members, 'CURRENT')) +}) +test('dual destination proof rejects reused customer, collapsed destination and missing route stop', () => { + const fixture = { members: [{ customerId: 'A', destination: { key: 'FBA:ONE' }, receiptVerified: true }, + { customerId: 'B', destination: { key: 'FBA:TWO' }, receiptVerified: true }], + tripProof: { stops: [{}, { destinationKey: 'FBA:ONE' }, { destinationKey: 'FBA:TWO' }] } } + assert.doesNotThrow(() => assertDualDestinationFixture(fixture)) + for (const mutate of [f => { f.members[1].customerId = 'A' }, f => { f.members[1].destination.key = 'FBA:ONE' }, + f => { f.tripProof.stops.pop() }]) { const bad = structuredClone(fixture); mutate(bad); assert.throws(() => assertDualDestinationFixture(bad)) } +}) +test('fee conservation uses integer cents and rejects unsafe, negative or overprecision amounts', () => { + assert.equal(feeCents('31.25'), 3125); assert.equal(feeCents(47.75), 4775) + for (const value of [null, false, -1, '1e2', '0.001', Number.MAX_SAFE_INTEGER]) assert.throws(() => feeCents(value)) +}) + +for (const unknown of [false, true]) test(`bounded two-actor race ${unknown ? 'preserves unknown result and stops' : 'starts both requests before either returns and conserves inventory'}`, async () => { + const fixture = { dispatchNo: 'OWN', tripNo: 'TRIP', warehouseId: 'W', pdaToken: 'OWN_PDA_TOKEN', members: [{ customerId: 'A' }, { customerId: 'B' }] } + const context = { marker: 'CURRENT', createdResources: { dispatchWorkflowCompetition: { status: 'CHECKING' } }, + checkpoint: async () => {}, assert: () => {} } + const acknowledgement = { recordVersion: '8', result: { confirmedLoad: { pallets: ['P1', 'P2'] } } } + const body = { recordVersion: '7', idempotencyKey: 'ORIGINAL', measurements: ['P1', 'P2'] }, requests = [], waiting = [] + const send = async (method, path, payload, token) => { + requests.push({ method, path, payload: structuredClone(payload), token }) + if (method === 'GET') return { http: 200, body: { code: '200', data: { immutable: path } } } + return new Promise((resolve, reject) => { + waiting.push({ resolve, reject, token }) + if (waiting.length === 2) for (const entry of waiting) { + if (entry.token) entry.resolve({ http: 200, body: { code: '200', data: { ...acknowledgement, replayed: true } } }) + else if (unknown) entry.reject(Error('Unknown write outcome; preserve original key')) + else entry.resolve({ http: 200, body: { code: 'A0403', message: '该请求标识不属于当前操作员' } }) + } + }) + } + const execute = () => verifyLoadingReplayRace(context, fixture, { readInventory: async scope => ({ ...scope, available: 0, picked: 1 }) }, send, body, acknowledgement) + if (unknown) await assert.rejects(execute(), /Unknown write outcome/) + else await execute() + assert.equal(waiting.length, 2) + assert.equal(requests.filter(row => row.method === 'POST').length, 2) + assert(requests.filter(row => row.method === 'POST').every(row => JSON.stringify(row.payload) === JSON.stringify(body))) + const proof = context.createdResources.dispatchWorkflowCompetition.loadingReplayRace + assert.equal(proof.status, unknown ? 'UNKNOWN_RESULT' : 'VERIFIED') + assert.equal(proof.firstExecutionRace, 'NOT_TESTED') + if (!unknown) assert.deepEqual(proof.before, proof.after) +}) + +test('BOL PDF evidence enforces empty/same/different appointment grouping and actual quantity conservation', () => { + const fixture = { members: [{ palletNo: 'PALLET_A', incomingNo: 'PALLET_A' }, { palletNo: 'PALLET_B', incomingNo: 'PALLET_B' }] } + const pdf = (isa, pallets, total) => `BILL OF LADING\nISA #: ${isa}\n${pallets.join('\n')}\n总箱数 / Total CTNS:\n${total}\nDriver signature` + assert.equal(assertBolPages([pdf('', ['PALLET_A', 'PALLET_B'], 2)], fixture, { appointments: [] }, 'bol-empty').totalPackages, 2) + assert.equal(assertBolPages([pdf('ISA1', ['PALLET_A', 'PALLET_B'], 2)], fixture, + { appointments: [{ data: { confirmationNo: 'ISA1' } }] }, 'bol-same').pageCount, 1) + const appointments = { appointments: [{ data: { confirmationNo: 'ISA1' } }, { data: { confirmationNo: 'ISA2' } }] } + const pages = [pdf('ISA1', ['PALLET_A'], 1), pdf('ISA2', ['PALLET_B'], 1)] + assert.equal(assertBolPages(pages, fixture, appointments, 'bol-different').pageCount, 2) + for (const bad of [[pdf('ISA1', ['PALLET_A'], 2), pdf('ISA2', ['PALLET_B'], 2)], + [pdf('ISA1', ['PALLET_A', 'PALLET_B'], 1), pdf('ISA2', ['PALLET_B'], 1)], + [pdf('ISA1', ['PALLET_A'], 1), pdf('ISA1', ['PALLET_B'], 1)]]) + assert.throws(() => assertBolPages(bad, fixture, appointments, 'bol-different')) +}) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs index 699e21fc4..8289b1741 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture-contract.test.mjs @@ -26,7 +26,7 @@ function model(fault = '', countPatch = {}) { let native const cargo = () => transfers.map((transfer, n) => ({ dispatchItemId: `ITEM${n}`, customerId: transfer.customerId, warehousePalletId: `PAL${n}`, palletNo: `PALLET${n}`, scheduledPackages: 1, pickedPackages: 0, - destination: { key: 'FBA:FBA' } })) + destination: { key: `FBA:${transfer.destinationWarehouseCode}` } })) const parentFixture = { marker: 'PARENT', tenantId: 'PARENT-ID', password: 'parent-secret' } const context = { marker: 'RUN-CROSS', token: 'admin-secret', options: { customerName: 'Parent customer' }, context: { merchantQualificationFixture: parentFixture }, createdResources: { merchantQualificationFixture: { marker: 'PARENT' } }, @@ -51,6 +51,8 @@ function model(fault = '', countPatch = {}) { if (path.startsWith('/api/pda/')) assert.equal(token, options.pdaToken) if (path.endsWith('/warehouse-admin/10')) return response(origin) if (path.endsWith('/warehouse-admin/20')) return response(destination) + if (path.endsWith('/warehouse-admin/21')) return response({ ...destination, + warehouse: { ...destination.warehouse, id: '21', warehouseCode: 'FBA2', warehouseName: 'Second destination' } }) if (path === '/api/platform/v1/iam/tenants') { const n = customers.length, id = fault === 'reused customer' ? '100' : String(100 + n) const customer = { id, username: `user${n}`, password: `fresh-secret-${n}`, marker: body.internalRemark } @@ -80,7 +82,7 @@ function model(fault = '', countPatch = {}) { const transferNo = `IN${transfers.length}` const itemId = `FORECAST-ITEM${transfers.length}` const transfer = { transferNo, inboundCode: transferNo, customerId: merchant.id, operatingWarehouseId: body.operatingWarehouseId, - status: 'DRAFT', packageCount: 1, pallets: [], + status: 'DRAFT', packageCount: 1, pallets: [], destinationWarehouseCode: body.destinationWarehouseCode, items: [{ ...copy(body.items[0]), id: itemId, ...(fault === 'missing declared quantity' ? { quantity: null } : {}) }], cartons: fault === 'missing forecast carton' ? [] : [{ cartonMark: `BX-FORECAST${transfers.length}`, status: 'FORECAST', transferItemId: fault === 'foreign forecast item' ? 'OTHER-ITEM' : itemId }] } @@ -141,7 +143,7 @@ function model(fault = '', countPatch = {}) { const n = transfers.findIndex(row => row.transferNo === url.searchParams.get('orderNo')), transfer = transfers[n] assert.equal(transfer.status, 'READY_FOR_DISPATCH') return response({ complete: true, candidates: [{ customerId: fault === 'foreign candidate' ? 'OTHER' : transfer.customerId, - warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: 'FBA:FBA' }, + warehouseId: '10', inboundCode: transfer.transferNo, palletNo: `PALLET${n}`, availablePackages: 1, destination: { key: `FBA:${transfer.destinationWarehouseCode}` }, sourceVersion: `SOURCE${n}`, blockReasons: [], selection: { sourceType: 'INBOUND_PLAN', planPalletId: `PLAN${n}`, sourceVersion: `SOURCE${n}` } }] }) } if (path === '/api/platform/v1/last-leg/dispatches' && method === 'POST') { @@ -152,10 +154,11 @@ function model(fault = '', countPatch = {}) { counts: { itemCount: 2, packageCount: 2, pickedPackageCount: 0, shippedPackageCount: 0, ...countPatch }, reviews: {}, documents: { outbounds: [], deliveries: [] }, dispatchInfo: {} } trip.dispatches = [{ dispatchNo: native.dispatchNo }] - Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, { name: 'FBA', destinationKey: 'FBA:FBA' }] }) + Object.assign(trip.trip, { tripPurpose: 'DELIVERY', stops: [{ name: 'Origin' }, ...[...new Set(transfers.map(row => row.destinationWarehouseCode))] + .map(code => ({ name: code, destinationKey: `FBA:${code}` }))] }) dispatches.push(native); return response(native) } - if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber; return response({ id: 'VEHICLE' }) } + if (path.endsWith('/master-data/vehicles')) { trip.trip.vehiclePlate = body.plateNumber.toUpperCase(); return response({ id: 'VEHICLE' }) } if (path.endsWith('/fleet/drivers')) return response({ id: 'DRIVER', recordVersion: '0' }) if (path.endsWith('/fleet/drivers/DRIVER/review')) return response({ reviewStatus: 'APPROVED' }) if (path === '/api/platform/v1/last-leg/fleet/trips') return response({ records: [trip.trip], total: 1, size: 200, page: 1 }) @@ -270,6 +273,26 @@ test('native Jackson Long count strings build the same complete one-main fixture assert.ok(fixture.members.every(member => member.dispatchNo === fixture.dispatchNo)) }) +test('explicit per-member destinations are read before writes and preserved in both receipts and route', async () => { + const m = model(); m.options.destinationWarehouseIds = ['20', '21'] + const fixture = await createFleetCrossCustomerFixture(m.context, m.options) + assert.deepEqual(fixture.members.map(row => row.destination.key), ['FBA:FBA', 'FBA:FBA2']) + assert.deepEqual(m.trip.trip.stops.slice(1).map(row => row.destinationKey), ['FBA:FBA', 'FBA:FBA2']) + const secondDestination = m.calls.findIndex(row => row.raw.includes('/warehouse-admin/21')) + const firstWrite = m.calls.findIndex(row => row.method !== 'GET') + assert.ok(secondDestination >= 0 && secondDestination < firstWrite) + assert.deepEqual(m.calls.filter(row => row.method === 'POST' && row.raw === '/api/merchant/v1/last-leg/warehouse-transfers') + .map(row => row.body.destinationWarehouseId), ['20', '21']) +}) + +test('malformed member destination scope fails without network', async () => { + for (const destinations of [[], ['20'], ['20', '21', '22'], ['21', '20'], ['20', '']]) { + const m = model(); m.options.destinationWarehouseIds = destinations + await assert.rejects(createFleetCrossCustomerFixture(m.context, m.options), /Two explicit/) + assert.equal(m.calls.length, 0) + } +}) + test('fixture count parser accepts only explicit non-negative safe integers and rejects coercion', () => { for (const [value, expected] of [[0, 0], ['0', 0], [2, 2], ['2', 2], [Number.MAX_SAFE_INTEGER, Number.MAX_SAFE_INTEGER], [String(Number.MAX_SAFE_INTEGER), Number.MAX_SAFE_INTEGER]]) { assert.equal(safeFixtureCount(value), expected) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs index e617cea61..df6ad6966 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs @@ -32,11 +32,19 @@ export const preparationContract = Object.freeze({ }) /** Preparation is opt-in and only for separately authorized TEST execution. Import/default call is offline. */ -export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, pdaToken, readInventory } = {}) { +export async function createFleetCrossCustomerFixture(context, { execute = false, warehouseId, destinationWarehouseId, destinationWarehouseIds, + shipmentReferences, prepareBatchAllocations, pdaToken, readInventory } = {}) { if (!execute) return preparationContract if (!context?.marker || !id(warehouseId) || !id(destinationWarehouseId) || !pdaToken || typeof readInventory !== 'function' || ['request', 'requestWithBody', 'requestMultipart', 'findAvailablePhone', 'apiSuccess', 'assert'].some(name => typeof context[name] !== 'function')) throw new Error('Missing current-run context, public warehouse/destination, caller-owned PDA identity or raw inventory reader') + const memberDestinations = destinationWarehouseIds ?? [destinationWarehouseId, destinationWarehouseId] + if (shipmentReferences !== undefined && (!Array.isArray(shipmentReferences) || shipmentReferences.length !== 2 + || shipmentReferences.some(value => typeof value !== 'string' || !value.trim()))) throw Error('Two explicit TEST shipment references required') + if (prepareBatchAllocations !== undefined && typeof prepareBatchAllocations !== 'function') throw Error('Appointment preparation must be callable') + if (!Array.isArray(memberDestinations) || memberDestinations.length !== 2 + || memberDestinations.some(value => !id(value)) || id(memberDestinations[0]) !== id(destinationWarehouseId)) + throw new Error('Two explicit member destinations must start with the primary destination') context.createdResources ??= {} if (context.createdResources.fleetCrossCustomerFixture) throw new Error('Fixture preparation already started; retain partial resources and reconcile explicitly') const proof = context.createdResources.fleetCrossCustomerFixture = { @@ -106,18 +114,23 @@ export async function createFleetCrossCustomerFixture(context, { execute = false try { // Explicit IDs are re-read through the public master-data API before any fixture writes. const origin = await request('GET', `/api/platform/v1/warehouse-admin/${encode(warehouseId)}?tenantId=0`, undefined, 'Verify receiving warehouse') - const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationWarehouseId)}?tenantId=0`, undefined, 'Verify FBA destination') const validMaster = (detail, expectedId, type) => id(detail?.warehouse?.id) === id(expectedId) && id(detail.warehouse.tenantId) === '0' && detail.warehouse.status === 'ACTIVE' && detail.warehouse.warehouseType === type && detail.warehouse.warehouseCode && detail.warehouse.warehouseName check('Origin and destination are current public active warehouse masters', validMaster(origin, warehouseId, 'OVERSEAS') - && origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true) - && validMaster(destination, destinationWarehouseId, 'FBA')) - const address = destination.addresses?.find(row => row.addressLine1) - check('FBA destination has a real address', Boolean(address)) - const deliveryAddress = [address.countryCode, address.stateProvince, address.city, address.district, - address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ') + && origin.capabilities?.some(row => row.capabilityCode === 'RECEIVING' && row.enabled === true)) + const destinations = new Map() + for (const destinationId of [...new Set(memberDestinations.map(id))]) { + const destination = await request('GET', `/api/platform/v1/warehouse-admin/${encode(destinationId)}?tenantId=0`, undefined, 'Verify FBA destination') + check('Each explicit destination is a public active FBA master', validMaster(destination, destinationId, 'FBA')) + const address = destination.addresses?.find(row => row.addressLine1) + check('FBA destination has a real address', Boolean(address)) + destinations.set(destinationId, { destination, deliveryAddress: [address.countryCode, address.stateProvince, address.city, address.district, + address.addressLine1, address.addressLine2, address.postalCode].filter(Boolean).join(' ') }) + } for (const suffix of ['A', 'B']) { + const memberDestinationId = id(memberDestinations[suffix === 'A' ? 0 : 1]) + const { destination, deliveryAddress } = destinations.get(memberDestinationId) const member = { marker: context.marker, customerMarker: `${context.marker}-CROSS-${suffix}`, receiptVerified: false } proof.members.push(member) // Qualification helper's mutable fixture, options and resources must not touch the parent's merchant slot. @@ -147,11 +160,12 @@ export async function createFleetCrossCustomerFixture(context, { execute = false const profile = await request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, 'Verify newly authenticated customer identity', auth.accessToken) check('Merchant session belongs to the newly qualified customer', id(profile?.id) === member.customerId) const createBody = { sourceLastlegOrderIds: [], operatingWarehouseId: id(warehouseId), - fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: id(destinationWarehouseId), + fbaWarehouseCode: destination.warehouse.warehouseCode, destinationType: 'FBA', destinationWarehouseId: memberDestinationId, destinationWarehouseCode: destination.warehouse.warehouseCode, destinationWarehouseName: destination.warehouse.warehouseName, deliveryAddress, packageCount: 1, volumeCbm: 1, deliveryMethod: 'CUSTOMER_DELIVERY', estimatedArrivalAt: localTime(1800000), needRelabel: false, remark: member.customerMarker, idempotencyKey: key(`CREATE-${suffix}`), items: [{ skuCode: `CROSS-${key(suffix).slice(0, 16)}`, productNameZh: '受控测试一箱货物', packageCount: 1, quantity: 1 }] } + if (shipmentReferences) createBody.fbaShipmentNo = shipmentReferences[proof.members.length - 1] let incoming = await request('POST', MERCHANT, createBody, `Create new incoming ${suffix}`, auth.accessToken) member.incomingNo = incoming?.transferNo check('Ordinary transfer creates fresh incoming without borrowed source inventory', member.incomingNo && incoming.inboundCode === member.incomingNo @@ -234,6 +248,20 @@ export async function createFleetCrossCustomerFixture(context, { execute = false proof.createDispatchRequest = { idempotencyKey: key('CREATE_ONE_NATIVE_DISPATCH'), warehouseId: id(warehouseId), vehicle: null, driver: null, plannedDepartureAt: null, estimatedArrivalAt: null, remark: `${context.marker} TEST跨客户同仓一张排车一趟车`, cargo: proof.members.map(member => member.selection), batchAllocations: [] } + if (prepareBatchAllocations) { + proof.createDispatchRequest.batchAllocations = await prepareBatchAllocations(proof.members) + if (!Array.isArray(proof.createDispatchRequest.batchAllocations)) throw Error('Formal appointment allocations must be explicit') + for (const member of proof.members) { + const current = await request('GET', `${ROOT}/dispatches/candidates?${new URLSearchParams({ warehouseId: id(warehouseId), orderNo: member.incomingNo })}`, + undefined, 'Refresh actual candidate after formal appointment creation') + const matches = current.candidates?.filter(row => id(row.customerId) === member.customerId && id(row.selection?.warehousePalletId) === member.palletId) + check('Appointment preparation preserves exact received physical source', matches?.length === 1 && current.complete === true + && matches[0].selection?.sourceType === 'INBOUND_PLAN' && matches[0].blockReasons?.length === 0) + member.selection = matches[0].selection + } + proof.createDispatchRequest.cargo = proof.members.map(member => member.selection) + await context.checkpoint?.() + } const created = await request('POST', `${ROOT}/dispatches`, proof.createDispatchRequest, 'Create one native dispatch from both real customers') check('One native Create returns both unpicked pallets without client-supplied customer identity', created?.dispatchNo && created.status === 'ACTIVE' && created.stage === 'BEFORE_LOADING' && safeFixtureCount(created.counts?.packageCount) === 2 && safeFixtureCount(created.counts?.pickedPackageCount) === 0 && safeFixtureCount(created.counts?.shippedPackageCount) === 0) diff --git a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs index a98dc4500..ce1b26415 100644 --- a/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/grouped-dispatch-acceptance.mjs @@ -47,7 +47,14 @@ export async function dispatchEvidenceContext(context) { } export function query(path, values) { const params = new URLSearchParams() - for (const [key, value] of Object.entries(values)) if (value !== undefined && value !== null && value !== '') params.set(key, String(value)) + for (const [key, value] of Object.entries(values)) { + if (value === undefined || value === null || value === '') continue + if (Array.isArray(value)) { + for (const item of value) if (item !== undefined && item !== null && item !== '') params.append(key, String(item)) + continue + } + params.set(key, String(value)) + } return `${path}?${params}` } export function commandKey(marker, namespace, action) { @@ -112,8 +119,8 @@ export function dispatchIo(context, module, proof, namespace) { const boundary = context.createdResources.dispatchWriteBoundary ??= { marker: context.marker, stopped: false } if (!context.marker || boundary.marker !== context.marker) throw new DispatchNotRun('请求边界不属于本轮marker') const checkpoint = async () => { if (typeof context.checkpoint === 'function') await context.checkpoint() } - const check = (label, response, passed) => { - context.assert?.(module, label, response, passed, `marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true }) + const check = (label, response, passed, detail = '') => { + context.assert?.(module, label, response, passed, `${detail ? `${detail}; ` : ''}marker=${context.marker}; HTTP=${response?.http}; requestId=${response?.requestId}; traceId=${response?.traceId}`, { scenario: namespace, deploymentBlocker: true }) if (!passed) throw Error(`${namespace}: ${label}`) } const send = async (method, path, body, label) => { @@ -136,7 +143,8 @@ export function dispatchIo(context, module, proof, namespace) { } const command = async (method, path, body, label) => { const response = await send(method, path, body, label); check(label, response, success(response)); return response.body.data } const key = action => commandKey(context.marker, namespace, action) - return { check, send, command, key } + return { check, send, command, key, + planIds: (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String) } } export function replayFacts(result) { const copy = structuredClone(result); if (copy && typeof copy === 'object') delete copy.replayed; return copy } export async function replay(io, path, body, result, label, method = 'POST') { @@ -145,7 +153,9 @@ export async function replay(io, path, body, result, label, method = 'POST') { return again } export async function candidatePage(io, warehouseId, extra = {}) { - const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, { warehouseId, sourceStage: 'RECEIVED', ...extra }), undefined, '读取同仓原生候选') + const planIds = extra.planIds ?? io?.planIds + const params = { warehouseId, sourceStage: 'RECEIVED', ...(Array.isArray(planIds) && planIds.length ? { planIds } : {}), ...extra } + const result = await io.command('GET', query(`${DISPATCH_BASE}/candidates`, params), undefined, '读取同仓原生候选') io.check('候选完整包络', null, result?.complete === true && Array.isArray(result.candidates)) return result.candidates } diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index fbd696dd1..8e2151f94 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -251,38 +251,45 @@ async function reusablePallets(context, warehouseId) { if (reusablePalletsByWarehouse.has(scopedCacheKey)) return reusablePalletsByWarehouse.get(scopedCacheKey) const pallets = [] - if (allowAnyExistingPallet) { - const response = await context.request('GET', - `/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`) - const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response, - `warehouseId=${warehouseId}`, OPTIONS) - const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey - && asId(item?.id) && String(item?.palletCode || '').trim()) - context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0, - `warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS) - if (candidates.length === 0) return null - const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) })) - reusablePalletsByWarehouse.set(scopedCacheKey, selected) - return selected - } - for (const palletCode of REUSABLE_PALLET_CODES) { - const response = await context.request('GET', - `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`) - const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response, - `warehouseId=${warehouseId}`, OPTIONS) - const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey - && String(item?.palletCode || '') === palletCode && asId(item?.id)) - context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true, - `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}, fallback=disabled`, OPTIONS) - if (!exact) { - context.assert(MODULE, '固定称重托盘主数据完整', response, false, - `warehouseId=${warehouseId}, missing=${palletCode}`, OPTIONS) - return null + const missingFixed = [] + if (!allowAnyExistingPallet) { + for (const palletCode of REUSABLE_PALLET_CODES) { + const response = await context.request('GET', + `/api/platform/v1/pallets?page=1&size=20&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}&palletCode=${encodeURIComponent(palletCode)}`) + const page = context.apiSuccess(MODULE, `查询原有可复用托盘 ${palletCode}`, response, + `warehouseId=${warehouseId}`, OPTIONS) + const exact = (page?.records || []).find((item) => asId(item?.warehouseId) === cacheKey + && String(item?.palletCode || '') === palletCode && asId(item?.id)) + context.assert(MODULE, `查询原有称重托盘 ${palletCode}`, response, true, + `warehouseId=${warehouseId}, matched=${exact?.palletCode || 'missing'}`, OPTIONS) + if (!exact) { + missingFixed.push(palletCode) + continue + } + pallets.push({ id: asId(exact.id), code: palletCode }) } - pallets.push({ id: asId(exact.id), code: palletCode }) + if (missingFixed.length === 0) { + reusablePalletsByWarehouse.set(scopedCacheKey, pallets) + return pallets + } + // 固定称重托盘是环境初始化样本;缺失时降级使用当前仓库已有托盘,测量与计费仍走真实接口。 + context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失', + `warehouseId=${warehouseId}, missing=${missingFixed.join(',')};改用当前仓库已有托盘继续`, + 'fixture', { blocking: false }) } - reusablePalletsByWarehouse.set(scopedCacheKey, pallets) - return pallets + + const response = await context.request('GET', + `/api/platform/v1/pallets?page=1&size=100&tenantId=0&warehouseId=${encodeURIComponent(warehouseId)}`) + const page = context.apiSuccess(MODULE, '查询当前仓库已有可复用托盘', response, + `warehouseId=${warehouseId}`, OPTIONS) + const candidates = (page?.records || []).filter((item) => asId(item?.warehouseId) === cacheKey + && asId(item?.id) && String(item?.palletCode || '').trim()) + context.assert(MODULE, '当前仓库至少存在一个可复用托盘', response, candidates.length > 0, + `warehouseId=${warehouseId}, matched=${candidates.map((item) => item.palletCode).join('|') || 'missing'}`, OPTIONS) + if (candidates.length === 0) return null + const selected = candidates.slice(0, 2).map((item) => ({ id: asId(item.id), code: String(item.palletCode) })) + reusablePalletsByWarehouse.set(scopedCacheKey, selected) + return selected } function randomlyReusePalletIds(pallets, palletCount, randomValue) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs index ee62dac6a..9aab796ab 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch-contract.test.mjs @@ -48,7 +48,7 @@ function server(fault = '', initial = false) { if (path === `${BASE}/candidates`) { assert.equal(params.get('warehouseId'), '44'); assert.equal(params.get('sourceStage'), 'RECEIVED') const claimed = new Set([...orders.values()].flatMap(o => o.items.map(i => i.sourceKey))) - for (const d of drafts.values()) if (d.status === 'ACTIVE' && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey) + for (const d of drafts.values()) if (d.status === 'ACTIVE' && params.get('draftToken') && d.draftToken !== params.get('draftToken')) for (const c of d.sources) claimed.add(c.sourceKey) return response({ candidates: candidates.filter(c => !claimed.has(c.sourceKey)), complete: true, snapshotAt: '2026-10-19' }) } if (path === `${BASE}/drafts/containers`) { assert.equal(body.recordVersion, draft.recordVersion); draft.containers.push({ draftContainerId: String(++containerId), sequenceNo: draft.containers.length + 1, capacity: body.capacity, sourceKeys: [] }); draft.recordVersion++; return remember(draft) } diff --git a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs index a7804bb7b..fa36bebb4 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-dispatch.mjs @@ -129,7 +129,12 @@ async function prepareDraft(context, io, candidates) { const own = await candidatePage(io, candidates[0].warehouseId, { draftToken: draft.draftToken }) io.check('原草稿仅放行自己的可选来源', null, candidates.every(c => own.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) const hidden = await candidatePage(io, candidates[0].warehouseId) - io.check('无草稿身份不能选择活动占用', null, candidates.every(c => !hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0))) + io.check('草稿锁为软占用不阻断其他草稿候选', null, + candidates.every(c => hidden.some(a => a.sourceKey === c.sourceKey && a.blockReasons?.length === 0)), + `hidden=${JSON.stringify(candidates.map(c => ({ key: c.sourceKey, reasons: hidden.filter(a => a.sourceKey === c.sourceKey).map(a => a.blockReasons) }))).slice(0, 600)}`) + context.addCoverageGap?.(MODULE, 'dispatch-occupancy', '正式排车活动占用阻断', + '草稿锁是软占用,不阻断其他草稿;正式排车占用阻断由 grouped 正式整车转换与 removeControl 回查覆盖', + 'not-implemented', { blocking: false }) return draft } async function createRelabel(context, io, proof) { @@ -152,8 +157,10 @@ export async function run(context) { const proof = context.createdResources.phase4OverseasDispatch = { marker: context.marker, status: 'CHECKING', dispatchNo: null, finalStatus: null, stage: null, requests: [] } try { const fixture = context.createdResources.phase4OverseasPda + const dispatchPlanIds = () => (context.createdResources?.phase4OverseasPda?.generatedPlanIds || []).map(String) markerCandidates(context, []) let io = dispatchIo(context, MODULE, proof, 'MAIN') + io.planIds = dispatchPlanIds() const page = await candidatePage(io, fixture.overseasWarehouseId) const routeFixture = selectDispatchRouteFixture(markerCandidates(context, page)) if (!routeFixture) throw new DispatchNotRun('本轮同客户仓需两个FBA目的地及同目的地双实托,不借历史样本') @@ -161,6 +168,7 @@ export async function run(context) { ;[...selected, routeFixture.duplicate].forEach((c, n) => createBody(context.marker, `PRECHECK-${n}`, [c])) context = await dispatchEvidenceContext(context) io = dispatchIo(context, MODULE, proof, 'MAIN') + io.planIds = dispatchPlanIds() proof.removalDispatchNo = await removeControl(context, io, selected[0]) const grouped = await verifyGroupedDispatch(context, MODULE, selected) if (grouped.status !== 'GROUPED_DISPATCH_VERIFIED') throw new DispatchNotRun(grouped.reason) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index 765e56c5e..dcd59091e 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -429,7 +429,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { shipmentNo: `SHIP-${context.marker}-${index + 1}`.slice(0, 128), newSkuCode, labelRequirement: context.marker, - cartonMark: `CM-${String(context.marker).slice(-28)}-${index + 1}`, + cartonMark: nonBlank(source.cartonMark) ? source.cartonMark : `CM-${String(context.marker).slice(-28)}-${index + 1}`, fbaCartonMark: changesCartonLabel ? `FBA-${String(context.marker).slice(-27)}-${index + 1}` : null, destinationFbaWarehouseCode: changesCartonLabel ? source.destinationFbaWarehouseCode : null, operationType: changesCartonLabel ? 'CARTON_AND_SKU' : 'SKU_LABEL', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index 1b6327edc..a829c9abc 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -374,6 +374,11 @@ export async function disablePicker(context, picker, index) { function rememberGeneratedPlans(context, plans) { if (!Array.isArray(plans)) return for (const plan of plans) { + const planId = id(plan?.id || plan?.planId) + if (longId(planId)) { + const ids = context.createdResources.phase4OverseasPda.generatedPlanIds ??= [] + if (!ids.includes(planId)) ids.push(planId) + } const inboundCode = id(plan?.lines?.[0]?.inboundCode) if (!nonBlank(inboundCode)) continue const generated = Array.isArray(plan?.pallets) ? plan.pallets : [] @@ -1172,6 +1177,7 @@ export async function run(context) { completedContainerFacts: [], overseasWarehouseId: null, overseasWarehouseName: null, generatedPalletsByInbound: {}, + generatedPlanIds: [], cleanup: 'Shared warehouse, pallets and locations used by completed inventory are retained as acceptance evidence', } const merchantToken = await loginMerchantForNegativeProbe(context) diff --git a/scripts/dispatch-workflow-extensions-test-env.mjs b/scripts/dispatch-workflow-extensions-test-env.mjs new file mode 100644 index 000000000..3032f5d01 --- /dev/null +++ b/scripts/dispatch-workflow-extensions-test-env.mjs @@ -0,0 +1,172 @@ +import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'node:fs' +import { createHash } from 'node:crypto' +import { execFileSync } from 'node:child_process' +import { dirname, resolve, join } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import { runCrossCustomer } from './fleet-cross-customer-test-env.mjs' +import { createFleetCrossCustomerFixture } from './admin-test-env-modules/fleet-cross-customer-fixture.mjs' +import { finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { AcceptanceRun } from './admin-test-env-acceptance.mjs' +import { fixtureContract } from './admin-test-env-modules/fleet-cross-customer-acceptance.mjs' +import { discoverSecondDestination, verifyDualDestination, verifyReviewExtension, verifyCompetitionExtension, prepareBolAppointments, verifyBolExtension, reconcileReviewTerminal, extensionMatrix } from './admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs' + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const hash = bytes => createHash('sha256').update(bytes).digest('hex') +export function parseExtensionArguments(args) { + const config = { execute: false, environment: 'TEST', runId: 'plan', scenario: 'dual-destination', warehouseId: '', destinationWarehouseId: '', pdaEnvFile: '', syntheticPalletSpaces: null, resumeReport: '', resumeReportSha256: '', pdfPython: '' } + const seen = new Set() + for (let index = 0; index < args.length; index++) { + const name = args[index] + if (['--plan', '--execute'].includes(name)) { + if (seen.has('mode')) throw Error('Duplicate mode'); seen.add('mode'); config.execute = name === '--execute'; continue + } + const field = { '--environment': 'environment', '--run-id': 'runId', '--scenario': 'scenario', '--warehouse-id': 'warehouseId', + '--destination-warehouse-id': 'destinationWarehouseId', '--pda-env-file': 'pdaEnvFile', '--synthetic-pallet-spaces': 'syntheticPalletSpaces', + '--resume-report': 'resumeReport', '--resume-report-sha256': 'resumeReportSha256', '--pdf-python': 'pdfPython' }[name] + if (!field || seen.has(name) || !args[index + 1] || args[index + 1].startsWith('--')) throw Error('Unsupported/duplicate argument') + seen.add(name); config[field] = args[++index] + } + if (config.environment !== 'TEST' || !/^[A-Za-z0-9][A-Za-z0-9_-]{0,47}$/.test(config.runId)) throw Error('Explicit TEST/fresh run ID required') + if (config.execute && (!['dual-destination', 'reviews', 'competition', 'review-terminal', 'bol-empty', 'bol-same', 'bol-different'].includes(config.scenario) || config.runId === 'plan' || !config.pdaEnvFile + || !/^[1-9]\d*$/.test(config.warehouseId) || !/^[1-9]\d*$/.test(config.destinationWarehouseId) || config.syntheticPalletSpaces !== '1')) + throw Error('Ready scenario, explicit warehouse/destination/PDA config and synthetic pallet-spaces=1 required') + if (config.scenario === 'review-terminal' && (!config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256))) throw Error('Explicit frozen review report required') + if (config.scenario !== 'review-terminal' && (config.resumeReport || config.resumeReportSha256) + && (!config.scenario.startsWith('bol-') || !config.resumeReport || !/^[0-9a-f]{64}$/.test(config.resumeReportSha256))) + throw Error('Resume options require an explicit frozen owned checkpoint') + if (config.execute && config.scenario.startsWith('bol-') && !config.pdfPython) throw Error('Explicit PDF reader runtime required') + return config +} +export async function runDispatchExtensions(config, env = process.env) { + if (!config.execute) return { execute: false, environment: 'TEST', matrix: extensionMatrix, + limits: 'No deploy, SQL writes, permission edits, deletion, old manual main mutation, blind retries or uncontrolled fault injection.' } + if (env.CHONGCHONG_ENV !== 'test') throw Error('Only the explicitly loaded TEST configuration is accepted') + const bol = config.scenario.startsWith('bol-') + if (bol) execFileSync(config.pdfPython, ['-c', 'import pypdf'], { stdio: 'pipe', timeout: 10000 }) + let prior + if (config.scenario === 'review-terminal' || bol && config.resumeReport) { + const sourcePath = resolve(ROOT, config.resumeReport) + const prefix = bol ? 'fleet-cross-DISPATCH20261020EXTBOL' : 'fleet-cross-DISPATCH20261020EXTREV' + if (!sourcePath.startsWith(join(ROOT, 'reports', prefix))) throw Error('Only this extension-owned frozen report can be reconciled') + const bytes = readFileSync(sourcePath) + if (hash(bytes) !== config.resumeReportSha256) throw Error('Original review report changed') + prior = JSON.parse(bytes) + if (bol) { + if (prior.success !== false || prior.error !== 'Expected values to be strictly equal:\n\n0 !== 1\n' + || prior.createdResources?.dispatchWorkflowBol?.status !== 'CHECKING' || prior.createdResources.dispatchWorkflowBol.scenario !== config.scenario + || prior.createdResources?.fleetCrossCustomerFixture?.status !== 'READY' || prior.createdResources?.fleetCrossCustomer + || prior.createdResources?.fleetWriteBoundary || prior.createdResources?.fleetTrips?.[0]?.closure) + throw Error('Not the bounded PDF long-pallet-code assertion checkpoint') + } else if (prior.success !== false || prior.error !== 'Native main/items/PDA retain both true customers and physical pallets' + || prior.createdResources?.dispatchWorkflowReviews?.status !== 'VERIFIED' || prior.createdResources?.fleetWriteBoundary + || prior.createdResources?.fleetTrips?.[0]?.closure) throw Error('Not the bounded known terminal-read race') + for (let number = 1; number <= prior.requestCount; number++) + if (JSON.parse(readFileSync(join(dirname(sourcePath), 'requests', `${number}.json`))).stage !== 'RESPONSE') throw Error('Prior request outcome uncertain; terminal mode forbidden') + } + const reportDirectory = join(ROOT, 'reports', `fleet-cross-${config.runId}`) + const intentPath = join(ROOT, 'reports', `dispatch-workflow-${config.runId}.intent.json`) + if (existsSync(reportDirectory) || existsSync(intentPath)) throw Error('Previous attempt exists; retain original requests and reconcile, never re-enter') + const pda = Object.fromEntries(readFileSync(config.pdaEnvFile, 'utf8').replace(/^\uFEFF/, '').split(/\r?\n/) + .filter(line => /^[A-Z][A-Z0-9_]*=/.test(line)).map(line => { const separator = line.indexOf('='); + return [line.slice(0, separator), line.slice(separator + 1).trim().replace(/^["']|["']$/g, '')] })) + if (!pda.PDA_USERNAME || !pda.PDA_PASSWORD) throw Error('Missing authorized existing overseas PDA credentials') + const files = ['scripts/dispatch-workflow-extensions-test-env.mjs', 'scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs', + 'scripts/admin-test-env-modules/fleet-cross-customer-fixture.mjs', 'scripts/fleet-cross-customer-test-env.mjs', + 'scripts/admin-test-env-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs', 'scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs'] + const seal = Object.fromEntries(files.map(path => [path, hash(readFileSync(join(ROOT, path)))])) + const stable = () => { for (const [path, expected] of Object.entries(seal)) if (hash(readFileSync(join(ROOT, path))) !== expected) throw Error(`Source drift: ${path}`) } + mkdirSync(join(ROOT, 'reports'), { recursive: true }) + writeFileSync(intentPath, JSON.stringify({ runId: config.runId, scenario: config.scenario, environment: 'TEST', seal, + createdAt: new Date().toISOString(), syntheticTestMaterials: true, physicalMeasurementVerified: false, hardwareVerified: false, + originalManualMain: 'READ_ONLY_NOT_USED', resumeReportSha256: prior ? config.resumeReportSha256 : null, + midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION' }, null, 2), { flag: 'wx' }) + const result = await runCrossCustomer(config, { + env: { ...env, DISPATCH_EXISTING_PDA_PASSWORD: pda.PDA_PASSWORD }, + contextFactory: options => { const context = new AcceptanceRun(options); if (prior) context.marker = prior.marker; return context }, + pickerCreate: async context => { + context.createdResources.extensionIdentity = { mode: 'EXISTING_TEST_PDA_OWNED_NEW_SESSION', accountMutation: false } + const response = await context.request('POST', '/api/auth/login', { loginId: pda.PDA_USERNAME, password: pda.PDA_PASSWORD }, '') + if (response.http !== 200 || String(response.body?.code) !== '200' || !response.body.data?.accessToken || !response.body.data.refreshToken) throw Error('Existing PDA login failed') + return { membershipId: null, password: pda.PDA_PASSWORD, token: response.body.data.accessToken, refreshToken: response.body.data.refreshToken } + }, + pickerDisable: async () => { throw Error('Existing identity must never be disabled') }, + prepare: async (context, options) => { + stable(); context.createdResources.extensionSourceSeal = seal + if (prior) { + const tripProof = structuredClone(prior.createdResources.fleetTrips[0]) + context.createdResources.fleetTrips = [tripProof] + context.createdResources[bol ? 'explicitBolCheckpoint' : 'explicitReviewTerminal'] = { sourceReport: config.resumeReport, sourceReportSha256: config.resumeReportSha256, + originalFailurePreserved: true, fulfillmentReplay: false, fixtureCreationReplay: false } + if (bol) { + context.createdResources.dispatchWorkflowBolAppointments = structuredClone(prior.createdResources.dispatchWorkflowBolAppointments) + context.createdResources.fleetCrossCustomerFixture = structuredClone(prior.createdResources.fleetCrossCustomerFixture) + return { marker: prior.marker, stage: fixtureContract.stage, dispatchNo: tripProof.dispatchNo, tripNo: tripProof.tripNo, + warehouseId: config.warehouseId, pdaToken: options.pdaToken, tripProof, + members: structuredClone(prior.createdResources.fleetCrossCustomerFixture.members), + loadingMeasurements: structuredClone(prior.createdResources.extensionSyntheticLoadingInput) } + } + const members = prior.createdResources.fleetCrossCustomerFixture.members.map(member => { + const match = prior.createdResources.fleetCrossCustomer.members.find(row => row.customerId === member.customerId) + if (!match) throw Error('Original receipt/member attribution missing') + return { ...structuredClone(member), ...structuredClone(match) } + }) + return { marker: prior.marker, stage: fixtureContract.stage, dispatchNo: tripProof.dispatchNo, tripNo: tripProof.tripNo, + warehouseId: config.warehouseId, pdaToken: options.pdaToken, tripProof, members } + } + if (bol) { + const master = await context.request('GET', `/api/platform/v1/warehouse-admin/${encodeURIComponent(config.destinationWarehouseId)}?tenantId=0`) + const timezone = master.body?.data?.warehouse?.timezone + if (master.http !== 200 || String(master.body?.code) !== '200' || !/^[A-Za-z_]+\/[A-Za-z_]+(?:\/[A-Za-z_]+)?$/.test(timezone || '')) + throw Error('BOL appointment fixtures require an existing FBA destination with an actual regional timezone before any fixture write') + } + const secondId = bol ? config.destinationWarehouseId : await discoverSecondDestination(context, config.destinationWarehouseId) + const fixture = await createFleetCrossCustomerFixture(context, { ...options, destinationWarehouseIds: [config.destinationWarehouseId, secondId], + ...(bol ? { shipmentReferences: ['A', 'B'].map(suffix => `TEST-FBA-${context.marker.slice(-12)}-${suffix}`), + prepareBatchAllocations: members => prepareBolAppointments(context, members, config.scenario, config.warehouseId) } : {}) }) + fixture.loadingMeasurements = { environment: 'TEST', marker: context.marker, dispatchNo: fixture.dispatchNo, syntheticTestMaterials: true, + physicalMeasurementVerified: false, hardwareVerified: false, pallets: fixture.members.map(member => ({ warehousePalletId: member.palletId, + palletSpaces: 1, evidence: [{ field: 'palletSpaces', value: 1, actual: true, sourceType: 'TEST_SYNTHETIC_LOADING_INPUT', + sourceBusinessNo: config.runId, observedAt: new Date().toISOString() }] })) } + context.createdResources.extensionSyntheticLoadingInput = fixture.loadingMeasurements + await context.checkpoint(); stable(); return fixture + }, + verify: async (...args) => { + stable() + if (prior && !bol) return reconcileReviewTerminal(...args, prior) + if (bol) { + const [context, fixture, options] = args + return verifyBolExtension(context, fixture, { ...options, bol: config.scenario, inspectBol: async () => { + const evidence = join(reportDirectory, 'evidence'), pdfPath = join(evidence, 'BOL.pdf'), journal = join(evidence, 'bol-http.json') + const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/prints/bol` + const record = { method: 'GET', path, stage: 'INTENT', numberOfBinaryRequests: 1 } + writeFileSync(journal, JSON.stringify(record, null, 2), { flag: 'wx' }); await context.checkpoint() + const response = await context.requestBinary('GET', path) + Object.assign(record, { stage: 'RESPONSE', http: response.http, contentType: response.contentType, + requestId: response.requestId, traceId: response.traceId, bytes: response.bytes.length, sha256: hash(response.bytes) }) + writeFileSync(journal, JSON.stringify(record, null, 2)) + if (response.http !== 200 || !response.contentType.includes('application/pdf') || Buffer.from(response.bytes).subarray(0, 5).toString() !== '%PDF-') + throw Error('Actual BOL endpoint did not return a PDF; preserve response metadata') + writeFileSync(pdfPath, response.bytes, { flag: 'wx' }) + const result = JSON.parse(execFileSync(config.pdfPython, ['-c', + 'import json,sys; from pypdf import PdfReader; r=PdfReader(sys.argv[1]); print(json.dumps({"pages":[p.extract_text() for p in r.pages]}))', pdfPath], + { encoding: 'utf8', timeout: 30000 })) + writeFileSync(join(evidence, 'bol-pages.json'), JSON.stringify(result, null, 2), { flag: 'wx' }) + context.createdResources.dispatchWorkflowBol.pdf = { path: 'evidence/BOL.pdf', ...record, pageCount: result.pages.length } + await context.checkpoint(); return result.pages + } }) + } + return (config.scenario === 'reviews' ? verifyReviewExtension : config.scenario === 'competition' ? verifyCompetitionExtension : verifyDualDestination)(...args) + }, + finish: async (...args) => { stable(); return finishFleetTrip(...args) }, + }) + return { success: result.success, error: result.error, runId: config.runId, requestCount: result.requestCount, summary: result.summary, + sessionsClosed: result.sessionsClosed, scenario: config.scenario, extensionStatus: result.createdResources?.dispatchWorkflowExtensions?.status, + reviewStatus: result.createdResources?.dispatchWorkflowReviews?.status, + competitionStatus: result.createdResources?.dispatchWorkflowCompetition?.status, + bolStatus: result.createdResources?.dispatchWorkflowBol?.status, + report: join(reportDirectory, 'report.json'), syntheticTestMaterials: true, hardwareVerified: false, physicalMeasurementVerified: false } +} +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const result = await runDispatchExtensions(parseExtensionArguments(process.argv.slice(2))) + console.log(JSON.stringify(result)); if (result.success === false) process.exitCode = 1 +} diff --git a/scripts/local-check.ps1 b/scripts/local-check.ps1 index db47323b7..4f3c9d6ea 100644 --- a/scripts/local-check.ps1 +++ b/scripts/local-check.ps1 @@ -12,27 +12,42 @@ $ErrorActionPreference = 'Stop' if (-not $IsWindows -or $PSVersionTable.PSVersion.Major -lt 7) { throw 'local-check requires Windows and PowerShell 7 (pwsh).' } -# 所有工作目录使用同一用户级锁;句柄随进程退出关闭,不依赖删除过期锁文件。 +# 所有工作目录共用两个用户级锁;首槽保留原锁名,运行中的旧入口仍计入容量。 $stateDirectory = Join-Path $env:LOCALAPPDATA 'Chongchong/LocalChecks' [IO.Directory]::CreateDirectory($stateDirectory) | Out-Null -$lockPath = Join-Path $stateDirectory 'heavy.lock' -$ownerPath = Join-Path $stateDirectory 'owner.json' -$resultPath = Join-Path $stateDirectory 'last-result.json' -function Read-Owner { - try { return [IO.File]::ReadAllText($ownerPath) } catch { return 'Owner information is being updated.' } +$slotDefinitions = @( + [pscustomobject]@{ number = 1; lock = (Join-Path $stateDirectory 'heavy.lock'); owner = (Join-Path $stateDirectory 'owner.json'); result = (Join-Path $stateDirectory 'last-result.json') }, + [pscustomobject]@{ number = 2; lock = (Join-Path $stateDirectory 'heavy-2.lock'); owner = (Join-Path $stateDirectory 'owner-2.json'); result = (Join-Path $stateDirectory 'last-result-2.json') } +) +function Read-Owner($definition) { + try { return [IO.File]::ReadAllText($definition.owner) } catch { return 'Owner information is being updated.' } } -function Open-Slot { - try { return [IO.File]::Open($lockPath, 'OpenOrCreate', 'ReadWrite', 'None') } +function Open-Slot($definition) { + try { return [IO.File]::Open($definition.lock, 'OpenOrCreate', 'ReadWrite', 'None') } catch [IO.IOException] { if (($_.Exception.HResult -band 0xffff) -notin @(32, 33)) { throw } return $null } } +function Open-AvailableSlot { + foreach ($definition in $slotDefinitions) { + $handle = Open-Slot $definition + if ($handle) { return [pscustomobject]@{ definition = $definition; handle = $handle } } + } + return $null +} if ($Status) { - $probe = Open-Slot - if ($probe) { $probe.Dispose(); Write-Host 'IDLE: heavy check slot is free.' } - else { Write-Host 'BUSY:'; Write-Host (Read-Owner) } - if (Test-Path -LiteralPath $resultPath) { Write-Host 'Last result:'; Write-Host ([IO.File]::ReadAllText($resultPath)) } + $busyCount = 0 + foreach ($definition in $slotDefinitions) { + $probe = Open-Slot $definition + if ($probe) { $probe.Dispose(); Write-Host "IDLE: slot $($definition.number)/2 is free." } + else { $busyCount++; Write-Host "BUSY: slot $($definition.number)/2"; Write-Host (Read-Owner $definition) } + if (Test-Path -LiteralPath $definition.result) { + Write-Host "Last result for slot $($definition.number):" + try { Write-Host ([IO.File]::ReadAllText($definition.result)) } catch { Write-Host 'Result is being updated.' } + } + } + Write-Host "Capacity: $busyCount/2 slots occupied." exit 0 } if (-not $Command -or -not $Command[0]) { throw 'Pass -Command as an array, for example @("pnpm", "typecheck").' } @@ -51,20 +66,24 @@ $owner = $null $exitCode = 1 try { $nextNotice = 0 - while (-not ($slot = Open-Slot)) { + while (-not ($lease = Open-AvailableSlot)) { if ($timer.Elapsed.TotalSeconds -ge $WaitSeconds) { Write-Host "QUEUE TIMEOUT after $WaitSeconds seconds. No check was started." exit 75 } if ($timer.Elapsed.TotalSeconds -ge $nextNotice) { - Write-Host "WAITING for the shared heavy check slot: $(Read-Owner)" + $owners = @($slotDefinitions | ForEach-Object { "slot $($_.number)/2: $(Read-Owner $_)" }) -join ' | ' + Write-Host "WAITING for either shared heavy check slot: $owners" $nextNotice = $timer.Elapsed.TotalSeconds + 15 } Start-Sleep -Milliseconds 250 } - $owner = [ordered]@{ label = $Label; pid = $PID; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') } + $slot = $lease.handle + $ownerPath = $lease.definition.owner + $resultPath = $lease.definition.result + $owner = [ordered]@{ label = $Label; pid = $PID; slot = $lease.definition.number; directory = $directory; tool = $Command[0]; startedAt = [DateTimeOffset]::Now.ToString('o') } [IO.File]::WriteAllText($ownerPath, ($owner | ConvertTo-Json -Compress)) - Write-Host "RUNNING [$Label] in $directory (one shared slot; timeout ${TimeoutSeconds}s)" + Write-Host "RUNNING [$Label] in $directory (shared slot $($lease.definition.number)/2; timeout ${TimeoutSeconds}s)" # Job Object 在包装器被强制结束时也回收本次子进程树,不影响其他 Node/Java。 if (-not ('LocalCheckJob' -as [type])) { diff --git a/scripts/local-check.test.mjs b/scripts/local-check.test.mjs index 76e7fec79..7f62c1775 100644 --- a/scripts/local-check.test.mjs +++ b/scripts/local-check.test.mjs @@ -34,7 +34,7 @@ async function until(check, message) { assert.fail(message) } -test('local-check serializes different directories, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 90000 }, async t => { +test('local-check bounds different directories to two slots, handles failures and recovers process trees', { skip: process.platform !== 'win32', timeout: 120000 }, async t => { const state = await mkdtemp(path.join(os.tmpdir(), 'local-check-test-')) const processes = [] const start = (...args) => { const result = run(state, ...args); processes.push(result); return result } @@ -46,19 +46,31 @@ test('local-check serializes different directories, handles failures and recover assert.match(result.output, /"2","--max-old-space-size=2048"/) assert.match(result.output, /ActiveProcessorCount=2 -Xmx1536m/) }) - await t.test('a running check excludes another directory and queue timeout starts nothing', async () => { - const first = start([process.execPath, '-e', 'console.log("HOLDER_READY");setTimeout(()=>{},4000)']) - await until(() => first.output().includes('HOLDER_READY'), first.output()) + await t.test('two directories run together; a third times out or waits until one lease is released', async () => { + const releaseFirst = path.join(state, 'release-first'), releaseSecond = path.join(state, 'release-second') + const hold = (file, label) => `const fs=require('fs');console.log(${JSON.stringify(label)});const timer=setInterval(()=>{if(fs.existsSync(${JSON.stringify(file)}))clearInterval(timer)},50)` + const first = start([process.execPath, '-e', hold(releaseFirst, 'FIRST_READY')], { timeout: 30 }) + await until(() => first.output().includes('FIRST_READY'), first.output()) + const second = start([process.execPath, '-e', hold(releaseSecond, 'SECOND_READY')], { cwd: path.dirname(launcher), timeout: 30 }) + await until(() => second.output().includes('SECOND_READY'), second.output()) + assert.equal(first.child.exitCode, null) + const owners = await Promise.all(['owner.json', 'owner-2.json'].map(name => readFile(path.join(state, 'Chongchong', 'LocalChecks', name), 'utf8').then(JSON.parse))) + assert.deepEqual(owners.map(owner => owner.slot).sort(), [1, 2]) + assert.equal(new Set(owners.map(owner => owner.directory)).size, 2) const timedOut = await start([process.execPath, '-e', 'console.log("SHOULD_NOT_RUN")'], { wait: 0, cwd: path.dirname(launcher) }).done assert.equal(timedOut.code, 75, timedOut.output) assert.doesNotMatch(timedOut.output, /SHOULD_NOT_RUN/) - const second = start([process.execPath, '-e', 'console.log("SECOND_READY")'], { cwd: path.dirname(launcher) }) - await until(() => second.output().includes('WAITING'), second.output()) - assert.doesNotMatch(second.output(), /SECOND_READY/) + const third = start([process.execPath, '-e', 'console.log("THIRD_READY")'], { cwd: state }) + await until(() => third.output().includes('WAITING'), third.output()) + assert.doesNotMatch(third.output(), /THIRD_READY/) + await writeFile(releaseFirst, '') assert.equal((await first.done).code, 0) - const result = await second.done + const result = await third.done assert.equal(result.code, 0, result.output) - assert.match(result.output, /SECOND_READY/) + assert.match(result.output, /THIRD_READY/) + assert.equal(second.child.exitCode, null) + await writeFile(releaseSecond, '') + assert.equal((await second.done).code, 0) }) await t.test('execution timeout kills descendants and frees the slot', async () => { const pidFile = path.join(state, 'descendant.pid') From 0dd9c8db073960173081f0edc1801c1ff94eb63b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 16:45:04 +0800 Subject: [PATCH 039/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...t-dispatch-navigation-20261020-targeted.py | 10 + deploy/sql/README.md | 2 + .../sql/lastleg-dispatch-daily-sequence.sql | 7 + deploy/sql/海外区域统一增量-MySQL8.sql | 8 +- lastleg-service/docs/business-index.md | 1 + ...astleg-internal-modularization-baseline.md | 6 +- .../analyze-lastleg-dependencies.test.mjs | 35 +- .../fleet/DispatchDetailReadAdapter.java | 24 ++ .../dispatch/port/DispatchDetailReader.java | 7 + .../repository/DispatchNumberSequence.java | 29 ++ .../service/DispatchAggregateService.java | 6 +- .../service/DispatchNumberGenerator.java | 33 ++ .../gateway/HttpOwnedBusinessFileGateway.java | 7 +- .../mapper/OwnedBusinessQueryMapper.java | 10 +- .../model/OwnedBusinessRows.java | 14 +- .../repository/OwnedBusinessRepository.java | 3 +- .../OwnedBusinessOwnershipService.java | 1 + .../service/OwnedBusinessProjection.java | 8 +- .../service/OwnedBusinessQueryService.java | 6 +- .../service/OwnedBusinessRecordService.java | 14 +- .../fleet/trip/FleetTripCalendarRow.java | 12 + .../fleet/trip/FleetTripCalendarService.java | 9 +- .../lastleg/fleet/trip/FleetTripMapper.java | 19 +- .../trip/PlatformFleetTripController.java | 5 +- .../src/main/resources/db/lastleg.sql | 8 +- .../resources/openapi/fleet-trips-v1.yaml | 13 +- .../fleet/DispatchDetailReadAdapterTest.java | 26 ++ .../DispatchNumberSequenceTest.java | 59 ++++ .../DispatchAggregatePersistenceTest.java | 3 + .../service/DispatchNumberGeneratorTest.java | 31 ++ .../ownedbusiness/OwnedBusinessFixtures.java | 10 +- .../OwnedBusinessOwnershipTest.java | 9 + .../OwnedBusinessPersistenceTest.java | 44 ++- .../ownedbusiness/OwnedBusinessQueryTest.java | 8 +- .../trip/FleetTripCalendarMapperTest.java | 17 +- .../trip/FleetTripCalendarServiceTest.java | 20 +- .../lastleg-slice-dependencies.json | 25 ++ scripts/README.md | 6 + scripts/admin-test-env-modules/README.md | 4 +- ...dispatch-workflow-extension-acceptance.mjs | 322 +++++++++++++++++- ...patch-workflow-extension-contract.test.mjs | 41 ++- .../dispatch-workflow-extensions-test-env.mjs | 42 ++- scripts/synchronous-dependency-baseline.mjs | 3 + .../synchronous-dependency-contract.test.mjs | 15 +- 44 files changed, 889 insertions(+), 93 deletions(-) create mode 100644 .logs/appointment-dispatch-navigation-20261020-targeted.py create mode 100644 deploy/sql/lastleg-dispatch-daily-sequence.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapter.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDetailReader.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRow.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapterTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGeneratorTest.java diff --git a/.logs/appointment-dispatch-navigation-20261020-targeted.py b/.logs/appointment-dispatch-navigation-20261020-targeted.py new file mode 100644 index 000000000..a22250bca --- /dev/null +++ b/.logs/appointment-dispatch-navigation-20261020-targeted.py @@ -0,0 +1,10 @@ +import subprocess, shutil, xml.etree.ElementTree as ET +from pathlib import Path +root=Path.cwd() +report=root/'lastleg-service/target/surefire-reports/TEST-com.baiyue.lastleg.dispatch.service.DispatchAggregatePersistenceTest.xml' +properties=ET.parse(report).getroot().find('properties') +classpath=next(item.attrib['value'] for item in properties if item.attrib['name']=='java.class.path') +sources=[str(root/'lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip'/name) for name in ['FleetTripCalendarServiceTest.java','FleetTripCalendarMapperTest.java']] +subprocess.run(['javac','-encoding','UTF-8','--release','17','-cp',classpath,'-d',str(root/'lastleg-service/target/test-classes'),*sources],check=True) +result=subprocess.run([shutil.which('mvn'),' -T'.strip(),'1','-DforkCount=1','-DreuseForks=true','-pl','lastleg-service','surefire:test','-Dtest=FleetTripCalendarServiceTest,FleetTripCalendarMapperTest'],check=False) +raise SystemExit(result.returncode) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 90a27fb40..784182525 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。 + 自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。 客户计费重独立确认新增 `fl_chargeable_weight_confirmation`,源表与国内空库基线同步;存量 TEST 使用 `migrations/cn/baiyue_firstleg/V8__chargeable_weight_confirmation.sql`,不回填费用确认。须随 Firstleg、Order、Lastleg、Admin 和 Client 匹配版本核对;本次未执行 SQL、未部署。 diff --git a/deploy/sql/lastleg-dispatch-daily-sequence.sql b/deploy/sql/lastleg-dispatch-daily-sequence.sql new file mode 100644 index 000000000..ca4740ee2 --- /dev/null +++ b/deploy/sql/lastleg-dispatch-daily-sequence.sql @@ -0,0 +1,7 @@ +-- 排车新编号所需独立结构;仅新建序列表,不改写已有排车业务号或业务数据。 +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence ( + business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', + PRIMARY KEY(business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 6ce789b99..a82791968 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1865,6 +1865,12 @@ CREATE TABLE ll_order_no_daily_sequence ( PRIMARY KEY (business_date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水'; +CREATE TABLE ll_dispatch_daily_sequence ( + business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', + PRIMARY KEY(business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; + CREATE TABLE ll_order_operation_log ( id bigint NOT NULL COMMENT '操作记录主键', customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户数据隔离键', @@ -4921,7 +4927,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( CONSTRAINT ck_owned_business_record_version CHECK (record_version>=0), CONSTRAINT ck_owned_business_money CHECK ( (currency IS NULL AND total_amount IS NULL) - OR (currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)), + OR (currency IS NOT NULL AND currency IN ('CNY','USD') AND total_amount IS NOT NULL AND total_amount>=0)), CONSTRAINT ck_owned_business_distance CHECK (actual_distance_miles IS NULL OR actual_distance_miles>=0), CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 37aa6e714..d1c7c2c63 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -85,6 +85,7 @@ - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 - 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 +- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。 - 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index dc68a0cbe..2ac4107d3 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -24,6 +24,10 @@ 既有预约执行与改量只经 `dispatch.port.DispatchAppointmentAmendmentPort` 进入整车事务边界。`FbaAppointmentExecutionAdapter` 对该端口、`DispatchContracts`、`DispatchTypes`,以及 `FbaAppointmentTransportAdapter` 对该端口、`DispatchCommands`、`DispatchContracts`、`DispatchTypes` 的七条精确类引用进入 `reviewedClassDependencies`;不增加 `inbound-pda→dispatch-delivery` 的切片通用许可。适配器不得直连整车 Service、Repository 或 Mapper,其他消费者不继承本项许可,反向合同同时核对这两类越界。此边界只覆盖原预约与现行整车的关联、执行和同事务变更,不授权新增日历预约权威或流程。 +### 自有车辆运营视图读取边界(2026-10-20) + +`fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。 + ### 平台自营统计边界 `selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。 @@ -32,7 +36,7 @@ 车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。 -当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环、fleet不反向导入dispatch/delivery、dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去这条聚合环。 +当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上节自有运营的五条公开只读引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。 2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index 44371e5bc..aa50f7c47 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -9,6 +9,13 @@ const moduleRoot = path.resolve(import.meta.dirname, '..'); const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs'); const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; +const ownedBusinessReadEdges = [ + ['model.OwnedBusinessModels', 'DispatchViews'], + ['service.OwnedBusinessProjection', 'DispatchContracts'], + ['service.OwnedBusinessQueryService', 'DispatchContracts'], + ['service.OwnedBusinessQueryService', 'DispatchTypes'], + ['service.OwnedBusinessQueryService', 'DispatchDetailReader'], +].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort(); test('selfoperated analytics is independent of customer outbound and inventory slices', () => { const report = analyze(); @@ -42,13 +49,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain'); assert.equal(policyClasses.length, 3); for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.'))); - assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['overseas-master-data', 'shared-technical']); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', 'shared-technical']); for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) { assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters'); } assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false); - assert.equal(report.classDependencies.some(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') - && /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)), false); + assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') + && /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges); const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(dispatchEdges.length > 0); @@ -58,13 +65,33 @@ test('fleet stays a business slice and its policies have no infrastructure depen || edge.source === 'com.baiyue.lastleg.dispatch.fleet.FleetDispatchPlanningMapper' && edge.target === 'com.baiyue.lastleg.fleet.trip.FleetDispatchPlanningGateway' || edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController' - && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery'), + && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery' + || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' + && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'), 'dispatch must use fleet ports and guards, not its persistence mappers'); const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(incomingEdges.length > 0); assert.ok(incomingEdges.every(edge => edge.source === 'com.baiyue.lastleg.transfer.fleet.FleetIncomingAdapter' && /\.(FleetBookingIncomingGateway|FleetDispatchGateway)$/.test(edge.target)), 'incoming adapter uses fleet ports only'); }); +test('owned business reads only reviewed dispatch ports and cannot inherit persistence access', () => { + const report = analyze(); + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const prefix = 'com.baiyue.lastleg.fleet.ownedbusiness.'; + const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.dispatch.')); + assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges); + assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false); + for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) { + const edge = { source: prefix + 'service.OwnedBusinessQueryService', target: 'com.baiyue.lastleg.dispatch.' + target }; + report.classDependencies.push(edge); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`))); + } + const unreviewed = { source: 'com.baiyue.lastleg.fleet.trip.FleetTripService', target: 'com.baiyue.lastleg.dispatch.port.DispatchDetailReader' }; + report.classDependencies.push(unreviewed); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`))); +}); + function analyze(sourceSet = 'main') { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { cwd: moduleRoot, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapter.java new file mode 100644 index 000000000..9e1afdab7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/fleet/DispatchDetailReadAdapter.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.dispatch.fleet; + +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchDetailReader; +import com.baiyue.lastleg.dispatch.port.DispatchViews; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import org.springframework.stereotype.Component; + +/** 只读委托既有排车应用服务,运营域不引用主流程实现或持久化实体。 */ +@Component +public class DispatchDetailReadAdapter implements DispatchDetailReader { + private final DispatchAggregateService dispatch; + + /** 复用唯一原排车详情提供者,不建立第二套查询规则。 */ + public DispatchDetailReadAdapter(DispatchAggregateService dispatch) { + this.dispatch = dispatch; + } + + /** 原单资格与可见性仍由原应用服务核对。 */ + @Override + public DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo) { + return dispatch.detail(actor, dispatchNo); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDetailReader.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDetailReader.java new file mode 100644 index 000000000..4225b40e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDetailReader.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.dispatch.port; + +/** 原排车详情的只读应用端口,供运营视图组合真实履约事实。 */ +public interface DispatchDetailReader { + /** 沿原排车读取及身份守卫,不创建单据、不推进状态。 */ + DispatchViews.Detail detail(DispatchContracts.Actor actor, String dispatchNo); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java new file mode 100644 index 000000000..b24f3ba13 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.dispatch.repository; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDate; + +/** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */ +@Repository +public class DispatchNumberSequence { + private final JdbcTemplate jdbc; + + /** 流水与排车主单使用相同服务数据库。 */ + public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; } + + /** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(LocalDate date) { + jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date); + Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败"); + if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满"); + return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 449149519..7c5c760be 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -10,6 +10,7 @@ import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture; import com.baiyue.lastleg.fleet.trip.FleetExecutionModels; import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; import com.fasterxml.jackson.core.JsonProcessingException; @@ -31,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ @Service public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + @jakarta.annotation.Resource private DispatchNumberGenerator numbers; @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @@ -47,7 +49,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA private final ObjectMapper json; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads; - @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture ownedBusinessOwnership; + @jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership; /** 领域编排只访问Repository和提供者应用入口。 */ public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources, @@ -268,7 +270,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var replay = commands.replay(actor,null,identity); if (replay.isPresent()) return replay.get(); long id=IdWorker.getId(); - String number="DS-"+UUID.randomUUID().toString().replace("-","").toUpperCase(Locale.ROOT); + String number=numbers.next(); var shell=fleet.createDispatchShell(actor.operatorId(),new FleetExecutionModels.DispatchShellRequest(id,number, Long.parseLong(request.warehouseId()),request.idempotencyKey(),identity.commandHash(),request.plannedDepartureAt(), fleetVehicle(request.vehicle()),fleetDriver(request.driver()),request.remark())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java new file mode 100644 index 000000000..0acba1e36 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchNumberGenerator.java @@ -0,0 +1,33 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.repository.DispatchNumberSequence; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; + +/** 平台排车单号:PC、北京时间日期与每日独立四位流水。 */ +@Component +public class DispatchNumberGenerator { + private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + private final DispatchNumberSequence sequences; + private final Clock clock; + + /** 正式运行按统一业务时区取当天日期。 */ + @Autowired + public DispatchNumberGenerator(DispatchNumberSequence sequences) { this(sequences, Clock.systemUTC()); } + + /** 固定时钟用于核实跨午夜归零边界。 */ + DispatchNumberGenerator(DispatchNumberSequence sequences, Clock clock) { this.sequences = sequences; this.clock = clock; } + + /** 已有命令重放由上层账本恢复原号,本方法只用于首次建单。 */ + public String next() { + LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE)); + long value = sequences.next(date); + if (value < 1 || value > 9999) throw new IllegalStateException("排车日流水不在四位范围内"); + return "PC" + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format(Locale.ROOT, "%04d", value); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java index 2d439889f..a962aa57b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/gateway/HttpOwnedBusinessFileGateway.java @@ -11,7 +11,8 @@ import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import java.time.LocalDateTime; import java.util.Set; -import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; /** File保存照片,本域另行授权原排车及分类引用。 */ @Component @@ -76,6 +77,10 @@ public class HttpOwnedBusinessFileGateway implements OwnedBusinessFileGateway { byte[] png={(byte)137,80,78,71,13,10,26,10};if(!"image/png".equals(type) || bytes.lengthSELECT * FROM ll_dispatch_item WHERE dispatch_order_id IN #{id} ORDER BY dispatch_order_id,id") - List cargo(@Param("ids") List ids); + @Select("") + List cargo(@Param("ids") List ids); /** 只聚合真实FBA分配,目的仓与数量保持原事实,不按客户重复整车。 */ @Select(""" ") List facts(@Param("ids") List ids); - /** 锁定客户自己的入仓单,串行确认与测量写入。 */ - @Select("SELECT * FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND id=#{id} AND deleted=0 FOR UPDATE") - com.baiyue.firstleg.entity.InboundOrderEntity lock(@Param("tenantId") long tenantId,@Param("id") long id); - /** 客户订单下全部独立入仓单。 */ - @Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND order_id=#{orderId} AND deleted=0 AND process_status != 'CANCELLED' ORDER BY id") - List orderInbounds(@Param("tenantId") long tenantId,@Param("orderId") long orderId); - /** 客户工作台只读取审核通过且有正计费重的来源。 */ - @Select("SELECT id FROM fl_inbound_order WHERE tenant_id=#{tenantId} AND deleted=0 AND process_status != 'CANCELLED' AND measure_review_status='APPROVED' AND chargeable_weight_kg>0 ORDER BY id") - List weightCandidates(@Param("tenantId") long tenantId); - /** 成员幂等键不允许跨确认对象复用。 */ - @Select("SELECT * FROM fl_chargeable_weight_confirmation WHERE tenant_id=#{tenantId} AND operator_id=#{operatorId} AND command_key=#{key}") - WorkflowFacts.Confirmation command(@Param("tenantId") long tenantId,@Param("operatorId") long operatorId,@Param("key") String key); - /** 独立记录,不修改费用或物流主状态。 */ - @Insert("INSERT INTO fl_chargeable_weight_confirmation(id,tenant_id,inbound_id,operator_id,command_key,fingerprint,chargeable_weight_kg,confirmed_at) VALUES(#{id},#{tenantId},#{inboundId},#{operatorId},#{commandKey},#{fingerprint},#{chargeableWeightKg},#{confirmedAt})") - int insert(WorkflowFacts.Confirmation value); + /** 客户确认页签只读取最新已推送费用版本,旧版本确认不能覆盖新版本。 */ + @Select(""" + + """) + List feeConfirmationRows(@Param("ids") List ids); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java index 567aaba6a..6d1bd9ba9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFactRepository.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.workflow; import org.springframework.stereotype.Repository; import java.util.List; -/** 本地柜事实与确认记录的持久化入口。 */ +/** 本地柜事实与费用确认状态的只读持久化入口。 */ @Repository public class WorkflowFactRepository { private final WorkflowFactMapper mapper; @@ -9,14 +9,8 @@ public class WorkflowFactRepository { public WorkflowFactRepository(WorkflowFactMapper mapper) { this.mapper=mapper; } /** 空候选不读取数据。 */ public List facts(List ids) { return ids.isEmpty()?List.of():mapper.facts(ids); } - /** 锁定所属客户来源。 */ - public com.baiyue.firstleg.entity.InboundOrderEntity lock(long tenant,long id) { return mapper.lock(tenant,id); } - /** 查询订单下独立来源。 */ - public List orderInbounds(long tenant,long order) { return mapper.orderInbounds(tenant,order); } - /** 客户计费重待确认候选。 */ - public List weightCandidates(long tenant) { return mapper.weightCandidates(tenant); } - /** 读取成员确认命令。 */ - public WorkflowFacts.Confirmation command(long tenant,long operator,String key) { return mapper.command(tenant,operator,key); } - /** 追加确认历史。 */ - public void insert(WorkflowFacts.Confirmation value) { mapper.insert(value); } + /** 按本轮有效入仓范围读取最新推送费用的客户确认状态。 */ + public List feeConfirmationRows(List ids) { + return ids.isEmpty() ? List.of() : mapper.feeConfirmationRows(ids); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java index afa08a995..173f66927 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java @@ -1,17 +1,12 @@ package com.baiyue.firstleg.workflow; -import java.math.BigDecimal; -import java.time.LocalDateTime; import java.util.List; -/** 页签事实与独立计费重确认契约。 */ +/** 订单业务页签的只读事实契约。 */ public final class WorkflowFacts { - /** 仅承载查询及确认契约,不允许实例化。 */ + /** 仅承载事实查询契约,不允许实例化。 */ private WorkflowFacts() {} - public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus,BigDecimal chargeableWeightKg,LocalDateTime measureCompletedAt, - boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,String confirmationFingerprint,LocalDateTime confirmedAt) {} + public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus, + boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {} public record Source(long tenantId,long inboundId) {} public record Request(List sources) {} public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {} - public record Weight(long inboundId,long orderId,BigDecimal chargeableWeightKg,String fingerprint,boolean confirmed,LocalDateTime confirmedAt) {} - public record Confirm(@jakarta.validation.constraints.Pattern(regexp="[a-f0-9]{64}") @jakarta.validation.constraints.NotNull String expectedFingerprint) {} - public record Confirmation(long id,long tenantId,long inboundId,long operatorId,String commandKey,String fingerprint,BigDecimal chargeableWeightKg,LocalDateTime confirmedAt) {} } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index c513cbf9f..522242750 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -22,6 +22,11 @@ public class WorkflowTabService { var batch=inbounds.subList(offset,Math.min(offset+500,inbounds.size())); var expected=new HashMap();batch.forEach(i->expected.put(i.getId(),i)); var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList()); + // 确认页签复用已推送费用版本。 + for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) { + if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable(); + result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); + } var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); Map overseas=new HashMap<>(); if(!sources.isEmpty()) { @@ -46,11 +51,10 @@ public class WorkflowTabService { } return List.copyOf(result.values()); } - /** 分类为可重叠的待办/证据集合,计费重确认独立于费用确认。 */ + /** 分类为可重叠的物流待办/证据集合,客户确认由最新费用版本单独提供。 */ public static Set classify(WorkflowFacts.Fact f,String process,WorkflowFacts.Overseas o) { Set keys=new LinkedHashSet<>(); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); - if(ChargeableWeightService.eligible(f)) keys.add(ChargeableWeightService.weight(f).confirmed()?"WEIGHT_CONFIRMED":"WAIT_WEIGHT_CONFIRM"); boolean signed="DELIVERED".equals(process); // 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。 if(!f.departed() && !signed) { diff --git a/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql b/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql deleted file mode 100644 index 1dcc7e9c7..000000000 --- a/firstleg/src/main/resources/db/chargeable-weight-confirmation.sql +++ /dev/null @@ -1,13 +0,0 @@ --- 客户独立确认计费重,不回填既有费用确认。 -CREATE TABLE IF NOT EXISTS fl_chargeable_weight_confirmation ( - id BIGINT NOT NULL COMMENT '确认记录ID', - tenant_id BIGINT NOT NULL COMMENT '客户隔离ID', - inbound_id BIGINT NOT NULL COMMENT '独立来源入仓单ID', - operator_id BIGINT NOT NULL COMMENT '确认客户成员ID', - command_key VARCHAR(128) NOT NULL COMMENT '成员确认幂等键', - fingerprint CHAR(64) NOT NULL COMMENT '计费重及测量轮次摘要', - chargeable_weight_kg DECIMAL(18,4) NOT NULL COMMENT '客户确认计费重千克', - confirmed_at DATETIME(3) NOT NULL COMMENT '客户确认时间', - PRIMARY KEY(id), UNIQUE KEY uk_weight_confirmation_command(tenant_id,operator_id,command_key), - KEY idx_weight_confirmation_inbound(tenant_id,inbound_id,id) -) ENGINE=InnoDB COMMENT='客户计费重独立确认历史'; diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 6e2607eea..59219443d 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -355,7 +355,7 @@ paths: /internal/firstleg/v1/inbounds/unified-orders/workbench-filters: post: summary: 查询单一客户工作台履约筛选标识(Order内部) - description: 只读返回命中状态、待独立确认计费重及待上传报关资料的订单标识。feeOrderIds沿用既有字段名,值为尚未确认当前计费重的订单,不代表金额确认。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 + description: 只读返回命中状态、待确认已推送费用及待上传报关资料的订单标识。feeOrderIds为最新已推送费用版本尚未由客户确认的订单,对应客户端“确认计费重”入口及费用明细弹窗。客户必须明确且为正数;不创建报关聚合,不跨Schema读取。传入orderIds为精确进度模式,仅计算progress,matchingStatusOrderIds为null,feeOrderIds和customsOrderIds为空;mainStatus在该模式不参与查询。 security: [{internalServiceToken: []}] requestBody: required: true @@ -388,7 +388,7 @@ paths: required: [feeOrderIds, customsOrderIds, progress] properties: matchingStatusOrderIds: {type: array, nullable: true, items: {type: integer, format: int64}} - feeOrderIds: {type: array, description: 待客户独立确认当前计费重的订单ID, items: {type: integer, format: int64}} + feeOrderIds: {type: array, description: 待客户确认最新已推送费用版本的订单ID, items: {type: integer, format: int64}} customsOrderIds: {type: array, items: {type: integer, format: int64}} progress: type: array diff --git a/firstleg/src/main/resources/openapi/order-workflow-v1.yaml b/firstleg/src/main/resources/openapi/order-workflow-v1.yaml deleted file mode 100644 index 9c9abf1e4..000000000 --- a/firstleg/src/main/resources/openapi/order-workflow-v1.yaml +++ /dev/null @@ -1,64 +0,0 @@ -openapi: 3.0.3 -info: {title: 客户独立计费重确认, version: 1.0.0} -security: [{bearerAuth: []}] -paths: - /api/merchant/v1/first-leg/orders/{orderId}/chargeable-weights: - get: - summary: 客户读取本票全部独立入仓计费重 - description: 认证客户隔离,要求merchant:order:view;仅返回测量审核通过且计费重为正的分片。 - parameters: - - {name: orderId, in: path, required: true, schema: {type: integer, format: int64}} - responses: - '200': - description: 标准ApiResponse,认证客户无可确认来源时返回空数组 - content: - application/json: - schema: - type: object - properties: - code: {type: string, example: '200'} - message: {type: string} - data: - type: array - items: {$ref: '#/components/schemas/Weight'} - /api/merchant/v1/first-leg/chargeable-weights/{inboundId}/confirm: - post: - summary: 客户独立确认当前计费重 - description: 要求merchant:order:edit。与费用金额确认独立;值或测量轮次变化后旧确认失效。客户范围取认证主体。 - parameters: - - {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}} - - {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} - requestBody: - required: true - content: - application/json: - schema: - type: object - required: [expectedFingerprint] - properties: - expectedFingerprint: {type: string, pattern: '^[a-f0-9]{64}$'} - responses: - '200': - description: 标准ApiResponse;版本或异参冲突返回业务冲突码,不改变费用状态 - content: - application/json: - schema: - type: object - properties: - code: {type: string, example: '200'} - message: {type: string} - data: {$ref: '#/components/schemas/Weight'} -components: - securitySchemes: - bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} - schemas: - Weight: - type: object - required: [inboundId, orderId, chargeableWeightKg, fingerprint, confirmed] - properties: - inboundId: {type: string, description: 独立来源入仓单ID} - orderId: {type: string, description: 独立订单ID} - chargeableWeightKg: {type: number} - fingerprint: {type: string} - confirmed: {type: boolean} - confirmedAt: {type: string, format: date-time, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java index 00b029a6d..ab435876e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -19,6 +19,7 @@ class MerchantWorkbenchFilterMapperTest { var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:workbench-facts-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1"); jdbc=new JdbcTemplate(ds); jdbc.execute("CREATE TABLE fl_inbound_order(id BIGINT PRIMARY KEY,tenant_id BIGINT,order_id BIGINT,deleted INT DEFAULT 0,tax_mode VARCHAR(32),process_status VARCHAR(32))"); + jdbc.execute("ALTER TABLE fl_inbound_order ADD measure_review_status VARCHAR(32) DEFAULT 'APPROVED'"); jdbc.execute("CREATE TABLE fl_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)"); jdbc.execute("CREATE TABLE fl_loading_plan(id BIGINT PRIMARY KEY,container_booking_id BIGINT,plan_status VARCHAR(32),confirmed_at TIMESTAMP,updated_at TIMESTAMP)"); jdbc.execute("CREATE TABLE fl_loading_plan_item(loading_plan_id BIGINT,inbound_id BIGINT,account_id BIGINT,deleted INT DEFAULT 0)"); @@ -33,7 +34,7 @@ class MerchantWorkbenchFilterMapperTest { var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); mapper=new SqlSessionTemplate(factory.getObject()).getMapper(MerchantWorkbenchFilterMapper.class); - jdbc.update("INSERT INTO fl_inbound_order VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')"); + jdbc.update("INSERT INTO fl_inbound_order(id,tenant_id,order_id,deleted,tax_mode,process_status) VALUES(1,11,101,0,'TAX_REFUND','LOADED'),(2,11,102,0,'TAX_REFUND','DELIVERED'),(3,22,103,0,'TAX_REFUND','LOADED'),(4,11,104,1,'TAX_REFUND','LOADED'),(5,11,105,0,'BUY_DECLARATION','LOADED')"); jdbc.update("INSERT INTO fl_container_booking VALUES(100,'ACTIVE')"); jdbc.update("INSERT INTO fl_loading_plan VALUES(100,100,'COMPLETED',CURRENT_TIMESTAMP,CURRENT_TIMESTAMP)"); jdbc.update("INSERT INTO fl_loading_plan_item(loading_plan_id,inbound_id,account_id,deleted) VALUES(100,1,11,0),(100,1,11,0),(100,2,11,0),(100,3,22,0),(100,4,11,0),(100,5,11,0)"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java index f9d21c3b5..f8a0117a4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/MerchantWorkbenchFilterServiceTest.java @@ -1,7 +1,7 @@ package com.baiyue.firstleg.service; import com.baiyue.firstleg.dto.MerchantWorkbenchFilters; -import com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper; +import com.baiyue.firstleg.repository.MerchantWorkbenchFilterRepository; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -9,25 +9,22 @@ 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); + /** 确认计费重待办使用费用版本的确认结果,与费用弹窗保持一致。 */ + @Test void customerQueueUsesPublishedFeeConfirmation() { + var repository=mock(MerchantWorkbenchFilterRepository.class); var service=new MerchantWorkbenchFilterService(); - ReflectionTestUtils.setField(service,"mapper",mapper); - ReflectionTestUtils.setField(service,"weights",weights); - when(weights.pendingOrders(11)).thenReturn(List.of(101L)); + ReflectionTestUtils.setField(service,"repository",repository); + when(repository.pendingFees(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()); + verify(repository).pendingFees(11); } @Test void exactDetailProgressSkipsUnrelatedCustomerQueues() { - var mapper=mock(MerchantWorkbenchFilterMapper.class); + var mapper=mock(MerchantWorkbenchFilterRepository.class); var unified=mock(UnifiedInboundQueryService.class); var service=new MerchantWorkbenchFilterService(); - ReflectionTestUtils.setField(service,"mapper",mapper); + ReflectionTestUtils.setField(service,"repository",mapper); ReflectionTestUtils.setField(service,"unified",unified); when(mapper.progressFragments(11,List.of(101L))).thenReturn(List.of( new MerchantWorkbenchFilters.Fragment(101,3),new MerchantWorkbenchFilters.Fragment(101,1))); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index 409acb688..d8db7cf4d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -4,8 +4,6 @@ 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.*; @@ -15,8 +13,7 @@ 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); + return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false); } /** 下游漏来源或返回其他客户均整体失败。 */ @@ -36,28 +33,4 @@ class WorkflowBoundaryTest { 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); - } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java index 6772b8ab7..4106e0665 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -1,18 +1,15 @@ 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 missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false); + var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true); + var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true); + var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true); + var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true); 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)) @@ -31,12 +28,12 @@ class WorkflowBusinessTest { .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); + private WorkflowFacts.Fact fact(long id,boolean uploaded) { + return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false); } /** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */ @Test void cancellationRestoresDispatchQueue() { - var f=fact(1,"100",null,true); + var f=fact(1,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); @@ -47,17 +44,10 @@ class WorkflowBusinessTest { } /** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */ @Test void independentSlicesAndUploadedClearance() { - assertThat(WorkflowTabService.classify(fact(1,"100",null,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false))) + assertThat(WorkflowTabService.classify(fact(1,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))) + assertThat(WorkflowTabService.classify(fact(2,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(); + assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java index 75d5c5565..23b012520 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java @@ -5,16 +5,16 @@ 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 { + @Test void clearanceAndPublishedFeeConfirmationUseCurrentFacts() 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_inbound_fee(id BIGINT PRIMARY KEY,tenant_id BIGINT,inbound_id BIGINT,fee_version INT,fee_status VARCHAR(32),customer_confirmed_at TIMESTAMP)"); 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))"); @@ -29,7 +29,6 @@ class WorkflowFactPersistenceTest { 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)"); @@ -39,15 +38,16 @@ class WorkflowFactPersistenceTest { 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); + config.addMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.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); + var workbench=new SqlSessionTemplate(factory.getObject()).getMapper(com.baiyue.firstleg.mapper.MerchantWorkbenchFilterMapper.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(); + var repository=new WorkflowFactRepository(mapper); 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(); @@ -92,29 +92,42 @@ class WorkflowFactPersistenceTest { 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); + // 未推送费用不进入确认页签;所有查询在没有独立重量确认表的库中运行。 + var downstream=org.mockito.Mockito.mock(LastlegWorkflowClient.class); + org.mockito.Mockito.when(downstream.read(org.mockito.ArgumentMatchers.any(),org.mockito.ArgumentMatchers.any())) + .thenReturn(com.baiyue.common.core.api.ApiResponse.success(List.of(new WorkflowFacts.Overseas(11,1,false,false,false,false)))); + var tabs=new WorkflowTabService(repository,downstream); + var inbound=new com.baiyue.firstleg.entity.InboundOrderEntity(); + inbound.setId(1L);inbound.setTenantId(11L);inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); + assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED")); + db.update("INSERT INTO fl_inbound_fee VALUES(20,11,1,1,'SENT',NULL),(21,22,2,1,'SENT',NULL)"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")) + .noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).containsExactly(101L); + // 客户确认费用后两端同时流转;同重量的新费用版本仍必须重新确认。 + db.update("UPDATE fl_inbound_fee SET customer_confirmed_at=CURRENT_TIMESTAMP WHERE id=20"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")) + .noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("INSERT INTO fl_inbound_fee VALUES(22,11,1,2,'CALCULATED',NULL)"); + assertThat(tabs.rows(List.of(inbound))).noneMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")||r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("UPDATE fl_inbound_fee SET fee_status='SENT' WHERE id=22"); + assertThat(tabs.rows(List.of(inbound))).anyMatch(r->r.mainStatus().equals("WAIT_WEIGHT_CONFIRM")) + .noneMatch(r->r.mainStatus().equals("WEIGHT_CONFIRMED")); + assertThat(workbench.pendingFees(11)).containsExactly(101L); + assertThat(workbench.pendingFees(22)).containsExactly(102L); + db.update("UPDATE fl_inbound_order SET measure_review_status='PENDING' WHERE id=1"); + assertThat(repository.feeConfirmationRows(List.of(1L))).isEmpty(); + assertThat(workbench.pendingFees(11)).isEmpty(); + db.update("UPDATE fl_inbound_order SET measure_review_status='APPROVED' WHERE id=1"); // 现行部分装柜由来源单承接实装,余量子单独立继续分柜,两票均应保留。 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(); + assertThat(mapper.facts(List.of(1L,3L))).singleElement().satisfies(f->assertThat(f.inboundId()).isEqualTo(3)); } } diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index dafc6ce68..bdb2b14a1 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -562,8 +562,8 @@ Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI' Assert-Contains $smartLoadingService 'repository.measuredPackageCount' '智能装柜已审核测量数量' Assert-NotContains $firstlegAgents '| 客户费用查询 |' 'Firstleg 重复客户费用入口' -Assert-Contains $clientAgents '所有订单行均可进入计费重弹框' 'Client 独立计费重入口' -Assert-Contains $clientOrdersView 'dialog-only' 'Client 独立计费重弹框实现' +Assert-Contains $clientAgents '所有订单行均可进入费用确认弹框' 'Client 费用确认入口' +Assert-Contains $clientOrdersView 'dialog-only' 'Client 费用确认弹框实现' Assert-Path 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java' # 企业联系信息与账号登录标识已经解耦,资质审核不得重新把法人身份证设为必填。 From 34b90f311ab5ecd7831113a3c6aeb32b6a1d1e6b Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 18:44:23 +0800 Subject: [PATCH 041/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/agents-index-contract.ps1 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index bdb2b14a1..f5b14cba4 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -562,7 +562,7 @@ Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI' Assert-Contains $smartLoadingService 'repository.measuredPackageCount' '智能装柜已审核测量数量' Assert-NotContains $firstlegAgents '| 客户费用查询 |' 'Firstleg 重复客户费用入口' -Assert-Contains $clientAgents '所有订单行均可进入费用确认弹框' 'Client 费用确认入口' +Assert-Contains $clientAgents 'customerFeeConfirmationRequired=true' 'Client 费用待确认入口' Assert-Contains $clientOrdersView 'dialog-only' 'Client 费用确认弹框实现' Assert-Path 'baiyue-firstleg/warehouse/src/main/java/com/baiyue/warehouse/service/LocationBlockService.java' From 137efc6809809d7e41820c83829660c6bbedd328 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 21:47:51 +0800 Subject: [PATCH 042/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + .../V32__dispatch_transport_tasks.sql | 23 +++ .../V33__looseboard_pool_demand.sql | 13 ++ deploy/sql/海外区域统一增量-MySQL8.sql | 43 +++- .../mapper/MerchantWorkbenchFilterMapper.java | 8 +- .../firstleg/workflow/WorkflowFactMapper.java | 3 +- .../firstleg/workflow/WorkflowFacts.java | 2 +- .../firstleg/workflow/WorkflowTabService.java | 14 +- .../main/resources/openapi/firstleg-v1.yaml | 4 +- .../MerchantWorkbenchFilterMapperTest.java | 7 + .../workflow/WorkflowBoundaryTest.java | 21 +- .../workflow/WorkflowBusinessTest.java | 37 ++-- .../workflow/WorkflowFactPersistenceTest.java | 3 +- .../analyze-lastleg-dependencies.test.mjs | 13 +- .../dispatch/entity/DispatchAggregate.java | 1 + .../mapper/DispatchAggregateMapper.java | 10 +- .../lastleg/dispatch/port/DispatchViews.java | 4 +- .../service/DispatchAggregateService.java | 10 +- .../transport/TransportTaskController.java | 91 +++++++++ .../transport/TransportTaskMapper.java | 42 ++++ .../transport/TransportTaskModels.java | 23 +++ .../transport/TransportTaskProjection.java | 84 ++++++++ .../transport/TransportTaskRepository.java | 34 ++++ .../transport/TransportTaskService.java | 158 +++++++++++++++ .../business/FleetLooseboardDemandMapper.java | 29 +++ .../FleetLooseboardDemandRepository.java | 28 +++ .../FleetLooseboardDemandService.java | 52 +++++ .../fleet/business/FleetPoolOrderBridge.java | 14 +- .../fleet/business/FleetPoolOrderMapper.java | 4 +- .../fleet/trip/FleetCapacityOfferModels.java | 11 +- .../fleet/trip/FleetCapacityOfferService.java | 12 +- .../fleet/trip/FleetTripExecutionService.java | 2 +- .../lastleg/fleet/trip/FleetTripService.java | 12 +- .../PlatformFleetCapacityOfferController.java | 3 + .../service/LocalLooseboardService.java | 9 +- .../trucking/mapper/UsTruckOrderMapper.java | 3 + .../repository/UsTruckOrderRepository.java | 2 + .../trucking/service/UsTruckOrderService.java | 2 +- .../src/main/resources/db/lastleg.sql | 28 +++ .../openapi/dispatch-transport-tasks-v1.yaml | 99 ++++++++++ .../openapi/fleet-capacity-offers-v1.yaml | 60 ++++++ .../DispatchAggregatePersistenceTest.java | 19 ++ .../TransportTaskProjectionTest.java | 31 +++ .../transport/TransportTaskServiceTest.java | 63 ++++++ .../FleetLooseboardDemandPersistenceTest.java | 50 +++++ .../FleetLooseboardDemandServiceTest.java | 39 ++++ .../business/FleetPoolOrderBridgeTest.java | 19 ++ .../fleet/trip/FleetTripApiContractTest.java | 2 +- .../trip/FleetTripAuthorizationTest.java | 16 ++ .../service/LocalLooseboardServiceTest.java | 13 +- .../lastleg-slice-dependencies.json | 35 ++++ .../src/main/resources/openapi/order-v1.yaml | 2 +- scripts/README.md | 2 + .../local-dispatch-transport-acceptance.mjs | 186 ++++++++++++++++++ 54 files changed, 1434 insertions(+), 63 deletions(-) create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql create mode 100644 deploy/sql/migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandService.java create mode 100644 lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandServiceTest.java create mode 100644 scripts/local-dispatch-transport-acceptance.mjs diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 1a7498180..37b406507 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -1,5 +1,7 @@ # 单测试环境数据库空库初始化 +仓储转运到仓取货、本土卡车接入现有排车的**可重复执行汇总SQL**为 `lastleg-dispatch-transport-tasks.sql`,文件内固定 `USE baiyue_lastleg`,对已存在字段、表安全跳过。新增 `ll_dispatch_order.task_type` 字段、`ll_dispatch_truck_task`、`ll_dispatch_transport_create`、`ll_dispatch_transport_command` 三张表,并确保既有正式排车单号日流水表存在;不回填或删除业务数据。前置是目标库已具备现有整车排车主表、FleetTrip、取货任务及本土卡车原单结构。按序先执行此 SQL,再配套发布 Lastleg 与 Admin;取消释放有效原单关联、完成保留关联。`migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql` 是迁移系统中的新增结构记录。本次仅在已授权本机执行并验收,不代表远端已部署。 + 排车单号使用`PCyyyyMMdd0001`,日期取`Asia/Shanghai`,全平台跨客户/仓库共用独立日流水。`ll_dispatch_daily_sequence`同步Lastleg源基线与海外空库基线;已有结构的定向新增入口为`lastleg-dispatch-daily-sequence.sql`,只建序列表,不改已有业务号、不回填历史、不重放空库初始化。源码和DDL须配套发布;文件存在不表示已执行或部署。流水在独立短事务分配,回滚或取消不复用,四位范围每日0001至9999,准确单量以主单统计为准。 自有业务运营视图的结构在 Lastleg 源基线及海外统一空库基线新增四张附加表:`ll_fleet_owned_business_ownership/record/command/history`,分别保存原排车装车完成时的车辆归属证据、运营信息、命令与不可变历史;不新增排车主单或第二套运输状态。权限为 IAM `lastleg-permissions.sql` 中的自有业务 list/edit/upload,并同步国内统一空库基线。业务唯一规则见工作区 `docs/海外仓业务/自有业务首版开发契约.md`。本次仅准备源码基线,无 SQL 执行、存量回填或部署;旧已装车 TEST 数据不倒推历史归属,匹配版本按新原生样本验收。 diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql new file mode 100644 index 000000000..4bbd4a3e3 --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V32__dispatch_transport_tasks.sql @@ -0,0 +1,23 @@ +-- 仓储取货与本土卡车接入现有排车;保留旧排车用途默认值。 +ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车' AFTER dispatch_no; +CREATE TABLE ll_dispatch_truck_task ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识', + order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号', + active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', + PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order), + KEY idx_transport_order(customer_id,order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; +CREATE TABLE ll_dispatch_transport_create ( + trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号', + request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; +CREATE TABLE ll_dispatch_transport_command ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', + request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要', + evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', + PRIMARY KEY(dispatch_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; diff --git a/deploy/sql/migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql b/deploy/sql/migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql new file mode 100644 index 000000000..d947295bf --- /dev/null +++ b/deploy/sql/migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql @@ -0,0 +1,13 @@ +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。 +-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。 +-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。 +SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''); +PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; +EXECUTE looseboard_demand_stmt; +DEALLOCATE PREPARE looseboard_demand_stmt; +SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)'); +PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; +EXECUTE looseboard_demand_stmt; +DEALLOCATE PREPARE looseboard_demand_stmt; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a82791968..8d24dba5f 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -416,6 +416,7 @@ CREATE TABLE ll_dispatch_item ( CREATE TABLE ll_dispatch_order ( id BIGINT NOT NULL COMMENT '整车排车主单内部主键', dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', + task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', @@ -3222,6 +3223,7 @@ CREATE TABLE ll_fleet_external_booking ( binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', binding_hash CHAR(64) NULL COMMENT '关联发运摘要', offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空', + source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源', sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人', business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK', delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓', @@ -3236,6 +3238,7 @@ CREATE TABLE ll_fleet_external_booking ( KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id), CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id), CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0), + KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status), PRIMARY KEY(id), UNIQUE KEY uk_fleet_booking_no(booking_no), UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), @@ -4968,4 +4971,42 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; +-- 运输原单接入现有排车,不产生仓内库存事实。 +USE baiyue_lastleg; +CREATE TABLE ll_dispatch_truck_task ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识', + order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号', + active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', + PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order), + KEY idx_transport_order(customer_id,order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; +CREATE TABLE ll_dispatch_transport_create ( + trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号', + request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; +CREATE TABLE ll_dispatch_transport_command ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', + request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要', + evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', + PRIMARY KEY(dispatch_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; + +-- Region: OVERSEAS +-- Schema: baiyue_lastleg +-- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。 +-- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。 +-- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。 +SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''); +PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; +EXECUTE looseboard_demand_stmt; +DEALLOCATE PREPARE looseboard_demand_stmt; +SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)'); +PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; +EXECUTE looseboard_demand_stmt; +DEALLOCATE PREPARE looseboard_demand_stmt; + +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 236 AS initialized_tables; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java index dcec5ed35..34b48058b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/MerchantWorkbenchFilterMapper.java @@ -64,13 +64,13 @@ public interface MerchantWorkbenchFilterMapper { ORDER BY i.order_id """) List pendingCustoms(@Param("tenantId") long tenantId); - /** Only physically loaded fragments can expose container milestones to the customer. */ + /** 仅按本客户实际装柜片段读取进度;清关资料保存后立即显示已清关。 */ @Select(""" ") List facts(@Param("ids") List ids); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java index 173f66927..97663a728 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowFacts.java @@ -5,7 +5,7 @@ public final class WorkflowFacts { /** 仅承载事实查询契约,不允许实例化。 */ private WorkflowFacts() {} public record Fact(long tenantId,long inboundId,long orderId,String reviewStatus, - boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady) {} + boolean departed,boolean clearanceUploaded,boolean missingCustoms,boolean exportCustomsReady,boolean customsCompleted) {} public record Source(long tenantId,long inboundId) {} public record Request(List sources) {} public record Overseas(long tenantId,long inboundId,boolean outboundExists,boolean pendingOutbound,boolean delivering,boolean unloaded) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index 522242750..418650e34 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -27,7 +27,7 @@ public class WorkflowTabService { if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable(); result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); } - var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); + var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); Map overseas=new HashMap<>(); if(!sources.isEmpty()) { try { @@ -56,16 +56,18 @@ public class WorkflowTabService { Set keys=new LinkedHashSet<>(); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); boolean signed="DELIVERED".equals(process); - // 报关页签按资料与实装事实汇总,不依赖点击开始报关;实际开船后转入清关链路。 - if(!f.departed() && !signed) { + // 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。 + boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded(); + if(!clearanceStage && !signed) { if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS"); if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS"); } if(signed) keys.add("SIGNED"); - if(f.departed() && !signed) { + if(clearanceStage && !signed) { if(!f.clearanceUploaded()) keys.add("SAILING_CLEARANCE"); - if(o!=null) { - if(f.clearanceUploaded() && !o.unloaded()) keys.add("WAIT_UNLOAD"); + // 清关资料保存成功后才进入海外待办;出库事实优先于拆柜和排车待办。 + if(f.clearanceUploaded() && o!=null) { + if(!o.outboundExists() && !o.unloaded()) keys.add("WAIT_UNLOAD"); // 取消的出库不构成有效覆盖,读取事实后自动回显,不能保存永久隐藏标志。 if(!o.outboundExists()) keys.add("WAIT_DISPATCH"); if(o.pendingOutbound()) keys.add("OUTBOUND_DOCUMENT"); diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 59219443d..472378efb 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -392,7 +392,7 @@ paths: customsOrderIds: {type: array, items: {type: integer, format: int64}} progress: type: array - description: 实际装载片段投影;报关要求COMPLETED,目的港清关要求PICKUP_READY实际时间,缺失节点不推测。 + description: 实际装载片段投影;报关要求COMPLETED,目的港清关以本柜DESTINATION_CLEARANCE资料成功绑定file_id为准,不等待PICKUP_READY船节点。 items: type: object required: [orderId, stage, partial] @@ -1674,7 +1674,7 @@ components: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, WAIT_SPLIT, LOADING, LOADED, DELIVERED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] nullable: true - description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,实际开船后移出;SAILING_CLEARANCE 按手动或拉取的实际离港节点进入,清关资料上传后进入 WAIT_UNLOAD,实际卸柜完成后移出。 + description: WAIT_SPLIT 为唯一待分柜筛选值;WAIT_CUSTOMS_DOCS 为实际装柜后缺少本票客户报关资料;EXPORT_CUSTOMS 按本票资料已上传且实际装柜完成汇总,买单无需客户退税资料,不依赖点击开始报关,报关完成或实际开船后移出;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。 customerId: {type: integer, format: int64, nullable: true} receivingWarehouseId: {type: integer, format: int64, nullable: true} destinationWarehouseId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java index ab435876e..fc10ee0f0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/MerchantWorkbenchFilterMapperTest.java @@ -30,6 +30,7 @@ class MerchantWorkbenchFilterMapperTest { jdbc.execute("ALTER TABLE fl_loading_plan_item ADD id BIGINT GENERATED BY DEFAULT AS IDENTITY"); jdbc.execute("CREATE TABLE fl_loading_pick_task(id BIGINT,loading_plan_id BIGINT,task_status VARCHAR(32))"); jdbc.execute("CREATE TABLE fl_loading_pick_item(pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR(32),picked_package_count INT)"); + jdbc.execute("CREATE TABLE fl_container_document(container_booking_id BIGINT,document_type VARCHAR(40),file_id BIGINT)"); jdbc.execute("CREATE TABLE fl_carrier_schedule_node_fact(container_booking_id BIGINT,standard_node VARCHAR(64),actual_at TIMESTAMP)"); var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(MerchantWorkbenchFilterMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); @@ -95,6 +96,12 @@ class MerchantWorkbenchFilterMapperTest { jdbc.update("INSERT INTO fl_carrier_schedule_node_fact VALUES(100,'PICKUP_READY',NULL)"); assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=CURRENT_TIMESTAMP"); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); + // 其他柜、其他资料类型或未绑定文件不能视为清关资料提交成功。 + jdbc.update("INSERT INTO fl_container_document VALUES(999,'DESTINATION_CLEARANCE',1),(100,'DELIVERY_ORDER',2),(100,'DESTINATION_CLEARANCE',NULL)"); + assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==2); + jdbc.update("UPDATE fl_carrier_schedule_node_fact SET actual_at=NULL"); + jdbc.update("UPDATE fl_container_document SET file_id=3 WHERE document_type='DESTINATION_CLEARANCE' AND container_booking_id=100"); assertThat(mapper.progressFragments(11, null)).allMatch(f->f.stage()==3); assertThat(mapper.progressFragments(11,List.of(101L))).singleElement().satisfies(f->{assertThat(f.orderId()).isEqualTo(101);assertThat(f.stage()).isEqualTo(3);}); jdbc.update("UPDATE fl_container_booking SET record_status='CANCELLED'"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index d8db7cf4d..cf264bcf4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -13,7 +13,7 @@ import static org.mockito.Mockito.*; class WorkflowBoundaryTest { /** 真实开船来源的测试事实。 */ private WorkflowFacts.Fact fact() { - return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false); + return new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,false,false); } /** 下游漏来源或返回其他客户均整体失败。 */ @@ -33,4 +33,23 @@ class WorkflowBoundaryTest { assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class); } + /** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */ + @Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() { + 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( + new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true))); + assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE"); + verifyNoInteractions(downstream); + when(repository.facts(List.of(1L))).thenReturn(List.of( + new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true))); + when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( + new WorkflowFacts.Overseas(11,1,false,false,false,false)))); + assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH"); + verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1))))); + } + } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java index 4106e0665..e0aa37b6e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -3,33 +3,46 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.*; /** 取消回显、分柜隔离和资料页签回归。 */ class WorkflowBusinessTest { - /** 报关资料齐备进入汇总,实际开船及清关资料分别驱动后续页签。 */ + /** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */ @Test void customsDocumentsDepartureAndClearanceDriveTabs() { - var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false); - var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true); - var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true); - var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true); - var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true); + var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false); + var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false); + var departed=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,false); + var cleared=new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,false); + var completed=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true); + var uploadedBeforeDeparture=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,false); 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(completed,"LOADED",null)) + .containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload)) - .contains("EXPORT_CUSTOMS").doesNotContain("SAILING_CLEARANCE","WAIT_UNLOAD"); + .containsExactly("WAIT_UNLOAD","WAIT_DISPATCH"); assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload)) - .contains("SAILING_CLEARANCE").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","WAIT_UNLOAD"); + .containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload)) - .contains("WAIT_UNLOAD").doesNotContain("WAIT_CUSTOMS_DOCS","EXPORT_CUSTOMS","SAILING_CLEARANCE"); + .containsExactly("WAIT_UNLOAD","WAIT_DISPATCH"); 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"); } + /** 清关前不能被海外建单事实提前推进,清关后出库与派送依次替换待办。 */ + @Test void downstreamFactsCannotBypassClearanceAndOutboundReplacesQueues() { + var pending=new WorkflowFacts.Overseas(11,1,true,true,false,false); + var shipped=new WorkflowFacts.Overseas(11,1,true,false,true,false); + assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",pending)).containsExactly("SAILING_CLEARANCE"); + assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",shipped)).containsExactly("SAILING_CLEARANCE"); + assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",pending)).containsExactly("OUTBOUND_DOCUMENT"); + assertThat(WorkflowTabService.classify(fact(1,true),"LOADED",shipped)).containsExactly("DELIVERING"); + assertThat(WorkflowTabService.classify(fact(1,true),"DELIVERED",shipped)).containsExactly("SIGNED"); + } /** 构造独立分柜来源。 */ private WorkflowFacts.Fact fact(long id,boolean uploaded) { - return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false); + return new WorkflowFacts.Fact(11,id,100+id,"APPROVED",true,uploaded,false,false,false); } /** 有效建单移出待排车,取消后恢复;实际出库不计未出库单。 */ @Test void cancellationRestoresDispatchQueue() { @@ -45,9 +58,9 @@ class WorkflowBusinessTest { /** 一柜资料上传不覆盖另一柜;签收不再进入待排。 */ @Test void independentSlicesAndUploadedClearance() { assertThat(WorkflowTabService.classify(fact(1,false),"LOADED",new WorkflowFacts.Overseas(11,1,false,false,false,false))) - .contains("SAILING_CLEARANCE","WAIT_DISPATCH").doesNotContain("WAIT_UNLOAD"); + .containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(fact(2,true),"LOADED",new WorkflowFacts.Overseas(11,2,true,true,false,false))) - .contains("WAIT_UNLOAD","OUTBOUND_DOCUMENT").doesNotContain("SAILING_CLEARANCE","WAIT_DISPATCH"); + .containsExactly("OUTBOUND_DOCUMENT"); assertThat(WorkflowTabService.classify(fact(1,false),"DELIVERED",null)).contains("SIGNED").doesNotContain("WAIT_DISPATCH"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java index 23b012520..2fa9ef78e 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowFactPersistenceTest.java @@ -60,10 +60,11 @@ class WorkflowFactPersistenceTest { 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(); + assertThat(mapper.facts(List.of(1L)).get(0).customsCompleted()).isEqualTo("COMPLETED".equals(status)); } db.update("UPDATE fl_customs_customer_document SET status='DELETED' WHERE id=10"); assertThat(mapper.facts(List.of(1L))).singleElement().satisfies(f->{ diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index aa50f7c47..77fa9d41e 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -49,7 +49,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen const policyClasses = classes.filter(entry => entry.packageName === 'com.baiyue.lastleg.fleet.domain'); assert.equal(policyClasses.length, 3); for (const entry of policyClasses) assert.ok(entry.imports.every(imported => imported.startsWith('java.'))); - assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', 'shared-technical']); + const reviewed = JSON.parse(readFileSync(contract, 'utf8')).reviewedClassDependencies; + const commercialEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && /com\.baiyue\.lastleg\.(trucking|looseboard|procurement)\./.test(edge.target)); + assert.ok(commercialEdges.every(edge => reviewed.some(allowed => allowed.source === edge.source && allowed.target === edge.target)), 'commercial integration only uses individually reviewed class references'); + assert.deepEqual(report.sliceEdges.filter(edge => edge.source === 'fleet').map(edge => edge.target).sort(), ['dispatch-delivery', 'overseas-master-data', ...(commercialEdges.length ? ['procurement-trucking-looseboard'] : []), 'shared-technical']); for (const edge of report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') && edge.target.startsWith(vehiclePrefix))) { assert.ok(edge.target.startsWith(vehiclePrefix + '.application.'), 'fleet must use vehicle application ports, not persistence adapters'); } @@ -67,7 +70,13 @@ test('fleet stays a business slice and its policies have no infrastructure depen || edge.source === 'com.baiyue.lastleg.dispatch.fleet.PdaFleetHandoverController' && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery' || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' - && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture'), + && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture' + || [ + 'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels', + 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', + 'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService', + 'TransportTaskService->reporting.TripOperationsEvidenceGateway', + ].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)), 'dispatch must use fleet ports and guards, not its persistence mappers'); const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(incomingEdges.length > 0); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java index 0fc306e52..c5975cb72 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java @@ -8,6 +8,7 @@ import java.time.LocalDateTime; public class DispatchAggregate { private Long id; private String dispatchNo; + private String taskType; private Long tripId; private String overseasWarehouseId; private String overseasWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index d4c0197ef..06c73e8b4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -14,7 +14,11 @@ public interface DispatchAggregateMapper { AND d.dispatch_no LIKE CONCAT('%',#{q.dispatchNo},'%') AND d.vehicle_plate LIKE CONCAT('%',#{q.vehiclePlate},'%') AND d.overseas_warehouse_id=#{q.warehouseId} - AND EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId}) + AND ( + EXISTS(SELECT 1 FROM ll_dispatch_item i WHERE i.dispatch_order_id=d.id AND i.customer_id=#{q.customerId}) + OR EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.dispatch_no=d.dispatch_no AND t.customer_id=#{q.customerId}) + OR EXISTS(SELECT 1 FROM ll_fleet_pickup_task p JOIN ll_fleet_trip f ON f.trip_no=p.trip_no WHERE f.id=d.trip_id AND p.customer_id=#{q.customerId}) + ) AND EXISTS(SELECT 1 FROM ll_dispatch_appointment_allocation a JOIN ll_inbound_fba_appointment ap ON a.batch_key=CONCAT('FBA_APPOINTMENT:',ap.id) WHERE a.dispatch_id=d.id AND ap.confirmation_no=#{q.fbaAppointmentNo}) """; String REVIEW_DISPATCH = "CASE d.review_state WHEN 'DRAFT' THEN 'NOT_SUBMITTED' WHEN 'DISPATCH_PENDING' THEN 'PENDING' WHEN 'DISPATCH_REJECTED' THEN 'REJECTED' ELSE 'APPROVED' END"; @@ -33,12 +37,12 @@ public interface DispatchAggregateMapper { /** 首次创建整车所有权及初始版本,不写customer_id。 */ @Insert(""" - INSERT INTO ll_dispatch_order(id,dispatch_no,trip_id,overseas_warehouse_id,overseas_warehouse_code, + INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code, overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round, dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at, estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count, remark,created_by,created_at,updated_at) - VALUES(#{r.id},#{r.dispatchNo},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, + VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, #{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash}, #{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson}, #{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount}, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 75f767fdd..264aa0a35 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -29,7 +29,7 @@ public final class DispatchViews { public record Page(List records, long total, long page, long size, long pages, Statistics statistics, Instant snapshotAt) { } - public record ListRow(String dispatchNo, Status status, Stage stage, long recordVersion, + public record ListRow(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion, ReviewState reviewState, Reviews reviews, Resources resources, String warehouseId, String warehouseCode, String warehouseName, Instant plannedDepartureAt, Instant departedAt, Counts counts, @@ -56,7 +56,7 @@ public final class DispatchViews { String sourceBusinessType, String sourceBusinessNo) { } /** 页头元数据和四个内容对象固定,附加能力不扩展为新页签。 */ - public record Detail(String dispatchNo, Status status, Stage stage, long recordVersion, + public record Detail(String dispatchNo, String taskType, Status status, Stage stage, long recordVersion, ReviewState reviewState, Reviews reviews, Counts counts, boolean pickingReconciled, Map actions, VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 7c5c760be..b2e83b1bd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -647,7 +647,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), d.status(),d.packageCount(),d.palletCount())).toList(); var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); - return new DispatchViews.Detail(number,Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs), documents,orderDetails(cargo)); @@ -754,6 +754,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 动作快照只反映实际当前资格,写入仍须再次加锁校验。 */ Map actions(Actor actor,DispatchAggregate row) { + // 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。 + if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { + var blocked=new EnumMap(Action.class); + for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作")); + return Collections.unmodifiableMap(blocked); + } var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); for(var action:Action.values()) { @@ -807,7 +813,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 列表不逐行读取明细、附件或派送。 */ private DispatchViews.ListRow listRow(Actor actor,DispatchAggregate row) { - return new DispatchViews.ListRow(row.getDispatchNo(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), + return new DispatchViews.ListRow(row.getDispatchNo(),row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),read(row.getResourcesJson(),Resources.class),row.getOverseasWarehouseId(),row.getOverseasWarehouseCode(),row.getOverseasWarehouseName(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),counts(row),reconciled(row),row.getArrangementKey()!=null,actions(actor,row)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java new file mode 100644 index 000000000..59ac4f516 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.pickup.*; +import com.baiyue.lastleg.fleet.trip.*; +import jakarta.validation.Valid; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.server.ResponseStatusException; + +/** 两类原单通过现有排车权限接入,禁止客户主体借同名权限调用。 */ +@RestController +@RequestMapping("/api/platform/v1/last-leg/dispatches/transport-tasks") +public class TransportTaskController { + private final TransportTaskService service; + private final FleetPickupService pickups; + private final FleetTripService trips; + private final FleetTripCompletionService completion; + /** 注入运输业务和现有现场执行能力。 */ + public TransportTaskController(TransportTaskService service,FleetPickupService pickups,FleetTripService trips,FleetTripCompletionService completion){this.service=service;this.pickups=pickups;this.trips=trips;this.completion=completion;} + /** 自动汇入审核后的上门取货需求。 */ + @GetMapping("/pickup-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse pickupCandidates(@AuthenticationPrincipal AuthenticatedUser user,@Valid FleetPickupModels.Query query){operator(user);return ApiResponse.success(pickups.candidates(query));} + /** 自动汇入本土卡车待派车需求。 */ + @GetMapping("/truck-candidates") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse truckCandidates(@AuthenticationPrincipal AuthenticatedUser user){operator(user);return ApiResponse.success(service.truckCandidates());} + /** 创建入仓取货排车。 */ + @PostMapping("/pickup") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')") + public ApiResponse pickup(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody FleetPickupModels.Create request){return ApiResponse.success(service.createPickup(operator(user),request));} + /** 创建本土卡车排车。 */ + @PostMapping("/truck") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:create')") + public ApiResponse truck(@AuthenticationPrincipal AuthenticatedUser user,@Valid @RequestBody TransportTaskModels.TruckCreate request){return ApiResponse.success(service.createTruck(operator(user),request));} + /** 当前排车详情的运输执行面板。 */ + @GetMapping("/{number}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));} + /** 分配或改派真实车辆司机。 */ + @PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));} + /** 取消未发车任务,来源回到待安排。 */ + @PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')") + public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));} + /** 候选搜索复用统一资源占用真相。 */ + @PostMapping("/{number}/resource-occupancy") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse occupancy(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.ResourceQuery query){operator(user);return ApiResponse.success(trips.resourceOccupancy(service.transport(number,false).getTripNo(),query));} + /** 实际任务结束后分别释放车辆与司机。 */ + @PostMapping("/{number}/release") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse release(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetCompletionModels.Release request){return ApiResponse.success(completion.release(service.transport(number,false).getTripNo(),operator(user),request));} + /** 取货发车仅推进运输。 */ + @PostMapping("/{number}/pickup/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") + public ApiResponse departPickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.depart(service.transport(number,false).getTripNo(),operator(user),request));} + /** 全部送仓后结束取货运输。 */ + @PostMapping("/{number}/pickup/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse completePickup(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.complete(service.transport(number,false).getTripNo(),operator(user),request));} + /** 凭证归属具体客户原单。 */ + @PostMapping(value="/{number}/pickup/{customer}/{source}/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse pickupFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@RequestPart MultipartFile file){return ApiResponse.success(pickups.upload(service.transport(number,false).getTripNo(),customer,source,operator(user),file));} + /** 确认实提箱数和原单凭证。 */ + @PostMapping("/{number}/pickup/{customer}/{source}/picked") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse picked(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Pickup request){return ApiResponse.success(pickups.picked(service.transport(number,false).getTripNo(),customer,source,operator(user),request));} + /** 送仓只开放原单收货门禁。 */ + @PostMapping("/{number}/pickup/{customer}/{source}/arrive") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse arrive(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@Valid @RequestBody FleetPickupModels.Command request){return ApiResponse.success(pickups.arrive(service.transport(number,false).getTripNo(),customer,source,operator(user),request));} + /** 取货凭证同时核对当前排车和原单归属。 */ + @GetMapping("/{number}/pickup/{customer}/{source}/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ResponseEntity pickupEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable String customer,@PathVariable String source,@PathVariable long fileId){ + operator(user);var trip=service.transport(number,false); + if(pickups.tasks(trip.getTripNo()).stream().noneMatch(task->task.customerId().equals(customer)&&task.transferNo().equals(source)))throw new ResponseStatusException(HttpStatus.NOT_FOUND,"当前排车没有该原单"); + return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).body(pickups.evidence(customer,source,fileId)); + } + /** 本土卡车按真实运输发车。 */ + @PostMapping("/{number}/truck/depart") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") + public ApiResponse departTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"depart",request));} + /** 本土卡车提供运输完成凭证后结案。 */ + @PostMapping("/{number}/truck/complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse completeTruck(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Command request){return ApiResponse.success(service.truckCommand(number,operator(user),"complete",request));} + /** 本土卡车凭证绑定本次排车资源记录。 */ + @PostMapping(value="/{number}/truck/files",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse truckFile(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@RequestPart MultipartFile file){return ApiResponse.success(service.upload(number,operator(user),file));} + /** 完成凭证经排车归属授权后下载。 */ + @GetMapping("/{number}/truck/files/{fileId}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ResponseEntity evidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@PathVariable long fileId){operator(user);return ResponseEntity.ok().contentType(MediaType.APPLICATION_OCTET_STREAM).header(HttpHeaders.CONTENT_DISPOSITION,"attachment; filename=transport-evidence").body(service.evidence(number,fileId));} + /** 认证身份来自网关安全上下文。 */ + private static String operator(AuthenticatedUser user){ + if(user==null || user.userId()==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,"请重新登录"); + if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId()))throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可以操作排车");return user.userId().toString(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java new file mode 100644 index 000000000..b3fa3dc26 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskMapper.java @@ -0,0 +1,42 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import org.apache.ibatis.annotations.*; + +/** 运输任务与现有排车主单的一对一关系及本土卡车原单关联。 */ +@Mapper +public interface TransportTaskMapper { + /** 已成功命令持久化的凭证引用可在刷新后恢复。 */ + @Select("SELECT evidence_json FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} ORDER BY created_at,request_key") + java.util.List evidence(String number); + /** 运输命令独立幂等账本,摘要包含动作与版本。 */ + @Select("SELECT request_hash FROM ll_dispatch_transport_command WHERE dispatch_no=#{number} AND request_key=#{key}") + String commandHash(@Param("number") String number,@Param("key") String key); + /** 仅在执行成功同事务记账。 */ + @Insert("INSERT INTO ll_dispatch_transport_command(dispatch_no,request_key,request_hash,evidence_json) VALUES(#{number},#{key},#{hash},#{evidence})") + int command(@Param("number") String number,@Param("key") String key,@Param("hash") String hash,@Param("evidence") String evidence); + /** 创建键关联原始请求摘要,避免修改来源后重放。 */ + @Select("SELECT request_hash FROM ll_dispatch_transport_create WHERE trip_no=#{trip}") + String createHash(String trip); + /** 保存首次本土卡车安排内容。 */ + @Insert("INSERT INTO ll_dispatch_transport_create(trip_no,request_hash) VALUES(#{trip},#{hash})") + int createHashSave(@Param("trip") String trip,@Param("hash") String hash); + /** 仅返回已完成原单收货分配且没有承运安排的本土卡车需求。 */ + @Select("SELECT o.* FROM ll_us_truck_order o WHERE o.status='PENDING_DISPATCH' AND NOT EXISTS(SELECT 1 FROM ll_dispatch_truck_task t WHERE t.customer_id=o.customer_id AND t.order_no=o.order_no AND t.active_order IS NOT NULL) AND NOT EXISTS(SELECT 1 FROM ll_fleet_pool_order_link p WHERE p.customer_id=o.customer_id AND p.business_type='US_TRUCK' AND p.business_order_id=o.id) ORDER BY o.created_at,o.id LIMIT 100") + java.util.List candidates(); + /** 原取货创建请求重放时恢复同一排车号。 */ + @Select("SELECT * FROM ll_dispatch_order WHERE trip_id=#{trip}") + DispatchAggregate byTrip(long trip); + /** 原单仅允许一个有效承运任务,取消后保留历史。 */ + @Insert("INSERT INTO ll_dispatch_truck_task(dispatch_no,trip_no,customer_id,order_no,active_order) VALUES(#{dispatchNo},#{tripNo},#{customerId},#{orderNo},CONCAT(#{customerId},':',#{orderNo}))") + int insert(TransportTaskModels.TruckLink row); + /** 按排车号读取本土卡车关联。 */ + @Select("SELECT dispatch_no,trip_no,customer_id,order_no FROM ll_dispatch_truck_task WHERE dispatch_no=#{number}") + TransportTaskModels.TruckLink truck(String number); + /** 取消任务解除有效单唯一约束,历史关联不删除。 */ + @Update("UPDATE ll_dispatch_truck_task SET active_order=NULL WHERE dispatch_no=#{number}") + int cancel(String number); + /** 查询排车唯一资源记录。 */ + @Select("SELECT trip_no FROM ll_fleet_trip WHERE id=#{id}") + String tripNo(long id); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java new file mode 100644 index 000000000..ff74f2627 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.fleet.trip.FleetTripModels; +import com.baiyue.lastleg.fleet.pickup.FleetPickupModels; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.time.Instant; +import java.util.List; + +/** 排车列表中的到仓取货与本土卡车运输命令。 */ +public final class TransportTaskModels { + /** 仅承载契约。 */ + private TransportTaskModels() { } + public record TruckCreate(@NotBlank String customerId,@NotBlank String orderNo, + @NotNull @Positive Long warehouseId,@NotNull Instant plannedDepartureAt, + @NotNull Instant expectedDriverEndAt,@NotBlank @Size(min=8,max=128) String idempotencyKey) { } + public record Command(@NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { } + public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution, + String customerId,String orderNo,List pickups, + List completionEvidence) { } + public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java new file mode 100644 index 000000000..831a9eb00 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.baiyue.lastleg.fleet.trip.TransportTripChanged; +import com.baiyue.lastleg.trucking.entity.*; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Service; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */ +@Service +public class TransportTaskProjection { + private final DispatchAggregateRepository roots; + private final TransportTaskRepository links; + private final UsTruckOrderRepository trucks; + private final DispatchNumberGenerator numbers; + private final ObjectMapper json; + /** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */ + public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) { + this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json; + } + /** 注册真实排车号;来源行锁和唯一 trip_id 防止重复建单。 */ + public DispatchAggregate register(FleetTripEntity trip,int items,int pallets,int packages,String operator) { + var existing=links.byTrip(trip.getId());if(existing!=null)return existing; + var row=new DispatchAggregate();row.setId(IdWorker.getId());row.setDispatchNo(numbers.next());row.setTaskType(trip.getTripPurpose()); + row.setTripId(trip.getId());row.setOverseasWarehouseId(trip.getWarehouseId().toString()); + row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName()); + row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L); + row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L); + var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null); + row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review)); + resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt()); + row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages); + row.setPickedPackageCount(0);row.setShippedPackageCount(0);row.setRemark(trip.getRemark());row.setCreatedBy(operator); + row.setCreatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setUpdatedAt(row.getCreatedAt());roots.insert(row); + roots.history(IdWorker.getId(),row,"TRANSPORT_CREATE",operator,"接入现有排车",write(row),row.getUpdatedAt());return row; + } + /** 与资源事实同事务更新主单,兼容仍被集成调用的旧执行接口。 */ + @EventListener + public void changed(TransportTripChanged event) { + var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return; + var found=links.byTrip(trip.getId());if(found==null)return; + var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion(); + row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt()); + row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE"); + row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";}); + row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt()); + row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip); + if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败"); + roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt()); + var link=links.truck(row.getDispatchNo());if(link==null)return; + String target=switch(trip.getStatus()){case "CANCELLED"->"PENDING_DISPATCH";case "IN_TRANSIT"->"IN_TRANSIT";case "COMPLETED"->"COMPLETED";default->"DISPATCHED";}; + transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion()); + if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo()); + } + /** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */ + public void transition(String customer,String number,String target,String action,String operator,String reason,String key) { + var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在"); + String from=order.getStatus();if(target.equals(from))return; + boolean allowed=("PENDING_DISPATCH".equals(from)&&"DISPATCHED".equals(target)) || ("DISPATCHED".equals(from)&&Set.of("PENDING_DISPATCH","IN_TRANSIT").contains(target)) || ("IN_TRANSIT".equals(from)&&"COMPLETED".equals(target)); + if(!allowed)throw new IllegalStateException("本土卡车原单状态与排车执行不一致"); + order.setStatus(target);order.setVersion(order.getVersion()+1);order.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));trucks.updateOrder(order); + var history=new UsTruckOrderStatusHistory();history.setId(IdWorker.getId());history.setOrderId(order.getId());history.setCustomerId(customer); + history.setOperationType(action);history.setFromStatus(from);history.setToStatus(target);history.setOperatorId(operator);history.setIdempotencyKey(key); + history.setRemark(reason);history.setCreatedAt(order.getUpdatedAt());trucks.insertHistory(history); + } + /** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */ + private void resources(DispatchAggregate row,FleetTripEntity trip) { + row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources( + trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()), + trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null)))); + } + /** 保存明确的业务快照,失败时终止事务。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskRepository.java new file mode 100644 index 000000000..5d6184daa --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskRepository.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import org.springframework.stereotype.Repository; + +/** 运输关联持久化边界,不在此决定业务状态。 */ +@Repository +public class TransportTaskRepository { + private final TransportTaskMapper mapper; + /** 注入本域 Mapper。 */ + public TransportTaskRepository(TransportTaskMapper mapper) { this.mapper=mapper; } + /** 已确认完成凭证引用。 */ + public java.util.List evidence(String number){return mapper.evidence(number);} + /** 读取已成功执行的运输命令摘要。 */ + public String commandHash(String number,String key){return mapper.commandHash(number,key);} + /** 成功后记账。 */ + public void command(String number,String key,String hash,String evidence){mapper.command(number,key,hash,evidence);} + /** 读取原创建内容摘要。 */ + public String createHash(String trip){return mapper.createHash(trip);} + /** 保存创建内容摘要。 */ + public void createHash(String trip,String hash){mapper.createHashSave(trip,hash);} + /** 按资源记录恢复排车主单。 */ + public DispatchAggregate byTrip(long trip) { return mapper.byTrip(trip); } + /** 保存原本土卡车单关联。 */ + public void insert(TransportTaskModels.TruckLink row) { mapper.insert(row); } + /** 读取原单关联。 */ + public TransportTaskModels.TruckLink truck(String number) { return mapper.truck(number); } + /** 取消后允许原单重新安排。 */ + public void cancel(String number) { mapper.cancel(number); } + /** 解析主单对应资源业务号。 */ + public String tripNo(long id) { return mapper.tripNo(id); } + /** 最早的一百票待派车原单,安排成功后自动移出候选。 */ + public java.util.List candidates() { return mapper.candidates(); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java new file mode 100644 index 000000000..848875bc1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -0,0 +1,158 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.fleet.pickup.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.multipart.MultipartFile; +import java.time.*; +import java.util.*; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +/** 现有排车入口编排运输任务,不调用仓内拣货、装车审核及扣库存命令。 */ +@Service +public class TransportTaskService { + private final DispatchAggregateRepository roots; + private final TransportTaskRepository links; + private final TransportTaskProjection projection; + private final FleetTripRepository tripRepo; + private final FleetTripService trips; + private final FleetPickupService pickups; + private final FleetPickupRepository pickupFacts; + private final UsTruckOrderRepository trucks; + private final FleetPoolOrderQueries pool; + private final DriverService drivers; + private final FleetTransportVehicleGuard vehicles; + private final TripOperationsEvidenceGateway files; + private final ObjectMapper json; + /** 复用已有资格、占用、原单及凭证能力。 */ + public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection, + FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts, + UsTruckOrderRepository trucks,FleetPoolOrderQueries pool,DriverService drivers,FleetTransportVehicleGuard vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) { + this.roots=roots;this.links=links;this.projection=projection;this.tripRepo=tripRepo;this.trips=trips;this.pickups=pickups;this.pickupFacts=pickupFacts; + this.trucks=trucks;this.pool=pool;this.drivers=drivers;this.vehicles=vehicles;this.files=files;this.json=json; + } + /** 待排本土卡车仅展示未被独立排车或拼车承运占用的原单。 */ + public List truckCandidates(){return links.candidates();} + /** 同仓取货沿用原任务规则,并注册一个真实排车主单。 */ + @Transactional + public TransportTaskModels.Detail createPickup(String operator,FleetPickupModels.Create request) { + var created=pickups.create(operator,request);var trip=tripRepo.lockTrip(created.trip().tripNo()); + var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator); + return detail(row.getDispatchNo()); + } + /** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */ + @Transactional + public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) { + String hash=hash(write(request));var prior=tripRepo.findCreated(operator,request.idempotencyKey()); + if(prior!=null)return replayCreate(prior,hash); + var order=trucks.lockNo(request.customerId(),request.orderNo());if(order==null)throw missing("本土卡车单不存在"); + prior=tripRepo.findCreated(operator,request.idempotencyKey());if(prior!=null)return replayCreate(prior,hash); + pool.requireStandalone(order.getCustomerId(),"US_TRUCK",order.getId()); + if(!"PENDING_DISPATCH".equals(order.getStatus()) || trucks.hasActiveDispatch(order.getCustomerId(),order.getOrderNo()))throw conflict("仅待派车且未安排承运的本土卡车单可以接入排车"); + if(order.getOriginAddress()==null || order.getOriginAddress().isBlank() || order.getDestinationAddress()==null || order.getDestinationAddress().isBlank())throw conflict("请先补齐原单起终点地址"); + var created=trips.createLocalTruck(operator,new FleetTripModels.Create(request.warehouseId(),request.plannedDepartureAt(),request.expectedDriverEndAt(), + List.of(new FleetTripModels.Stop(order.getOrigin(),order.getOriginAddress(),null),new FleetTripModels.Stop(order.getDestination(),order.getDestinationAddress(),null)),"本土卡车:"+order.getOrderNo(),request.idempotencyKey())); + var trip=tripRepo.lockTrip(created.trip().tripNo());var row=projection.register(trip,1,value(order.getPalletCount()),value(order.getPackageCount()),operator); + links.insert(new TransportTaskModels.TruckLink(row.getDispatchNo(),trip.getTripNo(),order.getCustomerId(),order.getOrderNo()));links.createHash(trip.getTripNo(),hash); + projection.transition(order.getCustomerId(),order.getOrderNo(),"DISPATCHED","TRANSPORT_ASSIGN",operator,"接入排车:"+row.getDispatchNo(),request.idempotencyKey()); + return detail(row.getDispatchNo()); + } + /** 重放只返回同一原始请求,不受后续原单状态推进影响。 */ + private TransportTaskModels.Detail replayCreate(FleetTripEntity trip,String hash) { + if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !hash.equals(links.createHash(trip.getTripNo())))throw conflict("同一安排请求不能更换来源或时间"); + var row=links.byTrip(trip.getId());if(row==null)throw conflict("原安排尚未完成,请重试");return detail(row.getDispatchNo()); + } + /** 通过排车号读取运输执行,不要求访问已隐藏的管理页面。 */ + @Transactional(readOnly=true) + public TransportTaskModels.Detail detail(String number) { + var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number); + return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(), + "PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(), + evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList()); + } + /** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */ + public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);} + /** 账本引用去重,不把上传但未确认的文件当作运输完成证据。 */ + private List evidenceIds(String number){return links.evidence(number).stream().filter(Objects::nonNull).flatMap(value->{try{return json.readValue(value,new com.fasterxml.jackson.core.type.TypeReference>(){}).stream();}catch(Exception e){throw new IllegalStateException("运输凭证引用读取失败",e);}}).distinct().toList();} + /** 所有命令先锁资源父记录,再由既有服务核对公共执行版本。 */ + public FleetTripEntity transport(String number,boolean lock) { + var root=roots.find(number);if(root==null)throw missing("排车单不存在"); + if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务"); + String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip; + } + /** 分配实际资源,容量超限时整次分配回滚。 */ + @Transactional + public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) { + var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request); + if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number); + } + /** 取消运输并保留原单、排车及资源审计。 */ + @Transactional + public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) { + var trip=transport(number,true);trips.cancel(trip.getTripNo(),operator,request);return detail(number); + } + /** 本土卡车发车和完成不制造仓库收发货记录。 */ + @Transactional + public TransportTaskModels.Detail truckCommand(String number,String operator,String action,TransportTaskModels.Command request) { + var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()))throw conflict("当前不是本土卡车运输任务"); + String hash=hash(action+":"+write(request));String prior=links.commandHash(number,request.idempotencyKey()); + if(prior!=null){if(!prior.equals(hash))throw conflict("同一请求标识不能提交不同内容");return detail(number);} + if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()))throw conflict("运输任务已变化,请刷新后重试"); + var order=truck(number); + if("depart".equals(action)) { + if(!"PLANNED".equals(trip.getStatus()) || !"DISPATCHED".equals(order.getStatus()))throw conflict("仅已派车的计划任务可发车"); + requireResources(trip);capacity(trip,order);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now()); + } else if("complete".equals(action)) { + if(!"IN_TRANSIT".equals(trip.getStatus()) || !"IN_TRANSIT".equals(order.getStatus()))throw conflict("请先确认发车"); + if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty())throw conflict("请上传至少一份本次运输完成凭证"); + for(long id:request.evidenceFileIds())files.metadata(trip.getId(),id); + trip.setStatus("COMPLETED");trip.setCompletedAt(now()); + trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of()))); + } else throw conflict("不支持的运输动作"); + trips.recordExecution(trip,request.recordVersion(),"LOCAL_TRUCK_"+action.toUpperCase(Locale.ROOT),request.reason(),operator); + links.command(number,request.idempotencyKey(),hash,write(request.evidenceFileIds()==null?List.of():request.evidenceFileIds()));return detail(number); + } + /** 上传只接受当前任务执行期间的真实文件,文件服务强制归属此资源记录。 */ + @Transactional + public com.baiyue.lastleg.fleet.reporting.TripOperationsRecordModels.EvidenceFile upload(String number,String operator,MultipartFile file) { + var trip=transport(number,true);if(!"LOCAL_TRUCK".equals(trip.getTripPurpose()) || !Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()))throw conflict("当前任务不能上传运输凭证"); + if(file==null || file.isEmpty() || file.getSize()>10L*1024*1024)throw conflict("请上传不超过10MB的凭证");return files.upload(trip.getId(),operator,file); + } + /** 原单在执行时锁定,不能通过普通原单手工推进绕过排车。 */ + private UsTruckOrder truck(String number){var link=links.truck(number);if(link==null)throw missing("本土卡车关联不存在");var order=trucks.lockNo(link.customerId(),link.orderNo());if(order==null)throw missing("本土卡车原单不存在");return order;} + /** 发车再次核对占用及证照,不把候选阶段校验当成执行资格。 */ + private void requireResources(FleetTripEntity trip) { + if(trip.getVehicleId()==null || trip.getDriverId()==null || !pickupFacts.ownsResources(trip.getId(),trip.getVehicleId(),trip.getDriverId()))throw conflict("请先分配有效车辆和司机"); + vehicles.validate(trip); + var driver=drivers.requireDispatchable(trip.getDriverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC));if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机"); + } + /** 原单实收托量、申报重量和体积均不得超过已分配车辆能力。 */ + private void capacity(FleetTripEntity trip,UsTruckOrder order) { + if(trip.getMaxPalletSpaces()==null || value(order.getPalletCount())>trip.getMaxPalletSpaces() || trip.getMaxLoadKg()==null || trip.getMaxVolumeCbm()==null + || (order.getTotalWeightKg()!=null && order.getTotalWeightKg().compareTo(trip.getMaxLoadKg())>0) + || (order.getTotalVolumeCbm()!=null && order.getTotalVolumeCbm().compareTo(trip.getMaxVolumeCbm())>0))throw conflict("本土卡车货量超过所选车辆托位、载重或容积"); + } + /** 可空业务数量按零显示,不产生虚构实收。 */ + private static int value(Integer value){return value==null?0:value;} + /** 固定 UTC 记录执行时刻。 */ + private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC).withNano(0);} + /** 稳定序列化请求摘要。 */ + private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} + /** 不在账本保存原始隐私内容。 */ + private static String hash(String value){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}} + /** 返回业务冲突。 */ + private static ResponseStatusException conflict(String value){return new ResponseStatusException(HttpStatus.CONFLICT,value);} + /** 返回不可访问资源。 */ + private static ResponseStatusException missing(String value){return new ResponseStatusException(HttpStatus.NOT_FOUND,value);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandMapper.java new file mode 100644 index 000000000..afa5e2bca --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandMapper.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import org.apache.ibatis.annotations.*; +import java.util.Collection; +import java.util.List; + +@Mapper +public interface FleetLooseboardDemandMapper { + /** 分页读取未匹配且没有独立实物履约的客户散板需求。 */ + @Select("") + Page pending(Page page,@Param("keyword") String keyword); + /** 原单锁内查询生效匹配,防止跨车次重复接单。 */ + @Select("SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customer} AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE") + Integer active(@Param("customer") String customer,@Param("id") long id); + /** 保存预订不可变来源,取消后保留历史。 */ + @Update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=#{id} WHERE id=#{booking} AND customer_id=#{customer} AND source_looseboard_order_id IS NULL") + int attach(@Param("booking") long booking,@Param("customer") String customer,@Param("id") long id); + /** 锁定当前预订对应的同客户原单。 */ + @Select("SELECT o.* FROM ll_local_looseboard_order o JOIN ll_fleet_external_booking b ON b.source_looseboard_order_id=o.id AND b.customer_id=o.customer_id WHERE b.id=#{booking} AND b.customer_id=#{customer} FOR UPDATE") + LocalLooseboardOrder source(@Param("booking") long booking,@Param("customer") String customer); + /** 批量读取草稿或取消记录的原单编号。 */ + @Select("") + List sourceNumbers(@Param("ids") Collection ids); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandRepository.java new file mode 100644 index 000000000..bf7fd7cf3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandRepository.java @@ -0,0 +1,28 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.Collection; +import java.util.List; + +@Repository +public class FleetLooseboardDemandRepository { + private final FleetLooseboardDemandMapper demands; + private final LocalLooseboardOrderMapper orders; + /** 注入需求查询和原单仓储。 */ + public FleetLooseboardDemandRepository(FleetLooseboardDemandMapper demands,LocalLooseboardOrderMapper orders) { this.demands=demands;this.orders=orders; } + /** 分页读取待匹配原单。 */ + public Page pending(int page,int size,String keyword) { return demands.pending(Page.of(page,size),keyword); } + /** 锁定客户原单。 */ + public LocalLooseboardOrder lock(String customer,String number) { return orders.lockNo(customer,number); } + /** 核对生效匹配。 */ + public boolean active(String customer,long id) { return demands.active(customer,id)!=null; } + /** 关联不可变来源。 */ + public int attach(long booking,String customer,long id) { return demands.attach(booking,customer,id); } + /** 锁定匹配来源。 */ + public LocalLooseboardOrder source(long booking,String customer) { return demands.source(booking,customer); } + /** 批量读取原单编号。 */ + public List sourceNumbers(Collection ids) { return demands.sourceNumbers(ids); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandService.java new file mode 100644 index 000000000..143b94503 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandService.java @@ -0,0 +1,52 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.http.HttpStatus; +import java.util.Objects; + +@Service +public class FleetLooseboardDemandService { + private final FleetLooseboardDemandRepository repository; + /** 注入原单需求仓储。 */ + public FleetLooseboardDemandService(FleetLooseboardDemandRepository repository) { this.repository=repository; } + /** 已授权平台入口读取待匹配原单。 */ + @Transactional(readOnly=true) + public PageResult pending(int page,int size,String keyword) { + var rows=repository.pending(page,size,keyword); + return new PageResult<>(rows.getRecords().stream().map(Demand::from).toList(),rows.getCurrent(),rows.getSize(),rows.getTotal(),rows.getPages()); + } + public record Demand(String orderNo,String customerId,long version,String origin,String destination,String productName, + Integer estimatedPalletCount,Integer packageCount,java.math.BigDecimal totalWeightKg,java.math.BigDecimal totalVolumeCbm, + String currency,String originContactName,String originContactPhone,String remark) { + /** 仅输出匹配所需资料,不泄露原单幂等键与请求哈希。 */ + static Demand from(LocalLooseboardOrder o) { return new Demand(o.getOrderNo(),o.getCustomerId(),o.getVersion(),o.getOrigin(),o.getDestination(),o.getProductName(),o.getEstimatedPalletCount(),o.getPackageCount(),o.getTotalWeightKg(),o.getTotalVolumeCbm(),o.getCurrency(),o.getOriginContactName(),o.getOriginContactPhone(),o.getRemark()); } + } + /** 锁定原单并校验客户、版本与唯一匹配,不预留容量。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public LocalLooseboardOrder prepare(String customer,String type,String number,Long version) { + if(number==null||number.isBlank()) { if(version!=null)throw conflict("来源版本必须与散板原单一起提交"); return null; } + if(!"LOCAL_LOOSEBOARD".equals(type))throw conflict("散板原单只能匹配本土散板承运"); + var order=repository.lock(customer,number); + if(order==null||version==null||!Objects.equals(version,order.getVersion()))throw conflict("散板原单不存在或已变化,请刷新待匹配需求"); + if(!"PENDING_ACCEPTANCE".equals(order.getStatus())||order.getLastlegOrderId()!=null||repository.active(customer,order.getId()))throw conflict("散板原单已有承运或独立履约,请核对原单"); + return order; + } + /** 原单锁保持到事务提交,跨车次匹配只允许成功一次。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void attach(long booking,LocalLooseboardOrder order) { + if(order!=null&&repository.attach(booking,order.getCustomerId(),order.getId())!=1)throw conflict("原单匹配失败,请刷新重试"); + } + /** 匹配后禁止改写业务类型或替换来源,允许省略已有来源参数。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void validateEdit(long booking,String customer,String type,String number) { + var source=repository.source(booking,customer); + if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(type)||number!=null&&!number.equals(source.getOrderNo())))throw conflict("已匹配散板原单不能替换,请取消登记后重新匹配"); + if(source==null&&number!=null&&!number.isBlank())throw conflict("请从待匹配散板需求创建新的登记"); + } + /** 返回业务冲突。 */ + private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index 19d19c6cb..d9c2ee625 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -20,6 +20,7 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*; /** Same-database bridge. Never creates inventory, publishes fees, or locks a parent trip. */ @Service public class FleetPoolOrderBridge { + @jakarta.annotation.Resource private FleetLooseboardDemandRepository demands; private final FleetPoolOrderRepository repository; private final FleetPoolOrderNumberGenerator numbers; private final ObjectMapper json; @@ -47,16 +48,20 @@ public class FleetPoolOrderBridge { validate(in); String hash=hash(in); var prior=repository.lockBooking(in.bookingId()); if(prior!=null){if(!hash.equals(prior.getCreationRequestHash()))throw conflict("同一承运来源不能创建不同商业订单");return ref(prior);} + var source=demands.source(in.bookingId(),in.customerId()); + if(source!=null&&(!"LOCAL_LOOSEBOARD".equals(in.businessType())||!"PENDING_ACCEPTANCE".equals(source.getStatus())||source.getLastlegOrderId()!=null))throw conflict("散板原单履约状态已变化"); var now=LocalDateTime.now();var row=new FleetPoolOrderLink(); row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo()); - row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(IdWorker.getId()); - row.setBusinessOrderNo(numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId())); + row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId()); + row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo()); row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey()); row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions()); row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L); row.setCreationRequestHash(hash);row.setCreatedBy(operator);row.setCreatedAt(now);row.setUpdatedAt(now); String transport=in.destinationKey().startsWith("FBA:")?"WAREHOUSE_TO_WAREHOUSE":"WAREHOUSE_TO_DOOR"; - if("LOCAL_LOOSEBOARD".equals(in.businessType())) { + if(source!=null) { + source.setStatus("WAITING_INBOUND");source.setVersion(source.getVersion()+1);source.setUpdatedAt(now);repository.updateLoose(source); + } else if("LOCAL_LOOSEBOARD".equals(in.businessType())) { var order=new LocalLooseboardOrder();order.setId(row.getBusinessOrderId());order.setCustomerId(in.customerId());order.setOrderNo(row.getBusinessOrderNo()); order.setTransportType(transport); // 货名与预计托数沿真实询价保存;不从数量推断打盘服务,也不预设取送时间。 @@ -79,7 +84,7 @@ public class FleetPoolOrderBridge { order.setRequestHash(hash);order.setCreatedBy(operator);order.setVersion(0L);order.setCreatedAt(now);order.setUpdatedAt(now); repository.insertTruck(order); // No simulated ll_us_truck_order_pallet rows. } - repository.insertLink(row);history(row,null,row.getFulfillmentStage(),operator,"确认承运并建立商业委托",now); + repository.insertLink(row);history(row,source==null?null:"PENDING_ACCEPTANCE",row.getFulfillmentStage(),operator,source==null?"确认承运并建立商业委托":"原单匹配车次并确认承运",now); return ref(row); } /** 锁定预订并关联唯一真实来货,不创建第二份实物来源。 */ @@ -177,6 +182,7 @@ public class FleetPoolOrderBridge { /** 批量读取预订的业务编号。 */ public Map businessOrderNumbers(Collection ids) { if(ids==null||ids.isEmpty())return Map.of();var result=new HashMap(); + demands.sourceNumbers(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo())); repository.bookings(ids).forEach(row->result.put(row.getBookingId(),row.getBusinessOrderNo()));return result; } static void validate(ConfirmedBooking in) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java index 090edfa2b..b87613189 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderMapper.java @@ -38,7 +38,9 @@ public interface FleetPoolOrderMapper extends BaseMapper { FleetPoolOrderLink lockIncoming(@Param("customerId") String customerId,@Param("no") String no); @Select("SELECT * FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND dispatch_no=#{no} ORDER BY id FOR UPDATE") List lockDispatch(@Param("customerId") String customerId,@Param("no") String no); - @Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id}") + /** 原单锁内使用当前读,避免等待匹配提交后仍按旧快照放行独立修改。 */ + @Select("SELECT COALESCE((SELECT 1 FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND business_type=#{type} AND business_order_id=#{id} LIMIT 1 FOR UPDATE),0) + " + + "COALESCE((SELECT 1 FROM ll_fleet_external_booking WHERE customer_id=#{customerId} AND #{type}='LOCAL_LOOSEBOARD' AND source_looseboard_order_id=#{id} AND status!='CANCELLED' LIMIT 1 FOR UPDATE),0)") int linked(@Param("customerId") String customerId,@Param("type") String type,@Param("id") long id); @Select("SELECT COUNT(*) FROM ll_fleet_pool_order_link WHERE customer_id=#{customerId} AND incoming_no=#{no}") int linkedIncoming(@Param("customerId") String customerId,@Param("no") String no); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java index b5f54bde1..a474e2a46 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferModels.java @@ -36,7 +36,16 @@ public final class FleetCapacityOfferModels { @NotBlank @Pattern(regexp="SELF_DELIVERY|MANUAL_PICKUP_TO_WAREHOUSE") String deliveryMode, Instant estimatedArrivalAt,@Min(1) Integer expectedPalletCount, @NotNull @Valid FleetBookingModels.Cargo cargo,@Valid FleetQuoteCandidateModels.QuoteRef quoteRef, - @NotBlank @Size(min=8,max=128) String idempotencyKey) { } + @NotBlank @Size(min=8,max=128) String idempotencyKey, + @Size(max=64) String sourceOrderNo,@Min(0) Long sourceOrderVersion) { + /** 保留无原单人工登记的构造方式。 */ + public InquiryWrite(Long tripRecordVersion,Long offerRecordVersion,Long recordVersion,Long customerId, + String businessType,String deliveryMode,Instant estimatedArrivalAt,Integer expectedPalletCount, + FleetBookingModels.Cargo cargo,FleetQuoteCandidateModels.QuoteRef quoteRef,String idempotencyKey) { + this(tripRecordVersion,offerRecordVersion,recordVersion,customerId,businessType,deliveryMode, + estimatedArrivalAt,expectedPalletCount,cargo,quoteRef,idempotencyKey,null,null); + } + } public record InquiryCommand(@NotNull @Min(0) Long tripRecordVersion,@NotNull @Min(0) Long offerRecordVersion, @NotNull @Min(0) Long recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, @Size(max=1000) String reason) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index ca291fe1a..eb0ca1ca7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -25,6 +25,7 @@ import java.util.*; @Service public class FleetCapacityOfferService { + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService demands; public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage"; private final FleetCapacityOfferMapper offers; private final FleetCapacityOfferCommandMapper commands; @@ -50,6 +51,11 @@ public class FleetCapacityOfferService { this.offers=offers;this.commands=commands;this.trips=trips;this.bookings=bookings;this.bookingService=bookingService; this.reservations=reservations;this.execution=execution;this.customers=customers;this.orders=orders;this.json=json;this.clock=clock; } + /** 平台需求池分页读取客户已下的散板原单。 */ + @Transactional(readOnly=true) + public PageResult pendingDemands(FleetCapacityOfferModels.InquiryQuery query) { + writePermission("inquiry-create");return demands.pending(query.page(),query.size(),query.keyword()); + } @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public PageResult page(FleetCapacityOfferModels.Query query){ readPermission(); @@ -171,17 +177,20 @@ public class FleetCapacityOfferService { touch(row);var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false); saveCommand(trip,action,number,request.idempotencyKey(),request,result);return result; } + /** 同一事务登记意向及原单来源,确认前不占位。 */ @Transactional public FleetCapacityOfferModels.InquiryResult createInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){ writePermission("inquiry-create");var initial=offer(number,false);var trip=trip(initial.getTripNo(),true);var row=offer(number,true); var replay=replay(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class); if(replay!=null)return replayed(replay); versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request); + var source=demands.prepare(request.customerId().toString(),request.businessType(),request.sourceOrderNo(),request.sourceOrderVersion()); var created=bookingService.create(trip.getTripNo(),operator(),new FleetBookingModels.Create(trip.getRecordVersion(),request.customerId(),request.cargo(),"POOL:"+hash(Arrays.asList(number,request.idempotencyKey())),request.quoteRef())); if(created.replayed())throw conflict("意向创建记录不完整,请保留原请求联系调度核对"); var booking=bookings.lock(trip.getId(),created.booking().bookingNo());booking.setOfferId(row.getId());booking.setSalesOwnerUserId(operator());apply(booking,request); - bookings.updateById(booking);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result; + bookings.updateById(booking);demands.attach(booking.getId(),source);var result=inquiryResult(row,booking);saveCommand(trip,"INQUIRY_CREATE",number,request.idempotencyKey(),request,result);return result; } + /** 编辑登记资料时保持客户及原单来源不可变。 */ @Transactional public FleetCapacityOfferModels.InquiryResult updateInquiry(String number,FleetCapacityOfferModels.InquiryWrite request){ writePermission("inquiry-edit");var initial=inquiryRow(number);own(initial);var trip=trip(initial.getTripNo(),true);var row=offers.lock(offers.selectById(initial.getOfferId()).getOfferNo()); @@ -189,6 +198,7 @@ public class FleetCapacityOfferService { var replay=replay(trip,"INQUIRY_EDIT",number,request.idempotencyKey(),request,FleetCapacityOfferModels.InquiryResult.class);if(replay!=null)return replayed(replay); versions(trip,row,request.tripRecordVersion(),request.offerRecordVersion());requireInquiryOpen(row,trip);validateInquiry(trip,request); if(!Objects.equals(booking.getCustomerId(),request.customerId().toString()))throw conflict("意向客户不能改写,请取消草稿后重新登记"); + demands.validateEdit(booking.getId(),booking.getCustomerId(),request.businessType(),request.sourceOrderNo()); if(request.recordVersion()==null)throw bad("缺少意向版本"); bookingService.updatePool(trip.getTripNo(),number,operator(),new FleetBookingModels.Update(trip.getRecordVersion(),request.recordVersion(),request.cargo(),request.quoteRef())); booking=bookings.lock(trip.getId(),number);apply(booking,request);bookings.updateById(booking); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index 9f8e22d1e..6ab3ee073 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -276,7 +276,7 @@ public class FleetTripExecutionService { } /** 出库装载只能用于派送车次,取货必须沿专用运输链执行。 */ private static void requireVersionAndPlan(FleetTripEntity trip, long version) { - if ("PICKUP".equals(trip.getTripPurpose())) throw conflict("取货车次请使用取货任务操作"); + if (java.util.Set.of("PICKUP","LOCAL_TRUCK").contains(java.util.Objects.toString(trip.getTripPurpose(),""))) throw conflict("运输排车请使用对应运输任务操作"); if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重新确认"); if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 5f9bcb3e6..8f26fd1ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -27,6 +27,7 @@ import java.util.*; @Service public class FleetTripService { + @org.springframework.beans.factory.annotation.Autowired(required=false) private org.springframework.context.ApplicationEventPublisher transportEvents; @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges; @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; private final FleetTripRepository repository; @@ -101,6 +102,12 @@ public class FleetTripService { return createPlan(operator, request, "PICKUP"); } + /** 本土卡车运输使用同一资源占用与司机资格规则。 */ + @Transactional + public FleetTripModels.Detail createLocalTruck(String operator, FleetTripModels.Create request) { + return createPlan(operator, request, "LOCAL_TRUCK"); + } + /** 主单创建事务内建立空资源记录,允许未知计划/临时资料,审核时再核齐。 */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public FleetExecutionModels.DispatchShell createDispatchShell(String operator, FleetExecutionModels.DispatchShellRequest request) { @@ -376,7 +383,7 @@ public class FleetTripService { /** 两类车次共用建档逻辑,幂等摘要包含用途以防串用。 */ private FleetTripModels.Detail createPlan(String operator, FleetTripModels.Create request, String purpose) { - String hash = hash(("PICKUP".equals(purpose) ? "PICKUP:" : "") + write(request)); + String hash = hash(("DELIVERY".equals(purpose) ? "" : purpose+":") + write(request)); var existing = repository.findCreated(operator, request.idempotencyKey()); if (existing != null) return replay(existing, hash); validatePlan(request.plannedDepartureAt(), request.expectedDriverEndAt(), request.stops()); @@ -447,7 +454,7 @@ public class FleetTripService { bookingReservations.requireRoute(trip, request.stops()); if (trip.getDriverId() != null) drivers.requireDispatchable(trip.getDriverId(), request.expectedDriverEndAt()); boolean unchangedRoute = response(trip).stops().equals(request.stops()); - if ("PICKUP".equals(trip.getTripPurpose()) && !unchangedRoute) throw conflict("取货路线由原单地址和入仓仓库确定,不能修改为出库路线"); + if (Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")) && !unchangedRoute) throw conflict("运输路线由原单确定,不能修改来源地址"); boolean unchangedPlan = unchangedRoute && trip.getPlannedDepartureAt().equals(utc(request.plannedDepartureAt())) && trip.getExpectedDriverEndAt().equals(utc(request.expectedDriverEndAt())); trip.setPlannedDepartureAt(utc(request.plannedDepartureAt())); trip.setExpectedDriverEndAt(utc(request.expectedDriverEndAt())); @@ -643,6 +650,7 @@ public class FleetTripService { } /** 为当前版本保存操作人、原因和完整车次历史。 */ private void record(FleetTripEntity trip, String action, String reason, String operator) { + if(transportEvents!=null)transportEvents.publishEvent(new TransportTripChanged(trip,action,reason,operator)); workChanges.changed(trip); var event = new FleetTripHistory(); event.setId(IdWorker.getId()); event.setTripId(trip.getId()); event.setRecordVersion(trip.getRecordVersion()); event.setAction(action); event.setReason(trim(reason)); event.setOperatorId(operator); event.setOccurredAt(trip.getUpdatedAt()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java index a54777c1d..a39fa512d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/PlatformFleetCapacityOfferController.java @@ -29,6 +29,9 @@ public class PlatformFleetCapacityOfferController { public ApiResponse resume(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"RESUME",request));} @PostMapping("/{offerNo}/close") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage')") public ApiResponse close(@PathVariable String offerNo,@Valid @RequestBody FleetCapacityOfferModels.Command request){return ApiResponse.success(service.command(offerNo,"CLOSE",request));} + /** 读取客户已提交、等待车次匹配的散板原单。 */ + @GetMapping("/pending-looseboard-demands") @PreAuthorize("hasAuthority('platform:fleet:capacity-offer:manage') or (hasAuthority('platform:fleet:capacity-offer:list') and hasAuthority('platform:fleet:capacity-offer:inquiry-create'))") + public ApiResponse> pendingDemands(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.pendingDemands(query));} @GetMapping("/inquiries") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") public ApiResponse> inquiries(@Valid FleetCapacityOfferModels.InquiryQuery query){return ApiResponse.success(service.inquiries(query));} @GetMapping("/inquiries/{bookingNo}") @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index d2d5fa6ee..1ba167f07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -51,7 +51,7 @@ public class LocalLooseboardService { @Resource private LastlegOrderNoGenerator lastlegOrderNoGenerator; @Resource private ObjectMapper objectMapper; - /** 在同一事务保存客户需求、唯一尾程来源及创建历史,创建键重复时核对请求哈希。 */ + /** 在同一事务保存客户需求及创建历史,等待需求池匹配后登记真实来货,创建键重复时核对请求哈希。 */ @Transactional public LocalLooseboardResponses.Detail create(String customerId, String operatorId, LocalLooseboardRequests.Create request) { @@ -75,8 +75,7 @@ public class LocalLooseboardService { order.setVersion(0L); order.setCreatedAt(now); order.setUpdatedAt(now); - LastlegOrder lastlegOrder = createLastlegOrder(order, requestHash, now); - order.setLastlegOrderId(lastlegOrder.getId()); + // 下单只保存池内需求,实际收货时再创建唯一实物尾程单。 try { repository.insertOrder(order); } catch (DuplicateKeyException exception) { @@ -86,8 +85,7 @@ public class LocalLooseboardService { } history(order, "CREATE", null, PENDING_ACCEPTANCE, operatorId, key, null, request.remark(), now); - lastlegHistory(customerId, lastlegOrder.getId(), null, lastlegOrder.getCurrentStatus(), - "Local looseboard order submitted", now); + return detail(customerId, order.getOrderNo()); } @@ -158,6 +156,7 @@ public class LocalLooseboardService { } if (!request.version().equals(value(order.getVersion()))) throw conflict("Order was changed; refresh and retry"); String next = norm(request.status()); + if (order.getLastlegOrderId() == null && !CANCELLED.equals(next)) throw conflict("请从散板需求池匹配车次,沿实际收货、排车和签收推进"); if (!STATUSES.contains(next)) throw badRequest("Unsupported looseboard order status"); String from = order.getStatus(); if (next.equals(from)) return response(order); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java index f6c43085d..f7e1c5a07 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/mapper/UsTruckOrderMapper.java @@ -8,6 +8,9 @@ import org.apache.ibatis.annotations.Select; @Mapper public interface UsTruckOrderMapper extends BaseMapper { + /** 原单被有效排车接管后,禁止独立改状态绕过运输事实。 */ + @Select("SELECT COUNT(*) FROM ll_dispatch_truck_task WHERE customer_id=#{customer} AND order_no=#{number} AND active_order IS NOT NULL") + long activeDispatch(@Param("customer") String customer,@Param("number") String number); @Select("SELECT * FROM ll_us_truck_order WHERE customer_id=#{customerId} AND order_no=#{orderNo} LIMIT 1") UsTruckOrder findNo(@Param("customerId") String customerId, @Param("orderNo") String orderNo); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java index 603047e03..aa238163f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/repository/UsTruckOrderRepository.java @@ -44,6 +44,8 @@ public class UsTruckOrderRepository { public UsTruckOrder findNo(String customerId, String orderNo) { return orders.findNo(customerId, orderNo); } /** 在当前事务中按客户和订单号加行锁。 */ public UsTruckOrder lockNo(String customerId, String orderNo) { return orders.lockNo(customerId, orderNo); } + /** 有效运输任务持有原单的执行权。 */ + public boolean hasActiveDispatch(String customer,String number) { return orders.activeDispatch(customer,number)>0; } /** 按客户和创建幂等键读取。 */ public UsTruckOrder findKey(String customerId, String key) { return orders.findKey(customerId, key); } /** 保存新订单。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index e2537549f..8501c21a9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -396,7 +396,7 @@ public class UsTruckOrderService { } /** 按客户范围锁单并校验独立订单资格。 */ - private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); return o; } + private UsTruckOrder lock(String customerId, String orderNo) { UsTruckOrder o = repository.lockNo(customerId, orderNo); if (o == null) throw notFound(); fleetPool.requireStandalone(customerId, "US_TRUCK", o.getId()); if(repository.hasActiveDispatch(customerId,orderNo))throw conflict("该单已接入排车,请在排车列表执行运输操作"); return o; } /** 复核创建请求摘要并返回既有订单。 */ private UsTruckOrderResponses.Detail replay(String customerId, UsTruckOrder order, String hash) { if (!hash.equals(order.getRequestHash())) throw conflict("Idempotency key was reused with different truck data"); return detail(customerId, order.getOrderNo()); } /** 读取订单资料与实际托盘和操作记录。 */ diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index e8617c50a..0a184629b 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1208,6 +1208,7 @@ CREATE TABLE ll_fleet_external_booking ( binding_key VARCHAR(128) NULL COMMENT '关联发运请求键', binding_hash CHAR(64) NULL COMMENT '关联发运摘要', offer_id BIGINT NULL COMMENT '来源运力发布,普通车次预订为空', + source_looseboard_order_id BIGINT NULL COMMENT '客户原始散板商业单ID,取消后保留来源', sales_owner_user_id VARCHAR(64) NULL COMMENT '登录身份冻结的销售负责人', business_type VARCHAR(32) NULL COMMENT '本土商业类型LOCAL_LOOSEBOARD或US_TRUCK', delivery_mode VARCHAR(32) NULL COMMENT '客户送仓或人工安排提货回仓', @@ -1222,6 +1223,7 @@ CREATE TABLE ll_fleet_external_booking ( KEY idx_fleet_booking_sales(sales_owner_user_id,offer_id,id), CONSTRAINT fk_fleet_booking_offer FOREIGN KEY(offer_id) REFERENCES ll_fleet_capacity_offer(id), CONSTRAINT chk_fleet_booking_expected_pallet CHECK(expected_pallet_count IS NULL OR expected_pallet_count > 0), + KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status), PRIMARY KEY(id), UNIQUE KEY uk_fleet_booking_no(booking_no), UNIQUE KEY uk_fleet_booking_create(trip_id,created_by,idempotency_key), @@ -1898,6 +1900,7 @@ CREATE TABLE IF NOT EXISTS ll_inbound_stock_receipt ( CREATE TABLE IF NOT EXISTS ll_dispatch_order ( id BIGINT NOT NULL COMMENT '整车排车主单内部主键', dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', + task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', @@ -4639,3 +4642,28 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( CONSTRAINT fk_owned_business_history_dispatch FOREIGN KEY (dispatch_id) REFERENCES ll_dispatch_order(id), CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; + + +-- 运输原单接入现有排车,不产生仓内库存事实。 +USE baiyue_lastleg; +CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识', + order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号', + active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', + PRIMARY KEY(dispatch_no), UNIQUE KEY uk_transport_trip(trip_no), UNIQUE KEY uk_transport_source(active_order), + KEY idx_transport_order(customer_id,order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; +CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create ( + trip_no VARCHAR(64) NOT NULL PRIMARY KEY COMMENT '本次运输资源记录号', + request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要' +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; +CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', + request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要', + evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', + PRIMARY KEY(dispatch_no,request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; diff --git a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml new file mode 100644 index 000000000..3b4cf3ba8 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml @@ -0,0 +1,99 @@ +openapi: 3.0.3 +info: + title: 现有排车运输任务 + version: 1.0.0 + description: >- + 平台主体(tenantId=0)在现有排车列表内安排到仓取货、本土卡车,使用真实PC排车号。 + 请求经统一Gateway与Bearer认证;JSON响应HTTP200,业务code=200成功,A0400参数错误、A0403权限不足、A0404不存在、A0409版本或状态冲突。 + list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。 + 创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。 + 到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。 +servers: + - url: /api/platform/v1/last-leg/dispatches/transport-tasks +security: + - bearerAuth: [] +paths: + /pickup-candidates: + get: + summary: 同仓上门取货候选(list,page/size/keyword/warehouseId) + responses: &response + '200': + description: 业务响应信封;data为对应详情、候选或操作结果 + /truck-candidates: + get: + summary: 待派车且未被运输或拼车占用的本土卡车候选(list) + responses: *response + /pickup: + post: + summary: 同一海外仓多客户取货安排(create) + description: sources为customerId/transferNo数组,plannedDepartureAt和expectedDriverEndAt为ISO时刻;idempotencyKey必填,remark可选。返回Detail。 + responses: *response + /truck: + post: + summary: 本土卡车原单接入排车(create) + description: customerId、orderNo、warehouseId、plannedDepartureAt、expectedDriverEndAt、idempotencyKey必填;原单须PENDING_DISPATCH且未被占用。返回Detail。 + responses: *response + /{number}: + parameters: &number + - { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 } + get: + summary: 排车运输详情(list) + description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)。 + responses: *response + /{number}/{action}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: action, in: path, required: true, schema: { type: string, enum: [assign, cancel, resource-occupancy, release] } } + post: + summary: 分配、取消、资源可用性查询与实际任务释放 + description: >- + assign(edit):recordVersion、vehicleId、driverId、reason;cancel(cancel):recordVersion、reason。 + resource-occupancy(list):kind=VEHICLE/DRIVER、resourceIds数组。 + release(edit):recordVersion、resourceKind、actualEndAt、endLocation、vehicleStatus(车辆必填)、idempotencyKey、reason。 + responses: *response + /{number}/{kind}/{action}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: kind, in: path, required: true, schema: { type: string, enum: [pickup, truck] } } + - { name: action, in: path, required: true, schema: { type: string, enum: [depart, complete] } } + post: + summary: 运输发车(depart)或完成(edit) + description: recordVersion、idempotencyKey、reason必填。本土卡车complete还须至少一份evidenceFileIds,文件归属本任务。取货complete要求所有原单已送仓。 + responses: *response + /{number}/pickup/{customer}/{source}/{action}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: customer, in: path, required: true, schema: { type: string } } + - { name: source, in: path, required: true, schema: { type: string }, description: 仓储转运单号 } + - { name: action, in: path, required: true, schema: { type: string, enum: [picked, arrive, files] } } + post: + summary: 按原单上传凭证、确认实提或送仓(edit) + description: files使用multipart字段file;picked使用recordVersion、idempotencyKey、actualPackages、evidenceFileIds、remark;arrive使用recordVersion、idempotencyKey、reason。凭证不能跨客户原单使用。 + responses: *response + /{number}/truck/files: + parameters: *number + post: + summary: 本土卡车凭证上传(edit) + description: multipart字段file,非空且不超过10MB,只能在计划或运输中上传。上传不等于完成,须通过complete命令确认文件引用。 + responses: *response + /{number}/truck/files/{fileId}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 已确认本土卡车完成凭证鉴权下载(list) + responses: + '200': { description: application/octet-stream文件;失败返回业务响应信封 } + /{number}/pickup/{customer}/{source}/files/{fileId}: + parameters: + - { name: number, in: path, required: true, schema: { type: string } } + - { name: customer, in: path, required: true, schema: { type: string } } + - { name: source, in: path, required: true, schema: { type: string } } + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64 } } + get: + summary: 当前排车原单已确认取货凭证鉴权下载(list) + responses: + '200': { description: application/octet-stream文件;当前排车及原单归属不符时拒绝 } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml index 52163e41b..3389fbc00 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-capacity-offers-v1.yaml @@ -373,6 +373,36 @@ paths: application/json: schema: $ref: '#/components/schemas/OfferCommand' + /api/platform/v1/last-leg/fleet/capacity-offers/pending-looseboard-demands: + get: + summary: 分页读取客户已提交的待匹配散板原单 + description: 权限:platform:fleet:capacity-offer:manage 或 (platform:fleet:capacity-offer:list 且 platform:fleet:capacity-offer:inquiry-create)。仅返回待接单、没有独立尾程来源且没有生效匹配的原单;取消未确认登记后恢复候选。下单及登记不占位,确认承运才占位。旧独立履约单不回填来源。 + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + - { name: keyword, in: query, schema: { type: string, maxLength: 128 } } + responses: + '200': + description: 统一业务响应 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + message: { type: string } + data: + type: object + properties: + records: + type: array + items: { $ref: '#/components/schemas/LooseboardDemand' } + page: { type: integer } + size: { type: integer } + total: { type: integer } + pages: { type: integer } + '401': { description: 未登录 } + '403': { description: 无需求匹配权限 } /api/platform/v1/last-leg/fleet/capacity-offers/inquiries: get: summary: 分页查询本人或获准的全部意向 @@ -1310,6 +1340,24 @@ components: minimum: 0 - type: string pattern: ^[0-9]+$ + LooseboardDemand: + type: object + description: 原单待匹配投影;不含创建幂等键、请求哈希和操作人技术身份。 + properties: + orderNo: { type: string } + customerId: { type: string } + version: { type: integer, format: int64 } + origin: { type: string } + destination: { type: string } + productName: { type: string, nullable: true } + estimatedPalletCount: { type: integer, nullable: true } + packageCount: { type: integer, nullable: true } + totalWeightKg: { type: number, nullable: true } + totalVolumeCbm: { type: number, nullable: true } + currency: { type: string } + originContactName: { type: string, nullable: true } + originContactPhone: { type: string, nullable: true } + remark: { type: string, nullable: true } InquiryWrite: type: object required: @@ -1321,6 +1369,18 @@ components: - cargo - idempotencyKey properties: + sourceOrderNo: + type: string + maxLength: 64 + nullable: true + description: 从待匹配散板选择原商业单;仅LOCAL_LOOSEBOARD支持。确认承运复用原单,不另开商业单。 + sourceOrderVersion: + type: integer + format: int64 + minimum: 0 + nullable: true + description: 创建匹配时与原单号一起提交;已匹配登记不可替换来源。 + tripRecordVersion: oneOf: - type: integer diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index add73ae54..61a4e77c3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -442,6 +442,23 @@ public class DispatchAggregatePersistenceTest { assertThat(f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).counts().packageCount()).isEqualTo(10); } + /** 到仓取货、本土卡车不制造货物明细,但仍须按原客户出现在现有整车排车列表。 */ + @Test void customerFilterFindsPickupAndLocalTruckByTheirRealSourceLinks() throws Exception { + var f=new Fixture(); + var pickup=f.tx.execute(s->f.service.create(f.actor,f.create("transport-pickup-list",List.of()))); + var truck=f.tx.execute(s->f.service.create(f.actor,f.create("transport-truck-list",List.of()))); + String pickupTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,pickup.dispatchNo()); + String truckTrip=f.db.queryForObject("SELECT trip_no FROM ll_fleet_trip WHERE id=(SELECT trip_id FROM ll_dispatch_order WHERE dispatch_no=?)",String.class,truck.dispatchNo()); + f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",pickup.dispatchNo()); + f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK' WHERE dispatch_no=?",truck.dispatchNo()); + f.db.update("INSERT INTO ll_fleet_pickup_task(customer_id,trip_no) VALUES('PICKUP-CUSTOMER',?)",pickupTrip); + f.db.update("INSERT INTO ll_dispatch_truck_task(dispatch_no,customer_id) VALUES(?,'TRUCK-CUSTOMER')",truck.dispatchNo()); + var pickupPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"PICKUP-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null))); + var truckPage=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,null,null,null,"TRUCK-CUSTOMER",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,null,null))); + assertThat(pickupPage.total()).isOne();assertThat(pickupPage.records().get(0).dispatchNo()).isEqualTo(pickup.dispatchNo());assertThat(pickupPage.records().get(0).taskType()).isEqualTo("PICKUP"); + assertThat(truckPage.total()).isOne();assertThat(truckPage.records().get(0).dispatchNo()).isEqualTo(truck.dispatchNo());assertThat(truckPage.records().get(0).taskType()).isEqualTo("LOCAL_TRUCK"); + } + /** 第N项已被另一车占用时,早先本车/来源/资源/账本全部回滚。 */ @Test void laterCargoConflictRollsBackTruckResourcesEarlierLocksAndItems() throws Exception { var f=new Fixture(); var a=f.source(1,"101",501,"ONT8",3); var b=f.source(2,"202",502,"LAX9",5); @@ -492,6 +509,8 @@ public class DispatchAggregatePersistenceTest { db.execute("ALTER TABLE ll_fleet_trip_dispatch ADD UNIQUE(trip_id)"); db.execute("ALTER TABLE ll_dispatch_physical_pallet_lock ADD UNIQUE(active_warehouse_pallet_id)"); db.execute("ALTER TABLE ll_dispatch_pallet_lock ADD UNIQUE(customer_id,active_key)"); + db.execute("CREATE TABLE ll_dispatch_truck_task(dispatch_no VARCHAR(64),customer_id VARCHAR(128))"); + db.execute("CREATE TABLE ll_fleet_pickup_task(customer_id VARCHAR(128),trip_no VARCHAR(64))"); db.execute("CREATE TABLE ll_dispatch_command(id BIGINT PRIMARY KEY,dispatch_id BIGINT,dispatch_no VARCHAR(64),action VARCHAR(40),operator_id VARCHAR(128),idempotency_key VARCHAR(255) UNIQUE,record_version BIGINT,command_hash CHAR(64),result_json CLOB,created_at TIMESTAMP(3))"); db.execute("CREATE TABLE ll_dispatch_history(id BIGINT PRIMARY KEY,dispatch_id BIGINT,record_version BIGINT,action VARCHAR(40),operator_id VARCHAR(128),reason VARCHAR(1000),snapshot_json CLOB,occurred_at TIMESTAMP(3))"); db.execute("CREATE TABLE ll_dispatch_pda_pick(id BIGINT,customer_id VARCHAR(64),dispatch_item_id BIGINT,inventory_position_id BIGINT,packages BIGINT,product_quantity BIGINT)"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java new file mode 100644 index 000000000..0465b9a48 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java @@ -0,0 +1,31 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.dispatch.service.DispatchNumberGenerator; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.baiyue.lastleg.fleet.trip.TransportTripChanged; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import java.time.LocalDateTime; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; +import static org.assertj.core.api.Assertions.*; + +class TransportTaskProjectionTest { + /** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */ + @Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){ + var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class); + var projection=new TransportTaskProjection(roots,links,trucks,mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules()); + var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setRecordVersion(3L);root.setTaskType("LOCAL_TRUCK"); + when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,3L)).thenReturn(1); + when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1")); + var order=new UsTruckOrder();order.setId(9L);order.setStatus("DISPATCHED");order.setVersion(5L);when(trucks.lockNo("C1","U1")).thenReturn(order); + var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("CANCELLED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now()); + projection.changed(new TransportTripChanged(trip,"CANCEL","取消运输","OP")); + assertThat(root.getStatus()).isEqualTo("CANCELLED");assertThat(root.getRecordVersion()).isEqualTo(4L);assertThat(order.getStatus()).isEqualTo("PENDING_DISPATCH");assertThat(order.getVersion()).isEqualTo(6L); + verify(links).cancel("PC1");verify(trucks).insertHistory(argThat(row->"DISPATCHED".equals(row.getFromStatus())&&"PENDING_DISPATCH".equals(row.getToStatus())&&"C1".equals(row.getCustomerId()))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java new file mode 100644 index 000000000..8279ac045 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java @@ -0,0 +1,63 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.baiyue.lastleg.fleet.pickup.*; +import com.baiyue.lastleg.fleet.trip.*; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.reporting.TripOperationsEvidenceGateway; +import com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries; +import com.baiyue.lastleg.trucking.entity.UsTruckOrder; +import com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 验证原单承运执行权、凭证隔离和成功请求重放,不能仅靠页面按钮门禁。 */ +class TransportTaskServiceTest { + DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips; + UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order; + final Map commands=new HashMap<>(); + @BeforeEach void setup(){ + roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class); + trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class); + service=new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips,mock(FleetPickupService.class),mock(FleetPickupRepository.class),trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class),mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules()); + var root=new DispatchAggregate();root.setDispatchNo("PC1");root.setTaskType("LOCAL_TRUCK");root.setTripId(1L); + when(roots.find("PC1")).thenReturn(root);when(links.byTrip(1L)).thenReturn(root);when(links.tripNo(1L)).thenReturn("TR1"); + when(links.truck("PC1")).thenReturn(new TransportTaskModels.TruckLink("PC1","TR1","C1","U1"));when(links.evidence("PC1")).thenReturn(List.of()); + trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("LOCAL_TRUCK");trip.setStatus("IN_TRANSIT");trip.setRecordVersion(3L); + when(tripRepo.lockTrip("TR1")).thenReturn(trip);when(tripRepo.findTrip("TR1")).thenReturn(trip); + order=new UsTruckOrder();order.setStatus("IN_TRANSIT");when(trucks.lockNo("C1","U1")).thenReturn(order); + when(links.commandHash(eq("PC1"),anyString())).thenAnswer(call->commands.get(call.getArgument(1))); + doAnswer(call->{commands.put(call.getArgument(1),call.getArgument(2));return null;}).when(links).command(eq("PC1"),anyString(),anyString(),anyString()); + doAnswer(call->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); + } + /** 无凭证或跨任务文件均不能结束运输,也不能写成功账本。 */ + @Test void completionRequiresOwnedEvidence(){ + assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-missing",List.of()))).hasMessageContaining("凭证"); + when(files.metadata(1L,99L)).thenThrow(new IllegalArgumentException("跨任务凭证")); + assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("key-foreign",List.of(99L)))).hasMessageContaining("跨任务"); + verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString());verify(links,never()).command(anyString(),anyString(),anyString(),anyString()); + } + /** 同请求在版本变化后仍恢复原成功结果,不重复推进原单。 */ + @Test void replayUsesOriginalSuccessfulCommandBeforeCurrentVersion(){ + var body=command("complete-key",List.of(77L));service.truckCommand("PC1","OP","complete",body);service.truckCommand("PC1","OP","complete",body); + assertThat(trip.getStatus()).isEqualTo("COMPLETED");verify(trips,times(1)).recordExecution(any(),eq(3L),eq("LOCAL_TRUCK_COMPLETE"),anyString(),eq("OP")); + assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("complete-key",List.of(88L)))).hasMessageContaining("不同内容"); + } + /** 旧版本和未发车原单均不能利用完成接口跳过真实执行。 */ + @Test void staleVersionAndWrongOriginalStatusCannotComplete(){ + assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",new TransportTaskModels.Command(2L,"stale-key","结束运输",List.of(77L)))).hasMessageContaining("刷新"); + order.setStatus("DISPATCHED");assertThatThrownBy(()->service.truckCommand("PC1","OP","complete",command("wrong-state",List.of(77L)))).hasMessageContaining("先确认发车");verifyNoInteractions(files); + } + /** 下载只允许已完成命令持久化的文件引用,不能凭共享文件 ID 访问。 */ + @Test void evidenceDownloadRequiresPersistedConfirmation(){ + assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files); + } + /** 构造固定版本的测试执行命令。 */ + private TransportTaskModels.Command command(String key,List files){return new TransportTaskModels.Command(3L,key,"结束运输",files);} +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandPersistenceTest.java new file mode 100644 index 000000000..79eec9804 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandPersistenceTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +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.List; +import java.util.UUID; +import static org.assertj.core.api.Assertions.assertThat; + +class FleetLooseboardDemandPersistenceTest { + /** 真实 SQL 验证同客户关联、唯一来源、取消恢复候选和跨客户隔离。 */ + @Test void originalDemandLeavesPoolUntilDraftIsCancelled() 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_local_looseboard_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),order_no VARCHAR(64),status VARCHAR(32),lastleg_order_id BIGINT,origin VARCHAR(64),destination VARCHAR(64),created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,customer_id VARCHAR(128),source_looseboard_order_id BIGINT,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_fleet_pool_order_link(customer_id VARCHAR(128),business_type VARCHAR(32),business_order_id BIGINT)"); + jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(1,'C-1','LB-1','PENDING_ACCEPTANCE',NULL,'LA','Ontario',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO ll_local_looseboard_order VALUES(2,'C-1','OLD','PENDING_ACCEPTANCE',99,'LA','Ontario',CURRENT_TIMESTAMP)"); + jdbc.update("INSERT INTO ll_fleet_external_booking VALUES(10,'C-1',NULL,'DRAFT'),(11,'C-2',NULL,'DRAFT')"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true); + config.addMapper(FleetLooseboardDemandMapper.class);config.addMapper(FleetPoolOrderMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var session=new SqlSessionTemplate(factory.getObject()); + var mapper=session.getMapper(FleetLooseboardDemandMapper.class); + var links=session.getMapper(FleetPoolOrderMapper.class); + assertThat(mapper.pending(Page.of(1,20),"Ontario").getRecords()).extracting("orderNo").containsExactly("LB-1"); + assertThat(mapper.attach(10,"C-2",1)).isZero(); + assertThat(mapper.attach(10,"C-1",1)).isEqualTo(1); + assertThat(mapper.attach(10,"C-1",2)).isZero(); + assertThat(mapper.active("C-1",1)).isEqualTo(1); + assertThat(mapper.active("C-2",1)).isNull(); + assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isEqualTo(1); + assertThat(mapper.source(10,"C-2")).isNull(); + assertThat(mapper.source(10,"C-1").getOrderNo()).isEqualTo("LB-1"); + assertThat(mapper.pending(Page.of(1,20),null).getRecords()).isEmpty(); + jdbc.update("UPDATE ll_fleet_external_booking SET status='CANCELLED' WHERE id=10"); + assertThat(mapper.active("C-1",1)).isNull(); + assertThat(links.linked("C-1","LOCAL_LOOSEBOARD",1)).isZero(); + assertThat(mapper.pending(Page.of(1,20),null).getRecords()).extracting("orderNo").containsExactly("LB-1"); + assertThat(mapper.sourceNumbers(List.of(10L,11L))).extracting(FleetPoolOrderLink::getBusinessOrderNo).containsExactly("LB-1"); + jdbc.update("UPDATE ll_fleet_external_booking SET source_looseboard_order_id=1 WHERE id=11"); + assertThat(mapper.source(11,"C-2")).isNull(); + assertThat(mapper.sourceNumbers(List.of(11L))).isEmpty(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandServiceTest.java new file mode 100644 index 000000000..501c3a182 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetLooseboardDemandServiceTest.java @@ -0,0 +1,39 @@ +package com.baiyue.lastleg.fleet.business; + +import com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder; +import org.junit.jupiter.api.Test; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class FleetLooseboardDemandServiceTest { + final FleetLooseboardDemandRepository repository=mock(FleetLooseboardDemandRepository.class); + final FleetLooseboardDemandService service=new FleetLooseboardDemandService(repository); + /** 校验客户隔离、版本、已有匹配与独立履约都阻止重复接单。 */ + @Test void rejectsMissingStaleMatchedAndPhysicalSources() { + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("不存在"); + var row=source();when(repository.lock("9","LB-1")).thenReturn(row); + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",1L)).hasMessageContaining("变化"); + assertThatThrownBy(()->service.prepare("9","US_TRUCK","LB-1",0L)).hasMessageContaining("散板"); + when(repository.active("9",1)).thenReturn(true); + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运"); + when(repository.active("9",1)).thenReturn(false);row.setLastlegOrderId(7L); + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("独立履约"); + verify(repository,never()).attach(anyLong(),anyString(),anyLong()); + } + /** 取消草稿使生效匹配清空,同张原单可重新选车且没有商业复制。 */ + @Test void allowsRematchAfterDraftCancellationAndKeepsSourceImmutable() { + var row=source();when(repository.lock("9","LB-1")).thenReturn(row); + when(repository.active("9",1)).thenReturn(true,false); + assertThatThrownBy(()->service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).hasMessageContaining("已有承运"); + assertThat(service.prepare("9","LOCAL_LOOSEBOARD","LB-1",0L)).isSameAs(row); + when(repository.attach(7,"9",1)).thenReturn(1);service.attach(7,row); + when(repository.source(7,"9")).thenReturn(row); + service.validateEdit(7,"9","LOCAL_LOOSEBOARD",null); + assertThatThrownBy(()->service.validateEdit(7,"9","US_TRUCK",null)).hasMessageContaining("不能替换"); + assertThatThrownBy(()->service.validateEdit(7,"9","LOCAL_LOOSEBOARD","LB-2")).hasMessageContaining("不能替换"); + } + /** 构造待匹配原单。 */ + private LocalLooseboardOrder source() { + var row=new LocalLooseboardOrder();row.setId(1L);row.setCustomerId("9");row.setOrderNo("LB-1");row.setVersion(0L);row.setStatus("PENDING_ACCEPTANCE");return row; + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index f1cd3e630..7a8594469 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -25,11 +25,13 @@ class FleetPoolOrderBridgeTest { FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class); FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class); com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class); + FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class); FleetPoolOrderBridge service; FleetPoolOrderLink row; LocalLooseboardOrder looseOrder; UsTruckOrder truckOrder; @BeforeEach void setup(){ service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); + org.springframework.test.util.ReflectionTestUtils.setField(service,"demands",demands); when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001"); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); when(links.lockIncoming("9","WT-7")).thenAnswer(a->row); @@ -45,6 +47,23 @@ class FleetPoolOrderBridgeTest { BigDecimal.TEN,BigDecimal.ONE,"USD","联系人","123456","本轮池订单");} void incoming(){service.bindIncoming("FB-7","9","WT-7",77,"92","FBA:ONT8","op");} + /** 原单匹配后全链复用客户商业单,实收到签收均不另建商业单。 */ + @Test void matchedSourceRemainsTheSameCommercialOrderThroughDelivery() { + looseOrder=new LocalLooseboardOrder();looseOrder.setId(31L);looseOrder.setCustomerId("9");looseOrder.setOrderNo("LB-ORIGINAL");looseOrder.setVersion(4L);looseOrder.setStatus("PENDING_ACCEPTANCE"); + looseOrder.setOrigin("客户原始提货地址");looseOrder.setDestinationContactName("原始收货人"); + when(demands.source(7,"9")).thenReturn(looseOrder);when(loose.lockNo("9","LB-ORIGINAL")).thenAnswer(a->looseOrder); + var original=looseOrder; + var confirmed=service.confirm(input("LOCAL_LOOSEBOARD"),"op"); + assertThat(confirmed.businessOrderNo()).isEqualTo("LB-ORIGINAL");assertThat(row.getBusinessOrderId()).isEqualTo(31L); + assertThat(service.confirm(input("LOCAL_LOOSEBOARD"),"op")).isEqualTo(confirmed); + incoming();service.receiving("9","WT-7","pda");service.acceptedReceipt(7,receipt(),3,"op");service.boundDispatch(7,"9","DISPATCH-7","op"); + service.departed("9","DISPATCH-7",Map.of(77L,4),"op",LocalDateTime.now()); + service.signed(delivery(4),Map.of(77L,4L),"op",LocalDateTime.now()); + assertThat(looseOrder).isSameAs(original);assertThat(original.getStatus()).isEqualTo("COMPLETED"); + assertThat(original.getLastlegOrderId()).isEqualTo(77L);assertThat(original.getDestinationContactName()).isEqualTo("原始收货人"); + verify(loose,never()).insert(any(LocalLooseboardOrder.class)); + } + @Test void materializesTheOriginalAppointmentAfterActualIncomingAssociationAndNeverOnReplay() { service.confirm(input("LOCAL_LOOSEBOARD"),"op"); incoming(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java index 61d092e6c..3a3546be2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripApiContractTest.java @@ -47,7 +47,7 @@ class FleetTripApiContractTest { count++; } } - assertThat(count).isEqualTo(46); + assertThat(count).isEqualTo(47); verifyRefs(document, document); verifyRefs(preparation, preparation); verifyRefs(pool, pool); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 910d83b73..eeaa9a59a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -78,6 +78,18 @@ class FleetTripAuthorizationTest { assertThatThrownBy(()->pool.saveAndConfirm("FB-1",null)).isInstanceOf(AccessDeniedException.class); } finally {SecurityContextHolder.clearContext();} } + /** 需求含客户原单资料,仅列表权限不能读取待匹配需求。 */ + @Test void pendingDemandRequiresManageOrListAndCreate() { + try(var context=new AnnotationConfigApplicationContext(Config.class)) { + var controller=context.getBean(PlatformFleetCapacityOfferController.class); + var service=context.getBean(FleetCapacityOfferService.class); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST)))); + assertThatThrownBy(()->controller.pendingDemands(null)).isInstanceOf(AccessDeniedException.class); + verifyNoInteractions(service); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("sales","unused",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST),new SimpleGrantedAuthority("platform:fleet:capacity-offer:inquiry-create")))); + controller.pendingDemands(null);verify(service).pendingDemands(null); + } finally {SecurityContextHolder.clearContext();} + } @Test void externalArrangeRequiresCurrentCreateAndAllIndependentPermissions() { var required=List.of("platform:fleet:capacity-offer:manage","platform:fleet:trip:booking-edit","platform:fleet:trip:edit","platform:overseas-warehouse:dispatch:create"); try(var context=new AnnotationConfigApplicationContext(Config.class)) { @@ -97,6 +109,10 @@ class FleetTripAuthorizationTest { } @Configuration @EnableMethodSecurity static class Config { + /** 原单需求依赖使用替身,权限测试不访问数据库。 */ + @Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); } + /** 桥接器的原单查询使用仓储替身。 */ + @Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); } /** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */ @Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); } @Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index c89e42be6..af2ceb30e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -64,6 +64,9 @@ class LocalLooseboardServiceTest { ReflectionTestUtils.setField(repository, "lastlegHistories", lastlegHistories); ReflectionTestUtils.setField(service, "lastlegOrderNoGenerator", lastlegOrderNoGenerator); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); + // 旧独立履约用例保留真实关联尾程,新增需求不创建该来源。 + var legacyTail = new LastlegOrder(); legacyTail.setId(99L); legacyTail.setCustomerId("100"); + org.mockito.Mockito.lenient().when(lastlegOrders.selectById(99L)).thenReturn(legacyTail); org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong())) .thenReturn("LB-WH-20260901-000001"); org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString())) @@ -94,12 +97,8 @@ class LocalLooseboardServiceTest { assertEquals("家居用品", detail.summary().productName()); assertEquals(3, detail.summary().estimatedPalletCount()); assertEquals(LocalDateTime.of(2030, 1, 2, 9, 30), detail.summary().pickupAt()); - verify(lastlegOrders).insert(org.mockito.ArgumentMatchers.argThat(value -> - "LOCAL_LOOSEBOARD".equals(value.getOrderType()) - && value.getSourceOrderNo().equals("LB-WH-20260901-000001") - && value.getInboundCode() == null - && value.getPalletCount() == 0 - && "LOCAL_LOOSEBOARD:PENDING_ACCEPTANCE".equals(value.getSubOrderStatus()))); + org.junit.jupiter.api.Assertions.assertNull(saved.get().getLastlegOrderId()); + verify(lastlegOrders, org.mockito.Mockito.never()).insert(org.mockito.ArgumentMatchers.any()); verify(histories).insert(org.mockito.ArgumentMatchers.argThat(value -> "CREATE".equals(value.getOperationType()))); } @@ -208,7 +207,7 @@ class LocalLooseboardServiceTest { private LocalLooseboardOrder order(String status, long version) { LocalLooseboardOrder order = new LocalLooseboardOrder(); - order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); + order.setId(1L); order.setCustomerId("100"); order.setOrderNo("LB1"); order.setLastlegOrderId(99L); order.setPalletRequired("YES"); order.setTransportType("WAREHOUSE_TO_WAREHOUSE"); order.setOrigin("Los Angeles"); order.setDestination("Ontario"); order.setPackageCount(12); order.setCurrency("USD"); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 578a127a9..c8337ab79 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -56,6 +56,21 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService", + "target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository", + "reason": "本土卡车接入现有排车的本地事务编排:创建和执行先锁原单,防止普通原单命令并发推进;仅允许此编排器通过Repository访问,不批准Mapper或跨服务SQL。" + }, + { + "source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService", + "target": "com.baiyue.lastleg.trucking.entity.UsTruckOrder", + "reason": "同一锁单事务核对本土卡车真实起终点、实收托数和申报载重容积,不能用浏览器载荷替代原单;不构造库存或向API暴露原单写模型。" + }, + { + "source": "com.baiyue.lastleg.dispatch.transport.TransportTaskProjection", + "target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository", + "reason": "同步运输事件与本土卡车状态必须同事务提交和回滚,旧内部执行入口也要同步原单并追加原单历史;取消释放有效原单关联,不合并客户和仓库归属。" + }, { "source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels", "target": "com.baiyue.lastleg.dispatch.port.DispatchViews", @@ -355,6 +370,26 @@ "source": "com.baiyue.lastleg.inboundplan.work.FbaAppointmentTransportAdapter", "target": "com.baiyue.lastleg.dispatch.port.DispatchTypes", "reason": "既有预约整车接线按已确认的排车统一作业契约调用JDK内部变更端口及公共命令/事实/枚举;仅批准此Adapter到此公共类型,不开放主流程实现、持久化或新增预约权威。" + }, + { + "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandMapper", + "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", + "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" + }, + { + "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository", + "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", + "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" + }, + { + "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository", + "target": "com.baiyue.lastleg.looseboard.mapper.LocalLooseboardOrderMapper", + "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" + }, + { + "source": "com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService", + "target": "com.baiyue.lastleg.looseboard.entity.LocalLooseboardOrder", + "reason": "客户原始散板需求匹配已有车次,确认承运复用原单;不跨服务读库、不创建第二商业单" } ], "applicationDependencyRule": { diff --git a/order/src/main/resources/openapi/order-v1.yaml b/order/src/main/resources/openapi/order-v1.yaml index fcaf50e23..ceed15f86 100644 --- a/order/src/main/resources/openapi/order-v1.yaml +++ b/order/src/main/resources/openapi/order-v1.yaml @@ -1743,7 +1743,7 @@ components: name: mainStatus in: query required: false - description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 为实际开船后待上传清关资料;清关资料上传后进入 WAIT_UNLOAD,持续到实际卸柜完成。实际开船统一读取手动或拉取的实际离港节点。 + description: 统一订单主状态及业务页签筛选;WAIT_PALLETIZE、WAIT_SPLIT 与存量兼容状态 INBOUNDED 独立查询。WAIT_CUSTOMS_DOCS 为实际装柜后待上传客户报关资料;EXPORT_CUSTOMS 为本票报关资料已上传且实际装柜完成、尚未报关完成或实际开船的汇总,买单票无需客户退税资料,不以点击开始报关为前提;SAILING_CLEARANCE 在整柜报关完成后进入,也接受实际离港事实;清关资料保存成功后才进入 WAIT_UNLOAD/WAIT_DISPATCH。WAIT_UNLOAD 持续到实际卸柜或生成有效出库单;生成出库单后进入 OUTBOUND_DOCUMENT,实际出库后进入 DELIVERING,派送完成进入 SIGNED。 schema: type: string enum: [PENDING_REVIEW, WAIT_MEASURE, WAIT_PALLETIZE, INBOUNDED, WAIT_SPLIT, LOADING, LOADED, DELIVERED, COMPLETED, WAIT_WEIGHT_CONFIRM, WEIGHT_CONFIRMED, WAIT_CUSTOMS_DOCS, EXPORT_CUSTOMS, SAILING_CLEARANCE, WAIT_UNLOAD, WAIT_DISPATCH, OUTBOUND_DOCUMENT, DELIVERING, SIGNED, MEASUREMENT_REVIEW] diff --git a/scripts/README.md b/scripts/README.md index 1cc09c76b..d2307f60d 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`local-dispatch-transport-acceptance.mjs`:仅本机 LOCAL 的现有排车运输任务 HTTP 验收。显式 `node --env-file=<本机凭据文件>` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME/PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`;拒绝远端网关。创建3张 TEST 仓储转运单、1张本土卡车原单、5张排车(完成/取消/重新安排),以及本轮专用调度/PDA账号、角色、车辆司机;缺失的正式排车权限仅补资源并授予专用角色,不修改既有角色。经正式网关验证原单审核、跨客户取货、资源冲突、凭证隔离、取消重排、发车完成回写、独立仓库清点和车辆司机释放。合成 PNG 只验文件链路,不代表实际运输或有效证照。默认报告 `reports/local-dispatch-transport.json`,可用 `TRANSPORT_ACCEPTANCE_OUTPUT` 指定;每步保留请求键、脱敏意图、错误码和关联标识,失败停止且保留数据。确认已知失败结果后显式 `TRANSPORT_ACCEPTANCE_RESUME=true` 复用同一报告继续;网络结果未知必须先核对,不自动清理或删除,不使用业务 SQL。已成功批次禁止重复运行;不能替代远端部署或人工截图验收。 + `local-check.ps1`:Windows / PowerShell 7 本机重型检查统一入口,适用于前后端测试、类型检查、构建、安装依赖及 Maven。示例:`& ./scripts/local-check.ps1 -WorkingDirectory ../apps/admin-web -Label 'Admin typecheck' -Command @('pnpm', 'typecheck')`;`-Status` 查看占用。当前用户所有工作目录共用一个文件句柄锁,默认排队900秒、执行1200秒;超时分别退出75/124,其余透传命令退出码。Vitest workers=2,Node堆2048MB,Java堆1536MB/处理器2;直接 Maven 自动单线程/单fork,直接 Node 内置测试自动并发2。Windows Job Object 仅回收本次后代,不接管旧进程;嵌套调用拒绝,批次内部执行原始命令。无凭据、不主动联网或写业务数据;被包装命令的环境授权不变。状态和最近结果仅写 `%LOCALAPPDATA%/Chongchong/LocalChecks/`,不存命令参数或输出;不自动缓存成功结果。失败保留最近摘要,确认输入后重新排队;锁文件无需手工删除,状态不是验收报告。完整规则见 `../../docs/AI工程执行与协作约束.md` 的“本机重型检查与多对话限流”;离线行为测试为 `local-check.test.mjs`。 `local-transfer-pickup-acceptance.mjs`:仅本机 LOCAL 仓储转运取货造数与 HTTP 验收,拒绝非 localhost/127.0.0.1 Gateway。通过 `node --env-file=<本轮明确的本地配置> scripts/local-transfer-pickup-acceptance.mjs` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME`、`ADMIN_TEST_PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`,不猜测或读取其他环境文件。创建16张明确 TEST 标记的业务单、专用PDA账号、司机车辆和多状态取货车次,含客户下单、跨客户共车、取消改派、资源占用、凭证隔离、送仓门禁与完整PDA测量。所有业务均走正式 Gateway HTTP,不用业务 SQL;文件是合成测试 PNG,不代表实际拍照或有效证照。报告默认 `reports/PICKUP-<新批次>.json`,可用 `PICKUP_ACCEPTANCE_OUTPUT` 指定;写入前保存脱敏意图与幂等键,逐阶段落盘。失败立即停止并保留已成功数据及报告,未知结果先核对原请求,不自动重发;重新执行会创建新批次,不自动续跑或清理旧记录。人工核实原请求结果后,可显式用 `PICKUP_ACCEPTANCE_RESUME=<本轮报告>` 沿已知单号继续验收;已成功批次禁止重复执行。脚本记录每次执行范围与结果,旧失败不删除;本机缺失的正式车次取消/结案/释放权限仅补权限资源并授权新建专用验收角色,不修改现有角色授权。账号和单据保留供本地页面验收,不能作为远端 TEST/生产或视觉验收结论。 diff --git a/scripts/local-dispatch-transport-acceptance.mjs b/scripts/local-dispatch-transport-acceptance.mjs new file mode 100644 index 000000000..6b3f7d63c --- /dev/null +++ b/scripts/local-dispatch-transport-acceptance.mjs @@ -0,0 +1,186 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { resolve, dirname } from 'node:path' +import { randomUUID, createHash } from 'node:crypto' + +// 仅针对显式指定的本机统一网关;业务事实全部使用正式 HTTP 创建。 +const gateway = String(process.env.ADMIN_TEST_GATEWAY || '').replace(/\/$/, '') +if (!gateway || !['localhost', '127.0.0.1'].includes(new URL(gateway).hostname)) throw new Error('必须显式指定本机 Gateway') +if (!process.env.ADMIN_TEST_USERNAME || !process.env.ADMIN_TEST_PASSWORD || !process.env.LOCAL_TRANSFER_PDA_PASSWORD) throw new Error('缺少显式验收凭据') +const output = resolve(process.env.TRANSPORT_ACCEPTANCE_OUTPUT || 'reports/local-dispatch-transport.json') +const report = process.env.TRANSPORT_ACCEPTANCE_RESUME ? JSON.parse(await readFile(output, 'utf8')) : { marker: `DT-${new Date().toISOString().replace(/\D/g, '').slice(0, 14)}`, evidence: 'SYNTHETIC_TEST_PNG_NOT_REAL_PHYSICAL_TRANSPORT', checks: [], documents: {}, requests: {}, intents: [] } +if (report.passed) throw new Error('本批次已通过,请直接查看报告') +const marker = report.marker, base = '/api/platform/v1/last-leg/dispatches/transport-tasks', transfers = '/api/platform/v1/last-leg/warehouse-transfers', trucks = '/api/platform/v1/last-leg/us-truck-orders' +const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') +let admin, pda +async function save() { await mkdir(dirname(output), { recursive: true }); await writeFile(output, JSON.stringify(report, null, 2)) } +function check(value, label) { report.checks.push({ label, passed: Boolean(value) }); if (!value) throw new Error(label) } +function image(name) { const body = new FormData(); body.append('file', new Blob([png], { type: 'image/png' }), `${marker}-${name}-TEST.png`); return body } +async function api(token, method, path, body, expected = '200') { + const form = body instanceof FormData + if (method !== 'GET' && !path.startsWith('/api/auth/')) { report.intents.push({ method, path, idempotencyKey: body?.idempotencyKey, hash: form ? undefined : createHash('sha256').update(JSON.stringify(body ?? null)).digest('hex') }); await save() } + const response = await fetch(`${gateway}${path}`, { method, signal: AbortSignal.timeout(45000), headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(body !== undefined && !form ? { 'Content-Type': 'application/json' } : {}) }, body: body === undefined ? undefined : form ? body : JSON.stringify(body) }) + const value = await response.json().catch(() => null), code = String(value?.code) + const passed = response.status === 200 && code === expected + report.checks.push({ method, path, code, http: response.status, expected, passed, message: code === '200' ? undefined : value?.message, requestId: response.headers.get('x-request-id') || value?.requestId, traceId: response.headers.get('x-trace-id') || value?.traceId }); await save() + if (!passed) throw new Error(`${method} ${path}: ${response.status}/${code} ${value?.message || ''}`) + return expected === '200' ? value.data : value +} +const call = (method, path, body, expected) => api(admin, method, path, body, expected) +const request = (key, body) => report.requests[key] ||= { ...body, idempotencyKey: randomUUID() } +const read = number => call('GET', `${base}/${number}`) +const command = detail => ({ recordVersion: detail.execution.trip.recordVersion, idempotencyKey: randomUUID(), reason: `${marker} 本机运输流程验收` }) +async function act(detail, suffix, extra = {}, expected) { return call('POST', `${base}/${detail.dispatchNo}/${suffix}`, { ...command(detail), ...extra }, expected) } +async function transfer(customer, warehouse, key) { + if (report.documents[key]) return report.documents[key] + const body = request(key, { operatingWarehouseId: warehouse.id, destinationType: 'PRIVATE', privateDestinationAddress: '100 TEST Distribution Road, Ontario, CA', privateDestinationPostalCode: '91761', privateDestinationContactName: '本机验收收货员', privateDestinationContactPhone: '0000000000', deliveryAddress: `1580 TEST Pickup Ave, Ontario, CA (${marker}-${key})`, pickupContactName: '本机验收取货员', pickupContactPhone: '0000000000', packageCount: 3, volumeCbm: 0, deliveryMethod: 'WAREHOUSE_PICKUP', estimatedArrivalAt: new Date(Date.now()+86400000).toISOString().slice(0,19), estimatedShipAt: new Date(Date.now()+172800000).toISOString().slice(0,19), remark: marker, needRelabel: false, items: [{ skuCode: `${marker}-${key}`, productNameZh: '本机验收收纳盒', quantity: 24, packageCount: 3, shippingMark: `${marker}-${key}` }] }) + await save() + let detail = await call('POST', `${transfers}?customerId=${customer.id}`, body) + detail = await call('POST', `${transfers}/${detail.transferNo}/review?customerId=${customer.id}`, { decision: 'APPROVE', remark: marker }) + report.documents[key] = { customerId: customer.id, transferNo: detail.transferNo, packageCount: detail.packageCount, cartonMarks: detail.cartons.map(row=>row.cartonMark) }; await save(); return report.documents[key] +} +async function pickup(sources, key) { + if (report.documents[key]) return read(report.documents[key].dispatchNo) + const body = request(key, { sources: sources.map(({customerId,transferNo})=>({customerId,transferNo})), plannedDepartureAt: new Date(Date.now()-300000).toISOString(), expectedDriverEndAt: new Date(Date.now()+86400000).toISOString(), remark: marker }) + await save(); const detail = await call('POST', `${base}/pickup`, body) + report.documents[key] = { dispatchNo: detail.dispatchNo, sources: body.sources }; await save() + const replay = await call('POST', `${base}/pickup`, body); check(replay.dispatchNo === detail.dispatchNo, '到仓取货重放返回同一排车号'); return detail +} +async function resources(warehouse) { + if (report.resources) return report.resources + const driverBase = '/api/platform/v1/last-leg/fleet/drivers' + let driver = await call('POST', driverBase, { name: `${marker}专用司机`, phone: '0000000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: marker, licenseType: 'Class A', licenseZone: 'UTC', licenseExpiresOn: new Date(Date.now()+365*86400000).toISOString().slice(0,10), remark: `${marker} TEST licence image`, profile: { warehouseId: warehouse.id, vehicleModels: '26ft Box Truck', languagesAndArea: 'Ontario TEST' } }) + driver = await call('POST', `${driverBase}/${driver.id}/license?recordVersion=${driver.recordVersion}`, image('licence')) + driver = await call('POST', `${driverBase}/${driver.id}/review`, { recordVersion: driver.recordVersion, decision: 'APPROVED', reason: '本机模拟资质审核' }) + const vehicle = await call('POST', '/api/platform/v1/last-leg/master-data/vehicles', { plateNumber: `TEST-DT-${marker.slice(-10)}`, model: '26ft Box Truck', fuelConsumptionPer100Km:22, mileageKm:18000, driverOrDispatchCompany:marker, phone:'0000000000', region:'OVERSEAS', ownership:'OWNED', status:'IN_SERVICE', remark:marker, maxPalletSpaces:12,maxLoadKg:8000,maxVolumeCbm:45 }) + report.resources = { vehicleId:vehicle.id,driverId:driver.id }; await save(); return report.resources +} +async function assign(detail, resource) { if(detail.execution.trip.vehicleId) return detail; return call('POST', `${base}/${detail.dispatchNo}/assign`, { recordVersion:detail.execution.trip.recordVersion, ...resource, reason:marker }) } +async function release(detail) { + for(const kind of ['VEHICLE','DRIVER']) { + detail = await read(detail.dispatchNo) + if(detail.execution.trip[kind === 'VEHICLE' ? 'vehicleRelease' : 'driverRelease']) continue + await act(detail,'release',{resourceKind:kind,actualEndAt:new Date().toISOString(),endLocation:'Ontario TEST warehouse',vehicleStatus:kind==='VEHICLE'?'IN_SERVICE':null}) + } + return read(detail.dispatchNo) +} +async function truck(customer,key) { + if(report.documents[key]) return call('GET',`${trucks}/${report.documents[key].orderNo}?customerId=${customer.id}`) + const body=request(key,{vehicleType:'PALLET_15',origin:'TEST取货地',originAddress:'100 TEST Origin Road, Ontario, CA',destination:'TEST送货地',destinationAddress:'200 TEST Destination Road, Ontario, CA',packageCount:4,totalWeightKg:40,totalVolumeCbm:1,budgetAmount:100,currency:'USD',originContactName:'本机验收员',originContactPhone:'0000000000',destinationContactName:'本机验收员',destinationContactPhone:'0000000000',pickupAt:new Date().toISOString().slice(0,19),deliveryAt:new Date(Date.now()+86400000).toISOString().slice(0,19),remark:marker}) + await save();let detail=await call('POST',`${trucks}?customerId=${customer.id}`,body) + report.documents[key]={customerId:customer.id,orderNo:detail.summary.orderNo};await save();return detail +} +async function truckReady(detail) { + const path=`${trucks}/${detail.summary.orderNo}`, query=`?customerId=${detail.summary.customerId}` + const step=async(suffix,body={})=>call('POST',`${path}/${suffix}${query}`,{version:detail.summary.version,idempotencyKey:randomUUID(),remark:marker,...body}) + if(detail.summary.status==='PENDING_REVIEW')detail=await step('review',{decision:'APPROVE'}) + if(detail.summary.status==='WAITING_INBOUND')detail=await step('receive-pallets',{actualPalletCount:1,packageCount:4,palletPackageCounts:[4]}) + if(detail.summary.status==='WAITING_CONSOLIDATION')detail=await step('allocate-pallets',{palletIds:detail.pallets.map(row=>row.id)}) + if(detail.summary.status==='ALLOCATED')detail=await step('prepare-dispatch') + check(detail.summary.status==='PENDING_DISPATCH','本土卡车原单审核、实收和分配后进入待派车');return detail +} +async function truckPlan(source,warehouse,key) { + if(report.documents[key])return read(report.documents[key].dispatchNo) + const body=request(key,{customerId:source.summary.customerId,orderNo:source.summary.orderNo,warehouseId:warehouse.id,plannedDepartureAt:new Date(Date.now()-300000).toISOString(),expectedDriverEndAt:new Date(Date.now()+86400000).toISOString()});await save() + const detail=await call('POST',`${base}/truck`,body);report.documents[key]={dispatchNo:detail.dispatchNo,orderNo:body.orderNo};await save() + check((await call('POST',`${base}/truck`,body)).dispatchNo===detail.dispatchNo,'本土卡车安排幂等重放');return detail +} +async function measure(source) { + const path=`/api/pda/v1/last-leg/warehouse-transfers/${source.transferNo}` + let detail=await api(pda,'GET',path) + if(detail.status==='WAIT_CUSTOMER_CONFIRMATION')return + if(!detail.pallets.length)detail=await api(pda,'POST',`${path}/pallets/drafts`,{idempotencyKey:randomUUID()}) + let pallet=detail.pallets[0] + if(pallet.status==='DRAFT')detail=await api(pda,'POST',`${path}/pallets/${pallet.id}/generate`,{idempotencyKey:randomUUID()}) + pallet=detail.pallets.find(row=>row.id===pallet.id) + if(pallet.status!=='BOUND')detail=await api(pda,'POST',`${path}/pallets/${pallet.id}/bind`,{idempotencyKey:randomUUID(),palletCode:pallet.palletNo}) + const boxes=detail.cartons.map(box=>({cartonMark:box.cartonMark,transferPalletId:pallet.id,length:40,width:30,height:25,weight:4,items:[{skuCode:detail.items[0].skuCode,quantity:8}]})) + if(!detail.cartons.some(row=>row.countedAt))detail=await api(pda,'POST',`${path}/cartons/count`,{idempotencyKey:randomUUID(),forecastPackageCount:detail.packageCount,boxes}) + const photo=await api(pda,'POST',`${path}/measurement-photos/files`,image('warehouse-count')) + detail=await api(pda,'POST',`${path}/palletize`,{idempotencyKey:randomUUID(),photoFileIds:[photo.fileId]}) + check(detail.status==='WAIT_CUSTOMER_CONFIRMATION'&&detail.orderStatus==='INBOUND_COMPLETED','取货到仓后仓库独立清点、测量、拍照完成入库');source.palletNo=pallet.palletNo;await save() +} +try { + const login=await api('','POST','/api/auth/login',{loginId:process.env.ADMIN_TEST_USERNAME,password:process.env.ADMIN_TEST_PASSWORD});admin=login.accessToken + // 本机旧权限目录只补正式排车资源,并授权本轮专用角色,不改既有用户或角色。 + const required=['list','create','edit','depart','cancel'].map(key=>`platform:overseas-warehouse:dispatch:${key}`) + if(!report.operatorUsername){ + const permissions=await call('GET','/api/platform/v1/iam/permissions') + const parent=permissions.find(row=>row.permissionCode===required[0]);check(parent,'存在正式排车列表权限') + for(const code of required)if(!permissions.some(row=>row.permissionCode===code))permissions.push(await call('POST','/api/platform/v1/iam/permissions',{type:'BUTTON',code,name:`排车${code.split(':').at(-1)}`,parentId:parent.id,httpMethod:'POST'})) + const role=await call('POST','/api/platform/v1/iam/roles',{roleCode:`${marker}_OPERATOR`,roleName:`${marker}本机排车验收`,description:'仅本轮本机业务验收'}) + const allowed=new Set([...login.authorities,...required]) + await call('PUT','/api/platform/v1/iam/roles/permissions',{roleId:role.id,permissionIds:permissions.filter(row=>allowed.has(row.permissionCode)).map(row=>row.id)}) + const username=`dispatch_dt_${marker.replace(/\D/g,'')}` + await call('POST','/api/platform/v1/iam/users',{username,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,displayName:`${marker}本机调度验收`,phone:`18${String(Date.now()).slice(-9)}`,tenantId:'0',roleIds:[role.id],status:'ACTIVE'}) + report.operatorUsername=username;await save() + } + admin=(await api('','POST','/api/auth/login',{loginId:report.operatorUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken + const customers=(await call('GET','/api/platform/v1/iam/tenants?page=1&size=200')).records.filter(row=>row.status==='ACTIVE') + check(customers.length>=2,'有两个有效客户验证同仓跨客户取货') + const warehouses=(await call('GET','/api/platform/v1/warehouse-admin?page=1&size=100&warehouseType=OVERSEAS&status=ACTIVE')).records + const warehouse=warehouses.find(row=>row.warehouseCode==='US-LAX-01')||warehouses[0];report.warehouseId=warehouse.id + if(!report.pdaUsername){const role=(await call('GET','/api/platform/v1/iam/roles')).find(row=>row.roleCode==='OVERSEAS_WAREHOUSE_PDA_OPERATOR'&&row.status==='ACTIVE');check(role,'存在海外PDA角色');const username=`pda_dt_${marker.replace(/\D/g,'')}`;await call('POST','/api/platform/v1/iam/users',{username,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD,displayName:`${marker}本机排车验收`,phone:`18${String(Date.now()).slice(-9)}`,tenantId:'0',roleIds:[role.id],overseasWarehouseId:warehouse.id,status:'ACTIVE'});report.pdaUsername=username;await save()} + pda=(await api('','POST','/api/auth/login',{loginId:report.pdaUsername,password:process.env.LOCAL_TRANSFER_PDA_PASSWORD})).accessToken + const a=await transfer(customers[0],warehouse,'pickupA'),b=await transfer(customers[1],warehouse,'pickupB'),c=await transfer(customers[0],warehouse,'pickupCancelSource') + let shared=await pickup([a,b],'pickupCompleted'),cancelled=await pickup([c],'pickupCancelled') + const resource=await resources(warehouse);shared=await assign(shared,resource) + if(cancelled.execution.trip.status==='PLANNED'){ + await call('POST',`${base}/${cancelled.dispatchNo}/assign`,{recordVersion:cancelled.execution.trip.recordVersion,...resource,reason:marker},'A0409') + await act(cancelled,'cancel');cancelled=await read(cancelled.dispatchNo) + } + check(cancelled.execution.trip.status==='CANCELLED','取消取货排车生效') + const replanned=await pickup([c],'pickupPendingSample');check(replanned.dispatchNo!==cancelled.dispatchNo,'取货取消后可重新安排新排车,保留待操作样单') + if(shared.execution.trip.status==='PLANNED'){ + await api(pda,'POST',`/api/pda/v1/last-leg/warehouse-transfers/${a.transferNo}/pallets/drafts`,{idempotencyKey:randomUUID()},'A0409') + await call('POST',`${base}/pickup`,{...report.requests.pickupCompleted,idempotencyKey:randomUUID()},'A0409') + await act(shared,'pickup/depart');shared=await read(shared.dispatchNo) + await act(shared,'pickup/complete',{},'A0409') + } + for(const source of [a,b]){ + shared=await read(shared.dispatchNo);let task=shared.pickups.find(row=>row.transferNo===source.transferNo) + const suffix=`pickup/${source.customerId}/${source.transferNo}` + if(task.status==='ASSIGNED'){ + if(source===b&&report.pickupEvidenceA)await act(shared,`${suffix}/picked`,{actualPackages:3,evidenceFileIds:[report.pickupEvidenceA]},'A0400') + const file=await call('POST',`${base}/${shared.dispatchNo}/${suffix}/files`,image(source.transferNo)) + if(source===a){report.pickupEvidenceA=file.fileId;await save()} + const body={...command(shared),actualPackages:3,evidenceFileIds:[file.fileId],remark:marker} + await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,body);await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,body) + await call('POST',`${base}/${shared.dispatchNo}/${suffix}/picked`,{...body,actualPackages:4},'A0409') + const original=await call('GET',`${transfers}/${source.transferNo}?customerId=${source.customerId}`) + check(original.packageCount===3&&original.status==='WAIT_PDA_RECEIVING'&&JSON.stringify(original.cartons.map(row=>row.cartonMark))===JSON.stringify(source.cartonMarks),'取货事实不篡改预报箱数、箱唛及仓库收货状态') + } + shared=await read(shared.dispatchNo);task=shared.pickups.find(row=>row.transferNo===source.transferNo) + if(task.status==='PICKED')await act(shared,`${suffix}/arrive`) + } + shared=await read(shared.dispatchNo);if(shared.execution.trip.status==='IN_TRANSIT')await act(shared,'pickup/complete') + shared=await release(await read(shared.dispatchNo));check(shared.execution.trip.status==='COMPLETED','到仓取货排车完成并分别释放司机车辆') + await measure(a) + let source=await truck(customers[0],'truckOriginal') + if(!report.documents.truckCancelled)source=await truckReady(source) + let native=await truckPlan(source,warehouse,'truckCancelled') + if(native.execution.trip.status==='PLANNED'){await act(native,'cancel');native=await read(native.dispatchNo)} + source=await call('GET',`${trucks}/${source.summary.orderNo}?customerId=${source.summary.customerId}`) + if(!report.documents.truckCompleted)check(source.summary.status==='PENDING_DISPATCH','取消本土卡车排车回退原单待派车') + native=await truckPlan(source,warehouse,'truckCompleted') + await call('POST',`${base}/truck`,{...report.requests.truckCompleted,plannedDepartureAt:new Date().toISOString()},'A0409') + if(native.execution.trip.status==='PLANNED'){ + await call('POST',`${trucks}/${source.summary.orderNo}/start-transit?customerId=${source.summary.customerId}`,{version:source.summary.version,idempotencyKey:randomUUID(),remark:marker},'A0409') + native=await assign(native,resource) + await act(native,'truck/depart',{recordVersion:native.execution.trip.recordVersion-1},'A0409') + await act(native,'truck/depart');native=await read(native.dispatchNo) + } + if(native.execution.trip.status==='IN_TRANSIT'){ + await act(native,'truck/complete',{evidenceFileIds:[]},'A0409') + const file=await call('POST',`${base}/${native.dispatchNo}/truck/files`,image('truck-completion')) + const body={...command(native),evidenceFileIds:[file.fileId]};await call('POST',`${base}/${native.dispatchNo}/truck/complete`,body);await call('POST',`${base}/${native.dispatchNo}/truck/complete`,body) + await call('POST',`${base}/${native.dispatchNo}/truck/complete`,{...body,evidenceFileIds:[]},'A0409') + } + native=await release(await read(native.dispatchNo)) + source=await call('GET',`${trucks}/${source.summary.orderNo}?customerId=${source.summary.customerId}`) + check(native.execution.trip.status==='COMPLETED'&&source.summary.status==='COMPLETED'&&native.completionEvidence.length>0,'本土卡车排车完成、凭证持久化、原单同步完成') + for(const doc of [shared,native,replanned]){ + const root=await call('GET',`/api/platform/v1/last-leg/dispatches/${doc.dispatchNo}`) + check(root.taskType===doc.taskType,'同一个现有排车详情返回正确任务类型') + } + report.final={pickup:shared.dispatchNo,truck:native.dispatchNo,pending:replanned.dispatchNo,truckOriginal:source.summary.orderNo};report.passed=true;report.completedAt=new Date().toISOString();await save();console.log(JSON.stringify({passed:true,output,...report.final})) +} catch(error){report.passed=false;report.lastError=String(error);await save();console.error(String(error));process.exitCode=1} From 868557d79db3207c7ad5471a2422806710f19447 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Tue, 20 Oct 2026 21:48:47 +0800 Subject: [PATCH 043/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sql/lastleg-dispatch-transport-tasks.sql | 108 ++++++++++++++++++ deploy/sql/排车运输接入-数据库变更汇总.md | 21 ++++ .../trip/FleetTransportVehicleGuard.java | 20 ++++ .../fleet/trip/TransportTripChanged.java | 5 + 4 files changed, 154 insertions(+) create mode 100644 deploy/sql/lastleg-dispatch-transport-tasks.sql create mode 100644 deploy/sql/排车运输接入-数据库变更汇总.md create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTransportVehicleGuard.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/TransportTripChanged.java diff --git a/deploy/sql/lastleg-dispatch-transport-tasks.sql b/deploy/sql/lastleg-dispatch-transport-tasks.sql new file mode 100644 index 000000000..a36d454bc --- /dev/null +++ b/deploy/sql/lastleg-dispatch-transport-tasks.sql @@ -0,0 +1,108 @@ +-- MySQL 8 可重复执行增量:仓储转运到仓取货与本土卡车接入现有排车。 +-- 目标数据库固定为 baiyue_lastleg;仅新增运输类型、原单关联及请求账本,不回填或删除业务数据。 +-- 前置:已完成 Lastleg 现有整车排车结构升级;如部署为缺少每日流水表的旧库,也创建正式排车流水表。 +USE baiyue_lastleg; + +SET @dispatch_transport_column_ddl = IF( + EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'task_type' + ), + 'SELECT 1', + 'ALTER TABLE ll_dispatch_order ADD COLUMN task_type VARCHAR(32) NOT NULL DEFAULT ''DELIVERY'' COMMENT ''任务类型:仓库派送、到仓取货、本土卡车'' AFTER dispatch_no' +); +PREPARE dispatch_transport_column_stmt FROM @dispatch_transport_column_ddl; +EXECUTE dispatch_transport_column_stmt; +DEALLOCATE PREPARE dispatch_transport_column_stmt; + +-- 旧库兼容:整车主单跨客户,原来单客户排车头字段不再要求填写。 +-- 如目标库已删除旧字段则跳过;已有客户值保留,不置空、不修改。 +SET @dispatch_transport_legacy_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_dispatch_order' AND column_name = 'customer_id' AND is_nullable = 'NO'), + 'ALTER TABLE ll_dispatch_order MODIFY COLUMN customer_id VARCHAR(128) NULL COMMENT ''旧客户排车归属,整车主单不使用''', + 'SELECT 1' +); +PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl; +EXECUTE dispatch_transport_legacy_stmt; +DEALLOCATE PREPARE dispatch_transport_legacy_stmt; + +-- 旧本土卡车结构兼容:新单沿用已有的起终点联系人及pickup_at/delivery_at字段。 +-- 旧单字段保留原值,仅放宽空值约束,避免新单被已弃用字段阻断。 +SET @dispatch_transport_legacy_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_name' AND is_nullable = 'NO'), + 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_name VARCHAR(128) NULL COMMENT ''旧本土卡车联系人,新单使用起终点联系人''', + 'SELECT 1' +); +PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl; +EXECUTE dispatch_transport_legacy_stmt; +DEALLOCATE PREPARE dispatch_transport_legacy_stmt; + +SET @dispatch_transport_legacy_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'contact_info' AND is_nullable = 'NO'), + 'ALTER TABLE ll_us_truck_order MODIFY COLUMN contact_info VARCHAR(128) NULL COMMENT ''旧本土卡车联系方式,新单使用起终点联系方式''', + 'SELECT 1' +); +PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl; +EXECUTE dispatch_transport_legacy_stmt; +DEALLOCATE PREPARE dispatch_transport_legacy_stmt; + +SET @dispatch_transport_legacy_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_us_truck_order' AND column_name = 'requested_date' AND is_nullable = 'NO'), + 'ALTER TABLE ll_us_truck_order MODIFY COLUMN requested_date DATE NULL COMMENT ''旧本土卡车请求日期,新单使用取货和送货时间''', + 'SELECT 1' +); +PREPARE dispatch_transport_legacy_stmt FROM @dispatch_transport_legacy_ddl; +EXECUTE dispatch_transport_legacy_stmt; +DEALLOCATE PREPARE dispatch_transport_legacy_stmt; + +CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence ( + business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', + last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', + PRIMARY KEY (business_date) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车单号每日独立原子流水'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_truck_task ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + customer_id VARCHAR(128) NOT NULL COMMENT '本土卡车原单客户标识', + order_no VARCHAR(64) NOT NULL COMMENT '本土卡车原单业务号', + active_order VARCHAR(193) NULL COMMENT '有效原单唯一键;取消置空,完成保留', + PRIMARY KEY (dispatch_no), + UNIQUE KEY uk_transport_trip (trip_no), + UNIQUE KEY uk_transport_source (active_order), + KEY idx_transport_order (customer_id, order_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车与本土卡车原单关联'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_transport_create ( + trip_no VARCHAR(64) NOT NULL COMMENT '本次运输资源记录号', + request_hash CHAR(64) NOT NULL COMMENT '首次安排请求摘要', + PRIMARY KEY (trip_no) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='本土卡车排车创建请求账本'; + +CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command ( + dispatch_no VARCHAR(64) NOT NULL COMMENT '排车业务号', + request_key VARCHAR(128) NOT NULL COMMENT '客户端幂等请求键', + request_hash CHAR(64) NOT NULL COMMENT '执行命令请求摘要', + evidence_json JSON NULL COMMENT '已确认运输完成凭证标识列表', + created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', + PRIMARY KEY (dispatch_no, request_key) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; + +-- 当前承运占用查询还依赖散板来源标识(与V33一致);仅补结构,不创建散板业务记录。 +SET @dispatch_transport_pool_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND column_name = 'source_looseboard_order_id'), + 'SELECT 1', + 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源''' +); +PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl; +EXECUTE dispatch_transport_pool_stmt; +DEALLOCATE PREPARE dispatch_transport_pool_stmt; + +SET @dispatch_transport_pool_ddl = IF( + EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema = DATABASE() AND table_name = 'll_fleet_external_booking' AND index_name = 'idx_booking_looseboard_source'), + 'SELECT 1', + 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id, source_looseboard_order_id, status)' +); +PREPARE dispatch_transport_pool_stmt FROM @dispatch_transport_pool_ddl; +EXECUTE dispatch_transport_pool_stmt; +DEALLOCATE PREPARE dispatch_transport_pool_stmt; diff --git a/deploy/sql/排车运输接入-数据库变更汇总.md b/deploy/sql/排车运输接入-数据库变更汇总.md new file mode 100644 index 000000000..c02772b3b --- /dev/null +++ b/deploy/sql/排车运输接入-数据库变更汇总.md @@ -0,0 +1,21 @@ +# 排车运输接入数据库变更汇总 + +执行文件:[lastleg-dispatch-transport-tasks.sql](lastleg-dispatch-transport-tasks.sql)。目标为 **MySQL 8 / baiyue_lastleg**,文件内已有 `USE baiyue_lastleg`。可整份重复执行;按 information_schema 判断字段及索引,按 IF NOT EXISTS 创建表。已在本机既有业务数据环境连续执行两次通过;未在远端环境执行。 + +| 对象 | 新增或调整 | 用途 | +|---|---|---| +| ll_dispatch_order.task_type | 新增 VARCHAR(32) NOT NULL DEFAULT DELIVERY | 区分仓库派送、到仓取货、本土卡车;旧单保留派送类型 | +| ll_dispatch_truck_task | 新表;dispatch_no、trip_no、customer_id、order_no、active_order | 真实排车关联原单;运输记录唯一、有效原单唯一;取消释放有效键,完成保留 | +| ll_dispatch_transport_create | 新表;trip_no、request_hash | 原安排请求摘要,避免重复建单及同键更换原单 | +| ll_dispatch_transport_command | 新表;dispatch_no、request_key、request_hash、evidence_json、created_at | 执行命令幂等、已确认完成凭证持久化 | +| ll_dispatch_daily_sequence | 既有正式日流水表,缺失时补建 | 统一 PC 日期与四位流水;不修改历史单号 | +| ll_dispatch_order.customer_id | 仅旧库存在且为 NOT NULL 时改为允许 NULL | 兼容整车跨客户主单;保留原值,不回填或置空 | +| ll_us_truck_order.contact_name / contact_info / requested_date | 仅旧库仍为 NOT NULL 时改为允许 NULL | 兼容现有原单起终点联系人及取送时间模型,保留历史字段和数据 | +| ll_fleet_external_booking.source_looseboard_order_id | 缺失时补 BIGINT NULL | 当前承运占用查询依赖,等同 V33;不创建散板业务 | +| ll_fleet_external_booking.idx_booking_looseboard_source | 缺失时补客户、原散板ID、状态联合索引 | 原单承运占用查询 | + +所有新增表、字段均有中文注释。SQL不创建账号、不改角色权限、不删除表或记录、不改库存及历史单号,也不回填旧任务。 + +前置:目标库已具备当前整车排车、FleetTrip、仓储转运取货及本土卡车业务结构;本文件是本次运输接入增量,不替代这些模块的完整升级。执行完成后配套发布 Lastleg 和 Admin。迁移系统记录为 V32,承运占用依赖结构为 V33;直接交付执行时使用本份汇总 SQL 即可,不再重复手动执行两个版本文件。 + +本机验收中发现的旧字段约束与缺少日流水表已一并纳入。业务数据由正式 Gateway HTTP 创建,结构验证未写业务SQL。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTransportVehicleGuard.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTransportVehicleGuard.java new file mode 100644 index 000000000..6f707ec9e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTransportVehicleGuard.java @@ -0,0 +1,20 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.stereotype.Service; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; + +/** 运输执行仍由车队应用边界核对车辆主档,不向排车暴露车辆持久化。 */ +@Service +public class FleetTransportVehicleGuard { + private final VehicleRepository vehicles; + /** 复用车辆应用端口及其行锁。 */ + public FleetTransportVehicleGuard(VehicleRepository vehicles){this.vehicles=vehicles;} + /** 发车核对实时车辆资格,并冻结当次有效容量供业务量复核。 */ + public void validate(FleetTripEntity trip){ + var vehicle=vehicles.lockById(trip.getVehicleId()).orElseThrow(()->new ResponseStatusException(HttpStatus.CONFLICT,"车辆不存在")); + if(!"OVERSEAS".equals(vehicle.region()) || !"IN_SERVICE".equals(vehicle.status()))throw new ResponseStatusException(HttpStatus.CONFLICT,"当前车辆不可出车"); + trip.setMaxPalletSpaces(vehicle.maxPalletSpaces());trip.setMaxLoadKg(vehicle.maxLoadKg());trip.setMaxVolumeCbm(vehicle.maxVolumeCbm()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/TransportTripChanged.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/TransportTripChanged.java new file mode 100644 index 000000000..fb395ffac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/TransportTripChanged.java @@ -0,0 +1,5 @@ +package com.baiyue.lastleg.fleet.trip; + + +/** 同步事务内的运输事实;任一投影失败时原执行一起回滚。 */ +public record TransportTripChanged(FleetTripEntity trip,String action,String reason,String operator) { } From 9f6e43ef3f7556eb9a90a715b9279a04df79dbc5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 23:34:22 +0800 Subject: [PATCH 044/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/头程数据库统一增量-MySQL8.sql | 6 + deploy/sql/海外区域统一增量-MySQL8.sql | 2 +- .../main/resources/db/lastleg-permissions.sql | 5 +- ...BusinessPermissionSeedSqlContractTest.java | 45 +++ lastleg-service/AGENTS.md | 2 + lastleg-service/docs/business-index.md | 1 + .../analyze-lastleg-dependencies.test.mjs | 18 +- .../mapper/DispatchCandidateMapper.java | 50 ++- .../mapper/DispatchNumberSequenceMapper.java | 22 ++ .../dispatch/mapper/DispatchSourceMapper.java | 23 ++ .../repository/DispatchNumberSequence.java | 12 +- .../repository/DispatchSourceRepository.java | 18 ++ .../service/DispatchAggregateService.java | 11 + .../dispatch/service/DispatchService.java | 77 ++++- .../OutsourcedBusinessController.java | 80 +++++ .../model/OutsourcedBusinessModels.java | 48 +++ .../service/OutsourcedBusinessService.java | 94 ++++++ .../mapper/OwnedBusinessMapper.java | 12 +- .../mapper/OwnedBusinessQueryMapper.java | 6 +- .../model/OwnedBusinessRows.java | 2 +- .../model/OwnedBusinessScope.java | 26 ++ .../service/OwnedBusinessPhotoService.java | 41 ++- .../service/OwnedBusinessProjection.java | 18 +- .../service/OwnedBusinessQueryService.java | 58 +++- .../service/OwnedBusinessRecordService.java | 30 +- .../service/OwnedBusinessSupport.java | 7 +- .../work/AppointmentWorkDispatchMapper.java | 8 +- .../RelabelDispatchSourceRepository.java | 2 + .../service/RelabelDispatchSourceService.java | 5 +- .../openapi/fleet-outsourced-business-v1.yaml | 304 ++++++++++++++++++ .../openapi/fleet-owned-business-v1.yaml | 48 +-- .../DispatchCandidateMapperContractTest.java | 63 +++- .../DispatchNumberSequenceTest.java | 34 +- .../DispatchMergedRelabelPersistenceTest.java | 29 +- .../DispatchWholeSourcePersistenceTest.java | 73 ++++- .../OutsourcedBusinessContractTest.java | 59 ++++ .../OwnedBusinessScopePersistenceTest.java | 265 +++++++++++++++ .../trip/FleetTripAuthorizationTest.java | 15 +- ...ppointmentWorkDispatchPersistenceTest.java | 21 +- .../lastleg-slice-dependencies.json | 5 + scripts/synchronous-dependency-baseline.mjs | 18 ++ .../synchronous-dependency-contract.test.mjs | 9 +- 42 files changed, 1526 insertions(+), 146 deletions(-) create mode 100644 iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchNumberSequenceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java create mode 100644 lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index db18e9fdd..1ac21825d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -6862,6 +6862,12 @@ INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107391,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107392,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107400,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107401,0,CURRENT_TIMESTAMP(3)); +INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); +INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107402,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5107370,0,CURRENT_TIMESTAMP(3)); INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5107380,0,'PLATFORM','API',5105000,'platform:finance:vehicle-cost:list','查看公司车辆成本','/api/platform/finance/v1/vehicle-costs','GET',1010,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index a82791968..e63767a39 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4968,4 +4968,4 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( CONSTRAINT ck_owned_business_history_version CHECK (record_version>0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营不可变历史'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 228 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 233 AS initialized_tables; diff --git a/iam/src/main/resources/db/lastleg-permissions.sql b/iam/src/main/resources/db/lastleg-permissions.sql index 994f145a4..c65f73eb3 100644 --- a/iam/src/main/resources/db/lastleg-permissions.sql +++ b/iam/src/main/resources/db/lastleg-permissions.sql @@ -101,6 +101,9 @@ VALUES (5107390,0,'PLATFORM','MENU',0,'platform:fleet:owned-business:list','自有业务','/overseas-master-data/owned-business','GET',993,'ACTIVE',1,0), (5107391,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:edit','登记自有业务运营信息',NULL,'PUT',994,'ACTIVE',1,0), (5107392,0,'PLATFORM','BUTTON',5107390,'platform:fleet:owned-business:upload','上传自有业务照片',NULL,'POST',995,'ACTIVE',1,0), + (5107400,0,'PLATFORM','MENU',0,'platform:fleet:outsourced-business:list','外配业务','/overseas-master-data/outsourced-business','GET',996,'ACTIVE',1,0), + (5107401,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:edit','登记外配业务承运信息',NULL,'PUT',997,'ACTIVE',1,0), + (5107402,0,'PLATFORM','BUTTON',5107400,'platform:fleet:outsourced-business:upload','上传外配业务照片',NULL,'POST',998,'ACTIVE',1,0), (5107370,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:handover','在途车辆司机交接',NULL,'POST',1000,'ACTIVE',1,0), (5107385,0,'PLATFORM','BUTTON',5107220,'platform:overseas-master-data:vehicle:record-finance-submit','提交维修救援公司成本',NULL,'POST',1015,'ACTIVE',1,0), (5107340,0,'PLATFORM','BUTTON',5107310,'platform:fleet:trip:cost-create','登记车次直接成本',NULL,'POST',970,'ACTIVE',1,0), @@ -164,7 +167,7 @@ INSERT IGNORE INTO iam_role_permission (tenant_id, role_id, permission_id, creat SELECT 0, role.id, permission.id, NOW(3) FROM iam_role role JOIN iam_permission permission ON permission.tenant_id=0 AND permission.app_code='PLATFORM' - AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit') + AND permission.permission_code IN ('platform:fleet:trip:cost-create','platform:fleet:trip:cost-edit','platform:fleet:quote:list','platform:fleet:quote:create','platform:fleet:quote:edit','platform:fleet:quote:status','platform:fleet:quote:delete','platform:fleet:quote:import','platform:fleet:quote:export','platform:fleet:operations:list','platform:fleet:operations:export','platform:fleet:operations:edit','platform:fleet:owned-business:list','platform:fleet:owned-business:edit','platform:fleet:owned-business:upload','platform:fleet:outsourced-business:list','platform:fleet:outsourced-business:edit','platform:fleet:outsourced-business:upload','platform:fleet:trip:handover','platform:overseas-master-data:vehicle:record-finance-submit') WHERE role.tenant_id=0 AND role.app_code='PLATFORM' AND role.role_code='PLATFORM_ADMIN' AND role.status='ACTIVE' AND role.deleted=0 AND permission.status='ACTIVE' AND permission.deleted=0; diff --git a/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java new file mode 100644 index 000000000..7abc0c123 --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/FleetBusinessPermissionSeedSqlContractTest.java @@ -0,0 +1,45 @@ +package com.baiyue.iam.repository; + +import org.junit.jupiter.api.Test; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.regex.Pattern; +import static org.assertj.core.api.Assertions.assertThat; + +/** 两类装车后运营入口的读写权限独立,初始化不向客户/PDA扩权。 */ +class FleetBusinessPermissionSeedSqlContractTest { + /** 两份权威种子保留页面与两个按钮的固定层级,不能拿写权限代替查看。 */ + @Test void seedsSeparatePlatformReadAndWriteResourcesInBothEntries() throws Exception { + String local=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql")); + String unified=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + for(String sql:List.of(local,unified)) { + for(var scope:List.of("owned-business","outsourced-business")) { + long base=scope.equals("owned-business")?5107390L:5107400L; + assertThat(sql).contains("("+base+",0,'PLATFORM','MENU',0,'platform:fleet:"+scope+":list'"); + assertThat(sql).contains("'/overseas-master-data/"+scope+"','GET'"); + assertThat(sql).contains("("+(base+1)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":edit'"); + assertThat(sql).contains("("+(base+2)+",0,'PLATFORM','BUTTON',"+base+",'platform:fleet:"+scope+":upload'"); + } + } + } + /** 模块授权语句必须同时限制有效平台管理员和有效平台权限。 */ + @Test void moduleGrantsOnlyToActivePlatformAdministrators() throws Exception { + String sql=Files.readString(Path.of("src/main/resources/db/lastleg-permissions.sql")); + var grants=Pattern.compile("INSERT IGNORE INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results() + .map(result->result.group()).filter(statement->statement.contains("platform:fleet:outsourced-business:")).toList(); + assertThat(grants).hasSize(1); + assertThat(grants.get(0)).contains("role.tenant_id=0", "role.app_code='PLATFORM'", "role.role_code='PLATFORM_ADMIN'", + "role.status='ACTIVE'", "role.deleted=0", "permission.status='ACTIVE'", "permission.deleted=0"); + } + /** 空库基线三项显式授权只能落到平台管理员种子,不授予客户模板或PDA。 */ + @Test void unifiedBaselineDoesNotGrantExternalOperationsToOtherRoleSeeds() throws Exception { + String sql=Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); + for(long id:List.of(5107400L,5107401L,5107402L)) { + var grants=Pattern.compile("INSERT INTO iam_role_permission[^;]+;",Pattern.DOTALL).matcher(sql).results() + .map(result->result.group()).filter(statement->statement.contains(","+id+",")).toList(); + assertThat(grants).hasSize(1); + assertThat(grants.get(0)).contains("VALUES (4000001,"+id+",0,CURRENT_TIMESTAMP(3))"); + } + } +} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index b74e1142f..9a045e112 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -17,6 +17,8 @@ 拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。 +原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。 + 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 沿代码追踪;状态、权限、资源或接口边界不明时,定向检索 `docs/business-index.md`;局部任务不扩读无关约束。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index d1c7c2c63..e7a751f4b 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -86,6 +86,7 @@ - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 - 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 - 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。 +- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。 - 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index aa50f7c47..fa1c086b4 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -16,6 +16,7 @@ const ownedBusinessReadEdges = [ ['service.OwnedBusinessQueryService', 'DispatchTypes'], ['service.OwnedBusinessQueryService', 'DispatchDetailReader'], ].map(([source, target]) => `com.baiyue.lastleg.fleet.ownedbusiness.${source}->com.baiyue.lastleg.dispatch.port.${target}`).sort(); +const outsourcedBusinessReadEdges = ['com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels->com.baiyue.lastleg.dispatch.port.DispatchViews']; test('selfoperated analytics is independent of customer outbound and inventory slices', () => { const report = analyze(); @@ -55,7 +56,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen } assert.equal(report.packageSccs.some(component => component.some(name => name.startsWith('com.baiyue.lastleg.fleet.'))), false); assert.deepEqual(report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.fleet.') - && /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), ownedBusinessReadEdges); + && /com\.baiyue\.lastleg\.(dispatch|delivery)\./.test(edge.target)).map(edge => `${edge.source}->${edge.target}`).sort(), [...ownedBusinessReadEdges, ...outsourcedBusinessReadEdges].sort()); const dispatchEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.dispatch.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); assert.ok(dispatchEdges.length > 0); @@ -92,6 +93,21 @@ test('owned business reads only reviewed dispatch ports and cannot inherit persi assert.ok(contractViolations(report, baseline).some(message => message.includes(`${unreviewed.source}->${unreviewed.target}`))); }); +test('outsourced business reads only its reviewed original dispatch view and cannot reach persistence', () => { + const report = analyze(); + const baseline = JSON.parse(readFileSync(contract, 'utf8')); + const prefix = 'com.baiyue.lastleg.fleet.outsourcedbusiness.'; + const reviewed = baseline.reviewedClassDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.dispatch.')); + assert.deepEqual(reviewed.map(edge => `${edge.source}->${edge.target}`).sort(), outsourcedBusinessReadEdges); + assert.equal(baseline.allowedSliceEdges.includes('fleet->dispatch-delivery'), false); + for (const target of ['entity.DispatchAggregate', 'mapper.DispatchAggregateMapper', 'service.DispatchAggregateService']) { + const edge = { source: prefix + 'service.OutsourcedBusinessService', target: 'com.baiyue.lastleg.dispatch.' + target }; + report.classDependencies.push(edge); + assert.ok(contractViolations(report, baseline).some(message => message.includes(`${edge.source}->${edge.target}`))); + } +}); + function analyze(sourceSet = 'main') { return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { cwd: moduleRoot, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 434f24fc9..299ef2d34 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -9,6 +9,21 @@ import java.util.List; @Mapper public interface DispatchCandidateMapper { + /** 自动预留托盘不是实收;尚未开始收货时仍以计划来源预排,不生成库存或实托装载事实。 */ + String PLAN_ONLY = "(p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0))"; + /** 在途计划的全部真实来源必须已离港;人工节点覆盖后不再借用原柜节点。 */ + String DEPARTED_PLAN = + "EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + + "ON dl.id=da.line_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id) AND NOT EXISTS (" + + "SELECT 1 FROM ll_inbound_plan_pallet_allocation da JOIN ll_inbound_plan_line dl " + + "ON dl.id=da.line_id LEFT JOIN ll_order departure_order " + + "ON departure_order.id=dl.order_id AND departure_order.customer_id=dl.customer_id WHERE da.pallet_id=p.id AND dl.customer_id=ip.customer_id AND " + + "(departure_order.id IS NULL OR COALESCE(departure_order.order_type,'')!='FIRST_LEG' OR NOT (" + + "CASE WHEN COALESCE(departure_order.manual_node_override,FALSE)=TRUE THEN EXISTS (" + + "SELECT 1 FROM ll_order_manual_node dn WHERE dn.order_id=departure_order.id AND dn.customer_id=departure_order.customer_id " + + "AND dn.node_code='ACTUAL_DEPARTURE' AND dn.node_at IS NOT NULL) ELSE EXISTS (" + + "SELECT 1 FROM ll_container_shipping_gate dg WHERE dg.container_booking_id=departure_order.source_container_booking_id " + + "AND dg.node_sequence>=40 AND dg.actual_at IS NOT NULL) END)))"; // Only the receiving party's confirmed appointment is a transport reference; it is not a loading plan. String CONFIRMED_APPOINTMENT_AT = "(SELECT CASE WHEN COUNT(DISTINCT fa.id)=1 THEN MAX(fa.appointment_at) END FROM ll_inbound_plan_pallet_allocation pa " + @@ -41,7 +56,7 @@ public interface DispatchCandidateMapper { List findCustomerScopesByPlanPalletIds(@Param("planPalletIds") List planPalletIds); String AVAILABLE_PLAN_BODY = - "SELECT p.plan_id AS planId,p.id AS planPalletId,p.warehouse_pallet_id AS warehousePalletId," + + "SELECT p.plan_id AS planId,p.id AS planPalletId,CASE WHEN " + PLAN_ONLY + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId," + "MIN(l.order_id) AS orderId," + "MIN(ip.customer_id) AS customerId,MIN(o.customer_name) AS customerName," + "MIN(ip.plan_no) AS planNo,MIN(o.order_type) AS orderType,MIN(o.volume_weight_kg) AS volumeWeightKg," + @@ -53,16 +68,16 @@ public interface DispatchCandidateMapper { "MIN(ip.container_no) AS containerNo," + CONFIRMED_APPOINTMENT_AT + " AS appointmentAt,NULL AS expectedLoadingAt," + // 仓ID文本统一保留128字符,避免混合CASE的NONE排序规则及无长度CHAR截断;来源优先级与身份等值不变。 - "CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id) " + + "CAST(CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id " + "AND pos.available_packages > 0) ELSE COALESCE(NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')," + "(SELECT NULLIF(TRIM(source_order.overseas_warehouse_id),'') FROM ll_order source_order " + "WHERE source_order.id=ip.source_order_id AND source_order.customer_id=ip.customer_id LIMIT 1)) END AS CHAR(128)) AS overseasWarehouseId," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_code) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_code) " + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + "ELSE MIN(o.overseas_warehouse_code) END AS overseasWarehouseCode," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(b.overseas_warehouse_name) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN (SELECT MIN(b.overseas_warehouse_name) " + "FROM ll_inventory_position pos JOIN ll_inventory_balance b ON b.id=pos.balance_id AND b.customer_id=pos.customer_id " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0) " + "ELSE MIN(o.overseas_warehouse_name) END AS overseasWarehouseName,MIN(o.order_no) AS orderNo," + @@ -70,11 +85,11 @@ public interface DispatchCandidateMapper { "p.pallet_no AS palletNo,p.destination_warehouse_id AS destinationWarehouseId," + "p.destination_warehouse_code AS destinationWarehouseCode,p.destination_warehouse_name AS destinationWarehouseName," + "COALESCE(NULLIF(MIN(l.fba_warehouse_code),''),NULLIF(p.destination_warehouse_code,'')) AS fbaWarehouseCode," + - "CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN COALESCE((SELECT SUM(pos.available_packages) " + + "CASE WHEN NOT " + PLAN_ONLY + " THEN COALESCE((SELECT SUM(pos.available_packages) " + "FROM ll_inventory_position pos WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id),0) " + "ELSE COALESCE(SUM(CASE WHEN a.picked_package_count > 0 THEN a.picked_package_count " + "ELSE a.planned_package_count END),0) END AS packageCount,p.allocated_volume_cbm AS volumeCbm," + - "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," + + "CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END AS sourceStage," + "(SELECT GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') " + "FROM ll_inbound_plan_carton c WHERE c.plan_pallet_id=p.id AND NOT EXISTS (" + "SELECT 1 FROM ll_relabel_item ri JOIN ll_inventory_position rp ON rp.id=ri.source_inventory_position_id " + @@ -108,12 +123,13 @@ public interface DispatchCandidateMapper { " AND (o.order_no LIKE CONCAT('%',#{orderNo},'%') OR l.inbound_code LIKE CONCAT('%',#{orderNo},'%')) " + " AND l.fba_warehouse_code=#{fbaWarehouseCode} " + " AND " + - "CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + + "CASE WHEN " + PLAN_ONLY + " THEN 'PLAN' ELSE 'RECEIVED' END=#{sourceStage} " + "AND NOT EXISTS (SELECT 1 FROM ll_inbound_plan_pallet_allocation ha " + "JOIN ll_inbound_plan_line hl ON hl.id=ha.line_id " + "JOIN ll_order ho ON ho.id=hl.order_id AND ho.customer_id=ip.customer_id " + "WHERE ha.pallet_id=p.id AND ho.detained=1) " + - "AND (p.warehouse_pallet_id IS NULL OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + + "AND (NOT " + PLAN_ONLY + " OR (" + DEPARTED_PLAN + ")) " + + "AND (" + PLAN_ONLY + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0)) " + "AND NOT EXISTS (SELECT 1 FROM ll_dispatch_pallet_lock d " + "WHERE d.customer_id=ip.customer_id AND d.status='ACTIVE' " + @@ -150,7 +166,7 @@ public interface DispatchCandidateMapper { @Param("fbaWarehouseCode") String fbaWarehouseCode,@Param("sourceStage") String sourceStage,@Param("draftToken") String draftToken); String SELECTED_PLAN_SQL = "") + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List stocks(@Param("customer")String customer,@Param("pallets")List pallets); + + /** 批读保留托盘身份和原实收顺序,拆分后与单托版本依据完全相同。 */ + @Select("") + List palletReceipts(@Param("customer")String customer,@Param("pallets")List pallets); + + /** 退回实收按客户与当前实托位置批读,不混入另一客户的同托记录。 */ + @Select("") + List palletReturnedReceipts(@Param("customer")String customer,@Param("pallets")List pallets); + /** 只使本客户本完整来源键的到期草稿成员失效,物理键由独立物理层管理。 */ @Update("UPDATE ll_dispatch_pallet_lock SET status='EXPIRED',active_key=NULL,updated_at=#{at} " + "WHERE status='ACTIVE' AND expires_at IS NOT NULL AND expires_at<=#{at} AND customer_id=#{customer} AND source_pallet_key=#{key}") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java index b24f3ba13..b6cb699ef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequence.java @@ -2,7 +2,7 @@ package com.baiyue.lastleg.dispatch.repository; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; -import org.springframework.jdbc.core.JdbcTemplate; +import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -11,17 +11,17 @@ import java.time.LocalDate; /** 整车单号独立日流水;独立短事务归还序列锁后才进入车辆、司机及货物锁。 */ @Repository public class DispatchNumberSequence { - private final JdbcTemplate jdbc; + private final DispatchNumberSequenceMapper mapper; /** 流水与排车主单使用相同服务数据库。 */ - public DispatchNumberSequence(JdbcTemplate jdbc) { this.jdbc = jdbc; } + public DispatchNumberSequence(DispatchNumberSequenceMapper mapper) { this.mapper = mapper; } /** 唯一日期行原子递增,连接内取回本次值;失败不复用已分配的业务号。 */ @Transactional(propagation = Propagation.REQUIRES_NEW) public long next(LocalDate date) { - jdbc.update("INSERT INTO ll_dispatch_daily_sequence(business_date,last_sequence) VALUES(?,LAST_INSERT_ID(1)) " - + "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)", date); - Long value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); + // 两个实际 Mapper 操作加入本次 REQUIRES_NEW,连接级结果不跨连接也不读缓存。 + mapper.allocate(date); + Long value = mapper.allocatedValue(); if (value == null || value < 1) throw new IllegalStateException("排车日流水分配失败"); if (value > 9999) throw new BizException(ResultCode.CONFLICT, "当日排车单四位流水号已用满"); return value; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 08d3261f6..0e90dcd0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -75,6 +75,24 @@ public class DispatchSourceRepository { public List receipts(String customer, Long pallet) { return source.receipts(customer,pallet); } /** 同一实托的退回实收不得从来源证明丢失。 */ public List returnedReceipts(String customer,Long pallet){return source.returnedReceipts(customer,pallet);} + /** 一条来源的库存与实收版本依据;无实托的在途计划三项均为空。 */ + public record SourceFacts(List stock,List receipts, + List returnedReceipts) { } + /** 同客户的候选托盘只用三次查询;拆分顺序与原单托查询一致,不跨请求缓存。 */ + public java.util.Map sourceFacts(String customer,List pallets) { + if(pallets.isEmpty())return java.util.Map.of(); + var stocks=source.stocks(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy(DispatchSourceMapper.Stock::palletId)); + var receipts=source.palletReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy( + DispatchSourceMapper.PalletReceipt::palletId,java.util.stream.Collectors.mapping( + row->new DispatchSourceMapper.Receipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages(),row.inboundCode()),java.util.stream.Collectors.toList()))); + var returned=source.palletReturnedReceipts(customer,pallets).stream().collect(java.util.stream.Collectors.groupingBy( + DispatchSourceMapper.PalletReturnedReceipt::palletId,java.util.stream.Collectors.mapping( + row->new DispatchSourceMapper.ReturnedReceipt(row.id(),row.orderId(),row.inventoryPositionId(),row.packages()),java.util.stream.Collectors.toList()))); + var result=new java.util.LinkedHashMap(); + for(var pallet:pallets)result.put(pallet,new SourceFacts(List.copyOf(stocks.getOrDefault(pallet,List.of())), + List.copyOf(receipts.getOrDefault(pallet,List.of())),List.copyOf(returned.getOrDefault(pallet,List.of())))); + return java.util.Collections.unmodifiableMap(result); + } /** 释放已到期草稿键。 */ public int expire(String customer, String key, LocalDateTime at) { return source.expire(customer,key,at); } /** 锁定当前活动资源占用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 7c5c760be..556ef8c14 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -32,6 +32,7 @@ import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; /** 真整车创建、加货与查询;客户只属于明细,不属于整车头。 */ @Service public class DispatchAggregateService implements DispatchExecutionPort,DispatchAppointmentAmendmentPort { + private static final org.slf4j.Logger LOG=org.slf4j.LoggerFactory.getLogger(DispatchAggregateService.class); @jakarta.annotation.Resource private DispatchNumberGenerator numbers; @jakarta.annotation.Resource private DispatchAppointmentAllocations appointments; @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @@ -149,9 +150,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法"); var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken())); + long started=System.nanoTime(); var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized); + long candidatesRead=System.nanoTime(); var groups=sourceRules.selectionGroups(rows); + long groupsRead=System.nanoTime(); var batchFacts=appointments.candidates(rows); + long batchesRead=System.nanoTime(); var result=new ArrayList(); for(var row:rows) { SourceType type=Set.of("RELABEL","RETURN_RELABEL").contains(Objects.toString(row.sourceStage(),""))?SourceType.RELABEL: @@ -181,6 +186,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.overseasWarehouseCode(),row.overseasWarehouseName(),row.orderNo(),row.inboundCode(),row.planNo(),row.palletNo(),row.containerNo(), destination,row.packageCount()==null?0:row.packageCount(),row.grossWeightKg(),row.volumeCbm(),"SOURCE_SUGGESTION",List.copyOf(references),List.copyOf(reasons),group)); } + long finished=System.nanoTime(); + // 慢查询只记录阶段耗时和条数,不记录客户、订单、仓库或草稿凭据。 + if(finished-started>=1_000_000_000L)LOG.warn( + "dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}", + rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L, + (batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L); return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index c2b706f2c..ff2f8b705 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.mapper.DispatchCandidateMapper; import com.baiyue.lastleg.dispatch.mapper.DispatchDraftMapper; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.SourceFacts; import com.baiyue.lastleg.relabel.mapper.RelabelApplicationMapper; import jakarta.annotation.Resource; import org.springframework.http.HttpStatus; @@ -34,16 +35,64 @@ public class DispatchService { /** 同一完整组只解析一次,隐藏的真实成员也随服务器组返回。 */ public Map selectionGroups(List rows) { + var reads=new CandidateReadScope(rows); var result=new LinkedHashMap(); for(var row:rows) { String key=wholeSourceKey(selection(row,null)); if(result.containsKey(key))continue; - var group=sourceGroup(row,false); + var group=sourceGroup(row,false,reads); for(var source:group.view().sources())result.put(source.sourceKey(),group.view()); } return Collections.unmodifiableMap(result); } + /** 仅当前候选事务内批读普通来源;写入和锁内校验继续逐组读取当前事实。 */ + private final class CandidateReadScope { + private final Map> loaded=new HashMap<>(); + private final Map>> available=new HashMap<>(); + private final Map> sourceFacts=new HashMap<>(); + + /** 同页同仓托盘一次读取,避免每一行再次执行完整计划和退回来源投影。 */ + private CandidateReadScope(List rows) { + var pallets=new TreeMap>(); + for(var row:rows)if(row.warehousePalletId()!=null) + pallets.computeIfAbsent(row.overseasWarehouseId(),key->new TreeSet<>()).add(row.warehousePalletId()); + pallets.forEach((warehouse,ids)->read(warehouse,List.copyOf(ids))); + loadFacts(rows); + } + + /** 换标闭包发现页外实托时补读缺失集合,不拿筛选后的页面充当完整组成。 */ + private List read(String warehouse,List pallets) { + var known=loaded.computeIfAbsent(warehouse,key->new HashSet<>()); + var byPallet=available.computeIfAbsent(warehouse,key->new HashMap<>()); + var missing=pallets.stream().filter(id->!known.contains(id)).distinct().sorted().toList(); + if(!missing.isEmpty()) { + var rows=wholeSources.physicalAvailable(warehouse,missing); + for(var row:rows) + byPallet.computeIfAbsent(row.warehousePalletId(),key->new ArrayList<>()).add(row); + loadFacts(rows); + known.addAll(missing); + } + return pallets.stream().flatMap(id->byPallet.getOrDefault(id,List.of()).stream()).toList(); + } + + /** 只补齐本请求尚未读取的客户实托,隐藏组成员也必须有完整版本依据。 */ + private void loadFacts(Collection rows) { + var missing=new TreeMap>(); + for(var row:rows)if(row.warehousePalletId()!=null + && !sourceFacts.getOrDefault(row.customerId(),Map.of()).containsKey(row.warehousePalletId())) + missing.computeIfAbsent(row.customerId(),key->new TreeSet<>()).add(row.warehousePalletId()); + missing.forEach((customer,pallets)->sourceFacts.computeIfAbsent(customer,key->new HashMap<>()) + .putAll(wholeSources.sourceFacts(customer,List.copyOf(pallets)))); + } + + /** 在途计划没有库存身份,不向数据库发出三个必为空的实收查询。 */ + private SourceFacts facts(DispatchPalletCandidate row) { + return row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()): + Objects.requireNonNull(sourceFacts.getOrDefault(row.customerId(),Map.of()).get(row.warehousePalletId()),"实托版本依据缺失"); + } + } + /** 草稿单次操作先从实际入口解析完整组,不让前端循环单来源凑整托。 */ public SelectionGroup selectionGroup(String warehouse,DispatchCommands.CargoSelection requested) { var row=readSelection(warehouse,requested);return sourceGroup(row,false).view(); @@ -78,6 +127,11 @@ public class DispatchService { /** 实托→申请→整申请实托求固定点;未证实来源只阻断,不补造第一客户或伪业务号。 */ private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked) { + return sourceGroup(seed,locked,null); + } + + /** 只读候选复用同事务批读,单组和写入路径不复用候选读取作用域。 */ + private SourceGroup sourceGroup(DispatchPalletCandidate seed,boolean locked,CandidateReadScope reads) { var rows=new TreeMap();rows.put(wholeSourceKey(selection(seed,null)),seed); var pallets=new TreeSet();if(seed.warehousePalletId()!=null)pallets.add(seed.warehousePalletId()); var applications=new TreeMap();var loaded=new HashSet();var reasons=new LinkedHashSet(); @@ -90,7 +144,9 @@ public class DispatchService { if(facts==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"实托完整来源快照缺失"); reasons.addAll(facts.blockReasons()); if(!facts.complete() && facts.blockReasons().isEmpty())reasons.add("实托实际绑定组成未核齐"); - facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId).filter(Objects::nonNull).forEach(pallets::add); + // 缺失的实托身份保留在事实与阻断原因中,不能拿零值扩组或传给严格的实托查询入口。 + facts.positions().stream().map(RelabelDispatchSourcePort.Position::warehousePalletId) + .filter(id->id!=null && id>0).forEach(pallets::add); for(var app:facts.applications())applications.put(app.relabelNo(),app.customerId()); var codes=rows.values().stream().map(DispatchPalletCandidate::palletNo).filter(Objects::nonNull).distinct().toList(); for(var app:wholeSources.nativeApplications(seed.overseasWarehouseId(),codes))applications.put(app.relabelNo(),app.customerId()); @@ -109,7 +165,8 @@ public class DispatchService { // 原生退货的资格由其真实箱生产者负责;普通位置提供者明确提示走原生分支。 reasons.removeIf(reason->nativeNos.stream().anyMatch(no->reason.startsWith(no+":"))); if(!pallets.isEmpty()) { - var available=wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)); + var available=reads==null?wholeSources.physicalAvailable(seed.overseasWarehouseId(),List.copyOf(pallets)): + reads.read(seed.overseasWarehouseId(),List.copyOf(pallets)); if(available.size()>1000)reasons.add("实托可用来源超过完整核验上限"); var buckets=available.stream().filter(r->r.packageCount()!=null && r.packageCount()>0).collect(Collectors.groupingBy(r->r.customerId()+":"+r.warehousePalletId(),TreeMap::new,Collectors.toList())); for(var entry:buckets.entrySet()) { @@ -130,7 +187,8 @@ public class DispatchService { reasons.add("实托还有未完整选择或重复计入的真实可用库存来源"); } } - var bases=new TreeMap();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),false)); + if(reads!=null)reads.loadFacts(rows.values()); + var bases=new TreeMap();for(var entry:rows.entrySet())bases.put(entry.getKey(),baseSourceVersion(entry.getValue(),entry.getKey(),reads)); String version=wholeHashes.source(Arrays.asList(bases,facts==null?null:facts.compositionVersion(),pallets)); var versions=new TreeMap();bases.forEach((key,basis)->versions.put(key,wholeHashes.source(List.of(basis,version)))); var sources=new ArrayList(); @@ -397,7 +455,7 @@ public class DispatchService { } /** 单来源基础摘要不递归包含组版本,组版本再绑定全部成员基础摘要。 */ - private String baseSourceVersion(DispatchPalletCandidate row,String key,boolean lockStock) { + private String baseSourceVersion(DispatchPalletCandidate row,String key,CandidateReadScope reads) { var facts = new java.util.TreeMap(); facts.put("sourceKey",key); facts.put("customerId",row.customerId()); facts.put("warehouseId",row.overseasWarehouseId()); facts.put("planPalletId",row.planPalletId()); facts.put("returnReceiptId",row.returnReceiptId()); @@ -409,12 +467,15 @@ public class DispatchService { facts.put("grossWeightKg",row.grossWeightKg()); facts.put("volumeCbm",row.volumeCbm()); facts.put("cartonMarks",row.relabelCartonMarks()); if(key.startsWith("RELABEL:")) facts.put("relabelBasis",wholeSources.relabelBasis(key.substring(8,key.lastIndexOf(':')))); - var stock=wholeSources.stock(row.customerId(),row.warehousePalletId(),lockStock); + var basis=row.warehousePalletId()==null?new SourceFacts(List.of(),List.of(),List.of()): + reads!=null?reads.facts(row):new SourceFacts(wholeSources.stock(row.customerId(),row.warehousePalletId(),false), + wholeSources.receipts(row.customerId(),row.warehousePalletId()),wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId())); + var stock=basis.stock(); if(stock.stream().anyMatch(position->!Objects.equals(row.overseasWarehouseId(),position.warehouseId()))) throw new ResponseStatusException(HttpStatus.CONFLICT,"实托库存起运仓不一致,请核对来源"); facts.put("stock",stock); - facts.put("receipts",wholeSources.receipts(row.customerId(),row.warehousePalletId())); - facts.put("returnedReceipts",wholeSources.returnedReceipts(row.customerId(),row.warehousePalletId())); + facts.put("receipts",basis.receipts()); + facts.put("returnedReceipts",basis.returnedReceipts()); return wholeHashes.source(facts); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java new file mode 100644 index 000000000..7b89a95e2 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/controller/OutsourcedBusinessController.java @@ -0,0 +1,80 @@ +package com.baiyue.lastleg.fleet.outsourcedbusiness.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*; +import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Query; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Actor; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** 外配八类路径固定独立权限,不接收浏览器业务作用域。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/fleet/outsourced-business") +public class OutsourcedBusinessController { + private final OutsourcedBusinessService service; + private final OwnedBusinessSupport access; + /** 会话只转真实平台操作者及其原权限集合。 */ + public OutsourcedBusinessController(OutsourcedBusinessService service,OwnedBusinessSupport access){this.service=service;this.access=access;} + /** 同筛选分页和完整阶段统计一次返回。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ApiResponse list(@AuthenticationPrincipal AuthenticatedUser user,@Valid Query query){return ApiResponse.success(service.list(actor(user),query));} + /** 列表权限包含详情,不借原排车或自有写权限。 */ + @GetMapping("/{dispatchNo}") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo){return ApiResponse.success(service.detail(actor(user),dispatchNo));} + /** 原主单和本页附加历史保持各自版本字段。 */ + @GetMapping("/{dispatchNo}/history") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ApiResponse history(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @RequestParam(defaultValue="1") @Min(1) Integer page,@RequestParam(defaultValue="20") @Min(1) @Max(200) Integer size) { + return ApiResponse.success(service.history(actor(user),dispatchNo,page,size)); + } + /** 只登记整车金额和逐目的仓到达,不分摊或触发财务。 */ + @PutMapping("/{dispatchNo}/operations") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:edit')") + public ApiResponse save(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@Valid @RequestBody SaveOperations request) { + return ApiResponse.success(service.save(actor(user),dispatchNo,request)); + } + /** 上传仅准备照片文件,幂等键必须在multipart内。 */ + @PostMapping(value="/{dispatchNo}/photos/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(actor(user),dispatchNo,slot,idempotencyKey,file)); + } + /** 绑定使用独立原键和运营附加版本,失败保留fileId。 */ + @PutMapping("/{dispatchNo}/photos/{slot}") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:upload')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + return ApiResponse.success(service.bind(actor(user),dispatchNo,slot,request)); + } + /** 当前或指定历史引用经过外配归属与权限校验后代理读取。 */ + @GetMapping("/{dispatchNo}/photos/{slot}/files/{fileId}/content") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @PathVariable @Positive long fileId,@RequestParam(required=false) @Min(1) Long revisionVersion) { + var result=service.content(actor(user),dispatchNo,slot,fileId,revisionVersion); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(result.photo().contentType())) + .header("X-Content-Type-Options","nosniff").body(result.content()); + } + /** 原键仅返回原操作者的结果,不自动补写或换键重试。 */ + @GetMapping("/{dispatchNo}/commands/{idempotencyKey}") + @PreAuthorize("hasAuthority('platform:fleet:outsourced-business:list')") + public ApiResponse command(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(service.command(actor(user),dispatchNo,idempotencyKey)); + } + /** 固定外配入口保留会话真实权限,不接受任何浏览器作用域。 */ + private Actor actor(AuthenticatedUser user){return access.actor(user,OwnedBusinessScope.OUTSOURCED);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java new file mode 100644 index 000000000..eadd9cb31 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/model/OutsourcedBusinessModels.java @@ -0,0 +1,48 @@ +package com.baiyue.lastleg.fleet.outsourcedbusiness.model; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.lastleg.dispatch.port.DispatchViews; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.Arrival; +import com.fasterxml.jackson.annotation.JsonAnySetter; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import java.util.List; + +/** 外配公开合同单独表达五框、承运金额和到达,不公开里程油耗字段。 */ +public final class OutsourcedBusinessModels { + /** DTO容器不允许实例化。 */ + private OutsourcedBusinessModels() {} + public static final String LIST="platform:fleet:outsourced-business:list"; + public static final String EDIT="platform:fleet:outsourced-business:edit"; + public static final String UPLOAD="platform:fleet:outsourced-business:upload"; + public enum PhotoSlot { LOADING_SCENE,LOADING_LIST,PRE_DEPARTURE_CHECK,DRIVER_LICENSE,RECEIPT } + public record Operations(@Valid OwnedBusinessModels.Money money,@NotNull List<@NotNull @Valid Arrival> arrivals) { + /** 不依赖全局Jackson未知字段开关,明确拒绝油耗、里程及浏览器归属。 */ + @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} + } + public record SaveOperations(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey, + @NotNull @Valid Operations operations,@Size(max=1000) String reason) { + /** 固定外配入口不接收浏览器作用域或其他协议字段。 */ + @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} + } + public record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) { + /** 绑定只接受原版本、原键和File编号,不能提交车辆归属。 */ + @JsonAnySetter public void unsupported(String field,Object value){throw unsupportedField(field);} + } + public record Vehicle(String vehicleId,String plate,String ownership,String carrierName) {} + public record PhotoSummary(int loadingCount) {} + public record Photo(PhotoSlot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {} + public record Row(String dispatchNo,OwnedBusinessModels.Stage stage,String plannedDepartureAt,String loadingCompletedAt,String departedAt,String deliveryCompletedAt, + OwnedBusinessModels.Origin origin,Vehicle vehicle,OwnedBusinessModels.Driver driver,List destinations, + Integer actualPalletCount,Operations operations,String unitPrice,String recordVersion,String dispatchRecordVersion, + PhotoSummary photoSummary,OwnedBusinessModels.Actions actions,List sourceIssues) {} + public record ListResult(List records,int page,int size,int total,OwnedBusinessModels.Summary summary) {} + public record Detail(Row row,DispatchViews.Detail dispatch,List cargo,List photos) {} + public record History(String eventSource,String recordVersion,String dispatchRecordVersion,String action,String operatorName,String occurredAt,String reason,Operations operations,List photos) {} + public record HistoryPage(List records,int page,int size,int total) {} + public record Command(String dispatchNo,String action,String idempotencyKey,String state,String recordVersion,boolean replayed,Object result,String errorCode,boolean retryAllowed) {} + /** 不支持字段作为明确业务输入错误,不能静默隐藏或降级解析。 */ + private static BizException unsupportedField(String field){return new BizException(ResultCode.BAD_REQUEST,"外配业务不支持字段:"+field);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java new file mode 100644 index 000000000..7052b263e --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java @@ -0,0 +1,94 @@ +package com.baiyue.lastleg.fleet.outsourcedbusiness.service; + +import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessQueryService; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessRecordService; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessPhotoService; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessProjection; +import com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import java.util.List; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.bad; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.conflict; + +/** 固定外配入口复用四表及原键机制,不伪造权限、不复制业务写实现。 */ +@Service +public class OutsourcedBusinessService { + private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.OUTSOURCED; + private final OwnedBusinessQueryService queries; + private final OwnedBusinessRecordService records; + private final OwnedBusinessPhotoService photos; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + /** 只通过内部服务复用事务与存储,Controller不接触Repository。 */ + public OutsourcedBusinessService(OwnedBusinessQueryService queries,OwnedBusinessRecordService records,OwnedBusinessPhotoService photos, + OwnedBusinessProjection projection,OwnedBusinessSupport codec){this.queries=queries;this.records=records;this.photos=photos;this.projection=projection;this.codec=codec;} + /** 列表承运商随批量来源读取,四项统计保持同筛选完整集合。 */ + public ListResult list(OwnedBusinessModels.Actor actor,OwnedBusinessModels.Query query) { + var result=queries.listScoped(actor,query,SCOPE);var page=result.result(); + return new ListResult(page.records().stream().map(r->row(r,result.carrierNames().get(r.dispatchNo()))).toList(),page.page(),page.size(),page.total(),page.summary()); + } + /** 三个页签读取同一原排车详情和外配附加记录。 */ + public Detail detail(OwnedBusinessModels.Actor actor,String number){return detail(queries.detail(actor,number,SCOPE));} + /** 原主单与运营历史共用分页,不在外配DTO输出油耗字段。 */ + public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) { + var result=queries.history(actor,number,page,size,SCOPE); + return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(), + h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total()); + } + /** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */ + public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) { + if(request==null || request.operations()==null)throw bad("运营登记内容缺失"); + var ops=request.operations(); + return detail(records.save(actor,number,new OwnedBusinessModels.SaveOperations(request.recordVersion(),request.idempotencyKey(), + new OwnedBusinessModels.Operations(ops.money(),null,null,ops.arrivals()),request.reason()),SCOPE)); + } + /** 五框使用已发布装车File分类,上传与绑定分别保留稳定键。 */ + public Photo upload(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,String key,MultipartFile file) { + return photo(photos.upload(actor,number,slot(slot),key,file,SCOPE)); + } + /** 原File编号由共享绑定事务校验,不创建第二份附件或写原主单。 */ + public Detail bind(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,BindPhoto request) { + if(request==null)throw bad("照片绑定请求缺失"); + return detail(photos.bind(actor,number,slot(slot),new OwnedBusinessModels.BindPhoto(request.recordVersion(),request.idempotencyKey(),request.fileId()),SCOPE)); + } + /** 当前或历史引用读取始终限定原车、照片框和外配权限。 */ + public OwnedBusinessPhotoService.Download content(OwnedBusinessModels.Actor actor,String number,PhotoSlot slot,long fileId,Long revision) { + return photos.content(actor,number,slot(slot),fileId,revision,SCOPE); + } + /** 原键结果转换成外配原始快照,不重算金额或使用现在的车辆档案。 */ + public Command command(OwnedBusinessModels.Actor actor,String number,String key) { + var original=photos.command(actor,number,key,SCOPE);Object result=null; + if(original.result()!=null)result=switch(original.action()) { + case "SAVE_OPERATIONS","BIND_PHOTO"->detail(codec.read(codec.write(original.result()),OwnedBusinessModels.Detail.class)); + case "UPLOAD_PHOTO"->photo(codec.read(codec.write(original.result()),OwnedBusinessModels.Photo.class)); + default->throw conflict("原命令类型不属于外配业务"); + }; + return new Command(original.dispatchNo(),original.action(),original.idempotencyKey(),original.state(),original.recordVersion(),original.replayed(),result,original.errorCode(),original.retryAllowed()); + } + /** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */ + private Detail detail(OwnedBusinessModels.Detail detail) { + return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList()); + } + /** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */ + private Row row(OwnedBusinessModels.Row r,String carrierName) { + if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致"); + return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(), + new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(), + r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues()); + } + /** 拒绝异常持久化油耗,不用DTO隐藏不受支持的数据。 */ + private Operations operations(OwnedBusinessModels.Operations ops) { + if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记"); + return new Operations(ops.money(),ops.arrivals()); + } + /** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */ + private Photo photo(OwnedBusinessModels.Photo p) { + SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt()); + } + /** 显式枚举转换不接受别名、兼容值或浏览器分类。 */ + private OwnedBusinessModels.PhotoSlot slot(PhotoSlot slot){if(slot==null)throw bad("请选择装车照片框");return OwnedBusinessModels.PhotoSlot.valueOf(slot.name());} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java index 4047fc84e..f3831c4a1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessMapper.java @@ -68,14 +68,20 @@ public interface OwnedBusinessMapper { #{reason},#{snapshotJson},#{occurredAt}) """) int insertHistory(History row); - /** 历史按本页版本顺序分页。 */ + /** 两表文本转连接字符规则后合并,长度取原值以保留中文及真实尾空格;不改原历史或JSON。 */ @Select(""" SELECT * FROM ( SELECT id,dispatch_id,record_version,NULL AS dispatch_record_version,'OPERATIONS' AS event_source, - action,operator_id,reason,snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} + RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action, + RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id, + RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason, + snapshot_json,occurred_at FROM ll_fleet_owned_business_history WHERE dispatch_id=#{id} UNION ALL SELECT id,dispatch_id,NULL AS record_version,record_version AS dispatch_record_version,'DISPATCH' AS event_source, - action,operator_id,reason,snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id} + RPAD(CAST(action AS CHAR(40)),CHAR_LENGTH(action),' ') AS action, + RPAD(CAST(operator_id AS CHAR(128)),CHAR_LENGTH(operator_id),' ') AS operator_id, + RPAD(CAST(reason AS CHAR(2000)),CHAR_LENGTH(reason),' ') AS reason, + snapshot_json,occurred_at FROM ll_dispatch_history WHERE dispatch_id=#{id} ) events ORDER BY occurred_at DESC,event_source,id DESC LIMIT #{limit} OFFSET #{offset} """) List history(@Param("id") long id,@Param("offset") int offset,@Param("limit") int limit); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java index 45588ded0..a3582c459 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -16,10 +16,14 @@ public interface OwnedBusinessQueryMapper { o.loading_completion_hash AS owned_loading_completion_hash,o.loading_completed_at AS owned_loading_completed_at FROM ll_dispatch_order d LEFT JOIN ll_fleet_owned_business_ownership o ON o.dispatch_id=d.id """; + /** 摘要按列上限128字节完整比较,不受新旧表默认排序规则影响,不能省略二进制宽度。 */ String FILTER=""" d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED') AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL - AND o.vehicle_ownership='OWNED' AND o.loading_completion_hash=d.loading_completion_hash + AND o.vehicle_ownership=#{q.scope.ownership} + AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128)) + AND o.loading_completed_at=d.loading_completed_at + AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL')) AND d.planned_departure_at >= #{q.from} AND d.planned_departure_at < #{q.to} AND d.stage=#{q.stage} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java index 20be59f32..38e66f20d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java @@ -52,6 +52,6 @@ public final class OwnedBusinessRows { private Long allocatedPackages; } public record Search(int offset,int limit,String keyword,String warehouseId,String stage, - LocalDateTime from,LocalDateTime to) {} + LocalDateTime from,LocalDateTime to,OwnedBusinessScope scope) {} @Data public static class StageCount { private String stage;private Long quantity; } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java new file mode 100644 index 000000000..fcefe58c5 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java @@ -0,0 +1,26 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.model; + +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; + +/** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */ +public enum OwnedBusinessScope { + OWNED("OWNED","platform:fleet:owned-business:"), + OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:"); + private final String ownership,prefix; + /** 车辆归属与入口权限一一固定,不能由请求指定。 */ + OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;} + /** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */ + public String getOwnership(){return ownership;} + /** 查看权限包含本作用域详情、历史和照片读取。 */ + public String listPermission(){return prefix+"list";} + /** 运营写权限独立,外配权限不映射成自有权限。 */ + public String editPermission(){return prefix+"edit";} + /** 照片写权限独立,不扩大到原排车。 */ + public String uploadPermission(){return prefix+"upload";} + /** 外配只支持装车五框,服务调用同样不能绕过。 */ + public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");} + /** 不存在只显示当前业务归属,不泄露内部身份。 */ + public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java index 12cec0302..0a6074143 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessPhotoService.java @@ -3,6 +3,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service; import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; import com.baiyue.lastleg.fleet.ownedbusiness.gateway.HttpOwnedBusinessFileGateway; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.*; @@ -32,7 +33,12 @@ public class OwnedBusinessPhotoService { public record Download(Photo photo,byte[] content) {} /** 写前落意图,绑定失败须保留原fileId,不能再上传。 */ public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) { - codec.require(actor,UPLOAD);codec.key(key);var source=queries.requireOwned(number); + return upload(actor,number,slot,key,multipart,OwnedBusinessScope.OWNED); + } + /** 归属与实际上传权限在写File意图前核验,外配拒绝两个油耗框。 */ + public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart,OwnedBusinessScope scope) { + codec.require(actor,scope.uploadPermission());scope.requireSlot(slot);codec.key(key); + var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope); if(slot==null || multipart==null)throw bad("请选择照片及分类"); if(multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException error){throw bad("无法读取待上传照片");} @@ -49,41 +55,56 @@ public class OwnedBusinessPhotoService { commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result; } catch(RuntimeException failure) { // 连接异常不能证明File没有写入;持久保留未知结果,禁止自动补造。 - try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"OWNED_UPLOAD_UNKNOWN_RESULT");} + try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,scope==OwnedBusinessScope.OWNED?"OWNED_UPLOAD_UNKNOWN_RESULT":"OUTSOURCED_UPLOAD_UNKNOWN_RESULT");} catch(RuntimeException recordingFailure){failure.addSuppressed(recordingFailure);} throw conflict("照片上传结果未知,请保留原请求键查询,勿重新上传"); } } /** 重放先于File复核,保证原成功结果可以精确恢复。 */ public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) { - codec.require(actor,UPLOAD);if(request==null || slot==null)throw bad("照片绑定请求缺失"); + return bind(actor,number,slot,request,OwnedBusinessScope.OWNED); + } + /** 原绑定键恢复仍先检查固定业务域和当前会话,不重新上传。 */ + public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,OwnedBusinessScope scope) { + codec.require(actor,scope.uploadPermission());if(request==null || slot==null)throw bad("照片绑定请求缺失");scope.requireSlot(slot); codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());long fileId=codec.version(request.fileId()); if(fileId<=0)throw bad("文件编号无效"); - var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); if(previous!=null) { commands.same(previous,"BIND_PHOTO",version,new OwnedBusinessRecordService.PhotoBinding(slot,request)); if(!"SUCCEEDED".equals(previous.getStatus()))throw conflict("绑定结果尚未明确,请查询原命令"); return codec.read(previous.getResultJson(),Detail.class); } - return records.bind(actor,number,slot,request,photo(slot,files.metadata(source.getId(),slot,fileId))); + var verified=photo(slot,files.metadata(source.getId(),slot,fileId)); + return scope==OwnedBusinessScope.OWNED?records.bind(actor,number,slot,request,verified):records.bind(actor,number,slot,request,verified,scope); } /** 只有当前或指定真实历史版本中的引用允许代理读取。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision) { - codec.require(actor,LIST);var source=queries.requireOwned(number);List bound; + return content(actor,number,slot,fileId,revision,OwnedBusinessScope.OWNED); + } + /** 五框历史授权仍由同车同版本的真实引用限定。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Download content(Actor actor,String number,PhotoSlot slot,long fileId,Long revision,OwnedBusinessScope scope) { + codec.require(actor,scope.listPermission());scope.requireSlot(slot);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);List bound; if(revision==null)bound=projection.photos(repository.record(source.getId())); else { if(revision<1)throw bad("历史版本无效");var row=repository.revision(source.getId(),revision); - if(row==null)throw missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos(); + if(row==null)throw scope.missing();bound=codec.read(row.getSnapshotJson(),Snapshot.class).photos(); } - var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(OwnedBusinessSupport::missing); + var photo=bound.stream().filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst().orElseThrow(scope::missing); return new Download(photo,files.content(source.getId(),slot,fileId)); } /** 恢复先校验原操作者,不披露他人载荷。 */ @Transactional(readOnly=true) public Command command(Actor actor,String number,String key) { - codec.require(actor,LIST);codec.key(key);var source=queries.requireOwned(number);var row=commands.existing(source.getId(),key,actor); - if(row==null)throw missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class); + return command(actor,number,key,OwnedBusinessScope.OWNED); + } + /** 原键查询不能越过归属隔离,外配权限不授权自有账本。 */ + @Transactional(readOnly=true) + public Command command(Actor actor,String number,String key,OwnedBusinessScope scope) { + codec.require(actor,scope.listPermission());codec.key(key);var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var row=commands.existing(source.getId(),key,actor); + if(row==null)throw scope.missing();Object result=row.getResultJson()==null?null:codec.read(row.getResultJson(),Object.class); String version=row.getExpectedVersion()==null?null:String.valueOf(Math.addExact(row.getExpectedVersion(),1)); return new Command(number,row.getAction(),key,row.getStatus(),version,true,result,row.getErrorCode(),false); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java index 35223717d..c3d96d31a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -4,6 +4,7 @@ import com.baiyue.lastleg.dispatch.port.DispatchContracts.ConfirmedLoad; import com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources; import com.baiyue.lastleg.fleet.trip.FleetDispatchGateway; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.*; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; import org.springframework.stereotype.Component; @@ -45,9 +46,15 @@ public class OwnedBusinessProjection { } /** 一车一行;目的地与预约只行内展开,不重复整车金额或托数。 */ public Row row(Source source,Record record,List cargo,List batches,Actor actor) { + return row(source,record,cargo,batches,actor,OwnedBusinessScope.OWNED); + } + /** 两类固定入口复用原事实装配,公开DTO由各域独立转换。 */ + public Row row(Source source,Record record,List cargo,List batches,Actor actor,OwnedBusinessScope scope) { var resources=codec.read(source.getResourcesJson(),Resources.class); if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失"); var ops=operations(record);var photos=photos(record);int pallets=palletCount(source); + if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null + || photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片"); var arrivals=new HashMap();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt()); var destinations=new LinkedHashMap(); for(var item:cargo) { @@ -61,11 +68,18 @@ public class OwnedBusinessProjection { return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()), codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()), new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()), - new Vehicle(String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),"OWNED"), + new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()), new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price, record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()), new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()), - new Actions(gate(actor,EDIT),gate(actor,UPLOAD)),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of()); + new Actions(gate(actor,scope.editPermission()),gate(actor,scope.uploadPermission())),pallets==0?List.of("实际装车清单无实托,单价未计算"):List.of()); + } + /** 承运商仅取原资源快照,列表不逐车查询当前档案。 */ + public String carrierName(Source source){return codec.read(source.getResourcesJson(),Resources.class).vehicle().carrierName();} + /** 原键回执的承运商沿原详情快照,不重新读取当前资源。 */ + public String carrierName(Detail detail) { + return detail.dispatch()==null || detail.dispatch().vehicleInfo()==null || detail.dispatch().vehicleInfo().vehicle()==null + ?null:detail.dispatch().vehicleInfo().vehicle().carrierName(); } /** 附加权限独立于原排车写动作。 */ private Gate gate(Actor actor,String permission) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java index 66a102f83..d9fb9d19e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -1,6 +1,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Search; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; @@ -29,12 +30,19 @@ public class OwnedBusinessQueryService { /** 四项统计与分页使用同一过滤和快照,GET不写库。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public ListResult list(Actor actor,Query query) { - codec.require(actor,LIST);if(query==null)throw bad("查询条件缺失"); + return listScoped(actor,query,OwnedBusinessScope.OWNED).result(); + } + /** 内部批量结果额外带原承运商;不扩展自有公开Row,不逐车补查。 */ + public record ScopedListResult(ListResult result,Map carrierNames) {} + /** 可信作用域只由服务调用方指定,权限与SQL同时限定同一域。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public ScopedListResult listScoped(Actor actor,Query query,OwnedBusinessScope scope) { + codec.require(actor,scope.listPermission());if(query==null)throw bad("查询条件缺失"); int page=page(query.page()),size=size(query.size());String keyword=optional(query.keyword(),128),warehouse=optional(query.warehouseId(),64); Instant from=codec.instant(query.plannedDepartureFrom()),to=codec.instant(query.plannedDepartureTo()); if(from!=null && to!=null && !from.isBefore(to))throw bad("计划发车时间范围无效"); int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");} - var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to)); + var search=new Search(offset,size,keyword,warehouse,query.stage()==null?null:query.stage().name(),codec.utc(from),codec.utc(to),scope); var counts=repository.summary(search);int after=0,delivering=0,delivered=0; for(var c:counts) { int n=Math.toIntExact(c.getQuantity()); @@ -44,18 +52,26 @@ public class OwnedBusinessQueryService { var sources=repository.page(search);var ids=sources.stream().map(Source::getId).toList(); var records=repository.records(ids).stream().collect(Collectors.toMap(Record::getDispatchId,r->r)); var cargo=repository.cargo(ids);var batches=repository.batches(ids); - var rows=sources.stream().map(s->{eligible(s);return projection.row(s,records.get(s.getId()), + var rows=sources.stream().map(s->{eligible(s,scope);return projection.row(s,records.get(s.getId()), cargo.stream().filter(c->Objects.equals(c.getDispatchOrderId(),s.getId())).toList(), - batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor);}).toList(); - return new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)); + batches.stream().filter(b->Objects.equals(b.getDispatchId(),s.getId())).toList(),actor,scope);}).toList(); + var carriers=new LinkedHashMap();for(var source:sources)carriers.put(source.getDispatchNo(),projection.carrierName(source)); + return new ScopedListResult(new ListResult(rows,page,size,total,new Summary(total,after,delivering,delivered)),Collections.unmodifiableMap(carriers)); } /** 对原排车详情做组合,不创建第二份主单或履约状态。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));} + /** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);} /** 写入已经有独立权限;回执仍由相同真实来源组成。 */ public Detail detailForWrite(Actor actor,Source source) { - eligible(source);var ids=List.of(source.getId());var cargo=repository.cargo(ids); - var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor); + return detailForWrite(actor,source,OwnedBusinessScope.OWNED); + } + /** 写回执也执行固定归属校验,调用者已核对本域独立写权限。 */ + public Detail detailForWrite(Actor actor,Source source,OwnedBusinessScope scope) { + eligible(source,scope);var ids=List.of(source.getId());var cargo=repository.cargo(ids); + var row=projection.row(source,repository.record(source.getId()),cargo,repository.batches(ids),actor,scope); var original=dispatch.detail(new DispatchContracts.Actor(actor.operatorId(),ActorMode.PLATFORM,null),source.getDispatchNo()); return new Detail(row,original,cargo.stream().map(c->new Cargo(c.getSourceKey(),c.getCustomerId(),c.getCustomerName(), c.getOrderNo(),c.getInboundCode(),c.getPalletNo(),projection.destinationKey(c),String.valueOf(c.getPackageCount()))).toList(), @@ -64,23 +80,37 @@ public class OwnedBusinessQueryService { /** 缺少历史归属必须明确拒绝,不能按当前车辆主档降级猜测。 */ public Source requireOwned(String number) { if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号"); - var source=repository.source(number);if(source==null)throw missing();eligible(source);return source; + var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source; + } + /** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */ + public Source requireScoped(String number,OwnedBusinessScope scope) { + if(scope==OwnedBusinessScope.OWNED)return requireOwned(number); + if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号"); + var source=repository.source(number);if(source==null)throw scope.missing();eligible(source,scope);return source; } /** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */ - private void eligible(Source source) { - if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入自有业务"); - if(!"OWNED".equals(source.getOwnedVehicleOwnership()))throw missing(); + private void eligible(Source source,OwnedBusinessScope scope) { + if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务"); + if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing(); if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage()) || source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实"); var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle(); - if(resource==null || resource.vehicleId()==null || !Objects.equals(source.getOwnedVehicleId(),resource.vehicleId()) - || !"MASTER".equals(source.getOwnedResourceMode()) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash()) + boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode()) + && resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId()); + boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name()) + && "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null; + if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash()) || !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致"); } /** 操作记录只返回真实附加版本,不制造初始示例事件。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize) { - codec.require(actor,LIST);var source=requireOwned(number);int page=page(requestedPage),size=size(requestedSize); + return history(actor,number,requestedPage,requestedSize,OwnedBusinessScope.OWNED); + } + /** 两类业务复用历史分页,但每次读取先核对权限和冻结归属。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public HistoryPage history(Actor actor,String number,Integer requestedPage,Integer requestedSize,OwnedBusinessScope scope) { + codec.require(actor,scope.listPermission());var source=requireScoped(number,scope);int page=page(requestedPage),size=size(requestedSize); int offset;try{offset=Math.multiplyExact(page-1,size);}catch(ArithmeticException error){throw bad("页码过大");} return new HistoryPage(repository.history(source.getId(),offset,size).stream().map(h->{ var snapshot="OPERATIONS".equals(h.getEventSource())?codec.read(h.getSnapshotJson(),Snapshot.class):null; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java index 581205798..1ec0b5a54 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessRecordService.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.fleet.ownedbusiness.service; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Record; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows.Source; @@ -30,9 +31,16 @@ public class OwnedBusinessRecordService { /** 整车只保留一份可更正/清空的money,实际油耗为手工L/100km。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public Detail save(Actor actor,String number,SaveOperations request) { - codec.require(actor,EDIT);if(request==null || request.operations()==null)throw bad("运营登记内容缺失"); + return save(actor,number,request,OwnedBusinessScope.OWNED); + } + /** 共享CAS与命令账本;固定外配域不允许保存里程或油耗。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail save(Actor actor,String number,SaveOperations request,OwnedBusinessScope scope) { + codec.require(actor,scope.editPermission());if(request==null || request.operations()==null)throw bad("运营登记内容缺失"); + if(scope==OwnedBusinessScope.OUTSOURCED && (request.operations().actualDistanceMiles()!=null || request.operations().fuelConsumptionLitresPer100Km()!=null)) + throw bad("外配业务不支持里程或油耗登记"); codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()); - var source=queries.requireOwned(number);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); + var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope);var previous=commands.existing(source.getId(),request.idempotencyKey(),actor); if(previous!=null){commands.same(previous,"SAVE_OPERATIONS",version,request);return result(previous);} if(request.reason()!=null && request.reason().length()>1000)throw bad("操作说明过长"); var ops=normalize(source,request.operations());var row=lock(source,actor); @@ -42,13 +50,18 @@ public class OwnedBusinessRecordService { row.setCurrency(ops.money()==null?null:ops.money().currency());row.setTotalAmount(ops.money()==null?null:new BigDecimal(ops.money().amount())); row.setActualDistanceMiles(decimal(ops.actualDistanceMiles()));row.setFuelConsumptionLitresPer100Km(decimal(ops.fuelConsumptionLitresPer100Km())); row.setArrivalsJson(codec.write(ops.arrivals())); - return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason()); + return commit(source,row,actor,version,"SAVE_OPERATIONS",request.idempotencyKey(),request,request.reason(),scope); } /** File元数据已经在HTTP外层校验,绑定事务只处理本页行锁。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified) { - codec.require(actor,UPLOAD);if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失"); - codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=queries.requireOwned(number); + return bind(actor,number,slot,request,verified,OwnedBusinessScope.OWNED); + } + /** 外配五框在事务内再次校验,不能靠调用共享写服务绕过。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Detail bind(Actor actor,String number,PhotoSlot slot,BindPhoto request,Photo verified,OwnedBusinessScope scope) { + codec.require(actor,scope.uploadPermission());if(request==null || slot==null || verified==null)throw bad("照片绑定内容缺失");scope.requireSlot(slot); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion());var source=scope==OwnedBusinessScope.OWNED?queries.requireOwned(number):queries.requireScoped(number,scope); Object identity=new PhotoBinding(slot,request); var old=commands.existing(source.getId(),request.idempotencyKey(),actor); if(old!=null){commands.same(old,"BIND_PHOTO",version,identity);return result(old);} @@ -60,7 +73,7 @@ public class OwnedBusinessRecordService { photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(), verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); row.setPhotosJson(codec.write(List.copyOf(photos.values()))); - return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot)); + return commit(source,row,actor,version,"BIND_PHOTO",request.idempotencyKey(),identity,photoReason(slot),scope); } /** 操作记录使用准确中文槽位,不向用户展示内部枚举。 */ private String photoReason(PhotoSlot slot) { @@ -104,13 +117,14 @@ public class OwnedBusinessRecordService { /** 版本变化必须由浏览器刷新,不能覆盖其他操作者的登记。 */ private void requireVersion(Record row,long expected){if(row.getRecordVersion()==null || row.getRecordVersion()!=expected)throw conflict("运营记录版本已变化,请刷新后重试");} /** 只保存附加版本和不可变历史,真实主单读取失败也必须回滚。 */ - private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason) { + private Detail commit(Source source,Record row,Actor actor,long expected,String action,String key,Object request,String reason,OwnedBusinessScope scope) { row.setRecordVersion(Math.addExact(expected,1));row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now())); if(repository.update(row,expected)!=1)throw conflict("运营记录版本竞争失败"); var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(source.getId());history.setRecordVersion(row.getRecordVersion()); history.setAction(action);history.setOperatorId(actor.operatorId());history.setReason(reason);history.setOccurredAt(row.getUpdatedAt()); history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorName())));repository.history(history); - var result=queries.detailForWrite(actor,source);commands.success(source.getId(),actor,key,action,expected,request,result);return result; + var result=scope==OwnedBusinessScope.OWNED?queries.detailForWrite(actor,source):queries.detailForWrite(actor,source,scope); + commands.success(source.getId(),actor,key,action,expected,request,result);return result; } /** 重放返回原成功结果,不能从现在的主单/附加版本重算。 */ private Detail result(OwnedBusinessRows.Command command) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java index 048c651df..59c4f2cef 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessSupport.java @@ -4,6 +4,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.JavaType; @@ -24,9 +25,13 @@ public class OwnedBusinessSupport { public OwnedBusinessSupport(ObjectMapper json){this.json=json;} /** 平台身份和操作者均取可信会话,不接收浏览器归属参数。 */ public Actor actor(AuthenticatedUser user) { + return actor(user,OwnedBusinessScope.OWNED); + } + /** 仅错误文案按固定入口区分,权限仍原样取会话,绝不补造角色。 */ + public Actor actor(AuthenticatedUser user,OwnedBusinessScope scope) { if(user==null)throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); if(!"PLATFORM".equals(user.appCode()) || !Long.valueOf(0).equals(user.tenantId())) - throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理自有业务"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN,"仅平台可管理"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务"); String operator=user.userId()==null?user.username():user.userId().toString(); if(operator==null || operator.isBlank())throw new ResponseStatusException(HttpStatus.FORBIDDEN,"缺少有效操作账号"); String name=user.displayName()==null || user.displayName().isBlank()?user.username():user.displayName(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 3a43629fe..1dd048ce0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -18,10 +18,10 @@ public interface AppointmentWorkDispatchMapper { record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { } record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { } - /** 一次读取整页真实客户/托盘的逐票实物及全来源有效占用。 */ + /** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */ @Select(""" """) @@ -98,7 +98,7 @@ public interface AppointmentWorkDispatchMapper { List transportRows(@Param("batches") List batches,@Param("trips") List trips); @Select("SELECT i.id item_id,i.plan_pallet_id,i.customer_id,i.source_stage,i.package_count packages FROM ll_dispatch_item i JOIN ll_dispatch_order d ON d.id=i.dispatch_order_id WHERE d.dispatch_no=#{number}") List dispatchPhysical(String number); @Select("SELECT id item_id,plan_pallet_id,customer_id,source_stage,package_count packages FROM ll_dispatch_item WHERE dispatch_order_id=#{id} ORDER BY id") List dispatchPhysicalById(long id); - @Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501") + @Select("SELECT DISTINCT p.id plan_pallet_id,p.pallet_no,p.plan_id,n.plan_no,p.customer_id,CASE WHEN p.warehouse_pallet_id IS NULL OR (COALESCE(p.assignment_status,'')='RESERVED' AND COALESCE(p.actual_package_count,0)=0) THEN 'PLAN' ELSE 'RECEIVED' END source_stage,EXISTS(SELECT 1 FROM ll_dispatch_pallet_lock k WHERE (k.plan_pallet_id=p.id OR (p.warehouse_pallet_id IS NOT NULL AND k.warehouse_pallet_id=p.warehouse_pallet_id)) AND k.status='ACTIVE' AND (k.dispatch_order_id IS NOT NULL OR k.expires_at>CURRENT_TIMESTAMP)) locked FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan n ON n.id=p.plan_id JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id JOIN ll_inbound_plan_line l ON l.id=a.line_id JOIN ll_inbound_fba_appointment_item i ON i.order_id=l.order_id AND i.customer_id=l.customer_id AND i.active=1 WHERE i.appointment_id=#{id} ORDER BY p.id LIMIT 501") List candidates(long id); @Select("SELECT l.order_id,l.customer_id,CASE WHEN l.destination_type='PRIVATE' THEN CONCAT('CUSTOMER:',l.order_id) ELSE CONCAT('FBA:',l.fba_warehouse_code) END destination_key,CASE WHEN COUNT(a.planned_package_count)=COUNT(a.line_id) THEN SUM(a.planned_package_count) END planned_packages,CASE WHEN COUNT(a.counted_package_count)=COUNT(a.line_id) THEN SUM(a.counted_package_count) END counted_packages FROM ll_inbound_plan_pallet_allocation a JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id WHERE a.pallet_id=#{pallet} AND a.customer_id=#{customer} GROUP BY l.order_id,l.customer_id,l.destination_type,l.fba_warehouse_code") List physical(@Param("pallet") long pallet,@Param("customer") String customer); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java index 2adcf5885..579500b79 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/repository/RelabelDispatchSourceRepository.java @@ -68,7 +68,9 @@ public class RelabelDispatchSourceRepository { if (position == null) position = mapper.position(item.getSourceInventoryPositionId()); if (position == null) continue; positions.put(position.getId(), position); + // 无效身份只作为本申请缺失组成保留,不能把其他未绑定位置误连为同一实托。 if (warehouseId.equals(position.getOverseasWarehouseId()) && position.getPalletId() != null + && position.getPalletId() > 0 && !visitedPallets.contains(position.getPalletId())) pending.add(position.getPalletId()); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java index 28a643181..5052440a0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelDispatchSourceService.java @@ -149,8 +149,9 @@ public class RelabelDispatchSourceService implements RelabelDispatchSourcePort { long notified = number(item.getNotifiedPackageCount()); if (!Objects.equals(item.getCustomerId(), app.getCustomerId()) || position == null || !Objects.equals(position.getCustomerId(), app.getCustomerId()) - || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null) { - reasons.add(app.getRelabelNo() + ":明细当前位置缺失或客户、仓库归属不一致"); + || !Objects.equals(warehouseId, position.getOverseasWarehouseId()) || position.getPalletId() == null + || position.getPalletId() <= 0) { + reasons.add(app.getRelabelNo() + ":明细当前位置缺失、实托身份无效或客户、仓库归属不一致"); bound = false; } if (item.getReturnCartonId() != null || "RETURN_BULK_SKU_LABEL".equals(item.getOperationType())) { diff --git a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml new file mode 100644 index 000000000..b1067f20e --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml @@ -0,0 +1,304 @@ +openapi: "3.0.3" +info: + title: "原排车外配车辆运营视图" + version: "1.0.0" + description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。" +security: + - bearerAuth: [] +paths: + /api/platform/v1/last-leg/fleet/outsourced-business: + get: + operationId: listOutsourcedBusiness + summary: "外配列表及同筛选四项完整统计" + x-permission-code: "platform:fleet:outsourced-business:list" + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + - { name: keyword, in: query, schema: { type: string, maxLength: 128 }, description: "原排车号、车牌或真实目的仓/FBA编码" } + - { name: warehouseId, in: query, schema: { type: string, maxLength: 64 } } + - { name: plannedDepartureFrom, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间下界(含)" } + - { name: plannedDepartureTo, in: query, schema: { type: string, format: date-time }, description: "原计划发车时间上界(不含)" } + - { name: stage, in: query, schema: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" } } + responses: + "200": + description: "完整条件统计不带分页,不逐行GET;响应为统一ApiResponse。" + content: + application/json: + schema: { "$ref": "#/components/schemas/ListResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] + get: + operationId: getOutsourcedBusiness + summary: "外配原排车组合详情" + x-permission-code: "platform:fleet:outsourced-business:list" + responses: + "200": { "$ref": "#/components/responses/DetailResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/history: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] + get: + operationId: getOutsourcedBusinessHistory + summary: "原排车与运营附加历史分页" + x-permission-code: "platform:fleet:outsourced-business:list" + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } + responses: + "200": + description: "DISPATCH/OPERATIONS保留独立版本,原历史无虚构运营字段。" + content: + application/json: + schema: { "$ref": "#/components/schemas/HistoryResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/operations: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] + put: + operationId: saveOutsourcedBusinessOperations + summary: "登记或清空整车承运金额和逐目的仓实际到达" + x-permission-code: "platform:fleet:outsourced-business:edit" + requestBody: + required: true + content: + application/json: + schema: { "$ref": "#/components/schemas/SaveOperations" } + responses: + "200": { "$ref": "#/components/responses/DetailResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/uploads: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ] + post: + operationId: uploadOutsourcedBusinessPhoto + summary: "准备五框照片文件,尚未绑定" + x-permission-code: "platform:fleet:outsourced-business:upload" + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file, idempotencyKey] + properties: + file: { type: string, format: binary, description: "非空JPG/PNG,签名校验,最大10MiB;固定装车File分类" } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + responses: + "200": + description: "savedAt=null;未知结果保留原键查询,不重传。" + content: + application/json: + schema: { "$ref": "#/components/schemas/PhotoResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ] + put: + operationId: bindOutsourcedBusinessPhoto + summary: "以独立稳定键绑定原File编号" + x-permission-code: "platform:fleet:outsourced-business:upload" + requestBody: + required: true + content: + application/json: + schema: { "$ref": "#/components/schemas/BindPhoto" } + responses: + "200": { "$ref": "#/components/responses/DetailResponse" } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/photos/{slot}/files/{fileId}/content: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" }, { "$ref": "#/components/parameters/Slot" } ] + get: + operationId: readOutsourcedBusinessPhotoContent + summary: "读取当前或精确历史版本的同车照片引用" + x-permission-code: "platform:fleet:outsourced-business:list" + parameters: + - { name: fileId, in: path, required: true, schema: { type: integer, format: int64, minimum: 1 } } + - { name: revisionVersion, in: query, schema: { type: integer, format: int64, minimum: 1 } } + responses: + "200": + description: "原始照片字节,Cache-Control=no-store,X-Content-Type-Options=nosniff;无引用不允许探测。" + content: + image/png: { schema: { type: string, format: binary } } + image/jpeg: { schema: { type: string, format: binary } } + /api/platform/v1/last-leg/fleet/outsourced-business/{dispatchNo}/commands/{idempotencyKey}: + parameters: [ { "$ref": "#/components/parameters/DispatchNo" } ] + get: + operationId: getOutsourcedBusinessCommand + summary: "仅原操作账号查询原命令结果" + x-permission-code: "platform:fleet:outsourced-business:list" + parameters: + - { name: idempotencyKey, in: path, required: true, schema: { type: string, minLength: 8, maxLength: 128 } } + responses: + "200": + description: "同dispatch唯一账本;相同键异内容/版本冲突,异账号拒绝,未知不自动重试。" + content: + application/json: + schema: { "$ref": "#/components/schemas/CommandResponse" } +components: + securitySchemes: + bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } + parameters: + DispatchNo: { name: dispatchNo, in: path, required: true, schema: { type: string, minLength: 1, maxLength: 128 } } + Slot: { name: slot, in: path, required: true, schema: { "$ref": "#/components/schemas/PhotoSlot" } } + responses: + DetailResponse: + description: "统一ApiResponse;成功code=200,业务拒绝A0400/A0403/A0404/A0409,认证/网关可真实401/403/503;保留requestId/traceId。" + content: + application/json: + schema: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Detail" } ], nullable: true } } } + schemas: + Envelope: + type: object + properties: + code: { type: string } + message: { type: string } + requestId: { type: string, nullable: true } + traceId: { type: string, nullable: true } + Operations: + type: object + additionalProperties: false + x-java-record: OutsourcedBusinessModels.Operations + required: [arrivals] + properties: + money: { allOf: [ { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Money" } ], nullable: true } + arrivals: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Arrival" }, description: "只接受原实际destinationKey,禁止重复或其他车辆目的地;省略目的地或null均不推定到达" } + SaveOperations: + type: object + additionalProperties: false + x-java-record: OutsourcedBusinessModels.SaveOperations + required: [recordVersion, idempotencyKey, operations] + properties: + recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + operations: { "$ref": "#/components/schemas/Operations" } + reason: { type: string, nullable: true, maxLength: 1000 } + BindPhoto: + type: object + additionalProperties: false + x-java-record: OutsourcedBusinessModels.BindPhoto + required: [recordVersion, idempotencyKey, fileId] + properties: + recordVersion: { type: string, pattern: "^(0|[1-9][0-9]*)$" } + idempotencyKey: { type: string, minLength: 8, maxLength: 128 } + fileId: { type: string, pattern: "^[1-9][0-9]*$" } + Vehicle: + type: object + x-java-record: OutsourcedBusinessModels.Vehicle + required: [ownership] + properties: + vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" } + plate: { type: string, nullable: true } + ownership: { type: string, enum: [DISPATCHED] } + carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" } + PhotoSlot: + type: string + enum: [LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT] + Photo: + type: object + x-java-record: OutsourcedBusinessModels.Photo + required: [slot, category, fileId, fileName, contentType, sizeBytes, uploadedAt] + properties: + slot: { "$ref": "#/components/schemas/PhotoSlot" } + category: { type: string, enum: [LOADING] } + fileId: { type: string } + fileName: { type: string } + contentType: { type: string, enum: [image/png, image/jpeg] } + sizeBytes: { type: string } + uploadedAt: { type: string, format: date-time } + savedAt: { type: string, format: date-time, nullable: true } + PhotoSummary: + type: object + x-java-record: OutsourcedBusinessModels.PhotoSummary + required: [loadingCount] + properties: + loadingCount: { type: integer, minimum: 0, maximum: 5 } + Row: + type: object + x-java-record: OutsourcedBusinessModels.Row + required: [dispatchNo, stage, plannedDepartureAt, loadingCompletedAt, origin, vehicle, driver, destinations, actualPalletCount, operations, recordVersion, dispatchRecordVersion, photoSummary, actions, sourceIssues] + properties: + dispatchNo: { type: string } + stage: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Stage" } + plannedDepartureAt: { type: string, format: date-time } + loadingCompletedAt: { type: string, format: date-time } + departedAt: { type: string, format: date-time, nullable: true } + deliveryCompletedAt: { type: string, format: date-time, nullable: true } + origin: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Origin" } + vehicle: { "$ref": "#/components/schemas/Vehicle" } + driver: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Driver" } + destinations: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Destination" } } + actualPalletCount: { type: integer, minimum: 0, description: "原实载warehousePalletId去重;同客户/跨客户共托只计一次,不用标准托位作除数" } + operations: { "$ref": "#/components/schemas/Operations" } + unitPrice: { type: string, nullable: true, description: "整车金额除实际物理托数,HALF_UP两位;金额null或托数0时null" } + recordVersion: { type: string, description: "本页附加版本,初始只读虚拟0;原主单版本独立" } + dispatchRecordVersion: { type: string } + photoSummary: { "$ref": "#/components/schemas/PhotoSummary" } + actions: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Actions" } + sourceIssues: { type: array, items: { type: string } } + ListResult: + type: object + x-java-record: OutsourcedBusinessModels.ListResult + required: [records, page, size, total, summary] + properties: + records: { type: array, items: { "$ref": "#/components/schemas/Row" } } + page: { type: integer } + size: { type: integer } + total: { type: integer } + summary: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Summary" } + Detail: + type: object + x-java-record: OutsourcedBusinessModels.Detail + required: [row, dispatch, cargo, photos] + properties: + row: { "$ref": "#/components/schemas/Row" } + dispatch: { "$ref": "overseas-business-v1.yaml#/components/schemas/DispatchDetail" } + cargo: { type: array, items: { "$ref": "fleet-owned-business-v1.yaml#/components/schemas/Cargo" } } + photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } } + History: + type: object + x-java-record: OutsourcedBusinessModels.History + required: [eventSource, action, occurredAt, photos] + properties: + eventSource: { type: string, enum: [DISPATCH, OPERATIONS] } + recordVersion: { type: string, nullable: true } + dispatchRecordVersion: { type: string, nullable: true } + action: { type: string } + operatorName: { type: string, nullable: true } + occurredAt: { type: string, format: date-time } + reason: { type: string, nullable: true } + operations: { allOf: [ { "$ref": "#/components/schemas/Operations" } ], nullable: true } + photos: { type: array, items: { "$ref": "#/components/schemas/Photo" } } + HistoryPage: + type: object + x-java-record: OutsourcedBusinessModels.HistoryPage + required: [records, page, size, total] + properties: + records: { type: array, items: { "$ref": "#/components/schemas/History" } } + page: { type: integer } + size: { type: integer } + total: { type: integer } + Command: + type: object + x-java-record: OutsourcedBusinessModels.Command + description: "SAVE_OPERATIONS/BIND_PHOTO成功时recordVersion和result必须有值;UPLOAD_PHOTO成功时result必须含fileId,recordVersion可空。仅可空的未确定或拒绝结果字段允许省略,不放宽成功回执的关联要求。" + required: [dispatchNo, action, idempotencyKey, state, replayed, retryAllowed] + properties: + dispatchNo: { type: string } + action: { type: string, enum: [SAVE_OPERATIONS, UPLOAD_PHOTO, BIND_PHOTO] } + idempotencyKey: { type: string } + state: { type: string, enum: [SUCCEEDED, PENDING, UNKNOWN_RESULT, REJECTED] } + recordVersion: { type: string, nullable: true } + replayed: { type: boolean } + result: { nullable: true, oneOf: [ { "$ref": "#/components/schemas/Detail" }, { "$ref": "#/components/schemas/Photo" } ] } + errorCode: { type: string, nullable: true } + retryAllowed: { type: boolean, enum: [false] } + ListResponse: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/ListResult" } ], nullable: true } } } + HistoryResponse: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/HistoryPage" } ], nullable: true } } } + PhotoResponse: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Photo" } ], nullable: true } } } + CommandResponse: + allOf: + - { "$ref": "#/components/schemas/Envelope" } + - { type: object, properties: { data: { allOf: [ { "$ref": "#/components/schemas/Command" } ], nullable: true } } } diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index 2b2b9372d..a65002c10 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -2,7 +2,7 @@ openapi: "3.0.3" info: title: "原排车自有车辆运营视图" version: "1.0.0" - description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。" + description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。" security: - bearerAuth: [] @@ -237,15 +237,6 @@ paths: - "$ref": "#/components/schemas/Photo" nullable: true - parameters: - - - name: "idempotencyKey" - in: "query" - required: true - schema: - type: "string" - minLength: 8 - maxLength: 128 requestBody: required: true content: @@ -254,7 +245,13 @@ paths: type: "object" required: - "file" + - "idempotencyKey" properties: + idempotencyKey: + type: "string" + minLength: 8 + maxLength: 128 + description: "原上传请求键,以multipart表单字段提交,绑定使用独立请求键。" file: type: "string" format: "binary" @@ -432,7 +429,6 @@ components: x-java-record: "OwnedBusinessModels.Arrival" required: - "destinationKey" - - "actualArrivedAt" properties: destinationKey: type: "string" @@ -446,9 +442,6 @@ components: type: "object" x-java-record: "OwnedBusinessModels.Operations" required: - - "money" - - "actualDistanceMiles" - - "fuelConsumptionLitresPer100Km" - "arrivals" properties: money: @@ -512,7 +505,6 @@ components: x-java-record: "OwnedBusinessModels.Gate" required: - "allowed" - - "blockReason" properties: allowed: type: "boolean" @@ -563,9 +555,6 @@ components: Driver: type: "object" x-java-record: "OwnedBusinessModels.Driver" - required: - - "name" - - "phone" properties: name: type: "string" @@ -578,8 +567,6 @@ components: x-java-record: "OwnedBusinessModels.Appointment" required: - "appointmentNo" - - "confirmationNo" - - "scheduledAt" - "allocatedPackages" properties: appointmentNo: @@ -599,11 +586,7 @@ components: x-java-record: "OwnedBusinessModels.Destination" required: - "destinationKey" - - "warehouseCode" - - "warehouseName" - - "address" - "appointments" - - "actualArrivedAt" properties: destinationKey: type: "string" @@ -635,7 +618,6 @@ components: - "contentType" - "sizeBytes" - "uploadedAt" - - "savedAt" properties: slot: "$ref": "#/components/schemas/PhotoSlot" @@ -684,15 +666,11 @@ components: - "stage" - "plannedDepartureAt" - "loadingCompletedAt" - - "departedAt" - - "deliveryCompletedAt" - "origin" - "vehicle" - "driver" - "destinations" - - "actualPalletCount" - "operations" - - "unitPrice" - "recordVersion" - "dispatchRecordVersion" - "photoSummary" @@ -804,9 +782,6 @@ components: required: - "sourceKey" - "customerId" - - "customerName" - - "orderNo" - - "inboundCode" - "palletNo" - "destinationKey" - "packages" @@ -856,13 +831,8 @@ components: x-java-record: "OwnedBusinessModels.History" required: - "eventSource" - - "recordVersion" - - "dispatchRecordVersion" - "action" - - "operatorName" - "occurredAt" - - "reason" - - "operations" - "photos" properties: eventSource: @@ -925,15 +895,13 @@ components: Command: type: "object" x-java-record: "OwnedBusinessModels.Command" + description: "SAVE_OPERATIONS/BIND_PHOTO成功时recordVersion和result必须有值;UPLOAD_PHOTO成功时result必须含fileId,recordVersion可空。仅可空的未确定或拒绝结果字段允许省略,不放宽成功回执的关联要求。" required: - "dispatchNo" - "action" - "idempotencyKey" - "state" - - "recordVersion" - "replayed" - - "result" - - "errorCode" - "retryAllowed" properties: dispatchNo: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java index daf2c0e44..5b0cdbba1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchCandidateMapperContractTest.java @@ -16,12 +16,62 @@ import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertEquals; class DispatchCandidateMapperContractTest { + /** 执行正式计划/实收判定与离港门禁,覆盖自动预留、人工覆盖、混合来源及库存耗尽。 */ + @Test + void reservedPalletsCanBePlannedAfterDepartureWithoutInventingReceivedStock() throws Exception { + try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-prearrival;MODE=MySQL")) { + var db = connection.createStatement(); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR,actual_package_count INT)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,order_id BIGINT)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT)"); + db.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,order_type VARCHAR,manual_node_override BOOLEAN,source_container_booking_id BIGINT)"); + db.execute("CREATE TABLE ll_order_manual_node(order_id BIGINT,customer_id VARCHAR,node_code VARCHAR,node_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_container_shipping_gate(container_booking_id BIGINT,node_sequence INT,actual_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR,pallet_id BIGINT,available_packages INT)"); + for (int id = 1; id <= 9; id++) { + db.execute("INSERT INTO ll_inbound_plan VALUES (" + id + ",'A')"); + db.execute("INSERT INTO ll_inbound_plan_pallet VALUES (" + id + "," + id + "," + (100 + id) + ",'RESERVED',0)"); + db.execute("INSERT INTO ll_inbound_plan_line VALUES (" + id + ",'A'," + id + ")"); + db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (" + id + "," + id + ")"); + db.execute("INSERT INTO ll_order VALUES (" + id + ",'A','FIRST_LEG',FALSE," + id + ")"); + if (id != 2) db.execute("INSERT INTO ll_container_shipping_gate VALUES (" + id + ",40,CURRENT_TIMESTAMP)"); + } + db.execute("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=5 WHERE id IN (3,4)"); + db.execute("INSERT INTO ll_inventory_position VALUES ('A',103,5),('A',104,0),('B',104,10)"); + db.execute("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=5"); + db.execute("UPDATE ll_order SET manual_node_override=TRUE WHERE id IN (6,7,9)"); + db.execute("INSERT INTO ll_order_manual_node VALUES (7,'A','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP),(9,'B','ACTUAL_DEPARTURE',CURRENT_TIMESTAMP)"); + db.execute("INSERT INTO ll_inbound_plan_line VALUES (20,'A',2)"); + db.execute("INSERT INTO ll_inbound_plan_pallet_allocation VALUES (8,20)"); + String plan = DispatchCandidateMapper.PLAN_ONLY; + String eligible = "AND (NOT " + plan + " OR (" + DispatchCandidateMapper.DEPARTED_PLAN + ")) " + + "AND (" + plan + " OR EXISTS (SELECT 1 FROM ll_inventory_position pos " + + "WHERE pos.customer_id=ip.customer_id AND pos.pallet_id=p.warehouse_pallet_id AND pos.available_packages > 0))"; + var ids = new ArrayList(); + try (var rows = db.executeQuery("SELECT p.id,CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS physical," + + "CASE WHEN " + plan + " THEN 'PLAN' ELSE 'RECEIVED' END AS stage " + + "FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE 1=1 " + eligible + " ORDER BY p.id")) { + while (rows.next()) { + long id = rows.getLong("id"); ids.add(id); + assertEquals(id == 3 ? "RECEIVED" : "PLAN", rows.getString("stage")); + assertEquals(id == 3 ? 103L : null, rows.getObject("physical", Long.class)); + } + } + assertEquals(List.of(1L, 3L, 7L), ids); + // 查询和写入选中来源共用同一判定;不允许仅在列表展示在途但提交时又要求实收。 + for (String sql : List.of(DispatchCandidateMapper.AVAILABLE_PLAN_BODY, DispatchCandidateMapper.SELECTED_PLAN_SQL)) { + assertTrue(sql.contains(eligible.replace("> 0", "> 0").replace("pos.customer_id=ip.customer_id", sql.contains("WHERE pos.customer_id=#{customerId}") ? "pos.customer_id=#{customerId}" : "pos.customer_id=ip.customer_id"))); + assertTrue(sql.contains("CASE WHEN " + plan + " THEN NULL ELSE p.warehouse_pallet_id END AS warehousePalletId")); + } + } + } /** 执行正式仓ID的完整CASE及外层过滤,保留实库存优先、客户来源和长ID文本身份。 */ @Test void wholeCandidateWarehouseProjectionExecutesAsTextWithoutChangingSourceOwnership() throws Exception { String sql = String.join(" ", DispatchCandidateMapper.class.getMethod("wholeAvailable", String.class, String.class, List.class, String.class, String.class, String.class, String.class).getAnnotation(Select.class).value()); - int start = sql.indexOf("CAST(CASE WHEN p.warehouse_pallet_id IS NOT NULL THEN (SELECT MIN(pos.overseas_warehouse_id)"); + int start = sql.indexOf("CAST(CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY + " THEN (SELECT MIN(pos.overseas_warehouse_id)"); int end = sql.indexOf(" AS overseasWarehouseId", start); assertTrue(start >= 0 && end > start, "正式whole候选必须在整个仓CASE外统一为文本投影"); String expression = sql.substring(start, end).replace(">", ">").replace("<", "<"); @@ -35,15 +85,16 @@ class DispatchCandidateMapperContractTest { try (var connection = DriverManager.getConnection("jdbc:h2:mem:dispatch-whole-warehouse-text;MODE=MySQL")) { var statement = connection.createStatement(); statement.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id BIGINT,source_order_id BIGINT)"); - statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT)"); + statement.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)"); statement.execute("CREATE TABLE ll_inventory_position(customer_id VARCHAR(128),pallet_id BIGINT,available_packages BIGINT,overseas_warehouse_id VARCHAR(128))"); statement.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR(128),overseas_warehouse_id VARCHAR(128))"); statement.execute("INSERT INTO ll_inbound_plan VALUES (1,'A',193000000000200002,NULL)," + "(2,'A',193000000000100001,NULL),(3,'B',NULL,20),(4,'A',193000000000100001,NULL)," + "(5,'B',193000000000200002,NULL),(6,'A',NULL,NULL),(7,'A',193000000000100001,21)," + "(8,'A',193000000000100001,NULL),(9,'A',NULL,21)"); - statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (1,1,10),(2,2,NULL),(3,3,NULL)," + statement.execute("INSERT INTO ll_inbound_plan_pallet(id,plan_id,warehouse_pallet_id) VALUES (1,1,10),(2,2,NULL),(3,3,NULL)," + "(4,4,40),(5,5,50),(6,6,NULL),(7,7,NULL),(8,8,80),(9,9,NULL)"); + statement.execute("INSERT INTO ll_inbound_plan_pallet VALUES (10,1,1000,'RESERVED',0)"); statement.execute("INSERT INTO ll_inventory_position VALUES ('A',10,1,'193000000000100001')," + "('B',40,1,'193000000000100001'),('A',40,0,'193000000000100001')," + "('B',50,1,'193000000000100001'),('A',80,1,'193000000000200002')"); @@ -51,7 +102,7 @@ class DispatchCandidateMapperContractTest { + "(20,'B','193000000000100001'),(21,'B','193000000000100001')"); try (var prepared = connection.prepareStatement(query)) { assertWarehouseRows(prepared, "193000000000100001", List.of(1L, 2L, 3L, 5L, 7L)); - assertWarehouseRows(prepared, "193000000000200002", List.of(8L)); + assertWarehouseRows(prepared, "193000000000200002", List.of(8L, 10L)); for (String invalid : List.of("19300000000010000", "193000000000100001x", "0193000000000100001", "1.93e17", "wrong")) assertWarehouseRows(prepared, invalid, List.of()); } @@ -215,7 +266,7 @@ class DispatchCandidateMapperContractTest { Method method = DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class); String sql = String.join(" ", method.getAnnotation(Select.class).value()); - assertTrue(sql.contains("p.warehouse_pallet_id IS NULL OR EXISTS")); + assertTrue(sql.contains("AND (" + DispatchCandidateMapper.PLAN_ONLY + " OR EXISTS")); assertTrue(sql.contains("pos.available_packages > 0")); } @@ -249,7 +300,7 @@ class DispatchCandidateMapperContractTest { String.class, String.class, String.class), DispatchCandidateMapper.class.getMethod("lockSelected", String.class, List.class))) { String sql = String.join(" ", method.getAnnotation(Select.class).value()); - assertTrue(sql.contains("CASE WHEN p.warehouse_pallet_id IS NOT NULL")); + assertTrue(sql.contains("CASE WHEN NOT " + DispatchCandidateMapper.PLAN_ONLY)); assertTrue(sql.contains("pos.overseas_warehouse_id")); assertTrue(sql.contains(method.getName().equals("available") ? "NULLIF(CAST(ip.overseas_warehouse_id AS CHAR(128)),'')" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java index d8758a6d9..5db5ce777 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/repository/DispatchNumberSequenceTest.java @@ -1,6 +1,10 @@ package com.baiyue.lastleg.dispatch.repository; +import com.baiyue.lastleg.dispatch.mapper.DispatchNumberSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; import org.springframework.aop.framework.ProxyFactory; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.datasource.*; @@ -18,22 +22,29 @@ class DispatchNumberSequenceTest { final JdbcTemplate jdbc; final DataSourceTransactionManager transactions; final DispatchNumberSequence sequence; - Fixture() { + final DispatchNumberSequenceMapper mapper; + /** 隔离库造表只使用测试 JDBC,被测分配和读取均由实际 Mapper 执行。 */ + Fixture() throws Exception { var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); jdbc.execute("CREATE TABLE ll_dispatch_daily_sequence(business_date DATE PRIMARY KEY,last_sequence BIGINT NOT NULL)"); - var proxy=new ProxyFactory(new DispatchNumberSequence(jdbc));proxy.setProxyTargetClass(true); + var configuration=new MybatisConfiguration();configuration.addMapper(DispatchNumberSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchNumberSequenceMapper.class); + var proxy=new ProxyFactory(new DispatchNumberSequence(mapper));proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); sequence=(DispatchNumberSequence)proxy.getProxy(); } } - @Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() { + /** 每次业务事务独立取号,同一天连续增长,下一天从一开始。 */ + @Test void startsAtOneEachDayAndContinuesAcrossGeneratorInstances() throws Exception { var f=new Fixture();var today=LocalDate.of(2026,10,20); assertThat(f.sequence.next(today)).isEqualTo(1); assertThat(f.sequence.next(today)).isEqualTo(2); assertThat(f.sequence.next(today.plusDays(1))).isEqualTo(1); assertThat(f.sequence.next(today)).isEqualTo(3); } + /** 两个真实事务连接争用同一天主键,全部已提交号码唯一且连续。 */ @Test void concurrentConnectionsAllocateEveryNumberOnce() throws Exception { var f=new Fixture();var date=LocalDate.of(2026,10,20);var pool=Executors.newFixedThreadPool(2); try { @@ -43,17 +54,30 @@ class DispatchNumberSequenceTest { assertThat(values.last()).isEqualTo(64L); } finally {pool.shutdownNow();} } - @Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() { + /** 外层建单回滚不能回滚独立取号事务,也不能复用已分配号。 */ + @Test void outerBusinessRollbackDoesNotReuseAnAlreadyAllocatedNumber() throws Exception { var f=new Fixture();var date=LocalDate.of(2026,10,20); assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ assertThat(f.sequence.next(date)).isEqualTo(1);throw new IllegalStateException("business rollback"); })).isInstanceOf(IllegalStateException.class); assertThat(f.sequence.next(date)).isEqualTo(2); } - @Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() { + /** 四位上限拒绝分配,并回滚本次递增,保持当日已用尽事实。 */ + @Test void fourDigitExhaustionDoesNotWrapOrIncrementTheStoredCounter() throws Exception { var f=new Fixture();var date=LocalDate.of(2026,10,20); f.jdbc.update("INSERT INTO ll_dispatch_daily_sequence VALUES(?,9999)",date); assertThatThrownBy(()->f.sequence.next(date)).hasMessageContaining("四位流水号已用满"); assertThat(f.jdbc.queryForObject("SELECT last_sequence FROM ll_dispatch_daily_sequence WHERE business_date=?",Long.class,date)).isEqualTo(9999L); } + + /** 直接改变连接级值后再次调用同一 Mapper,必须绕过一级会话缓存返回新值。 */ + @Test void connectionResultBypassesSessionCacheInTheSameTransaction() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index d4c32ae67..9a84ac56d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -148,6 +148,33 @@ class DispatchMergedRelabelPersistenceTest { var created=f.create("actual-plan",group.sources().stream().map(s->s.selection()).toList());assertThat(created.counts().palletCount()).isEqualTo(1);assertThat(created.counts().packageCount()).isEqualTo(4); } + /** 无效实托不成为候选或跨申请扩组锚点;同申请缺失的组成仍明确阻断建单。 */ + @ParameterizedTest @ValueSource(longs={0,-1}) + void invalidPalletIdentityDoesNotCrashOrConnectUnrelatedApplications(Long invalidPallet) throws Exception { + var f=new Merged(false); + for(long id:List.of(102L,103L))f.insert("ll_inventory_position","id",id,"customer_id","101","balance_id",601, + "overseas_warehouse_id","9","pallet_id",invalidPallet,"pallet_no","","batch_no","MISSING-"+id, + "warehouse_location_code","LOC-1","hold_packages",1); + f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","待核对来源", + "total_package_count",1,"notified_package_count",1,"carton_mark","OLD-MISSING","fba_carton_mark","NEW-MISSING", + "operation_type","CARTON_LABEL","source_inventory_balance_id",601,"source_inventory_position_id",102, + "source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING", + "destination_fba_warehouse_code","ONT8"); + f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED"); + var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null)); + var groups=f.rules.selectionGroups(candidates); + assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L)); + var group=groups.get("RELABEL:RL-A:501"); + assertThat(group.complete()).isFalse(); + assertThat(group.blockReasons()).anyMatch(reason->reason.contains("RL-A")&&reason.contains("明细当前位置缺失")); + assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo) + .containsExactlyInAnyOrder("RL-A","RL-B"); + assertThat(group.pallets()).hasSize(1); + assertThatThrownBy(()->f.create("invalid-pallet",group.sources().stream().map(s->s.selection()).toList())) + .isInstanceOf(org.springframework.web.server.ResponseStatusException.class); + assertThat(f.count("ll_dispatch_order")).isZero();assertThat(f.count("ll_dispatch_pallet_lock")).isZero(); + } + /** 只隔离测量和车队外部目录;来源提供者、候选、库存与所有排车Mapper均为实际实现。 */ private static final class Merged { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); @@ -163,7 +190,7 @@ class DispatchMergedRelabelPersistenceTest { for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", - "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item")) { + "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate")) { f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table); // H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java index 446d3e332..92e6eda23 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchWholeSourcePersistenceTest.java @@ -13,6 +13,67 @@ import static org.mockito.ArgumentMatchers.*; /** 候选投影在此受控,来源订单/仓/库存版本/实收组成与锁序均经实际MyBatis查询。 */ class DispatchWholeSourcePersistenceTest { + /** 同页普通来源批读不随托盘逐行重复,完整组与版本须和单组权威读取完全一致。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={true,false}) + void batchesPageSourceReadsWithoutChangingGroupsOrReusingThemAcrossRequests(boolean sameCustomer) throws Exception { + var fixture=new Sources(sameCustomer);var selections=fixture.selections(); + var first=fixture.rules.selectionGroup("9",selections.get(0)); + var second=fixture.rules.selectionGroup("9",selections.get(1)); + clearInvocations(fixture.sourceMapper,fixture.candidateMapper); + var rows=fixture.f.candidates.entrySet().stream().sorted(Map.Entry.comparingByKey()).map(Map.Entry::getValue).toList(); + var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows)); + assertThat(groups.get("PLAN:1")).isEqualTo(first); + assertThat(groups.get("PLAN:2")).isEqualTo(second); + verify(fixture.sourceMapper,times(1)).physicalPlans("9",List.of(501L,502L)); + verify(fixture.sourceMapper,times(1)).physicalReturns("9",List.of(501L,502L)); + if(sameCustomer) { + verify(fixture.candidateMapper,times(1)).readSelected("101",List.of(1L,2L)); + verify(fixture.candidateMapper,never()).readSelected("101",List.of(1L)); + } + var customers=sameCustomer?Map.of("101",List.of(501L,502L)):Map.of("101",List.of(501L),"202",List.of(502L)); + customers.forEach((customer,pallets)->{ + verify(fixture.sourceMapper,times(1)).stocks(customer,pallets); + verify(fixture.sourceMapper,times(1)).palletReceipts(customer,pallets); + verify(fixture.sourceMapper,times(1)).palletReturnedReceipts(customer,pallets); + }); + verify(fixture.sourceMapper,never()).stock(anyString(),anyLong(),anyBoolean()); + verify(fixture.sourceMapper,never()).receipts(anyString(),anyLong()); + verify(fixture.sourceMapper,never()).returnedReceipts(anyString(),anyLong()); + verify(fixture.sourceMapper,never()).physicalPlans("9",List.of(501L)); + fixture.f.db.update("UPDATE ll_inbound_stock_receipt SET received_packages=4 WHERE id=1"); + var refreshed=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows)); + assertThat(refreshed.get("PLAN:1").groupVersion()).isNotEqualTo(first.groupVersion()); + verify(fixture.sourceMapper,times(2)).physicalPlans("9",List.of(501L,502L)); + } + /** 未实收计划没有实托,整页版本生成不能逐条发空库存和实收查询。 */ + @Test void plannedCandidatesNeedNoPhysicalFactQueries() throws Exception { + var fixture=new Sources(true); + var rows=fixture.f.candidates.values().stream().map(row->row.warehousePalletId(null).sourceStage("PLAN")).toList(); + var expected=new HashMap(); + for(var row:rows)expected.put("PLAN:"+row.planPalletId(),fixture.rules.wholeSourceVersion(row,"PLAN:"+row.planPalletId(),false)); + clearInvocations(fixture.sourceMapper,fixture.candidateMapper); + var groups=fixture.f.tx.execute(status->fixture.rules.selectionGroups(rows)); + for(var entry:expected.entrySet())assertThat(groups.get(entry.getKey()).sources().get(0).selection().sourceVersion()).isEqualTo(entry.getValue()); + verifyNoInteractions(fixture.sourceMapper,fixture.candidateMapper); + } + /** 同实托不同客户的收货依据必须隔离,真实退回实收仍参加摘要且保持原顺序。 */ + @Test void batchFactsMatchIndividualQueriesForSharedPalletCustomersAndReturns() throws Exception { + var fixture=new Sources(); + fixture.f.db.update("INSERT INTO ll_inventory_position VALUES(3,'202',3,501,'PALLET-501','9',2,0,0,0,1)"); + fixture.f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(3,'202','IN-OTHER',9002,3,2,501)"); + fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(5,'101',1,9001,1,2)"); + fixture.f.db.update("INSERT INTO ll_return_stock_receipt VALUES(6,'202',3,9002,3,1)"); + var repository=new DispatchSourceRepository(fixture.candidateMapper,mock(ReturnDispatchCandidateMapper.class),fixture.sourceMapper); + for(var customer:List.of("101","202")) { + var batch=repository.sourceFacts(customer,List.of(501L,502L)); + for(var pallet:List.of(501L,502L)) { + assertThat(batch.get(pallet).stock()).isEqualTo(repository.stock(customer,pallet,false)); + assertThat(batch.get(pallet).receipts()).isEqualTo(repository.receipts(customer,pallet)); + assertThat(batch.get(pallet).returnedReceipts()).isEqualTo(repository.returnedReceipts(customer,pallet)); + } + } + } /** 同仓多客户先锁齐来源订单,随后稳定锁计划和库存,不以MIN展示订单替代组成。 */ @Test void resolvesMultipleCustomersAndLocksAllSourceOrdersBeforeInventory() throws Exception { var fixture=new Sources();var selections=fixture.selections();clearInvocations(fixture.sourceMapper); @@ -43,10 +104,12 @@ class DispatchWholeSourcePersistenceTest { /** 实际来源表保持独立客户;只有复杂候选投影查询在本用例隔离。 */ private static final class Sources { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); - final DispatchSourceMapper sourceMapper;final DispatchService rules=new DispatchService(); + final DispatchSourceMapper sourceMapper;final DispatchCandidateMapper candidateMapper;final DispatchService rules=new DispatchService(); /** 构造真实订单组成和库存版本,不能由候选响应替代这些数据库事实。 */ - Sources() throws Exception { - f.source(1,"101",501,"ONT8",3);f.source(2,"202",502,"LAX9",5); + Sources() throws Exception { this(false); } + /** 同客户多托用于核对候选批读次数,跨客户夹具继续覆盖原隔离与锁序。 */ + Sources(boolean sameCustomer) throws Exception { + f.source(1,"101",501,"ONT8",3);f.source(2,sameCustomer?"101":"202",502,"LAX9",5); f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0"); f.db.execute("ALTER TABLE ll_order ADD overseas_warehouse_id VARCHAR(64)"); f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),overseas_warehouse_id VARCHAR(64),source_order_id BIGINT)"); @@ -60,9 +123,9 @@ class DispatchWholeSourcePersistenceTest { f.db.update("INSERT INTO ll_inventory_position VALUES(?,?,?,?,?,?,?,0,0,0,1)",id,row.customerId(),id,row.warehousePalletId(),row.palletNo(),"9",row.packageCount()); f.db.update("INSERT INTO ll_inbound_stock_receipt VALUES(?,?,?,?,?,?,?)",id,row.customerId(),row.inboundCode(),row.orderId(),id,row.packageCount(),row.warehousePalletId()); } - var plans=mock(DispatchCandidateMapper.class); + var plans=mock(DispatchCandidateMapper.class);candidateMapper=plans; when(plans.findCustomerScopesByPlanPalletIds(anyList())).thenAnswer(call->{List ids=call.getArgument(0);return List.of(f.candidates.get(ids.get(0)).customerId());}); - when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List ids=call.getArgument(1);return List.of(f.candidates.get(ids.get(0)));}); + when(plans.readSelected(anyString(),anyList())).thenAnswer(call->{List ids=call.getArgument(1);return ids.stream().map(f.candidates::get).toList();}); sourceMapper=spy(f.session.getMapper(DispatchSourceMapper.class)); var sources=new DispatchSourceRepository(plans,mock(ReturnDispatchCandidateMapper.class),sourceMapper); ReflectionTestUtils.setField(rules,"wholeSources",sources);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java new file mode 100644 index 000000000..ddc0d4ca8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/outsourcedbusiness/OutsourcedBusinessContractTest.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.fleet.outsourcedbusiness; + +import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels; +import com.baiyue.lastleg.fleet.outsourcedbusiness.controller.OutsourcedBusinessController; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.core.JsonProcessingException; +import org.junit.jupiter.api.Test; +import org.springframework.security.access.prepost.PreAuthorize; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; + +/** HTTP公开JSON精简及拒绝行为,防止仅页面隐藏不支持字段。 */ +class OutsourcedBusinessContractTest { + /** 即使全局允许未知字段,本域仍明确拒绝油耗、里程及客户端归属。 */ + @Test void unsupportedOperationsAreRejectedEvenWithLenientGlobalJackson() throws Exception { + var json=new ObjectMapper().findAndRegisterModules().disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + String body="{\"recordVersion\":\"0\",\"idempotencyKey\":\"test-original-key\",\"operations\":{\"money\":null,\"arrivals\":[]}}"; + assertNotNull(json.readValue(body,OutsourcedBusinessModels.SaveOperations.class)); + for(String field:List.of("actualDistanceMiles","fuelConsumptionLitresPer100Km","actualFuelLitres","ownership","scope")) { + String extra=body.replace("\"arrivals\":[]","\"arrivals\":[],\""+field+"\":null"); + assertThrows(JsonProcessingException.class,()->json.readValue(extra,OutsourcedBusinessModels.SaveOperations.class),field); + } + assertThrows(JsonProcessingException.class,()->json.readValue(body.replace("\"recordVersion\":\"0\"","\"recordVersion\":\"0\",\"scope\":\"OWNED\""),OutsourcedBusinessModels.SaveOperations.class)); + assertThrows(JsonProcessingException.class,()->json.readValue("{\"recordVersion\":\"0\",\"idempotencyKey\":\"bind-original\",\"fileId\":\"9\",\"ownership\":\"OWNED\"}",OutsourcedBusinessModels.BindPhoto.class)); + } + /** 外配输出没有油耗字段和摘要,自有原JSON字段继续存在。 */ + @Test void outsourcedSerializationIsLeanAndOwnedPublicShapeRemains() throws Exception { + var json=new ObjectMapper().findAndRegisterModules(); + var output=json.valueToTree(new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),List.of())); + assertEquals(2,output.size());assertTrue(output.has("money"));assertTrue(output.has("arrivals")); + assertFalse(output.has("actualDistanceMiles"));assertFalse(output.has("fuelConsumptionLitresPer100Km")); + assertEquals(1,json.valueToTree(new OutsourcedBusinessModels.PhotoSummary(5)).size()); + var external=json.valueToTree(new OutsourcedBusinessModels.Vehicle(null,"TEST-EXT","DISPATCHED",null)); + assertTrue(external.get("vehicleId").isNull());assertTrue(external.get("carrierName").isNull());assertEquals(4,external.size()); + var owned=json.valueToTree(new OwnedBusinessModels.Operations(null,null,null,List.of())); + assertTrue(owned.has("actualDistanceMiles"));assertTrue(owned.has("fuelConsumptionLitresPer100Km")); + assertEquals(3,json.valueToTree(new OwnedBusinessModels.Vehicle("7","TEST-OWN","OWNED")).size()); + } + /** 油耗框不是外配合法URL枚举,不能先上传再在页面隐藏。 */ + @Test void onlyFiveLoadingPhotoSlotsCanDeserialize() throws Exception { + var json=new ObjectMapper();assertEquals(5,OutsourcedBusinessModels.PhotoSlot.values().length); + for(var slot:OutsourcedBusinessModels.PhotoSlot.values())assertEquals(slot,json.readValue("\""+slot.name()+"\"",OutsourcedBusinessModels.PhotoSlot.class)); + assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_START\"",OutsourcedBusinessModels.PhotoSlot.class)); + assertThrows(JsonProcessingException.class,()->json.readValue("\"FUEL_END\"",OutsourcedBusinessModels.PhotoSlot.class)); + } + /** 八类HTTP操作各用外配真实权限,不将外配权限伪装为自有。 */ + @Test void controllerAlwaysRequiresTheActualOutsourcedPermission() { + int count=0; + for(var method:OutsourcedBusinessController.class.getDeclaredMethods()) { + var authorization=method.getAnnotation(PreAuthorize.class);if(authorization==null)continue;count++; + String action=switch(method.getName()){case "save"->"edit";case "upload","bind"->"upload";default->"list";}; + assertEquals("hasAuthority('platform:fleet:outsourced-business:"+action+"')",authorization.value()); + assertTrue(Arrays.stream(method.getParameterTypes()).noneMatch(p->p.getSimpleName().equals("OwnedBusinessScope"))); + } + assertEquals(8,count); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java new file mode 100644 index 000000000..b2e75e9ec --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -0,0 +1,265 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.lastleg.dispatch.port.DispatchDetailReader; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchViews; +import com.baiyue.lastleg.dispatch.port.DispatchTypes; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels; +import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService; +import org.junit.jupiter.api.*; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.server.ResponseStatusException; +import java.util.*; +import java.util.concurrent.atomic.AtomicLong; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 复用既有四表测试装配,实际Mapper、事务、权限与原键一并验收。 */ +class OwnedBusinessScopePersistenceTest { + final OwnedBusinessPersistenceTest baseline=new OwnedBusinessPersistenceTest(); + OwnedBusinessQueryService queries; + OwnedBusinessRecordService records; + OwnedBusinessPhotoService photos; + OwnedBusinessFileGateway files; + OutsourcedBusinessService outsourced; + final Map uploaded=new HashMap<>(); + final byte[] png={(byte)137,80,78,71,13,10,26,10,0}; + /** 只调用原测试的建库装配,不继承或重复运行其@Test。 */ + @BeforeEach void setup() throws Exception { + baseline.setup();var reader=mock(DispatchDetailReader.class); + when(reader.detail(any(),anyString())).thenAnswer(call->{ + var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class); + return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), + DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of()); + }); + queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader)); + var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec)); + records=tx(new OwnedBusinessRecordService(baseline.repository,queries,baseline.projection,commands,baseline.codec)); + files=mock(OwnedBusinessFileGateway.class);var sequence=new AtomicLong(900); + when(files.upload(anyLong(),any(),anyString(),any())).thenAnswer(call->{ + long id=sequence.incrementAndGet();var evidence=new OwnedBusinessFileGateway.Evidence(id,"TEST-"+id+".png","image/png",png.length,"2026-10-20T08:00:00Z");uploaded.put(id,evidence);return evidence; + }); + when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->uploaded.get(call.getArgument(2))); + when(files.content(anyLong(),any(),anyLong())).thenReturn(png); + photos=tx(new OwnedBusinessPhotoService(queries,records,baseline.repository,commands,baseline.projection,baseline.codec,files)); + outsourced=new OutsourcedBusinessService(queries,records,photos,baseline.projection,baseline.codec); + insert(2,"MASTER","AFTER_LOADING","TEST原承运商");insert(3,"EXTERNAL","DELIVERING",null);insert(4,"MASTER","DELIVERED","TEST另一承运商"); + } + /** 数据库仅在本机隔离H2,用后关闭。 */ + @AfterEach void close(){baseline.closeDatabase();} + /** 所有被测写路径都走真实注解事务,不以内存集合替代Mapper。 */ + @SuppressWarnings("unchecked") private T tx(T target) { + var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(baseline.manager,new AnnotationTransactionAttributeSource()));return (T)proxy.getProxy(); + } + /** 两类外配身份固定原快照,实际托盘号和两个客户仍逐项保留。 */ + private void insert(long id,String mode,String stage,String carrier) { + var original=OwnedBusinessFixtures.source(); + var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class); + var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of()); + String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver())); + String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id)); + baseline.jdbc.update(""" + INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at, + loading_completion_hash,?,?,?,overseas_warehouse_id,overseas_warehouse_code,overseas_warehouse_name FROM ll_dispatch_order WHERE id=1 + """,id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id); + baseline.jdbc.update(""" + INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type, + destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id=1 + """,id*100,id,"PAL-"+(31+id),31+id); + var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId()); + ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L); + ownership.setLoadingCompletionHash(original.getLoadingCompletionHash());ownership.setLoadingCompletedAt(original.getLoadingCompletedAt());ownership.setOwnershipCapturedAt(original.getLoadingCompletedAt());baseline.repository.capture(ownership); + baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt()); + if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id); + if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id); + } + /** 外配真实三权限,与自有集合完全不相交。 */ + private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));} + /** 请求只包含金额和本车目的地到达,版本与原键逐次显式提供。 */ + private OutsourcedBusinessModels.SaveOperations request(String version,String key,OwnedBusinessModels.Money money,List arrivals) { + return new OutsourcedBusinessModels.SaveOperations(version,key,new OutsourcedBusinessModels.Operations(money,arrivals),"本机TEST承运登记"); + } + /** 真实SQL统计跨页且按归属、阶段、日期、车牌和目的仓共同过滤。 */ + @Test void fullSummaryAndFilterUseOneFrozenScope() { + var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,"9");var result=outsourced.list(actor(),query); + assertEquals(1,result.records().size());assertEquals(3,result.total());assertEquals(new OwnedBusinessModels.Summary(3,1,1,1),result.summary()); + assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total()); + assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"TEST-OUT-3",null,"9")).total()); + assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,"ONT8",null,"9")).total()); + assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,"other")).total()); + assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total()); + for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total()); + } + /** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */ + @Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() { + var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3"); + assertEquals("7",master.row().vehicle().vehicleId());assertEquals("TEST原承运商",master.row().vehicle().carrierName()); + assertNull(external.row().vehicle().vehicleId());assertNull(external.row().vehicle().carrierName()); + assertEquals("DISPATCHED",external.row().vehicle().ownership());assertEquals("0",master.row().recordVersion()); + assertEquals(2,master.cargo().size());assertEquals(1,master.row().actualPalletCount());assertTrue(master.row().actions().edit().allowed()); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + baseline.jdbc.update("DELETE FROM ll_fleet_owned_business_ownership WHERE dispatch_id=2"); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2"));assertEquals(2,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,null,null,null,null)).total());assertNull(baseline.repository.ownership(2)); + } + /** 实际服务权限与原归属双向隔离,不允许靠共享四表越权。 */ + @Test void bothPermissionAndDispatchScopeAreRequiredOnReadWriteAndCommand() { + assertThrows(ResponseStatusException.class,()->outsourced.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + assertThrows(ResponseStatusException.class,()->queries.detail(actor(),"DS-OWN-ONE")); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OWN-ONE")); + assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OWN-ONE",request("0","foreign-scope-save",null,List.of()))); + assertThrows(BizException.class,()->outsourced.command(actor(),"DS-OWN-ONE","foreign-scope-key")); + assertThrows(BizException.class,()->photos.command(OwnedBusinessFixtures.actor(),"DS-OUT-2","foreign-scope-key")); + assertThrows(ResponseStatusException.class,()->outsourced.save(OwnedBusinessFixtures.actor(),"DS-OUT-2",request("0","wrong-permission-write",null,List.of()))); + var all=new HashSet<>(actor().permissions());all.addAll(OwnedBusinessFixtures.actor().permissions()); + var both=new OwnedBusinessModels.Actor("both-scopes","TEST双权限操作员",all); + assertThrows(BizException.class,()->outsourced.detail(both,"DS-OWN-ONE"));assertThrows(BizException.class,()->queries.detail(both,"DS-OUT-2")); + assertThrows(BizException.class,()->records.save(both,"DS-OUT-2",new OwnedBusinessModels.SaveOperations("0","wrong-owned-write",new OwnedBusinessModels.Operations(null,null,null,List.of()),null))); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_command",Integer.class)); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + } + /** 多客户同一物理托盘只除一次,逐目的地登记、零值与NULL清空都走真实CAS。 */ + @Test void samePhysicalPalletMultipleDestinationsAndMoneyClearUseRealCas() { + baseline.jdbc.update("UPDATE ll_dispatch_item SET destination_warehouse_code='LAX9' WHERE id=212"); + var arrivals=List.of(new OwnedBusinessModels.Arrival("FBA:ONT8","2026-10-20T08:10:00Z"),new OwnedBusinessModels.Arrival("FBA:LAX9","2026-10-20T08:20:00Z")); + var body=request("0","carrier-amount-original",new OwnedBusinessModels.Money("CNY","60"),arrivals); + var first=outsourced.save(actor(),"DS-OUT-2",body); + assertEquals("60.00",first.row().unitPrice());assertEquals(2,first.row().destinations().size());assertTrue(first.row().destinations().stream().allMatch(d->d.actualArrivedAt()!=null)); + assertEquals(first,outsourced.save(actor(),"DS-OUT-2",body)); + assertEquals(first,outsourced.command(actor(),"DS-OUT-2",body.idempotencyKey()).result()); + var stale=assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","carrier-amount-stale",null,List.of())));assertEquals(ResultCode.CONFLICT,stale.getResultCode()); + assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0",body.idempotencyKey(),new OwnedBusinessModels.Money("USD","0"),arrivals))); + var zero=outsourced.save(actor(),"DS-OUT-2",request("1","carrier-amount-zero",new OwnedBusinessModels.Money("USD","0"),arrivals)); + assertEquals("0.00",zero.row().unitPrice());assertEquals("USD",zero.row().operations().money().currency()); + var cleared=outsourced.save(actor(),"DS-OUT-2",request("2","carrier-amount-clear",null,List.of())); + assertNull(cleared.row().operations().money());assertNull(cleared.row().unitPrice());assertTrue(cleared.row().destinations().stream().allMatch(d->d.actualArrivedAt()==null)); + assertNull(baseline.repository.record(2).getCurrency());assertNull(baseline.repository.record(2).getActualDistanceMiles());assertNull(baseline.repository.record(2).getFuelConsumptionLitresPer100Km()); + assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion()); + } + /** 油耗及里程在共享服务再次拒绝,不能绕过外配DTO直接调用内部入口。 */ + @Test void unsupportedFuelAndForeignDestinationAreRejectedBeforeAnyWrite() { + var body=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-write",new OwnedBusinessModels.Operations(null,"1",null,List.of()),null); + assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",body,OwnedBusinessScope.OUTSOURCED)); + var fuel=new OwnedBusinessModels.SaveOperations("0","illegal-fuel-rate",new OwnedBusinessModels.Operations(null,null,"1",List.of()),null); + assertThrows(BizException.class,()->records.save(actor(),"DS-OUT-2",fuel,OwnedBusinessScope.OUTSOURCED)); + assertThrows(BizException.class,()->photos.upload(actor(),"DS-OUT-2",OwnedBusinessModels.PhotoSlot.FUEL_START,"illegal-fuel-photo",new MockMultipartFile("file",png),OwnedBusinessScope.OUTSOURCED)); + assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","illegal-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:OTHER",null))))); + assertThrows(BizException.class,()->outsourced.save(actor(),"DS-OUT-2",request("0","duplicate-destination",null,List.of(new OwnedBusinessModels.Arrival("FBA:ONT8",null),new OwnedBusinessModels.Arrival("FBA:ONT8",null))))); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class));verifyNoInteractions(files); + } + /** 五框上传/绑定/原键恢复及历史读取保持同车原引用,异账号不能复用。 */ + @Test void fivePhotosReplayHistoryAndActorIsolationDoNotChangeDispatch() { + String version="0",firstFile=null;int index=0; + for(var slot:OutsourcedBusinessModels.PhotoSlot.values()) { + var file=new MockMultipartFile("file","TEST.png","image/png",png);String uploadKey="out-upload-"+slot; + var photo=outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file);assertNull(photo.savedAt()); + assertEquals(photo,outsourced.upload(actor(),"DS-OUT-2",slot,uploadKey,file)); + assertEquals(version,outsourced.detail(actor(),"DS-OUT-2").row().recordVersion()); + var binding=new OutsourcedBusinessModels.BindPhoto(version,"out-bind-"+slot,photo.fileId()); + var bound=outsourced.bind(actor(),"DS-OUT-2",slot,binding);assertEquals(bound,outsourced.bind(actor(),"DS-OUT-2",slot,binding));version=bound.row().recordVersion(); + if(index++==0)firstFile=photo.fileId();assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",slot,Long.parseLong(photo.fileId()),null).content()); + } + assertEquals(5,outsourced.detail(actor(),"DS-OUT-2").row().photoSummary().loadingCount()); + var replacement=outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-replacement-upload",new MockMultipartFile("file","TEST.png","image/png",png)); + outsourced.bind(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,new OutsourcedBusinessModels.BindPhoto(version,"out-replacement-bind",replacement.fileId())); + final long oldId=Long.parseLong(firstFile); + assertThrows(BizException.class,()->outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,null)); + assertArrayEquals(png,outsourced.content(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,oldId,1L).content()); + var history=outsourced.history(actor(),"DS-OUT-2",1,200);assertEquals(7,history.total()); + assertTrue(history.records().stream().anyMatch(h->h.eventSource().equals("DISPATCH") && h.recordVersion()==null && h.dispatchRecordVersion().equals("8") && h.operations()==null)); + assertTrue(history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).allMatch(h->h.dispatchRecordVersion()==null && h.reason().startsWith("保存"))); + var other=new OwnedBusinessModels.Actor("other","TEST另一外配账号",actor().permissions()); + assertThrows(ResponseStatusException.class,()->outsourced.command(other,"DS-OUT-2","out-upload-LOADING_SCENE")); + assertThrows(ResponseStatusException.class,()->outsourced.upload(other,"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_SCENE,"out-upload-LOADING_SCENE",new MockMultipartFile("file","TEST.png","image/png",png))); + verify(files,times(6)).upload(anyLong(),any(),anyString(),any());assertEquals(8L,baseline.repository.source("DS-OUT-2").getRecordVersion()); + } + /** File结果未知只保存原意图,任何同键重放不得再次写File。 */ + @Test void unknownUploadIsRecoverableButNeverAutomaticallyRetried() { + when(files.upload(anyLong(),any(),anyString(),any())).thenThrow(new IllegalStateException("本机模拟连接中断")); + var file=new MockMultipartFile("file","TEST.png","image/png",png); + assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file)); + assertThrows(BizException.class,()->outsourced.upload(actor(),"DS-OUT-2",OutsourcedBusinessModels.PhotoSlot.LOADING_LIST,"out-upload-unknown",file)); + var command=outsourced.command(actor(),"DS-OUT-2","out-upload-unknown");assertEquals("UNKNOWN_RESULT",command.state());assertFalse(command.retryAllowed());assertNull(command.result()); + assertEquals("OUTSOURCED_UPLOAD_UNKNOWN_RESULT",command.errorCode());verify(files,times(1)).upload(anyLong(),any(),anyString(),any()); + } + /** 原装车ID、模式、hash、时间任何一项不一致都不能进入外配详情。 */ + @Test void frozenIdentityHashAndTimeCannotBeSubstituted() { + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=999 WHERE dispatch_id=2"); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_id=7,loading_completion_hash='different' WHERE dispatch_id=2"); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash='actual-load',loading_completed_at=? WHERE dispatch_id=2",OwnedBusinessFixtures.source().getLoadingCompletedAt().plusSeconds(1)); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); + String external=baseline.repository.source("DS-OUT-3").getResourcesJson().replace("\"vehicleId\":null","\"vehicleId\":99"); + baseline.jdbc.update("UPDATE ll_dispatch_order SET resources_json=? WHERE id=3",external); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3")); + assertEquals(0,baseline.jdbc.queryForObject("SELECT COUNT(*) FROM ll_fleet_owned_business_record",Integer.class)); + } + /** 承运金额按两实托HALF_UP两位,零实托不能伪造单价或使用来源条数替代。 */ + @Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() { + String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},"); + baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load); + baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211"); + var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of())); + assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice()); + var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class); + baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",baseline.codec.write(new DispatchContracts.ConfirmedLoad(confirmation.manifestHash(),List.of(),confirmation.operatorId(),confirmation.confirmedAt()))); + var empty=outsourced.detail(actor(),"DS-OUT-2");assertEquals(0,empty.row().actualPalletCount());assertNull(empty.row().unitPrice());assertFalse(empty.row().sourceIssues().isEmpty()); + } + /** 元数据暂不可读时原绑定体和fileId不丢失,恢复后不重复上传。 */ + @Test void failedBindRetainsOriginalFileAndKeyForExplicitRecovery() { + var slot=OutsourcedBusinessModels.PhotoSlot.LOADING_LIST; + var uploadedPhoto=outsourced.upload(actor(),"DS-OUT-2",slot,"out-bind-failure-upload",new MockMultipartFile("file","TEST.png","image/png",png)); + long fileId=Long.parseLong(uploadedPhoto.fileId()); + when(files.metadata(2,OwnedBusinessModels.PhotoSlot.LOADING_LIST,fileId)).thenThrow(new BizException(ResultCode.CONFLICT,"本机模拟元数据暂不可读")).thenReturn(uploaded.get(fileId)); + var body=new OutsourcedBusinessModels.BindPhoto("0","out-bind-failure-key",uploadedPhoto.fileId()); + assertThrows(BizException.class,()->outsourced.bind(actor(),"DS-OUT-2",slot,body)); + assertEquals("0",outsourced.detail(actor(),"DS-OUT-2").row().recordVersion()); + var bound=outsourced.bind(actor(),"DS-OUT-2",slot,body);assertEquals(uploadedPhoto.fileId(),bound.photos().get(0).fileId()); + verify(files,times(1)).upload(anyLong(),any(),anyString(),any()); + } + /** 完整摘要严格匹配,同前缀末位、大小写和尾空格均不能误命中。 */ + @Test void queryHashComparesCompleteValueRatherThanDefaultBinaryWidth() { + String prefix="a".repeat(63),exact=prefix+"1"; + baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_completion_hash=? WHERE id=1",exact); + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact); + var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null); + assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total()); + for(String different:List.of(prefix+"2","A"+exact.substring(1),prefix+" ")) { + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",different); + var result=queries.list(OwnedBusinessFixtures.actor(),query);assertEquals(0,result.total());assertTrue(result.records().isEmpty()); + } + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET loading_completion_hash=? WHERE dispatch_id=1",exact); + assertEquals(1,queries.list(OwnedBusinessFixtures.actor(),query).total()); + } + /** 历史投影统一字符比较规则,完整保留中文、原尾空格、动作和原操作账号。 */ + @Test void historyCharacterProjectionPreservesOriginalTextAndSnapshot() { + // 原测试装配的原生历史采用较短替身列,这里只在隔离H2对齐实际原表上限。 + baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN operator_id VARCHAR(128)"); + baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN reason VARCHAR(2000)"); + baseline.jdbc.execute("ALTER TABLE ll_dispatch_history ALTER COLUMN action VARCHAR(40)"); + String nativeReason="原排车完整中文历史🌏"+"中".repeat(1200)+" ",operationReason="整车承运登记完整中文说明🌏 ",operator="o".repeat(127)+"X",nativeAction="A".repeat(39)+"X"; + baseline.jdbc.update("UPDATE ll_dispatch_history SET reason=?,operator_id=?,action=? WHERE dispatch_id=2",nativeReason,operator,nativeAction); + var saved=outsourced.save(actor(),"DS-OUT-2",new OutsourcedBusinessModels.SaveOperations("0","history-character-original", + new OutsourcedBusinessModels.Operations(new OwnedBusinessModels.Money("CNY","30"),List.of()),operationReason)); + var history=outsourced.history(actor(),"DS-OUT-2",1,20);assertEquals(2,history.total()); + var original=history.records().stream().filter(h->h.eventSource().equals("DISPATCH")).findFirst().orElseThrow(); + var operations=history.records().stream().filter(h->h.eventSource().equals("OPERATIONS")).findFirst().orElseThrow(); + assertEquals(nativeReason,original.reason());assertEquals(nativeAction,original.action()); + assertEquals(operationReason,operations.reason());assertEquals("SAVE_OPERATIONS",operations.action()); + assertEquals(saved.row().operations(),operations.operations());assertEquals("TEST外配操作员",operations.operatorName()); + var raw=baseline.repository.history(2,0,20).stream().filter(h->h.getEventSource().equals("DISPATCH")).findFirst().orElseThrow(); + assertEquals(operator,raw.getOperatorId()); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 910d83b73..e43e900e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -12,20 +12,23 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class FleetTripAuthorizationTest { - @Test void calendarRequiresTripReadPermissionThroughMethodSecurity() { + /** 日历按现行排车只读权限放行,预约和旧车次权限不能替代。 */ + @Test void calendarRequiresDispatchReadPermissionThroughMethodSecurity() { try (var context = new AnnotationConfigApplicationContext(Config.class)) { var controller = context.getBean(PlatformFleetTripController.class); var calendar = context.getBean(FleetTripCalendarService.class); var from = java.time.Instant.parse("2026-10-01T00:00:00Z"); var to = from.plusSeconds(86400); var user = new com.baiyue.common.security.AuthenticatedUser(1L, 0L, 0L, 1L, - "platform", "PLATFORM", "WEB", java.util.Set.of("platform:fleet:trip:list")); - SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( - user, "unused", List.of(new SimpleGrantedAuthority("platform:appointment:list")))); - assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class); + "platform", "PLATFORM", "WEB", java.util.Set.of("platform:overseas-warehouse:dispatch:list")); + for (var permission : List.of("platform:appointment:list", "platform:fleet:trip:list")) { + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + user, "unused", List.of(new SimpleGrantedAuthority(permission)))); + assertThatThrownBy(() -> controller.calendar(user, from, to)).isInstanceOf(AccessDeniedException.class); + } verifyNoInteractions(calendar); SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( - user, "unused", List.of(new SimpleGrantedAuthority("platform:fleet:trip:list")))); + user, "unused", List.of(new SimpleGrantedAuthority("platform:overseas-warehouse:dispatch:list")))); controller.calendar(user, from, to); verify(calendar).calendar(from, to); } finally { SecurityContextHolder.clearContext(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java index d22b4b544..0d500f4a7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchPersistenceTest.java @@ -26,6 +26,23 @@ import static org.mockito.Mockito.*; /** 真实MyBatis、批次行锁、逐客户分配与回滚;原批次和来源读取边界由当前DB事实供给。 */ class AppointmentWorkDispatchPersistenceTest { + /** 自动预留托盘与已实收托盘分开取数,防止有预约的在途货物被零实收数量挡住。 */ + @Test void reservedPalletKeepsPlannedQuantityInBatchReferencesUntilReceivingStarts() { + jdbc.update("UPDATE ll_inbound_plan_pallet SET assignment_status='RESERVED',actual_package_count=0 WHERE id=111"); + jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=0 WHERE pallet_id=111"); + var physical = List.of(new AppointmentWorkDispatchPort.CandidatePhysical("101",111L)); + assertEquals("PLAN", mapper.candidateSources(physical).get(0).sourceStage()); + assertEquals("PLAN", mapper.candidates(1L).stream().filter(row -> row.planPalletId()==111).findFirst().orElseThrow().sourceStage()); + var planned = candidates.batchReferences(physical).get(physical.get(0)); + assertTrue(planned.blockReasons().isEmpty()); + assertEquals(80, planned.appointmentBatches().get(0).sources().get(0).packageCount()); + jdbc.update("UPDATE ll_inbound_plan_pallet SET actual_package_count=1 WHERE id=111"); + assertEquals("RECEIVED", mapper.candidateSources(physical).get(0).sourceStage()); + assertFalse(candidates.batchReferences(physical).get(physical.get(0)).blockReasons().isEmpty()); + jdbc.update("UPDATE ll_inbound_plan_pallet SET assignment_status='ASSIGNED',actual_package_count=80 WHERE id=111"); + jdbc.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=80 WHERE pallet_id=111"); + assertTrue(candidates.batchReferences(physical).get(physical.get(0)).blockReasons().isEmpty()); + } private JdbcTemplate jdbc; private TransactionTemplate tx; private AppointmentWorkDispatchPort binding; @@ -46,7 +63,7 @@ class AppointmentWorkDispatchPersistenceTest { jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(40),version INT,replacement_keys_json VARCHAR(200))"); jdbc.execute("CREATE TABLE ll_inbound_fba_appointment_item(appointment_id BIGINT,order_id BIGINT,customer_id VARCHAR(40),source_key VARCHAR(60),fba_warehouse_code VARCHAR(40),package_count INT,active INT)"); jdbc.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,plan_no VARCHAR(40))"); - jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT)"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT PRIMARY KEY,pallet_no VARCHAR(40),plan_id BIGINT,customer_id VARCHAR(40),warehouse_pallet_id BIGINT,assignment_status VARCHAR DEFAULT 'ASSIGNED',actual_package_count INT DEFAULT 1)"); jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,order_id BIGINT,customer_id VARCHAR(40),destination_type VARCHAR(30),fba_warehouse_code VARCHAR(40))"); jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)"); jdbc.execute("CREATE TABLE ll_dispatch_pallet_lock(id BIGINT PRIMARY KEY,plan_pallet_id BIGINT,warehouse_pallet_id BIGINT,status VARCHAR(30),dispatch_order_id BIGINT,expires_at TIMESTAMP)"); @@ -67,7 +84,7 @@ class AppointmentWorkDispatchPersistenceTest { /** 插入真实客户、托盘和逐票数量,不从请求猜测。 */ private void seed(long item,String customer,long order,int count,String destination) { jdbc.update("INSERT INTO ll_dispatch_item VALUES(?,1,?,?,'RECEIVED',?)",item,customer,item+100,count); - jdbc.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); + jdbc.update("INSERT INTO ll_inbound_plan_pallet(id,pallet_no,plan_id,customer_id,warehouse_pallet_id) VALUES(?,?,1,?,?)",item+100,"P-"+item,customer,item+1000); jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,?,?, 'ONT8')",item,order,customer,destination); jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,?,?)",item+100,item,customer,count,count); } diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index 578a127a9..7dc76b0b9 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -56,6 +56,11 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels", + "target": "com.baiyue.lastleg.dispatch.port.DispatchViews", + "reason": "外配装车后视图组合原排车公开只读详情,复用同一履约事实,不读取实体或写主单。" + }, { "source": "com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels", "target": "com.baiyue.lastleg.dispatch.port.DispatchViews", diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 940e5d7c9..6afa6837f 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -15,6 +15,24 @@ const http = (path, source, target, contexts, access, idempotency, note) => ({ }) export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('firstleg/src/main/java/com/baiyue/firstleg/client/AppointmentWorkRouteClient.java', + 'firstleg-service', 'lastleg-service', ['REQUEST'], 'READ', 'SAFE_READ', + '直送路线守卫经内部令牌读取原预约路线事实,GET无远端业务写入,不接管预约创建'), + feign('firstleg/src/main/java/com/baiyue/firstleg/workflow/LastlegWorkflowClient.java', + 'firstleg-service', 'lastleg-service', ['REQUEST'], 'READ', 'SAFE_READ', + '工作流页按精确来源批量只读尾程事实,POST仅查询,不跨库或写尾程状态'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkFirstlegClient.java', + 'lastleg-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ', 'SAFE_READ', + '请求及周期同步读取原头程容器和来源事实,GET/批量POST均为远端只读,本地同步写入不等于写Firstleg'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkOwnerClient.java', + 'lastleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', + '预约负责人校验只读IAM有效平台成员目录,内部令牌保护,不修改账号角色或预约'), + http('integration-service/src/main/java/com/baiyue/integration/provider/lingxing/LingxingQuantityHttpSink.java', + 'integration-service', 'lastleg-service', ['BACKGROUND'], 'READ_WRITE', 'MIXED', + '有界诊断观察worker读取状态并记录观测/停止状态,不接触供应商;请求和响应有界、拒绝重定向,不自动重试或假定写入可免费重放'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/discard/gateway/HttpCargoDiscardPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '废弃照片按客户、业务类别和原任务核对File归属,上传无远端幂等键且不自动重传,元数据及内容只读'), feign('lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java', 'lastleg-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '预约影响刷新按柜次批量读取头程不可变船期事实;POST仅查询,刷新写入仅在Lastleg本域,不写Firstleg'), diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 968657de1..d063e1224 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -26,6 +26,7 @@ const expectedOnlineEdges = [ 'finance-service->warehouse-service', 'firstleg-service->file-service', 'firstleg-service->iam-service', + 'firstleg-service->lastleg-service', 'firstleg-service->order-service', 'firstleg-service->print-service', 'firstleg-service->warehouse-service', @@ -107,7 +108,7 @@ test('all Feign clients have reviewed context, access and idempotency metadata', const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') .map(({ path, source, target }) => ({ path, source, target })) .sort((left, right) => left.path.localeCompare(right.path)) - assert.equal(reviewed.length, 77) + assert.equal(reviewed.length, 81) assert.deepEqual(await discoverFeignEdges(), reviewed) }) @@ -123,7 +124,7 @@ test('all raw HTTP transports have reviewed context, access and idempotency meta } const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') .map(client => client.path).sort() - assert.equal(reviewed.length, 30) + assert.equal(reviewed.length, 32) assert.deepEqual(discovered.sort(), reviewed) }) @@ -278,6 +279,10 @@ test('known user entries expose every longest simple REQUEST path over the two-h 'iam-service->warehouse-service' ] }, 'order-service': { hops: 6, paths: [ + 'order-service->finance-service->firstleg-service->lastleg-service->file-service->iam-service->auth-service', + 'order-service->finance-service->firstleg-service->lastleg-service->file-service->iam-service->warehouse-service', + 'order-service->firstleg-service->lastleg-service->finance-service->file-service->iam-service->auth-service', + 'order-service->firstleg-service->lastleg-service->finance-service->file-service->iam-service->warehouse-service', 'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->auth-service', 'order-service->lastleg-service->finance-service->firstleg-service->file-service->iam-service->warehouse-service' ] }, From af5cf1e3bd11a57a05273102287c92c9ca9a6c78 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 20 Oct 2026 23:49:55 +0800 Subject: [PATCH 045/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/number/DailyDocumentNumber.java | 44 +++++++++ .../mapper/DocumentSequenceMapper.java | 21 +++++ .../DocumentSequenceRepository.java | 24 +++++ .../FinanceDocumentNumberGenerator.java | 49 +++------- .../service/FinanceSettlementService.java | 4 +- .../service/ContainerCustomsService.java | 14 +-- .../coload/service/ColoadGroupService.java | 4 +- .../mapper/DocumentSequenceMapper.java | 21 +++++ .../service/PortScheduleService.java | 4 +- .../DocumentSequenceRepository.java | 24 +++++ .../service/SailingCalendarService.java | 10 +- .../FirstlegDocumentNumberGenerator.java | 70 +++++--------- .../firstleg/service/SmartLoadingService.java | 39 ++++---- .../service/SmartLoadingServiceTest.java | 39 +++++--- .../controller/SmartDispatchController.java | 5 +- .../mapper/DispatchCandidatePageMapper.java | 59 ++++++++++++ .../dispatch/port/DispatchPlanningModels.java | 7 +- .../repository/DispatchSourceRepository.java | 7 ++ .../service/DispatchAggregateService.java | 10 +- .../service/DispatchPdaQueryService.java | 36 ++++++++ .../dispatch/service/DispatchService.java | 32 +++---- .../service/ReturnRelabelDispatchStock.java | 8 +- .../mapper/DocumentSequenceMapper.java | 21 +++++ .../DocumentSequenceRepository.java | 24 +++++ .../LastlegDocumentNumberGenerator.java | 34 +++++++ .../SmartDispatchControllerTest.java | 6 +- .../DispatchMergedRelabelPersistenceTest.java | 4 +- .../DispatchPlanPdaReadPersistenceTest.java | 91 +++++++++++++++++++ .../dispatch/service/DispatchServiceTest.java | 14 ++- .../order/mapper/DocumentSequenceMapper.java | 21 +++++ .../DocumentSequenceRepository.java | 24 +++++ .../service/OrderApplicationService.java | 8 +- .../service/OrderDocumentNumberGenerator.java | 34 +++++++ .../service/DomesticContainerService.java | 7 +- .../mapper/DocumentSequenceMapper.java | 21 +++++ .../DocumentSequenceRepository.java | 24 +++++ .../WarehouseDocumentNumberGenerator.java | 34 +++++++ 37 files changed, 714 insertions(+), 184 deletions(-) create mode 100644 common-core/src/main/java/com/baiyue/common/core/number/DailyDocumentNumber.java create mode 100644 finance/src/main/java/com/baiyue/finance/mapper/DocumentSequenceMapper.java create mode 100644 finance/src/main/java/com/baiyue/finance/repository/DocumentSequenceRepository.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/mapper/DocumentSequenceMapper.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/repository/DocumentSequenceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DocumentSequenceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/DocumentSequenceRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java create mode 100644 order/src/main/java/com/baiyue/order/mapper/DocumentSequenceMapper.java create mode 100644 order/src/main/java/com/baiyue/order/repository/DocumentSequenceRepository.java create mode 100644 order/src/main/java/com/baiyue/order/service/OrderDocumentNumberGenerator.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/mapper/DocumentSequenceMapper.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/repository/DocumentSequenceRepository.java create mode 100644 warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGenerator.java diff --git a/common-core/src/main/java/com/baiyue/common/core/number/DailyDocumentNumber.java b/common-core/src/main/java/com/baiyue/common/core/number/DailyDocumentNumber.java new file mode 100644 index 000000000..4d7936b7d --- /dev/null +++ b/common-core/src/main/java/com/baiyue/common/core/number/DailyDocumentNumber.java @@ -0,0 +1,44 @@ +package com.baiyue.common.core.number; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import java.time.Clock; +import java.time.LocalDate; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.util.Locale; +import java.util.function.ToLongFunction; + +/** 统一业务单号格式;序列仍由各服务自己的 Repository/Mapper 分配。 */ +public final class DailyDocumentNumber { + public static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); + + /** 工具类不允许实例化。 */ + private DailyDocumentNumber() {} + + /** 先固定北京时间业务日,再使用前缀和日期分配跨客户、跨仓库日流水。 */ + public static String next(String prefix, Clock clock, ToLongFunction sequences) { + requirePrefix(prefix); + LocalDate date = LocalDate.now(clock.withZone(BUSINESS_ZONE)); + String dateText = date.format(DateTimeFormatter.BASIC_ISO_DATE); + long value = sequences.applyAsLong(prefix + ":" + dateText); + return format(prefix, date, value); + } + + /** 严格四位流水,不允许溢出后产生五位号码或退回随机编号。 */ + public static String format(String prefix, LocalDate date, long value) { + requirePrefix(prefix); + if (value < 1 || value > 9999) { + throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据四位流水号已用尽或分配失败"); + } + return prefix + date.format(DateTimeFormatter.BASIC_ISO_DATE) + + String.format(Locale.ROOT, "%04d", value); + } + + /** 业务类型只能使用两至四位大写字母。 */ + private static void requirePrefix(String prefix) { + if (prefix == null || !prefix.matches("[A-Z]{2,4}")) { + throw new IllegalArgumentException("业务单据前缀必须为2至4位大写字母"); + } + } +} diff --git a/finance/src/main/java/com/baiyue/finance/mapper/DocumentSequenceMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..eb3096d29 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO fin_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/DocumentSequenceRepository.java b/finance/src/main/java/com/baiyue/finance/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..de1424ddc --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java index d9557200c..81baa3ffd 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceDocumentNumberGenerator.java @@ -1,55 +1,34 @@ package com.baiyue.finance.service; +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.finance.repository.DocumentSequenceRepository; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - import java.time.Clock; -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Locale; import java.util.Set; +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ @Component public class FinanceDocumentNumberGenerator { - private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); - private static final Set SUPPORTED_PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP"); - private static final long MAX_DAILY_SEQUENCE = 9_999L; - - private final JdbcTemplate jdbc; + private static final Set PREFIXES = Set.of("REC", "AR", "AP", "RC", "AC", "VC", "VP", "PAY"); + private final DocumentSequenceRepository sequences; private final Clock clock; + /** 正式运行使用统一业务时区。 */ @Autowired - public FinanceDocumentNumberGenerator(JdbcTemplate jdbc) { - this(jdbc, Clock.system(BUSINESS_ZONE)); + public FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); } - FinanceDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) { - this.jdbc = jdbc; + /** 固定时钟用于跨日及前缀独立性验证。 */ + FinanceDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; this.clock = clock; } - @Transactional(propagation = Propagation.MANDATORY) + /** 只为本服务已登记的业务类型分配号码。 */ public String next(String prefix) { - if (!SUPPORTED_PREFIXES.contains(prefix)) { - throw new IllegalArgumentException("unsupported finance document prefix: " + prefix); - } - LocalDate sequenceDate = LocalDate.now(clock); - String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate); - jdbc.update( - "INSERT INTO fin_document_sequence(sequence_key,current_value) " + - "VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " + - "current_value=LAST_INSERT_ID(current_value+1)", - sequenceKey); - Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); - if (sequence == null || sequence <= 0 || sequence > MAX_DAILY_SEQUENCE) { - throw new FinanceEventException("FIN_409", "当日" + prefix + "单据编号已用尽"); - } - return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" + - String.format(Locale.ROOT, "%04d", sequence); + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); } } diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 2038ebd45..0d75a804f 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -521,9 +521,9 @@ public class FinanceSettlementService { "outstandingAmount", decimal(settlement, "outstanding_amount"), "status", status); } + /** 结算收付款使用独立 PAY 日流水,重试由原业务幂等记录恢复。 */ private String transactionNo() { - return "PAY-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + - UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(Locale.ROOT); + return documentNumbers.next("PAY"); } private String hash(Object value) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index 2425bf67c..a2580c35f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -29,6 +29,8 @@ import java.util.stream.Collectors; @Service public class ContainerCustomsService { + @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; + public static final String WAITING = "WAITING_CUSTOMER_DOCUMENTS"; public static final String READY = "READY_TO_START"; public static final String DECLARING = "DECLARING"; @@ -197,7 +199,7 @@ public class ContainerCustomsService { throw conflict("报关资料尚未全部冻结,不能发送给报关行"); } long submissionId = IdWorker.getId(); - String batchNo = brokerBatchNo(planId); + String batchNo = documentNumbers.next("BR"); String snapshot = brokerSubmissionSnapshot(container.id()); if (mapper.insertBrokerSubmission(submissionId, container.id(), planId, batchNo, container.version(), snapshot, operatorId, operatorName) != 1) throw conflict("首次发送报关行批次创建失败"); @@ -372,7 +374,7 @@ public class ContainerCustomsService { if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); long batchId = IdWorker.getId(); String batchNo = trim(request.batchNo()); - if (batchNo == null) batchNo = "DECL-" + batchId; + if (batchNo == null) batchNo = documentNumbers.next("DECL"); String snapshot = declarationSnapshot(container.id()); if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot, trim(request.remark())) != 1) throw conflict("正式申报批次创建失败"); @@ -501,14 +503,6 @@ public class ContainerCustomsService { catch (JsonProcessingException ex) { throw new IllegalStateException(ex); } } - private String brokerBatchNo(long planId) { - PlanRow plan = requiredPlan(planId); - String planNo = trim(plan.planNo()); - if (planNo != null && planNo.matches("[A-Za-z0-9_-]{1,52}")) return "BR-" + planNo + "-01"; - String suffix = UUID.randomUUID().toString().replace("-", "").substring(0, 8).toUpperCase(Locale.ROOT); - return "BR-" + LocalDate.now() + "-" + suffix; - } - private String brokerSubmissionSnapshot(long containerId) { List> snapshot = mapper.documents(containerId, null).stream().map(document -> { List scopes = mapper.frozenScopes(document.id()); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java index b5b452245..9db82153a 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java @@ -38,6 +38,8 @@ import java.util.Set; /** 同柜不可拆组的唯一状态机与平台编排服务。 */ @Service public class ColoadGroupService implements ColoadGroupAssignmentPort { + @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; + private static final Set CURRENT_GROUP_STATUSES = Set.of("ACTIVE", "LOCKED", "CONSUMED"); private static final Set PRE_LOADING_GROUP_STATUSES = Set.of( "PREPARING", "WAIT_MEASURE", "MEASURING", "MEASURE_PENDING", "MEASURE_REJECTED", @@ -64,7 +66,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { validateSameRoute(candidates); Long lockedPlanId = validateAllocationState(candidates); long groupId = IdWorker.getId(); - String groupNo = "CLG-" + groupId; + String groupNo = documentNumbers.next("CLG"); InboundCandidateRow first = candidates.get(0); mapper.insertGroup(groupId, groupNo, first.getWarehouseId(), first.getChannelCode().trim(), operatorId, trim(operatorName)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/DocumentSequenceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..8e3af23f7 --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO fl_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java index b92d51b5c..353b0bf6f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/PortScheduleService.java @@ -32,6 +32,8 @@ import java.util.Set; @Service public class PortScheduleService { + @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; + private static final Set SOURCE_TYPES = Set.of("MANUAL", "CARRIER", "TERMINAL", "API"); @Resource private PortScheduleRepository repository; @@ -72,7 +74,7 @@ public class PortScheduleService { validateLinks(request.data()); PortScheduleEntity value = new PortScheduleEntity(); value.setId(IdWorker.getId()); - value.setScheduleNo("PS" + value.getId()); + value.setScheduleNo(documentNumbers.next("PS")); copy(value, request.data()); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/DocumentSequenceRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..cf85ab15d --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java index 425510c4f..e4a78e61c 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarService.java @@ -40,6 +40,8 @@ import static com.baiyue.firstleg.sailingcalendar.dto.SailingCalendarDtos.*; @Service public class SailingCalendarService { + @jakarta.annotation.Resource private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers; + private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); private static final String OPEN = "OPEN"; @Resource private SailingCalendarRepository repository; @@ -77,7 +79,7 @@ public class SailingCalendarService { if (version(request.expectedVersion()) != 0) throw bad("新建模板expectedVersion必须为0"); SailingChannelEntity channel = resolvePriceBookChannel(request.data().channelId(), request.data().warehouseId(), null); validateTemplate(request.data(), channel); if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建模板"); - SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo("ST-" + value.getId()); + SailingTemplateEntity value = new SailingTemplateEntity(); value.setId(IdWorker.getId()); value.setTemplateNo(documentNumbers.next("ST")); copy(value, request.data(), channel); value.setStatus("ACTIVE"); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); repository.insert(value); return value; @@ -310,7 +312,7 @@ public class SailingCalendarService { if (existing != null) return isCopyOf(existing, copyReason) ? detail(existing.getId()) : throwCopyConflict(request.etdAt(), source.getVoyageNo()); - SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(source.getTemplateId()); value.setChannelId(source.getChannelId()); value.setChannelCodeSnapshot(source.getChannelCodeSnapshot()); value.setChannelNameSnapshot(source.getChannelNameSnapshot()); value.setWarehouseId(source.getWarehouseId()); value.setWarehouseNameSnapshot(source.getWarehouseNameSnapshot()); value.setDeparturePortCode(source.getDeparturePortCode()); value.setDestinationPortCode(source.getDestinationPortCode()); value.setVesselName(source.getVesselName()); value.setVoyageNo(source.getVoyageNo()); value.setEtdAt(request.etdAt()); value.setWarehouseCutoffAt(request.cutoffAt()); value.setEtaAt(copyEta(source, request.etdAt())); value.setStatus("OPEN"); value.setSourceType("COPY"); value.setPlannedCapacityCbm(source.getPlannedCapacityCbm()); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); try { repository.insert(value); } catch (DuplicateKeyException ex) { SailingEntity duplicate = repository.sailingByBusinessKey(source.getWarehouseId(), source.getChannelCodeSnapshot(), source.getDeparturePortCode(), source.getDestinationPortCode(), request.etdAt(), source.getVoyageNo()); @@ -362,7 +364,7 @@ public class SailingCalendarService { if (version(request.expectedVersion()) != 0) throw bad("新建船期expectedVersion必须为0"); validateSailing(request.data()); SailingChannelEntity channel = resolveChannel(request.data()); if (!"ACTIVE".equals(channel.getStatus())) throw conflict("停用渠道不能创建船期"); - SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); copy(value, request.data(), channel); value.setStatus(OPEN); value.setSourceType(normalizeSource(request.data().sourceType())); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); value.setVersion(0); now(value); try { repository.insert(value); } catch (DuplicateKeyException ex) { throw conflict("船期业务唯一键冲突"); } @@ -398,7 +400,7 @@ public class SailingCalendarService { validateTemplateSnapshot(template); List result = new java.util.ArrayList<>(); for (GenerationPreview preview : generationPreview(templateId, from, to)) if (!preview.conflict()) { - SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo("SL-" + value.getId()); value.setTemplateId(templateId); + SailingEntity value = new SailingEntity(); value.setId(IdWorker.getId()); value.setSailingNo(documentNumbers.next("SL")); value.setTemplateId(templateId); value.setChannelId(channel.getId()); value.setChannelCodeSnapshot(channel.getChannelCode()); value.setChannelNameSnapshot(channel.getChannelName()); value.setWarehouseId(template.getWarehouseId()); value.setWarehouseNameSnapshot(template.getWarehouseNameSnapshot()); value.setDeparturePortCode(template.getDeparturePortCode()); value.setDestinationPortCode(template.getDestinationPortCode()); value.setVesselName(template.getVesselName()); value.setVoyageNo(preview.voyageNo()); value.setEtdAt(preview.etdAt()); value.setWarehouseCutoffAt(preview.cutoffAt()); value.setStatus(OPEN); value.setSourceType("TEMPLATE"); value.setGeneratedKey(key(template, preview.etdAt(), preview.voyageNo())); value.setVersion(0); value.setCreatedBy(operatorId); value.setCreatedByName(operatorName); value.setUpdatedBy(operatorId); value.setUpdatedByName(operatorName); now(value); repository.insert(value); recordHistory(value.getId(), "GENERATE", null, OPEN, "按固定周模板生成", operatorId, operatorName, value.getCreatedAt()); result.add(value); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java index 6fc7b24c8..86dc00a76 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGenerator.java @@ -1,71 +1,47 @@ package com.baiyue.firstleg.service; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.firstleg.repository.DocumentSequenceRepository; import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; - import java.time.Clock; -import java.time.LocalDate; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Locale; import java.util.Set; +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ @Component public class FirstlegDocumentNumberGenerator { - private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); - private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd", Locale.ROOT); - private static final Set SUPPORTED_PREFIXES = Set.of("OB", "TA", "LP", "DP"); - private static final long MAX_DAILY_SEQUENCE = 9_999L; - - private final JdbcTemplate jdbc; + private static final Set PREFIXES = Set.of("OB", "TA", "LP", "DECL", "BR", "PS", "SL", "ST", "CLG"); + private final DocumentSequenceRepository sequences; private final Clock clock; + /** 正式运行使用统一业务时区。 */ @Autowired - public FirstlegDocumentNumberGenerator(JdbcTemplate jdbc) { - this(jdbc, Clock.system(BUSINESS_ZONE)); + public FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); } - FirstlegDocumentNumberGenerator(JdbcTemplate jdbc, Clock clock) { - this.jdbc = jdbc; + /** 固定时钟用于跨日及前缀独立性验证。 */ + FirstlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; this.clock = clock; } - @Transactional(propagation = Propagation.MANDATORY) + /** 只为本服务已登记的业务类型分配号码。 */ public String next(String prefix) { - if (!SUPPORTED_PREFIXES.contains(prefix)) { - throw new IllegalArgumentException("unsupported firstleg document prefix: " + prefix); + // 实物标签未纳入本次改号,DP 保留原六位日期及横杠。 + if ("DP".equals(prefix)) { + java.time.LocalDate date = java.time.LocalDate.now(clock.withZone(DailyDocumentNumber.BUSINESS_ZONE)); + long value = sequences.next("DP:" + date.format(java.time.format.DateTimeFormatter.BASIC_ISO_DATE)); + DailyDocumentNumber.format("DP", date, value); + return "DP-" + date.format(java.time.format.DateTimeFormatter.ofPattern("yyMMdd")) + + "-" + String.format(java.util.Locale.ROOT, "%04d", value); } - LocalDate sequenceDate = LocalDate.now(clock); - String sequenceKey = prefix + ":" + DateTimeFormatter.BASIC_ISO_DATE.format(sequenceDate); - long sequence = nextSequence(sequenceKey); - if (sequence > MAX_DAILY_SEQUENCE) { - throw new BizException(ResultCode.CONFLICT, "当日" + prefix + "单据编号已用尽"); - } - return prefix + "-" + DATE_FORMAT.format(sequenceDate) + "-" + - String.format(Locale.ROOT, "%04d", sequence); + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); } - /** 平台柜次号是长期业务身份,使用不按日期重置的 D 序号。 */ - @Transactional(propagation = Propagation.MANDATORY) + /** 柜次号保持现有全局连续规则,不按日期重置,也不限制为四位。 */ public String nextCabinetNo() { - return "D" + String.format(Locale.ROOT, "%04d", nextSequence("CABINET")); - } - - private long nextSequence(String sequenceKey) { - jdbc.update( - "INSERT INTO fl_document_sequence(sequence_key,current_value) " + - "VALUES(?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE " + - "current_value=LAST_INSERT_ID(current_value+1)", - sequenceKey); - Long sequence = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); - if (sequence == null || sequence <= 0) { - throw new BizException(ResultCode.CONFLICT, "业务编号生成失败"); - } - return sequence; + return "D" + String.format(java.util.Locale.ROOT, "%04d", sequences.next("CABINET")); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java index 5b9879d81..4fbe848b9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/SmartLoadingService.java @@ -91,12 +91,13 @@ public class SmartLoadingService { } return repository.candidates(warehouseId); } + /** 按柜次渠道查询候选,关联订舱客户快照不限制其他客户的货物。 */ public List candidates(long bookingId, long warehouseId) { ContainerBookingEntity booking = requireBooking(bookingId); if (!Objects.equals(booking.getWarehouseId(), warehouseId)) throw validation("柜次仓库与候选仓库不一致"); if (!StringUtils.hasText(booking.getChannelGroupCode())) throw validation("柜次缺少计划渠道,请重新创建模拟柜"); List values = candidates(warehouseId).stream() - .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); + .filter(candidate -> sameChannel(booking, candidate)).toList(); return enrichColoadCandidates(booking, repository.planByBooking(bookingId), values); } @@ -108,10 +109,11 @@ public class SmartLoadingService { return uniqueCandidateUnits(grouped.values().stream().flatMap(details -> inboundCandidates(details).stream()).toList()); } + /** 推荐保留渠道和显式筛选范围,允许同柜包含多个客户。 */ private List recommendationCandidates(ContainerBookingEntity booking, Long customerId, String inboundCode) { List values = repository.candidates(null).stream() - .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)) + .filter(candidate -> sameChannel(booking, candidate)) .filter(candidate -> customerId == null || Objects.equals(customerId, candidate.accountId())) .filter(candidate -> !StringUtils.hasText(inboundCode) || inboundCode.trim().equals(candidate.inboundCode())) @@ -513,6 +515,7 @@ public class SmartLoadingService { return view(assigned, requireBooking(assigned.getContainerBookingId())); } + /** 原子新增明细或整托组成,使用货物自身客户归属而非订舱客户快照。 */ private Plan assign(long planId, AssignRequest request, long operatorId, String operatorName, boolean wholeGroupAssignment, Long palletId, String palletNo, Integer actualSkuQuantity, boolean primaryPalletLine, Candidate frozenCandidate, @@ -535,7 +538,6 @@ public class SmartLoadingService { } inboundHoldGuard.requireInboundNotHeld(candidate.accountId(), candidate.inboundId()); if (!wholeGroupAssignment) requireStandalone(candidate.accountId(), candidate.inboundId()); - requireSameCustomerScope(booking, candidate); var task = loadingPickService.mutationSnapshot(planId); if (task.taskId() != null && "LOADING".equals(task.taskStatus())) { splitPreloadService.ensureForRunningLoadingAllocation(candidate.accountId(), candidate.inboundId(), @@ -604,10 +606,7 @@ public class SmartLoadingService { return assignInbound(planId, request, operatorId, operatorName, false, null, true); } - /** - * Reserves declared cartons in a cabinet before order review. Review status is not an allocation - * gate; confirmation/PDA remains blocked until the resulting inbound reaches WAIT_SPLIT. - */ + /** 待审核订单可跨客户占位;权威订单归属和后续审核测量门禁仍必须满足。 */ @Transactional @BusinessAudit(event = "SMART_LOADING_PENDING_REVIEW_ASSIGN", resourceType = "LOADING_PLAN", resourceId = "#planId", description = "待审核订单加入装柜清单") @@ -652,7 +651,6 @@ public class SmartLoadingService { for (InboundAllocation allocation : allocations) { if (allocation.packageCount() == 0) continue; Candidate candidate = allocation.candidate(); - requireSameCustomerScope(booking, candidate); checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true); OrderCargoClient.CargoBinding cargo = declared.stream() .map(DeclaredCargo::cargo) @@ -722,7 +720,7 @@ public class SmartLoadingService { LoadingPlanEntity plan = editing(planId, request.expectedVersion(), operatorId, operatorName); ContainerBookingEntity booking = requireBooking(plan.getContainerBookingId()); List details = frozenDetails == null - ? manualCandidates(booking).stream() + ? manualCandidates().stream() .filter(candidate -> Objects.equals(candidate.inboundId(), request.inboundId())).toList() : List.copyOf(frozenDetails); if (details.isEmpty()) throw conflict("入仓码不存在、已分配完或尚未完成入库"); @@ -756,6 +754,7 @@ public class SmartLoadingService { return view(assigned, booking); } + /** 按入仓单自己的客户和申报事实建立占位,不以订舱客户限制拼柜。 */ private Plan assignDeclaredInbound(long planId, AssignInboundRequest request, InboundOrderEntity projection, String operation, String requestHash, long operatorId, String operatorName) { @@ -786,7 +785,6 @@ public class SmartLoadingService { for (InboundAllocation allocation : allocations) { if (allocation.packageCount() == 0) continue; Candidate candidate = allocation.candidate(); - requireSameCustomerScope(booking, candidate); checkRouteAndCapacity(booking, current, candidate, allocation.packageCount(), true); OrderCargoClient.CargoBinding cargo = declared.stream().map(DeclaredCargo::cargo) .filter(value -> Objects.equals(value.cargoId(), candidate.inboundDetailId())) @@ -1007,6 +1005,7 @@ public class SmartLoadingService { @Transactional @BusinessAudit(event = "SMART_LOADING_ITEM_ADJUST", resourceType = "LOADING_PLAN_ITEM", resourceId = "#itemId", description = "调整智能装柜明细") + /** 调整本计划明细箱数,保留原客户归属、可用量、拣货和容量校验。 */ public Plan adjust(long planId, long itemId, AdjustRequest request, long operatorId, String operatorName) { String operation = operation(request.operationId()); String requestHash = sha256("ADJUST|" + planId + '|' + itemId + '|' @@ -1053,7 +1052,6 @@ public class SmartLoadingService { if (request.packageCount() > maxPackageCount) { throw validation("调整箱数超过可用箱数"); } - requireSameCustomerScope(booking, candidate); List others = new ArrayList<>(repository.items(planId)); others.removeIf(x -> Objects.equals(x.getId(), itemId)); checkRouteAndCapacity(booking, others, candidate, request.packageCount(), true); @@ -1360,7 +1358,7 @@ public class SmartLoadingService { if (!"ACTIVE".equals(group.status()) || group.lockedPlanId() != null) { throw conflict("同柜不可拆组已锁定或已消费"); } - List available = manualCandidates(booking); + List available = manualCandidates(); Map> result = new LinkedHashMap<>(); for (Member member : group.members()) { if (!repository.activeItemsByInbound(member.tenantId(), member.inboundId()).isEmpty()) { @@ -1382,14 +1380,15 @@ public class SmartLoadingService { return result; } + /** 整组推荐可跨客户,但仍按柜次渠道选择候选。 */ private List availableCandidates(ContainerBookingEntity booking) { return repository.candidates(null).stream() - .filter(candidate -> sameChannel(booking, candidate) && sameCustomerScope(booking, candidate)).toList(); + .filter(candidate -> sameChannel(booking, candidate)).toList(); } - private List manualCandidates(ContainerBookingEntity booking) { - return repository.candidates(null).stream() - .filter(candidate -> sameCustomerScope(booking, candidate)).toList(); + /** 平台人工分配可拼装多个客户,明细始终保留各自的真实客户归属。 */ + private List manualCandidates() { + return repository.candidates(null); } private boolean groupFits(ContainerBookingEntity booking, List planItems, @@ -1807,12 +1806,6 @@ public class SmartLoadingService { return StringUtils.hasText(booking.getChannelGroupCode()) && candidateGroup != null && booking.getChannelGroupCode().equals(candidateGroup.code()); } - private static boolean sameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { - return booking.getCustomerId() == null || Objects.equals(booking.getCustomerId(), candidate.accountId()); - } - private static void requireSameCustomerScope(ContainerBookingEntity booking, Candidate candidate) { - if (!sameCustomerScope(booking, candidate)) throw forbidden(); - } /** * A draft may legitimately contain pre-review or pre-measurement members without a fee snapshot. * The pricing port reports that as NOT_READY, while any sent/customer-confirmed member fails and @@ -1911,5 +1904,5 @@ public class SmartLoadingService { } private static String trim(String x){return x==null?null:x.trim();} private static BigDecimal unit(BigDecimal x){return x==null?BigDecimal.ZERO:x.max(BigDecimal.ZERO);} - private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} private static BizException forbidden(){return new BizException(ResultCode.FORBIDDEN,"客户归属不匹配");} + private static void requireAccount(long x){if(x<=0)throw new BizException(ResultCode.BAD_REQUEST,"accountId必须大于零");} private static BizException conflict(String x){return new BizException(ResultCode.CONFLICT,x);} private static BizException validation(String x){return new BizException(ResultCode.VALIDATION_FAILED,x);} } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java index 33232b39c..d822c7242 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/SmartLoadingServiceTest.java @@ -462,8 +462,9 @@ class SmartLoadingServiceTest { verify(repository).occupiedOrderIds(eq(query), anySet()); } + /** 待审核订单来自其他客户时仍可拼柜,客户归属和费用重算维持原值。 */ @Test - void manuallyAssignsCrossChannelPendingReviewOrderAsAProvisionalContainerAllocation() { + void manuallyAssignsCrossCustomerAndChannelPendingReviewOrderAsAProvisionalContainerAllocation() { long customerId = 2092797167699816450L; long orderId = 2095052002489335810L; String inboundCode = "EXXHK2609020361"; @@ -503,6 +504,7 @@ class SmartLoadingServiceTest { assertThat(result.version()).isEqualTo(1); ArgumentCaptor item = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); verify(repository).insert(item.capture()); + assertThat(item.getValue().getAccountId()).isEqualTo(customerId); assertThat(item.getValue().getInboundId()).isEqualTo(10L); assertThat(item.getValue().getInboundDetailId()).isEqualTo(20L); assertThat(item.getValue().getPackageCount()).isEqualTo(5); @@ -1378,20 +1380,31 @@ class SmartLoadingServiceTest { verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); } + /** 订舱客户仅为关联资料;平台可将其他客户的货物加入同一柜次。 */ @Test - void rejectsManualAssignmentOutsideLinkedBookingCustomerScope() { - LoadingPlanEntity plan = plan("EDITING", 0); - ContainerBookingEntity linked = booking(); - linked.setCustomerId(88L); - when(repository.plan(1L)).thenReturn(plan); - when(repository.booking(99L)).thenReturn(linked); - when(repository.candidate(1L, 10L, 20L)).thenReturn(candidate(5)); + void allowsManualAssignmentFromAnotherCustomerWithoutRewritingCargoOwnership() { + stubAssignment(); - BizException error = catchThrowableOfType(() -> service.assign(1L, - new AssignRequest(10L, 20L, 1, 0, "assign-customer-mismatch"), 7L, "u"), BizException.class); + Plan result = service.assign(1L, + new AssignRequest(10L, 20L, 1, 0, "assign-cross-customer"), 7L, "u"); - assertThat(error.getResultCode()).isEqualTo(ResultCode.FORBIDDEN); - verify(repository, never()).touch(anyLong(), anyInt(), anyLong(), any()); + assertThat(result.version()).isEqualTo(1); + ArgumentCaptor inserted = ArgumentCaptor.forClass(LoadingPlanItemEntity.class); + verify(repository).insert(inserted.capture()); + assertThat(inserted.getValue().getAccountId()).isEqualTo(9L); + assertThat(inserted.getValue().getOrderId()).isEqualTo(30L); + verify(cargoClient).detail(anyString(), eq(9L), eq(30L), eq(20L)); + } + + /** 同渠道候选包含不同客户,不能被订舱客户快照裁剪。 */ + @Test + void listsCandidatesFromBothCustomersInALinkedBooking() { + when(repository.booking(99L)).thenReturn(booking()); + when(repository.candidates(100L)).thenReturn(List.of( + candidate(9L, 10L, 20L, 30L, 5), candidate(88L, 11L, 21L, 31L, 4))); + + assertThat(service.candidates(99L, 100L)).extracting(Candidate::accountId) + .containsExactly(9L, 88L); } @Test @@ -2075,9 +2088,11 @@ class SmartLoadingServiceTest { return group; } + /** 所有流程默认带历史订舱客户,覆盖候选、占位、整托、调整和推荐的跨客户拼柜。 */ private static ContainerBookingEntity booking() { ContainerBookingEntity value = new ContainerBookingEntity(); value.setId(99L); value.setWarehouseId(100L); value.setDestinationWarehouseCode("FBA1"); + value.setCustomerId(88L); value.setChannelGroupCode("MATSON_CLX"); value.setChannelCode(null); value.setContainerNo("MSCU1234567"); value.setCapacityCbm(new BigDecimal("60")); value.setMaxWeightKg(new BigDecimal("20000")); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index 17af0bea5..41a988862 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -28,8 +28,9 @@ public class SmartDispatchController { @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId, @RequestParam(required=false)List planIds,@RequestParam(required=false)String orderNo, - @RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken) { - return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken))); + @RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken, + @RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) { + return ApiResponse.success(aggregate.candidates(DispatchActorFactory.platform(user),new CandidateQuery(warehouseId,planIds,orderNo,destinationWarehouseCode,sourceStage,draftToken,page,size,warehouseTab))); } /** 只列出当前拥有者未失效草稿。 */ @GetMapping("/drafts") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java new file mode 100644 index 000000000..45db9281c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java @@ -0,0 +1,59 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery; +import org.apache.ibatis.annotations.*; +import java.util.*; +import java.util.stream.Collectors; + +/** 四种来源共用数据库分页;页外来源不进入测量、完整组和预约的逐组装配。 */ +@Mapper +public interface DispatchCandidatePageMapper { + /** 统计相同筛选下的来源行,不把当前页条数当全仓总数。 */ + @SelectProvider(type=Sql.class,method="count") + long count(@Param("query")CandidateQuery query); + /** 稳定排序后在数据库限页,完整组可包含页外成员。 */ + @SelectProvider(type=Sql.class,method="page") + List page(@Param("query")CandidateQuery query,@Param("offset")long offset); + + /** 复用既有资格SQL,只统一各来源投影的列序,不复制资格或客户隔离规则。 */ + final class Sql { + private static final String FIELDS="planId,planPalletId,warehousePalletId,orderId,customerId,customerName,planNo,orderType,volumeWeightKg,grossWeightKg,containerNo,appointmentAt,expectedLoadingAt,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,packageCount,volumeCbm,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptId,returnReceiptNo,returnCartonSource"; + private static final Set PLAN_MISSING=Set.of("returnReceiptId","returnReceiptNo","returnCartonSource"); + private static final Set RETURN_FIELDS=Set.of(("returnReceiptId,returnReceiptNo,orderId,customerId,customerName,orderType,orderNo,orderRefs,inboundCode,warehousePalletId,palletNo,overseasWarehouseId,overseasWarehouseName,overseasWarehouseCode,destinationType,destinationWarehouseCode,fbaWarehouseCode,destinationWarehouseName,deliveryAddress,postalCode,contactName,contactPhone,sourceStage,packageCount").split(",")); + private static final Set NATIVE_FIELDS=Set.of(("customerId,orderId,overseasWarehouseId,overseasWarehouseName,orderType,orderNo,orderRefs,inboundCode,palletNo,packageCount,sourceStage,returnCartonSource,relabelCartonMarks,fbaWarehouseCode,grossWeightKg,volumeCbm,destinationWarehouseCode").split(",")); + private static final String BINDINGS="" + +"" + +"" + +""; + /** 来源字段缺省仅表示该来源没有此字段,不伪造业务编号或实托身份。 */ + private static String project(String body,Set fields) { + return "SELECT "+Arrays.stream(FIELDS.split(",")).map(field->fields.contains(field)?"s."+field: + ("returnCartonSource".equals(field)?"0":"NULL")+" AS "+field).collect(Collectors.joining(",")) + +" FROM ("+body+") s"; + } + /** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */ + private static String rows() { + var planFields=new HashSet<>(Arrays.asList(FIELDS.split(",")));planFields.removeAll(PLAN_MISSING); + return ""+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields) + +"SELECT "+Arrays.stream(FIELDS.split(",")).map(f->"NULL AS "+f).collect(Collectors.joining(","))+" WHERE 1=0" + +"" + +" UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS)+"" + +" UNION ALL " + +project(DispatchCandidateMapper.RELABEL_BODY,planFields)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS)+""; + } + /** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */ + private static String filtered() { + return " FROM ("+rows()+") candidates WHERE overseasWarehouseId=#{query.warehouseId}" + +" AND fbaWarehouseCode=#{query.destinationWarehouseCode}" + +" AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'" + +" AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'"; + } + /** 同一事务内统计分页全集。 */ + public static String count(){return "";} + /** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */ + public static String page(){return "";} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java index 0ee629c41..9e961f9f9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchPlanningModels.java @@ -12,7 +12,8 @@ public final class DispatchPlanningModels { private DispatchPlanningModels() { } public record CandidateQuery(String warehouseId, List planIds, String orderNo, - String destinationWarehouseCode, String sourceStage, String draftToken) { } + String destinationWarehouseCode, String sourceStage, String draftToken, + int page, int size, String warehouseTab) { } /** sourceVersion为服务端事实摘要,不能用客户端时间或固定零替代。 */ public record Candidate(DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion, @@ -36,7 +37,9 @@ public final class DispatchPlanningModels { public record SelectionGroupPallet(Long warehousePalletId, String palletNo, List sourceKeys, long packageCount) { } - public record CandidateList(List candidates, Instant snapshotAt, boolean complete) { } + /** complete只在当前响应包含筛选全集时为true;组内complete独立保证跨页完整来源。 */ + public record CandidateList(List candidates, Instant snapshotAt, boolean complete, + long total, int page, int size, long pages) { } public record DraftContainer(Long draftContainerId, int sequenceNo, int capacity, List sourceKeys) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 0e90dcd0b..3009cb008 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -11,6 +11,13 @@ import java.util.List; /** 真实货物来源的数据访问封装,不在此层决定阶段或审核。 */ @Repository public class DispatchSourceRepository { + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper pages; + /** 筛选全集的数据库行数,与分页使用同一只读事务。 */ + public long candidateCount(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){return pages.count(query);} + /** 只返回当前页来源,禁止先全量装配再截取。 */ + public List candidatePage(com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query){ + return pages.page(query,Math.multiplyExact((long)query.page()-1,query.size())); + } private final DispatchCandidateMapper plans; private final ReturnDispatchCandidateMapper returns; private final DispatchSourceMapper source; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 4dfa88e83..bebb00435 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -148,10 +148,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA requirePlatform(actor); if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓"); if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法"); + if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法"); var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), - trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken())); + trim(query.destinationWarehouseCode()),trim(query.sourceStage()),trim(query.draftToken()),query.page(),query.size(),query.warehouseTab()); long started=System.nanoTime(); - var rows=sourceRules.wholeCandidates(actor.operatorId(),normalized); + var page=sourceRules.candidatePage(actor.operatorId(),normalized); + var rows=page.rows(); long candidatesRead=System.nanoTime(); var groups=sourceRules.selectionGroups(rows); long groupsRead=System.nanoTime(); @@ -192,7 +194,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA "dispatch_candidates_slow count={} candidatesMs={} groupsMs={} appointmentsMs={} assembleMs={} totalMs={}", rows.size(),(candidatesRead-started)/1_000_000L,(groupsRead-candidatesRead)/1_000_000L, (batchesRead-groupsRead)/1_000_000L,(finished-batchesRead)/1_000_000L,(finished-started)/1_000_000L); - return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS),true); + return new DispatchPlanningModels.CandidateList(List.copyOf(result),Instant.now().truncatedTo(ChronoUnit.MILLIS), + normalized.page()==1 && page.total()<=normalized.size(),page.total(),normalized.page(),normalized.size(), + (page.total()+normalized.size()-1)/normalized.size()); } /** 作业弹框读取真实明细ID和来源,不能从订单展示键或仓托ID推断删除身份。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index add8a5eae..5a6dc34b1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -16,6 +16,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; @Service public class DispatchPdaQueryService { @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources; + @jakarta.annotation.Resource private DispatchService sourceRules; private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow; /** 共用主流程资格,PDA不维护第二套版本和阶段。 */ public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;} @@ -59,6 +61,8 @@ public class DispatchPdaQueryService { var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet()); var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream() .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); + if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null) + sourcePositions=receivedPlanPositions(actor,item,items); if(normalRelabel(item)) { var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList(); if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false; @@ -89,6 +93,38 @@ public class DispatchPdaQueryService { } return List.copyOf(result); } + /** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */ + private List receivedPlanPositions(Actor actor,DispatchItem item,List items) { + if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of(); + var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(rows.size()!=1)return List.of(); + var actual=rows.get(0); + if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null + || !Objects.equals(actual.customerId(),item.getCustomerId()) + || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) + || !Objects.equals(actual.packageCount(),item.getPackageCount()) + || !Objects.equals(actual.destinationType(),item.getDestinationType()) + || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) + || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of(); + var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); + if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll( + sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of(); + // 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。 + var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection( + SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); + if(!group.complete() || group.sources().isEmpty())return List.of(); + for(var member:group.sources()) { + var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); + if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()) + || n(selected.getPackageCount())!=member.packages())return List.of(); + } + var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); + if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId()) + || !Objects.equals(p.palletId(),actual.warehousePalletId())) + || stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of(); + return stock.stream().filter(p->p.availablePackages()>0) + .map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList(); + } /** 仓和操作者均来自受信身份,不接收请求自报。 */ private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} /** 缺失计数不作为业务标识使用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index ff2f8b705..09aa03622 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -212,32 +212,22 @@ public class DispatchService { public record WholeSource(DispatchPalletCandidate candidate, com.baiyue.lastleg.dispatch.port.DispatchCommands.CargoSelection selection, String sourceKey, String sourceVersion) { } - /** 新公开候选一次读取同仓多客户;草稿排除只能使用当前操作者实际拥有的草稿。 */ - public List wholeCandidates(String operator, - com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery query) { + /** 总数与当前页同事务读取;页外候选不参与逐组装配。 */ + public record CandidatePage(List rows,long total) { } + + /** 草稿所有权先验证,数据库分页后才读取测量和原生退货物理身份。 */ + public CandidatePage candidatePage(String operator,CandidateQuery query) { validateSourceStage(query.sourceStage()); if(query.draftToken()!=null && drafts.findActiveByOperator(operator).stream() .noneMatch(d -> query.draftToken().equals(d.getDraftToken()))) throw new ResponseStatusException(HttpStatus.FORBIDDEN,"不能读取其他操作者的草稿来源"); - var result=new java.util.ArrayList(); - if(!"RELABEL".equals(query.sourceStage())) result.addAll(wholeSources.availablePlans(query)); - if(query.planIds()==null || query.planIds().isEmpty()) { - if(planIdsForReturns(query.sourceStage())) result.addAll(wholeSources.availableReturns(query)); - if(query.sourceStage()==null || Set.of("RECEIVED","RELABEL").contains(query.sourceStage())) { - var rows=wholeSources.availableRelabel(query); - if(rows.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); - result.addAll(measuredRelabelCandidates(rows)); - result.addAll(returnRelabelStock.wholeCandidates(query.warehouseId(),query.orderNo())); - } - } - if(result.size()>1000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"候选来源超过1000项,请缩小筛选范围"); - return result.stream().filter(row -> query.destinationWarehouseCode()==null - || query.destinationWarehouseCode().equals(row.fbaWarehouseCode())) - .sorted(java.util.Comparator.comparing((DispatchPalletCandidate row) -> Objects.toString(row.customerId(),"")) - .thenComparing(row -> Objects.toString(row.orderNo(),"")) - .thenComparing(row -> Objects.toString(row.palletNo(),""))).toList(); + long total=wholeSources.candidateCount(query); + var rows=wholeSources.candidatePage(query); + measuredRelabelCandidates(rows.stream().filter(row->"RELABEL".equals(row.sourceStage()) && !row.returnCartonSource()).toList()); + var nativeRows=rows.stream().filter(DispatchPalletCandidate::returnCartonSource).toList(); + if(!nativeRows.isEmpty())returnRelabelStock.resolvePage(nativeRows); + return new CandidatePage(List.copyOf(rows),total); } - /** 新主流程按来源真客户解析并先锁全部来源订单,最后锁库存;调用者已持整车父锁。 */ @Transactional(propagation = org.springframework.transaction.annotation.Propagation.MANDATORY) public List lockWholeSources(String warehouseId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java index 2b7517eab..3813a7cb1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/ReturnRelabelDispatchStock.java @@ -27,11 +27,9 @@ public class ReturnRelabelDispatchStock { return resolve(candidates.nativeReturnRelabelAvailable(customer,orderNo,relabelNo)); } - /** 整车候选只解析已经按仓限定且有界的原生退货记录。 */ - public List wholeCandidates(String warehouse,String orderNo) { - var rows=candidates.wholeNativeRelabelAvailable(warehouse,null,orderNo,null); - if(rows.size()>1000) throw conflict("候选来源超过1000项,请缩小筛选范围"); - return resolve(rows); + /** 分页后仅核实本页原生退货实托;身份失效明确拒绝,不能静默缩页并返回错误总数。 */ + public void resolvePage(List rows) { + if(resolve(rows).size()!=rows.size())throw conflict("当前页退货托盘的实际仓或状态已变化,请核对后重查"); } /** 物理托盘身份必须由原提供者核实,不能用显示托盘码伪造ID。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DocumentSequenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..2c688a046 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO ll_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DocumentSequenceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..f745c1240 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java new file mode 100644 index 000000000..95a9a594f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java @@ -0,0 +1,34 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.lastleg.repository.DocumentSequenceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.util.Set; + +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ +@Component +public class LastlegDocumentNumberGenerator { + private static final Set PREFIXES = Set.of("IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR"); + private final DocumentSequenceRepository sequences; + private final Clock clock; + + /** 正式运行使用统一业务时区。 */ + @Autowired + public LastlegDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); + } + + /** 固定时钟用于跨日及前缀独立性验证。 */ + LastlegDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; + this.clock = clock; + } + + /** 只为本服务已登记的业务类型分配号码。 */ + public String next(String prefix) { + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index be757d18e..80849d595 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -33,8 +33,8 @@ class SmartDispatchControllerTest { /** 候选仍委托实际主流程,保留仓库及全部真实筛选。 */ @Test void candidatesKeepWarehouseMultiCustomerFilters(){ - controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1"); - verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1")); + controller.candidates(platform(),"92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX"); + verify(aggregate).candidates(actor,new CandidateQuery("92",List.of(1L,2L),"LMO-1","ONT8","PLAN","draft-1",2,10,"LAX")); verifyNoInteractions(planning); } /** HTTP实际参数采用destinationWarehouseCode,不能因旧名称使前端筛选静默丢失。 */ @@ -43,7 +43,7 @@ class SmartDispatchControllerTest { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of())); mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseId","92").param("destinationWarehouseCode","LAX9")) .andExpect(status().isOk()); - verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null));verifyNoInteractions(planning); + verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null,1,20,"ALL"));verifyNoInteractions(planning); } /** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */ @Test void allDraftRoutesDelegateFrozenVersionedRequests(){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index 9a84ac56d..3b3606d0d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -161,7 +161,7 @@ class DispatchMergedRelabelPersistenceTest { "source_inbound_order_id",7201,"source_inbound_order_no","ORIGIN-MISSING","source_inbound_code","CODE-MISSING", "destination_fba_warehouse_code","ONT8"); f.application(203,"RL-UNRELATED","101",9003,304,103,1,"UNRELATED"); - var candidates=f.rules.wholeCandidates("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null)); + var candidates=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,20,"ALL")).rows(); var groups=f.rules.selectionGroups(candidates); assertThat(candidates).hasSize(2).allSatisfy(row->assertThat(row.warehousePalletId()).isEqualTo(501L)); var group=groups.get("RELABEL:RL-A:501"); @@ -200,7 +200,9 @@ class DispatchMergedRelabelPersistenceTest { } var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json); var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json); + f.session.getConfiguration().addMapper(DispatchCandidatePageMapper.class); var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class)); ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); ReflectionTestUtils.setField(rules,"relabelSources",provider);ReflectionTestUtils.setField(rules,"palletSerial",new PhysicalPalletSerialService(new PhysicalPalletSerialRepository(f.session.getMapper(PhysicalPalletSerialMapper.class)))); ReflectionTestUtils.setField(rules,"candidates",f.session.getMapper(DispatchCandidateMapper.class));ReflectionTestUtils.setField(rules,"relabelApplications",f.session.getMapper(RelabelApplicationMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java new file mode 100644 index 000000000..12932f0d1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -0,0 +1,91 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.Stock; +import com.baiyue.lastleg.dispatch.pda.mapper.DispatchWholePdaReadMapper; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */ +class DispatchPlanPdaReadPersistenceTest { + /** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */ + @Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception { + var f=new Fixture();var before=f.dbState(); + var task=f.read(); + assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L); + assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3); + assertThat(f.dbState()).isEqualTo(before); + verify(f.sources).stock("101",501L,false); + verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any()); + verify(f.rules,never()).lockExecutionSources(anyString(),anyList()); + } + /** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */ + @Test void unreceivedOrChangedPlanHasNoPositions() throws Exception { + var f=new Fixture();var row=f.f.candidates.get(1L); + row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty(); + row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty(); + row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty(); + row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty(); + verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); + } + /** 原计划不能吸收新的订单或未经原车审核的同托来源。 */ + @Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception { + var f=new Fixture(); + when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L)); + assertThat(f.read().items().get(0).positions()).isEmpty(); + when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); + when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( + "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( + null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of())); + assertThat(f.read().items().get(0).positions()).isEmpty(); + verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); + } + /** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */ + @Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception { + var f=new Fixture(); + for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1), + new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) { + when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock)); + assertThat(f.read().items().get(0).positions()).isEmpty(); + } + } + /** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */ + private static final class Fixture { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchSourceRepository sources=mock(DispatchSourceRepository.class); + final DispatchService rules=mock(DispatchService.class); + final DispatchPdaQueryService queries;final String number; + /** 原车已审核,只清除自然收货尚未补写的实托字段。 */ + Fixture() throws Exception { + var selection=f.source(1,"101",501,"ONT8",3); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection)))); + f.approve(created);number=created.dispatchNo(); + f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'"); + f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet(); + queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); + ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules); + when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L))); + when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L)); + when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); + when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1))); + when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( + "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( + selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of())); + } + /** 读取正式PDA入口。 */ + DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));} + /** 比较持久化原明细与主单版本。 */ + Object dbState(){return List.of(f.db.queryForList("SELECT * FROM ll_dispatch_item"),f.db.queryForList("SELECT record_version FROM ll_dispatch_order"));} + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 10478e7c8..9962d2043 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -58,12 +58,16 @@ class DispatchServiceTest { verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial); } @Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){ - assertThatThrownBy(()->service.wholeCandidates("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign"))).hasMessageContaining("草稿");verifyNoInteractions(sources); + assertThatThrownBy(()->service.candidatePage("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign",1,20,"ALL"))).hasMessageContaining("草稿");verifyNoInteractions(sources); } - @Test void wholeCandidatesPreserveActualCustomersAndDoNotChooseAHeaderOwner(){ - var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null); - when(sources.availablePlans(query)).thenReturn(List.of(row(2,502,"102"),row(1,501,"101"))); - assertThat(service.wholeCandidates("operator",query)).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102"); + @Test void candidatePagePreservesDatabaseOrderAndDoesNotReadOtherRows(){ + var query=new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN",null,2,20,"ALL"); + when(sources.candidateCount(query)).thenReturn(55L); + when(sources.candidatePage(query)).thenReturn(List.of(row(1,501,"101"),row(2,502,"102"))); + var page=service.candidatePage("operator",query); + assertThat(page.total()).isEqualTo(55); + assertThat(page.rows()).extracting(DispatchPalletCandidate::customerId).containsExactly("101","102"); + verify(sources).candidateCount(query);verify(sources).candidatePage(query);verifyNoMoreInteractions(sources); } @Test void duplicateAndPartialRelabelSelectionsAreRejectedBeforeMutation(){ var app=new RelabelApplication();app.setCustomerId("101");app.setRelabelNo("RL-1");app.setStatus("READY_FOR_OUTBOUND");app.setDeliveryMethod("TRUCK");app.setFulfillmentStatus("WAIT_PRE_DISPATCH"); diff --git a/order/src/main/java/com/baiyue/order/mapper/DocumentSequenceMapper.java b/order/src/main/java/com/baiyue/order/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..cfcbe8714 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.order.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO ord_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/order/src/main/java/com/baiyue/order/repository/DocumentSequenceRepository.java b/order/src/main/java/com/baiyue/order/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..fab12d8b3 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java index 39ccc4a2e..845d253d4 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java +++ b/order/src/main/java/com/baiyue/order/service/OrderApplicationService.java @@ -44,6 +44,8 @@ import java.time.format.DateTimeFormatter; @Service public class OrderApplicationService { + @jakarta.annotation.Resource private OrderDocumentNumberGenerator documentNumbers; + private static final ZoneId INBOUND_CODE_ZONE = ZoneId.of("Asia/Shanghai"); private static final DateTimeFormatter INBOUND_CODE_DATE = DateTimeFormatter.ofPattern("yyMMdd"); @Resource private OrderRepository repository; @@ -83,6 +85,7 @@ public class OrderApplicationService { return orderId; } + /** 创建主订单时分配独立日流水,入仓码与后续拆单规则保持不变。 */ private Long createDraftInternal(long tenantId, long userId, String username, OrderRequests.SaveRequest request, boolean allowLegacyInboundCode) { AddressSnapshots addressSnapshots = resolveCustomerAddresses(tenantId, request); @@ -94,7 +97,7 @@ public class OrderApplicationService { request.destinationType()); OrderEntity order = new OrderEntity(); long orderId = IdWorker.getId(); - order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(nextNo(orderId)); + order.setId(orderId); order.setTenantId(tenantId); order.setOrderNo(documentNumbers.next("FL")); order.setStatus(OrderStatus.DRAFT); apply(order, tenantId, request, snapshots, addressSnapshots, allowLegacyInboundCode); order.setCreatedBy(userId); order.setUpdatedBy(userId); @@ -984,7 +987,4 @@ public class OrderApplicationService { private static String normalize(String value) { return StringUtils.hasText(value) ? value.trim() : null; } - private String nextNo(long orderId) { - return "FL" + orderId; - } } diff --git a/order/src/main/java/com/baiyue/order/service/OrderDocumentNumberGenerator.java b/order/src/main/java/com/baiyue/order/service/OrderDocumentNumberGenerator.java new file mode 100644 index 000000000..27a71a2b3 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/service/OrderDocumentNumberGenerator.java @@ -0,0 +1,34 @@ +package com.baiyue.order.service; + +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.order.repository.DocumentSequenceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.util.Set; + +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ +@Component +public class OrderDocumentNumberGenerator { + private static final Set PREFIXES = Set.of("FL"); + private final DocumentSequenceRepository sequences; + private final Clock clock; + + /** 正式运行使用统一业务时区。 */ + @Autowired + public OrderDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); + } + + /** 固定时钟用于跨日及前缀独立性验证。 */ + OrderDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; + this.clock = clock; + } + + /** 只为本服务已登记的业务类型分配号码。 */ + public String next(String prefix) { + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java index 2f692993d..6244a6eb0 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerService.java @@ -24,6 +24,8 @@ import java.util.List; @Service public class DomesticContainerService { + @jakarta.annotation.Resource private com.baiyue.warehouse.service.WarehouseDocumentNumberGenerator documentNumbers; + private static final long PLATFORM_TENANT = 0L; @Resource private DomesticContainerRepository repository; @Resource private WarehouseRepository warehouseRepository; @@ -57,7 +59,7 @@ public class DomesticContainerService { String container = normalize(request.containerNo()); DomesticContainerOrderEntity order = new DomesticContainerOrderEntity(); order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("INBOUND"); - order.setOrderNo(nextNo("ZGRK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); + order.setOrderNo(documentNumbers.next("ZGRK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); order.setContainerNo(container); order.setCargoName(request.cargoName().trim()); order.setBoxCount(request.boxCount()); order.setVolumeCbm(request.volumeCbm()); order.setOperationTime(request.inboundTime()); order.setStatus(request.status()); order.setRemark(request.remark()); if ("COMPLETED".equals(request.status())) { @@ -86,7 +88,7 @@ public class DomesticContainerService { if (request.boxCount() > inventory.getAvailableBoxes()) throw new BizException(ResultCode.CONFLICT, "出库箱数超过可用库存"); } DomesticContainerOrderEntity order = new DomesticContainerOrderEntity(); order.setId(IdWorker.getId()); order.setTenantId(PLATFORM_TENANT); order.setBusinessType("OUTBOUND"); - order.setOrderNo(nextNo("ZGCK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); order.setContainerNo(inventory.getContainerNo()); + order.setOrderNo(documentNumbers.next("ZGCK")); order.setWarehouseId(warehouse.getId()); order.setWarehouseName(warehouse.getWarehouseName()); order.setContainerNo(inventory.getContainerNo()); order.setCargoName(inventory.getCargoName()); order.setBoxCount(request.boxCount()); order.setVolumeCbm(BigDecimal.ZERO); order.setOperationTime(request.outboundTime()); order.setStatus(request.status()); order.setRemark(request.remark()); repository.insertOrder(order); if ("COMPLETED".equals(request.status())) { inventory.setOutboundBoxes(inventory.getOutboundBoxes()+order.getBoxCount()); inventory.setAvailableBoxes(inventory.getAvailableBoxes()-order.getBoxCount()); inventory.setInventoryStatus(status(inventory)); requireUpdated(repository.updateInventory(inventory)); transaction(inventory, order, "OUTBOUND"); } @@ -107,7 +109,6 @@ public class DomesticContainerService { private void requireUpdated(int count) { if (count != 1) throw new BizException(ResultCode.CONFLICT, "库存已被并发修改,请刷新后重试"); } private void requireClaimCompleted(int count) { if (count != 1) throw new BizException(ResultCode.CONFLICT, "幂等操作状态已变化,请使用原键重试"); } private DomesticContainerDtos.CreateResult claim(String type, String key, String hash) { try { if (repository.claim(IdWorker.getId(), type, key, hash) == 1) return null; } catch (DuplicateKeyException ignored) {} DomesticContainerIdempotencyEntity e = repository.findClaim(type, key); if (e == null || !hash.equals(e.getRequestHash())) throw new BizException(ResultCode.CONFLICT, "幂等键已用于其他请求"); if ("COMPLETED".equals(e.getOperationStatus())) return new DomesticContainerDtos.CreateResult(e.getResourceId(), e.getResourceNo(), true); throw new BizException(ResultCode.CONFLICT, "幂等请求正在处理中"); } - private String nextNo(String prefix) { return prefix + java.time.format.DateTimeFormatter.ofPattern("yyyyMMddHHmmssSSS").format(LocalDateTime.now()) + IdWorker.getIdStr().substring(10); } private static String normalize(String s) { return s.trim().toUpperCase(java.util.Locale.ROOT); } private static String hash(String value) { try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); } catch (Exception e) { throw new IllegalStateException(e); } } private PageResult mapOrders(PageResult p) { return new PageResult<>(p.records().stream().map(o -> new DomesticContainerDtos.OrderView(o.getId(),o.getBusinessType(),o.getOrderNo(),o.getWarehouseId(),o.getWarehouseName(),o.getContainerNo(),o.getCargoName(),o.getBoxCount(),o.getVolumeCbm(),o.getOperationTime(),o.getStatus(),o.getRemark(),o.getVersion(),o.getCreatedAt())).toList(),p.page(),p.size(),p.total(),p.pages()); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/DocumentSequenceMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/DocumentSequenceMapper.java new file mode 100644 index 000000000..8f19686f3 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/DocumentSequenceMapper.java @@ -0,0 +1,21 @@ +package com.baiyue.warehouse.mapper; + +import org.apache.ibatis.annotations.Insert; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; + +/** 本服务业务编号序列,更新与读取必须处于同一事务连接。 */ +@Mapper +public interface DocumentSequenceMapper { + /** 唯一序列键原子递增,并在连接内保存本次分配值。 */ + @Insert("INSERT INTO wh_document_sequence(sequence_key,current_value) VALUES(#{key},LAST_INSERT_ID(1)) " + + "ON DUPLICATE KEY UPDATE current_value=LAST_INSERT_ID(current_value+1)") + int allocate(@Param("key") String key); + + /** 禁用一级和二级缓存,返回本事务刚刚分配的值。 */ + @Select("SELECT LAST_INSERT_ID()") + @Options(useCache=false, flushCache=Options.FlushCachePolicy.TRUE) + Long allocatedValue(); +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/DocumentSequenceRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/DocumentSequenceRepository.java new file mode 100644 index 000000000..16eb5d000 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/DocumentSequenceRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.repository; + +import com.baiyue.warehouse.mapper.DocumentSequenceMapper; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +/** 各业务共用本库序列,独立短事务避免持有编号锁进入业务资源锁。 */ +@Repository +public class DocumentSequenceRepository { + private final DocumentSequenceMapper mapper; + + /** 只访问所属服务的编号表。 */ + public DocumentSequenceRepository(DocumentSequenceMapper mapper) { this.mapper = mapper; } + + /** 已分配号不因外层业务回滚而复用,允许失败请求产生断号。 */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public long next(String key) { + mapper.allocate(key); + Long value = mapper.allocatedValue(); + if (value == null || value < 1) throw new IllegalStateException("业务编号分配失败"); + return value; + } +} diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGenerator.java b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGenerator.java new file mode 100644 index 000000000..95c0d2559 --- /dev/null +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGenerator.java @@ -0,0 +1,34 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.number.DailyDocumentNumber; +import com.baiyue.warehouse.repository.DocumentSequenceRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import java.time.Clock; +import java.util.Set; + +/** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ +@Component +public class WarehouseDocumentNumberGenerator { + private static final Set PREFIXES = Set.of("ZGRK", "ZGCK"); + private final DocumentSequenceRepository sequences; + private final Clock clock; + + /** 正式运行使用统一业务时区。 */ + @Autowired + public WarehouseDocumentNumberGenerator(DocumentSequenceRepository sequences) { + this(sequences, Clock.system(DailyDocumentNumber.BUSINESS_ZONE)); + } + + /** 固定时钟用于跨日及前缀独立性验证。 */ + WarehouseDocumentNumberGenerator(DocumentSequenceRepository sequences, Clock clock) { + this.sequences = sequences; + this.clock = clock; + } + + /** 只为本服务已登记的业务类型分配号码。 */ + public String next(String prefix) { + if (!PREFIXES.contains(prefix)) throw new IllegalArgumentException("未登记的业务编号前缀:" + prefix); + return DailyDocumentNumber.next(prefix, clock, sequences::next); + } +} From f00d0e16cfca7f8f0ee6b701f0333736b33c5b84 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 07:53:31 +0800 Subject: [PATCH 046/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../core/number/DailyDocumentNumberTest.java | 45 +++++++ deploy/sql/README.md | 10 +- deploy/sql/头程数据库统一增量-MySQL8.sql | 28 +++- deploy/sql/海外区域统一增量-MySQL8.sql | 44 ++---- finance/AGENTS.md | 2 +- .../service/FinanceSettlementService.java | 7 +- .../ReconciliationStatementService.java | 6 +- finance/src/main/resources/db/finance.sql | 6 +- .../FinanceSettlementBehaviorTest.java | 5 +- .../ReconciliationStatementBehaviorTest.java | 46 +++---- .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../FinanceDocumentNumberGeneratorTest.java | 85 ++++-------- firstleg/docs/business-index.md | 4 +- .../container/dto/ContainerCustomsDtos.java | 1 - .../service/ContainerCustomsService.java | 14 +- .../db/phase3-smart-loading-schema.sql | 4 +- .../main/resources/openapi/customs-v1.yaml | 2 +- .../main/resources/openapi/firstleg-v1.yaml | 15 ++- .../resources/openapi/loading-lists-v1.yaml | 4 +- .../resources/openapi/ocean-bookings-v1.yaml | 2 +- .../openapi/truck-appointments-v1.yaml | 4 +- .../InboundMeasurementReviewContractTest.java | 9 +- .../ContainerCustomsServiceTest.java | 64 ++++++++- .../coload/ColoadGroupServiceTest.java | 11 +- .../service/ArrivalCalendarTest.java | 1 + .../service/PortScheduleServiceTest.java | 2 + .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../Phase3FinalUnifiedSqlContractTest.java | 2 +- .../service/SailingCalendarServiceTest.java | 2 + .../FirstlegDocumentNumberGeneratorTest.java | 95 +++++-------- .../firstleg/service/TestDocumentNumbers.java | 21 +++ ...astleg-internal-modularization-baseline.md | 8 +- .../service/DeliveryTransferService.java | 13 +- .../service/DispatchDeliveryService.java | 4 +- .../service/CargoDiscardOutboundService.java | 2 +- .../discard/service/CargoDiscardService.java | 2 +- .../mapper/DispatchCandidateMapper.java | 5 +- .../mapper/DispatchCandidatePageMapper.java | 30 +++-- .../service/DispatchAggregateService.java | 5 +- .../service/DispatchPdaQueryService.java | 5 +- .../service/ExceptionOrderService.java | 3 +- .../service/ReturnCartonReturnService.java | 2 +- .../fleet/business/FleetPoolOrderBridge.java | 6 +- .../FleetPoolOrderNumberGenerator.java | 23 ---- .../fleet/maintenance/VehicleCareService.java | 5 +- .../fleet/trip/FleetBookingService.java | 5 +- .../fleet/trip/FleetCapacityOfferService.java | 5 +- .../lastleg/fleet/trip/FleetTripService.java | 6 +- .../gateway/HttpWarehouseResourceGateway.java | 18 ++- ...ainerInboundPlanConversionCoordinator.java | 2 +- .../service/InboundFbaAppointmentService.java | 10 +- .../service/InboundPlanService.java | 3 +- .../service/LocalLooseboardService.java | 4 +- .../LastlegOrderNoDailySequenceMapper.java | 19 --- .../entity/OverseasProcurementFee.java | 1 + .../mapper/OverseasProcurementFeeMapper.java | 3 + .../ProcurementFeeNumberRepository.java | 27 ++++ .../service/OverseasProcurementService.java | 5 +- .../service/ProcurementAdjustmentService.java | 8 +- .../service/ProcurementControlService.java | 5 +- .../service/ProcurementFinanceService.java | 18 +-- .../ProcurementOrderNumberGenerator.java | 48 ------- .../service/ProcurementPhysicalService.java | 5 +- .../service/ProcurementTransportService.java | 5 +- .../service/RelabelApplicationService.java | 10 +- .../service/ReturnInventoryService.java | 8 +- .../service/ReturnReceiptService.java | 13 +- .../service/CompactBusinessNumberService.java | 59 -------- .../service/LastlegOrderNoGenerator.java | 118 ++++------------ .../service/WarehouseTransferService.java | 5 +- .../trucking/service/UsTruckOrderService.java | 2 +- .../src/main/resources/db/lastleg.sql | 31 ++--- .../openapi/overseas-business-v1.yaml | 37 ++++- ...tchDeliveryArrangementPersistenceTest.java | 2 +- .../delivery/DispatchDeliveryServiceTest.java | 2 +- .../service/DeliveryTransferServiceTest.java | 2 +- .../service/ReturnOutboundClosureTest.java | 2 +- .../ReturnOutboundPdaPersistenceTest.java | 3 +- .../CargoDiscardOutboundServiceTest.java | 4 +- .../service/CargoDiscardServiceTest.java | 2 +- .../SmartDispatchOpenApiContractTest.java | 4 +- .../DispatchCandidateMySqlReadOnlyTest.java | 127 ++++++++++++++++++ .../DispatchAggregatePersistenceTest.java | 1 + .../DispatchDraftResourceLockTest.java | 2 + ...atchLoadingEligibilityPersistenceTest.java | 70 ++++++++++ .../DispatchMergedRelabelPersistenceTest.java | 56 +++++++- .../service/DispatchRepairWorkflowTest.java | 2 +- .../ReturnCartonReturnPersistenceTest.java | 4 +- .../business/FleetPoolOrderBridgeTest.java | 9 +- .../FleetPoolOrderNumberGeneratorTest.java | 21 --- .../VehicleCareAuthorizationTest.java | 4 + .../VehicleCarePersistenceTest.java | 1 + .../OwnedBusinessScopePersistenceTest.java | 2 +- .../trip/FleetCapacityOfferServiceTest.java | 1 + .../trip/FleetTripAuthorizationTest.java | 4 + .../HttpWarehouseResourceGatewayTest.java | 40 ++++++ ...rInboundPlanConversionCoordinatorTest.java | 8 +- .../FbaAppointmentPersistenceTest.java | 1 + .../InboundFbaAppointmentServiceTest.java | 3 +- .../service/InboundPlanServiceTest.java | 2 +- .../service/LocalLooseboardServiceTest.java | 5 +- ...ProcurementLifecycleAuthorizationTest.java | 4 + .../OverseasProcurementServiceTest.java | 4 +- .../ProcurementChargePersistenceTest.java | 3 + .../ProcurementControlPersistenceTest.java | 1 + .../ProcurementFinancePersistenceTest.java | 10 +- .../ProcurementOrderNumberGeneratorTest.java | 17 --- .../ProcurementPhysicalPersistenceTest.java | 1 + .../service/ProcurementQueryServiceTest.java | 2 + .../ProcurementTransportPersistenceTest.java | 2 + .../RelabelApplicationServiceTest.java | 8 +- .../service/ReturnRelabelDeliveryTest.java | 2 +- .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../service/ReturnInventoryServiceTest.java | 19 +-- .../service/ReturnReceiptServiceTest.java | 18 +-- .../query/DashboardSqlSemanticTest.java | 56 +++++++- .../CompactBusinessNumberServiceTest.java | 41 ------ .../LastlegDocumentNumberGeneratorTest.java | 41 ++++++ .../service/LastlegOrderNoGeneratorTest.java | 108 +++------------ .../service/LoadingCompletedConsumerTest.java | 8 +- .../service/PhysicalCartonNumberTest.java | 17 +++ .../service/ReturnBusinessNumberTest.java | 65 +++------ .../lastleg/service/TestDocumentNumbers.java | 23 ++++ .../service/WarehouseTransferServiceTest.java | 11 +- .../service/UsTruckOrderServiceTest.java | 2 +- .../lastleg-slice-dependencies.json | 5 + order/src/main/resources/db/order.sql | 8 ++ .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../MerchantOrderCommandTransactionTest.java | 1 + .../OrderApplicationServiceStateTest.java | 2 + .../OrderDocumentNumberGeneratorTest.java | 41 ++++++ .../service/OrderIdempotencyServiceTest.java | 2 + .../OrderRemarksSynchronizationTest.java | 1 + .../service/PlatformOrderPageServiceTest.java | 2 + .../service/TenantOrderCountServiceTest.java | 2 + .../TenantOrderStatisticsServiceTest.java | 2 + .../order/service/TestDocumentNumbers.java | 21 +++ .../phase3-container-customs.mjs | 2 +- .../phase5-root-tax-customs.mjs | 2 +- .../return-boundary-scenarios.mjs | 6 +- .../tailleg-owner-order-number-contract.mjs | 4 +- ...illeg-owner-order-number-contract.test.mjs | 26 ++-- warehouse/src/main/resources/db/warehouse.sql | 8 ++ .../service/DomesticContainerServiceTest.java | 10 +- .../DocumentSequenceRepositoryTest.java | 76 +++++++++++ .../service/TestDocumentNumbers.java | 21 +++ .../WarehouseDocumentNumberGeneratorTest.java | 41 ++++++ 147 files changed, 1696 insertions(+), 872 deletions(-) create mode 100644 common-core/src/test/java/com/baiyue/common/core/number/DailyDocumentNumberTest.java create mode 100644 finance/src/test/java/com/baiyue/finance/repository/DocumentSequenceRepositoryTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/DocumentSequenceRepositoryTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/service/TestDocumentNumbers.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMySqlReadOnlyTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/repository/DocumentSequenceRepositoryTest.java delete mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegDocumentNumberGeneratorTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/PhysicalCartonNumberTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/TestDocumentNumbers.java create mode 100644 order/src/test/java/com/baiyue/order/repository/DocumentSequenceRepositoryTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/OrderDocumentNumberGeneratorTest.java create mode 100644 order/src/test/java/com/baiyue/order/service/TestDocumentNumbers.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/repository/DocumentSequenceRepositoryTest.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/TestDocumentNumbers.java create mode 100644 warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGeneratorTest.java diff --git a/common-core/src/test/java/com/baiyue/common/core/number/DailyDocumentNumberTest.java b/common-core/src/test/java/com/baiyue/common/core/number/DailyDocumentNumberTest.java new file mode 100644 index 000000000..0baa3e1ef --- /dev/null +++ b/common-core/src/test/java/com/baiyue/common/core/number/DailyDocumentNumberTest.java @@ -0,0 +1,45 @@ +package com.baiyue.common.core.number; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import java.time.*; +import java.util.HashMap; +import java.util.Locale; +import java.util.concurrent.atomic.AtomicReference; +import static org.assertj.core.api.Assertions.*; + +class DailyDocumentNumberTest { + /** 午夜前后取号使用北京时间日期,与主机和 Clock 的时区无关。 */ + @Test void rollsAtShanghaiMidnightAndKeepsPrefixesIndependent() { + var values = new HashMap(); + java.util.function.ToLongFunction allocate = key -> values.merge(key, 1L, Long::sum); + var before = Clock.fixed(Instant.parse("2026-10-19T15:59:59Z"), ZoneOffset.UTC); + var after = Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"), ZoneId.of("America/Los_Angeles")); + assertThat(DailyDocumentNumber.next("FL", before, allocate)).isEqualTo("FL202610190001"); + assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200001"); + assertThat(DailyDocumentNumber.next("FL", after, allocate)).isEqualTo("FL202610200002"); + assertThat(DailyDocumentNumber.next("ZGRK", after, allocate)).isEqualTo("ZGRK202610200001"); + } + + /** 字符集和流水上限为硬边界,未知输入不能生成近似号码。 */ + @Test void rejectsInvalidPrefixAndSequence() { + for (String prefix : new String[]{"F", "ABCDE", "Fl", "F1", " FL", ""}) { + assertThatThrownBy(() -> DailyDocumentNumber.format(prefix, LocalDate.of(2026, 10, 20), 1)) + .isInstanceOf(IllegalArgumentException.class); + } + for (long value : new long[]{-1, 0, 10000}) { + assertThatThrownBy(() -> DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), value)) + .isInstanceOf(BizException.class); + } + assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 9999)).isEqualTo("FL202610209999"); + } + + /** 全局 Locale 不得让数字变为本地化字符。 */ + @Test void alwaysUsesAsciiDigits() { + Locale old = Locale.getDefault(); + try { + Locale.setDefault(Locale.forLanguageTag("ar")); + assertThat(DailyDocumentNumber.format("FL", LocalDate.of(2026, 10, 20), 1)).isEqualTo("FL202610200001"); + } finally { Locale.setDefault(old); } + } +} diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 37b406507..4da54370c 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -60,8 +60,8 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving | 区域 | 完整入口 | 服务 schema | 表数 | |---|---|---|---:| -| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 227 | -| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 228 | +| 国内 | `头程数据库统一增量-MySQL8.sql` | `baiyue_auth`、`baiyue_iam`、`baiyue_order`、`baiyue_warehouse`、`baiyue_firstleg`、`baiyue_file`、`baiyue_audit`、`baiyue_finance` | 229 | +| 海外 | `海外区域统一增量-MySQL8.sql` | `baiyue_lastleg`、`baiyue_lastlag_warehouse` | 234 | - 所有服务位于同一测试环境;两份文件只分组组织服务 schema,不代表两个部署区域。`baiyue_lastlag_warehouse` 保留当前正式的 `lastlag` 拼写。 @@ -84,7 +84,7 @@ V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving 5. DDL 隐式提交,不可整体回滚。失败后停止并保留错误;经另行授权准备空库后从头执行,不针对半初始化库追加迁移或从失败行续跑。 6. Firstleg 测量项与拆分项互为外键:建空表期间暂设会话 `foreign_key_checks=0`,随后立即恢复 `1`,再插入种子。失败时关闭连接。两边外键完整保留在 CREATE TABLE 内。 -成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=227/228`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 +成功时只在最末输出一行 `initialization_status=PASS`,并带 `region=CN/OVERSEAS`、`initialized_tables=229/234`。必须同时确认客户端退出码 0;忽略错误后出现的 PASS 不算成功。 ## 平台管理员密码 @@ -180,3 +180,7 @@ node --test scripts/admin-test-env-modules/initialization-baseline-contract.test 货物废弃通用库存与散箱字段迁移:`migrations/overseas/baiyue_lastleg/V25__cargo_discard_generic_inventory.sql`。新增库存仓位关联及箱唛快照,须先迁移再部署 Lastleg;不回填历史废弃单。 2026-10-16 拉取合并配套结构清单及执行边界见 [数据库变更汇总](20261016-数据库变更汇总.md),已有尾程库使用 `migrations/overseas/baiyue_lastleg/V28__merge_transfer_measurement_and_compact_numbers.sql`;空库初始化海外表数同步为 209。该增量可重复执行,尚未执行到任何实际数据库。 + +## 统一业务单号 + +编号规则见 `../../../docs/业务单据编号契约.md`。Order、Warehouse、Lastleg 空库基线新增各自的 `ord_document_sequence`、`wh_document_sequence`、`ll_document_sequence`;Firstleg 与 Finance 复用本库编号表。Lastleg 空库基线移除无生产调用的客户日序列、逻辑秒时钟及旧尾程日序列表,保留排车日序列。采购费用新增可空 `idempotency_key` 及订单/用途/请求键唯一约束,用于退款重放,业务号统一使用 PF。此处只准备基线,不执行 SQL、回填或删除测试存量。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 28f0e4a15..86c727afb 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -1981,11 +1981,11 @@ CREATE TABLE fl_customs_clearance_result ( ) ENGINE=InnoDB COMMENT='正式申报后的海关结果不可变事实'; CREATE TABLE fl_document_sequence ( - sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET', + sequence_key varchar(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET', current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fl_document_sequence_value CHECK ((current_value > 0)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留'; CREATE TABLE fl_fee_item ( id bigint unsigned NOT NULL COMMENT '费用项目ID', @@ -4856,7 +4856,7 @@ CREATE TABLE fin_document_sequence ( current_value int unsigned NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fin_document_sequence_value CHECK ((current_value > 0)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号'; +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号'; CREATE TABLE fin_fee_source ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', @@ -4983,7 +4983,7 @@ CREATE TABLE fin_receipt_bill ( CREATE TABLE fin_recharge_request ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', - request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####', + request_no varchar(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####', amount decimal(20,2) NOT NULL COMMENT '申请充值金额', voucher_file_id bigint unsigned NOT NULL COMMENT '付款截图File ID', voucher_file_name varchar(255) NOT NULL COMMENT '付款截图文件名快照', @@ -5234,7 +5234,7 @@ CREATE TABLE fin_wallet_hold ( CREATE TABLE fin_wallet_ledger ( id bigint unsigned NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id bigint unsigned NOT NULL COMMENT '客户ID和隔离键', - business_no varchar(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####', + business_no varchar(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####', change_type varchar(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整', amount decimal(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负', actual_balance_after decimal(20,2) NOT NULL COMMENT '变动后实际余额', @@ -8383,4 +8383,20 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) INSERT INTO iam_permission (id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name, route_path, http_method, sort_no, status, built_in, created_at, updated_at, deleted) VALUES (5102213,0,'PLATFORM','BUTTON',5102200,'platform:firstleg:terminal-drayage:direct-unload:confirm','登记直送目的仓实际卸货',NULL,'POST',60,'ACTIVE',1,CURRENT_TIMESTAMP(3),CURRENT_TIMESTAMP(3),0); INSERT INTO iam_role_permission (role_id, permission_id, tenant_id, created_at) VALUES (4000001,5102213,0,CURRENT_TIMESTAMP(3)); -SELECT 'PASS' AS initialization_status, 'CN' AS region, 227 AS initialized_tables; +USE baiyue_order; +CREATE TABLE ord_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; + +USE baiyue_warehouse; +CREATE TABLE wh_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; + +SELECT 'PASS' AS initialization_status, 'CN' AS region, 229 AS initialized_tables; diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 8d24dba5f..5f9b3d6c2 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -1860,12 +1860,6 @@ CREATE TABLE ll_order_manual_node_history ( KEY idx_ll_order_manual_node_history (customer_id,order_id,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程订单人工节点覆盖不可变历史'; -CREATE TABLE ll_order_no_daily_sequence ( - business_date date NOT NULL COMMENT '业务日期(Asia/Shanghai)', - last_sequence int NOT NULL COMMENT '当日已分配尾程单流水号', - PRIMARY KEY (business_date) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='尾程单号按日原子流水'; - CREATE TABLE ll_dispatch_daily_sequence ( business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', @@ -2078,6 +2072,7 @@ CREATE TABLE ll_overseas_procurement_fee ( settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号', request_hash CHAR(64) NULL COMMENT '确认请求摘要', id bigint NOT NULL COMMENT '主键ID', order_id bigint NOT NULL COMMENT '采购订单ID', @@ -2099,6 +2094,7 @@ CREATE TABLE ll_overseas_procurement_fee ( updated_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), + UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key), PRIMARY KEY (id), UNIQUE KEY uk_ll_procurement_fee_no (fee_no), KEY idx_ll_procurement_fee_order (customer_id,order_id,created_at), @@ -2369,14 +2365,6 @@ CREATE TABLE ll_pda_stocktake_task ( KEY idx_ll_pda_stocktake_list (overseas_warehouse_id,status,created_at) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外PDA库存盘点任务'; -CREATE TABLE ll_procurement_daily_sequence ( - customer_id varchar(128) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '客户ID', - business_date date NOT NULL COMMENT '北京时间业务日期', - sequence_value int NOT NULL COMMENT '客户当日已分配最大序号', - PRIMARY KEY (customer_id,business_date), - CONSTRAINT chk_ll_procurement_sequence CHECK ((sequence_value between 1 and 9999)) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外采购客户日序列'; - CREATE TABLE ll_receiving_container_operation ( id bigint NOT NULL COMMENT '柜号收货操作ID', session_id bigint NOT NULL COMMENT '柜号签收会话ID', @@ -4831,12 +4819,6 @@ CREATE TABLE ll_appointment_work_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; -- 海外业务短单号分配时钟。 -CREATE TABLE ll_compact_number_clock ( - number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', - last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒', - PRIMARY KEY (number_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; - -- 仓储转运上门取货表归尾程服务,仅用于空库初始化。 USE baiyue_lastleg; CREATE TABLE ll_fleet_pickup_plan ( @@ -4995,18 +4977,12 @@ CREATE TABLE ll_dispatch_transport_command ( PRIMARY KEY(dispatch_no,request_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; --- Region: OVERSEAS --- Schema: baiyue_lastleg --- Purpose: 客户散板原单接入拼车需求池,未确认登记不占位。 --- Preconditions: MySQL 8;已选择 baiyue_lastleg;预订表已存在。 --- Postconditions: 预订原单来源列及客户来源索引存在,不回填历史来源。 -SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND column_name='source_looseboard_order_id'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD COLUMN source_looseboard_order_id BIGINT NULL COMMENT ''客户原始散板商业单ID,取消后保留来源'''); -PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; -EXECUTE looseboard_demand_stmt; -DEALLOCATE PREPARE looseboard_demand_stmt; -SET @looseboard_demand_ddl = IF(EXISTS (SELECT 1 FROM information_schema.statistics WHERE table_schema=DATABASE() AND table_name='ll_fleet_external_booking' AND index_name='idx_booking_looseboard_source'), 'SELECT 1', 'ALTER TABLE ll_fleet_external_booking ADD KEY idx_booking_looseboard_source (customer_id,source_looseboard_order_id,status)'); -PREPARE looseboard_demand_stmt FROM @looseboard_demand_ddl; -EXECUTE looseboard_demand_stmt; -DEALLOCATE PREPARE looseboard_demand_stmt; +USE baiyue_lastleg; +CREATE TABLE ll_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; -SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 236 AS initialized_tables; +SELECT 'PASS' AS initialization_status, 'OVERSEAS' AS region, 234 AS initialized_tables; diff --git a/finance/AGENTS.md b/finance/AGENTS.md index b39c580e0..5013adedc 100644 --- a/finance/AGENTS.md +++ b/finance/AGENTS.md @@ -50,7 +50,7 @@ - 平台写操作必须校验权限、目标客户、幂等键、请求哈希、乐观锁版本和审计;客户接口只能从登录态取得客户范围,不接受客户端指定客户 ID。 - 出账后的业务来源和账单不得原地覆盖或物理删除;调整、作废、收款分配和修订必须保留可追溯记录。金额使用定点小数,当前业务币种固定为 CNY,长整型 ID 跨前端按字符串传输。 - 对账单出账后将客户确认状态置为 `PENDING_CONFIRMATION`;已出账账单调整后必须清除原确认快照并重新待确认,作废或重开时为 `NOT_REQUIRED`。客户只能确认本企业且状态为 `ISSUED`、`PARTIALLY_PAID` 或 `PAID` 的待确认账单,必须校验登录成员、乐观锁版本和幂等键,并记录确认人姓名快照与确认时间。 -- 对账单、应收结算单和应付结算单编号统一按北京时间生成 `REC/AR/AP-yyMMdd-####`,各前缀每日独立递增且单日最多 9999;不得回退为数据库 ID 拼接、随机数或跨时区日期。 +- 财务单据编号遵循 `../../docs/业务单据编号契约.md`,由本服务 Repository/Mapper 按前缀与北京时间日期原子分配;不得回退为数据库 ID 拼接、随机数或跨时区日期。 - 客户授信必须带晚于当天的到期日且不限制最远日期;到期日当天仍有效。有效期内消费先用实际资金,不足部分累计为已用授信;次日失效时已用授信自动并入实际余额并允许为负,充值先归还已用授信或负余额。Order 在客户提交新订单时必须拒绝负数实际余额,已存在的资金冻结仍按原业务单结算或释放。 - 应收款统计只按正式应收结算单的生成月份汇总 CNY 应收、已收和未收,支持可选客户筛选和分页;导出必须通过 Finance Provider + File `FINANCE_RECEIVABLE_STATISTICS` 异步任务,不在 Controller 或 Admin 直接生成文件。 - 费用入池只接受满足上游发布与结算条件的不可变来源版本;来源 ID、版本、客户归属、方向或金额变化时同时核对事件消费、主动快照查询、唯一约束和账单明细消费者。 diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 0d75a804f..732b1b98e 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -66,11 +66,7 @@ public class FinanceSettlementService { private final FinanceSupplierValidator supplierValidator; private final FinanceDocumentNumberGenerator documentNumbers; - public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, - FinanceSupplierValidator supplierValidator) { - this(jdbc, objectMapper, voucherValidator, supplierValidator, new FinanceDocumentNumberGenerator(jdbc)); - } - + /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, FinanceSupplierValidator supplierValidator, @@ -82,6 +78,7 @@ public class FinanceSettlementService { this.documentNumbers = documentNumbers; } + /** 创建应收结算单,幂等请求恢复原单,首次建单分配 AR 日流水。 */ @Transactional public SettlementDetail createReceivableSettlement(long operatorId, String key, CreateReceivableSettlementRequest request) { diff --git a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java index 7311d5375..0085f28e3 100644 --- a/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/ReconciliationStatementService.java @@ -46,10 +46,7 @@ public class ReconciliationStatementService { private final ObjectMapper objectMapper; private final FinanceDocumentNumberGenerator documentNumbers; - public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper) { - this(jdbc, objectMapper, new FinanceDocumentNumberGenerator(jdbc)); - } - + /** 注入统一编号组件,确保所有业务入口共用受事务保护的日流水。 */ @Autowired public ReconciliationStatementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceDocumentNumberGenerator documentNumbers) { @@ -58,6 +55,7 @@ public class ReconciliationStatementService { this.documentNumbers = documentNumbers; } + /** 创建对账单并分配 REC 日流水,重复请求返回已创建记录。 */ @Transactional public ReconciliationDetail create(long operatorId, String key, CreateReconciliationRequest request) { validateCreate(request); diff --git a/finance/src/main/resources/db/finance.sql b/finance/src/main/resources/db/finance.sql index 9c443bc26..de37e73e1 100644 --- a/finance/src/main/resources/db/finance.sql +++ b/finance/src/main/resources/db/finance.sql @@ -398,7 +398,7 @@ CREATE TABLE IF NOT EXISTS fin_document_sequence ( current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fin_document_sequence_value CHECK (current_value > 0) -) ENGINE=InnoDB COMMENT='Finance业务单号日序列;生成YYMMDD加四位当日序号'; +) ENGINE=InnoDB COMMENT='财务业务单号日序列;生成前缀加yyyyMMdd加四位当日序号'; CREATE TABLE IF NOT EXISTS fin_audit_log ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', @@ -524,7 +524,7 @@ CREATE TABLE IF NOT EXISTS fin_wallet_account ( CREATE TABLE IF NOT EXISTS fin_recharge_request ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', - request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RC-yyMMdd-####', + request_no VARCHAR(32) NOT NULL COMMENT '充值申请业务编号RCyyyyMMdd####', amount DECIMAL(20,2) NOT NULL COMMENT '申请充值金额', voucher_file_id BIGINT UNSIGNED NOT NULL COMMENT '付款截图File ID', voucher_file_name VARCHAR(255) NOT NULL COMMENT '付款截图文件名快照', @@ -549,7 +549,7 @@ CREATE TABLE IF NOT EXISTS fin_recharge_request ( CREATE TABLE IF NOT EXISTS fin_wallet_ledger ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT COMMENT '主键ID', customer_id BIGINT UNSIGNED NOT NULL COMMENT '客户ID和隔离键', - business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号AC-yyMMdd-####', + business_no VARCHAR(32) NOT NULL COMMENT '账户变动业务编号ACyyyyMMdd####', change_type VARCHAR(16) NOT NULL COMMENT '变动类型:充值、扣款、冻结、解冻、消费或授信调整', amount DECIMAL(20,2) NOT NULL COMMENT '带方向变动金额,充值为正、扣款为负', actual_balance_after DECIMAL(20,2) NOT NULL COMMENT '变动后实际余额', diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 19f41a707..7898c139e 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -620,8 +620,9 @@ class FinanceSettlementBehaviorTest { org.mockito.Mockito.lenient().when(jdbc.queryForList(startsWith("SELECT JSON_UNQUOTE"), anyLong(), anyLong())) .thenReturn(List.of(new java.util.HashMap<>(Map.of("supplier_category", "BOOKING")))); FinanceDocumentNumberGenerator documentNumbers = org.mockito.Mockito.mock(FinanceDocumentNumberGenerator.class); - org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR-260818-0001"); - org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP-260818-0001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001"); return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers); } diff --git a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java index 1064e9b0f..f741990ef 100644 --- a/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/ReconciliationStatementBehaviorTest.java @@ -70,7 +70,7 @@ class ReconciliationStatementBehaviorTest { @Test void selectedModeRejectsDuplicateFeeSourcesBeforeWriting() { JdbcTemplate jdbc = mock(JdbcTemplate.class); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); CreateReconciliationRequest request = new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L, 88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null); @@ -105,7 +105,7 @@ class ReconciliationStatementBehaviorTest { var result = target.create(71L, "create-key", request); assertThat(result.statementId()).isEqualTo(501L); - assertThat(result.statementNo()).isEqualTo("REC-260818-0001"); + assertThat(result.statementNo()).isEqualTo("REC202608180001"); assertThat(result.currency()).isEqualTo("CNY"); assertThat(result.createdByName()).isEqualTo("platform_admin"); assertThat(result.updatedByName()).isEqualTo("platform_admin"); @@ -132,7 +132,7 @@ class ReconciliationStatementBehaviorTest { }); when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT id,customer_id"), eq(88L))) .thenReturn(List.of(Map.of("id", 88L, "customer_id", 10L))); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); assertThatThrownBy(() -> target.create(71L, "create-key", new CreateReconciliationRequest(9L, "CNY", "SELECTED", List.of(88L), LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), null))) @@ -144,7 +144,7 @@ class ReconciliationStatementBehaviorTest { @Test void submitUsesExpectedVersionAndPersistsRevisionBeforeStateChange() throws Exception { JdbcTemplate jdbc = actionJdbc("DRAFT", "PENDING_REVIEW", "submit-key"); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "submit-key", new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of())); @@ -164,7 +164,7 @@ class ReconciliationStatementBehaviorTest { @Test void approveFreezesInternalBillAndReturnsContractStatus() throws Exception { JdbcTemplate jdbc = actionJdbc("PENDING_REVIEW", "APPROVED", "approve-key"); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "approve-key", new ReconciliationActionRequest("APPROVE", "审核通过", 1, List.of())); @@ -177,7 +177,7 @@ class ReconciliationStatementBehaviorTest { @Test void issueFreezesSourcesOnlyAfterApproval() throws Exception { JdbcTemplate jdbc = actionJdbc("APPROVED", "ISSUED", "issue-key"); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "issue-key", new ReconciliationActionRequest("ISSUE", null, 2, List.of())); @@ -202,7 +202,7 @@ class ReconciliationStatementBehaviorTest { }); when(jdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("confirm-key"))).thenReturn(List.of()); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.confirmForCustomer(9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3)); @@ -236,7 +236,7 @@ class ReconciliationStatementBehaviorTest { JdbcTemplate crossCustomer = mock(JdbcTemplate.class); when(crossCustomer.query(org.mockito.ArgumentMatchers.startsWith("SELECT b.*"), any(RowMapper.class), eq(501L), eq(9L))) .thenReturn(List.of()); - assertThatThrownBy(() -> new ReconciliationStatementService(crossCustomer, new ObjectMapper()).confirmForCustomer( + assertThatThrownBy(() -> serviceWithNumber(crossCustomer).confirmForCustomer( 9L, 71L, 501L, "confirm-key", new CustomerConfirmationRequest(3))) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_404"); @@ -251,7 +251,7 @@ class ReconciliationStatementBehaviorTest { }); when(draft.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("draft-key"))).thenReturn(List.of()); - assertThatThrownBy(() -> new ReconciliationStatementService(draft, new ObjectMapper()).confirmForCustomer( + assertThatThrownBy(() -> serviceWithNumber(draft).confirmForCustomer( 9L, 71L, 501L, "draft-key", new CustomerConfirmationRequest(0))) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); @@ -271,7 +271,7 @@ class ReconciliationStatementBehaviorTest { }); when(stale.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_CUSTOMER_CONFIRM"), eq("stale-key"))).thenReturn(List.of()); - assertThatThrownBy(() -> new ReconciliationStatementService(stale, new ObjectMapper()).confirmForCustomer( + assertThatThrownBy(() -> serviceWithNumber(stale).confirmForCustomer( 9L, 71L, 501L, "stale-key", new CustomerConfirmationRequest(2))) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); @@ -288,7 +288,7 @@ class ReconciliationStatementBehaviorTest { eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("used-key"))).thenReturn(List.of(Map.of( "request_hash", "different-request", "status", "SUCCEEDED", "result_resource_id", 501L, "updated_at", LocalDateTime.now()))); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); assertThatThrownBy(() -> target.action(71L, 501L, "used-key", new ReconciliationActionRequest("SUBMIT", "提交审核", 0, List.of()))) @@ -300,7 +300,7 @@ class ReconciliationStatementBehaviorTest { @Test void invalidActionAndVersionConflictAreRejectedBeforeMutation() throws Exception { JdbcTemplate jdbc = actionJdbc("DRAFT", "DRAFT", "invalid-key"); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); assertThatThrownBy(() -> target.action(71L, 501L, "invalid-key", new ReconciliationActionRequest("NOT_AN_ACTION", null, 0, List.of()))) @@ -318,7 +318,7 @@ class ReconciliationStatementBehaviorTest { JdbcTemplate jdbc = actionJdbc("ISSUED", "ISSUED", "adjust-key"); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COALESCE(SUM(amount)"), eq(BigDecimal.class), eq(9L), eq(501L))) .thenReturn(new BigDecimal("280.00")); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); target.action(71L, 501L, "adjust-key", new ReconciliationActionRequest("ADJUST", "运费更正", 3, List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("280.00"), "运费更正")))); @@ -342,7 +342,7 @@ class ReconciliationStatementBehaviorTest { return List.of(mapper.mapRow(rs, 0)); }); - assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L, + assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "low-adjust-key", new ReconciliationActionRequest("ADJUST", "金额错误", 3, List.of(new StatementAdjustmentLineRequest(601L, new BigDecimal("80.00"), "金额错误"))))) .isInstanceOf(FinanceEventException.class) @@ -355,7 +355,7 @@ class ReconciliationStatementBehaviorTest { JdbcTemplate jdbc = actionJdbc("ISSUED", "VOIDED", "void-key"); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_receipt_bill"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))).thenReturn(0L); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "void-key", new ReconciliationActionRequest("VOID", "客户重开账期", 3, List.of())); @@ -372,7 +372,7 @@ class ReconciliationStatementBehaviorTest { .thenReturn("receipt".equals(query) ? 1L : 0L); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_settlement"), eq(Long.class), eq(9L), eq(501L))) .thenReturn("settlement".equals(query) ? 1L : 0L); - assertThatThrownBy(() -> new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()).action(71L, 501L, + assertThatThrownBy(() -> serviceWithNumber(jdbc).action(71L, 501L, "void-" + query, new ReconciliationActionRequest("VOID", "作废原因", 3, List.of()))) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); @@ -384,7 +384,7 @@ class ReconciliationStatementBehaviorTest { void reopenVoidedStatementReacquiresEveryReleasedSource() throws Exception { JdbcTemplate jdbc = actionJdbc("VOIDED", "DRAFT", "reopen-key"); when(jdbc.queryForObject(org.mockito.ArgumentMatchers.startsWith("SELECT COUNT(*) FROM fin_bill_line"), eq(Integer.class), eq(9L), eq(501L))).thenReturn(1); - ReconciliationStatementService target = new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService target = serviceWithNumber(jdbc); var result = target.action(71L, 501L, "reopen-key", new ReconciliationActionRequest("REOPEN", "重新核对", 4, List.of())); @@ -399,7 +399,7 @@ class ReconciliationStatementBehaviorTest { JdbcTemplate firstJdbc = mock(JdbcTemplate.class, invocation -> "update".equals(invocation.getMethod().getName()) ? 1 : RETURNS_DEFAULTS.answer(invocation)); when(firstJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("first-key"))).thenReturn(List.of()); - ReconciliationStatementService firstService = new ReconciliationStatementService(firstJdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService firstService = serviceWithNumber(firstJdbc); Long first = ReflectionTestUtils.invokeMethod(firstService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "first-key", "POST", "/statements/501/actions", body); ArgumentCaptor hash = ArgumentCaptor.forClass(String.class); @@ -412,7 +412,7 @@ class ReconciliationStatementBehaviorTest { .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 501L, "updated_at", LocalDateTime.now()))); assertThat((Long) ReflectionTestUtils.invokeMethod( - new ReconciliationStatementService(replayJdbc, new ObjectMapper().findAndRegisterModules()), + serviceWithNumber(replayJdbc), "reserve", 9L, 71L, "RECONCILIATION_ACTION", "replay-key", "POST", "/statements/501/actions", body)) .isEqualTo(501L); @@ -420,7 +420,7 @@ class ReconciliationStatementBehaviorTest { when(processingJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("processing-key"))) .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now()))); assertThatThrownBy(() -> ReflectionTestUtils.invokeMethod( - new ReconciliationStatementService(processingJdbc, new ObjectMapper().findAndRegisterModules()), + serviceWithNumber(processingJdbc), "reserve", 9L, 71L, "RECONCILIATION_ACTION", "processing-key", "POST", "/statements/501/actions", body)) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); @@ -431,7 +431,7 @@ class ReconciliationStatementBehaviorTest { .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "FAILED", "updated_at", LocalDateTime.now()))); when(recoveryJdbc.queryForList(org.mockito.ArgumentMatchers.startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("RECONCILIATION_ACTION"), eq("stale-key"))) .thenReturn(List.of(Map.of("request_hash", hash.getValue(), "status", "PROCESSING", "updated_at", LocalDateTime.now().minusMinutes(10)))); - ReconciliationStatementService recoveryService = new ReconciliationStatementService(recoveryJdbc, new ObjectMapper().findAndRegisterModules()); + ReconciliationStatementService recoveryService = serviceWithNumber(recoveryJdbc); assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "failed-key", "POST", "/statements/501/actions", body)).isNull(); assertThat((Long) ReflectionTestUtils.invokeMethod(recoveryService, "reserve", 9L, 71L, "RECONCILIATION_ACTION", "stale-key", "POST", "/statements/501/actions", body)).isNull(); @@ -556,7 +556,7 @@ class ReconciliationStatementBehaviorTest { ResultSet rs = mock(ResultSet.class); when(rs.getLong("id")).thenReturn(501L); when(rs.getLong("tenant_id")).thenReturn(9L); - when(rs.getString("bill_no")).thenReturn("REC-260818-0001"); + when(rs.getString("bill_no")).thenReturn("REC202608180001"); when(rs.getString("customer_name_snapshot")).thenReturn("客户9"); when(rs.getString("status")).thenReturn(status); when(rs.getBigDecimal("total_amount")).thenReturn(new BigDecimal("300.00")); @@ -591,7 +591,7 @@ class ReconciliationStatementBehaviorTest { private static ReconciliationStatementService serviceWithNumber(JdbcTemplate jdbc) { FinanceDocumentNumberGenerator documentNumbers = mock(FinanceDocumentNumberGenerator.class); - org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC-260818-0001"); + org.mockito.Mockito.lenient().when(documentNumbers.next("REC")).thenReturn("REC202608180001"); return new ReconciliationStatementService(jdbc, new ObjectMapper().findAndRegisterModules(), documentNumbers); } } diff --git a/finance/src/test/java/com/baiyue/finance/repository/DocumentSequenceRepositoryTest.java b/finance/src/test/java/com/baiyue/finance/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..1c6f5d7d1 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE fin_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java index ba0177c12..5bbc279eb 100644 --- a/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java +++ b/finance/src/test/java/com/baiyue/finance/service/FinanceDocumentNumberGeneratorTest.java @@ -1,70 +1,41 @@ package com.baiyue.finance.service; +import com.baiyue.finance.repository.DocumentSequenceRepository; +import com.baiyue.common.core.exception.BizException; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.jdbc.core.JdbcTemplate; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class FinanceDocumentNumberGeneratorTest { - private static final Clock CLOCK = Clock.fixed( - Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai")); - - @Test - void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { - context.registerBean(JdbcTemplate.class, () -> jdbc); - context.register(FinanceDocumentNumberGenerator.class); - context.refresh(); + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(FinanceDocumentNumberGenerator.class);context.refresh(); assertThat(context.getBean(FinanceDocumentNumberGenerator.class)).isNotNull(); } } - - @Test - void formatsShortDailySequenceForAllThreeDocuments() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L, 2L, 38L); - FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); - - assertThat(generator.next("REC")).isEqualTo("REC-260818-0001"); - assertThat(generator.next("AR")).isEqualTo("AR-260818-0002"); - assertThat(generator.next("AP")).isEqualTo("AP-260818-0038"); - - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), - eq("REC:20260818")); + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("REC:20261020")).thenReturn(1L,2L,9999L); + var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("REC")).isEqualTo("REC202610200001"); + assertThat(numbers.next("REC")).isEqualTo("REC202610200002"); + assertThat(numbers.next("REC")).isEqualTo("REC202610209999"); + verify(sequences,times(3)).next("REC:20261020"); } - - @Test - void rejectsUnsupportedTypeAndOverflow() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); - assertThatThrownBy(() -> generator.next("PAY")).isInstanceOf(IllegalArgumentException.class); - - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L); - assertThatThrownBy(() -> generator.next("REC")) - .isInstanceOf(FinanceEventException.class) - .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN_409"); - } - - @Test - void vehicleCostsAndPaymentsUseIndependentSequenceKeys() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(1L); - FinanceDocumentNumberGenerator generator = new FinanceDocumentNumberGenerator(jdbc, CLOCK); - assertThat(generator.next("VC")).isEqualTo("VC-260818-0001"); - assertThat(generator.next("VP")).isEqualTo("VP-260818-0001"); - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VC:20260818")); - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fin_document_sequence"), eq("VP:20260818")); + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new FinanceDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("REC:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("REC")).isInstanceOf(BizException.class); } } diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index f161fd472..9d6532238 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -57,7 +57,7 @@ | 直送目的仓实际卸货 | `terminaldrayage/controller/PlatformDirectDeliveryController.java`、`service/DirectDeliveryService.java`、`UnloadingEvidenceGuard.java`;按实装入仓来源逐票记录签收人与凭据,必须经 Lastleg 确认 DIRECT 路线且已提柜;不创建海外库存,全柜卸毕才可人工还空;契约 `src/main/resources/openapi/direct-delivery-v1.yaml`,基线 `src/main/resources/db/phase3-direct-delivery-schema.sql` | | 目的港码头拖车与还空预警 | `terminaldrayage/controller/PlatformTerminalDrayageController.java`、`service/TerminalDrayageService.java`、`mapper/TerminalDrayageMapper.java`;货柜实际到港后引用 Warehouse `ACTIVE/TRUCKING/OVERSEAS` 海外车队供应商,依次选择车队、预约提柜、确认实际提柜,海外仓卸柜或全部直送目的仓实际卸货完成后确认还柜;服务端按卸柜完成时间加 72 小时返回报空预警,实际提柜和还柜通过现有 `CarrierScheduleQueryService` 推进 `PICKED_UP/EMPTY_RETURNED`,不复制船节点或车队主档。契约 `src/main/resources/openapi/terminal-drayage-v1.yaml`,基线 `src/main/resources/db/phase3-terminal-drayage-schema.sql` 邮件由 `terminaldrayage/service/TerminalDrayageMailService.java` 入队,`TerminalDrayageMailDispatcher.java` 顺序投递,模板与 SMTP 配置见 `docs/terminal-drayage-mail.md`;首次选队不发信,换队事务内生成取消与请求两封邮件。 | | 船节点交接事件 | `portschedule/service/CarrierScheduleNodeOutboxService.java`;实际节点落库后按 `containerBookingId` 柜级发布 `FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED` V1,信封固定使用 `tenantId=0` 平台技术范围且 Payload 不携带客户,复用 `LoadingCompletedOutboxRepository`/Relay 直投 Lastleg `/internal/lastleg/events`;消费见 `../lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java`,ACK/重试按 `../../docs/统一认证与服务通信契约.md` | -| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;当前支持 `OB`、`TA`、`CB`、`CAB`、`LP` 前缀,按北京时间每日独立编号;具体业务归属以各调用服务为准 | +| Firstleg 业务单据编号 | `service/FirstlegDocumentNumberGenerator.java`;前缀与生成规则见 `../../../docs/业务单据编号契约.md`,柜次及实物标签规则保持不变 | | 订舱级拖车与整柜出仓调度 | `controller/PlatformTruckAppointmentController.java` 的 `/candidates`、`service/TruckAppointmentService.java`、`repository/TruckAppointmentRepository.java`、`outbound/dispatch/controller/PlatformOutboundDispatchController.java`;拖车契约 `src/main/resources/openapi/truck-appointments-v1.yaml`。预约只绑定 `RELEASED` 订舱,候选排除缺提单/有效订舱文件或已有非终态预约的订舱;API 不含客户、柜次、装柜计划或真实柜号,每订舱最多一张有效预约,已有完成单不得重建收费;PDA 完成不推进拖车状态,全部要求货柜确认国内离仓后由 `TruckAppointmentCompletionService` 自动完成并经 `TruckAppointmentFeeService` 发布应付 | | 整柜出口报关 | `customs/container/controller/PlatformContainerCustomsController.java`、`customs/container/controller/MerchantContainerCustomsController.java`、`customs/container/service/ContainerCustomsService.java`、`customs/container/mapper/ContainerCustomsMapper.java`;装柜完成后按柜收集退税资料,多对多覆盖入仓码,开始报关冻结,完成文件逐资料一对一回传,全买单柜由平台确认并通过导出中心取明细;正式申报批次与海关结果在同一模块独立记录,不改报关四态、PDA 或船期事实;契约 `src/main/resources/openapi/customs-v1.yaml`,基线 `src/main/resources/db/phase3-container-customs-schema.sql`;消费者为 Admin `../../apps/admin-web/src/modules/container-loading/components/ContainerCustomsPanel.vue` 和 Client `../../apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue` | | 买单订单明细导出 Provider | `customs/container/controller/LoadingListBuyDeclarationExportController.java`、`service/LoadingListBuyDeclarationExportService.java`;只为 File 导出中心提供 `LOADING_LIST_BUY_DECLARATION_ITEMS` 的已完成装柜实际买单行,不在 Firstleg 生成文件 | @@ -81,7 +81,7 @@ - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 -- 订舱号统一使用 `OB-yyMMdd-####`,按北京时间创建日每日独立递增且单日最多 9999;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 +- 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 - 海外仓业务船节点任务从确认柜子离开国内仓库后启用,必须绑定真实订舱和柜次;十个标准节点、预计/实际时间、异常事实、供应商缺失节点和还柜停止规则统一以 `../../docs/海外仓业务/船节点标准化契约.md` 为准。`CarrierScheduleQuery` 的正常业务查询槽位固定为 `Asia/Shanghai` 每日 09:00、12:30、16:00;每分钟任务只扫描已到期 `next_allowed_at`,不是每分钟请求船司。成功、无变化、需人工或永久失败进入下一固定槽位;仅技术性 `RETRYABLE_FAILURE` 在当前槽内按15/30分钟退避,含首次最多3次且不得跨下一槽,实际 `EMPTY_RETURNED` 后停止。错过的正常槽位直接顺延,不在服务恢复时补跑;单轮按受控批次持续领取并受最大任务数保护。租约、调度尝试和内部停止入口不得暴露为平台页面 API,外部失败不得推进业务节点,不得在国内货柜履约另建查询任务。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java index 7acaa47e8..4e6a3b86e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/dto/ContainerCustomsDtos.java @@ -56,7 +56,6 @@ public final class ContainerCustomsDtos { String remark, List results) {} public record DeclarationBatchRequest(@Min(0) int expectedVersion, - @Size(max = 64) String batchNo, @Size(max = 64) String customsEntryNo, @Size(max = 500) String remark) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java index a2580c35f..257f30a81 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/customs/container/service/ContainerCustomsService.java @@ -181,6 +181,7 @@ public class ContainerCustomsService { return platformView(planId); } + /** 首次发送报关资料时生成 BR 批次号,重放沿用已有批次。 */ @Transactional public ContainerView sendInitialToBroker(long planId, long operatorId, String operatorName, String key, ActionRequest request) { @@ -358,11 +359,12 @@ public class ContainerCustomsService { return mapper.declarationBatches(container.id()).stream().map(this::declarationBatchView).toList(); } + /** 首次创建分配独立申报批次号,重复命令返回已创建批次。 */ @Transactional public DeclarationBatchView createDeclarationBatch(long planId, long operatorId, String operatorName, String key, DeclarationBatchRequest request) { String action = "CREATE_CUSTOMS_DECLARATION_BATCH_" + planId; - String requestHash = hash(action, request.expectedVersion(), request.batchNo(), request.remark()); + String requestHash = hash(action, request.expectedVersion(), request.remark()); CommandRow replay = claim(operatorId, action, key, requestHash); if (replay != null) return declarationBatchView(mapper.declarationBatch(replay.resultId())); ContainerRow container = mapper.lockContainer(planId); @@ -373,8 +375,8 @@ public class ContainerCustomsService { } if (!Objects.equals(container.version(), request.expectedVersion())) throw concurrent(); long batchId = IdWorker.getId(); - String batchNo = trim(request.batchNo()); - if (batchNo == null) batchNo = documentNumbers.next("DECL"); + // 申报批次号由服务端分配,外部报关单号独立保存在 customsEntryNo。 + String batchNo = documentNumbers.next("DECL"); String snapshot = declarationSnapshot(container.id()); if (mapper.insertDeclarationBatch(batchId, container.id(), planId, batchNo, container.version(), snapshot, trim(request.remark())) != 1) throw conflict("正式申报批次创建失败"); @@ -383,20 +385,20 @@ public class ContainerCustomsService { return declarationBatchView(mapper.declarationBatch(batchId)); } + /** 提交只回填外部报关单号,内部批次号保持创建时的值。 */ @Transactional public DeclarationBatchView submitDeclarationBatch(long batchId, long operatorId, String operatorName, String key, DeclarationBatchRequest request) { String action = "SUBMIT_CUSTOMS_DECLARATION_BATCH_" + batchId; String entryNo = trim(request.customsEntryNo()); if (entryNo == null) throw bad("正式申报必须回填报关单号"); - String requestHash = hash(action, request.expectedVersion(), request.batchNo(), entryNo, request.remark()); + String requestHash = hash(action, request.expectedVersion(), entryNo, request.remark()); CommandRow replay = claim(operatorId, action, key, requestHash); if (replay != null) return declarationBatchView(mapper.declarationBatch(batchId)); DeclarationBatchRow batch = mapper.lockDeclarationBatch(batchId); if (batch == null || !"READY".equals(batch.status())) throw conflict("正式申报批次不在可提交状态"); if (!Objects.equals(batch.version(), request.expectedVersion())) throw concurrent(); - String batchNo = trim(request.batchNo()); - if (batchNo == null) batchNo = batch.batchNo(); + String batchNo = batch.batchNo(); if (mapper.submitDeclarationBatch(batchId, batch.version(), batchNo, entryNo, operatorId, operatorName, trim(request.remark())) != 1) throw concurrent(); ContainerRow container = mapper.lockContainerById(batch.customsContainerId()); diff --git a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql index 8d264ff4a..3593d195d 100644 --- a/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql +++ b/firstleg/src/main/resources/db/phase3-smart-loading-schema.sql @@ -27,11 +27,11 @@ CALL phase3_add_allocation_picked_count(); DROP PROCEDURE phase3_add_allocation_picked_count; CREATE TABLE IF NOT EXISTS fl_document_sequence ( - sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如CB:20260818,柜次长期序列固定为CABINET', + sequence_key VARCHAR(32) NOT NULL COMMENT '单据序列键;日序列如LP:20260818,柜次长期序列固定为CABINET', current_value INT UNSIGNED NOT NULL COMMENT '该类型当日已分配的最大序号', PRIMARY KEY (sequence_key), CONSTRAINT chk_fl_document_sequence_value CHECK (current_value > 0) -) ENGINE=InnoDB COMMENT='头程履约业务单号日序列;生成YYMMDD加四位当日序号'; +) ENGINE=InnoDB COMMENT='头程业务编号序列;普通单据按日,柜次全局连续,实物标签独立保留'; CREATE TABLE IF NOT EXISTS fl_container_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '货柜预订ID(雪花ID)', diff --git a/firstleg/src/main/resources/openapi/customs-v1.yaml b/firstleg/src/main/resources/openapi/customs-v1.yaml index e5a7eb370..7852ed3ab 100644 --- a/firstleg/src/main/resources/openapi/customs-v1.yaml +++ b/firstleg/src/main/resources/openapi/customs-v1.yaml @@ -257,7 +257,7 @@ components: ContainerEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {allOf: [{$ref: '#/components/schemas/ContainerView'}], nullable: true, description: 装柜计划尚未完成时返回null,表示报关流程尚未生成}}}]} DocumentEnvelope: {allOf: [{$ref: '#/components/schemas/ApiEnvelope'}, {type: object, properties: {data: {$ref: '#/components/schemas/DocumentView'}}}]} ActionRequest: {type: object, required: [expectedVersion, confirm], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, confirm: {type: boolean}, remark: {type: string, maxLength: 500, nullable: true}}} - DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, batchNo: {type: string, maxLength: 64, nullable: true}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}} + DeclarationBatchRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, customsEntryNo: {type: string, maxLength: 64, nullable: true}, remark: {type: string, maxLength: 500, nullable: true}}} ClearanceResultRequest: {type: object, required: [expectedVersion, resultType, occurredAt], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resultType: {type: string, enum: [INSPECTION, SUPPLEMENT_REQUIRED, RELEASED, DETAINED, OTHER]}, occurredAt: {type: string, format: date-time}, resolution: {type: string, maxLength: 1000, nullable: true}, evidenceFileId: {$ref: '#/components/schemas/LongId', nullable: true}, resolved: {type: boolean}}} ClearanceResolveRequest: {type: object, required: [expectedVersion], additionalProperties: false, properties: {expectedVersion: {type: integer, minimum: 0}, resolution: {type: string, maxLength: 1000, nullable: true}}} ScopeRequest: {type: object, required: [inboundIds, expectedVersion], additionalProperties: false, properties: {inboundIds: {type: array, minItems: 1, maxItems: 200, items: {$ref: '#/components/schemas/LongId'}}, expectedVersion: {type: integer, minimum: 1}}} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 472378efb..81c7b0448 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -700,7 +700,8 @@ paths: 需要 platform:firstleg:smart-loading:list 权限。渠道目录只来自平台当前唯一 ACTIVE 价格版本中 对该国内仓存在 ACTIVE 价格行的渠道;候选入仓码数、可用箱数及可编排柜次数由 Firstleg 当前事实聚合。 零候选或零可用柜次的有效渠道仍返回,Admin 可展示空态。渠道只约束智能推荐;人工加入允许跨渠道, - 仍由服务端重新校验国内仓、客户范围、占用、同柜组、箱数和容量资格。 + 仍由服务端重新校验国内仓、货物原客户归属、占用、同柜组、箱数和容量资格。 + 平台柜次支持跨客户拼柜,关联订舱的客户快照不限制柜内成员客户。 parameters: - {name: warehouseId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} responses: @@ -835,6 +836,7 @@ paths: description: >- 需要 platform:firstleg:smart-loading:assign 权限。customerId 与 inboundCode 均为可选推荐范围; 传入时只推荐同时命中的候选,避免把同渠道历史候选批量加入当前柜。未传时按柜次渠道推荐全部可用候选。 + 关联订舱的客户快照不裁剪候选,未指定客户筛选时可推荐多个客户的货物。 一件代发仍按每个已封托托盘整体加入,不得按箱拆分;同一次推荐完成后按受影响客户统一重算费用。 parameters: - {name: planId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} @@ -848,7 +850,8 @@ paths: summary: 向预装计划新增入库单明细 description: >- EDITING 或 LOADING 且 PDA task 尚未完成时可用。服务端按 planId 锁定同一 PDA task, - 校验客户范围、可用箱数、容积和限重后原子新增/累加明细;人工加入允许订单国内仓及渠道与柜次不同, + 校验货物原客户归属、可用箱数、容积和限重后原子新增/累加明细;平台允许跨客户拼柜, + 不以关联订舱客户快照限定成员客户。人工加入允许订单国内仓及渠道与柜次不同, 智能推荐仍只选择柜次渠道。LOADING 时同步到原 taskId, 递增 planVersion、taskVersion,原 inventorySnapshotToken 随即失效。与 PDA batch/final complete 并发时, 锁顺序为 PDA task 行锁 -> plan 乐观锁;仅先取得任务锁且持有匹配版本的一方成功,另一方返回 A0409。 @@ -870,6 +873,7 @@ paths: 服务端在单一事务内按剩余规格箱数比例生成内部规格快照,用于体积估算及兼容 PDA/拆单; 人工分配不限制订单国内仓及渠道与柜次一致;智能推荐允许跨国内仓,但仍只选择柜次渠道; 占用、同柜组和容量门禁不变。 + 支持将不同客户的入仓码加入同一柜次,明细及费用仍保留各自原客户归属。 柜内总容积和总重量分别允许达到柜型基准值的150%,超过即拒绝。 只记录数量计划,具体实装箱号归属为 UNVERIFIED,不得将内部比例分摊宣称为现场箱号事实。 parameters: @@ -886,6 +890,7 @@ paths: 需要 platform:firstleg:smart-loading:assign 权限。订单审核状态不作为清单占位门禁;服务端以 customerId + orderId + inboundCode 重新核验 Order 权威待审核事实,幂等创建或复用 PREPARING 入仓投影,并按订单申报箱数、重量和体积建立柜次占位。人工分配允许订单渠道与柜次渠道不同; + 不同客户可加入同一柜次,关联订舱客户快照不作为客户准入限制。 已被其他有效柜次占用、国内仓不一致、 超容量或订单事实变化返回业务冲突且事务零漂移。清单中存在 PREPARING/WAIT_MEASURE 订单时不得 确认预装或启动 PDA,审核测量完成并进入 WAIT_SPLIT 后才可继续物理装柜。 @@ -2376,7 +2381,7 @@ components: id: {type: string, pattern: '^[1-9][0-9]*$'} currentPortScheduleId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, readOnly: true, description: 当前运输读模型由 latestForDispatch 选中的船期记录 ID;无匹配时为空,仅用于定位关联船期,不作为业务编号展示} planId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 关联智能装柜计划ID;未创建计划时为空} - planNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空} + planNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号;未创建计划时为空} containerNo: {type: string, maxLength: 64, nullable: true} cabinetNo: {type: string, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号} sourceType: {type: string, enum: [SIMULATION, OCEAN_BOOKING], description: SIMULATION 为内部模拟柜;OCEAN_BOOKING 为订舱登记直接生成的真实柜} @@ -2543,7 +2548,7 @@ components: inboundCode: {type: string, minLength: 1, maxLength: 40, description: 订单当前入仓片段业务号,UI主展示} orderNo: {type: string, nullable: true, description: 订单业务号,UI主展示} loadingPlanId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 装柜计划技术关联ID} - loadingPlanNo: {type: string, nullable: true, pattern: '^LP-[0-9]{6}-[0-9]{4}$', description: 装柜计划业务号,UI主展示} + loadingPlanNo: {type: string, nullable: true, pattern: '^LP[0-9]{12}$', description: 装柜计划业务号,UI主展示} containerBookingId: {type: string, pattern: '^[1-9][0-9]*$', nullable: true, description: 柜次技术关联ID} cabinetNo: {type: string, nullable: true, description: 稳定柜次业务号,UI主展示} containerNo: {type: string, nullable: true, description: 真实集装箱号,UI主展示} @@ -2676,7 +2681,7 @@ components: required: [id, planNo, status, bookingId, version, canAssign, canEdit, finalCompletionStatus, coLoadGroups, palletMembers, items] properties: id: {type: string} - planNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装与确认装柜清单共用的稳定编号} + planNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装与确认装柜清单共用的稳定编号} status: {type: string, enum: [EDITING, LOADING, COMPLETED]} bookingId: {type: string} containerNo: {type: string, nullable: true} diff --git a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml index e4cbe949a..5b1354570 100644 --- a/firstleg/src/main/resources/openapi/loading-lists-v1.yaml +++ b/firstleg/src/main/resources/openapi/loading-lists-v1.yaml @@ -299,7 +299,7 @@ components: properties: planId: {$ref: '#/components/schemas/LongId'} containerBookingId: {$ref: '#/components/schemas/LongId'} - preloadNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用} + preloadNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 预装单号;按北京时间创建日逐日编号,确认后继续作为同一装柜清单号使用} containerNo: {type: string, nullable: true, description: 集装箱号} cabinetNo: {type: string, nullable: true, pattern: '^D[0-9]{4,}$', example: D0001, description: 平台全局连续且不按日期重置的稳定柜次号;不得与真实集装箱号混用} customers: {type: array, description: 计划包含的客户, items: {$ref: '#/components/schemas/Customer'}} @@ -356,7 +356,7 @@ components: properties: planId: {$ref: '#/components/schemas/LongId'} containerBookingId: {$ref: '#/components/schemas/LongId'} - loadingListNo: {type: string, pattern: '^LP-[0-9]{6}-[0-9]{4}$', example: LP-260818-0001, description: 装柜清单号;沿用预装阶段生成的同一编号} + loadingListNo: {type: string, pattern: '^LP[0-9]{12}$', example: LP202608180001, description: 装柜清单号;沿用预装阶段生成的同一编号} warehouseId: {$ref: '#/components/schemas/NullableLongId'} warehouseName: {type: string, nullable: true, description: 仓库名称} containerNo: {type: string, nullable: true, description: 集装箱号} diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 59b73ebf1..4f28595ac 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -313,7 +313,7 @@ components: oceanBooking: type: object properties: - bookingNo: {type: string, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001, description: 平台订舱号;按北京时间创建日逐日编号} + bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号} parties: {type: array} containerRequirements: {type: array} containers: {type: array, description: Existing containers allocated after port pickup linking} diff --git a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml index 2aae6cb98..bf9680b35 100644 --- a/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml +++ b/firstleg/src/main/resources/openapi/truck-appointments-v1.yaml @@ -235,9 +235,9 @@ components: required: [appointmentNo, carrierId, carrierName, attachmentCount] description: Stable booking and supplier references returned by appointment detail. Container identities are intentionally absent. properties: - appointmentNo: {type: string, pattern: '^TA-[0-9]{6}-[0-9]{4}$', example: TA-260818-0001, description: 派车单号;按北京时间创建日逐日编号} + appointmentNo: {type: string, pattern: '^TA[0-9]{12}$', example: TA202608180001, description: 派车单号;按北京时间创建日逐日编号} oceanBookingId: {type: integer, format: int64, nullable: true} - oceanBookingNo: {type: string, nullable: true, pattern: '^OB-[0-9]{6}-[0-9]{4}$', example: OB-260818-0001} + oceanBookingNo: {type: string, nullable: true, pattern: '^OB[0-9]{12}$', example: OB202608180001} warehouseId: {type: integer, format: int64, nullable: true} warehouseName: {type: string, nullable: true} shippingCompanyId: {type: integer, format: int64, nullable: true} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java index a67097ac3..ff516dc3b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundMeasurementReviewContractTest.java @@ -32,7 +32,7 @@ class InboundMeasurementReviewContractTest { } @Test - @DisplayName("OpenAPI不包含待客户确认并声明幂等和字符串长ID") + @DisplayName("测量审核不进入待客户确认,并声明幂等和字符串长ID") void shouldDocumentReviewContract() throws Exception { String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); @@ -46,8 +46,11 @@ class InboundMeasurementReviewContractTest { .contains("name: inboundId") .contains("schema: {type: string, pattern:") .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/reject:") - .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:") - .doesNotContain("待客户确认"); + .doesNotContain("/api/platform/v1/inbounds/{inboundId}/measurements/exception-release:"); + // 约束仅针对测量审核;客户费用确认属于另一业务,允许独立存在。 + int start = openapi.indexOf(" /api/platform/v1/inbounds/{inboundId}/measurements/approve:"); + int end = openapi.indexOf("\n /", start + 1); + assertThat(openapi.substring(start, end < 0 ? openapi.length() : end)).doesNotContain("待客户确认"); } @Test diff --git a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java index d017e50fa..9521e272a 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/customs/container/ContainerCustomsServiceTest.java @@ -28,6 +28,8 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; class ContainerCustomsServiceTest { + @org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents(); + @Mock ContainerCustomsMapper mapper; @Mock CustomsFileClient files; @Mock ObjectMapper objectMapper; @@ -35,6 +37,58 @@ class ContainerCustomsServiceTest { @InjectMocks ContainerCustomsService service; @BeforeEach void setUp(){MockitoAnnotations.openMocks(this);} + /** 服务端分配批次号后,命令重放必须返回原号且不再消耗流水。 */ + @Test void declarationCreationReplayKeepsAllocatedNumber() throws Exception { + String action = "CREATE_CUSTOMS_DECLARATION_BATCH_9"; + var command = new AtomicReference(); + var batch = new AtomicReference(); + when(mapper.claimCommand(anyLong(), eq(5L), eq(action), eq("batch-key"), anyString())).thenAnswer(call -> { + if (command.get() != null) return 0; + command.set(new ContainerCustomsMapper.CommandRow(1L, 5L, action, "batch-key", call.getArgument(4), "PROCESSING", null, null, null)); + return 1; + }); + when(mapper.command(5L, action, "batch-key")).thenAnswer(call -> command.get()); + when(mapper.lockContainer(9L)).thenReturn(container(ContainerCustomsService.DECLARING, 3)); + when(mapper.taxRefundCount(9L)).thenReturn(1); + when(mapper.brokerSubmittedAt(2L)).thenReturn(java.time.LocalDateTime.of(2026, 10, 20, 9, 0)); + when(objectMapper.writeValueAsString(any())).thenReturn("[]"); + when(mapper.insertDeclarationBatch(anyLong(), eq(2L), eq(9L), anyString(), eq(3), eq("[]"), isNull())).thenAnswer(call -> { + batch.set(new ContainerCustomsMapper.DeclarationBatchRow(call.getArgument(0), 2L, 9L, call.getArgument(3), + "READY", 3, null, null, null, null, null, null, 0, null, null)); + return 1; + }); + when(mapper.declarationBatch(anyLong())).thenAnswer(call -> batch.get()); + when(mapper.insertHistory(anyLong(), eq(2L), eq("CREATE_CUSTOMS_DECLARATION_BATCH"), anyString(), anyString(), anyString(), eq(5L), eq("admin"))).thenReturn(1); + when(mapper.completeCommand(eq(5L), eq(action), eq("batch-key"), anyLong())).thenAnswer(call -> { + var old = command.get(); + command.set(new ContainerCustomsMapper.CommandRow(old.id(), 5L, action, "batch-key", old.requestHash(), "SUCCESS", call.getArgument(3), null, null)); + return 1; + }); + var request = new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(3, null, null); + var created = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request); + var replay = service.createDeclarationBatch(9L, 5L, "admin", "batch-key", request); + assertThat(created.batchNo()).isEqualTo("DECL202610200001"); + assertThat(replay.id()).isEqualTo(created.id()); + assertThat(replay.batchNo()).isEqualTo(created.batchNo()); + verify(documentNumbers, times(1)).next("DECL"); + } + + /** 外部报关单号与内部批次号独立,正式提交不能修改内部号。 */ + @Test void declarationSubmissionPreservesBatchNumber() { + var batch = new ContainerCustomsMapper.DeclarationBatchRow(41L, 2L, 9L, "DECL202610200001", + "READY", 3, null, null, null, null, null, null, 0, null, null); + when(mapper.claimCommand(anyLong(), eq(5L), eq("SUBMIT_CUSTOMS_DECLARATION_BATCH_41"), eq("submit-key"), anyString())).thenReturn(1); + when(mapper.lockDeclarationBatch(41L)).thenReturn(batch); + when(mapper.submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null)).thenReturn(1); + when(mapper.completeCommand(5L, "SUBMIT_CUSTOMS_DECLARATION_BATCH_41", "submit-key", 41L)).thenReturn(1); + when(mapper.declarationBatch(41L)).thenReturn(batch); + var result = service.submitDeclarationBatch(41L, 5L, "admin", "submit-key", + new com.baiyue.firstleg.customs.container.dto.ContainerCustomsDtos.DeclarationBatchRequest(0, "CUSTOMS-EXTERNAL-1", null)); + assertThat(result.batchNo()).isEqualTo(batch.batchNo()); + verify(mapper).submitDeclarationBatch(41L, 0, batch.batchNo(), "CUSTOMS-EXTERNAL-1", 5L, "admin", null); + verifyNoInteractions(documentNumbers); + } + @Test void merchantOwnBuyOrderIsNotFound(){ assertMerchantOrderNotFound(7L,31L); } @@ -211,7 +265,7 @@ class ContainerCustomsServiceTest { var declaring=container(ContainerCustomsService.DECLARING,3); var source=new ContainerCustomsMapper.DocumentRow(31L,2L,7L,10L,"source.bin","a".repeat(64), "application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1); - var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin", + var submission=new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin", java.time.LocalDateTime.now(),1); when(mapper.claimCommand(anyLong(),eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyString())).thenReturn(1); when(mapper.lockContainer(9L)).thenReturn(declaring); @@ -220,7 +274,7 @@ class ContainerCustomsServiceTest { when(mapper.documents(2L,null)).thenReturn(List.of(source)); when(mapper.frozenScopes(31L)).thenReturn(List.of(new ContainerCustomsMapper.ScopeRow(31L,11L,21L,"I"))); when(objectMapper.writeValueAsString(any())).thenReturn("[]"); - when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1); + when(mapper.insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin"))).thenReturn(1); when(mapper.bumpVersionAfterBrokerSubmission(2L,3)).thenReturn(1); when(mapper.insertHistory(anyLong(),eq(2L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER"),eq(ContainerCustomsService.DECLARING),eq(ContainerCustomsService.DECLARING),isNull(),eq(5L),eq("admin"))).thenReturn(1); when(mapper.completeCommand(eq(5L),eq("SEND_CUSTOMS_INITIAL_TO_BROKER_9"),eq("send-key"),anyLong())).thenReturn(1); @@ -235,9 +289,9 @@ class ContainerCustomsServiceTest { var result=service.sendInitialToBroker(9L,5L,"admin","send-key",new ActionRequest(3,true,null)); assertThat(result.brokerSubmission()).isNotNull(); - assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR-LP9-01"); + assertThat(result.brokerSubmission().batchNo()).isEqualTo("BR202610200001"); assertThat(result.brokerSubmission().sourceVersion()).isEqualTo(3); - verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR-LP9-01"),eq(3),eq("[]"),eq(5L),eq("admin")); + verify(mapper).insertBrokerSubmission(anyLong(),eq(2L),eq(9L),eq("BR202610200001"),eq(3),eq("[]"),eq(5L),eq("admin")); } @Test void initialBrokerSendRejectsStaleAggregateVersion(){ @@ -535,7 +589,7 @@ class ContainerCustomsServiceTest { } private static ContainerCustomsMapper.DocumentRow document(long id){return new ContainerCustomsMapper.DocumentRow(id,2L,7L,10L,"source.bin","a".repeat(64),"application/octet-stream",1L,"ACTIVE",5L,java.time.LocalDateTime.now(),1,java.time.LocalDateTime.now(),1);} - private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR-LP9-01","SENT",3,"admin",java.time.LocalDateTime.now(),1);} + private static ContainerCustomsMapper.BrokerSubmissionRow brokerSubmission(){return new ContainerCustomsMapper.BrokerSubmissionRow(41L,2L,9L,"BR202610200001","SENT",3,"admin",java.time.LocalDateTime.now(),1);} private static ContainerCustomsMapper.ContainerRow container(String status,int version){return new ContainerCustomsMapper.ContainerRow(2L,9L,8L,status,null,null,null,null,null,null,null,null,version);} private void assertMerchantOrderNotFound(long tenantId,long orderId){ assertThatThrownBy(()->service.merchantOrderView(tenantId,orderId)) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java index e2a4fffd8..9a05cf152 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java @@ -37,8 +37,17 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(ColoadGroupService.class) +@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class}) class ColoadGroupServiceTest { + @org.springframework.context.annotation.Configuration + static class Numbers { + /** 同柜组业务测试使用真实格式,序列由隔离测试替身提供。 */ + @org.springframework.context.annotation.Bean + com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers() { + return com.baiyue.firstleg.service.TestDocumentNumbers.documents(); + } + } + @MockBean private ColoadGroupMapper mapper; @MockBean private PendingReviewOrderClient pendingReviewOrders; diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/ArrivalCalendarTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/ArrivalCalendarTest.java index b636cb9bd..35df1de41 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/ArrivalCalendarTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/ArrivalCalendarTest.java @@ -14,6 +14,7 @@ class ArrivalCalendarTest { void rejectsInvalidRangesAndPassesTheWholeHalfOpenRangeToOneQuery() { var repository = mock(PortScheduleRepository.class); var service = new PortScheduleService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.firstleg.service.TestDocumentNumbers.documents()); ReflectionTestUtils.setField(service, "repository", repository); var from = LocalDate.of(2026, 9, 28); assertThrows(BizException.class, () -> service.arrivalCalendar(from, from)); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java index d0a794755..b41cccae1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/PortScheduleServiceTest.java @@ -35,6 +35,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class PortScheduleServiceTest { + @org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents(); + @Test void platformPublicQueryAllowsZeroButRejectsNegativeCustomerScope() { try (var factory = Validation.buildDefaultValidatorFactory()) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/DocumentSequenceRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..aa3b4b6db --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.firstleg.repository; + +import com.baiyue.firstleg.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE fl_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java index a37339ac9..129e54d5f 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/Phase3FinalUnifiedSqlContractTest.java @@ -80,7 +80,7 @@ class Phase3FinalUnifiedSqlContractTest { @Test void initializationPassIsUniqueAndFinal() throws Exception { String sql = sql(); - int expectedTables = 228; + int expectedTables = 229; long actualTables = java.util.regex.Pattern.compile("(?m)^CREATE TABLE\\s+") .matcher(sql).results().count(); assertThat(actualTables).isEqualTo(expectedTables); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java index 8381c951c..655008f11 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/sailingcalendar/service/SailingCalendarServiceTest.java @@ -24,6 +24,8 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class SailingCalendarServiceTest { + @org.mockito.Spy private com.baiyue.firstleg.service.FirstlegDocumentNumberGenerator documentNumbers = com.baiyue.firstleg.service.TestDocumentNumbers.documents(); + @Mock private SailingCalendarRepository repository; @InjectMocks private SailingCalendarService service; private final LocalDateTime copiedDeparture = LocalDateTime.now(ZoneId.of("Asia/Shanghai")).plusDays(14); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java index e0cd6f665..7a62e7f24 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FirstlegDocumentNumberGeneratorTest.java @@ -1,73 +1,52 @@ package com.baiyue.firstleg.service; +import com.baiyue.firstleg.repository.DocumentSequenceRepository; import com.baiyue.common.core.exception.BizException; import org.junit.jupiter.api.Test; import org.springframework.context.annotation.AnnotationConfigApplicationContext; -import org.springframework.jdbc.core.JdbcTemplate; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; class FirstlegDocumentNumberGeneratorTest { - private static final Clock CLOCK = Clock.fixed( - Instant.parse("2026-08-18T02:00:00Z"), ZoneId.of("Asia/Shanghai")); - - @Test - void startsAsASpringBeanWhenTheTestClockConstructorAlsoExists() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) { - context.registerBean(JdbcTemplate.class, () -> jdbc); - context.register(FirstlegDocumentNumberGenerator.class); - context.refresh(); + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(FirstlegDocumentNumberGenerator.class);context.refresh(); assertThat(context.getBean(FirstlegDocumentNumberGenerator.class)).isNotNull(); } } - - @Test - void formatsAllOperationalDocumentPrefixes() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)) - .thenReturn(1L, 1L, 1L, 1L, 1L); - FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); - - assertThat(generator.next("OB")).isEqualTo("OB-260818-0001"); - assertThat(generator.next("TA")).isEqualTo("TA-260818-0001"); - assertThatThrownBy(() -> generator.next("CB")).isInstanceOf(IllegalArgumentException.class); - assertThat(generator.next("LP")).isEqualTo("LP-260818-0001"); - assertThat(generator.nextCabinetNo()).isEqualTo("D0001"); - - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"), - eq("OB:20260818")); - verify(jdbc).update(org.mockito.ArgumentMatchers.startsWith("INSERT INTO fl_document_sequence"), - eq("CABINET")); + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("LP:20261020")).thenReturn(1L,2L,9999L); + var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("LP")).isEqualTo("LP202610200001"); + assertThat(numbers.next("LP")).isEqualTo("LP202610200002"); + assertThat(numbers.next("LP")).isEqualTo("LP202610209999"); + verify(sequences,times(3)).next("LP:20261020"); + } + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("LP:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("LP")).isInstanceOf(BizException.class); } - @Test - void cabinetSequenceDoesNotResetOrStopAtFourDigits() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(9_999L, 10_000L); - FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); - - assertThat(generator.nextCabinetNo()).isEqualTo("D9999"); - assertThat(generator.nextCabinetNo()).isEqualTo("D10000"); - } - - @Test - void rejectsUnsupportedTypeAndDailyOverflow() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - FirstlegDocumentNumberGenerator generator = new FirstlegDocumentNumberGenerator(jdbc, CLOCK); - assertThatThrownBy(() -> generator.next("MSCU")).isInstanceOf(IllegalArgumentException.class); - - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)).thenReturn(10_000L); - assertThatThrownBy(() -> generator.next("LP")).isInstanceOf(BizException.class); + /** 柜次和实物标签明确不改格式,柜次允许超过四位。 */ + @Test void preservesCabinetAndPalletRules() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("CABINET")).thenReturn(9999L,10000L); + when(sequences.next("DP:20261020")).thenReturn(1L); + var numbers=new FirstlegDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.nextCabinetNo()).isEqualTo("D9999"); + assertThat(numbers.nextCabinetNo()).isEqualTo("D10000"); + assertThat(numbers.next("DP")).isEqualTo("DP-261020-0001"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/TestDocumentNumbers.java b/firstleg/src/test/java/com/baiyue/firstleg/service/TestDocumentNumbers.java new file mode 100644 index 000000000..9a98b4129 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/TestDocumentNumbers.java @@ -0,0 +1,21 @@ +package com.baiyue.firstleg.service; + +import com.baiyue.firstleg.repository.DocumentSequenceRepository; +import java.time.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */ +public final class TestDocumentNumbers { + /** 测试工具不实例化。 */ + private TestDocumentNumbers() {} + /** 每个测试生成器独立计数,类型前缀之间互不占号。 */ + public static FirstlegDocumentNumberGenerator documents() { + var sequences=mock(DocumentSequenceRepository.class); + var counters=new ConcurrentHashMap(); + lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet()); + return new FirstlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC)); + } +} diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 2ac4107d3..13bab30cc 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -15,7 +15,7 @@ ### 预约、车队与导出依赖收口(2026-10-12) - 预约规划只声明 `AppointmentSourceLinker` 端口;入仓域负责创建入口与关联明细实现,入仓请求声明自己的来源 DTO,再映射到规划模型。公开 JSON、事务及来源校验保持不变,规划包不反向依赖入仓实现。 -- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号保持原 LB/TR 规则,由本域生成;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。 +- 车队商业单使用本域的收货证据和签收快照;派送域实现车队声明的证据端口并提供完整签收事实,车队不再导入派送实体、Mapper 或完成策略。商业单编号沿用 LB/TR 前缀,通过共享编号组件按工作区 `docs/业务单据编号契约.md` 分配,各入口共用本库日流水;`fleet.business` 不反向依赖 `fleet.trip`。既有散板、卡车商业单的同库写入不在此次拆分范围,本次门禁通过不代表完成全域解耦。 - 退货客户引用与转仓 FBA 目录各由本域内部客户端读取原权威服务,移除对车队客户端的借用;接口、令牌及 URL 配置不变。退货合箱共用 PDA 仓身份、转仓实收共用仓储资源网关,两条精确类边在基线中登记。 - 平台导出作为组合入口,仅登记 `LastlegExportProviderService` 对 12 个公开查询服务与 DTO/投影的引用,不开放整个切片;车辆仍限应用层。排车规划适配器只使用车队公开规划和收货端口。采购对海外主数据的旧依赖已不存在,移除该旧许可及入向断言。 - 反向检查继续拒绝新消费者、导出直连持久化、车辆应用层外向依赖和任何主源码包循环。分析器继续按既有显式 import、静态 import 及同包类型引用规则检查;本次不改变其识别范围。 @@ -28,6 +28,10 @@ `fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。 +### 外配业务原排车详情读取边界(2026-10-21) + +`OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。 + ### 平台自营统计边界 `selfoperated/analytics/` 在依赖分析器中作为独立 `selfoperated-analytics` 切片优先匹配,其 `outbound`、`inventory` 和 `model` 子包不归入客户出库、客户库存或尾程交接切片。该子域通过内部 HTTP 使用 Integration,持久化十张独立 `ll_selfop_*` 表,不引用现有客户履约实体或 Mapper。唯一基础能力引用为 `SelfopEgressController → security.InternalServiceTokenGuard`,复用内部令牌校验;基线仅登记此精确类边,不增加共享技术切片通配许可,包循环仍按同一门禁拒绝。 @@ -36,7 +40,7 @@ 车次关联客户排车单新增 `dispatch-delivery→fleet`:fleet声明 `FleetDispatchGateway`,dispatch内适配器锁定自身排车/出库/派送并同步车和司机快照;子排车修改与发车使用fleet公开的关联门禁。fleet不导入dispatch实体、Mapper或业务服务,因此不新增双向切片依赖。车次与客户单据的归属不变,关联和同步处于同一Lastleg事务;客户单据已经开工时不允许通过改派覆盖执行事实。 -当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上节自有运营的五条公开只读引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。 +当前分析器把 `wallet.WalletCommandOutboxService` 归入 `shared-technical`,它依赖尾程Outbox实体/Mapper;粗粒度图因而存在 `fleet→shared-technical→tailleg-handoff→dispatch-delivery→fleet` 聚合环。不能把这一聚合环等同于fleet包或Spring组件循环,也不能宣称切片图无环。门禁检查实际包无环,fleet仅允许上述自有运营的五条公开只读引用及外配详情的一条公开模型引用,dispatch只使用公开端口/门禁;粗粒度切片图保留为耦合报告,不因门禁通过隐去聚合环。 2026-10-02 新增 `fleet` 业务切片,归入当前 Lastleg 部署单元;司机档案与文件适配在 `fleet/driver`,计划与资源占用在 `fleet/trip`,纯资格、分段容量和成本分摊在 `fleet/domain`。fleet依赖技术HTTP配置,以及车辆应用层的 `VehicleRepository` / `VehicleAssignmentGuard` 端口和已有状态服务;新增 `fleet→overseas-master-data` 是同一物理车辆主档和车次占用的明确业务协作,不允许直接依赖车辆持久化适配器。车辆应用层继续只依赖自身端口,注入的占用检查实现归fleet。分析器显式分类并验证这些边界,旧统计及退役安全投影断言不代表当前架构事实。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java index fb6f930eb..3963539c5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DeliveryTransferService.java @@ -132,6 +132,7 @@ public class DeliveryTransferService { @Resource private com.baiyue.lastleg.delivery.repository.DeliveryCancellationRepository cancellations; @Resource private com.baiyue.lastleg.delivery.repository.OutboundQuantityRepository outboundQuantities; + /** 创建一件代发出库任务,使用独立的 DFCK 日流水标识。 */ @Transactional public DeliveryResponses.PdaTask createOneClick(String customerId, String operatorId, DeliveryRequests.OneClickOutbound request) { String requestHash = oneClickRequestHash(request); @@ -145,7 +146,7 @@ public class DeliveryTransferService { String selectedWarehouseId = required(firstPosition.getOverseasWarehouseId(), "库存缺少所属海外仓"); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP", selectedWarehouseId, outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("ONE_CLICK_DROPSHIP")); outbound.setOutboundType("ONE_CLICK_DROPSHIP"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(request.items().stream().mapToInt(i -> i.packages().intValue()).sum()); outbound.setPalletCount(request.items().size()); outbound.setIdempotencyKey(request.idempotencyKey().trim()); outbound.setCreatedBy(operatorId); outbound.setCreatedAt(now); outbound.setUpdatedAt(now); @@ -227,6 +228,7 @@ public class DeliveryTransferService { value(c.getTotalQuantity()), c.getStatus())).toList(); } + /** 校验退货库存后建立出库和派送单,各自分配业务日流水。 */ @Transactional public DeliveryResponses.PdaTask createReturnOutbound(String customerId, String operatorId, DeliveryRequests.ReturnOutbound request) { @@ -279,7 +281,7 @@ public class DeliveryTransferService { } OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND", warehouseId, outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("RETURN_OUTBOUND")); outbound.setOutboundType("RETURN_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo(request.recipient().trim()); outbound.setPackageCount(locked.size()); outbound.setPalletCount("PALLET".equals(request.unitType()) ? 1 : 0); outbound.setOverseasWarehouseId(warehouseId); outbound.setIdempotencyKey(request.idempotencyKey().trim()); @@ -296,7 +298,7 @@ public class DeliveryTransferService { carton.setStatus("OUTBOUND_PENDING"); carton.setUpdatedAt(now); returnCartons.updateById(carton); } DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", warehouseId, delivery.getId())); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY")); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setOverseasWarehouseId(warehouseId); delivery.setDeliveryGroupKey("RETURN:" + request.returnNo().trim()); delivery.setStatus("WAIT_DELIVERY"); delivery.setDeliveryAddress(request.recipient().trim()); @@ -358,6 +360,7 @@ public class DeliveryTransferService { return result; } + /** 为仓储转运建立普通出库和派送单,保留原库存关联。 */ @Transactional public DeliveryResponses.OutboundDetail createWarehouseTransferOutbound(String customerId, String operatorId, DeliveryRequests.WarehouseTransferOutbound request) { @@ -397,7 +400,7 @@ public class DeliveryTransferService { LocalDateTime now = LocalDateTime.now(); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(customerId); - outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", transfer.getOperatingWarehouseId(), outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND")); outbound.setOverseasWarehouseId(transfer.getOperatingWarehouseId()); outbound.setOutboundType("WAREHOUSE_TRANSFER_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setRecipientInfo("PRIVATE".equals(destinationType) @@ -434,7 +437,7 @@ public class DeliveryTransferService { LastlegOrder tailOrder = tailOrders.selectById(transfer.getLastlegOrderId()); DeliveryOrder delivery = new DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(customerId); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", transfer.getOperatingWarehouseId(), delivery.getId())); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY")); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setOverseasWarehouseId(transfer.getOperatingWarehouseId()); delivery.setCustomerName(tailOrder == null ? null : tailOrder.getCustomerName()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java index 46f2689c0..1ea9fd038 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java @@ -130,7 +130,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); var order=new OutboundOrder();order.setId(IdWorker.getId());order.setCustomerId(customer); - order.setOutboundNo(numbers.next("OUTBOUND",context.warehouseId(),order.getId())); + order.setOutboundNo(numbers.next("OUTBOUND")); order.setDispatchOrderId(context.dispatchId());order.setDispatchNo(context.dispatchNo());order.setOverseasWarehouseId(context.warehouseId()); order.setOutboundType("DISPATCH_DELIVERY");order.setStatus("WAIT_OUTBOUND");order.setLxCodes(""); order.setPackageCount(Math.toIntExact(cargo.stream().mapToLong(CargoLine::packages).sum())); @@ -276,7 +276,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { /** 客户、目的地和车辆司机仅来自权威事实,请求不能覆盖本趟资源。 */ private DeliveryOrder delivery(Context context,Group group,ArrangeGroup input,OutboundOrder outbound,String operator,String key,Instant now) { var d=new DeliveryOrder();d.setId(IdWorker.getId());d.setCustomerId(group.customerId());d.setCustomerName(group.customerName()); - d.setDeliveryNo(numbers.next("DELIVERY",context.warehouseId(),d.getId()));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo()); + d.setDeliveryNo(numbers.next("DELIVERY"));d.setOutboundOrderId(outbound.getId());d.setOutboundNo(outbound.getOutboundNo()); d.setDispatchOrderId(context.dispatchId());d.setDispatchNo(context.dispatchNo());d.setOverseasWarehouseId(context.warehouseId());d.setOverseasWarehouseName(context.warehouseName()); var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type()); d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java index d9ef4c468..2dd5503af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundService.java @@ -48,7 +48,7 @@ public class CargoDiscardOutboundService { LocalDateTime at = LocalDateTime.now(); OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); - outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("OUTBOUND")); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType(TYPE); outbound.setLxCodes(null); outbound.setRecipientInfo("废弃单 " + application.getDiscardNo()); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(com.baiyue.lastleg.discard.model.CargoDiscardQuantities.boxes(lines, false)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java index 4b9e3900b..48107fd17 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/discard/service/CargoDiscardService.java @@ -222,7 +222,7 @@ public class CargoDiscardService { if (!StringUtils.hasText(warehouseName)) warehouseName = warehouseNames().get(warehouseId); CargoDiscardApplication application = new CargoDiscardApplication(); application.setId(IdWorker.getId()); application.setCustomerId(customerId); - application.setDiscardNo(orderNoGenerator.next("DISCARD", warehouseId, application.getId())); + application.setDiscardNo(orderNoGenerator.next("DISCARD")); application.setOverseasWarehouseId(warehouseId); application.setOverseasWarehouseName(warehouseName); application.setInboundCodes(java.util.stream.Stream.concat( java.util.stream.Stream.concat(orderCache.values().stream().map(LastlegOrder::getInboundCode), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java index 299ef2d34..06e0e54a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMapper.java @@ -301,8 +301,9 @@ public interface DispatchCandidateMapper { "c.pallet_code AS palletNo,COUNT(DISTINCT c.id) AS packageCount,'RELABEL' AS sourceStage,1 AS returnCartonSource," + "GROUP_CONCAT(DISTINCT c.carton_mark ORDER BY c.carton_mark SEPARATOR ',') AS relabelCartonMarks," + "GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS fbaWarehouseCode," + - "COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.weight) THEN SUM(x.weight) END FROM (SELECT DISTINCT c2.id,c2.weight FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," + - "COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(x.volume) THEN SUM(x.volume) END FROM (SELECT DISTINCT c2.id,(c2.length*c2.width*c2.height)/1000000 AS volume FROM ll_return_inventory_carton c2 JOIN ll_relabel_item ri2 ON ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id JOIN ll_relabel_application a2 ON a2.id=ri2.application_id AND a2.customer_id=ri2.customer_id WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND a2.id=a.id) x),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," + + // 从唯一实箱汇总并用EXISTS核对申请,避免一箱多明细重复计量和跨层相关派生表。 + "COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.weight) THEN SUM(c2.weight) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.weight FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS grossWeightKg," + + "COALESCE((SELECT CASE WHEN COUNT(*)=COUNT(c2.length*c2.width*c2.height) THEN SUM(c2.length*c2.width*c2.height/1000000) END FROM ll_return_inventory_carton c2 WHERE c2.customer_id=a.customer_id AND c2.pallet_code=c.pallet_code AND c2.status='RELABEL_PENDING' AND EXISTS (SELECT 1 FROM ll_relabel_item ri2 WHERE ri2.return_carton_id=c2.id AND ri2.customer_id=c2.customer_id AND ri2.application_id=a.id)),(SELECT rr.length*rr.width*rr.height/1000000 FROM ll_return_receipt rr WHERE rr.customer_id=a.customer_id AND rr.unit_type='PALLET' AND rr.unit_code=c.pallet_code ORDER BY rr.signed_at DESC LIMIT 1)) AS volumeCbm," + "GROUP_CONCAT(DISTINCT ri.destination_fba_warehouse_code ORDER BY ri.destination_fba_warehouse_code SEPARATOR ',') AS destinationWarehouseCode " + "FROM ll_relabel_application a JOIN ll_relabel_item ri ON ri.application_id=a.id AND ri.customer_id=a.customer_id " + "JOIN ll_return_inventory_carton c ON c.id=ri.return_carton_id AND c.customer_id=a.customer_id " + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java index 45db9281c..3e92404a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java @@ -19,6 +19,7 @@ public interface DispatchCandidatePageMapper { /** 复用既有资格SQL,只统一各来源投影的列序,不复制资格或客户隔离规则。 */ final class Sql { private static final String FIELDS="planId,planPalletId,warehousePalletId,orderId,customerId,customerName,planNo,orderType,volumeWeightKg,grossWeightKg,containerNo,appointmentAt,expectedLoadingAt,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,packageCount,volumeCbm,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptId,returnReceiptNo,returnCartonSource"; + private static final Set TEXT_FIELDS=Set.of(("customerId,customerName,planNo,orderType,containerNo,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,orderNo,orderRefs,inboundCode,palletNo,destinationWarehouseId,destinationWarehouseCode,destinationWarehouseName,fbaWarehouseCode,sourceStage,cartonMarks,relabelCartonMarks,destinationType,deliveryAddress,postalCode,contactName,contactPhone,returnReceiptNo").split(",")); private static final Set PLAN_MISSING=Set.of("returnReceiptId","returnReceiptNo","returnCartonSource"); private static final Set RETURN_FIELDS=Set.of(("returnReceiptId,returnReceiptNo,orderId,customerId,customerName,orderType,orderNo,orderRefs,inboundCode,warehousePalletId,palletNo,overseasWarehouseId,overseasWarehouseName,overseasWarehouseCode,destinationType,destinationWarehouseCode,fbaWarehouseCode,destinationWarehouseName,deliveryAddress,postalCode,contactName,contactPhone,sourceStage,packageCount").split(",")); private static final Set NATIVE_FIELDS=Set.of(("customerId,orderId,overseasWarehouseId,overseasWarehouseName,orderType,orderNo,orderRefs,inboundCode,palletNo,packageCount,sourceStage,returnCartonSource,relabelCartonMarks,fbaWarehouseCode,grossWeightKg,volumeCbm,destinationWarehouseCode").split(",")); @@ -27,32 +28,37 @@ public interface DispatchCandidatePageMapper { +"" +""; /** 来源字段缺省仅表示该来源没有此字段,不伪造业务编号或实托身份。 */ - private static String project(String body,Set fields) { - return "SELECT "+Arrays.stream(FIELDS.split(",")).map(field->fields.contains(field)?"s."+field: - ("returnCartonSource".equals(field)?"0":"NULL")+" AS "+field).collect(Collectors.joining(",")) + private static String project(String body,Set fields,boolean count) { + return "SELECT "+Arrays.stream((count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS).split(",")).map(field->column(field,fields.contains(field)?"s."+field: + ("returnCartonSource".equals(field)?"0":"NULL"))).collect(Collectors.joining(",")) +" FROM ("+body+") s"; } + /** 在UNION前统一文本列,避免表字段、CAST及聚合结果采用不同collation而报1271;数值与时间保持原类型。 */ + private static String column(String field,String expression) { + return (TEXT_FIELDS.contains(field)?"CONVERT("+expression+" USING utf8mb4) COLLATE utf8mb4_unicode_ci":expression)+" AS "+field; + } /** 不同来源用同一组筛选和稳定行标识组合,排序前排除未选来源类型。 */ - private static String rows() { + private static String rows(boolean count) { var planFields=new HashSet<>(Arrays.asList(FIELDS.split(",")));planFields.removeAll(PLAN_MISSING); - return ""+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields) - +"SELECT "+Arrays.stream(FIELDS.split(",")).map(f->"NULL AS "+f).collect(Collectors.joining(","))+" WHERE 1=0" + var fields=count?"overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName,fbaWarehouseCode":FIELDS; + return ""+project(DispatchCandidateMapper.AVAILABLE_PLAN_BODY,planFields,count) + +"SELECT "+Arrays.stream(fields.split(",")).map(f->column(f,"NULL")).collect(Collectors.joining(","))+" WHERE 1=0" +"" - +" UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS)+"" + +" UNION ALL "+project(ReturnDispatchCandidateMapper.AVAILABLE_BODY,RETURN_FIELDS,count)+"" +" UNION ALL " - +project(DispatchCandidateMapper.RELABEL_BODY,planFields)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS)+""; + +project(DispatchCandidateMapper.RELABEL_BODY,planFields,count)+" UNION ALL "+project(DispatchCandidateMapper.NATIVE_RELABEL_BODY,NATIVE_FIELDS,count)+""; } /** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */ - private static String filtered() { - return " FROM ("+rows()+") candidates WHERE overseasWarehouseId=#{query.warehouseId}" + private static String filtered(boolean count) { + return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}" +" AND fbaWarehouseCode=#{query.destinationWarehouseCode}" +" AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'" +" AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'"; } /** 同一事务内统计分页全集。 */ - public static String count(){return "";} + public static String count(){return "";} /** LIMIT发生在任何逐组Java装配之前,最后补来源身份列以保证同名来源不乱序。 */ - public static String page(){return "";} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index bebb00435..e1f647243 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -524,7 +524,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var identity=identity(actor,number,Action.LOADING_COMPLETE,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); if(row.getRecordVersion()!=request.recordVersion().longValue() || !Status.ACTIVE.name().equals(row.getStatus()) || !Stage.LOADING.name().equals(row.getStage()) || !ReviewState.APPROVED.name().equals(row.getReviewState()))throw conflict("当前版本或审核阶段不能确认装车"); - lockExecutionSources(row,repository.cargo(row.getId())); + var items=repository.cargo(row.getId()); + // 未实收/未首拣的PLAN尚未物化实托,先按未核平拒绝,不能将空实托集合传给执行锁。 + if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平"); + lockExecutionSources(row,items); var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 5a6dc34b1..22ee82d6e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -30,7 +30,7 @@ public class DispatchPdaQueryService { require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); } - /** 固定批量查询数量,不因车数、客户数或托盘数执行逐行详情查询。 */ + /** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */ private List assemble(Actor actor,List roots) { if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号"); var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId)); @@ -100,6 +100,7 @@ public class DispatchPdaQueryService { if(rows.size()!=1)return List.of(); var actual=rows.get(0); if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null + || !Objects.equals(actual.planPalletId(),item.getPlanPalletId()) || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) || !Objects.equals(actual.packageCount(),item.getPackageCount()) @@ -112,7 +113,7 @@ public class DispatchPdaQueryService { // 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。 var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection( SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); - if(!group.complete() || group.sources().isEmpty())return List.of(); + if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of(); for(var member:group.sources()) { var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java index 685804d1a..b83ff9ee7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java @@ -418,6 +418,7 @@ public class ExceptionOrderService { tail == null ? inboundOrder.getPackageCount() : tail.getPackageCount(), List.of()); } + /** 保存异常单及来源关系,异常编号使用 EX 日流水。 */ private ExceptionOrderResponses.Created insert(ExceptionOrderResponses.Relation relation, DeliveryOrder delivery, String type, String reason, Integer packages, String sku, String direction, String logisticsNo, String sourceType, @@ -428,7 +429,7 @@ public class ExceptionOrderService { LocalDateTime now = LocalDateTime.now(); ExceptionOrder value = new ExceptionOrder(); value.setId(IdWorker.getId()); value.setCustomerId(relation.customerId()); - value.setExceptionNo(orderNoGenerator.next("EXCEPTION", relation.overseasWarehouseId(), value.getId())); + value.setExceptionNo(orderNoGenerator.next("EXCEPTION")); value.setExceptionType(type); value.setStatus("PENDING"); value.setReason(trim(reason)); value.setLastMileOrderId(relation.lastMileOrderId()); value.setLastMileOrderNo(relation.lastMileOrderNo()); value.setInboundOrderId(relation.inboundOrderId()); value.setInboundCode(relation.inboundCode()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java index 0d0c5850d..5a282056f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnService.java @@ -57,7 +57,7 @@ public class ReturnCartonReturnService { var pallet=pallets.resolve(input.targetPalletNo()); var location=warehouse.resolveLocation(Long.parseLong(order.getOverseasWarehouseId()),input.targetLocationCode()); if(pallet==null||pallet.palletId()!=input.targetPalletId()||!String.valueOf(pallet.warehouseId()).equals(order.getOverseasWarehouseId())||!Set.of("IDLE","OCCUPIED").contains(pallet.status())||location==null||location.locationId()!=input.targetLocationId()||!Objects.equals(pallet.locationId(),location.locationId())||!Set.of("ACTIVE","IDLE","OCCUPIED").contains(location.status())||"VIRTUAL".equalsIgnoreCase(location.locationType()))throw conflict("目标托盘、库位或海外仓不匹配,不能确认原箱回仓"); - LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT",order.getOverseasWarehouseId(),IdWorker.getId());boolean first=true; + LocalDateTime now=LocalDateTime.now();String receiptNo=numbers.next("RETURN_RECEIPT");boolean first=true; for(var movement:shipped) { repository.restore(movement); var receipt=new ReturnCartonStockReceipt();receipt.setId(IdWorker.getId());receipt.setReceiptNo(receiptNo);receipt.setCustomerId(order.getCustomerId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java index d9c2ee625..2de1179e7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridge.java @@ -22,13 +22,13 @@ import static com.baiyue.lastleg.fleet.business.FleetPoolOrderModels.*; public class FleetPoolOrderBridge { @jakarta.annotation.Resource private FleetLooseboardDemandRepository demands; private final FleetPoolOrderRepository repository; - private final FleetPoolOrderNumberGenerator numbers; + private final com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers; private final ObjectMapper json; private final FleetPoolDeliveryEvidence deliveryEvidence; private final com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources; /** 注入同库商业委托仓储及履约协作服务。 */ public FleetPoolOrderBridge(FleetPoolOrderRepository repository, - FleetPoolOrderNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, + com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers,ObjectMapper json,FleetPoolDeliveryEvidence deliveryEvidence, com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources) { this.repository=repository; this.numbers=numbers;this.json=json;this.deliveryEvidence=deliveryEvidence; @@ -53,7 +53,7 @@ public class FleetPoolOrderBridge { var now=LocalDateTime.now();var row=new FleetPoolOrderLink(); row.setId(IdWorker.getId());row.setBookingId(in.bookingId());row.setBookingNo(in.bookingNo()); row.setCustomerId(in.customerId());row.setBusinessType(in.businessType());row.setBusinessOrderId(source==null?IdWorker.getId():source.getId()); - row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TRUCK":"LOOSEBOARD",in.destinationLabel(),row.getBusinessOrderId()):source.getOrderNo()); + row.setBusinessOrderNo(source==null?numbers.next("US_TRUCK".equals(in.businessType())?"TR":"LB"):source.getOrderNo()); row.setWarehouseId(in.warehouseId());row.setDestinationKey(in.destinationKey()); row.setDeliveryMode(in.deliveryMode());row.setDeliveryInstructions(in.deliveryInstructions()); row.setFulfillmentStage("WAITING_INBOUND");row.setStageOccurredAt(now);row.setRecordVersion(0L); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java deleted file mode 100644 index 894686ddb..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGenerator.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.baiyue.lastleg.fleet.business; - -import org.springframework.stereotype.Component; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Locale; -import java.util.regex.Pattern; - -/** Commercial pool orders own LB/TR numbering; no tail-leg order sequence is consumed. */ -@Component -public class FleetPoolOrderNumberGenerator { - private static final Pattern LOCATION=Pattern.compile("LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD"); - public String next(String businessType,String destination,long uniqueId){ - String prefix=switch(businessType){case "TRUCK"->"TR";case "LOOSEBOARD"->"LB";default->throw new IllegalArgumentException("Unsupported pool business type");}; - return prefix+"-"+location(destination)+"-"+LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) - +"-"+String.format(Locale.ROOT,"%06d",Math.floorMod(uniqueId,1_000_000)); - } - private static String location(String value){ - String code=value==null?"":value.trim().toUpperCase(Locale.ROOT);var match=LOCATION.matcher(code); - if(Pattern.matches(".*("+LOCATION.pattern()+").*",code)&&match.find())return match.group(); - return code.matches("[A-Z0-9]{2,8}")?code:"WH"; - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java index 93ec5c040..025f756c5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareService.java @@ -22,6 +22,8 @@ import java.util.*; @Service public class VehicleCareService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final VehicleCareMapper records; private final VehicleCareHistoryMapper histories; private final VehicleRepository vehicles; @@ -41,6 +43,7 @@ public class VehicleCareService { return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } public VehicleCareModels.Response detail(long vehicleId, String number) { return response(required(vehicleId, number, false)); } + /** 建立车辆运维记录,首次建单分配 VR 编号。 */ @Transactional public VehicleCareModels.Response create(long vehicleId, String operator, VehicleCareModels.Create request) { // Serialize creation per vehicle; no empty-range locks or duplicate creation after a lost response. @@ -53,7 +56,7 @@ public class VehicleCareService { } validate(request.details()); var row = new VehicleCareEntity(); row.setId(IdWorker.getId()); - row.setRecordNo("VR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); + row.setRecordNo(documentNumbers.next("VR")); row.setVehicleId(vehicleId); row.setVehiclePlate(vehicle.plateNumber()); row.setRecordType(request.details().recordType()); row.setOccurredAt(utc(request.details().occurredAt())); row.setDetailsJson(write(request.details())); row.setFilesJson("[]"); row.setIdempotencyKey(request.idempotencyKey()); row.setRequestHash(fingerprint); row.setRecordVersion(0L); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index fdf8ad43d..6d84bf475 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -26,6 +26,8 @@ import java.util.Objects; @Service public class FleetBookingService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final FleetTripMapper trips; private final FleetBookingMapper bookings; private final FleetBookingReservations reservations; @@ -56,13 +58,14 @@ public class FleetBookingService { var context=quoteContext.resolve(trip(tripNo,false),query.customerId(),query.destinationKey(),query.currency(),query.priceBasis()); return quotes.candidates(new FleetQuoteCandidateModels.Query(context.customerId(),context.originCity(),context.destinationCode(),context.currency(),context.effectiveOn(),context.priceBasis(),query.page(),query.size())); } + /** 在车次内登记预订,首次请求分配 FB 业务号。 */ @Transactional public FleetBookingModels.Result create(String tripNo,String operator,FleetBookingModels.Create request){ var trip=trip(tripNo,true);String hash=hash(request); var existing=bookings.created(trip.getId(),operator,request.idempotencyKey()); if(existing!=null){if(!hash.equals(existing.getRequestHash()))throw conflict("同一登记请求不能变更客户或货物内容");return result(trip,existing,true);} requireTrip(trip,request.tripRecordVersion());var customer=customers.required(request.customerId()); - var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo("FB-"+row.getId());row.setTripId(trip.getId());row.setTripNo(tripNo); + var row=new FleetBookingEntity();row.setId(IdWorker.getId());row.setBookingNo(documentNumbers.next("FB"));row.setTripId(trip.getId());row.setTripNo(tripNo); row.setCustomerId(customer.id());row.setCustomerCode(customer.code());row.setCustomerName(customer.name());row.setStatus("DRAFT"); apply(trip,row,request.cargo());applyQuote(trip,row,request.quoteRef());row.setIdempotencyKey(request.idempotencyKey());row.setRequestHash(hash);row.setRecordVersion(0L); row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedBy(operator);row.setUpdatedAt(row.getCreatedAt()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java index eb0ca1ca7..84dece6fd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferService.java @@ -25,6 +25,8 @@ import java.util.*; @Service public class FleetCapacityOfferService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService demands; public static final String LIST="platform:fleet:capacity-offer:list", MANAGE="platform:fleet:capacity-offer:manage"; private final FleetCapacityOfferMapper offers; @@ -131,6 +133,7 @@ public class FleetCapacityOfferService { var row=offer(number,false);requireVisible(row);customers.required(query.customerId()); return bookingService.quoteCandidates(row.getTripNo(),query); } + /** 发布车队运力并分配 FO 日流水,重放复用原发布记录。 */ @Transactional public FleetCapacityOfferModels.OfferResult create(FleetCapacityOfferModels.Write request){ requireManage();var trip=trip(request.tripNo(),true); @@ -139,7 +142,7 @@ public class FleetCapacityOfferService { tripVersion(trip,request.tripRecordVersion()); if(offers.byTrip(trip.getId())!=null)throw conflict("该车次已有发布,请从既有发布修改"); validateCutoff(trip,request.cutoffAt()); - var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo("FO-"+row.getId());row.setTripId(trip.getId());row.setTripNo(trip.getTripNo()); + var row=new FleetCapacityOfferEntity();row.setId(IdWorker.getId());row.setOfferNo(documentNumbers.next("FO"));row.setTripId(trip.getId());row.setTripNo(trip.getTripNo()); row.setStatus("DRAFT");row.setRecordVersion(0L);row.setCreatedBy(operator());row.setCreatedAt(now());apply(row,request); if(offers.insert(row)!=1)throw conflict("发布创建失败"); var result=new FleetCapacityOfferModels.OfferResult(project(row,trip),false);saveCommand(trip,"CREATE",trip.getTripNo(),request.idempotencyKey(),request,result);return result; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 8f26fd1ce..98c5886db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -27,6 +27,8 @@ import java.util.*; @Service public class FleetTripService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + @org.springframework.beans.factory.annotation.Autowired(required=false) private org.springframework.context.ApplicationEventPublisher transportEvents; @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workChanges; @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; @@ -114,7 +116,7 @@ public class FleetTripService { var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); var trip = new FleetTripEntity(); trip.setId(IdWorker.getId()); - trip.setTripNo("TR-" + new java.math.BigInteger(UUID.randomUUID().toString().replace("-", ""), 16).toString(36).toUpperCase(Locale.ROOT)); + trip.setTripNo(documentNumbers.next("CT")); trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(request.commandHash()); trip.setStatus("PLANNED"); trip.setTripPurpose("DELIVERY"); trip.setWarehouseId(request.warehouseId()); trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); @@ -390,7 +392,7 @@ public class FleetTripService { var warehouse = warehouses.required(request.warehouseId(), "OVERSEAS"); var trip = new FleetTripEntity(); trip.setId(IdWorker.getId()); - trip.setTripNo("TR-" + UUID.randomUUID().toString().replace("-", "").substring(0, 16).toUpperCase(Locale.ROOT)); + trip.setTripNo(documentNumbers.next("CT")); trip.setIdempotencyKey(request.idempotencyKey()); trip.setRequestHash(hash); trip.setTripPurpose(purpose); trip.setStatus("PLANNED"); trip.setWarehouseId(Long.valueOf(warehouse.id())); trip.setWarehouseCode(warehouse.code()); trip.setWarehouseName(warehouse.name()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java index 2a56165fe..7a1cca23d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java @@ -179,15 +179,14 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .header("X-Internal-Service-Token", internalToken).retrieve() .onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); }) .body(WarehousePalletResponse.class); - if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, - "Warehouse pallet service returned no usable response"); + requirePalletResponse(response); return new WarehousePalletReference(response.data().id(), response.data().palletCode(), response.data().warehouseId(), response.data().locationId(), response.data().status(), response.data().fbaWarehouseCode(), response.data().unitPrice(), response.data().version()); } private ResponseStatusException palletLookupFailure(HttpStatusCode status) { - return new ResponseStatusException(status.value() == 404 ? HttpStatus.NOT_FOUND + return new ResponseStatusException(status.value() == 409 ? HttpStatus.CONFLICT : status.value() == 404 ? HttpStatus.NOT_FOUND : (status.is4xxClientError() ? HttpStatus.BAD_REQUEST : HttpStatus.SERVICE_UNAVAILABLE), status.value() == 404 ? "Warehouse pallet was not found" : "Warehouse pallet lookup failed"); } @@ -199,14 +198,23 @@ public class HttpWarehouseResourceGateway implements WarehouseResourceGateway { .retrieve().onStatus(HttpStatusCode::isError, (request, result) -> { throw palletLookupFailure(result.getStatusCode()); }).body(WarehousePalletResponse.class); + requirePalletResponse(response); + return response; + } + + /** 托盘状态冲突是业务拒绝,不能把HTTP 200内的A0409误报为仓储服务不可用。 */ + private void requirePalletResponse(WarehousePalletResponse response) { + if (response != null && "A0409".equals(response.code())) { + throw new ResponseStatusException(HttpStatus.CONFLICT, + response.message() == null || response.message().isBlank() ? "Warehouse pallet state conflicts with this operation" : response.message()); + } if (response != null && "A0404".equals(response.code())) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Warehouse pallet was not found"); } - if (response == null || response.data() == null) { + if (response == null || !"200".equals(response.code()) || response.data() == null) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "Warehouse pallet service returned no usable response"); } - return response; } private WarehousePalletReference reference(WarehousePalletData pallet) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java index 13e3782bc..51cc720d8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinator.java @@ -292,7 +292,7 @@ public class ContainerInboundPlanConversionCoordinator { ? plans.existingPlanIdentity(order.customerId(), Long.parseLong(order.orderId())) : null; long planId = existing == null ? IdWorker.getId() : existing.planId(); String planNo = existing == null - ? numbers.next("INBOUND_PLAN", order.destinationWarehouseCode(), planId) : existing.planNo(); + ? numbers.next("INBOUND_PLAN") : existing.planNo(); if (existing != null && existing.palletCount() != targetPalletCount) { throw new ResponseStatusException(HttpStatus.CONFLICT, "人工计划托数与柜级预览已不一致"); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java index cec90b860..b58948ce6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentService.java @@ -23,6 +23,8 @@ import java.util.stream.Collectors; @Service public class InboundFbaAppointmentService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + @Resource private InboundFbaAppointmentMapper appointmentMapper; @Resource private InboundFbaAppointmentItemMapper itemMapper; @Resource private InboundFbaAppointmentQueryMapper queryMapper; @@ -76,6 +78,7 @@ public class InboundFbaAppointmentService { new AppointmentCoverageService.SourceRef(source.customerId(),source.sourceKey(),source.destinationKey())).toList(), request.originWarehouseId(),request.fbaWarehouseCode(),request.details())); } + /** 校验预约覆盖的来源单据,首次创建时分配 YY 预约号。 */ private FbaAppointmentResponse create(String operator,List refs,String expectedOrigin,String expectedDestination,FbaAppointmentDetails details) { var resolved=resolveForWrite(refs); var group=group(resolved); @@ -83,7 +86,7 @@ public class InboundFbaAppointmentService { if(!Objects.equals(group.originWarehouseId(),expectedOrigin) || !Objects.equals(group.destinationKey(),"FBA:"+expectedDestination)) throw conflict("接仓点或目的仓已变化,请重新核对预约货物"); var appointment=new InboundFbaAppointment(); appointment.setId(IdWorker.getId()); - appointment.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + appointment.setAppointmentNo(documentNumbers.next("YY")); appointment.setOriginWarehouseId(group.originWarehouseId()); appointment.setFbaWarehouseCode(resolved.get(0).destinationWarehouseCode()); appointment.setSelectedCount(resolved.size());appointment.setVersion(0); @@ -261,6 +264,7 @@ public class InboundFbaAppointmentService { appointment.setAppointmentStartUtc(null);appointment.setAppointmentEndUtc(null); } + /** 重新分组预约来源,新建预约分配 YY 编号并保留覆盖约束。 */ @Transactional(isolation=org.springframework.transaction.annotation.Isolation.READ_COMMITTED) public RepartitionFbaAppointmentsResponse repartition(String operator,RepartitionFbaAppointmentsRequest request) { validateRepartition(request); @@ -330,7 +334,7 @@ public class InboundFbaAppointmentService { var rows=batch.allocations().stream().map(a->bySource.get(a.sourceKey())).toList(); var quantities=batch.allocations().stream().collect(Collectors.toMap(RepartitionFbaAppointmentsRequest.Allocation::sourceKey,RepartitionFbaAppointmentsRequest.Allocation::packageCount)); var row=new InboundFbaAppointment();row.setId(IdWorker.getId()); - row.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + row.setAppointmentNo(documentNumbers.next("YY")); row.setOriginWarehouseId(grouped.originWarehouseId());row.setFbaWarehouseCode(rows.get(0).destinationWarehouseCode());row.setSelectedCount(rows.size());row.setVersion(0); row.setCreatedBy(operator);row.setCreatedAt(now());row.setUpdatedAt(row.getCreatedAt()); apply(row,new FbaAppointmentDetails(batch.plannedDate(),null,null,null,null,null,"PENDING",batch.remark(),null,null),rows,evidence); @@ -471,7 +475,7 @@ public class InboundFbaAppointmentService { } if(laterAmounts.isEmpty()) throw bad("本次货量没有变化,无需创建变化记录"); var laterRows=resolved.stream().filter(s->laterAmounts.containsKey(s.sourceKey())).toList(); - var later=new InboundFbaAppointment();later.setId(IdWorker.getId());later.setAppointmentNo("AP-"+LocalDate.now(ZoneOffset.UTC).toString().replace("-","")+"-"+UUID.randomUUID().toString().replace("-","").substring(0,12).toUpperCase(Locale.ROOT)); + var later=new InboundFbaAppointment();later.setId(IdWorker.getId());later.setAppointmentNo(documentNumbers.next("YY")); later.setOriginWarehouseId(original.getOriginWarehouseId());later.setFbaWarehouseCode(original.getFbaWarehouseCode());later.setVersion(0);later.setSelectedCount(laterRows.size());later.setCreatedBy(operator);later.setCreatedAt(now());later.setUpdatedAt(now()); apply(later,new FbaAppointmentDetails(intent.laterDate(),null,null,null,null,null,"PENDING",intent.reason(),null,null),laterRows,evidence); appointmentMapper.insert(later);writeItems(later,laterRows,List.of(),evidence,laterAmounts); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java index 0713ee552..9d2786b62 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/service/InboundPlanService.java @@ -456,6 +456,7 @@ public class InboundPlanService { receivingWarehouse, plannedArrivalDate, null, Map.of()); } + /** 创建入仓计划,复用预分配编号或首次分配 IP 日流水。 */ private InboundPlanResponse createPlan(String customerId, Long selectedOrderId, List orders, List sourcePalletNos, List wholePallets, ManualInboundPlanRequest manual, @@ -468,7 +469,7 @@ public class InboundPlanService { InboundPlan plan = new InboundPlan(); plan.setId(preparedPlan == null ? IdWorker.getId() : preparedPlan.planId()); plan.setPlanNo(preparedPlan == null - ? orderNoGenerator.next("INBOUND_PLAN", orders.get(0).destinationWarehouseCode(), plan.getId()) + ? orderNoGenerator.next("INBOUND_PLAN") : preparedPlan.planNo()); plan.setCustomerId(customerId); plan.setSourceOrderId(selectedOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java index 1ba167f07..f1c5d0359 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardService.java @@ -63,7 +63,7 @@ public class LocalLooseboardService { LocalLooseboardOrder order = new LocalLooseboardOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); - order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", request.destination(), order.getId())); + order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD")); apply(order, request.origin(), request.originAddress(), request.destination(), request.destinationAddress(), request.productName(), request.estimatedPalletCount(), request.packageCount(), request.totalWeightKg(), request.totalVolumeCbm(), request.budgetAmount(), @@ -276,7 +276,7 @@ public class LocalLooseboardService { private LastlegOrder createLastlegOrder(LocalLooseboardOrder source, String requestHash, LocalDateTime now) { LastlegOrder order = new LastlegOrder(); order.setId(IdWorker.getId()); - order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD", source.getDestination(), order.getId())); + order.setOrderNo(lastlegOrderNoGenerator.next("LOOSEBOARD")); order.setCustomerId(source.getCustomerId()); order.setOrderType("LOCAL_LOOSEBOARD"); order.setInboundCode(null); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java deleted file mode 100644 index 7f18718cd..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderNoDailySequenceMapper.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.baiyue.lastleg.mapper; - -import org.apache.ibatis.annotations.Insert; -import org.apache.ibatis.annotations.Mapper; -import org.apache.ibatis.annotations.Param; -import org.apache.ibatis.annotations.Select; - -import java.time.LocalDate; - -@Mapper -public interface LastlegOrderNoDailySequenceMapper { - @Insert("INSERT INTO ll_order_no_daily_sequence(business_date,last_sequence) " + - "VALUES(#{businessDate},LAST_INSERT_ID(1)) " + - "ON DUPLICATE KEY UPDATE last_sequence=LAST_INSERT_ID(last_sequence+1)") - int nextValue(@Param("businessDate") LocalDate businessDate); - - @Select("SELECT LAST_INSERT_ID()") - long currentValue(); -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java index ac50d6b2f..520b2fcff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/entity/OverseasProcurementFee.java @@ -33,6 +33,7 @@ public class OverseasProcurementFee { private Long sourceFeeId; private Long parentFeeId; private String requestHash; + private String idempotencyKey; private String feeItemCode; private String feeItemName; private BigDecimal amount; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java index bc2e8f7ad..40193e3fa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/mapper/OverseasProcurementFeeMapper.java @@ -3,6 +3,9 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; import org.apache.ibatis.annotations.Mapper; @Mapper public interface OverseasProcurementFeeMapper extends BaseMapper { + /** 调用方持有订单锁时,读取当前退款版本上界。 */ + @org.apache.ibatis.annotations.Select("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=#{orderId} AND purpose='REFUND'") + int maxRefundVersion(@org.apache.ibatis.annotations.Param("orderId") long orderId); @org.apache.ibatis.annotations.Select("SELECT * FROM ll_overseas_procurement_fee WHERE id=#{id} AND order_id=#{orderId} AND customer_id=#{customerId} FOR UPDATE") OverseasProcurementFee lock(@org.apache.ibatis.annotations.Param("id") long id, @org.apache.ibatis.annotations.Param("orderId") long orderId, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java new file mode 100644 index 000000000..52e24df0d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/repository/ProcurementFeeNumberRepository.java @@ -0,0 +1,27 @@ +package com.baiyue.lastleg.procurement.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baiyue.lastleg.procurement.entity.OverseasProcurementFee; +import com.baiyue.lastleg.procurement.mapper.OverseasProcurementFeeMapper; +import org.springframework.stereotype.Repository; + +/** 采购退款的请求身份独立于展示单号,跨日重试仍定位原费用。 */ +@Repository +public class ProcurementFeeNumberRepository { + private final OverseasProcurementFeeMapper mapper; + + /** 使用采购费用所属服务的 Mapper。 */ + public ProcurementFeeNumberRepository(OverseasProcurementFeeMapper mapper) { this.mapper = mapper; } + + /** 上层已持有订单锁,同客户同订单的退款请求只对应一个费用。 */ + public OverseasProcurementFee findRefund(String customerId, long orderId, String key) { + return mapper.selectOne(new LambdaQueryWrapper() + .eq(OverseasProcurementFee::getCustomerId, customerId) + .eq(OverseasProcurementFee::getOrderId, orderId) + .eq(OverseasProcurementFee::getPurpose, "REFUND") + .eq(OverseasProcurementFee::getIdempotencyKey, key)); + } + + /** 版本沿用同订单退款序列,业务编号不承担费用版本含义。 */ + public int nextRefundVersion(long orderId) { return mapper.maxRefundVersion(orderId) + 1; } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java index 0c9019998..f47a55f0b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/OverseasProcurementService.java @@ -43,7 +43,7 @@ import org.springframework.web.multipart.MultipartFile; @Service public class OverseasProcurementService { - @Resource private ProcurementOrderNumberGenerator orderNoGenerator; + @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator; public static final String REVIEW_PERMISSION = OverseasPermissions.PURCHASE_REVIEW; public static final String MANAGE_PERMISSION = OverseasPermissions.PURCHASE_EDIT; public static final String PENDING_REVIEW = "PENDING_REVIEW"; @@ -61,6 +61,7 @@ public class OverseasProcurementService { @Resource private OverseasProcurementFeeMapper procurementFees; @Resource private ProcurementFileGateway fileGateway; + /** 创建客户采购订单,首次请求分配 CG 日流水。 */ @Transactional public OverseasProcurementResponses.Detail create(String customerId, String operatorId, OverseasProcurementRequests.Create request) { @@ -74,7 +75,7 @@ public class OverseasProcurementService { order.setId(IdWorker.getId()); order.setCustomerId(customerId); order.setCustomerName(trim(request.customerName())); - order.setOrderNo(orderNoGenerator.next(customerId, request.customerName())); + order.setOrderNo(orderNoGenerator.next("CG")); order.setPurchaseCategory(request.purchaseCategory() == null ? "" : request.purchaseCategory().trim()); order.setProductName(request.productName().trim()); order.setProductSpecification(trim(request.productSpecification())); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java index a8a5df697..de6cbda02 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementAdjustmentService.java @@ -16,6 +16,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService @Service @RequiredArgsConstructor public class ProcurementAdjustmentService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final ProcurementLifecycleService lifecycle; private final ProcurementFinanceService finance; private final JdbcTemplate jdbc; @@ -23,6 +25,7 @@ public class ProcurementAdjustmentService { BigDecimal exchangeRate,String exchangeRateMonth,BigDecimal targetAmount,BigDecimal previousNetAmount, BigDecimal differenceAmount,List feeNos) { } private record Identity(long rootFeeId,Correct request) { } + /** 更正原采购费用并以 PG 日流水标识本次更正记录。 */ @Transactional public Result correct(String customer,String orderNo,String operator,long rootFeeId,Correct request) { var order=lifecycle.lock(customer,orderNo);var identity=new Identity(rootFeeId,request); @@ -37,7 +40,7 @@ public class ProcurementAdjustmentService { .subtract(chain.stream().filter(f->"REFUNDED".equals(f.getWalletStatus())).map(OverseasProcurementFee::getAmount).reduce(BigDecimal.ZERO,BigDecimal::add)); BigDecimal target=request.targetOriginalAmount().multiply(root.getExchangeRate()).setScale(2,RoundingMode.HALF_UP),delta=target.subtract(net); if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围"); - long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + long id=IdWorker.getId();String no=documentNumbers.next("PG"); // The node command carries the exact request hash; this table stores the computed immutable monetary facts. jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", id,order.getId(),customer,no,rootFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),root.getOriginalCurrency(),root.getExchangeRate(),root.getExchangeRateMonth(),target,net,delta,request.voucherFileId(),request.reason(),operator); @@ -47,6 +50,7 @@ public class ProcurementAdjustmentService { lifecycle.complete(order,operator,"CORRECT_FEE_TARGET",request.idempotencyKey(),request.reason(),identity); return result(order.getId(),request.idempotencyKey()); } + /** 替换失败费用,保留原费用链并分配新的 PG 更正记录号。 */ @Transactional public Result replaceFailed(String customer,String orderNo,String operator,long originalFeeId,Correct request) { var order=lifecycle.lock(customer,orderNo);var identity=new Identity(originalFeeId,request); @@ -57,7 +61,7 @@ public class ProcurementAdjustmentService { BigDecimal target=request.targetOriginalAmount().multiply(original.getExchangeRate()).setScale(2,RoundingMode.HALF_UP); if("SAMPLE_PAYMENT".equals(original.getPurpose()))lifecycle.requireReplacementBudget(order,request.targetOriginalAmount(),original.getOriginalCurrency(),original.getBusinessDate(),target); if(target.precision()-target.scale()>18)throw bad("折算人民币金额超出支持范围"); - long id=IdWorker.getId();String no="PC-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + long id=IdWorker.getId();String no=documentNumbers.next("PG"); jdbc.update("INSERT INTO ll_procurement_fee_correction(id,order_id,customer_id,correction_no,root_fee_id,idempotency_key,request_hash,target_original_amount,original_currency,exchange_rate,exchange_rate_month,target_amount,previous_net_amount,difference_amount,voucher_file_id,reason,created_by) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", id,order.getId(),customer,no,originalFeeId,request.idempotencyKey(),lifecycle.requestHash(identity),request.targetOriginalAmount(),original.getOriginalCurrency(),original.getExchangeRate(),original.getExchangeRateMonth(),target,BigDecimal.ZERO,target,request.voucherFileId(),request.reason(),operator); var fee=finance.replaceFailed(customer,orderNo,operator,originalFeeId,request.targetOriginalAmount(),request.voucherFileId(),request.reason()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java index c5452b234..678b5e980 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementControlService.java @@ -13,6 +13,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService @Service @RequiredArgsConstructor public class ProcurementControlService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final ProcurementLifecycleService lifecycle; private final ProcurementFinanceService finance; private final ProcurementWarehouseDirectory warehouses; @@ -141,5 +143,6 @@ public class ProcurementControlService { return result; } private String serialize(Object value){if(value==null)return null;try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException(e);}} - private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();} + /** 说明和关闭记录使用各自独立的日流水。 */ + private String number(String prefix){return documentNumbers.next(prefix);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java index 0aeafce03..9f77bdc76 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementFinanceService.java @@ -38,6 +38,9 @@ import java.util.UUID; @Service @RequiredArgsConstructor public class ProcurementFinanceService implements WalletResultHandler { + @jakarta.annotation.Resource private com.baiyue.lastleg.procurement.repository.ProcurementFeeNumberRepository feeNumbers; + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final OverseasProcurementFeeMapper fees; private final OverseasProcurementFileMapper files; private final OverseasProcurementOrderMapper orders; @@ -118,7 +121,7 @@ public class ProcurementFinanceService implements WalletResultHandler { LocalDateTime now = LocalDateTime.now(); OverseasProcurementFee fee = new OverseasProcurementFee(); fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(order.getCustomerId()); - fee.setFeeNo("PF-" + UUID.randomUUID().toString().replace("-", "").substring(0, 24).toUpperCase()); + fee.setFeeNo(documentNumbers.next("PF")); fee.setPurpose(request.purpose().name()); fee.setConfirmationOrigin(origin); fee.setFeeVersion(maxVersion == null ? 1 : maxVersion + 1); fee.setActive(true); fee.setDetermination(request.determination()); fee.setFeeItemCode(code); fee.setFeeItemName(request.purpose() == ProcurementFeePurpose.OTHER ? request.feeItemName().trim() : request.purpose().label()); @@ -205,6 +208,7 @@ public class ProcurementFinanceService implements WalletResultHandler { var order = lockOrder(customerId, orderNo); return refundLocked(order,operatorId,originalFeeId,amount,voucherFileId,reason,idempotencyKey,true); } + /** 订单锁内先恢复同幂等键退款,首次请求校验后才分配独立 PF 业务号。 */ private OverseasProcurementFee refundLocked(OverseasProcurementOrder order,String operatorId,long originalFeeId, BigDecimal amount,long voucherFileId,String reason,String idempotencyKey,boolean checkChain) { String customerId=order.getCustomerId(),orderNo=order.getOrderNo(); @@ -212,11 +216,8 @@ public class ProcurementFinanceService implements WalletResultHandler { var request = new Confirmation(ProcurementFeePurpose.REFUND, "REFUND", "退款", "POSITIVE", amount, "CNY", LocalDate.now(), voucherFileId, reason); validate(request); - // Identity is independent of wall-clock time, so retrying after midnight returns the same refund. - String number = "RF-" + UUID.nameUUIDFromBytes((customerId + ':' + orderNo + ':' + idempotencyKey) - .getBytes(java.nio.charset.StandardCharsets.UTF_8)).toString().replace("-", "").toUpperCase(); String requestHash = hash(List.of(originalFeeId, amount, voucherFileId, reason)); - var existing = fees.selectOne(new LambdaQueryWrapper().eq(OverseasProcurementFee::getFeeNo, number)); + var existing = feeNumbers.findRefund(customerId, order.getId(), idempotencyKey); if (existing != null) { if (!requestHash.equals(existing.getRequestHash())) throw conflict("退款幂等键对应不同原扣款或金额"); return existing; @@ -237,10 +238,11 @@ public class ProcurementFinanceService implements WalletResultHandler { if (already.add(amount).compareTo(original.getAmount()) > 0) throw bad("退款超过原扣款可退余额"); var voucher = voucher(order, request, true); var fee = new OverseasProcurementFee(); + String number = documentNumbers.next("PF"); + fee.setIdempotencyKey(idempotencyKey); fee.setId(IdWorker.getId()); fee.setOrderId(order.getId()); fee.setCustomerId(customerId); fee.setFeeNo(number); fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("REFUND"); fee.setFeeItemCode("REFUND"); fee.setFeeItemName("关联费用退款"); - Integer last = jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0) FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='REFUND'", Integer.class, order.getId()); - fee.setFeeVersion(last == null ? 1 : last + 1); fee.setActive(true); fee.setDetermination("POSITIVE"); + fee.setFeeVersion(feeNumbers.nextRefundVersion(order.getId())); fee.setActive(true); fee.setDetermination("POSITIVE"); fee.setAmount(amount); fee.setCurrency("CNY"); fee.setOriginalAmount(amount); fee.setOriginalCurrency("CNY"); fee.setExchangeRate(BigDecimal.ONE); fee.setExchangeRateMonth(request.businessDate().toString().substring(0, 7)); fee.setBusinessDate(request.businessDate()); fee.setParentFeeId(originalFeeId); fee.setWalletBusinessId(fee.getId()); @@ -272,7 +274,7 @@ public class ProcurementFinanceService implements WalletResultHandler { requireSourceCapacity(delta,delta); var proof=voucher(order,new Confirmation(ProcurementFeePurpose.ADJUSTMENT,"ADJUSTMENT","追加费用","POSITIVE",delta,"CNY",root.getBusinessDate(),voucherId,reason),true); var fee=new OverseasProcurementFee();fee.setId(IdWorker.getId());fee.setOrderId(order.getId());fee.setCustomerId(customer); - fee.setFeeNo("PF-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase());fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT"); + fee.setFeeNo(documentNumbers.next("PF"));fee.setConfirmationOrigin("FINANCIAL");fee.setPurpose("ADJUSTMENT"); fee.setFeeItemCode("ADJ_"+root.getId());fee.setFeeItemName("原费用正额调整"); fee.setFeeVersion(jdbc.queryForObject("SELECT COALESCE(MAX(fee_version),0)+1 FROM ll_overseas_procurement_fee WHERE order_id=? AND purpose='ADJUSTMENT' AND fee_item_code=?",Integer.class,order.getId(),fee.getFeeItemCode())); fee.setActive(true);fee.setDetermination("POSITIVE");fee.setAmount(delta);fee.setCurrency("CNY");fee.setOriginalCurrency("CNY");fee.setOriginalAmount(delta); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java deleted file mode 100644 index da15190a1..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGenerator.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import net.sourceforge.pinyin4j.PinyinHelper; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; - -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import java.util.Locale; - -@Component -public class ProcurementOrderNumberGenerator { - private final JdbcTemplate jdbc; - public ProcurementOrderNumberGenerator(JdbcTemplate jdbc) { this.jdbc = jdbc; } - - @Transactional - public String next(String customerId, String customerName) { - LocalDate date = LocalDate.now(); - jdbc.update("INSERT INTO ll_procurement_daily_sequence(customer_id,business_date,sequence_value) VALUES(?,?,LAST_INSERT_ID(1)) ON DUPLICATE KEY UPDATE sequence_value=LAST_INSERT_ID(sequence_value+1)", - customerId, date); - Integer value = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Integer.class); - if (value == null || value < 1 || value > 9999) throw new IllegalStateException("客户采购订单当日序号已耗尽"); - return initials(customerName) + date.format(DateTimeFormatter.BASIC_ISO_DATE) + String.format("%04d", value); - } - - static String initials(String customerName) { - if (!StringUtils.hasText(customerName)) return "KH"; - StringBuilder result = new StringBuilder(); - boolean latinWord = false; - for (char value : customerName.trim().toCharArray()) { - if (result.length() >= 8) break; - if (Character.UnicodeScript.of(value) == Character.UnicodeScript.HAN) { - String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(value); - if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) result.append(Character.toUpperCase(pinyin[0].charAt(0))); - latinWord = false; - } else if (Character.isLetterOrDigit(value)) { - if (!latinWord) result.append(Character.toUpperCase(value)); - latinWord = true; - } else { - latinWord = false; - } - } - String normalized = result.toString().replaceAll("[^A-Z0-9]", "").toUpperCase(Locale.ROOT); - return normalized.isBlank() ? "KH" : normalized.substring(0, Math.min(normalized.length(), 8)); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java index 00b377863..23e160e5d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalService.java @@ -19,6 +19,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService @Service @RequiredArgsConstructor public class ProcurementPhysicalService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final ProcurementLifecycleService lifecycle; private final ProcurementWarehouseDirectory warehouses; private final ProcurementFinanceService finance; @@ -327,5 +329,6 @@ public class ProcurementPhysicalService { } private static void measure(BigDecimal value,int scale){if(value==null || value.signum()<=0 || value.scale()>scale)throw bad("箱规须为正数且最多两位小数,毛重须为正数且最多三位小数");} private static long add(long a,long b){try{return Math.addExact(a,b);}catch(ArithmeticException e){throw bad("数量超过可处理范围");}} - private static String number(String prefix){return prefix+'-'+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase();} + /** 实收记录使用独立 PR 日流水。 */ + private String number(String prefix){return documentNumbers.next(prefix);} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java index d1b18ee56..eb804e43c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/procurement/service/ProcurementTransportService.java @@ -19,6 +19,8 @@ import static com.baiyue.lastleg.procurement.service.ProcurementLifecycleService /** Every shipment is a single immutable consumption of this order's entire eligible batch. */ @Service @RequiredArgsConstructor public class ProcurementTransportService { + @jakarta.annotation.Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers; + private final ProcurementLifecycleService lifecycle; private final ProcurementPhysicalService physical; private final ProcurementFinanceService finance; @@ -142,6 +144,7 @@ public class ProcurementTransportService { return snapshot(order); } + /** 确认采购交寄并记录 PJ 日流水,保留原单关联与重放结果。 */ @Transactional public Snapshot ship(String customer,String orderNo,String operator,Ship request) { var order=lifecycle.lock(customer,orderNo); @@ -155,7 +158,7 @@ public class ProcurementTransportService { } else { if(request.shippedAt().isBefore(((LocalDateTime)flow.get("domestic_received_at"))))throw bad("交寄时间不能早于国内收货完成"); } - long id=IdWorker.getId();String number="PS-"+UUID.randomUUID().toString().replace("-","").substring(0,24).toUpperCase(); + long id=IdWorker.getId();String number=documentNumbers.next("PJ"); jdbc.update("INSERT INTO ll_procurement_shipment(id,order_id,customer_id,shipment_no,leg,manifest_id,shipped_at,address_snapshot,created_by) VALUES(?,?,?,?,?,?,?,CAST(? AS JSON),?)", id,order.getId(),customer,number,request.leg().name(),labels.manifestId(),request.shippedAt(),serialize(new Addresses(labels.sender(),labels.receiver())),operator); for(var carton:cargo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java index 65b28a945..f18fd781c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/relabel/service/RelabelApplicationService.java @@ -80,7 +80,7 @@ public class RelabelApplicationService implements WalletResultHandler { READY_FOR_OUTBOUND, "COMPLETED", "REJECTED", "CANCELLED"); private static final Set DELIVERY_METHODS = Set.of("TRUCK", "EXPRESS", "AMAZON_PICKUP", "CUSTOMER_PICKUP", "PICKUP"); - @Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @Resource private RelabelApplicationMapper applications; @Resource private com.baiyue.lastleg.relabel.repository.RelabelStatusRepository statusRepository; @Resource private RelabelItemMapper items; @@ -361,6 +361,7 @@ public class RelabelApplicationService implements WalletResultHandler { return create(customerId, operatorId, request, existingTailOrderId); } + /** 创建换标申请,使用 TB 日流水并保留来源货物关联。 */ private RelabelResponses.Detail create(String customerId, String operatorId, RelabelRequests.Create request, Long existingTailOrderId) { if (!StringUtils.hasText(request.operationWarehouseId())) throw badRequest("Operation warehouse is required"); @@ -488,7 +489,7 @@ public class RelabelApplicationService implements WalletResultHandler { LocalDateTime now = LocalDateTime.now(); long applicationId = IdWorker.getId(); String operationWarehouseId = request.operationWarehouseId().trim(); - String relabelNo = compactNumbers.next("TB", customerId); + String relabelNo = compactNumbers.next("TB"); BigDecimal chargeAmount = Objects.requireNonNullElse(request.chargeAmount(), BigDecimal.ZERO); boolean requiresWallet = chargeAmount.signum() > 0; int holdIndex = 0; @@ -1065,6 +1066,7 @@ public class RelabelApplicationService implements WalletResultHandler { return detail(customerId, relabelNo); } + /** 建立换标出库与派送单,分别分配 HBCK 和 DL 业务号。 */ private void createDirectOutbound(RelabelApplication application, String operatorId, LocalDateTime now) { String key = "RELABEL:" + application.getId() + ":OUTBOUND"; OutboundOrder existing = outboundOrders.findByIdempotency(application.getCustomerId(), key); @@ -1103,7 +1105,7 @@ public class RelabelApplicationService implements WalletResultHandler { } OutboundOrder outbound = new OutboundOrder(); outbound.setId(IdWorker.getId()); outbound.setCustomerId(application.getCustomerId()); - outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND", application.getOverseasWarehouseId(), outbound.getId())); + outbound.setOutboundNo(orderNoGenerator.next("RELABEL_OUTBOUND")); outbound.setOverseasWarehouseId(application.getOverseasWarehouseId()); outbound.setOutboundType("RELABEL_OUTBOUND"); outbound.setStatus("WAIT_PICK"); outbound.setPackageCount(relabelItems.stream().mapToInt(item -> item.getReturnCartonId() == null ? intValue(item.getNotifiedPackageCount()) : 1).sum()); outbound.setPalletCount((int) relabelItems.stream().map(this::outboundSourceKey).distinct().count()); @@ -1153,7 +1155,7 @@ public class RelabelApplicationService implements WalletResultHandler { for (var destination : destinations.entrySet()) { var delivery = new com.baiyue.lastleg.delivery.entity.DeliveryOrder(); delivery.setId(IdWorker.getId()); delivery.setCustomerId(application.getCustomerId()); - delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY", application.getOverseasWarehouseId(), delivery.getId())); + delivery.setDeliveryNo(orderNoGenerator.next("DELIVERY")); delivery.setOutboundOrderId(outbound.getId()); delivery.setOutboundNo(outbound.getOutboundNo()); delivery.setOverseasWarehouseId(application.getOverseasWarehouseId()); delivery.setCustomerName(customerName); delivery.setOverseasWarehouseName(warehouseName); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java index 0186a7b4c..c1156870e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryService.java @@ -189,7 +189,7 @@ public class ReturnInventoryService { List boxes = List.of(new ReturnInventoryRequests.PlanBox( reserved.entrySet().stream().map(e -> new ReturnInventoryRequests.PlanItem(e.getKey(), e.getValue())).toList(), null, null, null, null)); ReturnPackingPlan plan = new ReturnPackingPlan(); - plan.setId(IdWorker.getId()); plan.setPlanNo(numbers.next("RETURN_PACKING_PLAN", first.getOverseasWarehouseId(), plan.getId())); + plan.setId(IdWorker.getId()); plan.setPlanNo(numbers.next("RETURN_PACKING_PLAN")); plan.setCustomerId(first.getCustomerId()); plan.setOverseasWarehouseId(first.getOverseasWarehouseId()); plan.setReturnNosJson(json(returnNos)); plan.setBoxesJson(json(boxes)); plan.setSourceItemsJson(json(allocation)); plan.setMode(mode); plan.setStatus("PENDING_REVIEW"); plan.setRemark(request.remark()); plan.setIdempotencyKey(request.idempotencyKey()); @@ -540,7 +540,7 @@ public class ReturnInventoryService { long cartonId = IdWorker.getId(); // 包裹签收已生成 PK 标签,后续库存、废弃和扫码沿用同一编码。 if ("PARCEL".equals(receipt.getUnitType())) cartonMark = normalizedUnitCode; - else if (cartonMark == null) cartonMark = numbers.next("RETURN_CARTON", order.getOverseasWarehouseId(), cartonId); + else if (cartonMark == null) cartonMark = LastlegOrderNoGenerator.compactCartonMark(cartonId); if (cartons.findMark(cartonMark) != null) throw conflict("箱号已存在: " + cartonMark); LocalDateTime now = LocalDateTime.now(); long total = lines.stream().mapToLong(ReceiptLine::quantity).sum(); @@ -984,6 +984,7 @@ public class ReturnInventoryService { return mergeScoped(identity, request, limits, targetMark, null); } + /** 在当前仓范围执行合箱,目标实物箱码继续使用原标签算法。 */ private ReturnInventoryResponses.MergeResult mergeScoped(PdaWarehouseIdentity identity, ReturnInventoryRequests.Merge request, Map> limits, String targetMark, String generatedMark) { String requestHash = targetMark == null ? hash(request) : hash(List.of(request, targetMark, limits.keySet())); @@ -1122,7 +1123,7 @@ public class ReturnInventoryService { // A merged carton may remain loose; pallet binding is an explicit warehouse operation. target.setSourceUnitType("BOX"); target.setId(targetId); target.setCustomerId(customerId); target.setOverseasWarehouseId(sources.get(0).getOverseasWarehouseId()); - if (existingTarget == null) target.setCartonMark(generatedMark == null ? numbers.next("RETURN_CARTON", target.getOverseasWarehouseId(), targetId) : generatedMark); + if (existingTarget == null) target.setCartonMark(generatedMark == null ? LastlegOrderNoGenerator.compactCartonMark(targetId) : generatedMark); if (existingTarget != null) { if (text(target.getSourceBatchNo()) != null) consumedBatchNos.addAll(java.util.Arrays.asList(target.getSourceBatchNo().split(","))); if (text(target.getSourceReturnNo()) != null) consumedReturnNos.addAll(java.util.Arrays.asList(target.getSourceReturnNo().split(","))); @@ -1276,6 +1277,7 @@ public class ReturnInventoryService { + ""; } private static String dimension(BigDecimal value) { return value == null ? "—" : value.stripTrailingZeros().toPlainString(); } + /** 按合箱分组生成实物箱唛,保留完整 ID 编码和分箱后缀。 */ private List packingMarks(List boxes) { List> groups = boxes.stream().map(box -> box.items() == null ? List.of() : box.items().stream() .map(ReturnInventoryRequests.PlanItem::skuCode).distinct().sorted().toList()).toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java index 655bdefe7..81d9f5a16 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptService.java @@ -62,7 +62,7 @@ public class ReturnReceiptService { public static final String PARTIALLY_RECEIVED = "PARTIALLY_RECEIVED"; public static final String COMPLETED = "COMPLETED"; - @Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @Resource private ReturnOrderMapper orders; @Resource private ReturnOrderItemMapper orderItems; @Resource private ReturnReceiptMapper receipts; @@ -81,6 +81,7 @@ public class ReturnReceiptService { @Resource private com.baiyue.lastleg.service.FbaWarehouseValidator fbaWarehouseValidator; @Resource private com.baiyue.lastleg.service.ReturnOrderReferenceResolver returnOrderReferences; + /** 创建退货订单并分配 TH 日流水,实物标签继续沿用原规则。 */ @Transactional public ReturnOrderResponses.Detail create(String customerId, String operatorId, ReturnOrderRequests.Create request) { String key = text(request.idempotencyKey()); @@ -100,7 +101,7 @@ public class ReturnReceiptService { order.setOverseasWarehouseId(text(request.overseasWarehouseId())); order.setWarehouseContactName(text(request.warehouseContactName())); order.setWarehouseContactPhone(text(request.warehouseContactPhone())); - order.setReturnNo(compactNumbers.next("TT", customerId)); + order.setReturnNo(compactNumbers.next("TH")); order.setCustomerReferenceNo(text(request.customerReferenceNo())); order.setNeedRelabel(request.needRelabel()); order.setStatus(PENDING_REVIEW); order.setExpectedSkuTypes(skuCodes.size()); order.setExpectedQuantity(expected); order.setReceivedQuantity(0L); order.setRemark(text(request.remark())); order.setIdempotencyKey(key); order.setRequestHash(requestHash); @@ -396,6 +397,7 @@ public class ReturnReceiptService { return result; } + /** 从退货创建换标业务并分配 THHB 编号,重放沿用原记录。 */ @Transactional public ReturnOrderResponses.RelabelCreated createRelabel(String customerId, String operatorId, String returnNo, ReturnOrderRequests.RelabelCreate request) { @@ -430,7 +432,7 @@ public class ReturnReceiptService { totalQuantity = Math.addExact(totalQuantity, requested.quantity()); } long appId = IdWorker.getId(); LocalDateTime now = LocalDateTime.now(); - String relabelNo = compactNumbers.next("TT", customerId); + String relabelNo = compactNumbers.next("THHB"); LastlegOrder tail = order.getLastlegOrderId() == null ? null : lastlegOrders.selectById(order.getLastlegOrderId()); if (tail == null) { tail = new LastlegOrder(); tail.setId(IdWorker.getId()); tail.setOrderNo(relabelNo); @@ -569,6 +571,7 @@ public class ReturnReceiptService { return detail(order.getCustomerId(), order.getReturnNo()); } + /** 确认退货实收,新增实物箱码沿用原编码,不使用单据日流水。 */ @Transactional public PdaReturnReceiptResponses.Confirmed confirm(PdaWarehouseIdentity identity, PdaReturnReceiptRequests.Confirm request) { String key = text(request.idempotencyKey()); @@ -643,8 +646,8 @@ public class ReturnReceiptService { long receiptId = IdWorker.getId(); ReturnReceipt receipt = new ReturnReceipt(); receipt.setId(receiptId); receipt.setReturnOrderId(order.getId()); receipt.setCustomerId(order.getCustomerId()); - receipt.setReceiptNo(numbers.next("RETURN_RECEIPT", order.getOverseasWarehouseId(), receiptId)); - receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH", order.getOverseasWarehouseId(), receiptId)); + receipt.setReceiptNo(numbers.next("RETURN_RECEIPT")); + receipt.setReceiptBatchNo(numbers.next("RETURN_BATCH")); receipt.setTrackingNo(trackingNo); receipt.setBoundBy(startedBy); receipt.setStatus("SIGNED"); receipt.setUnitType(unitType); receipt.setUnitCode(unitCode); receipt.setTotalBoxCount(totalBoxCount); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java deleted file mode 100644 index ed45087b1..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/CompactBusinessNumberService.java +++ /dev/null @@ -1,59 +0,0 @@ -package com.baiyue.lastleg.service; - -import net.sourceforge.pinyin4j.PinyinHelper; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Transactional; - -import java.time.Instant; -import java.time.ZoneId; -import java.time.format.DateTimeFormatter; -import java.util.Locale; - -/** Allocates suffix-free document numbers across all service instances. */ -@Component -public class CompactBusinessNumberService { - private static final ZoneId ZONE = ZoneId.of("Asia/Shanghai"); - private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("yyMMddHHmmss", Locale.ROOT).withZone(ZONE); - private final JdbcTemplate jdbc; - private final ReturnOrderReferenceResolver customers; - - public CompactBusinessNumberService(JdbcTemplate jdbc, ReturnOrderReferenceResolver customers) { - this.jdbc = jdbc; - this.customers = customers; - } - - @Transactional - public String next(String prefix, String customerId) { - return allocate(prefix, initials(customers.customerName(customerId)), Instant.now()); - } - - String allocate(String prefix, String initials, Instant now) { - String key = prefix + "-" + initials; - long seconds = now.getEpochSecond(); - // The row lock serializes identical prefixes/initials even across customers and JVMs. - jdbc.update("INSERT INTO ll_compact_number_clock(number_key,last_second) VALUES(?,LAST_INSERT_ID(?)) " - + "ON DUPLICATE KEY UPDATE last_second=LAST_INSERT_ID(GREATEST(last_second+1,?))", key, seconds, seconds); - Long allocated = jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class); - if (allocated == null || allocated < seconds) throw new IllegalStateException("业务编号生成失败"); - return key + "-" + FORMAT.format(Instant.ofEpochSecond(allocated)); - } - - static String initials(String name) { - if (name == null || name.isBlank()) return "KH"; - StringBuilder result = new StringBuilder(); - boolean latinWord = false; - for (char c : name.trim().toCharArray()) { - if (result.length() >= 4) break; - if (Character.UnicodeScript.of(c) == Character.UnicodeScript.HAN) { - String[] pinyin = PinyinHelper.toHanyuPinyinStringArray(c); - if (pinyin != null && pinyin.length > 0) result.append(Character.toUpperCase(pinyin[0].charAt(0))); - latinWord = false; - } else if (c < 128 && Character.isLetterOrDigit(c)) { - if (!latinWord) result.append(Character.toUpperCase(c)); - latinWord = true; - } else latinWord = false; - } - return result.isEmpty() ? "KH" : result.toString(); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index f8ebba53f..3ba5d9878 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -1,122 +1,58 @@ package com.baiyue.lastleg.service; -import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; -import jakarta.annotation.Resource; import org.springframework.stereotype.Component; - import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDate; -import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.Locale; -import java.util.UUID; -import java.math.BigInteger; -import java.nio.ByteBuffer; +/** 海外单据类型映射;头程来源尾程号与实物箱码保持本次确认的范围边界。 */ @Component public class LastlegOrderNoGenerator { - private static final ZoneId BUSINESS_ZONE = ZoneId.of("Asia/Shanghai"); - private static final DateTimeFormatter BUSINESS_DATE = DateTimeFormatter.ofPattern("yyMMdd"); - @Resource private LastlegOrderNoDailySequenceMapper sequenceMapper; + private final LastlegDocumentNumberGenerator documents; - public String next() { - return next(LocalDate.now(BUSINESS_ZONE)); + /** 所有海外业务入口共用所属服务的日流水。 */ + public LastlegOrderNoGenerator(LastlegDocumentNumberGenerator documents) { + this.documents = documents; } - String next(LocalDate businessDate) { - sequenceMapper.nextValue(businessDate); - long sequence = sequenceMapper.currentValue(); - if (sequence < 1 || sequence > 9999) { - throw new IllegalStateException("尾程单日流水超出 0001-9999 范围: " + businessDate); - } - return "LMO-" + BUSINESS_DATE.format(businessDate) + "-" + String.format("%04d", sequence); - } - - public String next(String businessType, String warehouse, long uniqueId) { - String code = businessType == null ? "" : businessType.trim().toUpperCase(Locale.ROOT); - if ("RETURN_CARTON".equals(code)) return compactCartonMark(uniqueId); - String returnPrefix = switch (code) { - case "RETURN" -> "RT"; + /** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */ + public String next(String businessType) { + String prefix = switch (businessType) { + case "ONE_CLICK_DROPSHIP" -> "DFCK"; + case "OUTBOUND" -> "CK"; + case "RELABEL_OUTBOUND" -> "HBCK"; + case "RETURN_OUTBOUND" -> "THCK"; + case "DELIVERY" -> "DL"; + case "TRUCK" -> "TR"; + case "LOOSEBOARD" -> "LB"; + case "EXCEPTION" -> "EX"; + case "DISCARD" -> "CD"; + case "INBOUND_PLAN" -> "IP"; case "RETURN_RECEIPT" -> "RR"; case "RETURN_BATCH" -> "RB"; - case "RETURN_CARTON" -> "RC"; - case "RETURN_RELABEL" -> "RETU"; - default -> null; + case "RETURN_PACKING_PLAN" -> "HX"; + default -> throw new IllegalArgumentException("未登记的海外单据类型:" + businessType); }; - // Return numbers are independent business identifiers, not truncated database IDs. - // Keep every UUID bit; the longest result (RETU + 25 base-36 digits) fits VARCHAR(32). - if (returnPrefix != null) return returnPrefix + "-" + encodeReturnUuid(UUID.randomUUID()); - // Transfer numbers also identify synthetic inbound events globally. Truncating - // a database ID to six digits can collide between unrelated fresh transfers. - if ("TRANSFER".equals(code) || "WAREHOUSE_TRANSFER".equals(code)) { - return "WT-" + encodeReturnUuid(UUID.randomUUID()); - } - String type = normalizeType(businessType); - return type + "-" + warehouseCode(warehouse) + "-" - + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" - + String.format(Locale.ROOT, "%06d", Math.floorMod(uniqueId, 1_000_000)); + return documents.next(prefix); } - static String encodeReturnUuid(UUID uuid) { - byte[] bytes = ByteBuffer.allocate(16).putLong(uuid.getMostSignificantBits()) - .putLong(uuid.getLeastSignificantBits()).array(); - String encoded = new BigInteger(1, bytes).toString(36).toUpperCase(Locale.ROOT); - return "0".repeat(25 - encoded.length()) + encoded; - } - - /** Preserve every snowflake ID bit; do not truncate or hash physical label identifiers. */ + /** 实物箱码保留完整 ID 的三十六进制编码,本次不修改扫码标签。 */ public static String compactCartonMark(long id) { if (id <= 0) throw new IllegalArgumentException("Carton id must be positive"); return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT); } - private String normalizeType(String value) { - String code = value == null ? "LMO" : value.trim().toUpperCase(Locale.ROOT); - return switch (code) { - case "FIRST_LEG" -> "FL"; - case "ONE_CLICK_DROPSHIP" -> "OD"; - case "RELABEL_APPLICATION", "RELABEL" -> "RL"; - case "WAREHOUSE_TRANSFER", "TRANSFER" -> "WT"; - case "LOCAL_LOOSEBOARD", "LOOSEBOARD" -> "LB"; - case "DISPATCH", "PICKUP_CAR" -> "DS"; - case "OUTBOUND" -> "OB"; - case "DELIVERY" -> "DL"; - case "TRUCK" -> "TR"; - case "EXCEPTION" -> "EX"; - case "DISCARD" -> "CD"; - case "PROCUREMENT" -> "PR"; - case "INBOUND_PLAN" -> "IP"; - default -> { - String normalized = code.replaceAll("[^A-Z0-9]", ""); - yield normalized.isEmpty() ? "LMO" : normalized.substring(0, Math.min(4, normalized.length())); - } - }; - } - - public String warehouseCode(String value) { - String code = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); - if (code.isEmpty()) return "WH"; - if (code.matches(".*(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD).*")) { - var match = java.util.regex.Pattern.compile("(LAX|ONT|NYC|JFK|LGB|EWR|SFO|SEA|DFW|ORD|ATL|MIA|PHX|LAS|YYZ|YVR|AMS|LHR|FRA|CDG|HKG|NRT|SYD)").matcher(code); - if (match.find()) return match.group(1); - } - if (code.matches("[A-Z0-9]{2,8}")) return code; - return "WH"; - } - - /** The physical container number is the tail-leg business order grouping boundary. */ + /** 独立尾程编号尚未确认,头程装柜来源继续使用原有分组编号。 */ public String forContainerNo(String containerNo) { - if (containerNo == null || containerNo.isBlank()) { - throw new IllegalArgumentException("containerNo is required"); - } - String normalized = containerNo.trim(); + if (containerNo == null || containerNo.isBlank()) throw new IllegalArgumentException("containerNo is required"); try { - byte[] digest = MessageDigest.getInstance("SHA-256") - .digest(normalized.getBytes(StandardCharsets.UTF_8)); + byte[] digest = MessageDigest.getInstance("SHA-256").digest(containerNo.trim().getBytes(StandardCharsets.UTF_8)); long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong(); - return next("FIRST_LEG", null, suffix); + return "FL-WH-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" + + String.format(Locale.ROOT, "%06d", Math.floorMod(suffix, 1_000_000)); } catch (NoSuchAlgorithmException exception) { throw new IllegalStateException("SHA-256 is unavailable", exception); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 8a790f977..8d2e9859c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -96,7 +96,7 @@ public class WarehouseTransferService { @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; - @Resource private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @Resource private com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderBridge fleetPool; @Resource private com.baiyue.lastleg.fleet.business.FleetPoolOrderQueries fleetPoolQueries; @@ -239,7 +239,7 @@ public class WarehouseTransferService { } LocalDateTime now = LocalDateTime.now(); long transferId = IdWorker.getId(); - String transferNo = compactNumbers.next("ZY", customerId); + String transferNo = compactNumbers.next("ZY"); String requestedInboundCode = trim(request.inboundCode()); List selectedIds = request.sourceLastlegOrderIds() == null ? List.of() : request.sourceLastlegOrderIds().stream().distinct().toList(); @@ -2252,6 +2252,7 @@ public class WarehouseTransferService { return item.getId(); } + /** 保存转运箱信息,优先使用预印箱唛,新增箱唛仍按原标签规则生成。 */ private void insertTransferCarton(String customerId, long transferId, long transferItemId, String transferNo, int cartonIndex, int totalCartons, String customerCartonMark, LocalDateTime now, String printedMark) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java index 8501c21a9..a0c9b6635 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/trucking/service/UsTruckOrderService.java @@ -70,7 +70,7 @@ public class UsTruckOrderService { UsTruckOrder order = new UsTruckOrder(); order.setId(IdWorker.getId()); order.setCustomerId(customerId); - order.setOrderNo(orderNoGenerator.next("TRUCK", request.origin(), order.getId())); + order.setOrderNo(orderNoGenerator.next("TRUCK")); order.setVehicleType(request.vehicleType()); order.setOrigin(trim(request.origin())); order.setOriginAddress(trim(request.originAddress())); order.setDestination(trim(request.destination())); order.setDestinationAddress(trim(request.destinationAddress())); diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 0a184629b..6e4d49036 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -35,12 +35,6 @@ CREATE TABLE IF NOT EXISTS ll_container_shipping_gate ( KEY idx_ll_shipping_gate_version(container_booking_id,aggregate_version,node_sequence) ) ENGINE=InnoDB COMMENT='海外PDA柜号船节点收货资格快照'; -CREATE TABLE IF NOT EXISTS ll_order_no_daily_sequence ( - business_date DATE NOT NULL COMMENT '业务日期(Asia/Shanghai)', - last_sequence INT NOT NULL COMMENT '当日已分配尾程单流水号', - PRIMARY KEY(business_date) -) ENGINE=InnoDB COMMENT='尾程单号按日原子流水'; - CREATE TABLE IF NOT EXISTS ll_dispatch_daily_sequence ( business_date DATE NOT NULL COMMENT '排车编号业务日期(Asia/Shanghai)', last_sequence INT NOT NULL COMMENT '当日已分配排车流水号,范围0001至9999', @@ -3212,16 +3206,6 @@ CREATE TABLE IF NOT EXISTS ll_wallet_event_inbox ( -CREATE TABLE IF NOT EXISTS ll_procurement_daily_sequence ( - customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', - business_date DATE NOT NULL COMMENT '北京时间业务日期', - sequence_value INT NOT NULL COMMENT '客户当日已分配最大序号', - PRIMARY KEY (customer_id,business_date), - CONSTRAINT chk_ll_procurement_sequence CHECK (sequence_value BETWEEN 1 AND 9999) -) ENGINE=InnoDB COMMENT='海外采购客户日序列'; - - - CREATE TABLE IF NOT EXISTS ll_overseas_procurement_file ( id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', category VARCHAR(16) NOT NULL COMMENT 'RECEIPT实收、VOUCHER费用、LABEL面单、PURCHASE采购、BUDGET预算、EXCEPTION异常凭证', file_id BIGINT NOT NULL COMMENT 'File服务文件ID', @@ -3249,6 +3233,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( settled_command_event_id VARCHAR(64) NULL COMMENT '成功实扣或退款的资金命令事件', source_fee_id BIGINT NULL COMMENT '已实扣费用来源ID', parent_fee_id BIGINT NULL COMMENT '原费用或预估引用', + idempotency_key VARCHAR(128) NULL COMMENT '退款请求幂等键,独立于业务单号', request_hash CHAR(64) NULL COMMENT '确认请求摘要', id BIGINT NOT NULL COMMENT '主键ID', order_id BIGINT NOT NULL COMMENT '采购订单ID', customer_id VARCHAR(128) NOT NULL COMMENT '客户ID', fee_no VARCHAR(64) NOT NULL COMMENT '费用业务号', fee_item_code VARCHAR(32) NOT NULL COMMENT '费用项编码', fee_item_name VARCHAR(128) NOT NULL COMMENT '费用项名称', @@ -3259,6 +3244,7 @@ CREATE TABLE IF NOT EXISTS ll_overseas_procurement_fee ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间', updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间', CONSTRAINT chk_procurement_confirmation_origin CHECK(confirmation_origin IN('BUSINESS','FINANCIAL')), UNIQUE KEY uk_ll_procurement_fee_version(order_id,purpose,fee_item_code,fee_version), + UNIQUE KEY uk_ll_procurement_refund_request(order_id,purpose,idempotency_key), PRIMARY KEY(id), UNIQUE KEY uk_ll_procurement_fee_no(fee_no), KEY idx_ll_procurement_fee_order(customer_id,order_id,created_at), CONSTRAINT chk_ll_procurement_fee_amount CHECK(amount>=0), CONSTRAINT chk_ll_procurement_fee_currency CHECK(currency='CNY') ) ENGINE=InnoDB COMMENT='海外采购费用明细及资金生命周期'; @@ -4501,11 +4487,6 @@ CREATE TABLE IF NOT EXISTS ll_appointment_work_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='预约工作事项历史与幂等命令'; -- 新单据短号:相同业务及客户名称缩写共享逻辑秒,避免同秒或多实例重号。 -CREATE TABLE IF NOT EXISTS ll_compact_number_clock ( - number_key VARCHAR(16) NOT NULL COMMENT '业务前缀和客户名称缩写', - last_second BIGINT NOT NULL COMMENT '最后分配的Unix逻辑秒', - PRIMARY KEY (number_key) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='海外业务短单号分配时钟'; -- 库存转运无需再次预约入仓;目标库:lastleg-service 所属数据库。可重复执行。 SET @ddl = (SELECT IF(EXISTS (SELECT 1 FROM information_schema.columns WHERE table_schema=DATABASE() AND table_name='ll_warehouse_transfer_order' AND column_name='estimated_arrival_at' AND is_nullable='NO'), 'ALTER TABLE ll_warehouse_transfer_order MODIFY COLUMN estimated_arrival_at DATETIME(3) NULL COMMENT ''预计入仓时间,库存转运不填写''', 'SELECT 1')); PREPARE stmt FROM @ddl; EXECUTE stmt; DEALLOCATE PREPARE stmt; @@ -4667,3 +4648,11 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_transport_command ( created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '业务命令提交时间', PRIMARY KEY(dispatch_no,request_key) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='排车运输执行命令与凭证账本'; + +-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。 +CREATE TABLE IF NOT EXISTS ll_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_ll_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 86c396041..518c5080a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -888,8 +888,8 @@ paths: '422': { description: 排车单没有可打印的 BOL 明细 } /api/platform/v1/last-leg/dispatches/candidates: get: - summary: 读取真实候选及来源版本 - description: 权限 platform:overseas-warehouse:dispatch:list。 + summary: 分页读取真实候选及来源版本 + description: 权限 platform:overseas-warehouse:dispatch:list。数据库按客户、订单、托盘和来源身份稳定排序分页;总数与当前页来自同一只读快照。selectionGroup仍包含完整同托及整申请来源,可包含页外成员;选取时服务端再次权威解析,不以当前页充当完整组。 security: - bearerAuth: [] responses: @@ -947,6 +947,26 @@ paths: in: query schema: type: string + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 50 + default: 20 + - name: warehouseTab + in: query + description: 对起运仓名称和编码的服务端页签筛选,在统计和分页前生效。 + schema: + type: string + enum: [ALL, LAX, NYC] + default: ALL /api/platform/v1/last-leg/dispatches/drafts/pre-arrange: post: summary: 将所有非空容器原子转换为整车 @@ -6972,6 +6992,19 @@ components: nullable: true complete: type: boolean + description: 仅当前响应包含筛选后的全部候选时为true;分页中的false不影响每个selectionGroup的完整性。 + total: + type: string + pattern: ^[0-9]+$ + description: 同一筛选下的来源总行数。 + page: + type: integer + size: + type: integer + pages: + type: string + pattern: ^[0-9]+$ + required: [candidates, snapshotAt, complete, total, page, size, pages] DispatchDraftContainer: type: object x-java-record: DispatchPlanningModels.DraftContainer diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java index 74a79d9f2..09e58ad8b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java @@ -75,7 +75,7 @@ class DispatchDeliveryArrangementPersistenceTest { var repository=new DispatchDeliveryRepository(session.getMapper(DispatchDeliveryArrangementMapper.class),session.getMapper(OutboundOrderMapper.class), session.getMapper(OutboundPalletItemMapper.class),session.getMapper(DeliveryOrderMapper.class),session.getMapper(DeliveryOperationMapper.class), session.getMapper(LastlegOrderMapper.class),session.getMapper(LastlegOrderStatusHistoryMapper.class)); - var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2)); + var numbers=mock(LastlegOrderNoGenerator.class);when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); var target=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json); var proxy=new ProxyFactory(target);proxy.setProxyTargetClass(true);proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds),new AnnotationTransactionAttributeSource())); service=(DispatchDeliveryService)proxy.getProxy(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java index f87732f13..836b919f2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java @@ -39,7 +39,7 @@ class DispatchDeliveryServiceTest { @BeforeEach void prepare() { context=context(List.of(cargo(1,"101","ONT8",51,2),cargo(2,"101","LAX9",52,3),cargo(3,"102","ONT8",53,4)),false); var numbers=mock(LastlegOrderNoGenerator.class); - when(numbers.next(anyString(),anyString(),anyLong())).thenAnswer(a->a.getArgument(0)+"-"+a.getArgument(2)); + when(numbers.next(anyString())).thenAnswer(a->a.getArgument(0)+"-"+com.baomidou.mybatisplus.core.toolkit.IdWorker.getId()); service=new DispatchDeliveryService(execution,repository,grouping,fees,new NormalDeliveryCompletionPolicy(grouping),numbers,json); when(execution.lockForArrangement(any(),eq("DP-10"))).thenAnswer(a->context); when(execution.replay(any(),any(),any())).thenReturn(Optional.empty()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java index 9377fa9f5..4111fa0b9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/DeliveryTransferServiceTest.java @@ -153,7 +153,7 @@ class DeliveryTransferServiceTest { ReflectionTestUtils.setField(service, "planAllocations", planAllocations); ReflectionTestUtils.setField(service, "planLines", planLines); ReflectionTestUtils.setField(service, "inboundCartons", inboundCartons); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); ReflectionTestUtils.setField(service, "tailOrders", tailOrders); ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "tailOrderDetails", tailOrderDetails); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java index 58b8ffe06..440a02860 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundClosureTest.java @@ -72,7 +72,7 @@ class ReturnOutboundClosureTest { ReflectionTestUtils.setField(service, "pdaPicks", picks); ReflectionTestUtils.setField(service, "relabelApplications", relabelApplications); ReflectionTestUtils.setField(service, "relabelService", relabelService); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); carton = new ReturnInventoryCarton(); carton.setId(10L); carton.setCartonMark("RC#1-1"); carton.setCustomerId("C1"); carton.setOverseasWarehouseId("WH1"); carton.setSourceReturnNo("RT1,RT2"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java index 9626cdc09..48716f73e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/service/ReturnOutboundPdaPersistenceTest.java @@ -82,7 +82,8 @@ class ReturnOutboundPdaPersistenceTest { stockProxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); ReflectionTestUtils.setField(target, "returnStock", stockProxy.getProxy()); var numbers = mock(LastlegOrderNoGenerator.class); - when(numbers.next(anyString(), eq("WH-1"), anyLong())).thenAnswer(call -> "RETU-" + call.getArgument(2)); + var testNumber = new java.util.concurrent.atomic.AtomicInteger(); + when(numbers.next(anyString())).thenAnswer(call -> "THCK20261020" + String.format("%04d", testNumber.incrementAndGet())); ReflectionTestUtils.setField(target, "orderNoGenerator", numbers); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(ds), new AnnotationTransactionAttributeSource())); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java index 625dfc82d..3d694f07f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardOutboundServiceTest.java @@ -39,7 +39,7 @@ class CargoDiscardOutboundServiceTest { ReflectionTestUtils.setField(service, "cartons", cartons); ReflectionTestUtils.setField(service, "profiles", profiles); ReflectionTestUtils.setField(service, "returnCartons", returnCartons); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); application = new CargoDiscardApplication(); application.setId(1L); application.setCustomerId("C1"); application.setDiscardNo("CD-TEST"); application.setOverseasWarehouseId("99"); application.setTotalPackages(5); application.setStatus("APPROVED"); @@ -66,7 +66,7 @@ class CargoDiscardOutboundServiceTest { assertEquals("DISCARD_OUTBOUND", document.getOutboundType()); assertEquals("WAIT_PICK", document.getStatus()); assertNull(document.getLxCodes()); assertEquals(5, document.getPackageCount()); assertEquals(2, document.getPalletCount()); - assertTrue(service.outboundNo(application).startsWith("OB-")); + assertTrue(service.outboundNo(application).matches("CK[0-9]{12}")); assertEquals(List.of(3, 2), snapshots.stream().map(OutboundPalletItem::getPackageCount).toList()); verify(outbounds, times(1)).insert(any(OutboundOrder.class)); verify(pallets, times(2)).insert(any(OutboundPalletItem.class)); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java index 1daa421b3..ed641cf02 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/discard/service/CargoDiscardServiceTest.java @@ -134,7 +134,7 @@ class CargoDiscardServiceTest { ReflectionTestUtils.setField(service, "histories", histories); ReflectionTestUtils.setField(service, "inventory", inventory); ReflectionTestUtils.setField(service, "adjustments", adjustments); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderHistories", orderHistories); ReflectionTestUtils.setField(service, "returnCartons", returnCartons); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 33680f23e..1d1e13557 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -38,7 +38,9 @@ class SmartDispatchOpenApiContractTest { assertThat(map(paths.get(base+"/drafts"))).containsKey("get"); var candidate=map(map(paths.get(base+"/candidates")).get("get")); var parameterNames=((List)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList(); - assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken").doesNotContain("customerId","fbaWarehouseCode"); + assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode"); + assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet()) + .containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages"); var draft=map(map(schemas.get("DispatchDraft")).get("properties")); assertThat(draft.keySet()).containsExactlyInAnyOrder("draftToken","recordVersion","status","warehouseId","containers","sources","expiresAt"); var vehicle=map(map(schemas.get("DispatchDraftVehicle")).get("properties"));assertThat(vehicle.keySet()).containsExactlyInAnyOrder("draftContainerId","dispatch"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMySqlReadOnlyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMySqlReadOnlyTest.java new file mode 100644 index 000000000..b47dc0792 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidateMySqlReadOnlyTest.java @@ -0,0 +1,127 @@ +package com.baiyue.lastleg.dispatch.mapper; + +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery; +import org.apache.ibatis.scripting.defaults.DefaultParameterHandler; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.sql.*; +import java.util.*; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.*; + +/** 显式指定TEST配置才运行;只读真实MySQL,不建表、不造数、不输出客户记录或连接凭据。 */ +@EnabledIfSystemProperty(named="dispatch.mysql.profile",matches=".+") +class DispatchCandidateMySqlReadOnlyTest { + private Connection connect() throws Exception { + var profile=new HashMap(); + for(String line:Files.readAllLines(Path.of(System.getProperty("dispatch.mysql.profile")))) { + var match=java.util.regex.Pattern.compile("^\\s*(?:export\\s+)?([A-Z][A-Z0-9_]*)\\s*=\\s*(.*)$").matcher(line); + if(!match.matches())continue; + String value=match.group(2).trim(); + if(value.length()>1&&(value.startsWith("\"")&&value.endsWith("\"")||value.startsWith("'")&&value.endsWith("'"))) + value=value.substring(1,value.length()-1); + profile.put(match.group(1),value); + } + assertEquals("test",profile.get("CHONGCHONG_ENV"),"只允许显式TEST只读检查"); + String url="jdbc:mysql://"+profile.get("CHONGCHONG_DB_HOST")+":"+profile.get("CHONGCHONG_DB_PORT") + +"/baiyue_lastleg?characterEncoding=UTF-8&connectTimeout=5000&socketTimeout=15000"; + try { + var connection=DriverManager.getConnection(url,profile.get("CHONGCHONG_DB_USERNAME"),profile.get("CHONGCHONG_DB_PASSWORD")); + connection.setReadOnly(true); + return connection; + } catch(SQLException error) { + throw new AssertionError("MySQL connection failed; SQLState="+error.getSQLState()+", code="+error.getErrorCode()); + } + } + + /** 用纯SELECT构造冲突,确认原始UNION拒绝混合collation且实际生产投影保留中文/字符身份,无需修改测试库。 */ + @Test void mixedCollationsAreNormalizedBeforeUnionForCountAndPage() throws Exception { + var fields=Arrays.stream(DispatchPalletCandidate.class.getDeclaredFields()).filter(f->f.getType()==String.class) + .map(java.lang.reflect.Field::getName).collect(Collectors.toSet()); + var project=DispatchCandidatePageMapper.Sql.class.getDeclaredMethod("project",String.class,Set.class,boolean.class); + project.setAccessible(true); + String general="SELECT "+fields.stream().sorted().map(f->"_utf8mb4'洛杉矶-001' COLLATE utf8mb4_general_ci AS "+f).collect(Collectors.joining(",")); + String unicode=general.replace("utf8mb4_general_ci","utf8mb4_unicode_ci"); + try(var connection=connect();var statement=connection.createStatement()) { + statement.setQueryTimeout(10); + var failure=assertThrows(SQLException.class,()->statement.executeQuery(general+" UNION ALL "+unicode)); + assertTrue(Set.of(1267,1271).contains(failure.getErrorCode()),"MySQL应报告collation冲突"); + for(boolean count:List.of(true,false)) { + String sql=project.invoke(null,general,fields,count)+" UNION ALL "+project.invoke(null,unicode,fields,count); + try(var rows=statement.executeQuery(sql)) { + int total=0; + while(rows.next()) { + total++; + assertEquals("洛杉矶-001",rows.getString("overseasWarehouseId")); + assertEquals("洛杉矶-001",rows.getString("fbaWarehouseCode")); + if(!count) { + for(String field:fields)assertEquals("洛杉矶-001",rows.getString(field),field); + assertNull(rows.getObject("planPalletId")); + assertNull(rows.getObject("appointmentAt")); + assertNull(rows.getObject("grossWeightKg")); + } + } + assertEquals(2,total); + } + } + } + } + + /** 执行MyBatis正式SQL和绑定参数,分别检查真实四来源count/page以及筛选分支;仅输出行数和SQL耗时。 */ + @Test void actualCandidateQueriesExecuteOnMySql() throws Exception { + String warehouse=System.getProperty("dispatch.mysql.warehouse"); + assertNotNull(warehouse,"指定只读验收仓"); + var configuration=new Configuration(); + configuration.addMapper(DispatchCandidatePageMapper.class); + List queries=List.of( + new CandidateQuery(warehouse,null,null,null,null,null,1,20,"ALL"), + new CandidateQuery(warehouse,null,null,null,null,null,2,20,"ALL"), + new CandidateQuery(warehouse,null,null,null,"RELABEL",null,1,20,"ALL"), + new CandidateQuery(warehouse,null,null,null,"RECEIVED",null,1,20,"LAX"), + new CandidateQuery(warehouse,null,null,null,"PLAN",null,1,20,"NYC"), + new CandidateQuery(warehouse,null,null,"ONT8",null,null,1,20,"ALL"), + new CandidateQuery(warehouse,List.of(-1L),null,null,null,null,1,20,"ALL")); + try(var connection=connect()) { + try(var statement=connection.createStatement();var rows=statement.executeQuery("SELECT @@collation_connection,COLLATION(CAST(1 AS CHAR(128)))")) { + assertTrue(rows.next()); + System.out.printf("MYSQL_COLLATION connection=%s castAsChar=%s%n",rows.getString(1),rows.getString(2)); + } + try(var statement=connection.createStatement();var rows=statement.executeQuery( + "SELECT TABLE_NAME,COLUMN_NAME,COLLATION_NAME FROM information_schema.COLUMNS " + +"WHERE TABLE_SCHEMA=DATABASE() AND TABLE_NAME IN ('ll_order','ll_inbound_plan','ll_return_receipt','ll_relabel_application') " + +"AND COLUMN_NAME IN ('customer_id','overseas_warehouse_id','overseas_warehouse_name','overseas_warehouse_code') ORDER BY TABLE_NAME,COLUMN_NAME")) { + while(rows.next())System.out.printf("MYSQL_COLLATION %s.%s=%s%n",rows.getString(1),rows.getString(2),rows.getString(3)); + } + for(var query:queries) { + var parameters=new HashMap(); + parameters.put("query",query);parameters.put("offset",(long)(query.page()-1)*query.size()); + long total=0;int size=0;long started=System.nanoTime(); + for(String method:List.of("count","page")) { + var mapped=configuration.getMappedStatement(DispatchCandidatePageMapper.class.getName()+"."+method); + var bound=mapped.getBoundSql(parameters); + assertTrue(bound.getSql().stripLeading().startsWith("SELECT")); + try(var statement=connection.prepareStatement(bound.getSql())) { + statement.setQueryTimeout(10); + new DefaultParameterHandler(mapped,parameters,bound).setParameters(statement); + try(var rows=statement.executeQuery()) { + if(method.equals("count")) { assertTrue(rows.next());total=rows.getLong(1); } + else while(rows.next()) { + size++;assertEquals(warehouse,rows.getString("overseasWarehouseId")); + if(query.destinationWarehouseCode()!=null)assertEquals(query.destinationWarehouseCode(),rows.getString("fbaWarehouseCode")); + } + } + } + } + assertTrue(size<=query.size());assertTrue(size<=total); + System.out.printf(Locale.ROOT,"MYSQL_READ_ONLY page=%d stage=%s tab=%s total=%d rows=%d countAndPageMs=%.3f%n", + query.page(),query.sourceStage(),query.warehouseTab(),total,size,(System.nanoTime()-started)/1_000_000d); + } + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 61a4e77c3..14af55457 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -536,6 +536,7 @@ public class DispatchAggregatePersistenceTest { mock(com.baiyue.lastleg.delivery.mapper.DeliveryOperationMapper.class),mock(com.baiyue.lastleg.delivery.repository.DispatchCargoSourceRepository.class),mock(DispatchAggregateService.class),json); var vehicles=mock(VehicleRepository.class); var drivers=mock(DriverService.class);var reservations=mock(FleetBookingReservations.class); var fleetService=new FleetTripService(fleetRepo,vehicles,drivers,warehouses,json,gateway,reservations); + org.springframework.test.util.ReflectionTestUtils.setField(fleetService, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); nativeFleetService=fleetService; org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"workChanges",mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class)); org.springframework.test.util.ReflectionTestUtils.setField(fleetService,"appointmentAllocations",mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java index 7d7ce6c3f..f82c79f74 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftResourceLockTest.java @@ -22,6 +22,7 @@ class DispatchDraftResourceLockTest { when(repository.lockTrip("TR-7")).thenReturn(first);when(repository.lockTrip("TR-2")).thenReturn(second); when(repository.listBindings(7L)).thenReturn(List.of(a));when(repository.listBindings(2L)).thenReturn(List.of(b)); var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); service.lockExistingDispatchShells(List.of("DS-A","DS-B")); var order=inOrder(repository,vehicles); order.verify(repository).lockTrip("TR-2");order.verify(repository).lockTrip("TR-7"); @@ -37,6 +38,7 @@ class DispatchDraftResourceLockTest { when(vehicles.lockById(anyLong())).thenReturn(Optional.of(mock(VehicleRepository.VehicleSnapshot.class))); when(repository.lockDriverIdentity(anyLong())).thenAnswer(call->call.getArgument(0)); var service=new FleetTripService(repository,vehicles,mock(DriverService.class),mock(FleetWarehouseDirectory.class),new ObjectMapper(),mock(FleetDispatchGateway.class),mock(FleetBookingReservations.class)); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); var v9=new FleetExecutionModels.DispatchVehicle("MASTER",9L,null,null,null,null,null,List.of()); var v3=new FleetExecutionModels.DispatchVehicle("MASTER",3L,null,null,null,null,null,List.of()); var d8=new FleetExecutionModels.DispatchDriver("MASTER",8L,null,null,null,null,List.of(),null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java new file mode 100644 index 000000000..fa43bf205 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java @@ -0,0 +1,70 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import com.baiyue.lastleg.relabel.repository.RelabelDispatchSourceRepository; +import com.baiyue.lastleg.relabel.service.RelabelDispatchSourceService; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */ +class DispatchLoadingEligibilityPersistenceTest { + /** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */ + @ParameterizedTest + @ValueSource(booleans={false,true}) + void unmaterializedPlanRejectsLoadingBeforePhysicalSourceLock(boolean mixed) throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var plan=f.source(1,"101",501,"ONT8",3); + var selections=mixed?List.of(plan,f.source(2,"202",502,"LAX9",2)):List.of(plan); + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("unreceived-loading",selections))); + var approved=f.approve(created); + f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN' WHERE plan_pallet_id=1"); + if(mixed) { + f.picked(2,"202",502,2); + f.db.update("UPDATE ll_dispatch_order SET picked_package_count=2"); + } + var providerRows=mock(RelabelDispatchSourceRepository.class); + var provider=spy(new RelabelDispatchSourceService(providerRows,f.json)); + var sources=mock(DispatchSourceRepository.class); + var rules=spy(new DispatchService()); + ReflectionTestUtils.setField(rules,"relabelSources",provider); + ReflectionTestUtils.setField(rules,"wholeSources",sources); + ReflectionTestUtils.setField(f.service,"sourceRules",rules); + var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo())); + assertThat(preview.pickingReconciled()).isFalse(); + assertThat(preview.loadingComplete().allowed()).isFalse(); + assertThat(preview.loadingComplete().blockCode()).isEqualTo("PICKING_INCOMPLETE"); + assertThat(preview.pallets()).hasSize(mixed?1:0); + var tables=List.of("ll_dispatch_order","ll_dispatch_item","ll_dispatch_command","ll_dispatch_history", + "ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock", + "ll_fleet_trip","ll_fleet_trip_history"); + var before=snapshot(f,tables); + var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例"); + assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request))) + .isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)) + .hasMessage("逐项真实拣货尚未核平"); + assertThat(snapshot(f,tables)).isEqualTo(before); + verify(rules,never()).lockExecutionSources(anyString(),anyList()); + verifyNoInteractions(provider,providerRows,sources,f.ownedBusinessOwnership); + } + + /** 按实际列值读取CLOB及时间,避免比较JDBC临时对象身份。 */ + private Object snapshot(DispatchAggregatePersistenceTest.Fixture f,List tables) { + return tables.stream().map(table->f.db.query("SELECT * FROM "+table+" ORDER BY id",(row,index)->{ + var values=new java.util.ArrayList(); + for(int column=1;column<=row.getMetaData().getColumnCount();column++)values.add(row.getString(column)); + return values; + })).toList(); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index 3b3606d0d..79fc0ee00 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -28,6 +28,45 @@ import static org.mockito.Mockito.*; /** 真实提供者→完整组选货→主单/占用→实际库存命令/归属→PDA/装车的本机事务链。 */ class DispatchMergedRelabelPersistenceTest { + /** 原生退货分页按实箱去重计量,不能因申请多明细重复计箱或重量。 */ + @Test void nativeReturnPageMeasuresOneCartonOnce() throws Exception { + var f=new Merged(false);var at=LocalDateTime.of(2026,1,1,0,0); + f.insert("ll_return_inventory_carton","id",901,"customer_id","101","overseas_warehouse_id","9", + "carton_mark","NATIVE-CARTON","pallet_code","NATIVE-PALLET","relabel_application_id",201, + "relabel_no","RL-A","status","RELABEL_PENDING","sku_types",1,"total_quantity",2, + "weight",12,"length",100,"width",100,"height",100,"created_at",at,"updated_at",at); + f.f.db.update("UPDATE ll_relabel_item SET source_inventory_position_id=NULL,return_carton_id=901 WHERE id=301"); + f.insert("ll_relabel_item","id",303,"application_id",201,"customer_id","101","product_name_zh","同箱另一明细", + "total_package_count",1,"notified_package_count",1,"carton_mark","NATIVE-CARTON","operation_type","CARTON_LABEL", + "return_carton_id",901,"destination_fba_warehouse_code","ONT8"); + var query=new DispatchPlanningModels.CandidateQuery("9",null,"RL-A",null,"RELABEL",null,1,20,"ALL"); + var mapper=f.f.session.getMapper(DispatchCandidatePageMapper.class); + assertThat(mapper.count(query)).isOne(); + var row=mapper.page(query,0).get(0); + assertThat(row.returnCartonSource()).isTrue();assertThat(row.packageCount()).isOne(); + assertThat(row.grossWeightKg()).isEqualByComparingTo("12");assertThat(row.volumeCbm()).isEqualByComparingTo("1"); + f.f.db.update("UPDATE ll_return_inventory_carton SET weight=NULL,height=NULL WHERE id=901"); + row=mapper.page(query,0).get(0); + assertThat(row.grossWeightKg()).isNull();assertThat(row.volumeCbm()).isNull(); + } + /** 数据库限页不能截断同托来源组;总数、翻页及服务端搜索必须一致。 */ + @Test void candidatePagesRetainCompleteCrossPageSelectionGroup() throws Exception { + var f=new Merged(false); + var all=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,null,null,1,1,"ALL")); + assertThat(all.total()).isEqualTo(2);assertThat(all.rows()).hasSize(1); + var first=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,1,1,"ALL")); + var second=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"RELABEL",null,2,1,"ALL")); + assertThat(first.total()).isEqualTo(2);assertThat(second.total()).isEqualTo(2); + assertThat(first.rows()).hasSize(1);assertThat(second.rows()).hasSize(1); + assertThat(first.rows().get(0).orderRefs()).isNotEqualTo(second.rows().get(0).orderRefs()); + var group=f.rules.selectionGroups(first.rows()).values().iterator().next(); + assertThat(group.complete()).as(group.blockReasons().toString()).isTrue(); + assertThat(group.sources()).extracting(DispatchPlanningModels.SelectionGroupSource::sourceBusinessNo).containsExactlyInAnyOrder("RL-A","RL-B"); + var filtered=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("9",null,"RL-B","ONT8","RELABEL",null,1,1,"ALL")); + assertThat(filtered.total()).isOne();assertThat(filtered.rows()).extracting(r->r.orderRefs()).containsExactly("RL-B"); + var empty=f.rules.candidatePage("platform",new DispatchPlanningModels.CandidateQuery("99",null,null,null,"RELABEL",null,1,1,"ALL")); + assertThat(empty.total()).isZero();assertThat(empty.rows()).isEmpty(); + } /** 同客户共享position与跨客户共享physical都只占一托,来源及SHIP严格独立。 */ @ParameterizedTest @ValueSource(booleans={false,true}) void mergedApplicationsUseOnePhysicalPalletAndKeepExactSources(boolean crossCustomer) throws Exception { @@ -190,17 +229,23 @@ class DispatchMergedRelabelPersistenceTest { for(var table:List.of("ll_order","ll_inventory_position","ll_inventory_balance","ll_inventory_ledger","ll_inventory_operation","ll_physical_pallet_guard", "ll_relabel_application","ll_relabel_item","ll_relabel_pda_task","ll_relabel_pda_history","ll_relabel_pda_unit_fact","ll_inbound_plan","ll_inbound_plan_pallet", "ll_inbound_stock_receipt","ll_dispatch_item_source","ll_dispatch_physical_pallet_lock","ll_dispatch_pallet_lock","ll_dispatch_pda_task","ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_warehouse_transfer_inventory_allocation","ll_warehouse_transfer_order","ll_warehouse_transfer_pallet", - "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate")) { + "ll_inbound_plan_line","ll_inbound_plan_pallet_allocation","ll_inbound_plan_carton","ll_order_detail","ll_inbound_order","ll_inbound_fba_appointment","ll_inbound_fba_appointment_item","ll_order_manual_node","ll_container_shipping_gate", + "ll_return_inventory_carton","ll_return_receipt","ll_return_stock_receipt","ll_exception_order","ll_delivery_order")) { f.db.execute("DROP TABLE IF EXISTS "+table);int start=schema.indexOf("CREATE TABLE "+table+" (");int end=schema.indexOf(") ENGINE=",start); if(start<0 || end<0)throw new IllegalStateException("正式初始化表缺失:"+table); // H2的JSON、collation及约束命名空间与MySQL不同;保留实际列和等价四来源排他约束。 f.db.execute(schema.substring(start,end+1).replaceAll("(?i)\\bJSON\\b","CLOB").replaceAll("(?i)\\s+COLLATE\\s+\\w+","") + .replace("_utf8mb4'","'") + .replace("_utf8mb4", "") .replaceAll("(?i)\\b(UNIQUE KEY|KEY|CONSTRAINT)\\s+(\\w+)","$1 "+table+"_$2") .replaceAll("\\((receipt_id|return_receipt_id|return_carton_id|dispatch_item_source_id) IS NOT NULL\\)","(CASE WHEN $1 IS NOT NULL THEN 1 ELSE 0 END)")); } var provider=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(f.session.getMapper(RelabelDispatchSourceMapper.class)),f.json); var origins=new DispatchItemSourceRepository(f.session.getMapper(DispatchItemSourceMapper.class));var snapshots=new DispatchItemSourceService(origins,provider,f.json); - f.session.getConfiguration().addMapper(DispatchCandidatePageMapper.class); + // H2不实现MySQL的字符集转换;这里只验证分页/完整组,collation由真实MySQL只读测试覆盖。 + configuration.setDefaultScriptingLanguage(H2CandidateLanguageDriver.class); + configuration.addMapper(DispatchCandidatePageMapper.class); + configuration.setDefaultScriptingLanguage(org.apache.ibatis.scripting.xmltags.XMLLanguageDriver.class); var sourceRepository=new DispatchSourceRepository(f.session.getMapper(DispatchCandidateMapper.class),f.session.getMapper(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); ReflectionTestUtils.setField(sourceRepository,"pages",f.session.getMapper(DispatchCandidatePageMapper.class)); ReflectionTestUtils.setField(rules,"wholeSources",sourceRepository);ReflectionTestUtils.setField(rules,"wholeHashes",new DispatchCommandHasher(f.json)); @@ -247,4 +292,11 @@ class DispatchMergedRelabelPersistenceTest { /** 历史由真实提供者解析,固定发生次序不依赖当前系统时钟。 */ void history(long id,long app,String customer,String action,Object detail,int day) throws Exception {insert("ll_relabel_pda_history","id",id,"task_id",1000+app,"application_id",app,"customer_id",customer,"action",action,"to_status","WAIT_REVIEW","operator_key","worker","operator_name","测试","detail_json",f.json.writeValueAsString(detail),"created_at",LocalDateTime.of(2026,1,day,0,0));} } + + public static class H2CandidateLanguageDriver extends org.apache.ibatis.scripting.xmltags.XMLLanguageDriver { + @Override public org.apache.ibatis.mapping.SqlSource createSqlSource(org.apache.ibatis.session.Configuration configuration,String script,Class parameterType) { + return super.createSqlSource(configuration,script.replaceAll( + "CONVERT\\((s\\.\\w+|NULL) USING utf8mb4\\) COLLATE utf8mb4_unicode_ci","$1"),parameterType); + } + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java index 2d4e9bc52..87b5f2e07 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -111,7 +111,7 @@ class DispatchRepairWorkflowTest { db.execute("CREATE TABLE IF NOT EXISTS ll_physical_pallet_guard(warehouse_pallet_id BIGINT PRIMARY KEY,created_at TIMESTAMP(3) DEFAULT CURRENT_TIMESTAMP(3))"); for(var column:List.of("order_id BIGINT","warehouse_pallet_id BIGINT","inventory_position_id BIGINT","received_packages BIGINT"))db.execute("ALTER TABLE ll_inbound_stock_receipt ADD COLUMN IF NOT EXISTS "+column); candidates=spy(session.getMapper(DispatchCandidateMapper.class));doReturn(List.of()).when(candidates).nativeReturnRelabelAvailable(any(),any(),any());dependencies.put(DispatchCandidateMapper.class,candidates); - dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,new LastlegOrderNoGenerator()); + dependencies.put(ObjectMapper.class,json);dependencies.put(LastlegOrderNoGenerator.class,com.baiyue.lastleg.service.TestDocumentNumbers.orders()); var sourceRepository=new com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository(candidates,session.getMapper(ReturnDispatchCandidateMapper.class),session.getMapper(DispatchSourceMapper.class)); dependencies.put(com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository.class,sourceRepository);dependencies.put(DispatchCommandHasher.class,new DispatchCommandHasher(json)); var relabelSources=new RelabelDispatchSourceService(new RelabelDispatchSourceRepository(mapper(RelabelDispatchSourceMapper.class)),json); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java index a7181eb07..1057f33a6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/exceptionorder/service/ReturnCartonReturnPersistenceTest.java @@ -58,7 +58,7 @@ class ReturnCartonReturnPersistenceTest { var pallets=mock(PalletPhysicalGateway.class);var warehouse=mock(WarehouseResourceGateway.class); when(pallets.resolve("P-NEW")).thenReturn(new PalletPhysicalGateway.PalletReference(501,"P-NEW",92,301L,"OCCUPIED","ONT8",1)); when(warehouse.resolveLocation(92,"LOC-301")).thenReturn(new WarehouseResourceGateway.WarehouseLocationReference(301,"LOC-301","RACK",BigDecimal.TEN,"ACTIVE")); - service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + service=new ReturnCartonReturnService(repo,pallets,warehouse,new ObjectMapper(),com.baiyue.lastleg.service.TestDocumentNumbers.orders()); db.update("INSERT INTO ll_exception_order(id,customer_id,exception_no,delivery_order_id,last_mile_order_id,outbound_no,status,return_direction,overseas_warehouse_id) VALUES(1,'C','EX-1',3,100,'OUT-1','PROCESSING','OVERSEAS_WAREHOUSE','92')"); db.update("INSERT INTO ll_delivery_order(id,customer_id,outbound_order_id) VALUES(3,'C',2)"); db.update("INSERT INTO ll_outbound_order(id,customer_id,outbound_no,dispatch_order_id,status) VALUES(2,'C','OUT-1',10,'OUTBOUNDED')"); @@ -183,7 +183,7 @@ class ReturnCartonReturnPersistenceTest { org.springframework.test.util.ReflectionTestUtils.setField(business,"objectMapper",new ObjectMapper().findAndRegisterModules()); org.springframework.test.util.ReflectionTestUtils.setField(business,"evidenceFiles",mock(com.baiyue.lastleg.exceptionorder.gateway.ExceptionEvidenceFileGateway.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"nativeReturns",service); - org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + org.springframework.test.util.ReflectionTestUtils.setField(business,"orderNoGenerator",com.baiyue.lastleg.service.TestDocumentNumbers.orders()); org.springframework.test.util.ReflectionTestUtils.setField(business,"inboundOrders",map(com.baiyue.lastleg.mapper.LastlegInboundOrderMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundOrders",map(OutboundOrderMapper.class)); org.springframework.test.util.ReflectionTestUtils.setField(business,"outboundPalletItems",map(OutboundPalletItemMapper.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java index 7a8594469..8f465259c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderBridgeTest.java @@ -22,7 +22,7 @@ class FleetPoolOrderBridgeTest { LocalLooseboardHistoryMapper looseHistory=mock(LocalLooseboardHistoryMapper.class); UsTruckOrderMapper trucks=mock(UsTruckOrderMapper.class); UsTruckOrderStatusHistoryMapper truckHistory=mock(UsTruckOrderStatusHistoryMapper.class); - FleetPoolOrderNumberGenerator numbers=mock(FleetPoolOrderNumberGenerator.class); + com.baiyue.lastleg.service.LastlegDocumentNumberGenerator numbers=mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); FleetPoolDeliveryEvidence evidence=mock(FleetPoolDeliveryEvidence.class); com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker appointmentSources=mock(com.baiyue.lastleg.appointmentplanning.AppointmentSourceLinker.class); FleetLooseboardDemandRepository demands=mock(FleetLooseboardDemandRepository.class); @@ -32,7 +32,7 @@ class FleetPoolOrderBridgeTest { service=new FleetPoolOrderBridge(new FleetPoolOrderRepository(links,loose,looseHistory,trucks,truckHistory),numbers,new ObjectMapper().findAndRegisterModules(),evidence,appointmentSources); org.springframework.test.util.ReflectionTestUtils.setField(service,"demands",demands); - when(numbers.next(anyString(),any(),anyLong())).thenReturn("LOCAL-001"); + when(numbers.next(anyString())).thenReturn("LOCAL-001"); when(links.lockBooking(7L)).thenAnswer(a->row);when(links.lockBookingNo("FB-7")).thenAnswer(a->row); when(links.lockIncoming("9","WT-7")).thenAnswer(a->row); when(links.lockDispatch("9","DISPATCH-7")).thenAnswer(a->row==null?List.of():List.of(row)); @@ -190,7 +190,10 @@ class FleetPoolOrderBridgeTest { assertThat(method.getAnnotation(Transactional.class).propagation()).isEqualTo(Propagation.MANDATORY); assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getSimpleName())) .noneMatch(name->name.contains("FleetTrip")||name.contains("FleetBooking")||name.contains("Fee")||name.contains("Inventory")); - assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()).map(f->f.getType().getPackageName())) + assertThat(Arrays.stream(FleetPoolOrderBridge.class.getDeclaredFields()) + // 只允许共享的编号基础能力;业务编排服务仍禁止反向依赖。 + .filter(f -> f.getType() != com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class) + .map(f->f.getType().getPackageName())) .noneMatch(name->name.startsWith("com.baiyue.lastleg.delivery")||name.equals("com.baiyue.lastleg.fleet.trip") ||name.equals("com.baiyue.lastleg.service")); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java deleted file mode 100644 index 76c6a83aa..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/business/FleetPoolOrderNumberGeneratorTest.java +++ /dev/null @@ -1,21 +0,0 @@ -package com.baiyue.lastleg.fleet.business; - -import org.junit.jupiter.api.Test; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; -import static org.assertj.core.api.Assertions.*; - -class FleetPoolOrderNumberGeneratorTest { - private final FleetPoolOrderNumberGenerator generator=new FleetPoolOrderNumberGenerator(); - @Test void existingLooseboardAndTruckFormatLocationAndSuffixArePreserved(){ - String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - assertThat(generator.next("LOOSEBOARD","FBA:ONT8",1234567)).isEqualTo("LB-ONT-"+day+"-234567"); - assertThat(generator.next("TRUCK","nyc",1000001)).isEqualTo("TR-NYC-"+day+"-000001"); - } - @Test void existingPrivateDestinationAndShortCodeRulesArePreserved(){ - String day=LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); - assertThat(generator.next("TRUCK","private address",9)).isEqualTo("TR-WH-"+day+"-000009"); - assertThat(generator.next("LOOSEBOARD","ab12",9)).isEqualTo("LB-AB12-"+day+"-000009"); - assertThat(generator.next("TRUCK",null,-1)).isEqualTo("TR-WH-"+day+"-999999"); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java index 8fdde5eab..ca70d8559 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCareAuthorizationTest.java @@ -30,6 +30,10 @@ class VehicleCareAuthorizationTest { } private void authenticate(String code) { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken("user", "unused", List.of(new SimpleGrantedAuthority(code)))); } @Configuration @EnableMethodSecurity static class Config { + /** 为控制器权限测试中的服务替身补齐编号依赖。 */ + @Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { + return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); + } @Bean VehicleCareService service() { return mock(VehicleCareService.class); } @Bean PlatformVehicleCareController controller(VehicleCareService service) { return new PlatformVehicleCareController(service); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java index 6efa92b94..c35e7d950 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/maintenance/VehicleCarePersistenceTest.java @@ -51,6 +51,7 @@ class VehicleCarePersistenceTest { when(files.upload(anyLong(), anyString(), any())).thenReturn(new VehicleCareModels.File("9001", "photo.png", "image/png", 8)); var target = new VehicleCareService(session.getMapper(VehicleCareMapper.class), session.getMapper(VehicleCareHistoryMapper.class), vehicles, files, new ObjectMapper().registerModule(new JavaTimeModule()), mock(VehicleCareFinanceService.class)); + org.springframework.test.util.ReflectionTestUtils.setField(target, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); var proxy = new ProxyFactory(target); proxy.setProxyTargetClass(true); proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(source), new AnnotationTransactionAttributeSource())); service = (VehicleCareService) proxy.getProxy(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index b2e75e9ec..7101dd39f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -39,7 +39,7 @@ class OwnedBusinessScopePersistenceTest { baseline.setup();var reader=mock(DispatchDetailReader.class); when(reader.detail(any(),anyString())).thenAnswer(call->{ var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class); - return new DispatchViews.Detail(s.getDispatchNo(),DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), + return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of()); }); queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java index 25a81baf9..f0465d1a7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetCapacityOfferServiceTest.java @@ -32,6 +32,7 @@ class FleetCapacityOfferServiceTest { SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(user,"",List.of(new SimpleGrantedAuthority(FleetCapacityOfferService.LIST)))); service=new FleetCapacityOfferService(offers,mock(FleetCapacityOfferCommandMapper.class),trips,mock(FleetBookingMapper.class),mock(FleetBookingService.class),reservations, execution,mock(FleetCustomerDirectory.class),mock(FleetPoolOrderBridge.class),new ObjectMapper().registerModule(new JavaTimeModule()),Clock.fixed(now,ZoneOffset.UTC)); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); } @AfterEach void clear(){SecurityContextHolder.clearContext();} @Test void ordinaryPageOnlyCalculatesRequestedDatabasePageAndPreservesAccurateTotal(){ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 23c96088e..4736446b7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -112,6 +112,10 @@ class FleetTripAuthorizationTest { } @Configuration @EnableMethodSecurity static class Config { + /** 为控制器权限测试中的服务替身补齐编号依赖。 */ + @Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { + return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); + } /** 原单需求依赖使用替身,权限测试不访问数据库。 */ @Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService looseboardDemands() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandService.class); } /** 桥接器的原单查询使用仓储替身。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java index 6b3c47e9a..d7cfdc11d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGatewayTest.java @@ -164,6 +164,46 @@ class HttpWarehouseResourceGatewayTest { server.verify(); } + /** 实际SHIP后报废的原托扫码仍拒绝;保留提供者业务码/原因,不伪装为服务故障或按ID重试。 */ + @Test + void inspectPreservesScrappedPalletBusinessConflictWithoutFallback() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/D1747-1-001")) + .andRespond(withSuccess(""" + {"code":"A0409","message":"托盘已报废","data":null} + """, MediaType.APPLICATION_JSON)); + assertThatThrownBy(() -> gateway.inspectPallet("D1747-1-001")) + .isInstanceOfSatisfying(ResponseStatusException.class, error -> { + assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT); + assertThat(error.getReason()).isEqualTo("托盘已报废"); + }); + server.verify(); + } + + /** 按ID读取使用同一响应边界,错误码即使带data也不能被当作合法实托。 */ + @Test + void idLookupRejectsBusinessConflictEvenWithPayload() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/id/31")) + .andRespond(withSuccess(""" + {"code":"A0409","message":"托盘状态冲突","data":{"palletId":31,"palletCode":"PAL-1", + "warehouseId":88,"locationId":40,"status":"IDLE","version":1}} + """, MediaType.APPLICATION_JSON)); + assertThatThrownBy(() -> gateway.resolvePallet(31L)) + .isInstanceOfSatisfying(ResponseStatusException.class, error -> + assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + server.verify(); + } + + /** 同一个状态冲突无论通过HTTP状态还是业务信封传输,都不能降为400或503。 */ + @Test + void inspectPreservesHttpConflict() { + server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/PAL-CONFLICT")) + .andRespond(withStatus(HttpStatus.CONFLICT)); + assertThatThrownBy(() -> gateway.inspectPallet("PAL-CONFLICT")) + .isInstanceOfSatisfying(ResponseStatusException.class, error -> + assertThat(error.getStatusCode()).isEqualTo(HttpStatus.CONFLICT)); + server.verify(); + } + @Test void resolveReservedFallsBackToConfirmedPhysicalBindingForEarlierPlanDisplayCode() { server.expect(requestTo("http://warehouse/internal/v1/warehouse-pallets/resolve/IP-ACY2-20260909-166786-P001")) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java index 49195f710..29ce24699 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/ContainerInboundPlanConversionCoordinatorTest.java @@ -359,7 +359,7 @@ class ContainerInboundPlanConversionCoordinatorTest { new ContainerInboundPlanPreviewResponse.Group("ONE_CLICK_DROPSHIP", "31", "US-LAX-01", "Los Angeles", 1, 1, 1, 1, 0, 0, 1, 0, List.of(overseas))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); - when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))) + when(numbers.next(eq("INBOUND_PLAN"))) .thenReturn("IP-FBA", "IP-OVERSEAS"); when(plans.sourcePalletNos("100", 11L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-ABE3-001")); when(plans.sourcePalletNos("200", 12L, "ONE_CLICK_DROPSHIP")).thenReturn(List.of("MSCU6639870-US-LAX-01-001")); @@ -392,7 +392,7 @@ class ContainerInboundPlanConversionCoordinatorTest { List.of(new ContainerInboundPlanPreviewResponse.Group("FIRST_LEG", "31", "US-LAX-01", "Los Angeles", 1, 1, 2, 0, 2, 0, 2, 0, List.of(overseas))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); - when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-OVERSEAS"); + when(numbers.next(eq("INBOUND_PLAN"))).thenReturn("IP-OVERSEAS"); when(plans.sourcePalletNos("200", 12L, "FIRST_LEG")).thenReturn(List.of()); ContainerPlanConversionCommand command = ReflectionTestUtils.invokeMethod(coordinator, "prepareCommand", @@ -424,7 +424,7 @@ class ContainerInboundPlanConversionCoordinatorTest { List.of(new ContainerInboundPlanPreviewResponse.Group("FBA", null, "ACY2", "Amazon ACY2", 1, 1, 3, 2, 1, 0, 3, 0, List.of(order))), null); when(plans.previewContainer(BOOKING_ID)).thenReturn(preview); - when(numbers.next(eq("INBOUND_PLAN"), any(String.class), any(Long.class))).thenReturn("IP-ACY2"); + when(numbers.next(eq("INBOUND_PLAN"))).thenReturn("IP-ACY2"); when(plans.sourcePalletNos("100", 11L, "FIRST_LEG")).thenReturn(List.of( "D0004-101-001", "D0004-101-002")); @@ -475,7 +475,7 @@ class ContainerInboundPlanConversionCoordinatorTest { assertEquals(21L, preparation.items().get(0).planId()); assertEquals("IP-MANUAL", preparation.items().get(0).planNo()); - verify(numbers, never()).next(any(String.class), any(String.class), any(Long.class)); + verify(numbers, never()).next(any(String.class)); } private ContainerInboundPlanPreviewResponse.Order previewOrder(String customerId, String orderId, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java index bac6bc6e1..7bbb3f4ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/FbaAppointmentPersistenceTest.java @@ -262,6 +262,7 @@ class FbaAppointmentPersistenceTest { @Bean com.baiyue.lastleg.mapper.LastlegOrderMapper orders(){var mock=mock(com.baiyue.lastleg.mapper.LastlegOrderMapper.class);var row=new com.baiyue.lastleg.entity.LastlegOrder();row.setId(100L);row.setCustomerId("C");row.setInboundCode("WT-REAL");when(mock.selectById(100L)).thenReturn(row);return mock;} @Bean AppointmentExternalSourceGateway bookings(){var mock=mock(AppointmentExternalSourceGateway.class);when(mock.resolve(anyList())).thenReturn(List.of(new AppointmentExternalSourceGateway.Source("C","B-1","TR-1","RECEIVED","100","FBA:ONT8",100L,10,2L)));return mock;} @Bean AppointmentSourceLinker linker(com.baiyue.lastleg.mapper.LastlegOrderMapper orders,InboundFbaAppointmentItemMapper items,AppointmentExternalSourceGateway bookings){return new InboundAppointmentSourceLinker(orders,items,bookings);} + @Bean com.baiyue.lastleg.service.LastlegDocumentNumberGenerator documentNumbers() { return com.baiyue.lastleg.service.TestDocumentNumbers.documents(); } @Bean InboundFbaAppointmentService service(){return new InboundFbaAppointmentService();} @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper workMapper(SqlSessionTemplate session){return session.getMapper(com.baiyue.lastleg.appointmentwork.AppointmentWorkMapper.class);} /** 使用生产持久层,继续验证需求失效与预约写入的同事务行为。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java index 12871cd94..3be3975fd 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundFbaAppointmentServiceTest.java @@ -33,6 +33,7 @@ class InboundFbaAppointmentServiceTest { headers=mock(InboundFbaAppointmentMapper.class);items=mock(InboundFbaAppointmentItemMapper.class);query=mock(InboundFbaAppointmentQueryMapper.class); commands=mock(FbaAppointmentCommandMapper.class);sources=mock(AppointmentSourceService.class);flags=mock(AppointmentBatchPreviewMapper.class); service=new InboundFbaAppointmentService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.lastleg.service.TestDocumentNumbers.documents()); ReflectionTestUtils.setField(service,"receiving",mock(com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore.class)); ReflectionTestUtils.setField(service,"receivingWork",mock(com.baiyue.lastleg.inboundplan.receiving.FbaAppointmentWorkPort.class)); ReflectionTestUtils.setField(service,"workChanges",mock(com.baiyue.lastleg.inboundplan.work.AppointmentWorkAppointmentChanges.class)); @@ -95,7 +96,7 @@ class InboundFbaAppointmentServiceTest { } @Test void savesCrossCustomerArrangementBeforeAnyInboundPlanOrExternalNumberExists() { - var result=create("create");assertTrue(result.appointmentNo().startsWith("AP-"));assertNull(result.confirmationNo());assertNull(result.appointmentAt()); + var result=create("create");assertTrue(result.appointmentNo().matches("YY[0-9]{12}"));assertNull(result.confirmationNo());assertNull(result.appointmentAt()); assertEquals(LocalDate.of(2026,10,15),result.plannedDate());assertEquals(2,result.selectedCount());assertEquals("PENDING",result.appointmentStatus()); assertEquals(List.of("A","B"),result.items().stream().map(FbaAppointmentResponse.Item::customerId).toList());verify(query,never()).selectedLines(anyList()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index b810f9981..9df6a6190 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -75,7 +75,7 @@ class InboundPlanServiceTest { ReflectionTestUtils.setField(service, "commandMapper", commandMapper); ReflectionTestUtils.setField(service, "sourcePalletMapper", sourcePalletMapper); ReflectionTestUtils.setField(service, "objectMapper", JsonMapper.builder().findAndAddModules().build()); - ReflectionTestUtils.setField(service, "orderNoGenerator", new com.baiyue.lastleg.service.LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); org.mockito.Mockito.lenient().when(lineMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(palletMapper.selectList(any())).thenReturn(List.of()); org.mockito.Mockito.lenient().when(allocationMapper.selectList(any())).thenReturn(List.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java index af2ceb30e..6d46b625a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/looseboard/service/LocalLooseboardServiceTest.java @@ -47,7 +47,7 @@ class LocalLooseboardServiceTest { @Mock LocalLooseboardOperationMapper operations; @Mock LastlegOrderMapper lastlegOrders; @Mock LastlegOrderStatusHistoryMapper lastlegHistories; - @Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = new LastlegOrderNoGenerator(); + @Spy LastlegOrderNoGenerator lastlegOrderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); private LocalLooseboardService service; private final Map operationRows = new HashMap<>(); @@ -67,8 +67,7 @@ class LocalLooseboardServiceTest { // 旧独立履约用例保留真实关联尾程,新增需求不创建该来源。 var legacyTail = new LastlegOrder(); legacyTail.setId(99L); legacyTail.setCustomerId("100"); org.mockito.Mockito.lenient().when(lastlegOrders.selectById(99L)).thenReturn(legacyTail); - org.mockito.Mockito.lenient().when(lastlegOrderNoGenerator.next(anyString(), any(), anyLong())) - .thenReturn("LB-WH-20260901-000001"); + org.mockito.Mockito.lenient().doReturn("LB202610200001").when(lastlegOrderNoGenerator).next(anyString()); org.mockito.Mockito.lenient().when(operations.findKey(anyString(), any(), anyString())) .thenAnswer(call -> operationRows.get(call.getArgument(2))); org.mockito.Mockito.lenient().when(operations.insert(any(LocalLooseboardOperation.class))).thenAnswer(call -> { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java index 92e19711a..ef06232a8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/controller/ProcurementLifecycleAuthorizationTest.java @@ -27,6 +27,10 @@ class ProcurementLifecycleAuthorizationTest { 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);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java index 7f11a6952..a4b2578d3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/OverseasProcurementServiceTest.java @@ -43,7 +43,7 @@ class OverseasProcurementServiceTest { @Mock OverseasProcurementOrderMapper orders; @Mock OverseasProcurementStatusHistoryMapper histories; @Mock ProcurementWarehouseDirectory warehouseDirectory; - @Mock ProcurementOrderNumberGenerator orderNoGenerator; + @Mock com.baiyue.lastleg.service.LastlegDocumentNumberGenerator orderNoGenerator; @Mock OverseasProcurementFileMapper procurementFiles; @Mock OverseasProcurementFeeMapper procurementFees; @Mock ProcurementFileGateway fileGateway; @@ -62,7 +62,7 @@ class OverseasProcurementServiceTest { ReflectionTestUtils.setField(service, "procurementFiles", procurementFiles); ReflectionTestUtils.setField(service, "procurementFees", procurementFees); ReflectionTestUtils.setField(service, "fileGateway", fileGateway); - lenient().when(orderNoGenerator.next(anyString(), any())).thenReturn("ACME202609050001"); + lenient().when(orderNoGenerator.next(anyString())).thenReturn("ACME202609050001"); lenient().when(procurementFiles.selectList(any())).thenReturn(List.of()); lenient().when(procurementFees.selectList(any())).thenReturn(List.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java index f032b12ea..14927f83a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementChargePersistenceTest.java @@ -58,8 +58,11 @@ class ProcurementChargePersistenceTest { 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(); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java index 6a7fce90f..5cee6c842 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementControlPersistenceTest.java @@ -51,6 +51,7 @@ class ProcurementControlPersistenceTest { 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)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java index 1c1b269cc..5eda4b1ca 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementFinancePersistenceTest.java @@ -68,6 +68,8 @@ class ProcurementFinancePersistenceTest { 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() { @@ -235,7 +237,13 @@ class ProcurementFinancePersistenceTest { 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(tx.execute(s -> service.refund("9","PO-1","7",fee.getId(),new BigDecimal("20"),1,"correction","refund-key")).getId()).isEqualTo(refund.getId()); + 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(), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java deleted file mode 100644 index bd8ce150b..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementOrderNumberGeneratorTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.baiyue.lastleg.procurement.service; - -import org.junit.jupiter.api.Test; -import static org.assertj.core.api.Assertions.assertThat; - -class ProcurementOrderNumberGeneratorTest { - @Test void convertsChineseCompanyNameToInitials() { - assertThat(ProcurementOrderNumberGenerator.initials("杭州积乐贸易有限公司")).isEqualTo("HZJLMYYX"); - } - @Test void keepsOneInitialPerLatinWordAndUsesFallback() { - assertThat(ProcurementOrderNumberGenerator.initials("Great Ocean Trading LLC")).isEqualTo("GOTL"); - assertThat(ProcurementOrderNumberGenerator.initials(" ")).isEqualTo("KH"); - } - @Test void limitsInitialsToEightCharacters() { - assertThat(ProcurementOrderNumberGenerator.initials("一二三四五六七八九十")).hasSize(8); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java index 638a83214..8caa1277d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementPhysicalPersistenceTest.java @@ -55,6 +55,7 @@ class ProcurementPhysicalPersistenceTest { 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() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java index df56e9c73..0bf8057c8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementQueryServiceTest.java @@ -24,7 +24,9 @@ class ProcurementQueryServiceTest { 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); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java index ac1debecc..50f1bbacf 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/procurement/service/ProcurementTransportPersistenceTest.java @@ -68,7 +68,9 @@ class ProcurementTransportPersistenceTest { 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"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java index 1e1740d23..72f93a84f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/RelabelApplicationServiceTest.java @@ -208,17 +208,17 @@ class RelabelApplicationServiceTest { @Mock OverseasFeeSourceService feeSources; @Mock ReturnOrderMapper returnOrders; @Mock com.baiyue.lastleg.relabel.pda.mapper.PdaRelabelTaskMapper pdaTasks; - @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); + @Spy LastlegOrderNoGenerator orderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); private RelabelApplicationService service; - private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @BeforeEach void setUp() { - compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class); + compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new RelabelApplicationService(); ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers); - org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); + org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); ReflectionTestUtils.setField(service, "bindingGuard", bindingGuard); ReflectionTestUtils.setField(service, "returnPackingTraces", org.mockito.Mockito.mock(com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.class)); ReflectionTestUtils.setField(service, "skuConversions", org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.service.ReturnSkuConversionService.class)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java index a19d418ee..ae22320ec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/relabel/service/ReturnRelabelDeliveryTest.java @@ -108,7 +108,7 @@ class ReturnRelabelDeliveryTest { when(customers.find("C1")).thenReturn(new FleetCustomerDirectory.Customer("C1", "C", "测试客户", null, null)); when(warehouses.required(100L, "OVERSEAS")).thenReturn(new DriverModels.WarehouseOption("100", "WH", "洛杉矶测试仓", "OVERSEAS")); ReflectionTestUtils.setField(service, "outboundTasks", mock(OutboundPdaTaskMapper.class)); - ReflectionTestUtils.setField(service, "orderNoGenerator", new LastlegOrderNoGenerator()); + ReflectionTestUtils.setField(service, "orderNoGenerator", com.baiyue.lastleg.service.TestDocumentNumbers.orders()); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper()); var application = new RelabelApplication(); application.setId(1L); application.setCustomerId("C1"); application.setRelabelNo("REL1"); application.setOverseasWarehouseId("100"); application.setDeliveryMethod(deliveryMethod); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/DocumentSequenceRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..136356f8f --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE ll_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java index 12e253f98..39963ddad 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnInventoryServiceTest.java @@ -281,7 +281,7 @@ class ReturnInventoryServiceTest { receipt.setPalletCode("PAL-RETURN-1"); receipt.setUnitType("PALLET"); receipt.setUnitCode("PAL-RETURN-1"); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-1"); + service.createReceiptCarton(order, receipt, null, List.of(new ReturnInventoryService.ReceiptLine("SKU-A", "商品A", 2))); @@ -339,7 +339,7 @@ class ReturnInventoryServiceTest { when(cartons.listReceipt(21L)).thenReturn(List.of(source)); when(cartons.lockMark("RC-SOURCE")).thenReturn(source); when(cartonItems.lockCarton(31L)).thenReturn(List.of(skuA, skuB)); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-MERGED"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); service.merge(new PdaWarehouseIdentity("operator-1", "操作员", 42L), @@ -382,13 +382,16 @@ class ReturnInventoryServiceTest { when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 6L))); when(packingTrace.ownership("10001", "RC-SOURCE", "SKU-A")).thenReturn(List.of( new com.baiyue.lastleg.returnreceipt.mapper.ReturnPackingTraceMapper.Ownership("RT-1", 3L))); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(quantity)); + var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class); + verify(cartons).insert(target.capture()); + assertThat(target.getValue().getCartonMark()).isEqualTo(LastlegOrderNoGenerator.compactCartonMark(target.getValue().getId())); verify(packingTrace).insert(anyLong(), eq("10001"), anyLong(), eq("RC-SOURCE"), - eq("RC-TARGET"), eq("RT-1"), eq("SKU-A"), eq(quantity), any()); + eq(target.getValue().getCartonMark()), eq("RT-1"), eq("SKU-A"), eq(quantity), any()); verify(packingTrace, org.mockito.Mockito.times(1)).insert(anyLong(), anyString(), anyLong(), anyString(), anyString(), anyString(), anyString(), anyLong(), any()); assertThat(source.getTotalQuantity()).isEqualTo(6L - quantity); @@ -432,7 +435,7 @@ class ReturnInventoryServiceTest { void fullyConsumedParcelSourceIsMergedAndCreatesOnePhysicalCarton() { ReturnInventoryCarton locked = prepareConcurrentSource(); when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); service.merge(new PdaWarehouseIdentity("operator-2", "操作员2", 42L), mergeRequest(2)); assertThat(locked.getTotalQuantity()).isZero(); @@ -457,14 +460,14 @@ class ReturnInventoryServiceTest { var source = prepareConcurrentSource(); when(cartonItems.lockCarton(31L)).thenReturn(List.of(item(41L, "SKU-A", 2L))); when(cartons.completedSkuQuantity(31L, "SKU-A")).thenReturn(2L); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("RC-TARGET"); + when(balances.adjustCartonQuantity(anyString(), anyString(), anyString(), anyLong())).thenReturn(1); var relabel = org.mockito.Mockito.mock(com.baiyue.lastleg.relabel.mapper.RelabelItemMapper.class); ReflectionTestUtils.setField(service, "relabelItems", relabel); service.merge(new PdaWarehouseIdentity("operator-2", "name", 42L), mergeRequest(2)); var target = ArgumentCaptor.forClass(ReturnInventoryCarton.class); verify(cartons).insert(target.capture()); - verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), "RC-TARGET"); + verify(relabel).moveCompletedReturnCarton("10001", 31L, target.getValue().getId(), target.getValue().getCartonMark()); assertThat(source.getTotalQuantity()).isZero(); } @@ -579,7 +582,7 @@ class ReturnInventoryServiceTest { when(cartonItems.lockSku(31L, "SKU-A")).thenReturn(line); when(balances.reserveAvailable("10001", "42", "SKU-A", 2)).thenReturn(1); when(balances.releaseAvailable("10001", "42", "SKU-A", 2)).thenReturn(1); - when(numbers.next(anyString(), anyString(), anyLong())).thenReturn("PLAN-1"); + when(numbers.next(anyString())).thenReturn("PLAN-1"); var request = new ReturnInventoryRequests.PackingPlanCreate(List.of("RT-1"), "SKU", List.of(new ReturnInventoryRequests.PlanBox(List.of(new ReturnInventoryRequests.PlanItem("SKU-A", 2)), null, null, null, null)), null, "plan-key"); var created = service.createPackingPlan("platform", request); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java index 99f173a76..20fc7b23e 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/returnreceipt/service/ReturnReceiptServiceTest.java @@ -131,13 +131,13 @@ class ReturnReceiptServiceTest { ReturnReceiptService service; PdaWarehouseIdentity identity; - private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @BeforeEach void setUp() { - compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class); + compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new ReturnReceiptService(); ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers); - org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); + org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "202610200001"); ReflectionTestUtils.setField(service, "orders", orders); ReflectionTestUtils.setField(service, "orderItems", orderItems); ReflectionTestUtils.setField(service, "receipts", receipts); @@ -257,7 +257,7 @@ class ReturnReceiptServiceTest { when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 50, 50), item(12L, "SKU-B", 40, 40))); when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); - when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-MULTI"); + when(compactNumbers.next(eq("THHB"))).thenReturn("RL-MULTI"); when(lastlegOrders.selectById(99L)).thenReturn(tail); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(first, second)); when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); @@ -310,7 +310,7 @@ class ReturnReceiptServiceTest { when(orderItems.lockOrder(1L)).thenReturn(List.of(item(11L, "SKU-A", 100, 100))); when(fbaWarehouseValidator.required("200", "XLX6", null)).thenReturn( new FbaWarehouseValidator.VerifiedFbaWarehouse(200L, "XLX6", "FBA XLX6", "US", "CA")); - when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RL-SECOND"); + when(compactNumbers.next(eq("THHB"))).thenReturn("RL-SECOND"); when(lastlegOrders.selectById(99L)).thenReturn(tail); when(cartons.findAvailable("customer-1", "10")).thenReturn(List.of(carton)); when(inventory.matchesReturnSource(any(), eq("RT-MULTI"))).thenCallRealMethod(); @@ -365,7 +365,7 @@ class ReturnReceiptServiceTest { void createForecastPersistsCompleteTailOrderSourceAndReturnsItsBusinessNumber() { AtomicReference saved = new AtomicReference<>(); when(orders.findKey("customer-1", "create-key")).thenReturn(null); - when(compactNumbers.next(eq("TT"), eq("customer-1"))).thenReturn("RT-1"); + when(compactNumbers.next(eq("TH"))).thenReturn("RT-1"); when(orders.insert(any(ReturnOrder.class))).thenAnswer(invocation -> { saved.set(invocation.getArgument(0)); return 1; }); when(orders.findNo("customer-1", "RT-1")).thenAnswer(invocation -> saved.get()); when(orderItems.listOrder(anyLong())).thenReturn(List.of()); @@ -389,7 +389,7 @@ class ReturnReceiptServiceTest { assertEquals("商品A", cargo.getValue().getProductNameZh()); assertEquals(2, cargo.getValue().getProductQuantity()); assertEquals(0, cargo.getValue().getPackageCount()); - verify(numbers, never()).next(eq("RETURN_RELABEL"), anyString(), anyLong()); + verify(numbers, never()).next(eq("RETURN_RELABEL")); } @Test @@ -559,8 +559,8 @@ class ReturnReceiptServiceTest { when(orders.lockNo("RT-1")).thenReturn(order); when(orderItems.lockOrder(1L)).thenReturn(items); when(orderItems.listOrder(1L)).thenReturn(items); - when(numbers.next(eq("RETURN_RECEIPT"), eq("10"), anyLong())).thenReturn("RR-1"); - when(numbers.next(eq("RETURN_BATCH"), eq("10"), anyLong())).thenReturn("RB-1"); + when(numbers.next(eq("RETURN_RECEIPT"))).thenReturn("RR-1"); + when(numbers.next(eq("RETURN_BATCH"))).thenReturn("RB-1"); ReturnInventoryCarton carton = new ReturnInventoryCarton(); carton.setCartonMark(cartonMark); when(inventory.createReceiptCarton(eq(order), any(ReturnReceipt.class), any(), any())).thenReturn(carton); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java index 588b2fcbb..2b16ca6e6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/selfoperated/analytics/query/DashboardSqlSemanticTest.java @@ -344,6 +344,60 @@ class DashboardSqlSemanticTest { assertTrue(rendered.endsWith(finalSelect));rendered=rendered.substring(0,rendered.length()-finalSelect.length()) +"SELECT customer_code,sku_code,bucket_code FROM classified WHERE bucket_code IS NOT NULL"; } - String id="dashboard.literal."+(++sequence);config.addMappedStatement(new MappedStatement.Builder(config,id,new org.apache.ibatis.builder.StaticSqlSource(config,rendered),SqlCommandType.SELECT).resultMaps(original.getResultMaps()).build());return session.selectList(id); + // H2 会指数展开重复引用的 CTE;沿用周转测试的逐关系物化方式, + // 保留生产查询表达式和结果映射,不据此推定 MySQL 的执行计划。 + var relations = new ArrayList(); + try { + String finalSql = materializeRelations(rendered, relations); + String id="dashboard.literal."+(++sequence); + config.addMappedStatement(new MappedStatement.Builder(config,id, + new org.apache.ibatis.builder.StaticSqlSource(config,finalSql),SqlCommandType.SELECT) + .resultMaps(original.getResultMaps()).build()); + return session.selectList(id); + } catch (SQLException error) { + throw new AssertionError("本地统计 SQL 测试关系物化失败", error); + } finally { + Collections.reverse(relations); + for (String name : relations) { + try (var statement = session.getConnection().createStatement()) { + statement.execute("DROP TABLE " + name); + } catch (SQLException error) { + throw new AssertionError("本地统计 SQL 测试关系释放失败:" + name, error); + } + } + } + } + + /** 只物化实际生产 CTE 的查询内容,临时表局限在当前隔离 H2 会话。 */ + private String materializeRelations(String sql, List relations) throws SQLException { + if (!sql.startsWith("WITH ")) return sql; + int cursor = 5; + while (true) { + int as = sql.indexOf(" AS (", cursor); + assertTrue(as >= cursor, "生产 CTE 应使用具名 AS 查询"); + String name = sql.substring(cursor, as).trim(); + assertTrue(name.matches("[A-Za-z_][A-Za-z0-9_]*"), "测试临时关系必须是固定 SQL 标识"); + int start = as + 5, end = start, depth = 1; + boolean quoted = false; + while (depth > 0) { + char ch = sql.charAt(end++); + if (ch == '\'') { + if (quoted && end < sql.length() && sql.charAt(end) == '\'') { end++; continue; } + quoted = !quoted; + } else if (!quoted) { + if (ch == '(') depth++; + if (ch == ')') depth--; + } + } + try (var statement = session.getConnection().createStatement()) { + statement.execute("CREATE LOCAL TEMPORARY TABLE " + name + " AS " + sql.substring(start, end - 1)); + } + relations.add(name); + cursor = end; + while (cursor < sql.length() && Character.isWhitespace(sql.charAt(cursor))) cursor++; + if (sql.charAt(cursor) != ',') return sql.substring(cursor); + cursor++; + while (cursor < sql.length() && Character.isWhitespace(sql.charAt(cursor))) cursor++; + } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java deleted file mode 100644 index 07ff1c047..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/CompactBusinessNumberServiceTest.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.baiyue.lastleg.service; - -import org.junit.jupiter.api.Test; -import org.springframework.jdbc.core.JdbcTemplate; -import java.time.Instant; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -class CompactBusinessNumberServiceTest { - @Test void usesCustomerInitialsAndShanghaiSecondsWithoutSequenceSuffix() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - var customers = mock(ReturnOrderReferenceResolver.class); - when(customers.customerName("42")).thenReturn("柏悦物流"); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)) - .thenAnswer(call -> Instant.now().getEpochSecond()); - String number = new CompactBusinessNumberService(jdbc, customers).next("TB", "42"); - assertTrue(number.matches("TB-BYWL-[0-9]{12}"), number); - assertEquals("AB", CompactBusinessNumberService.initials("Alpha Beta")); - } - - @Test void usesDatabaseLogicalSecondAcrossInstancesAndClockRollback() { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - var first = new CompactBusinessNumberService(jdbc, null); - var second = new CompactBusinessNumberService(jdbc, null); - Instant now = Instant.parse("2026-10-16T02:30:00Z"); - when(jdbc.queryForObject("SELECT LAST_INSERT_ID()", Long.class)) - .thenReturn(now.getEpochSecond(), now.getEpochSecond() + 1, now.getEpochSecond() + 2); - assertEquals("ZY-ABC-261016103000", first.allocate("ZY", "ABC", now)); - assertEquals("ZY-ABC-261016103001", second.allocate("ZY", "ABC", now)); - assertEquals("ZY-ABC-261016103002", first.allocate("ZY", "ABC", now.minusSeconds(60))); - } - - @Test void physicalLabelKeepsEveryIdBitAndIsCompact() { - long id = 2087379674555461633L; - String mark = LastlegOrderNoGenerator.compactCartonMark(id); - assertTrue(mark.length() <= 16); - assertEquals(id, Long.parseLong(mark.substring(3), 36)); - assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000)); - assertEquals(mark, new LastlegOrderNoGenerator().next("RETURN_CARTON", "92", id)); - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegDocumentNumberGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..96fb3af0a --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegDocumentNumberGeneratorTest.java @@ -0,0 +1,41 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.repository.DocumentSequenceRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class LastlegDocumentNumberGeneratorTest { + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(LastlegDocumentNumberGenerator.class);context.refresh(); + assertThat(context.getBean(LastlegDocumentNumberGenerator.class)).isNotNull(); + } + } + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("YY:20261020")).thenReturn(1L,2L,9999L); + var numbers=new LastlegDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("YY")).isEqualTo("YY202610200001"); + assertThat(numbers.next("YY")).isEqualTo("YY202610200002"); + assertThat(numbers.next("YY")).isEqualTo("YY202610209999"); + verify(sequences,times(3)).next("YY:20261020"); + } + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new LastlegDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("YY:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("YY")).isInstanceOf(BizException.class); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index 8bef1431f..e6f0e86a7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -1,94 +1,30 @@ package com.baiyue.lastleg.service; -import com.baiyue.lastleg.mapper.LastlegOrderNoDailySequenceMapper; -import org.apache.ibatis.annotations.Insert; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.test.util.ReflectionTestUtils; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; -import java.time.LocalDate; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; -import static org.mockito.Mockito.times; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; - -@ExtendWith(MockitoExtension.class) class LastlegOrderNoGeneratorTest { - @Mock LastlegOrderNoDailySequenceMapper sequenceMapper; - private LastlegOrderNoGenerator generator; - - @BeforeEach - void setUp() { - generator = new LastlegOrderNoGenerator(); - ReflectionTestUtils.setField(generator, "sequenceMapper", sequenceMapper); + /** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */ + @Test void routesEveryOperationalTypeToItsDailySequence() { + var generator=TestDocumentNumbers.orders(); + Map.ofEntries(Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"), + Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"), + Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"), + Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"), + Map.entry("RETURN_RECEIPT","RR"),Map.entry("RETURN_BATCH","RB"),Map.entry("RETURN_PACKING_PLAN","HX")) + .forEach((type,prefix)->{ + assertEquals(prefix+"202610200001",generator.next(type)); + assertEquals(prefix+"202610200002",generator.next(type)); + }); + assertThrows(IllegalArgumentException.class,()->generator.next("UNKNOWN")); } - - @Test - void usesShanghaiBusinessDateAndDatabaseAllocatedSequence() { - LocalDate date = LocalDate.of(2026, 8, 27); - when(sequenceMapper.currentValue()).thenReturn(1L, 2L); - - assertEquals("LMO-260827-0001", generator.next(date)); - assertEquals("LMO-260827-0002", generator.next(date)); - verify(sequenceMapper, times(2)).nextValue(date); - } - - @Test - void mapperUsesOneAtomicDatabaseOperationForConcurrentInstances() throws Exception { - Insert insert = LastlegOrderNoDailySequenceMapper.class - .getMethod("nextValue", LocalDate.class).getAnnotation(Insert.class); - - assertTrue(insert.value()[0].contains("ON DUPLICATE KEY UPDATE")); - assertTrue(insert.value()[0].contains("LAST_INSERT_ID(last_sequence+1)")); - } - - @Test - void crossDayAllocationUsesTheNewShanghaiBusinessDateSequence() { - LocalDate firstDay = LocalDate.of(2026, 8, 27); - LocalDate nextDay = firstDay.plusDays(1); - when(sequenceMapper.currentValue()).thenReturn(42L, 1L); - - assertEquals("LMO-260827-0042", generator.next(firstDay)); - assertEquals("LMO-260828-0001", generator.next(nextDay)); - verify(sequenceMapper).nextValue(firstDay); - verify(sequenceMapper).nextValue(nextDay); - } - - @Test - void rejectsDailySequenceBeyondFourDigits() { - when(sequenceMapper.currentValue()).thenReturn(10_000L); - assertThrows(IllegalStateException.class, () -> generator.next(LocalDate.of(2026, 8, 27))); - } - - @Test void actualContainerNumberIsStableAndShared() { - LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - assertTrue(generator.forContainerNo("2087379674555461633").matches("FL-WH-\\d{8}-\\d{6}")); - assertEquals(generator.forContainerNo("MSCU1"), new LastlegOrderNoGenerator().forContainerNo("MSCU1")); - assertNotEquals(generator.forContainerNo("MSCU1"), generator.forContainerNo("CAB1")); - assertTrue(generator.forContainerNo("MSCU1234567890").length() <= 32); - assertThrows(IllegalArgumentException.class, () -> generator.forContainerNo(" ")); - } - - @Test void formatsBusinessWarehouseAndDate() { - String value = new LastlegOrderNoGenerator().next("OUTBOUND", "warehouse-LAX-01", 123L); - assertTrue(value.matches("OB-LAX-\\d{8}-000123"), value); - assertTrue(value.length() <= 24); - } - - @Test void transferNumbersDoNotCollideWhenDatabaseIdSuffixesRepeat() { - String first = generator.next("TRANSFER", "warehouse-LAX-01", 123L); - String second = generator.next("TRANSFER", "warehouse-LAX-01", 1_000_123L); - String repeatedId = generator.next("WAREHOUSE_TRANSFER", "warehouse-LAX-01", 123L); - assertTrue(first.matches("WT-[0-9A-Z]{25}"), first); - assertTrue(first.length() <= 32); - assertNotEquals(first, second); - assertNotEquals(first, repeatedId); + /** 头程来源尾程号未纳入独立改号,保持同柜分组关系。 */ + @Test void actualContainerNumberRemainsStableAndShared() { + var generator=TestDocumentNumbers.orders(); + assertTrue(generator.forContainerNo("MSCU6639870").matches("FL-WH-\\d{8}-\\d{6}")); + assertEquals(generator.forContainerNo("MSCU6639870"),TestDocumentNumbers.orders().forContainerNo("MSCU6639870")); + assertNotEquals(generator.forContainerNo("MSCU6639870"),generator.forContainerNo("CSQU3054383")); + assertThrows(IllegalArgumentException.class,()->generator.forContainerNo(" ")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 6b714df95..5f43e3f66 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -3,10 +3,10 @@ import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { private final Map frozenContainerNos=new HashMap<>(); - @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegOrderNoDailySequenceMapper sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(); ReflectionTestUtils.setField(generator,"sequenceMapper",sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().when(sequences.currentValue()).thenReturn(1L); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } + @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegDocumentNumberGenerator sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } - @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verifyNoInteractions(sequences); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } @@ -15,7 +15,7 @@ class LoadingCompletedConsumerTest { @Test void rejectsDifferentContainerNumberForSameSourceBookingBeforeAnyBusinessWrite(){ EventEnvelope source=event(); frozenContainerNos.put("book-1","CSQU3054383"); IllegalStateException error=assertThrows(IllegalStateException.class,()->service.consume(source)); assertTrue(error.getMessage().contains("sourceContainerBookingId")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void rejectsExistingOrderContainerDriftForSameSourceBooking(){ when(orders.lockContainerNumbers("book-1")).thenReturn(List.of("MSCU6639870","CSQU3054383")); assertThrows(IllegalStateException.class,()->service.consume(event())); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void acceptsSeparateCustomerEventsForTheSameFrozenContainerIdentity(){ EventEnvelope first=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(first).outcome()); com.fasterxml.jackson.databind.node.ObjectNode node=first.payload().deepCopy(); int index=0; for(JsonNode order:node.path("orders")){ ((com.fasterxml.jackson.databind.node.ObjectNode)order).put("customerId","200").put("customerName","Customer 200").put("sourceInboundId","customer-200-in-"+(++index)); } String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174200",first.eventType(),first.eventVersion(),first.occurredAt(),"200",first.aggregateType(),first.aggregateId(),first.aggregateVersion(),first.handoverVersion(),first.traceId(),first.correlationId(),first.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(second).outcome()); verify(shippingGates,times(2)).initialize(eq("book-1"),eq(""),eq("MSCU6639870"),eq(0L),eq(1L)); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(4)).insert(captured.capture()); assertEquals(Set.of("100","200"),captured.getAllValues().stream().map(LastlegOrder::getCustomerId).collect(java.util.stream.Collectors.toSet())); assertTrue(captured.getAllValues().stream().allMatch(order->"MSCU6639870".equals(order.getContainerNo()))); } - @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verify(sequences,never()).nextValue(any(java.time.LocalDate.class)); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } + @Test void replayPreservesExistingLmoNumberAndAddsNothing(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); row.setStatus("PROCESSED"); when(inbox.selectById(event.eventId())).thenReturn(row); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.DUPLICATE, service.consume(event).outcome()); verifyNoInteractions(sequences); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); verify(histories,never()).insert(any(LastlegOrderStatusHistory.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/PhysicalCartonNumberTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/PhysicalCartonNumberTest.java new file mode 100644 index 000000000..07f41c186 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/PhysicalCartonNumberTest.java @@ -0,0 +1,17 @@ +package com.baiyue.lastleg.service; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class PhysicalCartonNumberTest { + /** 箱码不属于本次改号,完整保留内部 ID 的每一位以保证扫码唯一性。 */ + @Test void preservesEveryIdBit() { + long id = 2087379674555461633L; + String mark = LastlegOrderNoGenerator.compactCartonMark(id); + assertTrue(mark.length() <= 16); + assertEquals(id, Long.parseLong(mark.substring(3), 36)); + assertNotEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id + 1_000_000)); + assertEquals(mark, LastlegOrderNoGenerator.compactCartonMark(id)); + assertThrows(IllegalArgumentException.class, () -> LastlegOrderNoGenerator.compactCartonMark(0)); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java index 06066a3aa..92e76366a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ReturnBusinessNumberTest.java @@ -1,60 +1,27 @@ package com.baiyue.lastleg.service; import org.junit.jupiter.api.Test; -import java.math.BigInteger; +import java.util.List; import java.util.HashSet; -import java.util.Map; -import java.util.UUID; -import java.util.stream.IntStream; import static org.junit.jupiter.api.Assertions.*; class ReturnBusinessNumberTest { - // 实物箱唛采用稳定BX编码,由CompactBusinessNumberServiceTest校验;此处仅验证独立业务编号。 - private static final Map TYPES = Map.of("RETURN", "RT", "RETURN_RECEIPT", "RR", - "RETURN_BATCH", "RB", "RETURN_RELABEL", "RETU"); - - @Test - void formerlyCollidingInternalIdsAndRepeatedIdsNoLongerDetermineBusinessNumbers() { - LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - long first = 2099800000000781889L, second = first + 1_000_000L; - assertEquals(Math.floorMod(first, 1_000_000), Math.floorMod(second, 1_000_000)); - TYPES.forEach((type, prefix) -> { - String a = generator.next(type, "warehouse-LAX-01", first); - String b = generator.next(type, "warehouse-LAX-01", second); - String c = generator.next(type, "warehouse-LAX-01", first); - assertEquals(3, new HashSet<>(java.util.List.of(a, b, c)).size()); - for (String value : java.util.List.of(a, b, c)) { - assertTrue(value.matches(prefix + "-[0-9A-Z]{25}"), value); - assertTrue(value.length() <= 32); - assertFalse(value.contains(Long.toString(first))); - } - }); - } - - @Test - void encodingPreservesAll128BitsIncludingLeadingZerosAndSignBits() { - for (UUID uuid : java.util.List.of(new UUID(0, 0), new UUID(-1, -1), new UUID(Long.MIN_VALUE, 1), - UUID.fromString("00000000-0000-4000-8000-000000000001"), - UUID.fromString("ffffffff-ffff-4fff-bfff-000000000001"))) { - String encoded = LastlegOrderNoGenerator.encodeReturnUuid(uuid); - assertEquals(25, encoded.length()); - assertEquals(new BigInteger(uuid.toString().replace("-", ""), 16), new BigInteger(encoded, 36)); + /** 独立单据不再依赖客户简称和内部 ID,各前缀从一开始。 */ + @Test void businessPrefixesHaveIndependentFourDigitSequences() { + var generator=TestDocumentNumbers.documents(); + for(String prefix:List.of("TH","THHB","TB","ZY","RR","RB","HX","CG","PR","PJ","PF","PN","PX","PG","CT","FB","FO","VR")) { + var values=List.of(generator.next(prefix),generator.next(prefix),generator.next(prefix)); + assertEquals(3,new HashSet<>(values).size()); + assertEquals(prefix+"202610200001",values.get(0)); + assertEquals(prefix+"202610200003",values.get(2)); } } - - @Test - void independentInstancesGenerateDistinctNumbersDuringConcurrentBurst() { - var values = IntStream.range(0, 20_000).parallel() - .mapToObj(i -> new LastlegOrderNoGenerator().next("RETURN_RECEIPT", "42", 123L)).toList(); - assertEquals(values.size(), new HashSet<>(values).size()); - } - - @Test - void nonReturnRulesRemainUnchangedAndReturnNormalizationIsLocaleIndependent() { - LastlegOrderNoGenerator generator = new LastlegOrderNoGenerator(); - for (String type : java.util.List.of("OUTBOUND", "RELABEL", "PROCUREMENT", "DISPATCH")) { - assertEquals(generator.next(type, "LAX", 123), generator.next(type, "LAX", 1_000_123)); - } - assertTrue(generator.next(" return_receipt ", "42", 1).matches("RR-[0-9A-Z]{25}")); + /** 商业池和普通作业入口必须共享卡车及散板同类型序列。 */ + @Test void commercialAndOperationalEntriesShareTheSameCounters() { + var documents=TestDocumentNumbers.documents();var operations=new LastlegOrderNoGenerator(documents); + assertEquals("TR202610200001",documents.next("TR")); + assertEquals("TR202610200002",operations.next("TRUCK")); + assertEquals("LB202610200001",operations.next("LOOSEBOARD")); + assertEquals("LB202610200002",documents.next("LB")); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/TestDocumentNumbers.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TestDocumentNumbers.java new file mode 100644 index 000000000..4756d4fd5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/TestDocumentNumbers.java @@ -0,0 +1,23 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.repository.DocumentSequenceRepository; +import java.time.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */ +public final class TestDocumentNumbers { + /** 测试工具不实例化。 */ + private TestDocumentNumbers() {} + /** 每个测试生成器独立计数,类型前缀之间互不占号。 */ + public static LastlegDocumentNumberGenerator documents() { + var sequences=mock(DocumentSequenceRepository.class); + var counters=new ConcurrentHashMap(); + lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet()); + return new LastlegDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC)); + } + /** 海外类型映射同样使用生产生成器。 */ + public static LastlegOrderNoGenerator orders() { return new LastlegOrderNoGenerator(documents()); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index 860ffb446..ae4a41420 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -196,7 +196,7 @@ class WarehouseTransferServiceTest { @Mock LastlegOrderDetailMapper orderDetails; @Mock LastlegOrderStatusHistoryMapper orderHistories; @Mock RelabelApplicationMapper relabelApplications; - @Spy LastlegOrderNoGenerator orderNoGenerator = new LastlegOrderNoGenerator(); + @Spy LastlegOrderNoGenerator orderNoGenerator = com.baiyue.lastleg.service.TestDocumentNumbers.orders(); @Mock InventoryCommandService inventory; @Mock InventoryCommandMapper inventoryCommands; @Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService; @@ -204,16 +204,16 @@ class WarehouseTransferServiceTest { @Mock WarehouseTransferReceiptCoordinator receiptCoordinator; @Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; - private com.baiyue.lastleg.service.CompactBusinessNumberService compactNumbers; + private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @BeforeEach void setUp() { - compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.CompactBusinessNumberService.class); + compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new WarehouseTransferService(); var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository(); ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons); ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository); ReflectionTestUtils.setField(service, "compactNumbers", compactNumbers); - org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); + org.mockito.Mockito.lenient().when(compactNumbers.next(org.mockito.ArgumentMatchers.anyString())).thenAnswer(call -> call.getArgument(0) + "-CS-261016102030"); ReflectionTestUtils.setField(service, "customerDirectory", customerDirectory); lenient().when(customerDirectory.find("TENANT-A")).thenReturn( new com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory.Customer("TENANT-A", "C-A", "Customer A", null, null)); @@ -267,8 +267,7 @@ class WarehouseTransferServiceTest { ReflectionTestUtils.setField(service, "inventoryCommands", inventoryCommands); ReflectionTestUtils.setField(service, "relabelService", relabelService); ReflectionTestUtils.setField(service, "objectMapper", new ObjectMapper().findAndRegisterModules()); - lenient().when(orderNoGenerator.next(anyString(), any(), anyLong())) - .thenReturn("WT-0000000000000000000000001"); + lenient().doReturn("ZY202610200001").when(orderNoGenerator).next(anyString()); lenient().when(inventoryAllocations.selectList(any())).thenReturn(List.of()); lenient().when(transferReceipts.selectList(any())).thenReturn(List.of()); lenient().when(transferCartons.listOrder(anyString(), anyLong())).thenReturn(List.of()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java index d5e6a928f..b69f00f26 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/trucking/service/UsTruckOrderServiceTest.java @@ -80,7 +80,7 @@ class UsTruckOrderServiceTest { @Test void createStoresBothContactsAndMinuteTimesWithoutInventingPallets() { - when(orderNoGenerator.next(anyString(), anyString(), anyLong())).thenReturn("TK1"); + when(orderNoGenerator.next(anyString())).thenReturn("TK1"); when(histories.listOrder(anyString(), anyLong())).thenReturn(historyRows); when(orders.insert(any(UsTruckOrder.class))).thenAnswer(call -> { order = call.getArgument(0); return 1; }); var request = create("PALLET_15", "new-truck"); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json index c8337ab79..e1412d1a5 100644 --- a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -56,6 +56,11 @@ "com.baiyue.lastleg.security.service.SecurityProjectionMonotonicity" ], "reviewedClassDependencies": [ + { + "source": "com.baiyue.lastleg.fleet.outsourcedbusiness.model.OutsourcedBusinessModels", + "target": "com.baiyue.lastleg.dispatch.port.DispatchViews", + "reason": "外配业务只组合原排车公开只读详情;精确边对应既有反向合同,不开放派送持久化或具体业务服务。" + }, { "source": "com.baiyue.lastleg.dispatch.transport.TransportTaskService", "target": "com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository", diff --git a/order/src/main/resources/db/order.sql b/order/src/main/resources/db/order.sql index 47fd3a3bf..3dfac6a2e 100644 --- a/order/src/main/resources/db/order.sql +++ b/order/src/main/resources/db/order.sql @@ -423,3 +423,11 @@ CREATE TABLE IF NOT EXISTS ord_idempotency_record ( UNIQUE KEY uk_order_idempotency_scope (tenant_id, operator_id, operation, idempotency_key), KEY idx_order_idempotency_created (created_at) ) ENGINE=InnoDB COMMENT='订单写接口持久化幂等记录:唯一键保证同租户同操作者并发请求只执行一次'; + +-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。 +CREATE TABLE IF NOT EXISTS ord_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_ord_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; diff --git a/order/src/test/java/com/baiyue/order/repository/DocumentSequenceRepositoryTest.java b/order/src/test/java/com/baiyue/order/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..e5ab7c8ef --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.order.repository; + +import com.baiyue.order.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE ord_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java index fe71ec2e1..108f095c8 100644 --- a/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java +++ b/order/src/test/java/com/baiyue/order/service/MerchantOrderCommandTransactionTest.java @@ -99,6 +99,7 @@ class MerchantOrderCommandTransactionTest { ReflectionTestUtils.setField(completion, "outbox", session.getMapper(OutboxEventMapper.class)); ReflectionTestUtils.setField(completion, "json", json); OrderApplicationService target = new OrderApplicationService(); + org.springframework.test.util.ReflectionTestUtils.setField(target, "documentNumbers", com.baiyue.order.service.TestDocumentNumbers.documents()); ReflectionTestUtils.setField(target, "repository", repository); ReflectionTestUtils.setField(target, "fulfillmentCompletion", proxy(completion, manager)); ReflectionTestUtils.setField(target, "json", json); diff --git a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java index d926c26fb..1f81d1917 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderApplicationServiceStateTest.java @@ -47,6 +47,8 @@ class OrderApplicationServiceStateTest { @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper().findAndRegisterModules(); } } @Test diff --git a/order/src/test/java/com/baiyue/order/service/OrderDocumentNumberGeneratorTest.java b/order/src/test/java/com/baiyue/order/service/OrderDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..1c4995399 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/OrderDocumentNumberGeneratorTest.java @@ -0,0 +1,41 @@ +package com.baiyue.order.service; + +import com.baiyue.order.repository.DocumentSequenceRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class OrderDocumentNumberGeneratorTest { + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(OrderDocumentNumberGenerator.class);context.refresh(); + assertThat(context.getBean(OrderDocumentNumberGenerator.class)).isNotNull(); + } + } + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("FL:20261020")).thenReturn(1L,2L,9999L); + var numbers=new OrderDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("FL")).isEqualTo("FL202610200001"); + assertThat(numbers.next("FL")).isEqualTo("FL202610200002"); + assertThat(numbers.next("FL")).isEqualTo("FL202610209999"); + verify(sequences,times(3)).next("FL:20261020"); + } + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new OrderDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("FL:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("FL")).isInstanceOf(BizException.class); + } +} diff --git a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java index 8914aa3aa..30e721604 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderIdempotencyServiceTest.java @@ -49,6 +49,8 @@ class OrderIdempotencyServiceTest { @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } @Test diff --git a/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java b/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java index 7c1eb5513..67c58fc15 100644 --- a/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java +++ b/order/src/test/java/com/baiyue/order/service/OrderRemarksSynchronizationTest.java @@ -23,6 +23,7 @@ class OrderRemarksSynchronizationTest { @SuppressWarnings("unchecked") ObjectProvider provider = mock(ObjectProvider.class); OrderApplicationService service = new OrderApplicationService(); + org.springframework.test.util.ReflectionTestUtils.setField(service, "documentNumbers", com.baiyue.order.service.TestDocumentNumbers.documents()); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "firstlegProvider", provider); ReflectionTestUtils.setField(service, "internalToken", "internal-token"); diff --git a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java index 64f0e0cf4..edaaff21a 100644 --- a/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/PlatformOrderPageServiceTest.java @@ -27,6 +27,8 @@ class PlatformOrderPageServiceTest { @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } private final OrderRequests.QueryRequest query = new OrderRequests.QueryRequest( diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java index 7e64a6269..21db75a20 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderCountServiceTest.java @@ -30,6 +30,8 @@ class TenantOrderCountServiceTest { @Resource private OrderApplicationService service; @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } @Test diff --git a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java index 9bee3e435..66e132c9d 100644 --- a/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java +++ b/order/src/test/java/com/baiyue/order/service/TenantOrderStatisticsServiceTest.java @@ -35,6 +35,8 @@ class TenantOrderStatisticsServiceTest { @Configuration(proxyBeanMethods = false) static class TestConfig { + @org.springframework.context.annotation.Bean com.baiyue.order.service.OrderDocumentNumberGenerator documentNumbers() { return com.baiyue.order.service.TestDocumentNumbers.documents(); } + @Bean ObjectMapper objectMapper() { return new ObjectMapper(); } } diff --git a/order/src/test/java/com/baiyue/order/service/TestDocumentNumbers.java b/order/src/test/java/com/baiyue/order/service/TestDocumentNumbers.java new file mode 100644 index 000000000..8b3142c0c --- /dev/null +++ b/order/src/test/java/com/baiyue/order/service/TestDocumentNumbers.java @@ -0,0 +1,21 @@ +package com.baiyue.order.service; + +import com.baiyue.order.repository.DocumentSequenceRepository; +import java.time.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */ +public final class TestDocumentNumbers { + /** 测试工具不实例化。 */ + private TestDocumentNumbers() {} + /** 每个测试生成器独立计数,类型前缀之间互不占号。 */ + public static OrderDocumentNumberGenerator documents() { + var sequences=mock(DocumentSequenceRepository.class); + var counters=new ConcurrentHashMap(); + lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet()); + return new OrderDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC)); + } +} diff --git a/scripts/admin-test-env-modules/phase3-container-customs.mjs b/scripts/admin-test-env-modules/phase3-container-customs.mjs index c51dabad5..851c6f0ee 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs.mjs @@ -352,7 +352,7 @@ export async function verifyFormalDeclarationAndExceptions(context, routes, fixt const before = await platformDetail(context, routes, fixture) const baseline = customsDocumentFacts(before.body?.data) const created = await routes.platformCreateDeclaration(context, fixture, { - expectedVersion: Number(before.body?.data?.version), batchNo: `${context.marker}-DECL`.slice(-64), remark: context.marker, + expectedVersion: Number(before.body?.data?.version), remark: context.marker, }, key(context, 'formal-create')) context.assert(MODULE, '平台人工创建正式申报批次', created, success(created) && created.body?.data?.status === 'READY', evidence(created), OPTIONS) diff --git a/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs b/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs index 59e069b02..abfbb548a 100644 --- a/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs +++ b/scripts/admin-test-env-modules/phase5-root-tax-customs.mjs @@ -61,7 +61,7 @@ export async function prepareTaxCustoms(context,loading){ {expectedVersion:Number(current.version),confirm:true,remark:context.marker},context.token,key('execute')) current=await detail() const batch=await api(context,'POST','F40 人工创建真实正式申报批次',`${base}/declaration-batches`, - {expectedVersion:Number(current.version),batchNo:`${context.marker}-DECL`.slice(-64),remark:context.marker},context.token,key('declaration')) + {expectedVersion:Number(current.version),remark:context.marker},context.token,key('declaration')) proof.declarationBatchId=id(batch.id) const submitted=await api(context,'POST','F40 人工提交正式申报',`${PLATFORM}/declaration-batches/${batch.id}/submit`, {expectedVersion:Number(batch.version),customsEntryNo:`F40TAX-${proof.loadingPlanId}`,remark:context.marker},context.token,key('submit-declaration')) diff --git a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs index b7a938f18..c6f1e66d7 100644 --- a/scripts/admin-test-env-modules/return-boundary-scenarios.mjs +++ b/scripts/admin-test-env-modules/return-boundary-scenarios.mjs @@ -91,8 +91,8 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p items, }) resources.returnNos.push(data.order.returnNo) - check('新退货单及退货主单采用独立完整编号', null, - /^RT-[0-9A-Z]{25}$/.test(data.order.returnNo) && /^RETU-[0-9A-Z]{25}$/.test(data.order.lastlegOrderNo)) + check('新退货单使用日流水且尾程主单沿用来源业务号', null, + /^TH[0-9]{12}$/.test(data.order.returnNo) && data.order.lastlegOrderNo === data.order.returnNo) const customerId = customerByToken.get(token) if (!customerId || data.order.status !== 'PENDING_REVIEW') throw new Error('边界退货客户或审核初态不符') const reviewed = await context.request('POST', `/api/platform/v1/last-leg/return-orders/${data.order.returnNo}/review`, { decision: 'APPROVE', remark: context.marker }, undefined, { 'X-Customer-Id': customerId }) @@ -113,7 +113,7 @@ export async function runReturnBoundaries(context, { merchantToken, warehouse, p async function receive(order, operator = picker) { const data = await good(operator.token, 'POST', '准备边界场景实收库存', `${RECEIPTS}/confirm`, receiptBody(order)) check('签收单批次采用独立完整编号且绑定本轮包裹', null, - /^RR-[0-9A-Z]{25}$/.test(data.receiptNo) && /^RB-[0-9A-Z]{25}$/.test(data.receiptBatchNo) + /^RR[0-9]{12}$/.test(data.receiptNo) && /^RB[0-9]{12}$/.test(data.receiptBatchNo) && data.unitType === 'PARCEL' && data.unitCode === order.unitCode && !data.palletCode) order.cartonMark = data.cartonMark order.batch = data.receiptBatchNo diff --git a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs index d750c8115..ea946baa8 100644 --- a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs +++ b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs @@ -6,8 +6,8 @@ export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({ }) export const TAILLEG_ORDER_NUMBER = Object.freeze({ - pattern: /^LMO-\d{6}-\d{4}$/, - timeZone: 'Asia/Shanghai', + pattern: /^FL-WH-\d{8}-\d{6}$/, + timeZone: 'server-local', sharedBy: 'containerNo', }) diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs index 0757cdc17..7c1b3dfa7 100644 --- a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -62,34 +62,30 @@ test('Lastleg query DTO uses the persisted frozen owner name', () => { assert.match(orderService, /o\.getCustomerName\(\)/) }) -test('new tailleg number is Shanghai daily LMO-yyMMdd-#### with concurrency and cross-day tests', () => { - assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO-260827-0001'), true) +test('head-leg sourced tail orders preserve the existing container grouping number', () => { + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('FL-WH-20261020-000042'), true) assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false) - assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai') - assert.match(generator, /Asia\/Shanghai/) - assert.match(generator, /yyMMdd/) - assert.match(generator, /LMO-/) - assert.match(generator, /%04d|padStart\(4|String\.format/) - assert.match(generatorTests, /(?:concurrent|并发)/i) - assert.match(generatorTests, /(?:cross.?day|next.?day|跨天)/i) - assert.match(generatorTests, /LMO-260827-0001/) - assert.match(generatorTests, /LMO-260828-0001/) + assert.equal(TAILLEG_ORDER_NUMBER.sharedBy, 'containerNo') + assert.match(generator, /FL-WH-/) + assert.match(generator, /BASIC_ISO_DATE/) + assert.match(generator, /SHA-256/) + assert.match(generatorTests, /actualContainerNumberRemainsStableAndShared/) }) test('one container with 15 inbound rows shares one stable container order number', () => { const rows = Array.from({ length: 15 }, (_, index) => ({ - containerNo: 'CONT-15', orderNo: 'LMO-260827-0042', sourceInboundId: `inbound-${index + 1}`, + containerNo: 'CONT-15', orderNo: 'FL-WH-20261020-000042', sourceInboundId: `inbound-${index + 1}`, })) assert.equal(validContainerOrderGroup(rows), true) assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 - ? { ...row, orderNo: 'LMO-260827-0043' } : row)), false) + ? { ...row, orderNo: 'FL-WH-20261020-000043' } : row)), false) assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.forContainerNo\(payload\.containerNo\(\)\);[\s\S]*for \(int index/) assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/) assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber/) }) test('same event replay adds nothing and preserves the allocated number', () => { - const before = { orderCount: 15, orderNo: 'LMO-260827-0042', detailCount: 30, historyCount: 15 } + const before = { orderCount: 15, orderNo: 'FL-WH-20261020-000042', detailCount: 30, historyCount: 15 } assert.equal(validEventReplay(before, { ...before }), true) assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox=')) @@ -103,5 +99,5 @@ test('same event replay adds nothing and preserves the allocated number', () => assert.match(racedBranch, /DeliveryAck\.Outcome\.DUPLICATE/) assert.match(consumerTests, /replayPreservesExistingLmoNumberAndAddsNothing/) - assert.match(consumerTests, /verify\(sequences,never\(\)\)\.nextValue/) + assert.match(consumerTests, /verifyNoInteractions\(sequences\)/) }) diff --git a/warehouse/src/main/resources/db/warehouse.sql b/warehouse/src/main/resources/db/warehouse.sql index 3ce0a8f8a..22fb93114 100644 --- a/warehouse/src/main/resources/db/warehouse.sql +++ b/warehouse/src/main/resources/db/warehouse.sql @@ -114,3 +114,11 @@ CREATE TABLE IF NOT EXISTS wh_overseas_storage_fee_tier ( CONSTRAINT chk_overseas_fee_tier_days CHECK (start_day>=1 AND (end_day IS NULL OR end_day>=start_day)), CONSTRAINT chk_overseas_fee_tier_price CHECK (unit_price>=0) ) ENGINE=InnoDB COMMENT='客户海外仓费用阶梯'; + +-- 统一业务单号:前缀 + yyyyMMdd + 四位日流水。 +CREATE TABLE IF NOT EXISTS wh_document_sequence ( + sequence_key VARCHAR(32) NOT NULL COMMENT '业务前缀与北京时间日期组成的全局序列键', + current_value BIGINT NOT NULL COMMENT '该前缀当日已分配流水,已分配号不回收', + PRIMARY KEY (sequence_key), + CONSTRAINT chk_wh_document_sequence_value CHECK (current_value > 0) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='业务单据每日独立原子流水'; diff --git a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java index bbaba8358..202e1438b 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/domesticcontainer/service/DomesticContainerServiceTest.java @@ -20,8 +20,16 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -@SpringJUnitConfig(DomesticContainerService.class) +@SpringJUnitConfig({DomesticContainerService.class, DomesticContainerServiceTest.Numbers.class}) class DomesticContainerServiceTest { + @org.springframework.context.annotation.Configuration + static class Numbers { + /** 业务测试复用真实格式和独立流水,避免依赖运行环境数据库。 */ + @org.springframework.context.annotation.Bean + com.baiyue.warehouse.service.WarehouseDocumentNumberGenerator documentNumbers() { + return com.baiyue.warehouse.service.TestDocumentNumbers.documents(); + } + } @MockBean DomesticContainerRepository repository; @MockBean WarehouseRepository warehouses; @Resource DomesticContainerService service; diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/DocumentSequenceRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/DocumentSequenceRepositoryTest.java new file mode 100644 index 000000000..0f4ff9832 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/DocumentSequenceRepositoryTest.java @@ -0,0 +1,76 @@ +package com.baiyue.warehouse.repository; + +import com.baiyue.warehouse.mapper.DocumentSequenceMapper; +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.*; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.*; +import java.util.concurrent.*; +import static org.assertj.core.api.Assertions.*; + +/** 实际 Mapper 和隔离 H2 MySQL 模式验证原子序列,不访问部署环境。 */ +class DocumentSequenceRepositoryTest { + private static class Fixture { + final JdbcTemplate jdbc; + final DataSourceTransactionManager transactions; + final DocumentSequenceMapper mapper; + final DocumentSequenceRepository repository; + /** 仅测试造表允许 JDBC;被测路径全部使用 Mapper。 */ + Fixture() throws Exception { + var data=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=10000","sa",""); + jdbc=new JdbcTemplate(data);transactions=new DataSourceTransactionManager(data); + jdbc.execute("CREATE TABLE wh_document_sequence(sequence_key VARCHAR(32) PRIMARY KEY,current_value BIGINT NOT NULL)"); + var configuration=new MybatisConfiguration();configuration.addMapper(DocumentSequenceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(data);factory.setConfiguration(configuration);factory.afterPropertiesSet(); + mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DocumentSequenceMapper.class); + var proxy=new ProxyFactory(new DocumentSequenceRepository(mapper));proxy.setProxyTargetClass(true); + proxy.addAdvice(new TransactionInterceptor(transactions,new AnnotationTransactionAttributeSource())); + repository=(DocumentSequenceRepository)proxy.getProxy(); + } + } + /** 日期和前缀各自独立,重复分配持续递增。 */ + @Test void dateAndPrefixHaveIndependentSequences() throws Exception { + var f=new Fixture(); + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + assertThat(f.repository.next("BB:20261020")).isEqualTo(1); + assertThat(f.repository.next("AA:20261021")).isEqualTo(1); + } + /** 两个线程使用真实事务连接并发争用,不能产生重号。 */ + @Test void concurrentConnectionsAllocateUniqueNumbers() throws Exception { + var f=new Fixture();var pool=Executors.newFixedThreadPool(2); + try { + var work=new ArrayList>(); + for(int i=0;i<64;i++)work.add(()->f.repository.next("AA:20261020")); + var values=new TreeSet(); + for(var future:pool.invokeAll(work))values.add(future.get()); + assertThat(values).hasSize(64);assertThat(values.first()).isEqualTo(1L);assertThat(values.last()).isEqualTo(64L); + } finally {pool.shutdownNow();} + } + /** 外层建单失败后不能把已经分配的单号再交给其他单据。 */ + @Test void businessRollbackDoesNotReuseAllocatedNumber() throws Exception { + var f=new Fixture(); + assertThatThrownBy(()->new TransactionTemplate(f.transactions).execute(status->{ + assertThat(f.repository.next("AA:20261020")).isEqualTo(1); + throw new IllegalStateException("业务回滚"); + })).isInstanceOf(IllegalStateException.class); + assertThat(f.repository.next("AA:20261020")).isEqualTo(2); + } + /** 同一会话中连接值变化后,读取必须绕过 MyBatis 缓存。 */ + @Test void connectionResultIsNeverCached() throws Exception { + var f=new Fixture(); + new TransactionTemplate(f.transactions).executeWithoutResult(status->{ + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(7)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(7L); + f.jdbc.queryForObject("SELECT LAST_INSERT_ID(37)",Long.class); + assertThat(f.mapper.allocatedValue()).isEqualTo(37L); + }); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/TestDocumentNumbers.java b/warehouse/src/test/java/com/baiyue/warehouse/service/TestDocumentNumbers.java new file mode 100644 index 000000000..f2c17f9be --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/TestDocumentNumbers.java @@ -0,0 +1,21 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.repository.DocumentSequenceRepository; +import java.time.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +/** 业务单测使用真实格式及隔离计数器;数据库并发由 Repository 集成测试覆盖。 */ +public final class TestDocumentNumbers { + /** 测试工具不实例化。 */ + private TestDocumentNumbers() {} + /** 每个测试生成器独立计数,类型前缀之间互不占号。 */ + public static WarehouseDocumentNumberGenerator documents() { + var sequences=mock(DocumentSequenceRepository.class); + var counters=new ConcurrentHashMap(); + lenient().when(sequences.next(anyString())).thenAnswer(call->counters.computeIfAbsent(call.getArgument(0),key->new AtomicLong()).incrementAndGet()); + return new WarehouseDocumentNumberGenerator(sequences,Clock.fixed(Instant.parse("2026-10-20T00:00:00Z"),ZoneOffset.UTC)); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGeneratorTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGeneratorTest.java new file mode 100644 index 000000000..bf3a9810a --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/WarehouseDocumentNumberGeneratorTest.java @@ -0,0 +1,41 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.warehouse.repository.DocumentSequenceRepository; +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; +import org.springframework.context.annotation.AnnotationConfigApplicationContext; +import java.time.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class WarehouseDocumentNumberGeneratorTest { + private static final Clock CLOCK=Clock.fixed(Instant.parse("2026-10-19T16:00:00Z"),ZoneOffset.UTC); + + /** 多构造器情况下正式依赖可以被 Spring 正确选择。 */ + @Test void springCreatesGeneratorWithRepository() { + try(var context=new AnnotationConfigApplicationContext()) { + context.registerBean(DocumentSequenceRepository.class,()->mock(DocumentSequenceRepository.class)); + context.register(WarehouseDocumentNumberGenerator.class);context.refresh(); + assertThat(context.getBean(WarehouseDocumentNumberGenerator.class)).isNotNull(); + } + } + /** 主机使用 UTC 时仍按北京时间跨午夜,且四位流水不截断。 */ + @Test void usesShanghaiDateAndIndependentSequenceKey() { + var sequences=mock(DocumentSequenceRepository.class); + when(sequences.next("ZGRK:20261020")).thenReturn(1L,2L,9999L); + var numbers=new WarehouseDocumentNumberGenerator(sequences,CLOCK); + assertThat(numbers.next("ZGRK")).isEqualTo("ZGRK202610200001"); + assertThat(numbers.next("ZGRK")).isEqualTo("ZGRK202610200002"); + assertThat(numbers.next("ZGRK")).isEqualTo("ZGRK202610209999"); + verify(sequences,times(3)).next("ZGRK:20261020"); + } + /** 未登记类型不能消耗流水,分配异常或溢出时拒绝出号。 */ + @Test void rejectsUnknownTypeAndOverflow() { + var sequences=mock(DocumentSequenceRepository.class); + var numbers=new WarehouseDocumentNumberGenerator(sequences,CLOCK); + assertThatThrownBy(()->numbers.next("ZZZZ")).isInstanceOf(IllegalArgumentException.class); + verifyNoInteractions(sequences); + when(sequences.next("ZGRK:20261020")).thenReturn(10000L); + assertThatThrownBy(()->numbers.next("ZGRK")).isInstanceOf(BizException.class); + } +} From b072c0a884b1e3f508a12e93d36915139b786ad2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 10:41:38 +0800 Subject: [PATCH 047/113] fix(finance): stabilize settlement idempotency request hashes --- .../service/FinanceSettlementService.java | 6 ++- .../FinanceSettlementRequestHashTest.java | 39 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceSettlementRequestHashTest.java diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 732b1b98e..56a110c45 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -12,6 +12,7 @@ import com.baiyue.finance.dto.FinanceSettlementDtos.SettlementActionRequest; import com.fasterxml.jackson.core.type.TypeReference; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; import org.slf4j.MDC; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.support.GeneratedKeyHolder; @@ -523,10 +524,13 @@ public class FinanceSettlementService { return documentNumbers.next("PAY"); } + /** 固定映射键序,避免 JVM 重启改变 Map.of 遍历顺序而误拒绝原请求。 */ private String hash(Object value) { try { + String canonical = objectMapper.writer() + .with(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(value); return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") - .digest(json(value).getBytes(StandardCharsets.UTF_8))); + .digest(canonical.getBytes(StandardCharsets.UTF_8))); } catch (Exception failure) { throw error("FIN_001", "幂等请求哈希失败"); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementRequestHashTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementRequestHashTest.java new file mode 100644 index 000000000..e3d231d2c --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementRequestHashTest.java @@ -0,0 +1,39 @@ +package com.baiyue.finance; + +import com.baiyue.finance.service.FinanceSettlementService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceSettlementRequestHashTest { + /** 模拟不同 JVM 的 Map 遍历顺序;同一付款请求必须得到相同摘要。 */ + @Test + void equivalentRequestsIgnoreMapIterationOrderButDetectChangedPayment() { + var service = new FinanceSettlementService(null, new ObjectMapper(), null, null, null); + Map body = new LinkedHashMap<>(); + body.put("amount", 190); body.put("expectedVersion", 0); + Map first = new LinkedHashMap<>(); + first.put("path", "/settlements/202/transactions"); first.put("method", "POST"); + first.put("customerId", 10L); first.put("body", body); + Map reversedBody = new LinkedHashMap<>(); + reversedBody.put("expectedVersion", 0); reversedBody.put("amount", 190); + Map second = new LinkedHashMap<>(); + second.put("body", reversedBody); second.put("customerId", 10L); + second.put("method", "POST"); second.put("path", "/settlements/202/transactions"); + + String original = ReflectionTestUtils.invokeMethod(service, "hash", first); + String reordered = ReflectionTestUtils.invokeMethod(service, "hash", second); + assertThat(reordered).isEqualTo(original); + reversedBody.put("amount", 191); + String changed = ReflectionTestUtils.invokeMethod(service, "hash", second); + assertThat(changed).isNotEqualTo(original); + second.put("customerId", 11L); reversedBody.put("amount", 190); + String otherCustomer = ReflectionTestUtils.invokeMethod(service, "hash", second); + assertThat(otherCustomer).isNotEqualTo(original); + } +} From e5f9cdcf01be7f2625e9eef97f64a53cb23c0249 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 10:52:17 +0800 Subject: [PATCH 048/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/web/GlobalExceptionHandler.java | 6 ++ .../web/GlobalExceptionHandlerTest.java | 34 ++++++++++ .../firstleg/workflow/WorkflowTabService.java | 23 +++++-- .../workflow/WorkflowBoundaryTest.java | 9 ++- .../mapper/CustomerOrderProgressMapper.java | 59 +++-------------- .../lastleg-customer-order-progress-v1.yaml | 7 +- .../CustomerOrderProgressMapperTest.java | 66 ++++++++++++++++--- .../service/UnifiedOrderQueryService.java | 13 +++- 8 files changed, 144 insertions(+), 73 deletions(-) diff --git a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java index 01958f455..438bcc465 100644 --- a/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java +++ b/common-web/src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java @@ -40,8 +40,14 @@ public class GlobalExceptionHandler { @Autowired(required = false) private MeterRegistry meterRegistry; + /** 系统类业务失败必须记录链路和异常,普通业务校验仍只返回统一响应。 */ @ExceptionHandler(BizException.class) public ResponseEntity> handleBusiness(BizException exception, HttpServletRequest request) { + if (exception.getResultCode().httpStatus().is5xxServerError()) { + log.error("系统业务请求失败 method={} path={} code={} requestId={} traceId={}", + request.getMethod(), request.getRequestURI(), exception.getResultCode().code(), + CorrelationContext.requestId(request), CorrelationContext.traceId(request), exception); + } return failure(exception.getResultCode(), exception.getMessage(), request); } diff --git a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java index c1283467e..c2cef199c 100644 --- a/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/GlobalExceptionHandlerTest.java @@ -5,6 +5,9 @@ import com.baiyue.common.core.exception.BizException; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.boot.test.system.CapturedOutput; +import org.springframework.boot.test.system.OutputCaptureExtension; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.core.MethodParameter; import org.springframework.validation.BeanPropertyBindingResult; @@ -39,6 +42,37 @@ class GlobalExceptionHandlerTest { assertThat(response.getBody().requestId()).isEqualTo("r1"); } + /** 系统类业务失败留下路径、关联标识和根因,但不打印请求参数或令牌。 */ + @Test + @ExtendWith(OutputCaptureExtension.class) + void systemBusinessFailureLogsCorrelationAndCause(CapturedOutput output) { + MockHttpServletRequest request = new MockHttpServletRequest("GET", "/api/platform/v1/orders/status-counts"); + request.setAttribute(CorrelationContext.REQUEST_ID, "status-count-request"); + request.setAttribute(CorrelationContext.TRACE_ID, "status-count-trace"); + request.setQueryString("keyword=private-query"); + request.addHeader("X-Internal-Service-Token", "private-service-token"); + BizException exception = new BizException(ResultCode.SERVICE_UNAVAILABLE, "统计暂不可用"); + exception.initCause(new IllegalStateException("缺少尾程来源")); + + var response = handler.handleBusiness(exception, request); + + assertThat(response.getStatusCode().value()).isEqualTo(200); + assertThat(response.getBody().code()).isEqualTo("S0503"); + assertThat(response.getBody().message()).isEqualTo("统计暂不可用"); + assertThat(output.getOut()).contains("ERROR", "GET", "/api/platform/v1/orders/status-counts", + "S0503", "status-count-request", "status-count-trace", "缺少尾程来源") + .doesNotContain("private-query", "private-service-token"); + } + + /** 预期的业务冲突不应升级为系统错误日志。 */ + @Test + @ExtendWith(OutputCaptureExtension.class) + void normalBusinessFailureDoesNotLogSystemError(CapturedOutput output) { + handler.handleBusiness(new BizException(ResultCode.CONFLICT, "normal-business-conflict"), + new MockHttpServletRequest()); + assertThat(output.getAll()).doesNotContain("系统业务请求失败", "normal-business-conflict"); + } + @Test void invalidQueryParameterTypeMapsToBadRequest() throws Exception { MockHttpServletRequest request = new MockHttpServletRequest(); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index 418650e34..1e6a102dc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -24,7 +24,7 @@ public class WorkflowTabService { var facts=repository.facts(batch.stream().map(InboundOrderEntity::getId).toList()); // 确认页签复用已推送费用版本。 for(var fee:repository.feeConfirmationRows(facts.stream().map(WorkflowFacts.Fact::inboundId).toList())) { - if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable(); + if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源"); result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); } var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); @@ -32,19 +32,24 @@ public class WorkflowTabService { if(!sources.isEmpty()) { try { var response=lastleg.read(token,new WorkflowFacts.Request(sources)); - if(response==null || !response.successful() || response.data()==null) throw unavailable(); + if(response==null || !response.successful() || response.data()==null) throw unavailable( + "尾程事实响应无效 code="+(response==null?null:response.code()) + +" requestId="+(response==null?null:response.requestId())+" traceId="+(response==null?null:response.traceId())); var allowed=new HashSet<>(sources); for(var fact:response.data()) { - if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable(); + if(fact==null || !allowed.contains(new WorkflowFacts.Source(fact.tenantId(),fact.inboundId()))) throw unavailable("尾程事实包含空记录或本批以外的客户/入仓来源"); overseas.merge(fact.tenantId()+":"+fact.inboundId(),fact,(a,b)->new WorkflowFacts.Overseas(a.tenantId(),a.inboundId(),a.outboundExists()||b.outboundExists(),a.pendingOutbound()||b.pendingOutbound(),a.delivering()||b.delivering(),a.unloaded()&&b.unloaded())); } - if(overseas.size()!=sources.size()) throw unavailable(); - } catch(BizException e) { throw e; } catch(Exception e) { throw unavailable(); } + if(overseas.size()!=sources.size()) throw unavailable("尾程事实缺少来源 expected="+sources.size()+" actual="+overseas.size() + +" missing="+sources.stream().filter(s->!overseas.containsKey(s.tenantId()+":"+s.inboundId())).limit(20).toList()); + } catch(BizException e) { throw e; } catch(Exception e) { + throw (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试").initCause(e); + } } // SQL排除仅作汇总的父票;保留承接实际装柜数量的来源票及独立余量票。 for(var fact:facts) { var inbound=expected.get(fact.inboundId()); - if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable(); + if(inbound==null || !Objects.equals(inbound.getTenantId(),fact.tenantId()) || !Objects.equals(inbound.getOrderId(),fact.orderId())) throw unavailable("头程事实与入仓单归属不一致 inboundId="+fact.inboundId()); var o=overseas.get(fact.tenantId()+":"+fact.inboundId()); for(String key:classify(fact,inbound.getProcessStatus(),o)) result.putIfAbsent(fact.orderId()+":"+key,new StatusRow(fact.tenantId(),fact.orderId(),key)); } @@ -77,5 +82,9 @@ public class WorkflowTabService { return keys; } /** 下游缺失或归属异常不能生成部分数量。 */ - private static BizException unavailable() { return new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试"); } + private static BizException unavailable(String reason) { + // 诊断信息只进入服务日志,公开响应保持稳定,不暴露内部来源标识。 + return (BizException) new BizException(ResultCode.SERVICE_UNAVAILABLE,"订单业务页签事实暂不可用,请稍后重试") + .initCause(new IllegalStateException(reason)); + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index cf264bcf4..ca565dcbe 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -25,12 +25,15 @@ class WorkflowBoundaryTest { 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); + assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class) + .hasMessage("订单业务页签事实暂不可用,请稍后重试") + .hasRootCauseMessage("尾程事实缺少来源 expected=1 actual=0 missing=[Source[tenantId=11, inboundId=1]]"); 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); + var failure=new IllegalStateException("unavailable"); + when(downstream.read(any(),any())).thenThrow(failure); + assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure); } /** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java index ef5448da4..c253d5ac6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/CustomerOrderProgressMapper.java @@ -4,62 +4,23 @@ import com.baiyue.lastleg.dto.CustomerOrderProgress; import org.apache.ibatis.annotations.*; import java.util.List; -/** Batch read of every actual source, never the representative MIN(order_id) on a shared pallet. */ +/** 按客户及不可变来源明细读取进度,不使用混托的代表订单。 */ @Mapper public interface CustomerOrderProgressMapper { - /** 本客户明细决定归属,真实主阶段和实际发车时间决定待排及在途数量。 */ + /** 已排车只统计有效待出库单的来源数量,与平台出库单节点一致;在途仍要求实际发车。 */ @Select(""" + """) + java.util.List calendarSources(@Param("numbers") java.util.List dispatchNumbers); @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo} FOR UPDATE") FleetTripEntity lockByNo(@Param("tripNo") String tripNo); @Select("SELECT * FROM ll_fleet_trip WHERE trip_no=#{tripNo}") diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index 9a62a6835..fcfcd23db 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -359,7 +359,7 @@ components: schemas: TripCalendarEntry: type: object - required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, incompleteReasons] + required: [sourceType, dispatchNo, status, plannedDepartureAt, expectedDriverEndAt, stops, containerNumbers, incompleteReasons] properties: sourceType: {type: string, enum: [DISPATCH]} dispatchNo: {type: string, description: 真实整车主单排车号,用于排车详情路由} @@ -375,6 +375,11 @@ components: departedAt: {type: string, format: date-time, nullable: true} completedAt: {type: string, format: date-time, nullable: true} stops: {type: array, items: {$ref: '#/components/schemas/Stop'}} + containerNumbers: + type: array + description: 本车全部货物正式来源快照中的真实柜号,去空去重;多客户、多柜保留完整数组,无柜号为空数组,不推测或补造。 + items: {type: string} + uniqueItems: true incompleteReasons: type: array items: {type: string, enum: [ORIGIN_WAREHOUSE_UNKNOWN, ORIGIN_COUNTRY_UNKNOWN, ORIGIN_TIMEZONE_UNKNOWN, ROUTE_INCOMPLETE]} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java index 1e373681c..00f4bf394 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarMapperTest.java @@ -62,4 +62,24 @@ class FleetTripCalendarMapperTest { bindDispatches(jdbc); assertThat(mapper.calendar(from,to)).hasSize(2001); } + /** 实际Mapper批读同单跨客户多柜,排除其他主单与取消主单,不用货物行放大日历。 */ + @Test void batchReadsEverySourceSnapshotOnlyForTheRequestedActiveDispatches() 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_dispatch_order(id BIGINT PRIMARY KEY,dispatch_no VARCHAR(80) UNIQUE,status VARCHAR(32))"); + jdbc.execute("CREATE TABLE ll_dispatch_item(id BIGINT PRIMARY KEY,dispatch_order_id BIGINT,customer_id VARCHAR(64),source_snapshot_json VARCHAR(2000))"); + jdbc.update("INSERT INTO ll_dispatch_order VALUES(1,'PC-A','ACTIVE'),(2,'PC-B','ACTIVE'),(3,'PC-OTHER','ACTIVE'),(4,'PC-CANCELLED','CANCELLED')"); + jdbc.update("INSERT INTO ll_dispatch_item VALUES(11,1,'101',?), (12,1,'102',?), (13,1,'101',?), (21,2,'103',?), (31,3,'101',?), (41,4,'101',?)", + "{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}", + "{\"planNo\":\"PLAN-B\"}","{\"containerNo\":\"OTHER-CARGO\"}","{\"containerNo\":\"CANCELLED-CARGO\"}"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(FleetTripMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(FleetTripMapper.class); + var repository=new FleetTripCalendarRepository(mapper); + assertThat(repository.sourceSnapshots(List.of())).isEmpty(); + var rows=repository.sourceSnapshots(List.of("PC-A","PC-B","PC-CANCELLED")); + assertThat(rows).extracting(FleetTripCalendarRepository.Source::dispatchNo).containsExactly("PC-A","PC-A","PC-A","PC-B"); + assertThat(rows).extracting(FleetTripCalendarRepository.Source::sourceSnapshotJson).containsExactly( + "{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU1234567\"}","{\"containerNo\":\"TSTU7654321\"}","{\"planNo\":\"PLAN-B\"}"); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java index 94c798b88..7b55e53b9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarServiceTest.java @@ -11,7 +11,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class FleetTripCalendarServiceTest { - final FleetTripMapper trips=mock(FleetTripMapper.class); + final FleetTripCalendarRepository trips=mock(FleetTripCalendarRepository.class); final FleetWarehouseClient warehouses=mock(FleetWarehouseClient.class); final FleetTripCalendarService service=new FleetTripCalendarService(trips,warehouses,new ObjectMapper(),"internal-test"); final Instant from=Instant.parse("2026-10-01T00:00:00Z"),to=from.plus(Duration.ofDays(44)); @@ -107,4 +107,51 @@ class FleetTripCalendarServiceTest { var platform=new com.baiyue.common.security.AuthenticatedUser(1L,0L,0L,1L,"platform","PLATFORM","WEB",Set.of("platform:overseas-warehouse:dispatch:list")); controller.calendar(platform,from,to);verify(calendar).calendar(from,to); } + /** 同车全部来源只批读一次,跨客户重复柜号去重,多柜完整保留。 */ + @Test void readsAllContainerNumbersInOneBatchAndPreservesTheLocalDateFacts() { + var first=trip("PC-A",1L);first.setPlannedDepartureAt(LocalDateTime.of(2026,10,22,16,0)); + when(trips.calendar(any(),any())).thenReturn(List.of(first,trip("PC-B",1L))); + when(trips.sourceSnapshots(List.of("PC-A","PC-B"))).thenReturn(List.of( + new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\" TSTU1234567 \",\"planNo\":\"PLAN-A\"}"), + new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU1234567\"}"), + new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":\"TSTU7654321\"}"), + new FleetTripCalendarRepository.Source("PC-A","{\"containerNo\":null}"), + new FleetTripCalendarRepository.Source("PC-B","{}"), + new FleetTripCalendarRepository.Source("PC-B","{\"containerNo\":\" \"}"))); + metadata(List.of(warehouse(1,"US","America/Los_Angeles")),List.of()); + var result=service.calendar(from,to); + assertThat(result.records()).hasSize(2); + assertThat(result.records().get(0).containerNumbers()).containsExactly("TSTU1234567","TSTU7654321"); + assertThat(result.records().get(1).containerNumbers()).isEmpty(); + assertThat(result.records().get(0).plannedDepartureAt()).isEqualTo(Instant.parse("2026-10-22T16:00:00Z")); + assertThat(result.records().get(0).originTimezone()).isEqualTo("America/Los_Angeles"); + assertThat(result.unresolvedRecords()).isEmpty(); + verify(trips).calendar(any(),any());verify(trips).sourceSnapshots(List.of("PC-A","PC-B"));verifyNoMoreInteractions(trips); + verify(warehouses).platformMetadata("internal-test",new FleetWarehouseClient.MetadataRequest(List.of(1L))); + verifyNoMoreInteractions(warehouses); + } + /** 缺时区的主单仍保留真实柜号,不将货物信息丢失误作起运地已核实。 */ + @Test void keepsKnownContainersOnAnUnresolvedOrigin() { + when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-UNKNOWN",1L))); + when(trips.sourceSnapshots(List.of("PC-UNKNOWN"))).thenReturn(List.of( + new FleetTripCalendarRepository.Source("PC-UNKNOWN","{\"containerNo\":\"TSTU1234567\"}"))); + metadata(List.of(warehouse(1,"US",null)),List.of()); + var result=service.calendar(from,to);assertThat(result.records()).isEmpty(); + assertThat(result.unresolvedRecords()).singleElement().satisfies(row->{ + assertThat(row.containerNumbers()).containsExactly("TSTU1234567"); + assertThat(row.incompleteReasons()).containsExactly("ORIGIN_TIMEZONE_UNKNOWN"); + }); + } + /** 损坏或非当前正式文本快照明确失败,不伪造空数组或静默隐藏整月。 */ + @Test void rejectsMalformedOrForeignSourceSnapshotsExplicitly() { + when(trips.calendar(any(),any())).thenReturn(List.of(trip("PC-A",1L))); + for(var invalid:Arrays.asList(null,"","not-json","null","[]","{\"containerNo\":123}","{\"containerNo\":[\"TSTU1234567\"]}","{} {}")) { + when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("PC-A",invalid))); + assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("柜号资料暂不可用"); + } + when(trips.sourceSnapshots(List.of("PC-A"))).thenReturn(List.of(new FleetTripCalendarRepository.Source("OTHER-PC","{}"))); + assertThatThrownBy(()->service.calendar(from,to)).isInstanceOf(ResponseStatusException.class).hasMessageContaining("柜号资料暂不可用"); + verifyNoInteractions(warehouses); + } } From b4ff441a70302d969b5b9bd37abbcf99743b3f75 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 11:43:55 +0800 Subject: [PATCH 053/113] fix(numbering): expose coload and settlement payment business numbers --- .../finance/dto/FinanceSettlementDtos.java | 2 +- .../mapper/FinanceSettlementDetailMapper.java | 43 ++++++++++++++ .../FinanceSettlementDetailRepository.java | 29 ++++++++++ .../service/FinanceSettlementService.java | 57 ++++++------------- .../FinancePlatformListContractTest.java | 2 +- .../FinanceSettlementBehaviorTest.java | 17 ++++-- .../FinanceSettlementDetailMapperTest.java | 36 ++++++++++++ .../PlatformColoadGroupController.java | 3 +- .../coload/model/ColoadGroupModels.java | 1 + .../coload/persistence/ColoadGroupMapper.java | 2 +- .../ColoadGroupQueryRepository.java | 20 +++++++ .../persistence/ColoadPersistenceRows.java | 1 + .../coload/service/ColoadGroupService.java | 9 ++- .../resources/openapi/coload-groups-v1.yaml | 7 ++- .../ColoadGroupOpenApiContractTest.java | 2 +- .../coload/ColoadGroupServiceTest.java | 4 +- .../coload/ColoadGroupSqlContractTest.java | 3 +- 17 files changed, 181 insertions(+), 57 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementDetailMapper.java create mode 100644 finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementDetailRepository.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceSettlementDetailMapperTest.java create mode 100644 firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupQueryRepository.java diff --git a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java index 4b4621849..a202d904b 100644 --- a/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java +++ b/finance/src/main/java/com/baiyue/finance/dto/FinanceSettlementDtos.java @@ -37,7 +37,7 @@ public final class FinanceSettlementDtos { BigDecimal quantity, BigDecimal unitPrice, BigDecimal amount, String currency, String reviewStatus, String sourceStatus, boolean statementGenerated) {} - public record SettlementTransaction(long transactionId, String transactionType, LocalDate transactionDate, + public record SettlementTransaction(long transactionId, String transactionNo, String transactionType, LocalDate transactionDate, BigDecimal amount, String method, String referenceNo, List voucherFileIds, long createdBy, String createdByName, LocalDateTime createdAt, diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementDetailMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementDetailMapper.java new file mode 100644 index 000000000..3efe4fd28 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceSettlementDetailMapper.java @@ -0,0 +1,43 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; +import java.util.Map; + +/** 结算详情只读SQL;子查询均保留所属客户和原结算范围。 */ +@Mapper +public interface FinanceSettlementDetailMapper { + String SUMMARY_SELECT = + "SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," + + "s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," + + "s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " + + "FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " + + "WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," + + "s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," + + "s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " + + "WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " + + "AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," + + "CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " + + "JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " + + "WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " + + "ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " + + "AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " + + "LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id "; + /** 读取平台结算主单及来源摘要。 */ + @Select(SUMMARY_SELECT + "WHERE s.id=#{settlementId}") + List> summary(@Param("settlementId") long settlementId); + /** 读取该客户该结算的费用明细。 */ + @Select("SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id,f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id WHERE sl.tenant_id=#{customerId} AND sl.settlement_id=#{settlementId} ORDER BY sl.id") + List> lines(@Param("customerId") long customerId, @Param("settlementId") long settlementId); + /** 读取该客户付款流水及落库业务号,不能用ID替代。 */ + @Select("SELECT id,transaction_no,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark FROM fin_settlement_payment WHERE tenant_id=#{customerId} AND settlement_id=#{settlementId} ORDER BY created_at,id") + List> payments(@Param("customerId") long customerId, @Param("settlementId") long settlementId); + /** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */ + @Select("SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id WHERE rb.tenant_id=#{customerId} AND rb.bill_id=#{statementId} AND r.status='CONFIRMED' ORDER BY r.created_at,r.id") + List> receipts(@Param("customerId") long customerId, @Param("statementId") long statementId); + /** 读取该客户结算修订历史。 */ + @Select("SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log WHERE tenant_id=#{customerId} AND resource_type='FIN_SETTLEMENT' AND resource_id=#{settlementId} ORDER BY created_at,id") + List> audits(@Param("customerId") long customerId, @Param("settlementId") long settlementId); +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementDetailRepository.java b/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementDetailRepository.java new file mode 100644 index 000000000..9721c872c --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/FinanceSettlementDetailRepository.java @@ -0,0 +1,29 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.mapper.FinanceSettlementDetailMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.LinkedCaseInsensitiveMap; +import java.util.List; +import java.util.Map; + +/** 结算详情查询封装,列名大小写差异不改变已有字段映射语义。 */ +@Repository +public class FinanceSettlementDetailRepository { + private final FinanceSettlementDetailMapper mapper; + /** 注入结算详情持久化入口。 */ + public FinanceSettlementDetailRepository(FinanceSettlementDetailMapper mapper) { this.mapper = mapper; } + /** 保留原数据库列名不区分大小写的读取语义。 */ + private List> rows(List> rows) { + return rows.stream().map(row -> { Map copy = new LinkedCaseInsensitiveMap<>(); copy.putAll(row); return copy; }).toList(); + } + /** 读取平台结算主单及来源摘要。 */ + public List> summary(long settlementId) { return rows(mapper.summary(settlementId)); } + /** 读取该客户该结算的费用明细。 */ + public List> lines(long customerId, long settlementId) { return rows(mapper.lines(customerId, settlementId)); } + /** 读取该客户付款流水及落库业务号,不能用ID替代。 */ + public List> payments(long customerId, long settlementId) { return rows(mapper.payments(customerId, settlementId)); } + /** 读取对账单已确认收款;收款凭证号不是PAY业务号。 */ + public List> receipts(long customerId, long statementId) { return rows(mapper.receipts(customerId, statementId)); } + /** 读取该客户结算修订历史。 */ + public List> audits(long customerId, long settlementId) { return rows(mapper.audits(customerId, settlementId)); } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java index 56a110c45..3f1e8a1fa 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinanceSettlementService.java @@ -45,23 +45,9 @@ import java.util.UUID; public class FinanceSettlementService { private static final String CNY = "CNY"; private static final Set PAYMENT_METHODS = Set.of("BANK_TRANSFER", "CASH", "OTHER"); - private static final String SETTLEMENT_SUMMARY_SELECT = - "SELECT s.id,s.tenant_id,s.settlement_no,s.direction,s.customer_name_snapshot,s.supplier_id," + - "s.supplier_name_snapshot,s.reconciliation_statement_id,b.bill_no reconciliation_statement_no," + - "s.business_period,(SELECT GROUP_CONCAT(DISTINCT fs.fee_item_name ORDER BY fs.fee_item_name SEPARATOR '、') " + - "FROM fin_settlement_line sl JOIN fin_fee_source fs ON fs.id=sl.fee_source_id " + - "WHERE sl.tenant_id=s.tenant_id AND sl.settlement_id=s.id) fee_item_summary," + - "s.currency_code,s.total_amount,s.paid_amount,s.outstanding_amount,s.generated_date,s.due_date," + - "s.payment_status,s.status,s.created_by,(SELECT a.operator_name_snapshot FROM fin_audit_log a " + - "WHERE a.tenant_id=s.tenant_id AND a.resource_type='FIN_SETTLEMENT' AND a.resource_id=s.id " + - "AND a.action='CREATE' ORDER BY a.created_at,a.id LIMIT 1) created_by_name,s.created_at,s.updated_at," + - "CASE WHEN s.direction='RECEIVABLE' THEN (SELECT MAX(r.created_at) FROM fin_receipt_bill rb " + - "JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " + - "WHERE rb.tenant_id=s.tenant_id AND rb.bill_id=s.reconciliation_statement_id AND r.status='CONFIRMED') " + - "ELSE (SELECT MAX(p.created_at) FROM fin_settlement_payment p WHERE p.tenant_id=s.tenant_id " + - "AND p.settlement_id=s.id) END last_payment_at,s.version FROM fin_settlement s " + - "LEFT JOIN fin_bill b ON b.tenant_id=s.tenant_id AND b.id=s.reconciliation_statement_id "; + private static final String SETTLEMENT_SUMMARY_SELECT = com.baiyue.finance.mapper.FinanceSettlementDetailMapper.SUMMARY_SELECT; private final JdbcTemplate jdbc; + private final com.baiyue.finance.repository.FinanceSettlementDetailRepository details; private final ObjectMapper objectMapper; private final FinanceVoucherValidator voucherValidator; private final FinanceSupplierValidator supplierValidator; @@ -71,8 +57,10 @@ public class FinanceSettlementService { @Autowired public FinanceSettlementService(JdbcTemplate jdbc, ObjectMapper objectMapper, FinanceVoucherValidator voucherValidator, FinanceSupplierValidator supplierValidator, - FinanceDocumentNumberGenerator documentNumbers) { + FinanceDocumentNumberGenerator documentNumbers, + com.baiyue.finance.repository.FinanceSettlementDetailRepository details) { this.jdbc = jdbc; + this.details = details; this.objectMapper = objectMapper; this.voucherValidator = voucherValidator; this.supplierValidator = supplierValidator; @@ -183,33 +171,21 @@ public class FinanceSettlementService { return getSettlement(settlementId); } + /** 读取结算及其业务流水号;全部详情数据访问经Repository和Mapper。 */ public SettlementDetail getSettlement(long settlementId) { if (settlementId <= 0) throw error("FIN_404", "结算单不存在"); - Map row = one( - SETTLEMENT_SUMMARY_SELECT + "WHERE s.id=?", - "结算单不存在", settlementId); - SettlementSummary summary = summary(row); - List lines = jdbc.queryForList( - "SELECT f.id fee_source_id,f.source_type,f.source_fee_id,f.source_fee_version,f.direction,f.customer_id," + - "f.order_id,f.inbound_id,f.container_id,f.fee_item_name,f.amount,f.currency_code,f.review_status,f.status,f.source_snapshot " + - "FROM fin_settlement_line sl JOIN fin_fee_source f ON f.id=sl.fee_source_id " + - "WHERE sl.tenant_id=? AND sl.settlement_id=? ORDER BY sl.id", summary.customerId(), settlementId) + List> records = details.summary(settlementId); + if (records.isEmpty()) throw error("FIN_404", "结算单不存在"); + SettlementSummary summary = summary(records.get(0)); + List lines = details.lines(summary.customerId(), settlementId) .stream().map(source -> line(source, summary)).toList(); - List transactions = new ArrayList<>(jdbc.queryForList( - "SELECT id,transaction_type,transaction_date,amount,method,reference_no,voucher_file_ids,created_by,created_by_name,created_at,remark " + - "FROM fin_settlement_payment WHERE tenant_id=? AND settlement_id=? ORDER BY created_at,id", - summary.customerId(), settlementId).stream().map(this::transaction).toList()); + List transactions = new ArrayList<>(details.payments(summary.customerId(), settlementId) + .stream().map(this::transaction).toList()); if ("RECEIVABLE".equals(summary.direction()) && summary.reconciliationStatementId() != null) { - transactions.addAll(jdbc.queryForList( - "SELECT r.id,'RECEIPT' transaction_type,DATE(r.received_at) transaction_date,rb.applied_amount amount,r.payment_method method,r.voucher_no reference_no,r.voucher_file_ids,r.created_by,r.created_by_name,r.created_at,r.remark " + - "FROM fin_receipt_bill rb JOIN fin_receipt r ON r.id=rb.receipt_id AND r.tenant_id=rb.tenant_id " + - "WHERE rb.tenant_id=? AND rb.bill_id=? AND r.status='CONFIRMED' ORDER BY r.created_at,r.id", - summary.customerId(), summary.reconciliationStatementId()).stream().map(this::transaction).toList()); + transactions.addAll(details.receipts(summary.customerId(), summary.reconciliationStatementId()) + .stream().map(this::transaction).toList()); } - List> audits = jdbc.queryForList( - "SELECT action,reason,before_data,after_data,operator_id,operator_name_snapshot,created_at FROM fin_audit_log " + - "WHERE tenant_id=? AND resource_type='FIN_SETTLEMENT' AND resource_id=? ORDER BY created_at,id", - summary.customerId(), settlementId); + List> audits = details.audits(summary.customerId(), settlementId); List revisions = new ArrayList<>(); for (int index = 0; index < audits.size(); index++) revisions.add(revision(index + 1, audits.get(index))); return new SettlementDetail(summary, lines, transactions, List.copyOf(revisions)); @@ -477,8 +453,9 @@ public class FinanceSettlementService { text(row, "status"), true); } + /** 返回真实付款业务号;未生成PAY号的收款记录保持为空,不回退成内部ID。 */ private SettlementTransaction transaction(Map row) { - return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_type"), + return new SettlementTransaction(number(row, "id").longValue(), text(row, "transaction_no"), text(row, "transaction_type"), date(row, "transaction_date"), decimal(row, "amount"), text(row, "method"), text(row, "reference_no"), stringList(text(row, "voucher_file_ids")), number(row, "created_by").longValue(), text(row, "created_by_name"), diff --git a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java index 3fd92a3a3..0bf551cb0 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePlatformListContractTest.java @@ -93,7 +93,7 @@ class FinancePlatformListContractTest { assertRecordFields(com.baiyue.finance.dto.ReconciliationStatementDtos.ReconciliationRevision.class, "operatorId", "operatorName"); assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementTransaction.class, - "createdBy", "createdByName"); + "createdBy", "createdByName", "transactionNo"); assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementRevision.class, "operatorId", "operatorName"); assertRecordFields(com.baiyue.finance.dto.FinanceSettlementDtos.SettlementSummary.class, diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java index 7898c139e..e5923f6ac 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementBehaviorTest.java @@ -236,8 +236,8 @@ class FinanceSettlementBehaviorTest { eq("SETTLEMENT_PAYMENT_CREATE"), eq("pay-key"))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(after)); when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(10L), eq(88L))).thenReturn(List.of()); - when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries( - Map.entry("id", 501L), Map.entry("transaction_type", "PAYMENT"), + when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(10L), eq(88L))).thenReturn(List.of(Map.ofEntries( + Map.entry("id", 501L), Map.entry("transaction_no", "PAY202610210001"), Map.entry("transaction_type", "PAYMENT"), Map.entry("transaction_date", Date.valueOf(LocalDate.of(2026, 8, 11))), Map.entry("amount", new BigDecimal("40.00")), Map.entry("method", "BANK_TRANSFER"), Map.entry("reference_no", "BANK-1"), Map.entry("voucher_file_ids", "[\"91\"]"), @@ -258,6 +258,7 @@ class FinanceSettlementBehaviorTest { assertThat(result.transactions()).singleElement().extracting(transaction -> transaction.transactionType()) .isEqualTo("PAYMENT"); assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin"); + assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null); verify(jdbc).update(startsWith("UPDATE fin_settlement SET paid_amount"), any(), any(), any(), any(), any(), any(), any(), any()); } @@ -522,6 +523,7 @@ class FinanceSettlementBehaviorTest { .isEqualTo("RECEIPT"); assertThat(result.transactions().get(0).amount()).isEqualByComparingTo("40.00"); assertThat(result.transactions().get(0).createdByName()).isEqualTo("platform_admin"); + assertThat(result.transactions().get(0).transactionNo()).isEqualTo("PAYMENT".equals(result.transactions().get(0).transactionType()) ? "PAY202610210001" : null); } @Test @@ -623,8 +625,15 @@ class FinanceSettlementBehaviorTest { org.mockito.Mockito.lenient().when(documentNumbers.next("AR")).thenReturn("AR202608180001"); org.mockito.Mockito.lenient().when(documentNumbers.next("AP")).thenReturn("AP202608180001"); org.mockito.Mockito.lenient().when(documentNumbers.next("PAY")).thenReturn("PAY202608180001"); + // 既有业务测试保留JDBC替身,新详情路径通过真实Repository和Mapper接口适配执行。 + var mapper = org.mockito.Mockito.mock(com.baiyue.finance.mapper.FinanceSettlementDetailMapper.class, invocation -> { + var select = invocation.getMethod().getAnnotation(org.apache.ibatis.annotations.Select.class); + if (select == null) return RETURNS_DEFAULTS.answer(invocation); + String sql = String.join(" ", select.value()).replaceAll("#\\{[a-zA-Z]+\\}", "?"); + return jdbc.queryForList(sql, invocation.getArguments()); + }); return new FinanceSettlementService(jdbc, new ObjectMapper().findAndRegisterModules(), - new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers); + new FinanceVoucherValidator(fileClient, "internal-token"), supplierValidator, documentNumbers, new com.baiyue.finance.repository.FinanceSettlementDetailRepository(mapper)); } private static FinanceSupplierValidator supplierValidator() { @@ -657,7 +666,7 @@ class FinanceSettlementBehaviorTest { when(jdbc.queryForList(startsWith("SELECT s.id,s.tenant_id,s.settlement_no"), eq(88L))).thenReturn(List.of(settlement)); long customerId = ((Number) settlement.get("tenant_id")).longValue(); when(jdbc.queryForList(startsWith("SELECT f.id fee_source_id"), eq(customerId), eq(88L))).thenReturn(List.of()); - when(jdbc.queryForList(startsWith("SELECT id,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of()); + when(jdbc.queryForList(startsWith("SELECT id,transaction_no,transaction_type"), eq(customerId), eq(88L))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT action,reason"), eq(customerId), eq(88L))).thenReturn(List.of()); } diff --git a/finance/src/test/java/com/baiyue/finance/FinanceSettlementDetailMapperTest.java b/finance/src/test/java/com/baiyue/finance/FinanceSettlementDetailMapperTest.java new file mode 100644 index 000000000..60cdfb49f --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceSettlementDetailMapperTest.java @@ -0,0 +1,36 @@ +package com.baiyue.finance; + +import com.baiyue.finance.mapper.FinanceSettlementDetailMapper; +import com.baiyue.finance.repository.FinanceSettlementDetailRepository; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class FinanceSettlementDetailMapperTest { + /** 真实执行付款查询,业务号必须来自本客户本结算的流水,空号不以其他字段替代。 */ + @Test + void paymentNumbersRemainScopedToCustomerAndSettlement() { + var source = new DriverManagerDataSource("jdbc:h2:mem:settlement-number;MODE=MySQL;DB_CLOSE_DELAY=-1", "sa", ""); + var jdbc = new JdbcTemplate(source); + jdbc.execute("CREATE TABLE fin_settlement_payment (id BIGINT PRIMARY KEY,tenant_id BIGINT,settlement_id BIGINT,transaction_no VARCHAR(32),transaction_type VARCHAR(32),transaction_date DATE,amount DECIMAL(18,2),method VARCHAR(32),reference_no VARCHAR(64),voucher_file_ids VARCHAR(255),created_by BIGINT,created_by_name VARCHAR(64),created_at TIMESTAMP,remark VARCHAR(255))"); + jdbc.update("INSERT INTO fin_settlement_payment (id,tenant_id,settlement_id,transaction_no,transaction_type,reference_no) VALUES (1,10,20,'PAY202610210001','PAYMENT','BANK-1'),(2,11,20,'PAY202610210002','PAYMENT','BANK-2'),(3,10,21,'PAY202610210003','PAYMENT','BANK-3'),(4,10,22,NULL,'PAYMENT','BANK-4')"); + var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), source)); + configuration.addMapper(FinanceSettlementDetailMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) { + var repository = new FinanceSettlementDetailRepository(session.getMapper(FinanceSettlementDetailMapper.class)); + var rows = repository.payments(10, 20); + assertThat(rows).hasSize(1); + assertThat(rows.get(0).get("transaction_no")).isEqualTo("PAY202610210001"); + assertThat(rows.get(0).get("reference_no")).isEqualTo("BANK-1"); + assertThat(repository.payments(11, 21)).isEmpty(); + assertThat(repository.payments(10, 22)).hasSize(1); + assertThat(repository.payments(10, 22).get(0).get("transaction_no")).isNull(); + } + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java index 62e4cd0a4..6f83c8ce0 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/controller/PlatformColoadGroupController.java @@ -67,10 +67,11 @@ public class PlatformColoadGroupController { idempotencyKey, user.userId(), user.username())); } + /** 按入仓码返回同柜组业务编号及当前有效成员,保留平台只读权限边界。 */ @GetMapping("/by-inbound-code/{inboundCode}") @PreAuthorize("hasAuthority('platform:firstleg:smart-loading:list')") @Operation(summary = "按入仓码查询同柜组业务摘要", - description = "仅返回业务状态、有效成员数及去重后的全部成员入仓码;内部组ID和组号不作为页面识别键。") + description = "返回真实业务组号、业务状态、有效成员数及去重后的全部成员入仓码;不返回内部组ID。") public ApiResponse byInboundCode( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Size(min = 1, max = 40) String inboundCode) { diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java index e6e7375d5..7c9d611d6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/model/ColoadGroupModels.java @@ -71,6 +71,7 @@ public final class ColoadGroupModels { /** 页面只读业务摘要;内部组标识不作为用户识别键。 */ public record BusinessSummary( + String groupNo, String status, int memberCount, List members diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java index 2be528ce5..2914c9c8f 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupMapper.java @@ -108,7 +108,7 @@ public interface ColoadGroupMapper { @Param("inboundId") long inboundId); @Select(""" - SELECT DISTINCT group_row.id groupId,group_row.group_status groupStatus + SELECT DISTINCT group_row.id groupId,group_row.group_no groupNo,group_row.group_status groupStatus FROM fl_inbound_order requested JOIN fl_coload_group_member member ON member.tenant_id=requested.tenant_id AND member.inbound_id=requested.id diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupQueryRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupQueryRepository.java new file mode 100644 index 000000000..f6fba0acf --- /dev/null +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadGroupQueryRepository.java @@ -0,0 +1,20 @@ +package com.baiyue.firstleg.loading.coload.persistence; + +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 平台同柜组摘要的只读数据访问,保留原入仓片段和组状态范围。 */ +@Repository +public class ColoadGroupQueryRepository { + private final ColoadGroupMapper mapper; + /** 注入同服务持久化入口。 */ + public ColoadGroupQueryRepository(ColoadGroupMapper mapper) { this.mapper = mapper; } + /** 按业务入仓码查询当前组及其真实业务组号。 */ + public List groupsByInboundCode(String code) { + return mapper.groupsByInboundCode(code); + } + /** 读取被冻结的成员,交由服务核对缺失和重复片段。 */ + public List effectiveMemberCodes(long groupId) { + return mapper.effectiveMemberCodes(groupId); + } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java index 6ee1eca41..c252139aa 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/persistence/ColoadPersistenceRows.java @@ -59,6 +59,7 @@ public final class ColoadPersistenceRows { @Setter public static class InboundGroupLookupRow { private Long groupId; + private String groupNo; private String groupStatus; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java index 9db82153a..851ef5450 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/loading/coload/service/ColoadGroupService.java @@ -47,6 +47,8 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { @Resource private ColoadGroupMapper mapper; + @Resource + private com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository queryRepository; @Resource private PendingReviewOrderClient pendingReviewOrders; @Resource private OrderCargoClient orderCargoClient; @Resource private InboundOrderCreationService inboundOrderCreationService; @@ -118,10 +120,11 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { return requiredSnapshot(groupId); } + /** 以入仓码读取同柜组业务号及全部有效成员,不返回内部组ID。 */ @Transactional(readOnly = true) public BusinessSummary queryByInboundCode(String inboundCode) { String code = normalizedInboundCode(inboundCode); - var groups = mapper.groupsByInboundCode(code).stream() + var groups = queryRepository.groupsByInboundCode(code).stream() .filter(row -> row.getGroupId() != null && CURRENT_GROUP_STATUSES.contains(row.getGroupStatus())) .toList(); long distinctGroups = groups.stream().map(row -> row.getGroupId()).distinct().limit(2).count(); @@ -131,7 +134,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { if (distinctGroups > 1) throw conflict("入仓码关联多个有效同柜不可拆组"); long groupId = groups.get(0).getGroupId(); String status = groups.get(0).getGroupStatus(); - var memberRows = mapper.effectiveMemberCodes(groupId); + var memberRows = queryRepository.effectiveMemberCodes(groupId); if (memberRows.isEmpty()) throw conflict("同柜不可拆组不存在有效入仓片段"); if (memberRows.stream().anyMatch(row -> row.getMemberId() == null || row.getInboundId() == null || trim(row.getInboundCode()) == null)) { @@ -142,7 +145,7 @@ public class ColoadGroupService implements ColoadGroupAssignmentPort { throw conflict("同柜不可拆组存在重复入仓片段业务号"); } List members = inboundCodes.stream().sorted().map(BusinessMember::new).toList(); - return new BusinessSummary(status, members.size(), members); + return new BusinessSummary(groups.get(0).getGroupNo(), status, members.size(), members); } private long requiredCurrentGroupId(String inboundCode) { diff --git a/firstleg/src/main/resources/openapi/coload-groups-v1.yaml b/firstleg/src/main/resources/openapi/coload-groups-v1.yaml index cf671fb3d..ad063bdc1 100644 --- a/firstleg/src/main/resources/openapi/coload-groups-v1.yaml +++ b/firstleg/src/main/resources/openapi/coload-groups-v1.yaml @@ -45,8 +45,8 @@ paths: summary: 按入仓码查询同柜组业务摘要 description: >- 需要 platform:firstleg:smart-loading:list。使用任一有效成员的全局唯一 inboundCode 查询; - 仅返回组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭; - 页面不持有或展示 groupId、groupNo、orderId,也不得从技术ID或订单号推导入仓码。 + 返回真实业务组号 groupNo、组业务状态、有效成员数和全部唯一成员入仓码。任一冻结成员缺少有效 inboundCode 时失败关闭; + 页面不持有或展示 groupId、orderId,也不得从技术ID或订单号推导入仓码。 parameters: - name: inboundCode in: path @@ -147,8 +147,9 @@ components: ColoadGroupBusinessSummary: type: object additionalProperties: false - required: [status, memberCount, members] + required: [groupNo, status, memberCount, members] properties: + groupNo: {type: string, pattern: "^CLG[0-9]{12}$", description: 真实同柜业务组号;与创建及重放结果一致} status: {type: string, enum: [ACTIVE, LOCKED, CONSUMED]} memberCount: {type: integer, minimum: 1} members: diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java index 67c3004be..92692278c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupOpenApiContractTest.java @@ -55,7 +55,7 @@ class ColoadGroupOpenApiContractTest { .doesNotContain("destinationCode") .doesNotContain("expectedVersion") .doesNotContain("platform:firstleg:coload"); - assertThat(summaryProperties).containsOnlyKeys("status", "memberCount", "members"); + assertThat(summaryProperties).containsOnlyKeys("groupNo", "status", "memberCount", "members"); assertThat(memberProperties).containsOnlyKeys("inboundCode"); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java index 9a05cf152..be14de3ef 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupServiceTest.java @@ -37,7 +37,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class}) +@SpringJUnitConfig({ColoadGroupService.class, ColoadGroupServiceTest.Numbers.class, com.baiyue.firstleg.loading.coload.persistence.ColoadGroupQueryRepository.class}) class ColoadGroupServiceTest { @org.springframework.context.annotation.Configuration static class Numbers { @@ -71,6 +71,7 @@ class ColoadGroupServiceTest { var result = service.queryByInboundCode(" IN-A "); + assertThat(result.groupNo()).isEqualTo("CLG202610210001"); assertThat(result.status()).isEqualTo("ACTIVE"); assertThat(result.memberCount()).isEqualTo(2); assertThat(result.members()).extracting("inboundCode").containsExactly("IN-A", "IN-B"); @@ -574,6 +575,7 @@ class ColoadGroupServiceTest { private static InboundGroupLookupRow lookup(long groupId, String status) { InboundGroupLookupRow value = new InboundGroupLookupRow(); value.setGroupId(groupId); + value.setGroupNo("CLG202610210001"); value.setGroupStatus(status); return value; } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java index 6609b0bcf..b1196cdb6 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/loading/coload/ColoadGroupSqlContractTest.java @@ -86,7 +86,8 @@ class ColoadGroupSqlContractTest { .contains("requested.inbound_code=#{inboundCode}") .contains("requested.deleted=0") .contains("'ACTIVE','LOCKED','CONSUMED'") - .doesNotContain("group_no", "order_id=#{"); + .contains("group_row.group_no groupNo") + .doesNotContain("order_id=#{"); assertThat(memberSql) .contains("member.id memberId", "member.inbound_id inboundId", "inbound.inbound_code inboundCode") .contains("LEFT JOIN fl_inbound_order inbound") From 337da6d67c4e04cfaef3a5268c1f2e76a509c213 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 13:22:35 +0800 Subject: [PATCH 054/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 6 +- README.md | 11 +- config/nacos/test/finance-service.yml | 3 +- lastleg-service/AGENTS.md | 2 +- lastleg-service/docs/business-index.md | 4 +- .../service/OutsourcedBusinessService.java | 2 +- .../mapper/OwnedBusinessQueryMapper.java | 24 +++- .../model/OwnedBusinessRows.java | 1 + .../model/OwnedBusinessScope.java | 17 +-- .../service/OwnedBusinessProjection.java | 2 +- .../service/OwnedBusinessQueryService.java | 12 +- .../openapi/fleet-outsourced-business-v1.yaml | 6 +- .../openapi/fleet-owned-business-v1.yaml | 11 +- .../ownedbusiness/OwnedBusinessFixtures.java | 1 + .../OwnedBusinessPersistenceTest.java | 6 +- .../ownedbusiness/OwnedBusinessQueryTest.java | 7 +- .../OwnedBusinessScopePersistenceTest.java | 60 ++++++++- scripts/AGENTS.md | 3 +- scripts/README.md | 56 ++++++++- scripts/acceptance-overview.mjs | 72 +++++++++++ scripts/acceptance-overview.test.mjs | 46 +++++++ scripts/admin-test-env-acceptance.mjs | 18 +++ scripts/admin-test-env-modules/README.md | 2 + scripts/backend-check.ps1 | 117 ++++++++++++++++++ scripts/backend-check.test.mjs | 82 ++++++++++++ 25 files changed, 528 insertions(+), 43 deletions(-) create mode 100644 scripts/acceptance-overview.mjs create mode 100644 scripts/acceptance-overview.test.mjs create mode 100644 scripts/backend-check.ps1 create mode 100644 scripts/backend-check.test.mjs diff --git a/AGENTS.md b/AGENTS.md index b28db77af..04e997b80 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -132,7 +132,11 @@ - 数据库只访问所属服务库。模块表结构位于 `/src/main/resources/db/`;开发环境按最新模块基线和 `deploy/sql/头程数据库统一增量-MySQL8.sql` 收敛,不为历史开发数据新增兼容迁移。已有名称含 `migration` 的脚本仅作结构来源和追溯,部署说明见 `deploy/sql/README.md`;生产升级另按明确发布规则处理。 - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 -- Java 签名变更的最低编译门禁是 `mvn -pl -am test-compile`,它必须在部署前完成并覆盖测试源码;有行为变化时继续运行 `mvn -pl -am test`。`-DskipTests` 可跳过测试执行但仍须保留测试编译,`-Dmaven.test.skip=true` 会跳过测试编译,不得作为签名变更的交付验证。全量验证仍使用 `mvn -DskipTests validate`、`mvn test`;不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 +- 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。 +- 开发迭代使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及直接调用点;Java 签名变化至少使用 `-Mode Compile` 编译生产与测试源码(等价于 `mvn -pl -am test-compile`)。单服务行为变更收尾使用默认 `Module` 模式执行该服务及上游依赖测试;跨服务 DTO、接口或事件变化必须显式列入生产者和受影响消费者。`-am` 只补上游构建依赖,不自动包含下游服务或 HTTP/事件消费者;不能以单类测试代替模块收尾。 +- 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。 +- 全仓回归只在以下情况触发:根 POM/统一依赖版本/聚合拓扑/测试基础配置变化;公共认证、序列化、基础模型等影响广泛且不能可靠限定消费者;跨多域批次集成验收;用户明确要求全量回归。使用 `scripts/backend-full-regression.ps1`,保留当前 CI 全量验证。普通单服务任务、单次提交或对话收尾不自动触发全仓;已明确消费者的公共或跨服务变更按受影响模块验证。同批同一相关输入只跑一次,输入变化后补跑受影响范围,触及上述全量条件再重跑全量。 +- 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 - 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。 - Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 - 单服务镜像构建/部署从 `Jenkinsfile.service`、`deploy/docker/Dockerfile`、`deploy/scripts/deploy-built-service.sh` 进入;新镜像启动失败或健康检查失败时只允许回滚到同服务先前镜像,并保持任务失败,离线合同见 `scripts/deploy-built-service-rollback.test.mjs`。实际部署仍须当前对话明确授权。 diff --git a/README.md b/README.md index d1cf1d8a7..7611f0394 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,16 @@ ## 本地构建 ```powershell -mvn -DskipTests validate -node --test scripts/java-topology-contract.test.mjs -mvn test +# 日常按受影响模块检查;跨服务变更显式列入消费者。 +.\scripts\backend-check.ps1 -Modules order -Mode Compile +.\scripts\backend-check.ps1 -Modules order +# 根构建/拓扑变化等全量触发条件见 AGENTS.md。 +.\scripts\local-check.ps1 -Command @('node', '--test', 'scripts/java-topology-contract.test.mjs') +.\scripts\backend-full-regression.ps1 ``` +开发中可通过 `backend-check.ps1 -Mode Focused -Modules <模块> -Tests <精确测试类名>` 快速反馈;参数、DryRun 和结果边界见 [后端分层检查](scripts/README.md#后端分层检查)。单服务修改收尾执行模块回归,不默认执行上面的全仓命令。 + GitLab 在独立 Node 镜像中自动执行拓扑合同,随后才允许服务制品打包。Maven `verify` 会为执行过测试且产生覆盖率数据的模块生成 `target/site/jacoco/index.html` 与 `jacoco.xml`;当前只生成报告,不设置覆盖率阈值,也不改变测试选择。Jenkins 的 `java-builder` 尚未在仓库中声明 Node 运行时,因此暂不重复执行该 Node 合同,以 GitLab 门禁和上述本地命令为准。 2026-07-30 订单与客户前端联调缺口已完成真实回归:19 个 Reactor 项目构建成功,75 项单元及组件测试、68 项认证与 IAM 真实 API、37 项订单与仓库真实 API 全部通过。详细证据见 `docs/phase1-business-maturity-regression-report.md`。 diff --git a/config/nacos/test/finance-service.yml b/config/nacos/test/finance-service.yml index dca977209..79267f412 100644 --- a/config/nacos/test/finance-service.yml +++ b/config/nacos/test/finance-service.yml @@ -5,7 +5,8 @@ spring: password: ${FINANCE_DB_PASSWORD} driver-class-name: com.mysql.cj.jdbc.Driver baiyue.clients.warehouse.url: ${WAREHOUSE_SERVICE_URL:} -baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:} +# TEST 流程验收复用 Client 的示例图片,图片标注不可付款且不含真实收款信息。 +baiyue.finance.recharge.qr-code-url: ${FINANCE_RECHARGE_QR_CODE_URL:/customer-recharge-qr.svg} baiyue.finance.wallet.default-credit-limit: ${FINANCE_WALLET_DEFAULT_CREDIT_LIMIT:0.00} baiyue.event-delivery.operations-token: ${EVENT_OPERATIONS_TOKEN:} baiyue.event-delivery.connect-timeout-ms: ${EVENT_CONNECT_TIMEOUT_MS:3000} diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 9a045e112..e99f710d2 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -17,7 +17,7 @@ 拼车余位发布查 `fleet/trip/FleetCapacityOfferService.java`;整车建单与准备查 `dispatch/controller/DispatchAggregateController.java` 和 `dispatch/service/DispatchAggregateService.java`,本土业务接线查 `fleet/business/`;既有外接登记衔接合同为 `fleet-preparation-v1.yaml`,排车流程按 `../../docs/海外仓业务/排车列表统一作业改造契约.md`。 -原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有/外配按同事务捕获的车辆归属,不按货源区分。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。 +原排车装车后运营视图分别查 `fleet/ownedbusiness/`、`fleet/outsourcedbusiness/` 与各自 `fleet-*-business-v1.yaml`;自有业务按无实际拼车货物、外配任务按已实际接入需求池货物分类;车辆归属仍读取装车快照,完整判定唯一见外配业务契约。两入口可信作用域及权限独立,外配仅整车承运金额、逐目的仓到达和五装车照片;复用四张附加表,不修改原履约/库存/财务状态。共同业务规则见 `../../docs/海外仓业务/自有业务首版开发契约.md`、`../../docs/海外仓业务/外配业务首版开发契约.md`,实际发布及HTTP只查部署主契约。 事件重试:`controller/EventOperationsController.java`,内部 `/internal/lastleg/event-operations/{entryId}` 查询、`/retry` 写入;仅卸柜、派送、暂扣、资金及费用事件,以 `EVENT_OPERATIONS_TOKEN` 保护。原事件不可改,DEAD→PENDING 与 `event_delivery_retry_audit` 同事务;脚本 `scripts/event-outbox-operations.mjs`。 diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index e7a751f4b..d628ca955 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -85,8 +85,8 @@ - 车次成本:`fleet/trip/FleetTripCostService.java`、`fleet-trip-costs-v1.yaml`;按DEPART实测及外接实收分摊,不产生客户账单/付款。 - 报价/统计:`fleet/quote/`、`fleet/reporting/`,合同`fleet-quotes-v1.yaml`、`fleet-operations-v1.yaml`;实测凭证按车次归属,仅当前/历史版本引用可下载。 -- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按装车时自有车辆归属展示,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。 -- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按冻结DISPATCHED归属展示,包括正式MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。 +- 自有业务运营:`fleet/ownedbusiness/`、`fleet-owned-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/自有业务首版开发契约.md`;原排车装车后三阶段按无实际拼车货物展示,车辆归属独立,仅补金额、逐目的地到达、里程、油耗与照片。原详情只经`dispatch.port.DispatchDetailReader`读取,原装车事务经`OwnedBusinessOwnershipCapture`捕获归属,不另建履约流程。 +- 外配业务运营:`fleet/outsourcedbusiness/`、`fleet-outsourced-business-v1.yaml`,共同规则见`../../../docs/海外仓业务/外配业务首版开发契约.md`;原排车装车后三阶段按实际接入需求池拼车货物展示,车辆归属独立,包含MASTER/EXTERNAL两种资源模式。只登记整车承运金额、逐目的仓到达和五装车照片,无里程/油耗或成本拆分。内部可信作用域复用四表,入口权限与自有严格分开,不引入第二履约模型。 - 外接选价合同:`fleet-quote-candidates-v1.yaml`、`fleet-trips-v1.yaml`;来源快照与成交价分开,确认承运复验。 - 在途交接:`fleet/exception/`、`fleet-handovers-v1.yaml`;后台确认交接,旧资源分别结束任务,DEPART不重写。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java index 7052b263e..185b14bae 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java @@ -75,7 +75,7 @@ public class OutsourcedBusinessService { } /** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */ private Row row(OwnedBusinessModels.Row r,String carrierName) { - if(!"DISPATCHED".equals(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照与冻结归属不一致"); + if(!java.util.Set.of("OWNED","DISPATCHED").contains(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照的车辆归属或照片不合法"); return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(), new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(), r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java index a3582c459..ac511bf51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -7,8 +7,24 @@ import java.util.List; /** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */ @Mapper public interface OwnedBusinessQueryMapper { + /** 拼车登记须已实收并绑定本车,且原实收来源仍在本车计划托盘组成内;多票只判存在。 */ + String POOLED_CARGO=""" + EXISTS (SELECT 1 FROM ll_fleet_external_booking b + JOIN ll_warehouse_transfer_order incoming ON incoming.customer_id=b.customer_id AND incoming.transfer_no=b.incoming_no + WHERE b.trip_id=d.trip_id AND b.dispatch_no=d.dispatch_no AND b.offer_id IS NOT NULL + AND b.status='BOUND' AND b.receipt_json IS NOT NULL + AND EXISTS (SELECT 1 FROM ll_dispatch_item cargo + JOIN ll_inbound_plan_pallet_allocation allocation ON allocation.pallet_id=cargo.plan_pallet_id + AND allocation.plan_id=cargo.plan_id AND allocation.customer_id=cargo.customer_id + JOIN ll_inbound_plan_line line ON line.id=allocation.line_id AND line.plan_id=cargo.plan_id + WHERE cargo.dispatch_order_id=d.id AND cargo.customer_id=b.customer_id + AND line.customer_id=b.customer_id AND line.inbound_code=incoming.inbound_code + AND cargo.warehouse_pallet_id IS NOT NULL AND cargo.package_count>0)) + """; String SOURCE=""" - SELECT d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id, + SELECT + """+POOLED_CARGO+" AS pooled_cargo,"+""" + d.id,d.dispatch_no,d.status,d.stage,d.record_version,d.overseas_warehouse_id, d.overseas_warehouse_code,d.overseas_warehouse_name,d.planned_departure_at,d.loading_completed_at, d.departed_at,d.delivery_completed_at,d.loading_completion_hash,d.loading_confirmation_json,d.resources_json, o.vehicle_id AS owned_vehicle_id,o.resource_mode AS owned_resource_mode, @@ -20,10 +36,12 @@ public interface OwnedBusinessQueryMapper { String FILTER=""" d.status='ACTIVE' AND d.stage IN ('AFTER_LOADING','DELIVERING','DELIVERED') AND d.loading_completed_at IS NOT NULL AND d.loading_confirmation_json IS NOT NULL - AND o.vehicle_ownership=#{q.scope.ownership} + AND o.vehicle_ownership IN ('OWNED','DISPATCHED') AND CAST(o.loading_completion_hash AS BINARY(128))=CAST(d.loading_completion_hash AS BINARY(128)) AND o.loading_completed_at=d.loading_completed_at - AND (o.resource_mode='MASTER' OR (#{q.scope.ownership}='DISPATCHED' AND o.resource_mode='EXTERNAL')) + AND (o.resource_mode='MASTER' OR (o.vehicle_ownership='DISPATCHED' AND o.resource_mode='EXTERNAL')) + AND NOT + """+POOLED_CARGO+""" AND d.planned_departure_at >= #{q.from} AND d.planned_departure_at < #{q.to} AND d.stage=#{q.stage} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java index 38e66f20d..7bdd8d7b5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessRows.java @@ -14,6 +14,7 @@ public final class OwnedBusinessRows { private LocalDateTime ownershipCapturedAt,loadingCompletedAt; } @Data public static class Source { + private Boolean pooledCargo; private Long id,recordVersion; private String dispatchNo,status,stage,overseasWarehouseId,overseasWarehouseCode,overseasWarehouseName; private String loadingCompletionHash,loadingConfirmationJson,resourcesJson; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java index fcefe58c5..fabd6344b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java @@ -6,13 +6,14 @@ import com.baiyue.common.core.api.ResultCode; /** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */ public enum OwnedBusinessScope { - OWNED("OWNED","platform:fleet:owned-business:"), - OUTSOURCED("DISPATCHED","platform:fleet:outsourced-business:"); - private final String ownership,prefix; - /** 车辆归属与入口权限一一固定,不能由请求指定。 */ - OwnedBusinessScope(String ownership,String prefix){this.ownership=ownership;this.prefix=prefix;} - /** SQL仅绑定此可信枚举的归属值,不拼接客户端片段。 */ - public String getOwnership(){return ownership;} + OWNED(false,"platform:fleet:owned-business:"), + OUTSOURCED(true,"platform:fleet:outsourced-business:"); + private final boolean pooledCargo; + private final String prefix; + /** 业务入口按是否实际接入拼车货物分类,与车辆归属独立。 */ + OwnedBusinessScope(boolean pooledCargo,String prefix){this.pooledCargo=pooledCargo;this.prefix=prefix;} + /** SQL绑定服务端固定分类,不接受浏览器指定作用域。 */ + public boolean isPooledCargo(){return pooledCargo;} /** 查看权限包含本作用域详情、历史和照片读取。 */ public String listPermission(){return prefix+"list";} /** 运营写权限独立,外配权限不映射成自有权限。 */ @@ -22,5 +23,5 @@ public enum OwnedBusinessScope { /** 外配只支持装车五框,服务调用同样不能绕过。 */ public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");} /** 不存在只显示当前业务归属,不泄露内部身份。 */ - public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有":"外配")+"业务");} + public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有业务":"外配任务"));} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java index c3d96d31a..e32e526a7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -68,7 +68,7 @@ public class OwnedBusinessProjection { return new Row(source.getDispatchNo(),Stage.valueOf(source.getStage()),codec.iso(source.getPlannedDepartureAt()), codec.iso(source.getLoadingCompletedAt()),codec.iso(source.getDepartedAt()),codec.iso(source.getDeliveryCompletedAt()), new Origin(source.getOverseasWarehouseId(),source.getOverseasWarehouseCode(),source.getOverseasWarehouseName()), - new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),scope.getOwnership()), + new Vehicle(resources.vehicle().vehicleId()==null?null:String.valueOf(resources.vehicle().vehicleId()),resources.vehicle().plate(),source.getOwnedVehicleOwnership()), new Driver(driver==null?null:driver.name(),driver==null?null:driver.phone()),List.copyOf(destinations.values()),pallets,ops,price, record==null?"0":String.valueOf(record.getRecordVersion()),String.valueOf(source.getRecordVersion()), new PhotoSummary((int)photos.stream().filter(p->"LOADING".equals(p.category())).count(),(int)photos.stream().filter(p->"FUEL".equals(p.category())).count()), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java index d9fb9d19e..f21ece7a1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -17,7 +17,7 @@ import java.util.stream.Collectors; import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; import static com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; -/** 自有车辆装车后视图;无归属快照的旧单不在GET时回填。 */ +/** 装车后按实际拼车货物分组;车辆归属仅保留原装车证据。 */ @Service public class OwnedBusinessQueryService { private final OwnedBusinessRepository repository; @@ -61,7 +61,7 @@ public class OwnedBusinessQueryService { /** 对原排车详情做组合,不创建第二份主单或履约状态。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public Detail detail(Actor actor,String number){codec.require(actor,LIST);return detailForWrite(actor,requireOwned(number));} - /** 外配入口只能读取其冻结归属,不能借自有权限取得原详情。 */ + /** 外配入口只读实际拼车任务,不能借自有权限取得原详情。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public Detail detail(Actor actor,String number,OwnedBusinessScope scope){codec.require(actor,scope.listPermission());return detailForWrite(actor,requireScoped(number,scope),scope);} /** 写入已经有独立权限;回执仍由相同真实来源组成。 */ @@ -82,7 +82,7 @@ public class OwnedBusinessQueryService { if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号"); var source=repository.source(number);if(source==null)throw missing();eligible(source,OwnedBusinessScope.OWNED);return source; } - /** 共享附加表仍按原装车归属隔离,不接受浏览器业务域参数。 */ + /** 共享附加表按实际拼车分类隔离,不接受浏览器业务域参数。 */ public Source requireScoped(String number,OwnedBusinessScope scope) { if(scope==OwnedBusinessScope.OWNED)return requireOwned(number); if(number==null || number.isBlank() || number.length()>128)throw bad("请指定排车单号"); @@ -91,13 +91,15 @@ public class OwnedBusinessQueryService { /** 阶段、真实装车hash及车辆快照须与捕获证据一致。 */ private void eligible(Source source,OwnedBusinessScope scope) { if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务"); - if(!scope.getOwnership().equals(source.getOwnedVehicleOwnership()))throw scope.missing(); + if(!Set.of("OWNED","DISPATCHED").contains(source.getOwnedVehicleOwnership()))throw conflict("装车车辆归属不合法"); + if(source.getPooledCargo()==null)throw conflict("本单缺少实际拼车分类依据"); + if(scope.isPooledCargo()!=source.getPooledCargo())throw scope.missing(); if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage()) || source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实"); var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle(); boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode()) && resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId()); - boolean external=scope==OwnedBusinessScope.OUTSOURCED && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name()) + boolean external="DISPATCHED".equals(source.getOwnedVehicleOwnership()) && resource!=null && resource.mode()!=null && "EXTERNAL".equals(resource.mode().name()) && "EXTERNAL".equals(source.getOwnedResourceMode()) && resource.vehicleId()==null && source.getOwnedVehicleId()==null; if((!master && !external) || !Objects.equals(source.getOwnedLoadingCompletionHash(),source.getLoadingCompletionHash()) || !Objects.equals(source.getOwnedLoadingCompletedAt(),source.getLoadingCompletedAt()))throw conflict("车辆归属与原装车事实不一致"); diff --git a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml index b1067f20e..00f84a83f 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-outsourced-business-v1.yaml @@ -1,8 +1,8 @@ openapi: "3.0.3" info: - title: "原排车外配车辆运营视图" + title: "原排车拼车任务运营视图" version: "1.0.0" - description: "仅ACTIVE且装车后/派送中/已完成。按原装车归属DISPATCHED及MASTER/EXTERNAL资源快照读取;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。" + description: "仅ACTIVE且装车后/派送中/已完成。按需求池已实收绑定及本车真实货物组成判定实际拼车。车辆归属允许OWNED/DISPATCHED,MASTER/EXTERNAL只表示资源身份;无快照不回填。复用四张附加表,不推进排车、不触发库存/财务。承运金额CNY/USD显式登记;单价除以实际物理托数并HALF_UP两位。无里程、油耗及油耗照片。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。" security: - bearerAuth: [] paths: @@ -182,7 +182,7 @@ components: properties: vehicleId: { type: string, nullable: true, description: "原临时EXTERNAL车辆合法为空,档案MASTER核对原捕获ID" } plate: { type: string, nullable: true } - ownership: { type: string, enum: [DISPATCHED] } + ownership: { type: string, enum: [OWNED, DISPATCHED] } carrierName: { type: string, nullable: true, description: "只取原车辆快照,缺失显示—;不查当前档案补值" } PhotoSlot: type: string diff --git a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml index a65002c10..5a8fdf286 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-owned-business-v1.yaml @@ -1,8 +1,8 @@ openapi: "3.0.3" info: - title: "原排车自有车辆运营视图" + title: "原排车未拼车业务运营视图" version: "1.0.0" - description: "装车完成时冻结车辆OWNED/DISPATCHED;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。" + description: "未实际接入拼车货物的装车后排车;按需求池已实收绑定及本车真实货物组成判定。车辆OWNED/DISPATCHED只表示装车时归属,不决定业务分类;所有主单事实来自原dispatch。新增数据仅附加运营记录、命令、历史及归属证据。油耗L/100km手工登记,金额CNY/USD不默认、不换汇;零与未登记不同。响应遵循NON_NULL:可空属性可省略,省略与null均表示空值,0仍是明确登记值;请求必填项仍以required及DTO校验为准。" security: - bearerAuth: [] @@ -10,7 +10,7 @@ paths: "/api/platform/v1/last-leg/fleet/owned-business": get: operationId: "listOwnedBusiness" - summary: "自有车辆装车后视图及同筛选四项统计" + summary: "未接入拼车货物的装车后视图及同筛选四项统计" description: "权限:platform:fleet:owned-business:list。仅可信平台主体;只读原排车事实,不改变其状态/版本,不发布费用。" x-permission-code: "platform:fleet:owned-business:list" responses: @@ -540,18 +540,19 @@ components: type: "object" x-java-record: "OwnedBusinessModels.Vehicle" required: - - "vehicleId" - - "plate" - "ownership" properties: vehicleId: type: "string" + nullable: true plate: type: "string" + nullable: true ownership: type: "string" enum: - "OWNED" + - "DISPATCHED" Driver: type: "object" x-java-record: "OwnedBusinessModels.Driver" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java index 8f69de744..9424d9403 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessFixtures.java @@ -20,6 +20,7 @@ final class OwnedBusinessFixtures { /** 装车完成但未发车,证明不依赖DEPART历史。 */ static Source source() { var row=new Source();row.setId(1L);row.setDispatchNo("DS-OWN-ONE");row.setStatus("ACTIVE");row.setStage("AFTER_LOADING"); + row.setPooledCargo(false); row.setRecordVersion(8L);row.setOverseasWarehouseId("9");row.setOverseasWarehouseCode("WH-9");row.setOverseasWarehouseName("测试仓"); row.setPlannedDepartureAt(LocalDateTime.parse("2026-10-20T08:00:00"));row.setLoadingCompletedAt(LocalDateTime.parse("2026-10-20T07:30:00")); row.setLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletionHash("actual-load");row.setOwnedLoadingCompletedAt(row.getLoadingCompletedAt()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java index f932ce7f9..868db7081 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessPersistenceTest.java @@ -50,8 +50,12 @@ class OwnedBusinessPersistenceTest { customer_name VARCHAR(128),source_key VARCHAR(128),order_no VARCHAR(128),inbound_code VARCHAR(128), pallet_no VARCHAR(128),warehouse_pallet_id BIGINT,destination_type VARCHAR(32), destination_warehouse_code VARCHAR(128),destination_warehouse_name VARCHAR(128),fba_warehouse_code VARCHAR(128), - delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT) + delivery_address VARCHAR(256),postal_code VARCHAR(64),package_count INT,plan_id BIGINT,plan_pallet_id BIGINT) """); + jdbc.execute("CREATE TABLE ll_fleet_external_booking(id BIGINT PRIMARY KEY,trip_id BIGINT,dispatch_no VARCHAR(128),customer_id VARCHAR(128),offer_id BIGINT,status VARCHAR(32),incoming_no VARCHAR(128),receipt_json LONGTEXT)"); + jdbc.execute("CREATE TABLE ll_warehouse_transfer_order(customer_id VARCHAR(128),transfer_no VARCHAR(128),inbound_code VARCHAR(128))"); + jdbc.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT PRIMARY KEY,plan_id BIGINT,customer_id VARCHAR(128),inbound_code VARCHAR(128))"); + jdbc.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,plan_id BIGINT,customer_id VARCHAR(128))"); jdbc.execute("CREATE TABLE ll_dispatch_appointment_allocation(dispatch_id BIGINT,dispatch_item_id BIGINT,customer_id VARCHAR(64),batch_key VARCHAR(128),destination_key VARCHAR(128),package_count INT)"); jdbc.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT,appointment_no VARCHAR(64),confirmation_no VARCHAR(64),appointment_start_utc DATETIME(3))"); jdbc.execute(""" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java index becb67302..a467180db 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessQueryTest.java @@ -41,14 +41,15 @@ class OwnedBusinessQueryTest { assertThrows(BizException.class,()->service.detail(OwnedBusinessFixtures.actor(),"DS-OWN-ONE")); verifyNoInteractions(dispatch);verify(repo,never()).capture(any());assertEquals(8L,source.getRecordVersion()); } - /** 装车前、非自有归属、失配的原装车hash均不能作为本页可写来源。 */ + /** 装车前、已实际拼车、失配的装车hash均不可写;未拼车的外配车辆仍属自有业务。 */ @Test void stageAndFrozenOwnershipFactsAreBothRequired() { var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); var service=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class)); var source=OwnedBusinessFixtures.source();when(repo.source("DS-OWN-ONE")).thenReturn(source); source.setStage("LOADING");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); - source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); - source.setOwnedVehicleOwnership("OWNED");source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); + source.setStage("AFTER_LOADING");source.setOwnedVehicleOwnership("DISPATCHED");assertSame(source,service.requireOwned("DS-OWN-ONE")); + source.setPooledCargo(true);assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); + source.setPooledCargo(false);source.setOwnedLoadingCompletionHash("other");assertThrows(BizException.class,()->service.requireOwned("DS-OWN-ONE")); } /** 列表权限不自动授予编辑或上传,不暴露主流程写动作。 */ @Test void readOnlyActorHasOnlyReadGates() { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index 7101dd39f..d54ff5f29 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -75,7 +75,7 @@ class OwnedBusinessScopePersistenceTest { """,id,"DS-OUT-"+id,stage,load,snapshot,"TEST-OUT-"+id); baseline.jdbc.update(""" INSERT INTO ll_dispatch_item SELECT id+?, ?,customer_id,customer_name,source_key,order_no,inbound_code,?, ?,destination_type, - destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE dispatch_order_id=1 + destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count,NULL,NULL FROM ll_dispatch_item WHERE dispatch_order_id=1 """,id*100,id,"PAL-"+(31+id),31+id); var ownership=new OwnedBusinessRows.Ownership();ownership.setDispatchId(id);ownership.setVehicleId(vehicle.vehicleId()); ownership.setResourceMode(mode);ownership.setVehicleOwnership("DISPATCHED");ownership.setVehicleRecordVersion(vehicle.vehicleId()==null?null:3L); @@ -83,6 +83,12 @@ class OwnedBusinessScopePersistenceTest { baseline.jdbc.update("INSERT INTO ll_dispatch_history VALUES(?,?,8,'LOADING_COMPLETE','TEST-picker',NULL,'{}',?)",100+id,id,original.getLoadingCompletedAt()); if(!"AFTER_LOADING".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET departed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(1),id); if("DELIVERED".equals(stage))baseline.jdbc.update("UPDATE ll_dispatch_order SET delivery_completed_at=? WHERE id=?",original.getLoadingCompletedAt().plusHours(2),id); + // 每张外配测试任务真实关联一票拼车来源,车辆归属不再充当分类依据。 + baseline.jdbc.update("UPDATE ll_dispatch_item SET plan_id=?,plan_pallet_id=? WHERE dispatch_order_id=? AND customer_id='101'",id*1000,id*1000+1,id); + baseline.jdbc.update("INSERT INTO ll_inbound_plan_line VALUES(?,?,'101','IN-A')",id*1000+2,id*1000); + baseline.jdbc.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,?,?,'101')",id*1000+1,id*1000+2,id*1000); + baseline.jdbc.update("INSERT INTO ll_warehouse_transfer_order VALUES('101',?,'IN-A')","INCOMING-"+id); + baseline.jdbc.update("INSERT INTO ll_fleet_external_booking VALUES(?,2,?,'101',9,'BOUND',?,'{}')",id,"DS-OUT-"+id,"INCOMING-"+id); } /** 外配真实三权限,与自有集合完全不相交。 */ private OwnedBusinessModels.Actor actor(){return new OwnedBusinessModels.Actor("outsourced-operator","TEST外配操作员",Set.of(OutsourcedBusinessModels.LIST,OutsourcedBusinessModels.EDIT,OutsourcedBusinessModels.UPLOAD));} @@ -101,6 +107,56 @@ class OwnedBusinessScopePersistenceTest { assertEquals(0,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,null,"2026-10-20T08:00:00Z",null,null,"9")).total()); for(var stage:OwnedBusinessModels.Stage.values())assertEquals(1,outsourced.list(actor(),new OwnedBusinessModels.Query(1,20,"2026-10-20T08:00:00Z","2026-10-21T08:00:00Z",null,stage,"9")).total()); } + /** 四种车辆/拼车组合只按货物分类;临时外配未拼车也可从自有入口登记。 */ + @Test void classificationIsIndependentOfVehicleOwnershipAndResourceMode() { + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_ownership='OWNED' WHERE dispatch_id=2"); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET status='RECEIVED',dispatch_no=NULL WHERE id=3"); + baseline.jdbc.update("UPDATE ll_fleet_owned_business_ownership SET vehicle_ownership='DISPATCHED' WHERE dispatch_id=1"); + var query=new OwnedBusinessModels.Query(1,20,null,null,null,null,null); + assertEquals(2,outsourced.list(actor(),query).total());assertEquals(2,queries.list(OwnedBusinessFixtures.actor(),query).total()); + assertEquals("OWNED",outsourced.detail(actor(),"DS-OUT-2").row().vehicle().ownership()); + var external=queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-3"); + assertNull(external.row().vehicle().vehicleId());assertEquals("DISPATCHED",external.row().vehicle().ownership()); + var saved=records.save(OwnedBusinessFixtures.actor(),"DS-OUT-3",new OwnedBusinessModels.SaveOperations("0","non-pool-external-save", + new OwnedBusinessModels.Operations(new OwnedBusinessModels.Money("USD","0"),null,null,List.of()),null)); + assertEquals("1",saved.row().recordVersion()); + var pooled=outsourced.save(actor(),"DS-OUT-2",request("0","pool-owned-save",new OwnedBusinessModels.Money("USD","25"),List.of())); + assertEquals("OWNED",pooled.row().vehicle().ownership());assertEquals("25.00",pooled.row().unitPrice()); + assertThrows(BizException.class,()->queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-3")); + } + /** 发布、询价、占位、实收未排入、取消和非池来源均不计拼车,详情与完整统计一致。 */ + @Test void onlyReceivedPoolCargoBoundToThisDispatchCounts() { + var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,null); + for(String status:List.of("DRAFT","CONFIRMED","RECEIVED","CANCELLED")) { + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET status=? WHERE id=2",status); + assertEquals(2,outsourced.list(actor(),query).total());assertEquals(2,queries.list(OwnedBusinessFixtures.actor(),query).total()); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); + } + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET status='BOUND',offer_id=NULL WHERE id=2"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET offer_id=9,receipt_json=NULL WHERE id=2"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET receipt_json='{}',trip_id=999 WHERE id=2"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET trip_id=2,dispatch_no='OTHER' WHERE id=2"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET dispatch_no='DS-OUT-2',customer_id='102' WHERE id=2"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + } + /** 用真实计划组成核对来源,展示入仓码不能代替组成;多条有效拼车登记不重复整车。 */ + @Test void actualCargoSourceIsRequiredAndMultipleBookingsNeverMultiplyStatistics() { + baseline.jdbc.update("UPDATE ll_dispatch_item SET inbound_code='DISPLAY-ONLY' WHERE dispatch_order_id=2"); + assertNotNull(outsourced.detail(actor(),"DS-OUT-2")); + baseline.jdbc.update("INSERT INTO ll_fleet_external_booking SELECT 22,trip_id,dispatch_no,customer_id,offer_id,status,incoming_no,receipt_json FROM ll_fleet_external_booking WHERE id=2"); + var query=new OwnedBusinessModels.Query(1,1,null,null,null,null,null); + assertEquals(3,outsourced.list(actor(),query).total());assertEquals(1,outsourced.list(actor(),query).records().size()); + baseline.jdbc.update("DELETE FROM ll_inbound_plan_pallet_allocation WHERE plan_id=2000"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),"DS-OUT-2")); + assertEquals(2,outsourced.list(actor(),query).total()); + assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); + } /** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */ @Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() { var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3"); @@ -210,7 +266,7 @@ class OwnedBusinessScopePersistenceTest { @Test void roundingUsesActualDistinctPalletsAndZeroDenominatorStaysNull() { String load=baseline.repository.source("DS-OUT-2").getLoadingConfirmationJson().replace("\"pallets\":[", "\"pallets\":[{\"warehousePalletId\":99,\"palletSpaces\":5,\"weightKg\":10,\"volumeCbm\":1,\"deliveryIndex\":1},"); baseline.jdbc.update("UPDATE ll_dispatch_order SET loading_confirmation_json=? WHERE id=2",load); - baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count FROM ll_dispatch_item WHERE id=211"); + baseline.jdbc.update("INSERT INTO ll_dispatch_item SELECT id+1000,dispatch_order_id,customer_id,customer_name,CONCAT(source_key,'-2'),order_no,inbound_code,'PAL-99',99,destination_type,destination_warehouse_code,destination_warehouse_name,fba_warehouse_code,delivery_address,postal_code,package_count,plan_id,plan_pallet_id FROM ll_dispatch_item WHERE id=211"); var saved=outsourced.save(actor(),"DS-OUT-2",request("0","out-half-up-rounding",new OwnedBusinessModels.Money("USD","10.01"),List.of())); assertEquals(2,saved.row().actualPalletCount());assertEquals(3,saved.cargo().size());assertEquals("5.01",saved.row().unitPrice()); var confirmation=baseline.codec.read(load,DispatchContracts.ConfirmedLoad.class); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 9cb00307e..1d9b08948 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -10,7 +10,8 @@ | 类别 | 正式入口 | 管理边界 | |---|---|---| -| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用一个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 | +| 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 | +| 后端分层检查 | `backend-check.ps1` | 默认指定模块回归,支持测试源码编译和精确测试类;必填模块,自动接入重型限流;全量触发条件见后端根指引,参数及副作用见 README | | 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README | | Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | | Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 | diff --git a/scripts/README.md b/scripts/README.md index d2307f60d..dd51f64f6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,29 @@ # 后端脚本管理与全量 HTTP 验收索引 +## 先看范围,再看证据 + +`acceptance-overview.mjs` 是离线只读总览入口,不需要凭据、不联网、不创建报告或测试数据。默认范围直接读取 Admin 实际注册表,不维护另一份模块枚举: + +```powershell +node scripts/acceptance-overview.mjs +node scripts/acceptance-overview.mjs --report reports/<明确的一次验收报告>.json +``` + +| 验收层 | 正式入口 | 结论边界 | +| --- | --- | --- | +| Admin 默认 HTTP | `admin-test-env-acceptance.mjs` | 总览的 defaultModules;optionalModules 需显式指定,不自动计入 | +| Client 全量 HTTP | `client-test-env-acceptance.mjs` | 独立执行,跳过详情不得视为详情已验证 | +| 测量计费专项 | `client-phase2-test-env-acceptance.mjs` | 不替代 Client 全量,使用本轮样本 | +| 车辆等专项 | 本索引对应的专项入口 | 只覆盖各自声明范围,不能合并宣称默认全量一次通过 | +| 离线检查 | `local-check.ps1` 包装受影响测试 | 证明源码检查,不证明部署或真实 HTTP | +| 页面与真机 | 应用验收规则 | 单独提供操作和视觉证据,HTTP 不代替 | + +总览显式读取一份报告,不遍历历史目录挑选绿灯。`FAILED` 表示有失败、fatal 或阻断缺口;`INCOMPLETE` 表示有跳过、非阻断缺口、计划模块未出现或仅准备/局部模式;`RECORDED_CHECKS_PASSED` 只表示所记录检查通过,**不是当前版本全量通过**。旧报告没有执行计划时显示 `UNKNOWN_LEGACY_REPORT`,不反推范围。报告损坏或格式不支持时退出1,不自动重跑;正常分析退出0,即使报告失败也不作为 CI 放行命令。 + +新 Admin 报告的 `executionScope` 保存申请模块、依赖展开后的计划、当轮默认范围及模式限制;这里只记录计划,不以注册即视为已执行。总览将有请求失败与无请求失败分开统计,后者包含模块退出/前置不足,不能把失败条数直接当作独立 Bug 数。缺少模块断言也仅表示缺少证据,不猜测未执行原因。 + +最新部署与已验/未验状态仍只维护在外层 `docs/测试环境单机部署与配置契约.md`;本索引不复制每日进度。总览不校验实际部署版本、不读取环境文件,执行前仍须经正式入口校验凭据和环境。直接离线测试为 `acceptance-overview.test.mjs`。 + `local-dispatch-transport-acceptance.mjs`:仅本机 LOCAL 的现有排车运输任务 HTTP 验收。显式 `node --env-file=<本机凭据文件>` 注入 `ADMIN_TEST_GATEWAY`、`ADMIN_TEST_USERNAME/PASSWORD`、`LOCAL_TRANSFER_PDA_PASSWORD`;拒绝远端网关。创建3张 TEST 仓储转运单、1张本土卡车原单、5张排车(完成/取消/重新安排),以及本轮专用调度/PDA账号、角色、车辆司机;缺失的正式排车权限仅补资源并授予专用角色,不修改既有角色。经正式网关验证原单审核、跨客户取货、资源冲突、凭证隔离、取消重排、发车完成回写、独立仓库清点和车辆司机释放。合成 PNG 只验文件链路,不代表实际运输或有效证照。默认报告 `reports/local-dispatch-transport.json`,可用 `TRANSPORT_ACCEPTANCE_OUTPUT` 指定;每步保留请求键、脱敏意图、错误码和关联标识,失败停止且保留数据。确认已知失败结果后显式 `TRANSPORT_ACCEPTANCE_RESUME=true` 复用同一报告继续;网络结果未知必须先核对,不自动清理或删除,不使用业务 SQL。已成功批次禁止重复运行;不能替代远端部署或人工截图验收。 `local-check.ps1`:Windows / PowerShell 7 本机重型检查统一入口,适用于前后端测试、类型检查、构建、安装依赖及 Maven。示例:`& ./scripts/local-check.ps1 -WorkingDirectory ../apps/admin-web -Label 'Admin typecheck' -Command @('pnpm', 'typecheck')`;`-Status` 查看占用。当前用户所有工作目录共用一个文件句柄锁,默认排队900秒、执行1200秒;超时分别退出75/124,其余透传命令退出码。Vitest workers=2,Node堆2048MB,Java堆1536MB/处理器2;直接 Maven 自动单线程/单fork,直接 Node 内置测试自动并发2。Windows Job Object 仅回收本次后代,不接管旧进程;嵌套调用拒绝,批次内部执行原始命令。无凭据、不主动联网或写业务数据;被包装命令的环境授权不变。状态和最近结果仅写 `%LOCALAPPDATA%/Chongchong/LocalChecks/`,不存命令参数或输出;不自动缓存成功结果。失败保留最近摘要,确认输入后重新排队;锁文件无需手工删除,状态不是验收报告。完整规则见 `../../docs/AI工程执行与协作约束.md` 的“本机重型检查与多对话限流”;离线行为测试为 `local-check.test.mjs`。 @@ -309,7 +333,8 @@ BOL长托盘码按现行模板省略,自动核验使用完整入仓码与本 | 订单与客户本地验收 | `phase1-acceptance.ps1` | 订单与客户本地服务/API 综合验收,会创建本地业务数据 | | 订单与客户本地验收 | `phase1-business-maturity-regression.ps1` | 订单与客户业务成熟度回归,调用本地服务并生成报告 | | 订单与客户本地验收 | `phase1-onboarding-order-e2e.ps1` | 注册、资质、审核到下单的本地 E2E,会创建客户、仓库、往来单位和订单 | -| 本地质量 | `backend-full-regression.ps1` | Gateway 路由与 Mapper 门禁后执行根 POM 全部有效模块 Maven 测试;`-DryRun` 只打印与校验 | +| 本地质量 | `backend-check.ps1` | 必填模块,默认模块回归;Compile 编译测试源码,Focused 运行精确测试类并检查本轮报告;`-DryRun` 只校验选择并打印 | +| 本地质量 | `backend-full-regression.ps1` | Gateway 路由与 Mapper 门禁后执行根 POM 全部有效模块 Maven 测试;`-DryRun` 只打印与校验;全量触发条件见后端根 AGENTS | | 本地质量 | `frontend-full-regression.ps1` | 编排 Admin/Client 本地测试、类型检查及国内/海外 PDA Node 全量测试,不访问 Gateway | | 本地质量 | `database-comment-audit.ps1` | 离线扫描数据库中文注释,不连接数据库 | | Jenkins 只读数据库结构 | `database-schema-contract.mjs` | 只查询 `information_schema` 核对区域初始化 SQL 的表和字段;不执行 SQL、不造业务数据、不执行迁移 | @@ -402,7 +427,7 @@ Gateway 路由防漏:`node --test scripts/gateway-route-contract.test.mjs`。 | 检查 | 入口 | |---|---| -| 后端 Maven 契约/单元测试 | `scripts/backend-full-regression.ps1` | +| 后端 Maven 契约/单元测试 | 日常 `scripts/backend-check.ps1`;全仓 `scripts/backend-full-regression.ps1` | | Admin/Client/国内与海外 PDA 本地全量回归 | `scripts/frontend-full-regression.ps1` | | PDA 本地页面和状态定向测试 | `../apps/pda-uniapp/tests/*.test.mjs` | | 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` | @@ -420,6 +445,33 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块 | Client 消息中心调用 | `scripts/client-notifications-contract.test.mjs` | | 验收入口与模块注册 | `scripts/full-regression-entry-contract.test.mjs`、`scripts/controller-pipeline-contract.test.mjs` | +## 后端分层检查 + +`backend-check.ps1` 是本地日常入口,PowerShell 7 执行,自动经 `local-check.ps1` 占用一个重型名额。全量触发条件唯一见 `../AGENTS.md`;不自动根据 Git diff 猜测跨服务消费者,不自动缓存或跳过测试。 + +在后端根目录执行,`-Modules` 使用逗号分隔的当前根 POM 模块名: + +```powershell +# 只查看范围,不运行 Maven。 +.\scripts\backend-check.ps1 -Modules order -DryRun +# 签名变化:保留生产及测试源码编译。 +.\scripts\backend-check.ps1 -Modules order -Mode Compile +# 开发迭代:精确类名,支持逗号分隔和全限定名。 +.\scripts\backend-check.ps1 -Modules common-core -Mode Focused -Tests ApiResponseTest +# 单服务收尾:指定模块及上游依赖测试。 +.\scripts\backend-check.ps1 -Modules order +# 跨服务收尾:显式列出受影响生产者、消费者。 +.\scripts\backend-check.ps1 -Modules 'order,firstleg' +``` + +三种模式均使用根 reactor 的 `-pl -am`,不会安装制品到本地仓库,不默认 `clean`。`Compile` 运行 `test-compile`;`Focused`/`Module` 运行 `test`。`-am` 只补上游构建依赖,HTTP/事件下游消费者须由调用者显式列入。公共模块变更也须核对下游,不能只测试公共模块自身。Focused 只用于迭代,不能替代有行为变化时的模块收尾;API、Mapper、索引等相关静态合同仍按其规则单独执行。 + +Focused 不接受通配符或方法选择,每个类必须唯一命中所选模块的 `src/test/java`。为允许上游无同名测试,Maven 设置 `surefire.failIfNoSpecifiedTests=false`,随后逐类核对本轮更新的 Surefire XML;报告缺失、陈旧、全部跳过或含失败均不能通过,避免零测试假绿。 + +无环境凭据,不调用真实 HTTP、部署或共享数据库,不启动常驻服务;Maven 可读取依赖缓存、下载缺失依赖,并写各模块 `target/`,测试中的隔离内存数据库行为不变。沿用现有 Maven 测试配置,不额外启用环境集成测试。报告仍在各模块 `target/surefire-reports/`,不创建额外报告目录、不删除旧报告。失败保留产物,修复后重新执行相同范围;报告新鲜度校验仅防止复用旧结果,不支持同一模块在多个对话并发执行测试。 + +`-MavenCommand` 可指定 Maven 可执行文件,默认 `mvn`。退出码:0 为所选范围通过(DryRun 仅预检通过),2 为模块/测试选择无效;Maven 非零退出码透传,报告核验失败返回非零。排队/执行超时沿用 `local-check.ps1` 的 75/124。入口行为测试为 `backend-check.test.mjs`。 + ## 后端全量回归 ## 入口 diff --git a/scripts/acceptance-overview.mjs b/scripts/acceptance-overview.mjs new file mode 100644 index 000000000..66db95ca3 --- /dev/null +++ b/scripts/acceptance-overview.mjs @@ -0,0 +1,72 @@ +#!/usr/bin/env node +import { readFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs' + +/** 只输出白名单统计和模块名,不转抄报告中的账号、地址、凭据或业务载荷。 */ +export function summarizeReport(report) { + if (!report || !Array.isArray(report.steps) || report.steps.length === 0 + || report.steps.some((step) => typeof step.passed !== 'boolean')) { + throw new Error('报告缺少有效 steps,不能认定验收结果') + } + const steps = report.steps + const failed = steps.filter((step) => !step.passed && !step.skipped) + const skipped = steps.filter((step) => step.skipped) + const skippedModules = Array.isArray(report.modules) ? report.modules.filter((module) => module.skipped).length : 0 + const gaps = Array.isArray(report.coverageGaps) ? report.coverageGaps : [] + const blockers = gaps.filter((gap) => gap.blocking || gap.deploymentBlocker) + const scope = report.executionScope + // 历史报告不包含计划信息,不能从断言数量或模块名字推定完整执行。 + const scopeKnown = Array.isArray(scope?.plannedModules) && scope.plannedModules.length > 0 + && Array.isArray(scope.defaultModules) && scope.defaultModules.length > 0 + const observedModules = new Set(steps.map((step) => step.module)) + const missingModules = scopeKnown ? scope.plannedModules.filter((name) => !observedModules.has(name)) : [] + const limited = Boolean(scope?.preparationOnly || scope?.returnMainChainOnly || report.fullAcceptance === false) + const failedResult = Boolean(report.fatal) || report.success === false || failed.length > 0 || blockers.length > 0 + return { + result: failedResult ? 'FAILED' : skipped.length || skippedModules || gaps.length || missingModules.length || limited ? 'INCOMPLETE' : 'RECORDED_CHECKS_PASSED', + passed: steps.filter((step) => step.passed && !step.skipped).length, + failed: failed.length, + skipped: skipped.length, + skippedModules, + blockingGaps: blockers.length, + nonBlockingGaps: gaps.length - blockers.length, + failedModules: [...new Set(failed.map((step) => step.module).filter(Boolean))], + // 无请求的失败包含前置不足和模块退出,不能计作独立接口缺陷。 + failuresWithRequest: failed.filter((step) => step.request).length, + failuresWithoutRequest: failed.filter((step) => !step.request).length, + scope: !scopeKnown ? 'UNKNOWN_LEGACY_REPORT' : limited ? 'LIMITED_SCOPE' + : scope.defaultModules.every((name) => scope.plannedModules.includes(name)) ? 'DEFAULT_ADMIN_PLAN' : 'SELECTED_MODULES', + missingPlannedModules: missingModules, + currentVersionVerified: false, + pageAcceptance: 'NOT_VERIFIED_BY_HTTP', + } +} + +/** 默认仅展示范围;显式报告只读分析,不发现历史目录、不联网、不执行验收。 */ +async function main(argv) { + if (argv.length && !(argv.length === 2 && argv[0] === '--report')) { + throw new Error('用法:node scripts/acceptance-overview.mjs [--report <明确JSON路径>]') + } + const scope = describeAcceptanceScope() + const result = { + purpose: '离线验收范围总览,不执行HTTP,不证明当前部署通过', + admin: { entry: 'scripts/admin-test-env-acceptance.mjs', ...scope }, + otherEntries: [ + { entry: 'scripts/client-test-env-acceptance.mjs', scope: 'Client全量HTTP,独立执行' }, + { entry: 'scripts/client-phase2-test-env-acceptance.mjs', scope: '测量计费专项,不替代Client全量' }, + { entry: 'scripts/fleet-test-env-acceptance.mjs', scope: '车辆专项,不计入Admin默认全量' }, + ], + boundaries: ['可选模块不等于默认执行;其他专项入口详见scripts/README.md', + '本地合同、HTTP、页面与真机验收分别报告', + '报告通过只代表该次记录;需另核对源码、配置与实际部署版本', + '环境与凭据前置仍由实际入口校验;本工具不读取环境文件'], + } + if (argv.length) result.report = summarizeReport(JSON.parse(await readFile(resolve(argv[1]), 'utf8'))) + console.log(JSON.stringify(result, null, 2)) +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + main(process.argv.slice(2)).catch((error) => { console.error(error.message); process.exitCode = 1 }) +} diff --git a/scripts/acceptance-overview.test.mjs b/scripts/acceptance-overview.test.mjs new file mode 100644 index 000000000..169646f68 --- /dev/null +++ b/scripts/acceptance-overview.test.mjs @@ -0,0 +1,46 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { summarizeReport } from './acceptance-overview.mjs' +import { describeAcceptanceScope } from './admin-test-env-acceptance.mjs' + +test('注册表返回独立副本,默认与可选不重复', () => { + const scope = describeAcceptanceScope() + assert.ok(scope.defaultModules.includes('appointment-work')) + assert.ok(scope.optionalModules.includes('phase4-overseas-return-followups')) + assert.ok(scope.optionalModules.every((name) => !scope.defaultModules.includes(name))) + scope.defaultModules.length = 0 + assert.ok(describeAcceptanceScope().defaultModules.length > 0) +}) + +test('旧报告全绿也不推断默认全量或当前版本通过', () => { + const result = summarizeReport({ success: true, steps: [{ module: 'orders', passed: true }] }) + assert.equal(result.scope, 'UNKNOWN_LEGACY_REPORT') + assert.equal(result.currentVersionVerified, false) + assert.equal(result.result, 'RECORDED_CHECKS_PASSED') +}) + +test('跳过、覆盖缺口和未执行模块不能被success掩盖', () => { + const base = { success: true, steps: [{ module: 'orders', passed: true }], + executionScope: { plannedModules: ['orders', 'finance'], defaultModules: ['orders', 'finance'] } } + assert.equal(summarizeReport(base).result, 'INCOMPLETE') + assert.deepEqual(summarizeReport(base).missingPlannedModules, ['finance']) + assert.equal(summarizeReport({ ...base, coverageGaps: [{ blocking: true }] }).result, 'FAILED') + assert.equal(summarizeReport({ steps: [{ passed: true, skipped: true }] }).result, 'INCOMPLETE') + assert.equal(summarizeReport({ steps: [{ passed: true }], modules: [{ name: 'orders', skipped: true }] }).result, 'INCOMPLETE') +}) + +test('前置退出与接口失败分开统计,输出不携带敏感载荷', () => { + const result = summarizeReport({ success: false, gateway: 'SECRET', steps: [ + { module: 'exports', passed: false, request: { path: 'SECRET' }, message: 'SECRET' }, + { module: 'dependent', passed: false, evidence: 'SECRET' }, + ] }) + assert.equal(result.failed, 2) + assert.equal(result.failuresWithRequest, 1) + assert.equal(result.failuresWithoutRequest, 1) + assert.equal(JSON.stringify(result).includes('SECRET'), false) +}) + +test('准备数据和局部模式不冒充完整验收,空报告拒绝', () => { + assert.equal(summarizeReport({ fullAcceptance: false, steps: [{ passed: true }] }).result, 'INCOMPLETE') + assert.throws(() => summarizeReport({ success: true, steps: [] }), /steps/) +}) diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index a51961e09..9b5d5ddbe 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -281,6 +281,14 @@ export function normalizeModuleOrder(modules) { } const DEFAULT_MODULES = normalizeModuleOrder(CONFIGURED_MODULES) + +/** 从执行注册表返回范围,供离线总览使用,避免维护第二套模块清单。 */ +export function describeAcceptanceScope() { + return { + defaultModules: [...DEFAULT_MODULES], + optionalModules: OPTIONAL_MODULES.filter((name) => !DEFAULT_MODULES.includes(name)), + } +} const WEIGHT_IMAGE_PATH = process.env.ADMIN_TEST_WEIGHT_IMAGE_PATH || '' const CUSTOMER_FIELDS = [ 'id', 'tenantCode', 'tenantName', 'status', 'legalName', 'legalRepresentative', @@ -1940,6 +1948,16 @@ export class AcceptanceRun { return { reportName: this.options.prepareDomesticPdaH5 ? '国内 PDA H5 待拣任务准备(非全链路验收)' : this.options.environment === 'gray' ? '灰度环境 FBA 定向闭环 HTTP API 验收' : '后台管理测试环境 HTTP API 验收', marker: this.marker, environment: this.options.environment || 'test', gateway: this.options.gateway, + executionScope: { + requestedModules: [...(this.options.requestedModules || [])], + plannedModules: [...this.options.modules], + defaultModules: [...DEFAULT_MODULES], + defaultPlanSelected: DEFAULT_MODULES.every((name) => this.options.modules.includes(name)), + preparationOnly: this.options.prepareDomesticPdaH5 === true || this.options.prepareDropshipManualCount === true, + returnMainChainOnly: this.options.returnMainChainOnly === true, + evidenceType: 'HTTP', + pageAcceptance: 'NOT_VERIFIED', + }, generatedAt: new Date().toISOString(), startedAt: this.startedAt, success: this.fatal === null && summary.failed === 0, fatal: this.fatal, ...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}), diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index c999fbc7b..ce82425ce 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,7 @@ # Admin 测试环境 HTTP 模块 +先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。 + `dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。 其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。 diff --git a/scripts/backend-check.ps1 b/scripts/backend-check.ps1 new file mode 100644 index 000000000..ec0279984 --- /dev/null +++ b/scripts/backend-check.ps1 @@ -0,0 +1,117 @@ +[CmdletBinding()] +param( + [ValidateSet('Compile', 'Focused', 'Module')][string]$Mode = 'Module', + [string]$Modules, + [string]$Tests, + [switch]$DryRun, + [string]$MavenCommand = 'mvn' +) + +$ErrorActionPreference = 'Stop' +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$pom = Join-Path $repoRoot 'pom.xml' + +# 显式选择当前聚合模块,避免漏传参数时意外触发全仓测试。 +function Read-Selection([string]$Value, [string]$Name) { + if ([string]::IsNullOrWhiteSpace($Value)) { throw "$Name is required." } + $items = @($Value.Split(',') | ForEach-Object { $_.Trim() }) + if ($items -contains '') { throw "$Name contains an empty selection." } + return @($items | Select-Object -Unique) +} + +try { + [xml]$rootPom = Get-Content -LiteralPath $pom -Raw + $selectedModules = @(Read-Selection $Modules 'Modules') + foreach ($module in $selectedModules) { + if ($module -cnotin @($rootPom.project.modules.module)) { + throw "Unknown active module: $module" + } + } + $selectedTests = @() + if ($Mode -eq 'Focused') { + $requestedTests = @(Read-Selection $Tests 'Tests') + # 只接受明确类名,不接受通配符或方法筛选;每个请求必须唯一命中所选模块。 + foreach ($requested in $requestedTests) { + if ($requested -cnotmatch '^[A-Za-z_$][A-Za-z0-9_$]*(\.[A-Za-z_$][A-Za-z0-9_$]*)*$') { + throw "Tests must contain exact Java class names: $requested" + } + $matches = @(foreach ($module in $selectedModules) { + $testRoot = Join-Path $repoRoot "$module/src/test/java" + if (-not (Test-Path -LiteralPath $testRoot)) { continue } + foreach ($file in Get-ChildItem -LiteralPath $testRoot -Recurse -File -Filter "$($requested.Split('.')[-1]).java") { + $source = Get-Content -LiteralPath $file.FullName -Raw + $package = [regex]::Match($source, '(?m)^\s*package\s+([\w.]+)\s*;').Groups[1].Value + $className = if ($package) { "$package.$($file.BaseName)" } else { $file.BaseName } + if ($requested -ceq $file.BaseName -or $requested -ceq $className) { + [pscustomobject]@{ module = $module; className = $className } + } + } + }) + if ($matches.Count -ne 1) { throw "Test must match exactly one source in selected modules: $requested (matches: $($matches.Count))" } + $selectedTests += $matches[0] + } + } elseif ($Tests) { + throw 'Tests is only supported in Focused mode.' + } +} catch { + Write-Host "INVALID CHECK: $($_.Exception.Message)" + exit 2 +} + +$mavenArgs = @('-T', '1', '-DforkCount=1', '-DreuseForks=true', '-f', $pom, '-pl', ($selectedModules -join ','), '-am') +if ($Mode -eq 'Compile') { + $mavenArgs += 'test-compile' +} else { + $mavenArgs += 'test' +} +if ($Mode -eq 'Focused') { + # 上游依赖可能没有所选类,允许该模块零命中;执行后逐类核对本轮报告。 + $mavenArgs += "-Dtest=$((@($selectedTests.className | Select-Object -Unique)) -join ',')" + $mavenArgs += '-Dsurefire.failIfNoSpecifiedTests=false' +} +Write-Host "Backend check: $Mode; selected modules: $($selectedModules -join ','); upstream dependencies included" +Write-Host 'Downstream consumers are NOT auto-selected; include affected consumers explicitly.' +Write-Host ("Command: {0} {1}" -f $MavenCommand, ($mavenArgs -join ' ')) +if ($DryRun) { + Write-Host 'DRY-RUN PASS: selection validated; no tests or compilation executed.' + exit 0 +} + +# 整批占用现有重型名额;已受保护的批次直接执行,不嵌套申请锁。 +if (-not $env:CHONGCHONG_LOCAL_CHECK_OWNER) { + $childArgs = @('pwsh', '-NoProfile', '-File', $PSCommandPath, '-Mode', $Mode, '-Modules', ($selectedModules -join ','), '-MavenCommand', $MavenCommand) + if ($Mode -eq 'Focused') { $childArgs += @('-Tests', $Tests) } + & (Join-Path $PSScriptRoot 'local-check.ps1') -WorkingDirectory $repoRoot -Label "Backend $Mode check" -Command $childArgs + exit $LASTEXITCODE +} + +$startedAt = [DateTime]::UtcNow +Push-Location $repoRoot +try { + & $MavenCommand @mavenArgs + $code = $LASTEXITCODE + if ($code -ne 0) { exit $code } + if ($Mode -eq 'Focused') { + foreach ($selection in $selectedTests) { + $reportDir = Join-Path $repoRoot "$($selection.module)/target/surefire-reports" + $executed = 0 + if (Test-Path -LiteralPath $reportDir) { + foreach ($report in Get-ChildItem -LiteralPath $reportDir -File -Filter 'TEST-*.xml') { + if ($report.LastWriteTimeUtc -lt $startedAt) { continue } + [xml]$xml = Get-Content -LiteralPath $report.FullName -Raw + $suite = $xml.testsuite + if ($suite.name -ceq $selection.className -or ([string]$suite.name).StartsWith($selection.className + '$', [StringComparison]::Ordinal)) { + if ([int]$suite.failures -gt 0 -or [int]$suite.errors -gt 0) { throw "Failed test report: $($suite.name)" } + $executed += [int]$suite.tests - [int]$suite.skipped + } + } + } + if ($executed -le 0) { throw "No executed tests in fresh reports for $($selection.className). Missing, stale or all-skipped reports cannot pass." } + Write-Host "Verified $($selection.className): $executed executed tests" + } + } +} finally { + Pop-Location +} +Write-Host "Backend $Mode check PASS (not a full regression or HTTP acceptance)" +exit 0 diff --git a/scripts/backend-check.test.mjs b/scripts/backend-check.test.mjs new file mode 100644 index 000000000..e88b469ad --- /dev/null +++ b/scripts/backend-check.test.mjs @@ -0,0 +1,82 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import test from 'node:test' +import { fileURLToPath } from 'node:url' + +const launcher = fileURLToPath(new URL('./backend-check.ps1', import.meta.url)) +const quote = value => `'${String(value).replaceAll("'", "''")}'` + +// 运行真实 PowerShell 参数解析与入口,执行分支使用内存 Maven 替身,不启动 Java。 +function run(args, mockExit) { + if (mockExit !== undefined) assert.ok(process.env.CHONGCHONG_LOCAL_CHECK_OWNER, 'Run tests through local-check.ps1') + const mock = mockExit === undefined ? '' : `function global:Mock-Maven { Write-Host ('MOCK_ARGS:' + (ConvertTo-Json -Compress -InputObject @($args))); $global:LASTEXITCODE = ${mockExit} }; ` + const parameters = [] + for (let i = 0; i < args.length; i++) { + const key = args[i].slice(1) + parameters.push(`${quote(key)}=${key === 'DryRun' ? '$true' : quote(args[++i])}`) + } + if (mockExit !== undefined) parameters.push("MavenCommand='Mock-Maven'") + const command = `$ErrorActionPreference='Stop'; ${mock}$parameters=@{${parameters.join(';')}}; & ${quote(launcher)} @parameters; exit $LASTEXITCODE` + return new Promise((resolve, reject) => { + const child = spawn('pwsh', ['-NoProfile', '-NonInteractive', '-OutputFormat', 'Text', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')]) + let output = '' + child.stdout.on('data', chunk => { output += chunk }) + child.stderr.on('data', chunk => { output += chunk }) + child.on('error', reject) + child.on('close', code => resolve({ code, output })) + }) +} + +test('module selection is mandatory and rejects inactive modules and injected Maven options', async () => { + for (const args of [[], ['-Modules', 'gateway-overseas'], ['-Modules', 'order,'], ['-Modules', '-DskipTests']]) { + const result = await run([...args, '-DryRun']) + assert.equal(result.code, 2, result.output) + assert.doesNotMatch(result.output, /DRY-RUN PASS/) + } +}) + +test('default selection runs only requested modules plus upstream dependencies', async () => { + const result = await run(['-Modules', 'order,firstleg,order', '-DryRun']) + assert.equal(result.code, 0, result.output) + assert.match(result.output, /-pl order,firstleg -am test/) + assert.doesNotMatch(result.output, /skipTests|maven.test.skip|backend-full-regression/) +}) + +test('compile retains compilation of test sources without executing tests', async () => { + const result = await run(['-Mode', 'Compile', '-Modules', 'order'], 0) + assert.equal(result.code, 0, result.output) + assert.match(result.output, /MOCK_ARGS:.*"-pl","order","-am","test-compile"/) + assert.match(result.output, /Compile check PASS/) +}) + +test('focused selection resolves exact classes and rejects missing or broad selectors', async () => { + const good = await run(['-Mode', 'Focused', '-Modules', 'common-core', '-Tests', 'ApiResponseTest,BoundedRetryPolicyTest', '-DryRun']) + assert.equal(good.code, 0, good.output) + assert.match(good.output, /-Dtest=com.baiyue.common.core.ApiResponseTest,com.baiyue.common.core.BoundedRetryPolicyTest/) + for (const name of ['MissingTest', '*Test', 'ApiResponseTest#method']) { + const result = await run(['-Mode', 'Focused', '-Modules', 'common-core', '-Tests', name, '-DryRun']) + assert.equal(result.code, 2, result.output) + } +}) + +test('test filter cannot silently apply to compile or module mode', async () => { + for (const mode of ['Compile', 'Module']) { + const result = await run(['-Mode', mode, '-Modules', 'common-core', '-Tests', 'ApiResponseTest', '-DryRun']) + assert.equal(result.code, 2, result.output) + } + const missing = await run(['-Mode', 'Focused', '-Modules', 'common-core', '-DryRun']) + assert.equal(missing.code, 2, missing.output) +}) + +test('Maven failure is propagated without claiming success', async () => { + const result = await run(['-Modules', 'common-core'], 7) + assert.equal(result.code, 7, result.output) + assert.doesNotMatch(result.output, /check PASS/) +}) + +test('Maven success without freshly executed selected tests cannot pass', async () => { + const result = await run(['-Mode', 'Focused', '-Modules', 'common-core', '-Tests', 'ApiResponseTest'], 0) + assert.notEqual(result.code, 0, result.output) + assert.match(result.output, /No executed tests in fresh reports/) + assert.doesNotMatch(result.output, /check PASS/) +}) From be79b90a4a16d04c34aa1877c98fd346d6d3a2f3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 13:45:21 +0800 Subject: [PATCH 055/113] fix(fleet): hash pooled dispatch source keys without object cast --- .../fleet/trip/FleetBookingService.java | 3 +- .../trip/FleetBookingRequestHashTest.java | 30 +++++++++++++++++++ 2 files changed, 32 insertions(+), 1 deletion(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingRequestHashTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java index 6d84bf475..82e53d986 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingService.java @@ -303,7 +303,8 @@ public class FleetBookingService { private static void requireTrip(FleetTripEntity trip,long version){if("PICKUP".equals(trip.getTripPurpose()))throw conflict("取货车次不能承接出库拼车预订");if(!"PLANNED".equals(trip.getStatus()))throw conflict("仅计划中的车次允许管理外接预订");if(trip.getRecordVersion()!=version)throw conflict("车次已被修改,请刷新");} private static void requireVersion(FleetBookingEntity row,long version){if(row.getRecordVersion()!=version)throw conflict("外接预订已被修改,请刷新");} private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception error){throw new IllegalStateException(error);}} - private String hash(Object value){try{ObjectNode node=json.valueToTree(value);node.remove("idempotencyKey");node.remove("tripRecordVersion");return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}} + /** 请求对象排除重放元数据;排车来源键的数组必须保留全部元素及顺序。 */ + private String hash(Object value){try{com.fasterxml.jackson.databind.JsonNode node=json.valueToTree(value);if(node instanceof ObjectNode object){object.remove("idempotencyKey");object.remove("tripRecordVersion");}return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(write(node).getBytes(StandardCharsets.UTF_8)));}catch(Exception error){throw new IllegalStateException(error);}} private static LocalDateTime now(){return LocalDateTime.now(ZoneOffset.UTC);} private static String trim(String value){return value==null||value.isBlank()?null:value.trim();} private static String reason(String value,String fallback){return trim(value)==null?fallback:trim(value);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingRequestHashTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingRequestHashTest.java new file mode 100644 index 000000000..6231251a1 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetBookingRequestHashTest.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.fleet.trip; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; + +class FleetBookingRequestHashTest { + final FleetBookingService service = new FleetBookingService(null, null, null, null, null, null, + new ObjectMapper().findAndRegisterModules(), null, null, null); + + private String hash(Object value) { return ReflectionTestUtils.invokeMethod(service, "hash", value); } + + @Test void incomingDispatchSourceKeyAcceptsArrayAndPreservesBookingAndCommandIdentity() { + var source = List.of("FB202610210004", "pool-arrange-command"); + var digest = assertDoesNotThrow(() -> hash(source)); + assertEquals(64, digest.length()); + assertEquals(digest, hash(source)); + assertNotEquals(digest, hash(List.of("FB202610210005", "pool-arrange-command"))); + assertNotEquals(digest, hash(List.of("FB202610210004", "another-command"))); + assertNotEquals(digest, hash(List.of("pool-arrange-command", "FB202610210004"))); + } + + @Test void bookingCommandsStillIgnoreReplayKeyAndTripVersionButRetainBookingVersion() { + var digest = hash(new FleetBookingModels.Command(8L, 4L, "command-original", "TEST")); + assertEquals(digest, hash(new FleetBookingModels.Command(9L, 4L, "command-replay", "TEST"))); + assertNotEquals(digest, hash(new FleetBookingModels.Command(8L, 5L, "command-original", "TEST"))); + } +} From 0af977bdefc1333186ead1234ea5aa824ebf83d9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 14:16:17 +0800 Subject: [PATCH 056/113] fix(fleet): compare pooled cargo identifiers without collation conflicts --- .../mapper/OwnedBusinessQueryMapper.java | 14 ++++++++------ .../OwnedBusinessScopePersistenceTest.java | 19 +++++++++++++++++++ 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java index ac511bf51..941053742 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/mapper/OwnedBusinessQueryMapper.java @@ -7,18 +7,20 @@ import java.util.List; /** 在原排车事实之上做自有运营分页,不复制或推进履约模型。 */ @Mapper public interface OwnedBusinessQueryMapper { - /** 拼车登记须已实收并绑定本车,且原实收来源仍在本车计划托盘组成内;多票只判存在。 */ + /** 拼车须实收且来源仍在本车;标识按完整字节精确比较,512字节覆盖128字符utf8mb4,避免列排序规则冲突或截断。 */ String POOLED_CARGO=""" EXISTS (SELECT 1 FROM ll_fleet_external_booking b - JOIN ll_warehouse_transfer_order incoming ON incoming.customer_id=b.customer_id AND incoming.transfer_no=b.incoming_no - WHERE b.trip_id=d.trip_id AND b.dispatch_no=d.dispatch_no AND b.offer_id IS NOT NULL + JOIN ll_warehouse_transfer_order incoming ON CAST(incoming.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512)) + AND CAST(incoming.transfer_no AS BINARY(512))=CAST(b.incoming_no AS BINARY(512)) + WHERE b.trip_id=d.trip_id AND CAST(b.dispatch_no AS BINARY(512))=CAST(d.dispatch_no AS BINARY(512)) AND b.offer_id IS NOT NULL AND b.status='BOUND' AND b.receipt_json IS NOT NULL AND EXISTS (SELECT 1 FROM ll_dispatch_item cargo JOIN ll_inbound_plan_pallet_allocation allocation ON allocation.pallet_id=cargo.plan_pallet_id - AND allocation.plan_id=cargo.plan_id AND allocation.customer_id=cargo.customer_id + AND allocation.plan_id=cargo.plan_id AND CAST(allocation.customer_id AS BINARY(512))=CAST(cargo.customer_id AS BINARY(512)) JOIN ll_inbound_plan_line line ON line.id=allocation.line_id AND line.plan_id=cargo.plan_id - WHERE cargo.dispatch_order_id=d.id AND cargo.customer_id=b.customer_id - AND line.customer_id=b.customer_id AND line.inbound_code=incoming.inbound_code + WHERE cargo.dispatch_order_id=d.id AND CAST(cargo.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512)) + AND CAST(line.customer_id AS BINARY(512))=CAST(b.customer_id AS BINARY(512)) + AND CAST(line.inbound_code AS BINARY(512))=CAST(incoming.inbound_code AS BINARY(512)) AND cargo.warehouse_pallet_id IS NOT NULL AND cargo.package_count>0)) """; String SOURCE=""" diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index d54ff5f29..5f3f64359 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -157,6 +157,25 @@ class OwnedBusinessScopePersistenceTest { assertEquals(2,outsourced.list(actor(),query).total()); assertThrows(BizException.class,()->outsourced.detail(actor(),"DS-OUT-2")); } + /** 同前缀编号末位必须参与来源匹配,128字符的多字节入仓码不能被二进制宽度截断。 */ + @Test void completeIdentifiersDeterminePoolScopeWithoutPrefixTruncation() { + String transfer="T".repeat(63)+"A",dispatch="D".repeat(63)+"A",inbound="仓".repeat(127)+"甲"; + baseline.jdbc.update("UPDATE ll_warehouse_transfer_order SET transfer_no=?,inbound_code=? WHERE transfer_no='INCOMING-2'",transfer,inbound); + baseline.jdbc.update("UPDATE ll_inbound_plan_line SET inbound_code=? WHERE plan_id=2000",inbound); + baseline.jdbc.update("UPDATE ll_dispatch_order SET dispatch_no=? WHERE id=2",dispatch); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET dispatch_no=?,incoming_no=? WHERE id=2",dispatch,transfer); + assertNotNull(outsourced.detail(actor(),dispatch)); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET incoming_no=? WHERE id=2","T".repeat(63)+"B"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),dispatch)); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET incoming_no=?,dispatch_no=? WHERE id=2",transfer,"D".repeat(63)+"B"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),dispatch)); + baseline.jdbc.update("UPDATE ll_fleet_external_booking SET dispatch_no=? WHERE id=2",dispatch); + baseline.jdbc.update("UPDATE ll_inbound_plan_line SET inbound_code=? WHERE plan_id=2000","仓".repeat(127)+"乙"); + assertNotNull(queries.detail(OwnedBusinessFixtures.actor(),dispatch)); + baseline.jdbc.update("UPDATE ll_inbound_plan_line SET inbound_code=? WHERE plan_id=2000",inbound); + assertNotNull(outsourced.detail(actor(),dispatch)); + assertEquals(3,outsourced.list(actor(),new OwnedBusinessModels.Query(1,1,null,null,null,null,null)).summary().dispatchCount()); + } /** 外配档案和临时车辆分别保持真实ID/空ID及原承运商,GET不建记录。 */ @Test void masterAndExternalReadOriginalSnapshotWithoutBackfill() { var master=outsourced.detail(actor(),"DS-OUT-2");var external=outsourced.detail(actor(),"DS-OUT-3"); From 40a03978cd477dea6f8c79764a0b26f3397e4d9c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 14:30:41 +0800 Subject: [PATCH 057/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- deploy/sql/海外区域统一增量-MySQL8.sql | 8 +- lastleg-service/AGENTS.md | 4 +- lastleg-service/docs/business-index.md | 4 +- .../DeliveryTransferController.java | 19 -- .../service/DispatchDeliveryService.java | 51 ++++- .../DispatchAggregateController.java | 12 +- .../mapper/DispatchAggregateMapper.java | 8 +- .../pda/controller/DispatchPdaController.java | 21 +- .../mapper/DispatchWholePdaReadMapper.java | 2 +- .../lastleg/dispatch/port/DispatchApi.java | 3 - .../dispatch/port/DispatchDeliveryPort.java | 3 + .../lastleg/dispatch/port/DispatchViews.java | 2 +- .../DispatchAggregateRepository.java | 4 +- .../service/DispatchAggregateService.java | 60 ++++- .../DispatchLoadingDepartureService.java | 58 +++++ .../service/DispatchPdaQueryService.java | 2 +- .../openapi/overseas-business-v1.yaml | 132 +---------- .../openapi/overseas-pda-operations-v1.yaml | 92 ++++---- ...tchDeliveryArrangementPersistenceTest.java | 28 +++ .../delivery/DispatchDeliveryServiceTest.java | 32 +++ .../PlatformDispatchOutboundContractTest.java | 29 +-- .../DispatchAggregatePersistenceTest.java | 5 +- ...spatchLoadingDeparturePersistenceTest.java | 144 ++++++++++++ .../DispatchPdaReadPersistenceTest.java | 26 +++ ...DispatchReviewWorkflowPersistenceTest.java | 126 +++++++++++ .../pda/PdaOutboundDispatchContractTest.java | 17 +- scripts/README.md | 6 +- scripts/admin-test-env-modules/README.md | 14 +- ...spatch-repair-acceptance-contract.test.mjs | 24 +- .../dispatch-repair-acceptance.mjs | 42 +--- ...dispatch-workflow-extension-acceptance.mjs | 73 ++----- ...patch-workflow-extension-contract.test.mjs | 26 +-- ...eet-capacity-pool-schema-contract.test.mjs | 5 +- ...ross-customer-acceptance-contract.test.mjs | 79 ++----- .../fleet-cross-customer-acceptance.mjs | 176 +++++---------- ...rnal-dispatch-acceptance-contract.test.mjs | 107 +++------ .../fleet-external-dispatch-acceptance.mjs | 150 +------------ ...-fee-coverage-acceptance-contract.test.mjs | 149 +++---------- .../fleet-fee-coverage-acceptance.mjs | 205 ++++-------------- ...rn-redispatch-acceptance-contract.test.mjs | 16 +- .../fleet-return-redispatch-acceptance.mjs | 35 ++- .../fleet-trip-fulfillment-contract.test.mjs | 6 +- .../fleet-trip-fulfillment.mjs | 5 +- .../inbound-measurement.mjs | 11 +- .../initialization-baseline-contract.test.mjs | 15 +- .../it-test-wait-split-bulk-contract.test.mjs | 29 +++ scripts/agents-index-contract.ps1 | 13 +- ...eet-capacity-pool-ui-fixture-gray.test.mjs | 18 +- ...leet-capacity-pool-ui-fixture-test-env.mjs | 8 +- ...capacity-pool-ui-fixture-test-env.test.mjs | 18 +- scripts/fleet-test-env-acceptance.mjs | 64 ++---- scripts/fleet-test-env-acceptance.test.mjs | 105 +++------ 53 files changed, 1004 insertions(+), 1291 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java diff --git a/AGENTS.md b/AGENTS.md index 04e997b80..16ebd4ca7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -108,14 +108,14 @@ ## 数据库访问与中文代码注释(所有服务及公共模块强制) -- 本节适用于全部后端服务和公共模块;新增、修改的方法必须执行,触及的存量代码同步整改。登记本约束不代表存量代码已全部完成迁移和注释补齐。 +- 本节适用于全部后端服务和公共模块;新增代码遵守下列规范,存量分层仅在影响本次正确性或用户明确要求时整改,不因局部修改迁移整条链路;以上为目标规范,不代表存量均已达标。 - 生产代码的关系型数据库读写统一经 MyBatis/MyBatis-Plus Mapper 执行;Controller、Service、定时任务、消费者、编号生成器和公共基础组件不得直接使用 `JdbcTemplate`、`NamedParameterJdbcTemplate`、原生 JDBC/Connection/Statement 或其他通道执行 SQL。仅改成 Repository 包装 JDBC 不算整改,SQL 必须进入 Mapper;无数据库职责的模块不得为满足本条新增数据库访问。 - 正常业务数据库访问路径统一为 `Controller → Service → Repository → Mapper`,四层职责明确,不得省略 Repository,也不得由 Controller 直接调用 Repository/Mapper,或由 Service 直接调用 Mapper。定时任务、消费者等非 HTTP 入口从 Service 进入,同样经 Repository → Mapper 完成持久化访问。 - Controller 负责请求接收、参数校验、入口权限和响应转换;Service 负责业务规则、编排及业务事务;Repository 负责领域数据访问封装、查询组合与持久化模型转换,对 Service 提供有业务含义的数据操作;Mapper 负责 SQL 执行及数据库记录映射。Repository 不承载业务状态流转,不直接用 JDBC 执行 SQL;Service 不继承 MyBatis-Plus `ServiceImpl` 等方式绕过 Repository。 - 复杂查询、批量处理、原子计数、锁定读取、Outbox 和数据库监控查询同样经 Repository → Mapper,不以 SQL 特殊或实现方便为由跳层。公共模块需要数据库能力时,按现有模块边界定义 Repository/持久化接口,由所属服务的 Repository → Mapper 实现,禁止借此跨服务读库。 - 迁移数据库操作须保留客户隔离条件、事务传播、行锁、幂等和并发语义;`LAST_INSERT_ID()` 等连接级操作必须在同一事务连接完成,并明确关闭相关读取缓存,不能仅机械搬移 SQL。Mapper 注册及扫描仍遵守下文统一规范。 - 测试代码可用 `JdbcTemplate` 创建隔离测试表、准备数据及核对结果;被测生产路径仍必须调用实际 Mapper,不得把测试造数做法带入生产。数据库基线和迁移脚本按现有部署契约管理。 -- 每个手写方法及构造器均须添加简短中文注释,通常 1—3 行说明业务用途;接口方法和 Mapper 方法优先使用中文 Javadoc。覆盖方法同样须有简短中文说明,实现中的关键规则仍须就地注释;自动生成的方法不手工逐个补注释。 +- 本次新增或修改的手写方法及构造器须有简短中文业务注释,通常 1—3 行;接口与 Mapper 方法优先用中文 Javadoc,覆盖方法同样适用,关键规则就地说明。不补齐未修改或自动生成方法的注释。 - 核心业务、复杂 SQL、状态流转、资金与库存变更、权限隔离、事务、锁、幂等、并发、重试和补偿逻辑,须在关键代码前增加适量中文注释,解释业务规则、处理原因、约束边界及失败影响;不逐行翻译代码,不添加无信息量或与实现不符的注释。 - 新增和修改的代码说明注释统一使用中文;必要的技术标识、协议名、类名可保留原文,第三方许可证和生成标记保持原样。修改方法时同步更新相关存量英文或过期说明;审查时同时核对 Controller → Service → Repository → Mapper 分层及中文方法、核心逻辑注释。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 5f9b3d6c2..31e9ca721 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -4874,7 +4874,7 @@ CREATE TABLE ll_fleet_pickup_history ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='取货不可变操作历史'; -- 自有业务仅保存原排车的归属证据和附加运营信息,不复制履约主单。 -CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership ( +CREATE TABLE ll_fleet_owned_business_ownership ( dispatch_id BIGINT NOT NULL COMMENT '原排车内部主键', resource_mode VARCHAR(16) NOT NULL COMMENT '原排车资源模式MASTER或EXTERNAL', vehicle_id BIGINT NULL COMMENT '真实车辆档案主键,临时外配为空', @@ -4892,7 +4892,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_ownership ( OR (resource_mode='EXTERNAL' AND vehicle_id IS NULL AND vehicle_record_version IS NULL AND vehicle_ownership='DISPATCHED')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车装车完成时的车辆归属证据'; -CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( +CREATE TABLE ll_fleet_owned_business_record ( id BIGINT NOT NULL COMMENT '附加运营记录内部主键', dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', record_version BIGINT NOT NULL COMMENT '附加运营记录并发版本,独立于原排车版本', @@ -4917,7 +4917,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_record ( CONSTRAINT ck_owned_business_fuel CHECK (fuel_consumption_litres_per_100km IS NULL OR fuel_consumption_litres_per_100km>=0) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车自有业务附加运营信息'; -CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command ( +CREATE TABLE ll_fleet_owned_business_command ( id BIGINT NOT NULL COMMENT '附加运营命令内部主键', dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', operator_id VARCHAR(64) NOT NULL COMMENT '原命令操作人标识', @@ -4938,7 +4938,7 @@ CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_command ( CONSTRAINT ck_owned_business_command_status CHECK (status IN ('PENDING','SUCCEEDED','REJECTED','UNKNOWN_RESULT')) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='原排车附加运营写命令及可恢复结果'; -CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_history ( +CREATE TABLE ll_fleet_owned_business_history ( id BIGINT NOT NULL COMMENT '附加运营历史内部主键', dispatch_id BIGINT NOT NULL COMMENT '所属原排车内部主键', record_version BIGINT NOT NULL COMMENT '成功写入后的附加记录版本', diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e99f710d2..e689cbace 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -57,7 +57,7 @@ | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | | 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | -| 草稿、双端装发与派送安排 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`,派送组合`delivery/controller/DeliveryTransferController.java` | +| 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | | 仓储转运、历史订单/入仓绑定、可选换标联动 | `transfer/controller/WarehouseTransferController.java` | | 一件代发接收、SKU/箱唛库存 | `dropship/controller/DropshipOrderController.java` | @@ -77,7 +77,7 @@ | 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | -| 实物装载、逐段容量与双端整车发车 | `dispatch/pda/controller/DispatchPdaController.java` → `dispatch/service/DispatchAggregateService.java`(装车)、`DispatchDepartureService.java`(发车);平台与PDA均使用本端`dispatches/{dispatchNo}/loading-complete`及`/depart`,公共DTO在`dispatch/port/`,已有单写入唯一`recordVersion`。装车前须真实PDA拣货核平,发车前须全车派送安排完整;装车及发车不重复执行扫码已完成的库存SHIP,Fleet容量和资源资格继续由真实提供者复核 | +| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | | 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index d628ca955..44aa31f9f 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -95,9 +95,9 @@ - 海外仓全流程连续覆盖尾程订单、入仓计划/PDA 入仓、换标、产品与箱唛快照、库存、转运、废弃、采购、本土卡车、本土散板、排车查询/智能预排、排车编辑/取消/发车、PDA 拣货与出库、一件代发、转派送、客户派送单、异常退回和客户回访。 - 服务端正式入口包括 `dispatch/controller/DispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`、`delivery/controller/DeliveryTransferController.java`、`delivery/controller/OutboundPdaController.java`、`delivery/controller/MerchantDeliveryController.java`、`exceptionorder/` 与 `customervisit/`;设备页面是否可用以海外 PDA 正式路由和能力开关为准,未由 PDA 承载的操作由 Admin/Client 完成。 - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 -- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车,装车完成后、实际发车前由后台安排派送,按客户与目的仓拆分派送单。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 +- 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 - 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 -- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车、安排派送和发车不重复执行已完成的库存扣减。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 +- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 - 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 - 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 - 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java index 7658c2a67..f9cd44881 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/controller/DeliveryTransferController.java @@ -37,25 +37,6 @@ public class DeliveryTransferController { return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo).groups()); } - /** 一次快照提供全部客户目的地、费用证据及既有安排的完整数组。 */ - @GetMapping("/dispatches/{dispatchNo}/delivery-arrangement") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") - public ApiResponse arrangement(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { - requirePlatform(user); - return ApiResponse.success(dispatchDelivery.preview(actor(user),dispatchNo)); - } - - /** 平台一次安排本车全部客户,身份只从认证上下文获取。 */ - @PostMapping("/dispatches/{dispatchNo}/transfer-delivery") - @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_TRANSFER_DELIVERY + "')") - @Operation(summary = "按真实客户生成出库单并按客户目的地生成派送单") - public ApiResponse transfer( - @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String dispatchNo, - @Valid @RequestBody ArrangeDelivery request) { - requirePlatform(user); - return ApiResponse.success(dispatchDelivery.arrange(actor(user),dispatchNo,request)); - } - @PostMapping("/outbound-orders/one-click-dropship") @PreAuthorize("hasAuthority('" + OverseasPermissions.OUTBOUND_CREATE + "')") @Operation(summary = "Create one-click dropship outbound and push a PDA picking task") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java index 1ea9fd038..d6d062eb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/delivery/service/DispatchDeliveryService.java @@ -126,6 +126,51 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { var input=inputs.get(group.key());var coverage=fees.coverage(group,input,actor.operatorId(),external.get(group.customerId())); fees.validate(group,input,coverage,external.get(group.customerId()));coverages.put(group.key(),coverage); } + var fact=createDocuments(context,actor,request.idempotencyKey(),businessHash,groups,inputs,external,coverages); + return execution.recordArrangement(context,actor,identity,fact,false); + } + + /** 装车父事务内自动生成全车单据;未知派送资料和未确认费用保持未登记。 */ + @Override + @Transactional(propagation=Propagation.MANDATORY) + public ArrangementFact prepareForDeparture(Context context,Actor actor,String commandKey) { + if(actor==null || blank(actor.operatorId()) || blank(commandKey) || commandKey.length()>255) + throw conflict("缺少装车操作身份或原请求标识"); + if(context==null || context.status()!=Status.ACTIVE || context.stage()!=Stage.AFTER_LOADING + || !context.pickingReconciled() || context.loadingCompletedAt()==null || blank(context.loadingCompletionHash()) + || blank(context.cargoHash()) || context.cargo()==null || context.cargo().isEmpty()) + throw conflict("请先完成真实拣货核平及装车确认"); + if(actor.mode()==ActorMode.PDA && !Objects.equals(actor.boundWarehouseId(),context.warehouseId())) + throw conflict("排车不属于当前绑定仓"); + var saved=repository.arrangement(context.dispatchId(),true); + if(saved!=null) { + if(!Objects.equals(saved.getOriginalCommandKey(),commandKey) + || !Objects.equals(saved.getCargoHash(),context.cargoHash()) + || !Objects.equals(saved.getLoadingCompletionHash(),context.loadingCompletionHash())) + throw conflict("本车已有不同装车命令生成的派送事实"); + var facts=departureFacts(context,true); + if(!facts.depart().allowed())throw conflict(facts.depart().blockReason()); + return fact(saved); + } + // 不能把缺少安排事实的孤立单据补建成另一套;全部客户在同一事务中创建。 + if(!repository.outbounds(context.dispatchId(),true).isEmpty() || !repository.deliveries(context.dispatchId(),true).isEmpty()) + throw conflict("已存在关联单据但缺少完整安排事实,请核对"); + var groups=grouping.groups(context);var external=fees.external(context); + var inputs=new HashMap(); + for(var group:groups) { + String carrier=context.resources()==null || context.resources().vehicle()==null?null:context.resources().vehicle().carrierName(); + inputs.put(group.key(),new ArrangeGroup(group.key(),group.cargoHash(),null,null,null,carrier,null,null, + null,null,null,null,null,external.get(group.customerId()).snapshotHash(),null,List.of(),List.of())); + } + String businessHash=grouping.hash(Arrays.asList(context.dispatchNo(),context.cargoHash(),context.loadingCompletionHash(), + groups.stream().map(g->inputs.get(g.key())).toList())); + return createDocuments(context,actor,commandKey,businessHash,groups,inputs,external,Map.of()); + } + + /** 自动装车与内部安排复用同一建单、来源锁及真实外接报价发布路径,不推定原费覆盖。 */ + private ArrangementFact createDocuments(Context context,Actor actor,String commandKey,String businessHash,List groups, + Map inputs,Map external, + Map coverages) { var now=Instant.now();String key="DLA"+IdWorker.getId();var outbounds=new LinkedHashMap(); for (var customer:groups.stream().map(Group::customerId).distinct().sorted().toList()) { var cargo=context.cargo().stream().filter(c->customer.equals(c.customerId())).toList(); @@ -169,9 +214,9 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { var arrangement=new DispatchDeliveryArrangement();arrangement.setId(IdWorker.getId());arrangement.setDispatchOrderId(context.dispatchId()); arrangement.setDispatchNo(context.dispatchNo());arrangement.setArrangementKey(key);arrangement.setBusinessHash(businessHash); arrangement.setCargoHash(context.cargoHash());arrangement.setLoadingCompletionHash(context.loadingCompletionHash()); - arrangement.setOriginalCommandKey(request.idempotencyKey());arrangement.setOriginalOperatorId(actor.operatorId()); + arrangement.setOriginalCommandKey(commandKey);arrangement.setOriginalOperatorId(actor.operatorId()); arrangement.setResultJson(write(result));arrangement.setCargoJson(write(context.cargo()));arrangement.setCreatedAt(local(now));repository.create(arrangement); - return execution.recordArrangement(context,actor,identity,fact(arrangement),false); + return fact(arrangement); } /** 发车先取得全部出库锁,再取得全部派送锁,当前读取不申请来源锁。 */ @@ -281,7 +326,7 @@ public class DispatchDeliveryService implements DispatchDeliveryPort { var destination=group.destination();d.setDeliveryGroupKey(grouping.documentGroupKey(destination));d.setDestinationType(destination.type()); d.setDestinationWarehouseId(destination.warehouseId());d.setDestinationWarehouseCode(destination.warehouseCode());d.setDestinationWarehouseName(destination.warehouseName()); d.setDeliveryAddress(destination.address());d.setPostalCode(destination.postalCode());d.setContactName(destination.contactName());d.setContactPhone(destination.contactPhone()); - d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(local(input.plannedDeliveryAt())); + d.setStatus("WAIT_DELIVERY");d.setVersion(0L);d.setDeliveryMethod(input.deliveryMethod());d.setPlannedDeliveryAt(input.plannedDeliveryAt()==null?null:local(input.plannedDeliveryAt())); d.setWaybillNo(input.waybillNo());d.setCarrierName(input.carrierName());d.setPickupMethod(input.pickupMethod());d.setLoadingType(input.loadingType()); d.setVehicleType(input.vehicleType());d.setVehicleSize(input.vehicleSize());d.setAppointmentAccount(input.appointmentAccount());d.setSellerName(input.sellerName());d.setRemark(input.remark()); if(context.resources()!=null){if(context.resources().vehicle()!=null)d.setVehiclePlate(context.resources().vehicle().plate()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index ad2c82703..a5909f6d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -14,7 +14,7 @@ import org.springframework.web.bind.annotation.*; @RequestMapping("/api/platform/v1/last-leg/dispatches") public class DispatchAggregateController { private final DispatchAggregateService service; - @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchDepartureService departure; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService loadingDeparture; /** 注入本域应用服务。 */ public DispatchAggregateController(DispatchAggregateService service) { this.service=service; } @@ -71,17 +71,11 @@ public class DispatchAggregateController { public ApiResponse loading(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { return ApiResponse.success(service.loading(DispatchActorFactory.platform(user),dispatchNo)); } - /** 显式确认装车,不复用最后扫码或实际发车操作。 */ + /** 显式确认装车后自动准备派送并发车,原请求恢复最终派送中结果。 */ @PostMapping("/{dispatchNo}/loading-complete") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:loading-complete')") public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { - return ApiResponse.success(service.loadingComplete(DispatchActorFactory.platform(user),dispatchNo,request)); - } - /** 与PDA共用实际发车服务和公共版本,未完整安排派送不能发车。 */ - @PostMapping("/{dispatchNo}/depart") - @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:depart')") - public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.Depart request) { - return ApiResponse.success(departure.depart(DispatchActorFactory.platform(user),dispatchNo,request)); + return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request)); } /** 本车证据先上传,主单引用随后走版本化编辑。 */ @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index 06c73e8b4..c742a4cb4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -26,6 +26,9 @@ public interface DispatchAggregateMapper { String CURRENT_FILTER = """ AND d.status=#{q.status} AND d.stage=#{q.stage} + AND d.stage='BEFORE_LOADING' AND d.review_state IN ('DRAFT','DISPATCH_REJECTED','LOADING_REJECTED') + AND d.stage='BEFORE_LOADING' AND d.review_state='DISPATCH_PENDING' + AND d.stage='BEFORE_LOADING' AND d.review_state='LOADING_PENDING' AND ( """ + REVIEW_DISPATCH + ")=#{q.reviewStatus}" + " AND (" + REVIEW_LOADING + ")=#{q.reviewStatus}"; @@ -76,14 +79,15 @@ public interface DispatchAggregateMapper { @Update(""" UPDATE ll_dispatch_order SET cargo_revision=cargo_revision+1,cargo_hash=#{hash},item_count=#{items}, pallet_count=#{pallets},package_count=#{packages},route=#{route},review_state='DRAFT', - review_basis_hash=NULL,dispatch_review_json=#{pending},loading_review_json=#{pending}, + review_basis_hash=NULL,dispatch_review_json=#{dispatchReview},loading_review_json=#{loadingReview}, record_version=record_version+1,updated_at=#{at} WHERE id=#{id} AND record_version=#{version} AND status='ACTIVE' AND stage='BEFORE_LOADING' AND picked_package_count=0 AND shipped_package_count=0 """) int changeCargo(@Param("id") long id, @Param("version") long version, @Param("hash") String hash, @Param("items") int items, @Param("pallets") int pallets, @Param("packages") int packages, - @Param("route") String route, @Param("pending") String pending, @Param("at") java.time.LocalDateTime at); + @Param("route") String route, @Param("dispatchReview") String dispatchReview, @Param("loadingReview") String loadingReview, + @Param("at") java.time.LocalDateTime at); /** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */ @Update(""" diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index defc08736..f37aa253b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -20,7 +20,7 @@ public class DispatchPdaController { @Resource private DispatchPdaQueryService queries; @Resource private DispatchPickingService picking; @Resource private DispatchAggregateService workflow; - @Resource private DispatchDepartureService departure; + @Resource private DispatchLoadingDepartureService loadingDeparture; /** 批量读取当前绑定仓的整车作业。 */ @GetMapping @@ -36,6 +36,13 @@ public class DispatchPdaController { return ApiResponse.success(queries.task(DispatchActorFactory.pda(user),dispatchNo)); } + /** 理货审核与后台共用同一轮次、版本和幂等账本,驳回须填写原因。 */ + @PostMapping("/{dispatchNo}/loading-review") + public ApiResponse loadingReview(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable String dispatchNo,@Valid @RequestBody DispatchCommands.ReviewCommand request) { + return ApiResponse.success(workflow.review(DispatchActorFactory.pda(user),dispatchNo,true,request)); + } + /** 共用公共版本和全局请求键执行一次真实库存扫码。 */ @PostMapping("/{dispatchNo}/items/{itemId}/pick") public ApiResponse pick(@AuthenticationPrincipal AuthenticatedUser user, @@ -50,18 +57,10 @@ public class DispatchPdaController { return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo)); } - /** 最后一次扫码不代替显式装车确认,双方使用同一正式命令。 */ + /** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */ @PostMapping("/{dispatchNo}/loading-complete") public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { - return ApiResponse.success(workflow.loadingComplete(DispatchActorFactory.pda(user),dispatchNo,request)); - } - - /** 与平台共用实际发车服务,不执行第二次库存扣减。 */ - @PostMapping("/{dispatchNo}/depart") - public ApiResponse depart(@AuthenticationPrincipal AuthenticatedUser user, - @PathVariable String dispatchNo, - @Valid @RequestBody DispatchCommands.Depart request) { - return ApiResponse.success(departure.depart(DispatchActorFactory.pda(user),dispatchNo,request)); + return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request)); } /** 原动作权限与绑定仓同时核对未知结果,不按单客户任务恢复。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java index 47fed9de8..43823927a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/mapper/DispatchWholePdaReadMapper.java @@ -14,7 +14,7 @@ public interface DispatchWholePdaReadMapper { /** 精确单号或有界阶段列表均先在SQL限制绑定仓。 */ @Select("") + +"AND (stage IN('LOADING','AFTER_LOADING','DELIVERING') OR (stage='BEFORE_LOADING' AND review_state='LOADING_PENDING')) ORDER BY updated_at DESC,id DESC LIMIT 201") List headers(@Param("warehouse")String warehouse,@Param("number")String number,@Param("stage")String stage); String ROOTS="#{id}"; /** 一次读取选中整车所有真实客户货物。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java index aae359617..f6d50694a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchApi.java @@ -24,9 +24,6 @@ public final class DispatchApi { public static final String LOADING_REVIEW = "/{dispatchNo}/loading-review"; public static final String RETURN_TO_REVIEW = "/{dispatchNo}/return-to-review"; public static final String LOADING_COMPLETE = "/{dispatchNo}/loading-complete"; - public static final String DEPART = "/{dispatchNo}/depart"; - public static final String DELIVERY_ARRANGEMENT = "/{dispatchNo}/delivery-arrangement"; - public static final String TRANSFER_DELIVERY = "/{dispatchNo}/transfer-delivery"; /** 端点容器不允许实例化。 */ private DispatchApi() { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java index 11fea00de..52b87092f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryPort.java @@ -7,6 +7,9 @@ import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; /** 派送域实现的整车安排和单据事实边界,禁止回写排车持久化实体。 */ public interface DispatchDeliveryPort { + /** 装车完成父事务自动准备全车真实客户单据,不要求人工安排或补造费用。 */ + ArrangementFact prepareForDeparture(Context context, Actor actor, String commandKey); + /** 一次预览返回同快照下全部客户目的地及费用证据。 */ ArrangementPreview preview(Context context); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 264aa0a35..87cd844d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -63,7 +63,7 @@ public final class DispatchViews { List orderDetails) { } public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, - Stage stage, long recordVersion, Reviews reviews, Counts counts, + Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts, boolean pickingReconciled, List items, Map actions) { } public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java index 222f415ee..c0da1705a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -46,8 +46,8 @@ public class DispatchAggregateRepository { public int removeItem(long itemId) {return items.deleteById(itemId);} /** 原子更新货量并作废旧审核依据。 */ public int changeCargo(long id, long version, String hash, int itemCount, int pallets, int packages, - String route, String pending, LocalDateTime at) { - return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, pending, at); + String route, String dispatchReview, String loadingReview, LocalDateTime at) { + return mapper.changeCargo(id, version, hash, itemCount, pallets, packages, route, dispatchReview, loadingReview, at); } /** 追加不可变业务快照。 */ public int history(long id, DispatchAggregate row, String action, String operator, String reason, String snapshot, LocalDateTime at) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index e1f647243..0bf073c27 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -400,7 +400,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 调度与理货审核按同一轮次、同一事实摘要串行执行;理货通过才进入装车。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult review(Actor actor,String number,boolean loading,DispatchCommands.ReviewCommand request) { - var row=lockPlatform(actor,number);Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW; + Action action=loading?Action.LOADING_REVIEW:Action.DISPATCH_REVIEW; + // PDA仅可处理本绑定仓理货审核,原键重试也先重新核验权限和范围。 + DispatchAggregate row; + if(actor!=null && actor.mode()==ActorMode.PDA) { + if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action)) + throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核"); + required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true); + } else row=lockPlatform(actor,number); var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); requireEditing(row,request.recordVersion()); @@ -422,6 +429,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound()))); } + if(request.decision()==Decision.REJECTED) { + // 任一级驳回均回客服处理,旧调度批准不再作为可继续理货的有效依据。 + row.setStage(Stage.BEFORE_LOADING.name());row.setReviewBasisHash(null); + if(loading)row.setDispatchReviewJson(write(new Review(ReviewStatus.NOT_SUBMITTED,null,null,null, + row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核"))); + } return saveCommand(row,actor,identity,request.reason()); } @@ -517,7 +530,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing)); } - /** 最后一次扫码只核平,显式装车完成才保存实载并进入装车后。 */ + /** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) { required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true); @@ -610,7 +623,15 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA private void invalidate(DispatchAggregate row,String reason) { row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason); - row.setDispatchReviewJson(write(invalid));row.setLoadingReviewJson(write(invalid)); + var previous=reviews(row); + row.setDispatchReviewJson(write(retainRejection(previous.dispatchReview(),invalid))); + row.setLoadingReviewJson(write(retainRejection(previous.loadingReview(),invalid))); + } + /** 客服改资料期间保留本轮驳回原因;下一轮提交统一重置,不复用批准。 */ + private static Review retainRejection(Review previous,Review invalid) { + return previous.status()==ReviewStatus.REJECTED + ?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason()) + :invalid; } /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ void verifyReviewResources(DispatchAggregate row) { @@ -726,7 +747,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA int pallets=(int)all.stream().map(i -> i.getWarehousePalletId()==null ? "PLAN:"+i.getPlanPalletId() : "PALLET:"+i.getWarehousePalletId()).distinct().count(); String route=all.stream().map(this::destinationLabel).filter(Objects::nonNull).distinct().sorted().collect(Collectors.joining(",")); var digest=all.stream().sorted(Comparator.comparing(DispatchItem::getSourceKey)).map(i -> List.of(i.getSourceKey(),i.getSourceVersion(),i.getCustomerId(),i.getPackageCount())).toList(); - if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,write(pending(row.getReviewRound())),now())!=1) throw conflict("排车版本已变化,未保存本次货物"); + invalidate(row,"排车货物已修改,请客服重新提交审核"); + if(repository.changeCargo(row.getId(),row.getRecordVersion(),hashes.source(digest),all.size(),pallets,packages,route,row.getDispatchReviewJson(),row.getLoadingReviewJson(),now())!=1) throw conflict("排车版本已变化,未保存本次货物"); var stops=all.stream().map(i -> new FleetExecutionModels.DispatchStop(destinationLabel(i),i.getDeliveryAddress(),destination(i).key())) .collect(Collectors.toMap(FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); fleet.updateDispatchCargo(row.getDispatchNo(),packages,stops); @@ -775,12 +797,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA // 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。 if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { var blocked=new EnumMap(Action.class); - for(var action:Action.values())blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作")); + for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) + blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作")); return Collections.unmodifiableMap(blocked); } var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); for(var action:Action.values()) { + if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue; boolean state=switch(action) { case CREATE -> actor.mode()==ActorMode.PLATFORM; case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; @@ -799,11 +823,20 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return Collections.unmodifiableMap(gates); } + /** 装车发运及原结果恢复都按当前装车权限与PDA绑定身份校验。 */ + void requireLoadingPermission(Actor actor) { + if(actor==null || actor.operatorId()==null || !hasPermission(actor,Action.LOADING_COMPLETE)) + throw new BizException(ResultCode.FORBIDDEN,"当前无装车完成权限"); + } + /** 主体类型和正式动作权限逐项判断,不把list权限等同写权限。 */ private static boolean hasPermission(Actor actor,Action action) { + if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)return false; var auth=SecurityContextHolder.getContext().getAuthentication(); if(auth==null || actor==null) return false; - if(actor.mode()==ActorMode.PDA) return Set.of(Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action) - && auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority())); + if(actor.mode()==ActorMode.PDA) return Set.of(Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE,Action.DEPART).contains(action) + && actor.boundWarehouseId()!=null + && auth.getAuthorities().stream().anyMatch(a->"lastleg:pda:operate".equals(a.getAuthority())) + && auth.getAuthorities().stream().anyMatch(a->"ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR".equals(a.getAuthority())); if(action==Action.PICK) return false; String permission=switch(action) { case CREATE -> PREFIX+"create"; case CANCEL -> PREFIX+"cancel"; @@ -814,6 +847,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return auth.getAuthorities().stream().anyMatch(a->permission.equals(a.getAuthority())); } + /** PDA审核读取整车冻结资源,与后台共用相同快照,查询不更改主档。 */ + Resources resources(DispatchAggregate row) { return read(row.getResourcesJson(),Resources.class); } + /** 统计按真整车去重并补齐全部零项。 */ private static DispatchViews.Statistics statistics(List groups) { var stages=new EnumMap(Stage.class); for(var stage:Stage.values()) stages.put(stage,0L); @@ -821,9 +857,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var group:groups) { if(Status.CANCELLED.name().equals(group.status())) { cancelled+=group.quantity(); continue; } active+=group.quantity(); var stage=Stage.valueOf(group.stage()); stages.merge(stage,group.quantity(),Long::sum); - if(stage==Stage.BEFORE_LOADING) { before+=group.quantity(); var review=ReviewState.valueOf(group.reviewState()); - dispatch[review==ReviewState.DRAFT?0:review==ReviewState.DISPATCH_PENDING?1:review==ReviewState.DISPATCH_REJECTED?3:2]+=group.quantity(); - loading[review==ReviewState.LOADING_PENDING?1:review==ReviewState.LOADING_REJECTED?3:review==ReviewState.APPROVED?2:0]+=group.quantity(); } + if(stage==Stage.BEFORE_LOADING) { + // 三分区表示当前待办,驳回只能计入客服处理,不能留在审核队列。 + var review=ReviewState.valueOf(group.reviewState()); + if(canSubmitReview(review.name()))before+=group.quantity(); + else if(review==ReviewState.DISPATCH_PENDING)dispatch[1]+=group.quantity(); + else if(review==ReviewState.LOADING_PENDING)loading[1]+=group.quantity(); + } } return new DispatchViews.Statistics(active+cancelled,active,cancelled,Collections.unmodifiableMap(stages),new DispatchViews.BeforeLoadingCounts(before, new DispatchViews.ReviewCounts(dispatch[0],dispatch[1],dispatch[2],dispatch[3]),new DispatchViews.ReviewCounts(loading[0],loading[1],loading[2],loading[3]))); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java new file mode 100644 index 000000000..4d83573a7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java @@ -0,0 +1,58 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; + +/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */ +@Service +public class DispatchLoadingDepartureService { + private final DispatchAggregateService workflow; + private final DispatchDeliveryPort delivery; + private final DispatchDepartureService departure; + private final DispatchCommandLedger commands; + private final DispatchCommandHasher hashes; + + /** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */ + public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery, + DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) { + this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes; + } + + /** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) { + workflow.requireLoadingPermission(actor); + var context=workflow.lockForArrangement(actor,number); + var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(), + hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request)); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + // 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。 + delivery.departureFacts(context,true); + var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(), + stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark())); + context=workflow.preview(actor,number); + String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE"); + var fact=delivery.prepareForDeparture(context,actor,arrangementKey); + var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(), + hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash()))); + var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false); + var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(), + stepKey(number,request.idempotencyKey(),"DEPART"),request.remark())); + if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded)) + throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平"); + var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(), + departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false); + commands.append(context.dispatchId(),actor,identity,result);return result; + } + + /** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */ + private String stepKey(String number,String original,String step) { + return "AUTO_"+step+"_"+hashes.source(List.of(number,original,step)); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 22ee82d6e..9b756f5e8 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -89,7 +89,7 @@ public class DispatchPdaQueryService { else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 - result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); + result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); } return List.copyOf(result); } diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 518c5080a..7cd0accd9 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1345,49 +1345,6 @@ paths: required: true schema: type: string - /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery: - post: - summary: 原子安排本车全部客户派送及费用 - description: 权限 platform:overseas-warehouse:dispatch:transfer-delivery。 - security: - - bearerAuth: [] - responses: - '200': - description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 - content: - application/json: - schema: - type: object - properties: - code: - type: string - message: - type: string - requestId: - type: string - nullable: true - traceId: - type: string - nullable: true - timestamp: - type: string - format: date-time - data: - allOf: - - $ref: '#/components/schemas/DispatchCommandResult' - nullable: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DispatchArrangeDelivery' - parameters: - - name: dispatchNo - in: path - required: true - schema: - type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/items: post: summary: 按真实来源版本向整车加货 @@ -1727,8 +1684,8 @@ paths: type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: - summary: PDA核平后确认装车完成 - description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 + summary: 确认装车完成并自动进入派送中 + description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。 security: - bearerAuth: [] responses: @@ -1878,43 +1835,6 @@ paths: required: true schema: type: string - /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement: - get: - summary: 读取全车派送分组与费用依据 - description: 权限 platform:overseas-warehouse:dispatch:list。 - security: - - bearerAuth: [] - responses: - '200': - description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 - content: - application/json: - schema: - type: object - properties: - code: - type: string - message: - type: string - requestId: - type: string - nullable: true - traceId: - type: string - nullable: true - timestamp: - type: string - format: date-time - data: - allOf: - - $ref: '#/components/schemas/DispatchArrangementPreview' - nullable: true - parameters: - - name: dispatchNo - in: path - required: true - schema: - type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}: get: summary: 恢复本人原动作不可变结果 @@ -2107,49 +2027,6 @@ paths: schema: type: string pattern: ^[1-9][0-9]*$ - /api/platform/v1/last-leg/dispatches/{dispatchNo}/depart: - post: - summary: 确认实际整车发车 - description: 权限 platform:overseas-warehouse:dispatch:depart。 - security: - - bearerAuth: [] - responses: - '200': - description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 - content: - application/json: - schema: - type: object - properties: - code: - type: string - message: - type: string - requestId: - type: string - nullable: true - traceId: - type: string - nullable: true - timestamp: - type: string - format: date-time - data: - allOf: - - $ref: '#/components/schemas/DispatchCommandResult' - nullable: true - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/DispatchDepart' - parameters: - - name: dispatchNo - in: path - required: true - schema: - type: string /api/platform/v1/last-leg/outbound-orders: get: summary: 分页查询出库单 @@ -6586,6 +6463,10 @@ components: allOf: - $ref: '#/components/schemas/DispatchReviews' nullable: true + resources: + allOf: + - $ref: '#/components/schemas/DispatchResources' + nullable: true counts: allOf: - $ref: '#/components/schemas/DispatchCounts' @@ -6610,6 +6491,7 @@ components: - stage - recordVersion - reviews + - resources - counts - pickingReconciled - items diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 5e5ce1334..3804a284f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -9,7 +9,7 @@ paths: /api/pda/v1/last-leg/dispatches: get: summary: 读取Token绑定仓五阶段整车任务 - description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。不接收客户或仓库范围参数;真实客户归属保留在items,整车公共版本及Gate由服务端返回。未指定阶段时返回待理货审核及装车、装车后、派送中的任务;车辆与司机依据读取resources。 security: - bearerAuth: [] responses: @@ -208,10 +208,53 @@ paths: required: true schema: type: string + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review: + post: + summary: 理货员审核整车,后台与PDA共用审核轮次 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。仅绑定仓且调度审核通过的本轮整车可审核;REJECTED 必填 reason 并退回客服处理,APPROVED 才进入 LOADING;公共版本拒绝双端重复审核,原键恢复仍核验本人、绑定仓和理货权限。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: - summary: 真实拣货核平后显式确认装车完成 - description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 + summary: 确认装车完成并自动进入派送中 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。 security: - bearerAuth: [] responses: @@ -251,49 +294,6 @@ paths: required: true schema: type: string - /api/pda/v1/last-leg/dispatches/{dispatchNo}/depart: - post: - summary: 完整安排派送后确认实际发车 - description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 - security: - - bearerAuth: [] - responses: - '200': - description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 - content: - application/json: - schema: - type: object - properties: - code: - type: string - message: - type: string - requestId: - type: string - nullable: true - traceId: - type: string - nullable: true - timestamp: - type: string - format: date-time - data: - allOf: - - $ref: overseas-business-v1.yaml#/components/schemas/DispatchCommandResult - nullable: true - requestBody: - required: true - content: - application/json: - schema: - $ref: overseas-business-v1.yaml#/components/schemas/DispatchDepart - parameters: - - name: dispatchNo - in: path - required: true - schema: - type: string /api/pda/v1/last-leg/outbound-tasks: get: summary: 查询Token绑定仓的一件代发出库任务 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java index 09e58ad8b..c140b46cb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryArrangementPersistenceTest.java @@ -37,6 +37,7 @@ import static org.mockito.Mockito.*; /** 真实生产Mapper及事务验证任何客户或主单CAS失败时全车原子回滚。 */ class DispatchDeliveryArrangementPersistenceTest { private JdbcTemplate db; + private org.springframework.transaction.support.TransactionTemplate tx; private DispatchDeliveryService service; private final DispatchExecutionPort execution=mock(DispatchExecutionPort.class); private final DispatchDeliveryFees fees=mock(DispatchDeliveryFees.class); @@ -49,6 +50,7 @@ class DispatchDeliveryArrangementPersistenceTest { @BeforeEach void prepare() throws Exception { var ds=new DriverManagerDataSource("jdbc:h2:mem:arrangement-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); db=new JdbcTemplate(ds); + tx=new org.springframework.transaction.support.TransactionTemplate(new DataSourceTransactionManager(ds)); for (var type:List.of(OutboundOrder.class,OutboundPalletItem.class,DeliveryOrder.class,LastlegOrder.class,LastlegOrderStatusHistory.class)) { String columns=Arrays.stream(type.getDeclaredFields()).filter(f->!Modifier.isStatic(f.getModifiers())).map(f->{ String name=f.getName().replaceAll("([A-Z])","_$1").toLowerCase(); @@ -173,6 +175,32 @@ class DispatchDeliveryArrangementPersistenceTest { assertEquals(6L,frozen.stream().filter(c->c.attributionId()==4).findFirst().orElseThrow().sourceOrigin().productQuantity()); } + /** 自动装车加入父事务,最终主单失败时全部客户单据与来源绑定均回滚。 */ + @Test void automaticPreparationSharesLoadingParentTransactionAndRollback() { + assertThrows(IllegalStateException.class,()->tx.execute(status->{ + service.prepareForDeparture(context,actor,"LOADING-FAIL"); + throw new IllegalStateException("装车最终保存失败"); + })); + for(var table:List.of("ll_outbound_order","ll_outbound_pallet_item","ll_delivery_order","ll_dispatch_delivery_arrangement","ll_loading_order_status_history")) + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM "+table,Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_order WHERE outbound_order_no IS NULL AND version=0",Integer.class)); + var fact=tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK")); + assertEquals(2,fact.result().deliveries().size()); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order WHERE planned_delivery_at IS NULL AND delivery_method IS NULL AND pickup_method IS NULL AND loading_type IS NULL",Integer.class)); + assertEquals(2,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order WHERE fee_coverage_json IS NULL",Integer.class)); + clearInvocations(fees); + assertEquals(fact,tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-OK"))); + verifyNoInteractions(fees); + } + + /** 外接费用发布失败同样回滚自动生成的全部客户单据,不能留下半车。 */ + @Test void automaticExternalFreightFailureRollsBackEveryCustomer() { + doThrow(new IllegalStateException("外接费用发布失败")).when(fees).publish(any(),argThat(g->g.customerId().equals("102")),any(),any(),any(),any(),anyString(),anyString()); + assertThrows(IllegalStateException.class,()->tx.execute(status->service.prepareForDeparture(context,actor,"LOADING-FAIL"))); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Integer.class)); + assertEquals(0,db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Integer.class)); + } + /** 按真实共享记录构造完整客户目的地请求。 */ private ArrangeDelivery request() { return new ArrangeDelivery(3L,"ARRANGE-1","cargo-hash","loading-hash",grouping.groups(context).stream().map(g-> new ArrangeGroup(g.key(),g.cargoHash(),DEPARTED,"TRUCK",null,null,"WAREHOUSE_PICKUP","PALLET",null,null,null,null,null, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java index 836b919f2..4128c31fb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/DispatchDeliveryServiceTest.java @@ -72,6 +72,38 @@ class DispatchDeliveryServiceTest { verify(fees,times(3)).publish(any(),any(),any(),any(),isNull(),any(),eq("OP-A"),anyString()); } + /** PDA装车自动生成完整客户目的地,不猜派送计划、不要求人工费用或原费覆盖。 */ + @Test void loadingAutomaticallyCreatesDocumentsWithoutInventedInputsOrFees() { + var pda=new Actor("PDA-1",ActorMode.PDA,"1001"); + var fact=service.prepareForDeparture(context,pda,"LOADING-1"); + assertEquals(2,fact.result().outbounds().size());assertEquals(3,fact.result().deliveries().size()); + assertEquals(4,fact.result().recordVersion()); + assertTrue(deliveries.stream().allMatch(d->d.getPlannedDeliveryAt()==null && d.getDeliveryMethod()==null + && d.getPickupMethod()==null && d.getLoadingType()==null)); + assertTrue(outbounds.stream().allMatch(o->o.getFeeCoverageJson()==null)); + assertTrue(calls.indexOf("SOURCE")>calls.lastIndexOf("DELIVERY")); + verify(fees,never()).validate(any(),any(),any(),any());verify(fees,never()).coverage(any(),any(),any(),any()); + var inputs=org.mockito.ArgumentCaptor.forClass(ArrangeGroup.class); + verify(fees,times(3)).publish(eq(context),any(),inputs.capture(),any(),isNull(),any(),eq("PDA-1"),anyString()); + assertTrue(inputs.getAllValues().stream().allMatch(i->i.fees().isEmpty() && i.feeCoverage().isEmpty())); + clearInvocations(fees,repository); + assertEquals(fact,service.prepareForDeparture(context,pda,"LOADING-1")); + verifyNoInteractions(fees);verify(repository,never()).create(any(OutboundOrder.class)); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->service.prepareForDeparture(context,pda,"DIFFERENT-KEY")); + } + + /** 自动生成仍拒绝越仓与孤立子单,不为异常半车补造第二套事实。 */ + @Test void automaticPreparationRejectsWrongWarehouseAndOrphanDocuments() { + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->service.prepareForDeparture(context,new Actor("PDA-1",ActorMode.PDA,"OTHER"),"LOADING-1")); + verifyNoInteractions(repository,fees); + outbounds.add(new OutboundOrder()); + assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->service.prepareForDeparture(context,actor,"LOADING-1")); + verifyNoInteractions(fees); + } + /** 同实托的两申请分别冻结原货源,出库引用逐次归属且真实客户目的地与履约订单不被替换。 */ @Test void relabelOriginsStayFrozenBehindOutboundAttributionReferences() throws Exception { var first=new SourceOrigin(10001L,201L,301L,401L,701L,"IN-701","CODE-701",null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index fb1243dd1..d5a79c7f3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -97,8 +97,6 @@ class PlatformDispatchOutboundContractTest { String.class, DispatchCommands.Update.class); Method cancel = DispatchAggregateController.class.getMethod("cancel", AuthenticatedUser.class, String.class, DispatchCommands.ReasonCommand.class); - Method transfer = DeliveryTransferController.class.getMethod("transfer", AuthenticatedUser.class, - String.class, com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.ArrangeDelivery.class); Method externalFreight = DeliveryTransferController.class.getMethod("externalFreight", AuthenticatedUser.class, String.class); assertPermission(externalFreight, OverseasPermissions.DISPATCH_LIST); Method outboundDetail = DeliveryTransferController.class.getMethod("outboundDetail", @@ -113,11 +111,12 @@ class PlatformDispatchOutboundContractTest { assertEquals(Set.of("/{dispatchNo}/cancel"), Set.of(cancelRoute.value())); assertFalse(cancel.isAnnotationPresent(org.springframework.web.bind.annotation.DeleteMapping.class)); assertTrue(cancel.getParameters()[2].isAnnotationPresent(org.springframework.web.bind.annotation.RequestBody.class)); - assertPermission(transfer, OverseasPermissions.DISPATCH_TRANSFER_DELIVERY); + assertTrue(Arrays.stream(DeliveryTransferController.class.getDeclaredMethods()) + .noneMatch(method -> Set.of("transfer", "arrangement").contains(method.getName()))); assertPermission(outboundDetail, OverseasPermissions.OUTBOUND_LIST); assertEquals(2, outboundDetail.getParameterCount()); assertPermission(outboundCancel, OverseasPermissions.OUTBOUND_CANCEL); - for (Method method : Set.of(update, cancel, transfer, externalFreight, outboundDetail, outboundCancel)) { + for (Method method : Set.of(update, cancel, externalFreight, outboundDetail, outboundCancel)) { assertTrue(Arrays.stream(method.getParameters()) .noneMatch(parameter -> parameter.isAnnotationPresent(RequestParam.class))); } @@ -202,16 +201,13 @@ class PlatformDispatchOutboundContractTest { assertFalse(schema.contains("uk_ll_outbound_no (customer_id,outbound_no)")); } - /** 当前整车请求按公共版本一次提交全部组,响应保留多客户出库及多目的地派送数组。 */ + /** 公开协议撤销人工安排入口,内部完整派送事实仍保留多客户和多目的地数组。 */ @Test void openApiPublishesOnlyTheFrozenPlatformProtocol() throws Exception { String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-business-v1.yaml"), StandardCharsets.UTF_8); String dispatchAction = slice(yaml, " /api/platform/v1/last-leg/dispatches/{dispatchNo}:", " /api/platform/v1/last-leg/dispatches/candidates:"); - String transfer = slice(yaml, - " /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:", - " /api/platform/v1/last-leg/outbound-orders:"); String outboundDetail = slice(yaml, " /api/platform/v1/last-leg/outbound-orders/{outboundNo}:", " /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:"); @@ -219,7 +215,7 @@ class PlatformDispatchOutboundContractTest { " /api/platform/v1/last-leg/outbound-orders/{outboundNo}/cancel:", " /api/platform/v1/last-leg/overseas-procurements:"); - for (String action : Set.of(dispatchAction, transfer, outboundDetail, outboundCancel)) { + for (String action : Set.of(dispatchAction, outboundDetail, outboundCancel)) { assertFalse(action.contains("name: customerId")); } assertTrue(outboundDetail.contains("pallets[].palletNo")); @@ -228,24 +224,13 @@ class PlatformDispatchOutboundContractTest { assertTrue(yaml.contains("单次 SQL 返回,不逐行补查")); assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:edit")); assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:cancel")); - assertTrue(yaml.contains("platform:overseas-warehouse:dispatch:transfer-delivery")); + assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/transfer-delivery:")); + assertFalse(yaml.contains(" /api/platform/v1/last-leg/dispatches/{dispatchNo}/delivery-arrangement:")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:list")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:create")); assertTrue(yaml.contains("platform:overseas-warehouse:outbound:cancel")); java.util.Map document = new org.yaml.snakeyaml.Yaml().load(yaml); java.util.Map schemas = (java.util.Map)((java.util.Map)document.get("components")).get("schemas"); - java.util.Map arrange = (java.util.Map)schemas.get("DispatchArrangeDelivery"); - assertNotNull(arrange); - assertTrue(((java.util.List)arrange.get("required")).containsAll(java.util.List.of("recordVersion", "idempotencyKey"))); - java.util.Map fields = (java.util.Map)arrange.get("properties"); - assertTrue(fields.keySet().containsAll(java.util.Set.of("recordVersion", "idempotencyKey", "cargoHash", "loadingCompletionHash", "groups"))); - assertFalse(fields.containsKey("customerId")); - assertFalse(fields.containsKey("expectedVersion")); - java.util.Map groups = (java.util.Map)fields.get("groups"); - assertEquals("array", groups.get("type")); - assertEquals("#/components/schemas/DispatchArrangeGroup", ((java.util.Map)groups.get("items")).get("$ref")); - java.util.Map groupFields = (java.util.Map)((java.util.Map)schemas.get("DispatchArrangeGroup")).get("properties"); - assertTrue(groupFields.keySet().containsAll(java.util.Set.of("groupKey", "groupCargoHash", "fees", "feeCoverage"))); java.util.Map resultFields = (java.util.Map)((java.util.Map)schemas.get("DispatchArrangementResult")).get("properties"); assertEquals("array", ((java.util.Map)resultFields.get("outbounds")).get("type")); assertEquals("array", ((java.util.Map)resultFields.get("deliveries")).get("type")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 14af55457..c9d17f466 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -419,12 +419,13 @@ public class DispatchAggregatePersistenceTest { assertThat(f.db.queryForList("SELECT DISTINCT customer_id FROM ll_dispatch_item ORDER BY customer_id",String.class)).containsExactly("101","202"); assertThat(f.db.queryForObject("SELECT COUNT(DISTINCT dispatch_order_id) FROM ll_dispatch_item",Long.class)).isOne(); assertThat(f.db.queryForObject("SELECT package_count FROM ll_fleet_trip_dispatch",Integer.class)).isEqualTo(8); - var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.DISPATCH_REVIEW,ReviewStatus.NOT_SUBMITTED); + var query=new DispatchViews.Query(1,20,null,null,null,"101",null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,BeforeLoadingSection.VEHICLE_INFO,null); var page=f.tx.execute(s->f.service.page(f.actor,query)); assertThat(page.total()).isOne(); assertThat(page.records()).hasSize(1); assertThat(page.records().get(0).counts().packageCount()).isEqualTo(8); assertThat(page.statistics().activeTotal()).isOne(); assertThat(page.statistics().stageCounts().get(Stage.BEFORE_LOADING)).isOne(); - assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isOne(); + assertThat(page.statistics().beforeLoadingReviewCounts().vehicleInfoTotal()).isOne(); + assertThat(page.statistics().beforeLoadingReviewCounts().dispatchReview().notSubmitted()).isZero(); long dispatch=f.db.queryForObject("SELECT id FROM ll_dispatch_order",Long.class); long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE customer_id='101'",Long.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java new file mode 100644 index 000000000..b2df98597 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -0,0 +1,144 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.*; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.AfterEach; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.*; +import java.util.concurrent.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchDeliveryModels.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */ +class DispatchLoadingDeparturePersistenceTest { + /** 每例恢复隔离身份,不影响其他服务权限测试。 */ + @AfterEach void clearSecurity(){SecurityContextHolder.clearContext();} + + /** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */ + @Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception { + authorize();var f=new Fixture(); + var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); + assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING); + assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class); + assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED"); + assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING"); + assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT"); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); + assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING"); + var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); + assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result()); + assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段"); + } + + /** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */ + @Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception { + authorize(); + for(boolean failPreparation:List.of(true,false)) { + var f=new Fixture(); + if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString()); + else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any()); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class); + var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version); + assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); + assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); + } + } + + /** PDA保持本仓权限、原键权限复验;不借自动流程绕开真实装载清单。 */ + @Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception { + authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9"); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓"); + var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化"); + verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString()); + var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING); + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限"); + } + + /** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */ + @Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception { + authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2); + try { + var a=workers.submit(()->f.tryComplete(start,f.f.actor,"admin")); + var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda")); + start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false); + } finally {workers.shutdownNow();} + assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING"); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + } + + /** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */ + private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","", + "platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));} + + /** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */ + private static final class Fixture { + final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); + final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class); + final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class); + final DispatchLoadingDepartureService service;final String number;final long version;final String manifest; + final List measurements; + /** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */ + Fixture() throws Exception { + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("create",List.of(f.source(1,"101",501,"ONT8",3),f.source(2,"202",502,"LAX9",5))))); + number=created.dispatchNo();version=f.approve(created).recordVersion(); + f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8"); + f.db.execute("ALTER TABLE ll_order ADD detained INT DEFAULT 0"); + var preview=f.tx.execute(s->f.service.loading(f.actor,number));manifest=preview.manifestHash(); + measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList(); + when(delivery.prepareForDeparture(any(),any(),anyString())).thenAnswer(call->{ + Context c=call.getArgument(0);assertThat(c.stage()).isEqualTo(Stage.AFTER_LOADING); + var os=outbounds("WAIT_OUTBOUND");var ds=deliveries(c,"WAIT_DELIVERY"); + for(int n=0;n{ + Context c=call.getArgument(0);f.db.queryForList("SELECT id FROM ll_outbound_order ORDER BY id FOR UPDATE");f.db.queryForList("SELECT id FROM ll_delivery_order ORDER BY id FOR UPDATE"); + return new DepartureFacts(number,"AUTO-ARR",c.cargoHash(),c.loadingCompletionHash(),"business-hash",outbounds("WAIT_OUTBOUND"),deliveries(c,"WAIT_DELIVERY"),c.cargo(),new Gate(true,null,null)); + }); + when(delivery.markDeparted(any(),any(),any(),anyString())).thenAnswer(call->{ + Context c=call.getArgument(0);f.db.update("UPDATE ll_outbound_order SET status='OUTBOUNDED'");f.db.update("UPDATE ll_delivery_order SET status='DELIVERING'"); + return new DepartureResult(outbounds("OUTBOUNDED"),deliveries(c,"DELIVERING"),8); + }); + var ledger=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); + var hashes=new DispatchCommandHasher(f.json); + var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json); + service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes); + } + /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ + DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");} + /** 两客户子出库保持独立业务号和箱数。 */ + List outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));} + /** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */ + List deliveries(Context c,String status){return List.of(new DeliveryReference("GA","101","客户101","OUT-A","DL-A",c.cargo().get(0).destination(),status,3,1),new DeliveryReference("GB","202","客户202","OUT-B","DL-B",c.cargo().get(1).destination(),status,5,1));} + /** 各线程独立身份和数据库事务竞争同一整车父锁。 */ + boolean tryComplete(CountDownLatch start,Actor actor,String key) throws Exception { + authorize();try {start.await(5,TimeUnit.SECONDS);f.tx.execute(s->service.complete(actor,number,request(key)));return true;} + catch(com.baiyue.common.core.exception.BizException expected){return false;} + finally {SecurityContextHolder.clearContext();} + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java index 3f3c4cf6a..c1d05ea82 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java @@ -13,6 +13,32 @@ import static org.assertj.core.api.Assertions.*; /** 统一PDA读取实际多客户主单和逐项扫码,仓限制在SQL完成。 */ class DispatchPdaReadPersistenceTest { + /** 默认PDA待办包含已通过调度的待理货整车,资源取同一冻结快照。 */ + @Test void pendingTallyReviewIsVisibleWithResourcesButDraftAndRejectedAreNot() throws Exception { + var security=org.springframework.security.core.context.SecurityContextHolder.getContext();var previous=security.getAuthentication(); + security.setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + try { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pending-read",List.of(f.source(1,"101",501,"ONT8",3))))); + f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)"); + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet(); + var queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); + var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null))); + var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1); + var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING); + assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse(); + assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机"); + f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符"))); + assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); + assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).reviews().loadingReview().reason()).isEqualTo("车辆不符"); + } finally {security.setAuthentication(previous);} + } + /** 同仓两个客户、两个目的地只返回一个整车作业,别仓不能借单号读取。 */ @Test void readsOneTruckAcrossCustomersAndRejectsOtherWarehouseNumber() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture();var a=f.source(1,"101",501,"ONT8",3);var b=f.source(2,"202",502,"LAX9",5); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java new file mode 100644 index 000000000..b1ca5fa4c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -0,0 +1,126 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.*; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; + +/** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ +class DispatchReviewWorkflowPersistenceTest { + /** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */ + @Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("queues",List.of(f.source(1,"101",501,"ONT8",3))))); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + var submitted=submit(f,created,"submit-1");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW); + var rejected=review(f,f.actor,submitted,false,"dispatch-reject",Decision.REJECTED," 车辆图片不清楚 "); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().reason()).isEqualTo("车辆图片不清楚"); + var resubmitted=submit(f,rejected,"submit-2"); + var approved=review(f,f.actor,resubmitted,false,"dispatch-approve",Decision.APPROVED,null); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW); + var tallyRejected=review(f,f.actor,approved,true,"loading-reject",Decision.REJECTED,"司机不符"); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + var detail=f.service.detail(f.actor,created.dispatchNo()); + assertThat(detail.reviews().loadingReview().reason()).isEqualTo("司机不符"); + assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED); + assertThatThrownBy(()->review(f,f.actor,tallyRejected,true,"cannot-skip-dispatch",Decision.APPROVED,null)).hasMessageContaining("状态已变化"); + var third=submit(f,tallyRejected,"submit-3");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW); + detail=f.service.detail(f.actor,created.dispatchNo()); + assertThat(detail.reviews().dispatchReview().reviewRound()).isEqualTo(3); + assertThat(detail.reviews().loadingReview().reason()).isNull(); + assertThatThrownBy(()->review(f,f.actor,third,true,"cannot-skip-again",Decision.APPROVED,null)).hasMessageContaining("状态已变化"); + } + + /** 两级拒绝都须真实原因;失败不写版本、审核历史或命令结果。 */ + @Test void blankRejectionReasonIsRejectedAtBothLevelsWithoutWrites() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("reason",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=submit(f,created,"submit"); + assertThatThrownBy(()->review(f,f.actor,submitted,false,"blank-dispatch",Decision.REJECTED," \n ")).hasMessageContaining("驳回原因不能为空"); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion()); + var approved=review(f,f.actor,submitted,false,"approve",Decision.APPROVED,null); + assertThatThrownBy(()->review(f,f.actor,approved,true,"blank-loading",Decision.REJECTED,null)).hasMessageContaining("驳回原因不能为空"); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key LIKE 'blank-%'",Long.class)).isZero(); + } + + /** 客服多次改车货保留本轮驳回原因,重新提交才开始全新审核轮次。 */ + @Test void cargoChangesKeepRejectionReasonUntilNextSubmission() throws Exception { + for(boolean loading:List.of(false,true)) { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("retain",List.of(f.source(1,"101",501,"ONT8",3))))); + var current=submit(f,created,"submit"); + if(loading)current=review(f,f.actor,current,false,"approve",Decision.APPROVED,null); + var rejected=review(f,f.actor,current,loading,"reject",Decision.REJECTED,"请重新核对货物"); + var added=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"add",List.of(f.source(2,"101",502,"ONT8",2)),List.of()))); + var addedAgain=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(added.recordVersion(),"add-again",List.of(f.source(3,"101",503,"ONT8",1)),List.of()))); + long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class); + var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓"))); + var resources=f.configured("updated-resources",List.of()); + var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正"))); + var detail=f.service.detail(f.actor,created.dispatchNo()); + var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview(); + assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT); + assertThat(retained.status()).isEqualTo(ReviewStatus.REJECTED);assertThat(retained.reason()).isEqualTo("请重新核对货物"); + assertThat(retained.basisHash()).isNull();assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + submit(f,updated,"resubmit");detail=f.service.detail(f.actor,created.dispatchNo()); + assertThat(detail.reviews().dispatchReview().status()).isEqualTo(ReviewStatus.PENDING); + assertThat(detail.reviews().dispatchReview().reason()).isNull();assertThat(detail.reviews().loadingReview().reason()).isNull(); + } + } + + /** PDA理货共享后台版本;成功恢复不重复写,越仓、失权和替换身份均拒绝。 */ + @Test void pdaReviewSharesVersionAndOriginalRecoveryWithAdmin() throws Exception { + var security=SecurityContextHolder.getContext();var previous=security.getAuthentication(); + try { + security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + var f=new DispatchAggregatePersistenceTest.Fixture(); + var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pda-review",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=submit(f,created,"submit"); + assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isFalse(); + assertThatThrownBy(()->review(f,pda,submitted,false,"pda-dispatch",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓"); + var approved=review(f,f.actor,submitted,false,"dispatch",Decision.APPROVED,null); + assertThat(f.service.actions(pda,f.repository.find(created.dispatchNo())).get(Action.LOADING_REVIEW).allowed()).isTrue(); + assertThatThrownBy(()->review(f,new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),approved,true,"wrong-warehouse",Decision.APPROVED,null)).hasMessageContaining("当前绑定仓"); + var loaded=review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null); + assertThat(loaded.stage()).isEqualTo(Stage.LOADING); + assertThatThrownBy(()->review(f,f.actor,approved,true,"admin-stale",Decision.APPROVED,null)).hasMessageContaining("版本已变化"); + assertThat(review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null).replayed()).isTrue(); + assertThat(f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve")).replayed()).isTrue(); + assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("picker",ActorMode.PDA,"99"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("当前绑定仓"); + assertThatThrownBy(()->f.tx.execute(s->f.service.command(new DispatchContracts.Actor("other",ActorMode.PDA,"9"),created.dispatchNo(),"pda-approve"))).hasMessageContaining("无权"); + security.setAuthentication(new TestingAuthenticationToken("picker","","lastleg:pda:operate")); + assertThatThrownBy(()->review(f,pda,approved,true,"pda-approve",Decision.APPROVED,null)).hasMessageContaining("仅当前绑定仓"); + assertThatThrownBy(()->f.tx.execute(s->f.service.command(pda,created.dispatchNo(),"pda-approve"))).hasMessageContaining("原动作权限"); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(loaded.recordVersion()); + } finally {security.setAuthentication(previous);} + } + + /** 使用服务真实SQL分页和全量统计验证当前队列,不以返回动作猜测队列。 */ + private static void assertQueue(DispatchAggregatePersistenceTest.Fixture f,String number,BeforeLoadingSection expected) { + for(var section:BeforeLoadingSection.values()) { + var page=f.tx.execute(s->f.service.page(f.actor,new DispatchViews.Query(1,20,number,null,null,null,null,StatusFilter.ACTIVE,Stage.BEFORE_LOADING,section,null))); + assertThat(page.total()).isEqualTo(section==expected?1:0); + var counts=page.statistics().beforeLoadingReviewCounts(); + assertThat(counts.vehicleInfoTotal()).isEqualTo(expected==BeforeLoadingSection.VEHICLE_INFO?1:0); + assertThat(counts.dispatchReview().pending()).isEqualTo(expected==BeforeLoadingSection.DISPATCH_REVIEW?1:0); + assertThat(counts.loadingReview().pending()).isEqualTo(expected==BeforeLoadingSection.LOADING_REVIEW?1:0); + } + } + + /** 每次提交以真实返回版本开始下一轮,不人工造审核状态。 */ + private static DispatchContracts.CommandResult submit(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.CommandResult prior,String key) { + return f.tx.execute(s->f.service.submitReview(f.actor,prior.dispatchNo(),new DispatchCommands.SubmitReview(prior.recordVersion(),key,null))); + } + + /** 通过同一审核服务分别调用两端,版本和幂等键始终由请求明确提供。 */ + private static DispatchContracts.CommandResult review(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.Actor actor,DispatchContracts.CommandResult prior,boolean loading,String key,Decision decision,String reason) { + return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason))); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java index 4e01a632f..c324fcb87 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java @@ -72,6 +72,18 @@ class PdaOutboundDispatchContractTest { verify(outboundService).pdaTasks("900", null); } + /** PDA只暴露理货审核且身份固定取绑定仓,不提供调度审核入口。 */ + @Test + void loadingReviewUsesTheSharedServiceWithBoundPdaIdentity() { + var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class); + var controller=new DispatchPdaController();ReflectionTestUtils.setField(controller,"workflow",workflow); + var user=pda(Set.of("lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"),900L); + var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对"); + controller.loadingReview(user,"PC202610210001",request); + verify(workflow).review(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),"PC202610210001",true,request); + assertFalse(Arrays.stream(DispatchPdaController.class.getDeclaredMethods()).anyMatch(method->method.getName().equals("dispatchReview"))); + } + @Test void missingRolePermissionOrWarehouseBindingIsRejected() { DispatchPdaController controller = new DispatchPdaController(); @@ -88,7 +100,10 @@ class PdaOutboundDispatchContractTest { String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-pda-operations-v1.yaml"), StandardCharsets.UTF_8); assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches:")); - assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:")); + assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-review:")); + assertTrue(yaml.contains("overseas-business-v1.yaml#/components/schemas/DispatchReviewCommand")); + assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:")); + assertTrue(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:")); assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:")); assertTrue(yaml.contains("/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:")); assertFalse(yaml.contains("name: customerId")); diff --git a/scripts/README.md b/scripts/README.md index dd51f64f6..829609a8a 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -117,7 +117,7 @@ F33 分阶段助手的组合换标顺序为 `unit → unit-carton → bind`: F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_CANCELLED` 只运行显式指定的一种顺序,省略仍串行执行三种;报告和成功范围仅覆盖所列模式。`--verify-delivery-event-recovery` 必须同时显式指定一种顺序,且环境为TEST;仅在该模式第一张真实FBA派送签收前开启定向ACK丢失,不能使用普通海外转运的签收替代国内回写。故障窗口会重建同镜像Lastleg并临时调整事件目标和退避配置,禁止与其他部署或共享环境写入并行;原事件DEAD恢复后必须验证原镜像/配置/网络身份、健康及代理退出。入口事先留存精确故障scope,finally兜底恢复,缺恢复证据或恢复校验失败一律失败。完整库存取证接入既有只读器,报告保留于该批次 `evidence/`,不能把某一模式或事件通过解读为全部反向事件完成。 -`fleet-cross-customer-test-env.mjs` 是最小两客户同一排车主单的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,真实来源一次Create到同一主单,自动唯一Fleet记录、配置资源并完成两审;一客户未核平时验证整车装车/发车拒绝及完整库存快照不变,全量实拣后按真实测量显式装车、平台逐客户目的地安排,再统一发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,缺实测不补计划值,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 +`fleet-cross-customer-test-env.mjs` 是最小两客户同一排车主单的串行 TEST 专项入口,默认 `--plan` 零网络。显式运行参数为 `--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外收货仓ID> --destination-warehouse-id `,凭据仅从显式 `node --env-file="" --env-file=""` 与 `FBA_CORE_JENKINS_AGENT` 提供。通过普通转运来货、PDA实收和客户确认创建两个新客户各一托一箱,真实来源一次Create到同一主单,自动唯一Fleet记录、配置资源并完成两审;一客户未核平时验证整车装车/发车拒绝及完整库存快照不变,全量实拣后按真实测量确认装车,自动生成客户目的地单据并发车、原键重放、逐客户签收,最后正式结案并分别释放车辆/司机,核对结案前后库存零变化。完整库存由只读取证器获取,缺实测不补计划值,不写业务SQL,不部署或改共享配置。报告位于 `reports/fleet-cross-<新批次>/`,每次请求发送前记录意图;失败保留原现场,不自动重跑或续跑,已存在批次拒绝覆盖。finally停用本轮PDA并退出独立会话;业务样本保留。前置拒绝不代表事务中途回滚,后者另验。对应入口保护合同为 `fleet-cross-customer-test-env.test.mjs`;内部生成与验收函数见模块README。 头程订单提交使用首次原始详情的 `expectedVersion` 与原 `Idempotency-Key`。单票验收的 `SUBMIT_INTENT` 必须先持久化 `submitCommand` 再发送,显式续跑不得使用当前版本替换原命令,缺少原命令的提交意图及后续检查点停止并等待人工核对。源 Excel 造数同样在提交前保存订单条目的 `submitCommand`;中断或失败条目保留原检查点且不自动重跑,不重建订单或换版本继续。 @@ -125,7 +125,7 @@ F40 专项可用 `--signature-mode ROOT_FIRST|CHILD_FIRST|ROOT_SIGNED_REMAINDER_ `fleet-trip-rollback-test-env.mjs` 是跨客户同一原生主单发车中途回滚的串行TEST入口,默认`--plan`零网络;实际参数沿用`--execute --environment TEST --run-id <新批次> --warehouse-id <实际海外仓ID> --destination-warehouse-id `,凭据只通过显式TEST/Jenkins环境文件及`FBA_CORE_JENKINS_AGENT`提供。复用正式两客户一托一箱普通转仓来货/实收、同主单两审/实拣/实测装车及平台安排链;按Java客户字符串顺序,通过原业务号只读解析当前主单、客户子单及真实转仓来源,第二客户转仓行建立65秒SELECT FOR UPDATE租约,等待事务自身的50秒会话锁超时使原整车发车失败。不修改业务行、Schema、共享参数或镜像;GLOBAL超时或HTTP超时不能代替会话与事务证据。等待图须精确对应原发车事务、目标转仓主键及实际UPDATE,同时独立READ UNCOMMITTED观察已写的第一客户子单、来源计数及历史;此时主单仍AFTER_LOADING、Fleet为PLANNED、尚无DEPART命令结果,不能沿用旧DEPARTED/重放表断言。最终新已提交读按实际Schema全字段比较相关主/子单、来源历史、占用、命令账本及已提交扫码/SHIP事实,脏读仅作中途证明。租约自动ROLLBACK,核对原事务和等待均已消失,再用同一原发车键/载荷完成正式发车、重放、逐客户签收和资源释放。报告沿`reports/fleet-cross-<新批次>/`,额外`rollback.json`、源码SHA与镜像身份保留;权限、字段、会话超时或任一证据缺失明确阻断,失败保留原现场,不自动重造或续跑,finally仅清理自有PDA/会话。执行需要对应TEST数据库取证、持锁及真实HTTP的明确授权,共享窗口禁止并行发布或操作相同库存。对应`fleet-trip-rollback-test-env.test.mjs`验证证据反例和范围;通用跨客户入口不默认故障注入。 -`fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证原费用全覆盖、取消重排后的部分覆盖、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 +`fleet-test-env-acceptance.mjs` 是显式 TEST 车辆专项入口,默认 `--plan` 仅输出计划;使用显式外部 TEST 与 Jenkins 配置后,以 `--execute --environment TEST --run-id <新批次>` 串行创建本轮头程/海外来源并验证两组独立货物自动生成派送单据、外接承运占位、PDA真实收货、外接排车计费、逐客户PDA拣货、整车发车、逐目的地签收及资源释放。报告在 `reports/fleet-/`;目录和锁独占,失败保留原请求/资源,不自动续跑或重造。会通过正式HTTP写入测试资源和取消未执行单据;既有收货取证使用Jenkins只读查询,不执行业务SQL写入、部署或共享配置修改。Finance费用池消费事实与生产者ACK分开记录;UI及未实现车辆产品范围另验。对应离线入口合同为 `fleet-test-env-acceptance.test.mjs`,内部助手详见模块README。 整车回滚专项的完整快照覆盖当前23表,包括本批真实实托的`ll_physical_pallet_guard`永久串行行;按实际Schema全列及COUNT取证,中途和回滚后均核对原主键与首次创建时间不变,不把删除重建串行点视作成功回滚。此取证范围仅限该回滚专项,不扩展通用车辆入口的故障注入。 @@ -295,7 +295,7 @@ node --env-file="<仓库外灰度验收环境文件>" scripts/admin-test-env-acc 排车扩展入口还支持独立新批次 `--scenario reviews`,真实覆盖两级驳回重提、零扫码退回改货改车及旧版本/幂等冲突,再接完整履约。`--scenario review-terminal --resume-report <本扩展原失败报告> --resume-report-sha256 <原SHA>` 只接收已完成双签收、请求全部明确RESPONSE、审核包VERIFIED但顺序只读快照跨过异步完成版本的精确停点;新报告先只读核齐原单DELIVERED及全量库存,再仅结案释放本批资源,不重拣、重发、重签或覆盖旧失败。其他未知写和原人工单不接受此模式。 -`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、独立费用/单据/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、费用、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。 +`dispatch-workflow-extensions-test-env.mjs` 是排车扩展 TEST HTTP 串行入口,默认 `--plan` 零网络。显式执行使用 `--execute --environment TEST --scenario dual-destination --run-id <新批次> --warehouse-id <实际海外仓> --destination-warehouse-id <首个FBA仓> --pda-env-file <现有海外PDA私有配置> --synthetic-pallet-spaces 1`;TEST及只读取证凭据由调用者在脚本前显式 `node --env-file=` 加载。正式目录找到第二个同国有效FBA仓后才造新客户和货物,验证双目的仓、自动单据、实际费用归属/库存与完整履约。现有PDA只开新会话、不创建停用账号;托位、附件标记TEST合成,重量体积必须来自正式测量证据,不代表现场或硬件。原人工单不参与新造数。报告 `reports/fleet-cross-/` 与独占intent存在即拒绝重入;未知写保留原键载荷并停写。只复用受保护库存读者,不新增SQL、部署、改权限或清理数据;退出自有会话、保留业务样本。未就绪矩阵保持未执行;直接合同为 `admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs`。 `--scenario competition` 同样仅使用本轮新样本,验证占用中的车辆/司机拒绝第二张空排车、装车后尚未安排派送的发车拒绝,以及已成功装车原键的两个 Admin/PDA 请求并发重放;原操作员恢复唯一结果,另一操作员被拒绝,完整库存与主单事实不变。该场景不宣称首次有效命令竞争或完整权限矩阵通过。第二次签收确定成功后增加最多30秒只读完结等待,再进入既有跨接口一致性快照;不重放签收写入,也不修改既有失败报告或共享验证器。 diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index ce82425ce..853e76efb 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,8 +1,12 @@ # Admin 测试环境 HTTP 模块 +`fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。 + +固定托盘造数中,`inbound-measurement.mjs` 缺少 `TP-001` 或 `YW-TP-002` 时记录阻断并停止,不自动选择其他托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 + 先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。 -`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;派送费用按真实group客户使用不同合成金额,完整正常链后核对逐客户目的仓、费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。 +`dispatch-workflow-extension-acceptance.mjs` 由 `../dispatch-workflow-extensions-test-env.mjs` 显式调用,不是独立CLI。双客户双目的仓扩展先从正式目录核验两FBA仓,复用本轮独立来货和PDA实收;装车完成自动生成派送并发车,完整正常链后核对逐客户目的仓、实际费用、单据和交叉读取拒绝。`fleet-cross-customer-fixture.mjs` 可接收两个明确 `destinationWarehouseIds`,在任何造数写前核验全部目的仓;原单目的仓调用不变。所有写经过既有逐请求持久化和未知结果停写边界,不修改共享runner;中途故障注入无工具时明确NOT_TESTED。直接合同 `dispatch-workflow-extension-contract.test.mjs` 不证明环境通过。 其 `competition` 扩展只并发两个已经成功装车命令的跨端原键重放,并回查完整库存、主单、货物和车次;不是首次执行竞争。占用资源与未安排派送发车分别串行验证拒绝后快照不变。签收成功后的自动完结只做有界只读等待,保持真实写回执,等待失败阻断下一请求而不伪报已知写为未知结果。 @@ -20,7 +24,7 @@ `dispatch-repair-acceptance.mjs` 是本批排车修复的内部增量验收助手,不是独立 CLI。`verifyDispatchRepair` 默认只返回计划,显式执行要求 TEST、Lastleg/Admin 健康及 Schema 就绪、请求前后持久化检查点。`context.producerEvidence` 指定本轮生产回执 JSON 的路径及 SHA-256;文件须含 `environment=TEST`、`marker`、已部署 Lastleg 的 `sourceCommit` 和 `requests`。请求条目保留实际 `method/path/body/actor`、`status=RESPONDED`、`http/code/requestId/traceId/result`,不得保存登录凭据或 Token。助手从正式创建响应及父来源回执提取业务号、客户和仓库,形成资源白名单;所有 fixture 关联身份在首个请求前核对,不能仅声明“本轮新建”。缺样本标记 NOT_RUN,不借旧业务样本。 -该助手核对取消出库/取消排车/释放重排、实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。只对已授权的新排车调用 DELETE 取消状态,不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。 +该助手将已撤销的装车后取消/人工重排场景标为 NOT_APPLICABLE,仍核对实际出库门禁、换标全托、混托两种展示来源、拆合托后重核、原箱回仓及只读权限。两种混托须使用独立排车/托盘/预订,普通与外接来源的箱数由生产实收回执确定,并回查实物仓位组成及报价数量/单价/金额。原箱限本轮单箱出库,按原始 SKU、客户、仓分页精确筛选库存及流水,以排车号匹配 OUTBOUND;先取完成前 S、首次完成后 R,再同键重放取 R′,核对本轮唯一 SKU 的完成前零库存基线及首次总量、可用展示数量、箱数恢复、receiptNo 的 EXCEPTION_RETURN_IN 精确集合、目标托盘库位及重放零增量。HTTP availableQuantity 是受实箱可用量限制的展示值,不冒称裸余额证明。不运行移托 DELETE 负向分支,不物理删除、SQL 造数或清理。未知结果保存原命令并停写;证据明确标为合成测试资料,不冒充真实外部预约/POD。保留 `sourceEventsVerified=false`,来源事件、人工页面及 PDA 真机仍单列。相邻 `dispatch-repair-acceptance-contract.test.mjs` 只做离线验证,未实际执行不得表述为环境通过。 - 人工状态矩阵的 `FEE_CONFIRMING` 使用独立新订单,在测量提交成功后暂停审核,经正式 `fees/calculate` 生成费用;回查同一费用版本、入库客户身份、测量仍待审核及入仓码精确筛选结果。该状态是必验项,不再列入不可造数缺口;不发送费用、不推进该样本、不借历史数据。入口仍为 `--modules manual-state-matrix`,对应 `order-inbound-status-fixtures-contract.test.mjs` 和 `manual-state-matrix-contract.test.mjs`。 - `WAIT_PRICING` 另建保留原始 `APPROVED` 的订单,先形成未发布的 `FEE_CONFIRMING`,再通过客户实时渠道目录与报价选择同仓、同目的仓、同税制的不同渠道,以正式申报更新触发费用失效;回查 `WAIT_PRICING/UNPRICED`、新渠道及精确筛选。缺少可计价替换渠道、客户漂移或状态未收敛均失败;不写业务SQL,退出专用目录查询会话。该状态同样为必验项,原先借历史 `WAIT_SPLIT` 样本的兜底已移除。 @@ -44,7 +48,7 @@ `fleet-cross-customer-fixture.mjs` 导出 `createFleetCrossCustomerFixture`,由上述专用 TEST 入口显式调用。默认只返回准备契约;执行时重新核对指定实际收货仓和FBA目的仓,通过正式客户资质、普通转运来货、PDA一托一箱测量/绑定/实收及客户确认生成两套独立本轮货物,再以真实来源一次 Create 建立同一原生排车主单、自动唯一 Fleet 资源记录并完成两级审核,停在 LOADING、零扫码、未生成出库和派送单。每条明细保留其真实客户、来货码、物理托盘和库位,主单不挂客户。客户上下文独立且不覆盖外层客户环境,退出本函数创建的客户会话,PDA启停归外层入口;无外接预订循环依赖、业务SQL、部署或自动恢复。`fleet-cross-customer-fixture-contract.test.mjs` 只证明生成编排及拒绝边界,真实运行另行留证。 -`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量显式装车、平台逐客户目的地安排,再以当前主单版本统一发车、原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 +`fleet-cross-customer-acceptance.mjs` 是跨客户同一原生排车主单的内部后半链验收函数,不是独立入口。默认只返回 fixture 契约、零网络;显式 `execute:true` 需要同批次两个独立客户、同仓各一托一箱已实收、同一主单已通过两级审核且未拣货,以及调用方提供的完整库存只读取证器。按各明细实际客户扫码,验证一个客户未核平时整车装车/发车拒绝及全部快照不变;全部实拣核平后按实际测量确认装车,在同一次命令自动生成客户目的地单据并发车;按详情 documents 核对真实单据,原键重放和分别签收。实托测量缺失明确阻断,不以计费/计划常量替代。前置门禁与事务中途回滚分别记录;默认后者为 `NOT_TESTED`,只有显式注入`verifyDepartureRollback`且返回精确本轮验证、完整一致性快照恢复相同后才记VERIFIED。专用`../fleet-trip-rollback-test-env.mjs`负责租约/脏读观察与持久化,普通入口不注入故障;钩子异常、未验证或快照漂移必须停止在正常发车前。失败保留当前样本与原请求,不自动续跑、补造或清理。对应合同 `fleet-cross-customer-acceptance-contract.test.mjs` 仅证明编排与反例断言,不能代替真实运行。临时账号生命周期、最小来货生成及报告保存由外层显式编排负责。 `TERMINAL_READONLY` 用于全部业务写入和结案助手均完成、仅专项入口汇总状态断言错误的已复核报告。结案助手成功状态为 `CARGO_COMPLETED_AND_RESOURCES_RELEASED`。该检查点登录后仅 GET 原车次、每张排车/派送、资源占用测试计划与精确财务来源版本,不重放任何业务写入,不覆盖原失败报告;真实状态、数量、签收或费用不符仍失败。它不补充生产者 ACK 或页面验收证据。 @@ -60,7 +64,7 @@ 车辆专项入口为 `../fleet-test-env-acceptance.mjs`,先使用原 `phase4-overseas-dispatch` 依赖链创建本轮客户、头程费用和海外库存,再执行专用车辆编排;不改变已通过的 F40 默认调用顺序。默认 `--plan` 无网络,实际运行须显式 `--execute --environment TEST --run-id <新批次>`。仅串行运行,会创建当前批次资源及执行正式未执行单据取消重排,不执行部署、配置修改或业务 SQL 写入;原收货取证沿用现有只读读取器。失败保留原样本、请求与报告,不自动重造或续跑。 -四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 用独立排车验证全覆盖及多原入仓的部分覆盖,核对原费用版本、目的地、快照及重放不重复收费;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。 +四个内部助手及各自 `*-contract.test.mjs`:`fleet-booking-reservation-acceptance.mjs` 验证询价/确认/超卖/取消的逐段占位与重放;`fleet-fee-coverage-acceptance.mjs` 复用两组独立排车样本,核对装车完成自动生成单据的客户、目的地、箱数和实际费用;已撤销的人工费用覆盖选择不计作通过;`fleet-external-receipt-acceptance.mjs` 通过正式来货与 PDA 测量、生成托盘、扫码绑定、照片和完成接口入库,按客户/仓/来货码查询余额与流水,验证重放不再入库、运营核量改价和预留替换;`fleet-external-dispatch-acceptance.mjs` 将该实收货物预排并绑定原车次,重核容量并逐客户审核,验证约定运费来源与重放。后两者会产生新外接货,必须走专用编排,不能直接加入只拣一张排车的 F40 默认流程。专项入口继续逐单 PDA 拣货、整车发车、逐目的地签收及资源释放;报告将 Finance 消费入池与生产者 ACK 分别记录,消费行存在不能冒充生产者已收到 ACK。以上助手不能独立当作全链入口,离线合同通过不代表真实 HTTP 或页面验收通过。 头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。 @@ -490,6 +494,6 @@ Client 全量为单文件,新增的客户账单、海外仓自助、跨境采 - 必须在 `baiyue-firstleg` 根运行批量合同:`auth-login-payload` 等合同按 `scripts/...` 相对路径读取,从工作区根运行会产生 `ENOENT` 假失败。 - `appointment-work-test-env`、`agents-index-registry`、`ci-maven-resource`、`deploy-built-service-rollback` 会 `spawn` 子进程;受限沙箱下 `EPERM` 属于运行环境限制,不是合同失败。 - `database-schema-diff`、`prod-release-*`、`fleet-*-test-env` 等需要数据库或测试环境的合同不进入本地批量扫描;按各自 README 单独授权执行。 -- 当前 134 个模块合同中有 4 个 `fleet-*` 合同失败(`fleet-cross-customer-fixture`、`fleet-external-dispatch-acceptance`、`fleet-fee-coverage-acceptance`、`fleet-return-redispatch-acceptance`),失败点在 fleet 业务模拟断言,与脚本新增模块和本次 `phase4-overseas-dispatch`/`finance-downloads` 修改无关,另行处理。 +- 合同数量与失败清单以同一源码快照的实际报告为准,不在模块索引维护固定失败数量;当前已验/未验范围统一查外层部署契约,历史报告不覆盖最新源码。 采购费用不再使用通用费用草稿、审核和发布链。`phase4-overseas-finance-cross-region.mjs` 对采购改为验证通用创建被拒绝,其他四类费用继续验证既有投递链;采购成功来源由完整样品流程核对实扣与来源关联。 diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs index d3ab96a18..77649523a 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance-contract.test.mjs @@ -63,7 +63,7 @@ function producerReport() { event('GET', '/api/platform/v1/last-leg/return-inventory/cartons/BOX', undefined, { cartonMark: 'BOX', sourceReturnNo: 'RT', customerId: 'C', overseasWarehouseId: 'W', status: 'AVAILABLE', items: [{ skuCode: 'SKU-A', quantity: 3 }, { skuCode: 'SKU-B', quantity: 2 }] }) event('POST', '/api/merchant/v1/last-leg/relabel-applications', { operationWarehouseId: 'W' }, { application: { relabelNo: 'RL-N', overseasWarehouseId: 'W' } }, 'MERCHANT') dispatch('NDP', [candidate(null, '601', null, { sourceStage: 'RELABEL', orderRefs: 'RL-N', returnCartonSource: true })], 'RL-N', 1) - event('POST', '/api/platform/v1/last-leg/dispatches/NDP/transfer-delivery', {}, { outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }] }) + event('GET', '/api/platform/v1/last-leg/dispatches/NDP', undefined, { stage:'DELIVERING', documents:{outbounds:[{outboundNo:'O',customerId:'C'}],deliveries:[{deliveryNo:'D',outboundNo:'O',customerId:'C'}]} }) event('GET', '/api/platform/v1/last-leg/delivery-orders/D/return-inbound-options?customerId=C', undefined, [{ customerId: 'C', lastMileOrderId: '91', deliveryNo: 'D', outboundNo: 'O', returnSources: [{ returnCartonId: '81', cartonMark: 'BOX', inventoryPositionId: null }] }]) return { environment: 'TEST', marker: 'CURRENT', sourceCommit: commit, requests } } @@ -98,7 +98,7 @@ test('import/plan is zero-network and missing current fixtures never become a pa const ctx = context(); assert.equal((await verifyDispatchRepair(ctx, {})).status, 'PLAN_ONLY'); assert.equal(ctx.calls.length, 0) const result = await execute(ctx, {}) assert.equal(result.status, 'PARTIAL_NOT_ALL_VERIFIED') - assert.equal(Object.values(result.cases).every(row => row.status === 'NOT_RUN'), true) + assert.equal(Object.entries(result.cases).filter(([key])=>key!=='cancellation').every(([,row]) => row.status === 'NOT_RUN'), true) assert.equal(result.sourceEventsVerified, false) }) for (const field of ['lastlegHealthy', 'adminHealthy', 'schemaReady']) test(`unready ${field} blocks before any HTTP`, async () => { @@ -137,25 +137,7 @@ test('failed pre-send checkpoint leaves the command unsent', async () => { assert.equal(ctx.calls.length, 0) }) -test('new unpicked dispatch cancels outbound, retains cancelled history, re-arranges released pallets and replays old transfer', async () => { - let cancelled = false, dispatchCancelled = false, transfers = 0 - const ctx = context((method, path, body) => { - if (path.endsWith('/transfer-delivery')) return ok({ outboundNo: 'O', deliveries: [{ deliveryNo: 'D' }], replayed: transfers++ > 0 }) - if (path.includes('/delivery-orders/') && path.includes('/status')) { assert.ok(['DELIVERING', 'REJECTED', 'CANCELLED'].includes(body.status)); assert.equal(body.expectedVersion, 0); return reject() } - if (path.includes('/delivery-orders/')) return ok({ order: { deliveryNo: 'D', outboundNo: 'O', status: cancelled ? 'CANCELLED' : 'WAIT_DELIVERY', version: 0, packageCount: 1 } }) - if (path.endsWith('/outbound-orders/O/cancel')) { cancelled = true; return ok(null) } - if (path.endsWith('/outbound-orders/O')) return ok({ outbound: { status: cancelled ? 'CANCELLED' : 'WAIT_OUTBOUND' } }) - if (method === 'DELETE') { assert.equal(path, '/api/platform/v1/last-leg/dispatches/DP'); assert.equal(cancelled, true); dispatchCancelled = true; return ok(null) } - if (path.endsWith('/dispatches/DP')) return ok({ status: dispatchCancelled ? 'CANCELLED' : 'PENDING_DEPARTURE', createdAt: '2026-10-18T16:00:00', actions: { canTransfer: true } }) - if (path.includes('/dispatches/candidates?')) return ok([{ planPalletId: '11' }]) - if (path.endsWith('/pre-arrange')) { assert.deepEqual(body.planPalletIds, ['11']); assert.equal(dispatchCancelled, true); return ok({ dispatchNo: 'DP-NEW', status: 'PENDING_DEPARTURE', items: [{ planPalletId: '11' }] }) } - assert.fail(path) - }) - const result = await execute(ctx, { cancellation: fresh({ allowDispatchCancellation: true, dispatchNo: 'DP', transferBody: { fees: [] }, rearrangeBody: { planPalletIds: ['11'] }, candidateQuery: { sourceStage: 'RECEIVED' } }) }) - assert.equal(result.cases.cancellation.status, 'PASS') - assert.equal(ctx.calls.filter(row => row.method === 'DELETE').length, 1) - assert.equal(ctx.calls.some(row => row.method === 'DELETE' && row.path.includes('/items/')), false) -}) +test('retired pre-departure cancellation is not reported as verified',async()=>{const ctx=context(),proof=await execute(ctx,{});assert.equal(proof.cases.cancellation.status,'NOT_APPLICABLE');assert.equal(ctx.calls.length,0);assert.notEqual(proof.status,'VERIFIED')}) test('domain rejection cannot be faked by a missing permission; actual shipment cancellation leaves facts unchanged', async () => { for (const forbidden of [false, true]) { diff --git a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs index 7e1d42e75..5f2a3f31d 100644 --- a/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs +++ b/scripts/admin-test-env-modules/dispatch-repair-acceptance.mjs @@ -100,11 +100,11 @@ async function loadProducerResources(context) { must(id(data.overseasWarehouseId) === sources[0].warehouseId && same((data.items || []).map(row => id(row.warehousePalletId)).sort(), sources.map(row => id(row.candidate.warehousePalletId)).sort()), 'Dispatch birth response differs from producer selection') add('dispatch', data.dispatchNo, sources[0].customerId, sources[0].warehouseId, e, { dispatch: data, arrangeBody: e.body }) } - match = path.match(/\/dispatches\/([^/]+)\/transfer-delivery$/) + match = path.match(/\/dispatches\/([^/]+)$/) const dispatched = match && own.dispatch.get(decodeURIComponent(match[1])) - if (e.method === 'POST' && dispatched && data.outboundNo && !data.replayed) { - add('outbound', data.outboundNo, dispatched.customerId, dispatched.warehouseId, e, { dispatchNo: dispatched.key }) - for (const row of data.deliveries || []) add('delivery', row.deliveryNo, dispatched.customerId, dispatched.warehouseId, e, { outboundNo: data.outboundNo, dispatchNo: dispatched.key }) + if (e.method === 'GET' && dispatched && data.stage === 'DELIVERING') { + for (const row of data.documents?.outbounds || []) if (id(row.customerId) === dispatched.customerId) add('outbound', row.outboundNo, dispatched.customerId, dispatched.warehouseId, e, { dispatchNo: dispatched.key }) + for (const row of data.documents?.deliveries || []) if (id(row.customerId) === dispatched.customerId && own.outbound.has(row.outboundNo)) add('delivery', row.deliveryNo, dispatched.customerId, dispatched.warehouseId, e, { outboundNo: row.outboundNo, dispatchNo: dispatched.key }) } match = path.match(/\/delivery-orders\/([^/]+)\/return-inbound-options$/) if (e.method === 'GET' && match && own.delivery.has(decodeURIComponent(match[1])) && Array.isArray(data)) { @@ -242,39 +242,7 @@ export async function verifyDispatchRepair(context, fixtures, { execute = false finally { await context.checkpoint() } } try { - await run('cancellation', async f => { - must(f.allowDispatchCancellation === true && f.dispatchNo && f.transferBody && f.rearrangeBody, 'Owned cancellation fixture and explicit business cancel scope required') - const path = `${BASE}/dispatches/${encodeURIComponent(f.dispatchNo)}` - const original = await call('GET', path) - must(original.status === 'PENDING_DEPARTURE' && original.createdAt && original.actions?.canTransfer, 'New unpicked execution required') - const transferBody = { ...f.transferBody, idempotencyKey: key() } - const transfer = await call('POST', `${path}/transfer-delivery`, transferBody) - must(transfer.outboundNo && transfer.deliveries?.length, 'New outbound and delivery required') - const dpath = scope(`${BASE}/delivery-orders/${encodeURIComponent(transfer.deliveries[0].deliveryNo)}`, f.customerId) - const before = await call('GET', dpath) - must(before.order?.status === 'WAIT_DELIVERY' && before.order.outboundNo === transfer.outboundNo, 'Delivery must belong to new unexecuted outbound') - for (const status of ['DELIVERING', 'REJECTED', 'CANCELLED']) { - await call('PUT', scope(`${BASE}/delivery-orders/${encodeURIComponent(before.order.deliveryNo)}/status`, f.customerId), - { status, expectedVersion: before.order.version, remark: context.marker }, { failure: true }) - } - must(same(shipment((await call('GET', dpath)).order), shipment(before.order)), 'Rejected status commands changed delivery') - const opath = `${BASE}/outbound-orders/${encodeURIComponent(transfer.outboundNo)}` - await call('POST', `${opath}/cancel`, { reason: context.marker }) - must((await call('GET', opath)).outbound?.status === 'CANCELLED', 'Outbound cancellation did not persist') - must((await call('GET', dpath)).order?.status === 'CANCELLED', 'Linked delivery cancellation did not persist') - await call('DELETE', path, undefined, { cancel: true }) - must((await call('GET', path)).status === 'CANCELLED', 'Original dispatch was not retained as cancelled') - const candidateQuery = new URLSearchParams(f.candidateQuery) - const candidates = await call('GET', `${BASE}/dispatches/candidates?${candidateQuery}`) - const expected = f.rearrangeBody.planPalletIds.map(id) - must(expected.length && expected.every(value => candidates.some(row => id(row.planPalletId) === value)), 'Cancelled source pallets were not released') - const replacement = await call('POST', `${BASE}/dispatches/pre-arrange`, f.rearrangeBody) - must(replacement.dispatchNo && replacement.dispatchNo !== f.dispatchNo && replacement.status === 'PENDING_DEPARTURE' - && same(replacement.items.map(row => id(row.planPalletId)).sort(), [...expected].sort()), 'Released source was not re-arranged exactly once') - const replay = await call('POST', `${path}/transfer-delivery`, transferBody) - must(replay.outboundNo === transfer.outboundNo && replay.replayed, 'Old transfer replay generated another outbound') - return { originalDispatchNo: f.dispatchNo, cancelledOutboundNo: transfer.outboundNo, replacementDispatchNo: replacement.dispatchNo } - }) + proof.cases.cancellation = { status: 'NOT_APPLICABLE', reason: '装车完成即派送,已撤销未发车出库取消及人工安排派送场景' } await run('departedGate', async f => { const opath = `${BASE}/outbound-orders/${encodeURIComponent(f.outboundNo)}` const dpath = scope(`${BASE}/delivery-orders/${encodeURIComponent(f.deliveryNo)}`, f.customerId) diff --git a/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs b/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs index 74c7e8f77..6e79dc488 100644 --- a/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs +++ b/scripts/admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs @@ -9,10 +9,10 @@ const OPTIONS = { scenario: MODULE, deploymentBlocker: true } const clone = value => structuredClone(value) const id = value => String(value ?? '') export const extensionMatrix = Object.freeze([ - { scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; distinct fee inputs and isolated execution documents', readiness: 'READY' }, + { scenario: 'dual-destination', scope: 'Two fresh customers, two distinct real FBA destinations, one main/trip; automatic isolated execution documents and actual fee facts', readiness: 'READY' }, { scenario: 'reviews', scope: 'Both rejection/resubmission rounds, zero-pick return and cargo/resource changes, stale/version/idempotency conflicts', readiness: 'READY' }, { scenario: 'relabel', scope: 'Fresh three-box customer receipt, confirmed zero-scan cancellation, two applications sharing one pallet and native rearrangement', readiness: 'READY_FORMAL_THREE_BOX_RECEIPT' }, - { scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources and unarranged departure gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' }, + { scenario: 'competition', scope: 'Two-request Admin/PDA replay race, occupied resources gates; first-execution race remains untested', readiness: 'READY_BOUNDED_REPLAY' }, { scenario: 'bol', scope: 'Fresh same-destination cargo, empty/same/different formal appointment allocations and actual PDF conservation', readiness: 'READY_FORMAL_APPOINTMENTS' }, ]) export function assertDualDestinationFixture(fixture) { @@ -30,24 +30,6 @@ export function feeCents(value) { const cents = Number(whole) * 100 + Number(fraction.padEnd(2, '0')) assert(Number.isSafeInteger(cents)); return cents } -export function applyIndependentFees(body, groups, members, marker) { - assert.equal(groups?.length, 2); assert.equal(body.groups?.length, 2) - assert.equal(new Set(groups.map(row => row.groupKey)).size, 2) - assert.equal(new Set(groups.map(row => id(row.customerId))).size, 2) - const centsByCustomer = new Map(members.map((member, index) => [member.customerId, index === 0 ? 3125 : 4775])) - const expected = {} - for (const group of body.groups) { - const matches = groups.filter(row => row.groupKey === group.groupKey) - assert.equal(matches.length, 1) - const customerId = id(matches[0].customerId), cents = centsByCustomer.get(customerId) - assert(cents && group.fees?.length > 0, 'Explicit current-customer fee services required') - assert(!expected[customerId], 'Duplicated customer fee group') - for (const fee of group.fees) { fee.amount = cents / 100; fee.feeName = `${marker} TEST合成客户独立费用` } - expected[customerId] = group.fees.map(fee => ({ feeType: fee.feeType, currency: fee.currency, cents: feeCents(fee.amount) })) - } - return expected -} - /** Public master-data reads only; no fixture writes until both independent US FBA masters are verified. */ export async function discoverSecondDestination(context, primaryId) { const get = async path => { @@ -88,17 +70,13 @@ export async function verifyDualDestination(context, fixture, options) { } const proof = context.createdResources.dispatchWorkflowExtensions = { scenario: options.bol || 'dual-destination', marker: context.marker, dispatchNo: fixture.dispatchNo, status: 'CHECKING', syntheticTestMaterials: true, physicalMeasurementVerified: false, - hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', expectedFees: {}, isolation: [] } + hardwareVerified: false, midTransactionRollback: 'NOT_TESTED_NO_CONTROLLED_INJECTION', isolation: [] } const original = context.request - let arrangement, deferredFailure, raceDone = false, firstRaceDone = false, unarrangedDone = false + let deferredFailure, raceDone = false, firstRaceDone = false const mainPath = `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}` const pdaPath = `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}` context.request = async function (method, path, body, ...rest) { if (deferredFailure) throw deferredFailure - if (method === 'POST' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/transfer-delivery`) { - proof.expectedFees = applyIndependentFees(body, arrangement?.groups, fixture.members, context.marker) - proof.arrangeRequest = clone(body); await context.checkpoint() - } let response if (options.firstCompetition && !firstRaceDone && method === 'POST' && path === `${pdaPath}/loading-complete` && fixture.tripProof.loadingCommand?.body?.idempotencyKey === body?.idempotencyKey) { @@ -107,21 +85,14 @@ export async function verifyDualDestination(context, fixture, options) { response = outcome.pdaResponse if (outcome.error) deferredFailure = outcome.error } else response = await original.call(this, method, path, body, ...rest) - if (method === 'GET' && path === `${ROOT}/dispatches/${encodeURIComponent(fixture.dispatchNo)}/delivery-arrangement` - && response.http === 200 && String(response.body?.code) === '200') arrangement = clone(response.body.data) // 写请求已收到确定回执后,只做独立扩展;后续核验失败延至下一读请求抛出,不能把已知成功伪报成未知写。 if (!deferredFailure && response.http === 200 && String(response.body?.code) === '200') { try { if (options.competition && method === 'POST' && path === `${pdaPath}/loading-complete` && !raceDone - && response.body.data?.replayed !== true && response.body.data?.stage === 'AFTER_LOADING') { + && response.body.data?.replayed !== true && response.body.data?.stage === 'DELIVERING') { raceDone = true await verifyLoadingReplayRace(context, fixture, options, original.bind(this), body, response.body.data) } - if (options.competition && method === 'GET' && path === `${mainPath}/delivery-arrangement` && !unarrangedDone - && response.body.data?.stage === 'AFTER_LOADING' && response.body.data?.arrangement == null) { - unarrangedDone = true - await verifyUnarrangedDeparture(context, fixture, options, original.bind(this), response.body.data) - } if (method === 'PUT' && body?.idempotencyKey === `${context.marker}-CROSS-SIGN-1`) { const fence = proof.completionReadFence = { status: 'READING', attempts: [], writeReceiptPreserved: true } for (let attempt = 1; attempt <= 60; attempt++) { @@ -154,11 +125,9 @@ export async function verifyDualDestination(context, fixture, options) { assert.equal(id(delivery.order.customerId), member.customerId) assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) assert.equal(Number(delivery.order.packageCount), 1); assert.equal(Number(delivery.order.palletCount), 1) - const expected = proof.expectedFees[member.customerId] - assert(expected?.length > 0); assert.equal(delivery.fees?.length, expected.length) + assert(Array.isArray(delivery.fees)) for (const fee of delivery.fees) { assert(fee.id && !feeIds.has(id(fee.id))); feeIds.add(id(fee.id)) - assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) assert.equal(fee.feeDirection, 'RECEIVABLE'); assert.equal(fee.sourceStatus, 'ACTIVE') } const other = fixture.members[1 - index] @@ -170,7 +139,6 @@ export async function verifyDualDestination(context, fixture, options) { proof.isolation.push({ customerId: member.customerId, wrongCustomerId: other.customerId, deliveryNo: delivery.order.deliveryNo, http: response.http, code: response.body.code, requestId: response.requestId, traceId: response.traceId }) } - assert.notDeepEqual(proof.expectedFees[fixture.members[0].customerId], proof.expectedFees[fixture.members[1].customerId]) proof.status = 'VERIFIED'; await context.checkpoint(); return verified } @@ -238,11 +206,11 @@ export async function executeFirstLoadingRace(context, fixture, options, send, b assert.equal(winnerIndexes.length, 1) const winnerIndex = winnerIndexes[0], winner = responses[winnerIndex], loser = responses[1 - winnerIndex] assert.equal(String(loser.body?.code), 'A0409') - assert.equal(winner.body.data.stage, 'AFTER_LOADING') + assert.equal(winner.body.data.stage, 'DELIVERING') assert.equal(winner.body.data.result?.confirmedLoad?.manifestHash, body.manifestHash) - assert.equal(BigInt(winner.body.data.recordVersion), BigInt(body.recordVersion) + 1n) + assert(BigInt(winner.body.data.recordVersion) > BigInt(body.recordVersion)) race.after = await competitionSnapshot(context, fixture, options, send) - assert.equal(race.after.main.stage, 'AFTER_LOADING') + assert.equal(race.after.main.stage, 'DELIVERING') assert.equal(id(race.after.main.recordVersion), id(winner.body.data.recordVersion)) assert.deepEqual(race.after.stock, race.before.stock) assert.equal(Number(race.after.main.counts.packageCount), 2) @@ -259,20 +227,6 @@ export async function executeFirstLoadingRace(context, fixture, options, send, b } } -async function verifyUnarrangedDeparture(context, fixture, options, send, arrangement) { - const proof = context.createdResources.dispatchWorkflowCompetition - const state = proof.unarrangedDeparture = { status: 'INTENT', before: await competitionSnapshot(context, fixture, options, send), - body: { recordVersion: arrangement.recordVersion, idempotencyKey: `${context.marker}-EXT-NO-ARRANGEMENT-DEPART`, remark: 'TEST装车后未安排派送必须拒绝' } } - assert.equal(state.before.main.stage, 'AFTER_LOADING'); assert.equal(state.before.main.documents?.deliveries?.length, 0) - await context.checkpoint() - const response = await send('POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(fixture.dispatchNo)}/depart`, state.body, fixture.pdaToken) - state.response = receipt(response); await context.checkpoint() - assert.equal(String(response.body?.code), 'A0409'); assert.match(response.body?.message || '', /派送|安排/) - state.after = await competitionSnapshot(context, fixture, options, send); assert.deepEqual(state.after, state.before) - context.assert(MODULE, 'Loaded but unarranged departure is rejected without inventory or execution mutation', response, true, '', OPTIONS) - state.status = 'VERIFIED'; await context.checkpoint() -} - export async function verifyCompetitionExtension(context, fixture, options) { const proof = context.createdResources.dispatchWorkflowCompetition = { marker: context.marker, dispatchNo: fixture.dispatchNo, status: 'CHECKING', firstExecutionRace: 'NOT_TESTED', arbitraryPrivilegeMatrix: 'NOT_TESTED_NO_PERMISSION_MUTATION' } @@ -291,7 +245,7 @@ export async function verifyCompetitionExtension(context, fixture, options) { context.assert(MODULE, 'Occupied vehicle/driver reject a second native main without changing the existing trip or inventory', response, true, '', OPTIONS) proof.resourceConflict.status = 'VERIFIED' const verified = await verifyDualDestination(context, fixture, { ...options, competition: true }) - assert.equal(proof.loadingReplayRace?.status, 'VERIFIED'); assert.equal(proof.unarrangedDeparture?.status, 'VERIFIED') + assert.equal(proof.loadingReplayRace?.status, 'VERIFIED') if (options.firstCompetition) assert.equal(proof.firstLoadingRace?.status, 'VERIFIED') proof.status = options.firstCompetition ? 'VERIFIED_FIRST_EXECUTION_AND_REPLAY' : 'VERIFIED_BOUNDED_SCOPE' await context.checkpoint(); return verified @@ -784,10 +738,11 @@ export async function reconcileReviewTerminal(context, fixture, { readInventory assert.equal(outbound.outbound.dispatchNo, fixture.dispatchNo); assert.equal(outbound.outbound.status, 'OUTBOUNDED') assert.equal(delivery.order.dispatchNo, fixture.dispatchNo); assert.equal(delivery.order.status, 'DELIVERED') assert.equal(id(delivery.order.customerId), member.customerId); assert.equal(delivery.order.destinationWarehouseCode, member.destination.warehouseCode) - const expected = extension.expectedFees[member.customerId]; assert.equal(delivery.fees.length, expected.length) + assert(Array.isArray(delivery.fees)) + const originalFees=old.snapshots.BEFORE_SIGN_1.members[index].delivery.fees + assert(Array.isArray(originalFees)); assert.deepEqual(delivery.fees,originalFees) for (const fee of delivery.fees) { - assert(!allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id)) - assert(expected.some(row => row.feeType === fee.feeType && row.currency === fee.currency && row.cents === feeCents(fee.amount))) + assert(fee.id && !allFeeIds.has(id(fee.id))); allFeeIds.add(id(fee.id)) } const stock = await readInventory({ marker: context.marker, customerId: member.customerId, warehouseId: fixture.warehouseId }) assert.deepEqual(stock, old.snapshots.BEFORE_SIGN_1.members[index].stock, 'Terminal completion must not change already shipped inventory') diff --git a/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs b/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs index 3eca8ccd1..11a57c230 100644 --- a/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs +++ b/scripts/admin-test-env-modules/dispatch-workflow-extension-contract.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { applyIndependentFees, feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, executeFirstLoadingRace, assertBolPages, assertConfirmedRelabelCancellation } from './dispatch-workflow-extension-acceptance.mjs' +import { feeCents, assertDualDestinationFixture, extensionMatrix, verifyLoadingReplayRace, executeFirstLoadingRace, assertBolPages, assertConfirmedRelabelCancellation } from './dispatch-workflow-extension-acceptance.mjs' import { parseExtensionArguments, runDispatchExtensions } from '../dispatch-workflow-extensions-test-env.mjs' test('default plan imports without credentials/network and keeps all unexecuted gaps explicit', async () => { @@ -24,26 +24,6 @@ test('terminal continuation requires an explicit original report hash and is nev '--resume-report', 'reports/current/report.json', '--resume-report-sha256', 'a'.repeat(64)]) assert.equal(config.scenario, 'review-terminal'); assert.equal(config.resumeReportSha256, 'a'.repeat(64)) }) -test('actual group owner controls distinct cents even when server group ordering differs', () => { - const groups = [{ groupKey: 'B-GROUP', customerId: 'B' }, { groupKey: 'A-GROUP', customerId: 'A' }] - const body = { recordVersion: '9007199254740993', idempotencyKey: 'ORIGINAL', groups: groups.map(row => ({ groupKey: row.groupKey, - fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY', amount: 30 }] })) } - const expected = applyIndependentFees(body, groups, [{ customerId: 'A' }, { customerId: 'B' }], 'CURRENT') - assert.deepEqual(body.groups.map(row => row.fees[0].amount), [47.75, 31.25]) - assert.equal(expected.A[0].cents, 3125); assert.equal(expected.B[0].cents, 4775) - assert.equal(body.recordVersion, '9007199254740993'); assert.equal(body.idempotencyKey, 'ORIGINAL') - assert(body.groups.every(row => row.fees[0].feeName.includes('TEST合成'))) -}) -test('unknown customers, repeated groups and missing real required fees fail before transport', () => { - const members = [{ customerId: 'A' }, { customerId: 'B' }] - for (const groups of [[{ groupKey: 'G', customerId: 'A' }, { groupKey: 'G', customerId: 'B' }], - [{ groupKey: 'A', customerId: 'A' }, { groupKey: 'X', customerId: 'FOREIGN' }]]) { - const body = { groups: groups.map(row => ({ groupKey: row.groupKey, fees: [{ feeType: 'DELIVERY_FEE', currency: 'CNY' }] })) } - assert.throws(() => applyIndependentFees(body, groups, members, 'CURRENT')) - } - assert.throws(() => applyIndependentFees({ groups: [{ groupKey: 'A', fees: [] }, { groupKey: 'B', fees: [] }] }, - [{ groupKey: 'A', customerId: 'A' }, { groupKey: 'B', customerId: 'B' }], members, 'CURRENT')) -}) test('dual destination proof rejects reused customer, collapsed destination and missing route stop', () => { const fixture = { members: [{ customerId: 'A', destination: { key: 'FBA:ONE' }, receiptVerified: true }, { customerId: 'B', destination: { key: 'FBA:TWO' }, receiptVerified: true }], @@ -120,10 +100,10 @@ for (const winner of ['PDA', 'ADMIN']) test(`first valid loading CAS race record const context = { marker: 'CURRENT', createdResources: { dispatchWorkflowCompetition: { status: 'CHECKING' } }, checkpoint: async () => {}, assert: () => {} } const fixture = { dispatchNo: 'OWN', tripNo: 'TRIP', warehouseId: 'W', pdaToken: 'OWN_PDA', members: [{ customerId: 'A' }, { customerId: 'B' }] } const body = { recordVersion: '7', idempotencyKey: 'ORIGINAL_PDA', manifestHash: 'ACTUAL_HASH' } - const success = { http: 200, body: { code: '200', data: { stage: 'AFTER_LOADING', recordVersion: '8', result: { confirmedLoad: { manifestHash: 'ACTUAL_HASH' } } } } } + const success = { http: 200, body: { code: '200', data: { stage: 'DELIVERING', recordVersion: '8', result: { confirmedLoad: { manifestHash: 'ACTUAL_HASH' } } } } } const rejected = { http: 200, body: { code: 'A0409' } }, pending = []; let completed = false const send = async (method, path, payload, token) => { - if (method === 'GET') return { http: 200, body: { code: '200', data: { stage: completed ? 'AFTER_LOADING' : 'LOADING', + if (method === 'GET') return { http: 200, body: { code: '200', data: { stage: completed ? 'DELIVERING' : 'LOADING', recordVersion: completed ? '8' : '7', counts: { packageCount: 2, pickedPackageCount: 2 } } } } return new Promise(resolve => { pending.push({ resolve, token, payload }) diff --git a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs index cf5a5f619..cbc2f10a2 100644 --- a/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-capacity-pool-schema-contract.test.mjs @@ -35,9 +35,10 @@ test('one booking maps to exactly one commercial order and one actual incoming s }) test('module and unified fresh baselines contain the exact same new table definitions', () => { - const module = read('lastleg-service/src/main/resources/db/lastleg.sql') + // 比较定义而不是文件换行;仍要求列、约束及表选项完全相同。 + const module = read('lastleg-service/src/main/resources/db/lastleg.sql').replace(/\r\n/g, '\n') for (const name of ['ll_fleet_capacity_offer', 'll_fleet_capacity_offer_command', 'll_fleet_pool_order_link']) { - const definition = table(overseas, 'baiyue_lastleg', name) + const definition = table(overseas, 'baiyue_lastleg', name).replace(/\r\n/g, '\n') assert.ok(module.includes(definition), `${name}: module and unified contract drift`) } const sql = read('deploy/sql/migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql') diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index 6942383e1..351724946 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -43,8 +43,8 @@ function model(fault = '') { if (method === 'POST' && keys.has(body?.idempotencyKey)) { const prior = keys.get(body.idempotencyKey); assert.deepEqual(body,prior.body) if(token!==prior.actor) { - if(fault==='foreign departure actor accepted'&&path.endsWith('/depart'))return response({...prior.result,replayed:true}) - if(fault==='foreign departure actor mutates'&&path.endsWith('/depart'))state[0].stock.ledger.push({...state[0].stock.ledger[0],id:'FOREIGN-ACTOR-MUTATION'}) + if(fault==='foreign departure actor accepted'&&path.endsWith('/loading-complete'))return response({...prior.result,replayed:true}) + if(fault==='foreign departure actor mutates'&&path.endsWith('/loading-complete'))state[0].stock.ledger.push({...state[0].stock.ledger[0],id:'FOREIGN-ACTOR-MUTATION'}) return forbidden() } return response({...prior.result,replayed:true}) @@ -56,6 +56,7 @@ function model(fault = '') { if(fault==='aggregation delayed'&&state.every(s=>s.delivery?.order.status==='DELIVERED')) { if(aggregationReads===0)stage='DELIVERED';else aggregationReads-- } + if(stopAtArrangement&&stage==='DELIVERING'&&fixture.tripProof.loadingCommand?.afterReplay)throw Error('Explicit test checkpoint after confirmed loading') return response(detail()) } if (path.endsWith('/loading') && method === 'GET') return response({dispatchNo:'DS-ONE',status:'ACTIVE',stage,recordVersion:nativeVersion,manifestHash:'REAL-MANIFEST', @@ -64,11 +65,15 @@ function model(fault = '') { evidence:[actualEvidence('weightKg',100,i),actualEvidence('volumeCbm',1,i)]}))}) if (path.endsWith('/loading-complete')) { if (!state.every(s=>s.picked===1)) { + if(fault==='gate reorders route')[trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] + if(fault==='gate mutates second customer')state[1].stock.balances[0].holdPackages='1' if(fault==='outage disguised as rejection')return {http:503,body:{code:'S0503'},requestId:'r',traceId:'t'} return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平') } assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2) - stage='AFTER_LOADING';nativeVersion++ + stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT' + for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'OUTBOUNDED',packageCount:1};s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:fault==='partial departure'&&i===1?'WAIT_DELIVERY':'DELIVERING',version:0}} } + if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'} confirmedLoad={manifestHash:body.manifestHash,pallets:copy(body.measurements),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'} return remember(body,{confirmedLoad,loadingCompletionHash:'LOADED'},token,'LOADING_COMPLETE') } @@ -82,29 +87,6 @@ function model(fault = '') { } if (path === '/api/pda/v1/last-leg/dispatches/DS-ONE') return response({dispatchNo:'DS-ONE',warehouseId:'WH',status:'ACTIVE',stage,recordVersion:nativeVersion,counts:counts(),pickingReconciled:state.every(s=>s.picked===1), items:items().map((item,i)=>({...item,positions:fault==='missing PDA position'?[]:[{positionId:members[i].positionId,balanceId:members[i].balanceId,availablePackages:state[i].stock.positions[0].availablePackages}]}))}) - if (path.endsWith('/delivery-arrangement')) { - if(stopAtArrangement)throw Error('Explicit test checkpoint after confirmed loading') - return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,status:'ACTIVE',stage,cargoHash:'CARGO',loadingCompletionHash:'LOADED',arrange:{allowed:true},arrangement:arranged?{status:'ARRANGED'}:null, - groups:members.map((m,i)=>({groupKey:`GROUP${i}`,customerId:m.customerId,groupCargoHash:`GROUP-HASH${i}`,destination:{key:i?'FBA:B':'FBA:A'},cargo:[{customerId:m.customerId,warehousePalletId:m.palletId}],blockReasons:[],requiredServices:['DELIVERY_FEE']}))}) - } - if (path.endsWith('/transfer-delivery')) { - assert.equal(stage,'AFTER_LOADING');assert.equal(body.recordVersion,nativeVersion);assert.equal(body.groups.length,2) - assert.ok(body.groups.every(g=>g.fees.length===1&&g.fees[0].amount===30)) - for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'WAIT_OUTBOUND'} - s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:'WAIT_DELIVERY',version:0}} } - arranged=true;nativeVersion++;return remember(body,{dispatchNo:'DS-ONE',outbounds:state.map((s,i)=>({...s.outbound,packageCount:1})),deliveries:deliveryRefs()},token) - } - if (path.endsWith('/depart')) { - if(!arranged) { - if(fault==='gate reorders route')[trip.trip.stops[1],trip.trip.stops[2]]=[trip.trip.stops[2],trip.trip.stops[1]] - if(fault==='gate mutates second customer'){state[1].stock.balances[0].holdPackages='1';state[1].stock.balances[0].availablePackages='0';state[1].stock.positions[0].holdPackages='1';state[1].stock.positions[0].availablePackages='0'} - return reject('请先完成装车并一次安排全部客户派送') - } - assert.equal(body.recordVersion,nativeVersion);stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT' - for(const [i,s]of state.entries())if(!(fault==='partial departure'&&i===1)){s.outbound.status='OUTBOUNDED';s.delivery.order.status='DELIVERING'} - if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'} - return remember(body,{shippedPackages:2,departedAt:'2026-10-19T01:00:00Z',deliveries:deliveryRefs(),outbounds:state.map(s=>s.outbound)},token) - } const index=members.findIndex((_m,i)=>path.includes(`/OUT${i}`)||path.includes(`/DL${i}`)),row=state[index] if(path.includes('/outbound-orders/'))return response({outbound:row.outbound,deliveries:[row.delivery.order]}) if(path.endsWith('/documents')) { @@ -127,7 +109,7 @@ test('default call and import do not request, create or activate anything',async test('current DTO-shaped two-customer trip rejects unready gate, departs once and signs independently',async()=>{ const m=model();const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}) assert.equal(proof.status,'VERIFIED');assert.equal(proof.gateProof,'PREREQUISITE_REJECTION_VERIFIED');assert.equal(proof.midTransactionRollback,'NOT_TESTED') - assert.equal(m.calls.filter(c=>c.raw.endsWith('/depart')).length,4) + assert.equal(m.calls.some(c=>c.raw.endsWith('/depart')),false) assert.deepEqual(m.state.map(s=>s.delivery.order.status),['DELIVERED','DELIVERED']) assert.ok(m.calls.filter(c=>c.raw.includes('/api/pda/')).every(c=>c.token===m.fixture.pdaToken)) assert.ok(!JSON.stringify(proof).includes(m.fixture.pdaToken)) @@ -148,10 +130,10 @@ test('explicit rollback verifier runs after both picks and before successful dep const m=model();let calls=0 const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory, verifyDepartureRollback:async({ready,departPath,departBody})=>{ - calls++;assert.equal(ready.trip.trip.status,'PLANNED');assert.equal(ready.dispatch.stage,'AFTER_LOADING') - assert.ok(ready.members.every(row=>row.outbound.outbound.status==='WAIT_OUTBOUND')) - assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) - assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.equal(departBody.idempotencyKey,'RUN-CROSS-DEPART') + calls++;assert.equal(ready.trip.trip.status,'PLANNED');assert.equal(ready.dispatch.stage,'LOADING') + assert.ok(ready.members.every(row=>!row.outbound)) + assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1) + assert.ok(departPath.includes(m.fixture.members[0].dispatchNo));assert.ok(departBody.idempotencyKey) return {verified:true,scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'} }}) assert.equal(calls,1);assert.equal(proof.midTransactionRollback,'VERIFIED') @@ -164,8 +146,8 @@ for(const failure of ['unverified','throws','mutates'])test(`rollback verificati if(failure==='mutates')m.state[0].stock.balances[0].availablePackages='-1' return {verified:failure!=='unverified',scope:'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK'} }})) - assert.equal(m.calls.filter(call=>call.raw.endsWith('/depart')).length,1) - assert.ok(m.state.every(row=>row.delivery.order.status==='WAIT_DELIVERY')) + assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1) + assert.ok(m.state.every(row=>row.delivery===null)) }) for(const fault of ['wrong stock scope','missing PDA position','duplicate ship','gate mutates second customer','gate reorders route','outage disguised as rejection','unrelated gate rejection','partial departure','depart deducts again','signs both','foreign delivery','foreign departure actor accepted','foreign departure actor mutates']) { test(`fails closed without widening the claimed evidence: ${fault}`,async()=>{ @@ -194,7 +176,7 @@ async function afterLoadingModel(fault = '') { const priorProof = structuredClone(m.context.createdResources.fleetCrossCustomer) assert.equal(priorProof.status,'FAILED') assert.equal(priorProof.gateProof,'PREREQUISITE_REJECTION_VERIFIED') - assert.equal(m.fixture.tripProof.loadingCommand.result.stage,'AFTER_LOADING') + assert.equal(m.fixture.tripProof.loadingCommand.result.stage,'DELIVERING') delete m.context.createdResources.fleetCrossCustomer m.fixture.stage = afterLoadingContinuationContract.stage m.resumeAfterLoading() @@ -227,7 +209,7 @@ test('explicit after-loading continuation reconciles original proof and never re const m=await afterLoadingModel(),original=structuredClone(m.priorProof),loading=structuredClone(m.fixture.tripProof.loadingCommand) const proof=await continueLoaded(m) assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true) - assert.equal(proof.continuation.mode,'EXPLICIT_AFTER_LOADING') + assert.equal(proof.continuation.mode,'EXPLICIT_COMPLETED_LOADING') assert.equal(proof.continuation.gateProofSource,'PRIOR_PROOF') assert.match(proof.continuation.priorProofSHA256,/^[a-f0-9]{64}$/) const normalize=value=>Array.isArray(value)?value.map(normalize):value&&typeof value==='object'?Object.fromEntries(Object.keys(value).sort().map(key=>[key,normalize(value[key])])):value @@ -235,33 +217,12 @@ test('explicit after-loading continuation reconciles original proof and never re assert.deepEqual(m.priorProof,original);assert.deepEqual(m.fixture.tripProof.loadingCommand,loading) assert.equal(proof.snapshots.AFTER_PICK_1,undefined,'no historical loading snapshot is fabricated in the new proof') assert.ok(proof.snapshots.CONTINUATION_INITIAL);assert.deepEqual(proof.continuation.originalAfterPickSnapshot,original.snapshots.AFTER_PICK_1) - assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false) - assert.equal(writes(m).filter(call=>call.raw.endsWith('/transfer-delivery')).length,2,'one arrangement plus the exact original-key replay') - const departures=writes(m).filter(call=>call.raw.endsWith('/depart')) - assert.deepEqual(departures.map(call=>call.token),[m.fixture.pdaToken,m.fixture.pdaToken,m.context.token]) + assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')),false) + assert.equal(writes(m).some(call=>call.raw.endsWith('/transfer-delivery')||call.raw.endsWith('/depart')),false) assert.deepEqual(m.state.map(row=>row.delivery.order.status),['DELIVERED','DELIVERED']) assert.equal(JSON.stringify(proof).includes(m.fixture.pdaToken),false) }) -test('after-loading accepts the official NON_NULL preview when the unarranged field is omitted',async()=>{ - const m=await afterLoadingModel() - let omitted=false - alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{ - delete value.arrangement - omitted=!Object.hasOwn(value,'arrangement') - }) - const proof=await continueLoaded(m) - assert.equal(omitted,true);assert.equal(proof.status,'VERIFIED');assert.equal(proof.continued,true) - assert.equal(writes(m).some(call=>call.raw.endsWith('/pick')||call.raw.endsWith('/loading-complete')),false) -}) - -test('after-loading rejects even an empty existing arrangement object before any business write',async()=>{ - const m=await afterLoadingModel() - alterRead(m,path=>path.endsWith('/delivery-arrangement'),value=>{value.arrangement={}}) - await assert.rejects(continueLoaded(m),/Continuation has no prior arrangement/) - assert.equal(writes(m).length,0) -}) - for(const failure of ['missing prior','marker','gate','source identity','prior picking','unknown prior write','loading result','loading manifest','loading pallet','loading version']) { test(`after-loading refuses incomplete or foreign source proof before any request: ${failure}`,async()=>{ const m=await afterLoadingModel() @@ -279,7 +240,7 @@ for(const failure of ['missing prior','marker','gate','source identity','prior p }) } -for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity','manifest drift','confirmed load drift','existing arrangement']) { +for(const failure of ['inventory drift','current main version','current shipped','existing outbound','current source identity']) { test(`after-loading refuses changed current facts before arranging or departing: ${failure}`,async()=>{ const m=await afterLoadingModel() if(failure==='inventory drift')m.state[0].stock.ledger.push({...m.state[0].stock.ledger[0],id:'UNEXPECTED-MOVEMENT'}) diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs index ed825617e..79b2de735 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs @@ -30,8 +30,8 @@ export const fixtureContract = Object.freeze({ export const afterLoadingContinuationContract = Object.freeze({ ...fixtureContract, - stage: 'TWO_OWNED_CUSTOMERS_ONE_DISPATCH_AFTER_LOADING_NOT_ARRANGED', - preparation: 'Explicit continuation only: prior failed proof with verified gate and complete AFTER_PICK_1; original successful loadingCommand/confirmedLoad; current AFTER_LOADING with no arrangement or execution documents.', + stage: 'TWO_OWNED_CUSTOMERS_ONE_DISPATCH_DELIVERING', + preparation: 'Explicit continuation only: prior failed proof with verified gate and complete AFTER_PICK_1; original successful loadingCommand/confirmedLoad; current DELIVERING with automatically created execution documents.', boundary: 'Reconcile original identities, complete picked inventory and original loading receipt through current reads before any new write. Never repeat picking/loading or rewrite the prior proof.', }) @@ -42,7 +42,7 @@ export async function verifyFleetCrossCustomer(context, fixture, { execute = fal export async function continueFleetCrossCustomerAfterLoading(context, fixture, { execute = false, readInventory, verifyDepartureRollback, priorProof, completionPolling } = {}) { if (!execute) return afterLoadingContinuationContract - if (!priorProof) throw new Error('Explicit prior cross-customer proof is required for AFTER_LOADING continuation') + if (!priorProof) throw new Error('Explicit prior cross-customer proof is required for completed-loading continuation') return runCrossCustomerCore(context, { ...fixture, members: structuredClone(fixture?.members) }, { readInventory, verifyDepartureRollback, priorProof, completionPolling }) } @@ -161,84 +161,29 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep equal(after, await snapshot(`PICK_REPLAY_${index}`), 'Pick replay changes neither customer nor any execution record') } try { - let cargoBasis, originalLoadingHash + let cargoBasis if (continuation) { - const prior = priorProof.snapshots?.AFTER_PICK_1, command = fixture.tripProof.loadingCommand - const confirmed = command?.result?.result?.confirmedLoad - check('Continuation has the original failed owned proof and verified prerequisite gate', priorProof.status === 'FAILED' - && priorProof.marker === context.marker && priorProof.dispatchNo === fixture.dispatchNo && priorProof.tripNo === fixture.tripNo - && id(priorProof.warehouseId) === id(fixture.warehouseId) && priorProof.gateProof === 'PREREQUISITE_REJECTION_VERIFIED' - && priorProof.members?.length === 2 && !priorProof.arrangeRequest && !priorProof.departure - && !priorProof.members.some(member => member.outboundNo || member.deliveryNo) - && Array.isArray(priorProof.requests) && priorProof.requests.every(entry => ['GET','HEAD','OPTIONS'].includes(entry.method) || entry.status === 'RESPONDED')) - const identityFields = ['marker','customerId','incomingNo','dispatchNo','dispatchItemId','balanceId','positionId','palletId','palletNo'] - check('Continuation preserves each original customer, source, item, balance, position and physical pallet', fixture.members.every((member,index) => - identityFields.every(field => id(member[field]) === id(priorProof.members[index]?.[field])))) - check('Prior AFTER_PICK_1 is a complete reviewed picked but not shipped native snapshot', prior?.dispatch?.dispatchNo === fixture.dispatchNo - && prior.dispatch.status === 'ACTIVE' && prior.dispatch.stage === 'LOADING' && prior.dispatch.reviewState === 'APPROVED' - && prior.dispatch.reviews?.dispatchReview?.status === 'APPROVED' && prior.dispatch.reviews?.loadingReview?.status === 'APPROVED' - && integer(prior.dispatch.counts?.packageCount) === 2 && integer(prior.dispatch.counts?.pickedPackageCount) === 2 - && integer(prior.dispatch.counts?.shippedPackageCount) === 0 && prior.task?.pickingReconciled === true - && prior.task.dispatchNo === fixture.dispatchNo && id(prior.task.warehouseId) === id(fixture.warehouseId) - && prior.task.stage === 'LOADING' && id(prior.task.recordVersion) === id(prior.dispatch.recordVersion) - && prior.items?.dispatchNo === fixture.dispatchNo && id(prior.items.recordVersion) === id(prior.dispatch.recordVersion) - && prior.items.items?.length === 2 && prior.task.items?.length === 2 - && prior.members?.length === 2 && prior.members.every(row => row.stock && !row.outbound && !row.delivery) - && prior.dispatch.documents?.outbounds?.length === 0 && prior.dispatch.documents?.deliveries?.length === 0) - check('Original loading command has a successful explicit receipt for this main and both exact physical pallets', command?.path === `${pdaPath}/loading-complete` - && command.body?.idempotencyKey && command.body.manifestHash && command.result?.dispatchNo === fixture.dispatchNo - && command.result.action === 'LOADING_COMPLETE' && command.result.idempotencyKey === command.body.idempotencyKey - && command.result.status === 'ACTIVE' && command.result.stage === 'AFTER_LOADING' && command.result.result?.loadingCompletionHash - && id(command.body.recordVersion) === id(prior.dispatch.recordVersion) - && /^(0|[1-9]\d*)$/.test(id(command.result.recordVersion)) - && /^(0|[1-9]\d*)$/.test(id(command.body.recordVersion)) - && BigInt(id(command.result.recordVersion)) > BigInt(id(command.body.recordVersion)) - && integer(command.result.counts?.packageCount) === 2 && integer(command.result.counts?.pickedPackageCount) === 2 - && integer(command.result.counts?.shippedPackageCount) === 0 - && confirmed?.manifestHash === command.body.manifestHash && confirmed.operatorId && Number.isFinite(Date.parse(confirmed.confirmedAt)) - && canonical(confirmed) === canonical(fixture.tripProof.confirmedLoad) - && Array.isArray(command.body.measurements) && command.body.measurements.length === 2 - && canonical(command.body.measurements) === canonical(confirmed.pallets) - && new Set(confirmed.pallets.map(row => id(row.warehousePalletId))).size === 2 - && fixture.members.every(member => confirmed.pallets.some(row => id(row.warehousePalletId) === member.palletId - && Number.isSafeInteger(row.palletSpaces) && row.palletSpaces > 0 - && ['weightKg','volumeCbm'].every(field => ['number','string'].includes(typeof row[field]) - && /^(?:0|[1-9]\d*)(?:\.\d+)?$/.test(String(row[field])) && Number.isFinite(Number(row[field])) && Number(row[field]) > 0) - && Number.isSafeInteger(row.deliveryIndex) && row.deliveryIndex > 0 && fixture.tripProof.stops?.[row.deliveryIndex]?.destinationKey))) - proof.continued = true - proof.continuation = { mode: 'EXPLICIT_AFTER_LOADING', priorProofSHA256: createHash('sha256').update(canonical(priorProof)).digest('hex'), - priorStatus: priorProof.status, gateProofSource: 'PRIOR_PROOF', originalAfterPickSnapshot: structuredClone(prior), - originalLoadingCommand: structuredClone(command), originalConfirmedLoad: structuredClone(confirmed) } - proof.gateProof = priorProof.gateProof - await context.checkpoint?.() - const initial = await snapshot('CONTINUATION_INITIAL') - check('Current main is exactly the unarranged confirmed loading version with two picked and zero shipped packages', initial.dispatch.status === 'ACTIVE' - && initial.dispatch.stage === 'AFTER_LOADING' && initial.task.stage === 'AFTER_LOADING' && initial.trip.trip.status === 'PLANNED' - && id(initial.dispatch.recordVersion) === id(command.result.recordVersion) && initial.task.pickingReconciled === true - && initial.dispatch.reviewState === 'APPROVED' && initial.dispatch.reviews?.dispatchReview?.status === 'APPROVED' - && initial.dispatch.reviews?.loadingReview?.status === 'APPROVED' && integer(initial.dispatch.counts.packageCount) === 2 - && integer(initial.dispatch.counts.pickedPackageCount) === 2 && integer(initial.dispatch.counts.shippedPackageCount) === 0 - && initial.dispatch.documents?.outbounds?.length === 0 && initial.dispatch.documents?.deliveries?.length === 0 - && id(initial.trip.trip.vehicleId) === id(fixture.tripProof.vehicleId) && id(initial.trip.trip.driverId) === id(fixture.tripProof.driverId) - && id(prior.trip?.trip?.vehicleId) === id(fixture.tripProof.vehicleId) && id(prior.trip?.trip?.driverId) === id(fixture.tripProof.driverId) - && canonical(initial.trip.trip.stops) === canonical(fixture.tripProof.stops) - && initial.items.items.every(item => integer(item.pickedPackages) === 1) && initial.task.items.every(item => integer(item.pickedPackages) === 1)) - equal(prior.members.map(row => row.stock), initial.members.map(row => row.stock), 'Continuation current complete inventory equals original AFTER_PICK_1 inventory') - const sourceItems = items => items.map(item => Object.fromEntries(['dispatchItemId','customerId','sourceKey','sourceVersion','sourceType', - 'planPalletId','returnReceiptId','relabelNo','warehousePalletId','palletNo','orderNo','inboundCode','destination','scheduledPackages','pickedPackages'] - .map(field => [field,item[field]]))).sort((a,b) => id(a.dispatchItemId).localeCompare(id(b.dispatchItemId))) - equal(sourceItems(prior.items.items), sourceItems(initial.items.items), 'Continuation preserves both picked source items and destinations') - const loading = await request('GET', `${pdaPath}/loading`, undefined, 'Reconcile current native manifest against original successful loading receipt', true) - check('Current loading preview preserves the original manifest, confirmed receipt and two reconciled physical pallets', loading.dispatchNo === fixture.dispatchNo - && loading.status === 'ACTIVE' && loading.stage === 'AFTER_LOADING' && id(loading.recordVersion) === id(command.result.recordVersion) - && loading.pickingReconciled === true && loading.manifestHash === confirmed.manifestHash - && canonical(loading.confirmedLoad) === canonical(confirmed) && loading.pallets?.length === 2 - && new Set(loading.pallets.map(row => id(row.warehousePalletId))).size === 2 - && fixture.members.every(member => loading.pallets.some(row => id(row.warehousePalletId) === member.palletId - && row.palletNo === member.palletNo && integer(row.scheduledPackages) === 1 && integer(row.pickedPackages) === 1))) - proof.continuation.currentLoadingPreview = loading - cargoBasis = initial - originalLoadingHash = command.result.result.loadingCompletionHash + const command = fixture.tripProof.loadingCommand + const prior = priorProof.snapshots?.AFTER_PICK_1 + check('Continuation preserves original successful loading command and current-run picked inventory', priorProof.dispatchNo === fixture.dispatchNo && priorProof.marker === context.marker && priorProof.gateProof === 'PREREQUISITE_REJECTION_VERIFIED' + && canonical(priorProof.members) === canonical(proof.members) + && priorProof.requests.every(row => row.method === 'GET' || row.status === 'RESPONDED') + && Number(prior?.dispatch?.counts?.pickedPackageCount) === 2 && id(command?.body?.recordVersion) === id(prior?.dispatch?.recordVersion) + && canonical(command?.body?.measurements) === canonical(command?.result?.result?.confirmedLoad?.pallets) + && prior?.dispatch?.dispatchNo === fixture.dispatchNo && prior.dispatch.stage === 'LOADING' + && command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE' && command.result.stage === 'DELIVERING' + && command.result.dispatchNo === fixture.dispatchNo && command.result.idempotencyKey === command.body.idempotencyKey + && command.result.result?.confirmedLoad?.manifestHash === command.body.manifestHash) + const current = await snapshot('CONTINUATION_INITIAL') + check('Continuation is already delivering with the original automatic documents', current.dispatch.stage === 'DELIVERING' + && current.task.stage === 'DELIVERING' && current.trip.trip.status === 'IN_TRANSIT' + && id(current.dispatch.recordVersion) === id(command.result.recordVersion) && Number(current.dispatch.counts.shippedPackageCount) === 2 + && canonical(current.items.items) === canonical(prior.items.items) + && current.dispatch.documents?.outbounds?.length === 2 && current.dispatch.documents?.deliveries?.length === 2) + equal(prior.members.map(row => row.stock), current.members.map(row => row.stock), 'Continuation preserves complete picked inventory') + proof.continued = true; proof.gateProof = priorProof.gateProof + proof.continuation = { mode: 'EXPLICIT_COMPLETED_LOADING', gateProofSource:'PRIOR_PROOF', originalAfterPickSnapshot:structuredClone(prior), priorProofSHA256: createHash('sha256').update(canonical(priorProof)).digest('hex') } + cargoBasis = prior } else { const initial = await snapshot('INITIAL') check('Fresh native fixture is reviewed LOADING with both customer receipts untouched', initial.trip.trip.status === 'PLANNED' @@ -257,67 +202,46 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep await request('POST', `${pdaPath}/loading-complete`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-LOAD'), manifestHash: preview.manifestHash, measurements: [], remark: context.marker }, 'Unready second customer blocks explicit loading', true, message => /拣货.*核平|尚未核平/.test(message)) - await request('POST', `${pdaPath}/depart`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-DEPART'), remark: context.marker }, - 'Unready second customer blocks whole trip before execution', true, message => /装车|派送/.test(message)) equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged') proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED' await pick(proof.members[1], 1) - await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements) cargoBasis = proof.snapshots.AFTER_PICK_1 + await confirmFleetLoading(context, MODULE, fixture.tripProof, fixture.pdaToken, fixture.loadingMeasurements, async command => { + if (verifyDepartureRollback === undefined) return + check('Explicit rollback verifier is callable', typeof verifyDepartureRollback === 'function') + const ready = await snapshot('READY') + const rollback = await verifyDepartureRollback({ context, fixture, ready, departPath: command.path, departBody: command.body, snapshot }) + check('Mid-transaction rollback has exact owned verification', rollback?.verified === true && rollback.scope === 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') + equal(ready, await snapshot('ROLLBACK_RESTORED'), 'Rollback restores both complete inventory snapshots before loading completion') + proof.rollbackEvidence = rollback; proof.midTransactionRollback = 'VERIFIED' + }) } - const arrangement = await request('GET', `${path}/delivery-arrangement`, undefined, 'Read native loaded per-customer delivery arrangement') - if (continuation) check('Continuation has no prior arrangement and retains the original loading completion hash', arrangement.arrangement == null - && arrangement.loadingCompletionHash === originalLoadingHash && id(arrangement.recordVersion) === id(fixture.tripProof.loadingCommand.result.recordVersion)) - check('Loaded arrangement groups cover exactly both real customers', arrangement.dispatchNo === fixture.dispatchNo && arrangement.status === 'ACTIVE' - && arrangement.stage === 'AFTER_LOADING' && arrangement.arrange?.allowed === true && arrangement.cargoHash && arrangement.loadingCompletionHash - && arrangement.groups?.length === 2 && proof.members.every(m => arrangement.groups.some(g => id(g.customerId) === m.customerId && g.cargo?.length - && g.cargo.every(c => id(c.customerId) === m.customerId && id(c.warehousePalletId) === m.palletId) && g.blockReasons?.length === 0 - && g.destination?.key === cargoBasis.items.items.find(i => id(i.customerId) === m.customerId)?.destination?.key))) - const arrangeBody = { recordVersion: arrangement.recordVersion, idempotencyKey: key('ARRANGE'), cargoHash: arrangement.cargoHash, - loadingCompletionHash: arrangement.loadingCompletionHash, groups: arrangement.groups.map(g => ({ groupKey: g.groupKey, groupCargoHash: g.groupCargoHash, - plannedDeliveryAt: new Date(Date.now() + 21600000).toISOString(), deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', - loadingType: 'PALLET', vehicleType: 'TRUCK', vehicleSize: '本轮实际装载', appointmentAccount: 'SELF', remark: `${context.marker} TEST合成资料`, - externalFreightHash: g.externalFreightHash, feeCoverageCargoHash: g.feeCoverageCargoHash, feeCoverage: [], - fees: (g.requiredServices || []).map(feeType => ({ feeType, feeName: 'TEST本轮派送费', direction: 'RECEIVABLE', currency: 'CNY', amount: 30 })) })) } - proof.arrangeRequest = arrangeBody - const arranged = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Arrange both customers after real loading before departure') - check('Native arrangement creates customer-isolated outbounds and deliveries', arranged.dispatchNo === fixture.dispatchNo && arranged.stage === 'AFTER_LOADING' - && arranged.result?.deliveries?.length === 2 && arranged.result.outbounds?.length === 2 - && new Set(arranged.result.deliveries.map(d => d.deliveryNo)).size === 2 && new Set(arranged.result.outbounds.map(o => o.outboundNo)).size === 2) + const detail = await request('GET', path, undefined, 'Read documents created by the one loading-complete command') + const documents = detail.documents + check('Automatic loading creates both customer-isolated outbounds and deliveries', detail.dispatchNo === fixture.dispatchNo && detail.stage === 'DELIVERING' + && documents?.deliveries?.length === 2 && documents.outbounds?.length === 2 + && new Set(documents.deliveries.map(d => d.deliveryNo)).size === 2 && new Set(documents.outbounds.map(o => o.outboundNo)).size === 2) for (const [index, member] of proof.members.entries()) { - const deliveries = arranged.result.deliveries.filter(d => id(d.customerId) === member.customerId) - check('Each customer has exactly its own source-specific delivery and outbound', deliveries.length === 1 && integer(deliveries[0].packageCount) === 1 - && arranged.result.outbounds.some(o => id(o.customerId) === member.customerId && o.outboundNo === deliveries[0].outboundNo && integer(o.packageCount) === 1)) + const deliveries = documents.deliveries.filter(d => id(d.customerId) === member.customerId) + check('Each customer has its own source destination and quantity', deliveries.length === 1 && deliveries[0].status === 'DELIVERING' + && integer(deliveries[0].packageCount) === 1 && deliveries[0].destination?.key === cargoBasis.items.items.find(i => id(i.customerId) === member.customerId)?.destination?.key + && documents.outbounds.some(o => id(o.customerId) === member.customerId && o.status === 'OUTBOUNDED' && o.outboundNo === deliveries[0].outboundNo && integer(o.packageCount) === 1)) Object.assign(member, { outboundNo: deliveries[0].outboundNo, deliveryNo: deliveries[0].deliveryNo }) Object.assign(fixture.members[index], { outboundNo: member.outboundNo, deliveryNo: member.deliveryNo }) } - const arrangedReplay = await request('POST', `${path}/transfer-delivery`, arrangeBody, 'Replay exact native customer arrangement') - check('Native arrangement replay keeps original documents and main version', arrangedReplay.replayed === true && id(arrangedReplay.recordVersion) === id(arranged.recordVersion) - && canonical(arrangedReplay.result) === canonical(arranged.result)) - const ready = await snapshot('READY'), departBody = { recordVersion: ready.task.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } - const departPath = `${pdaPath}/depart` - if (verifyDepartureRollback !== undefined) { - check('Explicit rollback verifier is callable', typeof verifyDepartureRollback === 'function') - const rollback = await verifyDepartureRollback({ context, fixture, ready, departPath, departBody, snapshot }) - check('Mid-transaction rollback has exact owned verification', rollback?.verified === true && rollback.scope === 'OWNED_TWO_CUSTOMER_DEPARTURE_ROLLBACK') - equal(ready, await snapshot('ROLLBACK_RESTORED'), 'Mid-transaction rollback restores both complete customer execution and inventory snapshots') - proof.rollbackEvidence = rollback; proof.midTransactionRollback = 'VERIFIED' - } - const departed = await request('POST', departPath, departBody, 'Depart one cross-customer native dispatch once', true) - check('Departure includes both customers in the one actual native main', departed.dispatchNo === fixture.dispatchNo && departed.status === 'ACTIVE' - && departed.stage === 'DELIVERING' && integer(departed.result?.shippedPackages) === 2 && departed.result.deliveries?.length === 2) + const command = fixture.tripProof.loadingCommand, departed = command.result proof.departure = departed const after = await snapshot('DEPARTED') check('Both customer outbounds completed and main delivering', after.trip.trip.status === 'IN_TRANSIT' && after.dispatch.stage === 'DELIVERING' && after.members.every(row => row.outbound.outbound.status === 'OUTBOUNDED' && row.delivery.order.status === 'DELIVERING')) - equal(ready.members.map(row => row.stock), after.members.map(row => row.stock), 'Departure does not deduct either inventory again') - const replay = await request('POST', departPath, departBody, 'Original PDA actor exact replay of native departure', true) - check('Shared departure replay is explicit and returns original result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion) + equal(cargoBasis.members.map(row => row.stock), after.members.map(row => row.stock), 'Loading completion does not deduct either inventory again') + const replay = await request('POST', command.path, command.body, 'Original PDA actor exact replay of loading completion', true) + check('Loading replay returns the original complete result', replay.replayed === true && id(replay.recordVersion) === id(departed.recordVersion) && canonical(replay.result) === canonical(departed.result)) const replayed = await snapshot('DEPART_REPLAY') - equal(after, replayed, 'Departure replay preserves both complete inventories and execution facts') - await request('POST', `${path}/depart`, departBody, 'Different platform actor cannot reuse the PDA departure command key', false, 'ACTOR_FORBIDDEN') - equal(replayed, await snapshot('FOREIGN_ACTOR_DEPART_REJECTED'), 'Different actor rejection preserves both complete inventories and execution facts') + equal(after, replayed, 'Loading replay preserves both complete inventories and execution documents') + await request('POST', `${path}/loading-complete`, command.body, 'Different platform actor cannot reuse the PDA loading key', false, 'ACTOR_FORBIDDEN') + equal(replayed, await snapshot('FOREIGN_ACTOR_DEPART_REJECTED'), 'Actor rejection preserves both complete inventories and execution facts') for (const [index, member] of proof.members.entries()) { const before = await snapshot(`BEFORE_SIGN_${index}`), documentPath = scoped(`${ROOT}/delivery-orders/${encode(member.deliveryNo)}/documents`, member.customerId) const docs = await request('GET', documentPath, undefined, 'Read this customer delivery document version') diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs index b03b98150..d43409129 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' -import { verifyFleetExternalDispatch, nativeArrangementGroups } from './fleet-external-dispatch-acceptance.mjs' +import { verifyFleetExternalDispatch } from './fleet-external-dispatch-acceptance.mjs' const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', TRIP = '/api/platform/v1/last-leg/fleet/trips/TR-CURRENT' const OWN = '900719925474099301', EXT = '900719925474099302', clone = value => structuredClone(value) @@ -21,12 +21,13 @@ function fixture(fault = '', nativeMode = false) { const tripProof = { tripNo: 'TR-CURRENT', dispatchNo: 'DS-OWN', warehouseId: '92', vehicleId: '71', driverId: '72', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }], externalReceipt: { marker: 'CURRENT', status: 'VERIFIED', incomingNo: 'WT-CURRENT', bookingNo: 'BK-CURRENT', warehousePalletId: '33', palletNo: 'PAL-3', createRequest: { customerId: externalCustomer }, finalSegments: [segment], finalBooking: { cargo: bookingCargo, agreedAmount: 30 } } } if (nativeMode) tripProof.stops.pop() - let recordVersion = 0, fleetVersion = 0, stage = fault === 'already-loaded' ? 'AFTER_LOADING' : 'LOADING', bound = false, planned = false, review = true, saved + let recordVersion = 0, fleetVersion = 0, stage = fault === 'already-loaded' ? 'DELIVERING' : 'LOADING', bound = false, planned = false, review = true, saved const booking = () => ({ bookingNo: 'BK-CURRENT', customerId: externalCustomer, status: bound ? 'BOUND' : 'RECEIVED', incomingNo: 'WT-CURRENT', dispatchNo: bound ? 'DS-OWN' : null, recordVersion: bound ? 1 : 0, cargo: bookingCargo, agreedAmount: 30, receipt: accepted }) const counts = rows => ({ itemCount: rows.length, palletCount: rows.length, packageCount: rows.reduce((n, i) => n + i.scheduledPackages, 0), pickedPackageCount: rows.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 }) - const detail = (full = false) => ({ dispatchNo: full ? 'DS-FULL' : 'DS-OWN', recordVersion: full ? 0 : recordVersion, status: 'ACTIVE', stage: full ? 'AFTER_LOADING' : stage, + const detail = (full = false) => ({ dispatchNo: full ? 'DS-FULL' : 'DS-OWN', recordVersion: full ? 0 : recordVersion, status: 'ACTIVE', stage: full ? 'DELIVERING' : stage, reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(full ? fullItems : items), pickingReconciled: !full && items.every(i => i.pickedPackages === i.scheduledPackages), dispatchInfo: { route: 'A1,A2' }, + documents: stage==='DELIVERING'&&!full?documents():{outbounds:[],deliveries:[]}, orderDetails: (full ? fullItems : items).map(i => ({ sourceKey: i.sourceKey, customerId: i.customerId, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) const ack = (action, result = {}) => ({ ...detail(), action, result, replayed: false }) const groups = () => { @@ -42,6 +43,7 @@ function fixture(fault = '', nativeMode = false) { for (const group of raw) { const previous = merged.get(group.groupKey); if (previous) { previous.cargo.push(...group.cargo); previous.externalQuotes.push(...group.externalQuotes) } else merged.set(group.groupKey, group) } return [...merged.values()].map(g => ({ ...g, requiredServices: g.cargo.some(c => !g.externalQuotes.some(q => q.inboundCode === c.inboundCode)) ? ['DELIVERY_FEE'] : [] })) } + const documents=()=>({outbounds:[...new Set(items.map(i=>i.customerId))].map(customerId=>({customerId,outboundNo:customerId===OWN?'OUT-OWN':'OUT-EXT',status:'OUTBOUNDED',packageCount:items.filter(i=>i.customerId===customerId).reduce((n,i)=>n+i.scheduledPackages,0)})),deliveries:groups().map((g,n)=>({groupKey:g.groupKey,customerId:g.customerId,destination:g.destination,outboundNo:g.customerId===EXT?'OUT-EXT':'OUT-OWN',deliveryNo:`DEL-${n}`,status:'DELIVERING',packageCount:g.cargo.reduce((n,c)=>n+c.packages,0)}))}) const trip = () => ({ trip: { tripNo: 'TR-CURRENT', warehouseId: '92', vehicleId: '71', driverId: '72', recordVersion: fleetVersion, stops: tripProof.stops }, dispatches: [{ dispatchNo: fault === 'second-dispatch' ? 'DS-OTHER' : 'DS-OWN' }] }) const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) const context = { marker: 'CURRENT', createdResources: nativeMode ? { fleetTrips: [tripProof] } : { fleetTrips: [tripProof, { dispatchNo: 'DS-FULL', tripNo: 'TR-FULL', warehouseId: '92' }], fleetFeeCoverageCases: [full, currentCase] }, checkpoint: async () => {}, @@ -49,8 +51,8 @@ function fixture(fault = '', nativeMode = false) { async request(method, raw, body, token) { calls.push({ method, path: raw, body: clone(body), token }) const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), key = `${normalized}:${body?.idempotencyKey}` - if (method !== 'GET' && body?.idempotencyKey && ledger.has(key)) { const original = ledger.get(key); assert.deepEqual(body, original.body); if (fault === 'replay-row-drift' && path.endsWith('/items')) items[0].dispatchItemId = '999'; return response({ ...clone(original.data), replayed: true }) } - const remember = data => { if (body?.idempotencyKey) ledger.set(key, { body: clone(body), data: clone(data) }); return response(data) } + if (method !== 'GET' && body?.idempotencyKey && ledger.has(key)) { const original = ledger.get(key); assert.deepEqual(body, original.body); if (fault === 'replay-row-drift' && path.endsWith('/items')) items[0].dispatchItemId = '999'; if(path.endsWith('/loading-complete')&&token!==original.token)return {http:200,body:{code:'A0403',message:'该请求标识不属于当前操作员'},requestId:'R',traceId:'T'}; return response({ ...clone(original.data), replayed: true }) } + const remember = data => { if (body?.idempotencyKey) ledger.set(key, { body: clone(body), data: clone(data), token }); return response(data) } if (path === `${TRIP}/capacity-plan`) { if (method === 'POST') { assert.equal(body.recordVersion, fleetVersion); assert.equal(body.pallets.length, 3); assert.equal(body.pallets.find(p => p.warehousePalletId === '33').palletSpaces, 2); planned = true; fleetVersion++ } return response({ recordVersion: fleetVersion, manifestHash: 'c'.repeat(64), planValid: items.length === 2 || planned || fault === 'stale-plan', @@ -74,14 +76,11 @@ function fixture(fault = '', nativeMode = false) { } const delivery = path.match(/\/delivery-orders\/DEL-(\d)$/) if (delivery) { - const index = Number(delivery[1]), group = groups()[index], input = saved.groups[index], who = group.customerId - assert.equal(params.get('customerId'), who) - const feeCoverage = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: group.cargo, operatorId: 'OP', confirmedAt: '2026-10-19T01:00:00Z', - references: input.feeCoverage.map(s => ({ services: s.services, source: { ...group.feeSources.find(f => f.financeFeeId === s.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId } })) } : null - const fees = input.fees.map(f => ({ id: String(981 + index), feeDirection: f.direction, feeType: f.feeType, amount: f.amount, currency: f.currency, sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' })) - if (group.externalQuotes.length) fees.push({ id: '980', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', amount: 30, currency: 'CNY', sourceStatus: 'ACTIVE', reviewStatus: 'APPROVED' }) - if (fault === 'duplicate-fee' && group.externalQuotes.length && calls.filter(c => c.path === raw).length > 1) fees.push(clone(fees[0])) - return response({ order: { customerId: who, dispatchNo: 'DS-OWN', deliveryNo: `DEL-${index}`, outboundNo: who === EXT ? 'OUT-EXT' : 'OUT-OWN', destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage }) + const index=Number(delivery[1]),group=groups()[index],who=group.customerId + assert.equal(params.get('customerId'),who) + const fees=group.externalQuotes.length?[{id:'980',feeDirection:'RECEIVABLE',feeType:'DELIVERY_FEE',amount:fault==='stale-price'?50:30,currency:'CNY',sourceStatus:'ACTIVE',reviewStatus:'APPROVED'}]:[] + if(fault==='duplicate-fee'&&fees.length)fees.push(clone(fees[0])) + return response({order:{customerId:who,dispatchNo:'DS-OWN',deliveryNo:`DEL-${index}`,outboundNo:who===EXT?'OUT-EXT':'OUT-OWN',destinationWarehouseCode:group.destination.warehouseCode,status:'DELIVERING',packageCount:group.cargo.reduce((n,c)=>n+c.packages,0)},fees,feeCoverage:null}) } const isFull = path.startsWith(`${BASE}/DS-FULL`), data = detail(isFull), cargo = isFull ? fullItems : items if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) }) @@ -89,7 +88,6 @@ function fixture(fault = '', nativeMode = false) { if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops }) if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1, evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) }) - if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return response({ ...data, cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), groups: groups(), arrange: { allowed: !saved }, arrangement: saved ? { dispatchNo: 'DS-OWN' } : null }) if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) } if (path.endsWith('/pick') && body.recordVersion !== recordVersion) return response(null, '公共版本已变化') assert.equal(body.recordVersion, recordVersion, raw) @@ -97,32 +95,21 @@ function fixture(fault = '', nativeMode = false) { if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) } if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) } if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) } - if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'AFTER_LOADING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } - assert.ok(path.endsWith('/transfer-delivery'), raw); assert.equal(stage, 'AFTER_LOADING') - if (body.cargoHash !== 'a'.repeat(64)) return response(null, '装车或实际货物已经变化') - if (body.groups.some(g => g.feeCoverage.some(s => s.sourceVersion !== 1))) return response(null, '原费用版本已变化') - if (body.groups.some(g => g.externalFreightHash !== 'b'.repeat(64))) return response(null, '外接实收或约定运费已变化') - if (body.groups.some(g => new Set(g.fees.map(f => `${f.direction}:${f.feeType}`)).size !== g.fees.length)) return response(null, '同组同方向同费用不得重复登记') - if (body.groups.some(g => g.groupKey === 'G-PLAN:23' && g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收') - if (!body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对') - saved = clone(body); recordVersion++ - if (fault === 'unknown-arrange') return { http: 0, body: null } - return remember(ack('ARRANGE_DELIVERY', { dispatchNo: 'DS-OWN', cargoHash: 'a'.repeat(64), outbounds: [...new Set(items.map(i => i.customerId))].map(customerId => ({ customerId, outboundNo: customerId === OWN ? 'OUT-OWN' : 'OUT-EXT', status: 'WAIT_OUTBOUND', packageCount: items.filter(i => i.customerId === customerId).reduce((n, i) => n + i.scheduledPackages, 0) })), - deliveries: groups().map((g, n) => ({ groupKey: g.groupKey, customerId: g.customerId, destination: g.destination, outboundNo: g.customerId === EXT ? 'OUT-EXT' : 'OUT-OWN', deliveryNo: `DEL-${n}`, status: 'WAIT_DELIVERY', packageCount: g.cargo.reduce((n, c) => n + c.packages, 0) })) })) + if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + throw Error(`Unexpected ${method} ${raw}`) } } - const options = nativeMode ? { pickerToken: 'OFFLINE-ONLY', arrangement: { mode: 'NATIVE', marker: 'CURRENT', dispatchNo: 'DS-OWN', plannedDeliveryAt: '2026-10-22T01:00:00Z', deliveryMethod: 'TRUCK_PALLET', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', appointmentAccount: 'SELF', - groups: [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户A明确派送费', currency: 'USD', amount: 11 }] }, { customerId: EXT, destinationKey: 'FBA:A1', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '客户B明确派送费', currency: 'CNY', amount: 17 }] }] } } : { coverageCase: currentCase, pickerToken: 'OFFLINE-ONLY' } + const options = nativeMode ? {pickerToken:'OFFLINE-ONLY'} : {coverageCase:currentCase,pickerToken:'OFFLINE-ONLY'} return { context, tripProof, currentCase, calls, assertions, items, options } } test('native external flow adds actual received cargo to same whole, re-reviews, scans, loads and arranges all customer groups', async () => { const f = fixture(), proof = await verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options) assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.dispatchNo, 'DS-OWN'); assert.equal(proof.sourceFees.length, 1); assert.equal(proof.sourceFees[0].chargeableQuantity, 2) - assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED'); assert.equal(f.currentCase.proof.status, 'VERIFIED'); assert.equal(f.currentCase.proof.newReceivable, 17.5) + assert.equal(proof.financeAcknowledgement, 'NOT_VERIFIED'); assert.equal(f.currentCase.proof.status, 'VERIFIED'); assert.equal(f.currentCase.proof.feeCoverageSelection,'NOT_APPLICABLE_AUTOMATIC_DELIVERY') assert.ok(f.assertions.every(a => a.passed)); assert.equal(f.calls.some(c => /pre-arrange|dispatches\/bind|dispatches\/unbind|\/cancel/.test(c.path)), false) assert.equal(f.calls.filter(c => c.method === 'POST' && c.path === BASE).length, 0) assert.ok(f.calls.findIndex(c => c.path.endsWith('/return-to-review')) < f.calls.findIndex(c => c.path.endsWith('/items') && c.method === 'POST')) - assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery'))) + assert.equal(f.calls.some(c => c.path.endsWith('/transfer-delivery')||c.path.endsWith('/depart')),false) assert.equal(f.items.length, 3); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /尚未装入/) }) @@ -133,8 +120,8 @@ for (const fault of ['foreign-customer', 'stale-plan', 'double-capacity', 'revie }) test('no external callback injection can replace real registered PARTIAL sources, current PDA or durable journal', async () => { - for (const mutate of [f => { delete f.options.coverageCase }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { - const f = fixture(); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options), /同主单未扫码PARTIAL/); assert.equal(f.calls.length, 0) + for (const mutate of [f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { + const f = fixture(); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external', f.tripProof, f.options)); assert.equal(f.calls.length, 0) } }) @@ -145,52 +132,14 @@ test('server binding requires the same existing whole dispatch and public cargo assert.equal(source.includes('/pre-arrange'), false); assert.equal(source.includes('draftToken'), false); assert.ok(source.includes('/return-to-review')); assert.ok(source.includes("'ADD_CARGO'")) }) -test('ordinary native mode accepts two WAREHOUSE_TRANSFER sources and same-customer external cargo without FULL/PARTIAL fixtures', async () => { - const f = fixture('', true), proof = await verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options) - assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.arrangementMode, 'NATIVE'); assert.equal(proof.nativeArrangement.status, 'VERIFIED') - assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false); assert.equal(f.currentCase.proof, undefined) - assert.equal(proof.deliveries.length, 2); assert.equal(proof.deliveries.reduce((n, d) => n + d.packageCount, 0), 3) - assert.ok(f.items.some(i => i.inboundCode === 'WT-OWN-1') && f.items.some(i => i.inboundCode === 'WT-OWN-2')); assert.equal(f.items.length, 3) - const requests = f.calls.filter(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')), actual = requests.at(-1) - assert.deepEqual(proof.transferRequest.groups.map(g => g.fees.map(f => [f.currency, f.amount])), [[['USD', 11]], [['CNY', 17]]]) - assert.ok(proof.transferRequest.groups.every(g => g.feeCoverage.length === 0 && !g.groupKey.startsWith('FBA:'))) - assert.equal(requests.length, 5); assert.equal(actual.body.idempotencyKey, proof.transferRequest.idempotencyKey) - assert.ok(f.calls.findIndex(c => c.path.endsWith('/pick')) < f.calls.findIndex(c => c.path.endsWith('/loading-complete'))) - assert.ok(f.calls.findIndex(c => c.path.endsWith('/loading-complete')) < f.calls.findIndex(c => c.path.endsWith('/transfer-delivery'))) - assert.ok(f.calls.every(c => !c.path.includes('DS-FULL') && !c.path.includes('pre-arrange'))); assert.ok(f.assertions.every(a => a.passed)) - const snapshots = proof.nativeArrangement.snapshots - assert.deepEqual(snapshots['DEL-0'].fees.map(f => [f.currency, f.amount]), [['USD', 11], ['CNY', 30]]) - assert.deepEqual(snapshots['DEL-1'].fees.map(f => [f.currency, f.amount]), [['CNY', 17]]) - assert.equal(proof.sourceFees[0].originalAmount, 30); assert.equal(proof.sourceFees[0].customerId, OWN) +test('ordinary current sources automatically deliver without manual fee configuration',async()=>{ + const f=fixture('',true),proof=await verifyFleetExternalDispatch(f.context,'external-native',f.tripProof,f.options) + assert.equal(proof.status,'VERIFIED');assert.equal(proof.fulfillmentMode,'AUTOMATIC_DELIVERY') + assert.equal(proof.deliveries.length,2);assert.equal(proof.deliveries.reduce((n,d)=>n+d.packageCount,0),3) + assert.ok(proof.deliveries.every(d=>d.status==='DELIVERING'));assert.ok(proof.loadingRequest.idempotencyKey) + assert.equal(f.calls.some(c=>c.path.endsWith('/transfer-delivery')||c.path.endsWith('/depart')),false) + assert.equal(proof.sourceFees[0].originalAmount,30) }) - -test('ordinary native configuration and coverage mode are exclusive and require current explicit group configuration', async () => { - for (const mutate of [f => { delete f.options.arrangement }, f => { f.options.coverageCase = f.currentCase }, f => { f.options.arrangement.mode = 'SAME_ROUTE' }, - f => { f.options.arrangement.marker = 'OLD' }, f => { f.options.arrangement.dispatchNo = 'OTHER' }, f => { f.options.arrangement.groups.push(clone(f.options.arrangement.groups[0])) }, - f => { f.options.arrangement.groups = async () => ({ success: true }) }, f => { f.options.arrangement.groups[0].fees[0].amount = 0 }, f => { f.options.arrangement.groups[0].fees.push(clone(f.options.arrangement.groups[0].fees[0])) }, - f => { f.options.arrangement.groups[0].feeCoverage = [] }, f => { delete f.options.pickerToken }, f => { delete f.context.checkpoint }]) { - const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.equal(f.calls.length, 0) - } - for (const mutate of [f => { f.options.arrangement.groups.pop() }, f => { f.options.arrangement.groups[0].customerId = '999' }]) { - const f = fixture('', true); mutate(f); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options), /加货前精确覆盖/); assert.equal(f.calls.some(c => c.method !== 'GET'), false) - } -}) - -for (const fault of ['foreign-customer', 'double-capacity', 'review-not-applied', 'stale-price', 'duplicate-fee', 'foreign-source-fee', 'wrong-quantity', 'replay-row-drift', 'already-loaded', 'missing-measurements', 'unknown-arrange']) test(`ordinary native flow preserves protection against ${fault}`, async () => { - const f = fixture(fault, true); await assert.rejects(verifyFleetExternalDispatch(f.context, 'external-native', f.tripProof, f.options)); assert.notEqual(f.tripProof.externalDispatch.status, 'VERIFIED') - assert.equal(Object.hasOwn(f.context.createdResources, 'fleetFeeCoverageCases'), false) - if (fault === 'missing-measurements') assert.equal(f.calls.some(c => c.path.endsWith('/loading-complete') || c.path.endsWith('/transfer-delivery')), false) - if (fault === 'unknown-arrange') { assert.equal(f.tripProof.externalDispatch.status, 'OUTCOME_UNKNOWN'); assert.equal(f.calls.at(-1).path.endsWith('/transfer-delivery'), true); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true); assert.ok(f.tripProof.externalDispatch.nativeArrangement.confirmationRequest.idempotencyKey) } -}) - -test('ordinary native plan permits explicit zero extra fee only for fully quoted cargo and never expands the quote into each group', () => { - const f = fixture('', true), receipt = f.tripProof.externalReceipt, plan = clone(f.options.arrangement) - plan.groups = [{ customerId: OWN, destinationKey: 'FBA:A1', fees: [] }] - const group = { groupKey: 'REAL-GROUP', customerId: OWN, destination: { key: 'FBA:A1' }, groupCargoHash: 'a'.repeat(64), externalFreightHash: 'b'.repeat(64), feeCoverageCargoHash: 'a'.repeat(64), blockReasons: [], requiredServices: [], feeSources: [], - cargo: [{ customerId: OWN, inboundCode: receipt.incomingNo, destination: { key: 'FBA:A1' } }], externalQuotes: [{ groupKey: 'REAL-GROUP', customerId: OWN, bookingNo: receipt.bookingNo, inboundCode: receipt.incomingNo, currency: 'CNY', amount: 30, sourceHash: 'b'.repeat(64) }] } - const groups = nativeArrangementGroups({ groups: [group] }, plan, receipt) - assert.deepEqual(groups[0].fees, []); assert.deepEqual(groups[0].feeCoverage, []); assert.equal(groups[0].groupKey, 'REAL-GROUP') - plan.groups[0].fees = [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: 'duplicate', currency: 'CNY', amount: 30 }] - assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /不得重复登记/) - plan.groups[0].fees = []; group.requiredServices = ['DELIVERY_FEE']; assert.throws(() => nativeArrangementGroups({ groups: [group] }, plan, receipt), /尚未覆盖/) +for(const fault of ['foreign-customer','double-capacity','review-not-applied','stale-price','duplicate-fee','foreign-source-fee','wrong-quantity','replay-row-drift','already-loaded','missing-measurements'])test(`automatic external flow rejects ${fault}`,async()=>{ + const f=fixture(fault,true);await assert.rejects(verifyFleetExternalDispatch(f.context,'external-native',f.tripProof,f.options));assert.notEqual(f.tripProof.externalDispatch.status,'VERIFIED') }) diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs index 78d9b70b6..e55341371 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance.mjs @@ -1,141 +1,19 @@ import { DISPATCH_BASE as BASE, id as stringId, version, createBody, candidatePage, readDispatch, replay } from './grouped-dispatch-acceptance.mjs' -import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' +import { fleetConsumerIo, readyFleetDispatch, stableRows, verifyFleetFeeCoverage, readAutomaticDelivery } from './fleet-fee-coverage-acceptance.mjs' const clone = value => structuredClone(value) const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) } const money = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('外接约定金额或单价不完整'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) } const identity = item => ({ dispatchItemId: id(item.dispatchItemId), sourceKey: item.sourceKey, customerId: id(item.customerId), warehousePalletId: id(item.warehousePalletId), scheduledPackages: Number(item.scheduledPackages), pickedPackages: Number(item.pickedPackages) }) -const groupIdentity = (customerId, destinationKey) => JSON.stringify([id(customerId), destinationKey]) -const sha = value => /^[a-f0-9]{64}$/i.test(value || '') -const feeIdentity = fee => `${fee.direction}:${fee.feeType}:${fee.currency}:${money(fee.amount)}` -function validateNativeArrangement(context, tripProof, plan) { - if (plan?.mode !== 'NATIVE' || plan.marker !== context.marker || plan.dispatchNo !== tripProof.dispatchNo - || typeof plan.plannedDeliveryAt !== 'string' || !Number.isFinite(Date.parse(plan.plannedDeliveryAt)) - || !plan.deliveryMethod || !plan.pickupMethod || plan.loadingType !== 'PALLET' || plan.appointmentAccount !== 'SELF' - || !Array.isArray(plan.groups) || !plan.groups.length) throw Error('普通原生安排须明确本轮主单、派送时间、PALLET/SELF及逐客户目的地费用') - const keys = new Set() - for (const group of plan.groups) { - const key = groupIdentity(group.customerId, group.destinationKey) - if (!/^[1-9]\d*$/.test(id(group.customerId)) || typeof group.destinationKey !== 'string' || !group.destinationKey.trim() || keys.has(key) || !Array.isArray(group.fees) || Object.hasOwn(group, 'feeCoverage')) throw Error('普通安排的客户目的地配置缺失、重复或含原费覆盖选择') - keys.add(key) - const fees = new Set() - for (const fee of group.fees) { - const feeKey = `${fee.direction}:${fee.feeType}` - if (!['RECEIVABLE', 'PAYABLE'].includes(fee.direction) || !['DELIVERY_FEE', 'LOADING_FEE', 'APPOINTMENT_FEE'].includes(fee.feeType) - || !fee.feeName || !['CNY', 'USD'].includes(fee.currency) || money(fee.amount) <= 0n || fees.has(feeKey)) throw Error('普通安排费用必须逐组明确正数、币种和服务,不得重复') - fees.add(feeKey) - } - } -} - -// 仅将明确的客户/目的地配置映射到实时groupKey与摘要;不推算费用或原费覆盖。 -export function nativeArrangementGroups(preview, plan, receipt) { - const configured = new Map(plan.groups.map(g => [groupIdentity(g.customerId, g.destinationKey), g])) - if (!Array.isArray(preview.groups) || preview.groups.length !== configured.size || new Set(preview.groups.map(g => groupIdentity(g.customerId, g.destination?.key))).size !== configured.size) throw Error('普通安排配置必须精确覆盖全车全部真实客户目的地') - return preview.groups.map(group => { - const config = configured.get(groupIdentity(group.customerId, group.destination?.key)) - if (!config || !group.groupKey || !sha(group.groupCargoHash) || !sha(group.externalFreightHash) || !sha(group.feeCoverageCargoHash) - || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.requiredServices) || !Array.isArray(group.externalQuotes) - || !Array.isArray(group.feeSources) || !Array.isArray(group.cargo) || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('普通原生安排的实际分组或来源门禁不完整') - if (group.externalQuotes.some(q => q.bookingNo !== receipt.bookingNo || q.inboundCode !== receipt.incomingNo || id(q.customerId) !== id(group.customerId) - || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash || q.currency !== receipt.finalBooking.cargo.currency || money(q.amount) !== money(receipt.finalBooking.agreedAmount))) throw Error('普通安排的外接报价已变化或越过客户来源') - if (group.requiredServices.some(service => !config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === service))) throw Error('普通安排必须明确填写本组尚未覆盖的服务费用') - const quotedAll = group.cargo.every(c => group.externalQuotes.some(q => q.inboundCode === c.inboundCode)) - if (quotedAll && config.fees.some(f => f.direction === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE')) throw Error('本组派送已由外接约定覆盖,不得重复登记应收') - return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: plan.plannedDeliveryAt, - deliveryMethod: plan.deliveryMethod, pickupMethod: plan.pickupMethod, loadingType: plan.loadingType, appointmentAccount: plan.appointmentAccount, - waybillNo: plan.waybillNo || null, carrierName: plan.carrierName || null, vehicleType: plan.vehicleType || null, vehicleSize: plan.vehicleSize || null, sellerName: null, remark: plan.remark || null, - externalFreightHash: group.externalFreightHash, feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], fees: clone(config.fees) } - }) -} - -async function arrangeNative(context, module, io, proof, tripProof, plan, pickerToken, actualMeasurements) { - const current = await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements }) - const path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}`, preview = await io.get(`${path}/delivery-arrangement`, '普通模式读取装车后实时全车派送分组') - io.check('普通安排使用当前公共版本和真实实载摘要', null, preview?.dispatchNo === tripProof.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' - && id(version(preview.recordVersion)) === id(version(current.data.recordVersion)) && preview.arrange?.allowed === true && !preview.arrangement && sha(preview.cargoHash) && sha(preview.loadingCompletionHash)) - const groups = nativeArrangementGroups(preview, plan, tripProof.externalReceipt), totals = new Map() - for (const group of preview.groups) for (const row of group.cargo) { - io.check('普通派送货物保留实际客户目的地和原物理行', null, current.items.items.some(i => id(i.dispatchItemId) === id(row.dispatchItemId) && id(i.customerId) === id(group.customerId) && i.destination.key === group.destination.key && id(i.warehousePalletId) === id(row.warehousePalletId))) - totals.set(id(row.dispatchItemId), (totals.get(id(row.dispatchItemId)) || 0) + Number(row.packages)) - } - io.check('普通安排逐物理行箱数完整守恒', null, totals.size === current.items.items.length && current.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages))) - const body = { recordVersion: version(preview.recordVersion), idempotencyKey: io.key('native-arrangement'), cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups } - proof.nativeArrangement = { mode: 'NATIVE', confirmationRequest: clone(body), status: 'CHECKING' }; await context.checkpoint() - const noDocuments = async () => { - for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) { - const rows = []; let total - for (let page = 1; ; page++) { - const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId, page: String(page), size: '200' })}`, '普通安排拒绝前后按客户回查无出库写入') - io.check('普通安排出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.total) >= 0 && Number(data.page) === page && Number(data.size) === 200 - && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total))) - total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break - } - io.check('普通安排拒绝未生成本主单出库', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== tripProof.dispatchNo)) - } - } - const reject = async (action, changed, pattern) => { - const before = await readDispatch(io, tripProof.dispatchNo) - await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: io.key(action) }, action, pattern) - await noDocuments(); io.check(`${action}不改整车版本及实物`, null, JSON.stringify(await readDispatch(io, tripProof.dispatchNo)) === JSON.stringify(before)) - } - await noDocuments() - const changedHash = hash => `${hash[0] === '0' ? '1' : '0'}${hash.slice(1)}` - await reject('普通安排陈旧货物摘要拒绝', { cargoHash: changedHash(body.cargoHash) }, /货物.*变化/) - const quoted = preview.groups.find(g => g.externalQuotes.length) - io.check('普通安排包含本轮已确认外接报价', null, Boolean(quoted)) - const stale = clone(groups); stale.find(g => g.groupKey === quoted.groupKey).externalFreightHash = changedHash(quoted.externalFreightHash) - await reject('普通安排陈旧外接报价摘要拒绝', { groups: stale }, /外接.*变化/) - const duplicate = clone(groups), quote = quoted.externalQuotes[0] - duplicate.find(g => g.groupKey === quoted.groupKey).fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接应收反例', currency: quote.currency, amount: quote.amount }) - await reject('普通安排重复外接应收拒绝', { groups: duplicate }, /重复|覆盖/) - const command = await io.request('POST', `${path}/transfer-delivery`, body, '普通原生模式一次安排全车全部客户目的地'), result = command?.result - io.check('普通原生命令完整返回真实客户出库和目的地派送', null, command?.dispatchNo === tripProof.dispatchNo && command.action === 'ARRANGE_DELIVERY' && command.status === 'ACTIVE' && command.stage === 'AFTER_LOADING' - && BigInt(version(command.recordVersion)) > BigInt(version(body.recordVersion)) && result?.dispatchNo === tripProof.dispatchNo && result.cargoHash === body.cargoHash - && result.deliveries?.length === preview.groups.length && new Set(result.deliveries.map(d => d.deliveryNo)).size === result.deliveries.length - && result.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size - && result.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key - && g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) && result.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId))) - && result.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && Number(o.packageCount) === result.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0))) - const snapshots = {} - for (const ref of result.deliveries) { - const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = groups.find(g => g.groupKey === ref.groupKey) - const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排回查客户目的地及明确费用') - const expectedFees = [...input.fees.map(feeIdentity), ...group.externalQuotes.filter(q => money(q.amount) > 0n).map(q => feeIdentity({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: q.currency, amount: q.amount }))] - io.check('普通派送保留本客户本目的地费用,无伪零、重复或原费覆盖回执', null, detail?.order?.dispatchNo === tripProof.dispatchNo && detail.order.deliveryNo === ref.deliveryNo && id(detail.order.customerId) === id(ref.customerId) - && detail.order.outboundNo === ref.outboundNo && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId) - && (detail.feeCoverage == null || detail.feeCoverage.references?.length === 0) && Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length - && detail.fees.every(f => money(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED') - && stableRows(detail.fees.map(f => feeIdentity({ direction: f.feeDirection, feeType: f.feeType, currency: f.currency, amount: f.amount }))) === stableRows(expectedFees)) - snapshots[ref.deliveryNo] = { fees: clone(detail.fees), feeCoverage: clone(detail.feeCoverage) } - } - const again = await replay(io, `${path}/transfer-delivery`, body, command, '普通安排原键原载荷重放'); io.check('普通安排明确恢复原成功命令', null, again.replayed === true) - for (const ref of result.deliveries) { - const after = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '普通安排重放后核对不追加应收') - io.check('普通安排重放不增费不改覆盖快照', null, JSON.stringify({ fees: after.fees, feeCoverage: after.feeCoverage }) === JSON.stringify(snapshots[ref.deliveryNo])) - } - const after = await io.get(`${path}/delivery-arrangement`, '普通安排后回查报价与原费用候选未被改写') - const originalFacts = value => stableRows(value.groups.map(g => ({ groupKey: g.groupKey, externalQuotes: g.externalQuotes, feeSources: g.feeSources }))) - io.check('普通安排保持报价及原费用来源快照', null, originalFacts(after) === originalFacts(preview)) - Object.assign(proof.nativeArrangement, { status: 'VERIFIED', command, snapshots }) - return { confirmationRequest: body, deliveries: result.deliveries, outbounds: result.outbounds } -} - -/** 已确认实收来源只ADD_CARGO到原整车;复审、真实扫码、显式装车后一次安排全部客户。 - * options二选一:coverageCase为本轮PARTIAL;或arrangement={mode:'NATIVE',marker,dispatchNo, - * plannedDeliveryAt,deliveryMethod,pickupMethod,loadingType:'PALLET',appointmentAccount:'SELF', - * groups:[{customerId,destinationKey,fees:FeeItem[]}]}; 金额和费用覆盖均不自动推断。 - */ -export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase, arrangement } = {}) { +/** 将本轮已实收外接货加入原车,复审及真实装车后读取自动派送。 */ +export async function verifyFleetExternalDispatch(context, module, tripProof, { pickerToken, actualMeasurements, coverageCase } = {}) { const receipt = tripProof?.externalReceipt, cases = context.createdResources?.fleetFeeCoverageCases if (!context.createdResources?.fleetTrips?.includes(tripProof) || receipt?.status !== 'VERIFIED' || tripProof.externalDispatch) throw Error('外接只接受本轮已运营确认且尚未装入本车的真实来货') - if (Boolean(coverageCase) === Boolean(arrangement)) throw Error('必须且只能选择同主单未扫码PARTIAL费用覆盖或普通NATIVE安排模式') - if (arrangement) validateNativeArrangement(context, tripProof, arrangement) if (coverageCase && (!cases?.includes(coverageCase) || coverageCase.marker !== context.marker || coverageCase.mode !== 'PARTIAL' || coverageCase.dispatchNo !== tripProof.dispatchNo || coverageCase.proof) || !pickerToken || typeof context.checkpoint !== 'function') throw Error('外接加货前须有同主单未扫码PARTIAL用例及本轮PDA身份;必须先外接后扫码安排') - const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, arrangementMode: arrangement ? 'NATIVE' : 'FEE_COVERAGE', status: 'CHECKING', requests: [] } + const proof = tripProof.externalDispatch = { marker: context.marker, dispatchNo: tripProof.dispatchNo, fulfillmentMode: 'AUTOMATIC_DELIVERY', status: 'CHECKING', requests: [] } const io = fleetConsumerIo(context, module, proof, `EXTERNAL-${tripProof.dispatchNo}`) const base = `/api/platform/v1/last-leg/fleet/trips/${encodeURIComponent(tripProof.tripNo)}`, path = `${BASE}/${encodeURIComponent(tripProof.dispatchNo)}` const commandReplay = async (path, body, result, label) => { const again = await replay(io, path, body, result, label); io.check(`${label}明确恢复原结果`, null, again.replayed === true) } @@ -163,10 +41,6 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, { io.check('候选仅为本次确认的全部真实外接来源', null, candidates.length === actual.length && candidates.every(c => c.destination.key === booking.cargo.destinationKey && actual.some(p => id(p.warehousePalletId) === id(c.selection.warehousePalletId) && p.palletNo === c.palletNo && Number(p.packages) === Number(c.availablePackages)))) const addition = createBody(context.marker, `EXTERNAL-${tripProof.dispatchNo}`, candidates) - if (arrangement) { - const actualGroups = new Set([...current.items.items.map(i => groupIdentity(i.customerId, i.destination?.key)), ...candidates.map(c => groupIdentity(c.customerId, c.destination?.key))]) - io.check('普通安排配置在加货前精确覆盖全部实际客户目的地', null, stableRows([...actualGroups]) === stableRows(arrangement.groups.map(g => groupIdentity(g.customerId, g.destinationKey)))) - } if (current.data.stage === 'LOADING') { proof.returnRequest = { recordVersion: version(current.data.recordVersion), idempotencyKey: io.key('explicit-return'), reason: '本轮零扫码外接实收显式退回装车前,保留同一整车' } const returned = await io.request('POST', `${path}/return-to-review`, proof.returnRequest, '零扫码明确退回审核') @@ -218,14 +92,10 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, { const ready = await io.get(path, '核对两级审核恢复真实扫码资格') io.check('同主单两级审核通过且仍零扫码', null, ready.stage === 'LOADING' && ready.reviewState === 'APPROVED' && ready.reviews?.dispatchReview?.status === 'APPROVED' && ready.reviews?.loadingReview?.status === 'APPROVED' && Number(ready.counts.pickedPackageCount) === 0) - const execution = arrangement ? await arrangeNative(context, module, io, proof, tripProof, arrangement, pickerToken, actualMeasurements) - : await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof }) - const groups = await io.get(`${path}/delivery-arrangement`, '安排后回查原生外接报价快照') - const quotes = groups.groups.flatMap(g => g.externalQuotes), expected = receipt.finalBooking - const matching = quotes.filter(q => q.bookingNo === receipt.bookingNo && q.inboundCode === receipt.incomingNo && id(q.customerId) === id(booking.customerId)) - io.check('原外接报价属于本次客户和来源且等于运营确认约定金额', null, matching.length === 1 && money(matching[0].amount) === money(expected.agreedAmount) - && matching[0].currency === expected.cargo.currency && matching[0].sourceHash && money(expected.agreedAmount) > 0n) - const deliveries = execution.deliveries.filter(d => groups.groups.some(g => g.groupKey === d.groupKey && g.externalQuotes.some(q => q.bookingNo === receipt.bookingNo))) + const execution = coverageCase ? await verifyFleetFeeCoverage(context, module, coverageCase, { pickerToken, actualMeasurements, externalTrip: tripProof }) + : await readAutomaticDelivery(io, await readyFleetDispatch(context, module, io, tripProof, { pickerToken, actualMeasurements }), tripProof) + const expected = receipt.finalBooking + const deliveries = execution.deliveries.filter(d => id(d.customerId) === id(booking.customerId) && d.destination?.key === booking.cargo.destinationKey) io.check('本次外接来源只落入其客户目的地派送', null, deliveries.length === 1 && id(deliveries[0].customerId) === id(booking.customerId)) proof.outboundNo = deliveries[0].outboundNo; proof.deliveryNo = deliveries[0].deliveryNo; proof.deliveries = execution.deliveries const delivery = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(proof.deliveryNo)}?customerId=${encodeURIComponent(id(booking.customerId))}`, '回查本客户外接应收来源') @@ -236,8 +106,8 @@ export async function verifyFleetExternalDispatch(context, module, tripProof, { && fees[0].originalCurrency === expected.cargo.currency && fees[0].reviewStatus === 'APPROVED' && id(fees[0].customerId) === id(booking.customerId) && quantity != null && Number(fees[0].chargeableQuantity) === Number(quantity) && money(fees[0].unitPrice) === money(expected.cargo.unitPrice) && fees[0].quantityUnit === (expected.cargo.priceBasis === 'PALLET' ? '托位' : '单') - && fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id))) - proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.transferRequest = execution.confirmationRequest + && fees[0].businessNo === proof.deliveryNo && delivery.fees.some(f => id(f.id) === id(fees[0].id) && money(f.amount) === money(fees[0].originalAmount) && f.currency === fees[0].originalCurrency)) + proof.customerId = id(booking.customerId); proof.sourceFees = fees; proof.loadingRequest = execution.confirmationRequest proof.finalTripVersion = (await io.get(base, '交接同车资源版本')).trip.recordVersion proof.financeAcknowledgement = 'NOT_VERIFIED'; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof } catch (error) { proof.status = proof.outcomeUnknown || context.createdResources.dispatchWriteBoundary?.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error } diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs index 1ad89c9f2..3581828b5 100644 --- a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance-contract.test.mjs @@ -1,122 +1,39 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { readFileSync } from 'node:fs' -import { verifyFleetFeeCoverage, coveragePlan } from './fleet-fee-coverage-acceptance.mjs' - -const BASE = '/api/platform/v1/last-leg/dispatches', PDA = '/api/pda/v1/last-leg/dispatches', customerId = '900719925474099301' -const clone = value => structuredClone(value) -function fixture(mode = 'FULL', fault = '', needsLoading = false) { - const cases = ['FULL', 'PARTIAL'].map((mode, index) => ({ marker: 'CURRENT-TEST', mode, dispatchNo: `DS-${index + 1}`, customerId, - transferBody: { plannedDeliveryAt: '2026-10-22T01:00:00Z', loadingType: 'PALLET', appointmentAccount: 'SELF', fees: [{ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '派送费', amount: 17.5, currency: 'USD' }] } })) - const source = cases.find(c => c.mode === mode), calls = [], assertions = [], ledger = new Map(), saved = new Map() - const states = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, recordVersion: 0, stage: needsLoading && c === source ? 'LOADING' : 'AFTER_LOADING', items: [1, 2].map(i => ({ - dispatchItemId: `${n + 1}1${i}`, sourceKey: `PLAN:${n + 1}2${i}`, sourceVersion: 'a'.repeat(64), customerId, warehousePalletId: `${n + 1}3${i}`, planPalletId: `${n + 1}2${i}`, - inboundCode: `IN-${n + 1}-${i}`, destination: { key: `FBA:A${i}`, warehouseCode: `A${i}` }, scheduledPackages: 2, pickedPackages: needsLoading && c === source ? 0 : 2 })) })) - if (fault === 'overlap') states[1].items[0].warehousePalletId = states[0].items[0].warehousePalletId - const triples = cases.map((c, n) => ({ dispatchNo: c.dispatchNo, tripNo: `TR-${n + 1}`, warehouseId: '92', stops: [{ index: 0, destinationKey: 'ORIGIN:92' }, { index: 1, destinationKey: 'FBA:A1' }, { index: 2, destinationKey: 'FBA:A2' }] })) - let dirty = false, replayed = false - const totals = s => ({ itemCount: s.items.length, palletCount: s.items.length, packageCount: 4, pickedPackageCount: s.items.reduce((n, i) => n + i.pickedPackages, 0), shippedPackageCount: 0 }) - const detail = s => ({ dispatchNo: s.dispatchNo, status: 'ACTIVE', stage: s.stage, recordVersion: s.recordVersion, counts: totals(s), pickingReconciled: s.items.every(i => i.pickedPackages === 2), - dispatchInfo: { route: 'A1,A2' }, orderDetails: s.items.map(i => ({ customerId, sourceKey: i.sourceKey, inboundCode: i.inboundCode, destinationFbaWarehouse: i.destination.warehouseCode })) }) - const cargo = s => s.items.map((i, n) => ({ ...i, orderId: `4${s.dispatchNo.slice(-1)}${n + 1}`, sourceRegion: 'FIRSTLEG', sourceInboundId: `9007199254740994${s.dispatchNo.slice(-1)}${n + 1}`, packages: 2 })) - const groups = s => cargo(s).map((row, n) => ({ groupKey: `G-${s.dispatchNo}-${n}`, customerId, destination: row.destination, groupCargoHash: `${n + 1}`.repeat(64), externalFreightHash: 'b'.repeat(64), externalQuotes: [], feeCoverageCargoHash: `${n + 1}`.repeat(64), cargo: [row], blockReasons: [], requiredServices: ['DELIVERY_FEE'], - feeSources: fault === 'missing-source' && n === 1 ? [] : [{ financeFeeId: `9007199254740995${s.dispatchNo.slice(-1)}${n}`, customerId: fault === 'foreign-customer' && n === 0 ? '999' : customerId, - currency: 'CNY', amount: fault === 'mutated-original' && saved.has(s.dispatchNo) ? 201 : 200, sourceVersion: 1, sourceHash: `fee-hash-${n}`, status: 'AVAILABLE', services: ['DELIVERY_FEE'], sourceKeys: [row.sourceKey] }] })) - const ack = (s, action, result = {}) => ({ dispatchNo: s.dispatchNo, action, recordVersion: s.recordVersion, status: 'ACTIVE', stage: s.stage, counts: totals(s), result, replayed: false }) - const response = (data, message = '') => ({ http: 200, body: { code: message ? 'A0409' : '200', message, data: clone(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) - const context = { marker: source.marker, createdResources: { fleetFeeCoverageCases: cases, fleetTrips: triples }, checkpoint: async () => {}, - assert(_module, name, _response, passed) { assertions.push({ name, passed }) }, addStep(_module, name, _response, passed) { assertions.push({ name, passed }) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, - async request(method, raw, body, token) { - calls.push({ method, path: raw, body: clone(body), token }) - const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, BASE), number = normalized.slice(BASE.length + 1).split('/')[0] - const state = states.find(s => s.dispatchNo === number), commandKey = `${normalized}:${body?.idempotencyKey}` - if (method === 'POST' && ledger.has(commandKey)) { assert.deepEqual(body, ledger.get(commandKey).body); replayed = true; return response({ ...clone(ledger.get(commandKey).data), replayed: true }) } - const remember = data => { ledger.set(commandKey, { body: clone(body), data: clone(data) }); return response(data) } - if (method === 'GET' && path === BASE) return response({ records: states.filter(s => s.dispatchNo === params.get('dispatchNo')).map(s => ({ ...detail(s), warehouseId: '92' })) }) - if (path.includes('/outbound-orders?') || raw.includes('/outbound-orders?')) return response({ records: dirty ? [{ outboundNo: 'OUT-DIRTY', dispatchNo: source.dispatchNo }] : [], total: dirty ? 1 : 0, page: 1, size: 200 }) - if (path.match(/\/outbound-orders\/OUT-DS-[12]$/)) { const n = path.slice(path.lastIndexOf('OUT-') + 4), s = states.find(s => s.dispatchNo === n), p = saved.get(n); return response({ outbound: { outboundNo: `OUT-${n}`, dispatchNo: n, status: 'WAIT_OUTBOUND' }, deliveries: p.groups.map((g, i) => ({ deliveryNo: `DEL-${n}-${i}`, status: 'WAIT_DELIVERY' })) }) } - const delivery = path.match(/\/delivery-orders\/DEL-(DS-[12])-(\d)$/) - if (delivery) { - assert.equal(params.get('customerId'), customerId) - const s = states.find(s => s.dispatchNo === delivery[1]), index = Number(delivery[2]), group = groups(s)[index], input = saved.get(s.dispatchNo).groups[index] - const references = input.feeCoverage.map(selection => ({ source: { ...group.feeSources.find(f => f.financeFeeId === selection.financeFeeId), sourceType: 'INBOUND_FEE', sourceInboundId: group.cargo[0].sourceInboundId, - ...(fault === 'snapshot-source-amount' ? { amount: 999 } : {}) }, services: selection.services })) - const evidence = input.feeCoverage.length ? { cargoHash: group.feeCoverageCargoHash, cargo: fault === 'wrong-cargo' ? cargo(s) : group.cargo, - references: fault === 'missing-reference' ? [] : references, operatorId: 'OP', confirmedAt: replayed && fault === 'rewrite-confirmation' ? '2026-10-19T02:00:00Z' : '2026-10-19T01:00:00Z' } : null - let fees = input.fees.map(f => ({ id: `8${index}`, feeDirection: f.direction, feeType: f.feeType, currency: f.currency, amount: f.amount, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' })) - if (fault === 'duplicate-new-fee' && index === 0 || fault === 'replay-adds-fee' && replayed) fees.push({ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }) - if (fault === 'charge-covered-destination') fees = index === 0 ? [{ id: '999', feeDirection: 'RECEIVABLE', feeType: 'DELIVERY_FEE', currency: 'USD', amount: 17.5, reviewStatus: 'APPROVED', sourceStatus: 'ACTIVE' }] : [] - return response({ order: { dispatchNo: s.dispatchNo, deliveryNo: `DEL-${s.dispatchNo}-${index}`, customerId: fault === 'wrong-delivery-customer' ? '999' : customerId, outboundNo: `OUT-${s.dispatchNo}`, destinationWarehouseCode: group.destination.warehouseCode }, fees, feeCoverage: evidence }) - } - assert.ok(state, `${method} ${raw}`) - if (path.startsWith(PDA) && method === 'GET' && normalized.endsWith(number)) return response({ ...detail(state), items: state.items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: 2 }] })) }) - if (method === 'POST' && normalized.endsWith('/pick')) { assert.ok(token); if (body.recordVersion !== state.recordVersion) return response(null, '公共版本已变化'); const itemId = normalized.split('/').at(-2); state.items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; state.recordVersion++; return remember(ack(state, 'PICK')) } - if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...detail(state), manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: state.items.map((i, n) => ({ ...i, palletNo: `PAL-${n}`, deliveryIndex: n + 1, palletSpaces: 1, weightKg: 20, volumeCbm: 0.2, - evidence: ['palletSpaces', 'weightKg', 'volumeCbm'].map(field => ({ field, value: field === 'palletSpaces' ? 1 : field === 'weightKg' ? 20 : 0.2, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) })) }) - if (method === 'POST' && normalized.endsWith('/loading-complete')) { assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.items.every(i => i.pickedPackages === 2), true); state.stage = 'AFTER_LOADING'; state.recordVersion++; return remember(ack(state, 'LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } - if (method === 'GET' && path.endsWith('/items')) return response({ dispatchNo: number, recordVersion: state.recordVersion, status: 'ACTIVE', stage: state.stage, items: state.items }) - if (method === 'GET' && path.endsWith('/route')) return response({ dispatchNo: number, recordVersion: state.recordVersion, stops: triples.find(t => t.dispatchNo === number).stops }) - if (method === 'GET' && path.endsWith('/delivery-arrangement')) return response({ ...detail(state), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'c'.repeat(64), arrange: { allowed: !saved.has(number) }, groups: groups(state), arrangement: saved.has(number) ? { dispatchNo: number } : null }) - if (method === 'POST' && path.endsWith('/transfer-delivery')) { - assert.ok(body.idempotencyKey); assert.equal(body.recordVersion, state.recordVersion); assert.equal(state.stage, 'AFTER_LOADING') - if (body.cargoHash !== 'a'.repeat(64)) { if (fault === 'failed-command-writes') dirty = true; return response(null, '装车或实际货物已经变化') } - if (body.groups.some(g => g.feeCoverage.some(f => f.sourceVersion !== 1))) return response(null, '原费用版本已变化') - if (source.mode === 'FULL' && body.groups.some(g => g.fees.length)) return response(null, '本组服务已全部覆盖,不得重复登记应收') - if (source.mode === 'PARTIAL' && !body.groups.some(g => g.fees.length)) return response(null, '本组尚有未覆盖的服务费用,请核对') - saved.set(number, clone(body)); state.recordVersion++ - const result = ack(state, 'ARRANGE_DELIVERY', { dispatchNo: number, cargoHash: body.cargoHash, outbounds: [{ customerId, outboundNo: `OUT-${number}`, status: 'WAIT_OUTBOUND', packageCount: 4 }], - deliveries: groups(state).map((g, n) => ({ groupKey: g.groupKey, customerId, destination: g.destination, outboundNo: `OUT-${number}`, deliveryNo: `DEL-${number}-${n}`, status: 'WAIT_DELIVERY', packageCount: 2 })) }) - if (fault === 'unknown-write') return { http: 0, body: null, raw: 'connection reset' } - return remember(result) - } - assert.equal(method, 'GET', raw); assert.equal(path, `${BASE}/${number}`); return response(detail(state)) - } } - return { context, source, cases, calls, assertions, states, groups, clearFault() { fault = '' } } +import { verifyFleetFeeCoverage } from './fleet-fee-coverage-acceptance.mjs' +const BASE = '/api/platform/v1/last-leg/dispatches' +function fixture(fault = '') { + const cases = ['FULL','PARTIAL'].map((mode,i)=>({marker:'RUN',mode,dispatchNo:`DS-${i}`,customerId:'C'})) + const trips = cases.map(c=>({dispatchNo:c.dispatchNo,tripNo:`TR-${c.mode}`,warehouseId:'92',loadingCommand:{body:{idempotencyKey:`KEY-${c.mode}`},result:{action:'LOADING_COMPLETE',dispatchNo:c.dispatchNo,stage:'DELIVERING',result:{loadingCompletionHash:'HASH'}}}})) + const items = cases.map((c,i)=>[{customerId:'C',warehousePalletId:fault==='overlap'?'PAL':`PAL-${i}`,sourceKey:`SOURCE-${i}`,destination:{key:'FBA:A'},pickedPackages:2,scheduledPackages:2}]) + const docs = i=>({outbounds:[{customerId:'C',outboundNo:`OUT-${i}`,status:'OUTBOUNDED'}],deliveries:[{customerId:'C',outboundNo:`OUT-${i}`,deliveryNo:`DEL-${i}`,destination:{key:'FBA:A'},status:'DELIVERING',packageCount:2}]}) + const detail = i=>({dispatchNo:`DS-${i}`,recordVersion:7,status:'ACTIVE',stage:'DELIVERING',pickingReconciled:true,counts:{packageCount:2,pickedPackageCount:2,shippedPackageCount:2},documents:fault==='missing-docs'?{}:docs(i)}) + const calls=[] + const context={marker:'RUN',createdResources:{fleetFeeCoverageCases:cases,fleetTrips:trips},checkpoint:async()=>{},assert(_m,label,_r,ok){assert.ok(ok,label)},apiSuccess(_m,_l,r){return r.body.data},async request(method,raw){ + calls.push({method,raw});assert.equal(method,'GET') + const url=new URL(raw,'https://offline.invalid'),path=url.pathname + let data + if(path===BASE){const i=Number(url.searchParams.get('dispatchNo').slice(-1));data={records:[{...detail(i),warehouseId:'92'}]}} + else if(path.includes('/delivery-orders/')){const i=Number(path.slice(-1));assert.equal(url.searchParams.get('customerId'),'C');data={order:{dispatchNo:`DS-${i}`,deliveryNo:`DEL-${i}`,outboundNo:`OUT-${i}`,customerId:fault==='foreign-customer'?'OTHER':'C',packageCount:2,status:'DELIVERING'},fees:fault==='duplicate-fee'?[{id:'F'},{id:'F'}]:[],feeCoverage:null}} + else{const i=Number(path.split('/dispatches/')[1][3]);data=path.endsWith('/items')?{...detail(i),items:items[i]}:detail(i)} + return {http:200,body:{code:'200',data},requestId:'REQ',traceId:'TRACE'} + }} + return {context,cases,trips,calls} } - -for (const mode of ['FULL', 'PARTIAL']) test(`${mode} uses native grouped fees, immutable confirmation evidence and original-key replay`, async () => { - const f = fixture(mode), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source) - assert.equal(proof.status, 'VERIFIED'); assert.equal(proof.newReceivable, mode === 'FULL' ? 0 : 17.5); assert.equal(proof.deliveryNos.length, 2) - assert.ok(f.assertions.every(a => a.passed)); assert.ok(f.calls.every(c => !/cancel|unbind|pre-arrange|fee-coverage/.test(c.path))) - assert.equal(proof.confirmationRequest.recordVersion, 0); assert.equal(proof.confirmationRequest.groups.length, 2) - await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source), /未执行/) +for(const index of [0,1])test(`automatic delivery reads authoritative documents for fixture ${index}`,async()=>{ + const f=fixture(),proof=await verifyFleetFeeCoverage(f.context,'automatic',f.cases[index]) + assert.equal(proof.status,'VERIFIED');assert.deepEqual(proof.deliveryNos,[`DEL-${index}`]) + assert.equal(proof.feeCoverageSelection,'NOT_APPLICABLE_AUTOMATIC_DELIVERY') + assert.equal(Object.hasOwn(proof,'newReceivable'),false) + assert.deepEqual(proof.confirmationRequest,f.trips[index].loadingCommand.body) + assert.ok(f.calls.every(c=>c.method==='GET')) + assert.equal((await verifyFleetFeeCoverage(f.context,'automatic',f.cases[index],{reconcilePersisted:true})).status,'VERIFIED') + await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[index]),/未执行/) }) - -test('LOADING performs actual PDA picks, immutable pick replay and explicit loading completion before arranging', async () => { - const f = fixture('FULL', '', true), proof = await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { pickerToken: 'OFFLINE-ONLY' }) - assert.equal(proof.status, 'VERIFIED') - const loading = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/loading-complete')), arrangement = f.calls.findIndex(c => c.method === 'POST' && c.path.endsWith('/transfer-delivery')) - assert.ok(loading > f.calls.findIndex(c => c.path.endsWith('/pick'))); assert.ok(arrangement > loading) +for(const fault of ['overlap','missing-docs','foreign-customer','duplicate-fee'])test(`automatic proof rejects ${fault}`,async()=>{ + const f=fixture(fault);await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[0]));assert.notEqual(f.cases[0].proof.status,'VERIFIED') }) - -test('reviewed persisted reconciliation only replays original successful native command after actual document readback', async () => { - const f = fixture('FULL', 'wrong-cargo'); await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.ok(f.source.proof.outboundNo) - const original = clone(f.source.proof.confirmationRequest), before = f.calls.length; f.clearFault() - assert.equal((await verifyFleetFeeCoverage(f.context, 'coverage', f.source, { reconcilePersisted: true })).status, 'VERIFIED') - assert.ok(f.calls.slice(before).filter(c => c.method === 'POST').every(c => JSON.stringify(c.body) === JSON.stringify(original))) -}) - -for (const fault of ['foreign-customer', 'missing-source', 'failed-command-writes', 'wrong-cargo', 'missing-reference', 'duplicate-new-fee', 'rewrite-confirmation', 'mutated-original', 'charge-covered-destination', 'replay-adds-fee', 'wrong-delivery-customer', 'overlap', 'unknown-write', 'snapshot-source-amount']) test(`native fee proof rejects ${fault}`, async () => { - const f = fixture(fault === 'charge-covered-destination' ? 'PARTIAL' : 'FULL', fault) - await assert.rejects(verifyFleetFeeCoverage(f.context, 'coverage', f.source)); assert.notEqual(f.source.proof.status, 'VERIFIED') - if (['foreign-customer', 'missing-source', 'overlap'].includes(fault)) assert.equal(f.calls.some(c => c.method === 'POST'), false) - if (fault === 'unknown-write') { assert.equal(f.source.proof.status, 'OUTCOME_UNKNOWN'); const last = f.calls.at(-1); assert.equal(last.method, 'POST'); assert.equal(f.context.createdResources.dispatchWriteBoundary.stopped, true) } -}) - -test('two current registered source groups are required before HTTP; missing PDA and ambiguous mixed provenance never guess', async () => { - const missing = fixture(); missing.context.createdResources.fleetFeeCoverageCases.pop(); await assert.rejects(verifyFleetFeeCoverage(missing.context, 'coverage', missing.source), /两组不重叠/); assert.equal(missing.calls.length, 0) - const noPicker = fixture('FULL', '', true); await assert.rejects(verifyFleetFeeCoverage(noPicker.context, 'coverage', noPicker.source), /PDA身份/); assert.equal(noPicker.calls.some(c => c.method === 'POST'), false) - const f = fixture('PARTIAL'), groups = f.groups(f.states[1]); groups[0].cargo.push({ ...groups[0].cargo[0], sourceInboundId: '999' }); assert.throws(() => coveragePlan({ groups }, f.source), /不能唯一映射/) - const multi = fixture('PARTIAL'), many = multi.groups(multi.states[1]), third = clone(many[1]); third.groupKey = 'GROUP-THIRD'; third.cargo[0].sourceKey = 'PLAN:999'; third.cargo[0].sourceInboundId = '999'; third.feeSources[0].financeFeeId = '9999'; third.feeSources[0].sourceKeys = ['PLAN:999']; many.push(third) - assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/) - multi.source.transferBody.groupFees = { [many[1].groupKey]: [{ ...multi.source.transferBody.fees[0], amount: 6 }] } - assert.throws(() => coveragePlan({ groups: many }, multi.source), /明确逐组费用/) - multi.source.transferBody.groupFees[third.groupKey] = [{ ...multi.source.transferBody.fees[0], amount: 7 }] - assert.deepEqual(coveragePlan({ groups: many }, multi.source).groups.map(g => g.fees.map(f => f.amount)), [[], [6], [7]]) - const unsafe = fixture(); unsafe.source.customerId = 9007199254740992; assert.throws(() => coveragePlan({ groups: unsafe.groups(unsafe.states[0]) }, unsafe.source), /ID精度无效/) -}) - -test('public native delivery DTO retains grouped fees and requires version and loading hashes', () => { - const dto = readFileSync(new URL('../../lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchDeliveryModels.java', import.meta.url), 'utf8') - assert.ok(dto.includes('record ArrangeDelivery(Long recordVersion')); assert.ok(dto.includes('String loadingCompletionHash')); assert.ok(dto.includes('List groups')) +test('requires original current-run loading completion receipt and registered disjoint fixtures',async()=>{ + const f=fixture();delete f.trips[0].loadingCommand;await assert.rejects(verifyFleetFeeCoverage(f.context,'automatic',f.cases[0])) + const missing=fixture();missing.cases.pop();await assert.rejects(verifyFleetFeeCoverage(missing.context,'automatic',missing.cases[0]));assert.equal(missing.calls.length,0) }) diff --git a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs index b678c5302..c9b228bd6 100644 --- a/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-fee-coverage-acceptance.mjs @@ -1,15 +1,9 @@ -import { DISPATCH_BASE as BASE, id as stringId, version, commandKey, dispatchIo, replay, readDispatch } from './grouped-dispatch-acceptance.mjs' +import { DISPATCH_BASE as BASE, id as stringId, dispatchIo, readDispatch } from './grouped-dispatch-acceptance.mjs' import { pickAll } from './phase5-overseas-fulfillment.mjs' import { confirmFleetLoading } from './fleet-trip-fulfillment.mjs' export const stableRows = rows => JSON.stringify(rows.map(row => JSON.stringify(row)).sort()) -const clone = value => structuredClone(value) const id = value => { if (typeof value === 'number' && !Number.isSafeInteger(value)) throw Error('服务端ID精度无效,必须保留Long字符串'); return stringId(value) } -const positive = value => /^[1-9]\d*$/.test(id(value)) -const sha = value => /^[a-f0-9]{64}$/i.test(value || '') -const cents = value => { const text = String(value); if (!/^\d+(?:\.\d{1,2})?$/.test(text)) throw Error('费用必须为明确的非负两位金额'); const [whole, fraction = ''] = text.split('.'); return BigInt(whole) * 100n + BigInt(fraction.padEnd(2, '0')) } -const cargoIdentity = row => ({ dispatchItemId: id(row.dispatchItemId), warehousePalletId: id(row.warehousePalletId), orderId: id(row.orderId), sourceInboundId: id(row.sourceInboundId), packages: Number(row.packages) }) -const feeIdentity = fee => ({ financeFeeId: id(fee.financeFeeId), customerId: id(fee.customerId), currency: fee.currency, amount: String(fee.amount), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, status: fee.status, sourceKeys: [...fee.sourceKeys].sort() }) export function fleetConsumerIo(context, module, proof, namespace) { const io = dispatchIo(context, module, proof, namespace) @@ -27,188 +21,65 @@ export async function readyFleetDispatch(context, module, io, trip, { pickerToke io.check('真实履约写入须有正式持久化检查点', null, typeof context.checkpoint === 'function') let current = await readDispatch(io, trip.dispatchNo) io.check('本轮实车仓库及未发车范围', null, context.createdResources.fleetTrips?.includes(trip) && trip.tripNo - && current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && Number(current.data.counts.shippedPackageCount) === 0) + && current.warehouseId === id(trip.warehouseId) && current.data.status === 'ACTIVE' && ['LOADING', 'DELIVERING'].includes(current.data.stage)) if (current.data.stage === 'LOADING') { io.check('真实扫码需本轮PDA身份', null, typeof pickerToken === 'string' && pickerToken.length > 0) io.check('PDA逐来源真实扫码完成', null, await pickAll(context, pickerToken, trip.dispatchNo)) await confirmFleetLoading(context, module, trip, pickerToken, actualMeasurements) current = await readDispatch(io, trip.dispatchNo) } - io.check('安排前已真实扫码并显式装车完成', null, current.data.stage === 'AFTER_LOADING' && current.data.pickingReconciled === true + io.check('装车完成后自动派送并保留真实拣货', null, current.data.stage === 'DELIVERING' && current.data.pickingReconciled === true && Number(current.data.counts.packageCount) > 0 && Number(current.data.counts.pickedPackageCount) === Number(current.data.counts.packageCount) && current.items.items.every(i => Number(i.pickedPackages) === Number(i.scheduledPackages))) return current } -// FeeCandidate未暴露sourceInboundId,仅在sourceKeys可唯一映射到实际原入仓时选择,混托歧义直接阻断。 -export function coveragePlan(preview, source, externalReceipt) { - if (!preview.groups?.length) throw Error('缺少当前全车派送分组') - const firstleg = preview.groups.flatMap(g => g.cargo.filter(c => c.sourceRegion === 'FIRSTLEG')) - const inboundIds = [...new Set(firstleg.map(c => id(c.sourceInboundId)))].sort() - if (!inboundIds.length || inboundIds.some(v => !positive(v)) || source.mode === 'PARTIAL' && inboundIds.length < 2) throw Error('FULL/PARTIAL需要本轮真实原入仓,PARTIAL至少两票') - const selectedInbounds = new Set(source.mode === 'FULL' ? inboundIds : [inboundIds[0]]) - const originalFees = [], selections = new Map(), uncovered = [] - for (const group of preview.groups) { - if (!group.groupKey || !positive(group.customerId) || !group.destination?.key || !sha(group.groupCargoHash) || !sha(group.feeCoverageCargoHash) - || !sha(group.externalFreightHash) || !Array.isArray(group.blockReasons) || group.blockReasons.length || !Array.isArray(group.feeSources) || !Array.isArray(group.externalQuotes) - || !group.cargo.length || group.cargo.some(c => id(c.customerId) !== id(group.customerId) || c.destination?.key !== group.destination.key)) throw Error('客户目的地或费用预览范围不完整') - const own = group.cargo.filter(c => c.sourceRegion === 'FIRSTLEG') - if (own.some(c => id(c.customerId) !== id(source.customerId))) throw Error('原费用跨客户,不能沿其他客户确认覆盖') - if (group.externalQuotes.some(q => !externalReceipt || q.bookingNo !== externalReceipt.bookingNo || q.inboundCode !== externalReceipt.incomingNo - || id(q.customerId) !== id(group.customerId) || q.groupKey !== group.groupKey || q.sourceHash !== group.externalFreightHash - || q.currency !== externalReceipt.finalBooking.cargo.currency || cents(q.amount) !== cents(externalReceipt.finalBooking.agreedAmount))) throw Error('当前外接报价与本轮运营确认金额或来源摘要不一致') - for (const cargo of group.cargo.filter(c => c.sourceRegion !== 'FIRSTLEG')) { - if (!externalReceipt || cargo.inboundCode !== externalReceipt.incomingNo || !externalReceipt.createRequest - || id(cargo.customerId) !== id(externalReceipt.createRequest.customerId) - || !group.externalQuotes.some(q => q.bookingNo === externalReceipt.bookingNo && q.inboundCode === cargo.inboundCode && id(q.customerId) === id(group.customerId))) throw Error('非头程货物没有本轮已确认外接报价') - } - const byInbound = new Map() - for (const fee of group.feeSources) { - if (id(fee.customerId) !== id(group.customerId) || !positive(fee.financeFeeId) || fee.currency !== 'CNY' || !['AVAILABLE', 'SETTLED'].includes(fee.status) - || !Number.isSafeInteger(fee.sourceVersion) || fee.sourceVersion < 1 || !fee.sourceHash || !Array.isArray(fee.sourceKeys) || !fee.sourceKeys.length - || !fee.services?.includes('DELIVERY_FEE')) throw Error('原费用客户、版本或来源不可用') - cents(fee.amount) - const actual = own.filter(c => fee.sourceKeys.includes(c.sourceKey)), ids = new Set(actual.map(c => id(c.sourceInboundId))) - if (ids.size !== 1 || fee.sourceKeys.some(k => !actual.some(c => c.sourceKey === k))) throw Error('原费用sourceKeys不能唯一映射原入仓;需生产预览提供原费用sourceInboundId或无混托真实来源') - const inbound = [...ids][0] - if (!byInbound.has(inbound)) byInbound.set(inbound, fee) - originalFees.push({ groupKey: group.groupKey, ...feeIdentity(fee) }) - } - if (own.some(c => !byInbound.has(id(c.sourceInboundId)))) throw Error('本轮每票必须有实际原费用,不能借其他票补齐') - selections.set(group.groupKey, [...byInbound].filter(([inbound]) => selectedInbounds.has(inbound)).map(([, fee]) => ({ financeFeeId: id(fee.financeFeeId), sourceVersion: fee.sourceVersion, sourceHash: fee.sourceHash, services: ['DELIVERY_FEE'] }))) - if (own.some(c => !selectedInbounds.has(id(c.sourceInboundId)))) uncovered.push(group.groupKey) +/** 装车完成已生成单据并发车;仅读权威单据,不补人工费用或覆盖选择。 */ +export async function readAutomaticDelivery(io, current, trip) { + const documents = current.data.documents + const deliveries = documents?.deliveries, outbounds = documents?.outbounds + io.check('自动派送单据完整且客户箱数守恒', null, current.data.stage === 'DELIVERING' + && Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length > 0 + && new Set(deliveries.map(d => d.deliveryNo)).size === deliveries.length + && new Set(outbounds.map(o => o.outboundNo)).size === outbounds.length + && deliveries.every(d => d.deliveryNo && d.status === 'DELIVERING' && outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId))) + && outbounds.every(o => o.outboundNo && o.status === 'OUTBOUNDED') + && deliveries.reduce((sum, d) => sum + Number(d.packageCount), 0) === Number(current.data.counts.packageCount) + && current.items.items.every(i => deliveries.some(d => id(d.customerId) === id(i.customerId) && d.destination?.key === i.destination?.key))) + const snapshots = {}, feeRows = {} + for (const ref of deliveries) { + const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查自动生成派送的客户、目的地和费用事实') + io.check('自动生成单据归属和货量正确', null, detail?.order?.dispatchNo === trip.dispatchNo && detail.order.deliveryNo === ref.deliveryNo + && id(detail.order.customerId) === id(ref.customerId) && detail.order.outboundNo === ref.outboundNo + && Number(detail.order.packageCount) === Number(ref.packageCount) && detail.order.status === 'DELIVERING' + && Array.isArray(detail.fees) && new Set(detail.fees.map(f => id(f.id))).size === detail.fees.length) + snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees) } - const template = source.transferBody - if (template?.loadingType !== 'PALLET' || template.appointmentAccount !== 'SELF' || template.fees?.length !== 1 - || template.fees[0].direction !== 'RECEIVABLE' || template.fees[0].feeType !== 'DELIVERY_FEE' || cents(template.fees[0].amount) <= 0n) throw Error('覆盖用例须有明确的未覆盖派送应收金额') - if (uncovered.length > 1 && uncovered.some(key => !Array.isArray(template.groupFees?.[key]) || template.groupFees[key].length !== 1)) throw Error('多个未覆盖目的地须由本轮用例明确逐组费用,不能自动分摊') - const groups = preview.groups.map(group => { - const fees = uncovered.includes(group.groupKey) ? clone(template.groupFees?.[group.groupKey] || template.fees) : [] - if (uncovered.includes(group.groupKey) && fees.length !== 1 || fees.some(f => f.direction !== 'RECEIVABLE' || f.feeType !== 'DELIVERY_FEE' || cents(f.amount) <= 0n || !['CNY', 'USD'].includes(f.currency) || f.currency !== template.fees[0].currency)) throw Error('逐组未覆盖应收不完整') - return { groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, plannedDeliveryAt: template.plannedDeliveryAt || new Date(Date.now() + 21600000).toISOString(), - deliveryMethod: template.deliveryMethod || 'TRUCK_PALLET', waybillNo: template.waybillNo || null, carrierName: template.carrierName || null, - pickupMethod: template.pickupMethod || 'WAREHOUSE_PICKUP', loadingType: 'PALLET', vehicleType: template.vehicleType || 'TRUCK', vehicleSize: template.vehicleSize || null, - appointmentAccount: 'SELF', sellerName: null, remark: template.remark || null, externalFreightHash: group.externalFreightHash, - feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: selections.get(group.groupKey), fees } - }) - return { groups, originalFees, selectedInbounds: [...selectedInbounds], inboundIds } + const command = trip.loadingCommand + io.check('本轮装车成功回执完整', null, command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE' + && command.result.dispatchNo === trip.dispatchNo && command.result.stage === 'DELIVERING') + return { outbounds, deliveries, snapshots, feeRows, confirmationRequest: command.body, command: command.result } } -async function noDocuments(io, source, label) { - let total, rows = [] - for (let page = 1; ; page++) { - const data = await io.get(`/api/platform/v1/last-leg/outbound-orders?${new URLSearchParams({ customerId: id(source.customerId), size: '200', page: String(page) })}`, label) - io.check('客户出库分页完整稳定', null, Array.isArray(data?.records) && Number.isSafeInteger(Number(data.total)) && Number(data.page) === page && Number(data.size) === 200 - && Number(data.total) >= 0 && (total === undefined || total === Number(data.total)) && (data.records.length > 0 || rows.length === Number(data.total))) - total ??= Number(data.total); rows.push(...data.records); if (rows.length >= total) break - } - io.check('拒绝后无本排车出库写入', null, rows.length === total && new Set(rows.map(r => r.outboundNo)).size === rows.length && rows.every(r => r.dispatchNo !== source.dispatchNo)) -} - -/** 两个本轮不重叠来源用例必须先准备;从不取消已扫码FULL再复用同批实物。 */ export async function verifyFleetFeeCoverage(context, module, source, { reconcilePersisted = false, pickerToken, actualMeasurements, externalTrip } = {}) { const cases = context.createdResources?.fleetFeeCoverageCases - if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted) throw Error('原费用验收需要本轮专用、未执行的排车用例;保留原请求') + if (!cases?.includes(source) || source.marker !== context.marker || !['FULL', 'PARTIAL'].includes(source.mode) || source.proof && !reconcilePersisted) + throw Error('验收需要本轮专用、未执行排车用例;保留原请求') const pair = ['FULL', 'PARTIAL'].map(mode => cases.filter(c => c.marker === context.marker && c.mode === mode)) - if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮生产者预先准备FULL/PARTIAL两组不重叠真实排车,不能取消已扫码FULL复用同批实物') - if (reconcilePersisted && (!source.proof?.confirmationRequest || !source.proof?.outboundNo || !source.proof?.deliveryNos?.length)) throw Error('续验须有原成功命令和已持久化单据,不可重造') + if (pair.some(rows => rows.length !== 1) || pair[0][0].dispatchNo === pair[1][0].dispatchNo) throw Error('需本轮两组不重叠真实排车') const proof = reconcilePersisted ? source.proof : source.proof = { marker: context.marker, status: 'CHECKING', mode: source.mode, dispatchNo: source.dispatchNo, requests: [] } - const io = fleetConsumerIo(context, module, proof, `FEE-${source.mode}`), path = `${BASE}/${encodeURIComponent(source.dispatchNo)}` + const io = fleetConsumerIo(context, module, proof, `AUTO-${source.mode}`) try { const scopes = [] for (const [candidate] of pair) scopes.push(await readDispatch(io, candidate.dispatchNo)) io.check('两组真实来源和实托均不重叠', null, scopes.every(s => s.items.items.length > 0) && scopes[0].items.items.every(i => !scopes[1].items.items.some(j => id(i.warehousePalletId) === id(j.warehousePalletId) || i.sourceKey === j.sourceKey))) const trip = context.createdResources.fleetTrips?.find(t => t.dispatchNo === source.dispatchNo) - io.check('本排车有本轮正式车辆审核生产回执', null, Boolean(trip)) - const loaded = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements }) - const preview = await io.get(`${path}/delivery-arrangement`, '读取原生全车费用预览') - io.check('原生派送门禁、版本及实载证据完整', null, preview?.dispatchNo === source.dispatchNo && preview.status === 'ACTIVE' && preview.stage === 'AFTER_LOADING' - && id(version(preview.recordVersion)) === id(version(loaded.data.recordVersion)) && sha(preview.cargoHash) && sha(preview.loadingCompletionHash) - && (reconcilePersisted ? Boolean(preview.arrangement) : preview.arrange?.allowed === true && !preview.arrangement)) - const receipt = externalTrip?.externalReceipt - if (externalTrip && (!context.createdResources.fleetTrips.includes(externalTrip) || externalTrip.dispatchNo !== source.dispatchNo || receipt?.status !== 'VERIFIED')) throw Error('外接费用只接受本轮同主单真实运营确认') - const plan = coveragePlan(preview, source, receipt) - const totals = new Map() - for (const c of preview.groups.flatMap(g => g.cargo)) totals.set(id(c.dispatchItemId), (totals.get(id(c.dispatchItemId)) || 0) + Number(c.packages)) - io.check('费用分组逐实物客户与箱数核平', null, totals.size === loaded.items.items.length && loaded.items.items.every(i => totals.get(id(i.dispatchItemId)) === Number(i.scheduledPackages) - && preview.groups.filter(g => g.cargo.some(c => id(c.dispatchItemId) === id(i.dispatchItemId))).every(g => id(g.customerId) === id(i.customerId) && g.destination.key === i.destination.key))) - const body = reconcilePersisted ? proof.confirmationRequest : { recordVersion: version(preview.recordVersion), idempotencyKey: commandKey(context.marker, `FEE-${source.mode}-${source.dispatchNo}`, 'arrange'), - cargoHash: preview.cargoHash, loadingCompletionHash: preview.loadingCompletionHash, groups: plan.groups } - if (!reconcilePersisted) { proof.confirmationRequest = clone(body); proof.originalFees = clone(plan.originalFees); await context.checkpoint?.() } - else io.check('原成功请求仍为同货物和实载', null, body.cargoHash === preview.cargoHash && body.loadingCompletionHash === preview.loadingCompletionHash) - const reject = async (label, changed, pattern) => { - const before = await readDispatch(io, source.dispatchNo) - await io.request('POST', `${path}/transfer-delivery`, { ...clone(body), ...changed, idempotencyKey: commandKey(context.marker, `${source.dispatchNo}-${source.mode}`, label) }, label, pattern) - for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, `${label}后无单据`) - io.check(`${label}后整车及实物零漂移`, null, JSON.stringify(await readDispatch(io, source.dispatchNo)) === JSON.stringify(before)) - } - if (!reconcilePersisted) { - for (const customerId of new Set(preview.groups.map(g => id(g.customerId)))) await noDocuments(io, { ...source, customerId }, '执行前确认无出库单') - await reject('陈旧全车货物摘要拒绝', { cargoHash: `${body.cargoHash[0] === '0' ? '1' : '0'}${body.cargoHash.slice(1)}` }, /货物.*变化/) - const stale = clone(body.groups), selected = stale.find(g => g.feeCoverage.length).feeCoverage[0]; selected.sourceVersion = selected.sourceVersion === 2147483647 ? selected.sourceVersion - 1 : selected.sourceVersion + 1 - await reject('陈旧原费用版本拒绝', { groups: stale }, /原费用版本.*变化/) - const wrong = clone(body.groups) - if (source.mode === 'FULL') wrong[0].fees = clone(source.transferBody.fees) - else wrong.find(g => g.fees.length).fees = [] - await reject(source.mode === 'FULL' ? '全覆盖重复应收拒绝' : '部分覆盖漏填应收拒绝', { groups: wrong }, source.mode === 'FULL' ? /覆盖.*重复|覆盖.*登记/ : /未覆盖.*费用/) - if (externalTrip) { - const extra = clone(body.groups), quoted = preview.groups.find(g => g.externalQuotes.length), input = extra.find(g => g.groupKey === quoted.groupKey), quote = quoted.externalQuotes[0] - input.fees.push({ direction: 'RECEIVABLE', feeType: 'DELIVERY_FEE', feeName: '本轮重复外接派送应收反例', currency: quote.currency, amount: quote.amount }) - await reject('外接已有约定运费禁止追加重复应收', { groups: extra }, /重复|覆盖.*登记/) - } - } else { - const stored = await io.get(`/api/platform/v1/last-leg/outbound-orders/${encodeURIComponent(proof.outboundNo)}`, '先核对原成功出库和派送集合') - io.check('原单据完整且未执行', null, stored?.outbound?.dispatchNo === source.dispatchNo && stored.outbound.status === 'WAIT_OUTBOUND' - && stableRows((stored.deliveries || []).map(d => d.deliveryNo)) === stableRows(proof.deliveryNos) && stored.deliveries.every(d => d.status === 'WAIT_DELIVERY')) - } - const result = await io.request('POST', `${path}/transfer-delivery`, body, '按全车真实分组确认原费用并安排派送'), arranged = result?.result - io.check('原生命令返回完整客户出库和客户目的地派送', null, result?.dispatchNo === source.dispatchNo && result.action === 'ARRANGE_DELIVERY' && result.status === 'ACTIVE' && result.stage === 'AFTER_LOADING' - && BigInt(version(result.recordVersion)) > BigInt(version(body.recordVersion)) - && arranged?.dispatchNo === source.dispatchNo && arranged.cargoHash === body.cargoHash && arranged.deliveries?.length === preview.groups.length - && new Set(arranged.deliveries.map(d => d.deliveryNo)).size === arranged.deliveries.length - && arranged.outbounds?.length === new Set(preview.groups.map(g => id(g.customerId))).size - && arranged.outbounds.every(o => o.outboundNo && o.status === 'WAIT_OUTBOUND' && arranged.deliveries.filter(d => d.outboundNo === o.outboundNo).reduce((n, d) => n + Number(d.packageCount), 0) === Number(o.packageCount)) && arranged.deliveries.every(d => d.deliveryNo && d.status === 'WAIT_DELIVERY' - && preview.groups.some(g => g.groupKey === d.groupKey && id(g.customerId) === id(d.customerId) && g.destination.key === d.destination?.key - && g.cargo.reduce((n, c) => n + Number(c.packages), 0) === Number(d.packageCount)) - && arranged.outbounds.some(o => o.outboundNo === d.outboundNo && id(o.customerId) === id(d.customerId)))) - if (reconcilePersisted) io.check('仅恢复原出库和派送集合', null, result.replayed === true && stableRows(arranged.deliveries.map(d => d.deliveryNo)) === stableRows(proof.deliveryNos)) - proof.outbounds = arranged.outbounds; proof.deliveries = arranged.deliveries; proof.command = result - proof.outboundNo = arranged.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo - proof.deliveryNos = arranged.deliveries.map(d => d.deliveryNo).sort() - const snapshots = {}, feeRows = {} - let added = 0n - for (const ref of arranged.deliveries) { - const group = preview.groups.find(g => g.groupKey === ref.groupKey), input = body.groups.find(g => g.groupKey === ref.groupKey) - const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '回查当前客户目的地费用和原费快照') - io.check('派送只归当前客户和目的地', null, detail?.order?.dispatchNo === source.dispatchNo && detail.order.deliveryNo === ref.deliveryNo - && id(detail.order.customerId) === id(group.customerId) && detail.order.outboundNo === ref.outboundNo - && id(detail.order.destinationWarehouseCode || detail.order.destinationWarehouseId) === id(group.destination.warehouseCode || group.destination.warehouseId)) - const evidence = detail.feeCoverage, expectedCargo = group.cargo.map(cargoIdentity) - if (input.feeCoverage.length) { - io.check('原费快照保留真实货物、确认人、时间和每票引用', null, evidence?.cargoHash === group.feeCoverageCargoHash && evidence.operatorId && Number.isFinite(Date.parse(evidence.confirmedAt)) - && stableRows(evidence.cargo.map(cargoIdentity)) === stableRows(expectedCargo) && Array.isArray(evidence.references) - && stableRows(evidence.references.map(r => ({ financeFeeId: id(r.source?.financeFeeId), sourceVersion: r.source?.sourceVersion, sourceHash: r.source?.sourceHash, services: r.services }))) === stableRows(input.feeCoverage) - && evidence.references.every(r => id(r.source.customerId) === id(group.customerId) && ['INBOUND_FEE', 'CONTAINER_FEE'].includes(r.source.sourceType) - && group.cargo.some(c => id(c.sourceInboundId) === id(r.source.sourceInboundId)) && group.feeSources.some(f => id(f.financeFeeId) === id(r.source.financeFeeId) - && f.currency === r.source.currency && cents(f.amount) === cents(r.source.amount) && f.status === r.source.status))) - } else io.check('未选原费不伪造覆盖证据', null, evidence == null || evidence.references?.length === 0) - io.check('费用为本客户明确正数应收列表', null, Array.isArray(detail.fees) && detail.fees.every(f => f.feeDirection && f.feeType)) - const receivables = detail.fees.filter(f => f.feeDirection === 'RECEIVABLE' && f.feeType === 'DELIVERY_FEE' && f.sourceStatus !== 'VOIDED') - const expectedAmounts = [...input.fees.map(f => `${f.currency}:${cents(f.amount)}`), ...group.externalQuotes.filter(q => cents(q.amount) > 0n).map(q => `${q.currency}:${cents(q.amount)}`)].sort() - io.check('仅未覆盖货物和原外接报价产生应收,禁止重复或零值', null, stableRows(receivables.map(f => `${f.currency}:${cents(f.amount)}`).sort()) === stableRows(expectedAmounts) - && receivables.every(f => cents(f.amount) > 0n && f.sourceStatus === 'ACTIVE' && f.reviewStatus === 'APPROVED')) - added += input.fees.reduce((n, f) => n + cents(f.amount), 0n) - snapshots[ref.deliveryNo] = detail.feeCoverage; feeRows[ref.deliveryNo] = stableRows(detail.fees) - } - const replayed = await replay(io, `${path}/transfer-delivery`, body, result, '费用确认原键原载荷重放') - io.check('费用安排重放明确恢复原成功命令', null, replayed.replayed === true) - for (const ref of arranged.deliveries) { - const detail = await io.get(`/api/platform/v1/last-leg/delivery-orders/${encodeURIComponent(ref.deliveryNo)}?customerId=${encodeURIComponent(id(ref.customerId))}`, '原键重放后回查每客户费用') - io.check('重放不改确认快照也不新增应收', null, JSON.stringify(detail.feeCoverage) === JSON.stringify(snapshots[ref.deliveryNo]) && stableRows(detail.fees) === feeRows[ref.deliveryNo]) - } - const after = await io.get(`${path}/delivery-arrangement`, '回查原费用来源未被确认改变') - const currentFees = after.groups.flatMap(g => g.feeSources.map(f => ({ groupKey: g.groupKey, ...feeIdentity(f) }))) - io.check('原费用版本金额状态保持不变', null, stableRows(currentFees) === stableRows(proof.originalFees)) - proof.snapshots = snapshots; proof.newReceivable = Number(added) / 100; proof.status = 'VERIFIED'; await context.checkpoint?.(); return proof + io.check('本排车有本轮生产回执', null, Boolean(trip) && (!externalTrip || externalTrip === trip)) + const current = await readyFleetDispatch(context, module, io, trip, { pickerToken, actualMeasurements }) + const execution = await readAutomaticDelivery(io, current, trip) + if (reconcilePersisted) io.check('恢复仅读原生成单据集合', null, stableRows(proof.deliveryNos) === stableRows(execution.deliveries.map(d => d.deliveryNo))) + Object.assign(proof, execution, { outboundNo: execution.outbounds.find(o => id(o.customerId) === id(source.customerId))?.outboundNo, + deliveryNos: execution.deliveries.map(d => d.deliveryNo).sort(), feeCoverageSelection: 'NOT_APPLICABLE_AUTOMATIC_DELIVERY', status: 'VERIFIED' }) + await context.checkpoint?.(); return proof } catch (error) { proof.status = proof.outcomeUnknown ? 'OUTCOME_UNKNOWN' : 'UNPROVEN'; proof.error = error.message; await context.checkpoint?.(); throw error } } diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index 4ee2b7d9e..ec0f40de8 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -31,6 +31,7 @@ function fixture(fault = '') { const nativeItem = () => ({ dispatchItemId: '501', customerId: CUSTOMER, sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), sourceType: 'RETURN_RECEIPT', planPalletId: null, returnReceiptId: '300', warehousePalletId: '100', palletNo: context.palletCode, inboundCode: 'IN-A', destination, scheduledPackages: 2, pickedPackages: picked ? 2 : 0, positions: [{ positionId: '201', availablePackages: picked ? 0 : 2 }] }) const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage, + documents: { deliveries: departed && !next ? [ref()] : [], outbounds: departed && !next ? [outbound()] : [] }, reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked, vehicleInfo: { vehicle: { vehicleId: '601' }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] }) const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 }) @@ -41,7 +42,8 @@ function fixture(fault = '') { const stockLedger = () => returned ? [{ id: '901', customerId: CUSTOMER, overseasWarehouseId: WAREHOUSE, inboundCode: 'IN-A', palletNo: context.palletCode, businessType: 'RETURN_STOCK_IN', businessOrderNo: 'EX-NEW', outboundOrderNo: 'OUT-ORIGINAL', deltaPackages: 2 }] : [] const envelope = (data, code = '200', message = '') => ({ http: 200, body: { code, message, data: copy(data) }, requestId: 'OFFLINE-REQ', traceId: 'OFFLINE-TRACE' }) const context = { marker: 'FRESH', createdResources: { fleetTrips: [trip], fleetAcceptance: { marker: 'FRESH', customerId: CUSTOMER, warehouseId: WAREHOUSE, tripNo: trip.tripNo, - departure: { dispatchNo: trip.dispatchNo, action: 'DEPART', status: 'ACTIVE', stage: 'DELIVERING', result: { departedAt: '2026-10-19T01:00:00Z', deliveries: [{ customerId: CUSTOMER, deliveryNo: 'DL-ORIGINAL' }] } } } }, + departureDeliveries: [{ customerId: CUSTOMER, deliveryNo: 'DL-ORIGINAL' }], + departure: { dispatchNo: trip.dispatchNo, action: 'LOADING_COMPLETE', status: 'ACTIVE', stage: 'DELIVERING', result: { confirmedLoad: { manifestHash: 'ORIGINAL' } } } } }, assert(_m, label, _r, passed) { if (!passed) throw Error(label) }, addStep(_m, label, _r, passed) { if (!passed) throw Error(label) }, addCoverageGap() {}, apiSuccess(_m, _l, r) { return r.body?.data }, async checkpoint() { checkpoints.push(copy(trip.returnRedispatch)) }, async requestMultipart(method, path, file) { @@ -52,6 +54,7 @@ function fixture(fault = '') { async request(method, raw, body, token) { requests.push({ method, path: raw, body: copy(body), token }) const path = raw.split('?')[0], params = new URLSearchParams(raw.split('?')[1]), normalized = path.replace(PDA, DISPATCH), ledgerKey = `${method}:${normalized}:${body?.idempotencyKey}` + if (normalized.endsWith('/loading-complete') && !token && ledger.has(ledgerKey)) return envelope(null, 'A0403', '该请求标识不属于当前操作员') if (body?.idempotencyKey && ledger.has(ledgerKey) && !path.endsWith('/resources/release')) { const prior = ledger.get(ledgerKey); assert.deepEqual(body, prior.body); return envelope({ ...copy(prior.data), replayed: true }) } const remember = data => { if (body?.idempotencyKey) ledger.set(ledgerKey, { body: copy(body), data: copy(data) }); return envelope(data) } if (path === '/api/platform/inbounds') { const name = params.get('inboundCode').slice(-1), delivered = name === 'A' ? newSigned || fault === 'domestic-premature-A' && originalSigned : originalSigned && fault !== 'domestic-blocked-B'; return envelope({ records: [{ inboundCode: `IN-${name}`, processStatus: delivered ? 'DELIVERED' : 'LOADED' }] }) } @@ -83,7 +86,7 @@ function fixture(fault = '') { sourceKey: 'RETURN:300', sourceVersion: 'a'.repeat(64), customerId: CUSTOMER, warehouseId: WAREHOUSE, orderNo: 'LM-A', inboundCode: 'IN-A', palletNo: context.palletCode, destination, availablePackages: fault === 'wrong-candidate' ? 4 : 2, appointmentBatches: [], blockReasons: [] }] }) } if (path === DISPATCH && method === 'POST') { if (!body.cargo.length) { nextCreated = true; vehicleOwner = 'TR-NEXT'; driverOwner = 'TR-NEXT'; return remember(command('CREATE', {}, true)) }; assert.equal(body.cargo[0].sourceType, 'RETURN_RECEIPT'); assert.equal(body.cargo[0].planPalletId, null); created = true; return remember(command('CREATE')) } if (path === DISPATCH) return envelope({ records: [{ ...detail(params.get('dispatchNo') === 'DP-NEXT'), warehouseId: WAREHOUSE }] }) - if (path.endsWith('/master-data/vehicles')) { vehiclePlate = body.plateNumber; return envelope({ id: '601' }) } + if (path.endsWith('/master-data/vehicles')) { vehiclePlate = body.plateNumber.toUpperCase(); return envelope({ id: '601' }) } if (path.endsWith('/fleet/drivers') && method === 'POST') return envelope({ id: '701', recordVersion: 0 }) if (path.endsWith('/fleet/drivers/701/review')) return envelope({ reviewStatus: 'APPROVED' }) if (path === `${BASE}/fleet/trips`) { const trips = nextCreated ? [tripDetail(false).trip, tripDetail(true).trip] : [tripDetail(false).trip]; return envelope({ records: trips, page: 1, size: 200, total: trips.length }) } @@ -121,9 +124,7 @@ function fixture(fault = '') { assert.equal(body.recordVersion, recordVersion, raw) if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) } if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) } - if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'AFTER_LOADING'; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } - if (normalized.endsWith('/transfer-delivery')) { assert.equal(stage, 'AFTER_LOADING'); arranged = true; recordVersion++; return remember(command('ARRANGE_DELIVERY', { dispatchNo: 'DP-NEW', cargoHash: body.cargoHash, outbounds: [outbound()], deliveries: [ref()] })) } - if (normalized.endsWith('/depart')) { assert.equal(arranged, true); departed = true; stage = 'DELIVERING'; recordVersion++; return remember(command('DEPART', { departedAt: '2026-10-19T03:00:00Z', shippedPackages: 2, outbounds: [outbound()], deliveries: [ref()] })) } + if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } throw Error(`Unexpected ${method} ${raw}`) } } const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } } @@ -136,10 +137,11 @@ test('native one-main multi-source return redispatch executes real APIs through const f = fixture(), result = await run(f) assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3) assert.equal(result.beforeRedispatch.A.order.subOrderStatus, 'DELIVERY:WAIT_DELIVERY'); assert.equal(result.beforeRedispatch.B.order.subOrderStatus, 'DELIVERY:DELIVERED') - assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED'); assert.equal(result.afterRedispatch.departed.action, 'DEPART'); assert.equal(result.afterRedispatch.departed.result.shippedPackages, 2) + assert.equal(result.afterRedispatch.A.order.subOrderStatus, 'DELIVERY:DELIVERED'); assert.equal(result.afterRedispatch.departed.action, 'LOADING_COMPLETE'); assert.equal(result.afterRedispatch.departed.counts.shippedPackageCount, 2) assert.equal(result.inventory.map(s => s.ledger.length).every((n, i) => n === (i === 0 ? 0 : 1)), true) assert.ok(!JSON.stringify(f.checkpoints).includes('private-pda-token')); assert.ok(f.checkpoints.some(p => p.requests.at(-1)?.status === 'SENDING' && p.requests.at(-1)?.file?.sha256)) - assert.ok(f.requests.findIndex(r => r.path.endsWith('/loading-complete')) < f.requests.findIndex(r => r.path.endsWith('/transfer-delivery'))) + assert.ok(f.requests.some(r => r.path.endsWith('/loading-complete'))) + assert.equal(f.requests.some(r => /\/(transfer-delivery|depart)$/.test(r.path)), false) assert.equal(f.requests.some(r => r.path.includes('/pre-arrange')), false) }) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index 953e9a8d3..7305f827a 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -3,7 +3,7 @@ import {join,resolve} from 'node:path' import {redactEvidence} from '../fba-core-regression-test-env.mjs' import { randomUUID, createHash } from 'node:crypto' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './fleet-trip-fulfillment.mjs' -import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './phase5-overseas-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete } from './phase5-overseas-fulfillment.mjs' import { version, createBody, candidatePage, readDispatch, replay as replayCommand } from './grouped-dispatch-acceptance.mjs' const BASE = '/api/platform/v1/last-leg' @@ -34,17 +34,17 @@ export function selectReturnSources(rows) { return { a, b } } -/** Owned departure only; reviewed source-selection recovery preserves prior starts. Requests are checkpointed before dispatch. */ +/** 只消费本轮自动发运事实;来源恢复保留已启动派送,每次请求发送前持久记录。 */ export async function verifyFleetReturnRedispatch(context, module, trip, input, dependencies = {}) { const run = context.createdResources?.fleetAcceptance if (Object.keys(dependencies).some(name => !['wait', 'onBeforeRedispatchSignature'].includes(name))) throw Error('Only readonly audit and wait observers may be supplied; native production, picking and signature cannot be replaced') const departure = run?.departure if (!context.marker || !context.createdResources?.fleetTrips?.includes(trip) || run?.marker !== context.marker - || run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'DEPART' - || !departure.result?.departedAt || !input?.pickerToken + || run.tripNo !== trip.tripNo || departure?.dispatchNo !== trip.dispatchNo || departure.status !== 'ACTIVE' || departure.stage !== 'DELIVERING' || departure.action !== 'LOADING_COMPLETE' + || !departure.result?.confirmedLoad || !input?.pickerToken || id(run.customerId) !== id(input.customerId) || id(run.warehouseId) !== id(input.warehouseId) || JSON.stringify([departure.dispatchNo]) !== JSON.stringify([...(input.dispatchNos || [])].sort()) - || JSON.stringify((departure.result.deliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].sort()) + || JSON.stringify((run.departureDeliveries || []).filter(row => id(row.customerId) === id(input.customerId)).map(row => row.deliveryNo).sort()) !== JSON.stringify([...(input.deliveryNos || [])].sort()) || !input.deliveryNos?.length || trip.returnRedispatch || typeof context.checkpoint !== 'function') throw Error('Redispatch requires a new owned departure, current PDA session and durable checkpoints; no automatic restart') const proof = trip.returnRedispatch = { marker: context.marker, status: 'CHECKING', requests: [], returnPackages: 2, @@ -271,20 +271,19 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, const picked = await inventory('RETURN_PICKED') check('Actual returned stock is picked once without rewriting the stock-in ledger', picked.available === 0 && picked.picked === 2 && JSON.stringify(picked.ledger) === JSON.stringify(stockIn.ledger)) await confirmFleetLoading(io, module, newTrip, input.pickerToken, input.actualMeasurements) - const transfer = await arrangeDispatchDelivery(io, arranged.dispatchNo) - check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo) - && id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2) + // 装车完成已原子生成派送并发运,只回查单据,不重复发送退役安排和发车命令。 const current = await request('GET', `${BASE}/dispatches/${encodeURIComponent(arranged.dispatchNo)}`) - check('New whole is loaded and fully arranged before actual departure', current.status === 'ACTIVE' && current.stage === 'AFTER_LOADING' && Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 0) - const departBody = { recordVersion: version(current.recordVersion), idempotencyKey: key(), remark: '退回2箱重新实际发车' } - const departPath = `/api/pda/v1/last-leg/dispatches/${arranged.dispatchNo}/depart` - const departed = await request('POST', departPath, departBody, input.pickerToken) - check('New DEPART ships exactly two returned boxes', departed.dispatchNo === arranged.dispatchNo && departed.action === 'DEPART' && departed.status === 'ACTIVE' - && departed.stage === 'DELIVERING' && departed.result?.departedAt && Number(departed.result.shippedPackages) === 2 - && departed.result.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === transfer.deliveries[0].deliveryNo) - const departedAgain = await request('POST', departPath, departBody, input.pickerToken) - const facts = value => { const result = copy(value); delete result.replayed; return JSON.stringify(result) } - check('Departure replay does not duplicate shipping', departedAgain.replayed === true && facts(departedAgain) === facts(departed)) + const transfer = current.documents + check('Redispatch produces a new single destination delivery', transfer?.deliveries?.length === 1 && !input.deliveryNos.includes(transfer.deliveries[0].deliveryNo) + && id(transfer.deliveries[0].customerId) === customerId && Number(transfer.deliveries[0].packageCount) === 2 + && transfer.deliveries[0].status === 'DELIVERING' && transfer.outbounds?.length === 1 + && transfer.outbounds[0].status === 'OUTBOUNDED' && transfer.outbounds[0].outboundNo === transfer.deliveries[0].outboundNo + && id(transfer.outbounds[0].customerId) === customerId) + const departed = newTrip.loadingCommand.result + check('Loading completion ships exactly two returned boxes', current.status === 'ACTIVE' && current.stage === 'DELIVERING' + && Number(current.counts.pickedPackageCount) === 2 && Number(current.counts.shippedPackageCount) === 2 + && departed.action === 'LOADING_COMPLETE' && departed.dispatchNo === arranged.dispatchNo + && version(current.recordVersion) === version(departed.recordVersion)) const shipped = await inventory('RETURN_DEPARTED'); check('Returned stock ships once and retains the original immutable stock-in ledger', shipped.available === 0 && shipped.picked === 0 && JSON.stringify(shipped.ledger) === JSON.stringify(stockIn.ledger)) const newDelivery = await delivery(transfer.deliveries[0].deliveryNo) proof.preSignatureGate = { status: 'WAITING', deliveryNo: newDelivery.order.deliveryNo, newTripNo: newTrip.tripNo, diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 145c61597..6d6b02987 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -145,7 +145,7 @@ function server(fault = '') { assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST') assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }]) loadingActors.set(body.idempotencyKey, token ? 'PDA' : 'PLATFORM') - detail.stage = 'AFTER_LOADING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } }) + detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } }) } if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) { const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind') @@ -304,7 +304,7 @@ test('PDA original-actor replay restores loading while another platform actor is const beforeCount = s.detail.counts.pickedPackageCount const v = await confirmFleetLoading(s.context, 'native', proof, 'PDA-IN-MEMORY-ONLY', receipt()) assert.equal(v, '9007199254741004'); assert.equal(proof.reassignment.status, 'REJECTED_WITHOUT_MUTATION') - assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'AFTER_LOADING') + assert.equal(s.detail.counts.pickedPackageCount, beforeCount); assert.equal(s.detail.stage, 'DELIVERING') const writes = s.calls.filter(c => c.path.endsWith('/loading-complete')) assert.equal(writes.length, 3) assert.deepEqual(writes.map(c => c.body), [writes[0].body, writes[0].body, writes[0].body]) @@ -327,7 +327,7 @@ test('platform positive loading remains executable in its own scenario with orig assert.deepEqual(writes.map(c => [c.path, c.actor]), [[`${D}/loading-complete`, 'PLATFORM'], [`${D}/loading-complete`, 'PLATFORM']]) assert.equal(proof.loadingCommand.replay.replayed, true) assert.equal(proof.loadingCommand.platformPositiveLoading, 'VERIFIED'); assert.equal(proof.loadingCommand.platformRejection, undefined) - assert.equal(s.detail.stage, 'AFTER_LOADING') + assert.equal(s.detail.stage, 'DELIVERING') }) for (const fault of ['cross-actor-accepted', 'cross-actor-generic-forbidden', 'cross-actor-mutates-main', 'cross-actor-mutates-items', 'cross-actor-mutates-loading', 'cross-actor-unknown']) test(`cross-actor loading cannot claim isolation: ${fault}`, async () => { diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index fb4e4ce17..492cf07db 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -219,7 +219,7 @@ async function verifyPreDepartureReassignment(context, module, proof) { state.status = 'REJECTED_WITHOUT_MUTATION' } -export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements) { +export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements, beforeComplete) { if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明') if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试') const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}` @@ -233,8 +233,9 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion, idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, measurements, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } } + if (beforeComplete) await beforeComplete(proof.loadingCommand) const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken) - check(context, module, '显式装车才进入装车后', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'AFTER_LOADING' + check(context, module, '装车完成即进入派送中', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'DELIVERING' && confirmed.result?.confirmedLoad?.manifestHash === preview.manifestHash && version(confirmed.recordVersion) > version(preview.recordVersion)) const returned = confirmed.result.confirmedLoad.pallets check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 8e2151f94..58e74a049 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -244,7 +244,8 @@ export async function verifyMeasurementApprovalReplay(context, input, payload, i inboundHistoryCount: after.facts.inbound.statusHistory.length } } -async function reusablePallets(context, warehouseId) { +/** 固定造数只允许指定托盘;显式通用模式才读取其他已有托盘。缺失不自动替换。 */ +export async function reusablePallets(context, warehouseId) { const cacheKey = asId(warehouseId) const allowAnyExistingPallet = context.context?.allowAnyExistingPallet === true const scopedCacheKey = `${cacheKey}:${allowAnyExistingPallet ? 'any' : 'fixed'}` @@ -272,10 +273,12 @@ async function reusablePallets(context, warehouseId) { reusablePalletsByWarehouse.set(scopedCacheKey, pallets) return pallets } - // 固定称重托盘是环境初始化样本;缺失时降级使用当前仓库已有托盘,测量与计费仍走真实接口。 + context.assert(MODULE, '固定称重托盘主数据完整', null, false, + `warehouseId=${warehouseId}, missing=${missingFixed.join(',')}`, OPTIONS) context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失', - `warehouseId=${warehouseId}, missing=${missingFixed.join(',')};改用当前仓库已有托盘继续`, - 'fixture', { blocking: false }) + `warehouseId=${warehouseId}, missing=${missingFixed.join(',')};停止固定托盘造数`, + 'fixture', { blocking: true }) + return null } const response = await context.request('GET', diff --git a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs index b5f255651..e714f4955 100644 --- a/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs +++ b/scripts/admin-test-env-modules/initialization-baseline-contract.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' +import { readFileSync } from 'node:fs' import { readInitialization, tableDefinitions, table, seedRows, roleAuthorities } from '../sql-baseline-contract-helpers.mjs' const cn = readInitialization() @@ -17,7 +18,8 @@ test('种子解析覆盖多行多值、函数括号及带逗号和引号的文 }) test('两个入口分别只操作八个主业务库与两个仓储业务库,包括限定名引用', () => { - for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 227], [overseas, overseasSchemas, domesticSchemas, 228]]) { + // 国内补齐订单/仓库流水表;海外含自有业务四表与当前运输、流水基线。 + for (const [sql, allowed, other, count] of [[cn, domesticSchemas, overseasSchemas, 229], [overseas, overseasSchemas, domesticSchemas, 234]]) { assert.deepEqual([...sql.matchAll(/^CREATE DATABASE IF NOT EXISTS (\w+)/gm)].map(m => m[1]), allowed) assert.deepEqual([...new Set([...sql.matchAll(/^USE (\w+);/gm)].map(m => m[1]))], allowed) const body = sql.replace(/^--.*$/gm, '') @@ -173,7 +175,7 @@ test('平台管理员由外部bcrypt引导,强制首登改密且不创建客 }) test('必要系统种子完整保留,但不写入示例资源与业务事实', () => { - for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 433, iam_role_permission: 614, + for (const [name, count] of Object.entries({ iam_role: 8, iam_permission: 440, iam_role_permission: 624, iam_data_scope: 7, wh_warehouse: 318, wh_warehouse_address: 318, wh_service_capability: 4, fl_fee_item: 112, fl_price_channel_catalog: 15, fl_price_origin_alias: 7, fl_price_warehouse_mapping: 4, file_export_task_guard: 1, fin_subject: 1 })) assert.equal(seedRows(cn, name).length, count, name) @@ -200,8 +202,15 @@ test('国内IAM权限与角色授权没有丢失或悬挂,客户模板仍完 for (const row of permissions) if (row.parent_id && row.parent_id !== '0') assert.ok(permissionIds.has(row.parent_id), 'missing parent ' + row.parent_id) const merchant = roleAuthorities(cn, 'MERCHANT_OWNER') for (const code of ['merchant:enterprise', 'merchant:order:quick-create', 'merchant:finance:statement', - 'merchant:warehouse:delivery:list', 'merchant:warehouse:delivery:create']) assert.ok(merchant.has(code), code) + 'merchant:warehouse:delivery:list']) assert.ok(merchant.has(code), code) + assert.ok(!merchant.has('merchant:warehouse:delivery:create'), '派送由排车履约生成,客户只读不能独立创建') const admin = roleAuthorities(cn, 'PLATFORM_ADMIN') + for (const domain of ['owned-business', 'outsourced-business']) { + for (const action of ['list', 'edit', 'upload']) { + assert.ok(admin.has(`platform:fleet:${domain}:${action}`)) + assert.ok(!merchant.has(`platform:fleet:${domain}:${action}`)) + } + } assert.ok(admin.has('platform:overseas-warehouse:discard:approve'), 'discard review endpoint must be grantable to platform administrator') for (const action of ['list', 'create', 'edit', 'status', 'review', 'upload']) { assert.ok(admin.has(`platform:overseas-master-data:driver:${action}`)) diff --git a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs index 6048ff9c7..608eb264d 100644 --- a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs +++ b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs @@ -1,6 +1,35 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { reusablePallets } from './inbound-measurement.mjs' + +test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓可重新验证', async () => { + const requests = [], gaps = [], checks = [] + let complete = false + const context = { + context: {}, + async request(method, path) { + requests.push({ method, path }) + const query = new URL(path, 'http://offline').searchParams + const code = query.get('palletCode') + assert.ok(code, '固定模式禁止用无编号列表替换缺失资源') + return { records: complete || code === 'TP-001' + ? [{ id: code === 'TP-001' ? '1' : '2', palletCode: code, warehouseId: 'offline-fixed-pallet' }] : [] } + }, + apiSuccess(_module, _name, response) { return response }, + assert(_module, name, _response, passed) { checks.push({ name, passed }) }, + addCoverageGap(...args) { gaps.push(args) }, + } + assert.equal(await reusablePallets(context, 'offline-fixed-pallet'), null) + assert.equal(requests.length, 2) + assert.ok(checks.some(row => !row.passed)) + assert.equal(gaps[0].at(-1).blocking, true) + complete = true + assert.deepEqual(await reusablePallets(context, 'offline-fixed-pallet'), [ + { id: '1', code: 'TP-001' }, { id: '2', code: 'YW-TP-002' }, + ]) + assert.equal(requests.length, 4, '失败不得缓存为成功') +}) const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index f5b14cba4..e4b4b357f 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -830,15 +830,15 @@ Assert-Contains $overseasPdaAgents '排车拣货与发车' '海外 PDA 排车入 Assert-Contains $overseasPdaAgents '普通/一件代发出库' '海外 PDA 出库入口' # 排车新主流程按单号、公开版本和动作资格定位,不再指向独立车次发车请求。 -Assert-Contains $overseasPdaEntry 'departDispatch' '海外 PDA 当前发车调用入口' -Assert-Contains $overseasPdaIndex 'actions.PICK/LOADING_COMPLETE/DEPART' '海外 PDA 服务端动作资格' +Assert-Contains $overseasPdaEntry 'completeDispatchLoading' '海外 PDA 装车直接派送调用入口' +Assert-Contains $overseasPdaIndex 'actions.PICK/LOADING_COMPLETE' '海外 PDA 服务端动作资格' Assert-Contains $overseasPdaIndex 'recordVersion' '海外 PDA 排车公开版本' Assert-Contains $lastlegIndex 'docs/海外仓业务/排车列表统一作业改造契约.md' 'Lastleg 当前排车主契约' Assert-NotContains $overseasPdaAgents 'tripDepartureRequest' '海外 PDA 旧车次发车入口退出' Assert-NotContains $overseasPdaAgents 'tripRecordVersion' '海外 PDA 旧车次版本退出' Assert-Contains $lastlegEntry 'dispatch/controller/DispatchAggregateController.java' 'Lastleg 主指引平台整车入口' Assert-Contains $lastlegEntry 'dispatch/pda/controller/DispatchPdaController.java' 'Lastleg 主指引 PDA 整车入口' -Assert-Contains $lastlegEntry 'DispatchDepartureService.java' 'Lastleg 主指引共用发车服务' +Assert-Contains $lastlegEntry 'DispatchLoadingDepartureService.java' 'Lastleg 主指引装车直接发运服务' foreach ($lastlegDispatchGuide in @( @{ Name = 'Lastleg 主指引'; Text = $lastlegEntry }, @{ Name = 'Lastleg 详细索引'; Text = $lastlegIndex } @@ -849,9 +849,10 @@ foreach ($lastlegDispatchGuide in @( } } $lastlegPdaPublicController = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java') -Assert-Contains $lastlegPdaPublicController 'DispatchDepartureService' 'Lastleg PDA 真实入口使用共用发车服务' -Assert-Contains $lastlegPdaPublicController 'DispatchCommands.Depart' 'Lastleg PDA 真实入口使用公开发车命令' -Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDepartureService.java' +Assert-Contains $lastlegPdaPublicController 'DispatchLoadingDepartureService' 'Lastleg PDA 真实入口使用装车发运服务' +Assert-Contains $lastlegPdaPublicController 'DispatchCommands.LoadingComplete' 'Lastleg PDA 真实入口使用装车完成命令' +Assert-NotContains $lastlegPdaPublicController '@PostMapping("/{dispatchNo}/depart")' 'Lastleg PDA 独立发车入口退出' +Assert-Path 'baiyue-firstleg/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java' # Admin 仅对静态资源过期做一次受控恢复,不能吞掉普通路由错误或覆盖未保存编辑。 $navigationRecovery = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/core/routing/navigationRecovery.ts') diff --git a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs index 470a20cef..3360ecb72 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-gray.test.mjs @@ -58,20 +58,14 @@ test('eight-hour cutoff has coherent later departure delivery and driver end wit assert.deepEqual(grayUiSchedule(now + 999), schedule) assert.match(schedule.cutoffAt, /Z$/); assert.match(schedule.plannedDepartureAt, /Z$/) assert.doesNotMatch(schedule.plannedDeliveryAt, /Z$/) - const body = { idempotencyKey: 'stable', recordVersion: '9007199254740993', groups: [ - { groupKey: 'A:FBA:ONT8', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, - { groupKey: 'B:FBA:ONT8', plannedDeliveryAt: 'other', fees: [{ amount: 20 }] }, - ] } - const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) - assert.deepEqual(applied.groups.map(group => group.plannedDeliveryAt), [schedule.plannedDeliveryAt, schedule.plannedDeliveryAt].map(value => new Date(value).toISOString())) + const body = { idempotencyKey: 'stable', recordVersion: '9007199254740993' } + const applied = applyGrayUiTransportWindow('PUT', '/api/platform/v1/last-leg/dispatches/DS-1', body, schedule) + assert.equal(applied.estimatedArrivalAt, new Date(schedule.plannedDeliveryAt).toISOString()) assert.equal(applied.idempotencyKey, body.idempotencyKey); assert.equal(applied.recordVersion, body.recordVersion) - assert.deepEqual(applied.groups.map(group => group.fees), body.groups.map(group => group.fees)) - assert.deepEqual(applied.groups.map(group => group.groupKey), body.groups.map(group => group.groupKey)) - assert.deepEqual(body.groups.map(group => group.plannedDeliveryAt), ['old', 'other']) - assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', - { idempotencyKey: 'stable', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, schedule), /NATIVE_DELIVERY/) + assert.equal(Object.hasOwn(body, 'estimatedArrivalAt'), false) + assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches', body, null), /NATIVE_DELIVERY/) assert.equal(applyGrayUiTransportWindow('POST', '/api/auth/login', body, schedule), body) - assert.equal(applyGrayUiTransportWindow('GET', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule), body) + assert.equal(applyGrayUiTransportWindow('GET', '/api/platform/v1/last-leg/dispatches/DS-1', body, schedule), body) assert.throws(() => grayUiSchedule(NaN), /CLOCK/) }) diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs index 783df1e02..3f3f3c303 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.mjs @@ -76,11 +76,9 @@ export function grayUiSchedule(now = Date.now()) { } export function applyGrayUiTransportWindow(method, path, body, schedule) { - if (method !== 'POST' || !/^\/api\/platform\/v1\/last-leg\/dispatches\/[^/]+\/transfer-delivery$/.test(path)) return body - demand(body && Array.isArray(body.groups) && body.groups.length && body.groups.every(group => group?.groupKey) - && schedule?.plannedDeliveryAt && Number.isFinite(Date.parse(schedule.plannedDeliveryAt)), 'GRAY_UI_NATIVE_DELIVERY_WINDOW_REQUIRED') - const plannedDeliveryAt = new Date(schedule.plannedDeliveryAt).toISOString() - return { ...body, groups: body.groups.map(group => ({ ...group, plannedDeliveryAt })) } + if (!['POST', 'PUT'].includes(method) || !/^\/api\/platform\/v1\/last-leg\/dispatches(?:\/[^/]+)?$/.test(path)) return body + demand(body && schedule?.plannedDeliveryAt && Number.isFinite(Date.parse(schedule.plannedDeliveryAt)), 'GRAY_UI_NATIVE_DELIVERY_WINDOW_REQUIRED') + return { ...body, estimatedArrivalAt: new Date(schedule.plannedDeliveryAt).toISOString() } } /** Private directory override is for isolated offline tests, never exposed as a CLI option. */ diff --git a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs index c57a3c7b9..e3211a1e5 100644 --- a/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-ui-fixture-test-env.test.mjs @@ -130,16 +130,12 @@ test('UI fixture prepares one shared native main and an explicitly planned 18-of }) -test('native GRAY delivery window preserves per-customer group identity and never adds a retired top-level ETA', () => { +test('GRAY dispatch resource update carries its ETA into automatic delivery without changing the command identity', () => { const schedule = grayUiSchedule(Date.parse('2030-01-01T00:00:00Z')) - const body = { recordVersion: '0', idempotencyKey: 'same-command', cargoHash: 'real-cargo', loadingCompletionHash: 'real-load', - groups: [{ groupKey: 'A:FBA:1', plannedDeliveryAt: 'old', fees: [{ amount: 30 }] }, { groupKey: 'B:FBA:1', plannedDeliveryAt: 'old', fees: [] }] } - const applied = applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', body, schedule) - assert.equal(applied.recordVersion, '0'); assert.equal(applied.idempotencyKey, body.idempotencyKey) - assert.equal(Object.hasOwn(applied, 'plannedDeliveryAt'), false) - assert.deepEqual(applied.groups.map(group => group.groupKey), body.groups.map(group => group.groupKey)) - assert.ok(applied.groups.every(group => group.plannedDeliveryAt === new Date(schedule.plannedDeliveryAt).toISOString())) - assert.deepEqual(applied.groups[0].fees, body.groups[0].fees) - assert.equal(body.groups[0].plannedDeliveryAt, 'old') - assert.throws(() => applyGrayUiTransportWindow('POST', '/api/platform/v1/last-leg/dispatches/DS-1/transfer-delivery', { plannedDeliveryAt: 'old' }, schedule), /NATIVE_DELIVERY_WINDOW_REQUIRED/) + const body = { recordVersion: '0', idempotencyKey: 'same-command', vehicle: { plate: 'TEST' } } + const applied = applyGrayUiTransportWindow('PUT', '/api/platform/v1/last-leg/dispatches/DS-1', body, schedule) + assert.equal(applied.recordVersion, body.recordVersion); assert.equal(applied.idempotencyKey, body.idempotencyKey) + assert.equal(applied.estimatedArrivalAt, new Date(schedule.plannedDeliveryAt).toISOString()); assert.deepEqual(applied.vehicle, body.vehicle) + assert.equal(Object.hasOwn(body, 'estimatedArrivalAt'), false) + assert.throws(() => applyGrayUiTransportWindow('PUT', '/api/platform/v1/last-leg/dispatches/DS-1', body, null), /NATIVE_DELIVERY_WINDOW_REQUIRED/) }) diff --git a/scripts/fleet-test-env-acceptance.mjs b/scripts/fleet-test-env-acceptance.mjs index ab1c41f77..25f510c1f 100644 --- a/scripts/fleet-test-env-acceptance.mjs +++ b/scripts/fleet-test-env-acceptance.mjs @@ -25,9 +25,9 @@ const id = value => value == null ? '' : String(value) const success = response => response?.http === 200 && id(response?.body?.code) === '200' const key = () => `fleet-${randomUUID()}` const NATIVE_PROTOCOL = 'NATIVE_DISPATCH_V1' -const nativeDeparture = (value, dispatchNo) => value?.action === 'DEPART' && value.dispatchNo === dispatchNo - && value.status === 'ACTIVE' && value.stage === 'DELIVERING' && Number.isFinite(Date.parse(value.result?.departedAt)) - && Number.isSafeInteger(Number(value.result.shippedPackages)) && Number(value.result.shippedPackages) > 0 +const nativeDeparture = (value, dispatchNo) => value?.action === 'LOADING_COMPLETE' && value.dispatchNo === dispatchNo + && value.status === 'ACTIVE' && value.stage === 'DELIVERING' && value.result?.loadingCompletionHash + && Number.isFinite(Date.parse(value.result.loadingCompletedAt)) && value.result.confirmedLoad?.manifestHash const instant = value => typeof value === 'string' && /^\d{4}-\d\d-\d\dT\d\d:\d\d(?::\d\d(?:\.\d+)?)?$/.test(value) ? `${value}Z` : value const sameInstant = (a, b) => Number.isFinite(Date.parse(instant(a))) && Date.parse(instant(a)) === Date.parse(instant(b)) const deliveryFacts = rows => JSON.stringify(rows.map(row => [id(row.customerId), row.deliveryNo, row.outboundNo, Number(row.packageCount)]).sort()) @@ -342,15 +342,12 @@ export function reviewedFullCheckpoint(report, stage = 'FULL_COVERAGE') { || resources.fleetFeeCoverageCases.length !== 2 || !proof.originalDispatch?.detail || proof.originalDispatch.detail.dispatchNo !== proof.dispatchNo) throw Error('Only a reviewed read-only FULL_COVERAGE failure can resume; mutated/unknown outcomes require case-specific reconciliation') const persisted = stage === 'FULL_COVERAGE_PERSISTED' - if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !full.proof.confirmationRequest?.idempotencyKey - || !full.proof.confirmationRequest.groups?.length || full.proof.confirmationRequest.recordVersion == null - || !full.proof.requests.some(row => row.method === 'POST' && row.path === `/api/platform/v1/last-leg/dispatches/${full.dispatchNo}/transfer-delivery` - && row.http === 200 && String(row.code) === '200' && row.status === 'RESPONDED' - && JSON.stringify(row.body) === JSON.stringify(full.proof.confirmationRequest) - && row.result?.action === 'ARRANGE_DELIVERY' && row.result.dispatchNo === full.dispatchNo - && row.result.status === 'ACTIVE' && row.result.stage === 'AFTER_LOADING' - && row.result.result?.outbounds?.some(o => o.outboundNo === full.proof.outboundNo) - && JSON.stringify(row.result.result?.deliveries?.map(d => d.deliveryNo).sort()) === JSON.stringify([...full.proof.deliveryNos].sort()))) + const fullTrip = resources.fleetTrips.find(row => row.dispatchNo === full.dispatchNo) + if (persisted ? (!full.proof.outboundNo || !full.proof.deliveryNos?.length || !fullTrip?.loadingCommand?.body?.idempotencyKey + || !fullTrip.loadingCommand.body.measurements?.length || fullTrip.loadingCommand.body.recordVersion == null + || !nativeDeparture(fullTrip.loadingCommand.result, full.dispatchNo) + || !fullTrip.loadingCommand.afterReplay?.main?.documents?.outbounds?.some(o => o.outboundNo === full.proof.outboundNo) + || JSON.stringify(fullTrip.loadingCommand.afterReplay.main.documents.deliveries.map(d => d.deliveryNo).sort()) !== JSON.stringify([...full.proof.deliveryNos].sort())) : (full.proof.requests.some(row => row.method !== 'GET') || full.proof.outboundNo)) throw Error('Mutated/unknown outcomes require the exact successful command and persisted document reconciliation') return { resources, marker: proof.marker, persisted } @@ -373,7 +370,7 @@ export async function verifyFleetTerminalState(context) { const detail=await get(`/api/platform/v1/last-leg/dispatches/${encodeURIComponent(proof.dispatchNo)}`) check('原生主单全部正常派送完成且数量守恒',detail.dispatchNo===proof.dispatchNo&&detail.status==='ACTIVE'&&detail.stage==='DELIVERED' && Number(detail.counts?.packageCount)===Number(detail.counts?.shippedPackageCount)&&Number(detail.counts?.pickedPackageCount)===Number(detail.counts?.shippedPackageCount) - && Number(detail.counts?.shippedPackageCount)===Number(proof.departure.result.shippedPackages)) + && Number(detail.counts?.shippedPackageCount)===Number(proof.departureSnapshot.detail.counts.shippedPackageCount)) evidence.dispatches.push(detail) const refs=nativeFleetDeliveries(detail) check('签收证明恰好覆盖主单每客户派送一次',JSON.stringify(proof.receipts.map(r=>`${r.customerId}:${r.deliveryNo}`).sort())===JSON.stringify(refs.map(r=>`${r.customerId}:${r.deliveryNo}`).sort())) @@ -408,7 +405,7 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f } const current = await get(base) check(current.trip?.status === 'IN_TRANSIT' && id(current.trip.recordVersion) === id(expectedTrip.recordVersion) - && sameInstant(current.trip.departedAt, proof.departure.result.departedAt) && id(current.trip.warehouseId) === id(proof.warehouseId) + && sameInstant(current.trip.departedAt, proof.departureSnapshot.detail.dispatchInfo.departedAt) && id(current.trip.warehouseId) === id(proof.warehouseId) && id(current.trip.vehicleId) === id(expectedTrip.vehicleId) && id(current.trip.driverId) === id(expectedTrip.driverId) && !current.trip.vehicleRelease && !current.trip.driverRelease, 'trip version, resources or departure changed') const history = await get(`${base}/history?page=1&size=100`) @@ -474,7 +471,7 @@ export async function verifyDepartedRecovery(context, { costsAlreadyVerified = f && !order.signedAt&&!Number(order.signedPackages||0)&&!order.completedAt&&order.documentSummary?.categories?.every(r=>Number(r.fileCount)===0),'delivery has started, changed or been signed') evidence.deliveries.push(order) } - check(evidence.deliveries.reduce((n,row)=>n+Number(row.packageCount),0)===Number(proof.departure.result.shippedPackages),'delivery quantity mismatch') + check(evidence.deliveries.reduce((n,row)=>n+Number(row.packageCount),0)===Number(proof.departureSnapshot.detail.counts.shippedPackageCount),'delivery quantity mismatch') if (createdVehicle) { const vehicle = await get(`/api/platform/v1/last-leg/master-data/vehicles/${encodeURIComponent(createdVehicle.snapshot.id)}`) const canonical = value => value && typeof value === 'object' && !Array.isArray(value) @@ -782,33 +779,18 @@ export async function departNativeFleet(context, trip, pickerToken, run) { if (!pickerToken || !run || run.dispatchNo !== trip.dispatchNo || run.tripNo !== trip.tripNo || run.departureRequest) throw Error('Native departure requires owned main and a new original command') const path = `/api/platform/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}` - const before = await readNativeFleetBundle(context, trip.dispatchNo) - const refs = nativeFleetDeliveries(before.detail) - const task = await nativeRequest(context, 'GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}`, undefined, pickerToken) - if (before.detail.status !== 'ACTIVE' || before.detail.stage !== 'AFTER_LOADING' || task.dispatchNo !== trip.dispatchNo || task.status !== 'ACTIVE' || task.stage !== 'AFTER_LOADING' - || id(task.warehouseId) !== id(trip.warehouseId) || id(task.recordVersion) !== id(before.detail.recordVersion) || task.actions?.DEPART?.allowed !== true - || !before.detail.pickingReconciled || !task.pickingReconciled || Number(before.detail.counts?.packageCount) !== Number(task.counts?.packageCount) - || Number(task.counts?.packageCount) !== Number(task.counts?.pickedPackageCount) || Number(task.counts?.shippedPackageCount) !== 0 - || before.items.items.some(row => Number(row.pickedPackages) !== Number(row.scheduledPackages)) - || before.items.items.reduce((n,row) => n + Number(row.scheduledPackages), 0) !== Number(task.counts?.packageCount) - || refs.reduce((n,d) => n + Number(d.packageCount), 0) !== Number(task.counts.packageCount)) throw Error('Native main is not fully loaded and arranged for both ends') - run.departureRequest = { recordVersion: task.recordVersion, idempotencyKey: key(), remark: 'TEST同一原生主单实际发车,不重复扣库存' } - await context.checkpoint?.() - const departed = await nativeRequest(context, 'POST', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}/depart`, run.departureRequest, pickerToken) - if (!nativeDeparture(departed, trip.dispatchNo) || BigInt(dispatchVersion(departed.recordVersion)) <= BigInt(dispatchVersion(task.recordVersion)) - || Number(departed.result.shippedPackages) !== Number(task.counts.packageCount) - || !Array.isArray(departed.result.deliveries) || deliveryFacts(departed.result.deliveries) !== deliveryFacts(refs)) - throw Error('Native departure did not preserve all customer shipment identities and quantities') - run.departure = departed - const replayed = await nativeRequest(context, 'POST', `${path}/depart`, run.departureRequest) - if (!nativeDeparture(replayed, trip.dispatchNo) || replayed.replayed !== true || id(replayed.recordVersion) !== id(departed.recordVersion) || JSON.stringify(replayed.result) !== JSON.stringify(departed.result)) - throw Error('Native cross-end departure replay changed the original outcome') - run.departureReplay = replayed const after = await readNativeFleetBundle(context, trip.dispatchNo) - if (after.detail.status !== 'ACTIVE' || after.detail.stage !== 'DELIVERING' || id(after.detail.recordVersion) !== id(departed.recordVersion) - || Number(after.detail.counts.shippedPackageCount) !== Number(task.counts.packageCount) || Number(after.detail.counts.packageCount) !== Number(task.counts.packageCount) - || Number(after.detail.counts.pickedPackageCount) !== Number(after.detail.counts.shippedPackageCount) - || deliveryFacts(nativeFleetDeliveries(after.detail)) !== deliveryFacts(refs)) throw Error('Native departure readback did not conserve physical quantities') + const refs = nativeFleetDeliveries(after.detail), original = trip.loadingCommand + if (!nativeDeparture(original?.result, trip.dispatchNo) || !original.body?.idempotencyKey + || after.detail.status !== 'ACTIVE' || after.detail.stage !== 'DELIVERING' + || id(after.detail.recordVersion) !== id(original.result.recordVersion) + || Number(after.detail.counts?.shippedPackageCount) !== Number(after.detail.counts?.packageCount) + || refs.reduce((sum, d) => sum + Number(d.packageCount), 0) !== Number(after.detail.counts.packageCount)) + throw Error('Automatic loading completion must have an original success receipt and complete real delivery documents') + const departed = await nativeRequest(context, 'GET', `/api/pda/v1/last-leg/dispatches/${encodeURIComponent(trip.dispatchNo)}/commands/${encodeURIComponent(original.body.idempotencyKey)}`, undefined, pickerToken) + if (!nativeDeparture(departed, trip.dispatchNo) || id(departed.recordVersion) !== id(original.result.recordVersion) + || JSON.stringify(departed.result) !== JSON.stringify(original.result.result)) throw Error('Original loading command readback changed its outcome') + run.departureRequest = original.body; run.departure = departed; run.departureReplay = original.replay run.departureSnapshot = after; run.dispatchNos = [trip.dispatchNo] run.departureDeliveries = [] for (const ref of nativeFleetDeliveries(after.detail)) { diff --git a/scripts/fleet-test-env-acceptance.test.mjs b/scripts/fleet-test-env-acceptance.test.mjs index 916e25c99..3f34fade6 100644 --- a/scripts/fleet-test-env-acceptance.test.mjs +++ b/scripts/fleet-test-env-acceptance.test.mjs @@ -13,12 +13,12 @@ function departedRecoveryFixture() { const measured=[{warehousePalletId:'P1',palletSpaces:2},{warehousePalletId:'P2',palletSpaces:1}] const departure={action:'DEPART',recordVersion:'27',snapshot:{trip:snapshotTrip,confirmedLoad:{manifestHash:'a'.repeat(64),cargo,pallets:measured}}} const refs=[{customerId:'C1',deliveryNo:'DL-OWN',outboundNo:'OUT-C1',packageCount:2},{customerId:'C1',deliveryNo:'DL-EXT',outboundNo:'OUT-C1',packageCount:1}] - const detail={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:3,pickedPackageCount:3,shippedPackageCount:3},documents:{deliveries:refs}} + const detail={dispatchInfo:{departedAt:snapshotTrip.departedAt},dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:3,pickedPackageCount:3,shippedPackageCount:3},documents:{deliveries:refs}} const items={dispatchNo:'OWN',recordVersion:'31',items:cargo.map(row=>({dispatchItemId:row.dispatchItemId,customerId:row.customerId,warehousePalletId:row.warehousePalletId,scheduledPackages:row.packages,pickedPackages:row.packages}))} const orders=refs.map(ref=>({...ref,dispatchNo:'OWN',status:'DELIVERING',version:'1',documentSummary:{categories:[{fileCount:0}]}})) const proof={protocol:'NATIVE_DISPATCH_V1',marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',customerId:'C1',status:'CHECKING',stage:'TRIP_OPERATING_COSTS',checkpoints:[], dispatchNos:['OWN'],picker:{membershipId:'OLD-PDA'},departureRequest:{idempotencyKey:'old-key'},departureSnapshot:{detail:structuredClone(detail),items:structuredClone(items)},departureDeliveries:structuredClone(orders), - departure:{action:'DEPART',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',result:{departedAt:snapshotTrip.departedAt,shippedPackages:3,deliveries:refs}}} + departure:{action:'LOADING_COMPLETE',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',result:{loadingCompletionHash:'HASH',loadingCompletedAt:snapshotTrip.departedAt,confirmedLoad:{manifestHash:'a'.repeat(64)}}}} const trip={marker:'RUN',tripNo:'TR-OWN',dispatchNo:'OWN',warehouseId:'W1',confirmedLoad:{manifestHash:'a'.repeat(64),pallets:measured},costAcceptance:{marker:'RUN',status:'FAILED',costs:[],departure, requests:[{method:'GET',status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t'}]},externalReceipt:{status:'VERIFIED',bookingNo:'BK',warehousePalletId:'P2'},externalDispatch:{dispatchNo:'OWN',deliveryNo:'DL-EXT'}} const report={success:false,steps:[{name:'独立预期覆盖全部实物且没有重叠托盘',passed:false},{name:'模块执行',passed:false}],createdResources:{fleetAcceptance:proof,fleetTrips:[trip],fleetCostActors:{status:'DISABLED'},fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DL-OWN','DL-EXT']}}]}} @@ -66,7 +66,7 @@ function returnSourceRecoveryFixture() { trip.returnRedispatch={marker:'RUN',status:'FAILED',failure:report.steps[0].evidence,originalDeliveries:originals, requests:originals.map(order=>({method:'GET',path:`/api/platform/v1/last-leg/delivery-orders/${order.deliveryNo}?customerId=C1`,status:'RESPONDED',http:200,code:'200',requestId:'r',traceId:'t',result:{order:structuredClone(order)}}))} report.createdResources.fleetFeeCoverageCases[0].proof.deliveryNos=['DL-A','DL-B','DL-EXT'] - const detail={dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:5,pickedPackageCount:5,shippedPackageCount:5},documents:{deliveries:originals.map(o=>({deliveryNo:o.deliveryNo,outboundNo:o.outboundNo,customerId:o.customerId,packageCount:o.packageCount}))}} + const detail={dispatchInfo:{departedAt:original.snapshot.trip.departedAt},dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'31',counts:{packageCount:5,pickedPackageCount:5,shippedPackageCount:5},documents:{deliveries:originals.map(o=>({deliveryNo:o.deliveryNo,outboundNo:o.outboundNo,customerId:o.customerId,packageCount:o.packageCount}))}} const items={dispatchNo:'OWN',recordVersion:'31',items:original.snapshot.confirmedLoad.cargo.map(c=>({dispatchItemId:c.dispatchItemId,customerId:c.customerId,warehousePalletId:c.warehousePalletId,scheduledPackages:c.packages,pickedPackages:c.packages}))} run.departureSnapshot={detail:structuredClone(detail),items:structuredClone(items)} responses.set('/api/platform/v1/last-leg/dispatches/OWN',detail);responses.set('/api/platform/v1/last-leg/dispatches/OWN/items',items) @@ -459,7 +459,7 @@ test('return redispatch is explicit, fresh-only and declares source-event proof }) test('handover stage exports only a verified same-run departure fixture before the optional UI checkpoint', async () => { - const order = [], departure = { action:'DEPART', dispatchNo:'PC-NEW', status:'ACTIVE', stage:'DELIVERING', result:{departedAt:'2026-10-19T00:00:00Z',shippedPackages:1} } + const order = [], departure = { action:'LOADING_COMPLETE', dispatchNo:'PC-NEW', status:'ACTIVE', stage:'DELIVERING', result:{loadingCompletionHash:'HASH',loadingCompletedAt:'2026-10-19T00:00:00Z',confirmedLoad:{manifestHash:'HASH'}} } const context = { marker: 'CURRENT', context: { saveFleetHandoverFixture(value) { order.push('save'); assert.equal(value.marker, 'CURRENT') } }, createdResources: { fleetAcceptance: { tripNo:'TR-NEW', dispatchNo:'PC-NEW', departure } } } const trip = { tripNo: 'TR-NEW', dispatchNo:'PC-NEW', warehouseId: '92', costAcceptance: { status: 'VERIFIED', marker: 'CURRENT', expected: { finalCny: '100.03', usd: '13.07' } } }, details = [{ dispatchNo: 'PC-NEW' }] @@ -706,10 +706,9 @@ test('checkpoint guard forbids repeating commands whose write outcome may be unk assert.throws(() => reviewedFullCheckpoint(mutated), /unknown outcomes/) assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) Object.assign(mutated.createdResources.fleetFeeCoverageCases[0].proof, { deliveryNos: ['DEL-1'], confirmationRequest: { recordVersion:0,groups:[{groupKey:'G'}],idempotencyKey: 'original-key' } }) - mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.push({ path:'/api/platform/v1/last-leg/dispatches/D/transfer-delivery',label: '正式保存本次原费用服务覆盖并转派送', method: 'POST',status:'RESPONDED',requestId:'r',traceId:'t',http: 200, code: '200', - body: structuredClone(mutated.createdResources.fleetFeeCoverageCases[0].proof.confirmationRequest), result: { action:'ARRANGE_DELIVERY',dispatchNo:'D',status:'ACTIVE',stage:'AFTER_LOADING',result:{outbounds:[{outboundNo:'OUT-1'}],deliveries:[{deliveryNo:'DEL-1'}]} } }) + mutated.createdResources.fleetTrips[1].loadingCommand={body:{recordVersion:0,idempotencyKey:'original-key',measurements:[{warehousePalletId:'P'}]},result:{action:'LOADING_COMPLETE',dispatchNo:'D',status:'ACTIVE',stage:'DELIVERING',result:{loadingCompletionHash:'HASH',loadingCompletedAt:'2026-10-02T20:00:00Z',confirmedLoad:{manifestHash:'HASH'}}},afterReplay:{main:{documents:{outbounds:[{outboundNo:'OUT-1'}],deliveries:[{deliveryNo:'DEL-1'}]}}}} assert.equal(reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED').persisted, true) - mutated.createdResources.fleetFeeCoverageCases[0].proof.requests.at(-1).body.idempotencyKey = 'different-key' + delete mutated.createdResources.fleetTrips[1].loadingCommand.result.result.loadingCompletionHash assert.throws(() => reviewedFullCheckpoint(mutated, 'FULL_COVERAGE_PERSISTED'), /exact successful command/) }) @@ -760,7 +759,7 @@ function terminalFixture(fault = '') { const refs=[{customerId:'92',deliveryNo:'DEL-OWN',outboundNo:'OUT',packageCount:5},{customerId:'92',deliveryNo:'DEL-EXT',outboundNo:'OUT',packageCount:1}] const proof={protocol:'NATIVE_DISPATCH_V1',marker:'M',tripNo:'T',dispatchNo:'OWN',dispatchNos:['OWN'],customerId:'92',stage:'RECEIPTS_AND_RESOURCE_RELEASE',closure, receipts:refs.map(row=>({customerId:'92',deliveryNo:row.deliveryNo,signedAt:'2026-10-02T22:00:00'})), - departure:{action:'DEPART',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',result:{departedAt:'2026-10-02T20:00:00Z',shippedPackages:6}},finance:{customerId:'92',status:'CONSUMER_PERSISTED',source:{sourceFeeId:'F1',sourceVersion:1}}} + departureSnapshot:{detail:{counts:{shippedPackageCount:6},dispatchInfo:{departedAt:'2026-10-02T20:00:00Z'}}},departure:{action:'LOADING_COMPLETE',dispatchNo:'OWN',status:'ACTIVE',stage:'DELIVERING',result:{loadingCompletionHash:'HASH',loadingCompletedAt:'2026-10-02T20:00:00Z',confirmedLoad:{manifestHash:'HASH'}}},finance:{customerId:'92',status:'CONSUMER_PERSISTED',source:{sourceFeeId:'F1',sourceVersion:1}}} const resources={fleetAcceptance:proof,fleetTrips:[trip],fleetFeeCoverageCases:[{mode:'PARTIAL',dispatchNo:'OWN',proof:{deliveryNos:['DEL-OWN','DEL-EXT']}}]} const calls=[],context={createdResources:resources,assert(){},async request(method,path){ calls.push({method,path});assert.equal(method,'GET');let data @@ -837,74 +836,32 @@ test('native delivery references deduplicate the shared external destination but assert.throws(() => nativeFleetDeliveries({ dispatchNo: 'MAIN', documents: { deliveries: [] } })) }) -function nativeDepartureFixture(fault = '') { - const refs = [{ customerId: '11', deliveryNo: 'DEL-A', outboundNo: 'OUT-A', packageCount: 2 }, - { customerId: '22', deliveryNo: 'DEL-B', outboundNo: 'OUT-B', packageCount: 1 }] - const before = { dispatchNo: 'MAIN', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993', pickingReconciled: true, - counts: { packageCount: 3, pickedPackageCount: 3, shippedPackageCount: 0 }, documents: { deliveries: refs } } - const task = { ...structuredClone(before), warehouseId: '12', actions: { DEPART: { allowed: true } } } - const items = { dispatchNo: 'MAIN', recordVersion: before.recordVersion, items: [ - { dispatchItemId: '1', scheduledPackages: 2, pickedPackages: 2 }, { dispatchItemId: '2', scheduledPackages: 1, pickedPackages: 1 }] } - if (fault === 'stale-task') task.recordVersion = '9007199254740992' - if (fault === 'wrong-main') task.dispatchNo = 'OTHER' - if (fault === 'not-reconciled') task.pickingReconciled = false - if (fault === 'not-loaded') before.stage = 'LOADING' - if (fault === 'not-arranged') task.actions.DEPART.allowed = false - const command = { dispatchNo: 'MAIN', status: 'ACTIVE', stage: 'DELIVERING', action: 'DEPART', recordVersion: '9007199254740994', replayed: false, - result: { departedAt: '2026-10-19T01:00:00Z', shippedPackages: 3, deliveries: structuredClone(refs) } } - const after = { ...structuredClone(before), stage: 'DELIVERING', recordVersion: command.recordVersion, - counts: { packageCount: 3, pickedPackageCount: 3, shippedPackageCount: 3 } } - let departed = false - const calls = [], saved = [], run = { tripNo: 'TRIP', dispatchNo: 'MAIN' }, trip = { tripNo: 'TRIP', dispatchNo: 'MAIN', warehouseId: '12' } - const context = { async checkpoint() { saved.push(structuredClone(run)) }, async request(method, path, body, token) { - calls.push({ method, path, body: structuredClone(body), token }); let value - if (method === 'POST') { - assert.deepEqual(saved[0].departureRequest, body, 'original current-version command is durable before sending') - assert.equal(body.recordVersion, '9007199254740993') - if (path.startsWith('/api/pda/')) { - assert.equal(token, 'pda'); departed = true; value = structuredClone(command) - if (fault === 'changed-owner') value.result.deliveries[0].customerId = '99' - if (fault === 'changed-quantities') { value.result.deliveries[0].packageCount = 1; value.result.deliveries[1].packageCount = 2 } - } else { - assert.equal(token, undefined); assert.deepEqual(body, calls.find(row => row.method === 'POST').body) - value = { ...structuredClone(command), replayed: fault !== 'replay-miss' } - if (fault === 'replay-version') value.recordVersion = '9007199254740995' - } - } else if (path.startsWith('/api/pda/')) { assert.equal(token, 'pda'); value = task } - else if (path.endsWith('/items')) value = { ...items, recordVersion: departed ? after.recordVersion : before.recordVersion } - else if (path.includes('/delivery-orders/')) { - const ref = refs.find(row => path.includes('/' + row.deliveryNo + '?')) - assert.ok(path.endsWith('customerId=' + ref.customerId)) - value = { order: { ...ref, dispatchNo: 'MAIN', status: 'DELIVERING', version: '3' } } - if (fault === 'foreign-delivery') value.order.customerId = '99' - } else value = departed ? after : before - return { http: 200, body: { code: '200', data: structuredClone(value) }, requestId: 'r', traceId: 't' } - } } - return { context, trip, run, calls, saved } +function nativeDepartureFixture(fault='') { + const refs=[{customerId:'11',deliveryNo:'DEL-A',outboundNo:'OUT-A',packageCount:2},{customerId:'22',deliveryNo:'DEL-B',outboundNo:'OUT-B',packageCount:1}] + const result={action:'LOADING_COMPLETE',dispatchNo:'MAIN',status:'ACTIVE',stage:'DELIVERING',recordVersion:'9007199254740994',result:{loadingCompletionHash:'HASH',loadingCompletedAt:'2026-10-19T01:00:00Z',confirmedLoad:{manifestHash:'HASH'}}} + const detail={dispatchNo:'MAIN',status:'ACTIVE',stage:fault==='not-loaded'?'LOADING':'DELIVERING',recordVersion:fault==='stale-version'?'1':result.recordVersion,counts:{packageCount:3,pickedPackageCount:3,shippedPackageCount:fault==='quantity'?2:3},documents:{deliveries:refs}} + const run={tripNo:'TRIP',dispatchNo:'MAIN'},trip={tripNo:'TRIP',dispatchNo:'MAIN',warehouseId:'12',loadingCommand:{body:{idempotencyKey:'original'},result:structuredClone(result),replay:{...result,replayed:true}}},calls=[] + if(fault==='missing-command')delete trip.loadingCommand + const context={async checkpoint(){},async request(method,path,body,token){ + calls.push({method,path,body,token});assert.equal(method,'GET');let data + if(path.includes('/commands/')){assert.equal(token,'pda');data=structuredClone(result);if(fault==='changed-command')data.result.loadingCompletionHash='OTHER'} + else if(path.endsWith('/items'))data={dispatchNo:'MAIN',recordVersion:detail.recordVersion,items:[]} + else if(path.includes('/delivery-orders/')){const ref=refs.find(r=>path.includes(r.deliveryNo));data={order:{...ref,dispatchNo:'MAIN',status:'DELIVERING',version:'3'}};if(fault==='foreign-delivery')data.order.customerId='OTHER'} + else data=detail + return {http:200,body:{code:'200',data:structuredClone(data)},requestId:'r',traceId:'t'} + }} + return {context,trip,run,calls} } - -test('one native current-version PDA DEPART replays exactly through platform and retains every customer delivery version', async () => { - const f = nativeDepartureFixture() - const result = await departNativeFleet(f.context, f.trip, 'pda', f.run) - assert.equal(result.departed, f.run.departure) - assert.deepEqual(f.run.dispatchNos, ['MAIN']) - assert.equal(result.dispatchDetails.length, 1) - assert.deepEqual(f.run.departureDeliveries.map(row => [row.customerId,row.version]), [['11','3'],['22','3']]) - assert.equal(f.calls.filter(row => row.method === 'POST').length, 2) - const before = f.calls.length - await assert.rejects(departNativeFleet(f.context, f.trip, 'pda', f.run), /original command/) - assert.equal(f.calls.length, before, 'no fresh key after an original command exists') +test('one completed loading command is recovered read-only with real customer delivery versions',async()=>{ + const f=nativeDepartureFixture(),result=await departNativeFleet(f.context,f.trip,'pda',f.run) + assert.equal(result.departed,f.run.departure);assert.deepEqual(f.run.dispatchNos,['MAIN']) + assert.deepEqual(f.run.departureDeliveries.map(r=>[r.customerId,r.version]),[['11','3'],['22','3']]);assert.ok(f.calls.every(c=>c.method==='GET')) + assert.deepEqual(f.run.departureRequest,f.trip.loadingCommand.body) + const before=f.calls.length;await assert.rejects(departNativeFleet(f.context,f.trip,'pda',f.run),/original command/);assert.equal(f.calls.length,before) +}) +for(const fault of ['not-loaded','stale-version','quantity','missing-command','changed-command','foreign-delivery'])test(`automatic departure readback rejects ${fault}`,async()=>{ + const f=nativeDepartureFixture(fault);await assert.rejects(departNativeFleet(f.context,f.trip,'pda',f.run));assert.ok(f.calls.every(c=>c.method==='GET')) }) - -for (const fault of ['stale-task','wrong-main','not-reconciled','not-loaded','not-arranged','changed-owner','changed-quantities','replay-miss','replay-version','foreign-delivery']) - test(`native departure refuses stale or conflicting actual evidence: ${fault}`, async () => { - const f = nativeDepartureFixture(fault) - await assert.rejects(departNativeFleet(f.context, f.trip, 'pda', f.run)) - if (['stale-task','wrong-main','not-reconciled','not-loaded','not-arranged'].includes(fault)) { - assert.equal(f.calls.filter(row => row.method === 'POST').length, 0) - assert.equal(f.saved.length, 0) - } else assert.ok(f.run.departureRequest?.idempotencyKey, 'sent requests retain their original key') - }) test('physical loading requires current-run actual per-field evidence and never fills pallet spaces from planned capacity', async () => { const calls = [], trip = { dispatchNo: 'MAIN', stops: [{ destinationKey: 'ORIGIN:12' }, { destinationKey: 'FBA:A' }] } From 6fe632f28eade0fdeec5d12d1da0d30e3c42df3b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 15:10:44 +0800 Subject: [PATCH 058/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../file/service/BusinessFileCategory.java | 3 +- .../openapi/dispatch-evidence-v1.yaml | 6 +- .../service/DispatchEvidenceFileTest.java | 19 +++- .../DispatchAggregateController.java | 4 +- .../gateway/DispatchEvidenceGateway.java | 4 +- .../gateway/HttpDispatchEvidenceGateway.java | 8 +- .../dispatch/port/DispatchCommands.java | 6 +- .../dispatch/port/DispatchContracts.java | 22 +++- .../service/DispatchAggregateService.java | 29 +++-- .../transport/TransportTaskProjection.java | 2 +- .../openapi/overseas-business-v1.yaml | 53 ++++++++- .../HttpDispatchEvidenceGatewayTest.java | 8 ++ .../DispatchAggregatePersistenceTest.java | 20 ++-- .../DispatchPdaReadPersistenceTest.java | 4 +- ...patchReviewAttachmentsPersistenceTest.java | 104 ++++++++++++++++++ ...DispatchReviewWorkflowPersistenceTest.java | 2 +- .../pda/PdaOutboundDispatchContractTest.java | 2 +- scripts/admin-test-env-modules/README.md | 4 +- .../fee-items-contract.test.mjs | 12 +- scripts/admin-test-env-modules/fee-items.mjs | 29 +++-- .../inbound-measurement.mjs | 4 +- .../inbound-weight-ocr.mjs | 4 +- .../it-test-wait-split-bulk-contract.test.mjs | 18 ++- .../phase3-carrier-schedule-queries.test.mjs | 3 +- .../phase3-warehouse-rbac.mjs | 5 +- ...canonical-warehouse-rbac-contract.test.mjs | 2 +- ...phase4-overseas-business-contract.test.mjs | 7 +- .../phase4-overseas-business.mjs | 58 +++++----- .../sailing-calendar.mjs | 3 +- ...ema-alignment-migrations-contract.test.mjs | 16 ++- scripts/fleet-trip-rollback-test-env.test.mjs | 10 +- scripts/java-quality-baseline.json | 2 +- scripts/java-quality-summary.test.mjs | 6 +- .../publish-nacos-config-contract.test.mjs | 3 +- 34 files changed, 372 insertions(+), 110 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewAttachmentsPersistenceTest.java diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index ed10c57fe..59407b4ee 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -11,6 +11,7 @@ enum BusinessFileCategory { FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), FLEET_VEHICLE_RECORD_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -78,7 +79,7 @@ enum BusinessFileCategory { && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); } - if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE) + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE) && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); } diff --git a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml index e384ffb41..f0be4156b 100644 --- a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml +++ b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml @@ -4,9 +4,9 @@ info: version: 1.0.0 description: >- 复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。 - 两类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, + 三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, 不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用 - OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE,不可互换。 + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。 File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、 PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId 联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。 @@ -126,7 +126,7 @@ components: description: 正数Long的十进制字符串,值不得超过9223372036854775807 DispatchEvidenceType: type: string - enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE] + enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE] MetadataEnvelope: type: object required: [code, data] diff --git a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java index 2444e080c..be8d7154d 100644 --- a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java @@ -30,6 +30,7 @@ import static org.mockito.Mockito.*; class DispatchEvidenceFileTest { private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE"; + private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE"; private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE"; private static final int MAX_BYTES = 10 * 1024 * 1024; @TempDir Path root; @@ -40,9 +41,9 @@ class DispatchEvidenceFileTest { FileObjectEntity.class); } - /** 两类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ + /** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ @ParameterizedTest - @ValueSource(strings = {VEHICLE, DRIVER}) + @ValueSource(strings = {VEHICLE, DRIVER, PHOTO}) void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) { var files = mock(FileObjectMapper.class); var storage = mock(BusinessFileStorage.class); @@ -64,6 +65,18 @@ class DispatchEvidenceFileTest { verifyNoInteractions(files, storage); } + /** 司机本人照片使用独立平台类别,只保存真实图片,拒绝PDF和伪造图片。 */ + @org.junit.jupiter.api.Test void driverPhotoAcceptsOnlyRealImages() throws Exception { + var storage=storage();var category=BusinessFileCategory.require(PHOTO); + for(var format:new String[]{"jpeg","png"}) { + var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format)); + var saved=storage.store(0,"71",upload,category); + assertThat(storage.read(saved.objectKey())).isEqualTo(upload.getBytes()); + } + assertThatThrownBy(()->storage.store(0,"71",pdf(),category)).isInstanceOf(BizException.class); + assertThatThrownBy(()->storage.store(0,"71",new MockMultipartFile("file","driver.png","image/png",pdf().getBytes()),category)).isInstanceOf(BizException.class); + } + /** 真实 PDF、JPG、PNG 均可写入并保持内容;上传不以扩展名代替 MIME 和签名。 */ @ParameterizedTest @ValueSource(strings = {VEHICLE, DRIVER}) @@ -149,7 +162,7 @@ class DispatchEvidenceFileTest { /** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */ @ParameterizedTest - @ValueSource(strings = {VEHICLE, DRIVER}) + @ValueSource(strings = {VEHICLE, DRIVER, PHOTO}) void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception { var files = mock(FileObjectMapper.class); var storage = mock(BusinessFileStorage.class); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index a5909f6d6..782f4583b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -77,9 +77,9 @@ public class DispatchAggregateController { public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request)); } - /** 本车证据先上传,主单引用随后走版本化编辑。 */ + /** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */ @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) - @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + @PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')") public ApiResponse uploadEvidence(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, @PathVariable com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,@RequestPart("file")org.springframework.web.multipart.MultipartFile file) { return ApiResponse.success(service.uploadEvidence(DispatchActorFactory.platform(user),dispatchNo,kind,file)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java index bedd86d03..d901f837d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java @@ -2,9 +2,9 @@ package com.baiyue.lastleg.dispatch.gateway; import org.springframework.web.multipart.MultipartFile; -/** 临时车辆和司机证据属于本张整车,不能借司机主档或客户文件授权。 */ +/** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */ public interface DispatchEvidenceGateway { - enum Kind { VEHICLE, DRIVER } + enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO } record File(Long fileId,String fileName,String contentType,long sizeBytes) { } /** 上传只写本车、本类证据。 */ File upload(long dispatchId,Kind kind,String operator,MultipartFile file); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java index 9f2516f8e..0797486bc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java @@ -29,7 +29,7 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { private static String type(Kind kind) { return "OVERSEAS_DISPATCH_"+Objects.requireNonNull(kind).name()+"_EVIDENCE"; } /** 本车证据上传完成后仍以File返回元数据核对复合归属。 */ @Override public File upload(long dispatch,Kind kind,String operator,MultipartFile file) { - if(file==null || file.isEmpty() || file.getSize()>MAX || !TYPES.contains(Objects.toString(file.getContentType(),"")))throw invalid(); + if(file==null || file.isEmpty() || file.getSize()>MAX || !supportedType(kind,file.getContentType()))throw invalid(); var body=new MultipartBodyBuilder(); try { body.part("file",new ByteArrayResource(file.getBytes()) { /** 保留用户文件名供审阅,不把路径当业务事实。 */ @@ -80,9 +80,13 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { var value=envelope.data(); if(!Objects.equals(value.tenantId(),0L) || !type(kind).equals(value.businessType()) || !Long.toString(dispatch).equals(value.businessId()) || value.fileId()==null || value.fileId()<=0 || id!=null&&!id.equals(value.fileId()) || value.fileName()==null || value.fileName().isBlank() - || !TYPES.contains(Objects.toString(value.contentType(),"")) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid(); + || !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid(); return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes()); } + /** 司机本人照片只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */ + private static boolean supportedType(Kind kind,String contentType) { + return TYPES.contains(Objects.toString(contentType,"")) && (kind!=Kind.DRIVER_PHOTO || !"application/pdf".equals(contentType)); + } /** 无效证据不能作为已核实资料。 */ private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} /** File明确拒绝归属或文件格式属于业务错误,不能伪装成服务不可用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java index 8b1ca3dee..a66d4b0f7 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -49,9 +49,9 @@ public final class DispatchCommands { } public record ReviewCommand(Long recordVersion, String idempotencyKey, Decision decision, - String reason) { - /** 两级审核都执行相同的必填版本约束。 */ - public ReviewCommand { recordVersion = requireVersion(recordVersion); } + String reason, ReviewAttachments attachments) { + /** 两级审核都执行相同的必填版本约束,未传附件表示没有本次补充。 */ + public ReviewCommand { recordVersion = requireVersion(recordVersion); attachments = attachments == null ? ReviewAttachments.empty() : attachments; } } /** 只有零真实扫码时可退回,保留原单号与审核轮次历史。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java index b3940213d..1326b540b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -41,7 +41,27 @@ public final class DispatchContracts { String contactName, String contactPhone) { } public record Review(ReviewStatus status, String reason, String reviewerId, Instant reviewedAt, - long reviewRound, String basisHash, String invalidatedReason) { } + long reviewRound, String basisHash, String invalidatedReason, ReviewAttachments attachments) { + /** 无补充附件时返回明确空集合,旧决定不得从资源快照自动继承。 */ + public Review { attachments = attachments == null ? ReviewAttachments.empty() : attachments; } + } + + /** 本次审核补充证据按分类归属本车,不修改车辆或司机业务资料。 */ + public record ReviewAttachments(List vehicleFileIds, List driverPhotoFileIds, List licenseFileIds) { + /** 每类附件有界、不可变且不允许空身份或重复引用。 */ + public ReviewAttachments { + vehicleFileIds = checked(vehicleFileIds); driverPhotoFileIds = checked(driverPhotoFileIds); licenseFileIds = checked(licenseFileIds); + } + /** 未传附件表示本次没有补充证据。 */ + public static ReviewAttachments empty() { return new ReviewAttachments(List.of(), List.of(), List.of()); } + /** 只规范引用形状;本车和类别归属由审核服务逐个验证。 */ + private static List checked(List ids) { + if (ids == null) return List.of(); + if (ids.size() > 20 || ids.stream().anyMatch(id -> id == null || id <= 0) || ids.stream().distinct().count() != ids.size()) + throw new IllegalArgumentException("每类审核附件最多20个,文件引用须有效且不重复"); + return List.copyOf(ids); + } + } public record Reviews(Review dispatchReview, Review loadingReview) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 0bf073c27..0d5ffbc31 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -235,10 +235,13 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return saveCommand(row,actor,identity,request.reason()); } - /** 上传不修改主单引用;返回本车文件ID后由公共版本编辑命令保存选择。 */ + /** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */ public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number, com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) { requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion()); + Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW + : ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT; + if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限"); return evidence.upload(row.getId(),kind,actor.operatorId(),file); } /** 列表权限含详情只读;仅冻结MASTER证照走司机归属,其余保持本车附件复合归属。 */ @@ -392,7 +395,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); long round=row.getReviewRound()+1;String basis=basis(row); row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); - row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null))); + row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null))); row.setLoadingReviewJson(write(pending(round))); return saveCommand(row,actor,identity,request.remark()); } @@ -420,20 +423,21 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var dispatch=reviews(row).dispatchReview(); if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效"); } + verifyReviewAttachments(row.getId(),request.attachments()); if(request.decision()==Decision.APPROVED)verifyReviewResources(row); - var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null); + var reviewed=new Review(request.decision()==Decision.APPROVED?ReviewStatus.APPROVED:ReviewStatus.REJECTED,trim(request.reason()),actor.operatorId(),Instant.now().truncatedTo(ChronoUnit.MILLIS),row.getReviewRound(),row.getReviewBasisHash(),null,request.attachments()); if(loading) { row.setLoadingReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.APPROVED.name():ReviewState.LOADING_REJECTED.name()); if(request.decision()==Decision.APPROVED)row.setStage(Stage.LOADING.name()); } else { row.setDispatchReviewJson(write(reviewed));row.setReviewState(request.decision()==Decision.APPROVED?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_REJECTED.name()); - row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null):pending(row.getReviewRound()))); + row.setLoadingReviewJson(write(request.decision()==Decision.APPROVED?new Review(ReviewStatus.PENDING,null,null,null,row.getReviewRound(),row.getReviewBasisHash(),null,null):pending(row.getReviewRound()))); } if(request.decision()==Decision.REJECTED) { // 任一级驳回均回客服处理,旧调度批准不再作为可继续理货的有效依据。 row.setStage(Stage.BEFORE_LOADING.name());row.setReviewBasisHash(null); if(loading)row.setDispatchReviewJson(write(new Review(ReviewStatus.NOT_SUBMITTED,null,null,null, - row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核"))); + row.getReviewRound(),null,"理货审核驳回,请客服修改后重新提交调度审核",reviews(row).dispatchReview().attachments()))); } return saveCommand(row,actor,identity,request.reason()); } @@ -622,7 +626,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ private void invalidate(DispatchAggregate row,String reason) { row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); - var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason); + var invalid=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,row.getReviewRound(),null,reason,null); var previous=reviews(row); row.setDispatchReviewJson(write(retainRejection(previous.dispatchReview(),invalid))); row.setLoadingReviewJson(write(retainRejection(previous.loadingReview(),invalid))); @@ -630,9 +634,16 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 客服改资料期间保留本轮驳回原因;下一轮提交统一重置,不复用批准。 */ private static Review retainRejection(Review previous,Review invalid) { return previous.status()==ReviewStatus.REJECTED - ?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason()) - :invalid; + ?new Review(previous.status(),previous.reason(),previous.reviewerId(),previous.reviewedAt(),previous.reviewRound(),null,invalid.invalidatedReason(),previous.attachments()) + :new Review(invalid.status(),null,null,null,invalid.reviewRound(),null,invalid.invalidatedReason(),previous.attachments()); } + /** 审核附件逐个验证本车与种类;MASTER档案证照不能冒充本次上传证据。 */ + private void verifyReviewAttachments(long dispatch,ReviewAttachments attachments) { + for(var id:attachments.vehicleFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,id); + for(var id:attachments.driverPhotoFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,id); + for(var id:attachments.licenseFileIds())evidence.metadata(dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,id); + } + /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ void verifyReviewResources(DispatchAggregate row) { var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); @@ -923,7 +934,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 当前审核记录,不以版本相等代替轮次有效性。 */ Reviews reviews(DispatchAggregate row) { return new Reviews(read(row.getDispatchReviewJson(),Review.class),read(row.getLoadingReviewJson(),Review.class)); } /** 未提交的审核不伪造待审核。 */ - private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null); } + private static Review pending(long round) { return new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,round,null,null,null); } /** 整车真实统计。 */ private static Counts counts(DispatchAggregate row) { return new Counts(n(row.getItemCount()),n(row.getPalletCount()),n(row.getPackageCount()),n(row.getPickedPackageCount()),n(row.getShippedPackageCount())); } /** 空单永远不能核平为装车完成。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java index 831a9eb00..3566ebc32 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java @@ -36,7 +36,7 @@ public class TransportTaskProjection { row.setOverseasWarehouseCode(trip.getWarehouseCode());row.setOverseasWarehouseName(trip.getWarehouseName()); row.setStatus("ACTIVE");row.setStage("BEFORE_LOADING");row.setRecordVersion(trip.getRecordVersion());row.setCargoRevision(0L); row.setCargoHash(trip.getRequestHash());row.setReviewState("DRAFT");row.setReviewRound(0L); - var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null); + var review=new Review(ReviewStatus.NOT_SUBMITTED,null,null,null,0,null,null,null); row.setDispatchReviewJson(write(review));row.setLoadingReviewJson(write(review)); resources(row,trip);row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt()); row.setRoute(("PICKUP".equals(trip.getTripPurpose())?"到仓取货":"本土卡车")+" · "+items+"单 · "+trip.getWarehouseCode());row.setItemCount(items);row.setPalletCount(pallets);row.setPackageCount(packages); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 7cd0accd9..88b3221e9 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1880,7 +1880,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}: post: summary: 上传本趟车辆或司机证据 - description: 权限 platform:overseas-warehouse:dispatch:edit。PDF/JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。上传后通过版本化EDIT保存引用。 + description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO仅JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。 security: - bearerAuth: [] responses: @@ -1934,6 +1934,7 @@ paths: enum: - VEHICLE - DRIVER + - DRIVER_PHOTO /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}: get: summary: 读取本车证据元数据 @@ -1979,6 +1980,7 @@ paths: enum: - VEHICLE - DRIVER + - DRIVER_PHOTO - name: fileId in: path required: true @@ -2021,6 +2023,7 @@ paths: enum: - VEHICLE - DRIVER + - DRIVER_PHOTO - name: fileId in: path required: true @@ -4151,7 +4154,10 @@ components: invalidatedReason: type: string nullable: true + attachments: + $ref: '#/components/schemas/DispatchReviewAttachments' required: + - attachments - status - reason - reviewerId @@ -5820,6 +5826,47 @@ components: required: - recordVersion - idempotencyKey + DispatchReviewAttachments: + type: object + description: 本次审核补充证据;省略或null为三组空数组。每类最多20个且不得重复;逐个验证本车和类别归属,不接受MASTER档案证照冒充本车审核上传。 + x-java-record: DispatchContracts.ReviewAttachments + properties: + vehicleFileIds: + type: array + maxItems: 20 + uniqueItems: true + description: 本车VEHICLE证据文件ID。 + items: + oneOf: + - type: string + pattern: ^[1-9][0-9]*$ + - type: integer + format: int64 + minimum: 1 + driverPhotoFileIds: + type: array + maxItems: 20 + uniqueItems: true + description: 本车DRIVER_PHOTO证据文件ID。 + items: + oneOf: + - type: string + pattern: ^[1-9][0-9]*$ + - type: integer + format: int64 + minimum: 1 + licenseFileIds: + type: array + maxItems: 20 + uniqueItems: true + description: 本车DRIVER证据文件ID。 + items: + oneOf: + - type: string + pattern: ^[1-9][0-9]*$ + - type: integer + format: int64 + minimum: 1 DispatchReviewCommand: type: object x-java-record: DispatchCommands.ReviewCommand @@ -5840,6 +5887,10 @@ components: reason: type: string nullable: true + attachments: + allOf: + - $ref: '#/components/schemas/DispatchReviewAttachments' + nullable: true required: - recordVersion - idempotencyKey diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java index 8cad48d09..dd375b1b1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java @@ -37,6 +37,14 @@ class HttpDispatchEvidenceGatewayTest { assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.VEHICLE,3L)).isInstanceOf(ResponseStatusException.class); assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",own),9,DispatchEvidenceGateway.Kind.DRIVER,4L)).isInstanceOf(ResponseStatusException.class); } + /** 司机照片使用独立本车类别且禁止PDF,不与驾驶证证据串用。 */ + @Test void driverPhotoUsesDistinctKindAndImageOnly() { + var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.png","image/png",128L); + assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L).fileId()).isEqualTo(3L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER,3L)).isInstanceOf(ResponseStatusException.class); + var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.pdf","application/pdf",128L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class); + } /** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */ @Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() { for(var value:java.util.List.of( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index c9d17f466..d5dcc0e6f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -340,12 +340,12 @@ public class DispatchAggregatePersistenceTest { assertSubmissionGate(f,created.dispatchNo(),ReviewState.DRAFT,true); var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submission-states-1",null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false); - var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度"))); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"submission-states-dispatch-rejected",Decision.REJECTED,"核对调度",null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_REJECTED,true); var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submission-states-2",null))); - var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"submission-states-dispatch-approved",Decision.APPROVED,null,null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_PENDING,false); - var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载"))); + var loadingRejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"submission-states-loading-rejected",Decision.REJECTED,"核对装载",null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.LOADING_REJECTED,true); f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(loadingRejected.recordVersion(),"submission-states-3",null))); assertSubmissionGate(f,created.dispatchNo(),ReviewState.DISPATCH_PENDING,false); @@ -363,13 +363,13 @@ public class DispatchAggregatePersistenceTest { var f=new Fixture();var source=f.source(1,"101",501,"ONT8",3); var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-create",List.of(source)))); var submit=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit-1",null))); - assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null)))).isInstanceOf(BizException.class); - var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划"))); + assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(submit.recordVersion(),"wrong-order",Decision.APPROVED,null,null)))).isInstanceOf(BizException.class); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null))); var resubmitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"submit-2",null))); - var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null))); - var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(resubmitted.recordVersion(),"dispatch-approved",Decision.APPROVED,null,null))); + var loading=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"loading-approved",Decision.APPROVED,null,null))); assertThat(loading.stage()).isEqualTo(Stage.LOADING); - var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划"))); + var replay=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submit.recordVersion(),"reject-1",Decision.REJECTED,"补核装载计划",null))); assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(replay.recordVersion()).isEqualTo(rejected.recordVersion()); var returned=f.tx.execute(s->f.service.returnToReview(f.actor,created.dispatchNo(),new DispatchCommands.ReturnToReview(loading.recordVersion(),"return-zero","重新核实资源"))); assertThat(returned.stage()).isEqualTo(Stage.BEFORE_LOADING); @@ -598,8 +598,8 @@ public class DispatchAggregatePersistenceTest { /** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */ public DispatchContracts.CommandResult approve(DispatchContracts.CommandResult created) { var submitted=tx.execute(s->service.submitReview(actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),created.dispatchNo()+"-submit",null))); - var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null))); - return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null))); + var reviewed=tx.execute(s->service.review(actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),created.dispatchNo()+"-dispatch",Decision.APPROVED,null,null))); + return tx.execute(s->service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),created.dispatchNo()+"-loading",Decision.APPROVED,null,null))); } /** 只为实载端口夹具准备真实扫码/归属记录,不声称运行库存命令。 */ public void picked(long source,String customer,long pallet,int packages) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java index c1d05ea82..2792c8cf7 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java @@ -28,12 +28,12 @@ class DispatchPdaReadPersistenceTest { assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); - var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null))); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,null))); var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1); var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING); assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse(); assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机"); - f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符"))); + f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符",null))); assertThat(f.tx.>execute(s->queries.list(actor,null))).isEmpty(); assertThat(f.tx.execute(s->queries.task(actor,created.dispatchNo())).reviews().loadingReview().reason()).isEqualTo("车辆不符"); } finally {security.setAuthentication(previous);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewAttachmentsPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewAttachmentsPersistenceTest.java new file mode 100644 index 000000000..44a1b5cd2 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewAttachmentsPersistenceTest.java @@ -0,0 +1,104 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.*; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 审核附件以真实Mapper保存并随审核轮次流转,不能改写车辆和司机资源。 */ +class DispatchReviewAttachmentsPersistenceTest { + /** 两级附件随决定保存,理货驳回与客服改货保留证据,重提清当前但历史不丢。 */ + @Test void attachmentsSurviveRejectionAndEditsUntilResubmission() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("review-evidence",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + var before=f.repository.find(created.dispatchNo()).getResourcesJson(); + var first=new DispatchContracts.ReviewAttachments(List.of(91L),List.of(92L),List.of(93L)); + var request=new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,first); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,request)); + assertThat(f.service.detail(f.actor,created.dispatchNo()).reviews().dispatchReview().attachments()).isEqualTo(first); + assertThat(f.repository.find(created.dispatchNo()).getResourcesJson()).isEqualTo(before); + var second=new DispatchContracts.ReviewAttachments(List.of(94L),List.of(95L),List.of(96L)); + var rejected=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"reject",Decision.REJECTED,"照片不清晰",second))); + var reviews=f.service.detail(f.actor,created.dispatchNo()).reviews(); + assertThat(reviews.dispatchReview().status()).isEqualTo(ReviewStatus.NOT_SUBMITTED); + assertThat(reviews.dispatchReview().attachments()).isEqualTo(first); + assertThat(reviews.loadingReview().attachments()).isEqualTo(second); + var changed=f.tx.execute(s->f.service.addCargo(f.actor,created.dispatchNo(),new DispatchCommands.AddCargo(rejected.recordVersion(),"cargo",List.of(f.source(2,"101",502,"ONT8",2)),List.of()))); + reviews=f.service.detail(f.actor,created.dispatchNo()).reviews(); + assertThat(reviews.dispatchReview().attachments()).isEqualTo(first); + assertThat(reviews.loadingReview().attachments()).isEqualTo(second); + assertThat(reviews.loadingReview().reason()).isEqualTo("照片不清晰"); + f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(changed.recordVersion(),"resubmit",null))); + reviews=f.service.detail(f.actor,created.dispatchNo()).reviews(); + assertThat(reviews.dispatchReview().attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty()); + assertThat(reviews.loadingReview().attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE snapshot_json LIKE '%driverPhotoFileIds%92%'",Long.class)).isPositive(); + assertThat(f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,request)).replayed()).isTrue(); + assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,second)))).hasMessageContaining("原请求标识"); + } + + /** 拒绝也逐文件核验本车分类;错误归属不提交状态和版本。 */ + @Test void rejectsForeignKindOrMasterLicenseAndKeepsOriginalVersion() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("verify-evidence",List.of(f.source(1,"101",501,"ONT8",3))))); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + var gateway=(DispatchEvidenceGateway)ReflectionTestUtils.getField(f.service,"evidence"); + long id=f.repository.find(created.dispatchNo()).getId(); + var attachments=new DispatchContracts.ReviewAttachments(List.of(91L),List.of(92L),List.of(93L)); + doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER,93L); + assertThatThrownBy(()->f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad",Decision.REJECTED,"需补资料",attachments)))).hasMessageContaining("归属不匹配"); + verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.VEHICLE,91L); + verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,92L); + verify(gateway).metadata(id,DispatchEvidenceGateway.Kind.DRIVER,93L); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(submitted.recordVersion()); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='bad'",Long.class)).isZero(); + } + + /** 只有当前待审角色能上传;维护权限不能替代审核资格,上传不变更版本和依据。 */ + @Test void uploadUsesCurrentReviewAuthorityWithoutEditAndDoesNotMutateAggregate() throws Exception { + var security=SecurityContextHolder.getContext();var prior=security.getAuthentication(); + try { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("upload-evidence",List.of(f.source(1,"101",501,"ONT8",3))))); + var file=new MockMultipartFile("file","driver.png","image/png",new byte[]{1}); + var kind=DispatchEvidenceGateway.Kind.DRIVER_PHOTO; + security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:dispatch-review")); + assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段"); + var submitted=f.tx.execute(s->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + var before=f.repository.find(created.dispatchNo()); + f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file); + security.setAuthentication(new TestingAuthenticationToken("operator","","platform:overseas-warehouse:dispatch:edit")); + assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段"); + var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"approve",Decision.APPROVED,null,null))); + security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:dispatch-review")); + assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("无当前阶段"); + security.setAuthentication(new TestingAuthenticationToken("operator","","platform:fleet:trip:loading-review")); + f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file); + var after=f.repository.find(created.dispatchNo()); + assertThat(after.getRecordVersion()).isEqualTo(approved.recordVersion()); + assertThat(after.getReviewBasisHash()).isEqualTo(before.getReviewBasisHash()); + assertThat(after.getResourcesJson()).isEqualTo(before.getResourcesJson()); + var loaded=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"load",Decision.APPROVED,null,null))); + assertThatThrownBy(()->f.service.uploadEvidence(f.actor,created.dispatchNo(),kind,file)).hasMessageContaining("装车前"); + } finally {security.setAuthentication(prior);} + } + + /** 省略附件保持PDA原输入语义,非法或过多引用在进入业务前拒绝。 */ + @Test void optionalAttachmentShapeIsBoundedAndNormalized() throws Exception { + var json=new com.fasterxml.jackson.databind.ObjectMapper(); + var request=json.readValue("{\"recordVersion\":1,\"idempotencyKey\":\"pda\",\"decision\":\"APPROVED\"}",DispatchCommands.ReviewCommand.class); + assertThat(request.attachments()).isEqualTo(DispatchContracts.ReviewAttachments.empty()); + assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(List.of(1L,1L),null,null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(null,List.of(-1L),null)).isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(()->new DispatchContracts.ReviewAttachments(null,null,java.util.stream.LongStream.rangeClosed(1,21).boxed().toList())).isInstanceOf(IllegalArgumentException.class); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index b1ca5fa4c..4c2a439f1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -121,6 +121,6 @@ class DispatchReviewWorkflowPersistenceTest { /** 通过同一审核服务分别调用两端,版本和幂等键始终由请求明确提供。 */ private static DispatchContracts.CommandResult review(DispatchAggregatePersistenceTest.Fixture f,DispatchContracts.Actor actor,DispatchContracts.CommandResult prior,boolean loading,String key,Decision decision,String reason) { - return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason))); + return f.tx.execute(s->f.service.review(actor,prior.dispatchNo(),loading,new DispatchCommands.ReviewCommand(prior.recordVersion(),key,decision,reason,null))); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java index c324fcb87..370e13e97 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOutboundDispatchContractTest.java @@ -78,7 +78,7 @@ class PdaOutboundDispatchContractTest { var workflow=mock(com.baiyue.lastleg.dispatch.service.DispatchAggregateService.class); var controller=new DispatchPdaController();ReflectionTestUtils.setField(controller,"workflow",workflow); var user=pda(Set.of("lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"),900L); - var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对"); + var request=new DispatchCommands.ReviewCommand(2L,"pda-review",DispatchTypes.Decision.REJECTED,"司机资料待核对",null); controller.loadingReview(user,"PC202610210001",request); verify(workflow).review(new DispatchContracts.Actor("7",DispatchTypes.ActorMode.PDA,"900"),"PC202610210001",true,request); assertFalse(Arrays.stream(DispatchPdaController.class.getDeclaredMethods()).anyMatch(method->method.getName().equals("dispatchReview"))); diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 853e76efb..1d33571e4 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -2,7 +2,9 @@ `fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。 -固定托盘造数中,`inbound-measurement.mjs` 缺少 `TP-001` 或 `YW-TP-002` 时记录阻断并停止,不自动选择其他托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 +固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 + +OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。 先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。 diff --git a/scripts/admin-test-env-modules/fee-items-contract.test.mjs b/scripts/admin-test-env-modules/fee-items-contract.test.mjs index 41d120019..ed563e8ef 100644 --- a/scripts/admin-test-env-modules/fee-items-contract.test.mjs +++ b/scripts/admin-test-env-modules/fee-items-contract.test.mjs @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs' import test from 'node:test' import { normalizeModuleOrder, parseArguments } from '../admin-test-env-acceptance.mjs' -import { cleanupSucceeded, isAcceptanceFeeItem } from './fee-items.mjs' +import { cleanupSucceeded, isAcceptanceFeeItem, formalFeeItems } from './fee-items.mjs' const source = readFileSync(new URL('./fee-items.mjs', import.meta.url), 'utf8') @@ -31,7 +31,15 @@ test('stale cleanup only recognizes the acceptance-owned code and name pair', () test('baseline checks run only after stale acceptance cleanup', () => { assert.ok(source.indexOf('cleanupStaleAcceptanceItems(context)') - < source.indexOf("'正式目录112条且默认20条对应6页'")) + < source.indexOf("'全部目录默认20条分页与完整列表一致'")) +}) + +test('正式费用按编码识别,扩展停用项目保留且不掩盖缺失正式编码', () => { + const rows = [{ feeCode: 'FBA_HEAD_FREIGHT' }, { feeCode: 'FEE_TYPE_001' }, + { feeCode: 'PRIVATE_LAST_MILE_DELIVERY' }, { feeCode: 'FEE_EXT_OTHER', status: 'DISABLED' }] + assert.deepEqual(formalFeeItems(rows), rows.slice(0, 3)) + assert.equal(rows.length, 4) + assert.equal(formalFeeItems([{ feeCode: 'FEE_TYPE_111' }]).length, 0) }) test('formal baseline includes both system fee items in addition to the 110 controlled items', () => { diff --git a/scripts/admin-test-env-modules/fee-items.mjs b/scripts/admin-test-env-modules/fee-items.mjs index 2778893b7..60469b57f 100644 --- a/scripts/admin-test-env-modules/fee-items.mjs +++ b/scripts/admin-test-env-modules/fee-items.mjs @@ -51,6 +51,11 @@ function expectedCodes() { ...Array.from({ length: 110 }, (_, index) => `FEE_TYPE_${String(index + 1).padStart(3, '0')}`), ].sort() } +/** 正式基线按受控编码识别;其他业务合法扩展不计入112条基线。 */ +export function formalFeeItems(rows) { + const codes = new Set(expectedCodes()) + return rows.filter(row => codes.has(row.feeCode)) +} export function isAcceptanceFeeItem(row) { return /^ACPT_\d+$/.test(String(row?.feeCode || '')) && String(row?.feeTypeName || '').startsWith('全量验收费用-') @@ -91,16 +96,16 @@ export async function run(context) { const initialPageResponse = await context.request('GET', query({ page: 1, size: 20 })) const initialPage = context.apiSuccess(MODULE, '费用项目查看权限与分页查询', initialPageResponse, 'page=1,size=20', OPTIONS) - context.assert(MODULE, '正式目录112条且默认20条对应6页', initialPageResponse, - records(initialPage).length === 20 && Number(initialPage?.total) === BASELINE_COUNT - && Number(initialPage?.pages) === 6 && Number(initialPage?.page) === 1, + const initial = await listAll(context) + context.assert(MODULE, '全部目录默认20条分页与完整列表一致', initialPageResponse, + records(initialPage).length === Math.min(20, initial.rows.length) && Number(initialPage?.total) === initial.rows.length + && Number(initialPage?.pages) === Math.ceil(initial.rows.length / 20) && Number(initialPage?.page) === 1, `records=${records(initialPage).length}, total=${initialPage?.total ?? 'n/a'}, pages=${initialPage?.pages ?? 'n/a'}`, OPTIONS) context.assert(MODULE, '费用项目查询不传 accountId 仍成功', initialPageResponse, success(initialPageResponse) && !String(initialPageResponse.path || initialPageResponse.request?.path || '').includes('accountId'), `path=${initialPageResponse.path || 'missing'}`, OPTIONS) - const initial = await listAll(context) - const initialRows = initial.rows + const initialRows = formalFeeItems(initial.rows) const domestic = initialRows.filter((row) => row.tradeScope === 'DOMESTIC') const international = initialRows.filter((row) => row.tradeScope === 'INTERNATIONAL') const actualCodes = initialRows.map((row) => row.feeCode).sort() @@ -126,7 +131,8 @@ export async function run(context) { const response = await context.request('GET', query({ tradeScope: scope, page: 1, size: 200 })) const page = context.apiSuccess(MODULE, `按 ${scope} 贸易范围筛选`, response, '', OPTIONS) context.assert(MODULE, `${scope} 筛选数量和范围正确`, response, - Number(page?.total) === expected && records(page).every((row) => row.tradeScope === scope), + Number(page?.total) === initial.rows.filter(row => row.tradeScope === scope).length + && formalFeeItems(records(page)).length === expected && records(page).every((row) => row.tradeScope === scope), `total=${page?.total ?? 'n/a'}, records=${records(page).length}`, OPTIONS) } @@ -142,8 +148,9 @@ export async function run(context) { const activeResponse = await context.request('GET', query({ status: 'ACTIVE', page: 1, size: 200 })) const activePage = context.apiSuccess(MODULE, '按 ACTIVE 状态筛选费用项目', activeResponse, '', OPTIONS) - context.assert(MODULE, 'ACTIVE 筛选返回112条且无停用记录', activeResponse, - Number(activePage?.total) === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'), + context.assert(MODULE, 'ACTIVE 筛选覆盖112条正式项目且无停用记录', activeResponse, + Number(activePage?.total) === initial.rows.filter(row => row.status === 'ACTIVE').length + && formalFeeItems(records(activePage)).length === BASELINE_COUNT && records(activePage).every((row) => row.status === 'ACTIVE'), `total=${activePage?.total ?? 'n/a'}`, OPTIONS) const merchantLoginResponse = await context.request('POST', '/api/auth/login', { @@ -273,8 +280,10 @@ export async function run(context) { `feeItemId=${feeItemId}`, OPTIONS) } const final = await listAll(context) - context.assert(MODULE, '清理后恢复112条正式基线且无临时编码或名称', final.response, - final.rows.length === BASELINE_COUNT && !final.rows.some((row) => row.feeCode === createPayload.feeCode + context.assert(MODULE, '清理后保留原目录与112条正式基线且无本轮临时项目', final.response, + formalFeeItems(final.rows).length === BASELINE_COUNT + && final.rows.map(row => row.feeCode).sort().join(',') === initial.rows.map(row => row.feeCode).sort().join(',') + && !final.rows.some((row) => row.feeCode === createPayload.feeCode || String(row.feeTypeName).includes(suffix)), `rows=${final.rows.length}, temporary=${final.rows.filter((row) => row.feeCode === createPayload.feeCode).length}`, OPTIONS) context.createdResources.feeItems = { diff --git a/scripts/admin-test-env-modules/inbound-measurement.mjs b/scripts/admin-test-env-modules/inbound-measurement.mjs index 58e74a049..f4c7273b1 100644 --- a/scripts/admin-test-env-modules/inbound-measurement.mjs +++ b/scripts/admin-test-env-modules/inbound-measurement.mjs @@ -269,11 +269,11 @@ export async function reusablePallets(context, warehouseId) { } pallets.push({ id: asId(exact.id), code: palletCode }) } - if (missingFixed.length === 0) { + if (pallets.length > 0) { reusablePalletsByWarehouse.set(scopedCacheKey, pallets) return pallets } - context.assert(MODULE, '固定称重托盘主数据完整', null, false, + context.assert(MODULE, '指定称重托盘池至少存在一只可复用托盘', null, false, `warehouseId=${warehouseId}, missing=${missingFixed.join(',')}`, OPTIONS) context.addCoverageGap?.(MODULE, 'measurement-pallet-fixture', '固定称重托盘主数据缺失', `warehouseId=${warehouseId}, missing=${missingFixed.join(',')};停止固定托盘造数`, diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index e6920b8b8..0b1912ed7 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -5,7 +5,6 @@ import process from 'node:process' const MODULE = 'inbound-weight-ocr' const SCENARIO = 'weight-image-recognition-and-confirmation' export const WEIGHT_PALLET_OPTIONS_PAGE_SIZE = 50 -const DEFAULT_IMAGE = 'C:/Users/BY-OY/Desktop/上传图片包/测量图片/测量图片/orig_1cf27695-b2c3-44b3-98e3-c0be8b80f79a.JPG' const EDITABLE_STATUSES = new Set(['WAIT_MEASURE', 'MEASURING', 'MEASURE_REJECTED']) const OCR_STATUSES = new Set(['RECOGNIZED', 'FAILED']) const NEW_CONTRACT_OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } @@ -392,7 +391,8 @@ export async function run(context) { tenantId: target.tenantId, inboundId: target.inboundId, resultIds: [], cleaned: false, } - const imagePath = process.env.ADMIN_TEST_OCR_IMAGE || DEFAULT_IMAGE + const imagePath = process.env.ADMIN_TEST_OCR_IMAGE + if (!imagePath) throw new Error('请显式配置 ADMIN_TEST_OCR_IMAGE 为本轮称重验收图片,禁止依赖个人桌面路径') const imageBytes = await readFile(imagePath) const image = { bytes: imageBytes, name: basename(imagePath), contentType: 'image/jpeg' } const initial = await loadWorkspace(context, target, '读取 OCR 测试前重量工作区') diff --git a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs index 608eb264d..3ec8be83a 100644 --- a/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs +++ b/scripts/admin-test-env-modules/it-test-wait-split-bulk-contract.test.mjs @@ -13,7 +13,7 @@ test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓 const query = new URL(path, 'http://offline').searchParams const code = query.get('palletCode') assert.ok(code, '固定模式禁止用无编号列表替换缺失资源') - return { records: complete || code === 'TP-001' + return { records: complete ? [{ id: code === 'TP-001' ? '1' : '2', palletCode: code, warehouseId: 'offline-fixed-pallet' }] : [] } }, apiSuccess(_module, _name, response) { return response }, @@ -31,6 +31,20 @@ test('固定托盘缺失停止造数,不查询其他托盘;补齐后同仓 assert.equal(requests.length, 4, '失败不得缓存为成功') }) +test('指定托盘池仅剩一只时可复用,不要求同时存在两只', async () => { + const context = { + context: {}, + async request(_method, path) { + const code = new URL(path, 'http://offline').searchParams.get('palletCode') + assert.ok(['TP-001', 'YW-TP-002'].includes(code)) + return { records: code === 'YW-TP-002' ? [{ id: '2', palletCode: code, warehouseId: 'offline-single-pallet' }] : [] } + }, + apiSuccess(_module, _name, response) { return response }, + assert(_module, _name, _response, passed) { assert.equal(passed, true) }, + } + assert.deepEqual(await reusablePallets(context, 'offline-single-pallet'), [{ id: '2', code: 'YW-TP-002' }]) +}) + const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') const measurement = readFileSync(new URL('./inbound-measurement.mjs', import.meta.url), 'utf8') @@ -62,7 +76,7 @@ test('称重只随机复用固定托盘且不创建新托盘', () => { assert.match(measurement, /context\.request\('GET',[\s\S]*\/api\/platform\/v1\/pallets\?page=1&size=20/) assert.match(measurement, /randomlyReusePalletIds\(candidates, palletCount/) assert.match(measurement, /随机复用原有称重托盘/) - assert.match(measurement, /固定称重托盘主数据完整/) + assert.match(measurement, /指定称重托盘池至少存在一只可复用托盘/) assert.doesNotMatch(measurement, /context\.request\('POST', '\/api\/platform\/v1\/pallets/) assert.doesNotMatch(acceptance, /createPhase3FreshMeasurementPallet/) }) diff --git a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs index 1a98665b4..ae4f161ba 100644 --- a/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs +++ b/scripts/admin-test-env-modules/phase3-carrier-schedule-queries.test.mjs @@ -54,7 +54,8 @@ test('phase3 script only discovers the current outbound task and blocks missing assert.match(source, /providerEventId/) assert.match(source, /\^\\d\{16,/) assert.doesNotMatch(source, /request\(context,\s*'POST',\s*BASE/) - assert.doesNotMatch(source, /manual-nodes|manual-results|BILL_OF_LADING/) + // 覆盖缺口说明可以提及人工接口;禁止的是构造这些接口路径。 + assert.doesNotMatch(source, /\/manual-(?:nodes|results)(?=['"`/?])|BILL_OF_LADING/) assert.doesNotMatch(source, /records\[0\]/) }) diff --git a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs index e112f93b4..794b48429 100644 --- a/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs +++ b/scripts/admin-test-env-modules/phase3-warehouse-rbac.mjs @@ -14,6 +14,9 @@ const PHASE4_READ_CODES = [ ] const PHASE4_WRITE_SUFFIXES = [':create', ':edit', ':status', ':export', ':adjust', ':delete', ':review'] const PHASE5_OPERATOR_CODES = [ + 'platform:overseas-warehouse:dispatch:create', + 'platform:overseas-warehouse:dispatch:loading-complete', + 'platform:overseas-warehouse:dispatch:depart', 'platform:overseas-warehouse:dispatch:list', 'platform:overseas-warehouse:dispatch:edit', 'platform:overseas-warehouse:dispatch:cancel', @@ -90,7 +93,7 @@ export async function run(context) { context.assert(MODULE, '操作员具备国内入仓权限、四期只读权限与五期履约权限', rolesResponse, operatorCodes.has('platform:firstleg:inbound:loading:view') && operatorCodes.has('platform:firstleg:inbound:loading:edit') - && operatorCodes.size === 31 + && operatorCodes.size === 34 && PHASE4_READ_CODES.every((code) => operatorCodes.has(code)) && PHASE5_OPERATOR_CODES.every((code) => operatorCodes.has(code)) && !operatorCodes.has('platform:firstleg:inbound:loading:complete') diff --git a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs index 56733ff62..ff8b5db23 100644 --- a/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-canonical-warehouse-rbac-contract.test.mjs @@ -26,7 +26,7 @@ test('warehouse operator and tally use the exact phase-four and phase-five permi assert.match(iamSql, new RegExp(`'WAREHOUSE_OPERATOR','${code}'`)) assert.match(iamSql, new RegExp(`'WAREHOUSE_TALLY','${code}'`)) } - assert.match(acceptance, /operatorCodes\.size === 31/) + assert.match(acceptance, /operatorCodes\.size === 34/) assert.match(acceptance, /tallyCodes\.size === 27/) assert.match(acceptance, /PHASE4_WRITE_SUFFIXES/) assert.match(iamSql, /assert_phase4_warehouse_role_minimum/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index e8c7834a1..c78d8740c 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -152,8 +152,7 @@ function fixture(options = {}) { const replay = state.looseboardStatuses.get(body.idempotencyKey) if (replay) return replay.hash === hash ? response(replay.result) : response(null, 'A0409') if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409') - const allowed = (state.looseboard.status === 'PENDING_ACCEPTANCE' && ['DISPATCHED', 'CANCELLED'].includes(body.status)) - || (state.looseboard.status === 'DISPATCHED' && body.status === 'CANCELLED') + const allowed = state.looseboard.status === 'PENDING_ACCEPTANCE' && body.status === 'CANCELLED' if (!allowed) return response(null, 'A0409') const from = state.looseboard.status state.looseboard = { ...state.looseboard, status: body.status, version: state.looseboard.version + 1 } @@ -162,6 +161,7 @@ function fixture(options = {}) { state.looseboardStatuses.set(body.idempotencyKey, { hash, result }) return response(result) } + if (requestPath.includes('/pending-looseboard-demands?') && method === 'GET') return response(page([state.looseboard])) if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'GET') return response(looseboardDetail()) if (requestPath.includes('/local-looseboard-orders/LB1?') && method === 'PUT') { if (Number(body.version) !== state.looseboard.version) return response(null, 'A0409') @@ -222,7 +222,8 @@ test('procurement creation review boundaries and transport cancellation paths pa await run(context) assert.equal(context.steps.every((step) => step.passed), true, context.steps.filter((step) => !step.passed).map((step) => `${step.name}: ${step.evidence}`).join('\n')) - assert.equal(context.gaps.length, 0) + assert.equal(context.gaps.length, 1) + assert.equal(context.gaps[0][1], 'looseboard-pool-fulfillment') assert.equal(context.blockers.length, 0) assert.equal(context.calls.filter(call => call.path === '/api/auth/login').length, 1) assert.equal(context.calls.filter(call => call.path === '/api/auth/logout').length, 1) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index 9f00735d3..a8f628708 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -373,7 +373,8 @@ async function truckFlow(context, customerId) { after?.destination === `${payload.destination}-EDIT` && Number(after?.version) === Number(before?.version) + 1 && after?.status === 'PENDING_REVIEW', `version=${after?.version ?? 'missing'}, destination=${after?.destination || 'missing'}; ${trace(edited.response)}`, OPTIONS) - const stale = await context.request('PATCH', path, { version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) + const stale = await context.request('PATCH', path, { ...payload, idempotencyKey: undefined, + version: Number(before?.version), destination: 'STALE-DO-NOT-APPLY' }) expectedConflict(context, '卡车旧 version 编辑返回 A0409', stale, true) const afterStale = await overseas(context, 'GET', '卡车旧 version 冲突后零漂移回查', path) const afterStaleSummary = summary(afterStale.data) @@ -456,38 +457,33 @@ async function looseboardFlow(context, customerId) { `destination=${afterStaleSummary?.destination || 'missing'}, version=${afterStaleSummary?.version ?? 'missing'}; ${trace(afterStale.response)}`, OPTIONS) const statusPath = query(`${base}/${encodeURIComponent(orderNo)}/status`, { customerId }) - const forwarded = await overseas(context, 'PATCH', '散板状态正向推进到已派车', statusPath, - { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', - idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), - remark: `${context.marker}-FORWARD` }) - const forwardedSummary = summary(forwarded.data) - context.assert(MODULE, '散板状态正向推进成功', forwarded.response, - forwardedSummary?.status === 'DISPATCHED' - && Number(forwardedSummary?.version) === Number(afterStaleSummary?.version) + 1, - `status=${forwardedSummary?.status || 'missing'}, version=${forwardedSummary?.version ?? 'missing'}; ${trace(forwarded.response)}`, OPTIONS) const forwardBody = { version: Number(afterStaleSummary?.version), status: 'DISPATCHED', - idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), - remark: `${context.marker}-FORWARD` } - const forwardReplay = await overseas(context, 'PATCH', '散板状态同键同载荷精确重放', statusPath, - forwardBody) - context.assert(MODULE, '散板状态重放复用已派车终态且版本不漂移', forwardReplay.response, - summary(forwardReplay.data)?.status === 'DISPATCHED' - && Number(summary(forwardReplay.data)?.version) === Number(forwardedSummary?.version), - trace(forwardReplay.response), OPTIONS) - const forwardMismatch = await context.request('PATCH', statusPath, { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }) - expectedConflict(context, '散板状态同键异载荷返回冲突', forwardMismatch) - const reversed = await context.request('PATCH', statusPath, { version: Number(forwardedSummary?.version), status: 'PENDING_ACCEPTANCE', - idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255), - remark: 'REVERSE-DO-NOT-APPLY' }) - expectedConflict(context, '散板状态逆向回退返回冲突', reversed) - const history = await overseas(context, 'GET', '散板状态冲突后流水回查', path) + idempotencyKey: `${context.marker}-looseboard-status-dispatched`.slice(0, 255), remark: `${context.marker}-FORWARD` } + // 散板状态由需求池匹配和真实履约驱动,普通状态接口只能取消。 + const beforeRejected = JSON.stringify(afterStale.data) + for (const [name, body] of [ + ['散板禁止绕过需求池直接派车', forwardBody], + ['散板被拒命令同键重放仍拒绝', forwardBody], + ['散板被拒命令异载荷仍拒绝', { ...forwardBody, remark: 'DIFFERENT-DO-NOT-APPLY' }], + ['散板状态逆向回退返回冲突', { ...forwardBody, status: 'PENDING_ACCEPTANCE', + idempotencyKey: `${context.marker}-looseboard-status-terminal-reverse`.slice(0, 255) }], + ]) { + const rejected = await context.request('PATCH', statusPath, body) + expectedConflict(context, name, rejected) + } + const history = await overseas(context, 'GET', '散板被拒命令后完整详情与流水回查', path) const historySummary = summary(history.data) - const histories = Array.isArray(history.data?.histories) ? history.data.histories : [] - const operations = new Set(histories.map((item) => item?.operationType)) - context.assert(MODULE, '散板流水覆盖创建编辑与正向状态', history.response, - historySummary?.status === 'DISPATCHED' && ['CREATE', 'UPDATE', 'STATUS'].every((type) => operations.has(type)) - && histories.some((item) => String(item?.remark || item?.changeDetail || '').includes(context.marker)), - `status=${historySummary?.status || 'missing'}, historyCount=${histories.length}, operations=${[...operations].join(',') || 'missing'}; ${trace(history.response)}`, OPTIONS) + context.assert(MODULE, '散板非法推进未改变完整详情及流水', history.response, + JSON.stringify(history.data) === beforeRejected && historySummary?.status === 'PENDING_ACCEPTANCE', + trace(history.response), OPTIONS) + const pending = await overseas(context, 'GET', '散板原单进入待匹配需求池', + query(`${BASE}/fleet/capacity-offers/pending-looseboard-demands`, { page: 1, size: 100, keyword: orderNo })) + context.assert(MODULE, '待匹配需求保持本轮原单客户与版本', pending.response, + records(pending.data).some(row => row.orderNo === orderNo && id(row.customerId) === customerId + && Number(row.version) === Number(historySummary?.version)), + trace(pending.response), OPTIONS) + context.addCoverageGap(MODULE, 'looseboard-pool-fulfillment', '散板需求池接车至签收', + '本模块验证原单进入需求池、禁止手工跳状态及取消;接车、实收、装车、签收需本轮独立车次生产链,未以手工改状态代替。', 'fixture') Object.assign(registry.looseboard, { version: Number(historySummary?.version), finalStatus: historySummary?.status }) } finally { await cancelMarkerOrder(context, registry.looseboard, base, ['PENDING_ACCEPTANCE', 'DISPATCHED'], '散板') diff --git a/scripts/admin-test-env-modules/sailing-calendar.mjs b/scripts/admin-test-env-modules/sailing-calendar.mjs index cc4173f36..8f04f39d5 100644 --- a/scripts/admin-test-env-modules/sailing-calendar.mjs +++ b/scripts/admin-test-env-modules/sailing-calendar.mjs @@ -28,7 +28,8 @@ export async function run(context) { const summary = calendar?.summary const filterOptions = calendar?.filterOptions context.assert(module, '月历记录、汇总与筛选选项结构完整', calendarResponse, - records.every((row) => id(row.id) && id(row.sailingNo) && 'etaAt' in row) + records.every((row) => id(row.id) && id(row.sailingNo) + && (row.etaAt == null || Number.isFinite(Date.parse(row.etaAt)))) && summary && Number.isFinite(count(summary.sailingCount)) && filterOptions && Array.isArray(filterOptions.warehouses) && Array.isArray(filterOptions.channels), `month=${month},records=${records.length},sailingCount=${summary?.sailingCount ?? 'missing'}`) diff --git a/scripts/database-schema-alignment-migrations-contract.test.mjs b/scripts/database-schema-alignment-migrations-contract.test.mjs index 625f13a9a..b96f69e3a 100644 --- a/scripts/database-schema-alignment-migrations-contract.test.mjs +++ b/scripts/database-schema-alignment-migrations-contract.test.mjs @@ -121,20 +121,24 @@ test('数据库迁移计划包含授权版本和事件可靠投递的正式目 commit: null, }) const targets = Object.fromEntries(plan.regions.flatMap((region) => region.schemas.map((schema) => [schema.schema, schema]))) - assert.equal(targets.baiyue_iam.target, '4') + assert.equal(targets.baiyue_iam.target, '6') assert.equal(targets.baiyue_iam.migrations.at(-1).path, - 'migrations/cn/baiyue_iam/V4__fleet_capacity_offer_permissions.sql') - assert.equal(targets.baiyue_firstleg.target, '7') + 'migrations/cn/baiyue_iam/V6__cargo_discard_customer_confirmation_permission.sql') + assert.equal(targets.baiyue_firstleg.target, '9') + assert.equal(targets.baiyue_firstleg.migrations.at(-1).path, + 'migrations/cn/baiyue_firstleg/V9__drop_chargeable_weight_confirmation.sql') assert.equal(targets.baiyue_finance.target, '2') - assert.equal(targets.baiyue_lastleg.target, '13') - assert.deepEqual(targets.baiyue_lastleg.migrations.slice(-4).map(migration => migration.path), [ + assert.equal(targets.baiyue_lastleg.target, '33') + assert.equal(targets.baiyue_lastleg.migrations.at(-1).path, + 'migrations/overseas/baiyue_lastleg/V33__looseboard_pool_demand.sql') + assert.deepEqual(targets.baiyue_lastleg.migrations.filter(migration => /\/V1[0-3]__/.test(migration.path)).map(migration => migration.path), [ 'migrations/overseas/baiyue_lastleg/V10__fleet_capacity_pool.sql', 'migrations/overseas/baiyue_lastleg/V11__align_capacity_pool_collation.sql', 'migrations/overseas/baiyue_lastleg/V12__preserve_unknown_pool_order_fields.sql', 'migrations/overseas/baiyue_lastleg/V13__widen_inbound_plan_creation_mode.sql', ]) assert.equal(targets.baiyue_lastlag_warehouse.target, '2') - assert.deepEqual(targets.baiyue_firstleg.migrations.slice(-3).map((migration) => migration.path), [ + assert.deepEqual(targets.baiyue_firstleg.migrations.filter(migration => /\/V[5-7]__/.test(migration.path)).map((migration) => migration.path), [ 'migrations/cn/baiyue_firstleg/V5__event_delivery_retry_audit.sql', 'migrations/cn/baiyue_firstleg/V6__event_delivery_attempt.sql', 'migrations/cn/baiyue_firstleg/V7__settle_partial_loaded_cargo.sql', diff --git a/scripts/fleet-trip-rollback-test-env.test.mjs b/scripts/fleet-trip-rollback-test-env.test.mjs index 7025bac55..5da348213 100644 --- a/scripts/fleet-trip-rollback-test-env.test.mjs +++ b/scripts/fleet-trip-rollback-test-env.test.mjs @@ -284,8 +284,14 @@ test('offline verifier preserves original command and requires all phases before const saved = JSON.parse(fs.readFileSync('reports/fleet-cross-offline/rollback.json', 'utf8')) assert.equal(saved.verified, true); assert.ok(saved.finishedAt) const caller = fs.readFileSync(new URL('./admin-test-env-modules/fleet-cross-customer-acceptance.mjs', import.meta.url), 'utf8') - assert.ok(caller.indexOf('await verifyDepartureRollback(') < caller.indexOf("const departed = await request('POST', departPath, departBody")) - assert.match(caller, /Platform exact replay of PDA native departure/); assert.match(caller, /This customer independently signed/) + assert.match(caller, /await confirmFleetLoading\([^\n]+async command => \{/) + assert.match(caller, /await verifyDepartureRollback\(\{ context, fixture, ready, departPath: command.path, departBody: command.body, snapshot \}\)/) + assert.match(caller, /Rollback restores both complete inventory snapshots before loading completion/) + const loading = fs.readFileSync(new URL('./admin-test-env-modules/fleet-trip-fulfillment.mjs', import.meta.url), 'utf8') + const hook = loading.indexOf('if (beforeComplete) await beforeComplete(proof.loadingCommand)') + const completion = loading.indexOf("const confirmed = await command(context, module, 'POST', proof.loadingCommand.path") + assert.ok(hook >= 0 && completion > hook, '回滚验证必须在真正提交装车完成命令前结束') + assert.match(caller, /Original PDA actor exact replay of loading completion/); assert.match(caller, /This customer independently signed/) } finally { process.chdir(original) assert.ok(resolve(temp).startsWith(resolve(tmpdir()) + '\\') || resolve(temp).startsWith(resolve(tmpdir()) + '/')) diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index d8bb15b80..117bfdec5 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 13 }, "openapi": { - "documents": 100, + "documents": 109, "coveredServices": 11, "codeFirstServices": 2 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 89ab9fb46..48af828a6 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,11 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 }) - assert.equal(summary.openapi.documents, 100) + assert.equal(summary.openapi.documents, 109) + for (const name of ['cargo-discard-statistics', 'dispatch-transport-tasks', 'fleet-outsourced-business', 'fleet-owned-business', 'order-workflow', 'relabel-quantities', 'return-order-statistics', 'warehouse-transfer-quantities']) { + assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) + } + assert.ok(summary.openapi.files.includes('file/src/main/resources/openapi/fleet-owned-business-photos-v1.yaml')) for (const name of ['lastleg-exports', 'return-packing', 'fleet-capacity-offers']) { assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) } diff --git a/scripts/publish-nacos-config-contract.test.mjs b/scripts/publish-nacos-config-contract.test.mjs index 176d28e2f..d379bc576 100644 --- a/scripts/publish-nacos-config-contract.test.mjs +++ b/scripts/publish-nacos-config-contract.test.mjs @@ -59,7 +59,8 @@ try { } catch { $failed = $true } [ordered]@{failed=$failed; calls=$global:calls; published=$global:published} | ConvertTo-Json -Compress ` - const result = spawnSync(process.platform === 'win32' ? 'powershell.exe' : 'pwsh', + // 使用项目统一的 PowerShell 7,避免子进程回落 Windows PowerShell 5 的模块路径与解析环境。 + const result = spawnSync('pwsh', ['-NoProfile', '-NonInteractive', '-EncodedCommand', Buffer.from(command, 'utf16le').toString('base64')], { encoding: 'utf8', timeout: 30_000 }) assert.equal(result.status, 0, result.stderr || result.error?.message) From 94595c810bb193b831423971503304cfa03614fc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 15:28:44 +0800 Subject: [PATCH 059/113] fix(ci): retain configured image repository defaults --- Jenkinsfile | 3 ++- Jenkinsfile.service | 3 ++- scripts/test-environment-pipeline-contract.test.mjs | 3 ++- 3 files changed, 6 insertions(+), 3 deletions(-) diff --git a/Jenkinsfile b/Jenkinsfile index 01e5992d5..030b6c755 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -161,7 +161,8 @@ pipeline { choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)') - string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库地址 host[:port]/owner/image') + // 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。 + string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') choice(name: 'DOWNSTREAM_JOB_PREFIX', choices: downstreamJobPrefixChoicesForJob(env.JOB_BASE_NAME), description: '由当前总控 Job 固定的单服务 Job 前缀') } diff --git a/Jenkinsfile.service b/Jenkinsfile.service index a83e45409..7693a915d 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -49,7 +49,8 @@ pipeline { booleanParam(name: 'ALLOW_SAME_CONTRACT_ROLLBACK', defaultValue: false, description: '仅确认相同协议与Schema时允许回滚;架构切换保持关闭') booleanParam(name: 'DEPLOY', defaultValue: false, description: '构建成功后是否部署本服务') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试') - string(name: 'IMAGE_REPOSITORY', defaultValue: '', description: '必须填写已核实的共享镜像仓库基础路径,不包含服务名和标签') + // 独立构建沿用本 Job 配置,总控调度时保留其明确传入的地址。 + string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库基础路径,不包含服务名和标签;首次配置或更换仓库时填写') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') } diff --git a/scripts/test-environment-pipeline-contract.test.mjs b/scripts/test-environment-pipeline-contract.test.mjs index e27d65b98..9ad321f05 100644 --- a/scripts/test-environment-pipeline-contract.test.mjs +++ b/scripts/test-environment-pipeline-contract.test.mjs @@ -17,7 +17,8 @@ const serviceCompose = (await readFile(new URL('../deploy/docker-compose.yml', i test('both pipelines reject the placeholder registry from failed build 662 before Maven or Docker', () => { for (const source of [coordinatorPipeline, pipeline]) { - assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: ''/) + assert.match(source, /string\(name: 'IMAGE_REPOSITORY', defaultValue: params\.IMAGE_REPOSITORY \?: ''/, + 'declarative parameter refresh must retain this job\'s configured registry instead of resetting it') const guard = source.match(/(?:requestedRepository|imageRepository)\.tokenize\('\/'\)\[0\] ==~ \/\(\?i\)([^\n]+)\//) assert.ok(guard, 'registry host must reject reserved example domains') const reservedHost = new RegExp(`^(?:${guard[1]})$`, 'i') From 090591076f80e1158ca231c7e425e94f5d2bafdc Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 15:53:33 +0800 Subject: [PATCH 060/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 4 +- deploy/sql/海外区域统一增量-MySQL8.sql | 1 + .../file/service/BusinessFileCategory.java | 5 +- .../openapi/dispatch-evidence-v1.yaml | 4 +- .../service/DispatchEvidenceFileTest.java | 12 +- .../DispatchAggregateController.java | 6 + .../dispatch/entity/DispatchAggregate.java | 1 + .../gateway/DispatchEvidenceGateway.java | 2 +- .../gateway/HttpDispatchEvidenceGateway.java | 4 +- .../mapper/DispatchAggregateMapper.java | 2 +- .../dispatch/port/DispatchCommands.java | 7 + .../lastleg/dispatch/port/DispatchTypes.java | 4 +- .../lastleg/dispatch/port/DispatchViews.java | 6 +- .../service/DispatchAggregateService.java | 44 ++++++- .../service/DispatchCommandCodec.java | 2 +- .../lastleg/dto/ContainerArrivalTimeView.java | 3 +- .../lastleg/mapper/LastlegOrderMapper.java | 9 +- .../src/main/resources/db/lastleg.sql | 1 + .../openapi/overseas-business-v1.yaml | 122 +++++++++++++++++- .../calendar/WarehouseCalendarMapperTest.java | 12 ++ .../HttpDispatchEvidenceGatewayTest.java | 9 ++ .../DispatchExceptionPersistenceTest.java | 89 +++++++++++++ .../service/LastlegOrderServiceTest.java | 2 +- scripts/AGENTS.md | 2 +- scripts/README.md | 6 +- scripts/admin-test-env-acceptance.mjs | 2 + scripts/admin-test-env-modules/README.md | 4 +- .../appointment-work-contract.test.mjs | 21 ++- .../appointment-work.mjs | 21 ++- .../inbound-weight-ocr-contract.test.mjs | 17 +++ .../inbound-weight-ocr.mjs | 30 +++-- ...nt-qualification-fixture-contract.test.mjs | 2 +- .../merchant-qualification-fixture.mjs | 2 - .../pda-audit-sailing-contract.test.mjs | 4 + .../phase3-container-overview.mjs | 2 +- ...se3-container-regression-contract.test.mjs | 1 + ...phase4-overseas-business-contract.test.mjs | 3 +- ...se4-overseas-master-data-contract.test.mjs | 22 ++++ .../phase4-overseas-master-data.mjs | 16 ++- ...s-wallet-and-pallet-cost-contract.test.mjs | 7 +- ...phase4-overseas-wallet-and-pallet-cost.mjs | 9 +- 41 files changed, 459 insertions(+), 63 deletions(-) create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchExceptionPersistenceTest.java diff --git a/AGENTS.md b/AGENTS.md index 16ebd4ca7..dd3922234 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,9 +133,9 @@ - 后端同样遵守根指引中的“开发数据和上传文件可丢弃”边界:不得为现有开发库孤儿文件、旧文件归属或历史部分失败新增删除补偿接口、兼容复用、回填或清理链路;只修复当前正式上传与持久化路径,既有开发文件由人工重新上传。 - 通用配置在 `config/nacos/test/`;服务日志、上传文件、`target/`、`reports/` 不属于源码。 - 后端本地验证默认使用 `scripts/backend-check.ps1`,必须显式指定 `-Modules`,不传模块直接拒绝;命令及参数见 `scripts/README.md` 的“后端分层检查”。入口自动经 `local-check.ps1` 限流,`-DryRun` 只检查选择范围,不代表测试通过。 -- 开发迭代使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及直接调用点;Java 签名变化至少使用 `-Mode Compile` 编译生产与测试源码(等价于 `mvn -pl -am test-compile`)。单服务行为变更收尾使用默认 `Module` 模式执行该服务及上游依赖测试;跨服务 DTO、接口或事件变化必须显式列入生产者和受影响消费者。`-am` 只补上游构建依赖,不自动包含下游服务或 HTTP/事件消费者;不能以单类测试代替模块收尾。 +- 局部逻辑使用 `-Mode Focused -Tests <精确测试类名>`,覆盖修改行为及必要边界即可交付,不因收尾再跑 `Module`。Java 签名变化须覆盖生产与测试源码编译;定向测试已编译时不重复跑 `-Mode Compile`。只有影响扩及服务内多个行为且定向覆盖不足才选 `Module`,先说明原因。跨服务 DTO、接口或事件变化显式列入生产者和实际消费者;`-am` 仅补上游构建依赖,不自动包含下游或 HTTP/事件消费者。 - 仅注释/文档调整不触发 Java 回归;脚本调整执行受影响脚本测试和登记合同。API、Mapper、AGENTS 等静态合同仍按各自触发条件执行。纯重构也应覆盖受影响行为,不能只用编译宣称回归通过。`-Dmaven.test.skip=true` 跳过测试编译,不得作为签名变更交付验证。 -- 全仓回归只在以下情况触发:根 POM/统一依赖版本/聚合拓扑/测试基础配置变化;公共认证、序列化、基础模型等影响广泛且不能可靠限定消费者;跨多域批次集成验收;用户明确要求全量回归。使用 `scripts/backend-full-regression.ps1`,保留当前 CI 全量验证。普通单服务任务、单次提交或对话收尾不自动触发全仓;已明确消费者的公共或跨服务变更按受影响模块验证。同批同一相关输入只跑一次,输入变化后补跑受影响范围,触及上述全量条件再重跑全量。 +- 全仓回归仅用于已确认影响广泛且定向覆盖不足的公共依赖/聚合拓扑/认证/序列化/基础模型变化,或用户要求的全量/跨域集成验收;先说明具体影响,不能因未查清范围而全跑。根 POM、依赖或测试配置改动先按实际影响选编译或测试,不一律全仓。入口为 `scripts/backend-full-regression.ps1`,CI 全量保留。普通提交、单服务任务或对话收尾不触发;有效结果复用,输入变化只补跑受影响范围。 - 不以缩短耗时为由删除金额、库存、权限隔离、状态流转、幂等和并发测试;冗余测试须根据重复场景及维护成本单独评估。不要在本地启动 Java/Nacos/数据库,除非用户明确要求。 - 同一批 `AGENTS.md` 索引修改完成后从后端根统一运行一次 `./scripts/agents-index-contract.ps1`;脚本只读源码目录和指定指引,不访问 HTTP、数据库或部署环境。 - Mapper 注册静态合同为 `scripts/mapper-scan-contract.mjs`、`mapper-scan-contract.test.mjs`:自动发现聚合模块、生产 `*Mapper` 接口、服务启动类和分散扫描配置,不启动 Spring、不连接数据库;遗漏注解、扫描根漂移或重新引入子域扫描时直接失败并输出类与文件路径。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 31e9ca721..347bc2820 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -429,6 +429,7 @@ CREATE TABLE ll_dispatch_order ( review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次', review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态', dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照', loading_review_json JSON NULL COMMENT '当前理货审核事实快照', resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java index 59407b4ee..94bdeb1af 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileCategory.java @@ -11,6 +11,7 @@ enum BusinessFileCategory { FLEET_TRIP_OPERATION_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_VEHICLE_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_EVIDENCE(false, FormatPolicy.PDF_IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), + OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_LOADING_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), OVERSEAS_DISPATCH_FUEL_PHOTO(false, FormatPolicy.IMAGE, Limits.STANDARD, BusinessIdPolicy.POSITIVE_LONG), @@ -79,9 +80,9 @@ enum BusinessFileCategory { && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "排车运营照片必须归属平台"); } - if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE) + if ((this == OVERSEAS_DISPATCH_VEHICLE_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_EVIDENCE || this == OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE || this == OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE) && tenantId != 0) { - throw new BizException(ResultCode.BAD_REQUEST, "排车车辆及司机证据必须归属平台"); + throw new BizException(ResultCode.BAD_REQUEST, "排车证据必须归属平台"); } if (this == FLEET_TRIP_OPERATION_EVIDENCE && tenantId != 0) { throw new BizException(ResultCode.BAD_REQUEST, "出车实测凭证必须归属平台"); diff --git a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml index f0be4156b..af84151d9 100644 --- a/file/src/main/resources/openapi/dispatch-evidence-v1.yaml +++ b/file/src/main/resources/openapi/dispatch-evidence-v1.yaml @@ -6,7 +6,7 @@ info: 复用 InternalBusinessFileController 的现有通用内部入口,不新增公开文件路由。 三类证据均固定平台 tenantId=0,businessId 为排车内部正数 Long ID 的十进制表示, 不使用车辆 ID、司机 ID、车牌或客户 ID。车辆与司机证据分别使用 - OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。 + OVERSEAS_DISPATCH_VEHICLE_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_EVIDENCE、OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE,不可互换。司机本人照片DRIVER_PHOTO和排车异常图片EXCEPTION仅支持JPG/PNG;其他两类沿PDF/JPG/PNG。 File 校验内部服务令牌、平台归属、正数业务 ID、文件非空、最大 10MiB(10485760 字节,包含边界)、 PDF/JPG/PNG MIME 及对应内容签名。元数据和下载按 tenantId、businessType、businessId、fileId 联合查询 ACTIVE 且未删除文件,错车或错类别均视为归属不匹配,下载前完成归属校验。 @@ -126,7 +126,7 @@ components: description: 正数Long的十进制字符串,值不得超过9223372036854775807 DispatchEvidenceType: type: string - enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE] + enum: [OVERSEAS_DISPATCH_VEHICLE_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_EVIDENCE, OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE, OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE] MetadataEnvelope: type: object required: [code, data] diff --git a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java index be8d7154d..82ac97a6e 100644 --- a/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java +++ b/file/src/test/java/com/baiyue/file/service/DispatchEvidenceFileTest.java @@ -30,6 +30,7 @@ import static org.mockito.Mockito.*; class DispatchEvidenceFileTest { private static final String VEHICLE = "OVERSEAS_DISPATCH_VEHICLE_EVIDENCE"; + private static final String EXCEPTION = "OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE"; private static final String PHOTO = "OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE"; private static final String DRIVER = "OVERSEAS_DISPATCH_DRIVER_EVIDENCE"; private static final int MAX_BYTES = 10 * 1024 * 1024; @@ -43,7 +44,7 @@ class DispatchEvidenceFileTest { /** 三类证据只接受平台与有效排车内部 ID,非法输入不得访问数据库或磁盘。 */ @ParameterizedTest - @ValueSource(strings = {VEHICLE, DRIVER, PHOTO}) + @ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION}) void requiresPlatformAndPositiveDispatchIdBeforeAccess(String type) { var files = mock(FileObjectMapper.class); var storage = mock(BusinessFileStorage.class); @@ -65,9 +66,10 @@ class DispatchEvidenceFileTest { verifyNoInteractions(files, storage); } - /** 司机本人照片使用独立平台类别,只保存真实图片,拒绝PDF和伪造图片。 */ - @org.junit.jupiter.api.Test void driverPhotoAcceptsOnlyRealImages() throws Exception { - var storage=storage();var category=BusinessFileCategory.require(PHOTO); + /** 司机照片与异常图片使用独立平台类别,拒绝PDF和伪造图片。 */ + @ParameterizedTest @ValueSource(strings = {PHOTO, EXCEPTION}) + void photoAcceptsOnlyRealImages(String type) throws Exception { + var storage=storage();var category=BusinessFileCategory.require(type); for(var format:new String[]{"jpeg","png"}) { var upload=new MockMultipartFile("file","driver."+format,"image/"+format,image(format)); var saved=storage.store(0,"71",upload,category); @@ -162,7 +164,7 @@ class DispatchEvidenceFileTest { /** 元数据和下载均逐列带全复合键;错车、错类别、错文件不得读取存储内容。 */ @ParameterizedTest - @ValueSource(strings = {VEHICLE, DRIVER, PHOTO}) + @ValueSource(strings = {VEHICLE, DRIVER, PHOTO, EXCEPTION}) void metadataAndDownloadEnforceEveryScopeKeyBeforeReading(String type) throws Exception { var files = mock(FileObjectMapper.class); var storage = mock(BusinessFileStorage.class); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index 782f4583b..b0ea5d313 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -77,6 +77,12 @@ public class DispatchAggregateController { public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.platform(user),dispatchNo,request)); } + /** 派送异常登记不触发签收或退货入库。 */ + @PostMapping("/{dispatchNo}/exceptions") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse markException(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.MarkException request) { + return ApiResponse.success(service.markException(DispatchActorFactory.platform(user),dispatchNo,request)); + } /** 本车证据先上传;维护和当前待审权限分别由服务端核验,引用随对应命令保存。 */ @PostMapping(value="/{dispatchNo}/evidence/{kind}",consumes=org.springframework.http.MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAnyAuthority('platform:overseas-warehouse:dispatch:edit','platform:fleet:trip:dispatch-review','platform:fleet:trip:loading-review')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java index c5975cb72..0fa048fba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java @@ -24,6 +24,7 @@ public class DispatchAggregate { private String dispatchReviewJson; private String loadingReviewJson; private String resourcesJson; + private String exceptionsJson; private String vehiclePlate; private String route; private LocalDateTime plannedDepartureAt; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java index d901f837d..5d9dd30ab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/DispatchEvidenceGateway.java @@ -4,7 +4,7 @@ import org.springframework.web.multipart.MultipartFile; /** 车辆、司机及审核补充证据属于本张整车,不能借司机主档或客户文件授权。 */ public interface DispatchEvidenceGateway { - enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO } + enum Kind { VEHICLE, DRIVER, DRIVER_PHOTO, EXCEPTION } record File(Long fileId,String fileName,String contentType,long sizeBytes) { } /** 上传只写本车、本类证据。 */ File upload(long dispatchId,Kind kind,String operator,MultipartFile file); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java index 0797486bc..579473683 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGateway.java @@ -83,9 +83,9 @@ public class HttpDispatchEvidenceGateway implements DispatchEvidenceGateway { || !supportedType(kind,value.contentType()) || value.sizeBytes()==null || value.sizeBytes()<=0 || value.sizeBytes()>MAX)throw invalid(); return new File(value.fileId(),value.fileName(),value.contentType(),value.sizeBytes()); } - /** 司机本人照片只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */ + /** 司机本人照片与异常证据只接受图片;车辆和驾驶证沿现有PDF/图片证据规则。 */ private static boolean supportedType(Kind kind,String contentType) { - return TYPES.contains(Objects.toString(contentType,"")) && (kind!=Kind.DRIVER_PHOTO || !"application/pdf".equals(contentType)); + return TYPES.contains(Objects.toString(contentType,"")) && ((kind!=Kind.DRIVER_PHOTO && kind!=Kind.EXCEPTION) || !"application/pdf".equals(contentType)); } /** 无效证据不能作为已核实资料。 */ private static ResponseStatusException invalid(){return new ResponseStatusException(HttpStatus.BAD_REQUEST,"证据须为本车有效PDF、JPG或PNG,最大10MiB");} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index c742a4cb4..b58b70e61 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -94,7 +94,7 @@ public interface DispatchAggregateMapper { UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion}, review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash}, dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson}, - cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, + exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java index a66d4b0f7..17497319f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -73,6 +73,13 @@ public final class DispatchCommands { public LoadingComplete { recordVersion = requireVersion(recordVersion); } } + /** 派送异常仅登记事实,不改变派送、签收或库存。 */ + public record MarkException(Long recordVersion, String idempotencyKey, ExceptionType exceptionType, + ReturnDirection returnDirection, String description, List imageFileIds) { + /** 必填版本用于阻止过期页面覆盖当前排车。 */ + public MarkException { recordVersion = requireVersion(recordVersion); } + } + public record Depart(Long recordVersion, String idempotencyKey, String remark) { /** 实际发车不得接受缺省版本。 */ public Depart { recordVersion = requireVersion(recordVersion); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java index 69a90951b..e4aa42451 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchTypes.java @@ -9,6 +9,8 @@ public final class DispatchTypes { public enum Stage { BEFORE_LOADING, LOADING, AFTER_LOADING, DELIVERING, DELIVERED } public enum ReviewState { DRAFT, DISPATCH_PENDING, DISPATCH_REJECTED, LOADING_PENDING, LOADING_REJECTED, APPROVED } public enum ReviewStatus { NOT_SUBMITTED, PENDING, APPROVED, REJECTED } + public enum ExceptionType { REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION } + public enum ReturnDirection { OVERSEAS_WAREHOUSE, OTHER } public enum Decision { APPROVED, REJECTED } public enum ResourceMode { MASTER, EXTERNAL } public enum ActorMode { PLATFORM, PDA } @@ -18,6 +20,6 @@ public final class DispatchTypes { public enum Effect { APPLIED, EXISTING } public enum Action { CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, DISPATCH_REVIEW, - LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART + LOADING_REVIEW, RETURN_TO_REVIEW, PICK, LOADING_COMPLETE, ARRANGE_DELIVERY, DEPART, MARK_EXCEPTION } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 87cd844d6..2d662b366 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -44,7 +44,11 @@ public final class DispatchViews { public record DispatchInfo(String route, Instant plannedDepartureAt, Instant departedAt, Instant estimatedArrivalAt, List fbaAppointments, - List appointmentBatches) { } + List appointmentBatches, List exceptions) { } + + /** 排车级异常记录;退货去向只描述安排,不产生退货入库事实。 */ + public record ExceptionRecord(ExceptionType exceptionType, ReturnDirection returnDirection, + String description, List imageFileIds, Instant occurredAt) { } public record Documents(String dispatchNo, List outbounds, List deliveries, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 0d5ffbc31..a97d8c45b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -238,7 +238,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 上传只返回本车证据;待审按当前审核权限授权,客服阶段按维护权限授权。 */ public com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File uploadEvidence(Actor actor,String number, com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,org.springframework.web.multipart.MultipartFile file) { - requirePlatform(actor);var row=required(actor,number,false);requireEditing(row,row.getRecordVersion()); + requirePlatform(actor);var row=required(actor,number,false); + if(kind==com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION) { + requireExceptionAllowed(actor,row,row.getRecordVersion()); + return evidence.upload(row.getId(),kind,actor.operatorId(),file); + } + requireEditing(row,row.getRecordVersion()); Action permission = ReviewState.DISPATCH_PENDING.name().equals(row.getReviewState()) ? Action.DISPATCH_REVIEW : ReviewState.LOADING_PENDING.name().equals(row.getReviewState()) ? Action.LOADING_REVIEW : Action.EDIT; if(!hasPermission(actor,permission))throw new BizException(ResultCode.FORBIDDEN,"无当前阶段附件上传权限"); @@ -663,6 +668,40 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var id:ids)evidence.metadata(dispatch,kind,id); } + /** 仅派送中登记异常;原键重试先恢复,避免追加重复记录。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult markException(Actor actor,String number,DispatchCommands.MarkException request) { + var row=lockPlatform(actor,number); + if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限"); + var identity=identity(actor,number,Action.MARK_EXCEPTION,request.recordVersion(),request.idempotencyKey(),request); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + requireExceptionAllowed(actor,row,request.recordVersion()); + if(request.exceptionType()==null)throw bad("请选择异常类型"); + var description=trim(request.description()); + if(description!=null && description.length()>1000)throw bad("异常说明不能超过1000字"); + var ids=request.imageFileIds()==null?List.of():request.imageFileIds(); + if(ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("异常图片引用无效或超过20张"); + // 文件须属于本车异常类别,不能借用其他车辆或审核附件。 + for(var id:ids)evidence.metadata(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.EXCEPTION,id); + var records=new ArrayList<>(exceptionRecords(row)); + records.add(new DispatchViews.ExceptionRecord(request.exceptionType(),request.returnDirection(),description,List.copyOf(ids),Instant.now())); + row.setExceptionsJson(write(records)); + return saveCommand(row,actor,identity,description); + } + + /** 异常登记复用排车维护权限;版本、任务类型与阶段由服务端再次核验。 */ + private void requireExceptionAllowed(Actor actor,DispatchAggregate row,long version) { + if(!hasPermission(actor,Action.MARK_EXCEPTION))throw new BizException(ResultCode.FORBIDDEN,"无标记异常权限"); + if(row.getRecordVersion()!=version)throw conflict("排车版本已变化,请刷新后再标记异常"); + if(!actions(actor,row).get(Action.MARK_EXCEPTION).allowed())throw conflict("仅派送中的仓库派送排车可以标记异常"); + } + + /** 尚未登记时为空集合,已登记记录按保存顺序完整回看。 */ + private List exceptionRecords(DispatchAggregate row) { + if(row.getExceptionsJson()==null)return List.of(); + return Arrays.asList(read(row.getExceptionsJson(),DispatchViews.ExceptionRecord[].class)); + } + /** 同一只读快照返回分页、总数和基集分布,不能在前端过滤当前页。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchViews.Page page(Actor actor, DispatchViews.Query query) { @@ -699,7 +738,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var documents=new DispatchViews.Documents(number,repository.outbounds(row.getId()),deliveries,instant(row.getCreatedAt()),instant(row.getUpdatedAt()),row.getRemark()); return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), - new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs), + new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)), documents,orderDetails(cargo)); } @@ -817,6 +856,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var action:Action.values()) { if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue; boolean state=switch(action) { + case MARK_EXCEPTION -> active && stage==Stage.DELIVERING; case CREATE -> actor.mode()==ActorMode.PLATFORM; case EDIT,ADD_CARGO,REMOVE_CARGO,CANCEL -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0; case SUBMIT_REVIEW -> active && stage==Stage.BEFORE_LOADING && n(row.getPickedPackageCount())==0 && canSubmitReview(row.getReviewState()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java index 29c7db77c..431d3562e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandCodec.java @@ -73,7 +73,7 @@ public final class DispatchCommandCodec { case DEPART -> DispatchResults.Departed.class; case ARRANGE_DELIVERY -> DispatchDeliveryModels.ArrangementResult.class; case CREATE, EDIT, ADD_CARGO, REMOVE_CARGO, CANCEL, SUBMIT_REVIEW, - DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW -> DispatchResults.Saved.class; + DISPATCH_REVIEW, LOADING_REVIEW, RETURN_TO_REVIEW, MARK_EXCEPTION -> DispatchResults.Saved.class; }; } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java index a8071e88b..72aa9e053 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dto/ContainerArrivalTimeView.java @@ -7,5 +7,6 @@ public record ContainerArrivalTimeView( String sourceContainerBookingId, LocalDateTime estimatedWarehouseArrivalAt, LocalDateTime actualWarehouseArrivalAt, - int version + int version, + long planCount ) { } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java index 7d12730b1..801fa1ebd 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/mapper/LastlegOrderMapper.java @@ -210,12 +210,19 @@ public interface LastlegOrderMapper extends BaseMapper { @Select("SELECT * FROM ll_order WHERE source_container_booking_id=#{sourceContainerBookingId} ORDER BY customer_id,id FOR UPDATE") List lockContainerOrders(@Param("sourceContainerBookingId") String sourceContainerBookingId); + /** 人工时间及计划冻结时间优先;返回计划数量供各页面统一判断默认日期。 */ @Select("") diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index 6e4d49036..aa3604c51 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1907,6 +1907,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order ( review_state VARCHAR(32) NOT NULL DEFAULT 'DRAFT' COMMENT '审核子流程当前状态', review_round BIGINT NOT NULL DEFAULT 0 COMMENT '当前审核轮次', review_basis_hash CHAR(64) NULL COMMENT '本审核轮次核对的货物和资源依据摘要', + exceptions_json JSON NULL COMMENT '派送异常登记记录及图片引用,不改变派送及库存状态', dispatch_review_json JSON NULL COMMENT '当前调度审核事实快照', loading_review_json JSON NULL COMMENT '当前理货审核事实快照', resources_json JSON NULL COMMENT '本趟主档或临时车辆司机的真实资源快照', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 88b3221e9..448375c73 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1682,6 +1682,49 @@ paths: required: true schema: type: string + /api/platform/v1/last-leg/dispatches/{dispatchNo}/exceptions: + post: + summary: 登记派送异常 + description: 权限 platform:overseas-warehouse:dispatch:edit;仅有效仓库派送单DELIVERING可登记,保存后阶段不变,不产生退货、签收或库存变化。原键重试恢复原结果。 + security: + - bearerAuth: [] + responses: + '200': + description: 统一ApiResponse;业务失败通过code返回,不能仅凭HTTP200判断成功。 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + requestId: + type: string + nullable: true + traceId: + type: string + nullable: true + timestamp: + type: string + format: date-time + data: + allOf: + - $ref: '#/components/schemas/DispatchCommandResult' + nullable: true + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/DispatchMarkException' + parameters: + - name: dispatchNo + in: path + required: true + schema: + type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: summary: 确认装车完成并自动进入派送中 @@ -1880,7 +1923,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}: post: summary: 上传本趟车辆或司机证据 - description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO仅JPG/PNG,非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。 + description: 客服阶段使用 platform:overseas-warehouse:dispatch:edit;调度待审/理货待审分别使用 platform:fleet:trip:dispatch-review / platform:fleet:trip:loading-review,无需维护车辆权限。VEHICLE和DRIVER支持PDF/JPG/PNG,DRIVER_PHOTO和EXCEPTION仅JPG/PNG;EXCEPTION仅派送中使用排车edit权限,引用随异常登记保存。非空且不超过10MiB;平台、本车、类型复合归属。审核补充附件随ReviewCommand保存,不修改资源,不重启审核。 security: - bearerAuth: [] responses: @@ -1935,6 +1978,7 @@ paths: - VEHICLE - DRIVER - DRIVER_PHOTO + - EXCEPTION /api/platform/v1/last-leg/dispatches/{dispatchNo}/evidence/{kind}/{fileId}: get: summary: 读取本车证据元数据 @@ -1981,6 +2025,7 @@ paths: - VEHICLE - DRIVER - DRIVER_PHOTO + - EXCEPTION - name: fileId in: path required: true @@ -2024,6 +2069,7 @@ paths: - VEHICLE - DRIVER - DRIVER_PHOTO + - EXCEPTION - name: fileId in: path required: true @@ -3503,12 +3549,13 @@ components: remark: { type: string, maxLength: 500, nullable: true } ContainerArrivalTimeView: type: object - required: [sourceContainerBookingId, version] + required: [sourceContainerBookingId, version, planCount] properties: sourceContainerBookingId: { type: string } - estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true } + estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 } actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 } - version: { type: integer, minimum: 0 } + version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 } + planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 } ContainerPlanProgressDetail: type: object required: [summary, orders] @@ -5142,6 +5189,7 @@ components: - LOADING_COMPLETE - ARRANGE_DELIVERY - DEPART + - MARK_EXCEPTION DispatchGate: type: object x-java-record: DispatchContracts.Gate @@ -6314,6 +6362,10 @@ components: items: $ref: '#/components/schemas/DispatchBatchReference' nullable: true + exceptions: + type: array + items: + $ref: '#/components/schemas/DispatchExceptionRecord' required: - route - plannedDepartureAt @@ -6321,6 +6373,7 @@ components: - estimatedArrivalAt - fbaAppointments - appointmentBatches + - exceptions DispatchDocuments: type: object x-java-record: DispatchViews.Documents @@ -7940,5 +7993,66 @@ components: - sourceInboundOrderNo - sourceInboundCode - productQuantity + + DispatchExceptionType: + type: string + enum: [REFUSED, PARTIAL_RECEIPT, VEHICLE_EXCEPTION] + description: 拒收、部分签收、车辆异常。 + DispatchReturnDirection: + type: string + enum: [OVERSEAS_WAREHOUSE, OTHER] + description: 退回海外仓、其他;仅登记去向,不自动退货入库。 + DispatchMarkException: + type: object + x-java-record: DispatchCommands.MarkException + required: [recordVersion, idempotencyKey, exceptionType] + properties: + recordVersion: + oneOf: + - type: string + pattern: ^[0-9]+$ + - type: integer + minimum: 0 + idempotencyKey: + type: string + exceptionType: + $ref: '#/components/schemas/DispatchExceptionType' + returnDirection: + allOf: + - $ref: '#/components/schemas/DispatchReturnDirection' + nullable: true + description: + type: string + maxLength: 1000 + nullable: true + imageFileIds: + type: array + maxItems: 20 + uniqueItems: true + items: + type: string + pattern: ^[1-9][0-9]*$ + DispatchExceptionRecord: + type: object + x-java-record: DispatchViews.ExceptionRecord + required: [exceptionType, returnDirection, description, imageFileIds, occurredAt] + properties: + exceptionType: + $ref: '#/components/schemas/DispatchExceptionType' + returnDirection: + allOf: + - $ref: '#/components/schemas/DispatchReturnDirection' + nullable: true + description: + type: string + nullable: true + imageFileIds: + type: array + items: + type: string + occurredAt: + type: string + format: date-time + securitySchemes: bearerAuth: { type: http, scheme: bearer, bearerFormat: JWT } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java index 2e84dc2b9..a71d9901c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java @@ -2,6 +2,7 @@ package com.baiyue.lastleg.calendar; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; import org.apache.ibatis.mapping.Environment; import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; import org.h2.jdbcx.JdbcDataSource; @@ -28,12 +29,23 @@ class WarehouseCalendarMapperTest { } var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(WarehouseCalendarMapper.class); + config.addMapper(LastlegOrderMapper.class); try(var session=new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1")); assertEquals(3,rows.size()); assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt()); assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString()); assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt()); + // 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。 + var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3")); + assertEquals(3,customs.size()); + for(var calendar:rows) { + var arrival=customs.stream().filter(row -> row.sourceContainerBookingId().equals(calendar.sourceContainerBookingId())).findFirst().orElseThrow(); + assertEquals(calendar.plannedAt(),arrival.estimatedWarehouseArrivalAt()); + assertEquals(calendar.actualAt(),arrival.actualWarehouseArrivalAt()); + assertEquals(calendar.version(),arrival.version()); + assertEquals(calendar.planCount(),arrival.planCount()); + } } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java index dd375b1b1..235006c02 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/gateway/HttpDispatchEvidenceGatewayTest.java @@ -45,6 +45,15 @@ class HttpDispatchEvidenceGatewayTest { var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_DRIVER_PHOTO_EVIDENCE","9","driver.pdf","application/pdf",128L); assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class); } + /** 异常图片独立归属本车,审核照片及PDF均不能用作异常图片。 */ + @Test void exceptionImagesRejectOtherKindsCarsAndPdf() { + var photo=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.png","image/png",128L); + assertThat(HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L).fileId()).isEqualTo(3L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),10,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",photo),9,DispatchEvidenceGateway.Kind.DRIVER_PHOTO,3L)).isInstanceOf(ResponseStatusException.class); + var pdf=new HttpDispatchEvidenceGateway.Metadata(3L,0L,"OVERSEAS_DISPATCH_EXCEPTION_EVIDENCE","9","exception.pdf","application/pdf",128L); + assertThatThrownBy(()->HttpDispatchEvidenceGateway.verified(new HttpDispatchEvidenceGateway.Envelope("200",pdf),9,DispatchEvidenceGateway.Kind.EXCEPTION,3L)).isInstanceOf(ResponseStatusException.class); + } /** 客户、旧主档或缺失媒体类型均拒绝,不因为文件本身存在而放行。 */ @Test void rejectsForeignTenantMasterScopeAndMalformedMetadata() { for(var value:java.util.List.of( diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchExceptionPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchExceptionPersistenceTest.java new file mode 100644 index 000000000..b1fdd40d8 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchExceptionPersistenceTest.java @@ -0,0 +1,89 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.TestingAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.List; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 通过真实Mapper和事务验证异常登记,不把登记事实解释成签收或退货。 */ +class DispatchExceptionPersistenceTest { + /** 登记使用排车独立维护权限。 */ + @BeforeEach void authorize() { SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:edit")); } + /** 清除线程身份,避免影响其他权限回归。 */ + @AfterEach void clear() { SecurityContextHolder.clearContext(); } + + /** 每种原型类型均可保存,原键重试不追加,最终完成后仍恢复原结果。 */ + @Test void recordsAllTypesAndReplaysWithoutChangingDeliveryFacts() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);long version=0; + var before=f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order"); + for(var type:ExceptionType.values()) { + var body=new DispatchCommands.MarkException(version,"exception-"+type,type,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of()); + var saved=f.tx.execute(t->f.service.markException(f.actor,no,body)); + var replay=f.tx.execute(t->f.service.markException(f.actor,no,body)); + assertThat(saved.stage()).isEqualTo(Stage.DELIVERING);assertThat(replay.replayed()).isTrue(); + assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion());version=saved.recordVersion(); + } + var records=f.service.detail(f.actor,no).dispatchInfo().exceptions();assertThat(records).hasSize(3); + assertThat(records).allSatisfy(r->{assertThat(r.description()).isEqualTo("真实异常");assertThat(r.occurredAt()).isNotNull();}); + assertThat(f.db.queryForMap("SELECT stage,shipped_package_count,departed_at,review_state FROM ll_dispatch_order")).isEqualTo(before); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='MARK_EXCEPTION'",Long.class)).isEqualTo(3); + var first=new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,ReturnDirection.OVERSEAS_WAREHOUSE,"真实异常",List.of()); + f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERED'"); + assertThat(f.tx.execute(t->f.service.markException(f.actor,no,first)).replayed()).isTrue(); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,new DispatchCommands.MarkException(0L,"exception-REFUSED",ExceptionType.REFUSED,null,"改了内容",List.of())))).isInstanceOf(RuntimeException.class); + assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).hasSize(3); + } + + /** 非派送阶段、过期版本、只读身份、PDA及运输任务不能登记。 */ + @Test void rejectsWrongStageVersionPermissionAndTask() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var body=request(0,"blocked",List.of()); + for(var stage:List.of("BEFORE_LOADING","LOADING","AFTER_LOADING","DELIVERED")) { + f.db.update("UPDATE ll_dispatch_order SET stage=?",stage); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中"); + } + f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING'"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(9,"stale",List.of())))).hasMessageContaining("版本"); + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator-test","","platform:overseas-warehouse:dispatch:list")); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("权限"); + authorize();f.db.update("UPDATE ll_dispatch_order SET task_type='LOCAL_TRUCK'"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中"); + f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',status='CANCELLED'"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,body))).hasMessageContaining("仅派送中"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(new DispatchContracts.Actor("pda",ActorMode.PDA,"9"),no,body))).hasMessageContaining("仅平台"); + assertThat(f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isZero(); + } + + /** 图片严格核对当前车和类别;附件失败或缺类型不保存任何业务记录。 */ + @Test void verifiesImageOwnershipAndKeepsFailedRequestUnchanged() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture();var no=create(f);var id=f.repository.find(no).getId(); + var files=mock(DispatchEvidenceGateway.class);ReflectionTestUtils.setField(f.service,"evidence",files); + when(files.metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L)).thenThrow(new IllegalArgumentException("文件不属于当前车")); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L))))).hasMessageContaining("不属于"); + assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions()).isEmpty(); + var missingType=new DispatchCommands.MarkException(0L,"no-type",null,null,null,List.of()); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,missingType))).hasMessageContaining("请选择异常类型"); + assertThatThrownBy(()->f.tx.execute(t->f.service.markException(f.actor,no,request(0,"duplicates",List.of(91L,91L))))).hasMessageContaining("图片引用"); + doReturn(new DispatchEvidenceGateway.File(91L,"现场.png","image/png",10L)).when(files).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L); + f.tx.execute(t->f.service.markException(f.actor,no,request(0,"files",List.of(91L)))); + assertThat(f.service.detail(f.actor,no).dispatchInfo().exceptions().get(0).imageFileIds()).containsExactly(91L); + verify(files,times(2)).metadata(id,DispatchEvidenceGateway.Kind.EXCEPTION,91L); + } + + /** 创建真实排车后仅以SQL模拟已发车前置,不声称此用例执行装车流程。 */ + private String create(DispatchAggregatePersistenceTest.Fixture f) { + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("create",List.of()))); + f.db.update("UPDATE ll_dispatch_order SET stage='DELIVERING',shipped_package_count=3,departed_at=CURRENT_TIMESTAMP");return created.dispatchNo(); + } + /** 构造类型必填、其余选填的最小登记请求。 */ + private DispatchCommands.MarkException request(long version,String key,List images) { return new DispatchCommands.MarkException(version,key,ExceptionType.VEHICLE_EXCEPTION,null,null,images); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index 61b4a81dd..ddd8685d2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -148,7 +148,7 @@ class LastlegOrderServiceTest { when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L); when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2); when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of( - new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1))); + new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0))); ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1", new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin"); diff --git a/scripts/AGENTS.md b/scripts/AGENTS.md index 1d9b08948..0e74c2bb5 100644 --- a/scripts/AGENTS.md +++ b/scripts/AGENTS.md @@ -11,7 +11,7 @@ | 类别 | 正式入口 | 管理边界 | |---|---|---| | 本机重型检查限流 | `local-check.ps1` | 所有工作目录、对话共用两个用户级执行名额;PowerShell 7,状态/超时/并发与 Job Object 回收规则见外层 `docs/AI工程执行与协作约束.md`;只读状态不申请重型名额 | -| 后端分层检查 | `backend-check.ps1` | 默认指定模块回归,支持测试源码编译和精确测试类;必填模块,自动接入重型限流;全量触发条件见后端根指引,参数及副作用见 README | +| 后端分层检查 | `backend-check.ps1` | 局部任务显式选 Focused;模式缺省为 Module,不是日常必跑;必填模块并接入限流,范围见后端根指引,参数见 README | | 测试/灰度单票验收 | `prod-release-acceptance.mjs`、`prod-release-delivery-stage.mjs`、内部 `prod-release-order-stage.mjs` | 显式 test/gray、统一 Gateway/accessToken;同一票义乌到洛杉矶、前置同时核对东莞;full-flow 串行覆盖买单FBA正常链至签收回传及车次资源释放,保留分阶段入口与独立检查点;不调用测试造数模块,不创建账号/主档或删除数据;配置和边界见 README | | Admin 全量 HTTP 验证 | `admin-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;默认模块覆盖平台全链路,定向验证使用 `--modules`,模块注册和依赖以总入口及 `admin-test-env-modules/README.md` 为准 | | Client 全量 HTTP 验证 | `client-test-env-acceptance.mjs` | 通过显式环境文件运行统一 TEST Gateway/会话,拒绝旧海外网关参数;覆盖客户工作台完整链路,`client-phase2-test-env-acceptance.mjs` 只是入库测量与计费专项,不替代全量入口 | diff --git a/scripts/README.md b/scripts/README.md index 829609a8a..3bda1eebb 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -458,13 +458,13 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块 .\scripts\backend-check.ps1 -Modules order -Mode Compile # 开发迭代:精确类名,支持逗号分隔和全限定名。 .\scripts\backend-check.ps1 -Modules common-core -Mode Focused -Tests ApiResponseTest -# 单服务收尾:指定模块及上游依赖测试。 +# 服务级影响且定向覆盖不足:指定模块及上游依赖测试。 .\scripts\backend-check.ps1 -Modules order -# 跨服务收尾:显式列出受影响生产者、消费者。 +# 跨服务模块回归:显式列出受影响生产者、消费者。 .\scripts\backend-check.ps1 -Modules 'order,firstleg' ``` -三种模式均使用根 reactor 的 `-pl -am`,不会安装制品到本地仓库,不默认 `clean`。`Compile` 运行 `test-compile`;`Focused`/`Module` 运行 `test`。`-am` 只补上游构建依赖,HTTP/事件下游消费者须由调用者显式列入。公共模块变更也须核对下游,不能只测试公共模块自身。Focused 只用于迭代,不能替代有行为变化时的模块收尾;API、Mapper、索引等相关静态合同仍按其规则单独执行。 +三种模式均使用根 reactor 的 `-pl -am`,不会安装制品到本地仓库,不默认 `clean`。`Compile` 运行 `test-compile`;`Focused`/`Module` 运行 `test`。`-am` 只补上游构建依赖,HTTP/事件下游消费者须显式列入。局部逻辑使用 `Focused` 覆盖修改行为及必要边界即可交付,不因收尾改跑 `Module`;模块回归只用于定向覆盖不足的服务级影响。公共模块按实际消费者验证。模式缺省值为 `Module`,局部任务须显式选 `Focused`;API、Mapper、索引合同按各自条件执行。 Focused 不接受通配符或方法选择,每个类必须唯一命中所选模块的 `src/test/java`。为允许上游无同名测试,Maven 设置 `surefire.failIfNoSpecifiedTests=false`,随后逐类核对本轮更新的 Surefire XML;报告缺失、陈旧、全部跳过或含失败均不能通过,避免零测试假绿。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 9b5d5ddbe..b1e036567 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -57,6 +57,8 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, } const MODULE_DEPENDENCIES = new Map([ + ['inbound-weight-ocr', ['orders']], + ['orders', ['merchant-qualification-fixture']], ['phase5-root-fulfillment', ['merchant-qualification-fixture']], ['pda-loading-link-regression', ['merchant-qualification-fixture']], ['phase4-overseas-return-followups', ['phase4-overseas-return-receipt']], diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 1d33571e4..14a62e2cb 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -4,7 +4,9 @@ 固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 -OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。 +OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖个人桌面路径;定向运行自动展开客户资质及订单前置。识别失败的空字段允许省略,但识别成功必须有有效重量与单位;人工修正和确认分别增加版本。合成称重测试图只证明上传、识别响应及人工确认协议,不代表现场称重照片或设备验收。费用项目模块按受控编码验证112条正式基线,合法扩展项目保留并单独核对分页和筛选。散板普通状态接口仅验证取消及非法派车拒绝,原单须出现在待匹配需求池;需求池接车到签收另列覆盖缺口,不以手工跳状态冒充履约。 + +预约工作查询在本轮业务写入后,仅对服务端明确返回的 A0409“预约批次或运输关联已变化,服务端正在协调完整工作清单”或“已开船工作范围变化,请先完成服务端同步”每 5 秒重读,最多等待 240 秒;超时保留失败,其他错误不重试。这是后台完整扫描的就绪等待,不是页面响应性能验收,也不触发同步写接口。 先运行 `node scripts/acceptance-overview.mjs`(后端根目录,无网络)查看当前默认与可选模块。范围来自真实注册表;本文件描述各模块用途与依赖,不代表所有模块默认执行或已通过。报告解读及跨端/专项边界见 [总索引](../README.md#先看范围再看证据),最新运行状态只查外层部署契约。 diff --git a/scripts/admin-test-env-modules/appointment-work-contract.test.mjs b/scripts/admin-test-env-modules/appointment-work-contract.test.mjs index 2a9827277..abc56814c 100644 --- a/scripts/admin-test-env-modules/appointment-work-contract.test.mjs +++ b/scripts/admin-test-env-modules/appointment-work-contract.test.mjs @@ -1,7 +1,26 @@ import assert from 'node:assert/strict' import test from 'node:test' import { readFileSync } from 'node:fs' -import { run } from './appointment-work.mjs' +import { run, readAfterSynchronization } from './appointment-work.mjs' + +test('同步就绪只重试明确协调状态,超限及其他错误原样保留', async () => { + const coordinating = {http:200,body:{code:'A0409',message:'预约批次或运输关联已变化,服务端正在协调完整工作清单'}} + const success = {http:200,body:{code:'200',data:{}}} + let calls = 0 + assert.equal(await readAfterSynchronization({request:async()=>++calls === 1 ? coordinating : success}, '/summary', {attempts:3,delay:async()=>{}}),success) + assert.equal(calls,2) + const scopeChanged = {http:200,body:{code:'A0409',message:'已开船工作范围变化,请先完成服务端同步'}} + calls=0 + assert.equal(await readAfterSynchronization({request:async()=>++calls===1?scopeChanged:success}, '/summary', {attempts:3,delay:async()=>{}}),success) + assert.equal(calls,2) + calls=0 + assert.equal(await readAfterSynchronization({request:async()=>{calls++;return coordinating}}, '/summary', {attempts:3,delay:async()=>{}}),coordinating) + assert.equal(calls,3) + const other={http:500,body:{code:'S0500'}} + assert.equal(await readAfterSynchronization({request:async()=>other}, '/summary', {delay:async()=>assert.fail('must not retry')}),other) + const conflict={http:200,body:{code:'A0409',message:'预约版本冲突'}} + assert.equal(await readAfterSynchronization({request:async()=>conflict}, '/summary', {delay:async()=>assert.fail('must not retry unrelated conflict')}),conflict) +}) const entry = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const moduleSource = readFileSync(new URL('./appointment-work.mjs', import.meta.url), 'utf8') diff --git a/scripts/admin-test-env-modules/appointment-work.mjs b/scripts/admin-test-env-modules/appointment-work.mjs index 321290252..c8fbbb6fb 100644 --- a/scripts/admin-test-env-modules/appointment-work.mjs +++ b/scripts/admin-test-env-modules/appointment-work.mjs @@ -1,3 +1,18 @@ +import { setTimeout as sleep } from 'node:timers/promises' + +export async function readAfterSynchronization(context, path, { attempts = 49, delay = () => sleep(5000) } = {}) { + let response + for (let attempt = 1; attempt <= attempts; attempt += 1) { + response = await context.request('GET', path) + const coordinating = response.http === 200 && String(response.body?.code) === 'A0409' + && ['预约批次或运输关联已变化,服务端正在协调完整工作清单', + '已开船工作范围变化,请先完成服务端同步'].includes(response.body?.message) + if (!coordinating || attempt === attempts) return response + await delay() + } + return response +} + function count(value) { const number = Number(value) return Number.isFinite(number) ? number : NaN @@ -15,7 +30,7 @@ export async function run(context) { const from = shanghaiDate(-7) const to = shanghaiDate(7) - const calendarResponse = await context.request('GET', `${base}/calendar?from=${from}&to=${to}`) + const calendarResponse = await readAfterSynchronization(context, `${base}/calendar?from=${from}&to=${to}`) const calendar = context.apiSuccess(module, '查询预约工作日历', calendarResponse) const days = Array.isArray(calendar?.days) ? calendar.days : [] const dayTotal = days.reduce((sum, day) => sum + Number(day?.total || 0), 0) @@ -29,14 +44,14 @@ export async function run(context) { `date=${day?.date || 'missing'},total=${day?.total ?? 'missing'},typeTotal=${typeTotal}`) } - const summaryResponse = await context.request('GET', `${base}/summary`) + const summaryResponse = await readAfterSynchronization(context, `${base}/summary`) const summary = context.apiSuccess(module, '查询预约工作汇总', summaryResponse) const summaryFields = ['all', 'followToday', 'overdue', 'undated', 'review'] context.assert(module, '工作汇总五项计数完整', summaryResponse, summaryFields.every((field) => Number.isFinite(Number(summary?.[field]))), summaryFields.map((field) => `${field}=${summary?.[field] ?? 'missing'}`).join(',')) - const pageResponse = await context.request('GET', `${base}/items?view=ALL&page=1&size=20`) + const pageResponse = await readAfterSynchronization(context, `${base}/items?view=ALL&page=1&size=20`) const page = context.apiSuccess(module, '查询预约工作事项首页', pageResponse) const records = Array.isArray(page?.records) ? page.records : [] const total = count(page?.total) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs index eed42368e..c3eb62d36 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr-contract.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' import { + validRecognition, sameOptionalWeight, confirmedVersion, confirmedNetSummary, isTransientGatewayFailure, normalizeWeightPalletOptionPage, @@ -14,7 +16,22 @@ import { workspaceWeightMatchesBaseline, } from './inbound-weight-ocr.mjs' +test('OCR failure permits absent optional values but recognition requires a valid weight; correction and confirmation each advance version', () => { + assert.equal(validRecognition({ recognitionStatus: 'FAILED' }), true) + assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED' }), false) + assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED', weightValue: 45, weightUnit: 'KG', confidence: 0.9 }), true) + assert.equal(validRecognition({ recognitionStatus: 'RECOGNIZED', weightValue: 45, weightUnit: 'KG', confidence: 2 }), false) + assert.equal(sameOptionalWeight(undefined, null), true) + assert.equal(sameOptionalWeight(undefined, 0), false) + assert.equal(sameOptionalWeight(5, 6), false) + assert.equal(confirmedVersion(3, false), 4) + assert.equal(confirmedVersion(3, true), 5) +}) + test('retries only transient gateway failures for the unauthenticated upload probe', () => { + const modules = normalizeModuleOrder(['inbound-weight-ocr']) + assert.ok(modules.indexOf('merchant-qualification-fixture') < modules.indexOf('orders')) + assert.ok(modules.indexOf('orders') < modules.indexOf('inbound-weight-ocr')) assert.equal(isTransientGatewayFailure({ http: 0 }), true) assert.equal(isTransientGatewayFailure({ http: 502 }), true) assert.equal(isTransientGatewayFailure({ http: 503 }), true) diff --git a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs index 0b1912ed7..5fd02301e 100644 --- a/scripts/admin-test-env-modules/inbound-weight-ocr.mjs +++ b/scripts/admin-test-env-modules/inbound-weight-ocr.mjs @@ -22,6 +22,22 @@ function closeNumber(actual, expected, tolerance = 0.0005) { return Number.isFinite(Number(actual)) && Math.abs(Number(actual) - Number(expected)) < tolerance } +export function validRecognition(result) { + if (!result || !OCR_STATUSES.has(result.recognitionStatus)) return false + if (result.confidence != null && (!Number.isFinite(Number(result.confidence)) || Number(result.confidence) < 0 || Number(result.confidence) > 1)) return false + if (result.recognitionStatus === 'FAILED') return result.weightValue == null + return result.weightValue != null && Number(result.weightValue) > 0 + && normalizedGrossKg(result.weightValue, result.weightUnit) != null +} + +export function sameOptionalWeight(actual, expected) { + return actual == null || expected == null ? actual == null && expected == null : closeNumber(actual, expected) +} + +export function confirmedVersion(version, hasAdjustment) { + return Number(version) + (hasAdjustment ? 2 : 1) +} + function palletSnapshotComplete(pallet) { return Boolean(id(pallet?.palletId)) && typeof pallet?.palletCode === 'string' && pallet.palletCode.length > 0 @@ -254,10 +270,7 @@ async function createUploadedResult(context, target, pallet, image, overrides = Boolean(fileId) && typeof upload?.fileId === 'string', `fileIdType=${typeof upload?.fileId}, status=${upload?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) context.assert(MODULE, 'OCR 上传响应字段完整且置信度受限', uploadResponse, - upload && OCR_STATUSES.has(upload.recognitionStatus) - && Object.hasOwn(upload, 'weightValue') && Object.hasOwn(upload, 'weightUnit') - && Object.hasOwn(upload, 'confidence') - && (upload.confidence == null || (Number(upload.confidence) >= 0 && Number(upload.confidence) <= 1)), + validRecognition(upload), `status=${upload?.recognitionStatus ?? 'missing'}, hasWeight=${upload?.weightValue != null}, confidence=${upload?.confidence ?? 'null'}`, SCENARIO_OPTIONS) if (!fileId) throw new Error('称重图片上传未返回 fileId,无法登记和清理测试资源') @@ -269,8 +282,7 @@ async function createUploadedResult(context, target, pallet, image, overrides = const retry = context.apiSuccess(MODULE, '重试当前称重图片 OCR', retryResponse, `fileId=${fileId}`, SCENARIO_OPTIONS) context.assert(MODULE, 'OCR 重试保持文件身份并返回识别契约', retryResponse, - id(retry?.fileId) === fileId && OCR_STATUSES.has(retry?.recognitionStatus) - && Object.hasOwn(retry || {}, 'weightValue') && Object.hasOwn(retry || {}, 'confidence'), + id(retry?.fileId) === fileId && validRecognition(retry), `sameFile=${id(retry?.fileId) === fileId}, status=${retry?.recognitionStatus ?? 'missing'}`, SCENARIO_OPTIONS) if (retry) recognized = retry @@ -474,8 +486,8 @@ export async function run(context) { const registeredRow = registered.workspace?.weightResults?.find((row) => id(row.id) === original.resultId) context.assert(MODULE, '工作区保留本轮 OCR 原值且未覆盖历史结果', registered.response, registeredRow && !baselineIds.has(original.resultId) - && closeNumber(registeredRow.ocrWeightValue, original.result.ocrWeightValue) - && registeredRow.ocrWeightUnit === original.result.ocrWeightUnit, + && sameOptionalWeight(registeredRow.ocrWeightValue, original.result.ocrWeightValue) + && (registeredRow.ocrWeightUnit ?? null) === (original.result.ocrWeightUnit ?? null), `resultFound=${Boolean(registeredRow)}, baselineCount=${baselineIds.size}, currentCount=${registered.workspace?.weightResults?.length ?? 'n/a'}`, SCENARIO_OPTIONS) @@ -506,7 +518,7 @@ export async function run(context) { const confirmedTare = Number(confirmed?.palletTareWeightKg) const confirmedNet = Number(confirmed?.netWeightKg) context.assert(MODULE, '确认结果返回托盘快照、毛重和去皮净重', confirmResponse, - confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === original.version + 1 + confirmed?.recognitionStatus === 'CONFIRMED' && Number(confirmed?.version) === confirmedVersion(original.version, !canConfirmOriginal) && id(confirmed?.palletId) === id(pallet.palletId) && confirmed?.palletCode === pallet.palletCode && confirmed?.palletType === pallet.palletType && closeNumber(confirmed?.palletCapacityCbm, pallet.capacityCbm) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 636c72864..eb9860bcc 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -53,7 +53,7 @@ test('商户夹具通过正式 File 和 IAM 审核流程建立下单资格', () assert.match(fixture, /MERCHANT_TEST_PASSWORD/) assert.match(fixture, /context\.options\?\.customerName/) assert.match(fixture, /tenantName/) - assert.match(fixture, /expectedProcurementPrefix: customerName \? null : 'QLYSXDKH'/) + assert.doesNotMatch(fixture, /expectedProcurementPrefix/) assert.match(acceptance, /administrator-phone-availability\?phone=/) assert.match(acceptance, /attempt < 100/) assert.doesNotMatch(acceptance, /phoneSequence % 10/) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 07fa6f343..0207beac9 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -120,10 +120,8 @@ export async function run(context, { publishEnvironment = true } = {}) { } context.context.merchantQualificationFixture = { tenantId, tenantName, username, password, marker: context.marker, - expectedProcurementPrefix: customerName ? null : 'QLYSXDKH', } context.createdResources.merchantQualificationFixture = { tenantId, tenantName, username, marker: context.marker, - expectedProcurementPrefix: customerName ? null : 'QLYSXDKH', } } diff --git a/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs b/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs index 01e43a3a8..8875b71e9 100644 --- a/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-audit-sailing-contract.test.mjs @@ -111,4 +111,8 @@ test('船期日历模块注册为默认模块并核对月历、渠道、模板 assert.ok(ctx.calls[3].path.includes('/sailing-assignments/exceptions')) assert.ok(ctx.calls[4].path.endsWith('/11')) assert.ok(ctx.calls[5].path.endsWith('/999999999999999')) + delete sailing.etaAt + await runSailingCalendar(ctx) + sailing.etaAt = 'invalid-eta' + await assert.rejects(runSailingCalendar(ctx), /月历记录、汇总与筛选选项结构完整/) }) diff --git a/scripts/admin-test-env-modules/phase3-container-overview.mjs b/scripts/admin-test-env-modules/phase3-container-overview.mjs index 503ddfe26..09b88b72d 100644 --- a/scripts/admin-test-env-modules/phase3-container-overview.mjs +++ b/scripts/admin-test-env-modules/phase3-container-overview.mjs @@ -103,7 +103,7 @@ export async function run(context) { const clearance = await context.request('GET', `${CONTAINERS}/${encodeURIComponent(containerId)}/clearance`) context.assert(MODULE, '国内未出仓时目的港清关不生成国外工作台数据', clearance, clearance.http === 200 && String(clearance?.body?.code) === 'A0404' - && String(clearance?.body?.message || '').includes('国外工作台货柜不存在'), + && clearance?.body?.message === '货柜尚未离仓或实际开船,不能维护目的港清关资料', `HTTP=${clearance.http}, code=${clearance?.body?.code ?? 'missing'}, containerBookingId=${containerId}`, OPTIONS) const customs = await context.request('GET', `/api/platform/v1/first-leg/loading-lists/${encodeURIComponent(planId)}/customs?warehouseId=${encodeURIComponent(warehouseId)}`) if (fixture?.finalStatus === 'LOADED') { diff --git a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs index 211ec91ec..579927d24 100644 --- a/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase3-container-regression-contract.test.mjs @@ -55,6 +55,7 @@ test('overview regression requires workflowStatus and distinguishes 100 percent test('pre-departure overview rejects destination workbench documents explicitly', () => { assert.match(overviewAcceptance, /国内未出仓时 \$\{documentType\} 不生成国外工作台数据/) assert.match(overviewAcceptance, /国内未出仓时目的港清关不生成国外工作台数据/) + assert.match(overviewAcceptance, /货柜尚未离仓或实际开船,不能维护目的港清关资料/) assert.match(overviewAcceptance, /国外工作台货柜不存在/) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index c78d8740c..27d6a21de 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -207,7 +207,8 @@ function fixture(options = {}) { test('module is context injected, blocks missing prerequisites and excludes forbidden surfaces', () => { assert.match(source, /export async function run\(context\)/) assert.doesNotMatch(source, /https?:\/\/|process\.env|fetch\(/) - assert.doesNotMatch(source, /addCoverageGap|coverageGap\(/) + assert.equal((source.match(/addCoverageGap\(/g) || []).length, 1) + assert.match(source, /looseboard-pool-fulfillment/) assert.match(source, /deploymentBlocker: true/) assert.match(source, /contactPhone: '\d+'/) assert.doesNotMatch(source, /contactPhone: '\+[^']+'/) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs index ef7f9d3ca..91f3eae7b 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data-contract.test.mjs @@ -5,6 +5,28 @@ import test from 'node:test' import { run, vehicleCapacityMatches } from './phase4-overseas-master-data.mjs' import { run as runCare } from './fleet-vehicle-care.mjs' +test('平台用户手机号已存在只换号重试,未知创建结果不得重发', async () => { + for (const collision of [true, false]) { + const { context } = fakeContext({ softAssertions: true }) + const original = context.request.bind(context) + const phones = [] + let sequence = 0 + context.findAvailablePhone = async () => `1380000${String(++sequence).padStart(4, '0')}` + context.request = async (method, path, body, ...rest) => { + if (method === 'POST' && path === '/api/platform/v1/iam/users') { + phones.push(body.phone) + if (phones.length === 1) return collision + ? {http:200,body:{code:'A0409',message:'手机号已存在'}} + : {http:500,body:{code:'S0500'}} + } + return original(method,path,body,...rest) + } + await run(context) + assert.equal(phones.length, collision ? 2 : 1) + if (collision) assert.notEqual(phones[0],phones[1]) + } +}) + test('运维专项接受NON_NULL真实响应但仍完成全部六类记录和两车停用', async () => { const { context, state } = fakeContext() const request = context.request.bind(context) diff --git a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs index 39bfb6f33..71c8a6838 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-master-data.mjs @@ -375,13 +375,19 @@ async function verifyCanonicalPermissionDenial(context, resource) { if (!roleId) throw new Error('无主数据权限角色未返回ID') resource.permissionProbe = { roleId, membershipId: null, roleStatus: 'ACTIVE', userStatus: null } - const phone = await context.findAvailablePhone(MODULE) const username = `p4_md_deny_${suffix}`.slice(0, 64).toLowerCase() const password = `P4md${suffix.slice(-8)}A1!` - const userResponse = await context.request('POST', '/api/platform/v1/iam/users', { - username, displayName: `四期主数据拒绝-${suffix}`, phone, - email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE', - }) + let userResponse + for (let attempt = 0; attempt < 3; attempt += 1) { + const phone = await context.findAvailablePhone(MODULE) + userResponse = await context.request('POST', '/api/platform/v1/iam/users', { + username, displayName: `四期主数据拒绝-${suffix}`, phone, + email: `${username}@example.com`, tenantId: '0', roleIds: [roleId], password, status: 'ACTIVE', + }) + // 客户管理员候选检查不覆盖平台用户;仅明确未创建的手机号冲突允许换号。 + if (!(userResponse.http === 200 && String(userResponse.body?.code) === 'A0409' + && userResponse.body?.message === '手机号已存在')) break + } const user = requireData(context, '创建无主数据权限用户', userResponse) const membershipId = asId(user?.membershipId) if (!membershipId) throw new Error('无主数据权限用户未返回membershipId') diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs index 9653b962e..73c20dc83 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost-contract.test.mjs @@ -10,9 +10,10 @@ const pda = readFileSync(new URL('./phase4-overseas-pda.mjs', import.meta.url), test('采购单号按北京时间日期执行精确合同', () => { const instant = new Date('2026-09-05T16:30:00.000Z') assert.equal(beijingDate(instant), '20260906') - assert.equal(matchesProcurementNo('QLYSXDKH202609060001', 'QLYSXDKH', instant), true) - assert.equal(matchesProcurementNo('QLYSXDKH202609050001', 'QLYSXDKH', instant), false) - assert.equal(matchesProcurementNo('QLYSXDKH20260906001', 'QLYSXDKH', instant), false) + assert.equal(matchesProcurementNo('CG202609060001', instant), true) + assert.equal(matchesProcurementNo('CG202609050001', instant), false) + assert.equal(matchesProcurementNo('CG20260906001', instant), false) + assert.equal(matchesProcurementNo('QLYSXDKH202609060001', instant), false) }) test('钱包闭环覆盖充值、授信、采购冻结结算和换标结算释放', () => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs index 3c473f159..e67eba4c2 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-wallet-and-pallet-cost.mjs @@ -28,9 +28,8 @@ export function beijingDate(now = new Date()) { return `${parts.year}${parts.month}${parts.day}` } -export function matchesProcurementNo(orderNo, prefix, now = new Date()) { - return typeof prefix === 'string' && prefix.length > 0 - && new RegExp(`^${prefix}${beijingDate(now)}\\d{4}$`).test(String(orderNo || '')) +export function matchesProcurementNo(orderNo, now = new Date()) { + return new RegExp(`^CG${beijingDate(now)}\\d{4}$`).test(String(orderNo || '')) } async function poll(action, predicate, attempts = 60, intervalMs = 1_000) { @@ -133,8 +132,8 @@ async function procurementFlow(context, fixture, token, warehouseId) { customerName: fixture.tenantName, customerToken: token, warehouseId, upload, readWallet: async () => { const result = await platformAccount(context, customerId); if (!success(result.response) || !result.account) throw new Error('本轮客户钱包查询失败'); return result.account } }) assert(context, '采购正常全链路及资金完成', null, proof.status === 'PASSED', `orderNo=${proof.orderNo}`) - assert(context, '采购单号日期和客户前缀正确', null, - fixture.expectedProcurementPrefix ? matchesProcurementNo(proof.orderNo, fixture.expectedProcurementPrefix) : /^[A-Z0-9]{1,8}\d{8}\d{4}$/.test(proof.orderNo), `orderNo=${proof.orderNo}`) + assert(context, '采购单号为CG加北京时间日期和流水', null, + matchesProcurementNo(proof.orderNo), `orderNo=${proof.orderNo}`) return { orderNo: proof.orderNo, walletBusinessNos: proof.walletBusinessNos, settledAmountCny: proof.settledAmountCny } } finally { for (const check of context.createdResources.procurementSample?.checks || []) assert(context, check.label, null, check.passed) From c962f614e64b3ec6698d633faeafdec5fb4585a0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 16:40:18 +0800 Subject: [PATCH 061/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/SmartDispatchController.java | 2 +- .../mapper/DispatchCandidatePageMapper.java | 3 +- .../service/DispatchAggregateService.java | 3 +- .../service/OutsourcedBusinessService.java | 8 +- .../DispatchVehicleDocumentController.java | 76 ++++ .../model/OwnedBusinessScope.java | 9 +- .../service/OwnedBusinessProjection.java | 3 +- .../service/OwnedBusinessQueryService.java | 3 +- .../dispatch-vehicle-documents-v1.yaml | 379 ++++++++++++++++++ .../openapi/overseas-business-v1.yaml | 3 +- .../SmartDispatchOpenApiContractTest.java | 3 + .../SmartDispatchControllerTest.java | 10 + .../DispatchRegionalCandidatesTest.java | 47 +++ scripts/agents-index-contract.ps1 | 138 +++++-- 14 files changed, 635 insertions(+), 52 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchVehicleDocumentController.java create mode 100644 lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java index 41a988862..223270899 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchController.java @@ -26,7 +26,7 @@ public class SmartDispatchController { /** 读取实际候选,不在草稿域复制来源筛选逻辑。 */ @GetMapping("/candidates") @PreAuthorize("hasAuthority('"+OverseasPermissions.DISPATCH_LIST+"')") - public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam String warehouseId, + public ApiResponse candidates(@AuthenticationPrincipal AuthenticatedUser user,@RequestParam(required=false) String warehouseId, @RequestParam(required=false)List planIds,@RequestParam(required=false)String orderNo, @RequestParam(required=false)String destinationWarehouseCode,@RequestParam(required=false)String sourceStage,@RequestParam(required=false)String draftToken, @RequestParam(defaultValue="1")int page,@RequestParam(defaultValue="20")int size,@RequestParam(defaultValue="ALL")String warehouseTab) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java index 3e92404a7..307e5bc80 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchCandidatePageMapper.java @@ -50,7 +50,8 @@ public interface DispatchCandidatePageMapper { } /** 仓库页签也是服务端筛选,分页后不能再用本地筛选伪装完整结果。 */ private static String filtered(boolean count) { - return " FROM ("+rows(count)+") candidates WHERE overseasWarehouseId=#{query.warehouseId}" + return " FROM ("+rows(count)+") candidates WHERE 1=1" + +" AND overseasWarehouseId=#{query.warehouseId}" +" AND fbaWarehouseCode=#{query.destinationWarehouseCode}" +" AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'lax|洛杉矶|los angeles'" +" AND LOWER(CONCAT(COALESCE(overseasWarehouseCode,''),' ',COALESCE(overseasWarehouseName,''))) REGEXP 'nyc|jfk|(^|[^a-z])ny([^a-z]|$)|纽约|new[ -]?york'"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index a97d8c45b..3693e5a3b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -146,7 +146,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchPlanningModels.CandidateList candidates(Actor actor,DispatchPlanningModels.CandidateQuery query) { requirePlatform(actor); - if(query==null || query.warehouseId()==null || !query.warehouseId().matches("[1-9][0-9]*")) throw bad("请选择真实海外起运仓"); + // 未指定起运仓时按区域浏览;写入草稿仍由来源确定唯一真实起运仓。 + if(query==null || query.warehouseId()!=null && !query.warehouseId().matches("[1-9][0-9]*")) throw bad("起运仓筛选无效"); if(query.planIds()!=null && (query.planIds().size()>500 || query.planIds().stream().anyMatch(id->id==null || id<=0))) throw bad("计划筛选不合法"); if(query.page()<1 || query.size()<1 || query.size()>50 || !Set.of("ALL","LAX","NYC").contains(Objects.toString(query.warehouseTab(),"")))throw bad("候选分页或仓库页签不合法"); var normalized=new DispatchPlanningModels.CandidateQuery(query.warehouseId(),query.planIds(),trim(query.orderNo()), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java index 185b14bae..620a910f2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/outsourcedbusiness/service/OutsourcedBusinessService.java @@ -37,7 +37,7 @@ public class OutsourcedBusinessService { public HistoryPage history(OwnedBusinessModels.Actor actor,String number,Integer page,Integer size) { var result=queries.history(actor,number,page,size,SCOPE); return new HistoryPage(result.records().stream().map(h->new History(h.eventSource(),h.recordVersion(),h.dispatchRecordVersion(),h.action(),h.operatorName(), - h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().map(this::photo).toList())).toList(),result.page(),result.size(),result.total()); + h.occurredAt(),h.reason(),h.operations()==null?null:operations(h.operations()),h.photos().stream().filter(p->"LOADING".equals(p.category())).map(this::photo).toList())).toList(),result.page(),result.size(),result.total()); } /** 外配只生成金额和到达的规范内部请求,不接收归属或隐藏油耗写入。 */ public Detail save(OwnedBusinessModels.Actor actor,String number,SaveOperations request) { @@ -71,11 +71,11 @@ public class OutsourcedBusinessService { } /** 回执公开形状不带内部里程、油耗或油耗照片摘要。 */ private Detail detail(OwnedBusinessModels.Detail detail) { - return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().map(this::photo).toList()); + return new Detail(row(detail.row(),projection.carrierName(detail)),detail.dispatch(),detail.cargo(),detail.photos().stream().filter(p->"LOADING".equals(p.category())).map(this::photo).toList()); } /** 临时车辆合法保留空vehicleId;承运商缺失保留null供页面显示破折号。 */ private Row row(OwnedBusinessModels.Row r,String carrierName) { - if(!java.util.Set.of("OWNED","DISPATCHED").contains(r.vehicle().ownership()) || r.photoSummary().fuelCount()!=0)throw conflict("外配运营快照的车辆归属或照片不合法"); + if(!java.util.Set.of("OWNED","DISPATCHED").contains(r.vehicle().ownership()))throw conflict("外配运营快照的车辆归属或照片不合法"); return new Row(r.dispatchNo(),r.stage(),r.plannedDepartureAt(),r.loadingCompletedAt(),r.departedAt(),r.deliveryCompletedAt(),r.origin(), new Vehicle(r.vehicle().vehicleId(),r.vehicle().plate(),r.vehicle().ownership(),carrierName==null || carrierName.isBlank()?null:carrierName),r.driver(),r.destinations(), r.actualPalletCount(),operations(r.operations()),r.unitPrice(),r.recordVersion(),r.dispatchRecordVersion(),new PhotoSummary(r.photoSummary().loadingCount()),r.actions(),r.sourceIssues()); @@ -85,7 +85,7 @@ public class OutsourcedBusinessService { if(ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null)throw conflict("外配业务不能包含里程或油耗登记"); return new Operations(ops.money(),ops.arrivals()); } - /** 外配照片枚举只有五框,异常油耗历史不能借复用向外输出。 */ + /** 外配照片枚举只有五框,排车油耗图片保留在共用记录中但不在外配入口输出。 */ private Photo photo(OwnedBusinessModels.Photo p) { SCOPE.requireSlot(p.slot());return new Photo(PhotoSlot.valueOf(p.slot().name()),p.category(),p.fileId(),p.fileName(),p.contentType(),p.sizeBytes(),p.uploadedAt(),p.savedAt()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchVehicleDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchVehicleDocumentController.java new file mode 100644 index 000000000..e098d7cc4 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchVehicleDocumentController.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.util.List; + +/** 排车资料入口复用车队原照片记录;签收及BOL仍由派送单管理。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents") +public class DispatchVehicleDocumentController { + private static final OwnedBusinessScope SCOPE=OwnedBusinessScope.DISPATCH; + private final OwnedBusinessQueryService queries; + private final OwnedBusinessPhotoService photos; + private final OwnedBusinessSupport access; + /** 沿已有服务复用来源校验、文件归属、版本和命令账本。 */ + public DispatchVehicleDocumentController(OwnedBusinessQueryService queries,OwnedBusinessPhotoService photos,OwnedBusinessSupport access) { + this.queries=queries;this.photos=photos;this.access=access; + } + public record Documents(String dispatchNo,String recordVersion,boolean editable,List photos) {} + /** 只输出油耗资料,避免把车队签收照片当作派送签收文件。 */ + private Documents documents(Detail value) { + return new Documents(value.row().dispatchNo(),value.row().recordVersion(),value.row().actions().upload().allowed(), + value.photos().stream().filter(p->"FUEL".equals(p.category())).toList()); + } + /** 排车列表权限包含车辆资料只读,不扩大派送单客户访问权限。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(documents(queries.detail(access.actor(user,SCOPE),dispatchNo,SCOPE))); + } + /** 上传和引用保存分开,沿原请求键防止重传。 */ + @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(photos.upload(access.actor(user,SCOPE),dispatchNo,slot,idempotencyKey,file,SCOPE)); + } + /** 只更新既有附加资料版本,不修改排车阶段或签收状态。 */ + @PutMapping("/{slot}") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + return ApiResponse.success(documents(photos.bind(access.actor(user,SCOPE),dispatchNo,slot,request,SCOPE))); + } + /** 预览只读取本车当前已经绑定的照片。 */ + @GetMapping("/{slot}/files/{fileId}/content") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) { + var value=photos.content(access.actor(user,SCOPE),dispatchNo,slot,fileId,null,SCOPE); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType())) + .header("X-Content-Type-Options","nosniff").body(value.content()); + } + /** 恢复仅返回油耗上传结果,不通过排车入口暴露其他运营命令。 */ + @GetMapping("/uploads/{idempotencyKey}") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + var actor=access.actor(user,SCOPE); + access.require(actor,SCOPE.uploadPermission()); + var command=photos.command(actor,dispatchNo,idempotencyKey,SCOPE); + if(!"UPLOAD_PHOTO".equals(command.action()) || !"SUCCEEDED".equals(command.state()) || command.result()==null) + throw OwnedBusinessSupport.conflict("原上传结果尚未确认,请保留原请求稍后核对"); + var value=access.read(access.write(command.result()),Photo.class);SCOPE.requireSlot(value.slot()); + return ApiResponse.success(value); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java index fabd6344b..7f17f7c55 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/model/OwnedBusinessScope.java @@ -7,7 +7,8 @@ import com.baiyue.common.core.api.ResultCode; /** 仅服务端固定入口选择的作用域;不得成为浏览器请求字段或权限别名。 */ public enum OwnedBusinessScope { OWNED(false,"platform:fleet:owned-business:"), - OUTSOURCED(true,"platform:fleet:outsourced-business:"); + OUTSOURCED(true,"platform:fleet:outsourced-business:"), + DISPATCH(false,"platform:overseas-warehouse:dispatch:"); private final boolean pooledCargo; private final String prefix; /** 业务入口按是否实际接入拼车货物分类,与车辆归属独立。 */ @@ -19,9 +20,9 @@ public enum OwnedBusinessScope { /** 运营写权限独立,外配权限不映射成自有权限。 */ public String editPermission(){return prefix+"edit";} /** 照片写权限独立,不扩大到原排车。 */ - public String uploadPermission(){return prefix+"upload";} + public String uploadPermission(){return prefix+(this==DISPATCH?"edit":"upload");} /** 外配只支持装车五框,服务调用同样不能绕过。 */ - public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");} + public void requireSlot(PhotoSlot slot){if(slot==null || this==OUTSOURCED && !"LOADING".equals(slot.category()) || this==DISPATCH && !"FUEL".equals(slot.category()))throw new BizException(ResultCode.BAD_REQUEST,"当前业务不支持该照片框");} /** 不存在只显示当前业务归属,不泄露内部身份。 */ - public BizException missing(){return new BizException(ResultCode.NOT_FOUND,"排车单不存在或不属于当前"+(this==OWNED?"自有业务":"外配任务"));} + public BizException missing(){return new BizException(ResultCode.NOT_FOUND,this==DISPATCH?"排车单不存在":"排车单不存在或不属于当前"+(this==OWNED?"自有业务":"外配任务"));} } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java index e32e526a7..d6edec772 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessProjection.java @@ -53,8 +53,7 @@ public class OwnedBusinessProjection { var resources=codec.read(source.getResourcesJson(),Resources.class); if(resources.vehicle()==null)throw OwnedBusinessSupport.conflict("原车辆快照缺失"); var ops=operations(record);var photos=photos(record);int pallets=palletCount(source); - if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null - || photos.stream().anyMatch(p->!"LOADING".equals(p.category()))))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片"); + if(scope==OwnedBusinessScope.OUTSOURCED && (ops.actualDistanceMiles()!=null || ops.fuelConsumptionLitresPer100Km()!=null))throw OwnedBusinessSupport.conflict("外配运营记录含不支持的里程、油耗或照片"); var arrivals=new HashMap();for(var a:ops.arrivals())arrivals.put(a.destinationKey(),a.actualArrivedAt()); var destinations=new LinkedHashMap(); for(var item:cargo) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java index f21ece7a1..05e4a0045 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/OwnedBusinessQueryService.java @@ -93,9 +93,10 @@ public class OwnedBusinessQueryService { if(source.getOwnedVehicleOwnership()==null)throw conflict("本单缺少装车时车辆归属证据,不能归入"+(scope==OwnedBusinessScope.OWNED?"自有":"外配")+"业务"); if(!Set.of("OWNED","DISPATCHED").contains(source.getOwnedVehicleOwnership()))throw conflict("装车车辆归属不合法"); if(source.getPooledCargo()==null)throw conflict("本单缺少实际拼车分类依据"); - if(scope.isPooledCargo()!=source.getPooledCargo())throw scope.missing(); + if(scope!=OwnedBusinessScope.DISPATCH && scope.isPooledCargo()!=source.getPooledCargo())throw scope.missing(); if(!"ACTIVE".equals(source.getStatus()) || source.getStage()==null || !Set.of("AFTER_LOADING","DELIVERING","DELIVERED").contains(source.getStage()) || source.getLoadingCompletedAt()==null || source.getLoadingConfirmationJson()==null)throw conflict("本单尚无合法装车完成事实"); + if(scope==OwnedBusinessScope.DISPATCH && !Set.of("DELIVERING","DELIVERED").contains(source.getStage()))throw conflict("仅派送中和配送完成可管理车辆资料"); var resource=codec.read(source.getResourcesJson(),DispatchContracts.Resources.class).vehicle(); boolean master=resource!=null && resource.mode()!=null && "MASTER".equals(resource.mode().name()) && "MASTER".equals(source.getOwnedResourceMode()) && resource.vehicleId()!=null && Objects.equals(source.getOwnedVehicleId(),resource.vehicleId()); diff --git a/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml new file mode 100644 index 000000000..1bc04a1e7 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml @@ -0,0 +1,379 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "排车车辆资料", + "version": "1.0.0", + "description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。" + }, + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": { + "get": { + "operationId": "getDispatchVehicleDocuments", + "summary": "读取本车油耗图片;复用车队附加记录", + "x-permission-code": "platform:overseas-warehouse:dispatch:list", + "parameters": [ + { + "$ref": "#/components/parameters/dispatchNo" + } + ], + "responses": { + "200": { + "description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Documents" + } + } + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": { + "post": { + "operationId": "uploadDispatchVehiclePhoto", + "summary": "准备本车油耗图片;尚未绑定保存", + "x-permission-code": "platform:overseas-warehouse:dispatch:edit", + "parameters": [ + { + "$ref": "#/components/parameters/dispatchNo" + }, + { + "$ref": "#/components/parameters/slot" + } + ], + "responses": { + "200": { + "description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Photo" + } + } + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "file", + "idempotencyKey" + ], + "properties": { + "file": { + "type": "string", + "format": "binary", + "description": "非空JPG/PNG,最大10MiB" + }, + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 128 + } + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": { + "put": { + "operationId": "bindDispatchVehiclePhoto", + "summary": "按附加版本绑定原上传文件,不改变排车或签收状态", + "x-permission-code": "platform:overseas-warehouse:dispatch:edit", + "parameters": [ + { + "$ref": "#/components/parameters/dispatchNo" + }, + { + "$ref": "#/components/parameters/slot" + } + ], + "responses": { + "200": { + "description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Documents" + } + } + } + } + } + } + }, + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BindPhoto" + } + } + } + } + } + }, + "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": { + "get": { + "operationId": "readDispatchVehiclePhoto", + "summary": "只读取本车当前绑定图片", + "x-permission-code": "platform:overseas-warehouse:dispatch:list", + "parameters": [ + { + "$ref": "#/components/parameters/dispatchNo" + }, + { + "$ref": "#/components/parameters/slot" + }, + { + "$ref": "#/components/parameters/fileId" + } + ], + "responses": { + "200": { + "description": "no-store图片内容", + "content": { + "image/jpeg": { + "schema": { + "type": "string", + "format": "binary" + } + }, + "image/png": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, + "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": { + "get": { + "operationId": "recoverDispatchVehiclePhoto", + "summary": "只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传", + "x-permission-code": "platform:overseas-warehouse:dispatch:edit", + "parameters": [ + { + "$ref": "#/components/parameters/dispatchNo" + }, + { + "$ref": "#/components/parameters/idempotencyKey" + } + ], + "responses": { + "200": { + "description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "data": { + "$ref": "#/components/schemas/Photo" + } + } + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT" + } + }, + "parameters": { + "dispatchNo": { + "name": "dispatchNo", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + "slot": { + "name": "slot", + "in": "path", + "required": true, + "schema": { + "type": "string", + "enum": [ + "FUEL_START", + "FUEL_END" + ] + } + }, + "fileId": { + "name": "fileId", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + }, + "idempotencyKey": { + "name": "idempotencyKey", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + }, + "schemas": { + "Photo": { + "type": "object", + "required": [ + "slot", + "fileId", + "fileName", + "contentType", + "sizeBytes", + "uploadedAt" + ], + "properties": { + "slot": { + "type": "string", + "enum": [ + "FUEL_START", + "FUEL_END" + ] + }, + "category": { + "type": "string", + "enum": [ + "FUEL" + ] + }, + "fileId": { + "type": "string" + }, + "fileName": { + "type": "string" + }, + "contentType": { + "type": "string" + }, + "sizeBytes": { + "type": "string" + }, + "uploadedAt": { + "type": "string" + }, + "savedAt": { + "type": "string", + "nullable": true + } + } + }, + "Documents": { + "type": "object", + "required": [ + "dispatchNo", + "recordVersion", + "editable", + "photos" + ], + "properties": { + "dispatchNo": { + "type": "string" + }, + "recordVersion": { + "type": "string" + }, + "editable": { + "type": "boolean" + }, + "photos": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Photo" + } + } + } + }, + "BindPhoto": { + "type": "object", + "required": [ + "recordVersion", + "idempotencyKey", + "fileId" + ], + "properties": { + "recordVersion": { + "type": "string", + "pattern": "^(0|[1-9][0-9]*)$" + }, + "idempotencyKey": { + "type": "string", + "minLength": 8, + "maxLength": 128 + }, + "fileId": { + "type": "string" + } + } + } + } + } +} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 448375c73..f6ffb7d10 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -920,7 +920,8 @@ paths: parameters: - name: warehouseId in: query - required: true + description: 可选真实起运仓;未指定时按 warehouseTab 跨仓分页浏览,草稿及建单仍要求同一起运仓。 + required: false schema: type: string - name: planIds diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java index 1d1e13557..1bf6c5120 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/SmartDispatchOpenApiContractTest.java @@ -38,6 +38,9 @@ class SmartDispatchOpenApiContractTest { assertThat(map(paths.get(base+"/drafts"))).containsKey("get"); var candidate=map(map(paths.get(base+"/candidates")).get("get")); var parameterNames=((List)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map).map(p->p.get("name")).toList(); + var warehouseParameter=((List)candidate.get("parameters")).stream().map(SmartDispatchOpenApiContractTest::map) + .filter(p->"warehouseId".equals(p.get("name"))).findFirst().orElseThrow(); + assertThat(warehouseParameter.get("required")).isEqualTo(false); assertThat(parameterNames).contains("warehouseId","destinationWarehouseCode","draftToken","page","size","warehouseTab").doesNotContain("customerId","fbaWarehouseCode"); assertThat(map(map(schemas.get("DispatchCandidateList")).get("properties")).keySet()) .containsExactlyInAnyOrder("candidates","snapshotAt","complete","total","page","size","pages"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java index 80849d595..dc37b0973 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/SmartDispatchControllerTest.java @@ -45,6 +45,16 @@ class SmartDispatchControllerTest { .andExpect(status().isOk()); verify(aggregate).candidates(actor,new CandidateQuery("92",null,null,"LAX9",null,null,1,20,"ALL"));verifyNoInteractions(planning); } + /** 区域入口无需隐藏默认仓,全部与城市均由服务端分页。 */ + @Test void candidateHttpQueryAllowsRegionalBrowsingWithoutWarehouse()throws Exception { + var mvc=MockMvcBuilders.standaloneSetup(controller).setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver()).build(); + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(platform(),null,List.of())); + for(String tab:List.of("ALL","LAX","NYC")) { + mvc.perform(get("/api/platform/v1/last-leg/dispatches/candidates").param("warehouseTab",tab)).andExpect(status().isOk()); + verify(aggregate).candidates(actor,new CandidateQuery(null,null,null,null,null,null,1,20,tab)); + } + verifyNoInteractions(planning); + } /** 每个写入都将冻结DTO原样传递,拥有者只能来自认证主体。 */ @Test void allDraftRoutesDelegateFrozenVersionedRequests(){ var create=new CreateDraft("create","92");var container=new CreateContainer(0L,"container","d",15); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java new file mode 100644 index 000000000..95dbffcf3 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRegionalCandidatesTest.java @@ -0,0 +1,47 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.mapper.DispatchCandidatePageMapper; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchPlanningModels.CandidateQuery; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ActorMode; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 区域候选的读取范围与单仓写入职责分离,不依赖测试环境数据。 */ +class DispatchRegionalCandidatesTest { + /** 真实服务接受不指定仓的三种区域,保留服务端分页总数;非法仓和区域仍拒绝。 */ + @Test void serviceAcceptsRegionalQueriesAndRejectsMalformedScopes() { + var sources=mock(DispatchService.class);var appointments=mock(DispatchAppointmentAllocations.class); + var service=new DispatchAggregateService(null,null,sources,null,null,null,new com.fasterxml.jackson.databind.ObjectMapper()); + ReflectionTestUtils.setField(service,"appointments",appointments); + var actor=new Actor("7",ActorMode.PLATFORM,null); + when(sources.candidatePage(eq("7"),any())).thenReturn(new DispatchService.CandidatePage(List.of(),41)); + when(sources.selectionGroups(List.of())).thenReturn(Map.of());when(appointments.candidates(List.of())).thenReturn(Map.of()); + for(String tab:List.of("ALL","LAX","NYC")) { + var query=new CandidateQuery(null,null,null,null,null,null,2,20,tab); + var result=service.candidates(actor,query); + assertThat(result.total()).isEqualTo(41);assertThat(result.page()).isEqualTo(2);assertThat(result.pages()).isEqualTo(3); + verify(sources).candidatePage("7",query); + } + assertThatThrownBy(()->service.candidates(actor,new CandidateQuery("bad",null,null,null,null,null,1,20,"ALL"))).hasMessageContaining("起运仓筛选无效"); + assertThatThrownBy(()->service.candidates(actor,new CandidateQuery(null,null,null,null,null,null,1,20,"bad"))).hasMessageContaining("仓库页签不合法"); + } + /** 解析实际Mapper动态SQL,统计和分页采用相同区域条件,单仓消费者继续绑定真实仓。 */ + @Test void countAndPageUseOptionalWarehouseAndTheSameRegionPredicate() { + var config=new Configuration();config.addMapper(DispatchCandidatePageMapper.class); + for(String method:List.of("count","page")) for(String tab:List.of("ALL","LAX","NYC")) for(String warehouse:List.of("ALL_WAREHOUSES","321")) { + var query=new CandidateQuery(warehouse.equals("321")?warehouse:null,null,null,null,null,null,1,20,tab); + var sql=config.getMappedStatement(DispatchCandidatePageMapper.class.getName()+"."+method).getBoundSql(Map.of("query",query,"offset",0)).getSql(); + if(query.warehouseId()==null)assertThat(sql).doesNotContain("overseasWarehouseId=?"); + else assertThat(sql).contains("overseasWarehouseId=?"); + if(tab.equals("LAX"))assertThat(sql).contains("REGEXP 'lax|洛杉矶|los angeles'"); + if(tab.equals("NYC"))assertThat(sql).contains("REGEXP 'nyc|jfk|"); + if(tab.equals("ALL"))assertThat(sql).doesNotContain("REGEXP 'lax|", "REGEXP 'nyc|"); + if(method.equals("page"))assertThat(sql).contains("LIMIT ? OFFSET ?"); + } + } +} diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index e4b4b357f..ae20134ad 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -141,6 +141,10 @@ $backendAgentsPath = Join-Path $backendRoot 'AGENTS.md' $rootAgents = Read-RequiredText $rootAgentsPath $businessIndexPath = Join-Path $workspaceRoot 'docs/业务快速索引.md' $businessIndex = Read-RequiredText $businessIndexPath +$businessRulesPath = Join-Path $workspaceRoot 'docs/业务实现边界.md' +$businessRules = Read-RequiredText $businessRulesPath +# 业务断言检查实际正文,不再要求导航表复制规则;路由校验仍使用短索引。 +$businessFacts = $businessIndex + "`n" + $businessRules Assert-Contains $rootAgents 'docs/业务快速索引.md' '根按需业务索引入口' Assert-MarkdownLinks $rootAgentsPath $rootAgents '根导航与文档链接' Assert-MarkdownTableUnique $businessIndexPath '## 业务快速路由表' 0 '详细业务路由表' @@ -273,6 +277,66 @@ Write-Host "定向检索样本:$($lookupSamples.Count) 项,命中行共 $loo $scriptsAgents = Read-RequiredText (Join-Path $backendRoot 'scripts/AGENTS.md') $engineeringConstraints = Read-RequiredText (Join-Path $workspaceRoot 'docs/AI工程执行与协作约束.md') $docsIndex = Read-RequiredText (Join-Path $workspaceRoot 'docs/README.md') + +# 只检查明确列出的当前入口和迁移正文,不递归扫描历史资料。 +$overseasDocEntryPath = Join-Path $workspaceRoot 'docs/海外仓业务/README.md' +$overseasDocEntry = Read-RequiredText $overseasDocEntryPath +$overseasDocRulesPath = Join-Path $workspaceRoot 'docs/海外仓业务/业务规则.md' +$overseasDocRules = Read-RequiredText $overseasDocRulesPath +$financeDocEntryPath = Join-Path $workspaceRoot 'docs/财务管理/README.md' +$financeDocEntry = Read-RequiredText $financeDocEntryPath +$financeP1Path = Join-Path $workspaceRoot 'docs/财务管理/财务对账结算P1闭环.md' +$financeP1 = Read-RequiredText $financeP1Path +foreach ($document in @( + @{ Name = '业务导航'; Path = $businessIndexPath; Text = $businessIndex; Limit = 12500 }, + @{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 }, + @{ Name = '海外仓文档入口'; Path = $overseasDocEntryPath; Text = $overseasDocEntry; Limit = 2000 }, + @{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 } +)) { + Assert-MarkdownLinks $document.Path $document.Text $document.Name + $checked++ + if (($document.Text -replace "`r`n", "`n").Length -gt $document.Limit) { + Add-Failure "$($document.Name) 超出入口预算 $($document.Limit),请将正文归回主题主文档" + } +} +foreach ($document in @( + @{ Name = '跨端实现边界'; Path = $businessRulesPath; Text = $businessRules }, + @{ Name = '海外仓业务正文'; Path = $overseasDocRulesPath; Text = $overseasDocRules }, + @{ Name = '财务当前闭环'; Path = $financeP1Path; Text = $financeP1 } +)) { Assert-MarkdownLinks $document.Path $document.Text $document.Name } +Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表' +$businessRouteSection = (($businessIndex -split '(?m)^## 业务快速路由表\r?$', 2)[1] -split '(?m)^## ', 2)[0] +foreach ($topic in [regex]::Matches($businessRules, '(?m)^## (?.+?)\r?$')) { + $prefix = '| ' + $topic.Groups['name'].Value + ' |' + $hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) }) + $checked++ + if ($hits.Count -ne 1) { Add-Failure "业务正文必须有唯一导航入口:$($topic.Groups['name'].Value)" } +} +Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单' +Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件' +Assert-Contains $financeDocEntry '财务对账结算P1闭环.md#客户资金账户' '当前资金账户正文入口' +Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史排除范围' +Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文' +Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文' +Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文' +$docLookupCharacters = 0 +$docLookupSamples = @( + @{ Key = '客户头程订单批量导入'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' }, + @{ Key = '客户资金账户、充值、授信与业务扣费'; Text = $businessIndex; Entry = 'apps/client-web/AGENTS.md' }, + @{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' }, + @{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Entry = '财务对账结算P1闭环.md#客户资金账户' }, + @{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Entry = '排车列表统一作业改造契约.md' }, + @{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Entry = '业务规则.md#本土散板简化下单' } +) +foreach ($sample in $docLookupSamples) { + $hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') }) + $checked++ + if ($hits.Count -ne 1) { Add-Failure "文档路由样本必须唯一命中:$($sample.Key)"; continue } + Assert-Contains $hits[0] $sample.Entry "文档路由样本 $($sample.Key)" + $docLookupCharacters += $hits[0].Length +} +Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。" + $testTaskRegistry = Read-RequiredText (Join-Path $workspaceRoot 'docs/独立任务协作登记.md') $devTaskRegistry = Read-RequiredText (Join-Path $workspaceRoot 'docs/开发环境独立任务协作登记.md') $mapperScanContract = Read-RequiredText (Join-Path $backendRoot 'scripts/mapper-scan-contract.mjs') @@ -282,7 +346,7 @@ Assert-Contains $rootAgents '源码结构、接口与 DTO、字段、状态机 Assert-Contains $rootAgents '数据库记录、上传文件、缓存、会话、消息、搜索索引和其他运行状态均视为可丢弃' '根开发运行状态范围' Assert-Contains $rootAgents '后端 MyBatis Mapper 新增、移动或扫描遗漏' '根 Mapper 快速入口' Assert-Contains $rootAgents 'node scripts/mapper-scan-contract.mjs' '根 Mapper 静态门禁' -Assert-Contains $businessIndex '脚本管理、全量验证与测试造数' '根脚本管理快速入口' +Assert-Contains $businessFacts '脚本管理、全量验证与测试造数' '根脚本管理快速入口' Assert-Contains $rootAgents 'baiyue-firstleg/scripts/AGENTS.md' '根脚本最近指引' Assert-Contains $engineeringConstraints '禁止为旧代码、旧调用或开发环境存量保留旧接口/旧字段别名' '工程无兼容执行规则' Assert-Contains $engineeringConstraints '不授权自动重置数据库、删除数据/文件、执行 SQL、部署或修改环境' '无兼容安全边界' @@ -340,7 +404,7 @@ foreach ($businessDocsEntry in @( 'docs/国内货柜履约/README.md', 'docs/海外仓业务/README.md' )) { - Assert-Contains $businessIndex $businessDocsEntry "根业务域入口 $businessDocsEntry" + Assert-Contains $businessFacts $businessDocsEntry "根业务域入口 $businessDocsEntry" } foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三期/', 'docs/四期/')) { Assert-NotContains $rootAgents $removedPhaseDocsEntry "根已删除阶段目录 $removedPhaseDocsEntry" @@ -348,18 +412,18 @@ foreach ($removedPhaseDocsEntry in @('docs/一期/', 'docs/二期/', 'docs/三 Assert-NotContains $rootAgents '不含出库和派送' '根海外仓全流程范围' # 新增稳定业务能力必须从根表和目标部署单元直接命中。 -Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货路由' +Assert-Contains $businessFacts '入库扣货、解除与凭证' '根入库扣货路由' Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货路由' Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货路由' -Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由' +Assert-Contains $businessFacts '客户资金账户、充值、授信与业务扣费' '根客户资金账户路由' Assert-Contains $adminAgents '客户资金账户与柜级公共费用分摊' 'Admin 客户资金账户路由' Assert-Contains $clientAgents '客户资金账户、授信与充值申请' 'Client 客户资金账户路由' Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户资金账户路由' -Assert-Contains $businessIndex '顶层授权快照' '根海外顶层授权快照' +Assert-Contains $businessFacts '顶层授权快照' '根海外顶层授权快照' Assert-Contains $backendAgents '顶层授权快照' '后端海外顶层授权快照' -Assert-Contains $businessIndex '目的港码头拖车与还空预警' '根目的港码头拖车路由' -Assert-Contains $businessIndex '与订舱级拖车预约是不同生命周期' '根两类拖车边界' -Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由' +Assert-Contains $businessFacts '目的港码头拖车与还空预警' '根目的港码头拖车路由' +Assert-Contains $businessFacts '与订舱级拖车预约是不同生命周期' '根两类拖车边界' +Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根尾程订单控制路由' Assert-Contains $adminAgents '目的港码头拖车与还空预警' 'Admin 目的港码头拖车路由' Assert-Contains $adminAgents '尾程订单控制与柜级入仓计划转换' 'Admin 尾程订单控制路由' Assert-Contains $backendAgents '目的港码头拖车与还空预警' '后端目的港码头拖车路由' @@ -385,7 +449,7 @@ $firstlegAgents = $firstlegEntry + "`n" + $firstlegIndex $lastlegAgents = $lastlegEntry + "`n" + $lastlegIndex $warehouseAgents = Read-RequiredText (Join-Path $backendRoot 'warehouse/AGENTS.md') $coloadOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/coload-groups-v1.yaml') -Assert-Contains $businessIndex 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口' +Assert-Contains $businessFacts 'modules/unified-orders/api/coLoadGroups.ts' '根同柜不可拆组入口' Assert-Contains $adminAgents '成员数是入仓片段数,不等于订单票数' 'Admin 同柜成员语义' Assert-Contains $backendAgents 'loading/coload/controller/PlatformColoadGroupController.java' '后端同柜不可拆组入口' Assert-Contains $firstlegAgents '服务端允许待审核、待测量/测量中、待分柜及装柜中订单' 'Firstleg 同柜建组边界' @@ -409,7 +473,7 @@ Assert-Contains $lastlegAgents '/api/platform/v1/last-leg/inbound-plans/convert- # 本轮新增的批量下单、测量计费、智能装柜、账单联查与导出重试必须从最近指引直达。 $fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') $financeAgents = Read-RequiredText (Join-Path $backendRoot 'finance/AGENTS.md') -Assert-Contains $businessIndex '客户头程订单批量导入' '根批量下单快速路由' +Assert-Contains $businessFacts '客户头程订单批量导入' '根批量下单快速路由' Assert-Contains $orderAgents 'MerchantBatchImportService.java' 'Order 批量导入入口' Assert-Contains $orderAgents '仓库目录必须翻页读取全部候选' 'Order 批量模板全量仓库约束' Assert-Contains $clientAgents '中文批量导入' 'Client 批量导入入口' @@ -435,7 +499,7 @@ $orderDeclarationService = Read-RequiredText (Join-Path $backendRoot 'order/src/ $unifiedOrderQueryService = Read-RequiredText (Join-Path $backendRoot 'order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java') $firstlegDeclarationService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/OrderDeclarationSyncService.java') $adminDeclarationEditor = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/components/OrderDeclarationEditorDialog.vue') -Assert-Contains $businessIndex '平台修改客户原始申报与履约渠道' '根订单申报快速路由' +Assert-Contains $businessFacts '平台修改客户原始申报与履约渠道' '根订单申报快速路由' Assert-Contains $adminAgents '客户原始申报资料编辑与渠道变更' 'Admin 订单申报快速路由' Assert-Contains $backendAgents '平台修改客户原始申报、履约渠道变更与跨服务同步' '后端订单申报快速路由' Assert-Contains $orderAgents '平台版本化修改客户原始申报' 'Order 申报编辑入口' @@ -448,7 +512,7 @@ Assert-Contains $orderDeclarationService 'ORDER_DECLARATION_UPDATED_V1' 'Order Assert-Contains $firstlegDeclarationService 'mapper.byEvent(event.tenantId(), event.eventId())' 'Firstleg 申报 eventId 幂等' Assert-Contains $firstlegDeclarationService 'event.version() < current.orderVersion()' 'Firstleg 申报版本单调性' Assert-Contains $adminDeclarationEditor '仓库/PDA 实测数据不会被覆盖' 'Admin 申报事实边界提示' -Assert-Contains $businessIndex '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态' +Assert-Contains $businessFacts '统一列表仍固定显示 `PENDING_REVIEW` 并提供审核动作' '根待审核订单优先状态' Assert-Contains $orderAgents '统一主状态/下一动作仍固定为 `PENDING_REVIEW/APPROVE_ORDER`' 'Order 待审核优先状态' Assert-Contains $unifiedOrderQueryService 'order.getStatus() == OrderStatus.SUBMITTED' 'Order 待审核状态判断' Assert-Contains $unifiedOrderQueryService 'pendingReview ? "PENDING_REVIEW" : completed ? "COMPLETED" : summary.mainStatus()' 'Order 待审核主状态优先于完成态与履约投影' @@ -461,14 +525,14 @@ $fbaPricingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/mai $inboundFeeDetailService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/InboundFeeDetailService.java') $adminUnifiedOrdersApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/unified-orders/api/unifiedOrders.ts') $adminCarrierRatesView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/fba-pricing/carrier-rates/pages/CarrierRatesView.vue') -Assert-Contains $businessIndex '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由' +Assert-Contains $businessFacts '头程订单、批量审核预览与入仓码关系' '根批量审核预览路由' Assert-Contains $adminAgents '订单列表、批量审核预览、详情、商品图片与入库费用' 'Admin 批量审核预览路由' Assert-Contains $backendAgents '平台订单批量审核预览' '后端批量审核预览路由' Assert-Contains $orderAgents '平台批量审核预览' 'Order 批量审核预览入口' Assert-Contains $orderOpenApi '/api/platform/v1/orders/first-leg/batch-review-preview:' 'Order 批量审核预览 OpenAPI' Assert-Contains $batchReviewPreviewService '固定两次批量查询生成审核弹窗快照' 'Order 批量审核禁止逐票查询' Assert-Contains $adminUnifiedOrdersApi '/platform/v1/orders/first-leg/batch-review-preview' 'Admin 批量审核预览 API' -Assert-Contains $businessIndex '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由' +Assert-Contains $businessFacts '客户专属报价、柜级阶梯运费与费用调整' '根客户专属报价路由' Assert-Contains $adminAgents '客户专属价格簿、柜级阶梯运费与受控调价' 'Admin 客户专属报价路由' Assert-Contains $backendAgents '客户专属价格簿、柜级阶梯运费与受控调价' '后端客户专属报价路由' Assert-Contains $firstlegAgents '客户专属价格簿、柜级阶梯运费与调价边界' 'Firstleg 客户专属报价入口' @@ -485,8 +549,8 @@ Assert-Path 'baiyue-firstleg/firstleg/src/main/java/com/baiyue/firstleg/pricing/ # 海外异常处理必须贯通证据、原出库来源和库存恢复,不能退化为仅修改派送状态。 $exceptionOrderService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/exceptionorder/service/ExceptionOrderService.java') $inventoryCommandService = Read-RequiredText (Join-Path $backendRoot 'lastleg-service/src/main/java/com/baiyue/lastleg/inventory/command/service/InventoryCommandService.java') -Assert-Contains $businessIndex '海外出库、排车、派送、异常与回访' '根海外异常库存恢复路由' -Assert-Contains $businessIndex 'exceptionorder/' '根海外异常服务入口' +Assert-Contains $businessFacts '海外出库、排车、派送、异常与回访' '根海外异常库存恢复路由' +Assert-Contains $businessFacts 'exceptionorder/' '根海外异常服务入口' Assert-Contains $lastlegIndex '退回海外仓须把实收按原出库来源恢复到服务端校验的真实托盘/库位' 'Lastleg 海外异常库存恢复规则' Assert-Contains $adminOverseasAgents '库存恢复、异常完成与派送 `RETURNED` 由服务端原子推进' 'Admin 海外异常原子边界' Assert-Contains $backendAgents '海外异常退回、库存恢复与客户回访' '后端海外异常库存恢复路由' @@ -503,9 +567,9 @@ $pendingReviewOrderService = Read-RequiredText (Join-Path $backendRoot 'order/sr $merchantInboundBillingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/service/MerchantInboundBillingService.java') $clientSession = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/session.ts') $clientHttp = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/api/http.ts') -Assert-Contains $businessIndex '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选' -Assert-Contains $businessIndex '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界' -Assert-Contains $businessIndex '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界' +Assert-Contains $businessFacts '`PENDING_REVIEW/WAIT_MEASURE/WAIT_SPLIT/LOADING`' '根智能装柜四态候选' +Assert-Contains $businessFacts '能否加入以服务端 `canAssign` 为准' '根智能装柜资格边界' +Assert-Contains $businessFacts '人工加入允许跨渠道,智能推荐仍按柜次渠道' '根智能装柜渠道边界' Assert-Contains $backendAgents '/pending-review-assignments' '后端待审核装柜占位入口' Assert-Contains $backendAgents '/unpicked-items' '后端未拣整批移除入口' Assert-Contains $firstlegAgents '任一成员已拣则整组保留' 'Firstleg 同柜组整批移除边界' @@ -516,7 +580,7 @@ Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans Assert-Contains $firstlegOpenApi '/api/platform/v1/first-leg/smart-loading/plans/{planId}/unpicked-items:' '未拣整批移除 OpenAPI' Assert-Contains $pendingReviewOrderService 'orders.pendingReviewPage' 'Order 待审核候选权威查询' Assert-Contains $pendingReviewOrderService '"PENDING_REVIEW"' 'Order 待审核候选状态映射' -Assert-Contains $businessIndex '客户入库结果与头程费用确认' '根客户入库费用确认入口' +Assert-Contains $businessFacts '客户入库结果与头程费用确认' '根客户入库费用确认入口' Assert-Contains $firstlegAgents 'expectedFeeVersion' 'Firstleg 客户费用版本确认' Assert-Contains $clientAgents 'components/InboundBillingPanel.vue' 'Client 入库费用确认入口' Assert-Contains $firstlegOpenApi '/api/merchant/v1/first-leg/orders/{orderId}/inbound-billing/confirm:' '客户费用确认 OpenAPI' @@ -536,7 +600,7 @@ $smartLoadingService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/m $inboundSchema = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/db/phase2-inbound-schema.sql') $adminWeightPanel = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/inbounds/weight/components/WeightOcrPanel.vue') $clientOrdersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/views/FirstLegOrdersView.vue') -Assert-Contains $businessIndex '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由' +Assert-Contains $businessFacts '称重确认、整票库位块与部分拣货资源结算' '根称重库位块路由' Assert-Contains $backendAgents '称重确认、整票库位块与 PDA 资源结算' '后端称重库位块路由' Assert-Contains $firstlegAgents 'MeasurementLocationOccupationService.java' 'Firstleg 称重库位块入口' Assert-Contains $firstlegAgents '最新测量批次的正数 `totalVolumeCbm/originalVolumeCbm` 取较大值' 'Firstleg 库位块测量批次体积选择' @@ -556,7 +620,7 @@ Assert-Contains $inboundSchema 'UNIQUE KEY uk_pallet_allocation_weight (tenant_i Assert-Contains $adminWeightPanel 'row.locationCode' 'Admin 称重卡片库位消费' Assert-Contains $adminWeightPanel 'putawayStatusLabel(row.slotStatus)' 'Admin 称重卡片槽位状态' Assert-NotContains $adminWeightPanel 'class="putaway-guide"' 'Admin 已移除整票预留引导区' -Assert-Contains $businessIndex '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量' +Assert-Contains $businessFacts '`totalPackageCount/remainingPackageCount`' '根智能装柜暂估数量' Assert-Contains $firstlegAgents '`totalPackageCount/remainingPackageCount`' 'Firstleg 智能装柜暂估数量' Assert-Contains $adminAgents '`totalPackageCount/remainingPackageCount`' 'Admin 智能装柜暂估数量' Assert-Contains $firstlegOpenApi 'required: [id, packageCount, totalPackageCount, remainingPackageCount' '智能装柜计划数量 OpenAPI' @@ -575,7 +639,7 @@ $iamMemberService = Read-RequiredText (Join-Path $backendRoot 'iam/src/main/java $adminCustomersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/customers/pages/CustomersView.vue') $clientCompanyProfile = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/CompanyProfileView.vue') $clientMembersView = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/tenant-management/views/MembersView.vue') -Assert-Contains $businessIndex '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界' +Assert-Contains $businessFacts '公司联系电话/邮箱是企业资料,不作为成员登录标识' '根企业联系人边界' Assert-Contains $adminAgents '公司联系电话只作企业联系信息,不修改管理员/成员登录手机号' 'Admin 企业联系人边界' Assert-Contains $clientAgents '成员登录手机号可选,修改时同步 Auth、撤销该账号会话并审计' 'Client 成员登录手机号边界' Assert-Contains $authAgents '登录标识与企业联系人解耦' 'Auth 登录标识入口' @@ -589,7 +653,7 @@ Assert-Contains $clientCompanyProfile '不会修改任何成员的登录手机 Assert-Contains $clientMembersView '用于该成员手机号登录;修改后会同步认证账号并退出该成员现有会话' 'Client 成员登录手机号提示' $fileAgents = Read-RequiredText (Join-Path $backendRoot 'file/AGENTS.md') $qualificationOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/qualification-required-package-v1.yaml') -Assert-Contains $businessIndex '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义' +Assert-Contains $businessFacts '法人身份证选填,可在已有营业执照后单独补传或替换' '根法人身份证单独上传语义' Assert-Contains $fileAgents '法人身份证选填,客户可在已有营业执照后通过 `/api/merchant/v1/files/qualifications` 单独新增或替换' 'File 法人身份证单独上传入口' Assert-Contains $qualificationOpenApi '/api/merchant/v1/files/qualifications:' '客户单文件资质 OpenAPI' Assert-Contains $qualificationOpenApi 'LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换' '客户法人身份证单独上传契约' @@ -605,8 +669,8 @@ $adminLastMileOrders = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-w $loadingListsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/resources/openapi/loading-lists-v1.yaml') $pdaLoadingApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/pda-uniapp/api/pda-loading.js') $cabinetEvidenceService = Read-RequiredText (Join-Path $backendRoot 'firstleg/src/main/java/com/baiyue/firstleg/loading/service/CabinetLoadingEvidenceService.java') -Assert-Contains $businessIndex '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义' -Assert-Contains $businessIndex '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口' +Assert-Contains $businessFacts '拖车预约由 `RELEASED` 订舱拥有,不按客户、柜次、装柜计划或真实柜号归属' '根订舱级拖车语义' +Assert-Contains $businessFacts '证据以 `taskId + warehouseId` 进入任务专用 API' '根柜级证据入口' Assert-Contains $adminAgents '订舱级拖车预约' 'Admin 拖车快速入口' Assert-Contains $adminAgents '不选择或提交客户、柜次、装柜计划或真实集装箱号' 'Admin 拖车订舱级边界' Assert-Contains $backendAgents '订舱级拖车' '后端拖车快速入口' @@ -645,7 +709,7 @@ Assert-Contains $adminAgents '确认预装和创建 PDA 任务前必须补齐' ' Assert-Contains $pdaAgents '当前 `taskId + warehouseId` 走任务专用 API' '国内 PDA 柜级证据入口' Assert-Contains $pdaLoadingApi '/first-leg/pda-loading/tasks/${encodeURIComponent(taskId)}/evidence?warehouseId=' '国内 PDA 证据查询 API' Assert-NotContains $pdaLoadingApi '/inbounds/${encodeURIComponent(inboundId)}/loading/evidence' '国内 PDA 旧入仓证据 API' -Assert-Contains $businessIndex '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界' +Assert-Contains $businessFacts '上传以幂等键绑定包含文件内容的请求哈希' '根装柜证据上传重放边界' Assert-Contains $firstlegAgents '完全重放返回当前视图且不得再次上传 File' 'Firstleg 装柜证据重放边界' Assert-Contains $cabinetEvidenceService 'uploadHash(warehouseId, expectedVersion, evidenceType, sealNo, remark, multipart)' '装柜证据上传请求哈希' Assert-Contains $cabinetEvidenceService 'digest.update(multipart.getBytes())' '装柜证据文件内容哈希' @@ -819,7 +883,7 @@ foreach ($overseasFulfillmentRoute in @( foreach ($overseasFulfillmentRootEntry in @( 'OutboundsView.vue', 'DeliveryOrdersView.vue', 'ExceptionOrdersView.vue', 'CustomerVisitsView.vue' )) { - Assert-Contains $businessIndex $overseasFulfillmentRootEntry '根海外履约正式业务路由' + Assert-Contains $businessFacts $overseasFulfillmentRootEntry '根海外履约正式业务路由' } Assert-Contains $adminAgents '海外排车、出库与派送' 'Admin 海外出库派送入口' Assert-Contains $adminAgents '海外异常退回与客户回访' 'Admin 海外异常回访入口' @@ -936,8 +1000,8 @@ $containerCustomsOpenApi = Read-RequiredText (Join-Path $backendRoot 'firstleg/s $adminContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/container-loading/api/platformContainerCustoms.ts') $clientContainerCustomsApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/client-web/src/modules/first-leg/api/customs.ts') $customsFileOpenApi = Read-RequiredText (Join-Path $backendRoot 'file/src/main/resources/openapi/customs-file-and-export-v1.yaml') -Assert-Contains $businessIndex 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口' -Assert-Contains $businessIndex 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口' +Assert-Contains $businessFacts 'ContainerCustomsPanel.vue' '根整柜报关 Admin 入口' +Assert-Contains $businessFacts 'CustomsDocumentsDialog.vue' '根整柜报关 Client 入口' Assert-Contains $adminAgents 'components/ContainerCustomsPanel.vue' 'Admin 整柜报关入口' Assert-Contains $adminAgents 'complete-all-buy' 'Admin 全买单柜入口' Assert-Contains $clientAgents 'components/CustomsDocumentsDialog.vue' 'Client 退税报关资料入口' @@ -981,7 +1045,7 @@ Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划 Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希' Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' -Assert-Contains $businessIndex '尾程订单控制与柜级入仓计划转换' '根柜级计划入口' +Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口' Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围' Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围' Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围' @@ -1027,12 +1091,12 @@ $financeEventConsumer = Read-RequiredText (Join-Path $backendRoot 'finance/src/m $financePhase1Service = Read-RequiredText (Join-Path $backendRoot 'finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java') $overseasFinancePhase0ContractPath = Join-Path $workspaceRoot 'docs/财务管理/海外仓费用统一接入阶段0技术契约.md' $overseasFinancePhase0Contract = Read-RequiredText $overseasFinancePhase0ContractPath -Assert-Contains $businessIndex '财务管理与柜级公共费用分摊' '根柜费分摊快速路由' -Assert-Contains $businessIndex '海外业务费用来源与客户财务' '根海外费用快速路由' +Assert-Contains $businessFacts '财务管理与柜级公共费用分摊' '根柜费分摊快速路由' +Assert-Contains $businessFacts '海外业务费用来源与客户财务' '根海外费用快速路由' Assert-Contains $adminAgents '财务、客户资金账户与柜级公共费用分摊' 'Admin 柜费分摊快速路由' Assert-Contains $backendAgents '柜级公共费用跨客户分摊' '后端柜费分摊快速路由' Assert-Contains $backendAgents '海外业务费用来源跨区入池' '后端海外费用快速路由' -Assert-Contains $businessIndex '海外仓费用统一接入阶段0技术契约.md' '根海外费用阶段 0 按需入口' +Assert-Contains $businessFacts '海外仓费用统一接入阶段0技术契约.md' '根海外费用阶段 0 按需入口' Assert-Contains $financeAgents '海外仓费用统一接入阶段0技术契约.md' 'Finance 海外费用阶段 0 按需入口' Assert-Contains $lastlegAgents '海外仓费用统一接入阶段0技术契约.md' 'Lastleg 海外费用阶段 0 按需入口' Assert-Contains $lastlegAgents '不能因文档存在宣称已实现' '阶段 0 设计与代码事实边界' @@ -1066,16 +1130,16 @@ Assert-Contains $financeBusinessClient '/internal/finance/v1/business-fee-source Assert-Contains $financeBusinessClient '/internal/finance/v1/wallet-command-events' 'Finance 资金内部入口' Assert-Contains $financeAgents '`eventId + payloadHash` 防重放冲突' 'Finance 海外事件幂等边界' Assert-Contains $financeAgents '`PAYABLE + DIRECT_PAID` 审核版本必须生成 `BUSINESS_DIRECT_PAID`' 'Finance 直接已付隔离边界' -Assert-Contains $businessIndex '入库扣货、解除与凭证' '根入库扣货快速路由' +Assert-Contains $businessFacts '入库扣货、解除与凭证' '根入库扣货快速路由' Assert-Contains $adminAgents '入库扣货、解除与凭证历史' 'Admin 入库扣货快速路由' Assert-Contains $backendAgents '入库单扣货、解除、凭证与履约门禁' '后端入库扣货快速路由' Assert-Contains $firstlegAgents '入库单扣货、履约门禁与凭证' 'Firstleg 入库扣货入口' -Assert-Contains $businessIndex '客户资金账户、充值、授信与业务扣费' '根客户钱包快速路由' +Assert-Contains $businessFacts '客户资金账户、充值、授信与业务扣费' '根客户钱包快速路由' Assert-Contains $backendAgents '客户资金账户、充值、授信、冻结、结算与跨区资金指令' '后端客户钱包快速路由' Assert-Contains $lastlegAgents '海外业务资金指令与结果' 'Lastleg 跨区钱包入口' Assert-Contains $lastlegAgents '拆柜打托成本与客户应收分摊' 'Lastleg 拆柜打托费用入口' Assert-Contains $clientAgents '换标申请必须提交正数 CNY `chargeAmount`' 'Client 换标钱包金额边界' -Assert-Contains $businessIndex '统一认证与服务通信契约.md' '根统一认证契约入口' +Assert-Contains $businessFacts '统一认证与服务通信契约.md' '根统一认证契约入口' Assert-Contains $backendAgents '各端共用 Gateway 和 Redis 会话' '后端统一认证目标' $unifiedCommunication = Read-RequiredText (Join-Path $workspaceRoot 'docs/统一认证与服务通信契约.md') Assert-Contains $unifiedCommunication 'JWT 不携带角色或权限集合' '统一 JWT 最小身份边界' From 55436e3a0a0690fdb58293b3829c21a1864d705c Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 16:45:42 +0800 Subject: [PATCH 062/113] fix(firstleg): align warehouse arrival sources on latest observed port fact --- .../mapper/CarrierScheduleQueryMapper.java | 7 ++- .../CarrierScheduleQuerySqlContractTest.java | 10 ++-- .../ContainerCalendarSourceMapperTest.java | 46 +++++++++++++++++++ 3 files changed, 59 insertions(+), 4 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java index f256e9c9a..79334b6b9 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/mapper/CarrierScheduleQueryMapper.java @@ -63,6 +63,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper SELECT b.id AS containerBookingId,q.id AS queryId,q.version AS queryVersion, @@ -70,7 +71,7 @@ public interface CarrierScheduleQueryMapper extends BaseMapper#{id} GROUP BY b.id,q.id,q.version,q.status,current.standard_node,current.actual_at diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java index 93ddbab9f..13ff0dbed 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/repository/CarrierScheduleQuerySqlContractTest.java @@ -44,8 +44,9 @@ class CarrierScheduleQuerySqlContractTest { } } + /** 保留批量读取,实际到港与日历采用相同的最新观测顺序。 */ @Test - void batchesActualContainerFactsWithoutEstimatedOrPerNodeQueries() throws Exception { + void batchesActualContainerFactsWithLatestObservedArrivalAndWithoutEstimatedTimes() throws Exception { String sql = String.join("\n", CarrierScheduleQueryMapper.class .getMethod("selectContainerActualFacts", List.class) .getAnnotation(Select.class).value()); @@ -55,13 +56,16 @@ class CarrierScheduleQuerySqlContractTest { .contains("f.actual_at IS NOT NULL") .contains("MAX(CASE WHEN f.standard_node='DEPARTED_WAREHOUSE'") .contains("MAX(CASE WHEN f.standard_node='DEPARTED_ORIGIN_PORT'") - .contains("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'") + .contains("MAX(arrival.actual_at) AS arrivedDestinationPortAt") + .contains("a.standard_node='ARRIVED_DESTINATION_PORT' AND a.actual_at IS NOT NULL") + .contains("ORDER BY a.observed_at DESC,a.id DESC LIMIT 1") + .doesNotContain("MAX(CASE WHEN f.standard_node='ARRIVED_DESTINATION_PORT'") .contains("MAX(CASE WHEN f.standard_node='PICKUP_READY'") .contains("MAX(CASE WHEN f.standard_node='PICKED_UP'") .contains("MAX(CASE WHEN f.standard_node='EMPTY_RETURNED'") .contains("ORDER BY b.id") .doesNotContain("estimated_at"); - assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(3); + assertThat(count(sql, "fl_carrier_schedule_node_fact")).isEqualTo(5); assertThat(count(sql, "SELECT f2.id FROM fl_carrier_schedule_node_fact f2")).isEqualTo(1); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java index c5695c497..6b5a89190 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java @@ -2,6 +2,7 @@ package com.baiyue.firstleg.terminaldrayage.mapper; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.core.MybatisSqlSessionFactoryBuilder; +import com.baiyue.firstleg.portschedule.mapper.CarrierScheduleQueryMapper; import org.apache.ibatis.mapping.Environment; import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; import org.h2.jdbcx.JdbcDataSource; @@ -10,6 +11,51 @@ import java.time.LocalDateTime; import static org.junit.jupiter.api.Assertions.*; class ContainerCalendarSourceMapperTest { + /** 后续观测可将实际到港修正为更早日期,两入口须取同一条最新实际记录。 */ + @Test + void usesLatestObservedArrivalIncludingSameObservationIdTieBreakAcrossBothEntrypoints() throws Exception { + var ds = new JdbcDataSource(); + ds.setURL("jdbc:h2:mem:arrival-source-correction;MODE=MySQL;DB_CLOSE_DELAY=-1"); + try (var connection = ds.getConnection(); var sql = connection.createStatement()) { + sql.execute("CREATE TABLE fl_container_booking(id BIGINT PRIMARY KEY,cabinet_no VARCHAR,container_no VARCHAR," + + "vessel_name VARCHAR,voyage_no VARCHAR,destination_port_name VARCHAR,destination_warehouse_code VARCHAR," + + "actual_port_arrival_at TIMESTAMP,estimated_port_arrival_at TIMESTAMP,unloading_completed_at TIMESTAMP,record_status VARCHAR)"); + sql.execute("CREATE TABLE fl_terminal_drayage(container_booking_id BIGINT,actual_empty_return_at TIMESTAMP)"); + sql.execute("CREATE TABLE fl_carrier_schedule_query(id BIGINT,version INT,status VARCHAR)"); + sql.execute("CREATE TABLE fl_carrier_schedule_node_fact(id BIGINT,query_id BIGINT,container_booking_id BIGINT," + + "standard_node VARCHAR,actual_at TIMESTAMP,observed_at TIMESTAMP)"); + sql.execute("CREATE TABLE fl_direct_delivery_unloading(container_booking_id BIGINT,actual_at TIMESTAMP)"); + sql.execute("CREATE TABLE fl_outbound_dispatch(container_booking_id BIGINT,confirmed_at TIMESTAMP,reversed_at TIMESTAMP,status VARCHAR)"); + sql.execute("INSERT INTO fl_container_booking(id,cabinet_no,estimated_port_arrival_at,record_status) VALUES " + + "(1,'D1','2026-10-29 08:00:00','ACTIVE'),(2,'D2','2026-10-29 08:00:00','ACTIVE')"); + sql.execute("INSERT INTO fl_carrier_schedule_query VALUES(1,0,'READY'),(2,0,'READY')"); + sql.execute("INSERT INTO fl_outbound_dispatch SELECT id,'2026-10-01 00:00:00',NULL,'WAITING_FOR_LOADING' FROM fl_container_booking"); + sql.execute("INSERT INTO fl_carrier_schedule_node_fact VALUES " + + "(1,1,1,'ARRIVED_DESTINATION_PORT','2026-10-23 14:00:00','2026-10-24 14:00:00')," + + "(2,1,1,'ARRIVED_DESTINATION_PORT','2026-10-21 14:00:00','2026-10-25 14:00:00')," + + "(3,1,1,'ARRIVED_DESTINATION_PORT',NULL,'2026-10-26 14:00:00')," + + "(4,2,2,'ARRIVED_DESTINATION_PORT','2026-10-24 14:00:00','2026-10-25 14:00:00')," + + "(5,2,2,'ARRIVED_DESTINATION_PORT','2026-10-22 14:00:00','2026-10-25 14:00:00')"); + } + var config = new MybatisConfiguration(); + config.setEnvironment(new Environment("test", new JdbcTransactionFactory(), ds)); + config.addMapper(ContainerCalendarSourceMapper.class); + config.addMapper(CarrierScheduleQueryMapper.class); + try (var session = new MybatisSqlSessionFactoryBuilder().build(config).openSession()) { + var from = LocalDateTime.parse("2026-10-01T00:00:00"); + var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1)); + var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L)); + assertEquals(2, calendar.size()); + assertEquals(2, workbench.size()); + for (int index = 0; index < calendar.size(); index++) { + var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00"); + assertEquals(expected, calendar.get(index).arrivalAt()); + assertEquals("ACTUAL", calendar.get(index).arrivalType()); + assertEquals(expected, workbench.get(index).arrivedDestinationPortAt()); + assertEquals(calendar.get(index).arrivalAt().plusDays(2).toLocalDate(), workbench.get(index).arrivedDestinationPortAt().plusDays(2).toLocalDate()); + } + } + } /** 使用实际 Mapper 核对跨月默认日期候选、缺日期与真实出仓边界。 */ @Test void selectsShiftedArrivalUndatedAndOlderReturnCandidatesWithoutInactiveOrUndepartedContainers() throws Exception { From 4a0e86bd428bee8b162be2686d9b4de9ced023e1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 16:58:30 +0800 Subject: [PATCH 063/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../DispatchLoadingDepartureService.java | 28 +- .../DispatchLoadingDocumentController.java | 60 ++ .../PdaDispatchLoadingDocumentController.java | 60 ++ .../DispatchLoadingDocumentService.java | 179 +++++ .../dispatch-vehicle-documents-v1.yaml | 621 +++++++----------- .../DispatchVehicleDocumentTest.java | 76 +++ scripts/agents-index-contract.ps1 | 47 +- 7 files changed, 667 insertions(+), 404 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/DispatchVehicleDocumentTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java index 4d83573a7..0db7c8884 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java @@ -9,7 +9,7 @@ import java.util.List; import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; -/** 两端装车完成即发车:所有中间状态只存在于同一事务,原请求只恢复最终结果。 */ +/** 装车完成停在装车后;资料齐全时由资料服务在保存事务中推进派送。 */ @Service public class DispatchLoadingDepartureService { private final DispatchAggregateService workflow; @@ -24,31 +24,31 @@ public class DispatchLoadingDepartureService { this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes; } - /** 原键恢复先于阶段校验;任一步失败均回滚装载、子单、费用、发车与全部账本。 */ + /** 两端装车完成只保存真实实载,原请求精确恢复装车后结果。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) { workflow.requireLoadingPermission(actor); + return workflow.loadingComplete(actor,number,request); + } + + /** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */ + @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) + public CommandResult departAfterDocuments(Actor actor,String number,String key) { var context=workflow.lockForArrangement(actor,number); - var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(), - hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request)); - var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + if(context.stage()!=Stage.AFTER_LOADING || context.status()!=Status.ACTIVE) + throw new BizException(ResultCode.CONFLICT,"当前阶段不能按装车资料推进派送"); // 先取得本车单据锁再申请真实来源锁,后续所有步骤保持同一个整车父锁。 delivery.departureFacts(context,true); - var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(), - stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark())); - context=workflow.preview(actor,number); - String arrangementKey=stepKey(number,request.idempotencyKey(),"ARRANGE"); + String arrangementKey=stepKey(number,key,"ARRANGE"); var fact=delivery.prepareForDeparture(context,actor,arrangementKey); var arrangementIdentity=new CommandIdentity(Action.ARRANGE_DELIVERY,arrangementKey,context.recordVersion(), hashes.source(List.of(number,actor.operatorId(),context.recordVersion(),fact.arrangementBusinessHash()))); var arranged=workflow.recordArrangement(context,actor,arrangementIdentity,fact,false); var departed=departure.depart(actor,number,new DispatchCommands.Depart(arranged.recordVersion(), - stepKey(number,request.idempotencyKey(),"DEPART"),request.remark())); - if(departed.stage()!=Stage.DELIVERING || !(loaded.result() instanceof DispatchResults.Loaded)) + stepKey(number,key,"DEPART"),"五张装车照片已齐全,自动进入派送中")); + if(departed.stage()!=Stage.DELIVERING) throw new BizException(ResultCode.CONFLICT,"装车发运结果未核平"); - var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),departed.recordVersion(), - departed.status(),departed.stage(),departed.occurredAt(),Effect.APPLIED,departed.counts(),loaded.result(),false,false); - commands.append(context.dispatchId(),actor,identity,result);return result; + return departed; } /** 固定长度内部键绑定原请求和步骤,不向客户端暴露中间命令作为恢复入口。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java new file mode 100644 index 000000000..8e86f384f --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** 平台排车装车资料入口,与另一端共用五框和自动派送门禁。 */ +@RestController +@Validated +@RequestMapping("/api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents") +public class DispatchLoadingDocumentController { + private final DispatchLoadingDocumentService service; + /** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */ + public DispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;} + /** 读取本车已保存照片,不以设备缓存作为上传事实。 */ + @GetMapping + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.get(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 上传先记录原请求,成功回执后再保存本车槽位引用。 */ + @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file)); + } + /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ + @PutMapping("/{slot}") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + return ApiResponse.success(service.bind(DispatchActorFactory.platform(user),dispatchNo,slot,request)); + } + /** 原上传仅允许当前有权限的原操作者恢复。 */ + @GetMapping("/uploads/{idempotencyKey}") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(service.recover(DispatchActorFactory.platform(user),dispatchNo,idempotencyKey)); + } + /** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */ + @GetMapping("/{slot}/files/{fileId}/content") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) { + var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType())) + .header("X-Content-Type-Options","nosniff").body(value.content()); + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java new file mode 100644 index 000000000..9d214c073 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java @@ -0,0 +1,60 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.*; +import org.springframework.http.*; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + +/** 绑定仓PDA装车资料入口,与另一端共用五框和自动派送门禁。 */ +@RestController +@Validated +@RequestMapping("/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents") +public class PdaDispatchLoadingDocumentController { + private final DispatchLoadingDocumentService service; + /** Controller只负责会话与输入映射,所有资料和状态规则归服务。 */ + public PdaDispatchLoadingDocumentController(DispatchLoadingDocumentService service){this.service=service;} + /** 读取本车已保存照片,不以设备缓存作为上传事实。 */ + @GetMapping + @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.get(DispatchActorFactory.pda(user),dispatchNo)); + } + /** 上传先记录原请求,成功回执后再保存本车槽位引用。 */ + @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { + return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file)); + } + /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ + @PutMapping("/{slot}") + @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + return ApiResponse.success(service.bind(DispatchActorFactory.pda(user),dispatchNo,slot,request)); + } + /** 原上传仅允许当前有权限的原操作者恢复。 */ + @GetMapping("/uploads/{idempotencyKey}") + @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") + public ApiResponse recover(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable String idempotencyKey) { + return ApiResponse.success(service.recover(DispatchActorFactory.pda(user),dispatchNo,idempotencyKey)); + } + /** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */ + @GetMapping("/{slot}/files/{fileId}/content") + @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) { + var value=service.content(DispatchActorFactory.pda(user),dispatchNo,slot,fileId); + return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType())) + .header("X-Content-Type-Options","nosniff").body(value.content()); + } +} + diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java new file mode 100644 index 000000000..7c20036b1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java @@ -0,0 +1,179 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage; +import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; +import com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** Admin与绑定仓PDA共用原整车五框照片;不复制派送单或文件引用。 */ +@Service +public class DispatchLoadingDocumentService { + public static final List SLOTS=List.of(PhotoSlot.LOADING_SCENE,PhotoSlot.LOADING_LIST, + PhotoSlot.PRE_DEPARTURE_CHECK,PhotoSlot.DRIVER_LICENSE,PhotoSlot.RECEIPT); + private final DispatchAggregateService workflow; + private final DispatchLoadingDepartureService departure; + private final OwnedBusinessRepository repository; + private final OwnedBusinessCommandService commands; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + private final OwnedBusinessFileGateway files; + /** 复用现有附加表、文件归属校验和上传意图账本。 */ + public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure, + OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection, + OwnedBusinessSupport codec,OwnedBusinessFileGateway files) { + this.workflow=workflow;this.departure=departure;this.repository=repository;this.commands=commands; + this.projection=projection;this.codec=codec;this.files=files; + } + public record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion, + String stage,boolean editable,boolean complete,List photos) {} + public record Binding(PhotoSlot slot,BindPhoto request) {} + public record Download(Photo photo,byte[] content) {} + + /** GET不写入照片或推进阶段;PDA每次重验冻结仓。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Documents get(Actor actor,String number) { + require(actor,false);var context=context(actor,number,false); + return documents(context,repository.record(context.dispatchId()),canWrite(actor)); + } + + /** 先落稳定上传意图,再调用文件服务,未知结果不能换键重传。 */ + public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) { + require(actor,true);slot(slot);codec.key(key);var context=context(actor,number,false); + if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE) + throw bad("照片须为非空JPG/PNG,最大10MiB"); + byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} + var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); + HttpOwnedBusinessFileGateway.validate(payload); + var identity=new OwnedBusinessPhotoService.UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); + var reservation=commands.reserveUpload(context.dispatchId(),operator(actor),key,identity); + if(!reservation.created())return uploaded(reservation.command()); + try { + var result=photo(slot,files.upload(context.dispatchId(),slot,actor.operatorId(),payload)); + commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result; + } catch(RuntimeException failure) { + try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");} + catch(RuntimeException recording){failure.addSuppressed(recording);} + throw conflict("照片上传结果未知,请保留原请求键核对"); + } + } + + /** 原上传恢复只允许原操作者和本车装车槽位。 */ + public Photo recover(Actor actor,String number,String key) { + require(actor,true);codec.key(key);var context=context(actor,number,false); + var command=commands.existing(context.dispatchId(),key,operator(actor)); + if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求"); + return uploaded(command); + } + + /** 锁顺序为车次、排车、附加行;第五张绑定与派送发车同事务成功或回滚。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Documents bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) { + require(actor,true);slot(slot);if(request==null)throw bad("照片绑定请求缺失"); + codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()),fileId=codec.version(request.fileId()); + if(fileId<=0)throw bad("文件编号无效"); + var context=context(actor,number,true);var operator=operator(actor);var identity=new Binding(slot,request); + var old=commands.existing(context.dispatchId(),request.idempotencyKey(),operator); + if(old!=null) { + commands.same(old,"BIND_LOADING_DOCUMENT",version,identity); + if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认"); + return codec.read(old.getResultJson(),Documents.class); + } + // 文件必须属于本排车与指定槽位,不能拿其他车或签收资料充数。 + var verified=photo(slot,files.metadata(context.dispatchId(),slot,fileId)); + var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(context.dispatchId()); + seed.setCreatedBy(actor.operatorId());seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); + var row=repository.lock(seed); + if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); + var photos=new EnumMap(PhotoSlot.class); + for(var value:projection.photos(row))photos.put(value.slot(),value); + photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); + row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1)); + row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now())); + if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); + var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(context.dispatchId()); + history.setRecordVersion(row.getRecordVersion());history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(actor.operatorId()); + history.setReason("保存装车照片 "+(SLOTS.indexOf(slot)+1));history.setOccurredAt(row.getUpdatedAt()); + history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorId())));repository.history(history); + if(context.stage()==Stage.AFTER_LOADING && complete(List.copyOf(photos.values()))) { + departure.departAfterDocuments(actor,number,request.idempotencyKey()); + context=workflow.preview(actor,number); + } + var result=documents(context,row,true); + commands.success(context.dispatchId(),operator,request.idempotencyKey(),"BIND_LOADING_DOCUMENT",version,identity,result); + return result; + } + + /** 预览必须引用当前本车已绑定文件,下载不会暴露未保存上传。 */ + @Transactional(readOnly=true) + public Download content(Actor actor,String number,PhotoSlot slot,long fileId) { + require(actor,false);slot(slot);var context=context(actor,number,false); + var photo=projection.photos(repository.record(context.dispatchId())).stream() + .filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst() + .orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片")); + return new Download(photo,files.content(context.dispatchId(),slot,fileId)); + } + + /** 阶段与真实装载证据均须存在,PDA仓隔离由主流程统一复核。 */ + private Context context(Actor actor,String number,boolean lock) { + var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number); + if(value.status()!=Status.ACTIVE || !Set.of(Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage()) + || value.loadingCompletedAt()==null || value.loadingCompletionHash()==null)throw conflict("装车完成后才能管理装车资料"); + return value; + } + /** 返回当前共享附加版本,五个固定框各有一份已保存图片才算齐全。 */ + private Documents documents(Context context,OwnedBusinessRows.Record row,boolean editable) { + var photos=projection.photos(row).stream().filter(p->SLOTS.contains(p.slot())).toList(); + return new Documents(context.dispatchNo(),row==null?"0":row.getRecordVersion().toString(),Long.toString(context.recordVersion()), + context.stage().name(),editable,complete(photos),photos); + } + /** 不按总张数判断,重复槽位和空文件引用不能满足五框。 */ + static boolean complete(List photos) { + return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank())); + } + /** 平台读写权限分离,PDA沿既有仓作业身份授权。 */ + private void require(Actor actor,boolean write) { + if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限"); + } + /** 上传按钮只反映当前主体的实际写权限。 */ + private boolean canWrite(Actor actor){return allowed(actor,true);} + /** 权限直接读取当前会话,不给PDA补造平台权限。 */ + private boolean allowed(Actor actor,boolean write) { + var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false; + var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList(); + return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate") + && authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR") + :authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list")); + } + /** 账本只消费可信操作者,权限由本服务独立校验。 */ + private OwnedBusinessModels.Actor operator(Actor actor){return new OwnedBusinessModels.Actor(actor.operatorId(),actor.operatorId(),Set.of());} + /** 油耗照片不能替代装车五框。 */ + private void slot(PhotoSlot slot){if(!SLOTS.contains(slot))throw bad("请选择有效的装车照片框");} + /** 恢复回执仍须属于上传动作及装车五框。 */ + private Photo uploaded(OwnedBusinessRows.Command command) { + if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null) + throw conflict("原上传结果尚未确认,请保留原请求"); + var result=codec.read(command.getResultJson(),Photo.class);slot(result.slot());return result; + } + /** 上传回执不标记已绑定,实际保存时间只在绑定事务填写。 */ + private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) { + return new Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null); + } +} diff --git a/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml index 1bc04a1e7..e6fe3e2d6 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-vehicle-documents-v1.yaml @@ -1,379 +1,242 @@ -{ - "openapi": "3.0.3", - "info": { - "title": "排车车辆资料", - "version": "1.0.0", - "description": "仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。" - }, - "security": [ - { - "bearerAuth": [] - } - ], - "paths": { - "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents": { - "get": { - "operationId": "getDispatchVehicleDocuments", - "summary": "读取本车油耗图片;复用车队附加记录", - "x-permission-code": "platform:overseas-warehouse:dispatch:list", - "parameters": [ - { - "$ref": "#/components/parameters/dispatchNo" - } - ], - "responses": { - "200": { - "description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/Documents" - } - } - } - } - } - } - } - } - }, - "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads": { - "post": { - "operationId": "uploadDispatchVehiclePhoto", - "summary": "准备本车油耗图片;尚未绑定保存", - "x-permission-code": "platform:overseas-warehouse:dispatch:edit", - "parameters": [ - { - "$ref": "#/components/parameters/dispatchNo" - }, - { - "$ref": "#/components/parameters/slot" - } - ], - "responses": { - "200": { - "description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/Photo" - } - } - } - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "multipart/form-data": { - "schema": { - "type": "object", - "required": [ - "file", - "idempotencyKey" - ], - "properties": { - "file": { - "type": "string", - "format": "binary", - "description": "非空JPG/PNG,最大10MiB" - }, - "idempotencyKey": { - "type": "string", - "minLength": 8, - "maxLength": 128 - } - } - } - } - } - } - } - }, - "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}": { - "put": { - "operationId": "bindDispatchVehiclePhoto", - "summary": "按附加版本绑定原上传文件,不改变排车或签收状态", - "x-permission-code": "platform:overseas-warehouse:dispatch:edit", - "parameters": [ - { - "$ref": "#/components/parameters/dispatchNo" - }, - { - "$ref": "#/components/parameters/slot" - } - ], - "responses": { - "200": { - "description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/Documents" - } - } - } - } - } - } - }, - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BindPhoto" - } - } - } - } - } - }, - "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content": { - "get": { - "operationId": "readDispatchVehiclePhoto", - "summary": "只读取本车当前绑定图片", - "x-permission-code": "platform:overseas-warehouse:dispatch:list", - "parameters": [ - { - "$ref": "#/components/parameters/dispatchNo" - }, - { - "$ref": "#/components/parameters/slot" - }, - { - "$ref": "#/components/parameters/fileId" - } - ], - "responses": { - "200": { - "description": "no-store图片内容", - "content": { - "image/jpeg": { - "schema": { - "type": "string", - "format": "binary" - } - }, - "image/png": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, - "/api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}": { - "get": { - "operationId": "recoverDispatchVehiclePhoto", - "summary": "只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传", - "x-permission-code": "platform:overseas-warehouse:dispatch:edit", - "parameters": [ - { - "$ref": "#/components/parameters/dispatchNo" - }, - { - "$ref": "#/components/parameters/idempotencyKey" - } - ], - "responses": { - "200": { - "description": "成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "code": { - "type": "string" - }, - "message": { - "type": "string" - }, - "data": { - "$ref": "#/components/schemas/Photo" - } - } - } - } - } - } - } - } - } - }, - "components": { - "securitySchemes": { - "bearerAuth": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "JWT" - } - }, - "parameters": { - "dispatchNo": { - "name": "dispatchNo", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - "slot": { - "name": "slot", - "in": "path", - "required": true, - "schema": { - "type": "string", - "enum": [ - "FUEL_START", - "FUEL_END" - ] - } - }, - "fileId": { - "name": "fileId", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - }, - "idempotencyKey": { - "name": "idempotencyKey", - "in": "path", - "required": true, - "schema": { - "type": "string" - } - } - }, - "schemas": { - "Photo": { - "type": "object", - "required": [ - "slot", - "fileId", - "fileName", - "contentType", - "sizeBytes", - "uploadedAt" - ], - "properties": { - "slot": { - "type": "string", - "enum": [ - "FUEL_START", - "FUEL_END" - ] - }, - "category": { - "type": "string", - "enum": [ - "FUEL" - ] - }, - "fileId": { - "type": "string" - }, - "fileName": { - "type": "string" - }, - "contentType": { - "type": "string" - }, - "sizeBytes": { - "type": "string" - }, - "uploadedAt": { - "type": "string" - }, - "savedAt": { - "type": "string", - "nullable": true - } - } - }, - "Documents": { - "type": "object", - "required": [ - "dispatchNo", - "recordVersion", - "editable", - "photos" - ], - "properties": { - "dispatchNo": { - "type": "string" - }, - "recordVersion": { - "type": "string" - }, - "editable": { - "type": "boolean" - }, - "photos": { - "type": "array", - "items": { - "$ref": "#/components/schemas/Photo" - } - } - } - }, - "BindPhoto": { - "type": "object", - "required": [ - "recordVersion", - "idempotencyKey", - "fileId" - ], - "properties": { - "recordVersion": { - "type": "string", - "pattern": "^(0|[1-9][0-9]*)$" - }, - "idempotencyKey": { - "type": "string", - "minLength": 8, - "maxLength": 128 - }, - "fileId": { - "type": "string" - } - } - } - } - } -} +openapi: 3.0.3 +info: + title: 排车车辆资料 + version: 1.0.0 + description: 仅平台有效排车DELIVERING/DELIVERED可用;兼容有无拼车货物,沿装车归属证据校验。共享原车队照片、版本、命令与历史,不新建附件存储。派送文件仍使用delivery-orders + documents;不把车队RECEIPT照片当签收。列表权限只读,排车edit独立写;恢复还需list。 +security: +- bearerAuth: [] +paths: + /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents: + get: + operationId: getDispatchVehicleDocuments + summary: 读取本车油耗图片;复用车队附加记录 + x-permission-code: platform:overseas-warehouse:dispatch:list + parameters: + - $ref: '#/components/parameters/dispatchNo' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Documents' + /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/uploads: + post: + operationId: uploadDispatchVehiclePhoto + summary: 准备本车油耗图片;尚未绑定保存 + x-permission-code: platform:overseas-warehouse:dispatch:edit + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Photo' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + - idempotencyKey + properties: + file: + type: string + format: binary + description: 非空JPG/PNG,最大10MiB + idempotencyKey: + type: string + minLength: 8 + maxLength: 128 + /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}: + put: + operationId: bindDispatchVehiclePhoto + summary: 按附加版本绑定原上传文件,不改变排车或签收状态 + x-permission-code: platform:overseas-warehouse:dispatch:edit + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Documents' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BindPhoto' + /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/{slot}/files/{fileId}/content: + get: + operationId: readDispatchVehiclePhoto + summary: 只读取本车当前绑定图片 + x-permission-code: platform:overseas-warehouse:dispatch:list + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + - $ref: '#/components/parameters/fileId' + responses: + '200': + description: no-store图片内容 + content: + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + /api/platform/v1/last-leg/dispatches/{dispatchNo}/vehicle-documents/uploads/{idempotencyKey}: + get: + operationId: recoverDispatchVehiclePhoto + summary: 只恢复当前操作者原油耗上传成功结果,未知结果拒绝重传 + x-permission-code: platform:overseas-warehouse:dispatch:edit + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/idempotencyKey' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Photo' +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + dispatchNo: + name: dispatchNo + in: path + required: true + schema: + type: string + slot: + name: slot + in: path + required: true + schema: + type: string + enum: + - FUEL_START + - FUEL_END + fileId: + name: fileId + in: path + required: true + schema: + type: string + idempotencyKey: + name: idempotencyKey + in: path + required: true + schema: + type: string + schemas: + Photo: + type: object + required: + - slot + - fileId + - fileName + - contentType + - sizeBytes + - uploadedAt + properties: + slot: + type: string + enum: + - FUEL_START + - FUEL_END + category: + type: string + enum: + - FUEL + fileId: + type: string + fileName: + type: string + contentType: + type: string + sizeBytes: + type: string + uploadedAt: + type: string + savedAt: + type: string + nullable: true + Documents: + type: object + required: + - dispatchNo + - recordVersion + - editable + - photos + properties: + dispatchNo: + type: string + recordVersion: + type: string + editable: + type: boolean + photos: + type: array + items: + $ref: '#/components/schemas/Photo' + BindPhoto: + type: object + required: + - recordVersion + - idempotencyKey + - fileId + properties: + recordVersion: + type: string + pattern: ^(0|[1-9][0-9]*)$ + idempotencyKey: + type: string + minLength: 8 + maxLength: 128 + fileId: + type: string diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/DispatchVehicleDocumentTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/DispatchVehicleDocumentTest.java new file mode 100644 index 000000000..b06c3ddfb --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/DispatchVehicleDocumentTest.java @@ -0,0 +1,76 @@ +package com.baiyue.lastleg.fleet.ownedbusiness; + +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.DispatchDetailReader; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessScope; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.fleet.outsourcedbusiness.service.OutsourcedBusinessService; +import com.baiyue.lastleg.fleet.ownedbusiness.controller.DispatchVehicleDocumentController; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.server.ResponseStatusException; +import org.junit.jupiter.api.Test; +import java.util.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 排车资料复用同一车辆照片记录,不扩大旧入口或改变履约状态。 */ +class DispatchVehicleDocumentTest { + /** 无拼车和实际拼车都能在派送后读取;原车队分类和冻结装车门禁继续生效。 */ + @Test void dispatchScopeAcceptsBothCargoGroupsOnlyAfterDeparture() { + var repo=mock(OwnedBusinessRepository.class);var codec=OwnedBusinessFixtures.codec(); + var queries=new OwnedBusinessQueryService(repo,new OwnedBusinessProjection(codec),codec,mock(DispatchDetailReader.class)); + var source=OwnedBusinessFixtures.source();when(repo.source(source.getDispatchNo())).thenReturn(source); + assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH)); + for(boolean pooled:List.of(false,true))for(String stage:List.of("DELIVERING","DELIVERED")) { + source.setPooledCargo(pooled);source.setStage(stage); + assertSame(source,queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH)); + } + assertThrows(BizException.class,()->queries.requireOwned(source.getDispatchNo())); + source.setOwnedLoadingCompletionHash("other-load"); + assertThrows(BizException.class,()->queries.requireScoped(source.getDispatchNo(),OwnedBusinessScope.DISPATCH)); + verify(repo,never()).update(any(),anyLong()); + } + /** 排车入口只接受油耗图片;签收和装车文件仍走派送单,旧外配仍不能上传油耗。 */ + @Test void scopesKeepIndependentWritePermissionsAndPhotoCategories() { + var scope=OwnedBusinessScope.DISPATCH; + assertEquals("platform:overseas-warehouse:dispatch:list",scope.listPermission()); + assertEquals("platform:overseas-warehouse:dispatch:edit",scope.uploadPermission()); + for(var slot:PhotoSlot.values()) { + if(slot.category().equals("FUEL")) { assertDoesNotThrow(()->scope.requireSlot(slot));assertThrows(BizException.class,()->OwnedBusinessScope.OUTSOURCED.requireSlot(slot)); } + else assertThrows(BizException.class,()->scope.requireSlot(slot)); + } + var actor=new Actor("reader","只读",Set.of(scope.listPermission())); + var row=new OwnedBusinessProjection(OwnedBusinessFixtures.codec()).row(OwnedBusinessFixtures.source(),null,OwnedBusinessFixtures.cargo(),List.of(),actor,scope); + assertFalse(row.actions().upload().allowed()); + } + /** 油耗与车队装车照片共存,排车补充油耗不破坏外配业务读取。 */ + @Test void sharedFuelPhotosDoNotInvalidateOutsourcedProjection() { + var codec=OwnedBusinessFixtures.codec();var record=new OwnedBusinessRows.Record();record.setRecordVersion(3L);record.setArrivalsJson("[]"); + record.setPhotosJson(codec.write(List.of(new Photo(PhotoSlot.FUEL_START,"FUEL","77","fuel.png","image/png","9","2026-10-21T00:00:00Z","2026-10-21T00:00:00Z")))); + var projection=new OwnedBusinessProjection(codec);var source=OwnedBusinessFixtures.source();source.setPooledCargo(true);source.setStage("DELIVERED"); + var row=projection.row(source,record,OwnedBusinessFixtures.cargo(),List.of(),OwnedBusinessFixtures.actor(),OwnedBusinessScope.OUTSOURCED); + assertEquals(1,row.photoSummary().fuelCount());assertEquals("3",row.recordVersion());assertEquals(8L,source.getRecordVersion());assertEquals("DELIVERED",source.getStage()); + assertEquals("77",projection.photos(record).get(0).fileId()); + var queries=mock(OwnedBusinessQueryService.class); + when(queries.detail(any(),eq(source.getDispatchNo()),eq(OwnedBusinessScope.OUTSOURCED))) + .thenReturn(new Detail(row,null,List.of(),projection.photos(record))); + var outsourced=new OutsourcedBusinessService(queries,mock(OwnedBusinessRecordService.class),mock(OwnedBusinessPhotoService.class),projection,codec); + assertTrue(outsourced.detail(OwnedBusinessFixtures.actor(),source.getDispatchNo()).photos().isEmpty()); + } + /** 新入口读写权限独立;服务层同样拒绝仅查看账号和车队权限绕入。 */ + @Test void controllerAndPhotoServiceKeepDispatchWriteBoundary() throws Exception { + assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:list')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods()) + .filter(m->m.getName().equals("get")).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value()); + for(String name:List.of("upload","bind","recover"))assertEquals("hasAuthority('platform:overseas-warehouse:dispatch:edit')",Arrays.stream(DispatchVehicleDocumentController.class.getMethods()) + .filter(m->m.getName().equals(name)).findFirst().orElseThrow().getAnnotation(PreAuthorize.class).value()); + var queries=mock(OwnedBusinessQueryService.class);var repo=mock(OwnedBusinessRepository.class);var files=mock(OwnedBusinessFileGateway.class);var codec=OwnedBusinessFixtures.codec(); + var service=new OwnedBusinessPhotoService(queries,mock(OwnedBusinessRecordService.class),repo,mock(OwnedBusinessCommandService.class),new OwnedBusinessProjection(codec),codec,files); + for(var actor:List.of(new Actor("read","只读",Set.of(OwnedBusinessScope.DISPATCH.listPermission())),OwnedBusinessFixtures.actor())) + assertThrows(ResponseStatusException.class,()->service.upload(actor,"PC-1",PhotoSlot.FUEL_START,"upload-original",null,OwnedBusinessScope.DISPATCH)); + verifyNoInteractions(files,repo,queries); + } +} diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index ae20134ad..3aee9acaa 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -306,11 +306,21 @@ foreach ($document in @( )) { Assert-MarkdownLinks $document.Path $document.Text $document.Name } Assert-TableRowWidth $businessIndexPath '## 业务快速路由表' 260 '详细业务导航表' $businessRouteSection = (($businessIndex -split '(?m)^## 业务快速路由表\r?$', 2)[1] -split '(?m)^## ', 2)[0] -foreach ($topic in [regex]::Matches($businessRules, '(?m)^## (?.+?)\r?$')) { - $prefix = '| ' + $topic.Groups['name'].Value + ' |' - $hits = @($businessRouteSection -split '\r?\n' | Where-Object { $_.StartsWith($prefix) }) +# 同一规则可供多个终端入口复用;校验实际正文链接,不要求路由名称复制正文标题。 +$businessRuleSections = [regex]::Matches($businessRules, '(?ms)^## (?[^\r\n]+)\r?\n(?.*?)(?=^## |\z)') +foreach ($topic in $businessRuleSections) { + $name = $topic.Groups['name'].Value + $slug = [regex]::Replace($name.Trim().ToLowerInvariant(), '[^\p{L}\p{M}\p{N}_\s-]', '') -replace '\s+', '-' + $target = '业务实现边界.md#' + $slug + $hasRoute = $businessRouteSection.Contains('](' + $target + ')') + if (-not $hasRoute) { + # 已有正式正文时允许索引直接跳转,避免为覆盖率强加一层摘要。 + $sourceLine = [regex]::Match($topic.Groups['body'].Value, '(?m)^范围依据:[^\r\n]+') + $sourceLink = [regex]::Match($sourceLine.Value, '\[[^\]]+\]\((?[^)]+)\)') + $hasRoute = $sourceLink.Success -and $businessRouteSection.Contains('](' + $sourceLink.Groups['target'].Value + ')') + } $checked++ - if ($hits.Count -ne 1) { Add-Failure "业务正文必须有唯一导航入口:$($topic.Groups['name'].Value)" } + if (-not $hasRoute) { Add-Failure "业务正文或其正式依据缺少导航入口:$name" } } Assert-Contains $rootAgents '备选导航' '索引不是串行读取清单' Assert-Contains $rootAgents '命中正文即停止扩展' '正文命中后的读取停止条件' @@ -320,19 +330,34 @@ Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入 Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文' Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文' $docLookupCharacters = 0 +$walletRuleTarget = '财务管理/财务对账结算P1闭环.md#客户资金账户' $docLookupSamples = @( - @{ Key = '客户头程订单批量导入'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' }, - @{ Key = '客户资金账户、充值、授信与业务扣费'; Text = $businessIndex; Entry = 'apps/client-web/AGENTS.md' }, - @{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' }, - @{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Entry = '财务对账结算P1闭环.md#客户资金账户' }, - @{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Entry = '排车列表统一作业改造契约.md' }, - @{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Entry = '业务规则.md#本土散板简化下单' } + @{ Key = '客户头程订单批量导入'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' }, + @{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget }, + @{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget }, + @{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget }, + @{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/warehouses/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' }, + @{ Key = '海外仓库主档、名称、地址与能力'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' }, + @{ Key = '海外库位、托盘与资源锁'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/warehouse-overseas-service/AGENTS.md'; Rules = '业务实现边界.md#海外仓库与通用资料' }, + @{ Key = '客户统计与运营指标'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/customers/AGENTS.md' }, + @{ Key = '平台日志、操作日志、审计日志与操作人缺失'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/audit/AGENTS.md' }, + @{ Key = '客户账单、客户对账单、账单确认'; Text = $businessIndex; Column = 2; Entry = 'apps/client-web/src/modules/finance/views/FinanceBillsView.vue' }, + @{ Key = '客户资金账户、充值、授信、冻结与业务扣费'; Text = $financeDocEntry; Column = 1; Entry = '财务对账结算P1闭环.md#客户资金账户' }, + @{ Key = '海外排车、出库与派送改造'; Text = $overseasDocEntry; Column = 1; Entry = '排车列表统一作业改造契约.md' }, + @{ Key = '本土卡车与散板表单'; Text = $overseasDocEntry; Column = 1; Entry = '业务规则.md#本土散板简化下单' } ) foreach ($sample in $docLookupSamples) { $hits = @($sample.Text -split '\r?\n' | Where-Object { $_.StartsWith('| ' + $sample.Key + ' |') }) $checked++ if ($hits.Count -ne 1) { Add-Failure "文档路由样本必须唯一命中:$($sample.Key)"; continue } - Assert-Contains $hits[0] $sample.Entry "文档路由样本 $($sample.Key)" + # 代码入口与规则正文分别核对,不能靠其他列碰巧包含目标路径通过。 + $cells = @($hits[0].Trim('|').Split('|') | ForEach-Object { $_.Trim() }) + if ($sample.Column -ge $cells.Count) { Add-Failure "文档路由样本缺少目标列:$($sample.Key)"; continue } + Assert-Contains $cells[$sample.Column] $sample.Entry "文档路由样本 $($sample.Key)" + if ($sample.Rules) { + if ($cells.Count -lt 3) { Add-Failure "文档路由样本缺少规则正文:$($sample.Key)"; continue } + Assert-Contains $cells[2] $sample.Rules "文档正文样本 $($sample.Key)" + } $docLookupCharacters += $hits[0].Length } Write-Host "文档路由样本:$($docLookupSamples.Count) 项,命中行共 $docLookupCharacters 字符(不含上层指引或按需正文,不代表端到端耗时)。" From 0c6c55f6f1fa39c717e3dbb36f6f3e59ada3e697 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 17:08:52 +0800 Subject: [PATCH 064/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatch-loading-documents-v1.yaml | 393 ++++++++++++++++++ .../openapi/overseas-business-v1.yaml | 2 +- .../openapi/overseas-pda-operations-v1.yaml | 2 +- ...spatchLoadingDeparturePersistenceTest.java | 105 ++++- 4 files changed, 480 insertions(+), 22 deletions(-) create mode 100644 lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml diff --git a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml new file mode 100644 index 000000000..23c86c5b7 --- /dev/null +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -0,0 +1,393 @@ +openapi: 3.0.3 +info: + title: 排车装车资料(Admin/PDA共用) + version: 1.0.0 + description: 装车完成进入AFTER_LOADING。五个整车照片框均已保存时,在最后一次绑定事务内自动生成派送并进入DELIVERING;失败回滚该次绑定和派送,前四张与已确认实载保留。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 +security: +- bearerAuth: [] +paths: + /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents: + get: + operationId: getDispatchLoadingDocuments + summary: 读取本车装车资料或原上传结果 + x-permission-code: platform:overseas-warehouse:dispatch:list + parameters: + - $ref: '#/components/parameters/dispatchNo' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Documents' + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents: + get: + operationId: getDispatchLoadingDocumentsPda + summary: 读取本车装车资料或原上传结果 + x-permission-code: lastleg:pda:operate + parameters: + - $ref: '#/components/parameters/dispatchNo' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Documents' + x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR + /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads: + post: + operationId: uploadDispatchLoadingPhoto + summary: 上传整车装车照片,尚未绑定 + x-permission-code: platform:overseas-warehouse:dispatch:edit + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Photo' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + - idempotencyKey + properties: + file: + type: string + format: binary + description: 非空JPG/PNG,最大10MiB + idempotencyKey: + type: string + minLength: 8 + maxLength: 128 + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/uploads: + post: + operationId: uploadDispatchLoadingPhotoPda + summary: 上传整车装车照片,尚未绑定 + x-permission-code: lastleg:pda:operate + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Photo' + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: + - file + - idempotencyKey + properties: + file: + type: string + format: binary + description: 非空JPG/PNG,最大10MiB + idempotencyKey: + type: string + minLength: 8 + maxLength: 128 + x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR + /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}: + put: + operationId: bindDispatchLoadingPhoto + summary: 保存装车照片;五框齐全时自动进入派送中 + x-permission-code: platform:overseas-warehouse:dispatch:edit + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Documents' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BindPhoto' + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}: + put: + operationId: bindDispatchLoadingPhotoPda + summary: 保存装车照片;五框齐全时自动进入派送中 + x-permission-code: lastleg:pda:operate + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Documents' + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/BindPhoto' + x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR + /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content: + get: + operationId: readDispatchLoadingPhoto + summary: 读取本车装车资料或原上传结果 + x-permission-code: platform:overseas-warehouse:dispatch:list + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + - $ref: '#/components/parameters/fileId' + responses: + '200': + description: no-store图片内容 + content: + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/{slot}/files/{fileId}/content: + get: + operationId: readDispatchLoadingPhotoPda + summary: 读取本车装车资料或原上传结果 + x-permission-code: lastleg:pda:operate + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/slot' + - $ref: '#/components/parameters/fileId' + responses: + '200': + description: no-store图片内容 + content: + image/jpeg: + schema: + type: string + format: binary + image/png: + schema: + type: string + format: binary + x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR + /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}: + get: + operationId: recoverDispatchLoadingPhoto + summary: 读取本车装车资料或原上传结果 + x-permission-code: platform:overseas-warehouse:dispatch:edit + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/idempotencyKey' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Photo' + /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-documents/uploads/{idempotencyKey}: + get: + operationId: recoverDispatchLoadingPhotoPda + summary: 读取本车装车资料或原上传结果 + x-permission-code: lastleg:pda:operate + parameters: + - $ref: '#/components/parameters/dispatchNo' + - $ref: '#/components/parameters/idempotencyKey' + responses: + '200': + description: 成功;业务错误沿ApiResponse返回A0400/A0403/A0404/A0409及诊断编号 + content: + application/json: + schema: + type: object + properties: + code: + type: string + message: + type: string + data: + $ref: '#/components/schemas/Photo' + x-required-role: OVERSEAS_WAREHOUSE_PDA_OPERATOR +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + parameters: + dispatchNo: + name: dispatchNo + in: path + required: true + schema: + type: string + slot: + name: slot + in: path + required: true + schema: + type: string + enum: &id001 + - LOADING_SCENE + - LOADING_LIST + - PRE_DEPARTURE_CHECK + - DRIVER_LICENSE + - RECEIPT + fileId: + name: fileId + in: path + required: true + schema: + type: string + idempotencyKey: + name: idempotencyKey + in: path + required: true + schema: + type: string + schemas: + Photo: + type: object + required: + - slot + - fileId + - fileName + - contentType + - sizeBytes + - uploadedAt + properties: + slot: + type: string + enum: *id001 + category: + type: string + enum: + - LOADING + fileId: + type: string + fileName: + type: string + contentType: + type: string + sizeBytes: + type: string + uploadedAt: + type: string + savedAt: + type: string + nullable: true + Documents: + type: object + required: + - dispatchNo + - recordVersion + - editable + - photos + - dispatchRecordVersion + - stage + - complete + properties: + dispatchNo: + type: string + recordVersion: + type: string + editable: + type: boolean + photos: + type: array + items: + $ref: '#/components/schemas/Photo' + dispatchRecordVersion: + type: string + stage: + type: string + enum: + - AFTER_LOADING + - DELIVERING + - DELIVERED + complete: + type: boolean + description: 五个固定框各有一张已保存照片 + BindPhoto: + type: object + required: + - recordVersion + - idempotencyKey + - fileId + properties: + recordVersion: + type: string + pattern: ^(0|[1-9][0-9]*)$ + idempotencyKey: + type: string + minLength: 8 + maxLength: 128 + fileId: + type: string diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index f6ffb7d10..95e6679bb 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1729,7 +1729,7 @@ paths: /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: summary: 确认装车完成并自动进入派送中 - description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。 + description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。 security: - bearerAuth: [] responses: diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index 3804a284f..bf9062af3 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -254,7 +254,7 @@ paths: /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: summary: 确认装车完成并自动进入派送中 - description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求在同一事务完成实载确认、全车派送单生成及发车;成功action为LOADING_COMPLETE、stage为DELIVERING,result保留真实Loaded装车证据。任一步失败整体回滚,重试原键恢复最终结果,不另行安排派送或确认发车。 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。 security: - bearerAuth: [] responses: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index b2df98597..36e5391d6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -1,6 +1,14 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.lastleg.dispatch.mapper.*; +import com.baiyue.lastleg.fleet.ownedbusiness.service.*; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.BindPhoto; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import java.nio.file.*; +import java.util.regex.Pattern; import com.baiyue.lastleg.dispatch.pda.service.DispatchPdaWorkflowService; import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.repository.*; @@ -19,43 +27,53 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; import static org.mockito.ArgumentMatchers.*; -/** 装完即发车的共同事务、原键最终结果及跨端竞争使用真实根单据和版本。 */ +/** 装车后五框门禁、保存发车共同事务及跨端竞争使用真实单据和版本。 */ class DispatchLoadingDeparturePersistenceTest { /** 每例恢复隔离身份,不影响其他服务权限测试。 */ @AfterEach void clearSecurity(){SecurityContextHolder.clearContext();} - /** 一次确认完成全部客户发运;原键重放返回派送中,不再生成子单或执行来源。 */ - @Test void loadingCompletesAllCustomersAndReplaysOnlyFinalResult() throws Exception { + /** 装车完成不发车,第五张保存自动发车,旧装车键保持原装车后结果。 */ + @Test void loadingWaitsForFivePhotosThenDepartsOnce() throws Exception { authorize();var f=new Fixture(); var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); - assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.DELIVERING); - assertThat(result.counts().shippedPackageCount()).isEqualTo(8);assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class); + assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE);assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); + for(int i=0;i<4;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("AFTER_LOADING"); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); + var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING"); + assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5); + var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,PhotoSlot.RECEIPT,new BindPhoto("4","photo-bind-4","1004"))); + assertThat(replayPhoto).isEqualTo(fifth); + assertThat(result.counts().shippedPackageCount()).isZero();assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class); assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED"); assertThat(f.f.db.queryForList("SELECT status FROM ll_delivery_order",String.class)).containsExactly("DELIVERING","DELIVERING"); assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("IN_TRANSIT"); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); - assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("DELIVERING"); + assertThat(f.f.db.queryForObject("SELECT result_json FROM ll_dispatch_command WHERE idempotency_key='complete'",String.class)).contains("AFTER_LOADING"); var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); assertThat(replay.replayed()).isTrue();assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result()); - assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.DELIVERING); + assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"complete")).stage()).isEqualTo(Stage.AFTER_LOADING); verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); assertThat(f.f.service.actions(f.f.actor,f.f.repository.find(f.number))).doesNotContainKeys(Action.ARRANGE_DELIVERY,Action.DEPART); assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("stale-new")))).hasMessageContaining("版本或审核阶段"); } - /** 任一后续步骤失败都恢复装车中,已提交的真实拣货保持,不留下中间装车或子单。 */ - @Test void preparationAndDepartureFailuresRollBackTheEntireLoadingCommand() throws Exception { + /** 派送失败回滚第五张绑定,保留已确认装车和前四张资料,原文件可重试。 */ + @Test void preparationAndDepartureFailuresRollBackTheFifthPhoto() throws Exception { authorize(); for(boolean failPreparation:List.of(true,false)) { var f=new Fixture(); + f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); + for(int i=0;i<4;i++)f.bind(i,f.f.actor); if(failPreparation)doThrow(new IllegalStateException("模拟单据准备失败")).when(f.delivery).prepareForDeparture(any(),any(),anyString()); else doThrow(new IllegalStateException("模拟来源发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any()); - assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("failed")))).isInstanceOf(IllegalStateException.class); - var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version); - assertThat(row.getLoadingCompletionHash()).isNull();assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull(); + assertThatThrownBy(()->f.bind(4,f.f.actor)).isInstanceOf(IllegalStateException.class); + var row=f.f.repository.find(f.number);assertThat(row.getStage()).isEqualTo("AFTER_LOADING");assertThat(row.getRecordVersion()).isEqualTo(f.version+1); + assertThat(row.getLoadingCompletionHash()).isNotNull(); + assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(4);assertThat(row.getArrangementKey()).isNull();assertThat(row.getDepartedAt()).isNull(); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_outbound_order",Long.class)).isZero(); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); - assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('LOADING_COMPLETE','ARRANGE_DELIVERY','DEPART')",Long.class)).isZero(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action IN ('ARRANGE_DELIVERY','DEPART')",Long.class)).isZero(); assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isEqualTo(2); assertThat(f.f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); } @@ -68,12 +86,12 @@ class DispatchLoadingDeparturePersistenceTest { var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null); assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化"); verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString()); - var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.DELIVERING); + var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING); SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("picker","","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")))).hasMessageContaining("装车完成权限"); } - /** 同一版本后台/PDA同时确认只有一条完整链提交,不会产生半车或重复派送。 */ + /** 同一版本后台/PDA同时确认只有一次装车提交,资料未齐不发车。 */ @Test void concurrentAdminAndPdaCompleteOnlyOneDeparture() throws Exception { authorize();var f=new Fixture();var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2); try { @@ -81,20 +99,52 @@ class DispatchLoadingDeparturePersistenceTest { var b=workers.submit(()->f.tryComplete(start,new Actor("picker",ActorMode.PDA,"9"),"pda")); start.countDown();assertThat(List.of(a.get(25,TimeUnit.SECONDS),b.get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false); } finally {workers.shutdownNow();} - assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING"); - verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("AFTER_LOADING"); + verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any()); } - /** 仅配置装车完成及PDA既有角色权限,不授予人工派送、发车权限。 */ + /** 资料上传必须在装车后且属于绑定仓,平台只读权限不能保存照片。 */ + @Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception { + authorize();var f=new Fixture(); + assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("装车完成后"); + f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); + assertThatThrownBy(()->f.documents.get(new Actor("picker",ActorMode.PDA,"99"),f.number)).hasMessageContaining("当前绑定仓"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,PhotoSlot.FUEL_START,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框"); + SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("reader","","platform:overseas-warehouse:dispatch:list")); + assertThat(f.documents.get(f.f.actor,f.number).editable()).isFalse(); + assertThatThrownBy(()->f.bind(0,f.f.actor)).hasMessageContaining("无装车资料操作权限"); + } + + /** 两端同版本竞争第五张,仅一个绑定和一次真实派送成功。 */ + @Test void concurrentFifthPhotoCannotDispatchTwice() throws Exception { + authorize();var f=new Fixture();f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); + for(int i=0;i<4;i++)f.bind(i,f.f.actor); + var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2); + try { + var results=new ArrayList>(); + for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{ + authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor; + f.f.tx.execute(s->f.documents.bind(actor,f.number,PhotoSlot.RECEIPT,new BindPhoto("4",key,"1004")));return true; + }catch(com.baiyue.common.core.exception.BizException expected){return false;} + finally {SecurityContextHolder.clearContext();} + })); + start.countDown();assertThat(List.of(results.get(0).get(25,TimeUnit.SECONDS),results.get(1).get(25,TimeUnit.SECONDS))).containsExactlyInAnyOrder(true,false); + } finally {workers.shutdownNow();} + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString()); + assertThat(f.documents.get(f.f.actor,f.number).photos()).hasSize(5); + assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING"); + } + + /** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */ private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","", - "platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));} + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));} /** 主流程与发车使用真实SQL,派送提供者受控写入真实子单来验证共同回滚。 */ private static final class Fixture { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final DispatchDeliveryPort delivery=mock(DispatchDeliveryPort.class); final DispatchPdaWorkflowService sourceWorkflow=mock(DispatchPdaWorkflowService.class); - final DispatchLoadingDepartureService service;final String number;final long version;final String manifest; + final DispatchLoadingDepartureService service;final DispatchLoadingDocumentService documents;final String number;final long version;final String manifest; final List measurements; /** 创建两客户货物并走真实两级审核、真实拣货归属,不伪造已装车状态。 */ Fixture() throws Exception { @@ -127,6 +177,21 @@ class DispatchLoadingDeparturePersistenceTest { var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json); service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes); + f.session.getConfiguration().addMapper(OwnedBusinessMapper.class); + String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); + for(String suffix:List.of("record","command","history")) { + var matcher=Pattern.compile("(?s)CREATE TABLE IF NOT EXISTS ll_fleet_owned_business_"+suffix+" \\(.*?;").matcher(schema); + assertThat(matcher.find()).isTrue(); + f.db.execute(matcher.group().replace(" JSON"," LONGTEXT").replace(" COLLATE utf8mb4_bin","").replaceFirst("(?s) ENGINE=InnoDB.*?;$",";")); + } + var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class)); + var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class); + when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z")); + documents=new DispatchLoadingDocumentService(f.service,service,records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); + } + /** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */ + DispatchLoadingDocumentService.Documents bind(int index,Actor actor) { + return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index)))); } /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");} From 0976b6ef0d3a4406bd9449e5f57b2dd11b32324e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 17:22:19 +0800 Subject: [PATCH 065/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lastleg-service/AGENTS.md | 2 +- ...astleg-internal-modularization-baseline.md | 4 + .../analyze-lastleg-dependencies.test.mjs | 4 + .../DispatchLoadingDocumentController.java | 16 +- .../mapper/DispatchAggregateMapper.java | 2 +- .../pda/controller/DispatchPdaController.java | 2 +- .../PdaDispatchLoadingDocumentController.java | 16 +- .../service/DispatchAggregateService.java | 2 +- .../DispatchLoadingDepartureService.java | 23 ++- .../DispatchLoadingDocumentService.java | 85 +++++++++ .../service/DispatchPdaQueryService.java | 37 +--- .../service/DispatchPickingService.java | 54 ++++-- .../service/DispatchReceivedPlan.java | 55 ++++++ .../port/DispatchLoadingPhotoPort.java | 29 +++ .../DispatchLoadingDocumentService.java | 179 ------------------ .../service/DispatchLoadingPhotoAdapter.java | 124 ++++++++++++ .../dispatch-loading-documents-v1.yaml | 3 +- .../openapi/overseas-business-v1.yaml | 4 +- .../openapi/overseas-pda-operations-v1.yaml | 4 +- ...spatchLoadingDeparturePersistenceTest.java | 69 ++++++- .../DispatchPickingPersistenceTest.java | 53 +++++- .../DispatchPlanPdaReadPersistenceTest.java | 39 +++- scripts/agents-index-contract.ps1 | 57 +++++- 23 files changed, 577 insertions(+), 286 deletions(-) rename lastleg-service/src/main/java/com/baiyue/lastleg/{fleet/ownedbusiness => dispatch}/controller/DispatchLoadingDocumentController.java (83%) rename lastleg-service/src/main/java/com/baiyue/lastleg/{fleet/ownedbusiness => dispatch/pda}/controller/PdaDispatchLoadingDocumentController.java (84%) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java delete mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index e689cbace..6b429627a 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -77,7 +77,7 @@ | 整车调度与理货审核 | `dispatch/controller/DispatchAggregateController.java` → `dispatch/service/DispatchAggregateService.java` 的提交及两级审核;同一排车先调度审核、再理货审核,通过后才允许真实扫码。资源、货物或路线关键变更使原依据失效;零扫码显式退回后重新审核,不复用旧客户子单审核状态 | | 外接询价、确认占位及取消 | `fleet/trip/PlatformFleetBookingController.java`、`FleetBookingService.java`、`FleetBookingReservations.java`;客户从`fleet/customer/`读取IAM有效主档与资格,询价不占位,确认后与自有货计划共同校验逐段容量。约定金额不代表应收/实收,`FleetBookingIncomingGateway`由`transfer/fleet/FleetIncomingAdapter`实现来货登记/实收确认,排车关联通过`FleetDispatchGateway.sourceCargo`核对实收来源 | | 拣货前计划容量与逐段余位 | `FleetTripExecutionService.capacityPlan/confirmCapacityPlan`、`/fleet/trips/{tripNo}/capacity-plan`;保存自有货的计划值,不检查拣货完成、不推进审核或扣库存;`planValid`校验当前货物hash,不把计划值当实测值,资源/计划/关联变更后须重新核对 | -| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,同一事务复用实载确认、派送单准备及发车,成功直接`DELIVERING`;请求携带唯一`recordVersion`,原键恢复最终结果。公开手工安排派送、`/depart`入口已移除;内部`AFTER_LOADING`和步骤命令只用于事务编排。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | +| 实物装载、逐段容量与双端整车发车 | 两端`dispatches/{dispatchNo}/loading-complete`统一调用`dispatch/service/DispatchLoadingDepartureService.java`,实载确认成功进入`AFTER_LOADING`;请求携带唯一`recordVersion`,原键恢复原装车后结果。`dispatch/service/DispatchLoadingDocumentService.java`复用整车附加照片与账本,平台/PDA均经`loading-documents`保存;五框齐全的最后一次绑定与派送准备、发车同事务,成功`DELIVERING`。公开手工安排派送、`/depart`入口仍不开放。真实PDA拣货须核平,不重复库存SHIP,Fleet容量和资源资格仍复核;未知费用不补零、不阻塞发运,真实已明确外接报价沿原费用来源防重 | | 车次货物结案、车辆和司机分别释放 | `fleet/trip/FleetTripCompletionService.java`、`FleetCompletionModels.java`;平台 `/fleet/trips/{tripNo}/completion`、`/complete`、`/resources/release`,独立complete/release权限。结案核对所有派送终态与实发数量;实际任务结束时间/地点及车辆结束状态单独记录,不以结案或收款自动释放资源 | | 跨服务 HTTP 客户端装配 | `config/LastlegRestClientFactory.java` | | 数据库基线、表索引与中文注释契约 | `src/main/resources/db/lastleg.sql` | diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 13bab30cc..9d7fc7509 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -28,6 +28,10 @@ `fleet.ownedbusiness`只为原排车装车后的自有车辆组合运营资料。五条精确引用限`OwnedBusinessModels→DispatchViews`、`OwnedBusinessProjection→DispatchContracts`、`OwnedBusinessQueryService→DispatchContracts/DispatchTypes/DispatchDetailReader`,均为`dispatch.port`公开事实或只读应用端口;不增加`fleet→dispatch-delivery`通用许可,不共享原排车实体、Mapper或具体Service。原详情适配器在dispatch内部委托唯一提供者。`DispatchAggregateService→OwnedBusinessOwnershipCapture`仅在原装车事务捕获归属,不能反向调用运营查询;捕获失败随原确认回滚。反向合同拒绝具体持久化/Service引用、其他消费者借用端口许可及任何包循环。 +### 装车资料触发派送边界(2026-10-21) + +`DispatchLoadingDocumentService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,调用端口保存照片后在同一事务推进派送并记录原结果。结构门禁仅开放上述三类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。 + ### 外配业务原排车详情读取边界(2026-10-21) `OutsourcedBusinessModels → DispatchViews` 仅组合既有 `dispatch.port` 公开详情。依赖登记与已有外配反向合同同步,不开放 `fleet→dispatch-delivery` 通用许可;原排车实体、Mapper、具体 Service 及其他消费者仍由门禁拒绝。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index de5c65519..db76e8b93 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -72,6 +72,10 @@ test('fleet stays a business slice and its policies have no infrastructure depen && edge.target === 'com.baiyue.lastleg.fleet.exception.FleetHandoverPdaQuery' || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture' + || ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService', + 'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController', + 'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source) + && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort' || [ 'TransportTaskModels->pickup.FleetPickupModels', 'TransportTaskModels->trip.FleetTripModels', 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java similarity index 83% rename from lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java index 8e86f384f..3adc9d1aa 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/DispatchLoadingDocumentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchLoadingDocumentController.java @@ -1,10 +1,10 @@ -package com.baiyue.lastleg.fleet.ownedbusiness.controller; +package com.baiyue.lastleg.dispatch.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; -import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*; +import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import org.springframework.http.*; @@ -25,21 +25,21 @@ public class DispatchLoadingDocumentController { /** 读取本车已保存照片,不以设备缓存作为上传事实。 */ @GetMapping @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") - public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { return ApiResponse.success(service.get(DispatchActorFactory.platform(user),dispatchNo)); } /** 上传先记录原请求,成功回执后再保存本车槽位引用。 */ @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") - public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot, @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { return ApiResponse.success(service.upload(DispatchActorFactory.platform(user),dispatchNo,slot,idempotencyKey,file)); } /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ @PutMapping("/{slot}") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") - public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, - @PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable Slot slot,@Valid @RequestBody BindPhoto request) { return ApiResponse.success(service.bind(DispatchActorFactory.platform(user),dispatchNo,slot,request)); } /** 原上传仅允许当前有权限的原操作者恢复。 */ @@ -51,7 +51,7 @@ public class DispatchLoadingDocumentController { /** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */ @GetMapping("/{slot}/files/{fileId}/content") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") - public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) { + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) { var value=service.content(DispatchActorFactory.platform(user),dispatchNo,slot,fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType())) .header("X-Content-Type-Options","nosniff").body(value.content()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index b58b70e61..a8a1e3745 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -98,7 +98,7 @@ public interface DispatchAggregateMapper { estimated_arrival_at=#{r.estimatedArrivalAt},remark=#{r.remark},loading_completed_at=#{r.loadingCompletedAt}, loading_completion_hash=#{r.loadingCompletionHash},loading_confirmation_json=#{r.loadingConfirmationJson},arrangement_key=#{r.arrangementKey}, arrangement_business_hash=#{r.arrangementBusinessHash},departed_at=#{r.departedAt}, - delivery_completed_at=#{r.deliveryCompletedAt},picked_package_count=#{r.pickedPackageCount}, + delivery_completed_at=#{r.deliveryCompletedAt},package_count=#{r.packageCount},pallet_count=#{r.palletCount},picked_package_count=#{r.pickedPackageCount}, shipped_package_count=#{r.shippedPackageCount},updated_at=#{r.updatedAt} WHERE id=#{r.id} AND record_version=#{version} """) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java index f37aa253b..4a3fec3cc 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/DispatchPdaController.java @@ -57,7 +57,7 @@ public class DispatchPdaController { return ApiResponse.success(workflow.loading(DispatchActorFactory.pda(user),dispatchNo)); } - /** 真实扫码核平后确认装车即发车,两端使用同一事务命令。 */ + /** 真实扫码核平后确认装车进入装车后,两端共用同一命令并等待五张装车照片。 */ @PostMapping("/{dispatchNo}/loading-complete") public ApiResponse loadingComplete(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody DispatchCommands.LoadingComplete request) { return ApiResponse.success(loadingDeparture.complete(DispatchActorFactory.pda(user),dispatchNo,request)); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java similarity index 84% rename from lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java index 9d214c073..229ea7140 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/controller/PdaDispatchLoadingDocumentController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/pda/controller/PdaDispatchLoadingDocumentController.java @@ -1,10 +1,10 @@ -package com.baiyue.lastleg.fleet.ownedbusiness.controller; +package com.baiyue.lastleg.dispatch.pda.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.lastleg.dispatch.controller.DispatchActorFactory; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; -import com.baiyue.lastleg.fleet.ownedbusiness.service.DispatchLoadingDocumentService; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*; +import com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import org.springframework.http.*; @@ -25,21 +25,21 @@ public class PdaDispatchLoadingDocumentController { /** 读取本车已保存照片,不以设备缓存作为上传事实。 */ @GetMapping @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") - public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + public ApiResponse get(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { return ApiResponse.success(service.get(DispatchActorFactory.pda(user),dispatchNo)); } /** 上传先记录原请求,成功回执后再保存本车槽位引用。 */ @PostMapping(value="/{slot}/uploads",consumes=MediaType.MULTIPART_FORM_DATA_VALUE) @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") - public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot, + public ApiResponse upload(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot, @RequestParam @Size(min=8,max=128) String idempotencyKey,@RequestPart("file") MultipartFile file) { return ApiResponse.success(service.upload(DispatchActorFactory.pda(user),dispatchNo,slot,idempotencyKey,file)); } /** 最后一框保存成功时自动进入派送中,不新增人工发车按钮。 */ @PutMapping("/{slot}") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") - public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, - @PathVariable PhotoSlot slot,@Valid @RequestBody BindPhoto request) { + public ApiResponse bind(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo, + @PathVariable Slot slot,@Valid @RequestBody BindPhoto request) { return ApiResponse.success(service.bind(DispatchActorFactory.pda(user),dispatchNo,slot,request)); } /** 原上传仅允许当前有权限的原操作者恢复。 */ @@ -51,7 +51,7 @@ public class PdaDispatchLoadingDocumentController { /** 每次预览核对本车当前绑定,文件不得跨车跨仓读取。 */ @GetMapping("/{slot}/files/{fileId}/content") @PreAuthorize("hasAuthority('lastleg:pda:operate') and hasAuthority('ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR')") - public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable PhotoSlot slot,@PathVariable @Positive long fileId) { + public ResponseEntity content(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@PathVariable Slot slot,@PathVariable @Positive long fileId) { var value=service.content(DispatchActorFactory.pda(user),dispatchNo,slot,fileId); return ResponseEntity.ok().cacheControl(CacheControl.noStore()).contentType(MediaType.parseMediaType(value.photo().contentType())) .header("X-Content-Type-Options","nosniff").body(value.content()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 3693e5a3b..56215676a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -540,7 +540,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA List.copyOf(pallets),row.getLoadingConfirmationJson()==null?null:read(row.getLoadingConfirmationJson(),ConfirmedLoad.class),gate,List.copyOf(missing)); } - /** 内部实载步骤:扫码核平后保存装载证据,中间装车后状态由外层同事务继续推进发车。 */ + /** 实载确认:扫码核平后保存装载证据并进入装车后,五张装车资料齐全再推进派送。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult loadingComplete(Actor actor,String number,DispatchCommands.LoadingComplete request) { required(actor,number,false);fleet.lockDispatchShell(number);var row=required(actor,number,true); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java index 0db7c8884..13031244e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java @@ -3,13 +3,14 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.common.core.api.ResultCode; import com.baiyue.common.core.exception.BizException; import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.*; import java.util.List; import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; -/** 装车完成停在装车后;资料齐全时由资料服务在保存事务中推进派送。 */ +/** 装车与资料可并行完成,两项齐全时在当前事务自动推进派送。 */ @Service public class DispatchLoadingDepartureService { private final DispatchAggregateService workflow; @@ -17,18 +18,32 @@ public class DispatchLoadingDepartureService { private final DispatchDepartureService departure; private final DispatchCommandLedger commands; private final DispatchCommandHasher hashes; + private final DispatchLoadingPhotoPort documents; /** 复用装车、派送和发车的真实规则,不另建库存或运输状态实现。 */ public DispatchLoadingDepartureService(DispatchAggregateService workflow,DispatchDeliveryPort delivery, - DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes) { + DispatchDepartureService departure,DispatchCommandLedger commands,DispatchCommandHasher hashes, + DispatchLoadingPhotoPort documents) { this.workflow=workflow;this.delivery=delivery;this.departure=departure;this.commands=commands;this.hashes=hashes; + this.documents=documents; } - /** 两端装车完成只保存真实实载,原请求精确恢复装车后结果。 */ + /** 原请求记录最终阶段;重放不重复装车、创建派送或发车。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult complete(Actor actor,String number,DispatchCommands.LoadingComplete request) { workflow.requireLoadingPermission(actor); - return workflow.loadingComplete(actor,number,request); + var context=workflow.lockForArrangement(actor,number); + var identity=new CommandIdentity(Action.LOADING_COMPLETE,request.idempotencyKey(),request.recordVersion(), + hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request)); + var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get(); + var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(), + stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark())); + var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos()); + var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded; + var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(), + finalState.status(),finalState.stage(),finalState.occurredAt(),finalState.effect(),finalState.counts(),loaded.result(),false,false); + commands.append(context.dispatchId(),actor,identity,result); + return result; } /** 仅供装车资料齐全的保存事务调用;派送准备和发车失败回滚最后一张绑定。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java new file mode 100644 index 000000000..e49905832 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDocumentService.java @@ -0,0 +1,85 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.*; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.util.*; + +/** Admin/PDA按整车共用装车资料;通过车队公开端口保存照片,不读取其持久化。 */ +@Service +public class DispatchLoadingDocumentService { + public static final List SLOTS=List.of(Slot.values()); + private final DispatchAggregateService workflow; + private final DispatchLoadingDepartureService departure; + private final DispatchLoadingPhotoPort photos; + /** 装车资料与履约原子编排留在排车域,车队只提供照片事实。 */ + public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure,DispatchLoadingPhotoPort photos) { + this.workflow=workflow;this.departure=departure;this.photos=photos; + } + /** GET只读,PDA每次按可信主体重验冻结仓。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public Documents get(Actor actor,String number) { + require(actor,false);var context=context(actor,number,false);return documents(context,photos.read(context.dispatchId()),allowed(actor,true)); + } + /** 上传先记原请求意图,不提前绑定或推进派送。 */ + public Photo upload(Actor actor,String number,Slot slot,String key,MultipartFile file) { + require(actor,true);var context=context(actor,number,false);return photos.upload(context.dispatchId(),actor.operatorId(),slot,key,file); + } + /** 原操作者仍须有权限且满足本车绑定仓范围,不能跨账号恢复。 */ + public Photo recover(Actor actor,String number,String key) { + require(actor,true);var context=context(actor,number,false);return photos.recover(context.dispatchId(),actor.operatorId(),key); + } + /** 锁顺序为车次、排车、附加行;第五张绑定与派送发车共同提交或回滚。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public Documents bind(Actor actor,String number,Slot slot,BindPhoto request) { + require(actor,true);var context=context(actor,number,true); + var replay=photos.replayBind(context.dispatchId(),actor.operatorId(),slot,request);if(replay!=null)return replay; + var state=photos.bind(context.dispatchId(),actor.operatorId(),slot,request); + if(context.stage()==Stage.AFTER_LOADING && complete(state.photos())) { + departure.departAfterDocuments(actor,number,request.idempotencyKey());context=workflow.preview(actor,number); + } + var result=documents(context,state,true); + photos.recordBind(context.dispatchId(),actor.operatorId(),slot,request,result);return result; + } + /** 当前引用由照片提供者再次核验,拒绝未绑定或其他车文件。 */ + @Transactional(readOnly=true) + public Download content(Actor actor,String number,Slot slot,long fileId) { + require(actor,false);var context=context(actor,number,false);return photos.content(context.dispatchId(),slot,fileId); + } + /** 装车中可同步上传;后续阶段仍须有真实装载证据,仓隔离沿用主流程。 */ + private Context context(Actor actor,String number,boolean lock) { + var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number); + if(value.status()!=Status.ACTIVE || !Set.of(Stage.LOADING,Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage()) + || value.stage()!=Stage.LOADING && (value.loadingCompletedAt()==null || value.loadingCompletionHash()==null)) + throw new BizException(ResultCode.CONFLICT,"进入装车中后才能管理装车资料"); + return value; + } + /** 对外同时返回资料版本与当前排车阶段,二者不互相替代。 */ + private Documents documents(Context context,State state,boolean editable) { + return new Documents(context.dispatchNo(),state.recordVersion(),Long.toString(context.recordVersion()),context.stage().name(),editable,complete(state.photos()),state.photos()); + } + /** 按五个固定框核齐,不能用总数或重复槽位代替。 */ + static boolean complete(List photos) { + return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank())); + } + /** 平台list与edit分离,PDA使用既有作业角色和绑定仓。 */ + private void require(Actor actor,boolean write) { + if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限"); + } + /** 权限取当前会话,不为PDA补造平台权限。 */ + private boolean allowed(Actor actor,boolean write) { + var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false; + var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList(); + return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate") + && authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR") + :authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list")); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 9b756f5e8..33799a454 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -87,7 +87,7 @@ public class DispatchPdaQueryService { if(!sourceValid)actions.put(Action.PICK,new Gate(false,"SOURCE_COMPOSITION_CHANGED","换标来源实际位置或累计剩余额度已变化,请回平台核对")); if(!complete)actions.put(Action.LOADING_COMPLETE,new Gate(false,"PICKING_INCOMPLETE","逐项真实拣货来源尚未核平")); else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); - var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),n(root.getPackageCount()),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); + var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); } @@ -95,36 +95,11 @@ public class DispatchPdaQueryService { } /** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */ private List receivedPlanPositions(Actor actor,DispatchItem item,List items) { - if(!Objects.equals(actor.boundWarehouseId(),item.getOverseasWarehouseId()))return List.of(); - var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); - if(rows.size()!=1)return List.of(); - var actual=rows.get(0); - if(!"RECEIVED".equals(actual.sourceStage()) || actual.warehousePalletId()==null - || !Objects.equals(actual.planPalletId(),item.getPlanPalletId()) - || !Objects.equals(actual.customerId(),item.getCustomerId()) - || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) - || !Objects.equals(actual.packageCount(),item.getPackageCount()) - || !Objects.equals(actual.destinationType(),item.getDestinationType()) - || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) - || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))return List.of(); - var originalOrders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); - if(originalOrders.isEmpty() || !new HashSet<>(originalOrders).containsAll( - sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return List.of(); - // 同托跨客户、换标及整申请来源必须全部已在原车;不能把新合托货物暴露为原计划可拣库存。 - var group=sourceRules.selectionGroup(actor.boundWarehouseId(),new DispatchCommands.CargoSelection( - SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); - if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return List.of(); - for(var member:group.sources()) { - var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); - if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()) - || n(selected.getPackageCount())!=member.packages())return List.of(); - } - var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); - if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),actor.boundWarehouseId()) - || !Objects.equals(p.palletId(),actual.warehousePalletId())) - || stock.stream().mapToLong(p->p.availablePackages()).sum()!=n(item.getPackageCount()))return List.of(); - return stock.stream().filter(p->p.availablePackages()>0) - .map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList(); + var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules); + if(received.isEmpty())return List.of(); + // 只修改本次投影对象,不在GET更新排车、版本或库存。 + item.setPackageCount(received.get().source().packageCount()); + return received.get().positions(); } /** 仓和操作者均来自受信身份,不接收请求自报。 */ private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 5e157632c..72da6cc6f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -27,6 +27,7 @@ public class DispatchPickingService { @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchService sourceRules; + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet; private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; @@ -101,33 +102,44 @@ public class DispatchPickingService { new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false); commands.append(row.getId(),actor,identity,result);return result; } - /** 原计划自然收货只补齐同一来源实托,数量/客户/目的地变化必须回到平台核对。 */ + /** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */ private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List items) { long dispatch=row.getId(); if(item.getWarehousePalletId()!=null) { relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return; } - if(item.getPlanPalletId()==null)throw conflict("本来源尚未关联真实实托"); - var orders=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); - if(orders.isEmpty())throw conflict("原计划缺少真实订单组成"); - var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); - if(rows.size()!=1)throw conflict("请先完成本来源实际入仓收货");var actual=rows.get(0); - if(actual.warehousePalletId()==null || !Objects.equals(actual.customerId(),item.getCustomerId()) || !Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) - || !Objects.equals(actual.packageCount(),item.getPackageCount()) || !Objects.equals(actual.destinationType(),item.getDestinationType()) - || !Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) || !Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()))throw conflict("原计划实收数量、仓或目的地已变化,请先回平台核对"); - if(!new HashSet<>(orders).containsAll(sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))throw conflict("实托新增了未审核的来源订单"); - item.setWarehousePalletId(actual.warehousePalletId()); + var received=new LinkedHashMap(); + for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules) + .ifPresent(fact->received.put(candidate,fact)); + if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对"); + // 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。 + for(var entry:received.entrySet()) { + entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId()); + entry.getKey().setPackageCount(entry.getValue().source().packageCount()); + } relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); - if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); - var fresh=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); - if(fresh.size()!=1 || !Objects.equals(actual.warehousePalletId(),fresh.get(0).warehousePalletId()) || !Objects.equals(actual.packageCount(),fresh.get(0).packageCount()))throw conflict("原计划实托组成已变化,请回平台核对"); - var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC);sources.stock(item.getCustomerId(),actual.warehousePalletId(),true); - var pallets=new ArrayList();repository.cargo(dispatch).stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).forEach(pallets::add);pallets.add(actual.warehousePalletId()); - var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at).get(actual.warehousePalletId()); - try{if(sources.materialize(dispatch,item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical,at)!=1)throw conflict("原计划来源占用已变化");} - catch(DuplicateKeyException occupied){throw conflict("实托已经被其他来源安排占用");} - item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); - if(picking.materialize(item)!=1)throw conflict("本来源实际实托未保存"); + for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { + if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId()); + if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0)) + || !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId()) + || !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码"); + } + var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC); + var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList(); + var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at); + for(var entry:received.entrySet()) { + var candidate=entry.getKey();var actual=entry.getValue().source(); + try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");} + catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");} + candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED"); + if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存"); + } + row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum())); + var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop( + i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key())) + .collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops); } /** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */ private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java new file mode 100644 index 000000000..d9ed44cda --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java @@ -0,0 +1,55 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType; + +/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */ +final class DispatchReceivedPlan { + record Fact(DispatchPalletCandidate source, List positions) { } + + /** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */ + static Optional resolve(String warehouse, DispatchItem item, List items, + DispatchSourceRepository sources, DispatchService rules) { + if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null + || !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty(); + var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(rows.size()!=1)return Optional.empty(); + var actual=rows.get(0); + if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty(); + var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); + if(original.isEmpty() || !new HashSet<>(original).containsAll( + sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty(); + var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection( + SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); + if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty(); + for(var member:group.sources()) { + var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); + if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty(); + // 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。 + if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null) + && !Objects.equals(Long.valueOf(selected.getPackageCount()),member.packages()))return Optional.empty(); + } + var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); + if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse) + || !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0 + || p.allocatedPackages()!=0 || p.pickedPackages()!=0) + || stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty(); + return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0) + .map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList())); + } + + /** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */ + static boolean sameIdentity(DispatchItem item,DispatchPalletCandidate actual) { + return "RECEIVED".equals(actual.sourceStage()) && actual.warehousePalletId()!=null + && Objects.equals(actual.planPalletId(),item.getPlanPalletId()) + && Objects.equals(actual.customerId(),item.getCustomerId()) + && Objects.equals(actual.overseasWarehouseId(),item.getOverseasWarehouseId()) + && Objects.equals(actual.destinationType(),item.getDestinationType()) + && Objects.equals(actual.fbaWarehouseCode(),item.getFbaWarehouseCode()) + && Objects.equals(actual.deliveryAddress(),item.getDeliveryAddress()); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java new file mode 100644 index 000000000..de58e6d38 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/port/DispatchLoadingPhotoPort.java @@ -0,0 +1,29 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.port; + +import jakarta.validation.constraints.*; +import java.util.List; +import org.springframework.web.multipart.MultipartFile; + +/** 整车装车资料公开端口;排车只组合资料事实,不读取车队持久化实现。 */ +public interface DispatchLoadingPhotoPort { + enum Slot { LOADING_SCENE, LOADING_LIST, PRE_DEPARTURE_CHECK, DRIVER_LICENSE, RECEIPT } + record Photo(Slot slot,String category,String fileId,String fileName,String contentType,String sizeBytes,String uploadedAt,String savedAt) {} + record BindPhoto(@NotBlank String recordVersion,@NotBlank @Size(min=8,max=128) String idempotencyKey,@NotBlank String fileId) {} + record State(String recordVersion,List photos) {} + record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion,String stage,boolean editable,boolean complete,List photos) {} + record Download(Photo photo,byte[] content) {} + /** 只读取原整车附加资料版本与五框,不初始化记录。 */ + State read(long dispatchId); + /** 上传保存原请求意图;不提前计入已保存照片。 */ + Photo upload(long dispatchId,String operator,Slot slot,String key,MultipartFile file); + /** 仅原操作者可恢复原上传结果。 */ + Photo recover(long dispatchId,String operator,String key); + /** 先校验原槽位、完整载荷与操作者;无原成功命令时返回null。 */ + Documents replayBind(long dispatchId,String operator,Slot slot,BindPhoto request); + /** 调用者已持排车父锁;保存照片、资料版本及历史但不单独提交事务。 */ + State bind(long dispatchId,String operator,Slot slot,BindPhoto request); + /** 与绑定及自动发车共同提交完整原结果。 */ + void recordBind(long dispatchId,String operator,Slot slot,BindPhoto request,Documents result); + /** 文件只可从本车当前已绑定槽位下载。 */ + Download content(long dispatchId,Slot slot,long fileId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java deleted file mode 100644 index 7c20036b1..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingDocumentService.java +++ /dev/null @@ -1,179 +0,0 @@ -package com.baiyue.lastleg.fleet.ownedbusiness.service; - -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baiyue.common.core.api.ResultCode; -import com.baiyue.common.core.exception.BizException; -import com.baiyue.lastleg.dispatch.port.DispatchContracts.Actor; -import com.baiyue.lastleg.dispatch.port.DispatchContracts.Context; -import com.baiyue.lastleg.dispatch.port.DispatchTypes.*; -import com.baiyue.lastleg.dispatch.port.DispatchTypes.Stage; -import com.baiyue.lastleg.dispatch.service.DispatchAggregateService; -import com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService; -import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.*; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; -import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.*; -import org.springframework.web.multipart.MultipartFile; -import java.io.IOException; -import java.time.Instant; -import java.util.*; -import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; - -/** Admin与绑定仓PDA共用原整车五框照片;不复制派送单或文件引用。 */ -@Service -public class DispatchLoadingDocumentService { - public static final List SLOTS=List.of(PhotoSlot.LOADING_SCENE,PhotoSlot.LOADING_LIST, - PhotoSlot.PRE_DEPARTURE_CHECK,PhotoSlot.DRIVER_LICENSE,PhotoSlot.RECEIPT); - private final DispatchAggregateService workflow; - private final DispatchLoadingDepartureService departure; - private final OwnedBusinessRepository repository; - private final OwnedBusinessCommandService commands; - private final OwnedBusinessProjection projection; - private final OwnedBusinessSupport codec; - private final OwnedBusinessFileGateway files; - /** 复用现有附加表、文件归属校验和上传意图账本。 */ - public DispatchLoadingDocumentService(DispatchAggregateService workflow,DispatchLoadingDepartureService departure, - OwnedBusinessRepository repository,OwnedBusinessCommandService commands,OwnedBusinessProjection projection, - OwnedBusinessSupport codec,OwnedBusinessFileGateway files) { - this.workflow=workflow;this.departure=departure;this.repository=repository;this.commands=commands; - this.projection=projection;this.codec=codec;this.files=files; - } - public record Documents(String dispatchNo,String recordVersion,String dispatchRecordVersion, - String stage,boolean editable,boolean complete,List photos) {} - public record Binding(PhotoSlot slot,BindPhoto request) {} - public record Download(Photo photo,byte[] content) {} - - /** GET不写入照片或推进阶段;PDA每次重验冻结仓。 */ - @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) - public Documents get(Actor actor,String number) { - require(actor,false);var context=context(actor,number,false); - return documents(context,repository.record(context.dispatchId()),canWrite(actor)); - } - - /** 先落稳定上传意图,再调用文件服务,未知结果不能换键重传。 */ - public Photo upload(Actor actor,String number,PhotoSlot slot,String key,MultipartFile multipart) { - require(actor,true);slot(slot);codec.key(key);var context=context(actor,number,false); - if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE) - throw bad("照片须为非空JPG/PNG,最大10MiB"); - byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} - var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); - HttpOwnedBusinessFileGateway.validate(payload); - var identity=new OwnedBusinessPhotoService.UploadIdentity(slot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); - var reservation=commands.reserveUpload(context.dispatchId(),operator(actor),key,identity); - if(!reservation.created())return uploaded(reservation.command()); - try { - var result=photo(slot,files.upload(context.dispatchId(),slot,actor.operatorId(),payload)); - commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return result; - } catch(RuntimeException failure) { - try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");} - catch(RuntimeException recording){failure.addSuppressed(recording);} - throw conflict("照片上传结果未知,请保留原请求键核对"); - } - } - - /** 原上传恢复只允许原操作者和本车装车槽位。 */ - public Photo recover(Actor actor,String number,String key) { - require(actor,true);codec.key(key);var context=context(actor,number,false); - var command=commands.existing(context.dispatchId(),key,operator(actor)); - if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求"); - return uploaded(command); - } - - /** 锁顺序为车次、排车、附加行;第五张绑定与派送发车同事务成功或回滚。 */ - @Transactional(isolation=Isolation.READ_COMMITTED) - public Documents bind(Actor actor,String number,PhotoSlot slot,BindPhoto request) { - require(actor,true);slot(slot);if(request==null)throw bad("照片绑定请求缺失"); - codec.key(request.idempotencyKey());long version=codec.version(request.recordVersion()),fileId=codec.version(request.fileId()); - if(fileId<=0)throw bad("文件编号无效"); - var context=context(actor,number,true);var operator=operator(actor);var identity=new Binding(slot,request); - var old=commands.existing(context.dispatchId(),request.idempotencyKey(),operator); - if(old!=null) { - commands.same(old,"BIND_LOADING_DOCUMENT",version,identity); - if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认"); - return codec.read(old.getResultJson(),Documents.class); - } - // 文件必须属于本排车与指定槽位,不能拿其他车或签收资料充数。 - var verified=photo(slot,files.metadata(context.dispatchId(),slot,fileId)); - var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(context.dispatchId()); - seed.setCreatedBy(actor.operatorId());seed.setUpdatedBy(actor.operatorId());seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); - var row=repository.lock(seed); - if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); - var photos=new EnumMap(PhotoSlot.class); - for(var value:projection.photos(row))photos.put(value.slot(),value); - photos.put(slot,new Photo(slot,slot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); - row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1)); - row.setUpdatedBy(actor.operatorId());row.setUpdatedAt(codec.utc(Instant.now())); - if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); - var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(context.dispatchId()); - history.setRecordVersion(row.getRecordVersion());history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(actor.operatorId()); - history.setReason("保存装车照片 "+(SLOTS.indexOf(slot)+1));history.setOccurredAt(row.getUpdatedAt()); - history.setSnapshotJson(codec.write(new Snapshot(projection.operations(row),projection.photos(row),actor.operatorId())));repository.history(history); - if(context.stage()==Stage.AFTER_LOADING && complete(List.copyOf(photos.values()))) { - departure.departAfterDocuments(actor,number,request.idempotencyKey()); - context=workflow.preview(actor,number); - } - var result=documents(context,row,true); - commands.success(context.dispatchId(),operator,request.idempotencyKey(),"BIND_LOADING_DOCUMENT",version,identity,result); - return result; - } - - /** 预览必须引用当前本车已绑定文件,下载不会暴露未保存上传。 */ - @Transactional(readOnly=true) - public Download content(Actor actor,String number,PhotoSlot slot,long fileId) { - require(actor,false);slot(slot);var context=context(actor,number,false); - var photo=projection.photos(repository.record(context.dispatchId())).stream() - .filter(p->p.slot()==slot && String.valueOf(fileId).equals(p.fileId())).findFirst() - .orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片")); - return new Download(photo,files.content(context.dispatchId(),slot,fileId)); - } - - /** 阶段与真实装载证据均须存在,PDA仓隔离由主流程统一复核。 */ - private Context context(Actor actor,String number,boolean lock) { - var value=lock?workflow.lockForArrangement(actor,number):workflow.preview(actor,number); - if(value.status()!=Status.ACTIVE || !Set.of(Stage.AFTER_LOADING,Stage.DELIVERING,Stage.DELIVERED).contains(value.stage()) - || value.loadingCompletedAt()==null || value.loadingCompletionHash()==null)throw conflict("装车完成后才能管理装车资料"); - return value; - } - /** 返回当前共享附加版本,五个固定框各有一份已保存图片才算齐全。 */ - private Documents documents(Context context,OwnedBusinessRows.Record row,boolean editable) { - var photos=projection.photos(row).stream().filter(p->SLOTS.contains(p.slot())).toList(); - return new Documents(context.dispatchNo(),row==null?"0":row.getRecordVersion().toString(),Long.toString(context.recordVersion()), - context.stage().name(),editable,complete(photos),photos); - } - /** 不按总张数判断,重复槽位和空文件引用不能满足五框。 */ - static boolean complete(List photos) { - return SLOTS.stream().allMatch(slot->photos.stream().anyMatch(p->p.slot()==slot && p.fileId()!=null && !p.fileId().isBlank())); - } - /** 平台读写权限分离,PDA沿既有仓作业身份授权。 */ - private void require(Actor actor,boolean write) { - if(actor==null || actor.operatorId()==null || !allowed(actor,write))throw new BizException(ResultCode.FORBIDDEN,"无装车资料操作权限"); - } - /** 上传按钮只反映当前主体的实际写权限。 */ - private boolean canWrite(Actor actor){return allowed(actor,true);} - /** 权限直接读取当前会话,不给PDA补造平台权限。 */ - private boolean allowed(Actor actor,boolean write) { - var auth=SecurityContextHolder.getContext().getAuthentication();if(auth==null || actor==null)return false; - var authorities=auth.getAuthorities().stream().map(a->a.getAuthority()).toList(); - return actor.mode()==ActorMode.PDA?actor.boundWarehouseId()!=null && authorities.contains("lastleg:pda:operate") - && authorities.contains("ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR") - :authorities.contains("platform:overseas-warehouse:dispatch:"+(write?"edit":"list")); - } - /** 账本只消费可信操作者,权限由本服务独立校验。 */ - private OwnedBusinessModels.Actor operator(Actor actor){return new OwnedBusinessModels.Actor(actor.operatorId(),actor.operatorId(),Set.of());} - /** 油耗照片不能替代装车五框。 */ - private void slot(PhotoSlot slot){if(!SLOTS.contains(slot))throw bad("请选择有效的装车照片框");} - /** 恢复回执仍须属于上传动作及装车五框。 */ - private Photo uploaded(OwnedBusinessRows.Command command) { - if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null) - throw conflict("原上传结果尚未确认,请保留原请求"); - var result=codec.read(command.getResultJson(),Photo.class);slot(result.slot());return result; - } - /** 上传回执不标记已绑定,实际保存时间只在绑定事务填写。 */ - private Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) { - return new Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java new file mode 100644 index 000000000..9712823d3 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/ownedbusiness/service/DispatchLoadingPhotoAdapter.java @@ -0,0 +1,124 @@ +package com.baiyue.lastleg.fleet.ownedbusiness.service; + +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.fleet.ownedbusiness.gateway.*; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessRows; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort; +import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.IOException; +import java.time.Instant; +import java.util.*; +import static com.baiyue.lastleg.fleet.ownedbusiness.service.OwnedBusinessSupport.*; + +/** 在原照片表和命令账本实现装车资料端口,不反向调用排车履约服务。 */ +@Service +public class DispatchLoadingPhotoAdapter implements DispatchLoadingPhotoPort { + private final OwnedBusinessRepository repository; + private final OwnedBusinessCommandService commands; + private final OwnedBusinessProjection projection; + private final OwnedBusinessSupport codec; + private final OwnedBusinessFileGateway files; + /** 复用现有照片、版本、历史及文件归属校验。 */ + public DispatchLoadingPhotoAdapter(OwnedBusinessRepository repository,OwnedBusinessCommandService commands, + OwnedBusinessProjection projection,OwnedBusinessSupport codec,OwnedBusinessFileGateway files) { + this.repository=repository;this.commands=commands;this.projection=projection;this.codec=codec;this.files=files; + } + public record Binding(Slot slot,BindPhoto request) {} + /** GET只读;首次保存前资料版本为0。 */ + @Override public State read(long id) { + var row=repository.record(id); + return new State(row==null?"0":row.getRecordVersion().toString(),projection.photos(row).stream() + .filter(p->"LOADING".equals(p.category())).map(this::publicPhoto).toList()); + } + /** 先落稳定上传意图,再请求File;未知结果不允许换键重传。 */ + @Override public Photo upload(long id,String operator,Slot slot,String key,MultipartFile multipart) { + codec.key(key);var originalSlot=slot(slot); + if(multipart==null || multipart.isEmpty() || multipart.getSize()>HttpOwnedBusinessFileGateway.MAX_SIZE)throw bad("照片须为非空JPG/PNG,最大10MiB"); + byte[] bytes;try{bytes=multipart.getBytes();}catch(IOException failure){throw bad("无法读取待上传照片");} + var payload=new OwnedBusinessFileGateway.Upload(multipart.getOriginalFilename(),multipart.getContentType(),bytes); + HttpOwnedBusinessFileGateway.validate(payload); + var identity=new OwnedBusinessPhotoService.UploadIdentity(originalSlot,payload.fileName(),payload.contentType(),bytes.length,codec.digest(bytes)); + var reservation=commands.reserveUpload(id,operator(operator),key,identity); + if(!reservation.created())return uploaded(reservation.command()); + try { + var result=photo(originalSlot,files.upload(id,originalSlot,operator,payload)); + commands.finishUpload(reservation.command(),"SUCCEEDED",result,null);return publicPhoto(result); + } catch(RuntimeException failure) { + try{commands.finishUpload(reservation.command(),"UNKNOWN_RESULT",null,"DISPATCH_LOADING_UPLOAD_UNKNOWN");} + catch(RuntimeException recording){failure.addSuppressed(recording);} + throw conflict("照片上传结果未知,请保留原请求键核对"); + } + } + /** 上传结果仅限本车原操作者及装车槽位。 */ + @Override public Photo recover(long id,String operator,String key) { + codec.key(key);var command=commands.existing(id,key,operator(operator)); + if(command==null)throw new BizException(ResultCode.NOT_FOUND,"未找到本车原上传请求");return uploaded(command); + } + /** 完整原结果先于当前资料版本比较,防止重复推进。 */ + @Override public Documents replayBind(long id,String operator,Slot slot,BindPhoto request) { + validate(slot,request);var old=commands.existing(id,request.idempotencyKey(),operator(operator)); + if(old==null)return null; + commands.same(old,"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request)); + if(!"SUCCEEDED".equals(old.getStatus()))throw conflict("原资料保存结果尚未确认");return codec.read(old.getResultJson(),Documents.class); + } + /** 父排车锁由调用者持有;附加版本竞争与最后一张发车失败沿外层事务回滚。 */ + @Override @Transactional(propagation=Propagation.MANDATORY,isolation=Isolation.READ_COMMITTED) + public State bind(long id,String operator,Slot slot,BindPhoto request) { + validate(slot,request);long version=codec.version(request.recordVersion());var originalSlot=slot(slot); + var verified=photo(originalSlot,files.metadata(id,originalSlot,codec.version(request.fileId()))); + var seed=new OwnedBusinessRows.Record();seed.setId(IdWorker.getId());seed.setDispatchId(id); + seed.setCreatedBy(operator);seed.setUpdatedBy(operator);seed.setCreatedAt(codec.utc(Instant.now()));seed.setUpdatedAt(seed.getCreatedAt()); + var row=repository.lock(seed); + if(row==null || row.getRecordVersion()==null || row.getRecordVersion()!=version)throw conflict("装车资料已被其他操作更新,请刷新后核对"); + var photos=new EnumMap(PhotoSlot.class);for(var p:projection.photos(row))photos.put(p.slot(),p); + photos.put(originalSlot,new OwnedBusinessModels.Photo(originalSlot,originalSlot.category(),verified.fileId(),verified.fileName(),verified.contentType(),verified.sizeBytes(),verified.uploadedAt(),Instant.now().toString())); + row.setPhotosJson(codec.write(List.copyOf(photos.values())));row.setRecordVersion(Math.addExact(version,1));row.setUpdatedBy(operator);row.setUpdatedAt(codec.utc(Instant.now())); + if(repository.update(row,version)!=1)throw conflict("装车资料版本竞争失败"); + var history=new OwnedBusinessRows.History();history.setId(IdWorker.getId());history.setDispatchId(id);history.setRecordVersion(row.getRecordVersion()); + history.setAction("BIND_LOADING_DOCUMENT");history.setOperatorId(operator);history.setReason("保存装车照片 "+(slot.ordinal()+1));history.setOccurredAt(row.getUpdatedAt()); + history.setSnapshotJson(codec.write(new OwnedBusinessModels.Snapshot(projection.operations(row),projection.photos(row),operator)));repository.history(history); + return read(id); + } + /** 保存调用者提供的最终阶段,命令与资料、派送同事务提交。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public void recordBind(long id,String operator,Slot slot,BindPhoto request,Documents result) { + commands.success(id,operator(operator),request.idempotencyKey(),"BIND_LOADING_DOCUMENT",codec.version(request.recordVersion()),new Binding(slot,request),result); + } + /** 下载仅允许当前已保存引用,不接受未绑定上传或其他车文件。 */ + @Override public Download content(long id,Slot slot,long fileId) { + var originalSlot=slot(slot);var photo=projection.photos(repository.record(id)).stream() + .filter(p->p.slot()==originalSlot && String.valueOf(fileId).equals(p.fileId())).findFirst() + .orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车未绑定该装车照片")); + return new Download(publicPhoto(photo),files.content(id,originalSlot,fileId)); + } + /** 原五框技术名保持文件归属,拒绝油耗框作为装车资料。 */ + private PhotoSlot slot(Slot slot){if(slot==null)throw bad("请选择有效的装车照片框");return PhotoSlot.valueOf(slot.name());} + /** 版本0合法,文件编号必须为正且请求键不得缺失。 */ + private void validate(Slot slot,BindPhoto request) { + slot(slot);if(request==null)throw bad("照片绑定请求缺失");codec.key(request.idempotencyKey());codec.version(request.recordVersion()); + if(codec.version(request.fileId())<=0)throw bad("文件编号无效"); + } + /** 账本只消费可信操作者;平台/PDA权限在排车入口复核。 */ + private OwnedBusinessModels.Actor operator(String operator){return new OwnedBusinessModels.Actor(operator,operator,Set.of());} + /** 回执必须是已知成功上传,其他类别或未明确结果不得充数。 */ + private Photo uploaded(OwnedBusinessRows.Command command) { + if(!"UPLOAD_PHOTO".equals(command.getAction()) || !"SUCCEEDED".equals(command.getStatus()) || command.getResultJson()==null)throw conflict("原上传结果尚未确认,请保留原请求"); + var value=codec.read(command.getResultJson(),OwnedBusinessModels.Photo.class); + if(!"LOADING".equals(value.category()))throw bad("原上传不是装车照片");return publicPhoto(value); + } + /** 外部事实仅投影照片字段,不暴露持久化记录。 */ + private Photo publicPhoto(OwnedBusinessModels.Photo photo) { + return new Photo(Slot.valueOf(photo.slot().name()),photo.category(),photo.fileId(),photo.fileName(),photo.contentType(),photo.sizeBytes(),photo.uploadedAt(),photo.savedAt()); + } + /** 上传回执不伪造保存时间。 */ + private OwnedBusinessModels.Photo photo(PhotoSlot slot,OwnedBusinessFileGateway.Evidence value) { + return new OwnedBusinessModels.Photo(slot,slot.category(),Long.toString(value.fileId()),value.fileName(),value.contentType(),Long.toString(value.sizeBytes()),value.uploadedAt(),null); + } +} diff --git a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml index 23c86c5b7..0b3087b25 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-loading-documents-v1.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: 排车装车资料(Admin/PDA共用) version: 1.0.0 - description: 装车完成进入AFTER_LOADING。五个整车照片框均已保存时,在最后一次绑定事务内自动生成派送并进入DELIVERING;失败回滚该次绑定和派送,前四张与已确认实载保留。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 + description: LOADING阶段即可同步上传并保存五个整车照片框;照片齐全不会提前发车。装车确认时资料已齐则直接进入DELIVERING,否则进入AFTER_LOADING,最后一次绑定自动生成派送并进入DELIVERING。触发推进的装车确认或照片绑定与派送同事务,失败保留先前已保存资料。复用车队照片记录,技术槽位仅表示照片1至5,不代表驾驶证审核或真实签收。PDA每次核验Token冻结仓,平台list只读、edit写;原键仅原操作者恢复。 security: - bearerAuth: [] paths: @@ -369,6 +369,7 @@ components: stage: type: string enum: + - LOADING - AFTER_LOADING - DELIVERING - DELIVERED diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 95e6679bb..a5739f874 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -1728,8 +1728,8 @@ paths: type: string /api/platform/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: - summary: 确认装车完成并自动进入派送中 - description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。 + summary: 确认装车完成,资料齐全时自动进入派送中 + description: 权限 platform:overseas-warehouse:dispatch:loading-complete。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。 security: - bearerAuth: [] responses: diff --git a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml index bf9062af3..b5a5fc0bf 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-pda-operations-v1.yaml @@ -253,8 +253,8 @@ paths: type: string /api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete: post: - summary: 确认装车完成并自动进入派送中 - description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 原请求保存实载确认;成功action为LOADING_COMPLETE、stage为AFTER_LOADING,result保留真实Loaded装车证据。通过loading-documents保存五张装车照片后自动生成派送并发车,进入DELIVERING;最后一次绑定与派送同事务回滚。重试装车原键保持原AFTER_LOADING结果,当前阶段另行读取。 + summary: 确认装车完成,资料齐全时自动进入派送中 + description: 权限 OVERSEAS_WAREHOUSE_PDA_OPERATOR + lastleg:pda:operate。只用整车recordVersion和全载荷幂等键;扫码执行真实库存SHIP,装车完成和发车不重复SHIP。错误或结果未知保留原请求核对。 装车中可通过loading-documents同步保存五张装车照片。原请求保存实载确认;成功action为LOADING_COMPLETE,资料已齐时stage为DELIVERING,未齐时为AFTER_LOADING,result保留真实Loaded装车证据。装车确认与资料齐全两项均完成时自动生成派送并发车;触发该次推进的装车确认或最后一次绑定与派送同事务回滚,先前已保存照片保留。重试装车原键保持原最终阶段结果,当前阶段另行读取。 security: - bearerAuth: [] responses: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index 36e5391d6..fb9f2e1ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -3,8 +3,9 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.lastleg.dispatch.mapper.*; import com.baiyue.lastleg.fleet.ownedbusiness.service.*; import com.baiyue.lastleg.fleet.ownedbusiness.gateway.OwnedBusinessFileGateway; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.BindPhoto; -import com.baiyue.lastleg.fleet.ownedbusiness.model.OwnedBusinessModels.PhotoSlot; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.BindPhoto; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Documents; +import com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort.Slot; import com.baiyue.lastleg.fleet.ownedbusiness.mapper.*; import com.baiyue.lastleg.fleet.ownedbusiness.repository.OwnedBusinessRepository; import java.nio.file.*; @@ -42,7 +43,7 @@ class DispatchLoadingDeparturePersistenceTest { assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); var fifth=f.bind(4,new Actor("picker",ActorMode.PDA,"9"));assertThat(fifth.stage()).isEqualTo("DELIVERING"); assertThat(fifth.complete()).isTrue();assertThat(fifth.photos()).hasSize(5); - var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,PhotoSlot.RECEIPT,new BindPhoto("4","photo-bind-4","1004"))); + var replayPhoto=f.f.tx.execute(s->f.documents.bind(new Actor("picker",ActorMode.PDA,"9"),f.number,Slot.RECEIPT,new BindPhoto("4","photo-bind-4","1004"))); assertThat(replayPhoto).isEqualTo(fifth); assertThat(result.counts().shippedPackageCount()).isZero();assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class); assertThat(f.f.db.queryForList("SELECT status FROM ll_outbound_order",String.class)).containsExactly("OUTBOUNDED","OUTBOUNDED"); @@ -103,13 +104,16 @@ class DispatchLoadingDeparturePersistenceTest { verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString());verify(f.sourceWorkflow,never()).departWholeSources(any(),any(),any(),any()); } - /** 资料上传必须在装车后且属于绑定仓,平台只读权限不能保存照片。 */ + /** 装车中可上传,装车前与跨仓拒绝,平台只读权限不能保存照片。 */ @Test void loadingDocumentsEnforceStageWarehouseAndWritePermission() throws Exception { authorize();var f=new Fixture(); - assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("装车完成后"); + assertThat(f.documents.get(f.f.actor,f.number).stage()).isEqualTo("LOADING"); + f.f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING'"); + assertThatThrownBy(()->f.documents.get(f.f.actor,f.number)).hasMessageContaining("进入装车中后"); + f.f.db.update("UPDATE ll_dispatch_order SET stage='LOADING'"); f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("complete"))); assertThatThrownBy(()->f.documents.get(new Actor("picker",ActorMode.PDA,"99"),f.number)).hasMessageContaining("当前绑定仓"); - assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,PhotoSlot.FUEL_START,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框"); + assertThatThrownBy(()->f.f.tx.execute(s->f.documents.bind(f.f.actor,f.number,null,new BindPhoto("0","wrong-slot","1000")))).hasMessageContaining("装车照片框"); SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("reader","","platform:overseas-warehouse:dispatch:list")); assertThat(f.documents.get(f.f.actor,f.number).editable()).isFalse(); assertThatThrownBy(()->f.bind(0,f.f.actor)).hasMessageContaining("无装车资料操作权限"); @@ -124,7 +128,7 @@ class DispatchLoadingDeparturePersistenceTest { var results=new ArrayList>(); for(String key:List.of("admin-last","pda-last"))results.add(workers.submit(()->{ authorize();try {start.await(5,TimeUnit.SECONDS);var actor=key.startsWith("pda")?new Actor("picker",ActorMode.PDA,"9"):f.f.actor; - f.f.tx.execute(s->f.documents.bind(actor,f.number,PhotoSlot.RECEIPT,new BindPhoto("4",key,"1004")));return true; + f.f.tx.execute(s->f.documents.bind(actor,f.number,Slot.RECEIPT,new BindPhoto("4",key,"1004")));return true; }catch(com.baiyue.common.core.exception.BizException expected){return false;} finally {SecurityContextHolder.clearContext();} })); @@ -135,6 +139,50 @@ class DispatchLoadingDeparturePersistenceTest { assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING"); } + /** 资料先齐不能提前发车;确认装车后直接派送,原键返回相同最终结果。 */ + @Test void photosReadyBeforeLoadingConfirmationDepartAndReplayOnce() throws Exception { + authorize();var f=new Fixture(); + for(int i=0;i<5;i++)assertThat(f.bind(i,f.f.actor).stage()).isEqualTo("LOADING"); + verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString()); + var result=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-complete"))); + assertThat(result.action()).isEqualTo(Action.LOADING_COMPLETE); + assertThat(result.stage()).isEqualTo(Stage.DELIVERING);assertThat(result.counts().shippedPackageCount()).isEqualTo(8); + assertThat(result.result()).isInstanceOf(DispatchResults.Loaded.class); + var replay=f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("ready-complete"))); + assertThat(replay.replayed()).isTrue();assertThat(replay.stage()).isEqualTo(Stage.DELIVERING); + assertThat(replay.recordVersion()).isEqualTo(result.recordVersion());assertThat(replay.result()).isEqualTo(result.result()); + assertThat(f.f.tx.execute(s->f.f.service.command(f.f.actor,f.number,"ready-complete")).stage()).isEqualTo(Stage.DELIVERING); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString()); + verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + } + + /** 资料先齐时派送失败回滚本次装车确认,五张已保存照片保留供原请求重试。 */ + @Test void readyDocumentsSurviveFailedLoadingAndOriginalRequestCanRetry() throws Exception { + authorize();var f=new Fixture();for(int i=0;i<5;i++)f.bind(i,f.f.actor); + doThrow(new IllegalStateException("模拟发运失败")).when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any()); + assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("retry-ready")))).hasMessageContaining("模拟发运失败"); + assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("LOADING"); + assertThat(f.documents.get(f.f.actor,f.number).complete()).isTrue(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_delivery_order",Long.class)).isZero(); + assertThat(f.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='retry-ready'",Long.class)).isZero(); + doNothing().when(f.sourceWorkflow).departWholeSources(any(),any(),any(),any()); + assertThat(f.f.tx.execute(s->f.service.complete(f.f.actor,f.number,f.request("retry-ready"))).stage()).isEqualTo(Stage.DELIVERING); + } + + /** 最后一张照片与装车确认同时提交,不论谁先拿父锁均只发车一次。 */ + @Test void lastPhotoAndLoadingConfirmationCanFinishConcurrently() throws Exception { + authorize();var f=new Fixture();for(int i=0;i<4;i++)f.bind(i,f.f.actor); + var start=new CountDownLatch(1);var workers=Executors.newFixedThreadPool(2); + try { + var load=workers.submit(()->f.tryComplete(start,f.f.actor,"concurrent-load")); + var photo=workers.submit(()->{authorize();try {start.await(5,TimeUnit.SECONDS);return f.bind(4,new Actor("picker",ActorMode.PDA,"9"));}finally{SecurityContextHolder.clearContext();}}); + start.countDown();assertThat(load.get(25,TimeUnit.SECONDS)).isTrue();assertThat(photo.get(25,TimeUnit.SECONDS)).isNotNull(); + } finally {workers.shutdownNow();} + assertThat(f.f.repository.find(f.number).getStage()).isEqualTo("DELIVERING"); + verify(f.delivery,times(1)).prepareForDeparture(any(),any(),anyString()); + verify(f.sourceWorkflow,times(1)).departWholeSources(any(),any(),any(),any()); + } + /** 配置资料与装车权限及PDA既有角色,不授予人工派送、发车权限。 */ private static void authorize(){SecurityContextHolder.getContext().setAuthentication(new TestingAuthenticationToken("operator","", "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR"));} @@ -176,7 +224,6 @@ class DispatchLoadingDeparturePersistenceTest { var hashes=new DispatchCommandHasher(f.json); var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); var departure=new DispatchDepartureService(f.service,f.repository,sources,delivery,sourceWorkflow,f.fleet,ledger,hashes,f.json); - service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes); f.session.getConfiguration().addMapper(OwnedBusinessMapper.class); String schema=Files.readString(Path.of("src/main/resources/db/lastleg.sql")); for(String suffix:List.of("record","command","history")) { @@ -187,10 +234,12 @@ class DispatchLoadingDeparturePersistenceTest { var records=new OwnedBusinessRepository(f.session.getMapper(OwnedBusinessMapper.class),mock(OwnedBusinessQueryMapper.class)); var codec=new OwnedBusinessSupport(f.json);var files=mock(OwnedBusinessFileGateway.class); when(files.metadata(anyLong(),any(),anyLong())).thenAnswer(call->new OwnedBusinessFileGateway.Evidence(call.getArgument(2),"loading.png","image/png",12,"2026-10-21T00:00:00Z")); - documents=new DispatchLoadingDocumentService(f.service,service,records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); + var photos=new DispatchLoadingPhotoAdapter(records,new OwnedBusinessCommandService(records,codec),new OwnedBusinessProjection(codec),codec,files); + service=new DispatchLoadingDepartureService(f.service,delivery,departure,ledger,hashes,photos); + documents=new DispatchLoadingDocumentService(f.service,service,photos); } /** 每张照片保留独立版本与幂等键,复用真实附加记录保存事务。 */ - DispatchLoadingDocumentService.Documents bind(int index,Actor actor) { + Documents bind(int index,Actor actor) { return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index)))); } /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 05963953b..44a1c4eab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -23,6 +23,34 @@ import static org.mockito.Mockito.*; /** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ class DispatchPickingPersistenceTest { + /** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */ + @Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception { + var fixture=new Picking(true);fixture.receivedPlans(9,11); + var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null); + var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest)); + assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11); + assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20); + assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED"); + var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest)); + assertThat(replay.replayed()).isTrue(); + var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem, + new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null))); + assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); + verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any()); + assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); + } + + /** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */ + @Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception { + var fixture=new Picking(true);fixture.receivedPlans(8,11); + doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any()); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, + new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class); + assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); + assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero(); + assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20); + assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version); + } /** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */ @Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception { var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码"); @@ -68,6 +96,7 @@ class DispatchPickingPersistenceTest { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class); final DispatchPickingService service;final String number;final long item,version;final Long secondItem; + final DispatchSourceRepository sources; final DispatchContracts.Actor actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); /** 建立真实客户来源、作业表、扫码表与可以参与回滚的库存行。 */ Picking() throws Exception {this(false);} @@ -94,10 +123,32 @@ class DispatchPickingPersistenceTest { doAnswer(call->{com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests.Move move=call.getArgument(2);f.db.update("UPDATE ll_inventory_position SET available_packages=available_packages-?,version=version+1 WHERE id=? AND customer_id=?",move.packages(),move.positionId(),call.getArgument(0));return null;}).when(inventory).ship(anyString(),eq("picker"),any()); doAnswer(call->{String customer=call.getArgument(0);DispatchPdaPick pick=call.getArgument(1);long order="101".equals(customer)?9001L:9002L;long receipt="101".equals(customer)?1L:2L;f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity)VALUES(?,?,?,?,?,?,?)",pick.getId(),customer,pick.getId(),order,receipt,pick.getPackages(),pick.getProductQuantity());return List.of(new DispatchPickOrderAttributionService.OrderPackages(order,pick.getPackages()));}) .when(attribution).attribute(anyString(),any(DispatchPdaPick.class),isNull()); - var sources=new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class)); + sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class))); var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json); for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); + org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet); + } + /** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */ + void receivedPlans(int first,int second) { + f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN',package_count=10"); + f.db.update("UPDATE ll_dispatch_order SET package_count=20"); + var rules=(DispatchService)org.springframework.test.util.ReflectionTestUtils.getField(service,"sourceRules"); + for(int n=1;n<=2;n++) { + long plan=n,pallet=500+n;String customer=n==1?"101":"102";int count=n==1?first:second; + f.candidates.get(plan).packageCount(count); + f.db.update("UPDATE ll_inventory_position SET available_packages=? WHERE id=?",count,10+n); + doReturn(List.of(f.candidates.get(plan))).when(sources).selectedPlan(customer,plan); + doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,null,plan); + doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,pallet,plan); + doReturn(plan).when(sources).lockPlan(customer,plan); + doReturn(1).when(sources).materialize(anyLong(),eq(customer),eq(plan),eq(pallet),anyLong(),any()); + } + when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{ + var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId();var row=f.candidates.get(plan); + return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( + selected,"PLAN:"+plan,row.customerId(),row.customerName(),"INBOUND_PLAN","PLAN-"+plan,row.packageCount())),List.of(),true,List.of()); + }); } /** 使用实际实体属性建最小测试表,不修改或声称验证正式DDL。 */ private static String sqlType(Class type){return type==Long.class?"BIGINT":type==Integer.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":"VARCHAR(20000)";} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index 12932f0d1..acb329134 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -28,15 +28,23 @@ class DispatchPlanPdaReadPersistenceTest { verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any()); verify(f.rules,never()).lockExecutionSources(anyString(),anyList()); } - /** 在途、预留托盘和实收数量漂移均不成为可拣库存。 */ + /** 在途和来源身份变化均不成为可拣库存。 */ @Test void unreceivedOrChangedPlanHasNoPositions() throws Exception { var f=new Fixture();var row=f.f.candidates.get(1L); row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty(); - row.sourceStage("RECEIVED");row.packageCount(2);assertThat(f.read().items().get(0).positions()).isEmpty(); - row.packageCount(3);row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty(); + row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty(); row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty(); verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); } + /** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */ + @Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception { + var f=new Fixture(true);var before=f.dbState(); + var task=f.read(); + assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L); + assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty()); + assertThat(task.counts().packageCount()).isEqualTo(20); + assertThat(f.dbState()).isEqualTo(before); + } /** 原计划不能吸收新的订单或未经原车审核的同托来源。 */ @Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception { var f=new Fixture(); @@ -65,9 +73,13 @@ class DispatchPlanPdaReadPersistenceTest { final DispatchService rules=mock(DispatchService.class); final DispatchPdaQueryService queries;final String number; /** 原车已审核,只清除自然收货尚未补写的实托字段。 */ - Fixture() throws Exception { - var selection=f.source(1,"101",501,"ONT8",3); - var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",List.of(selection)))); + Fixture() throws Exception {this(false);} + /** 同一客户两托先预排再实收,模拟计划拆分与真实入托数量不同。 */ + Fixture(boolean twoPallets) throws Exception { + var selection=f.source(1,"101",501,"ONT8",twoPallets?10:3); + var selections=new java.util.ArrayList();selections.add(selection); + if(twoPallets)selections.add(f.source(2,"101",502,"ONT8",10)); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("plan-pda-read",selections))); f.approve(created);number=created.dispatchNo(); f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=NULL,source_stage='PLAN'"); f.db.execute("CREATE TABLE ll_inventory_position(id BIGINT,customer_id VARCHAR(64),balance_id BIGINT,pallet_id BIGINT,overseas_warehouse_id VARCHAR(64),available_packages BIGINT,hold_packages BIGINT,allocated_packages BIGINT,picked_packages BIGINT)"); @@ -82,6 +94,21 @@ class DispatchPlanPdaReadPersistenceTest { when(rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( selection,"PLAN:1","101","测试客户","INBOUND_PLAN","PLAN-1",3)),List.of(),true,List.of())); + if(twoPallets) { + for(int n=1;n<=2;n++) { + long plan=n,pallet=500+n;int count=n==1?9:11; + f.candidates.get(plan).packageCount(count); + when(sources.selectedPlan("101",plan)).thenReturn(List.of(f.candidates.get(plan))); + when(sources.sourceOrders("101",null,plan)).thenReturn(List.of(9000+plan)); + when(sources.sourceOrders("101",pallet,plan)).thenReturn(List.of(9000+plan)); + when(sources.stock("101",pallet,false)).thenReturn(List.of(new Stock(10+n,20+n,pallet,"9",count,0,0,0,1))); + } + when(rules.selectionGroup(anyString(),any())).thenAnswer(call->{ + var selected=(DispatchCommands.CargoSelection)call.getArgument(1);long plan=selected.planPalletId(); + return new DispatchPlanningModels.SelectionGroup("group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( + selected,"PLAN:"+plan,"101","测试客户","INBOUND_PLAN","PLAN-"+plan,plan==1?9:11)),List.of(),true,List.of()); + }); + } } /** 读取正式PDA入口。 */ DispatchViews.PdaTask read(){return f.tx.execute(s->queries.task(new DispatchContracts.Actor("picker",ActorMode.PDA,"9"),number));} diff --git a/scripts/agents-index-contract.ps1 b/scripts/agents-index-contract.ps1 index 3aee9acaa..91b9cac8e 100644 --- a/scripts/agents-index-contract.ps1 +++ b/scripts/agents-index-contract.ps1 @@ -157,6 +157,10 @@ $adminIndex = Read-RequiredText $adminIndexPath $adminAgents = $adminEntry + "`n" + $adminIndex $adminOverseasAgentsPath = Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/AGENTS.md' $adminOverseasAgents = Read-RequiredText $adminOverseasAgentsPath +# 页面细则归入既有业务正文;只读取对应子节,保留原有字段和状态断言。 +$adminInboundPageRules = [regex]::Match($businessRules, '(?ms)^### Admin 入仓计划页面约束\r?\n(?.*?)(?=^#{1,3} |\z)').Groups['body'].Value +$checked++ +if (-not $adminInboundPageRules) { Add-Failure '缺少 Admin 入仓计划页面约束正文' } Assert-Contains $adminAgents 'src/modules/overseas-warehouse/AGENTS.md' 'Admin 海外仓按需指引入口' Assert-MarkdownTableUnique $adminOverseasAgentsPath '## 页面与业务快速索引' 0 'Admin 海外仓模块表' $clientEntry = Read-RequiredText $clientAgentsPath @@ -245,7 +249,8 @@ $lookupSamples = @( $lookupCharacters = 0 foreach ($alias in @( @{ Term = '转运打托'; Entry = 'apps/pda-overseas-uniapp/src/pages/warehouse-transfer/warehouse-transfer.vue' }, - @{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, + @{ Term = '转运通过'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, + @{ Term = '转运驳回'; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' }, @{ Term = '退税资料'; Entry = 'apps/client-web/src/modules/first-leg/components/CustomsDocumentsDialog.vue' }, @{ Term = '订单审核'; Entry = 'apps/admin-web/src/modules/unified-orders/' }, @{ Term = 'PDA 出库'; Entry = 'apps/pda-overseas-uniapp/src/pages/dispatch/dispatch.vue' }, @@ -291,7 +296,8 @@ foreach ($document in @( @{ Name = '业务导航'; Path = $businessIndexPath; Text = $businessIndex; Limit = 12500 }, @{ Name = '文档总入口'; Path = (Join-Path $workspaceRoot 'docs/README.md'); Text = $docsIndex; Limit = 3500 }, @{ Name = '海外仓文档入口'; Path = $overseasDocEntryPath; Text = $overseasDocEntry; Limit = 2000 }, - @{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 } + @{ Name = '财务文档入口'; Path = $financeDocEntryPath; Text = $financeDocEntry; Limit = 1800 }, + @{ Name = 'Admin 海外仓指引'; Path = $adminOverseasAgentsPath; Text = $adminOverseasAgents; Limit = 8000 } )) { Assert-MarkdownLinks $document.Path $document.Text $document.Name $checked++ @@ -329,10 +335,43 @@ Assert-NotContains $financeDocEntry '钱包不做' '财务入口不混入历史 Assert-Contains $financeP1 '首批固定为 CNY' '资金账户币种规则迁入现行正文' Assert-Contains $financeP1 '充值先归还已用授信或负余额' '资金账户充值规则迁入现行正文' Assert-Contains $financeP1 '新账户默认授信为 `0.00`' '资金账户默认授信规则迁入现行正文' +# 入口存在不足以证明动作存在:核对弹框实际调用与 API 结论,避免将接口能力当成按钮。 +$transferReviewUi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue') +$transferReviewApi = Read-RequiredText (Join-Path $workspaceRoot 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts') +$transferUiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 仓储转运审核、审核通过 |') }) +$transferApiRow = @($adminOverseasAgents -split '\r?\n' | Where-Object { $_.StartsWith('| 转运驳回、REJECT 接口能力 |') }) +$checked++ +if ($transferUiRow.Count -ne 1 -or $transferApiRow.Count -ne 1) { + Add-Failure '转运审核的界面入口与 API 能力必须分别定位' +} else { + Assert-Contains $transferUiRow[0] 'components/WarehouseTransferReviewDialog.vue' '转运通过界面入口' + Assert-Contains $transferUiRow[0] 'APPROVE' '转运通过界面结论' + Assert-Contains $transferApiRow[0] 'api/warehouseTransfers.ts' '转运驳回 API 入口' + Assert-Contains $transferApiRow[0] 'REJECT' '转运 API 驳回结论' + Assert-Matches $transferReviewUi 'reviewWarehouseTransfer\([^)]*,\s*[''"]APPROVE[''"](?:\s*,[^)]*)?\s*\)' '转运通过真实调用' + Assert-Matches $transferReviewApi 'decision\s*:\s*[''"]APPROVE[''"]\s*\|\s*[''"]REJECT[''"]' '转运 API 允许的结论' + if ($transferReviewUi -match 'reviewWarehouseTransfer\([^)]*,\s*[''"]REJECT[''"]') { + Assert-NotContains $transferUiRow[0] '无驳回按钮' '转运界面能力变化后更新指引' + Assert-Contains $transferUiRow[0] 'REJECT' '转运界面新增驳回后更新指引' + } else { + Assert-Contains $transferUiRow[0] '无驳回按钮' '转运界面不能虚构驳回动作' + Assert-NotContains $transferUiRow[0] 'REJECT' '转运界面与 API 能力分开' + } +} +Assert-Contains $adminOverseasAgents 'baiyue-firstleg/print/AGENTS.md' '托盘模板指向 Print 权威入口' +Assert-NotContains $adminOverseasAgents 'INBOUND_MARK_V1' 'Admin 不复制箱唛模板为托盘模板' +$adminOverseasVerification = [regex]::Match($adminOverseasAgents, '(?ms)^## 验证\r?\n(?.*?)(?=^## |\z)').Groups['body'].Value +$checked++ +if (-not $adminOverseasVerification -or ($adminOverseasVerification -replace '\r', '').Length -gt 800) { + Add-Failure 'Admin 海外仓验证节须存在且不超过800字符,业务正文应归回按需条款' +} + $docLookupCharacters = 0 $walletRuleTarget = '财务管理/财务对账结算P1闭环.md#客户资金账户' $docLookupSamples = @( @{ Key = '客户头程订单批量导入'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/first-leg/AGENTS.md' }, + @{ Key = '仓储转运审核、转运通过'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/components/WarehouseTransferReviewDialog.vue' }, + @{ Key = '转运驳回、REJECT'; Text = $businessIndex; Column = 2; Entry = 'apps/admin-web/src/modules/overseas-warehouse/api/warehouseTransfers.ts' }, @{ Key = '平台资金账户、充值审核、手工充扣与授信'; Text = $businessIndex; Column = 1; Entry = 'apps/admin-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget }, @{ Key = '客户资金账户、余额、授信与充值申请'; Text = $businessIndex; Column = 1; Entry = 'apps/client-web/src/modules/finance/AGENTS.md'; Rules = $walletRuleTarget }, @{ Key = '服务端资金账户、授信、冻结、结算与业务扣费'; Text = $businessIndex; Column = 1; Entry = 'baiyue-firstleg/finance/AGENTS.md'; Rules = $walletRuleTarget }, @@ -723,7 +762,7 @@ Assert-Contains $lastlegOrderCommandService '必须提交完整8行人工时间 Assert-Contains $lastlegOrderCommandService '尾程订单已进入出库流程,不能新增暂扣' '尾程暂扣出库门禁实现' Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/hold' 'Admin 尾程暂扣入口' Assert-Contains $adminLastMileOrders '/platform/v1/last-leg/orders/${encodeURIComponent(orderId)}/manual-nodes' 'Admin 尾程人工节点入口' -Assert-Contains $adminOverseasAgents '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界' +Assert-Contains $adminInboundPageRules '入仓码只读展示并提交订单冻结值' 'Admin 尾程计划冻结入仓码边界' Assert-Contains $lastlegAgents '接柜仓编码/名称以该 ID 当前目录快照为准' 'Lastleg 稳定接柜仓身份边界' Assert-Contains $lastlegOrderCommandService '同一幂等键不能用于不同请求' '尾程命令请求哈希重放边界' Assert-Contains $loadingListsOpenApi '且真实集装箱号已经补录' '确认预装真实柜号门禁' @@ -1069,16 +1108,16 @@ Assert-Contains $lastlegBusinessOpenApi '/api/platform/v1/last-leg/inbound-plans Assert-Contains $lastlegBusinessOpenApi 'snapshotVersion' 'Lastleg 整柜计划快照版本' Assert-Contains $lastlegBusinessOpenApi 'snapshotHash' 'Lastleg 整柜计划快照哈希' Assert-Contains $lastlegBusinessOpenApi 'sourceContainerBookingId' 'Lastleg 稳定来源柜身份' -Assert-Contains $adminOverseasAgents '/orders/container-summaries' 'Admin 柜级摘要目标入口' +Assert-Contains $adminInboundPageRules '/orders/container-summaries' 'Admin 柜级摘要目标入口' Assert-Contains $businessFacts '尾程订单控制与柜级入仓计划转换' '根柜级计划入口' Assert-Contains $backendAgents '分页返回全部稳定来源柜' '后端全柜工作台范围' -Assert-Contains $adminOverseasAgents '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围' +Assert-Contains $adminInboundPageRules '服务端分页返回全部稳定来源柜' 'Admin 全柜工作台范围' Assert-Contains $lastlegAgents '分页返回全部稳定来源柜' 'Lastleg 全柜工作台范围' Assert-Contains $lastlegBusinessOpenApi '返回全部稳定来源柜' 'Lastleg 全柜工作台 OpenAPI' -Assert-Contains $adminOverseasAgents 'ACTIVE + FBA' 'Admin FBA 权威仓候选' -Assert-Contains $adminOverseasAgents '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' -Assert-Contains $adminOverseasAgents '/inbound-plans/container-preview' 'Admin 整柜预览目标入口' -Assert-Contains $adminOverseasAgents '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界' +Assert-Contains $adminInboundPageRules 'ACTIVE + FBA' 'Admin FBA 权威仓候选' +Assert-Contains $adminInboundPageRules '/inbound-plans/convert-container' 'Admin 整柜转换目标入口' +Assert-Contains $adminInboundPageRules '/inbound-plans/container-preview' 'Admin 整柜预览目标入口' +Assert-Contains $adminInboundPageRules '同一 `snapshotVersion/snapshotHash` 原样提交' 'Admin 整柜快照确认边界' Assert-Contains $adminLastMileApi '/orders/container-summaries' 'Admin 柜级摘要真实调用' Assert-Contains $adminLastMileApi '/platform/v1/last-leg/inbound-plans/container-preview' 'Admin 柜级预览真实调用' Assert-NotContains $adminLastMileApi '/orders/pending-containers' 'Admin 旧柜级摘要调用' From 1ea7b4ff12c13736bf82c0bf37e0e1291b6bb959 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 17:26:41 +0800 Subject: [PATCH 066/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...astleg-internal-modularization-baseline.md | 2 +- .../analyze-lastleg-dependencies.test.mjs | 1 + .../dispatch/mapper/DispatchSourceMapper.java | 5 ++++ .../repository/DispatchSourceRepository.java | 2 ++ .../service/DispatchReceivedPlan.java | 3 ++- .../DispatchPickingPersistenceTest.java | 11 ++++++++ .../DispatchPlanPdaReadPersistenceTest.java | 26 ++++++++++++++++++- 7 files changed, 47 insertions(+), 3 deletions(-) diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 9d7fc7509..6a0a01299 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -30,7 +30,7 @@ ### 装车资料触发派送边界(2026-10-21) -`DispatchLoadingDocumentService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,调用端口保存照片后在同一事务推进派送并记录原结果。结构门禁仅开放上述三类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。 +`DispatchLoadingDocumentService`、`DispatchLoadingDepartureService`及平台/PDA两个装车资料Controller只引用`fleet.ownedbusiness.port.DispatchLoadingPhotoPort`的公开照片、版本、绑定请求和结果。`DispatchLoadingPhotoAdapter`在车队内部复用原附加表、上传账本和文件校验,不反向调用排车服务。排车服务持整车父锁,在装车确认或资料保存后核对两项条件,同一事务推进派送并记录原结果。结构门禁仅开放上述四类到此端口的精确引用,不开放车队Service、Repository或Mapper,也不新增包循环。 ### 外配业务原排车详情读取边界(2026-10-21) diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index db76e8b93..b8baaae29 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -73,6 +73,7 @@ test('fleet stays a business slice and its policies have no infrastructure depen || edge.source === 'com.baiyue.lastleg.dispatch.service.DispatchAggregateService' && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.OwnedBusinessOwnershipCapture' || ['com.baiyue.lastleg.dispatch.service.DispatchLoadingDocumentService', + 'com.baiyue.lastleg.dispatch.service.DispatchLoadingDepartureService', 'com.baiyue.lastleg.dispatch.controller.DispatchLoadingDocumentController', 'com.baiyue.lastleg.dispatch.pda.controller.PdaDispatchLoadingDocumentController'].includes(edge.source) && edge.target === 'com.baiyue.lastleg.fleet.ownedbusiness.port.DispatchLoadingPhotoPort' diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index b88eb00dd..80fff1894 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -93,6 +93,11 @@ public interface DispatchSourceMapper { @Select("SELECT p.id FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id WHERE p.id=#{id} AND ip.customer_id=#{customer} FOR UPDATE") Long lockPlanPallet(@Param("customer") String customer, @Param("id") Long id); + /** 原计划完成实收后才能把预排估算替换为实际整托数量,避免收货中提前锁定半托。 */ + @Select("SELECT COUNT(*) FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id " + + "WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status IN ('COMPLETED','PENDING_RECONCILIATION')") + int receivedPlanCompleted(@Param("customer") String customer,@Param("id") Long id); + /** 退回来源仍使用本票实际实收,不借普通入仓别名。 */ @Select("SELECT id FROM ll_return_stock_receipt WHERE id=#{id} AND customer_id=#{customer} FOR UPDATE") Long lockReturn(@Param("customer") String customer, @Param("id") Long id); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index 3009cb008..bf2d59126 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -74,6 +74,8 @@ public class DispatchSourceRepository { public Long lockOrder(String customer, Long id) { return source.lockOrder(customer,id); } /** 锁定真实计划托盘根。 */ public Long lockPlan(String customer, Long id) { return source.lockPlanPallet(customer,id); } + /** 只读核对原计划已经结束实际收货。 */ + public boolean receivedPlanCompleted(String customer,Long id){return source.receivedPlanCompleted(customer,id)==1;} /** 锁定实际退回收据根。 */ public Long lockReturn(String customer, Long id) { return source.lockReturn(customer,id); } /** 真实库存数量及版本。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java index d9ed44cda..2eb896b59 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java @@ -16,6 +16,7 @@ final class DispatchReceivedPlan { DispatchSourceRepository sources, DispatchService rules) { if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null || !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty(); + if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty(); var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); if(rows.size()!=1)return Optional.empty(); var actual=rows.get(0); @@ -31,7 +32,7 @@ final class DispatchReceivedPlan { if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty(); // 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。 if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null) - && !Objects.equals(Long.valueOf(selected.getPackageCount()),member.packages()))return Optional.empty(); + && (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty(); } var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 44a1c4eab..940f5870d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -23,6 +23,16 @@ import static org.mockito.Mockito.*; /** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ class DispatchPickingPersistenceTest { + /** 数量可自然同步不代表能接管其他车的实际托盘。 */ + @Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception { + var fixture=new Picking(true);fixture.receivedPlans(9,11); + fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501"); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, + new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null)))) + .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用"); + verify(fixture.inventory,never()).ship(any(),any(),any()); + assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); + } /** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */ @Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception { var fixture=new Picking(true);fixture.receivedPlans(9,11); @@ -139,6 +149,7 @@ class DispatchPickingPersistenceTest { f.candidates.get(plan).packageCount(count); f.db.update("UPDATE ll_inventory_position SET available_packages=? WHERE id=?",count,10+n); doReturn(List.of(f.candidates.get(plan))).when(sources).selectedPlan(customer,plan); + doReturn(true).when(sources).receivedPlanCompleted(customer,plan); doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,null,plan); doReturn(List.of(9000+plan)).when(sources).sourceOrders(customer,pallet,plan); doReturn(plan).when(sources).lockPlan(customer,plan); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index acb329134..9fcc6d86a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -17,6 +17,22 @@ import static org.mockito.ArgumentMatchers.*; /** 真实PDA任务投影衔接原计划;来源提供者隔离,GET不得写绑定或库存。 */ class DispatchPlanPdaReadPersistenceTest { + /** 完成状态查询走真实Mapper,未完成或其他客户的计划不能被当作本托实收。 */ + @Test void completionGateUsesOriginalCustomerAndPlan() throws Exception { + var f=new Fixture(); + f.f.db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR(64),status VARCHAR(64))"); + f.f.db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR(64))"); + f.f.db.update("INSERT INTO ll_inbound_plan VALUES(1,'101','IN_PROGRESS')"); + f.f.db.update("INSERT INTO ll_inbound_plan_pallet VALUES(2,1,'101')"); + var mapper=f.f.session.getMapper(com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper.class); + assertThat(mapper.receivedPlanCompleted("101",2L)).isZero(); + for(var status:List.of("COMPLETED","PENDING_RECONCILIATION")) { + f.f.db.update("UPDATE ll_inbound_plan SET status=?",status); + assertThat(mapper.receivedPlanCompleted("101",2L)).isOne(); + assertThat(mapper.receivedPlanCompleted("other",2L)).isZero(); + assertThat(mapper.receivedPlanCompleted("101",3L)).isZero(); + } + } /** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */ @Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception { var f=new Fixture();var before=f.dbState(); @@ -61,11 +77,18 @@ class DispatchPlanPdaReadPersistenceTest { @Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception { var f=new Fixture(); for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1), - new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1))) { + new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1), + new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) { when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock)); assertThat(f.read().items().get(0).positions()).isEmpty(); } } + /** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */ + @Test void receivingPlanDoesNotExposePartialPallet() throws Exception { + var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false); + assertThat(f.read().items().get(0).positions()).isEmpty(); + verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); + } /** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */ private static final class Fixture { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); @@ -88,6 +111,7 @@ class DispatchPlanPdaReadPersistenceTest { queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules); when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L))); + when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true); when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L)); when(sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); when(sources.stock("101",501L,false)).thenReturn(List.of(new Stock(11,21,501L,"9",3,0,0,0,1))); From c442c9eaa751183740b6affb56362657e849f02d Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 17:26:43 +0800 Subject: [PATCH 067/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatch/port/DispatchContracts.java | 5 +- .../service/DispatchAggregateService.java | 50 +++++++--- .../service/DispatchCommandHasher.java | 9 +- .../transport/TransportTaskProjection.java | 42 ++++++++- .../transport/TransportTaskService.java | 25 +++++ .../fleet/pickup/FleetPickupMapper.java | 3 + .../fleet/pickup/FleetPickupModels.java | 10 +- .../fleet/pickup/FleetPickupRepository.java | 2 + .../fleet/pickup/FleetPickupService.java | 30 +++++- .../fleet/pickup/PickupDepartureReview.java | 7 ++ .../fleet/pickup/PickupOrderCancellation.java | 9 ++ .../lastleg/fleet/trip/FleetTripService.java | 12 ++- .../TransferPickupCancellationMapper.java | 30 ++++++ .../TransferPickupCancellationRepository.java | 24 +++++ .../TransferPickupCancellationService.java | 32 +++++++ .../service/WarehouseTransferService.java | 6 ++ .../openapi/dispatch-transport-tasks-v1.yaml | 4 + .../openapi/fleet-transfer-pickup-v1.yaml | 13 ++- .../openapi/overseas-business-v1.yaml | 8 ++ .../DispatchAggregatePersistenceTest.java | 4 +- .../service/DispatchRepairWorkflowTest.java | 2 +- ...DispatchResourcePhotosPersistenceTest.java | 93 +++++++++++++++++++ ...DispatchReviewWorkflowPersistenceTest.java | 26 ++++++ .../TransportTaskProjectionTest.java | 29 ++++++ .../transport/TransportTaskServiceTest.java | 24 +++++ .../fleet/pickup/FleetPickupMapperTest.java | 8 ++ .../fleet/pickup/FleetPickupServiceTest.java | 48 +++++++++- .../trip/FleetTripAuthorizationTest.java | 2 + .../pda/PdaOperationsOpenApiContractTest.java | 4 +- ...sferPickupCancellationPersistenceTest.java | 62 +++++++++++++ .../WarehouseTransferCartonCountTest.java | 2 +- .../service/WarehouseTransferServiceTest.java | 5 +- 32 files changed, 587 insertions(+), 43 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupDepartureReview.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupOrderCancellation.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferPickupCancellationMapper.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferPickupCancellationRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationPersistenceTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java index 1326b540b..d4c8f3e8c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -25,7 +25,10 @@ public final class DispatchContracts { /** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */ public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, - List fileIds, Instant expectedTaskEndAt) { } + List fileIds, Instant expectedTaskEndAt, List photoFileIds) { + /** 司机照片独立于驾驶证,缺省为空且引用须有界、有效、不重复。 */ + public DriverEvidence { photoFileIds = ReviewAttachments.checked(photoFileIds); } + } public record VehicleSnapshot(ResourceMode mode, Long vehicleId, String plate, String carrierName, String vehicleStatus, Capacity capacity, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 56215676a..397f2b149 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -51,6 +51,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway evidence; @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchExecutionRepository executionReads; @jakarta.annotation.Resource private OwnedBusinessOwnershipCapture ownedBusinessOwnership; + @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks; /** 领域编排只访问Repository和提供者应用入口。 */ public DispatchAggregateService(DispatchAggregateRepository repository, DispatchSourceRepository sources, @@ -304,7 +305,9 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L); row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L); row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0))); - Resources resources=resources(shell); row.setResourcesJson(write(resources)); + var photos=request.driver()==null || request.driver().evidence()==null?List.of():request.driver().evidence().photoFileIds(); + if(!photos.isEmpty())verifyEvidence(id,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos); + Resources resources=resources(shell,photos); row.setResourcesJson(write(resources)); row.setVehiclePlate(resources.vehicle()==null ? null : resources.vehicle().plate()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt())); row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt())); row.setItemCount(0); row.setPalletCount(0); row.setPackageCount(0); row.setPickedPackageCount(0); row.setShippedPackageCount(0); @@ -382,8 +385,11 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); requireEditing(row,request.recordVersion()); String before=basis(row); + // 本趟司机照片随资源维护保存,逐个核对本车及照片类别。 + var photos=request.driver()==null || request.driver().evidence()==null?List.of():request.driver().evidence().photoFileIds(); + if(!photos.isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,photos); var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); - row.setResourcesJson(write(resources(actual)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); + row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark()); @@ -398,7 +404,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA requireEditing(row,request.recordVersion()); if(!canSubmitReview(row.getReviewState()))throw conflict("当前审核已提交,请刷新核对"); verifyReviewResources(row); - if(n(row.getPackageCount())<=0 || repository.cargo(row.getId()).isEmpty())throw bad("空排车不能提交审核"); + if(n(row.getPackageCount())<=0 || (!"PICKUP".equals(row.getTaskType()) && repository.cargo(row.getId()).isEmpty()))throw bad("空排车不能提交审核"); long round=row.getReviewRound()+1;String basis=basis(row); row.setReviewRound(round);row.setReviewBasisHash(basis);row.setReviewState(ReviewState.DISPATCH_PENDING.name()); row.setDispatchReviewJson(write(new Review(ReviewStatus.PENDING,null,null,null,round,basis,null,null))); @@ -415,7 +421,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(actor!=null && actor.mode()==ActorMode.PDA) { if(!loading || actor.boundWarehouseId()==null || !hasPermission(actor,action)) throw new BizException(ResultCode.FORBIDDEN,"仅当前绑定仓理货员可执行理货审核"); - required(actor,number,false);fleet.lockDispatchShell(number);row=required(actor,number,true); + lockReviewRoot(actor,number);row=required(actor,number,true); } else row=lockPlatform(actor,number); var identity=identity(actor,number,action,request.recordVersion(),request.idempotencyKey(),request); var original=commands.replay(actor,number,identity);if(original.isPresent())return original.get(); @@ -610,7 +616,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA } /** 共同根锁顺序固定,当前阶段资格留给原命令恢复之后。 */ - private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);fleet.lockDispatchShell(number);return required(actor,number,true); } + private DispatchAggregate lockPlatform(Actor actor,String number) { requirePlatform(actor);lockReviewRoot(actor,number);return required(actor,number,true); } + /** 运输取货锁原车次,仓库派送锁其整车资源;统一先父后主单。 */ + private void lockReviewRoot(Actor actor,String number) { + var row=required(actor,number,false); + if("PICKUP".equals(row.getTaskType()))transportTasks.transport(number,true);else fleet.lockDispatchShell(number); + } /** 严格请求身份不接收前端摘要或操作者。 */ private CommandIdentity identity(Actor actor,String no,Action action,long version,String key,Object request) {return new CommandIdentity(action,key,version,hashes.command(actor,action,no,version,request));} /** 每次状态变更、历史及不可变命令结果同事务保存。 */ @@ -627,7 +638,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 只包含可影响两级审核的事实,不因备注修改伪造新依据。 */ private String basis(DispatchAggregate row) { var facts=new TreeMap();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); - facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt());facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));return hashes.source(facts); + facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt()); + if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson()); + else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));} + return hashes.source(facts); } /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ private void invalidate(DispatchAggregate row,String reason) { @@ -652,6 +666,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ void verifyReviewResources(DispatchAggregate row) { + if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;} var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话"); var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积"); @@ -659,9 +674,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(trim(e.licenseNo())==null || e.licenseExpiresOn()==null || e.licenseExpiresOn().isBefore(e.expectedTaskEndAt().atZone(ZoneOffset.UTC).toLocalDate()))throw bad("司机证照须覆盖预计任务结束日期"); if(v.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.VEHICLE,v.evidenceFileIds()); if(d.mode()==ResourceMode.EXTERNAL)verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER,e.fileIds()); + if(!e.photoFileIds().isEmpty())verifyEvidence(row.getId(),com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER_PHOTO,e.photoFileIds()); var actual=fleet.dispatchResources(row.getDispatchNo(),new FleetExecutionModels.DispatchVehicle(v.mode().name(),v.vehicleId(),v.plate(),v.carrierName(),capacity.palletSpaces(),capacity.maxLoadKg(),capacity.maxVolumeCbm(),v.evidenceFileIds()), new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); - if(!Objects.equals(hashes.source(r),hashes.source(resources(actual))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); + if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); } /** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */ private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List ids) { @@ -848,8 +864,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA // 到仓取货和本土卡车沿运输任务执行,不能进入仓内拣货、扣库存和转派送链。 if (row.getTaskType()!=null && !"DELIVERY".equals(row.getTaskType())) { var blocked=new EnumMap(Action.class); - for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) - blocked.put(action,new Gate(false,"TRANSPORT_TASK","请在排车运输任务中操作")); + boolean pickup="PICKUP".equals(row.getTaskType()) && "ACTIVE".equals(row.getStatus()); + for(var action:Action.values())if(action!=Action.ARRANGE_DELIVERY && action!=Action.DEPART) { + boolean state=pickup && switch(action) { + case EDIT,CANCEL -> Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()); + case SUBMIT_REVIEW -> "BEFORE_LOADING".equals(row.getStage()) && canSubmitReview(row.getReviewState()); + case DISPATCH_REVIEW -> "DISPATCH_PENDING".equals(row.getReviewState()); + case LOADING_REVIEW -> "LOADING_PENDING".equals(row.getReviewState()); + default -> false; + }; + boolean allowed=state && hasPermission(actor,action); + blocked.put(action,new Gate(allowed,allowed?null:"TRANSPORT_TASK",allowed?null:"请按取货运输审核流程操作")); + } return Collections.unmodifiableMap(blocked); } var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); @@ -940,12 +966,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return List.copyOf(result); } - /** 把Fleet权威主档或真实临时输入转换成整车审阅快照。 */ - private static Resources resources(FleetExecutionModels.DispatchShell shell) { + /** 合并Fleet权威资源与本趟司机照片,照片不写入司机主档。 */ + private static Resources resources(FleetExecutionModels.DispatchShell shell,List photos) { var v=shell.vehicle(); var d=shell.driver(); return new Resources(v==null?null:new VehicleSnapshot(ResourceMode.valueOf(v.mode()),v.vehicleId(),v.plate(),v.carrierName(),shell.vehicleStatus(), new Capacity(v.palletSpaces(),v.maxLoadKg(),v.maxVolumeCbm()),v.evidenceFileIds()),d==null?null:new DriverSnapshot(ResourceMode.valueOf(d.mode()),d.driverId(),d.name(),d.phone(), - new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt()))); + new DriverEvidence(d.licenseNo(),d.licenseExpiresOn(),d.evidenceFileIds(),d.expectedTaskEndAt(),photos))); } /** 主档选择不接受请求覆盖其车牌和容量。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java index baa4ab651..246464c21 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCommandHasher.java @@ -19,7 +19,7 @@ import java.util.TreeMap; @Component public final class DispatchCommandHasher { private static final Set UNORDERED = Set.of("cargo", "groups", "fees", "feeCoverage", "services", - "batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds"); + "batchAllocations", "measurements", "paymentVoucherFileIds", "evidenceFileIds", "fileIds", "photoFileIds"); private final ObjectMapper json; /** 复用已注册的时间、数值和公开字段序列化配置。 */ @@ -60,7 +60,12 @@ public final class DispatchCommandHasher { if (node == null || node.isNull()) return "null"; if (node.isObject()) { var values = new TreeMap(); - node.fields().forEachRemaining(entry -> values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey()))); + node.fields().forEachRemaining(entry -> { + // 新增可选照片为空时沿用旧摘要,保留历史命令重放与已通过审核的依据。 + if (entry.getKey().equals("photoFileIds") && (entry.getValue().isNull() + || entry.getValue().isArray() && entry.getValue().isEmpty())) return; + values.put(entry.getKey(), canonical(entry.getValue(), entry.getKey())); + }); var result = new ArrayList(); values.forEach((key, value) -> result.add(quoted(key) + ":" + value)); return "{" + String.join(",", result) + "}"; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java index 3566ebc32..4a9b5085d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java @@ -18,12 +18,13 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 现有排车主单与运输事实同步,旧执行入口也不能留下不同步的单据。 */ @Service -public class TransportTaskProjection { +public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup.PickupDepartureReview { private final DispatchAggregateRepository roots; private final TransportTaskRepository links; private final UsTruckOrderRepository trucks; private final DispatchNumberGenerator numbers; private final ObjectMapper json; + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickupFacts; /** 注入本地聚合与原单持久化能力,无跨服务数据库访问。 */ public TransportTaskProjection(DispatchAggregateRepository roots,TransportTaskRepository links,UsTruckOrderRepository trucks,DispatchNumberGenerator numbers,ObjectMapper json) { this.roots=roots;this.links=links;this.trucks=trucks;this.numbers=numbers;this.json=json; @@ -50,11 +51,22 @@ public class TransportTaskProjection { var trip=event.trip();if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(trip.getTripPurpose(),"")))return; var found=links.byTrip(trip.getId());if(found==null)return; var row=roots.lock(found.getDispatchNo());long version=row.getRecordVersion(); - row.setRecordVersion(trip.getRecordVersion());row.setUpdatedAt(trip.getUpdatedAt()); + row.setRecordVersion(version+1);row.setUpdatedAt(trip.getUpdatedAt()); row.setStatus("CANCELLED".equals(trip.getStatus())?"CANCELLED":"ACTIVE"); - row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";default->"BEFORE_LOADING";}); + row.setStage(switch(trip.getStatus()){case "IN_TRANSIT"->"DELIVERING";case "COMPLETED"->"DELIVERED";case "PLANNED"->row.getStage();default->"BEFORE_LOADING";}); + // 旧发车入口只记录出发事实,取货照片尚未齐全时仍在装货中。 + if("PICKUP".equals(row.getTaskType()) && "IN_TRANSIT".equals(trip.getStatus()) + && pickupFacts.tasks(trip.getTripNo()).stream().anyMatch(task->"ASSIGNED".equals(task.getStatus())))row.setStage("LOADING"); row.setDepartedAt(trip.getDepartedAt());row.setDeliveryCompletedAt(trip.getCompletedAt()); + String oldResources=row.getResourcesJson();var oldDeparture=row.getPlannedDepartureAt();var oldArrival=row.getEstimatedArrivalAt(); row.setPlannedDepartureAt(trip.getPlannedDepartureAt());row.setEstimatedArrivalAt(trip.getExpectedDriverEndAt());row.setRemark(trip.getRemark());resources(row,trip); + // 资源或计划改变后作废本轮审核,保留审核驳回原因供修改时核对。 + if("PICKUP".equals(row.getTaskType()) && "PLANNED".equals(trip.getStatus()) + && (!Objects.equals(oldResources,row.getResourcesJson()) || !Objects.equals(oldDeparture,row.getPlannedDepartureAt()) || !Objects.equals(oldArrival,row.getEstimatedArrivalAt()))) { + row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING"); + row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound())); + row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound())); + } if(roots.saveState(row,version)!=1)throw new IllegalStateException("运输排车版本保存失败"); roots.history(IdWorker.getId(),row,event.action(),event.operator(),event.reason(),write(row),trip.getUpdatedAt()); var link=links.truck(row.getDispatchNo());if(link==null)return; @@ -62,6 +74,30 @@ public class TransportTaskProjection { transition(link.customerId(),link.orderNo(),target,event.action(),event.operator(),event.reason(),row.getDispatchNo()+":"+trip.getRecordVersion()); if("CANCELLED".equals(trip.getStatus()))links.cancel(row.getDispatchNo()); } + /** 旧取货发车入口也必须锁定并核验同轮两级批准,不允许无主单发车。 */ + @Override + public void requireApproved(long tripId) { + var found=links.byTrip(tripId); + if(found==null)throw new IllegalStateException("取货排车缺失,请先接入排车审核"); + var row=roots.lock(found.getDispatchNo()); + if(!"PICKUP".equals(row.getTaskType()) || !"ACTIVE".equals(row.getStatus()) || !"APPROVED".equals(row.getReviewState()) || row.getReviewBasisHash()==null) + throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"请先完成调度审核和理货员审核"); + for(String value:List.of(row.getDispatchReviewJson(),row.getLoadingReviewJson())) { + try { + var review=json.readValue(value,Review.class); + if(review.status()!=ReviewStatus.APPROVED || review.reviewRound()!=row.getReviewRound() || !Objects.equals(review.basisHash(),row.getReviewBasisHash())) + throw new IllegalStateException("取货审核依据已失效,请重新提交审核"); + } catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);} + } + } + /** 保留驳回事实,同时明确此前批准已因资源变更失效。 */ + private String invalidated(String value,long round) { + try { + var old=json.readValue(value,Review.class); + return write(new Review(old.status()==ReviewStatus.REJECTED?ReviewStatus.REJECTED:ReviewStatus.NOT_SUBMITTED, + old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机或计划已变更,请重新提交两级审核",old.attachments())); + } catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);} + } /** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */ public void transition(String customer,String number,String target,String action,String operator,String reason,String key) { var order=trucks.lockNo(customer,number);if(order==null)throw new IllegalStateException("关联本土卡车单不存在"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java index 848875bc1..d661e2cb1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -51,6 +51,22 @@ public class TransportTaskService { var tasks=pickups.tasks(trip.getTripNo());var row=projection.register(trip,tasks.size(),0,tasks.stream().mapToInt(FleetPickupModels.Task::packageCount).sum(),operator); return detail(row.getDispatchNo()); } + /** 审核通过后按原单锁幂等生成待发车排车;重复审核保留原安排及取消结果。 */ + @Transactional + public void ensureApprovedPickup(String customer,String number,String operator) { + var source=pickupFacts.source(customer,number,true); + if(source==null)throw missing("转运单不存在"); + if(!"WAREHOUSE_PICKUP".equals(source.getDeliveryMethod()) || !number.equals(source.getInboundCode()) + || !"WAIT_PDA_RECEIVING".equals(source.getStatus()))return; + var task=pickupFacts.lockTask(source.getId()); + // 已安排、执行或主动取消的取货任务均不因审核重放重新建单。 + if(task!=null)return; + var departure=Instant.now().truncatedTo(java.time.temporal.ChronoUnit.SECONDS); + createPickup(operator,new FleetPickupModels.Create(List.of(new FleetPickupModels.Source(customer,number)), + departure,departure.plus(Duration.ofHours(24)), + "审核自动生成到仓取货排车;时间为暂定计划,调度确认后分配车辆司机", + "transfer-approval:"+source.getId())); + } /** 原单锁和有效关联唯一约束共同阻止同单重复排车。 */ @Transactional public TransportTaskModels.Detail createTruck(String operator,TransportTaskModels.TruckCreate request) { @@ -97,6 +113,15 @@ public class TransportTaskService { var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request); if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number); } + /** 取货提交与两级通过时复验真实占用、有效证照和车辆容量。 */ + public void verifyPickupReview(String number) { + var trip=transport(number,true); + if(!"PICKUP".equals(trip.getTripPurpose()) || !"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).isEmpty())throw conflict("仅有真实取货任务的未发车排车可以审核"); + if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null || !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间"); + var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm(); + requireResources(trip); + if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息"); + } /** 取消运输并保留原单、排车及资源审计。 */ @Transactional public TransportTaskModels.Detail cancel(String number,String operator,FleetTripModels.Cancel request) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java index 18d1c5813..010ba8fbe 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapper.java @@ -39,6 +39,9 @@ public interface FleetPickupMapper extends BaseMapper { /** 解除计划中的取货关联,保留历史后允许再次安排。 */ @Update("UPDATE ll_fleet_pickup_task SET trip_no=NULL,status='WAITING',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'") int unassign(@Param("tripNo") String tripNo); + /** 原单同时取消时保留车次关联供详情和审计追溯,不再回到待安排。 */ + @Update("UPDATE ll_fleet_pickup_task SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE trip_no=#{tripNo} AND status='ASSIGNED'") + int cancelTasks(@Param("tripNo") String tripNo); /** 不可变操作历史覆盖安排、解除、取货、到仓及结案。 */ @Insert("INSERT INTO ll_fleet_pickup_history(customer_id,transfer_no,trip_no,action,operator_id,occurred_at,detail) VALUES(#{customer},#{number},#{trip},#{action},#{operator},CURRENT_TIMESTAMP,#{detail})") int history(@Param("customer") String customer,@Param("number") String number,@Param("trip") String trip, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java index 5c9544cfb..9792ba1a5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupModels.java @@ -24,7 +24,10 @@ public final class FleetPickupModels { @NotBlank @Size(min=8,max=128) String idempotencyKey) { } public record Command(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min=8,max=128) String idempotencyKey, - @NotBlank @Size(max=1000) String reason) { } + @NotBlank @Size(max=1000) String reason,List<@NotNull @Positive Long> evidenceFileIds) { + /** 无照片的非送仓命令保持原调用形式。 */ + public Command(Long recordVersion,String idempotencyKey,String reason){this(recordVersion,idempotencyKey,reason,null);} + } public record Pickup(@NotNull @Min(0) Long recordVersion, @NotBlank @Size(min=8,max=128) String idempotencyKey, @Min(1) @Max(5000) int actualPackages, @@ -35,6 +38,9 @@ public final class FleetPickupModels { String status, String tripNo, String vehiclePlate, String driverName, String driverPhone, Instant plannedDepartureAt, Integer actualPackages, Instant pickedAt, Instant arrivedAt, String remark, List evidence) { } - public record Evidence(String fileId, String fileName, String contentType, long sizeBytes) { } + public record Evidence(String fileId, String fileName, String contentType, long sizeBytes,String phase) { + /** 旧取货凭证明确归入取货阶段,不冒充到仓照片。 */ + public Evidence(String fileId,String fileName,String contentType,long sizeBytes){this(fileId,fileName,contentType,sizeBytes,"PICKUP");} + } public record History(String action, String tripNo, String operatorId, Instant occurredAt, String detail) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java index ceb88b69c..356cf44de 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupRepository.java @@ -34,6 +34,8 @@ public class FleetPickupRepository { public FleetPickupTask lockTask(long id) { return mapper.lockTask(id); } /** 释放未开始的成员关联。 */ public int unassign(String number) { return mapper.unassign(number); } + /** 联动取消保留原车次与转运单的可追溯关联。 */ + public int cancelTasks(String number) { return mapper.cancelTasks(number); } /** 保存不可变业务操作记录。 */ public void history(FleetPickupTask task,String action,String operator,String detail) { mapper.history(task.getCustomerId(),task.getTransferNo(),task.getTripNo(),action,operator,detail); } /** 核对旧拼车承运关系,防止同票走两条运输链。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java index 50002ec18..c7bca9d45 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/FleetPickupService.java @@ -28,11 +28,13 @@ public class FleetPickupService { private final VehicleRepository vehicles; private final TripOperationsEvidenceGateway files; private final ObjectMapper json; + private final PickupDepartureReview departureReview; /** 共用现有车次、司机资格、车辆占用及鉴权文件能力。 */ public FleetPickupService(FleetPickupRepository repository,FleetTripService trips,FleetWarehouseDirectory warehouses, - DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json) { + DriverService drivers,VehicleRepository vehicles,TripOperationsEvidenceGateway files,ObjectMapper json,PickupDepartureReview departureReview) { this.repository=repository;this.trips=trips;this.warehouses=warehouses;this.drivers=drivers; this.vehicles=vehicles;this.files=files;this.json=json; + this.departureReview=departureReview; } /** 审核通过的原单自动出现在候选中,客户自送和库存转运不参与。 */ @Transactional(readOnly=true,isolation=org.springframework.transaction.annotation.Isolation.REPEATABLE_READ) @@ -120,6 +122,7 @@ public class FleetPickupService { if(replayed(trip,request.idempotencyKey(),hash))return trips.detail(number); version(trip,request.recordVersion()); if(!"PLANNED".equals(trip.getStatus()) || repository.tasks(number).isEmpty())throw conflict("仅有取货任务的计划车次可以发车"); + departureReview.requireApproved(trip.getId()); requireResources(trip); for(var task:repository.tasks(number))eligible(source(task.getCustomerId(),task.getTransferNo(),true)); trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now()); @@ -142,28 +145,47 @@ public class FleetPickupService { var trip=pickupTrip(number,true);String hash=commandHash("PICKED:"+customer+":"+transferNo,request); if(replayed(trip,request.idempotencyKey(),hash))return tasks(number); version(trip,request.recordVersion());var task=member(trip,customer,transferNo); - if(!"IN_TRANSIT".equals(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("请先发车,且该单尚未确认取货"); + if(!Set.of("PLANNED","IN_TRANSIT").contains(trip.getStatus()) || !"ASSIGNED".equals(task.getStatus()))throw conflict("该单尚未处于可确认取货阶段"); + if("PLANNED".equals(trip.getStatus()))departureReview.requireApproved(trip.getId()); + if(request.actualPackages()<=0 || request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty() || request.evidenceFileIds().size()>10)throw bad("请填写实提箱数并上传取货照片"); eligible(source(customer,transferNo,true)); var evidence=request.evidenceFileIds().stream().distinct().map(id->{ if(!repository.ownsUpload(task.getTransferOrderId(),number,id))throw bad("取货凭证不属于当前订单或车次"); var file=files.metadata(trip.getId(),id); + if(file.contentType()==null || !file.contentType().startsWith("image/"))throw bad("请上传有效取货照片"); return new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes()); }).toList(); task.setStatus("PICKED");task.setActualPackages(request.actualPackages());task.setPickedAt(now()); task.setEvidenceJson(write(evidence));task.setRemark(blank(request.remark()));task.setUpdatedAt(now());repository.update(task); repository.history(task,"PICKED",operator,"实提"+request.actualPackages()+"箱;"+Objects.toString(task.getRemark(),"")); + // 全车照片和实提数量核齐后自动进入派送中,不能以发车按钮代替取货照片。 + if("PLANNED".equals(trip.getStatus()) && repository.tasks(number).stream().allMatch(row->"PICKED".equals(row.getStatus()))) { + requireResources(trip);trip.setStatus("IN_TRANSIT");trip.setDepartedAt(now()); + } trips.recordExecution(trip,request.recordVersion(),"PICKUP_PICKED","确认原单取货",operator); repository.command(number,request.idempotencyKey(),hash);return tasks(number); } - /** 到仓确认只开放 PDA 清点,不覆盖预报箱数和 SKU 件数。 */ + /** 到仓照片独立归档,全部送仓后自动完成本次取货派送并开放PDA清点。 */ @Transactional public List arrive(String number,String customer,String transferNo,String operator,FleetPickupModels.Command request) { var trip=pickupTrip(number,true);String hash=commandHash("ARRIVED:"+customer+":"+transferNo,request); if(replayed(trip,request.idempotencyKey(),hash))return tasks(number); version(trip,request.recordVersion());var task=member(trip,customer,transferNo); if(!"IN_TRANSIT".equals(trip.getStatus()) || !"PICKED".equals(task.getStatus()))throw conflict("请先确认该单已取货"); - source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setUpdatedAt(now());repository.update(task); + if(request.evidenceFileIds()==null || request.evidenceFileIds().isEmpty() || request.evidenceFileIds().size()>10)throw bad("请上传到仓照片"); + var old=evidence(task);var arrival=new ArrayList(old); + for(long id:request.evidenceFileIds().stream().distinct().toList()) { + if(!repository.ownsUpload(task.getTransferOrderId(),number,id) || old.stream().anyMatch(file->file.fileId().equals(String.valueOf(id))))throw bad("请上传本票新的到仓照片,不能复用取货照片"); + var file=files.metadata(trip.getId(),id); + if(file.contentType()==null || !file.contentType().startsWith("image/"))throw bad("请上传有效到仓照片"); + arrival.add(new FleetPickupModels.Evidence(String.valueOf(file.fileId()),file.fileName(),file.contentType(),file.sizeBytes(),"ARRIVAL")); + } + source(customer,transferNo,true);task.setStatus("ARRIVED");task.setArrivedAt(now());task.setEvidenceJson(write(arrival));task.setUpdatedAt(now());repository.update(task); repository.history(task,"ARRIVED",operator,request.reason()); + if(repository.tasks(number).stream().allMatch(row->"ARRIVED".equals(row.getStatus()))) { + trip.setStatus("COMPLETED");trip.setCompletedAt(now()); + trip.setCompletionJson(write(new FleetCompletionModels.Completed(request.idempotencyKey(),hash,trip.getCompletedAt().toInstant(ZoneOffset.UTC),Instant.now(),operator,List.of()))); + } trips.recordExecution(trip,request.recordVersion(),"PICKUP_ARRIVED",request.reason(),operator); repository.command(number,request.idempotencyKey(),hash);return tasks(number); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupDepartureReview.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupDepartureReview.java new file mode 100644 index 000000000..10bcc33f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupDepartureReview.java @@ -0,0 +1,7 @@ +package com.baiyue.lastleg.fleet.pickup; + +/** 取货发车必须通过排车主单的本轮两级审核。 */ +public interface PickupDepartureReview { + /** 调用方已持车次锁,校验并锁定对应排车审核事实。 */ + void requireApproved(long tripId); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupOrderCancellation.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupOrderCancellation.java new file mode 100644 index 000000000..19125145d --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/pickup/PickupOrderCancellation.java @@ -0,0 +1,9 @@ +package com.baiyue.lastleg.fleet.pickup; + +import java.util.List; + +/** 原单所属域负责取消未执行取货的转运业务,失败时整车取消回滚。 */ +public interface PickupOrderCancellation { + /** 调用方持有车次锁,按稳定来源顺序取消全部成员。 */ + void cancel(List sources,String tripNo,String operator,String reason); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index 98c5886db..a42438b8f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -34,6 +34,7 @@ public class FleetTripService { @jakarta.annotation.Resource private com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort appointmentAllocations; private final FleetTripRepository repository; @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups; + @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation; private final FleetDispatchGateway dispatches; private final VehicleRepository vehicles; private final DriverService drivers; @@ -467,18 +468,20 @@ public class FleetTripService { - /** 取消未执行车次,释放资源并将取货原单退回待安排。 */ + /** 取消未执行车次,取货同时取消全部转运原单并释放资源。 */ @Transactional public FleetTripModels.Detail cancel(String tripNo, String operator, FleetTripModels.Cancel request) { FleetTripEntity trip = planned(tripNo, request.recordVersion()); requireReason(request.reason()); if ("PICKUP".equals(trip.getTripPurpose())) { // 取消车次与释放原单关联同事务,失败时全部回滚。 - for (var task : pickups.tasks(tripNo)) { + var tasks=pickups.tasks(tripNo); + for (var task : tasks) { if (!"ASSIGNED".equals(task.getStatus())) throw conflict("已开始取货,不能取消车次"); - pickups.history(task,"UNASSIGN",operator,request.reason()); } - pickups.unassign(tripNo); + pickupCancellation.cancel(tasks.stream().map(task->new com.baiyue.lastleg.fleet.pickup.FleetPickupModels.Source(task.getCustomerId(),task.getTransferNo())).toList(),tripNo,operator,request.reason()); + for(var task:tasks)pickups.history(task,"CANCEL",operator,"取消排车及转运单:"+request.reason()); + if(pickups.cancelTasks(tripNo)!=tasks.size())throw conflict("取货关联已变化,取消未完成"); } if (!repository.listBindings(trip.getId()).isEmpty()) throw conflict("请先解除客户排车单关联,再取消车次"); if (bookingReservations.hasConfirmed(trip.getId())) throw conflict("请先取消已确认的外接预订,再取消车次"); @@ -554,6 +557,7 @@ public class FleetTripService { if (trip == null) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "车次不存在"); if (trip.getRecordVersion() != version) throw conflict("车次已被修改,请刷新后重试"); if (!"PLANNED".equals(trip.getStatus())) throw conflict("仅计划中的车次允许此操作"); + if("PICKUP".equals(trip.getTripPurpose()) && pickups.tasks(number).stream().anyMatch(task->!"ASSIGNED".equals(task.getStatus())))throw conflict("已开始取货,不能修改资源、计划或取消排车"); return trip; } /** 复核创建请求摘要,同键不同内容不允许复用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferPickupCancellationMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferPickupCancellationMapper.java new file mode 100644 index 000000000..2633b2714 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/mapper/TransferPickupCancellationMapper.java @@ -0,0 +1,30 @@ +package com.baiyue.lastleg.transfer.mapper; + +import com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder; +import org.apache.ibatis.annotations.*; + +/** 仅取消本客户外来取货的待执行原单,不释放或回滚任何已入库库存。 */ +@Mapper +public interface TransferPickupCancellationMapper { + /** 与PDA共用转运原单锁,串行核验收货事实。 */ + @Select("SELECT * FROM ll_warehouse_transfer_order WHERE customer_id=#{customer} AND transfer_no=#{number} FOR UPDATE") + WarehouseTransferOrder lock(@Param("customer") String customer,@Param("number") String number); + /** 任何收货、箱清点或托盘测量开始后均禁止普通取消。 */ + @Select("SELECT (SELECT COUNT(*) FROM ll_warehouse_transfer_receipt WHERE customer_id=#{customer} AND transfer_order_id=#{id}) + (SELECT COUNT(*) FROM ll_warehouse_transfer_carton WHERE customer_id=#{customer} AND transfer_order_id=#{id} AND counted_at IS NOT NULL) + (SELECT COUNT(*) FROM ll_warehouse_transfer_pallet WHERE customer_id=#{customer} AND transfer_order_id=#{id})") + int started(@Param("customer") String customer,@Param("id") long id); + /** 同一取消事务只接受待PDA收货的转运单。 */ + @Update("UPDATE ll_warehouse_transfer_order SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND status='WAIT_PDA_RECEIVING'") + int cancelTransfer(@Param("customer") String customer,@Param("id") long id); + /** 写入主单取消事实并递增版本,防止旧页面继续写入。 */ + @Update("UPDATE ll_order SET current_status='CANCELLED',sub_order_status='WAREHOUSE_TRANSFER:CANCELLED',version=version+1,updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND current_status='INBOUND_MEASURING'") + int cancelOrder(@Param("customer") String customer,@Param("id") long id); + /** 只取消尚未接货的外来入仓单。 */ + @Update("UPDATE ll_inbound_order SET status='CANCELLED',updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND id=#{id} AND status='PENDING_INBOUND'") + int cancelInbound(@Param("customer") String customer,@Param("id") long id); + /** 只取消原转运生成且仍待PDA的唯一计划。 */ + @Update("UPDATE ll_inbound_plan SET status='CANCELLED',version=version+1,updated_at=CURRENT_TIMESTAMP WHERE customer_id=#{customer} AND source_order_id=#{order} AND creation_mode='WAREHOUSE_TRANSFER' AND status='PDA_PENDING'") + int cancelPlan(@Param("customer") String customer,@Param("order") long order); + /** 不可变历史保留取消前状态、原车次与操作者。 */ + @Insert("INSERT INTO ll_loading_order_status_history(customer_id,order_id,from_status,to_status,event_id,remark,created_at) SELECT customer_id,id,current_status,'CANCELLED',#{trip},CONCAT('取消取货排车及转运单;操作人:',#{operator},';原因:',#{reason}),CURRENT_TIMESTAMP FROM ll_order WHERE customer_id=#{customer} AND id=#{id}") + int history(@Param("customer") String customer,@Param("id") long id,@Param("trip") String trip,@Param("operator") String operator,@Param("reason") String reason); +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferPickupCancellationRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferPickupCancellationRepository.java new file mode 100644 index 000000000..76ed7d5f8 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/repository/TransferPickupCancellationRepository.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.transfer.repository; + +import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper; +import org.springframework.stereotype.Repository; + +/** 未执行取货取消的原单、入仓与历史持久化边界。 */ +@Repository +public class TransferPickupCancellationRepository { + private final TransferPickupCancellationMapper mapper; + /** 注入本业务取消映射器。 */ + public TransferPickupCancellationRepository(TransferPickupCancellationMapper mapper){this.mapper=mapper;} + /** 锁定真实客户所属转运来源。 */ + public com.baiyue.lastleg.transfer.entity.WarehouseTransferOrder lock(String customer,String number){return mapper.lock(customer,number);} + /** 已存在清点、托盘或收货事实时禁止普通取消。 */ + public boolean started(String customer,long id){return mapper.started(customer,id)>0;} + /** 取消转运并检查当前状态。 */ + public int cancelTransfer(String customer,long id){return mapper.cancelTransfer(customer,id);} + /** 取消尾程主单并记录当前状态来源。 */ + public int cancelOrder(String customer,long id,String trip,String operator,String reason){mapper.history(customer,id,trip,operator,reason);return mapper.cancelOrder(customer,id);} + /** 取消原入仓单,保留不可删除的业务关联。 */ + public int cancelInbound(String customer,long id){return mapper.cancelInbound(customer,id);} + /** 取消待收货计划,已执行的计划导致整车取消失败。 */ + public int cancelPlan(String customer,long order){return mapper.cancelPlan(customer,order);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationService.java new file mode 100644 index 000000000..a56488e0c --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationService.java @@ -0,0 +1,32 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baiyue.lastleg.fleet.pickup.*; +import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.http.HttpStatus; +import org.springframework.web.server.ResponseStatusException; +import java.util.List; + +/** 取货排车取消时原子取消其全部转运、尾程、入仓单及待执行入仓计划。 */ +@Service +public class TransferPickupCancellationService implements PickupOrderCancellation { + private final TransferPickupCancellationRepository repository; + /** 取消只访问本域持久化边界,不反向调用排车服务。 */ + public TransferPickupCancellationService(TransferPickupCancellationRepository repository){this.repository=repository;} + /** 任一来源已经执行或归属不符时抛错,使外层整车取消全部回滚。 */ + @Override @Transactional(propagation=Propagation.MANDATORY) + public void cancel(List sources,String tripNo,String operator,String reason) { + if(sources==null || sources.isEmpty())throw conflict(); + for(var source:sources) { + var row=repository.lock(source.customerId(),source.transferNo()); + if(row==null || !"WAREHOUSE_PICKUP".equals(row.getDeliveryMethod()) || !row.getTransferNo().equals(row.getInboundCode()) || !"WAIT_PDA_RECEIVING".equals(row.getStatus()) || repository.started(source.customerId(),row.getId()))throw conflict(); + if(repository.cancelTransfer(source.customerId(),row.getId())!=1 + || repository.cancelOrder(source.customerId(),row.getLastlegOrderId(),tripNo,operator,reason)!=1 + || repository.cancelInbound(source.customerId(),row.getInboundOrderId())!=1 + || repository.cancelPlan(source.customerId(),row.getLastlegOrderId())!=1)throw conflict(); + } + } + /** 保留用户上下文,不把部分取消作为成功返回。 */ + private static ResponseStatusException conflict(){return new ResponseStatusException(HttpStatus.CONFLICT,"转运单已开始收货或关联状态变化,不能取消排车及转运单,请刷新核对");} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java index 8d2e9859c..d18b0c51d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/transfer/service/WarehouseTransferService.java @@ -94,6 +94,7 @@ import java.util.stream.Collectors; public class WarehouseTransferService { @Resource private TransferQuantityService transferQuantityService; @Resource private com.baiyue.lastleg.fleet.pickup.FleetPickupService pickupService; + @Resource private com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTaskService; /** 退货单在转运来源中的展示编码,避免把退货单号误当成普通入仓码查询。 */ public static final String RETURN_ORDER_PREFIX = "RETURN_ORDER:"; @Resource private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @@ -814,6 +815,7 @@ public class WarehouseTransferService { return new WarehouseTransferResponses.Label(transferNo, cartons, html); } + /** 审核与入仓计划、上门取货排车原子提交,重放复用原业务关联。 */ @Transactional public WarehouseTransferResponses.Detail review(String customerId, String transferNo, String operatorId, WarehouseTransferRequests.Review request) { @@ -836,6 +838,7 @@ public class WarehouseTransferService { .contains(transfer.getStatus())) { if (APPROVED.equals(next) && transfer.getTransferNo().equals(transfer.getInboundCode())) { activateTransferInbound(customerId, transfer, LocalDateTime.now()); + transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId); } return detail(customerId, transferNo); } @@ -865,6 +868,9 @@ public class WarehouseTransferService { "Warehouse transfer reviewed: " + next + (StringUtils.hasText(request.remark()) ? "; " + request.remark().trim() : ""), now); } + if (APPROVED.equals(next) && incomingTransit) { + transportTaskService.ensureApprovedPickup(customerId, transferNo, operatorId); + } return detail(customerId, transferNo); } diff --git a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml index 3b4cf3ba8..15cb98986 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml @@ -8,6 +8,10 @@ info: list读取、create安排、edit分配/执行/释放、depart发车、cancel取消权限前缀均为platform:overseas-warehouse:dispatch:。 创建及执行使用稳定idempotencyKey;重试保留完整原载荷。取消仅限未发车;完成后分别释放司机、车辆。 到仓取货逐原单确认实提和送仓,送仓只开放仓库收货门禁;本土卡车同步原单运输状态,完成须本任务凭证。 + 仓储转运上门取货入仓单审核通过时自动生成待发车排车并绑定原转运单;重复审核不重复建单,主动取消排车时对应转运、尾程、入仓单和待收货计划一并取消。 + PICKUP 维护车辆后复用 dispatches/{dispatchNo}/submit-review、dispatch-review、loading-review 两级审核入口及版本化命令。 + 调度通过后待理货审核,任一级驳回返回车辆信息修改重提;两级通过后进入装货中;逐票上传取货照片并确认后自动进入派送中,逐票上传到仓照片并确认后自动完成取货派送,再走原仓库收货流程,不走仓内拣货扣库存。 + 车辆、司机或计划时刻变化作废本轮审核;运输版本与排车审核版本各自递增,命令使用对应详情的当前版本。 servers: - url: /api/platform/v1/last-leg/dispatches/transport-tasks security: diff --git a/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml index 39b1a15f8..b4bd3a5e0 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-transfer-pickup-v1.yaml @@ -3,12 +3,16 @@ info: title: 仓储转运上门取货与车辆排车 version: 1.0.0 description: | - 审核通过的 WAREHOUSE_PICKUP 外来转运单自动进入待取货候选;客户自送、库存转运、未审核及已完成测量单不进入。 + WAREHOUSE_PICKUP 外来转运单审核通过后,同事务自动生成绑定原单的 PICKUP 排车单(PC号),待分配车辆司机;客户自送、库存转运不自动排车。 + 自动计划暂定为审核时刻发车、24小时后结束,并在备注标明待调度确认;不填写实际发车、实提或送仓事实。 + 取货排车先维护车辆信息、提交调度审核,经调度和理货员同轮审核通过后进入装货中。逐票上传取货照片并确认实提,全车齐全后自动进入派送中;逐票上传独立到仓照片,全部到仓后自动完成取货派送,再继续原仓库收货流程。 + 修改车辆、司机或计划时刻后本轮审核失效,须重新提交;审核不触发仓内拣货或库存扣减。 + 重复审核沿原单锁复用已有任务,不重复建单,也不恢复已主动取消的任务;取消排车时对应转运单、尾程单、入仓单及待PDA计划同事务取消,不再进入取货候选。 入仓取货和出库派送共用 ll_fleet_trip 及车辆司机占用,tripPurpose 分别为 PICKUP / DELIVERY,禁止混用库存出库命令、外接预订及余位发布。 同车多单必须送往同一公共海外仓,原客户归属与转运单保留,不重复生成尾程单、不修改预报箱唛或库存。 取货确认必须填写实提箱数并引用本票本车已上传的 PDF/JPG/PNG 凭证;送仓后开放 PDA 清点。 司机实提箱数不替代仓库实收。全部送仓后结案运输,车辆和司机仍通过原 resources/release 分别结束任务。 - 取消 PLANNED 车次原子释放资源和未执行取货关联,原单重新成为候选;已发车不可取消。 + 取消 PLANNED 车次原子取消本车全部对应转运单、尾程单、入仓单及待PDA计划并释放资源;任一成员已有清点或收货事实则全部回滚,已发车不可取消。 写命令具备车次版本及请求键,同键同内容重放不重复写入,同键不同内容拒绝。普通业务返回 HTTP 200 + body.code。 security: - bearerAuth: [] @@ -79,7 +83,7 @@ paths: parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] post: summary: 逐票确认取货 - description: 仅平台主体,权限 platform:fleet:trip:edit。车次已发车且任务 ASSIGNED,核对本票本车上传归属后转 PICKED,实提箱数不覆盖预报。 + description: 仅平台主体,权限 platform:fleet:trip:edit。两级审核通过的计划车次或已出发车次,任务 ASSIGNED;核对本票本车图片上传归属后转 PICKED,实提箱数不覆盖预报。全车取货照片齐全后自动进入 IN_TRANSIT,排车阶段为 DELIVERING。 requestBody: required: true content: @@ -98,7 +102,7 @@ paths: parameters: [{$ref: '#/components/parameters/tripNo'}, {$ref: '#/components/parameters/customerId'}, {$ref: '#/components/parameters/transferNo'}] post: summary: 确认本票送达海外仓 - description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;开放原 PDA 清点,不直接完成测量或入库。 + description: 仅平台主体,权限 platform:fleet:trip:edit。仅 PICKED 可以转 ARRIVED;evidenceFileIds 必填且为本票本车新上传的图片,不得复用取货照片。Evidence.phase 为 PICKUP 或 ARRIVAL 区分两次照片。全车均 ARRIVED 后自动完成运输,开放原 PDA 清点,不直接完成测量或入库。 requestBody: {$ref: '#/components/requestBodies/command'} responses: {'200': {description: ApiResponse 当前本车 Task 数组;车次版本已更新}} /api/platform/v1/last-leg/fleet/pickups/{tripNo}/complete: @@ -157,3 +161,4 @@ components: recordVersion: {type: integer, format: int64, minimum: 0} idempotencyKey: {type: string, minLength: 8, maxLength: 128} reason: {type: string, maxLength: 1000} + evidenceFileIds: {type: array, minItems: 1, maxItems: 10, items: {type: string}, description: 送仓确认必填,必须为本票本车新上传的到仓图片} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index a5739f874..b1213ba0a 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -5253,6 +5253,14 @@ components: type: object x-java-record: DispatchContracts.DriverEvidence properties: + photoFileIds: + type: array + maxItems: 20 + uniqueItems: true + description: 维护车辆司机时保存的本趟司机照片,按DRIVER_PHOTO类别校验,与驾驶证附件独立。 + items: + type: string + pattern: ^[1-9][0-9]*$ licenseNo: type: string nullable: true diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index d5dcc0e6f..672223c04 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -148,7 +148,7 @@ public class DispatchAggregatePersistenceTest { String dispatch(ResourceMode mode,Long driverId,List ids) throws Exception { var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of()))); var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789", - new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z")))); + new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of()))); f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo(); } /** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */ @@ -591,7 +591,7 @@ public class DispatchAggregatePersistenceTest { return new DispatchCommands.Create(key,"9",new DispatchCommands.Vehicle(ResourceMode.EXTERNAL,null,"TEST-TRUCK","测试承运方", new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)), new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789", - new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400))), + new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())), departure,null,null,sources,List.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java index 87b5f2e07..1d5bdc914 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -157,7 +157,7 @@ class DispatchRepairWorkflowTest { assertThat(created.dispatchNo()).isNotEqualTo("DP-1"); var current=dispatch.detail(aggregateFixture.actor,created.dispatchNo()); assertThat(current.actions().get(DispatchTypes.Action.CANCEL).allowed()).isTrue(); - assertThat(current.actions().get(DispatchTypes.Action.ARRANGE_DELIVERY).allowed()).isFalse(); + assertThat(current.actions()).doesNotContainKey(DispatchTypes.Action.ARRANGE_DELIVERY); assertThat(created.stage()).isEqualTo(DispatchTypes.Stage.BEFORE_LOADING); assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE' AND dispatch_order_id<>1", Long.class)).isEqualTo(1); assertThat(db.queryForObject("SELECT dispatch_order_id FROM ll_dispatch_physical_pallet_lock WHERE active_warehouse_pallet_id=501",Long.class)).isEqualTo(aggregateFixture.repository.find(created.dispatchNo()).getId()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java new file mode 100644 index 000000000..48bbcca4c --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java @@ -0,0 +1,93 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.atLeastOnce; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +/** 车辆维护保存司机照片后,真实资源快照、审核和原键恢复均保留相同引用。 */ +class DispatchResourcePhotosPersistenceTest { + /** 新字段缺省不改变升级前命令及审核依据的摘要,实际照片变化仍必须识别。 */ + @Test void optionalPhotosPreserveLegacyHashesButBindActualReferences() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("legacy-photos", List.of()); + var request = update(input, 0L, "legacy-edit", List.of()); + var oldJson = f.json.valueToTree(request); + ((com.fasterxml.jackson.databind.node.ObjectNode) oldJson.path("driver").path("evidence")).remove("photoFileIds"); + var hashes = new DispatchCommandHasher(f.json); + var action = com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.EDIT; + var oldHash = hashes.command(f.actor, action, "PC-OLD", 0L, oldJson); + assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, request)).isEqualTo(oldHash); + assertThat(hashes.source(request)).isEqualTo(hashes.source(oldJson)); + var withPhotos = update(input, 0L, "legacy-edit", List.of(401L, 402L)); + assertThat(hashes.command(f.actor, action, "PC-OLD", 0L, withPhotos)).isNotEqualTo(oldHash) + .isEqualTo(hashes.command(f.actor, action, "PC-OLD", 0L, update(input, 0L, "legacy-edit", List.of(402L, 401L)))); + } + + /** 使用真实资源持久化与两级审核,防止车队资源规范化吞掉独立司机照片。 */ + @Test void savedPhotosSurviveReplayAndBothReviews() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("photos-create", List.of(f.source(1, "101", 501, "ONT8", 3))); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var request = update(input, created.recordVersion(), "photos-save", List.of(401L)); + var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)); + assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue(); + var approved = f.approve(saved); + var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())); + assertThat(detail.vehicleInfo().driver().evidence().photoFileIds()).containsExactly(401L); + assertThat(detail.vehicleInfo().driver().evidence().fileIds()).containsExactly(202L); + verify(gateway(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L); + assertThat(approved.recordVersion()).isGreaterThan(saved.recordVersion()); + } + + /** 错车或错类附件失败时,事务不得覆盖原资源、推进版本或留下成功命令。 */ + @Test void foreignPhotoCannotChangeSavedResources() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("foreign-photo-create", List.of(f.source(1, "101", 501, "ONT8", 3))); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo()); + doThrow(new IllegalArgumentException("本车分类归属不匹配")).when(gateway(f)) + .metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.DRIVER_PHOTO, 401L); + var request = update(input, created.recordVersion(), "foreign-photo-save", List.of(401L)); + assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request))) + .hasMessageContaining("归属不匹配"); + assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='foreign-photo-save'", Long.class)).isZero(); + } + + /** 编辑清空照片必须真实持久化;历史资源缺该字段时仍按空集合读取。 */ + @Test void photosCanBeRemovedAndOldSnapshotsRemainReadable() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("remove-photo-create", List.of()); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, created.recordVersion(), "add-photo", List.of(401L)))); + f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), update(input, saved.recordVersion(), "remove-photo", List.of()))); + assertThat(f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())).vehicleInfo().driver().evidence().photoFileIds()).isEmpty(); + var legacy = f.json.readValue("{\"licenseNo\":\"TEST\",\"fileIds\":[202]}", DispatchContracts.DriverEvidence.class); + assertThat(legacy.photoFileIds()).isEmpty(); + assertThat(legacy.fileIds()).containsExactly(202L); + } + + /** 仅替换司机照片,保留车辆、驾驶证和计划字段。 */ + private static DispatchCommands.Update update(DispatchCommands.Create input, long version, String key, List photos) { + var d = input.driver(); + var e = d.evidence(); + var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(), + new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos)); + return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片"); + } + + /** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */ + private static DispatchEvidenceGateway gateway(DispatchAggregatePersistenceTest.Fixture f) { + return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index 4c2a439f1..1fea3557a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -11,6 +11,32 @@ import static org.assertj.core.api.Assertions.*; /** 审核两端共用真实持久主单,驳回回客服且不能复用上一轮批准。 */ class DispatchReviewWorkflowPersistenceTest { + /** 取货复用同轮两级审核与驳回重提,不创建仓内拣货任务。 */ + @Test void pickupUsesBothReviewsAndRejectsSkippingOrStaleApproval() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pickup-review",List.of(f.source(1,"101",501,"ONT8",3))))); + f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP' WHERE dispatch_no=?",created.dispatchNo()); + var transport=org.mockito.Mockito.mock(com.baiyue.lastleg.dispatch.transport.TransportTaskService.class); + var trip=new com.baiyue.lastleg.fleet.trip.FleetTripEntity();trip.setStopsJson("[]"); + org.mockito.Mockito.when(transport.transport(org.mockito.ArgumentMatchers.eq(created.dispatchNo()),org.mockito.ArgumentMatchers.anyBoolean())).thenReturn(trip); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"transportTasks",transport); + var submitted=submit(f,created,"pickup-submit");assertQueue(f,created.dispatchNo(),BeforeLoadingSection.DISPATCH_REVIEW); + assertThatThrownBy(()->review(f,f.actor,submitted,true,"skip",Decision.APPROVED,null)).hasMessageContaining("状态已变化"); + var approved=review(f,f.actor,submitted,false,"pickup-dispatch",Decision.APPROVED,null); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.LOADING_REVIEW); + var rejected=review(f,f.actor,approved,true,"pickup-reject",Decision.REJECTED,"车辆不符"); + assertQueue(f,created.dispatchNo(),BeforeLoadingSection.VEHICLE_INFO); + var next=submit(f,rejected,"pickup-resubmit"); + var nextApproved=review(f,f.actor,next,false,"pickup-dispatch-2",Decision.APPROVED,null); + var loaded=review(f,f.actor,nextApproved,true,"pickup-loading-2",Decision.APPROVED,null); + assertThat(loaded.stage()).isEqualTo(Stage.LOADING); + var row=f.repository.find(created.dispatchNo()); + assertThat(row.getReviewState()).isEqualTo("APPROVED"); + assertThat(f.service.actions(f.actor,row).get(Action.PICK).allowed()).isFalse(); + assertThat(f.service.actions(f.actor,row).get(Action.LOADING_COMPLETE).allowed()).isFalse(); + assertThat(f.repository.outbounds(row.getId())).isEmpty(); + org.mockito.Mockito.verify(transport,org.mockito.Mockito.times(5)).verifyPickupReview(created.dispatchNo()); + } /** 客服、调度、理货队列互斥,任一级驳回都只回客服队列。 */ @Test void eachReviewRejectionReturnsOnlyToCustomerServiceQueue() throws Exception { var f=new DispatchAggregatePersistenceTest.Fixture(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java index 0465b9a48..cac9de227 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java @@ -15,6 +15,35 @@ import static org.mockito.ArgumentMatchers.*; import static org.assertj.core.api.Assertions.*; class TransportTaskProjectionTest { + /** 已出发也不能跳过照片阶段,全部取货照片确认后才进入派送中。 */ + @Test void oldDepartureDoesNotSkipPickupPhotosStage() { + var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var pickups=mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); + var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),new ObjectMapper().findAndRegisterModules()); + org.springframework.test.util.ReflectionTestUtils.setField(projection,"pickupFacts",pickups); + var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setRecordVersion(10L);root.setStage("LOADING"); + var task=new com.baiyue.lastleg.fleet.pickup.FleetPickupTask();task.setStatus("ASSIGNED"); + when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(eq(root),anyLong())).thenReturn(1);when(pickups.tasks("TR1")).thenReturn(java.util.List.of(task)); + var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT"); + projection.changed(new TransportTripChanged(trip,"PICKUP_DEPART","出发","OP"));assertThat(root.getStage()).isEqualTo("LOADING"); + task.setStatus("PICKED");projection.changed(new TransportTripChanged(trip,"PICKUP_PICKED","照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERING"); + trip.setStatus("COMPLETED");projection.changed(new TransportTripChanged(trip,"PICKUP_ARRIVED","到仓照片核齐","OP"));assertThat(root.getStage()).isEqualTo("DELIVERED"); + } + /** 审核推进后车次版本可能较小,换车仍须递增主单版本并作废两级审核。 */ + @Test void pickupResourceChangeInvalidatesApprovalWithoutRegressingVersion() throws Exception { + var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class); + var json=new ObjectMapper().findAndRegisterModules(); + var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),json); + var root=new DispatchAggregate();root.setId(2L);root.setDispatchNo("PC1");root.setTaskType("PICKUP");root.setStatus("ACTIVE");root.setStage("LOADING");root.setRecordVersion(10L);root.setReviewRound(1L);root.setReviewState("APPROVED");root.setReviewBasisHash("basis");root.setResourcesJson("{}"); + var review=new com.baiyue.lastleg.dispatch.port.DispatchContracts.Review(com.baiyue.lastleg.dispatch.port.DispatchTypes.ReviewStatus.APPROVED,null,"OP",java.time.Instant.now(),1,"basis",null,null); + root.setDispatchReviewJson(json.writeValueAsString(review));root.setLoadingReviewJson(json.writeValueAsString(review)); + when(links.byTrip(1L)).thenReturn(root);when(roots.lock("PC1")).thenReturn(root);when(roots.saveState(root,10L)).thenReturn(1); + assertThatCode(()->projection.requireApproved(1L)).doesNotThrowAnyException(); + var trip=new FleetTripEntity();trip.setId(1L);trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED");trip.setRecordVersion(4L);trip.setUpdatedAt(LocalDateTime.now()); + projection.changed(new TransportTripChanged(trip,"ASSIGN","换车","OP")); + assertThat(root.getRecordVersion()).isEqualTo(11L);assertThat(root.getReviewState()).isEqualTo("DRAFT");assertThat(root.getStage()).isEqualTo("BEFORE_LOADING"); + assertThatThrownBy(()->projection.requireApproved(1L)).hasMessageContaining("调度审核和理货员审核"); + verify(roots).saveState(root,10L); + } /** 旧取消入口产生的运输事实也必须撤销原单安排和资源投影。 */ @Test void legacyCancellationSynchronizesDispatchAndUnlocksSource(){ var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var trucks=mock(UsTruckOrderRepository.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java index 8279ac045..93563ceec 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java @@ -58,6 +58,30 @@ class TransportTaskServiceTest { @Test void evidenceDownloadRequiresPersistedConfirmation(){ assertThatThrownBy(()->service.evidence("PC1",77L)).hasMessageContaining("没有该完成凭证");verifyNoInteractions(files); } + /** 只对审核通过的上门取货生成排车,已有及取消任务都不得重建。 */ + @Test void approvalCreatesOnlyEligibleUnassignedPickup() { + var facts=mock(FleetPickupRepository.class); + var subject=spy(new TransportTaskService(roots,links,mock(TransportTaskProjection.class),tripRepo,trips, + mock(FleetPickupService.class),facts,trucks,mock(FleetPoolOrderQueries.class),mock(DriverService.class), + mock(FleetTransportVehicleGuard.class),files,new ObjectMapper().findAndRegisterModules())); + var source=new FleetPickupSource();source.setId(42L);source.setTransferNo("ZY1");source.setInboundCode("ZY1"); + source.setDeliveryMethod("WAREHOUSE_PICKUP");source.setStatus("WAIT_PDA_RECEIVING"); + when(facts.source("C1","ZY1",true)).thenReturn(source); + doReturn(null).when(subject).createPickup(anyString(),any()); + subject.ensureApprovedPickup("C1","ZY1","OP"); + var request=org.mockito.ArgumentCaptor.forClass(FleetPickupModels.Create.class); + verify(subject).createPickup(eq("OP"),request.capture()); + assertThat(request.getValue().sources()).containsExactly(new FleetPickupModels.Source("C1","ZY1")); + assertThat(request.getValue().idempotencyKey()).isEqualTo("transfer-approval:42"); + assertThat(request.getValue().expectedDriverEndAt()).isAfter(request.getValue().plannedDepartureAt()); + clearInvocations(subject); + when(facts.lockTask(42L)).thenReturn(new FleetPickupTask()); + subject.ensureApprovedPickup("C1","ZY1","OP2"); + verify(subject,never()).createPickup(anyString(),any()); + source.setDeliveryMethod("CUSTOMER_DELIVERY");clearInvocations(facts); + subject.ensureApprovedPickup("C1","ZY1","OP"); + verify(facts,never()).lockTask(anyLong()); + } /** 构造固定版本的测试执行命令。 */ private TransportTaskModels.Command command(String key,List files){return new TransportTaskModels.Command(3L,key,"结束运输",files);} } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java index 11db2b00a..81001c2a6 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupMapperTest.java @@ -60,4 +60,12 @@ class FleetPickupMapperTest { db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','PICKED',CURRENT_TIMESTAMP)"); assertThat(mapper.unassign("TR-1")).isZero();assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1"); } + /** 联动取消保留原车次身份,已取货成员不能被取消SQL覆盖。 */ + @Test void cancellationKeepsSourceLinkAndDoesNotRewritePickedFacts() { + db.update("INSERT INTO ll_fleet_pickup_task VALUES(1,'C1','WT-1','TR-1','ASSIGNED',CURRENT_TIMESTAMP)"); + db.update("INSERT INTO ll_fleet_pickup_task VALUES(6,'C6','WT-6','TR-1','PICKED',CURRENT_TIMESTAMP)"); + assertThat(mapper.cancelTasks("TR-1")).isEqualTo(1); + assertThat(mapper.task(1L).getTripNo()).isEqualTo("TR-1");assertThat(mapper.task(1L).getStatus()).isEqualTo("CANCELLED"); + assertThat(mapper.task(6L).getStatus()).isEqualTo("PICKED");assertThat(mapper.cancelTasks("TR-1")).isZero(); + } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java index 8fceea32d..cc0c64c15 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/pickup/FleetPickupServiceTest.java @@ -15,11 +15,15 @@ import static org.mockito.ArgumentMatchers.*; class FleetPickupServiceTest { FleetPickupRepository repository;FleetTripService trips;FleetPickupService service; + PickupDepartureReview departureReview; + DriverService drivers;VehicleRepository vehicles; TripOperationsEvidenceGateway files;FleetTripEntity trip;FleetPickupSource source;FleetPickupTask task; final Map commands=new HashMap<>(); @BeforeEach void setup() { repository=mock(FleetPickupRepository.class);trips=mock(FleetTripService.class);files=mock(TripOperationsEvidenceGateway.class); - service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),mock(DriverService.class),mock(VehicleRepository.class),files,new ObjectMapper().findAndRegisterModules()); + departureReview=mock(PickupDepartureReview.class); + drivers=mock(DriverService.class);vehicles=mock(VehicleRepository.class); + service=new FleetPickupService(repository,trips,mock(FleetWarehouseDirectory.class),drivers,vehicles,files,new ObjectMapper().findAndRegisterModules(),departureReview); trip=new FleetTripEntity();trip.setId(9L);trip.setTripNo("TR-1");trip.setTripPurpose("PICKUP");trip.setStatus("IN_TRANSIT");trip.setWarehouseId(100L);trip.setRecordVersion(2L); source=source(1L,"C1","WT-1","100");task=new FleetPickupTask();task.setTransferOrderId(1L);task.setCustomerId("C1");task.setTransferNo("WT-1");task.setTripNo("TR-1");task.setStatus("ASSIGNED"); when(repository.trip("TR-1",true)).thenReturn(trip);when(repository.trip("TR-1",false)).thenReturn(trip); @@ -29,6 +33,29 @@ class FleetPickupServiceTest { doAnswer(c->{commands.put(c.getArgument(1),c.getArgument(2));return null;}).when(repository).command(anyString(),anyString(),anyString()); doAnswer(c->{trip.setRecordVersion(trip.getRecordVersion()+1);return null;}).when(trips).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); } + /** 审核完成后直接在装货阶段上传取货照片,全车核齐自动进入派送。 */ + @Test void pickupPhotosStartDeliveryWithoutSeparateDeparture() { + trip.setStatus("PLANNED");trip.setVehicleId(20L);trip.setDriverId(30L);trip.setExpectedDriverEndAt(LocalDateTime.now(ZoneOffset.UTC).plusDays(1)); + when(repository.ownsResources(9L,20L,30L)).thenReturn(true); + var vehicle=mock(VehicleRepository.VehicleSnapshot.class);when(vehicle.region()).thenReturn("OVERSEAS");when(vehicle.status()).thenReturn("IN_SERVICE");when(vehicles.lockById(20L)).thenReturn(Optional.of(vehicle)); + var driver=mock(DriverModels.Response.class);var details=mock(DriverModels.Draft.class);when(details.region()).thenReturn("OVERSEAS");when(driver.details()).thenReturn(details);when(drivers.requireDispatchable(eq(30L),any())).thenReturn(driver); + when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true); + when(files.metadata(9L,88L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("88","取货照片.jpg","image/jpeg",500)); + var request=new FleetPickupModels.Pickup(2L,"pickup-photo-key",5,List.of(88L),null); + service.picked("TR-1","C1","WT-1","OP",request); + assertThat(task.getStatus()).isEqualTo("PICKED");assertThat(trip.getStatus()).isEqualTo("IN_TRANSIT");assertThat(trip.getDepartedAt()).isNotNull(); + verify(departureReview).requireApproved(9L);verify(trips,times(1)).recordExecution(any(),eq(2L),eq("PICKUP_PICKED"),anyString(),eq("OP")); + service.picked("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task); + } + /** 原车队发车接口不能在两级审核未通过时写入实际发车事实。 */ + @Test void legacyDepartureCannotBypassReview() { + trip.setStatus("PLANNED"); + doThrow(new IllegalStateException("请先完成两级审核")).when(departureReview).requireApproved(9L); + assertThatThrownBy(()->service.depart("TR-1","OP",new FleetPickupModels.Command(2L,"depart-review-key","发车"))).hasMessageContaining("两级审核"); + assertThat(trip.getStatus()).isEqualTo("PLANNED");assertThat(trip.getDepartedAt()).isNull(); + verify(trips,never()).recordExecution(any(),anyLong(),anyString(),anyString(),anyString()); + assertThat(commands).isEmpty(); + } /** 原单所属域锁定来源后,车队仅以该来源核对真实送仓事实。 */ @Test void pickupMustArriveBeforePda() { assertThatThrownBy(()->service.requireArrived(1L,"WAREHOUSE_PICKUP","WT-1","WT-1")).hasMessageContaining("先完成车辆取货送仓"); @@ -79,10 +106,23 @@ class FleetPickupServiceTest { } @Test void arrivalOpensPdaAndCompletionKeepsWarehouseOrderInMeasurement() { task.setStatus("PICKED");task.setActualPackages(5);task.setPickedAt(LocalDateTime.now(ZoneOffset.UTC)); - service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台")); + when(repository.ownsUpload(1L,"TR-1",89L)).thenReturn(true); + when(files.metadata(9L,89L)).thenReturn(new TripOperationsRecordModels.EvidenceFile("89","到仓照片.jpg","image/jpeg",500)); + var request=new FleetPickupModels.Command(2L,"arrive-key-1","已送海外仓月台",List.of(89L)); + service.arrive("TR-1","C1","WT-1","OP",request); assertThat(task.getStatus()).isEqualTo("ARRIVED");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); - service.complete("TR-1","OP",new FleetPickupModels.Command(3L,"complete-key-1","全部送仓")); - assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("complete-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + assertThat(trip.getStatus()).isEqualTo("COMPLETED");assertThat(trip.getCompletionJson()).contains("arrive-key-1");assertThat(source.getStatus()).isEqualTo("WAIT_PDA_RECEIVING"); + assertThat(task.getEvidenceJson()).contains("ARRIVAL","到仓照片"); + service.arrive("TR-1","C1","WT-1","OP",request);verify(repository,times(1)).update(task); + } + /** 到仓必须上传独立的新照片,不能用取货照片冒充或提前开放清点。 */ + @Test void arrivalRequiresFreshPhotoEvidence() { + task.setStatus("PICKED"); + task.setEvidenceJson("[{\"fileId\":\"88\",\"fileName\":\"取货.jpg\",\"contentType\":\"image/jpeg\",\"sizeBytes\":1,\"phase\":\"PICKUP\"}]"); + assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"no-photo","送仓"))).hasMessageContaining("上传到仓照片"); + when(repository.ownsUpload(1L,"TR-1",88L)).thenReturn(true); + assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(2L,"same-photo","送仓",List.of(88L)))).hasMessageContaining("不能复用取货照片"); + assertThat(task.getStatus()).isEqualTo("PICKED");verify(repository,never()).update(any()); } @Test void staleVersionAndWrongTripPurposeAreRejected() { assertThatThrownBy(()->service.arrive("TR-1","C1","WT-1","OP",new FleetPickupModels.Command(1L,"arrive-key-1","送仓"))).hasMessageContaining("车次已变化"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java index 4736446b7..5623e4aa3 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/trip/FleetTripAuthorizationTest.java @@ -122,6 +122,8 @@ class FleetTripAuthorizationTest { @Bean com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository looseboardDemandRepository() { return mock(com.baiyue.lastleg.fleet.business.FleetLooseboardDemandRepository.class); } /** 既有取货能力依赖原仓储引用;权限测试使用只读替身,不连接数据库。 */ @Bean com.baiyue.lastleg.fleet.pickup.FleetPickupRepository pickups() { return mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); } + /** 取消原单使用所属域替身,权限测试不写业务数据。 */ + @Bean com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation pickupCancellation() { return mock(com.baiyue.lastleg.fleet.pickup.PickupOrderCancellation.class); } @Bean com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges workTripChanges() { return mock(com.baiyue.lastleg.fleet.trip.work.AppointmentWorkTripChanges.class); } @Bean com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort workDispatches() { return mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); } @Bean FleetTripCalendarService calendar() { return mock(FleetTripCalendarService.class); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java index 23c7e4aa7..b99ddced9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/pda/PdaOperationsOpenApiContractTest.java @@ -75,7 +75,6 @@ class PdaOperationsOpenApiContractTest { "/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/loading-complete:", "/api/pda/v1/last-leg/dispatches/{dispatchNo}/commands/{idempotencyKey}:", - "/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:", "/api/pda/v1/last-leg/outbound-tasks:", "/api/pda/v1/last-leg/outbound-tasks/resolve/{businessNo}:", "/api/pda/v1/last-leg/outbound-tasks/{taskNo}:", @@ -83,7 +82,8 @@ class PdaOperationsOpenApiContractTest { "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/pallets/{palletNo}/pick:", "/api/pda/v1/last-leg/outbound-tasks/{taskNo}/complete:"); for (String path : paths) assertTrue(yaml.contains(path), path); - assertEquals(36, paths.size()); + assertEquals(35, paths.size()); + assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/depart:")); assertFalse(yaml.contains("/api/pda/v1/last-leg/dispatches/{dispatchNo}/complete:")); assertTrue(yaml.contains("enum: [LOCATION, PALLET]")); assertTrue(yaml.contains("enum: [APPROVE, REWORK]")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationPersistenceTest.java new file mode 100644 index 000000000..c6beb6bd5 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/TransferPickupCancellationPersistenceTest.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.transfer.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.fleet.pickup.FleetPickupModels; +import com.baiyue.lastleg.transfer.mapper.TransferPickupCancellationMapper; +import com.baiyue.lastleg.transfer.repository.TransferPickupCancellationRepository; +import org.h2.jdbcx.JdbcDataSource; +import org.mybatis.spring.SqlSessionTemplate; +import org.junit.jupiter.api.*; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +/** 通过真实SQL验证联动取消、客户隔离以及多票失败回滚。 */ +class TransferPickupCancellationPersistenceTest { + JdbcTemplate db;TransactionTemplate tx;TransferPickupCancellationService service; + /** 每例建立隔离库,保留取消所需真实字段和状态约束。 */ + @BeforeEach void setup() throws Exception { + var ds=new JdbcDataSource();ds.setURL("jdbc:h2:mem:pickup_cancel_"+System.nanoTime()+";MODE=MySQL;DB_CLOSE_DELAY=-1");db=new JdbcTemplate(ds);tx=new TransactionTemplate(new DataSourceTransactionManager(ds)); + db.execute("CREATE TABLE ll_warehouse_transfer_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),transfer_no VARCHAR(64),inbound_code VARCHAR(64),lastleg_order_id BIGINT,inbound_order_id BIGINT,delivery_method VARCHAR(40),status VARCHAR(32),updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),current_status VARCHAR(40),sub_order_status VARCHAR(255),version INT,updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inbound_order(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),status VARCHAR(40),updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT PRIMARY KEY,customer_id VARCHAR(64),source_order_id BIGINT,creation_mode VARCHAR(40),status VARCHAR(40),version INT,updated_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_loading_order_status_history(customer_id VARCHAR(64),order_id BIGINT,from_status VARCHAR(40),to_status VARCHAR(40),event_id VARCHAR(64),remark VARCHAR(1000),created_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_warehouse_transfer_receipt(customer_id VARCHAR(64),transfer_order_id BIGINT)"); + db.execute("CREATE TABLE ll_warehouse_transfer_carton(customer_id VARCHAR(64),transfer_order_id BIGINT,counted_at TIMESTAMP)"); + db.execute("CREATE TABLE ll_warehouse_transfer_pallet(customer_id VARCHAR(64),transfer_order_id BIGINT)"); + for(int i=1;i<=2;i++) { + db.update("INSERT INTO ll_warehouse_transfer_order VALUES(?,? ,? ,? ,?,?,'WAREHOUSE_PICKUP','WAIT_PDA_RECEIVING',CURRENT_TIMESTAMP)",i,"C"+i,"WT"+i,"WT"+i,i,i); + db.update("INSERT INTO ll_order VALUES(?,?,'INBOUND_MEASURING','WAREHOUSE_TRANSFER:APPROVED',0,CURRENT_TIMESTAMP)",i,"C"+i); + db.update("INSERT INTO ll_inbound_order VALUES(?,?,'PENDING_INBOUND',CURRENT_TIMESTAMP)",i,"C"+i); + db.update("INSERT INTO ll_inbound_plan VALUES(?, ?,?,'WAREHOUSE_TRANSFER','PDA_PENDING',0,CURRENT_TIMESTAMP)",i,"C"+i,i); + } + var config=new MybatisConfiguration();config.addMapper(TransferPickupCancellationMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config); + service=new TransferPickupCancellationService(new TransferPickupCancellationRepository(new SqlSessionTemplate(factory.getObject()).getMapper(TransferPickupCancellationMapper.class))); + } + /** 释放本例数据库资源。 */ + @AfterEach void close(){db.execute("SHUTDOWN");} + /** 全车跨客户成员分别取消自己的原单与计划,不影响其他客户。 */ + @Test void cancelsOnlyTheLinkedBusinessAndKeepsAudit() { + tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1")),"TR1","OP","客户取消")); + for(String table:List.of("ll_warehouse_transfer_order","ll_inbound_order","ll_inbound_plan")) { + assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=1",String.class)).isEqualTo("CANCELLED"); + assertThat(db.queryForObject("SELECT status FROM "+table+" WHERE id=2",String.class)).isNotEqualTo("CANCELLED"); + } + assertThat(db.queryForObject("SELECT current_status FROM ll_order WHERE id=1",String.class)).isEqualTo("CANCELLED"); + assertThat(db.queryForObject("SELECT version FROM ll_order WHERE id=1",Integer.class)).isEqualTo(1); + assertThat(db.queryForObject("SELECT remark FROM ll_loading_order_status_history",String.class)).contains("OP","客户取消"); + } + /** 后一票已经清点时,前一票取消及历史写入全部回滚。 */ + @Test void oneStartedMemberRollsBackAllOtherCancellations() { + db.update("INSERT INTO ll_warehouse_transfer_carton VALUES('C2',2,CURRENT_TIMESTAMP)"); + assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C1","WT1"),new FleetPickupModels.Source("C2","WT2")),"TR1","OP","取消"))).hasMessageContaining("不能取消"); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_warehouse_transfer_order WHERE status='CANCELLED'",Integer.class)).isZero(); + assertThat(db.queryForObject("SELECT COUNT(*) FROM ll_loading_order_status_history",Integer.class)).isZero(); + assertThatThrownBy(()->tx.executeWithoutResult(s->service.cancel(List.of(new FleetPickupModels.Source("C2","WT1")),"TR1","OP","取消"))).hasMessageContaining("不能取消"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java index 1ce61ed87..01dc83420 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferCartonCountTest.java @@ -27,7 +27,7 @@ class WarehouseTransferCartonCountTest { service = spy(new WarehouseTransferService()); ReflectionTestUtils.setField(service, "transfers", orders); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); - ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); + ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class))); ReflectionTestUtils.setField(service, "transferCartons", cartons); ReflectionTestUtils.setField(service, "transferItems", items); ReflectionTestUtils.setField(service, "transferPallets", palletMapper); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java index ae4a41420..86125224d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/transfer/service/WarehouseTransferServiceTest.java @@ -202,6 +202,7 @@ class WarehouseTransferServiceTest { @Mock com.baiyue.lastleg.relabel.service.RelabelApplicationService relabelService; private WarehouseTransferService service; @Mock WarehouseTransferReceiptCoordinator receiptCoordinator; + @Mock com.baiyue.lastleg.dispatch.transport.TransportTaskService transportTasks; @Mock com.baiyue.lastleg.fleet.customer.FleetCustomerDirectory customerDirectory; private com.baiyue.lastleg.service.LastlegDocumentNumberGenerator compactNumbers; @@ -209,6 +210,7 @@ class WarehouseTransferServiceTest { void setUp() { compactNumbers = org.mockito.Mockito.mock(com.baiyue.lastleg.service.LastlegDocumentNumberGenerator.class); service = new WarehouseTransferService(); + ReflectionTestUtils.setField(service, "transportTaskService", transportTasks); var measurementRepository = new com.baiyue.lastleg.transfer.repository.TransferMeasurementRepository(); ReflectionTestUtils.setField(measurementRepository, "cartons", transferCartons); ReflectionTestUtils.setField(service, "transferMeasurementRepository", measurementRepository); @@ -233,7 +235,7 @@ class WarehouseTransferServiceTest { }); ReflectionTestUtils.setField(service, "transfers", transfers); var pickupRepository = org.mockito.Mockito.mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); - ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper())); + ReflectionTestUtils.setField(service, "pickupService", new com.baiyue.lastleg.fleet.pickup.FleetPickupService(pickupRepository,null,null,null,null,null,new ObjectMapper(),mock(com.baiyue.lastleg.fleet.pickup.PickupDepartureReview.class))); ReflectionTestUtils.setField(service, "transferItems", transferItems); ReflectionTestUtils.setField(service, "transferCartons", transferCartons); ReflectionTestUtils.setField(service, "sourceOrders", sourceOrders); @@ -724,6 +726,7 @@ class WarehouseTransferServiceTest { assertEquals(5, line.getValue().getRequiredPackageCount()); assertEquals("PENDING_INBOUND", inbound.getStatus()); assertEquals(WarehouseTransferService.WAIT_PDA_RECEIVING, transfer.getStatus()); + verify(transportTasks).ensureApprovedPickup("TENANT-A", "WTR-1", "USER-1"); } @Test From 044812cd900de6584b7ccfc29eb567ae562bdd44 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 17:52:28 +0800 Subject: [PATCH 068/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dispatch/port/DispatchContracts.java | 7 +- .../service/DispatchAggregateService.java | 2 + .../transport/TransportReviewPhotos.java | 81 +++++++++++++++++++ .../transport/TransportTaskController.java | 2 +- .../transport/TransportTaskModels.java | 8 +- .../transport/TransportTaskProjection.java | 29 ++++++- .../transport/TransportTaskService.java | 21 ++++- .../openapi/dispatch-transport-tasks-v1.yaml | 6 +- .../transport/TransportReviewPhotosTest.java | 73 +++++++++++++++++ 9 files changed, 218 insertions(+), 11 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotos.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java index d4c8f3e8c..c1ed5a6c9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchContracts.java @@ -25,7 +25,12 @@ public final class DispatchContracts { /** 临时资源证据只归本趟,不能据此自动建车辆或司机主档。 */ public record DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, - List fileIds, Instant expectedTaskEndAt, List photoFileIds) { + List fileIds, Instant expectedTaskEndAt, List photoFileIds, + @com.fasterxml.jackson.annotation.JsonInclude(com.fasterxml.jackson.annotation.JsonInclude.Include.NON_NULL) Long masterLicenseFileId) { + /** 普通派送继续按既有主档证照语义构造,取货显式记录主档引用来源。 */ + public DriverEvidence(String licenseNo, LocalDate licenseExpiresOn, List fileIds, Instant expectedTaskEndAt, List photoFileIds) { + this(licenseNo, licenseExpiresOn, fileIds, expectedTaskEndAt, photoFileIds, null); + } /** 司机照片独立于驾驶证,缺省为空且引用须有界、有效、不重复。 */ public DriverEvidence { photoFileIds = ReviewAttachments.checked(photoFileIds); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 397f2b149..8f65aba51 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -274,6 +274,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var resources=read(row.getResourcesJson(),Resources.class);var driver=resources==null?null:resources.driver(); if(driver==null || driver.mode()!=ResourceMode.MASTER || driver.evidence()==null || driver.evidence().fileIds()==null || !driver.evidence().fileIds().contains(fileId))return null; + // 取货允许补传本车驾驶证,仅明确冻结的主档引用才走司机归属。 + if("PICKUP".equals(row.getTaskType()) && !Objects.equals(driver.evidence().masterLicenseFileId(),fileId))return null; if(driver.driverId()==null || driver.driverId()<=0)throw bad("排车冻结司机档案身份无效,不能读取证照"); return driver.driverId(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotos.java new file mode 100644 index 000000000..dd9bb9a7b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotos.java @@ -0,0 +1,81 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.fleet.driver.DriverService; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.ZoneOffset; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.*; + +/** 取货审核照片按本车分类校验,驾驶证主档引用明确区分于本车补传。 */ +@Service +public class TransportReviewPhotos { + private final DispatchEvidenceGateway files; + private final DriverService drivers; + private final ObjectMapper json; + /** 注入正式文件归属与司机资格入口。 */ + public TransportReviewPhotos(DispatchEvidenceGateway files, DriverService drivers, ObjectMapper json) { + this.files=files; this.drivers=drivers; this.json=json; + } + /** 读取已保存资源;空排车还没有照片。 */ + public Resources resources(DispatchAggregate row) { + if(row.getResourcesJson()==null)return new Resources(null,null); + try{return json.readValue(row.getResourcesJson(),Resources.class);} + catch(Exception e){throw new IllegalStateException("取货照片快照读取失败",e);} + } + /** 校验三类照片及主档身份,换车或换司机不能沿用旧对象的照片。 */ + public DriverEvidence validate(DispatchAggregate row, FleetTripEntity trip, TransportTaskModels.Assign request) { + var photos=request.reviewPhotos(); + if(photos==null || photos.vehicleFileIds().isEmpty() || photos.driverPhotoFileIds().isEmpty() + || photos.licenseFileIds().isEmpty() && request.masterLicenseFileId()==null) + throw conflict("请补齐车辆图片、司机照片和驾驶证照片后保存"); + var old=resources(row); + if(old.vehicle()!=null && !Objects.equals(old.vehicle().vehicleId(),request.vehicleId())) + rejectReused(old.vehicle().evidenceFileIds(),photos.vehicleFileIds()); + if(old.driver()!=null && old.driver().evidence()!=null && !Objects.equals(old.driver().driverId(),request.driverId())) { + rejectReused(old.driver().evidence().photoFileIds(),photos.driverPhotoFileIds()); + rejectReused(old.driver().evidence().fileIds(),photos.licenseFileIds()); + } + var driver=drivers.requireDispatchable(request.driverId(),trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC)); + if(!"OVERSEAS".equals(driver.details().region()))throw conflict("请选择海外司机"); + verify(row.getId(),VEHICLE,photos.vehicleFileIds()); + verify(row.getId(),DRIVER_PHOTO,photos.driverPhotoFileIds()); + verify(row.getId(),DRIVER,photos.licenseFileIds()); + var licenseIds=new ArrayList<>(photos.licenseFileIds()); + if(request.masterLicenseFileId()!=null) { + if(!request.masterLicenseFileId().toString().equals(driver.licenseFileId()))throw conflict("司机主档驾驶证已变化,请重新选择司机"); + drivers.download(request.driverId(),request.masterLicenseFileId()); + if(licenseIds.contains(request.masterLicenseFileId()))throw conflict("驾驶证主档引用不能作为本车上传重复提交"); + licenseIds.add(request.masterLicenseFileId()); + } + return new DriverEvidence(driver.details().licenseNumber(),driver.details().licenseExpiresOn(),List.copyOf(licenseIds), + trip.getExpectedDriverEndAt().toInstant(ZoneOffset.UTC),photos.driverPhotoFileIds(),request.masterLicenseFileId()); + } + /** 提交和两级通过均复验照片,避免仅前端必填而绕过审核依据。 */ + public void verifyReview(DispatchAggregate row, FleetTripEntity trip) { + var saved=resources(row);var driver=saved.driver();var vehicle=saved.vehicle(); + if(vehicle==null || driver==null || driver.evidence()==null + || !Objects.equals(vehicle.vehicleId(),trip.getVehicleId()) || !Objects.equals(driver.driverId(),trip.getDriverId())) + throw conflict("请维护车辆司机并补齐审核照片"); + var e=driver.evidence(); + var licenses=e.fileIds()==null?List.of():e.fileIds().stream().filter(id->!Objects.equals(id,e.masterLicenseFileId())).toList(); + validate(row,trip,new TransportTaskModels.Assign(trip.getRecordVersion(),trip.getVehicleId(),trip.getDriverId(),"核验取货照片", + row.getRecordVersion(),new ReviewAttachments(vehicle.evidenceFileIds(),e.photoFileIds(),licenses),e.masterLicenseFileId())); + } + /** 不把同排车旧司机或车辆的上传当成新对象的证据。 */ + private void rejectReused(List old,List selected) { + if(old!=null && selected.stream().anyMatch(old::contains))throw conflict("车辆或司机已更换,请重新上传对应照片"); + } + /** 文件网关逐项校验平台、本车和文件类别,失效或跨单引用直接失败。 */ + private void verify(long dispatch,DispatchEvidenceGateway.Kind kind,List ids) { + for(long id:ids)files.metadata(dispatch,kind,id); + } + /** 返回可处理的业务冲突。 */ + private static ResponseStatusException conflict(String message) {return new ResponseStatusException(HttpStatus.CONFLICT,message);} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java index 59ac4f516..f4287c7e9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskController.java @@ -39,7 +39,7 @@ public class TransportTaskController { public ApiResponse detail(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number){operator(user);return ApiResponse.success(service.detail(number));} /** 分配或改派真实车辆司机。 */ @PostMapping("/{number}/assign") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") - public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));} + public ApiResponse assign(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody TransportTaskModels.Assign request){return ApiResponse.success(service.assign(number,operator(user),request));} /** 取消未发车任务,来源回到待安排。 */ @PostMapping("/{number}/cancel") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:cancel')") public ApiResponse cancel(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String number,@Valid @RequestBody FleetTripModels.Cancel request){return ApiResponse.success(service.cancel(number,operator(user),request));} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java index ff74f2627..47a460c8e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskModels.java @@ -18,6 +18,12 @@ public final class TransportTaskModels { @NotBlank @Size(max=1000) String reason,List<@Positive Long> evidenceFileIds) { } public record Detail(String dispatchNo,String taskType,FleetTripModels.Detail execution, String customerId,String orderNo,List pickups, - List completionEvidence) { } + List completionEvidence, + Long dispatchRecordVersion, com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources resources) { } + public record Assign(@NotNull @Min(0) Long recordVersion, @NotNull @Positive Long vehicleId, + @NotNull @Positive Long driverId, @NotBlank @Size(max=1000) String reason, + @Min(0) Long dispatchRecordVersion, + @Valid com.baiyue.lastleg.dispatch.port.DispatchContracts.ReviewAttachments reviewPhotos, + @Positive Long masterLicenseFileId) { } public record TruckLink(String dispatchNo,String tripNo,String customerId,String orderNo) { } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java index 4a9b5085d..033c2a4c6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjection.java @@ -95,7 +95,7 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup. try { var old=json.readValue(value,Review.class); return write(new Review(old.status()==ReviewStatus.REJECTED?ReviewStatus.REJECTED:ReviewStatus.NOT_SUBMITTED, - old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机或计划已变更,请重新提交两级审核",old.attachments())); + old.reason(),old.reviewerId(),old.reviewedAt(),round,null,"车辆、司机、照片或计划已变更,请重新提交两级审核",old.attachments())); } catch(com.fasterxml.jackson.core.JsonProcessingException e){throw new IllegalStateException("取货审核记录读取失败",e);} } /** 本土卡车只回写真实运输状态并追加原业务不可变历史。 */ @@ -111,9 +111,32 @@ public class TransportTaskProjection implements com.baiyue.lastleg.fleet.pickup. } /** 主单仅展示真实主档分配,不伪造临时证照或仓库装载事实。 */ private void resources(DispatchAggregate row,FleetTripEntity trip) { + var old=readResources(row); row.setVehiclePlate(trip.getVehiclePlate());row.setResourcesJson(write(new Resources( - trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),List.of()), - trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(),null)))); + trip.getVehicleId()==null?null:new VehicleSnapshot(ResourceMode.MASTER,trip.getVehicleId(),trip.getVehiclePlate(),null,null,new Capacity(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()), + old.vehicle()!=null && Objects.equals(old.vehicle().vehicleId(),trip.getVehicleId())?old.vehicle().evidenceFileIds():List.of()), + trip.getDriverId()==null?null:new DriverSnapshot(ResourceMode.MASTER,trip.getDriverId(),trip.getDriverName(),trip.getDriverPhone(), + old.driver()!=null && Objects.equals(old.driver().driverId(),trip.getDriverId())?old.driver().evidence():null)))); + } + /** 照片变更单独纳入主单版本和审核依据,资源分配失败时由外层事务一起回滚。 */ + public void savePhotos(String number,String operator,List vehiclePhotos,DriverEvidence driverPhotos) { + var row=roots.lock(number);var old=readResources(row);var v=old.vehicle();var d=old.driver(); + if(v==null || d==null)throw new IllegalStateException("请先完成车辆司机分配"); + var next=new Resources(new VehicleSnapshot(v.mode(),v.vehicleId(),v.plate(),v.carrierName(),v.vehicleStatus(),v.capacity(),vehiclePhotos), + new DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(),driverPhotos)); + if(next.equals(old))return; + long version=row.getRecordVersion();row.setResourcesJson(write(next));row.setRecordVersion(version+1); + row.setUpdatedAt(LocalDateTime.now(ZoneOffset.UTC));row.setReviewState("DRAFT");row.setReviewBasisHash(null);row.setStage("BEFORE_LOADING"); + row.setDispatchReviewJson(invalidated(row.getDispatchReviewJson(),row.getReviewRound())); + row.setLoadingReviewJson(invalidated(row.getLoadingReviewJson(),row.getReviewRound())); + if(roots.saveState(row,version)!=1)throw new IllegalStateException("取货照片保存版本冲突"); + roots.history(IdWorker.getId(),row,"PICKUP_RESOURCE_PHOTOS",operator,"保存车辆、司机和驾驶证审核照片",write(row),row.getUpdatedAt()); + } + /** 状态投影只保留同一资源的已保存照片,不能在运输流转中清空审核证据。 */ + private Resources readResources(DispatchAggregate row) { + if(row.getResourcesJson()==null)return new Resources(null,null); + try{return json.readValue(row.getResourcesJson(),Resources.class);} + catch(Exception e){throw new IllegalStateException("运输资源照片读取失败",e);} } /** 保存明确的业务快照,失败时终止事务。 */ private String write(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw new IllegalStateException("运输快照序列化失败",e);}} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java index d661e2cb1..7368d9cb3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/transport/TransportTaskService.java @@ -35,6 +35,7 @@ public class TransportTaskService { private final FleetTransportVehicleGuard vehicles; private final TripOperationsEvidenceGateway files; private final ObjectMapper json; + @jakarta.annotation.Resource private TransportReviewPhotos reviewPhotos; /** 复用已有资格、占用、原单及凭证能力。 */ public TransportTaskService(DispatchAggregateRepository roots,TransportTaskRepository links,TransportTaskProjection projection, FleetTripRepository tripRepo,FleetTripService trips,FleetPickupService pickups,FleetPickupRepository pickupFacts, @@ -95,7 +96,8 @@ public class TransportTaskService { var trip=transport(number,false);var row=links.byTrip(trip.getId());var link=links.truck(number); return new TransportTaskModels.Detail(number,row.getTaskType(),trips.detail(trip.getTripNo()),link==null?null:link.customerId(),link==null?null:link.orderNo(), "PICKUP".equals(row.getTaskType())?pickups.tasks(trip.getTripNo()):List.of(), - evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList()); + evidenceIds(number).stream().map(id->files.metadata(trip.getId(),id)).toList(),row.getRecordVersion(), + "PICKUP".equals(row.getTaskType())?reviewPhotos.resources(row):null); } /** 仅允许读取已确认属于本排车的完成凭证,文件元数据再次核对资源归属。 */ public byte[] evidence(String number,long id){var trip=transport(number,false);if(!evidenceIds(number).contains(id))throw missing("当前排车没有该完成凭证");files.metadata(trip.getId(),id);return files.download(trip.getId(),id);} @@ -107,10 +109,20 @@ public class TransportTaskService { if(!Set.of("PICKUP","LOCAL_TRUCK").contains(Objects.toString(root.getTaskType(),"")))throw conflict("当前排车单不是运输任务"); String tripNo=links.tripNo(root.getTripId());var trip=lock?tripRepo.lockTrip(tripNo):tripRepo.findTrip(tripNo);if(trip==null)throw missing("运输资源记录不存在");return trip; } - /** 分配实际资源,容量超限时整次分配回滚。 */ + /** 资源与三类照片同事务保存;双版本阻止审核期间的旧页面覆盖。 */ @Transactional - public TransportTaskModels.Detail assign(String number,String operator,FleetTripModels.Assign request) { - var trip=transport(number,true);trips.assign(trip.getTripNo(),operator,request); + public TransportTaskModels.Detail assign(String number,String operator,TransportTaskModels.Assign request) { + var trip=transport(number,true); + com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence photos=null; + if("PICKUP".equals(trip.getTripPurpose())) { + var row=roots.lock(number); + if(!Objects.equals(trip.getRecordVersion(),request.recordVersion()) || request.dispatchRecordVersion()==null + || !Objects.equals(row.getRecordVersion(),request.dispatchRecordVersion()))throw conflict("排车或审核信息已变化,请刷新后重试"); + if(!"PLANNED".equals(trip.getStatus()) || pickups.tasks(trip.getTripNo()).stream().anyMatch(task->!"ASSIGNED".equals(task.status())))throw conflict("已取货的任务不能更换资源照片"); + photos=reviewPhotos.validate(row,trip,request); + } + trips.assign(trip.getTripNo(),operator,new FleetTripModels.Assign(request.recordVersion(),request.vehicleId(),request.driverId(),request.reason())); + if(photos!=null)projection.savePhotos(number,operator,request.reviewPhotos().vehicleFileIds(),photos); if("LOCAL_TRUCK".equals(trip.getTripPurpose()))capacity(tripRepo.findTrip(trip.getTripNo()),truck(number));return detail(number); } /** 取货提交与两级通过时复验真实占用、有效证照和车辆容量。 */ @@ -120,6 +132,7 @@ public class TransportTaskService { if(trip.getPlannedDepartureAt()==null || trip.getExpectedDriverEndAt()==null || !trip.getExpectedDriverEndAt().isAfter(trip.getPlannedDepartureAt()) || !trip.getExpectedDriverEndAt().isAfter(now()))throw conflict("请维护有效的计划发车与任务结束时间"); var pallets=trip.getMaxPalletSpaces();var weight=trip.getMaxLoadKg();var volume=trip.getMaxVolumeCbm(); requireResources(trip); + reviewPhotos.verifyReview(roots.find(number),trip); if(!Objects.equals(pallets,trip.getMaxPalletSpaces()) || !Objects.equals(weight,trip.getMaxLoadKg()) || !Objects.equals(volume,trip.getMaxVolumeCbm()))throw conflict("车辆主档已变化,请重新维护车辆信息"); } /** 取消运输并保留原单、排车及资源审计。 */ diff --git a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml index 15cb98986..622547914 100644 --- a/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/dispatch-transport-tasks-v1.yaml @@ -42,7 +42,7 @@ paths: - { name: number, in: path, required: true, schema: { type: string }, description: 真实PC排车业务号 } get: summary: 排车运输详情(list) - description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)。 + description: data包含dispatchNo、taskType(PICKUP/LOCAL_TRUCK)、execution(现有资源与执行详情)、customerId、orderNo、pickups、completionEvidence(已确认凭证)、dispatchRecordVersion(排车审核版本)、resources(取货车辆司机照片快照;其他类型为null)。resources.driver.evidence.masterLicenseFileId仅表示司机主档驾驶证来源,包含在fileIds内;其他fileIds属于本车补传。 responses: *response /{number}/{action}: parameters: @@ -52,6 +52,10 @@ paths: summary: 分配、取消、资源可用性查询与实际任务释放 description: >- assign(edit):recordVersion、vehicleId、driverId、reason;cancel(cancel):recordVersion、reason。 + PICKUP分配还必须提交dispatchRecordVersion和reviewPhotos(vehicleFileIds、driverPhotoFileIds、licenseFileIds),每类最多20个唯一正整数ID。 + 车辆图片和司机照片各至少一张,驾驶证须有本车licenseFileIds或masterLicenseFileId;后者仅可引用所选司机当前有效主档驾驶证,不放入请求的licenseFileIds。 + 所有补传经dispatches/{number}/evidence/{VEHICLE|DRIVER_PHOTO|DRIVER}上传并校验本车类别归属;主档预览复用司机档案受权下载。 + 资源、照片与审核失效同事务,双版本过期或任一照片不合法则不分配资源。重新换车/司机不得沿用对应旧照片,照片修改必须重新两级审核,运输状态同步保留同一资源的照片。 resource-occupancy(list):kind=VEHICLE/DRIVER、resourceIds数组。 release(edit):recordVersion、resourceKind、actualEndAt、endLocation、vehicleStatus(车辆必填)、idempotencyKey、reason。 responses: *response diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java new file mode 100644 index 000000000..127eea75e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java @@ -0,0 +1,73 @@ +package com.baiyue.lastleg.dispatch.transport; + +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.fleet.driver.*; +import com.baiyue.lastleg.fleet.trip.FleetTripEntity; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.*; +import java.time.*; +import java.util.*; +import static com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; +import static com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +class TransportReviewPhotosTest { + final ObjectMapper json=new ObjectMapper().findAndRegisterModules(); + final DispatchEvidenceGateway files=mock(DispatchEvidenceGateway.class); + final DriverService drivers=mock(DriverService.class); + final TransportReviewPhotos service=new TransportReviewPhotos(files,drivers,json); + final DispatchAggregate row=new DispatchAggregate(); + final FleetTripEntity trip=new FleetTripEntity(); + /** 使用已审核海外司机及未来任务时间,不依赖运行中的服务。 */ + @BeforeEach void setup() { + row.setId(10L);row.setRecordVersion(9L); + trip.setVehicleId(1L);trip.setDriverId(2L);trip.setRecordVersion(4L);trip.setExpectedDriverEndAt(LocalDateTime.of(2099,1,1,0,0)); + var response=mock(DriverModels.Response.class,RETURNS_DEEP_STUBS); + when(response.details().region()).thenReturn("OVERSEAS");when(response.details().licenseNumber()).thenReturn("TEST"); + when(response.details().licenseExpiresOn()).thenReturn(LocalDate.of(2099,2,1));when(response.licenseFileId()).thenReturn("33"); + when(drivers.requireDispatchable(eq(2L),any())).thenReturn(response); + } + /** 必填照片不足时不能调用文件或资源保存。 */ + @Test void requiresAllThreeCategories() { + for(var photos:List.of(new ReviewAttachments(List.of(),List.of(22L),List.of(23L)), + new ReviewAttachments(List.of(21L),List.of(),List.of(23L)),new ReviewAttachments(List.of(21L),List.of(22L),List.of()))) { + assertThatThrownBy(()->service.validate(row,trip,request(photos,null))).hasMessageContaining("补齐"); + } + verifyNoInteractions(files,drivers); + } + /** 主档与本车补传走各自归属,禁止拿别人的证照冒充当前司机。 */ + @Test void distinguishesMasterLicenseFromDispatchUploads() { + var evidence=service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of()),33L)); + assertThat(evidence.fileIds()).containsExactly(33L);assertThat(evidence.masterLicenseFileId()).isEqualTo(33L); + verify(drivers).download(2L,33L);verify(files,never()).metadata(10L,DRIVER,33L); + var uploaded=service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(23L)),null)); + assertThat(uploaded.masterLicenseFileId()).isNull();verify(files).metadata(10L,DRIVER,23L); + assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of()),99L))).hasMessageContaining("主档驾驶证已变化"); + } + /** 同车不同分类及跨排车文件由文件网关拒绝。 */ + @Test void rejectsForeignOrWrongCategoryPhoto() { + when(files.metadata(10L,DRIVER_PHOTO,22L)).thenThrow(new IllegalArgumentException("文件归属错误")); + assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(23L)),null))).hasMessageContaining("归属错误"); + } + /** 换司机后旧人像不允许重复绑定。 */ + @Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception { + row.setResourcesJson(json.writeValueAsString(new Resources(null,new DriverSnapshot(ResourceMode.MASTER,5L,"原司机","000", + new DriverEvidence(null,null,List.of(23L),null,List.of(22L)))))); + assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(24L)),null))).hasMessageContaining("重新上传"); + } + /** 审核复验已保存证据,照片后续失效也不能通过。 */ + @Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception { + row.setResourcesJson(json.writeValueAsString(new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,null,null,List.of(21L)), + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L)))))); + service.verifyReview(row,trip);verify(files).metadata(10L,VEHICLE,21L); + when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效")); + assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效"); + } + /** 构造同一车辆、司机的双版本保存请求。 */ + private TransportTaskModels.Assign request(ReviewAttachments photos,Long master) { + return new TransportTaskModels.Assign(4L,1L,2L,"维护照片",9L,photos,master); + } +} From 6d04659ced96c7cff27ed81cfc483bf2a07ba6be Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 18:00:13 +0800 Subject: [PATCH 069/113] =?UTF-8?q?=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../analyze-lastleg-dependencies.test.mjs | 2 ++ .../openapi/overseas-business-v1.yaml | 5 +++++ .../DispatchAggregatePersistenceTest.java | 19 +++++++++++++++++ .../TransportTaskProjectionTest.java | 21 +++++++++++++++++++ .../transport/TransportTaskServiceTest.java | 8 +++++++ 5 files changed, 55 insertions(+) diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs index b8baaae29..b1b7ab34f 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -82,6 +82,8 @@ test('fleet stays a business slice and its policies have no infrastructure depen 'TransportTaskProjection->trip.FleetTripEntity', 'TransportTaskProjection->trip.TransportTripChanged', 'TransportTaskService->business.FleetPoolOrderQueries', 'TransportTaskService->driver.DriverService', 'TransportTaskService->reporting.TripOperationsEvidenceGateway', + // 取货照片沿既有运输边界读取车次身份/时间,并复用司机资格与证照授权;不开放 Mapper。 + 'TransportReviewPhotos->driver.DriverService', 'TransportReviewPhotos->trip.FleetTripEntity', ].some(reference => `${edge.source}->${edge.target}` === `com.baiyue.lastleg.dispatch.transport.${reference.replace('->', '->com.baiyue.lastleg.fleet.')}`)), 'dispatch must use fleet ports and guards, not its persistence mappers'); const incomingEdges = report.classDependencies.filter(edge => edge.source.startsWith('com.baiyue.lastleg.transfer.') && edge.target.startsWith('com.baiyue.lastleg.fleet.')); diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index b1213ba0a..cae109929 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -5253,6 +5253,11 @@ components: type: object x-java-record: DispatchContracts.DriverEvidence properties: + masterLicenseFileId: + type: string + pattern: ^[1-9][0-9]*$ + nullable: true + description: 取货排车显式冻结的司机主档驾驶证ID,包含在响应fileIds中;其余取货证照为本车上传。普通派送沿既有MASTER证照语义。 photoFileIds: type: array maxItems: 20 diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 672223c04..ffefff7f4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -86,6 +86,25 @@ public class DispatchAggregatePersistenceTest { verify(e.files,never()).download(anyLong(),anyLong());verifyNoInteractions(e.dispatchFiles); } + /** 取货已保存的补传证照保持本车归属,只有明确主档引用可以读取司机证照。 */ + @Test void pickupSavedLicenseUploadsDoNotBecomeMasterLicenseReferences() throws Exception { + var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L,91L)); + var row=e.f.repository.find(number); + var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class); + var d=resources.driver();var proof=d.evidence(); + var next=new DispatchContracts.Resources(resources.vehicle(),new DispatchContracts.DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(), + new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L))); + e.f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP', resources_json=? WHERE dispatch_no=?",e.f.json.writeValueAsString(next),number); + var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; + var uploaded=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"pickup.png","image/png",3L); + when(e.dispatchFiles.metadata(row.getId(),kind,91L)).thenReturn(uploaded); + assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,91L)).isEqualTo(uploaded); + verifyNoInteractions(e.driverRows,e.history,e.files); + e.allowFile(81L); + assertThat(e.f.service.evidenceMetadata(e.f.actor,number,kind,81L).fileId()).isEqualTo(81L); + verify(e.files).metadata(71L,81L); + } + /** MASTER改外配的编辑尚未保存时,新上传文件只走本车归属,不误借旧司机白名单。 */ @Test void unsavedExternalUploadOnMasterDispatchRetainsOriginalDispatchFileReads() throws Exception { var e=new DriverEvidenceFixture();var number=e.dispatch(ResourceMode.MASTER,71L,List.of(81L)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java index cac9de227..8a8448185 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskProjectionTest.java @@ -15,6 +15,27 @@ import static org.mockito.ArgumentMatchers.*; import static org.assertj.core.api.Assertions.*; class TransportTaskProjectionTest { + /** 照片写入使审核失效,后续出发、到仓、结案均保留同一资源的照片。 */ + @Test void photosInvalidateReviewAndSurviveExecutionProjection() throws Exception { + var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class); + var json=new ObjectMapper().findAndRegisterModules(); + var projection=new TransportTaskProjection(roots,links,mock(UsTruckOrderRepository.class),mock(DispatchNumberGenerator.class),json); + org.springframework.test.util.ReflectionTestUtils.setField(projection,"pickupFacts",mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class)); + var row=new DispatchAggregate();row.setId(2L);row.setDispatchNo("PC1");row.setTaskType("PICKUP");row.setStatus("ACTIVE");row.setRecordVersion(10L);row.setReviewRound(1L);row.setReviewState("APPROVED");row.setStage("LOADING"); + var oldReview=new com.baiyue.lastleg.dispatch.port.DispatchContracts.Review(com.baiyue.lastleg.dispatch.port.DispatchTypes.ReviewStatus.APPROVED,null,"OP",java.time.Instant.now(),1,"basis",null,null); + row.setDispatchReviewJson(json.writeValueAsString(oldReview));row.setLoadingReviewJson(json.writeValueAsString(oldReview)); + row.setResourcesJson("{\"vehicle\":{\"mode\":\"MASTER\",\"vehicleId\":7,\"evidenceFileIds\":[]},\"driver\":{\"mode\":\"MASTER\",\"driverId\":8}}"); + when(roots.lock("PC1")).thenReturn(row);when(roots.saveState(eq(row),anyLong())).thenReturn(1);when(links.byTrip(1L)).thenReturn(row); + var photos=new com.baiyue.lastleg.dispatch.port.DispatchContracts.DriverEvidence("LICENSE",java.time.LocalDate.of(2099,1,1),java.util.List.of(33L),java.time.Instant.parse("2098-01-01T00:00:00Z"),java.util.List.of(22L),33L); + projection.savePhotos("PC1","OP",java.util.List.of(21L),photos); + assertThat(row.getReviewState()).isEqualTo("DRAFT");assertThat(row.getRecordVersion()).isEqualTo(11L);assertThat(row.getStage()).isEqualTo("BEFORE_LOADING"); + var trip=new FleetTripEntity();trip.setId(1L);trip.setTripNo("TR1");trip.setTripPurpose("PICKUP");trip.setVehicleId(7L);trip.setDriverId(8L);trip.setStatus("IN_TRANSIT"); + projection.changed(new TransportTripChanged(trip,"PICKUP_PICKED","装货","OP")); + trip.setStatus("COMPLETED");projection.changed(new TransportTripChanged(trip,"PICKUP_ARRIVED","到仓","OP")); + var saved=json.readValue(row.getResourcesJson(),com.baiyue.lastleg.dispatch.port.DispatchContracts.Resources.class); + assertThat(saved.vehicle().evidenceFileIds()).containsExactly(21L);assertThat(saved.driver().evidence()).isEqualTo(photos); + assertThat(row.getStage()).isEqualTo("DELIVERED"); + } /** 已出发也不能跳过照片阶段,全部取货照片确认后才进入派送中。 */ @Test void oldDepartureDoesNotSkipPickupPhotosStage() { var roots=mock(DispatchAggregateRepository.class);var links=mock(TransportTaskRepository.class);var pickups=mock(com.baiyue.lastleg.fleet.pickup.FleetPickupRepository.class); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java index 93563ceec..7817a3072 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportTaskServiceTest.java @@ -22,6 +22,14 @@ class TransportTaskServiceTest { DispatchAggregateRepository roots;TransportTaskRepository links;FleetTripRepository tripRepo;FleetTripService trips; UsTruckOrderRepository trucks;TripOperationsEvidenceGateway files;TransportTaskService service;FleetTripEntity trip;UsTruckOrder order; final Map commands=new HashMap<>(); + /** 审核已改变的旧页面不能通过仍相同的车次版本覆盖资源和照片。 */ + @Test void staleDispatchVersionCannotAssignEvenWhenTripVersionMatches() { + var root=roots.find("PC1");root.setTaskType("PICKUP");root.setRecordVersion(9L);when(roots.lock("PC1")).thenReturn(root); + trip.setTripPurpose("PICKUP");trip.setStatus("PLANNED"); + var photos=mock(TransportReviewPhotos.class);org.springframework.test.util.ReflectionTestUtils.setField(service,"reviewPhotos",photos); + assertThatThrownBy(()->service.assign("PC1","OP",new TransportTaskModels.Assign(3L,7L,8L,"保存",8L,null,null))).hasMessageContaining("刷新"); + verify(trips,never()).assign(anyString(),anyString(),any());verifyNoInteractions(photos); + } @BeforeEach void setup(){ roots=mock(DispatchAggregateRepository.class);links=mock(TransportTaskRepository.class);tripRepo=mock(FleetTripRepository.class);trips=mock(FleetTripService.class); trucks=mock(UsTruckOrderRepository.class);files=mock(TripOperationsEvidenceGateway.class); From 6088ff10c2fe63806f3253b668148d6f370c90a4 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 18:28:15 +0800 Subject: [PATCH 070/113] perf(ci): scope Lastleg tests to affected changes --- Jenkinsfile | 34 ++++- deploy/scripts/ci-test-scope.mjs | 226 +++++++++++++++++++++++++++++++ lastleg-service/pom.xml | 5 + scripts/README.md | 2 + scripts/ci-test-scope.test.mjs | 153 +++++++++++++++++++++ 5 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 deploy/scripts/ci-test-scope.mjs create mode 100644 scripts/ci-test-scope.test.mjs diff --git a/Jenkinsfile b/Jenkinsfile index 030b6c755..bc2e2cae4 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -67,6 +67,8 @@ def parseAutoBuildState(String description, List allowedServices) { List failedServices = [] if (state.startsWith('RESULTS')) { String summary = state.substring('RESULTS'.length()).trim() + int metadataIndex = summary.indexOf(' | ') + if (metadataIndex >= 0) summary = summary.substring(0, metadataIndex).trim() summary.split(',').collect { it.trim() }.findAll { it }.each { item -> int separator = item.indexOf('=') if (separator > 0) { @@ -103,6 +105,25 @@ def previousAutoBuildState(def previousBuild, List allowedServices) { return null } +// 测试范围必须相对成功门禁,而非失败/取消轮次;否则补跑可能遗漏前次未通过的变更。 +def previousSuccessfulTestCommit(def previousBuild, List requiredServices) { + def candidate = previousBuild + int inspected = 0 + while (candidate != null && inspected < 30) { + String description = candidate.description ?: '' + String marker = ' | TESTS=PASSED ' + int markerIndex = description.lastIndexOf(marker) + if (candidate.result == 'SUCCESS' && markerIndex >= 0) { + List metadata = description.substring(markerIndex + marker.length()).trim().split(' ') + if (metadata.size() == 2 && metadata[0] ==~ /[0-9a-fA-F]{7,40}/ && + requiredServices.every { metadata[1].split(',').contains(it) }) return metadata[0] + } + candidate = candidate.previousBuild + inspected++ + } + return '' +} + def environmentChoicesForJob(String jobBaseName) { return [fixedEnvironmentForJob(jobBaseName) ?: 'test'] } @@ -161,6 +182,7 @@ pipeline { choice(name: 'ENVIRONMENT', choices: environmentChoicesForJob(env.JOB_BASE_NAME), description: '部署环境') booleanParam(name: 'DEPLOY', defaultValue: false, description: '各服务构建成功后是否独立部署') booleanParam(name: 'SKIP_TESTS', defaultValue: false, description: '是否跳过测试执行(仍编译测试源码)') + choice(name: 'TEST_SCOPE', choices: ['AUTO', 'MODULE'], description: 'AUTO 对已支持的局部变更执行关联测试;公共/未知变更自动完整模块回归;MODULE 显式完整模块回归') // 地址由当前 Job 配置提供;参数声明刷新时保留已配置值,不将环境地址写入源码。 string(name: 'IMAGE_REPOSITORY', defaultValue: params.IMAGE_REPOSITORY ?: '', description: '沿用当前 Job 已配置的镜像仓库地址;首次配置或更换仓库时填写 host[:port]/owner/image') string(name: 'DOCKER_BASE_IMAGE', defaultValue: 'eclipse-temurin:17-jre-alpine', description: 'Java 17 基础镜像') @@ -269,6 +291,7 @@ pipeline { selected = selected.unique() env.SELECTED_SERVICES = selected.join(',') + env.TEST_BASE_COMMIT = previousSuccessfulTestCommit(currentBuild.previousBuild, selected) env.NO_SERVICE_CHANGE = selected ? 'false' : 'true' if (!selected) { currentBuild.description = params.BUILD_MODE == 'AUTO' @@ -335,16 +358,22 @@ pipeline { .findAll { it } .join(',') echo "统一测试本次选中的服务及其公共依赖: ${selectedModules}" + String testPlan = "${pwd(tmp: true)}/ci-test-plan.json" lock(resource: 'firstleg-test-maven') { timeout(time: 50, unit: 'MINUTES') { sh """ test \"\$(git rev-parse HEAD)\" = \"${env.SOURCE_COMMIT}\" find . -type f -path '*/target/surefire-reports/TEST-*.xml' -delete - sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test + node deploy/scripts/ci-test-scope.mjs plan --modules '${selectedModules}' --base '${env.TEST_BASE_COMMIT ?: ''}' --mode '${env.RESOLVED_ENVIRONMENT == 'test' ? (params.TEST_SCOPE ?: 'AUTO') : 'MODULE'}' --plan '${testPlan}' + node deploy/scripts/ci-test-scope.mjs checks --plan '${testPlan}' + test_arguments=\"\$(node deploy/scripts/ci-test-scope.mjs args --plan '${testPlan}')\" + sh deploy/scripts/run-ci-maven.sh --settings deploy/maven/settings-ci.xml --batch-mode --errors -pl ${selectedModules} -am test \$test_arguments + node deploy/scripts/ci-test-scope.mjs verify --plan '${testPlan}' """ } } echo "统一测试门禁通过: ${env.SOURCE_COMMIT};下游单服务任务不再重复执行测试" + env.TEST_GATE_PASSED = 'true' } } post { @@ -401,6 +430,9 @@ pipeline { currentBuild.description = params.BUILD_MODE == 'AUTO' ? "AUTO ${env.IMAGE_TAG} | RESULTS ${summary}" : "${env.IMAGE_TAG} | ${summary}" + if (env.TEST_GATE_PASSED == 'true') { + currentBuild.description += " | TESTS=PASSED ${env.SOURCE_COMMIT} ${env.SELECTED_SERVICES}" + } if (failed) { unstable("部分服务执行失败,但其他服务已独立完成:${failed.join(', ')}") } diff --git a/deploy/scripts/ci-test-scope.mjs b/deploy/scripts/ci-test-scope.mjs new file mode 100644 index 000000000..ae82ee59a --- /dev/null +++ b/deploy/scripts/ci-test-scope.mjs @@ -0,0 +1,226 @@ +#!/usr/bin/env node +import { readFileSync, readdirSync, writeFileSync, statSync } from 'node:fs' +import { resolve, relative, basename } from 'node:path' +import { execFileSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const moduleName = 'lastleg-service' +const testClass = /(?:^Test.+|.+(?:Test|Tests|TestCase))\.java$/ +const words = source => new Set(source.match(/[A-Za-z_$][A-Za-z0-9_$]*/g) || []) +const tables = source => new Set(source.match(/\bll_[a-z0-9_]+\b/g) || []) +const writtenTables = source => new Set([...source.matchAll(/\b(?:UPDATE|INSERT\s+(?:IGNORE\s+)?INTO|DELETE\s+FROM)\s+(ll_[a-z0-9_]+)/gi)].map(match => match[1])) + +// 按完整注解比较 SQL,避免只改 SELECT 时把同一 Mapper 内未改的全部写语句扩大为全域回归。 +function writeStatements(source) { + const result = [] + for (const match of source.matchAll(/@(?:Insert|Update|Delete)\s*\(/g)) { + let depth = 1, quote = '', index = match.index + match[0].length + for (; index < source.length && depth; index++) { + if (quote) { + if (source.startsWith(quote, index)) { index += quote.length - 1; quote = '' } + else if (source[index] === '\\' && quote.length === 1) index++ + } else if (source.startsWith('"""', index)) { quote = '"""'; index += 2 } + else if (source[index] === '"' || source[index] === "'") quote = source[index] + else if (source[index] === '(') depth++ + else if (source[index] === ')') depth-- + } + if (depth) throw new Error('Cannot parse SQL annotation; use MODULE test scope') + result.push(source.slice(match.index, index)) + } + return result +} + +function changedWrites(node) { + if (node.previousSource === undefined) return writtenTables(node.source) + const before = writeStatements(node.previousSource), after = writeStatements(node.source) + const changed = [...before.filter(sql => !after.includes(sql)), ...after.filter(sql => !before.includes(sql))] + const oldRest = before.reduce((text, sql) => text.replace(sql, ''), node.previousSource) + const newRest = after.reduce((text, sql) => text.replace(sql, ''), node.source) + if (oldRest !== newRest) changed.push(oldRest, newRest) + return writtenTables(changed.join('\n')) +} + +/** 宁可扩大范围,不用文件名猜测唯一测试;未识别的输入保留完整模块门禁。 */ +export function planTests({ modules, mode = 'AUTO', changes = [], sources = [], baseline = '' }) { + const architectureScript = `${moduleName}/scripts/analyze-lastleg-dependencies` + const staticChecks = changes.some(path => path.startsWith(architectureScript)) + ? [`${architectureScript}.test.mjs`] : [] + const full = reason => ({ scope: 'MODULE', reason, modules, baseline, tests: [], staticChecks }) + if (mode !== 'AUTO' && mode !== 'MODULE') throw new Error('TEST_SCOPE must be AUTO or MODULE') + if (mode === 'MODULE') return full('显式完整模块回归') + if (!baseline) return full('没有成功测试基线') + if (modules.length !== 1 || modules[0] !== moduleName) return full('多服务或尚未核对的服务采用完整模块回归') + const prefix = `${moduleName}/` + const relevant = changes.filter(path => path.startsWith(prefix)) + const shared = changes.some(path => path !== 'scripts/agents-index-contract.ps1' && !path.startsWith(prefix) + && /^(?:common-[^/]+\/|platform-bom\/|pom\.xml$|Jenkinsfile|deploy\/|config\/|scripts\/.*\.(?:mjs|sh|ps1)$)/.test(path)) + if (shared) return full('公共能力、发布或测试基础设施变更') + if (!relevant.length) return full('补跑服务没有可验证的业务差异') + const java = `${prefix}src/main/java/com/baiyue/lastleg/` + const supported = path => path.startsWith(`${prefix}src/test/java/`) + || path.startsWith(`${prefix}src/main/resources/openapi/`) + || path.startsWith(architectureScript) + || (path.startsWith(java) && path.slice(java.length).includes('/') + && !/^(?:config|security|entity|dto|model|mapper|repository|service|client)\//.test(path.slice(java.length))) + || /\.(?:md)$/.test(path) + if (relevant.some(path => !supported(path))) return full('共享模型、配置、SQL、资源或未识别路径变更') + + const nodes = sources.map(({ path, source, previousSource }) => ({ path, source, previousSource, + symbol: basename(path, '.java'), tokens: words(source), tables: tables(source), + test: path.startsWith(`${prefix}src/test/java/`), + name: `${source.match(/\bpackage\s+([\w.]+)\s*;/)?.[1] || ''}.${basename(path, '.java')}` })) + const byPath = new Map(nodes.map(node => [node.path, node])) + const bySymbol = new Map() + for (const node of nodes) { + if (!bySymbol.has(node.symbol)) bySymbol.set(node.symbol, []) + bySymbol.get(node.symbol).push(node) + } + const reverse = new Map(nodes.map(node => [node.path, new Set()])) + for (const node of nodes) { + // 包含反射字符串、静态 import、同包引用及测试夹具;重名类全部纳入。 + for (const token of node.tokens) for (const dependency of bySymbol.get(token) || []) { + if (dependency !== node) reverse.get(dependency.path).add(node.path) + } + // 实现变化也要覆盖只依赖接口/基类的调用者,不能仅沿显式 import 反查。 + for (const match of node.source.matchAll(/\b(?:implements|extends)\s+([^{};]+)/g)) { + for (const token of words(match[1])) for (const contract of bySymbol.get(token) || []) { + if (contract !== node) reverse.get(node.path).add(contract.path) + } + } + } + const seeds = new Set() + const changedTables = new Set() + for (const path of relevant.filter(path => path.endsWith('.java'))) { + const node = byPath.get(path) + if (node) { + seeds.add(path) + // 读查询变化不改变其他查询的数据;潜在写入变化则纳入同表读写消费者。 + if (!node.test) for (const table of changedWrites(node)) changedTables.add(table) + } else { + // 删除/移动旧类型时,仍按旧类名查现存消费者。 + const symbol = basename(path, '.java') + const consumers = nodes.filter(consumer => consumer.tokens.has(symbol)) + if (!consumers.length) return full('删除类型缺少可追踪的现存消费者') + for (const consumer of consumers) seeds.add(consumer.path) + } + } + if (!seeds.size) return full('没有可追踪的 Java 行为差异') + for (const node of nodes) if ([...node.tables].some(table => changedTables.has(table))) seeds.add(node.path) + const affected = new Set(seeds) + const queue = [...seeds] + for (let index = 0; index < queue.length; index++) { + for (const consumer of reverse.get(queue[index]) || []) if (!affected.has(consumer)) { + affected.add(consumer); queue.push(consumer) + } + } + const allTests = nodes.filter(node => node.test && testClass.test(basename(node.path)) + && /@(?:Test|ParameterizedTest|RepeatedTest|TestFactory|TestTemplate)\b/.test(node.source)) + const contractChange = relevant.some(path => path.startsWith(`${prefix}src/main/resources/openapi/`)) + const candidates = allTests.filter(node => affected.has(node.path) + || /(?:Security|Isolation|MapperRegistrationArchitecture|QueryBinding)/.test(node.symbol) + || (contractChange && /(?:Contract|OpenApi|Schema|Architecture|Permission)/.test(node.symbol))) + // 显式依赖真实环境的类仍由 MODULE/专项授权入口负责,不能把它们默认跳过算作执行成功。 + const optIn = node => /@(?:EnabledIf(?:SystemProperty|EnvironmentVariable)|Disabled(?:IfSystemProperty|IfEnvironmentVariable)?\b)/ + .test(node.source.slice(0, node.source.indexOf(`class ${node.symbol}`))) + const tests = candidates.filter(node => !optIn(node)) + if (!tests.some(node => affected.has(node.path))) return full('没有匹配到行为测试,拒绝零测试通过') + if (tests.some(node => !/^com\.[\w.$]+$/.test(node.name))) return full('测试类名无法安全传入 Maven') + return { scope: 'AFFECTED', reason: 'Java 反向引用、接口实现、测试夹具、共享 SQL 表和相关静态合同', + modules, baseline, tests: tests.map(node => node.name).sort(), totalTests: allTests.length, staticChecks, + optInTests: candidates.filter(optIn).map(node => node.name).sort() } +} + +function walk(root) { + const files = [] + for (const item of readdirSync(root, { withFileTypes: true })) { + const path = resolve(root, item.name) + if (item.isDirectory()) files.push(...walk(path)) + else if (item.isFile() && item.name.endsWith('.java')) files.push(path) + } + return files +} + +/** 每个所选类必须有本轮实际执行的报告;允许上游零匹配不能变成整个门禁零测试。 */ +export function verifyReports(plan, reports) { + if (plan.scope !== 'AFFECTED') return + for (const name of plan.tests) { + const matches = reports.filter(report => report.name === name || report.name.startsWith(`${name}$`)) + if (!matches.length || matches.some(report => report.failures || report.errors + || ['tests', 'skipped', 'failures', 'errors'].some(key => !Number.isInteger(report[key]) || report[key] < 0) + || report.skipped > report.tests) + || matches.reduce((sum, report) => sum + report.tests - report.skipped, 0) <= 0) { + throw new Error(`Missing, skipped or failed fresh test report: ${name}`) + } + } +} + +function main(args) { + const [action, ...options] = args + const values = {} + for (let i = 0; i < options.length; i += 2) { + if (!options[i].startsWith('--') || options[i + 1] === undefined) throw new Error('Expected --name value') + values[options[i].slice(2)] = options[i + 1] + } + const root = process.cwd() + const planFile = values.plan + if (!planFile) throw new Error('--plan is required') + if (action === 'plan') { + const modules = (values.modules || '').split(',') + if (modules.some(module => !/^[a-z][a-z0-9-]+$/.test(module))) throw new Error('Invalid modules') + let baseline = values.base || '' + let changes = [] + if (baseline) { + if (!/^[0-9a-f]{7,40}$/i.test(baseline)) throw new Error('Invalid baseline commit') + try { + execFileSync('git', ['merge-base', '--is-ancestor', baseline, 'HEAD'], { stdio: 'pipe' }) + changes = execFileSync('git', ['diff', '--name-only', '-z', baseline, 'HEAD'], { encoding: 'utf8' }).split('\0').filter(Boolean) + } catch { baseline = '' } + } + const sources = modules.length === 1 && modules[0] === moduleName + ? ['main', 'test'].flatMap(set => walk(resolve(root, moduleName, 'src', set, 'java'))) + .map(path => { + const key = relative(root, path).replaceAll('\\', '/') + const entry = { path: key, source: readFileSync(path, 'utf8') } + if (baseline && changes.includes(key)) { + try { entry.previousSource = execFileSync('git', ['show', `${baseline}:${key}`], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }) } + catch { entry.previousSource = '' } + } + return entry + }) : [] + const plan = { ...planTests({ modules, mode: values.mode || 'AUTO', baseline, changes, sources }), startedAt: Date.now() } + writeFileSync(planFile, JSON.stringify(plan)) + console.log(`CI test scope: ${plan.scope}; ${plan.reason}`) + if (plan.scope === 'AFFECTED') console.log(`Selected ${plan.tests.length}/${plan.totalTests} test classes:\n${plan.tests.join('\n')}`) + if (plan.optInTests?.length) console.log(`Explicit environment tests remain opt-in (not counted as passed): ${plan.optInTests.join(',')}`) + return + } + const plan = JSON.parse(readFileSync(planFile, 'utf8')) + if (action === 'args') { + if (plan.scope === 'AFFECTED') process.stdout.write(`-Dtest=${plan.tests.join(',')} -Dsurefire.failIfNoSpecifiedTests=false -Dci.sql-semantic.skip=true`) + } else if (action === 'checks') { + for (const check of plan.staticChecks || []) { + if (check !== `${moduleName}/scripts/analyze-lastleg-dependencies.test.mjs`) throw new Error('Unsupported static check') + execFileSync(process.execPath, ['--test', '--test-concurrency=2', check], { stdio: 'inherit' }) + } + } else if (action === 'verify') { + const reports = [] + if (plan.scope === 'AFFECTED') for (const module of plan.modules) { + const directory = resolve(root, module, 'target/surefire-reports') + for (const filename of readdirSync(directory)) { + if (!/^TEST-.*\.xml$/.test(filename)) continue + const path = resolve(directory, filename) + if (statSync(path).mtimeMs < plan.startedAt - 2000) continue + const xml = readFileSync(path, 'utf8').match(/]+)>/)?.[1] + if (!xml) continue + const attrs = Object.fromEntries([...xml.matchAll(/([\w-]+)="([^"]*)"/g)].map(match => [match[1], match[2]])) + reports.push({ name: attrs.name || '', ...Object.fromEntries(['tests', 'failures', 'errors', 'skipped'].map(key => [key, Number(attrs[key] || 0)])) }) + } + } + verifyReports(plan, reports) + console.log(`CI test reports verified: ${plan.scope}`) + } else throw new Error(`Unknown action: ${action}`) +} + +if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { main(process.argv.slice(2)) } catch (error) { console.error(error.message); process.exitCode = 1 } +} diff --git a/lastleg-service/pom.xml b/lastleg-service/pom.xml index b8ccfdaa2..6c83fb3c1 100644 --- a/lastleg-service/pom.xml +++ b/lastleg-service/pom.xml @@ -4,6 +4,10 @@ 4.0.0 com.baiyue.firstlegbaiyue-firstleg-platform1.0.0-SNAPSHOT../pom.xml lastleg-service + + + false + com.google.zxingcore3.5.3 com.baiyue.sharedcommon-event-contract${project.version} @@ -42,6 +46,7 @@ test test + ${ci.sql-semantic.skip} **/*SqlSemanticTest.java false diff --git a/scripts/README.md b/scripts/README.md index 3bda1eebb..b236798b6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -447,6 +447,8 @@ Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块 ## 后端分层检查 +Jenkins 总控的测试范围入口为 `../deploy/scripts/ci-test-scope.mjs`,由 `TEST_SCOPE=AUTO|MODULE` 控制,不部署、不访问业务数据库。`plan` 使用带门禁通过标记且覆盖目标服务的成功构建基线与当前源码生成 Job 临时目录的 JSON,`checks` 执行受影响的既有架构合同,`args` 只输出精确测试类参数,`verify` 拒绝缺失、陈旧、失败或全部跳过的所选类报告。AUTO 当前仅优化已识别的单 Lastleg 局部变更,公共/未知/配置/SQL/多服务等保留完整回归;完整编译保留,真实环境 opt-in 不计通过,JVM 内存与隔离配置不放宽。定向列表在默认 Surefire execution 执行,关闭后置 SQL execution 的重复执行;未指定定向参数时原完整 SQL 回归不变。静态筛选边界及 MODULE 使用条件见工作区 `docs/测试环境Jenkins部署指南.md`;直接合同为 `ci-test-scope.test.mjs`。本地普通任务继续使用下述显式范围入口,不自动触发 CI 或增加完整回归。 + `backend-check.ps1` 是本地日常入口,PowerShell 7 执行,自动经 `local-check.ps1` 占用一个重型名额。全量触发条件唯一见 `../AGENTS.md`;不自动根据 Git diff 猜测跨服务消费者,不自动缓存或跳过测试。 在后端根目录执行,`-Modules` 使用逗号分隔的当前根 POM 模块名: diff --git a/scripts/ci-test-scope.test.mjs b/scripts/ci-test-scope.test.mjs new file mode 100644 index 000000000..d20da774f --- /dev/null +++ b/scripts/ci-test-scope.test.mjs @@ -0,0 +1,153 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import { readFileSync, mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFileSync } from 'node:child_process' +import { planTests, verifyReports } from '../deploy/scripts/ci-test-scope.mjs' + +const module = 'lastleg-service' +function java(type, body = '', set = 'main') { + const name = type.split('.').at(-1) + const pkg = type.slice(0, -name.length - 1) + return { path: `${module}/src/${set}/java/com/baiyue/lastleg/${type.replaceAll('.', '/')}.java`, + source: `package com.baiyue.lastleg.${pkg}; class ${name} { ${body} }` } +} +const source = java('dispatch.DispatchService') +const fixture = java('dispatch.Fixture', 'DispatchService service;', 'test') +const direct = java('dispatch.DispatchTest', 'Fixture fixture; @Test void check() {}', 'test') +const consumer = java('delivery.DeliveryService', 'DispatchService service;') +const indirect = java('delivery.DeliveryTest', 'DeliveryService service; @Test void check() {}', 'test') +const unrelated = java('selfoperated.AnalyticsTest', '@Test void check() {}', 'test') +const security = java('workflow.OrderSecurityTest', '@Test void check() {}', 'test') +const sources = [source, fixture, direct, consumer, indirect, unrelated, security] +const plan = (options = {}) => planTests({ modules: [module], baseline: '1234567', + changes: [source.path], sources, ...options }) + +test('changed Java selects transitive callers and fixture consumers without unrelated domains', () => { + const result = plan() + assert.equal(result.scope, 'AFFECTED') + assert.deepEqual(result.tests, [ + 'com.baiyue.lastleg.delivery.DeliveryTest', 'com.baiyue.lastleg.dispatch.DispatchTest', + 'com.baiyue.lastleg.workflow.OrderSecurityTest' + ]) +}) + +test('implementation changes reach callers of interfaces, including duplicate simple names', () => { + const contract = java('dispatch.Port') + const implementation = java('dispatch.Adapter', 'implements Port') + const portCaller = java('fleet.PortCaller', 'Port port;') + const portTest = java('fleet.PortCallerTest', 'PortCaller service; @Test void check() {}', 'test') + const result = plan({ changes: [implementation.path], sources: [...sources, contract, implementation, portCaller, portTest] }) + assert.ok(result.tests.includes('com.baiyue.lastleg.fleet.PortCallerTest')) +}) + +test('SQL tables expand to other mappers and their tests', () => { + const mapper = java('dispatch.SourceMapper', 'String sql = "UPDATE ll_dispatch_source SET version=2";') + const sibling = java('delivery.DeliveryMapper', 'String sql = "UPDATE ll_dispatch_source SET version=1";') + const check = java('delivery.DeliveryMapperTest', 'DeliveryMapper mapper; @Test void check() {}', 'test') + const result = plan({ changes: [mapper.path], sources: [...sources, mapper, sibling, check] }) + assert.ok(result.tests.includes('com.baiyue.lastleg.delivery.DeliveryMapperTest')) +}) + +test('unchanged writes beside a changed SELECT do not pull in unrelated table consumers', () => { + const mapper = java('dispatch.QueryMapper', '@Update("UPDATE ll_shared SET value=1") void write(); @Select("SELECT value FROM ll_source") void read();') + mapper.previousSource = mapper.source.replace('SELECT value', 'SELECT old_value') + const check = java('dispatch.QueryMapperTest', 'QueryMapper mapper; @Test void check() {}', 'test') + const unrelatedTable = java('selfoperated.SharedTableTest', 'String query = "SELECT * FROM ll_shared"; @Test void check() {}', 'test') + const result = plan({ changes: [mapper.path], sources: [...sources, mapper, check, unrelatedTable] }) + assert.ok(result.tests.includes('com.baiyue.lastleg.dispatch.QueryMapperTest')) + assert.ok(!result.tests.includes('com.baiyue.lastleg.selfoperated.SharedTableTest')) + mapper.source = mapper.source.replace('value=1', 'value=2') + assert.ok(plan({ changes: [mapper.path], sources: [...sources, mapper, check, unrelatedTable] }) + .tests.includes('com.baiyue.lastleg.selfoperated.SharedTableTest')) +}) + +test('environment opt-in classes are reported separately and do not count as passing tests', () => { + const optional = java('dispatch.RealMysqlTest', 'DispatchService service; @Test void check() {}', 'test') + optional.source = optional.source.replace('class RealMysqlTest', '@EnabledIfSystemProperty(named="live",matches="true") class RealMysqlTest') + const result = plan({ sources: [...sources, optional] }) + assert.ok(!result.tests.includes('com.baiyue.lastleg.dispatch.RealMysqlTest')) + assert.deepEqual(result.optInTests, ['com.baiyue.lastleg.dispatch.RealMysqlTest']) +}) + +test('changed test fixtures and deleted Java types keep their dependent tests', () => { + assert.ok(plan({ changes: [fixture.path] }).tests.includes('com.baiyue.lastleg.dispatch.DispatchTest')) + const deleted = java('dispatch.RemovedService') + const user = java('delivery.ReplacementTest', 'String old = "RemovedService"; @Test void check() {}', 'test') + assert.ok(plan({ changes: [deleted.path], sources: [...sources, user] }).tests.includes('com.baiyue.lastleg.delivery.ReplacementTest')) +}) + +test('public API changes also select the existing contract tests', () => { + const contract = java('selfoperated.AnalyticsOpenApiContractTest', '@Test void check() {}', 'test') + const result = plan({ changes: [source.path, `${module}/src/main/resources/openapi/dispatch.yaml`], sources: [...sources, contract] }) + assert.ok(result.tests.includes('com.baiyue.lastleg.selfoperated.AnalyticsOpenApiContractTest')) +}) + +test('shared, unknown, configuration, schema and missing-baseline changes retain full regression', () => { + for (const path of ['pom.xml', 'Jenkinsfile', 'common-core/src/main/java/Core.java', + 'deploy/scripts/run-ci-maven.sh', 'config/nacos/test/lastleg.yml', + `${module}/pom.xml`, `${module}/src/main/resources/db/lastleg.sql`, + `${module}/src/test/resources/test-config.yml`, `${module}/src/main/java/com/baiyue/lastleg/entity/Order.java`]) { + assert.equal(plan({ changes: [source.path, path] }).scope, 'MODULE', path) + } + assert.equal(plan({ baseline: '' }).scope, 'MODULE') + assert.equal(plan({ mode: 'MODULE' }).scope, 'MODULE') + assert.equal(plan({ modules: ['order'] }).scope, 'MODULE') + assert.equal(plan({ modules: [module, 'iam'] }).scope, 'MODULE') + assert.equal(plan({ changes: [] }).scope, 'MODULE') + assert.equal(plan({ sources: [source] }).scope, 'MODULE') + assert.throws(() => plan({ mode: 'SKIP' })) +}) + +test('architecture script changes run their own checks instead of broadening Java regression', () => { + const result = plan({ changes: [source.path, 'scripts/agents-index-contract.ps1', + `${module}/scripts/analyze-lastleg-dependencies.test.mjs`] }) + assert.equal(result.scope, 'AFFECTED') + assert.deepEqual(result.staticChecks, [`${module}/scripts/analyze-lastleg-dependencies.test.mjs`]) +}) + +test('focused report verification rejects missing, failed and entirely skipped classes', () => { + const focused = { scope: 'AFFECTED', tests: ['com.example.Test'] } + const report = { name: 'com.example.Test', tests: 2, skipped: 0, failures: 0, errors: 0 } + verifyReports(focused, [report]) + verifyReports(focused, [{ ...report, name: 'com.example.Test$Nested' }]) + for (const reports of [[], [{ ...report, tests: 0 }], [{ ...report, skipped: 2 }], + [{ ...report, failures: 1 }], [{ ...report, errors: 1 }], [{ ...report, tests: NaN }]]) { + assert.throws(() => verifyReports(focused, reports)) + } +}) + +test('CLI prevents double execution of selected classes in the SQL semantic execution', () => { + const directory = mkdtempSync(join(tmpdir(), 'ci-test-scope-')) + try { + const file = join(directory, 'plan.json') + writeFileSync(file, JSON.stringify(plan())) + const args = execFileSync(process.execPath, + ['deploy/scripts/ci-test-scope.mjs', 'args', '--plan', file], { encoding: 'utf8' }) + assert.match(args, /-Dtest=com\.baiyue/) + assert.match(args, /-Dsurefire.failIfNoSpecifiedTests=false/) + assert.match(args, /-Dci.sql-semantic.skip=true/) + const pom = readFileSync(new URL('../lastleg-service/pom.xml', import.meta.url), 'utf8') + assert.match(pom, /false<\/ci.sql-semantic.skip>/) + const execution = pom.slice(pom.indexOf('sql-semantic-tests')) + assert.match(execution, /\$\{ci.sql-semantic.skip\}<\/skip>/) + assert.match(execution, /false<\/reuseForks>/) + } finally { rmSync(directory, { recursive: true, force: true }) } +}) + +test('Jenkins plans from a successful baseline, verifies fresh reports and preserves module override', () => { + const pipeline = readFileSync(new URL('../Jenkinsfile', import.meta.url), 'utf8') + assert.match(pipeline, /candidate.result == 'SUCCESS' && markerIndex >= 0/) + assert.match(pipeline, /requiredServices.every \{ metadata\[1\].split\(','\).contains\(it\) \}/) + assert.match(pipeline, /if \(env.TEST_GATE_PASSED == 'true'\)/) + assert.match(pipeline, /TESTS=PASSED \$\{env.SOURCE_COMMIT\} \$\{env.SELECTED_SERVICES\}/) + assert.match(pipeline, /summary.indexOf\(' \| '\)/) + assert.match(pipeline, /env.TEST_BASE_COMMIT = previousSuccessfulTestCommit/) + assert.match(pipeline, /choice\(name: 'TEST_SCOPE', choices: \['AUTO', 'MODULE'\]/) + assert.match(pipeline, /ci-test-scope.mjs plan --modules/) + assert.match(pipeline, /ci-test-scope.mjs checks --plan/) + assert.match(pipeline, /-am test \\\$test_arguments/) + assert.match(pipeline, /ci-test-scope.mjs verify --plan/) + assert.match(pipeline, /booleanParam\(name: 'SKIP_TESTS', value: true\)/) +}) From 538a63c6355952b81e8edb26ca357df7b9855392 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 18:44:26 +0800 Subject: [PATCH 071/113] fix(dispatch): confirm scanned loading without repeated measurements --- lastleg-service/docs/business-index.md | 2 +- .../dispatch/port/DispatchCommands.java | 4 +-- .../service/DispatchAggregateService.java | 16 ++++----- .../DispatchLoadingDepartureService.java | 2 +- .../fleet/domain/TripCapacityPolicy.java | 35 ++++++++++++++----- .../fleet/exception/FleetHandoverPolicy.java | 2 +- .../fleet/trip/FleetBookingReservations.java | 15 +++++--- .../fleet/trip/FleetTripExecutionService.java | 14 ++++---- .../lastleg/fleet/trip/FleetTripService.java | 4 +-- .../openapi/overseas-business-v1.yaml | 7 +--- .../DispatchAggregatePersistenceTest.java | 12 ++++--- .../DispatchDeparturePersistenceTest.java | 2 +- ...spatchLoadingDeparturePersistenceTest.java | 4 +-- ...atchLoadingEligibilityPersistenceTest.java | 2 +- .../DispatchMergedRelabelPersistenceTest.java | 2 +- .../fleet/domain/TripCapacityPolicyTest.java | 14 ++++++++ ...ross-customer-acceptance-contract.test.mjs | 14 ++++---- .../fleet-cross-customer-acceptance.mjs | 4 +-- .../fleet-trip-fulfillment-contract.test.mjs | 15 ++++---- .../fleet-trip-fulfillment.mjs | 8 ++--- 20 files changed, 105 insertions(+), 73 deletions(-) diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index 44aa31f9f..c2221b6fe 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -97,7 +97,7 @@ - 平台一件代发出库候选由 `/outbound-orders/one-click-dropship/candidates` 统一返回已完成入库且仍有可用箱唛、SKU 汇总和库存仓位绑定的托盘明细;查询不强制先传 `customerId`,未传时跨客户展示供页面分页筛选,创建时必须从所选候选解析为唯一客户和唯一海外仓,禁止一张出库单跨客户或跨仓。创建后出库单按 `WAIT_PICK → PICKING → PICKED → OUTBOUNDED` 推进;海外 PDA 扫描托盘码时通过整托命令原子拣取该托盘在当前任务内全部剩余箱件和 SKU,任一库存明细失败须整托回滚,逐箱拣货入口继续保留。 - 排车主流程以工作区 `docs/海外仓业务/排车列表统一作业改造契约.md` 为正式入口:一张排车单代表一趟车;Admin/PDA确认`loading-complete`时,同一事务自动按客户与目的仓生成派送单并发车,成功直接进入`DELIVERING`。公开手工安排派送及`/depart`入口已删除;内部`AFTER_LOADING`仅是同事务中间态。`DispatchPdaWorkflowService` 按公开 `status`、`stage`、`recordVersion` 和 `actions` 返回作业事实与资格,Admin/PDA 共用服务端规则;BOL 与签收资料归拆分后的派送单,费用按其来源去重,不以旧转派送阶段或前端推算代替资格校验。 - 排车主单允许同一起运仓的跨客户货物同车,每条来源保留客户、库存与费用归属;不再要求用户先建独立车次再关联多张客户排车。Fleet 的资源、司机证照、容量、历史与附加能力继续沿 `fleet/trip/` 定位;计划或资源变化对审核、实载确认的影响按正式契约及服务端结果核对,已拣货事实不能因改派或取消被直接抹除,实际发车后不得伪造资源释放。 -- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积必须来自实际确认,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 +- 实物装载按仓库托盘去重计量,客户货量与物理占位分别保存;重量/体积由已有实测自动带出,未知保持空值且不阻塞装车,不要求逐托补填,不用计费材积重回填。装车前真实拣货须核平,后台不能补录拣货数量绕过;装车发运编排不重复执行已完成的库存扣减;费用未明确时保持未计费,不要求理货员补费用,也不填零,真实已明确外接报价仍按原来源防重发布。来源、审核、实载与资源版本变化按正式契约重新校验,保留操作历史;双端使用同一版本与原请求键核对结果,任何失败均不得伪造完成事实。 - 车次结案只消费派送事实:实签须signedAt/signedPackages,实退须原来源入账流水/RETURN_COMPLETED;实签加本趟实退等于原实发且余量为零。拒收、待退回、取消不算完成,时间取最后实际终态。结案不改客户状态/费用、不释放占用;车/司机分别记录实际结束时间地点,车辆状态与释放同事务。过期证照不阻止旧任务结束、不恢复新任务资格;原键重放不影响后续车次。 - 已签收后另发起退货保留原运输的签收事实,后续退货不重开原车次、不被车次结案改写;未签收货物仍按实际退回完成核对。`DeliveryDocumentService.save` 首次保存签收资料触发完成时要求关联出库已OUTBOUNDED,未出库可以准备BOL/装车资料但不能提前完成派送。 - 排车执行与 `delivery/` Controller 一同注册;平台排车查询和智能预排入口分别沿 `lastleg.dispatch-query-api.enabled`、`lastleg.smart-dispatch-api.enabled` 配置定位,启用现状以配置和实际路由核对。平台查询按全局排车号及来源归属加载跨客户同车明细,客户接口保持本客户隔离;预排创建逐项校验同一起运仓及各来源客户,不能强制整张排车只含一个客户。草稿托盘锁仍按操作人与草稿标识持有,释放、失效及转正式锁由服务端原子处理。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java index 17497319f..3d6b87df9 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -66,9 +66,9 @@ public final class DispatchCommands { public Pick { recordVersion = requireVersion(recordVersion); } } - /** measurements仅为真实装载测量,禁止携带替代PDA拣货的箱数。 */ + /** 按服务端已核平的扫码托盘确认装车,不要求现场重复测量或补填箱数。 */ public record LoadingComplete(Long recordVersion, String idempotencyKey, String manifestHash, - List measurements, String remark) { + String remark) { /** 后台与PDA共用必填版本约束。 */ public LoadingComplete { recordVersion = requireVersion(recordVersion); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 8f65aba51..9acc8c401 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -535,10 +535,10 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(weight!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("weightKg","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),weight,true)); if(volume!=null)proof.add(new DispatchPlanningModels.MeasurementEvidence("volumeCbm","WAREHOUSE_TRANSFER",fact.sourceNo(),instant(fact.observedAt()),volume,true)); } - var absent=new ArrayList();absent.add("palletSpaces");if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex"); + var absent=new ArrayList();if(weight==null)absent.add("weightKg");if(volume==null)absent.add("volumeCbm");if(index==null)absent.add("deliveryIndex"); missing.addAll(absent.stream().map(field->Objects.toString(first.getPalletNo(),"未关联托盘")+":"+field).toList()); pallets.add(new DispatchPlanningModels.LoadingPallet(entry.getKey(),first.getPalletNo(),group.stream().mapToLong(i->n(i.getPackageCount())).sum(), - group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),null,weight,volume,index,List.copyOf(absent),List.copyOf(proof))); + group.stream().mapToLong(i->picked.getOrDefault(i.getId(),0L)).sum(),1,weight,volume,index,List.copyOf(absent),List.copyOf(proof))); } if(items.stream().anyMatch(i->i.getWarehousePalletId()==null))missing.add("存在未关联实托来源"); String hash=hashes.source(Arrays.asList(context.cargo(),context.resources(),stops,pallets)); @@ -561,16 +561,12 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA lockExecutionSources(row,items); var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); - if(request.measurements()==null || request.measurements().size()!=preview.pallets().size())throw bad("请确认本车全部实托测量"); - var supplied=new HashMap(); - for(var m:request.measurements())if(m==null || m.warehousePalletId()==null || supplied.putIfAbsent(m.warehousePalletId(),m)!=null || m.palletSpaces()<=0 || m.weightKg()==null || m.weightKg().signum()<=0 || m.volumeCbm()==null || m.volumeCbm().signum()<=0)throw bad("实托测量必须完整且不得重复"); - for(var p:preview.pallets()) { - var m=supplied.get(p.warehousePalletId());if(m==null || p.deliveryIndex()!=null && p.deliveryIndex()!=m.deliveryIndex() - || p.weightKg()!=null && p.weightKg().compareTo(m.weightKg())!=0 || p.volumeCbm()!=null && p.volumeCbm().compareTo(m.volumeCbm())!=0)throw conflict("已知实测或卸货站发生变化,请重新核对"); - } + if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线"); + // 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。 + var measurements=preview.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1, + p.weightKg(),p.volumeCbm(),p.deliveryIndex())).sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList(); verifyReviewResources(row);var context=context(actor,row); var cargo=context.cargo().stream().map(c->new FleetExecutionModels.Cargo(number,c.customerId(),c.dispatchItemId().toString(),c.warehousePalletId().toString(),c.palletNo(),Math.toIntExact(c.packages()),c.destination().key(),"PRIVATE".equals(c.destination().type())?c.destination().address():c.destination().warehouseCode())).toList(); - var measurements=request.measurements().stream().sorted(Comparator.comparing(LoadingMeasurement::warehousePalletId)).toList(); fleet.confirmDispatchLoad(number,actor.operatorId(),preview.manifestHash(),cargo,measurements.stream().map(m->new FleetExecutionModels.Measurement(m.warehousePalletId().toString(),m.palletSpaces(),m.weightKg(),m.volumeCbm(),m.deliveryIndex())).toList(),request.remark()); var at=Instant.now().truncatedTo(ChronoUnit.MILLIS);var confirmed=new ConfirmedLoad(preview.manifestHash(),measurements,actor.operatorId(),at); row.setLoadingConfirmationJson(write(confirmed));row.setLoadingCompletionHash(hashes.source(confirmed));row.setLoadingCompletedAt(utc(at));row.setStage(Stage.AFTER_LOADING.name()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java index 13031244e..1b1150699 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDepartureService.java @@ -37,7 +37,7 @@ public class DispatchLoadingDepartureService { hashes.command(actor,Action.LOADING_COMPLETE,number,request.recordVersion(),request)); var original=workflow.replay(context,actor,identity);if(original.isPresent())return original.get(); var loaded=workflow.loadingComplete(actor,number,new DispatchCommands.LoadingComplete(request.recordVersion(), - stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.measurements(),request.remark())); + stepKey(number,request.idempotencyKey(),"LOAD"),request.manifestHash(),request.remark())); var ready=DispatchLoadingDocumentService.complete(documents.read(context.dispatchId()).photos()); var finalState=ready?departAfterDocuments(actor,number,request.idempotencyKey()):loaded; var result=new CommandResult(number,Action.LOADING_COMPLETE,request.idempotencyKey(),finalState.recordVersion(), diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicy.java index 11853b693..abba0a618 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicy.java @@ -12,14 +12,14 @@ public final class TripCapacityPolicy { public record Load(int palletSpaces, BigDecimal weightKg, BigDecimal volumeCbm) { public Load { - if (palletSpaces < 0 || weightKg == null || volumeCbm == null - || weightKg.signum() < 0 || volumeCbm.signum() < 0) { + if (palletSpaces < 0 || weightKg != null && weightKg.signum() < 0 || volumeCbm != null && volumeCbm.signum() < 0) { throw new IllegalArgumentException("托位、重量和体积须为非负数"); } } public Load plus(Load other) { return new Load(Math.addExact(palletSpaces, other.palletSpaces), - weightKg.add(other.weightKg), volumeCbm.add(other.volumeCbm)); + weightKg == null || other.weightKg == null ? null : weightKg.add(other.weightKg), + volumeCbm == null || other.volumeCbm == null ? null : volumeCbm.add(other.volumeCbm)); } } @@ -28,7 +28,7 @@ public final class TripCapacityPolicy { if (bookingNo == null || bookingNo.isBlank()) throw new IllegalArgumentException("缺少承运业务编号"); if (pickupIndex < 0 || deliveryIndex <= pickupIndex) throw new IllegalArgumentException("卸货站必须在提货站之后"); Objects.requireNonNull(load, "load"); - if (load.palletSpaces() == 0 || load.weightKg().signum() == 0 || load.volumeCbm().signum() == 0) { + if (load.palletSpaces() == 0 || load.weightKg() != null && load.weightKg().signum() == 0 || load.volumeCbm() != null && load.volumeCbm().signum() == 0) { throw new IllegalArgumentException("承运货物须有有效托位、重量和体积"); } } @@ -37,31 +37,48 @@ public final class TripCapacityPolicy { public record Segment(int fromIndex, int toIndex, Load occupied, Load remaining) { } public static List calculate(int stopCount, Load capacity, List bookings) { + return calculate(stopCount, capacity, bookings, false); + } + + /** 装车使用已有测量;未知维度保留空值,仅校验已知货量,不把未知当作可售余量。 */ + public static List calculateActual(int stopCount, Load capacity, List bookings) { + return calculate(stopCount, capacity, bookings, true); + } + + private static List calculate(int stopCount, Load capacity, List bookings, boolean actual) { Objects.requireNonNull(capacity, "capacity"); Objects.requireNonNull(bookings, "bookings"); if (stopCount < 2 || stopCount > 200) throw new IllegalArgumentException("车次须有2至200个站点"); - if (capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) { + if (capacity.weightKg() == null || capacity.volumeCbm() == null || capacity.palletSpaces() <= 0 || capacity.weightKg().signum() <= 0 || capacity.volumeCbm().signum() <= 0) { throw new IllegalArgumentException("车辆容量必须大于零"); } var numbers = new HashSet(); for (Booking booking : bookings) { Objects.requireNonNull(booking, "booking"); + if (!actual && (booking.load().weightKg() == null || booking.load().volumeCbm() == null)) + throw new IllegalArgumentException("计划承运货物须有重量和体积"); if (!numbers.add(booking.bookingNo())) throw new IllegalArgumentException("承运明细重复分配"); if (booking.deliveryIndex() >= stopCount) throw new IllegalArgumentException("承运站点不属于当前路线"); } List segments = new ArrayList<>(); for (int index = 0; index < stopCount - 1; index++) { Load occupied = new Load(0, BigDecimal.ZERO, BigDecimal.ZERO); + BigDecimal knownWeight = BigDecimal.ZERO, knownVolume = BigDecimal.ZERO; for (Booking booking : bookings) { - if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) occupied = occupied.plus(booking.load()); + if (booking.pickupIndex() <= index && index < booking.deliveryIndex()) { + occupied = occupied.plus(booking.load()); + if (booking.load().weightKg() != null) knownWeight = knownWeight.add(booking.load().weightKg()); + if (booking.load().volumeCbm() != null) knownVolume = knownVolume.add(booking.load().volumeCbm()); + } } if (occupied.palletSpaces() > capacity.palletSpaces() - || occupied.weightKg().compareTo(capacity.weightKg()) > 0 - || occupied.volumeCbm().compareTo(capacity.volumeCbm()) > 0) { + || knownWeight.compareTo(capacity.weightKg()) > 0 + || knownVolume.compareTo(capacity.volumeCbm()) > 0) { throw new IllegalArgumentException("第" + (index + 1) + "段路线超过车辆托位、重量或容积限制"); } Load remaining = new Load(capacity.palletSpaces() - occupied.palletSpaces(), - capacity.weightKg().subtract(occupied.weightKg()), capacity.volumeCbm().subtract(occupied.volumeCbm())); + occupied.weightKg() == null ? null : capacity.weightKg().subtract(occupied.weightKg()), + occupied.volumeCbm() == null ? null : capacity.volumeCbm().subtract(occupied.volumeCbm())); segments.add(new Segment(index, index + 1, occupied, remaining)); } return List.copyOf(segments); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java index bef114894..229fe4312 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/exception/FleetHandoverPolicy.java @@ -112,7 +112,7 @@ public final class FleetHandoverPolicy { return new Destination(cargo.customerId(), cargo.dispatchNo(), cargo.destinationKey()); } private static boolean positive(Load load) { - return load != null && load.palletSpaces() > 0 && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0; + return load != null && load.palletSpaces() > 0 && load.weightKg() != null && load.volumeCbm() != null && load.weightKg().signum() > 0 && load.volumeCbm().signum() > 0; } private static boolean blank(String text) { return text == null || text.isBlank(); } private static IllegalArgumentException invalid(String message) { return new IllegalArgumentException(message); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java index 46128f80c..b44d88447 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetBookingReservations.java @@ -31,7 +31,11 @@ public class FleetBookingReservations { /** 使用当前自有货计划与全部未绑定预留计算区段,不重复计入待替换承运。 */ public List segments(FleetTripEntity trip, FleetExecutionModels.PlannedLoad own, Long excludedId, FleetBookingEntity addition) { - return segments(trip,own.pallets(),excludedId,addition); + return segments(trip,own.pallets(),excludedId,addition,false); + } + /** 已扫码装车无需重复测量,未知重量体积不计作可接运力。 */ + public List actualSegments(FleetTripEntity trip,List pallets) { + return segments(trip,pallets,null,null,true); } /** 已实收承运解除唯一排车后,自有货为零仍可按本票新实测和其他预留重新核容。 */ public void requireReceiptCapacity(FleetTripEntity trip,FleetExecutionModels.Planning own,FleetBookingEntity row) { @@ -40,17 +44,20 @@ public class FleetBookingReservations { throw conflict("请先重新核对自有货容量计划,再确认外接实收"); // 空自有货只用于已有实收的重新确认;不创建计划、不允许首次实收跳过自有货计划。 List pallets=releasedReceipt?List.of():own.capacityPlan().pallets(); - segments(trip,pallets,row.getId(),row); + segments(trip,pallets,row.getId(),row,false); } /** 所有承诺使用同一容量计算,空自有货也必须保留其他承运占位及车辆上限。 */ private List segments(FleetTripEntity trip,List own, - Long excludedId,FleetBookingEntity addition) { + Long excludedId,FleetBookingEntity addition,boolean actual) { var stops=stops(trip); var loads=new ArrayList(); for (var pallet:own) loads.add(new TripCapacityPolicy.Booking("PALLET:"+pallet.warehousePalletId(),0,pallet.deliveryIndex(), new TripCapacityPolicy.Load(pallet.palletSpaces(),pallet.weightKg(),pallet.volumeCbm()))); for(var row:new FleetBookingCargoRepository(bookings).listForTrip(trip.getId())) if(unbound(row) && !Objects.equals(row.getId(),excludedId)) add(loads,stops,row); if(addition!=null) add(loads,stops,addition); - try { return TripCapacityPolicy.calculate(stops.size(),new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()),loads); } + try { + var capacity=new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(),trip.getMaxLoadKg(),trip.getMaxVolumeCbm()); + return actual?TripCapacityPolicy.calculateActual(stops.size(),capacity,loads):TripCapacityPolicy.calculate(stops.size(),capacity,loads); + } catch(IllegalArgumentException|ArithmeticException error){throw conflict(error.getMessage());} } public void requireReassignmentFits(FleetTripEntity trip, Integer spaces, java.math.BigDecimal weight, java.math.BigDecimal volume) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java index 6ab3ee073..eb23768ce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripExecutionService.java @@ -88,9 +88,8 @@ public class FleetTripExecutionService { List measurements,String reason) { var identity=history.dispatchIdentity(no);var trip=requireTrip(identity.tripNo(),true); if(!"PLANNED".equals(trip.getStatus()))throw conflict("本车已开始运输"); - var ownSegments=capacity(trip,cargo,measurements); - var plan=new FleetExecutionModels.PlannedLoad(manifestHash,cargo,measurements,ownSegments,operator,Instant.now()); - var segments=bookingReservations.segments(trip,plan,null,null); + capacity(trip,cargo,measurements,true); + var segments=bookingReservations.actualSegments(trip,measurements); // 同时核对原有外接预订占用;不因新主流程绕过已确认容量。 var load=new FleetExecutionModels.ConfirmedLoad(manifestHash,cargo,measurements,segments,operator,Instant.now()); trip.setLoadJson(write(load));history.recordExecution(trip,trip.getRecordVersion(),"DISPATCH_CONFIRM_LOAD",reason,operator); @@ -103,7 +102,7 @@ public class FleetTripExecutionService { var load=load(trip);if(!"PLANNED".equals(trip.getStatus()) || load==null || !Objects.equals(load.manifestHash(),manifestHash))throw conflict("本车实载确认已变化,请重新核对"); if(trip.getVehicleId()!=null)requireResource("VEHICLE",trip.getVehicleId(),trip.getId()); if(trip.getDriverId()!=null)requireResource("DRIVER",trip.getDriverId(),trip.getId()); - bookingReservations.segments(trip,new FleetExecutionModels.PlannedLoad(load.manifestHash(),load.cargo(),load.pallets(),load.segments(),load.operatorId(),load.confirmedAt()),null,null); + bookingReservations.actualSegments(trip,load.pallets()); appointmentAllocations.requireDeparture(no); } @@ -157,7 +156,7 @@ public class FleetTripExecutionService { String fingerprint = hash(write(cargo)); if (!fingerprint.equals(request.manifestHash())) throw conflict("货物明细已变化,请刷新后重新核对计划容量"); var pallets = request.pallets().stream().sorted(Comparator.comparing(FleetExecutionModels.Measurement::warehousePalletId)).toList(); - var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets), operator, Instant.now()); + var plan = new FleetExecutionModels.PlannedLoad(fingerprint, cargo, pallets, capacity(trip, cargo, pallets, false), operator, Instant.now()); bookingReservations.segments(trip, plan, null, null); trip.setPlannedLoadJson(write(plan)); history.recordExecution(trip, request.recordVersion(), "PLAN_CAPACITY", request.reason(), operator); @@ -234,7 +233,7 @@ public class FleetTripExecutionService { return null; } private List capacity(FleetTripEntity trip, List cargo, - List pallets) { + List pallets, boolean actual) { List stops; try { stops = json.readValue(trip.getStopsJson(), new TypeReference<>() { }); } catch (JsonProcessingException error) { throw new IllegalStateException("车次路线读取失败", error); } @@ -262,7 +261,8 @@ public class FleetTripExecutionService { if (!measured.equals(destinations.keySet())) throw conflict("请确认每个实物托盘的托位、实际重量及体积"); if (trip.getMaxPalletSpaces() == null || trip.getMaxLoadKg() == null || trip.getMaxVolumeCbm() == null) throw conflict("车辆容量未确认"); - return TripCapacityPolicy.calculate(stops.size(), new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()), bookings); + var limits = new TripCapacityPolicy.Load(trip.getMaxPalletSpaces(), trip.getMaxLoadKg(), trip.getMaxVolumeCbm()); + return actual ? TripCapacityPolicy.calculateActual(stops.size(), limits, bookings) : TripCapacityPolicy.calculate(stops.size(), limits, bookings); } catch (IllegalArgumentException | ArithmeticException error) { throw conflict(error.getMessage()); } } private void requireResource(String kind, long id, long tripId) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java index a42438b8f..406273a62 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripService.java @@ -426,8 +426,8 @@ public class FleetTripService { if (trip.getLoadJson() != null) { var load = read(trip.getLoadJson(), FleetExecutionModels.ConfirmedLoad.class); if (load.segments().stream().anyMatch(segment -> segment.occupied().palletSpaces() > vehicle.maxPalletSpaces() - || segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0 - || segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0)) + || segment.occupied().weightKg() != null && segment.occupied().weightKg().compareTo(vehicle.maxLoadKg()) > 0 + || segment.occupied().volumeCbm() != null && segment.occupied().volumeCbm().compareTo(vehicle.maxVolumeCbm()) > 0)) throw bad("已确认装载超过替换车辆的托位、载重或容积,请先调整装载安排"); } if (!Objects.equals(trip.getVehicleId(), vehicle.id())) diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index cae109929..111d3406e 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6023,18 +6023,13 @@ components: manifestHash: type: string nullable: true - measurements: - type: array - items: - $ref: '#/components/schemas/DispatchLoadingMeasurement' - nullable: true remark: type: string nullable: true required: - recordVersion - idempotencyKey - description: 必须先由真实PDA拣货核平;这里只确认实载,不代填拣货箱数,不等于实际发车。 + description: 必须先由真实PDA拣货核平;按已扫实托确认装车;重量体积由服务端已有测量带出,缺失不阻塞,不接受现场补填测量或箱数。 DispatchDepart: type: object x-java-record: DispatchCommands.Depart diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index ffefff7f4..fd2bf8209 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -283,10 +283,8 @@ public class DispatchAggregatePersistenceTest { var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo())); assertThat(preview.pallets().get(0).weightKg()).isEqualByComparingTo("100"); assertThat(preview.pallets().get(0).evidence()).allMatch(DispatchPlanningModels.MeasurementEvidence::actual); - assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm","palletSpaces"); - var measurements=preview.pallets().stream().map(p->new DispatchContracts.LoadingMeasurement(p.warehousePalletId(),1, - p.warehousePalletId()==501L?new BigDecimal("100"):new BigDecimal("200"),p.warehousePalletId()==501L?BigDecimal.ONE:new BigDecimal("2"),p.deliveryIndex())).toList(); - var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),measurements,"实际核对"); + assertThat(preview.pallets().get(1).weightKg()).isNull();assertThat(preview.pallets().get(1).missingFields()).contains("weightKg","volumeCbm"); + var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"explicit-loaded",preview.manifestHash(),"实际核对"); // 捕获失败必须回滚同一原生装车事务,不能留下装车后状态或成功命令。 org.mockito.Mockito.doThrow(new IllegalStateException("ownership-capture-failed")).doNothing() .when(f.ownedBusinessOwnership).capture(anyLong(),org.mockito.ArgumentMatchers.nullable(Long.class), @@ -298,6 +296,10 @@ public class DispatchAggregatePersistenceTest { assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='explicit-loaded'",Long.class)).isZero(); var loaded=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING);assertThat(loaded.result()).isInstanceOf(DispatchResults.Loaded.class); + var facts=((DispatchResults.Loaded)loaded.result()).confirmedLoad(); + assertThat(facts.pallets()).hasSize(2); + assertThat(facts.pallets().get(0).weightKg()).isEqualByComparingTo("100"); + assertThat(facts.pallets().get(1).weightKg()).isNull();assertThat(facts.pallets().get(1).volumeCbm()).isNull(); assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip",String.class)).isEqualTo("PLANNED"); var replay=f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),request)); assertThat(replay.replayed()).isTrue();assertThat(replay.result()).isEqualTo(loaded.result()); @@ -316,7 +318,7 @@ public class DispatchAggregatePersistenceTest { f.db.update("UPDATE ll_dispatch_pda_pick_order SET packages=2"); var preview=f.tx.execute(s->f.service.loading(f.actor,created.dispatchNo())); assertThat(preview.pickingReconciled()).isFalse();assertThat(preview.loadingComplete().allowed()).isFalse(); - assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),List.of(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平"); + assertThatThrownBy(()->f.tx.execute(s->f.service.loadingComplete(f.actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"missing-rejected",preview.manifestHash(),null)))).isInstanceOf(BizException.class).hasMessageContaining("核平"); assertThat(f.db.queryForObject("SELECT stage FROM ll_dispatch_order",String.class)).isEqualTo("LOADING"); } /** 已提交根的公开资格关闭;新键重复提交不改变事实,原键仍先于当前状态恢复结果。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java index 55e79d820..b20c0b488 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDeparturePersistenceTest.java @@ -64,7 +64,7 @@ class DispatchDeparturePersistenceTest { var created=f.tx.execute(s->f.service.create(f.actor,f.configured("depart-create",List.of(a,b))));number=created.dispatchNo();var approved=f.approve(created); f.picked(1,"101",501,3);f.picked(2,"202",502,5);f.db.update("UPDATE ll_dispatch_order SET picked_package_count=8"); var loading=f.tx.execute(s->f.service.loading(f.actor,number));var measurements=loading.pallets().stream().map(p->new LoadingMeasurement(p.warehousePalletId(),1,BigDecimal.ONE,BigDecimal.ONE,p.deliveryIndex())).toList(); - f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),measurements,"实际实载"))); + f.tx.executeWithoutResult(s->f.service.loadingComplete(f.actor,number,new DispatchCommands.LoadingComplete(approved.recordVersion(),"depart-loaded",loading.manifestHash(),"实际实载"))); var context=f.tx.execute(s->f.service.preview(f.actor,number)); outbounds=List.of(new OutboundReference("101","测试客户101","OUT-A","WAIT_OUTBOUND",3,1),new OutboundReference("202","测试客户202","OUT-B","WAIT_OUTBOUND",5,1)); deliveryRefs=List.of(new DeliveryReference("GROUP-A","101","测试客户101","OUT-A","DL-A",context.cargo().get(0).destination(),"WAIT_DELIVERY",3,1),new DeliveryReference("GROUP-B","202","测试客户202","OUT-B","DL-B",context.cargo().get(1).destination(),"WAIT_DELIVERY",5,1)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java index fb9f2e1ae..b957971ab 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingDeparturePersistenceTest.java @@ -84,7 +84,7 @@ class DispatchLoadingDeparturePersistenceTest { @Test void pdaUsesOnePermissionButRejectsOtherWarehouseAndInvalidEvidence() throws Exception { authorize();var f=new Fixture();var pda=new Actor("picker",ActorMode.PDA,"9"); assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(new Actor("picker",ActorMode.PDA,"99"),f.number,f.request("foreign")))).hasMessageContaining("当前绑定仓"); - var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",f.measurements,null); + var bad=new DispatchCommands.LoadingComplete(f.version,"bad-manifest","changed",null); assertThatThrownBy(()->f.f.tx.execute(s->f.service.complete(pda,f.number,bad))).hasMessageContaining("真实装载清单已变化"); verify(f.delivery,never()).prepareForDeparture(any(),any(),anyString()); var result=f.f.tx.execute(s->f.service.complete(pda,f.number,f.request("pda")));assertThat(result.stage()).isEqualTo(Stage.AFTER_LOADING); @@ -243,7 +243,7 @@ class DispatchLoadingDeparturePersistenceTest { return f.tx.execute(s->documents.bind(actor,number,DispatchLoadingDocumentService.SLOTS.get(index),new BindPhoto(Integer.toString(index),"photo-bind-"+index,Long.toString(1000L+index)))); } /** 每次请求沿用最初真实测量和版本,便于验证竞争及原键恢复。 */ - DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,measurements,"实装发车");} + DispatchCommands.LoadingComplete request(String key){return new DispatchCommands.LoadingComplete(version,key,manifest,"实装发车");} /** 两客户子出库保持独立业务号和箱数。 */ List outbounds(String status){return List.of(new OutboundReference("101","客户101","OUT-A",status,3,1),new OutboundReference("202","客户202","OUT-B",status,5,1));} /** 两目的地仍对应各自真实客户,不把整车合并为单客户。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java index fa43bf205..c83496c6a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java @@ -50,7 +50,7 @@ class DispatchLoadingEligibilityPersistenceTest { "ll_dispatch_pda_pick","ll_dispatch_pda_pick_order","ll_dispatch_pallet_lock","ll_dispatch_physical_pallet_lock", "ll_fleet_trip","ll_fleet_trip_history"); var before=snapshot(f,tables); - var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),List.of(),"未实收负例"); + var request=new DispatchCommands.LoadingComplete(approved.recordVersion(),"unreceived-negative",preview.manifestHash(),"未实收负例"); assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),request))) .isInstanceOfSatisfying(BizException.class,error->assertThat(error.getResultCode()).isEqualTo(ResultCode.CONFLICT)) .hasMessage("逐项真实拣货尚未核平"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index 79fc0ee00..0efae0f2d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -96,7 +96,7 @@ class DispatchMergedRelabelPersistenceTest { var preview=f.f.service.loading(f.f.actor,created.dispatchNo());assertThat(preview.pallets()).hasSize(1);assertThat(preview.pallets().get(0).scheduledPackages()).isEqualTo(5); var pallet=preview.pallets().get(0); var loaded=f.f.tx.execute(s->f.f.service.loadingComplete(f.picker,created.dispatchNo(),new DispatchCommands.LoadingComplete(last.recordVersion(),"load",preview.manifestHash(), - List.of(new DispatchContracts.LoadingMeasurement(501L,1,new BigDecimal("10"),BigDecimal.ONE,pallet.deliveryIndex()==null?1:pallet.deliveryIndex())),null))); + null))); assertThat(loaded.stage()).isEqualTo(Stage.AFTER_LOADING); assertThat(f.count("ll_inventory_ledger WHERE business_type='SHIP'")).isEqualTo(2); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java index 12aeb0f9b..772ef7239 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/domain/TripCapacityPolicyTest.java @@ -53,4 +53,18 @@ class TripCapacityPolicyTest { @Test void emptyTripRetainsCapacityOnEachSegment() { assertThat(calculate(3, capacity, List.of())).allSatisfy(segment -> assertThat(segment.remaining()).isEqualTo(capacity)); } + + @Test void thirtyActualPalletsWithoutMeasurementsDoNotBecomeZeroWeightOrSellableCapacity() { + var pallets=java.util.stream.IntStream.range(0,30).mapToObj(i->new Booking("P"+i,0,1,new Load(1,null,null))).toList(); + var segment=calculateActual(2,capacity,pallets).get(0); + assertThat(segment.occupied().palletSpaces()).isEqualTo(30); + assertThat(segment.occupied().weightKg()).isNull();assertThat(segment.remaining().volumeCbm()).isNull(); + assertThatThrownBy(()->calculate(2,capacity,pallets)).hasMessageContaining("重量和体积"); + var tooMany=new java.util.ArrayList<>(pallets);tooMany.add(new Booking("EXTRA",0,1,new Load(1,null,null))); + assertThatThrownBy(()->calculateActual(2,capacity,tooMany)).hasMessageContaining("超过"); + } + @Test void unknownPalletDoesNotHideKnownOverweight() { + assertThatThrownBy(()->calculateActual(2,capacity,List.of(new Booking("UNKNOWN",0,1,new Load(1,null,null)), + new Booking("KNOWN",0,1,load(1,"18001","1"))))).hasMessageContaining("超过"); + } } diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index 351724946..26653a445 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -70,11 +70,11 @@ function model(fault = '') { if(fault==='outage disguised as rejection')return {http:503,body:{code:'S0503'},requestId:'r',traceId:'t'} return reject(fault==='unrelated gate rejection'?'车次版本已变化':'逐项真实拣货尚未核平') } - assert.equal(body.recordVersion,nativeVersion); assert.equal(body.measurements.length,2) + assert.equal(body.recordVersion,nativeVersion); assert.equal('measurements' in body,false) stage='DELIVERING';nativeVersion++;trip.trip.status='IN_TRANSIT' for(const [i,s] of state.entries()) { const m=members[i];s.outbound={outboundNo:`OUT${i}`,dispatchNo:'DS-ONE',overseasWarehouseId:'WH',customerId:m.customerId,status:'OUTBOUNDED',packageCount:1};s.delivery={order:{deliveryNo:`DL${i}`,customerId:fault==='foreign delivery'?'OTHER':m.customerId,dispatchNo:'DS-ONE',outboundNo:`OUT${i}`,status:fault==='partial departure'&&i===1?'WAIT_DELIVERY':'DELIVERING',version:0}} } if(fault==='depart deducts again'){state[0].stock.balances[0].availablePackages='-1';state[0].stock.positions[0].availablePackages='-1'} - confirmedLoad={manifestHash:body.manifestHash,pallets:copy(body.measurements),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'} + confirmedLoad={manifestHash:body.manifestHash,pallets:members.map((m,i)=>({warehousePalletId:m.palletId,palletSpaces:1,weightKg:100,volumeCbm:1,deliveryIndex:i+1})),operatorId:'PDA-ACTOR',confirmedAt:'2026-10-19T01:00:00Z'} return remember(body,{confirmedLoad,loadingCompletionHash:'LOADED'},token,'LOADING_COMPLETE') } if (path.endsWith('/pick')) { @@ -119,13 +119,13 @@ test('current DTO-shaped two-customer trip rejects unready gate, departs once an assert.ok(!m.calls.some(c=>c.raw.endsWith('/complete')||c.raw.includes('/dispatches/bind'))) }) -test('missing physical pallet-space evidence blocks after both real picks without fabricating loading or delivery',async()=>{ +test('scanned pallets confirm without a separate pallet-space measurement receipt',async()=>{ const m=model();delete m.fixture.loadingMeasurements - await assert.rejects(verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}),/palletSpaces/) - assert.ok(m.state.every(row=>row.picked===1&&row.delivery===null)) - assert.equal(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).length,1,'only the earlier expected unready rejection was sent') - assert.equal(m.calls.some(call=>call.raw.endsWith('/transfer-delivery')),false) + await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory}) + assert.ok(m.state.every(row=>row.picked===1&&row.delivery!==null)) + assert.ok(m.calls.filter(call=>call.raw.endsWith('/loading-complete')).every(call=>!('measurements' in call.body))) }) + test('explicit rollback verifier runs after both picks and before successful departure',async()=>{ const m=model();let calls=0 const proof=await verifyFleetCrossCustomer(m.context,m.fixture,{execute:true,readInventory:m.readInventory, diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs index 79b2de735..05215ee69 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance.mjs @@ -169,7 +169,7 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep && canonical(priorProof.members) === canonical(proof.members) && priorProof.requests.every(row => row.method === 'GET' || row.status === 'RESPONDED') && Number(prior?.dispatch?.counts?.pickedPackageCount) === 2 && id(command?.body?.recordVersion) === id(prior?.dispatch?.recordVersion) - && canonical(command?.body?.measurements) === canonical(command?.result?.result?.confirmedLoad?.pallets) + && canonical(fixture.tripProof.loadingPreview?.pallets?.map(p=>({warehousePalletId:id(p.warehousePalletId),palletSpaces:1,weightKg:p.weightKg??null,volumeCbm:p.volumeCbm??null,deliveryIndex:p.deliveryIndex}))) === canonical(command?.result?.result?.confirmedLoad?.pallets) && prior?.dispatch?.dispatchNo === fixture.dispatchNo && prior.dispatch.stage === 'LOADING' && command?.body?.idempotencyKey && command.result?.action === 'LOADING_COMPLETE' && command.result.stage === 'DELIVERING' && command.result.dispatchNo === fixture.dispatchNo && command.result.idempotencyKey === command.body.idempotencyKey @@ -200,7 +200,7 @@ async function runCrossCustomerCore(context, fixture, { readInventory, verifyDep && rejectedBefore.dispatch.stage === 'LOADING' && rejectedBefore.task.pickingReconciled === false) const preview = await request('GET', `${pdaPath}/loading`, undefined, 'Read incomplete native loading manifest', true) await request('POST', `${pdaPath}/loading-complete`, { recordVersion: rejectedBefore.task.recordVersion, idempotencyKey: key('UNREADY-LOAD'), - manifestHash: preview.manifestHash, measurements: [], remark: context.marker }, 'Unready second customer blocks explicit loading', true, + manifestHash: preview.manifestHash, remark: context.marker }, 'Unready second customer blocks explicit loading', true, message => /拣货.*核平|尚未核平/.test(message)) equal(rejectedBefore, await snapshot('GATE_AFTER'), 'Rejected departure leaves both complete inventories/outbounds/deliveries/trip unchanged') proof.gateProof = 'PREREQUISITE_REJECTION_VERIFIED' diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 6d6b02987..b637ea262 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -143,9 +143,9 @@ function server(fault = '') { } assert.equal(detail.stage, 'LOADING'); assert.equal(detail.counts.pickedPackageCount, 3) assert.equal(body.recordVersion, detail.recordVersion); assert.equal(body.manifestHash, 'REAL-MANIFEST') - assert.deepEqual(body.measurements, [{ warehousePalletId: PALLET, palletSpaces: 2, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }]) + assert.equal('measurements' in body, false) loadingActors.set(body.idempotencyKey, token ? 'PDA' : 'PLATFORM') - detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } }) + detail.stage = 'DELIVERING'; bump(); return remember('LOADING_COMPLETE', body, { confirmedLoad: { manifestHash: body.manifestHash, pallets: [{ warehousePalletId: PALLET, palletSpaces: 1, weightKg: '187.25', volumeCbm: '1.375', deliveryIndex: 1 }] } }) } if (method === 'GET' && path.startsWith(`${F}/resource-occupancy?`)) { const query = new URL(`https://unused.test${path}`).searchParams, kind = query.get('kind') @@ -313,7 +313,7 @@ test('PDA original-actor replay restores loading while another platform actor is assert.equal(proof.loadingCommand.platformPositiveLoading, 'PENDING_SEPARATE_SCENARIO') assert.deepEqual(proof.loadingCommand.beforeReplay, proof.loadingCommand.afterReplay) assert.equal(proof.loadingCommand.platformReplay, undefined) - assert.equal(writes[0].body.measurements[0].weightKg, '187.25') + assert.equal('measurements' in writes[0].body, false) assert.ok(s.calls.every(c => !/\/fleet\/trips\/.*\/(?:assignment|loading-review|loading)$/.test(c.path))) const count = s.calls.length await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA', receipt()), /已开始/) @@ -347,10 +347,11 @@ for (const fault of ['edit-mutates-on-rejection', 'replay-new-version']) test(`l if (fault === 'edit-mutates-on-rejection') assert.equal(s.calls.some(c => c.path.endsWith('/loading-complete')), false) }) -test('missing actual producer measurement blocks before any edit rejection or loading write', async () => { - const s = server(), proof = await prepare(s); s.pick(); const count = s.calls.length - await assert.rejects(confirmFleetLoading(s.context, 'native', proof, 'PDA'), /palletSpaces/) - assert.equal(s.calls.length, count + 1); assert.equal(s.calls.at(-1).method, 'GET') +test('scanned loading no longer requires the operator to supply a measurement receipt', async () => { + const s = server(), proof = await prepare(s); s.pick() + await confirmFleetLoading(s.context, 'native', proof, 'PDA') + assert.equal(proof.loadingCommand.result.stage, 'DELIVERING') + assert.equal('measurements' in proof.loadingCommand.body, false) }) test('wrong-run producer receipt and unreconciled picking block loading without mutation', async () => { diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index 492cf07db..f8816b7b7 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -228,11 +228,11 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu || preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`) if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker || actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车') - const measurements = controlledPalletMeasurements(preview.pallets, proof.stops, actualMeasurements?.pallets) + const measurements = preview.pallets.map(p => ({ warehousePalletId: id(p.warehousePalletId), palletSpaces: 1, weightKg: p.weightKg ?? null, volumeCbm: p.volumeCbm ?? null, deliveryIndex: p.deliveryIndex })) await verifyPreDepartureReassignment(context, module, proof) proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion, - idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, measurements, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } } + idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } } if (beforeComplete) await beforeComplete(proof.loadingCommand) const confirmed = await command(context, module, 'POST', proof.loadingCommand.path, proof.loadingCommand.body, '原生显式装车完成', pdaToken) check(context, module, '装车完成即进入派送中', confirmed.dispatchNo === proof.dispatchNo && confirmed.status === 'ACTIVE' && confirmed.stage === 'DELIVERING' @@ -240,8 +240,8 @@ export async function confirmFleetLoading(context, module, proof, pdaToken, actu const returned = confirmed.result.confirmedLoad.pallets check(context, module, '实载回执逐托保留原测量且不增加货量', returned?.length === measurements.length && measurements.every(m => returned.some(r => id(r.warehousePalletId) === m.warehousePalletId - && r.palletSpaces === m.palletSpaces && decimal(r.weightKg) === decimal(m.weightKg) - && decimal(r.volumeCbm) === decimal(m.volumeCbm) && r.deliveryIndex === m.deliveryIndex))) + && r.palletSpaces === m.palletSpaces && (r.weightKg == null ? null : decimal(r.weightKg)) === (m.weightKg == null ? null : decimal(m.weightKg)) + && (r.volumeCbm == null ? null : decimal(r.volumeCbm)) === (m.volumeCbm == null ? null : decimal(m.volumeCbm)) && r.deliveryIndex === m.deliveryIndex))) proof.loadingCommand.result = confirmed; proof.confirmedLoad = confirmed.result.confirmedLoad const snapshot = async label => ({ main: await command(context, module, 'GET', path, undefined, `${label}主单`), From 632d35faafde104171fe0dbdbdae8bcd11e93dd3 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 18:45:06 +0800 Subject: [PATCH 072/113] docs(api): describe unknown actual loading measurements --- .../src/main/resources/openapi/fleet-trips-v1.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml index fcfcd23db..63bd72cdf 100644 --- a/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/fleet-trips-v1.yaml @@ -602,8 +602,8 @@ components: properties: warehousePalletId: {type: string} palletSpaces: {type: integer, minimum: 1} - weightKg: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999} - volumeCbm: {type: number, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999} + weightKg: {type: number, nullable: true, description: 已确认装车的未知实测保持空值;计划容量输入仍须填写正数, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999} + volumeCbm: {type: number, nullable: true, description: 已确认装车的未知实测保持空值;计划容量输入仍须填写正数, minimum: 0.001, multipleOf: 0.001, maximum: 999999999.999} deliveryIndex: {type: integer, minimum: 1, description: 卸货站索引,起运仓固定为0} PlanningEnvelope: allOf: @@ -641,8 +641,8 @@ components: type: object properties: palletSpaces: {type: integer} - weightKg: {type: number} - volumeCbm: {type: number} + weightKg: {type: number, nullable: true, description: 实测不完整时未知,不表示零重量或可售余量} + volumeCbm: {type: number, nullable: true, description: 实测不完整时未知,不表示零体积或可售余量} ConfirmLoad: type: object required: [recordVersion, manifestHash, pallets, reason] From e2ad7857b421ef0891da7e280ee89cfd733837da Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 18:50:56 +0800 Subject: [PATCH 073/113] =?UTF-8?q?feat(dispatch):=20=E4=BB=8E=E5=BD=93?= =?UTF-8?q?=E5=89=8D=E6=8E=92=E8=BD=A6=E6=8F=90=E4=BE=9B=E5=86=85=E9=83=A8?= =?UTF-8?q?=E6=95=A3=E6=9D=BF=E9=9C=80=E6=B1=82=E6=B1=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + deploy/sql/海外区域统一增量-MySQL8.sql | 1 + lastleg-service/AGENTS.md | 1 + .../controller/DispatchQueryController.java | 9 +++ .../dispatch/entity/DispatchAggregate.java | 2 + .../mapper/DispatchAggregateMapper.java | 17 +++- .../port/DispatchCapacityDemands.java | 22 ++++++ .../dispatch/port/DispatchCommands.java | 9 ++- .../lastleg/dispatch/port/DispatchViews.java | 2 +- .../DispatchAggregateRepository.java | 2 + .../service/DispatchAggregateService.java | 4 +- .../DispatchCapacityDemandService.java | 66 ++++++++++++++++ .../resources/db/dispatch-capacity-demand.sql | 3 + .../src/main/resources/db/lastleg.sql | 1 + .../openapi/overseas-business-v1.yaml | 60 ++++++++++++++ .../PlatformDispatchOutboundContractTest.java | 2 +- .../dispatch/DispatchSchemaContractTest.java | 2 +- .../DispatchQueryControllerTest.java | 15 ++++ .../port/DispatchPublicJsonContract.java | 2 +- .../DispatchAggregatePersistenceTest.java | 10 +-- .../DispatchAppointmentAllocationsTest.java | 2 +- ...DispatchCapacityDemandPersistenceTest.java | 78 +++++++++++++++++++ .../service/DispatchDraftPersistenceTest.java | 6 +- .../service/DispatchRepairWorkflowTest.java | 2 +- ...DispatchResourcePhotosPersistenceTest.java | 2 +- ...DispatchReviewWorkflowPersistenceTest.java | 2 +- .../OwnedBusinessScopePersistenceTest.java | 2 +- 27 files changed, 302 insertions(+), 24 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCapacityDemands.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandService.java create mode 100644 lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 4da54370c..5697a4360 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -52,6 +52,8 @@ 换标直接出库的实物体积快照在 `ll_outbound_order.volume_cbm`、`ll_outbound_pallet_item.volume_cbm`、`ll_delivery_order.total_volume_cbm` 三列均允许 NULL,表示来源尚未确认;已有实盘尺寸按箱计算,不能用默认 0 代替未知,也不按 SKU 件数重复累计。两份初始化基线同步此定义,已有 TEST 只能在核对结构和授权后精确调整这三列,不重放空库文件或回填历史行。 +排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。 + ## 唯一入口与区域边界 V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index 347bc2820..c9c1fe2a0 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -417,6 +417,7 @@ CREATE TABLE ll_dispatch_order ( id BIGINT NOT NULL COMMENT '整车排车主单内部主键', dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车', + publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', diff --git a/lastleg-service/AGENTS.md b/lastleg-service/AGENTS.md index 6b429627a..155910180 100644 --- a/lastleg-service/AGENTS.md +++ b/lastleg-service/AGENTS.md @@ -56,6 +56,7 @@ | 贴标申请、箱唛/SKU/组合换标与 PDA 完工 | `relabel/` | | PDA 仓内通用扫码与操作历史 | `pda/warehouse/controller/PdaWarehouseController.java` | | 船节点接收与海外 PDA 可收货门禁 | `shipping/service/ContainerShippingNodeConsumer.java` | +| 内部散板需求只读来源 | `dispatch/service/DispatchCapacityDemandService.java`、`DispatchQueryController.java`的`/dispatches/capacity-demands`;默认发布字段随原排车创建/编辑保存,当前业务口径见拼车主契约,不恢复旧车次人工发布入口 | | 平台排车查询、整车四块详情、审核与实载 | `dispatch/controller/DispatchQueryController.java`、`DispatchAggregateController.java`;公开契约`dispatch/port/`,业务规则见`../../docs/海外仓业务/排车列表统一作业改造契约.md` | | 草稿、双端装车发运 | `dispatch/controller/SmartDispatchController.java`、`dispatch/pda/controller/DispatchPdaController.java`;平台维护`DispatchController.java`;自动发运编排`dispatch/service/DispatchLoadingDepartureService.java` | | 仓储资源调用 | `inboundplan/pda/gateway/WarehouseResourceGateway.java` | diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java index fbaa18cec..f2ab02744 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryController.java @@ -36,6 +36,7 @@ import java.nio.charset.StandardCharsets; public class DispatchQueryController { @Resource private DispatchAggregateService aggregateService; @Resource private DispatchBolPrintService printService; + @Resource private com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands; @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @@ -47,6 +48,14 @@ public class DispatchQueryController { return ApiResponse.success(aggregateService.page(DispatchActorFactory.platform(user),query)); } + /** 内部需求池沿独立只读权限,不授予销售整车客户及司机资料读取权限。 */ + @GetMapping("/capacity-demands") + @PreAuthorize("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')") + public ApiResponse> capacityDemands( + @AuthenticationPrincipal AuthenticatedUser user, @Valid com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) { + requirePlatform(user); return ApiResponse.success(capacityDemands.page(query)); + } + @GetMapping("/{dispatchNo}") @PreAuthorize("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')") @Operation(summary = "Get dispatch details and all pallet destinations") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java index 0fa048fba..337013c1d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/entity/DispatchAggregate.java @@ -9,6 +9,8 @@ public class DispatchAggregate { private Long id; private String dispatchNo; private String taskType; + /** 是否向内部散板需求池发布本车余位。 */ + private Boolean publishLooseboardDemand = true; private Long tripId; private String overseasWarehouseId; private String overseasWarehouseCode; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java index a8a1e3745..e53ef8f34 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchAggregateMapper.java @@ -40,12 +40,12 @@ public interface DispatchAggregateMapper { /** 首次创建整车所有权及初始版本,不写customer_id。 */ @Insert(""" - INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,trip_id,overseas_warehouse_id,overseas_warehouse_code, + INSERT INTO ll_dispatch_order(id,dispatch_no,task_type,publish_looseboard_demand,trip_id,overseas_warehouse_id,overseas_warehouse_code, overseas_warehouse_name,status,stage,record_version,cargo_revision,cargo_hash,review_state,review_round, dispatch_review_json,loading_review_json,resources_json,vehicle_plate,route,planned_departure_at, estimated_arrival_at,item_count,pallet_count,package_count,picked_package_count,shipped_package_count, remark,created_by,created_at,updated_at) - VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, + VALUES(#{r.id},#{r.dispatchNo},COALESCE(#{r.taskType},'DELIVERY'),#{r.publishLooseboardDemand},#{r.tripId},#{r.overseasWarehouseId},#{r.overseasWarehouseCode}, #{r.overseasWarehouseName},#{r.status},#{r.stage},#{r.recordVersion},#{r.cargoRevision},#{r.cargoHash}, #{r.reviewState},#{r.reviewRound},#{r.dispatchReviewJson},#{r.loadingReviewJson},#{r.resourcesJson}, #{r.vehiclePlate},#{r.route},#{r.plannedDepartureAt},#{r.estimatedArrivalAt},#{r.itemCount},#{r.palletCount}, @@ -66,6 +66,17 @@ public interface DispatchAggregateMapper { + " ORDER BY d.created_at DESC,d.id DESC LIMIT #{q.limit} OFFSET #{q.offset}") List page(@Param("q") Search query); + /** 只读余位来源仅含勾选发布、未发车的有效派送排车,不混入取货和本土卡车。 */ + @Select(""" + + """) + List capacityDemands(@Param("q") com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query); + /** 分页总数复用完全相同筛选,不能用当前页长度替代。 */ @Select("") long count(@Param("q") Search query); @@ -91,7 +102,7 @@ public interface DispatchAggregateMapper { /** 父锁内按公共版本保存状态及审核依据,显式清空旧依据而不依赖实体非空更新策略。 */ @Update(""" - UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion}, + UPDATE ll_dispatch_order SET status=#{r.status},stage=#{r.stage},record_version=#{r.recordVersion},publish_looseboard_demand=#{r.publishLooseboardDemand}, review_state=#{r.reviewState},review_round=#{r.reviewRound},review_basis_hash=#{r.reviewBasisHash}, dispatch_review_json=#{r.dispatchReviewJson},loading_review_json=#{r.loadingReviewJson}, exceptions_json=#{r.exceptionsJson},cargo_hash=#{r.cargoHash},cargo_revision=#{r.cargoRevision},resources_json=#{r.resourcesJson},vehicle_plate=#{r.vehiclePlate},planned_departure_at=#{r.plannedDepartureAt}, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCapacityDemands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCapacityDemands.java new file mode 100644 index 000000000..9ac6ce756 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCapacityDemands.java @@ -0,0 +1,22 @@ +package com.baiyue.lastleg.dispatch.port; + +import java.math.BigDecimal; +import java.time.Instant; + +/** 内部余位池仅公开本车运输摘要,不包含客户货物或司机私人资料。 */ +public final class DispatchCapacityDemands { + /** 只承载只读查询模型。 */ + private DispatchCapacityDemands() { } + public record Query(Integer page, Integer size, String origin, String destination) { + /** 查询范围有界,默认每页二十条。 */ + public Query { + page = page == null ? 1 : page; size = size == null ? 20 : size; + if (page < 1 || size < 1 || size > 100) throw new IllegalArgumentException("余位需求分页参数无效"); + origin = origin == null || origin.isBlank() ? null : origin.trim(); + destination = destination == null || destination.isBlank() ? null : destination.trim(); + } + } + public record Demand(String dispatchNo, String warehouseName, String route, Instant plannedDepartureAt, + Integer vehiclePalletSpaces, Integer scheduledPalletCount, Integer remainingPalletSpaces, + BigDecimal remainingWeightKg, BigDecimal remainingVolumeCbm, boolean capacityKnown, String capacityReason) { } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java index 3d6b87df9..937664907 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchCommands.java @@ -23,13 +23,16 @@ public final class DispatchCommands { /** 空cargo表示先建空单;实际来源及客户必须由服务端解析。 */ public record Create(String idempotencyKey, String warehouseId, Vehicle vehicle, Driver driver, Instant plannedDepartureAt, Instant estimatedArrivalAt, String remark, - List cargo, List batchAllocations) { } + List cargo, List batchAllocations, Boolean publishLooseboardDemand) { + /** 新建排车默认发布内部余位,显式取消时保持不发布。 */ + public Create { publishLooseboardDemand = publishLooseboardDemand == null ? true : publishLooseboardDemand; } + } public record Update(Long recordVersion, String idempotencyKey, Vehicle vehicle, Driver driver, Instant plannedDepartureAt, Instant estimatedArrivalAt, - String remark) { + String remark, Boolean publishLooseboardDemand) { /** 缺版本与有效版本零必须区分。 */ - public Update { recordVersion = requireVersion(recordVersion); } + public Update { recordVersion = requireVersion(recordVersion); if (publishLooseboardDemand == null) throw new IllegalArgumentException("请明确是否发布散板需求"); } } public record AddCargo(Long recordVersion, String idempotencyKey, List cargo, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 2d662b366..3cbaacdc3 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -64,7 +64,7 @@ public final class DispatchViews { ReviewState reviewState, Reviews reviews, Counts counts, boolean pickingReconciled, Map actions, VehicleInfo vehicleInfo, DispatchInfo dispatchInfo, Documents documents, - List orderDetails) { } + List orderDetails, boolean publishLooseboardDemand) { } public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java index c0da1705a..f98e11c32 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -34,6 +34,8 @@ public class DispatchAggregateRepository { public DispatchAggregate lock(String number) { return mapper.lock(number); } /** 整车分页。 */ public List page(DispatchAggregateMapper.Search query) { return mapper.page(query); } + /** 一次读取当前有效的内部余位来源,服务计算满载筛选后再分页。 */ + public List capacityDemands(com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query query) { return mapper.capacityDemands(query); } /** 整车分页总数。 */ public long count(DispatchAggregateMapper.Search query) { return mapper.count(query); } /** 全搜索基集的阶段和审核分布。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 9acc8c401..f63a97c40 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -305,6 +305,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA row.setId(id); row.setDispatchNo(number); row.setTripId(shell.tripId()); row.setOverseasWarehouseId(shell.warehouseId()); row.setOverseasWarehouseCode(shell.warehouseCode()); row.setOverseasWarehouseName(shell.warehouseName()); row.setStatus(Status.ACTIVE.name()); row.setStage(Stage.BEFORE_LOADING.name()); row.setRecordVersion(0L); + row.setPublishLooseboardDemand(request.publishLooseboardDemand()); row.setCargoRevision(0L); row.setCargoHash(hashes.source(List.of())); row.setReviewState(ReviewState.DRAFT.name()); row.setReviewRound(0L); row.setDispatchReviewJson(write(pending(0))); row.setLoadingReviewJson(write(pending(0))); var photos=request.driver()==null || request.driver().evidence()==null?List.of():request.driver().evidence().photoFileIds(); @@ -393,6 +394,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var actual=fleet.dispatchResources(number,fleetVehicle(request.vehicle()),fleetDriver(request.driver())); row.setResourcesJson(write(resources(actual,photos)));row.setVehiclePlate(actual.vehicle()==null?null:actual.vehicle().plate()); row.setPlannedDepartureAt(utc(request.plannedDepartureAt()));row.setEstimatedArrivalAt(utc(request.estimatedArrivalAt()));row.setRemark(request.remark()); + row.setPublishLooseboardDemand(request.publishLooseboardDemand()); if(!Objects.equals(before,basis(row)))invalidate(row,"车辆、司机或计划信息已修改"); fleet.saveDispatchResources(number,actor.operatorId(),actual,request.plannedDepartureAt(),request.remark()); return saveCommand(row,actor,identity,request.remark()); @@ -754,7 +756,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return new DispatchViews.Detail(number,row.getTaskType()==null?"DELIVERY":row.getTaskType(),Status.valueOf(row.getStatus()),Stage.valueOf(row.getStage()),row.getRecordVersion(),ReviewState.valueOf(row.getReviewState()), reviews(row),counts(row),reconciled(row),actions(actor,row),new DispatchViews.VehicleInfo(resources.vehicle(),resources.driver()), new DispatchViews.DispatchInfo(row.getRoute(),instant(row.getPlannedDepartureAt()),instant(row.getDepartedAt()),instant(row.getEstimatedArrivalAt()),appointments,batchRefs,exceptionRecords(row)), - documents,orderDetails(cargo)); + documents,orderDetails(cargo),Boolean.TRUE.equals(row.getPublishLooseboardDemand())); } /** 恢复动作结果前重新核验当前单据范围和原动作权限。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandService.java new file mode 100644 index 000000000..cba0ab965 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandService.java @@ -0,0 +1,66 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.dispatch.entity.DispatchAggregate; +import com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts; +import com.baiyue.lastleg.dispatch.repository.DispatchAggregateRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Isolation; +import java.math.BigDecimal; +import java.time.ZoneOffset; +import java.util.Objects; + +/** 从日常排车实时投影内部余位,不创建第二套车次、发布单或占位。 */ +@Service +public class DispatchCapacityDemandService { + private final DispatchAggregateRepository repository; + private final ObjectMapper json; + /** 复用排车仓储和正式装载快照解析。 */ + public DispatchCapacityDemandService(DispatchAggregateRepository repository, ObjectMapper json) { + this.repository = repository; this.json = json; + } + /** 当前候选一次查询;已知满载先过滤,随后分页,保证总数与结果一致。 */ + @Transactional(readOnly = true, isolation = Isolation.REPEATABLE_READ) + public PageResult page(DispatchCapacityDemands.Query query) { + var values = repository.capacityDemands(query).stream().map(this::project).filter(Objects::nonNull).toList(); + long start = Math.min((long)(query.page()-1)*query.size(), values.size()); + int end = (int)Math.min(start+query.size(), values.size()); + return new PageResult<>(values.subList((int)start,end), query.page(), query.size(), values.size(), (values.size()+query.size()-1)/query.size()); + } + /** 实托数不等于标准托位;缺少可靠装载证据时明确未知,不能拿三十托或零值推算。 */ + private DispatchCapacityDemands.Demand project(DispatchAggregate row) { + Integer slots = null, remaining = null; BigDecimal weight = null, volume = null; + String reason = "占用标准托位、重量及体积待确认"; + try { + var resources = row.getResourcesJson() == null ? null : json.readValue(row.getResourcesJson(), DispatchContracts.Resources.class); + var cap = resources == null || resources.vehicle() == null ? null : resources.vehicle().capacity(); + if (cap != null) slots = cap.palletSpaces(); + var load = row.getLoadingConfirmationJson() == null ? null : json.readValue(row.getLoadingConfirmationJson(), DispatchContracts.ConfirmedLoad.class); + if (cap == null || slots == null || slots <= 0 || !positive(cap.maxLoadKg()) || !positive(cap.maxVolumeCbm())) reason = "车辆真实容量待确认"; + if (cap != null && load != null && load.pallets() != null && !load.pallets().isEmpty() + && load.pallets().stream().allMatch(p -> p.warehousePalletId()!=null && p.palletSpaces()>0) + && load.pallets().stream().map(DispatchContracts.LoadingMeasurement::warehousePalletId).distinct().count()==load.pallets().size()) { + // 首版均在起运仓上车,后续仅卸货;任一已知维度满载都退出需求池。 + long occupied = load.pallets().stream().mapToLong(DispatchContracts.LoadingMeasurement::palletSpaces).sum(); + if (slots != null && slots > 0) { if (occupied >= slots) return null; remaining = Math.toIntExact(slots-occupied); } + if (positive(cap.maxLoadKg()) && load.pallets().stream().allMatch(p -> positive(p.weightKg()))) { + var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::weightKg).reduce(BigDecimal.ZERO,BigDecimal::add); + if (used.compareTo(cap.maxLoadKg())>=0) return null; weight=cap.maxLoadKg().subtract(used); + } + if (positive(cap.maxVolumeCbm()) && load.pallets().stream().allMatch(p -> positive(p.volumeCbm()))) { + var used = load.pallets().stream().map(DispatchContracts.LoadingMeasurement::volumeCbm).reduce(BigDecimal.ZERO,BigDecimal::add); + if (used.compareTo(cap.maxVolumeCbm())>=0) return null; volume=cap.maxVolumeCbm().subtract(used); + } + if (remaining!=null && weight!=null && volume!=null) reason=null; + } + } catch (JsonProcessingException invalid) { reason="车辆或装载资料待核对"; } + return new DispatchCapacityDemands.Demand(row.getDispatchNo(),row.getOverseasWarehouseName(),row.getRoute(), + row.getPlannedDepartureAt()==null?null:row.getPlannedDepartureAt().toInstant(ZoneOffset.UTC),slots,row.getPalletCount(),remaining,weight,volume,reason==null,reason); + } + /** 三维容量均须有真实正值。 */ + private static boolean positive(BigDecimal value) { return value!=null && value.signum()>0; } +} diff --git a/lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql b/lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql new file mode 100644 index 000000000..6a5f798cc --- /dev/null +++ b/lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql @@ -0,0 +1,3 @@ +-- 本次仅新增排车内部余位发布选项;执行需单独授权,不回填、清理或重置业务数据。 +ALTER TABLE ll_dispatch_order + ADD COLUMN publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位' AFTER task_type; diff --git a/lastleg-service/src/main/resources/db/lastleg.sql b/lastleg-service/src/main/resources/db/lastleg.sql index aa3604c51..72a1c4fa0 100644 --- a/lastleg-service/src/main/resources/db/lastleg.sql +++ b/lastleg-service/src/main/resources/db/lastleg.sql @@ -1895,6 +1895,7 @@ CREATE TABLE IF NOT EXISTS ll_dispatch_order ( id BIGINT NOT NULL COMMENT '整车排车主单内部主键', dispatch_no VARCHAR(64) NOT NULL COMMENT '真实排车业务号', task_type VARCHAR(32) NOT NULL DEFAULT 'DELIVERY' COMMENT '任务类型:仓库派送、到仓取货、本土卡车', + publish_looseboard_demand TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否向内部散板需求池发布余位', trip_id BIGINT NOT NULL COMMENT '本趟唯一车辆资源记录ID', overseas_warehouse_id VARCHAR(128) NOT NULL COMMENT '真实起运海外仓ID', overseas_warehouse_code VARCHAR(128) NULL COMMENT '起运海外仓编码快照', diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index 111d3406e..f895b4d25 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -4,6 +4,35 @@ info: description: 派送资料完整契约见同目录 delivery-documents-v1.yaml;派送列表/详情行返回 bolNo、documentSummary(三类状态与x/3),状态仍使用 version 和 allowedActions。 version: 1.0.0 paths: + /api/platform/v1/last-leg/dispatches/capacity-demands: + get: + summary: 读取有效派送排车的内部散板余位需求 + description: 仅平台内部capacity-offer:list或manage可读;返回运输摘要,不返回客户、司机私人资料。实时排除未勾选、取货、本土卡车、已发车、取消及已知满载排车;缺测余位明确未知。 + operationId: listDispatchCapacityDemands + security: [{ bearerAuth: [] }] + parameters: + - { name: page, in: query, schema: { type: integer, minimum: 1, default: 1 } } + - { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } } + - { name: origin, in: query, schema: { type: string } } + - { name: destination, in: query, schema: { type: string } } + responses: + '200': + description: 统一业务响应,data为包含records、page、size、total和pages的分页结果。 + content: + application/json: + schema: + type: object + properties: + code: { type: string } + data: + type: object + properties: + records: { type: array, items: { $ref: '#/components/schemas/DispatchCapacityDemand' } } + page: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] } + size: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] } + total: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] } + pages: { oneOf: [{ type: string, pattern: '^[0-9]+$' }, { type: integer }] } + /api/merchant/v1/last-leg/warehouse-transfers/{transferNo}/increase-forecast-cartons: post: summary: 测量完成前增加预报箱数并补充箱唛 @@ -3961,6 +3990,10 @@ components: type: object x-java-record: DispatchCommands.Update properties: + publishLooseboardDemand: + type: boolean + description: 是否向内部散板需求池发布本车余位;新建默认勾选。 + recordVersion: oneOf: - type: string @@ -3990,6 +4023,7 @@ components: type: string nullable: true required: + - publishLooseboardDemand - recordVersion - idempotencyKey DeliveryStatusChange: @@ -5783,10 +5817,31 @@ components: required: - sourceType - sourceVersion + DispatchCapacityDemand: + type: object + x-java-record: DispatchCapacityDemands.Demand + properties: + dispatchNo: { type: string } + warehouseName: { type: string, nullable: true } + route: { type: string, nullable: true } + plannedDepartureAt: { type: string, format: date-time, nullable: true } + vehiclePalletSpaces: { type: integer, nullable: true } + scheduledPalletCount: { type: integer, nullable: true } + remainingPalletSpaces: { type: integer, nullable: true } + remainingWeightKg: { type: number, nullable: true } + remainingVolumeCbm: { type: number, nullable: true } + capacityKnown: { type: boolean } + capacityReason: { type: string, nullable: true } + required: [dispatchNo, capacityKnown] DispatchCreate: type: object x-java-record: DispatchCommands.Create properties: + publishLooseboardDemand: + type: boolean + description: 是否向内部散板需求池发布本车余位;新建默认勾选。 + default: true + idempotencyKey: type: string warehouseId: @@ -6475,6 +6530,10 @@ components: type: object x-java-record: DispatchViews.Detail properties: + publishLooseboardDemand: + type: boolean + description: 是否向内部散板需求池发布本车余位;新建默认勾选。 + dispatchNo: type: string nullable: true @@ -6530,6 +6589,7 @@ components: $ref: '#/components/schemas/DispatchOrderDetail' nullable: true required: + - publishLooseboardDemand - dispatchNo - status - stage diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java index d5a79c7f3..1fbb530f4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/delivery/PlatformDispatchOutboundContractTest.java @@ -123,7 +123,7 @@ class PlatformDispatchOutboundContractTest { assertTrue(Arrays.stream(DispatchController.class.getDeclaredMethods()) .noneMatch(method -> Set.of("depart", "cancel").contains(method.getName()))); assertEquals(Set.of("recordVersion", "idempotencyKey", "vehicle", "driver", - "plannedDepartureAt", "estimatedArrivalAt", "remark"), + "plannedDepartureAt", "estimatedArrivalAt", "remark", "publishLooseboardDemand"), Arrays.stream(DispatchCommands.Update.class.getRecordComponents()) .map(component -> component.getName()).collect(java.util.stream.Collectors.toSet())); assertEquals(Set.of("recordVersion", "idempotencyKey", "reason"), diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java index 90fd2d6ff..9dd91818a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/DispatchSchemaContractTest.java @@ -38,7 +38,7 @@ class DispatchSchemaContractTest { "ll_dispatch_pda_task",com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaTask.class); for(var entry:tables.entrySet()) { String body=table(sql,entry.getKey());var columns=new TreeSet(); - var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body); + var matcher=Pattern.compile("(?:^|,)\\s*([a-z][a-z0-9_]*)\\s+(?:BIGINT|TINYINT|INT|CHAR|VARCHAR|DATETIME|TIMESTAMP|JSON|TEXT|LONGTEXT|DECIMAL)\\b",Pattern.MULTILINE).matcher(body); while(matcher.find())columns.add(matcher.group(1)); var fields=new TreeSet();for(var field:entry.getValue().getDeclaredFields())if(!Modifier.isStatic(field.getModifiers()) && !field.isSynthetic())fields.add(field.getName().replaceAll("([a-z0-9])([A-Z])","$1_$2").toLowerCase(Locale.ROOT)); assertThat(columns).as(entry.getKey()).isEqualTo(fields).doesNotContain("customer_id"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java index a192dc45f..940454818 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/controller/DispatchQueryControllerTest.java @@ -29,6 +29,7 @@ import static org.mockito.Mockito.when; class DispatchQueryControllerTest { @Mock DispatchAggregateService service; @Mock DispatchBolPrintService printService; + @Mock com.baiyue.lastleg.dispatch.service.DispatchCapacityDemandService capacityDemands; private DispatchQueryController controller; @BeforeEach @@ -36,6 +37,7 @@ class DispatchQueryControllerTest { controller = new DispatchQueryController(); ReflectionTestUtils.setField(controller, "aggregateService", service); ReflectionTestUtils.setField(controller, "printService", printService); + ReflectionTestUtils.setField(controller, "capacityDemands", capacityDemands); } @Test @@ -72,6 +74,19 @@ class DispatchQueryControllerTest { assertThat(print.value()).isEqualTo("hasAuthority('" + OverseasPermissions.DISPATCH_LIST + "')"); } + /** 需求池使用独立列表权限且只允许平台身份,不向客户开放摘要来源。 */ + @Test void capacityDemandPoolUsesItsOwnPermissionAndRejectsMerchantIdentity() throws Exception { + var query=new com.baiyue.lastleg.dispatch.port.DispatchCapacityDemands.Query(1,20,null,null); + var permission=DispatchQueryController.class.getMethod("capacityDemands",AuthenticatedUser.class,query.getClass()).getAnnotation(PreAuthorize.class); + assertThat(permission.value()).isEqualTo("hasAnyAuthority('platform:fleet:capacity-offer:list','platform:fleet:capacity-offer:manage')"); + when(capacityDemands.page(query)).thenReturn(new PageResult<>(List.of(),1,20,0,0)); + assertThat(controller.capacityDemands(platform(),query).data().records()).isEmpty(); + var merchant=new AuthenticatedUser(7L,9L,9L,8L,"customer","MERCHANT","WEB",Set.of()); + assertThatThrownBy(()->controller.capacityDemands(merchant,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(403)); + assertThatThrownBy(()->controller.capacityDemands(null,query)).isInstanceOfSatisfying(ResponseStatusException.class,error->assertThat(error.getStatusCode().value()).isEqualTo(401)); + verify(capacityDemands).page(query); + } + private static AuthenticatedUser platform() { return new AuthenticatedUser(7L, 0L, 0L, 8L, "operator", "PLATFORM", "WEB", Set.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java index 0928101c6..af9f501d9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/port/DispatchPublicJsonContract.java @@ -39,7 +39,7 @@ public final class DispatchPublicJsonContract { rejectsMissing(requests, request, "{}"); rejectsMissing(requests, request, "{\"recordVersion\":null}"); rejectsMissing(requests, request, "{\"recordVersion\":-1}"); - Object zero = requests.readValue("{\"recordVersion\":0}", request); + Object zero = requests.readValue(request == DispatchCommands.Update.class ? "{\"recordVersion\":0,\"publishLooseboardDemand\":true}" : "{\"recordVersion\":0}", request); require(Long.valueOf(0).equals(request.getMethod("recordVersion").invoke(zero)), "显式零版本必须合法"); } rejectsMissing(requests, DispatchCommands.Update.class, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index fd2bf8209..c18f12569 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -204,7 +204,7 @@ public class DispatchAggregatePersistenceTest { /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); - var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注"); + var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)); assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero(); assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); @@ -244,7 +244,7 @@ public class DispatchAggregatePersistenceTest { } @Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception { var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input)); - var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划"); + var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划"); var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue(); @@ -604,7 +604,7 @@ public class DispatchAggregatePersistenceTest { } /** 空计划和资源是合法待完善事实,不补假日期或主档。 */ - public DispatchCommands.Create create(String key,List selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of()); } + public DispatchCommands.Create create(String key,List selections) { return new DispatchCommands.Create(key,"9",null,null,null,null,null,selections,List.of(), true); } /** 临时资源不创建主档;文件提供者在此夹具隔离,归属HTTP网关另行验证。 */ public DispatchCommands.Create configured(String key,List sources) { @@ -613,7 +613,7 @@ public class DispatchAggregatePersistenceTest { new DispatchContracts.Capacity(15,new BigDecimal("10000"),new BigDecimal("50")),List.of(101L)), new DispatchCommands.Driver(ResourceMode.EXTERNAL,null,"测试司机","123456789", new DispatchContracts.DriverEvidence("TEST-LICENSE",java.time.LocalDate.of(2099,1,1),List.of(202L),departure.plusSeconds(86400),List.of())), - departure,null,null,sources,List.of()); + departure,null,null,sources,List.of(), true); } /** 通过实际两级流程进入装车,测试不直接伪造通过状态。 */ @@ -632,6 +632,6 @@ public class DispatchAggregatePersistenceTest { } /** 测试建表按实际持久属性类型生成,正式DDL仍由独立主文档集成。 */ - private static String type(Class type) { return type==Long.class||type==long.class?"BIGINT":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; } + private static String type(Class type) { return type==Long.class||type==long.class?"BIGINT":type==Boolean.class||type==boolean.class?"BOOLEAN":type==Integer.class||type==int.class?"INT":type==LocalDateTime.class?"TIMESTAMP(3)":type==BigDecimal.class?"DECIMAL(18,6)":"VARCHAR(20000)"; } } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java index bd34fb202..1cd4a33c4 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocationsTest.java @@ -40,6 +40,6 @@ class DispatchAppointmentAllocationsTest { when(provider.candidateBatches(identities)).thenReturn(Map.of());assertThatThrownBy(()->service.candidates(List.of(a,b))).hasMessageContaining("批读范围不完整"); } private static com.baiyue.lastleg.dispatch.port.DispatchCommands.Create create(String key,List allocations) { - return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations); + return new com.baiyue.lastleg.dispatch.port.DispatchCommands.Create(key,"9",null,null,null,null,null,List.of(),allocations, true); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java new file mode 100644 index 000000000..4af5a43be --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java @@ -0,0 +1,78 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import org.junit.jupiter.api.Test; +import java.math.BigDecimal; +import java.time.Instant; +import java.util.List; +import static org.assertj.core.api.Assertions.*; + +class DispatchCapacityDemandPersistenceTest { + /** 默认发布和取消发布随原排车命令同事务保存,原请求重放不重复修改。 */ + @Test void defaultsAndUnpublishesThroughTheExistingVersionedCommand() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var request=f.json.readValue("{\"idempotencyKey\":\"pool-default\",\"warehouseId\":\"9\",\"cargo\":[],\"batchAllocations\":[]}",DispatchCommands.Create.class); + var created=f.tx.execute(s->f.service.create(f.actor,request)); + var service=new DispatchCapacityDemandService(f.repository,f.json); + assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo()); + assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue(); + var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false); + var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); + assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse(); + assertThat(service.page(query()).total()).isZero(); + var replay=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); + assertThat(replay.recordVersion()).isEqualTo(saved.recordVersion()); + assertThat(service.page(query()).total()).isZero(); + } + /** 余位缺测时保持未知;不能将实托数量当标准托位或使用默认三十托。 */ + @Test void unknownCapacityIsVisibleAndNonDeliveryOrDepartedRowsAreExcluded() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var service=new DispatchCapacityDemandService(f.repository,f.json); + var created=f.tx.execute(s->f.service.create(f.actor,f.create("pool-unknown",List.of()))); + var demand=service.page(query()).records().get(0); + assertThat(demand.capacityKnown()).isFalse();assertThat(demand.remainingPalletSpaces()).isNull(); + assertThat(demand.remainingWeightKg()).isNull();assertThat(demand.capacityReason()).isNotBlank(); + for(var type:List.of("PICKUP","LOCAL_TRUCK")) { + f.db.update("UPDATE ll_dispatch_order SET task_type=?",type);assertThat(service.page(query()).total()).isZero(); + } + f.db.update("UPDATE ll_dispatch_order SET task_type='DELIVERY',stage='DELIVERING'");assertThat(service.page(query()).total()).isZero(); + f.db.update("UPDATE ll_dispatch_order SET stage='BEFORE_LOADING',status='CANCELLED'");assertThat(service.page(query()).total()).isZero(); + } + /** 真实装载去重后计算三维余位,已知满载在分页前排除。 */ + @Test void computesConfirmedCapacityAndRemovesFullLoadsBeforePagination() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var service=new DispatchCapacityDemandService(f.repository,f.json); + var created=f.tx.execute(s->f.service.create(f.actor,f.configured("pool-known",List.of()))); + var load=new ConfirmedLoad("test-manifest",List.of(new LoadingMeasurement(101L,3,new BigDecimal("400"),new BigDecimal("5"),1)),"operator-test",Instant.now()); + f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?,stage='AFTER_LOADING',pallet_count=1",f.json.writeValueAsString(load)); + var demand=service.page(query()).records().get(0); + assertThat(demand.capacityKnown()).isTrue();assertThat(demand.remainingPalletSpaces()).isEqualTo(12); + assertThat(demand.remainingWeightKg()).isEqualByComparingTo("9600");assertThat(demand.remainingVolumeCbm()).isEqualByComparingTo("45"); + // 同一张实托占三个标准托位,不能按实托一件减一。 + assertThat(demand.scheduledPalletCount()).isOne(); + var full=new ConfirmedLoad("full",List.of(new LoadingMeasurement(101L,15,new BigDecimal("400"),new BigDecimal("5"),1)),"operator-test",Instant.now()); + f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(full)); + assertThat(service.page(query()).total()).isZero();assertThat(service.page(query()).records()).isEmpty(); + } + /** 重量或体积缺测不掩盖已明确的托位满载。 */ + @Test void knownFullPalletSpacesHideEvenWithUnknownWeight() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + f.tx.execute(s->f.service.create(f.actor,f.configured("pool-full-slots",List.of()))); + var load=new ConfirmedLoad("partial",List.of(new LoadingMeasurement(101L,15,null,null,1)),"operator-test",Instant.now()); + f.db.update("UPDATE ll_dispatch_order SET loading_confirmation_json=?",f.json.writeValueAsString(load)); + assertThat(new DispatchCapacityDemandService(f.repository,f.json).page(query()).total()).isZero(); + } + /** 新建时可以显式取消默认发布,后续不产生额外发布记录。 */ + @Test void createCanOptOutAndFiltersUseTheActualWarehouseAndRoute() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture();var initial=f.create("pool-opt-out",List.of()); + var request=new DispatchCommands.Create(initial.idempotencyKey(),initial.warehouseId(),null,null,null,null,null,List.of(),List.of(),false); + f.tx.execute(s->f.service.create(f.actor,request)); + var service=new DispatchCapacityDemandService(f.repository,f.json);assertThat(service.page(query()).total()).isZero(); + f.db.update("UPDATE ll_dispatch_order SET publish_looseboard_demand=TRUE,overseas_warehouse_name='测试仓',route='测试仓 → ONT8'"); + assertThat(service.page(new DispatchCapacityDemands.Query(1,20,"测试","ONT8")).total()).isOne(); + assertThat(service.page(new DispatchCapacityDemands.Query(1,20,"其它仓",null)).total()).isZero(); + } + /** 使用与正式列表相同的分页输入。 */ + private DispatchCapacityDemands.Query query() { return new DispatchCapacityDemands.Query(1,20,null,null); } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java index 83efefece..12f6d1774 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchDraftPersistenceTest.java @@ -326,7 +326,7 @@ class DispatchDraftPersistenceTest { @Test void conversionKeepsWholeSharedGroupAndOriginalCommandIdentity(){ relabelPair();var state=select(empty(1),0,1);var full=conversion(state,"convert-whole");var vehicle=full.groups().get(0);var create=vehicle.dispatch(); var partial=new DispatchCommands.Create("partial-child",create.warehouseId(),create.vehicle(),create.driver(),create.plannedDepartureAt(), - create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations()); + create.estimatedArrivalAt(),create.remark(),create.cargo().subList(0,1),create.batchAllocations(), true); assertThatThrownBy(()->service.convert(owner,new ConvertDraft(state.recordVersion(),"partial-convert",state.draftToken(),List.of(new DraftVehicle(vehicle.draftContainerId(),partial))))) .isInstanceOf(BizException.class); assertThat(db.queryForObject("SELECT COUNT(*) FROM draft_created_dispatch",Integer.class)).isZero(); @@ -368,7 +368,7 @@ class DispatchDraftPersistenceTest { var state=select(empty(2),0,1);state=select(state,1,2);var original=conversion(state,"convert"); assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"partial",original.draftToken(),original.groups().subList(0,1)))).isInstanceOf(BizException.class); var first=original.groups().get(0);var r=first.dispatch(); - var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations()); + var foreign=new DispatchCommands.Create(r.idempotencyKey(),"93",r.vehicle(),r.driver(),r.plannedDepartureAt(),r.estimatedArrivalAt(),r.remark(),r.cargo(),r.batchAllocations(), true); assertThatThrownBy(()->service.convert(owner,new ConvertDraft(original.recordVersion(),"foreign-wh",original.draftToken(),List.of(new DraftVehicle(first.draftContainerId(),foreign),original.groups().get(1))))).isInstanceOf(BizException.class); clearInvocations(aggregate);versions.put(1L,"changed"); assertThatThrownBy(()->service.convert(owner,original)).isInstanceOf(IllegalStateException.class); @@ -554,7 +554,7 @@ class DispatchDraftPersistenceTest { } /** 每个非空服务器容器生成一组完整创建请求。 */ private ConvertDraft conversion(Draft draft,String key){var groups=new ArrayList();int n=0;for(var c:draft.containers())if(!c.sourceKeys().isEmpty())groups.add(new DraftVehicle(c.draftContainerId(),new DispatchCommands.Create("vehicle-"+(++n),draft.warehouseId(),null,null,null,null,null, - draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of())));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);} + draft.sources().stream().filter(s->c.sourceKeys().contains(s.sourceKey())).map(Candidate::selection).toList(),List.of(), true)));return new ConvertDraft(draft.recordVersion(),key,draft.draftToken(),groups);} /** 主单边界返回冻结的真实公开结果形状。 */ private static CommandResult result(String no,String key,int cargo){return new CommandResult(no,Action.CREATE,key,0,Status.ACTIVE,Stage.BEFORE_LOADING,Instant.now(),Effect.APPLIED,new Counts(cargo,cargo,10L*cargo,0,0),new DispatchResults.Saved(no,List.of()),false,false);} /** 测试独立命令键不依赖执行顺序或系统时间。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java index 1d5bdc914..68502950b 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchRepairWorkflowTest.java @@ -397,7 +397,7 @@ class DispatchRepairWorkflowTest { } /** 使用当前公开整车创建DTO,不恢复旧客户预排接口。 */ private DispatchCommands.Create create(List cargo) { - return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of()); + return new DispatchCommands.Create(UUID.randomUUID().toString(),"9",null,null,null,null,null,cargo,List.of(), true); } /** 重放固定原版本及原键,取消旧单不会取得或释放新单占用。 */ private void cancel(String number) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java index 48bbcca4c..8a3f4cdba 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java @@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest { var e = d.evidence(); var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(), new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos)); - return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片"); + return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true); } /** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index 1fea3557a..f8705f263 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest { long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class); var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓"))); var resources=f.configured("updated-resources",List.of()); - var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正"))); + var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true))); var detail=f.service.detail(f.actor,created.dispatchNo()); var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview(); assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index 5f3f64359..b95f2a579 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest { when(reader.detail(any(),anyString())).thenAnswer(call->{ var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class); return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), - DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of()); + DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of(),true); }); queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader)); var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec)); From 234216b3eb1e09107d1499804501102b253e71f1 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 19:42:32 +0800 Subject: [PATCH 074/113] =?UTF-8?q?fix(dispatch):=20=E5=9C=A8=E5=AE=A1?= =?UTF-8?q?=E6=A0=B8=E5=8F=8A=E6=8B=A3=E8=B4=A7=E5=89=8D=E6=A0=B8=E5=AF=B9?= =?UTF-8?q?=E9=A2=84=E7=BA=A6=E4=B8=8E=E8=BF=90=E8=BE=93=E6=97=B6=E9=97=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppointmentWorkDispatchPort.java | 5 ++ .../lastleg/dispatch/port/DispatchViews.java | 3 +- .../service/DispatchAggregateService.java | 46 +++++++++++++-- .../DispatchAppointmentAllocations.java | 6 ++ .../service/DispatchPdaQueryService.java | 4 +- .../service/DispatchPickingService.java | 1 + .../work/AppointmentWorkDispatchBinding.java | 57 ++++++++++++++++-- .../openapi/overseas-business-v1.yaml | 15 +++++ .../DispatchAggregatePersistenceTest.java | 10 +++- ...atchLoadingEligibilityPersistenceTest.java | 58 +++++++++++++++++++ .../DispatchPdaReadPersistenceTest.java | 4 ++ .../DispatchPickingPersistenceTest.java | 11 ++++ .../AppointmentWorkDispatchBindingTest.java | 11 ++++ 13 files changed, 216 insertions(+), 15 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 80182e0a5..188106dc0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -25,6 +25,11 @@ public interface AppointmentWorkDispatchPort { void requireDeparture(String dispatchNo); /** Read-only explanation; departure must repeat the same authority checks while holding batch locks. */ String departureBlockReason(String dispatchNo); + record ReceivingWindow(String batchKey,String batchNo,String customerId,String destinationKey,String confirmationNo, + java.time.Instant startAt,java.time.Instant endAt,String agreementKey,int version) { } + /** 预排不受限;提交审核、审核通过和开始拣货须有真实收货确认及可执行时间。 */ + String reviewBlockReason(String dispatchNo,java.time.Instant plannedDepartureAt,java.time.Instant estimatedArrivalAt); + List reviewEvidence(String dispatchNo); void removeItem(long dispatchId,long itemId); void requireTripBinding(String dispatchNo,long tripId); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java index 3cbaacdc3..9b9c232b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchViews.java @@ -68,7 +68,8 @@ public final class DispatchViews { public record PdaTask(String taskNo, String dispatchNo, String warehouseId, Status status, Stage stage, long recordVersion, Reviews reviews, Resources resources, Counts counts, - boolean pickingReconciled, List items, Map actions) { } + boolean pickingReconciled, List items, Map actions, + Instant plannedDepartureAt, Instant estimatedArrivalAt, List fbaAppointments) { } public record PdaItem(Long dispatchItemId, String customerId, String customerName, String palletNo, Destination destination, long scheduledPackages, long pickedPackages, diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index f63a97c40..ab3304907 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -434,8 +434,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA String expected=loading?ReviewState.LOADING_PENDING.name():ReviewState.DISPATCH_PENDING.name(); if(!expected.equals(row.getReviewState()))throw conflict("审核轮次或状态已变化"); if(request.decision()==Decision.REJECTED && trim(request.reason())==null)throw bad("驳回原因不能为空"); - if(!Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交"); - if(loading) { + if(request.decision()==Decision.APPROVED && !Objects.equals(row.getReviewBasisHash(),basis(row)))throw conflict("审核依据已变化,请重新提交"); + if(loading && request.decision()==Decision.APPROVED) { var dispatch=reviews(row).dispatchReview(); if(dispatch.status()!=ReviewStatus.APPROVED || dispatch.reviewRound()!=row.getReviewRound() || !Objects.equals(dispatch.basisHash(),row.getReviewBasisHash()))throw conflict("调度审核尚未通过或依据已失效"); } @@ -562,6 +562,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA if(items.isEmpty() || items.stream().anyMatch(i->i.getWarehousePalletId()==null))throw conflict("逐项真实拣货尚未核平"); lockExecutionSources(row,items); var preview=loading(actor,number);if(!preview.pickingReconciled())throw conflict("逐项真实拣货尚未核平"); + String appointmentBlock=appointments.departureBlockReason(number); + if(appointmentBlock!=null)throw conflict(appointmentBlock); if(!Objects.equals(preview.manifestHash(),request.manifestHash()))throw conflict("真实装载清单已变化,请重新核对"); if(preview.pallets().stream().anyMatch(p->p.deliveryIndex()==null))throw conflict("托盘卸货站尚未匹配,请调度核对路线"); // 现场仅确认已扫码装车事实;每个实托计一托,已有实测自动留存,未知值不伪造为零。 @@ -640,7 +642,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var facts=new TreeMap();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt()); if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson()); - else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));} + else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));} return hashes.source(facts); } /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ @@ -667,6 +669,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA /** 临时取证、有效期和实际主档资格必须在提交及通过时再次核验。 */ void verifyReviewResources(DispatchAggregate row) { if("PICKUP".equals(row.getTaskType())) {transportTasks.verifyPickupReview(row.getDispatchNo());return;} + requireReviewAppointment(row); var r=read(row.getResourcesJson(),Resources.class);var v=r.vehicle();var d=r.driver(); if(v==null || d==null || trim(v.plate())==null || trim(d.name())==null || trim(d.phone())==null)throw bad("请补齐车辆、司机和联系电话"); var capacity=v.capacity();if(capacity==null || capacity.palletSpaces()==null || capacity.palletSpaces()<=0 || capacity.maxLoadKg()==null || capacity.maxLoadKg().signum()<=0 || capacity.maxVolumeCbm()==null || capacity.maxVolumeCbm().signum()<=0)throw bad("请补齐车辆真实托位、载重及容积"); @@ -679,6 +682,17 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA new FleetExecutionModels.DispatchDriver(d.mode().name(),d.driverId(),d.name(),d.phone(),e.licenseNo(),e.licenseExpiresOn(),e.fileIds(),e.expectedTaskEndAt())); if(!Objects.equals(hashes.source(r),hashes.source(resources(actual,e.photoFileIds()))))throw conflict("车辆或司机主档已变化,请重新保存并提交审核"); } + /** 从客服提交到真实扫码共用预约及时间复验,保留预排但不放行未确认货物。 */ + void requireReviewAppointment(DispatchAggregate row) { + String reason=reviewAppointmentBlock(row); + if(reason!=null)throw conflict(reason); + } + private String reviewAppointmentBlock(DispatchAggregate row) { + String reason=appointments.reviewBlockReason(row.getDispatchNo(),instant(row.getPlannedDepartureAt()),instant(row.getEstimatedArrivalAt())); + if(reason==null && row.getReviewBasisHash()!=null && !canSubmitReview(row.getReviewState()) && !Objects.equals(row.getReviewBasisHash(),basis(row))) + return "预约或运输安排已变化,原审核依据失效,请重新核对审核"; + return reason; + } /** 同类证据有界且必须属于当前整车,跨车文件不能用于审核。 */ private void verifyEvidence(long dispatch,com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind kind,List ids) { if(ids==null || ids.isEmpty() || ids.size()>20 || ids.stream().anyMatch(id->id==null || id<=0) || ids.stream().distinct().count()!=ids.size())throw bad("请补齐本车有效的车辆及司机证据"); @@ -736,6 +750,18 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA statistics(repository.summaries(filter)),Instant.now()); } + /** Admin与PDA审核读取相同的当前有效预约,未确认成员不能被已确认成员覆盖。 */ + List reviewAppointments(DispatchAggregate row) { + String number=row.getDispatchNo();var batches=repository.batches(row.getId()); + var confirmedAppointments=this.appointments.reviewEvidence(number); + return batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap( + b -> List.of(b.batchKey(),b.customerId(),b.destinationKey()),b->b,(a,b)->a,LinkedHashMap::new)).values().stream().map(b -> { + var confirmed=confirmedAppointments.stream().filter(w->w.batchKey().equals(b.batchKey()) && w.customerId().equals(b.customerId()) && w.destinationKey().equals(b.destinationKey())).findFirst().orElse(null); + return new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),confirmed==null?null:confirmed.confirmationNo(),b.destinationKey(),b.warehouseCode(), + confirmed==null?"PENDING":"SUCCESS",confirmed==null?null:confirmed.startAt(),confirmed==null?"尚无当前有效收货确认":null); + }).toList(); + } + /** 一次读取真实全部客户明细和单据,客户端不暴露整车入口。 */ @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) public DispatchViews.Detail detail(Actor actor, String number) { @@ -746,9 +772,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA batches.forEach(b -> references.computeIfAbsent(b.batchKey()+"\u0000"+b.customerId(),k->new ArrayList<>()).add(b)); var batchRefs=references.values().stream().map(group -> new BatchReference(group.get(0).batchKey(),group.get(0).batchNo(),group.get(0).customerId(), group.stream().map(b -> new BatchSource(b.sourceKey(),b.destinationKey(),b.packageCount())).toList(),group.get(0).recordVersion())).toList(); - var appointments=batches.stream().filter(b -> b.batchKey().startsWith("FBA_APPOINTMENT:")).collect(Collectors.toMap( - b -> b.batchKey()+"\u0000"+b.destinationKey(),b->b,(a,b)->a,LinkedHashMap::new)).values().stream() - .map(b -> new DispatchViews.FbaAppointment(b.batchKey(),b.batchNo(),b.confirmationNo(),b.destinationKey(),b.warehouseCode(),b.status(),instant(b.startUtc()),null)).toList(); + var appointments=reviewAppointments(row); var deliveries=repository.deliveries(row.getId()).stream().map(d -> new DispatchDeliveryModels.DeliveryReference(d.groupKey(),d.customerId(),d.customerName(), d.outboundNo(),d.deliveryNo(),new Destination(com.baiyue.lastleg.fleet.trip.FleetDispatchGateway.destinationKey(d.type(),d.warehouseCode(),d.warehouseCode(),d.address(),d.postalCode()),d.type(),d.warehouseId(),d.warehouseCode(),d.warehouseName(),d.address(),d.postalCode(),d.contactName(),d.contactPhone()), d.status(),d.packageCount(),d.palletCount())).toList(); @@ -880,6 +904,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA } var gates=new EnumMap(Action.class); boolean active=Status.ACTIVE.name().equals(row.getStatus()); Stage stage=Stage.valueOf(row.getStage()); ReviewState review=ReviewState.valueOf(row.getReviewState()); + String reviewBlock=active && (stage==Stage.BEFORE_LOADING || stage==Stage.LOADING) + ?reviewAppointmentBlock(row):null; for(var action:Action.values()) { if(action==Action.ARRANGE_DELIVERY || action==Action.DEPART)continue; boolean state=switch(action) { @@ -897,6 +923,14 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA }; boolean permitted=hasPermission(actor,action); gates.put(action,new Gate(state && permitted,!permitted?"FORBIDDEN":state?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":state?null:"当前阶段或作业事实不满足操作条件")); + if(state && permitted && reviewBlock!=null && Set.of(Action.SUBMIT_REVIEW,Action.DISPATCH_REVIEW,Action.LOADING_REVIEW,Action.PICK,Action.LOADING_COMPLETE).contains(action)) { + boolean rejectionAllowed=action==Action.DISPATCH_REVIEW || action==Action.LOADING_REVIEW; + gates.put(action,new Gate(rejectionAllowed,rejectionAllowed?"APPROVAL_BLOCKED":"APPOINTMENT_NOT_READY",reviewBlock)); + } + if(action==Action.LOADING_COMPLETE && state && permitted) { + String reason=appointments.departureBlockReason(row.getDispatchNo()); + if(reason!=null)gates.put(action,new Gate(false,"APPOINTMENT_NOT_READY",reason)); + } } return Collections.unmodifiableMap(gates); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java index 158c648d6..34434c8af 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java @@ -17,6 +17,12 @@ public class DispatchAppointmentAllocations { private final AppointmentWorkDispatchPort provider; public DispatchAppointmentAllocations(AppointmentWorkDispatchPort provider){this.provider=provider;} + /** 装车按钮提前展示原预约的真实阻塞,提交仍由发车事务加锁复验。 */ + public String departureBlockReason(String dispatchNo){return provider.departureBlockReason(dispatchNo);} + /** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */ + public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);} + public List reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);} + /** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */ public Map candidates(List rows) { var identities=rows.stream().filter(r->r.planPalletId()!=null).map(r->new AppointmentWorkDispatchPort.CandidatePhysical(r.customerId(),r.planPalletId())).distinct().toList(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 33799a454..0272daf1a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -89,7 +89,9 @@ public class DispatchPdaQueryService { else actions.put(Action.PICK,new Gate(false,"PICKING_RECONCILED","本车全部来源已核平,请显式确认装车")); var counts=new Counts(n(root.getItemCount()),n(root.getPalletCount()),detail.stream().mapToLong(DispatchViews.PdaItem::scheduledPackages).sum(),n(root.getPickedPackageCount()),n(root.getShippedPackageCount())); // 整车作业直接以排车业务号识别;真实客户子taskId只在服务端审计保存。 - result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions))); + result.add(new DispatchViews.PdaTask(root.getDispatchNo(),root.getDispatchNo(),root.getOverseasWarehouseId(),Status.valueOf(root.getStatus()),Stage.valueOf(root.getStage()),root.getRecordVersion(),workflow.reviews(root),workflow.resources(root),counts,complete,List.copyOf(detail),Collections.unmodifiableMap(actions), + root.getPlannedDepartureAt()==null?null:root.getPlannedDepartureAt().toInstant(java.time.ZoneOffset.UTC), + root.getEstimatedArrivalAt()==null?null:root.getEstimatedArrivalAt().toInstant(java.time.ZoneOffset.UTC),workflow.reviewAppointments(root))); } return List.copyOf(result); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 72da6cc6f..79dd6bc11 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -47,6 +47,7 @@ public class DispatchPickingService { var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); if(context.recordVersion()!=request.recordVersion() || context.status()!=Status.ACTIVE || context.stage()!=Stage.LOADING || context.reviews().dispatchReview().status()!=ReviewStatus.APPROVED || context.reviews().loadingReview().status()!=ReviewStatus.APPROVED)throw conflict("当前版本或两级审核不允许扫码"); + workflow.requireReviewAppointment(row); if(request.packages()<=0 || request.packages()>Integer.MAX_VALUE || request.productQuantity()<0)throw bad("实际扫码箱数及商品数不合法"); try{Math.multiplyExact(request.productQuantity(),request.packages());}catch(ArithmeticException error){throw bad("实际商品数量超出可支持范围");} var currentItems=repository.cargo(row.getId()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java index 86288be8b..6ad5a1e21 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -141,15 +141,61 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(remaining.values().stream().anyMatch(n->n!=0)) throw conflict("批次逐票分配必须与所选托盘实际货物完全一致"); if(item.packages()!=null && selected.stream().mapToLong(Allocation::packageCount).sum()!=item.packages()) throw conflict("逐票分配与所选托盘箱数不一致"); } - public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true); } + public void requireDeparture(String dispatchNo) { checkDeparture(dispatchNo,true,true); } public String departureBlockReason(String dispatchNo) { - try { checkDeparture(dispatchNo,false);return null; } + try { checkDeparture(dispatchNo,false,true);return null; } catch(org.springframework.web.server.ResponseStatusException error) { if(error.getStatusCode().value()!=409) throw error; return error.getReason(); } } - private void checkDeparture(String dispatchNo,boolean locked) { + /** 审核使用计划/实收分配,不要求尚未发生的拣货;发车继续核对真实已拣来源。 */ + public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival) { + try { + checkDeparture(dispatchNo,false,false); + var windows=reviewEvidence(dispatchNo); + if(!windows.isEmpty()) { + if(departure==null || arrival==null)return "请先明确计划来车/发车时间和预计送达时间,再提交审核"; + if(arrival.isBefore(departure))return "预计送达时间不能早于计划发车时间"; + var first=windows.stream().min(Comparator.comparing(ReceivingWindow::startAt)).orElseThrow(); + if(!departure.isBefore(first.startAt()))return "计划发车时间须早于本车最早的FBA预约送达时间"; + if(arrival.isAfter(first.endAt()==null?first.startAt():first.endAt()))return "预计送达时间晚于本车首个FBA预约收货时间,请调度重新核对"; + } + return null; + } catch(org.springframework.web.server.ResponseStatusException error) { + if(error.getStatusCode().value()!=409)throw error; + return error.getReason(); + } + } + /** 审核依据取当前有效收货确认,不能用旧批次头的计划时间替代。 */ + public List reviewEvidence(String dispatchNo) { + var result=new ArrayList();var seen=new HashSet>(); + for(var row:mapper.bindings(dispatchNo)) { + if(row.batchKey().startsWith("DISPATCH:") || !seen.add(List.of(row.batchKey(),row.customerId(),row.destinationKey())))continue; + var agreement=receiving.currentAgreement(row.batchKey(),row.customerId()); + if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus()) || agreement.details().appointmentAt()==null)continue; + var member=agreement.members().stream().filter(m->row.batchKey().equals(m.batchKey()) && row.customerId().equals(m.customerId())).findFirst().orElse(null); + if(member==null)continue; + var details=agreement.details(); + result.add(new ReceivingWindow(row.batchKey(),member.appointmentNo(),row.customerId(),row.destinationKey(),details.confirmationNo(), + receivingInstant(details.appointmentAt(),agreement.timezone(),details.startOffset()), + details.appointmentEndAt()==null?null:receivingInstant(details.appointmentEndAt(),agreement.timezone(),details.endOffset()),agreement.agreementKey(),agreement.version())); + } + return result.stream().sorted(Comparator.comparing(ReceivingWindow::batchKey).thenComparing(ReceivingWindow::customerId).thenComparing(ReceivingWindow::destinationKey)).toList(); + } + /** 保留仓库当地时区及明确偏移,不把美国预约时间按服务器时区解释。 */ + private java.time.Instant receivingInstant(java.time.LocalDateTime at,String timezone,String offset) { + try { + if(timezone==null)throw conflict("预约收货时间的时区尚未明确"); + var zone=java.time.ZoneId.of(timezone); + var valid=zone.getRules().getValidOffsets(at); + if(valid.isEmpty() || offset==null && valid.size()!=1)throw conflict("预约收货时间的时区偏移尚未明确"); + var selected=offset==null?valid.get(0):java.time.ZoneOffset.of(offset); + if(!valid.contains(selected))throw conflict("预约收货时间的时区偏移无效"); + return at.toInstant(selected); + } catch(java.time.DateTimeException error) {throw conflict("预约收货时间的时区或偏移无效");} + } + private void checkDeparture(String dispatchNo,boolean locked,boolean picked) { var rows=mapper.bindings(dispatchNo); var physical=mapper.dispatchPhysical(dispatchNo); for(var row:rows) if(physical.stream().noneMatch(i->i.itemId()==row.itemId() && Objects.equals(i.planPalletId(),row.palletId()) && row.customerId().equals(i.customerId()))) @@ -158,6 +204,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(locked) for(var batch:batches) lock(batch); for(var item:physical) if(item.planPalletId()!=null && rows.stream().noneMatch(r->r.itemId()==item.itemId() && r.customerId().equals(item.customerId()) && item.planPalletId().equals(r.palletId()))) { requireNoBatch(item.customerId(),item.planPalletId()); + if(!picked && mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")))throw conflict("本车FBA货物尚未关联已确认预约,请客服核对送达时间后提交审核"); if(mapper.physical(item.planPalletId(),item.customerId()).stream().anyMatch(p->p.destinationKey()!=null && p.destinationKey().startsWith("CUSTOMER:"))) throw conflict("客户仓排车缺少逐票实物批次关联,请重新核对排车"); } for(var batch:batches) { @@ -175,8 +222,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.customerId(),Long.toString(r.itemId()),Long.toString(r.palletId())))).values()) { var first=group.get(0); var item=physical.stream().filter(i->i.itemId()==first.itemId()).findFirst().orElseThrow(); - validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),"RECEIVED",item.packages()), - group.stream().map(a->allocation(a,null)).toList(),true,true); + validatePhysical(first.customerId(),new PhysicalItem(first.itemId(),first.customerId(),first.palletId(),item.sourceStage(),item.packages()), + group.stream().map(a->allocation(a,null)).toList(),picked || "RECEIVED".equals(item.sourceStage()),picked); } } private void requireReceiving(String batch,String customer,List rows) { diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index f895b4d25..befd48f10 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -6644,6 +6644,21 @@ components: allOf: - $ref: '#/components/schemas/DispatchCounts' nullable: true + plannedDepartureAt: + type: string + format: date-time + nullable: true + description: 当前排车计划发车时间,审核必须核对。 + estimatedArrivalAt: + type: string + format: date-time + nullable: true + description: 当前排车预计送达时间。 + fbaAppointments: + type: array + items: + $ref: '#/components/schemas/DispatchFbaAppointment' + description: 按本车各客户批次读取当前有效收货确认;缺失确认显示PENDING。 pickingReconciled: type: boolean items: diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index c18f12569..5b367fa19 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -454,8 +454,14 @@ public class DispatchAggregatePersistenceTest { f.db.update("INSERT INTO ll_dispatch_appointment_allocation(dispatch_id,dispatch_item_id,plan_pallet_id,batch_key,customer_id,source_key,destination_key,package_count) VALUES(?,?,1,'FBA_APPOINTMENT:900','101','ORDER:9001','FBA:ONT8',3)",dispatch,item); var detail=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())); assertThat(detail.orderDetails()).hasSize(2); assertThat(detail.dispatchInfo().appointmentBatches()).hasSize(1); - assertThat(detail.dispatchInfo().fbaAppointments().get(0).appointmentNo()).isEqualTo("CONFIRM-REAL"); - assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isEqualTo(Instant.parse("2026-10-19T10:00:00Z")); + // 批次头写SUCCESS不代表存在当前有效收货确认,不再把旧头时间作为审批依据。 + assertThat(detail.dispatchInfo().fbaAppointments().get(0).status()).isEqualTo("PENDING"); + assertThat(detail.dispatchInfo().fbaAppointments().get(0).confirmedAt()).isNull(); + var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + when(provider.reviewEvidence(original.dispatchNo())).thenReturn(List.of(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:900","AP-EXISTING","101","FBA:ONT8","CURRENT-CONFIRM",Instant.parse("2026-10-20T17:00:00Z"),null,"AGR-1",1))); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); + var current=f.tx.execute(s->f.service.detail(f.actor,original.dispatchNo())).dispatchInfo().fbaAppointments().get(0); + assertThat(current.appointmentNo()).isEqualTo("CURRENT-CONFIRM");assertThat(current.confirmedAt()).isEqualTo(Instant.parse("2026-10-20T17:00:00Z")); var c=f.source(3,"101",503,"ONT8",2); f.tx.executeWithoutResult(s->f.service.addCargo(f.actor,original.dispatchNo(),new DispatchCommands.AddCargo(original.recordVersion(),"add-third",List.of(c),List.of()))); var restored=f.tx.execute(s->f.service.create(f.actor,f.create("two-customers",List.of(a,b)))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java index c83496c6a..5c41b43b0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchLoadingEligibilityPersistenceTest.java @@ -19,6 +19,64 @@ import static org.mockito.Mockito.*; /** 真实排车、车次、命令和来源入口回归;未物化PLAN不得进入仅接受实托的执行锁。 */ class DispatchLoadingEligibilityPersistenceTest { + @org.junit.jupiter.api.BeforeEach void authorize() { + org.springframework.security.core.context.SecurityContextHolder.getContext().setAuthentication(new org.springframework.security.authentication.TestingAuthenticationToken("operator","", + "platform:overseas-warehouse:dispatch:list","platform:overseas-warehouse:dispatch:edit","platform:overseas-warehouse:dispatch:loading-complete", + "platform:fleet:trip:dispatch-review","platform:fleet:trip:loading-review","lastleg:pda:operate","ROLE_OVERSEAS_WAREHOUSE_PDA_OPERATOR")); + } + @org.junit.jupiter.api.AfterEach void clearIdentity(){org.springframework.security.core.context.SecurityContextHolder.clearContext();} + /** 允许预排;客服提交及两级批准均复验,缺预约时仍能驳回客服而不能开始拣货。 */ + @org.junit.jupiter.api.Test void preArrangementRemainsAllowedButReviewsCannotApproveUnconfirmedAppointments() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("review-appointment",List.of(f.source(1,"101",501,"ONT8",3))))); + var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + assertThatThrownBy(()->f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"blocked-submit",null)))).hasMessageContaining("尚未确认"); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null); + var submitted=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(created.recordVersion(),"submit",null))); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + var gate=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.DISPATCH_REVIEW); + assertThat(gate.allowed()).isTrue();assertThat(gate.blockCode()).isEqualTo("APPROVAL_BLOCKED"); + assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"bad-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认"); + // 预约依据变化后仍可驳回,不让失效审核把客服修正入口锁死。 + var changedWindow=new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.ReceivingWindow("FBA_APPOINTMENT:1","YY1","101","FBA:ONT8","CONF1",java.time.Instant.parse("2026-12-01T10:00:00Z"),null,"agreement-1",2); + when(provider.reviewEvidence(anyString())).thenReturn(List.of(changedWindow)); + var rejected=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"reject",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.REJECTED,"请客服核对预约",null))); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null); + var submittedAgain=f.tx.execute(t->f.service.submitReview(f.actor,created.dispatchNo(),new DispatchCommands.SubmitReview(rejected.recordVersion(),"resubmit",null))); + var reviewed=f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submittedAgain.recordVersion(),"dispatch-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null))); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + assertThatThrownBy(()->f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"bad-loading-approve",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null)))).hasMessageContaining("尚未确认"); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn(null); + f.tx.execute(t->f.service.review(f.actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(reviewed.recordVersion(),"loading-pass",com.baiyue.lastleg.dispatch.port.DispatchTypes.Decision.APPROVED,null,null))); + when(provider.reviewEvidence(anyString())).thenReturn(List.of()); + assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("原审核依据失效"); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + var pda=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + assertThat(f.tx.execute(t->f.service.detail(pda,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.PICK).allowed()).isFalse(); + assertThatThrownBy(()->f.tx.execute(t->{f.service.requireReviewAppointment(f.repository.find(created.dispatchNo()));return null;})).hasMessageContaining("尚未确认"); + } + /** 照片或终端不改变预约门槛;显示与正式提交均拒绝未准备好的预约。 */ + @org.junit.jupiter.api.Test void appointmentBlockIsVisibleBeforeLoadingSubmission() throws Exception { + var f=new DispatchAggregatePersistenceTest.Fixture(); + var created=f.tx.execute(t->f.service.create(f.actor,f.configured("appointment-gate",List.of(f.source(1,"101",501,"ONT8",3))))); + var approved=f.approve(created);f.picked(1,"101",501,3); + f.db.update("UPDATE ll_dispatch_order SET picked_package_count=3"); + var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + when(provider.departureBlockReason(created.dispatchNo())).thenReturn("请先约仓成功再排车或发车"); + ReflectionTestUtils.setField(f.service,"appointments",new DispatchAppointmentAllocations(provider)); + var actor=new DispatchContracts.Actor("picker",ActorMode.PDA,"9"); + var preview=f.tx.execute(t->f.service.loading(actor,created.dispatchNo())); + assertThat(preview.pickingReconciled()).isTrue(); + assertThat(preview.loadingComplete().allowed()).isFalse(); + assertThat(preview.loadingComplete().blockCode()).isEqualTo("APPOINTMENT_NOT_READY"); + assertThat(f.tx.execute(t->f.service.detail(actor,created.dispatchNo())).actions().get(com.baiyue.lastleg.dispatch.port.DispatchTypes.Action.LOADING_COMPLETE).allowed()).isFalse(); + assertThatThrownBy(()->f.tx.execute(t->f.service.loadingComplete(actor,created.dispatchNo(),new DispatchCommands.LoadingComplete(approved.recordVersion(),"blocked-loading",preview.manifestHash(),null)))) + .hasMessageContaining("先约仓成功"); + assertThat(f.repository.find(created.dispatchNo()).getStage()).isEqualTo("LOADING"); + assertThat(f.repository.find(created.dispatchNo()).getRecordVersion()).isEqualTo(approved.recordVersion()); + } /** 全车在途和部分实托已拣的混合车均应业务拒绝,原单/命令/占用/扫码事实完全不变。 */ @ParameterizedTest @ValueSource(booleans={false,true}) diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java index 2792c8cf7..c5734b2bb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPdaReadPersistenceTest.java @@ -31,6 +31,10 @@ class DispatchPdaReadPersistenceTest { var approved=f.tx.execute(s->f.service.review(f.actor,created.dispatchNo(),false,new DispatchCommands.ReviewCommand(submitted.recordVersion(),"dispatch",Decision.APPROVED,null,null))); var list=f.tx.execute(s->queries.list(actor,null));assertThat(list).hasSize(1); var task=list.get(0);assertThat(task.stage()).isEqualTo(Stage.BEFORE_LOADING); + var platform=f.tx.execute(s->f.service.detail(f.actor,created.dispatchNo())); + assertThat(task.plannedDepartureAt()).isEqualTo(platform.dispatchInfo().plannedDepartureAt()); + assertThat(task.estimatedArrivalAt()).isEqualTo(platform.dispatchInfo().estimatedArrivalAt()); + assertThat(task.fbaAppointments()).isEqualTo(platform.dispatchInfo().fbaAppointments()); assertThat(task.actions().get(Action.LOADING_REVIEW).allowed()).isTrue();assertThat(task.actions().get(Action.PICK).allowed()).isFalse(); assertThat(task.resources().vehicle().plate()).isEqualTo("TEST-TRUCK");assertThat(task.resources().driver().name()).isEqualTo("测试司机"); f.tx.execute(s->f.service.review(actor,created.dispatchNo(),true,new DispatchCommands.ReviewCommand(approved.recordVersion(),"tally-reject",Decision.REJECTED,"车辆不符",null))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 940f5870d..6eab729cc 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -23,6 +23,17 @@ import static org.mockito.Mockito.*; /** 根版本、幂等、真实客户作业与扫码持久化;库存/归属应用命令在此明确模拟,另由原域直接测试验证。 */ class DispatchPickingPersistenceTest { + /** 即使旧记录已经审核通过,真实扫码仍复验预约;拒绝时不物化、不扣库存。 */ + @Test void unconfirmedAppointmentBlocksPickingBeforeInventoryWrites() throws Exception { + var fixture=new Picking(true);fixture.receivedPlans(9,11); + var provider=mock(com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.class); + when(provider.reviewBlockReason(anyString(),any(),any())).thenReturn("预约收货时间尚未确认"); + org.springframework.test.util.ReflectionTestUtils.setField(fixture.f.service,"appointments",new DispatchAppointmentAllocations(provider)); + assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, + new DispatchCommands.Pick(fixture.version,"appointment-blocked",11L,9,0,null,null)))).hasMessageContaining("尚未确认"); + verify(fixture.inventory,never()).ship(any(),any(),any()); + assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); + } /** 数量可自然同步不代表能接管其他车的实际托盘。 */ @Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception { var fixture=new Picking(true);fixture.receivedPlans(9,11); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java index e2b18a5d3..ed681cca9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBindingTest.java @@ -58,6 +58,16 @@ class AppointmentWorkDispatchBindingTest { when(mapper.pickedPhysical(30,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",null,4))); when(sources.canonicalOrders(anyList())).thenReturn(List.of(new AppointmentCoverageService.SourceRef("C","ORDER:1",null))); assertDoesNotThrow(()->service.requireDeparture("D-20")); + when(mapper.physical(10,"C")).thenReturn(List.of(new AppointmentWorkDispatchMapper.Physical(1L,"C","FBA:ONT8",4,4))); + var departure=java.time.Instant.parse("2026-10-18T12:00:00Z"); + var arrival=java.time.Instant.parse("2026-10-18T17:00:00Z"); + clearInvocations(mapper); + assertNull(service.reviewBlockReason("D-20",departure,arrival)); + verify(mapper,never()).pickedPhysical(anyLong(),anyString()); + assertEquals(arrival,service.reviewEvidence("D-20").get(0).startAt(),"10点洛杉矶预约应按当地时区解释"); + assertNotNull(service.reviewBlockReason("D-20",departure,null)); + assertNotNull(service.reviewBlockReason("D-20",arrival.plusSeconds(60),arrival.plusSeconds(120))); + assertNotNull(service.reviewBlockReason("D-20",departure,arrival.plusSeconds(60))); when(item.packageCount()).thenReturn(5);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(1L); assertTrue(service.departureBlockReason("D-20").contains("收货确认未完整覆盖")); // Batch has 5, but the current agreement covers only 4 across every dispatch. when(item.packageCount()).thenReturn(4);when(mapper.allocated("FBA_APPOINTMENT:1","C","ORDER:1","FBA:ONT8",20)).thenReturn(0L); @@ -76,6 +86,7 @@ class AppointmentWorkDispatchBindingTest { assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20")); when(receiving.currentAgreement("FBA_APPOINTMENT:1","C")).thenReturn(null); assertThrows(org.springframework.web.server.ResponseStatusException.class,()->service.requireDeparture("D-20")); + assertTrue(service.reviewBlockReason("D-20",departure,arrival).contains("尚无有效收货确认")); verifyNoInteractions(changes); } private com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.Agreement agreement(String customer,int packages) { From 5356a8c8983c033c0fc25310bf340cb8b0f620cf Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 21:30:24 +0800 Subject: [PATCH 075/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/README.md | 21 + scripts/admin-test-env-acceptance.mjs | 51 +- scripts/admin-test-env-modules/README.md | 6 + ...ainer-customs-acceptance-contract.test.mjs | 5 + .../finance-direct-paid-isolation.mjs | 1 + .../finance-full-fixtures-contract.test.mjs | 25 +- .../finance-full-fixtures.mjs | 21 +- .../firstleg-container-fee-allocation.mjs | 1 + .../fleet-quotes-operations.mjs | 2 +- .../fleet-trip-fulfillment-contract.test.mjs | 12 +- .../fleet-trip-fulfillment.mjs | 11 +- ...nt-qualification-fixture-contract.test.mjs | 91 ++- .../merchant-qualification-fixture.mjs | 41 +- .../order-inbound-status-fixtures.mjs | 2 +- .../overseas-storage-fees.mjs | 10 + .../phase3-container-customs-fixture.mjs | 1 + ...eas-finance-cross-region-contract.test.mjs | 15 +- .../phase4-overseas-finance-cross-region.mjs | 9 +- ...hase4-overseas-warehouse-contract.test.mjs | 20 +- .../phase4-overseas-warehouse.mjs | 12 +- ...ient-test-env-acceptance-contract.test.mjs | 36 +- scripts/client-test-env-acceptance.mjs | 62 +- scripts/dispatch-loading-flow-test-env.mjs | 156 +++++ scripts/fleet-booking-quotes-test-env.mjs | 2 +- scripts/procurement-sample-test-env.mjs | 2 +- scripts/test-env-business-reset-plan.mjs | 200 ++++++ scripts/test-env-business-reset-plan.test.mjs | 168 +++++ scripts/test-env-business-reset-policy.json | 605 ++++++++++++++++++ 28 files changed, 1543 insertions(+), 45 deletions(-) create mode 100644 scripts/dispatch-loading-flow-test-env.mjs create mode 100644 scripts/test-env-business-reset-plan.mjs create mode 100644 scripts/test-env-business-reset-plan.test.mjs create mode 100644 scripts/test-env-business-reset-policy.json diff --git a/scripts/README.md b/scripts/README.md index b236798b6..e4065a966 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,26 @@ # 后端脚本管理与全量 HTTP 验收索引 +`dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及原请求幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据和固定客户。报告位于 `reports/dispatch-loading-/`,保留失败前的请求和业务数据;同标识拒绝重跑,先核对原请求再决定恢复。`--resume-report <原报告>` 仅用于已明确收到编辑 A0400、仍为零拣货草稿的原单,保留原车与原司机,回查状态后补齐编辑字段继续;未知写入结果不能使用此恢复入口。默认停用本次临时 PDA 账号;`--keep-pda-for-ui` 仅在成功后将临时登录资料保存到系统临时目录供页面验收,页面结束后必须停用账号。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。 + +Admin/Client TEST 回归统一配置 `ADMIN_TEST_FIXED_CUSTOMER_ID` 与 `MERCHANT_TEST_USERNAME/PASSWORD`,复用指定 ACTIVE 客户;独立 Admin runner 同样读取这些显式环境变量。Client 默认不再注册审核、修改固定客户资料/账号、创建成员角色或通知,也不运行余额/渠道共享配置及缺第二客户的隔离场景;这些范围列为 skipped,`fullCoverage=false`,执行范围通过不代表全量覆盖。Admin 排除的主档生命周期范围见模块 README。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。 + +## TEST 业务数据重置准备 + +`test-env-business-reset-plan.mjs` 是**离线预览入口**,读取两份统一空库基线与 `test-env-business-reset-policy.json` 的显式逐表清单;不联网、不读取凭据、不执行 SQL,也没有清库执行模式。策略按全部客户的头程、海外仓、尾程、车队业务整理;资金记录与实际余额清零,保留授信额度/有效期;基础资料、编号序列、自营采集、独立审计、文件元数据和物理文件保留。此策略是候选处理范围,不能直接作为执行白名单。 + +```powershell +# 默认只打印摘要;无需环境文件 +node scripts/test-env-business-reset-plan.mjs +# 输出新的独占报告目录;相同目录再次运行会失败,不覆盖既有证据 +node scripts/test-env-business-reset-plan.mjs --out-dir reports/business-reset-<本次唯一标识> +# 人工选择 TEST 连接执行 inspection.sql,将首条查询的 inventory_json 原样保存后再比对 +node scripts/test-env-business-reset-plan.mjs --inventory <实际结构清单.json> --out-dir reports/business-reset-<新的唯一标识> +``` + +报告 `plan.json` 逐表区分 `KEEP`、`CLEAR_CANDIDATE`、`RESET_REVIEW` 和 `REVIEW`,包含源码指纹、外键引用和触发器清单。独立审计保留,订单自身的状态/作业历史按已确认范围随单清理;人工船期计划、车辆运维档案和站内消息保留。业务导出与自营/基础资料导出混在同表,必须按明确类型白名单处理。基线变化、未分类新表、策略残留、实际缺表/多表/视图及保留表引用待清主表都会显式报告;不会根据表前缀自动扩大清理。`inspection.sql` 只查询目标 schema 的表、字段、外键、触发器及索引,并检查指向目标表的外部外键;操作者须保证账号对目标结构的完整可见性,缺少权限不能解释为数据不存在。`TABLE_ROWS` 仅为估算,导入完整且表名吻合的实际清单后才生成 `counts.sql` 精确计数查询;脚本本身仍不连接数据库。结构 JSON 不包含业务行或连接凭据。 + +退出 0 只表示预览成功;结果始终为 `REVIEW_REQUIRED / executionSupported=false`,不是清库放行。解析失败退出1并保留已生成文件,修正原因后使用新报告目录重跑,不自动删除或覆盖报告。数据库连接由操作者在环境操作阶段明确选择,不能用初始化文件重放代替重置;真实 SQL、删除/重置、维护窗口、备份恢复、缓存及消息重投处理均需按环境规则单独落实。资金可用余额须按有效授信重算,不能与实际余额一并固定归零;停用/报废资源不自动复活,业务过程历史与独立审计、船期计划、车辆运维、混合通知/导出及固定协调锁仍需专项核对。直接离线测试为 `test-env-business-reset-plan.test.mjs`,新增入口同时运行 `script-documentation-contract.test.mjs`,均经 `local-check.ps1`。 + ## 先看范围,再看证据 `acceptance-overview.mjs` 是离线只读总览入口,不需要凭据、不联网、不创建报告或测试数据。默认范围直接读取 Admin 实际注册表,不维护另一份模块枚举: diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index b1e036567..3f3d79038 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -57,6 +57,8 @@ export function planA0LocationIsolation(locations, requiredSlots = 1, } const MODULE_DEPENDENCIES = new Map([ + ['customers', ['merchant-qualification-fixture']], + ['overseas-storage-fees', ['merchant-qualification-fixture']], ['inbound-weight-ocr', ['orders']], ['orders', ['merchant-qualification-fixture']], ['phase5-root-fulfillment', ['merchant-qualification-fixture']], @@ -406,6 +408,11 @@ export function parseArguments(argv) { const requestedModules = (values.get('modules') || DEFAULT_MODULES.join(',')) .split(',').map((v) => v.trim()).filter(Boolean) const modules = normalizeModuleOrder(requestedModules) + if (environment === 'test') { + const fixtureIndex = modules.indexOf('merchant-qualification-fixture') + if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) + modules.unshift('merchant-qualification-fixture') + } const merchantUsername = values.get('merchant-username') || envValue('MERCHANT_TEST_USERNAME', 'MERCHANT_GRAY_USERNAME') const merchantPassword = values.get('merchant-password') || envValue('MERCHANT_TEST_PASSWORD', 'MERCHANT_GRAY_PASSWORD') const requiresMarkerOwnedMerchantFixture = [ @@ -417,7 +424,7 @@ export function parseArguments(argv) { 'phase4-overseas-finance-cross-region', 'phase4-overseas-wallet-and-pallet-cost', ].some((module) => modules.includes(module)) - if (merchantUsername && merchantPassword && !requiresMarkerOwnedMerchantFixture) { + if (environment !== 'test' && merchantUsername && merchantPassword && !requiresMarkerOwnedMerchantFixture) { const fixtureIndex = modules.indexOf('merchant-qualification-fixture') if (fixtureIndex >= 0) modules.splice(fixtureIndex, 1) } @@ -431,6 +438,7 @@ export function parseArguments(argv) { pdaPassword: values.get('pda-password') || envValue('ADMIN_PDA_PASSWORD', 'ADMIN_GRAY_PDA_PASSWORD'), modules, requestedModules, + fixedCustomerId: environment === 'test' ? process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' : '', customerName: values.get('customer-name') || envValue('ADMIN_TEST_CUSTOMER_NAME', 'ADMIN_GRAY_CUSTOMER_NAME'), merchantUsername, merchantPassword, @@ -629,7 +637,12 @@ export function buildAcceptanceSummary(steps = [], modules = [], coverageGaps = export class AcceptanceRun { constructor(options) { assertGrayExecutionScope(options) - this.options = options + this.options = { ...options } + if (options.environment === 'test') { + this.options.fixedCustomerId ||= process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' + this.options.merchantUsername ||= process.env.MERCHANT_TEST_USERNAME || '' + this.options.merchantPassword ||= process.env.MERCHANT_TEST_PASSWORD || '' + } const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` this.startedAt = new Date().toISOString() @@ -648,7 +661,17 @@ export class AcceptanceRun { this.fatal = null } + assertCustomerWriteAllowed(method, path) { + if (this.options.environment !== 'test') return + const pathname = new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, '') + if (String(method).toUpperCase() === 'POST' && (pathname === '/api/platform/v1/iam/tenants' + || /^\/api\/platform\/v1\/iam\/tenants\/[^/]+\/(archive|submit-review)$/.test(pathname))) { + throw new Error('TEST 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户') + } + } + async request(method, path, body = undefined, token = this.token, extraHeaders = {}) { + this.assertCustomerWriteAllowed(method, path) const headers = { Accept: 'application/json', ...extraHeaders } if (this.pdaToken && /\/pda-loading\/tasks\/\d+/.test(String(path))) token = this.pdaToken if (token) headers.Authorization = `Bearer ${token}` @@ -691,6 +714,7 @@ export class AcceptanceRun { } async requestWithBody(method, path, body, token = this.token, extraHeaders = {}) { + this.assertCustomerWriteAllowed(method, path) const headers = { Accept: 'application/json', ...extraHeaders } if (token) headers.Authorization = `Bearer ${token}` if (!(body instanceof FormData) && body !== undefined) headers['Content-Type'] = 'application/json; charset=utf-8' @@ -793,6 +817,14 @@ export class AcceptanceRun { } async executeModule(name, handler) { + if (this.options.fixedCustomerId && new Set(['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications', + 'pricing', 'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']).has(name)) { + this.addCoverageGap(name, 'fixed-resource-lifecycle', '固定资源模式排除新增主档及账户生命周期', + '业务流程复用现有资源;不执行主档创建、账号创建、价格发布或钱包初始化', 'not-implemented', { blocking: false }) + this.modules.push({ name, success: false, skipped: true, passed: 0, failed: 0, + reason: '固定资源模式排除生命周期;不计为验收通过' }) + return + } const startedAt = new Date().toISOString(); const start = this.steps.length; let fatal = null try { await handler() } catch (error) { fatal = error instanceof Error ? error.message : String(error) @@ -1124,6 +1156,12 @@ export class AcceptanceRun { } async runCustomers() { + if (this.options.fixedCustomerId) { + this.context.customerId = this.context.merchantQualificationFixture?.tenantId + this.addCoverageGap('customers', 'customer-lifecycle', '按用户要求排除建客及归档生命周期', + '复用固定客户;不改其资质、状态和账户,不把该生命周期列为通过', 'not-implemented', {blocking:false}) + return + } const module = 'customers'; const phone = await this.findAvailablePhone(module); const suffix = this.marker.slice(-12) const payload = { tenantName: `后台客户验收-${suffix}`, customerNickname: `后台客户-${suffix}`, legalName: `后台客户验收有限公司-${suffix}`, @@ -1782,6 +1820,9 @@ export class AcceptanceRun { } async run() { + if (this.options.environment === 'test' && !this.options.fixedCustomerId) { + throw new Error('TEST 验收必须配置 ADMIN_TEST_FIXED_CUSTOMER_ID;禁止自动创建测试客户') + } if (this.options.merchantUsername && this.options.merchantPassword) { process.env.MERCHANT_TEST_USERNAME = this.options.merchantUsername process.env.MERCHANT_TEST_PASSWORD = this.options.merchantPassword @@ -1903,6 +1944,11 @@ export class AcceptanceRun { } finally { if (restoreFixture) restoreFixture() } + if (module === 'merchant-qualification-fixture' && this.options.fixedCustomerId + && !this.context.merchantQualificationFixture?.reused) { + this.fatal = '固定客户身份校验失败,停止后续造数' + break + } if (module === 'inbound-measurement' && this.captureWaitSplitFixture(this.options.waitSplitSequence || 1)) { await this.createAdditionalWaitSplitFixtures() @@ -1962,6 +2008,7 @@ export class AcceptanceRun { }, generatedAt: new Date().toISOString(), startedAt: this.startedAt, success: this.fatal === null && summary.failed === 0, fatal: this.fatal, + fullCoverage: this.coverageGaps.length === 0 && !this.modules.some(module => module.skipped), ...(this.options.prepareDomesticPdaH5 ? { fullAcceptance: false, purpose: 'DOMESTIC_PDA_H5_FIXTURE' } : {}), summary, modules: this.modules, scenarios: this.scenarios, deploymentBlockers: this.deploymentBlockers, diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 14a62e2cb..7dc90aa6d 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,11 @@ # Admin 测试环境 HTTP 模块 +固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。 + +固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。 + +Admin TEST 入口必须通过显式环境文件配置 `ADMIN_TEST_FIXED_CUSTOMER_ID`(`TEN` 编号或数字 ID)及该客户的 `MERCHANT_TEST_USERNAME`、`MERCHANT_TEST_PASSWORD`。每次先核验账号归属、ACTIVE 状态和尺寸初始化,再复用客户造业务数据;校验失败停止后续模块,不自动建客、重提资质或重置密码。客户创建/归档生命周期明确排除,海外仓储费模块只读核验既有规则,不覆盖固定客户配置。需要第二客户的隔离用例在单客户模式下阻断,不用同一客户替代跨客户验证。该限制不代表这些生命周期或跨客户场景验收通过,也不清理历史客户。 + `fleet-return-redispatch-acceptance.mjs` 的原出发证明读取本轮 `LOADING_COMPLETE` 回执与 `departureDeliveries`;重派在 PDA 装车完成后回查自动生成的出库/派送及两箱已发运事实,不再调用手工安排派送或确认发车。原键重放和跨操作员拒绝仍由共享装车验证覆盖,原签收不漂移、源订单完成事件审计及资源分别释放仍须独立核对。离线模拟不能代替匹配部署的真实 HTTP。 固定托盘造数中,`inbound-measurement.mjs` 只复用 `TP-001`、`YW-TP-002` 指定池中的现有托盘;一只可复用,不要求两只同时存在,两只均缺失才阻断,不自动选择池外托盘。仅明确设置内部 `allowAnyExistingPallet` 的通用场景可读取当前仓库已有资源;两者均不创建托盘。 diff --git a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs index bc499ab73..50b159a16 100644 --- a/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/container-customs-acceptance-contract.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' +import { prepare } from './phase3-container-customs-fixture.mjs' + +test('fixed single customer blocks mixed customs before creating orders or loading fixtures', async () => { + await assert.rejects(prepare({ options: { fixedCustomerId: '123' } }), /停止订单及装柜造数/) +}) import { verifyFormalDeclarationAndExceptions, verifyMixedReceiptIsolation } from './phase3-container-customs.mjs' import { diff --git a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs index df2ea3c86..c98617651 100644 --- a/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs +++ b/scripts/admin-test-env-modules/finance-direct-paid-isolation.mjs @@ -236,6 +236,7 @@ async function verifyFrozenSource(context, fixture) { } async function createSecondCustomer(context) { + if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户财务写入') const originalMarker = context.marker const originalContext = context.context?.merchantQualificationFixture const originalResource = context.createdResources?.merchantQualificationFixture diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index 26230cb77..db8d2db84 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -4,7 +4,30 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { buildContainerCostBody, buildSupplierBody, normalizeFinanceFixtureSources, run, - supplierCategoryForFeeItem } from './finance-full-fixtures.mjs' + supplierCategoryForFeeItem, loadSupplier } from './finance-full-fixtures.mjs' + +test('fixed customer supplier selection paginates and never creates a fallback', async () => { + const calls = [], failures = [] + const supplier = { id: '123', status: 'ACTIVE', category: 'TRUCKING', name: 'Existing' } + const context = { + options: { fixedCustomerId: '456' }, marker: 'REUSE', + async request(method, path) { + calls.push({ method, path }) + const data = path.includes('/123?') ? supplier : path.includes('page=2') + ? { records: [supplier], total: 101 } + : { records: Array.from({ length: 100 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), total: 101 } + return { body: { data } } + }, + apiSuccess(_module, _name, response) { return response.body.data }, + addStep(...args) { failures.push(args) }, + } + assert.equal((await loadSupplier(context, 'TRUCKING')).id, '123') + assert.equal(calls.length, 3) + assert.ok(calls.every(call => call.method === 'GET')) + context.request = async method => { assert.equal(method, 'GET'); return { body: { data: { records: [], total: 0 } } } } + assert.equal(await loadSupplier(context, 'TRUCKING'), null) + assert.equal(failures.length, 1) +}) const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8') diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 95257e89b..8e40bdbd0 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -258,8 +258,27 @@ async function loadPublishedAutomaticFee(context, resource) { actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm } } -async function loadSupplier(context, category) { +export async function loadSupplier(context, category) { if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category) + if (context.options?.fixedCustomerId) { + for (let page = 1; ; page += 1) { + const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`) + const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS) + if (!data) return null + const records = rows(data) + const candidate = records.find(item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category)) + if (candidate) { + const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`) + const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS) + if (id(detail?.id) === id(candidate.id) && detail?.status === 'ACTIVE' && (!category || detail.category === category)) return detail + blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商') + return null + } + if (!records.length || page * 100 >= Number(data.total ?? records.length)) break + } + blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`) + return null + } const effectiveCategory = category || 'BOOKING' const body = buildSupplierBody(context.marker, effectiveCategory) const response = await request(context, 'POST', '/api/platform/v1/suppliers?tenantId=0', body) diff --git a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs index 5179848e3..0d8d892e5 100644 --- a/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs +++ b/scripts/admin-test-env-modules/firstleg-container-fee-allocation.mjs @@ -122,6 +122,7 @@ async function createCustomerWaitSplit(context, suffix, fixedRoute = null) { } async function provisionCrossCustomerPlan(context) { + if (context.options?.fixedCustomerId) throw new Error('缺少第二个固定客户,停止跨客户柜费造数') const originalMarker = context.marker const originalUsername = process.env.MERCHANT_TEST_USERNAME const originalPassword = process.env.MERCHANT_TEST_PASSWORD diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index 683d9bff2..b34cc8404 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -143,7 +143,7 @@ export async function runQuoteSession(context, { marker = 'FLEET-QUOTE-20261003' if (!successful(response) || !login?.accessToken || !login?.refreshToken) throw new Error('Independent TEST login failed') context.token = login.accessToken; context.refreshToken = login.refreshToken const prepare = prepareCustomer || (await import('./merchant-qualification-fixture.mjs')).run - await context.executeModule('merchant-qualification-fixture', () => prepare(context, { publishEnvironment: false })) + await context.executeModule('merchant-qualification-fixture', () => prepare(context, { publishEnvironment: false, requireDistinctCustomer: false })) if (context.steps.some(step => !step.passed)) throw new Error('Current-run customer preparation failed; quotes not executed') await context.executeModule(MODULE, () => run(context)) } catch (error) { fatal = error instanceof Error ? error.message : String(error) } diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index b637ea262..2dc86911e 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -34,7 +34,7 @@ test('real Jackson long counts accept safe decimal strings without changing pall function server(fault = '') { const calls = [], assertions = [], saved = new Map(), loadingActors = new Map() const detail = { dispatchNo: 'DP-A', status: 'ACTIVE', stage: 'BEFORE_LOADING', reviewState: 'DRAFT', recordVersion: '9007199254740998', - counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, + counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, publishLooseboardDemand: false, dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} } const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops } let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail @@ -103,6 +103,7 @@ function server(fault = '') { pallets: [pallet()], missingFields: ['PLT-A:palletSpaces'] }) } if (method === 'PUT' && path === D) { + assert.equal(body.publishLooseboardDemand, detail.publishLooseboardDemand) if (detail.stage !== 'BEFORE_LOADING') { if (fault === 'edit-mutates-on-rejection') detail.counts.pickedPackageCount-- return conflict('仅装车前可编辑,请显式退回;已经扫码不得改派') @@ -180,6 +181,15 @@ function server(fault = '') { const prepare = s => prepareFleetTrip(s.context, 'native', 'DP-A', '92') +test('review preparation uses the current confirmed appointment when arrival is unset', async () => { + const s = server(), at = new Date(Date.now() + 72 * 3600000).toISOString() + s.detail.dispatchInfo.estimatedArrivalAt = null + s.detail.dispatchInfo.fbaAppointments = [{ status: 'PENDING', confirmedAt: null }, { status: 'SUCCESS', confirmedAt: at }] + const proof = await prepare(s) + assert.equal(proof.updateRequest.estimatedArrivalAt, at) + assert.ok(Date.parse(proof.expectedDriverEndAt) > Date.parse(at)) +}) + function jacksonCountWire(s) { const names = new Set(['itemCount', 'palletCount', 'packageCount', 'pickedPackageCount', 'shippedPackageCount', 'scheduledPackages', 'pickedPackages']) const serialize = value => Array.isArray(value) ? value.map(serialize) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index f8816b7b7..59e4eca41 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -104,11 +104,14 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) }, '建立本轮TEST车辆主档及容量') proof.vehicleId = id(vehicle.id) if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID') - await prepareQualifiedDriver(context, module, proof) + const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort() + const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null + await prepareQualifiedDriver(context, module, proof, arrival) proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'), + publishLooseboardDemand: before.publishLooseboardDemand, vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId }, driver: { mode: 'MASTER', driverId: proof.driverId, evidence: { expectedTaskEndAt: proof.expectedDriverEndAt } }, - plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: before.dispatchInfo?.estimatedArrivalAt ?? null, + plannedDepartureAt: new Date(Date.now() + 3600000).toISOString(), estimatedArrivalAt: arrival, remark: `${context.marker} TEST合成资料正常履约` } let result = await command(context, module, 'PUT', path, proof.updateRequest, '通过排车主单编辑正式分配车辆司机') for (const action of ['submit-review', 'dispatch-review', 'loading-review']) { @@ -127,8 +130,8 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) proof.preparation = 'VERIFIED'; await context.checkpoint?.(); return proof } -async function prepareQualifiedDriver(context, module, proof) { - proof.expectedDriverEndAt = new Date(Date.now() + 48 * 3600000).toISOString() +async function prepareQualifiedDriver(context, module, proof, arrival) { + proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString() let driver = await command(context, module, 'POST', `${BASE}/drivers`, { name: `${proof.marker}验收司机`, phone: '19900000000', ownership: 'OWNED', region: 'OVERSEAS', licenseNumber: proof.marker, licenseType: 'Class A', licenseZone: 'UTC', diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index eb9860bcc..0ed2513be 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -1,7 +1,96 @@ import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' import test from 'node:test' -import { qualificationCreditCode } from './merchant-qualification-fixture.mjs' +import vm from 'node:vm' +import { qualificationCreditCode, normalizeFixedCustomerId, run } from './merchant-qualification-fixture.mjs' +import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' + +function fixedContext(profileId = '2100000000000000001') { + const calls = [] + return { + calls, marker: 'FIXED-CUSTOMER-TEST', context: {}, createdResources: {}, + options: { fixedCustomerId: 'TEN2100000000000000001', merchantUsername: 'fixture-user', merchantPassword: 'fixture-password' }, + async request(method, path) { + calls.push({ method, path }) + const data = path.endsWith('/login') ? { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' } + : path.endsWith('/profile') ? { id: profileId, status: 'ACTIVE', tenantName: 'Fixture' } + : { status: 'READY' } + return { http: 200, body: { code: 200, data } } + }, + apiSuccess(_module, _label, response) { return response.body.data }, + assert() {}, + } +} + +test('fixed customer accepts business number without losing integer precision', () => { + assert.equal(normalizeFixedCustomerId('TEN2100000000000000001'), '2100000000000000001') + assert.throws(() => normalizeFixedCustomerId('TENinvalid')) +}) + +test('fixed customer preparation only authenticates and reads existing customer', async () => { + const context = fixedContext() + await run(context, { publishEnvironment: false, requireDistinctCustomer: false }) + assert.equal(context.context.merchantQualificationFixture.reused, true) + assert.equal(context.context.merchantQualificationFixture.tenantId, '2100000000000000001') + assert.equal(context.context.customerId, '2100000000000000001') + assert.deepEqual(context.calls.filter(call => call.method !== 'GET').map(call => call.path), ['/api/auth/login', '/api/auth/logout']) + assert.equal(context.createdResources.merchantQualificationFixture.password, undefined) +}) + +test('wrong customer identity stops preparation and logs out', async () => { + const context = fixedContext('2100000000000000002') + await assert.rejects(run(context, { publishEnvironment: false, requireDistinctCustomer: false }), /归属或状态不匹配/) + assert.equal(context.context.merchantQualificationFixture, undefined) + assert.equal(context.calls.at(-1).path, '/api/auth/logout') + assert.equal(context.calls.length, 3) +}) + +test('second customer fixture cannot silently reuse the first customer', async () => { + const context = fixedContext() + await assert.rejects(run(context, { publishEnvironment: false }), /跨客户场景阻断/) + assert.equal(context.calls.length, 0) +}) + +test('TEST fixture without fixed customer fails before any request', async () => { + const context = fixedContext() + context.options = { environment: 'test' } + await assert.rejects(run(context), /必须配置固定客户/) + assert.equal(context.calls.length, 0) +}) + +test('cross-customer financial helpers stop before provisioning or writing', async () => { + for (const [file, name, end] of [ + ['finance-direct-paid-isolation.mjs', 'createSecondCustomer', 'async function verifyCustomerIsolation'], + ['firstleg-container-fee-allocation.mjs', 'provisionCrossCustomerPlan', 'export async function run'], + ]) { + const source = readFileSync(new URL(file, import.meta.url), 'utf8') + const helper = source.slice(source.indexOf(`async function ${name}(`), source.indexOf(end)) + const scope = vm.createContext({}) + vm.runInContext(helper.replaceAll('import.meta.url', "'file:///unused-test-module.mjs'"), scope) + await assert.rejects(scope[name]({ options: { fixedCustomerId: '123' } }), /缺少第二个固定客户/) + } +}) + +test('fixed resource runner excludes lifecycle handlers without marking them passed', async () => { + const runner = new AcceptanceRun({ environment: 'test', fixedCustomerId: '123', quiet: true }) + let calls = 0 + for (const name of ['customers', 'supplemental-boundaries', 'system', 'master-data', 'suppliers', 'notifications', 'pricing', + 'phase4-overseas-master-data', 'phase4-overseas-wallet-and-pallet-cost']) { + await runner.executeModule(name, async () => { calls++ }) + } + assert.equal(calls, 0) + assert.equal(runner.modules.length, 9) + assert.ok(runner.modules.every(module => module.skipped && !module.success)) +}) + +test('both request entrypoints reject customer creation before network access', async () => { + const runner = new AcceptanceRun({ environment: 'test' }) + for (const method of ['request', 'requestWithBody']) { + for (const path of ['/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) { + await assert.rejects(runner[method]('POST', path, {}), /禁止创建、归档客户/) + } + } +}) const acceptance = readFileSync(new URL('../admin-test-env-acceptance.mjs', import.meta.url), 'utf8') const fixture = readFileSync(new URL('./merchant-qualification-fixture.mjs', import.meta.url), 'utf8') diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs index 0207beac9..10c50ad36 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture.mjs @@ -38,7 +38,46 @@ async function waitForDimensionInitialization(context, tenantId) { if (status !== 'READY') throw new Error(`客户默认尺寸初始化未就绪: ${status}`) } -export async function run(context, { publishEnvironment = true } = {}) { +export function normalizeFixedCustomerId(value) { + const text = String(value || '').trim().replace(/^TEN/, '') + if (!/^[1-9]\d{0,19}$/.test(text)) throw new Error('固定客户编号必须为 TEN 编号或数字 ID') + return text +} + +async function reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) { + if (requireDistinctCustomer) throw new Error('固定客户模式未配置第二个客户,跨客户场景阻断;禁止自动建客或用同一客户替代隔离验证') + const tenantId = normalizeFixedCustomerId(context.options.fixedCustomerId) + const username = context.options.merchantUsername || process.env.MERCHANT_TEST_USERNAME + const password = context.options.merchantPassword || process.env.MERCHANT_TEST_PASSWORD + if (!username || !password) throw new Error('请配置固定客户的 MERCHANT_TEST_USERNAME / MERCHANT_TEST_PASSWORD;不会新建客户或重置密码') + const loginResponse = await context.request('POST', '/api/auth/login', {loginId:username,password}, '') + const login = context.apiSuccess(MODULE, '登录已配置的固定客户账号', loginResponse) + if (!login?.accessToken) throw new Error('固定客户账号登录失败') + let profile + try { + const response = await context.request('GET', '/api/merchant/v1/iam/tenant/profile', undefined, login.accessToken) + profile = context.apiSuccess(MODULE, '核对固定客户账号实际归属和状态', response) + const valid = id(profile?.id) === tenantId && profile?.status === 'ACTIVE' + context.assert(MODULE, '固定客户账号属于指定已审核客户', response, valid, + `expected=${tenantId}, actual=${id(profile?.id)}, status=${profile?.status}`, {deploymentBlocker:true}) + if (!valid) throw new Error('固定客户账号归属或状态不匹配,停止造数;不会新建客户') + await waitForDimensionInitialization(context, tenantId) + } finally { + await context.request('POST', '/api/auth/logout', {refreshToken:login.refreshToken}, login.accessToken) + } + if (publishEnvironment) { + process.env.MERCHANT_TEST_USERNAME = username + process.env.MERCHANT_TEST_PASSWORD = password + } + const fixture = {tenantId,tenantName:profile.tenantName,username,marker:context.marker,reused:true} + context.context.merchantQualificationFixture = {...fixture,password} + context.context.customerId = tenantId + context.createdResources.merchantQualificationFixture = fixture +} + +export async function run(context, { publishEnvironment = true, requireDistinctCustomer = !publishEnvironment } = {}) { + if (context.options?.fixedCustomerId) return reuseFixedCustomer(context, publishEnvironment, requireDistinctCustomer) + if (context.options?.environment === 'test') throw new Error('TEST 必须配置固定客户,禁止自动建客') const suffix = context.marker.replace(/[^0-9A-Z]/gi, '').slice(-10).padStart(10, '0') const customerName = String(context.options?.customerName || '').trim() const tenantName = customerName || `全量验收下单客户-${suffix}` diff --git a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs index ca61aa28b..92a6c89e6 100644 --- a/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs +++ b/scripts/admin-test-env-modules/order-inbound-status-fixtures.mjs @@ -140,7 +140,7 @@ export async function createCompletedOrder(context, runFulfillment = async scope return run(scope) }, prepareCustomer = async scope => { const { run } = await import('./merchant-qualification-fixture.mjs') - return run(scope, { publishEnvironment: false }) + return run(scope, { publishEnvironment: false, requireDistinctCustomer: false }) }) { const scope = Object.create(context) scope.marker = `${context.marker}-COMPLETED` diff --git a/scripts/admin-test-env-modules/overseas-storage-fees.mjs b/scripts/admin-test-env-modules/overseas-storage-fees.mjs index 62a76d58f..7229d2ade 100644 --- a/scripts/admin-test-env-modules/overseas-storage-fees.mjs +++ b/scripts/admin-test-env-modules/overseas-storage-fees.mjs @@ -75,6 +75,16 @@ async function createFixtureCustomer(context) { } export async function run(context) { + if (context.options?.fixedCustomerId) { + const tenantId = context.context.merchantQualificationFixture?.tenantId + if (!tenantId) throw new Error('固定客户前置未完成,禁止创建海外仓费用客户') + const response = await context.request('GET', `/api/platform/v1/iam/tenants/${encodeURIComponent(tenantId)}/overseas-storage-fees`) + const view = context.apiSuccess(MODULE, '读取固定客户海外仓费用配置', response) + context.assert(MODULE, '费用配置属于固定客户', response, id(view?.customerId) === id(tenantId), `customerId=${id(view?.customerId)}`, OPTIONS) + context.addCoverageGap(MODULE, SCENARIO, '固定客户模式排除费用规则破坏性生命周期', + '只读现有费用配置,不覆盖共享客户费率或归档客户', 'not-implemented', {blocking:false}) + return + } const start = context.steps.length // Always create a module-owned customer. The customers module may archive its // own fixture, and this lifecycle must never depend on that or on history. diff --git a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs index 5507e1853..2fa7bf161 100644 --- a/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs +++ b/scripts/admin-test-env-modules/phase3-container-customs-fixture.mjs @@ -260,6 +260,7 @@ export async function withPlatformBookingScope(context, runLoading) { } export async function prepare(context) { + if (context.options?.fixedCustomerId) throw new Error('混合报关柜需要第二个固定客户,停止订单及装柜造数') const outerMarker = context.marker const isolatedResourceKeys = ['phase3Loading', 'phase3LoadingCompletedProducer', 'orchestrationFixtures'] const isolatedResourceSnapshot = new Map(isolatedResourceKeys.map((name) => [name, { diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs index 8102681e4..1076661f4 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs @@ -3,10 +3,21 @@ import { readFileSync } from 'node:fs' import test from 'node:test' import { fileURLToPath } from 'node:url' -import { run } from './phase4-overseas-finance-cross-region.mjs' +import { run, selectCurrentInboundPlan } from './phase4-overseas-finance-cross-region.mjs' const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8') +test('inbound fees cannot select historical plans or another customer under a fixed account', () => { + const current = { planId: '4001', planNo: 'CURRENT', customerId: '2001' } + const old = { planId: '3999', planNo: 'OLD', customerId: '2001' } + const pda = { marker: 'RUN', customerId: '2001', generatedPlanIds: ['4001'] } + assert.equal(selectCurrentInboundPlan([old, current], pda, 'RUN'), current) + assert.equal(selectCurrentInboundPlan([old], pda, 'RUN'), null) + assert.equal(selectCurrentInboundPlan([current], pda, 'OTHER'), null) + assert.equal(selectCurrentInboundPlan([{ ...current, customerId: '2002' }], pda, 'RUN'), null) + assert.equal(selectCurrentInboundPlan([current], { ...pda, generatedPlanIds: [] }, 'RUN'), null) +}) + function response(data, code = '200', http = 200, region) { return { http, @@ -40,7 +51,7 @@ function fixture() { truck: { orderNo: 'TK-1', customerId: '1201' }, looseboard: { orderNo: 'LB-1', customerId: '1301' }, }, - phase4OverseasPda: { customerId: '2001', overseasWarehouseId: '9001' }, + phase4OverseasPda: { marker, generatedPlanIds: ['4001'], customerId: '2001', overseasWarehouseId: '9001' }, }, async request(...args) { const route = /\/(?:last-leg|overseas-warehouse-resources)(?:\/|\?|$)/.test(String(args[1])); diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs index 05e834f89..3d2aa93f7 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs @@ -56,6 +56,13 @@ async function resolveListedBusiness(context, base, resource, customerId, label) return longId(row?.id) ? { businessId: id(row.id), businessNo: row.orderNo, customerId } : null } +export function selectCurrentInboundPlan(plans, pda, marker) { + if (!pda || pda.marker !== marker || !longId(pda.customerId)) return null + const ids = new Set(rows(pda.generatedPlanIds).map(id)) + return rows(plans).find(item => ids.has(id(item?.planId || item?.id)) + && id(item?.customerId) === id(pda.customerId) && item?.planNo) || null +} + async function resolveFixtures(context) { const business = context.createdResources?.phase4OverseasBusiness const merchantCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId @@ -76,7 +83,7 @@ async function resolveFixtures(context) { if (longId(inboundCustomerId)) { const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划', `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`) - const plan = rows(listed.data).find((item) => longId(item?.planId || item?.id) && item?.planNo) + const plan = selectCurrentInboundPlan(listed.data, pda, context.marker) context.assert(MODULE, '海外入仓费用关联真实计划', listed.response, Boolean(plan) && longId(plan?.planId || plan?.id), `planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs index 94275e0c7..a1784faac 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse-contract.test.mjs @@ -161,5 +161,23 @@ test('合同覆盖库存CSV并明确排除暂缓、离仓、PDA与内部路径', assert.doesNotMatch(source, /创建本轮换标单|verifyRelabelLifecycle/) assert.match(source, /创建本轮公共库位/) assert.match(source, /公共托盘旧版本编辑返回冲突/) - assert.doesNotMatch(source, /deploymentBlocker|addCoverageGap|coverageGap/) + assert.doesNotMatch(source, /deploymentBlocker/) + assert.match(source, /固定资源模式缺少海外仓,禁止自动创建仓库/) +}) + +test('fixed customer mode reuses warehouse without location or pallet lifecycle writes', async () => { + const context = contextFixture() + context.options = { fixedCustomerId: '10001' } + context.context.merchantQualificationFixture = { tenantId: '10001' } + await run(context) + assert.equal(context.context.phase4OverseasWarehouse.customerId, '10001') + assert.equal(context.context.phase4OverseasWarehouse.warehouseId, '20001') + assert.ok(context.calls.filter(call => call.method !== 'GET') + .every(call => call.method === 'POST' && call.path === '/api/platform/v1/files/export-tasks')) + assert.equal(context.gaps.length, 1) + const missing = contextFixture({ warehouseRecords: [] }) + missing.options = context.options + missing.context.merchantQualificationFixture = { tenantId: '10001' } + await assert.rejects(run(missing), /禁止自动创建仓库/) + assert.equal(missing.calls.some(call => call.method === 'POST' && call.path === '/api/platform/v1/warehouse-admin'), false) }) diff --git a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs index 123b0dcc0..0d1e041d6 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-warehouse.mjs @@ -67,7 +67,10 @@ export async function run(context) { context.createdResources.phase4OverseasWarehouse = { marker: context.marker, customerId: null, warehouseId: null, palletId: null, terminalStatus: null } const tenantsResult = await domesticGet(context, '查询可用客户候选', '/api/platform/v1/iam/tenants?page=1&size=100&status=ACTIVE') assertPage(context, '可用客户候选', tenantsResult) - const customer = records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') + const fixedCustomer = context.context?.merchantQualificationFixture + const customer = context.options?.fixedCustomerId + ? { id: fixedCustomer?.tenantId, status: fixedCustomer?.tenantId ? 'ACTIVE' : null } + : records(tenantsResult.data).find((item) => longId(item?.id) && item?.status === 'ACTIVE') const customerId = id(customer?.id) if (!customerId) { context.assert(MODULE, '海外仓验收必须取得ACTIVE客户', tenantsResult.response, false, @@ -151,6 +154,7 @@ export async function run(context) { let warehouse = await findWarehouse(context, records(tenantsResult.data), customerId) let ownedWarehouse = false if (!warehouse) { + if (context.options?.fixedCustomerId) throw new Error('固定资源模式缺少海外仓,禁止自动创建仓库') warehouse = await createWarehouseFixture(context, customerId) ownedWarehouse = Boolean(warehouse) } @@ -185,6 +189,12 @@ export async function run(context) { `listId=${id(pallet.id)}, detailId=${id(detail.data?.id) || 'missing'}; ${trace(detail.response)}`, OPTIONS) } + if (context.options?.fixedCustomerId) { + context.context.phase4OverseasWarehouse = { customerId, warehouseId, palletId: null } + context.addCoverageGap(MODULE, 'resource-lifecycle', '复用海外仓资源,仅核验列表和详情', + '不创建或修改公共库位及托盘;业务分配由后续实际库存流程决定', 'not-implemented', { blocking: false }) + return + } try { await verifyLocationLifecycle(context, warehouseId) await verifyPalletLifecycle(context, warehouseId) diff --git a/scripts/client-test-env-acceptance-contract.test.mjs b/scripts/client-test-env-acceptance-contract.test.mjs index 6474dd4a3..0aeb82b71 100644 --- a/scripts/client-test-env-acceptance-contract.test.mjs +++ b/scripts/client-test-env-acceptance-contract.test.mjs @@ -7,6 +7,28 @@ import { inflateRawSync, deflateRawSync } from 'node:zlib' const source = await readFile(new URL('./client-test-env-acceptance.mjs', import.meta.url), 'utf8') +test('fixed Client authenticates and verifies ownership without registration', async () => { + const calls = [] + const scope = vm.createContext({ username: 'fixture', clientPassword: 'test-password', fixedCustomerId: '123', + report: { resources: {} }, assertEqual: assert.equal, + requireSuccess: json => assert.equal(json.code, 200), assertPresent: assert.ok, + async apiStep(_module, _name, request, validate) { + calls.push(request) + const json = { code: 200, data: request.path.endsWith('/login') + ? { accessToken: 'test-token', refreshToken: 'test-refresh' } + : { id: '123', status: 'ACTIVE', tenantName: 'Fixture' } } + validate({ json }); return { json } + }, + }) + const helper = source.slice(source.indexOf('async function reuseFixedClient()'), source.indexOf('async function registrationModule()')) + vm.runInContext(`let merchantToken, merchantRefreshToken, companyName; ${helper}`, scope) + await scope.reuseFixedClient() + assert.deepEqual(calls.map(call => call.path), ['/api/auth/login', '/api/merchant/v1/iam/tenant/profile']) + assert.equal(scope.report.resources.tenantId, '123') + scope.fixedCustomerId = '456' + await assert.rejects(scope.reuseFixedClient(), /fixed customer identity/) +}) + test('client registration distinguishes incomplete-registration replay from credential conflicts', () => { assert.match(source, /same credentials replay the incomplete registration/) assert.match(source, /\['accountId', 'tenantId', 'membershipId'\]/) @@ -510,12 +532,12 @@ test('F01 client bills module covers customer-only read APIs and rejects unknown assert.match(source, /async function merchantFinanceBillsModule\(\)/) assert.match(source, /await merchantFinanceBillsModule\(\)/) const callOrder = [ - source.indexOf('await orderSubmissionChannelGateModule()'), + source.indexOf('await orderExcelImportModule()'), source.indexOf('await merchantFinanceBillsModule()'), - source.indexOf('await notificationInboxModule()'), + source.indexOf('await merchantOverseasWarehouseModule()'), ] assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1], - 'bills module must run after channel gate and before notifications') + 'fixed customer bills module runs after orders and before overseas queries') assert.match(source, /const base = '\/api\/client\/finance\/v1'/) for (const path of ['/bills', '/statements', '/receipts', '/receivable-settlements', '/confirm']) { assert.ok(source.includes(path), path) @@ -535,10 +557,10 @@ test('F02 client overseas self-service covers customer-scoped lists and the retu const callOrder = [ source.indexOf('await merchantFinanceBillsModule()'), source.indexOf('await merchantOverseasWarehouseModule()'), - source.indexOf('await notificationInboxModule()'), + source.indexOf('await merchantCrossBorderModule()'), ] assert.ok(callOrder[0] >= 0 && callOrder[1] > callOrder[0] && callOrder[2] > callOrder[1], - 'overseas self-service must run after bills and before notifications') + 'overseas self-service runs after bills and before cross-border flow') for (const path of ['/api/merchant/v1/warehouses', '/return-orders', '/relabel-applications', '/warehouse-transfers', '/cargo-discards', '/return-inventory', '/return-inventory/summary', '/return-inventory/cartons']) { @@ -559,10 +581,10 @@ test('F03 client cross-border, warehouse orders and pricing/customs cover curren assert.match(source, new RegExp(`await ${name}\\(\\)`), name) } const order = ['merchantOverseasWarehouseModule', 'merchantCrossBorderModule', - 'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule', 'notificationInboxModule'] + 'merchantWarehouseOrdersModule', 'merchantPricingCustomsModule'] .map((name) => source.indexOf(`await ${name}()`)) assert.ok(order.every((index) => index >= 0) && order.every((index, position) => position === 0 || index > order[position - 1]), - 'new client modules must run in order before notifications') + 'fixed customer business modules preserve their dependency order') for (const path of ['/us-truck-orders', '/local-looseboard-orders', '/overseas-procurements', '/inbound-plans', '/delivery-orders', '/first-leg/pricing/channels', '/customs/pending-summary', '/customs/orders/']) { diff --git a/scripts/client-test-env-acceptance.mjs b/scripts/client-test-env-acceptance.mjs index 24090611b..d039bcd6d 100644 --- a/scripts/client-test-env-acceptance.mjs +++ b/scripts/client-test-env-acceptance.mjs @@ -17,8 +17,10 @@ import { randomUUID, createHash } from 'node:crypto' import { resolve } from 'node:path' import { createInterface } from 'node:readline/promises' import { inflateRawSync, deflateRawSync } from 'node:zlib' +import { normalizeFixedCustomerId } from './admin-test-env-modules/merchant-qualification-fixture.mjs' const args = parseArgs(process.argv.slice(2)) +const fixedCustomerId = normalizeFixedCustomerId(process.env.ADMIN_TEST_FIXED_CUSTOMER_ID) if ((args.environment && args.environment !== 'test') || args['overseas-gateway'] || process.env.ADMIN_OVERSEAS_GATEWAY) throw new Error('Only one TEST gateway is supported; remove regional options') const gateway = trimSlash(args.gateway ?? process.env.ADMIN_TEST_GATEWAY) const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USERNAME @@ -26,9 +28,9 @@ const platformUsername = args['platform-username'] ?? process.env.ADMIN_TEST_USE const platformPassword = args['platform-password'] ?? process.env.ADMIN_TEST_PASSWORD ?? await promptSecret('平台审核密码') const existingClientUsername = args['existing-client-username'] ?? process.env.MERCHANT_TEST_USERNAME - ?? await promptText('跨客户隔离测试账号(必须由正式注册/建档产生)', '') + ?? await promptText('固定客户测试账号', '') const existingClientPassword = args['existing-client-password'] ?? process.env.MERCHANT_TEST_PASSWORD - ?? await promptSecret('跨客户隔离测试密码') + ?? await promptSecret('固定客户测试密码') const reportDir = resolve(args['report-dir'] ?? process.env.CLIENT_TEST_REPORT_DIR ?? 'reports') const timeoutMs = Number(args['timeout-ms'] ?? process.env.CLIENT_TEST_TIMEOUT_MS ?? 30_000) @@ -43,12 +45,12 @@ if (!Number.isFinite(timeoutMs) || timeoutMs < 1_000) { const now = new Date() const marker = `${compactTimestamp(now)}-${randomUUID().slice(0, 8)}` -const username = `client_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) +const username = existingClientUsername const temporaryUsername = `renamed_${compactTimestamp(now)}_${randomUUID().slice(0, 6)}`.slice(0, 64) -const clientPassword = `Client${randomUUID().replaceAll('-', '').slice(0, 14)}9!` +const clientPassword = existingClientPassword const phoneSuffix = `${Date.now()}`.slice(-8) const legalPhone = `139${phoneSuffix}` -const companyName = `客户端全链路测试企业-${marker}`.slice(0, 128) +let companyName = '' const creditCode = `91${compactTimestamp(now)}${randomUUID().replaceAll('-', '').slice(0, 2).toUpperCase()}`.slice(0, 18) const reportPath = resolve(reportDir, `client-test-env-${marker}.json`) @@ -58,6 +60,7 @@ const report = { generatedAt: now.toISOString(), completedAt: null, success: false, + fullCoverage: false, fatal: null, testAccount: { username, companyName, legalPhone }, resources: { @@ -94,33 +97,34 @@ let ownerPasswordNeedsRestore = false let temporaryOwnerPassword try { - await registrationModule() + await reuseFixedClient() await platformReviewerLogin() - await merchantBeforeApprovalModule() - await qualificationAndReviewModule() - await merchantAfterApprovalModule() - await profileManagementModule() - await loginIdentifierModule() - await rbacAndMemberModule() + for (const module of ['registration', 'qualification-review', 'profile-management', 'login-identifiers', + 'rbac-members', 'isolation', 'order-submission-balance', 'order-mutation-evidence', + 'order-submission-channel', 'notification-inbox', 'account-security']) { + recordSkipped(module, '固定客户模式排除生命周期或共享配置写入', + '不注册客户、不修改固定账号资料或余额、不发布通知;跨客户场景需第二个固定客户') + } await orderLifecycleModule() - await isolationModule() - await orderSubmissionBalanceGateModule() - await orderMutationEvidenceModule() await orderExcelImportModule() - await orderSubmissionChannelGateModule() await merchantFinanceBillsModule() await merchantOverseasWarehouseModule() await merchantCrossBorderModule() await merchantWarehouseOrdersModule() await merchantPricingCustomsModule() - await notificationInboxModule() - await accountSecurityModule() report.success = report.steps.every((step) => step.passed) + report.fullCoverage = !report.steps.some((step) => step.skipped) } catch (error) { report.fatal = sanitizeError(error) report.success = false } finally { if (ownerPasswordNeedsRestore) await recoverOwnerPassword() + if (merchantToken) { + const logout = await softApiStep('fixed-customer', '退出固定客户测试会话', { + method: 'POST', path: '/api/auth/logout', token: merchantToken, body: { refreshToken: merchantRefreshToken }, + }, ({ json }) => requireSuccess(json)) + if (!logout) report.success = false + } report.completedAt = new Date().toISOString() await mkdir(reportDir, { recursive: true }) await writeFile(reportPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8') @@ -129,6 +133,24 @@ try { process.exitCode = report.success ? 0 : 1 +async function reuseFixedClient() { + const login = await apiStep('fixed-customer', '登录固定客户', { + method: 'POST', path: '/api/auth/login', body: { loginId: username, password: clientPassword }, + }, ({ json }) => { requireSuccess(json); assertPresent(json.data?.accessToken, 'accessToken') }) + merchantToken = login.json.data.accessToken + merchantRefreshToken = login.json.data.refreshToken + const profile = await apiStep('fixed-customer', '校验固定客户归属及状态', { + method: 'GET', path: '/api/merchant/v1/iam/tenant/profile', token: merchantToken, + }, ({ json }) => { + requireSuccess(json) + assertEqual(String(json.data?.id), fixedCustomerId, 'fixed customer identity') + assertEqual(json.data?.status, 'ACTIVE', 'fixed customer status') + }) + report.resources.tenantId = fixedCustomerId + companyName = profile.json.data.tenantName + report.testAccount = { username, reused: true } +} + async function registrationModule() { const registrationBody = { username, @@ -3360,6 +3382,10 @@ async function softApiStep(module, name, request, validate, evidenceBuilder = de } async function httpRequest({ method = 'GET', path, token, headers = {}, body, responseType = 'json' }) { + if (String(method).toUpperCase() === 'POST' + && ['/api/auth/register', '/api/platform/v1/iam/tenants'].includes(new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, ''))) { + throw new Error('固定客户模式禁止注册或创建客户') + } const requestHeaders = { Accept: 'application/json', 'X-Request-Id': `client-e2e-${randomUUID()}`, ...headers } if (token) requestHeaders.Authorization = `Bearer ${token}` let requestBody = body diff --git a/scripts/dispatch-loading-flow-test-env.mjs b/scripts/dispatch-loading-flow-test-env.mjs new file mode 100644 index 000000000..c9b67bfa0 --- /dev/null +++ b/scripts/dispatch-loading-flow-test-env.mjs @@ -0,0 +1,156 @@ +import assert from 'node:assert/strict' +import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs' +import { resolve, join } from 'node:path' +import { tmpdir } from 'node:os' +import { createHash } from 'node:crypto' +import { pathToFileURL } from 'node:url' +import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' +import { run as fixedCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs' +import { resolvePickerAccess, createPicker, disablePicker } from './admin-test-env-modules/phase4-overseas-pda.mjs' +import { prepareFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' +import { prepareBolAppointments } from './admin-test-env-modules/dispatch-workflow-extension-acceptance.mjs' + +const ROOT = '/api/platform/v1/last-leg', PDA = '/api/pda/v1/last-leg' +const slots = ['LOADING_SCENE','LOADING_LIST','PRE_DEPARTURE_CHECK','DRIVER_LICENSE','RECEIPT'] +const id = value => String(value ?? '') +const hash = bytes => createHash('sha256').update(bytes).digest('hex') +export function parseFlowArguments(args) { + const config = { execute: false, keepPda: false } + for(let i=0;im.receiptVerified && m.customerId===previous.customerId));report.members=previous.members;report.dispatchNo=previous.dispatchNo;report.resumedFrom=config.resumeReport} + let sequence=0,picker + const clean=value=>Array.isArray(value)?value.map(clean):value && typeof value==='object'?Object.fromEntries(Object.entries(value).map(([key,v])=>[key,/password|token|authorization/i.test(key)?'[REDACTED]':clean(v)])):typeof value==='string' && secrets.includes(value)?'[REDACTED]':value + const save=()=>writeFileSync(join(directory,'report.json'),JSON.stringify(clean({...report,requestCount:sequence,createdResources:context.createdResources}),null,2)) + context.checkpoint=async()=>save() + const check=(label,condition)=>{report.checks.push({label,passed:!!condition});save();assert(condition,label)} + const key=action=>hash(`${context.marker}:${config.runId}:${action}`) + for(const method of ['request','requestMultipart']) { + const original=context[method].bind(context) + context[method]=async(...args)=>{ + const file=join(directory,'requests',`${++sequence}.json`), record={method:args[0],path:args[1],body:method==='request'?clean(args[2]):{fileName:args[2]?.name},stage:'INTENT'} + writeFileSync(file,JSON.stringify(record,null,2)) + try{const response=await original(...args);if(args[1]==='/api/auth/login' && response.body?.data?.accessToken){secrets.push(response.body.data.accessToken,response.body.data.refreshToken);sessions.push(response.body.data)}record.stage='RESPONSE';record.response=clean(response);writeFileSync(file,JSON.stringify(record,null,2));save();return response} + catch(error){record.stage='UNKNOWN';record.error=error.message;writeFileSync(file,JSON.stringify(record,null,2));throw error} + } + } + const send=async(method,path,body,token=context.token,deny=false)=>{ + const response=await context.request(method,path,body,token,{}) + if(deny){check(`rejected ${path}`,id(response.body?.code)==='A0409');return response.body} + if(response.http!==200 || id(response.body?.code)!=='200')throw Error(`${method} ${path}: HTTP ${response.http}, ${response.body?.code}, ${response.body?.message}, requestId=${response.requestId}`) + return response.body.data + } + const upload=async(path,name,token=context.token)=>{ + const r=await context.requestMultipart('POST',path,{name,type:'image/png',content:image},token) + if(r.http!==200 || id(r.body?.code)!=='200')throw Error(`Upload failed ${path}: ${r.body?.message}, requestId=${r.requestId}`) + return r.body.data + } + const phase=value=>{report.phase=value;save();console.log(JSON.stringify({phase:value,dispatchNo:report.dispatchNo}))} + try { + const auth=await send('POST','/api/auth/login',{loginId:options.username,password:options.password},'');context.token=auth.accessToken;context.refreshToken=auth.refreshToken + await fixedCustomer(context,{requireDistinctCustomer:false});const customer=context.context.merchantQualificationFixture + const merchant=await send('POST','/api/auth/login',{loginId:customer.username,password:customer.password},'');delete context.context.merchantQualificationFixture + report.customerId=id(customer.tenantId) + const access=await resolvePickerAccess(context);assert(access,'Existing PDA role missing') + picker=await createPicker(context,82,config.warehouseId,access);assert(picker?.token,'PDA login failed');secrets.push(picker.password) + const master=await send('GET',`/api/platform/v1/warehouse-admin/${config.destinationId}?tenantId=0`) + assert(master.warehouse.status==='ACTIVE' && master.warehouse.warehouseType==='FBA' && master.warehouse.timezone.includes('/')) + const address=master.addresses.find(a=>a.addressLine1), own=`?customerId=${report.customerId}` + if(!previous){ + phase('RECEIVING_TWO_TEST_PALLETS') + for(const suffix of ['A','B']) { + const m={customerId:report.customerId,marker:context.marker,receiptVerified:false};report.members.push(m) + const merchantRoot='/api/merchant/v1/last-leg/warehouse-transfers' + const body={sourceLastlegOrderIds:[],operatingWarehouseId:config.warehouseId,fbaWarehouseCode:master.warehouse.warehouseCode,destinationType:'FBA',destinationWarehouseId:config.destinationId,destinationWarehouseCode:master.warehouse.warehouseCode,destinationWarehouseName:master.warehouse.warehouseName,deliveryAddress:[address.countryCode,address.city,address.addressLine1,address.postalCode].filter(Boolean).join(' '),packageCount:1,volumeCbm:1,deliveryMethod:'CUSTOMER_DELIVERY',estimatedArrivalAt:new Date(Date.now()+3600000).toISOString().slice(0,19),needRelabel:false,remark:`${context.marker} TEST synthetic ${suffix}`,idempotencyKey:key(`CREATE-${suffix}`),fbaShipmentNo:`TEST-FBA-${config.runId}-${suffix}`,items:[{skuCode:`TEST-${config.runId}-${suffix}`,productNameZh:'TEST验收货物',packageCount:1,quantity:1}]} + let incoming=await send('POST',merchantRoot,body,merchant.accessToken);m.incomingNo=incoming.transferNo;save() + const mp=`${merchantRoot}/${encodeURIComponent(m.incomingNo)}`, pp=`${PDA}/warehouse-transfers/${encodeURIComponent(m.incomingNo)}` + await send('POST',mp+'/submit',undefined,merchant.accessToken) + await send('POST',`${ROOT}/warehouse-transfers/${encodeURIComponent(m.incomingNo)}/review${own}`,{decision:'APPROVE',remark:context.marker}) + const task=await send('GET',pp,undefined,picker.token);assert.equal(task.cartons.length,1) + const draft=await send('POST',pp+'/pallets/drafts',{idempotencyKey:key(`DRAFT-${suffix}`)},picker.token), palletPath=pp+'/pallets/'+draft.pallets[0].id + const generated=await send('POST',palletPath+'/generate',{idempotencyKey:key(`GENERATE-${suffix}`)},picker.token) + m.palletId=id(generated.pallets[0].warehousePalletId);m.palletNo=generated.pallets[0].palletNo + await send('POST',palletPath+'/bind',{palletCode:m.palletNo,idempotencyKey:key(`BIND-${suffix}`)},picker.token) + await send('POST',pp+'/cartons/count',{idempotencyKey:key(`COUNT-${suffix}`),forecastPackageCount:1,boxes:[{cartonMark:task.cartons[0].cartonMark,transferItemId:id(task.items[0].id),transferPalletId:id(draft.pallets[0].id),length:100,width:100,height:100,weight:100,items:[{skuCode:task.items[0].skuCode,quantity:1}]}]},picker.token) + const photo=await upload(pp+'/measurement-photos/files',`${config.runId}-TEST-measurement.png`,picker.token) + await send('POST',pp+'/palletize',{idempotencyKey:key(`PALLETIZE-${suffix}`),photoFileIds:[id(photo.fileId)]},picker.token) + incoming=await send('POST',mp+'/confirm-measurement',{idempotencyKey:key(`CUSTOMER-${suffix}`),remark:context.marker},merchant.accessToken) + check('received '+m.incomingNo,incoming.status==='READY_FOR_DISPATCH') + const grouped=await send('GET',`${ROOT}/inventory/balances?`+new URLSearchParams({customerId:m.customerId,overseasWarehouseId:config.warehouseId,inboundCode:m.incomingNo,page:'1',size:'100'})) + assert.equal(grouped.records.length,1,'One exact receipt balance expected');const balance=grouped.records[0] + m.balanceId=id(balance.id);m.receipt={balance:await send('GET',`${ROOT}/inventory/balances/${m.balanceId}${own}`)} + const positions=await send('GET',`${ROOT}/inventory/balances/${m.balanceId}/positions${own}`);m.positionId=id(positions.find(p=>id(p.palletId)===m.palletId).id) + const candidates=await send('GET',`${ROOT}/dispatches/candidates?`+new URLSearchParams({warehouseId:config.warehouseId,orderNo:m.incomingNo})) + const candidate=candidates.candidates.find(c=>c.palletNo===m.palletNo && id(c.customerId)===m.customerId);assert(candidate?.selection);m.selection=candidate.selection;m.destination=candidate.destination;m.receiptVerified=true;save() + } + phase('CONFIRMED_TEST_APPOINTMENT') + const allocations=await prepareBolAppointments(context,report.members,'bol-same',config.warehouseId) + for(const m of report.members){const fresh=await send('GET',`${ROOT}/dispatches/candidates?`+new URLSearchParams({warehouseId:config.warehouseId,orderNo:m.incomingNo}));m.selection=fresh.candidates.find(c=>c.palletNo===m.palletNo).selection} + const created=await send('POST',ROOT+'/dispatches',{warehouseId:config.warehouseId,vehicle:null,driver:null,plannedDepartureAt:null,estimatedArrivalAt:null,remark:`${context.marker} TEST HTTP/UI`,cargo:report.members.map(m=>m.selection),batchAllocations:allocations,idempotencyKey:key('DISPATCH')}) + report.dispatchNo=created.dispatchNo;assert(report.dispatchNo);save() + } + const path=ROOT+'/dispatches/'+report.dispatchNo, pp=PDA+'/dispatches/'+report.dispatchNo + if(!previous){const created=await send('GET',path);await send('POST',path+'/submit-review',{recordVersion:created.recordVersion,idempotencyKey:key('NO_SCHEDULE'),remark:'TEST missing transport schedule must block'},context.token,true)} + phase('REVIEWS_AND_REAL_PICKS') + if(previous){ + const current=await send('GET',path), proof=previous.createdResources.fleetTrips[0] + assert.equal(current.stage,'BEFORE_LOADING');assert.equal(current.reviewState,'DRAFT');assert.equal(Number(current.counts.pickedPackageCount),0);assert.equal(id(proof.warehouseId),config.warehouseId) + assert(previous.createdResources.fleetTripCommands.some(c=>c.method==='PUT' && c.path===path && c.status==='RESPONDED' && c.code==='A0400')) + let result=await send('PUT',path,{...proof.updateRequest,recordVersion:current.recordVersion,idempotencyKey:key('FIX_EDIT'),publishLooseboardDemand:current.publishLooseboardDemand}) + for(const action of ['submit-review','dispatch-review','loading-review'])result=await send('POST',path+'/'+action,{recordVersion:result.recordVersion,idempotencyKey:key(action),...(action==='submit-review'?{remark:'TEST full flow'}:{decision:'APPROVED',reason:'TEST full flow'})}) + report.trip=proof + }else report.trip=await prepareFleetTrip(context,'dispatch-loading-flow',report.dispatchNo,config.warehouseId) + for(const [index,m] of report.members.entries()) { + const task=await send('GET',pp,undefined,picker.token),item=task.items.find(i=>i.palletNo===m.palletNo);assert(item);m.dispatchItemId=id(item.dispatchItemId) + const body={recordVersion:task.recordVersion,positionId:m.positionId,packages:1,productQuantity:1,idempotencyKey:key('PICK-'+index),remark:context.marker} + const picked=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token) + const replay=await send('POST',`${pp}/items/${m.dispatchItemId}/pick`,body,picker.token);check('pick replay '+index,replay.replayed===true && id(replay.recordVersion)===id(picked.recordVersion)) + if(index===0){const preview=await send('GET',pp+'/loading',undefined,picker.token);await send('POST',pp+'/loading-complete',{recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('INCOMPLETE')},picker.token,true)} + } + const ledger=async()=>Promise.all(report.members.map(m=>send('GET',`${ROOT}/inventory/ledger?`+new URLSearchParams({customerId:m.customerId,overseasWarehouseId:config.warehouseId,inboundCode:m.incomingNo,page:'1',size:'100'})))) + report.stockAfterPick=await ledger() + phase('PDA_PHOTOS_AND_ADMIN_FILE_READBACK') + for(const slot of slots){const up=await upload(`${pp}/loading-documents/${slot}/uploads?idempotencyKey=${key('UPLOAD-'+slot)}`,`${config.runId}-TEST-${slot}.png`,picker.token);const docs=await send('GET',pp+'/loading-documents',undefined,picker.token);await send('PUT',`${pp}/loading-documents/${slot}`,{recordVersion:docs.recordVersion,idempotencyKey:key('BIND-'+slot),fileId:id(up.fileId)},picker.token)} + const docs=await send('GET',path+'/loading-documents');check('Admin sees five photos',docs.complete && docs.photos.length===5) + report.photos=[] + for(const photo of docs.photos){const r=await context.requestBinary('GET',`${path}/loading-documents/${photo.slot}/files/${photo.fileId}/content`);check('Admin bytes '+photo.slot,r.http===200 && hash(r.bytes)===hash(image));report.photos.push({slot:photo.slot,fileId:photo.fileId,sha256:hash(r.bytes)})} + phase('AUTOMATIC_OUTBOUND_AND_DELIVERY') + const preview=await send('GET',pp+'/loading',undefined,picker.token),body={recordVersion:preview.recordVersion,manifestHash:preview.manifestHash,idempotencyKey:key('LOAD'),remark:'TEST scanned pallets; no manual measurements'} + const loaded=await send('POST',pp+'/loading-complete',body,picker.token);check('automatic departure',loaded.stage==='DELIVERING') + const after=await send('GET',path),replay=await send('POST',pp+'/loading-complete',body,picker.token) + check('loading replay',replay.replayed && id(replay.recordVersion)===id(loaded.recordVersion)) + assert.deepEqual(await send('GET',path),after);assert.deepEqual(await ledger(),report.stockAfterPick) + check('one customer outbound and destination delivery',after.documents.outbounds.length===1 && after.documents.deliveries.length===1 && after.documents.outbounds[0].status==='OUTBOUNDED' && after.documents.deliveries[0].status==='DELIVERING' && Number(after.counts.shippedPackageCount)===2) + report.final=after;report.success=true;phase('HTTP_PASSED_AWAITING_UI') + if(config.keepPda){report.privateUiSession=join(tmpdir(),`dispatch-ui-${config.runId}.json`);writeFileSync(report.privateUiSession,JSON.stringify(picker),{mode:0o600,flag:'wx'})} + } catch(error){report.error=error.message;report.success=false;phase('FAILED')} + finally { + if(picker?.membershipId && !(report.success && config.keepPda))await disablePicker(context,picker,82).catch(e=>{report.cleanupError=e.message}) + for(const session of sessions)await context.request('POST','/api/auth/logout',{refreshToken:session.refreshToken},session.accessToken,{}).catch(()=>{}) + save() + } + return {success:report.success,error:report.error,dispatchNo:report.dispatchNo,report:join(directory,'report.json'),privateUiSession:report.privateUiSession} +} +if(process.argv[1] && import.meta.url===pathToFileURL(resolve(process.argv[1])).href){try{const result=await runLoadingFlow(parseFlowArguments(process.argv.slice(2)));console.log(JSON.stringify(result));if(result.success===false)process.exitCode=1}catch(error){console.error(error.message);process.exitCode=1}} diff --git a/scripts/fleet-booking-quotes-test-env.mjs b/scripts/fleet-booking-quotes-test-env.mjs index 4ab271d83..74172bcd6 100644 --- a/scripts/fleet-booking-quotes-test-env.mjs +++ b/scripts/fleet-booking-quotes-test-env.mjs @@ -102,7 +102,7 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e } if (!warehouse) throw Error('No active warehouse has unambiguous city/timezone; no business fixtures created') context.createdResources.quoteWarehouse = warehouse - await context.executeModule('merchant-qualification-fixture', () => prepareCustomer(context, { publishEnvironment: false })) + await context.executeModule('merchant-qualification-fixture', () => prepareCustomer(context, { publishEnvironment: false, requireDistinctCustomer: false })) if (context.steps.some(s => !s.passed)) throw Error('Fresh customer fixture failed') await verifyBookingQuotes(context, warehouse, planned) } catch (cause) { error = cause.message; if (context.createdResources.bookingQuoteAcceptance) context.createdResources.bookingQuoteAcceptance.status = 'FAILED' } diff --git a/scripts/procurement-sample-test-env.mjs b/scripts/procurement-sample-test-env.mjs index d8e5924c1..d5b4de83f 100644 --- a/scripts/procurement-sample-test-env.mjs +++ b/scripts/procurement-sample-test-env.mjs @@ -80,7 +80,7 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e context.token = await login(env.CHONGCHONG_ADMIN_USERNAME,env.CHONGCHONG_ADMIN_PASSWORD,'platform') const warehouses = requireSuccess(await context.request('GET','/api/platform/v1/last-leg/overseas-procurements/warehouse-options')) if (!Array.isArray(warehouses) || !warehouses.some(row => String(row.warehouseId) === config.warehouseId)) throw new Error('指定收货仓不在有效采购仓候选中') - await prepareCustomer(context,{publishEnvironment:false}) + await prepareCustomer(context,{publishEnvironment:false,requireDistinctCustomer:false}) if (context.steps.some(step => !step.passed)) throw new Error('独立客户准备检查未全部通过') const fixture = context.context.merchantQualificationFixture const customerId = String(fixture.tenantId), customerToken = await login(fixture.username,fixture.password,'customer') diff --git a/scripts/test-env-business-reset-plan.mjs b/scripts/test-env-business-reset-plan.mjs new file mode 100644 index 000000000..cf3ea891b --- /dev/null +++ b/scripts/test-env-business-reset-plan.mjs @@ -0,0 +1,200 @@ +import { createHash } from 'node:crypto' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { tableDefinitions } from './sql-baseline-contract-helpers.mjs' + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)) +const policyPath = new URL('./test-env-business-reset-policy.json', import.meta.url) +const baselinePaths = [ + 'deploy/sql/头程数据库统一增量-MySQL8.sql', + 'deploy/sql/海外区域统一增量-MySQL8.sql', +] +const actions = new Set(['KEEP', 'CLEAR_CANDIDATE', 'RESET_REVIEW', 'REVIEW']) + +/** 对基线正文取指纹;统一换行,避免不同工作区换行规则造成误报。 */ +export function fingerprint(text) { + return createHash('sha256').update(text.replaceAll('\r\n', '\n')).digest('hex') +} + +/** 只接受当前基线使用的简单标识符,生成的 SQL 不接受外部表达式。 */ +function identifier(value) { + if (typeof value !== 'string' || !/^[a-z][a-z0-9_]*$/.test(value)) { + throw new Error('表或 schema 标识符非法') + } + return value +} + +/** 从空库建表基线提取范围和约束,不执行初始化 SQL。 */ +export function readCatalog(sources) { + const tables = new Map() + for (const source of sources) { + const definitions = tableDefinitions(source.sql) + const declarationCount = [...source.sql.matchAll(/^CREATE\s+TABLE\b/gim)].length + if (definitions.size !== declarationCount) throw new Error(`未能完整解析建表定义:${source.path}`) + for (const [key, definition] of definitions) { + if (tables.has(key)) throw new Error(`重复表:${key}`) + const [schema, name] = key.split('.').map(identifier) + const references = [...definition.matchAll(/\bREFERENCES\s+(?:(\w+)\.)?(\w+)\s*\(/gi)] + .map(match => `${match[1] ?? schema}.${match[2]}`) + tables.set(key, { + key, schema, name, source: source.path, + definitionSha256: fingerprint(definition), + description: definition.match(/\) ENGINE=[^\n]*COMMENT='([^']*)'/)?.[1] ?? '', + references: [...new Set(references)], triggers: [], + }) + } + let schema + for (const match of source.sql.matchAll(/^USE (\w+);|^CREATE TRIGGER (\w+) (BEFORE|AFTER) (INSERT|UPDATE|DELETE) ON (\w+)/gm)) { + if (match[1]) schema = match[1] + else { + const table = tables.get(`${schema}.${match[5]}`) + if (!table) throw new Error(`触发器对应表不在清单中:${match[2]}`) + table.triggers.push({ name: match[2], timing: match[3], event: match[4] }) + } + } + } + return [...tables.values()].sort((a, b) => a.key.localeCompare(b.key)) +} + +/** 将已逐表登记的建议与当前基线比对;未知表始终保持待审,不自动加入清理。 */ +export function buildPlan(sources, policy, inventory = null) { + if (policy.version !== 1 || policy.environment !== 'TEST' || !Array.isArray(policy.groups)) { + throw new Error('清理策略必须为版本 1 的 TEST 策略') + } + const registered = new Map() + for (const group of policy.groups) { + if (!actions.has(group.action) || !group.reason || !Array.isArray(group.tables)) throw new Error('策略分组格式非法') + for (const key of group.tables) { + if (registered.has(key)) throw new Error(`策略重复登记:${key}`) + registered.set(key, group) + } + } + const blockers = [] + const sourcesStatus = sources.map(source => { + const sha256 = fingerprint(source.sql) + if (policy.sourceSha256?.[source.path] !== sha256) blockers.push({ code: 'BASELINE_CHANGED', source: source.path }) + return { path: source.path, sha256 } + }) + const tables = readCatalog(sources).map(table => { + const group = registered.get(table.key) + if (!group) blockers.push({ code: 'UNCLASSIFIED_TABLE', table: table.key }) + return { ...table, action: group?.action ?? 'REVIEW', reason: group?.reason ?? '新表未分类,禁止自动清理', + estimatedRows: null } + }) + const byKey = new Map(tables.map(table => [table.key, table])) + for (const key of registered.keys()) { + if (!byKey.has(key)) blockers.push({ code: 'STALE_POLICY_TABLE', table: key }) + } + const schemas = [...new Set(tables.map(table => table.schema))].sort() + const inventoryProblems = [] + if (inventory === null) blockers.push({ code: 'LIVE_INVENTORY_REQUIRED' }) + else { + if (!Array.isArray(inventory)) throw new Error('结构清单应为 inspection.sql 第一条查询导出的 JSON 数组') + const seen = new Set() + for (const row of inventory) { + const key = `${identifier(row.schema)}.${identifier(row.table)}` + if (seen.has(key)) throw new Error(`实际结构清单重复表:${key}`) + seen.add(key) + if (!schemas.includes(row.schema)) throw new Error('结构清单包含目标范围外的 schema') + if (!byKey.has(key)) inventoryProblems.push({ code: 'UNKNOWN_LIVE_TABLE', table: key }) + else if (row.type !== 'BASE TABLE') inventoryProblems.push({ code: 'UNEXPECTED_TABLE_TYPE', table: key }) + else { + // INFORMATION_SCHEMA.TABLE_ROWS 只是估算;不得将其作为删除数量或精确验收结果。 + byKey.get(key).estimatedRows = row.estimatedRows ?? null + } + } + for (const table of tables) if (!seen.has(table.key)) inventoryProblems.push({ code: 'MISSING_LIVE_TABLE', table: table.key }) + blockers.push(...inventoryProblems) + } + const retainedReferences = tables.flatMap(table => table.action === 'CLEAR_CANDIDATE' ? [] : + table.references.filter(key => byKey.get(key)?.action === 'CLEAR_CANDIDATE') + .map(parent => ({ child: table.key, parent }))) + for (const relation of retainedReferences) blockers.push({ code: 'RETAINED_CHILD_REFERENCES_CLEAR_PARENT', ...relation }) + const summary = { total: tables.length, KEEP: 0, CLEAR_CANDIDATE: 0, RESET_REVIEW: 0, REVIEW: 0 } + for (const table of tables) summary[table.action]++ + return { + version: 1, environment: 'TEST', status: 'REVIEW_REQUIRED', executionSupported: false, + // 结构清单的表名吻合不证明字段、外键、触发器、运行版本或目标环境已经核对通过。 + confirmedScope: policy.confirmedScope, + summary, sources: sourcesStatus, schemas, blockers, tables, + canGenerateCountQueries: inventory !== null && inventoryProblems.length === 0, + pendingReview: policy.pendingReview, + limitations: [ + '仅生成候选清单和只读查询;不连接数据库,不生成或执行删除、重置、停机及文件清理命令。', + '基线不是实际部署证明。必须核对实际字段、外键、索引、触发器、当前服务实现及混合表逐行处理条件。', + '保留基础资料、自营数据、独立审计和文件元数据;业务过程历史的保留边界与外键需审阅。', + '执行前须确定维护窗口、停止全部相关写入者及后台重试、备份恢复方案,并对精确目标和操作取得授权。', + 'Redis、消息重投、搜索索引和外部采集不在此 SQL 清单内,不可由数据库清理成功推定已处理。', + 'TABLE_ROWS 为估算;counts.sql 仅在导入完整且表名吻合的清单后提供,结果仍需在静默窗口重新核对。', + ], + } +} + +/** 生成只读结构盘点 SQL;不输出业务行、密码、文件路径或连接地址。 */ +export function inspectionSql(schemas) { + const scope = schemas.map(schema => `'${identifier(schema)}'`).join(', ') + return `-- 仅在操作者明确选择的 TEST 连接执行;本文件只有只读查询。\n` + + `-- 将第一条查询的 inventory_json 字段原样保存为 JSON 文件,可交给 --inventory。\n` + + `-- 只读账号需要能看见全部目标表;缺少权限可能表现为表缺失,必须人工核实。\n` + + `SELECT JSON_ARRAYAGG(JSON_OBJECT('schema', TABLE_SCHEMA, 'table', TABLE_NAME, 'type', TABLE_TYPE, 'estimatedRows', TABLE_ROWS)) AS inventory_json\nFROM information_schema.TABLES WHERE TABLE_SCHEMA IN (${scope});\n\n` + + `SELECT TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION, COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_DEFAULT, EXTRA\nFROM information_schema.COLUMNS WHERE TABLE_SCHEMA IN (${scope}) ORDER BY TABLE_SCHEMA, TABLE_NAME, ORDINAL_POSITION;\n\n` + + `SELECT TABLE_SCHEMA, TABLE_NAME, CONSTRAINT_NAME, COLUMN_NAME, REFERENCED_TABLE_SCHEMA, REFERENCED_TABLE_NAME, REFERENCED_COLUMN_NAME\nFROM information_schema.KEY_COLUMN_USAGE WHERE REFERENCED_TABLE_NAME IS NOT NULL AND (TABLE_SCHEMA IN (${scope}) OR REFERENCED_TABLE_SCHEMA IN (${scope})) ORDER BY TABLE_SCHEMA, TABLE_NAME, CONSTRAINT_NAME, ORDINAL_POSITION;\n\n` + + `SELECT TRIGGER_SCHEMA, TRIGGER_NAME, EVENT_OBJECT_TABLE, EVENT_MANIPULATION, ACTION_TIMING, ACTION_STATEMENT\nFROM information_schema.TRIGGERS WHERE EVENT_OBJECT_SCHEMA IN (${scope}) ORDER BY TRIGGER_SCHEMA, EVENT_OBJECT_TABLE, TRIGGER_NAME;\n\n` + + `SELECT TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, NON_UNIQUE, SEQ_IN_INDEX, COLUMN_NAME\nFROM information_schema.STATISTICS WHERE TABLE_SCHEMA IN (${scope}) ORDER BY TABLE_SCHEMA, TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX;\n` +} + +/** 只对已盘点且表名完整吻合的范围生成精确计数查询;仍不执行。 */ +export function countSql(plan) { + if (!plan.canGenerateCountQueries) throw new Error('须先导入完整且表名吻合的实际结构清单') + return '-- 只读精确计数;大表可能耗时。须在维护窗口核对,禁止遇错继续。\n' + + plan.tables.map(table => `SELECT '${table.key}' AS table_key, COUNT(*) AS exact_rows FROM \`${identifier(table.schema)}\`.\`${identifier(table.name)}\`;`).join('\n') + '\n' +} + +/** 只解析离线参数,不提供 execute 模式或数据库凭据参数。 */ +function parseArgs(args) { + const options = {} + for (let index = 0; index < args.length; index++) { + const name = args[index] + if (name === '--help') { options.help = true; continue } + if (!['--out-dir', '--inventory'].includes(name) || options[name] || !args[index + 1] || args[index + 1].startsWith('--')) { + throw new Error(`未知、重复或缺少值的参数:${name}`) + } + options[name] = args[++index] + } + return options +} + +/** 输出到新的报告目录,独占创建,防止覆盖既有证据或源码。 */ +export function writeReport(outputDirectory, plan) { + const reportsRoot = path.join(repositoryRoot, 'reports') + const target = path.resolve(repositoryRoot, outputDirectory) + if (path.dirname(target) !== reportsRoot || !/^business-reset-[a-zA-Z0-9_-]+$/.test(path.basename(target))) { + throw new Error('报告目录必须为 reports/business-reset-<本次唯一标识>,且直接位于 reports 下') + } + mkdirSync(reportsRoot, { recursive: true }) + mkdirSync(target) + writeFileSync(path.join(target, 'plan.json'), `${JSON.stringify(plan, null, 2)}\n`, { flag: 'wx' }) + writeFileSync(path.join(target, 'inspection.sql'), inspectionSql(plan.schemas), { flag: 'wx' }) + if (plan.canGenerateCountQueries) writeFileSync(path.join(target, 'counts.sql'), countSql(plan), { flag: 'wx' }) + return target +} + +/** 命令行入口仅做离线准备;退出 0 只表示报告生成成功,绝不表示允许清库。 */ +function main() { + const options = parseArgs(process.argv.slice(2)) + if (options.help) { + console.log('离线 TEST 清理预览:node scripts/test-env-business-reset-plan.mjs [--out-dir reports/business-reset-<唯一标识>] [--inventory <结构清单.json>]\n无执行模式;不联网;不需要凭据。') + return + } + const sources = baselinePaths.map(name => ({ path: name, sql: readFileSync(path.join(repositoryRoot, name), 'utf8') })) + const policy = JSON.parse(readFileSync(policyPath, 'utf8')) + const inventory = options['--inventory'] ? JSON.parse(readFileSync(options['--inventory'], 'utf8')) : null + const plan = buildPlan(sources, policy, inventory) + const output = options['--out-dir'] ? writeReport(options['--out-dir'], plan) : null + console.log(JSON.stringify({ status: plan.status, executionSupported: false, summary: plan.summary, blockers: plan.blockers, output }, null, 2)) +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + try { main() } catch (error) { console.error(error.message); process.exitCode = 1 } +} diff --git a/scripts/test-env-business-reset-plan.test.mjs b/scripts/test-env-business-reset-plan.test.mjs new file mode 100644 index 000000000..e94f7cac3 --- /dev/null +++ b/scripts/test-env-business-reset-plan.test.mjs @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import { spawnSync } from 'node:child_process' +import { fileURLToPath } from 'node:url' +import test from 'node:test' +import { buildPlan, countSql, fingerprint, inspectionSql, readCatalog, writeReport } from './test-env-business-reset-plan.mjs' + +const repositoryRoot = fileURLToPath(new URL('..', import.meta.url)) +const sourcePaths = ['deploy/sql/头程数据库统一增量-MySQL8.sql', 'deploy/sql/海外区域统一增量-MySQL8.sql'] +const sources = sourcePaths.map(name => ({ path: name, sql: readFileSync(new URL(`../${name}`, import.meta.url), 'utf8') })) +const policy = JSON.parse(readFileSync(new URL('./test-env-business-reset-policy.json', import.meta.url), 'utf8')) + +/** 构造少量离线表,覆盖新增表和保留表外键的失败关闭行为。 */ +function fixture() { + const sql = `USE baiyue_order; +CREATE TABLE ord_order ( + id bigint NOT NULL, + PRIMARY KEY (id) +) ENGINE=InnoDB COMMENT='订单'; +CREATE TABLE retained_audit ( + order_id bigint, + FOREIGN KEY (order_id) REFERENCES ord_order (id) +) ENGINE=InnoDB COMMENT='保留审计'; +CREATE TRIGGER protect_order BEFORE DELETE ON ord_order FOR EACH ROW BEGIN END; +` + const source = { path: 'fixture.sql', sql } + return { sources: [source], policy: { version: 1, environment: 'TEST', sourceSha256: { 'fixture.sql': fingerprint(sql) }, groups: [ + { action: 'CLEAR_CANDIDATE', reason: '业务单据', tables: ['baiyue_order.ord_order'] }, + { action: 'KEEP', reason: '独立审计', tables: ['baiyue_order.retained_audit'] }, + ] } } +} + +/** 结构清单只模拟表名及估算数量,不冒充真实环境验证。 */ +function inventoryFor(plan) { + return plan.tables.map(table => ({ schema: table.schema, table: table.name, type: 'BASE TABLE', estimatedRows: 7 })) +} + +test('当前每张基线表显式登记,离线结果始终不可执行', () => { + const plan = buildPlan(sources, policy) + assert.equal(plan.summary.total, readCatalog(sources).length) + assert.equal(plan.summary.total, policy.groups.flatMap(group => group.tables).length) + assert.equal(plan.executionSupported, false) + assert.equal(plan.status, 'REVIEW_REQUIRED') + assert.equal(plan.canGenerateCountQueries, false) + assert.ok(plan.blockers.some(item => item.code === 'LIVE_INVENTORY_REQUIRED')) + assert.ok(!plan.blockers.some(item => ['BASELINE_CHANGED', 'UNCLASSIFIED_TABLE', 'STALE_POLICY_TABLE'].includes(item.code))) + assert.ok(plan.tables.some(table => table.triggers.some(trigger => trigger.event === 'DELETE'))) +}) + +test('身份、文件、报价、自营和序列保留,资金与资源混合表进入字段审阅', () => { + const tables = new Map(buildPlan(sources, policy).tables.map(table => [table.key, table])) + for (const key of ['baiyue_auth.auth_account', 'baiyue_iam.iam_tenant', 'baiyue_file.file_object', + 'baiyue_file.file_pda_release', 'baiyue_audit.sys_audit_log', 'baiyue_firstleg.fl_price_book_version', + 'baiyue_lastleg.ll_selfop_inventory_snapshot', 'baiyue_lastleg.ll_document_sequence', 'baiyue_order.ord_customer_address']) { + assert.equal(tables.get(key).action, 'KEEP', key) + } + for (const key of ['baiyue_finance.fin_wallet_account', 'baiyue_warehouse.wh_location', + 'baiyue_lastlag_warehouse.wh_pallet', 'baiyue_lastlag_warehouse.wh_location', 'baiyue_lastleg.ll_vehicle', + 'baiyue_lastleg.ll_appointment_work_guard']) assert.equal(tables.get(key).action, 'RESET_REVIEW', key) + assert.match(tables.get('baiyue_finance.fin_wallet_account').reason, /可用余额.*有效授信.*不能固定为零/) + assert.equal(tables.get('baiyue_finance.fin_recharge_request').action, 'CLEAR_CANDIDATE') + assert.equal(tables.get('baiyue_finance.fin_wallet_ledger').action, 'CLEAR_CANDIDATE') +}) + +test('独立审计保留而订单自身历史随单清理,人工计划与资源档案保留', () => { + const plan = buildPlan(sources, policy) + const tables = new Map(plan.tables.map(table => [table.key, table])) + for (const key of ['baiyue_order.ord_order_status_history', 'baiyue_firstleg.fl_loading_pick_history', + 'baiyue_lastleg.ll_dispatch_history', 'baiyue_lastleg.ll_fleet_trip_history']) { + assert.equal(tables.get(key).action, 'CLEAR_CANDIDATE', key) + } + for (const key of ['baiyue_finance.fin_audit_log', 'baiyue_firstleg.fl_sailing', + 'baiyue_firstleg.fl_sailing_history', 'baiyue_lastleg.ll_fleet_vehicle_record', + 'baiyue_lastleg.ll_fleet_vehicle_record_history', 'baiyue_iam.iam_notification_task']) { + assert.equal(tables.get(key).action, 'KEEP', key) + } + assert.equal(tables.get('baiyue_file.file_export_task').action, 'REVIEW') + assert.equal(tables.get('baiyue_file.file_export_outbox').action, 'REVIEW') + assert.ok(!plan.blockers.some(item => item.code === 'RETAINED_CHILD_REFERENCES_CLEAR_PARENT')) +}) + +test('基线新增表不会继承相似前缀的删除建议', () => { + const data = fixture() + data.sources[0].sql += `CREATE TABLE ord_order_new ( + id bigint +) ENGINE=InnoDB; +` + const plan = buildPlan(data.sources, data.policy) + assert.equal(plan.tables.find(table => table.name === 'ord_order_new').action, 'REVIEW') + assert.ok(plan.blockers.some(item => item.code === 'UNCLASSIFIED_TABLE')) + assert.ok(plan.blockers.some(item => item.code === 'BASELINE_CHANGED')) +}) + +test('保留表引用待清主表时明确报告,且提取删除保护触发器', () => { + const data = fixture() + const plan = buildPlan(data.sources, data.policy) + assert.ok(plan.blockers.some(item => item.code === 'RETAINED_CHILD_REFERENCES_CLEAR_PARENT' + && item.child === 'baiyue_order.retained_audit' && item.parent === 'baiyue_order.ord_order')) + assert.deepEqual(plan.tables.find(table => table.name === 'ord_order').triggers, + [{ name: 'protect_order', timing: 'BEFORE', event: 'DELETE' }]) +}) + +test('策略重复项、错误环境和未完整解析的 CREATE TABLE 拒绝处理', () => { + const data = fixture() + const duplicate = structuredClone(data.policy) + duplicate.groups[0].tables.push('baiyue_order.retained_audit') + assert.throws(() => buildPlan(data.sources, duplicate), /重复登记/) + assert.throws(() => buildPlan(data.sources, { ...data.policy, environment: 'PROD' }), /TEST/) + assert.throws(() => readCatalog([{ path: 'unsupported.sql', sql: 'CREATE TABLE `quoted` (id INT);' }]), /未能完整解析/) +}) + +test('实际清单缺表、多表或视图均阻断计数 SQL 的生成', () => { + const data = fixture() + const initial = buildPlan(data.sources, data.policy) + for (const rows of [ + [], + [...inventoryFor(initial), { schema: 'baiyue_order', table: 'unclassified', type: 'BASE TABLE' }], + inventoryFor(initial).map(row => ({ ...row, type: 'VIEW' })), + ]) { + const plan = buildPlan(data.sources, data.policy, rows) + assert.equal(plan.canGenerateCountQueries, false) + assert.throws(() => countSql(plan), /完整且表名吻合/) + } +}) + +test('完整实际表清单仅解锁 SELECT COUNT,估算行数不转为已验证精确数量', () => { + const data = fixture() + const initial = buildPlan(data.sources, data.policy) + const plan = buildPlan(data.sources, data.policy, inventoryFor(initial)) + assert.equal(plan.executionSupported, false) + assert.equal(plan.status, 'REVIEW_REQUIRED') + assert.equal(plan.tables[0].estimatedRows, 7) + assert.equal(plan.tables[0].exactRows, undefined) + const sql = countSql(plan).split('\n').filter(line => line && !line.startsWith('--')) + assert.equal(sql.length, plan.tables.length) + assert.ok(sql.every(line => /^SELECT '[a-z_.]+' AS table_key, COUNT\(\*\) AS exact_rows FROM `[a-z_]+`\.`[a-z_]+`;$/.test(line))) +}) + +test('外部清单拒绝重复、范围外和 SQL 注入标识符', () => { + const data = fixture() + const rows = inventoryFor(buildPlan(data.sources, data.policy)) + assert.throws(() => buildPlan(data.sources, data.policy, [...rows, rows[0]]), /重复表/) + assert.throws(() => buildPlan(data.sources, data.policy, [{ ...rows[0], schema: 'other_database' }]), /范围外/) + assert.throws(() => buildPlan(data.sources, data.policy, [{ ...rows[0], table: 'ord_order; DROP TABLE x' }]), /标识符非法/) + assert.throws(() => inspectionSql(["x'); DROP DATABASE x; --"]), /标识符非法/) +}) + +test('盘点 SQL 只有元数据 SELECT 并包含跨范围外键引用,输出路径不能覆盖源码', () => { + const plan = buildPlan(sources, policy) + const sql = inspectionSql(plan.schemas).split('\n').filter(line => !line.startsWith('--')).join('\n') + const statements = sql.split(';').map(value => value.trim()).filter(Boolean) + assert.equal(statements.length, 5) + assert.ok(statements.every(statement => statement.startsWith('SELECT '))) + assert.match(sql, /OR REFERENCED_TABLE_SCHEMA IN/) + assert.throws(() => writeReport('scripts', plan), /报告目录必须/) + assert.throws(() => writeReport('reports/../scripts/business-reset-test', plan), /报告目录必须/) +}) + +test('真实 CLI 默认仅打印离线摘要,拒绝 execute 参数', () => { + const script = 'scripts/test-env-business-reset-plan.mjs' + const result = spawnSync(process.execPath, [script], { cwd: repositoryRoot, encoding: 'utf8' }) + assert.equal(result.status, 0, result.stderr) + assert.equal(JSON.parse(result.stdout).executionSupported, false) + assert.equal(JSON.parse(result.stdout).output, null) + const execute = spawnSync(process.execPath, [script, '--execute'], { cwd: repositoryRoot, encoding: 'utf8' }) + assert.equal(execute.status, 1) + assert.match(execute.stderr, /未知、重复或缺少值/) +}) diff --git a/scripts/test-env-business-reset-policy.json b/scripts/test-env-business-reset-policy.json new file mode 100644 index 000000000..01f22ea86 --- /dev/null +++ b/scripts/test-env-business-reset-policy.json @@ -0,0 +1,605 @@ +{ + "version": 1, + "environment": "TEST", + "confirmedScope": { + "customers": "ALL", + "business": "头程、海外仓、尾程、车队全部业务单据及关联库存、财务", + "wallet": "测试充值、资金流水、实际余额清零;保留授信额度和有效期,释放冻结及授信占用", + "selfOperated": "KEEP", + "audit": "KEEP_INDEPENDENT_AUDIT_CLEAR_ORDER_HISTORY", + "physicalFiles": "KEEP", + "numberSequences": "KEEP" + }, + "sourceSha256": { + "deploy/sql/头程数据库统一增量-MySQL8.sql": "38013327896214ccc4fe317f507bfcf0b11377b07a66cd4ffa41b109b73558fd", + "deploy/sql/海外区域统一增量-MySQL8.sql": "db307236f5d0275c214db49888fe0d16d8a954802eb50ad5de52ed33ccfab4a7" + }, + "pendingReview": [ + "精确 TEST 目标与实际结构、触发器定义、保留表外键及行数须在停写窗口重新核对;当前表清单不是维护窗口快照。", + "实际删除保护触发器对应表的具体执行、恢复校验与失败恢复;不得将预览成功等同清库放行。", + "资金账户服务时区和有效授信重算;国内库位、海外真实/虚拟库位、托盘、车辆及固定协调锁的字段重置条件。", + "导出任务按业务类型白名单过滤;关联 Outbox 同步处理。保留消息及车辆运维的重新提交路径核对。", + "基线外的表逐项核实;安全、序列、自营诊断保留,已空的历史业务表不扩大为删表操作。", + "维护窗口、相关服务/任务写入者、消息重投及缓存范围;备份路径、完整性和恢复能力。", + "实际删除/重置前精确目标与操作清单的授权;本策略仅为候选处理规则,无数据库执行能力。" + ], + "groups": [ + { + "action": "KEEP", + "reason": "保留独立审计及其可靠投递证据,不将其混入业务事件清空。", + "tables": [ + "baiyue_audit.sys_audit_dead_letter", + "baiyue_audit.sys_audit_log", + "baiyue_auth.sys_audit_outbox", + "baiyue_finance.event_delivery_attempt", + "baiyue_finance.event_delivery_retry_audit", + "baiyue_finance.fin_audit_log", + "baiyue_firstleg.event_delivery_attempt", + "baiyue_firstleg.event_delivery_retry_audit", + "baiyue_firstleg.sys_audit_outbox", + "baiyue_iam.sys_audit_outbox", + "baiyue_lastleg.event_delivery_attempt", + "baiyue_lastleg.event_delivery_retry_audit", + "baiyue_order.sys_audit_outbox", + "baiyue_warehouse.sys_audit_outbox" + ] + }, + { + "action": "KEEP", + "reason": "保留身份、客户、权限、基础资料和配置初始化事实;账号会话不在本次清理范围。", + "tables": [ + "baiyue_auth.auth_account", + "baiyue_auth.auth_refresh_token", + "baiyue_iam.iam_customer_dimension_initialization_task", + "baiyue_iam.iam_data_scope", + "baiyue_iam.iam_membership", + "baiyue_iam.iam_membership_role", + "baiyue_iam.iam_notification_template", + "baiyue_iam.iam_number_sequence", + "baiyue_iam.iam_permission", + "baiyue_iam.iam_role", + "baiyue_iam.iam_role_permission", + "baiyue_iam.iam_security_subject_version", + "baiyue_iam.iam_security_tenant_version", + "baiyue_iam.iam_tenant", + "baiyue_iam.iam_tenant_address", + "baiyue_iam.iam_tenant_qualification", + "baiyue_iam.iam_tenant_review", + "baiyue_iam.iam_user" + ] + }, + { + "action": "KEEP", + "reason": "保留物理文件对应元数据、下载记录、PDA发布及固定保护行,不整库清理文件。", + "tables": [ + "baiyue_file.file_download_log", + "baiyue_file.file_download_token", + "baiyue_file.file_export_task_guard", + "baiyue_file.file_object", + "baiyue_file.file_pda_release", + "baiyue_file.file_pda_release_channel" + ] + }, + { + "action": "REVIEW", + "reason": "按明确 export_type 白名单清除订单、库存和财务导出任务及其 Outbox;客户资料、报价、汇率、车辆及自营导出保留。未知导出类型不自动删除,物理文件与 file_object 不动。", + "tables": [ + "baiyue_file.file_export_outbox", + "baiyue_file.file_export_task" + ] + }, + { + "action": "CLEAR_CANDIDATE", + "reason": "全部客户的业务单据、库存、费用资金、关联明细及业务消息/幂等/占用的清空候选;只表示业务范围,须审阅外键、触发器、实际结构和运行写入者后形成执行方案。", + "tables": [ + "baiyue_finance.fin_bill", + "baiyue_finance.fin_bill_line", + "baiyue_finance.fin_bill_revision", + "baiyue_finance.fin_cost", + "baiyue_finance.fin_cost_allocation", + "baiyue_finance.fin_fee_source", + "baiyue_finance.fin_idempotency_record", + "baiyue_finance.fin_inbox_event", + "baiyue_finance.fin_receipt", + "baiyue_finance.fin_receipt_bill", + "baiyue_finance.fin_recharge_request", + "baiyue_finance.fin_settlement", + "baiyue_finance.fin_settlement_line", + "baiyue_finance.fin_settlement_payment", + "baiyue_finance.fin_vehicle_cost", + "baiyue_finance.fin_vehicle_cost_payment", + "baiyue_finance.fin_vehicle_cost_revision", + "baiyue_finance.fin_wallet_command_inbox", + "baiyue_finance.fin_wallet_event_outbox", + "baiyue_finance.fin_wallet_hold", + "baiyue_finance.fin_wallet_ledger", + "baiyue_firstleg.fl_carrier_schedule_exception_fact", + "baiyue_firstleg.fl_carrier_schedule_node_fact", + "baiyue_firstleg.fl_carrier_schedule_query", + "baiyue_firstleg.fl_carrier_schedule_query_attempt", + "baiyue_firstleg.fl_carrier_schedule_query_operation", + "baiyue_firstleg.fl_coload_command", + "baiyue_firstleg.fl_coload_group", + "baiyue_firstleg.fl_coload_group_member", + "baiyue_firstleg.fl_consumed_event", + "baiyue_firstleg.fl_container_booking", + "baiyue_firstleg.fl_container_document", + "baiyue_firstleg.fl_container_event_inbox", + "baiyue_firstleg.fl_container_fee_detail", + "baiyue_firstleg.fl_container_fee_source_snapshot", + "baiyue_firstleg.fl_customs_broker_submission", + "baiyue_firstleg.fl_customs_clearance_result", + "baiyue_firstleg.fl_customs_command", + "baiyue_firstleg.fl_customs_completion_document", + "baiyue_firstleg.fl_customs_completion_document_version", + "baiyue_firstleg.fl_customs_container", + "baiyue_firstleg.fl_customs_customer_document", + "baiyue_firstleg.fl_customs_customer_document_inbound", + "baiyue_firstleg.fl_customs_customer_document_version", + "baiyue_firstleg.fl_customs_declaration_batch", + "baiyue_firstleg.fl_customs_final_document", + "baiyue_firstleg.fl_customs_frozen_document_inbound", + "baiyue_firstleg.fl_direct_delivery_unloading", + "baiyue_firstleg.fl_dropship_pallet", + "baiyue_firstleg.fl_dropship_pallet_carton", + "baiyue_firstleg.fl_dropship_pallet_carton_sku", + "baiyue_firstleg.fl_dropship_pallet_task", + "baiyue_firstleg.fl_finance_fee_source_command", + "baiyue_firstleg.fl_fulfillment_task", + "baiyue_firstleg.fl_inbound_carton_label", + "baiyue_firstleg.fl_inbound_carton_label_sku", + "baiyue_firstleg.fl_inbound_destination_snapshot", + "baiyue_firstleg.fl_inbound_fee", + "baiyue_firstleg.fl_inbound_fee_detail", + "baiyue_firstleg.fl_inbound_fee_item", + "baiyue_firstleg.fl_inbound_file", + "baiyue_firstleg.fl_inbound_hold_record", + "baiyue_firstleg.fl_inbound_idempotency", + "baiyue_firstleg.fl_inbound_internal_note", + "baiyue_firstleg.fl_inbound_loading_pick_task", + "baiyue_firstleg.fl_inbound_measurement", + "baiyue_firstleg.fl_inbound_measurement_item", + "baiyue_firstleg.fl_inbound_order", + "baiyue_firstleg.fl_inbound_outbox", + "baiyue_firstleg.fl_inbound_pallet_allocation", + "baiyue_firstleg.fl_inbound_pickup_snapshot", + "baiyue_firstleg.fl_inbound_split_batch", + "baiyue_firstleg.fl_inbound_split_detail", + "baiyue_firstleg.fl_inbound_split_item", + "baiyue_firstleg.fl_inbound_transport_snapshot", + "baiyue_firstleg.fl_inbound_weight_result", + "baiyue_firstleg.fl_inbound_weight_revision", + "baiyue_firstleg.fl_loading_cabinet_evidence", + "baiyue_firstleg.fl_loading_completion_checkpoint", + "baiyue_firstleg.fl_loading_completion_command", + "baiyue_firstleg.fl_loading_operation", + "baiyue_firstleg.fl_loading_pallet_label", + "baiyue_firstleg.fl_loading_pick_assignment", + "baiyue_firstleg.fl_loading_pick_item", + "baiyue_firstleg.fl_loading_pick_item_assignment", + "baiyue_firstleg.fl_loading_pick_operation", + "baiyue_firstleg.fl_loading_pick_review", + "baiyue_firstleg.fl_loading_pick_task", + "baiyue_firstleg.fl_loading_plan", + "baiyue_firstleg.fl_loading_plan_item", + "baiyue_firstleg.fl_measure_review_record", + "baiyue_firstleg.fl_mojia_measurement_receipt", + "baiyue_firstleg.fl_ocean_booking", + "baiyue_firstleg.fl_ocean_booking_container_allocation", + "baiyue_firstleg.fl_ocean_booking_container_requirement", + "baiyue_firstleg.fl_ocean_booking_document", + "baiyue_firstleg.fl_ocean_booking_operation", + "baiyue_firstleg.fl_ocean_booking_party", + "baiyue_firstleg.fl_order_declaration_event", + "baiyue_firstleg.fl_order_declaration_snapshot", + "baiyue_firstleg.fl_outbound_dispatch", + "baiyue_firstleg.fl_outbound_dispatch_operation", + "baiyue_firstleg.fl_port_schedule", + "baiyue_firstleg.fl_port_schedule_operation", + "baiyue_firstleg.fl_preload_loading_plan_link", + "baiyue_firstleg.fl_preload_plan", + "baiyue_firstleg.fl_sailing_assignment", + "baiyue_firstleg.fl_sailing_booking_link", + "baiyue_firstleg.fl_sailing_inbound_volume_forecast", + "baiyue_firstleg.fl_sailing_receipt_batch_fact", + "baiyue_firstleg.fl_split_operation", + "baiyue_firstleg.fl_terminal_drayage", + "baiyue_firstleg.fl_terminal_drayage_mail", + "baiyue_firstleg.fl_terminal_drayage_operation", + "baiyue_firstleg.fl_truck_appointment", + "baiyue_firstleg.fl_truck_appointment_attachment", + "baiyue_firstleg.fl_truck_appointment_operation", + "baiyue_lastlag_warehouse.wh_pallet_batch", + "baiyue_lastlag_warehouse.wh_pallet_batch_item", + "baiyue_lastlag_warehouse.wh_pallet_business_binding", + "baiyue_lastlag_warehouse.wh_pallet_operation", + "baiyue_lastlag_warehouse.wh_resource_lock", + "baiyue_lastleg.ll_appointment_cargo_verification", + "baiyue_lastleg.ll_appointment_followup", + "baiyue_lastleg.ll_appointment_impact", + "baiyue_lastleg.ll_appointment_work", + "baiyue_lastleg.ll_appointment_work_commitment", + "baiyue_lastleg.ll_cargo_discard_application", + "baiyue_lastleg.ll_cargo_discard_carton", + "baiyue_lastleg.ll_cargo_discard_item", + "baiyue_lastleg.ll_cargo_discard_photo", + "baiyue_lastleg.ll_container_arrival_time_operation", + "baiyue_lastleg.ll_container_loading_evidence", + "baiyue_lastleg.ll_container_plan_conversion_command", + "baiyue_lastleg.ll_container_shipping_gate", + "baiyue_lastleg.ll_customer_visit", + "baiyue_lastleg.ll_delivery_document_revision", + "baiyue_lastleg.ll_delivery_facts_changed", + "baiyue_lastleg.ll_delivery_operation", + "baiyue_lastleg.ll_delivery_order", + "baiyue_lastleg.ll_destuff_pallet_cost", + "baiyue_lastleg.ll_destuff_pallet_cost_allocation", + "baiyue_lastleg.ll_dispatch_appointment_allocation", + "baiyue_lastleg.ll_dispatch_command", + "baiyue_lastleg.ll_dispatch_delivery_arrangement", + "baiyue_lastleg.ll_dispatch_draft", + "baiyue_lastleg.ll_dispatch_draft_command", + "baiyue_lastleg.ll_dispatch_draft_container", + "baiyue_lastleg.ll_dispatch_item", + "baiyue_lastleg.ll_dispatch_item_source", + "baiyue_lastleg.ll_dispatch_order", + "baiyue_lastleg.ll_dispatch_pallet_lock", + "baiyue_lastleg.ll_dispatch_pda_operation", + "baiyue_lastleg.ll_dispatch_pda_pick", + "baiyue_lastleg.ll_dispatch_pda_pick_order", + "baiyue_lastleg.ll_dispatch_pda_task", + "baiyue_lastleg.ll_dispatch_physical_pallet_lock", + "baiyue_lastleg.ll_dispatch_transport_command", + "baiyue_lastleg.ll_dispatch_transport_create", + "baiyue_lastleg.ll_dispatch_truck_task", + "baiyue_lastleg.ll_dropship_carton", + "baiyue_lastleg.ll_dropship_carton_ledger", + "baiyue_lastleg.ll_dropship_carton_sku", + "baiyue_lastleg.ll_dropship_order_profile", + "baiyue_lastleg.ll_dropship_sku_inventory", + "baiyue_lastleg.ll_event_conflict", + "baiyue_lastleg.ll_event_inbox", + "baiyue_lastleg.ll_event_outbox", + "baiyue_lastleg.ll_exception_order", + "baiyue_lastleg.ll_exception_order_command", + "baiyue_lastleg.ll_exception_return_item", + "baiyue_lastleg.ll_fba_appointment_command", + "baiyue_lastleg.ll_fba_receiving_agreement", + "baiyue_lastleg.ll_fba_receiving_change", + "baiyue_lastleg.ll_fba_receiving_member", + "baiyue_lastleg.ll_fba_receiving_plan", + "baiyue_lastleg.ll_fba_receiving_revision", + "baiyue_lastleg.ll_finance_fee_command", + "baiyue_lastleg.ll_finance_fee_source", + "baiyue_lastleg.ll_fleet_capacity_offer", + "baiyue_lastleg.ll_fleet_capacity_offer_command", + "baiyue_lastleg.ll_fleet_external_booking", + "baiyue_lastleg.ll_fleet_finance_source", + "baiyue_lastleg.ll_fleet_owned_business_command", + "baiyue_lastleg.ll_fleet_owned_business_ownership", + "baiyue_lastleg.ll_fleet_owned_business_record", + "baiyue_lastleg.ll_fleet_pickup_command", + "baiyue_lastleg.ll_fleet_pickup_plan", + "baiyue_lastleg.ll_fleet_pickup_task", + "baiyue_lastleg.ll_fleet_pickup_upload", + "baiyue_lastleg.ll_fleet_pool_order_link", + "baiyue_lastleg.ll_fleet_resource_assignment", + "baiyue_lastleg.ll_fleet_trip", + "baiyue_lastleg.ll_fleet_trip_cost", + "baiyue_lastleg.ll_fleet_trip_dispatch", + "baiyue_lastleg.ll_fleet_trip_handover", + "baiyue_lastleg.ll_fleet_trip_operations_record", + "baiyue_lastleg.ll_inbound_fba_appointment", + "baiyue_lastleg.ll_inbound_fba_appointment_item", + "baiyue_lastleg.ll_inbound_order", + "baiyue_lastleg.ll_inbound_order_item", + "baiyue_lastleg.ll_inbound_pallet_merge", + "baiyue_lastleg.ll_inbound_pda_operation", + "baiyue_lastleg.ll_inbound_pda_task", + "baiyue_lastleg.ll_inbound_plan", + "baiyue_lastleg.ll_inbound_plan_carton", + "baiyue_lastleg.ll_inbound_plan_line", + "baiyue_lastleg.ll_inbound_plan_pallet", + "baiyue_lastleg.ll_inbound_plan_pallet_allocation", + "baiyue_lastleg.ll_inbound_stock_receipt", + "baiyue_lastleg.ll_inventory_balance", + "baiyue_lastleg.ll_inventory_ledger", + "baiyue_lastleg.ll_inventory_operation", + "baiyue_lastleg.ll_inventory_position", + "baiyue_lastleg.ll_loading_event_inbox", + "baiyue_lastleg.ll_local_looseboard_operation", + "baiyue_lastleg.ll_local_looseboard_order", + "baiyue_lastleg.ll_order", + "baiyue_lastleg.ll_order_command_record", + "baiyue_lastleg.ll_order_detail", + "baiyue_lastleg.ll_order_fee", + "baiyue_lastleg.ll_order_manual_node", + "baiyue_lastleg.ll_order_source_pallet", + "baiyue_lastleg.ll_outbound_order", + "baiyue_lastleg.ll_outbound_pallet_item", + "baiyue_lastleg.ll_outbound_pda_pick", + "baiyue_lastleg.ll_outbound_pda_task", + "baiyue_lastleg.ll_overseas_procurement_fee", + "baiyue_lastleg.ll_overseas_procurement_file", + "baiyue_lastleg.ll_overseas_procurement_order", + "baiyue_lastleg.ll_pda_inventory_move", + "baiyue_lastleg.ll_pda_pallet_operation", + "baiyue_lastleg.ll_pda_stocktake_action", + "baiyue_lastleg.ll_pda_stocktake_item", + "baiyue_lastleg.ll_pda_stocktake_task", + "baiyue_lastleg.ll_physical_pallet_guard", + "baiyue_lastleg.ll_procurement_budget_authorization", + "baiyue_lastleg.ll_procurement_carton", + "baiyue_lastleg.ll_procurement_carton_source", + "baiyue_lastleg.ll_procurement_closure", + "baiyue_lastleg.ll_procurement_closure_item", + "baiyue_lastleg.ll_procurement_command", + "baiyue_lastleg.ll_procurement_control_note", + "baiyue_lastleg.ll_procurement_disposition", + "baiyue_lastleg.ll_procurement_estimate_review", + "baiyue_lastleg.ll_procurement_fee_correction", + "baiyue_lastleg.ll_procurement_fee_correction_item", + "baiyue_lastleg.ll_procurement_flow", + "baiyue_lastleg.ll_procurement_label_item", + "baiyue_lastleg.ll_procurement_label_set", + "baiyue_lastleg.ll_procurement_manifest", + "baiyue_lastleg.ll_procurement_receipt", + "baiyue_lastleg.ll_procurement_repack", + "baiyue_lastleg.ll_procurement_shipment", + "baiyue_lastleg.ll_procurement_shipment_item", + "baiyue_lastleg.ll_procurement_wallet_attempt", + "baiyue_lastleg.ll_receiving_container_operation", + "baiyue_lastleg.ll_receiving_container_session", + "baiyue_lastleg.ll_receiving_fba_pallet_count", + "baiyue_lastleg.ll_receiving_sku_receipt", + "baiyue_lastleg.ll_receiving_warehouse_operation", + "baiyue_lastleg.ll_relabel_application", + "baiyue_lastleg.ll_relabel_item", + "baiyue_lastleg.ll_relabel_pda_operation", + "baiyue_lastleg.ll_relabel_pda_task", + "baiyue_lastleg.ll_relabel_pda_unit_fact", + "baiyue_lastleg.ll_return_carton_count_session", + "baiyue_lastleg.ll_return_carton_stock_receipt", + "baiyue_lastleg.ll_return_inventory_balance", + "baiyue_lastleg.ll_return_inventory_carton", + "baiyue_lastleg.ll_return_inventory_carton_item", + "baiyue_lastleg.ll_return_inventory_operation", + "baiyue_lastleg.ll_return_inventory_outbound_movement", + "baiyue_lastleg.ll_return_order", + "baiyue_lastleg.ll_return_order_item", + "baiyue_lastleg.ll_return_packing_plan", + "baiyue_lastleg.ll_return_packing_trace", + "baiyue_lastleg.ll_return_receipt", + "baiyue_lastleg.ll_return_receipt_batch", + "baiyue_lastleg.ll_return_receipt_item", + "baiyue_lastleg.ll_return_sku_conversion", + "baiyue_lastleg.ll_return_stock_receipt", + "baiyue_lastleg.ll_us_truck_order", + "baiyue_lastleg.ll_us_truck_order_operation", + "baiyue_lastleg.ll_us_truck_order_pallet", + "baiyue_lastleg.ll_wallet_event_inbox", + "baiyue_lastleg.ll_warehouse_transfer_carton", + "baiyue_lastleg.ll_warehouse_transfer_inventory_allocation", + "baiyue_lastleg.ll_warehouse_transfer_item", + "baiyue_lastleg.ll_warehouse_transfer_measurement_photo", + "baiyue_lastleg.ll_warehouse_transfer_order", + "baiyue_lastleg.ll_warehouse_transfer_pallet", + "baiyue_lastleg.ll_warehouse_transfer_pallet_item", + "baiyue_lastleg.ll_warehouse_transfer_receipt", + "baiyue_lastleg.ll_warehouse_transfer_receipt_command", + "baiyue_lastleg.ll_warehouse_transfer_relabel_binding", + "baiyue_lastleg.ll_warehouse_transfer_source_order", + "baiyue_order.ord_idempotency_record", + "baiyue_order.ord_order", + "baiyue_order.ord_order_cargo", + "baiyue_order.ord_order_fulfillment_split", + "baiyue_order.ord_order_internal_note", + "baiyue_order.ord_order_party", + "baiyue_order.ord_outbox_event", + "baiyue_warehouse.wh_domestic_container_idempotency", + "baiyue_warehouse.wh_domestic_container_inventory", + "baiyue_warehouse.wh_domestic_container_order", + "baiyue_warehouse.wh_domestic_container_transaction", + "baiyue_warehouse.wh_location_block", + "baiyue_warehouse.wh_location_block_slot", + "baiyue_warehouse.wh_location_occupancy", + "baiyue_warehouse.wh_location_reservation", + "baiyue_warehouse.wh_pallet_location_release_operation", + "baiyue_warehouse.wh_pallet_location_settlement_operation", + "baiyue_warehouse.wh_pallet_location_transfer_operation" + ] + }, + { + "action": "KEEP", + "reason": "保留主档、报价、计费规则及配置历史。", + "tables": [ + "baiyue_finance.fin_customer_settlement_profile", + "baiyue_finance.fin_subject", + "baiyue_firstleg.fl_carrier_fba_rate", + "baiyue_firstleg.fl_customer_charge_rule", + "baiyue_firstleg.fl_customer_dimension_config", + "baiyue_firstleg.fl_fee_item", + "baiyue_firstleg.fl_price_book_channel", + "baiyue_firstleg.fl_price_book_version", + "baiyue_firstleg.fl_price_channel_catalog", + "baiyue_firstleg.fl_price_origin_alias", + "baiyue_firstleg.fl_price_warehouse_mapping", + "baiyue_firstleg.fl_sailing_channel", + "baiyue_firstleg.fl_sailing_template", + "baiyue_lastleg.ll_fleet_driver", + "baiyue_lastleg.ll_fleet_driver_history", + "baiyue_lastleg.ll_fleet_quote", + "baiyue_lastleg.ll_fleet_quote_history", + "baiyue_lastleg.ll_monthly_exchange_rate", + "baiyue_lastleg.ll_monthly_exchange_rate_history", + "baiyue_lastleg.ll_overseas_quote", + "baiyue_lastleg.ll_overseas_quote_fee", + "baiyue_lastleg.ll_overseas_quote_transit_price", + "baiyue_order.ord_customer_address", + "baiyue_warehouse.wh_express_company", + "baiyue_warehouse.wh_overseas_storage_fee_rule", + "baiyue_warehouse.wh_overseas_storage_fee_tier", + "baiyue_warehouse.wh_pallet", + "baiyue_warehouse.wh_partner", + "baiyue_warehouse.wh_partner_type", + "baiyue_warehouse.wh_service_capability", + "baiyue_warehouse.wh_supplier", + "baiyue_warehouse.wh_supplier_carrier", + "baiyue_warehouse.wh_warehouse", + "baiyue_warehouse.wh_warehouse_address" + ] + }, + { + "action": "KEEP", + "reason": "保留编号序列,避免既有标签、附件及审计引用与新单重号。", + "tables": [ + "baiyue_finance.fin_document_sequence", + "baiyue_firstleg.fl_document_sequence", + "baiyue_lastleg.ll_dispatch_daily_sequence", + "baiyue_lastleg.ll_document_sequence", + "baiyue_order.ord_document_sequence", + "baiyue_order.ord_inbound_code_daily_sequence", + "baiyue_warehouse.wh_document_sequence" + ] + }, + { + "action": "RESET_REVIEW", + "reason": "保留账户、币种、授信额度及到期日;实际余额、冻结、已用授信、冻结授信归零;可用余额按执行日有效授信重新计算,不能固定为零;核对服务时区及版本更新。", + "tables": [ + "baiyue_finance.fin_wallet_account" + ] + }, + { + "action": "CLEAR_CANDIDATE", + "reason": "已确认订单自身的状态流转、拣货、派送等作业历史随业务单据清除;独立审计另行保留。执行仍须处理外键及不可删除触发器。", + "tables": [ + "baiyue_firstleg.fl_assignment_history", + "baiyue_firstleg.fl_carrier_schedule_query_history", + "baiyue_firstleg.fl_coload_group_history", + "baiyue_firstleg.fl_container_exception_history", + "baiyue_firstleg.fl_container_transport_history", + "baiyue_firstleg.fl_customs_completion_action_history", + "baiyue_firstleg.fl_customs_container_history", + "baiyue_firstleg.fl_inbound_split_history", + "baiyue_firstleg.fl_inbound_status_history", + "baiyue_firstleg.fl_loading_pick_history", + "baiyue_firstleg.fl_loading_plan_history", + "baiyue_firstleg.fl_ocean_booking_status_history", + "baiyue_firstleg.fl_outbound_dispatch_history", + "baiyue_firstleg.fl_port_schedule_history", + "baiyue_firstleg.fl_terminal_drayage_history", + "baiyue_firstleg.fl_truck_appointment_history", + "baiyue_lastleg.ll_appointment_impact_history", + "baiyue_lastleg.ll_appointment_work_history", + "baiyue_lastleg.ll_cargo_discard_status_history", + "baiyue_lastleg.ll_dispatch_history", + "baiyue_lastleg.ll_fleet_owned_business_history", + "baiyue_lastleg.ll_fleet_pickup_history", + "baiyue_lastleg.ll_fleet_trip_cost_history", + "baiyue_lastleg.ll_fleet_trip_history", + "baiyue_lastleg.ll_loading_order_status_history", + "baiyue_lastleg.ll_local_looseboard_history", + "baiyue_lastleg.ll_order_hold_history", + "baiyue_lastleg.ll_order_manual_node_history", + "baiyue_lastleg.ll_order_operation_log", + "baiyue_lastleg.ll_order_status_history", + "baiyue_lastleg.ll_overseas_procurement_status_history", + "baiyue_lastleg.ll_pda_operation_history", + "baiyue_lastleg.ll_relabel_pda_history", + "baiyue_lastleg.ll_relabel_status_history", + "baiyue_lastleg.ll_us_truck_order_status_history", + "baiyue_order.ord_order_status_history" + ] + }, + { + "action": "KEEP", + "reason": "保留人工维护的船期计划及其计划变更历史;订单归班、实测、预报和订舱关联事实在各自业务表清理,不重置计划本身。", + "tables": [ + "baiyue_firstleg.fl_sailing", + "baiyue_firstleg.fl_sailing_history" + ] + }, + { + "action": "RESET_REVIEW", + "reason": "保留真实/虚拟库位及停用状态;现行实现真实库位使用 IDLE/OCCUPIED,虚拟库位保持 ACTIVE,不能依据基线旧注释统一置 ACTIVE。", + "tables": [ + "baiyue_lastlag_warehouse.wh_location" + ] + }, + { + "action": "RESET_REVIEW", + "reason": "保留实物托盘和允许保留的库位关联,核对业务绑定、FBA归属与占用的释放条件;已废弃或逻辑删除托盘不自动复活。", + "tables": [ + "baiyue_lastlag_warehouse.wh_pallet" + ] + }, + { + "action": "RESET_REVIEW", + "reason": "保留固定协调锁初始行,重置同步游标、初始化标记和本次业务派生版本;须按当前服务及实际种子核对,不能直接清空导致后台协调失效。", + "tables": [ + "baiyue_lastleg.ll_appointment_work_guard" + ] + }, + { + "action": "KEEP", + "reason": "保留车辆保养、维修等档案及其版本历史;独立公司费用及费用提交来源随本次财务业务清理,处理前核对重新提交行为。", + "tables": [ + "baiyue_lastleg.ll_fleet_vehicle_record", + "baiyue_lastleg.ll_fleet_vehicle_record_history" + ] + }, + { + "action": "KEEP", + "reason": "按已确认口径保留全部自营采集事实、版本、维度、任务及发布指针。", + "tables": [ + "baiyue_lastleg.ll_selfop_inventory_daily", + "baiyue_lastleg.ll_selfop_inventory_day_version", + "baiyue_lastleg.ll_selfop_inventory_flow", + "baiyue_lastleg.ll_selfop_inventory_snapshot", + "baiyue_lastleg.ll_selfop_outbound_discovery_scope", + "baiyue_lastleg.ll_selfop_outbound_item", + "baiyue_lastleg.ll_selfop_outbound_order", + "baiyue_lastleg.ll_selfop_owner", + "baiyue_lastleg.ll_selfop_product", + "baiyue_lastleg.ll_selfop_sku_daily_sales", + "baiyue_lastleg.ll_selfop_sku_profile", + "baiyue_lastleg.ll_selfop_sync_run", + "baiyue_lastleg.ll_selfop_sync_scope", + "baiyue_lastleg.ll_selfop_sync_stage", + "baiyue_lastleg.ll_selfop_sync_task", + "baiyue_lastleg.ll_selfop_warehouse_daily_sales" + ] + }, + { + "action": "RESET_REVIEW", + "reason": "保留车辆资料、里程和维护/停用状态;核对实际状态机后仅释放业务车次造成的派出状态,不按 ownership 归属字段判断占用。", + "tables": [ + "baiyue_lastleg.ll_vehicle" + ] + }, + { + "action": "RESET_REVIEW", + "reason": "保留库位资料、停用和逻辑删除状态;核对当前实现后只释放本轮业务造成的 RESERVED/OCCUPIED,占用/预占/库位块同步清理。", + "tables": [ + "baiyue_warehouse.wh_location" + ] + }, + { + "action": "KEEP", + "reason": "独立第三方调用留档保留,不作为本次业务重置的删除对象。", + "tables": [ + "baiyue_firstleg.fl_third_party_call_log" + ] + }, + { + "action": "KEEP", + "reason": "站内消息与通知模板保留;执行前核查排队通知是否引用本次待清业务,不按测试样本名称或消息正文批量删除。", + "tables": [ + "baiyue_iam.iam_notification_event_inbox", + "baiyue_iam.iam_notification_inbox", + "baiyue_iam.iam_notification_recipient", + "baiyue_iam.iam_notification_task" + ] + } + ] +} From bbf8f2f7923f6afd6b06eaa8594b7d6f8b07fa97 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Wed, 21 Oct 2026 21:49:48 +0800 Subject: [PATCH 076/113] =?UTF-8?q?=E5=B0=BE=E7=A8=8B=E8=AE=A2=E5=8D=95?= =?UTF-8?q?=E5=88=97=E8=A1=A8=E6=9F=A5=E8=AF=A2=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../client/OrderPreDepartureClient.java | 24 ++++++++ .../lastleg/service/LastlegOrderService.java | 60 +++++++++++++++++++ .../InternalPreDepartureOrderController.java | 56 +++++++++++++++++ .../order/dto/PreDepartureOrderDtos.java | 17 ++++++ .../service/UnifiedOrderQueryService.java | 8 +-- 5 files changed, 161 insertions(+), 4 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/client/OrderPreDepartureClient.java create mode 100644 order/src/main/java/com/baiyue/order/controller/InternalPreDepartureOrderController.java create mode 100644 order/src/main/java/com/baiyue/order/dto/PreDepartureOrderDtos.java diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/client/OrderPreDepartureClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/client/OrderPreDepartureClient.java new file mode 100644 index 000000000..dfa4320ac --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/client/OrderPreDepartureClient.java @@ -0,0 +1,24 @@ +package com.baiyue.lastleg.client; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +@FeignClient(name="order-service", contextId="lastlegOrderPreDepartureClient", path="/internal/order/v1/predeparture-orders") +public interface OrderPreDepartureClient { + @GetMapping + ApiResponse> page(@RequestHeader("X-Internal-Service-Token") String token, + @RequestParam(required=false) Long customerId, @RequestParam(required=false) String orderNo, + @RequestParam(required=false) String inboundCode, @RequestParam int page, @RequestParam int size); + record Row(Long orderId, Long customerId, String orderNo, String inboundCode, String mainStatus, + String customerName, String lxCode, String receivingWarehouseName, String destinationWarehouseName, + String destinationCode, Integer packageCount, BigDecimal volumeCbm, BigDecimal grossWeightKg, + BigDecimal netWeightKg, LocalDateTime expectedArrivalAt, String containerNo, String cabinetNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, LocalDateTime createdAt, LocalDateTime updatedAt) {} +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 6e556f464..7b9bdd6b0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -19,6 +19,7 @@ import com.baiyue.lastleg.entity.LastlegOrderOperationLog; import com.baiyue.lastleg.entity.LastlegOrderManualNode; import com.baiyue.lastleg.entity.ReceivingWarehouseOperation; import com.baiyue.lastleg.entity.ContainerArrivalTimeOperation; +import com.baiyue.lastleg.client.OrderPreDepartureClient; import com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; @@ -42,6 +43,7 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.server.ResponseStatusException; import java.nio.charset.StandardCharsets; @@ -87,12 +89,23 @@ public class LastlegOrderService { @Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ReturnOrderReferenceResolver returnOrderReferences; @Resource private com.baiyue.lastleg.transfer.service.TransferQuantityService transferQuantities; + @Resource private OrderPreDepartureClient orderPreDepartureClient; + @Value("${baiyue.security.internal-service-token:}") private String internalServiceToken; public PageResult page(String customerId, LastlegOrderRequests.Query query) { return page(customerId, query, null); } public PageResult page(String customerId, LastlegOrderRequests.Query query, String relabelNo) { + if (query.sailingStage() == LastlegOrderRequests.SailingStage.BEFORE_DEPARTURE) { + Long customer = parseLong(customerId); + var response = orderPreDepartureClient.page(internalServiceToken, customer, query.orderNo(), query.inboundCode(), + query.page().intValue(), query.size().intValue()); + if (response == null || response.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用"); + List rows = response.data().records().stream().map(this::preDepartureSummary).toList(); + return new PageResult<>(rows, response.data().page(), response.data().size(), response.data().total(), response.data().pages()); + } + if (query.sailingStage() == null && relabelNo == null) return aggregateAllPage(customerId, query); Page page = orderMapper.selectPage( new Page<>(query.page(), query.size()), filter(customerId, query, relabelNo).orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)); Map> mergeRelations = palletMergeRelations(page.getRecords()); @@ -112,6 +125,53 @@ public class LastlegOrderService { page.getCurrent(), page.getSize(), page.getTotal(), page.getPages()); } + /** 全部页签合并订单服务的未开船头程数据与本服务已有尾程单,尾程记录优先覆盖同一来源订单。 */ + private PageResult aggregateAllPage(String customerId, LastlegOrderRequests.Query query) { + var source = orderPreDepartureClient.page(internalServiceToken, parseLong(customerId), query.orderNo(), query.inboundCode(), 1, 2000); + if (source == null || source.data() == null) throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "订单服务暂时不可用"); + List existing = orderMapper.selectList(filter(customerId, query, null) + .orderByDesc(LastlegOrder::getCreatedAt).orderByDesc(LastlegOrder::getId)); + Map existingNodes = containerNodes(existing); + Map existingWeights = orderWeights(existing); + Map merged = new LinkedHashMap<>(); + source.data().records().forEach(row -> { + var summary = preDepartureSummary(row); + merged.put(orderKey(summary.customerId(), summary.sourceOrderNo()), summary); + }); + for (LastlegOrder order : existing) { + String key = orderKey(order.getCustomerId(), StringUtils.hasText(order.getSourceOrderNo()) ? order.getSourceOrderNo() : order.getOrderNo()); + merged.put(key, summary(order, List.of(), StringUtils.hasText(order.getSourceContainerBookingId()) + ? existingNodes.get(order.getSourceContainerBookingId()) : null, "UNSCHEDULED", + existingWeights.get(order.getId()), List.of(), null, null)); + } + List rows = merged.values().stream().sorted(java.util.Comparator.comparing(LastlegOrderSummary::createdAt, + java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))).toList(); + int from = Math.min((query.page().intValue() - 1) * query.size().intValue(), rows.size()); + int to = Math.min(from + query.size().intValue(), rows.size()); + long total = rows.size(); + return new PageResult<>(rows.subList(from, to), query.page(), query.size(), total, + total == 0 ? 0 : (total + query.size() - 1) / query.size()); + } + + private static String orderKey(String customerId, String orderNo) { + return String.valueOf(customerId) + ":" + String.valueOf(orderNo); + } + + /** 将订单服务权威头程摘要投影为只读尾程列表行;尚未生成尾程单的字段保持为空。 */ + private LastlegOrderSummary preDepartureSummary(OrderPreDepartureClient.Row row) { + LastlegOrder order = new LastlegOrder(); + // 头程聚合行尚未生成尾程单,尾程订单号必须保持为空;来源头程订单号单独保留。 + order.setId(row.orderId()); order.setOrderNo(null); order.setOrderType("FIRST_LEG"); + order.setInboundCode(row.inboundCode()); order.setLxCode(row.lxCode()); order.setSourceOrderNo(row.orderNo()); + order.setCustomerId(row.customerId() == null ? null : String.valueOf(row.customerId())); order.setCustomerName(row.customerName()); + order.setSourceWarehouseName(row.receivingWarehouseName()); order.setOverseasWarehouseName(row.receivingWarehouseName()); + order.setFbaWarehouseCode(row.destinationCode()); + order.setContainerNo(row.containerNo()); order.setPackageCount(row.packageCount()); order.setVolumeCbm(row.volumeCbm()); + order.setEstimatedArrivalAt(row.expectedArrivalAt()); order.setCurrentStatus(row.mainStatus()); + order.setCreatedAt(row.createdAt()); order.setUpdatedAt(row.updatedAt()); order.setVersion(0); + return summary(order, List.of(), null, "UNSCHEDULED", null, List.of(), null, null); + } + @Transactional(readOnly = true) public PageResult containerProgressPage( LastlegOrderRequests.ContainerProgressQuery query) { diff --git a/order/src/main/java/com/baiyue/order/controller/InternalPreDepartureOrderController.java b/order/src/main/java/com/baiyue/order/controller/InternalPreDepartureOrderController.java new file mode 100644 index 000000000..6507b1cf8 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/controller/InternalPreDepartureOrderController.java @@ -0,0 +1,56 @@ +package com.baiyue.order.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.order.dto.PreDepartureOrderDtos; +import com.baiyue.order.dto.UnifiedOrderQuery; +import com.baiyue.order.dto.UnifiedOrderResponse; +import com.baiyue.order.service.UnifiedOrderQueryService; +import jakarta.annotation.Resource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.bind.annotation.*; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.stream.Collectors; + +/** Service-only aggregate query. It intentionally has no platform-user permission. */ +@RestController +@RequestMapping("/internal/order/v1/predeparture-orders") +public class InternalPreDepartureOrderController { + @Resource private UnifiedOrderQueryService service; + @Value("${baiyue.security.internal-service-token}") private String expectedToken; + + @GetMapping + public ApiResponse> page( + @RequestHeader(value="X-Internal-Service-Token", required=false) String token, + @RequestParam(required=false) Long customerId, @RequestParam(required=false) String orderNo, + @RequestParam(required=false) String inboundCode, @RequestParam(defaultValue="1") int page, + @RequestParam(defaultValue="200") int size) { + if (expectedToken == null || expectedToken.isBlank() || token == null || !java.security.MessageDigest.isEqual(expectedToken.getBytes(StandardCharsets.UTF_8), token.getBytes(StandardCharsets.UTF_8))) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.FORBIDDEN); + if (page < 1 || size < 1 || size > 2000) throw new org.springframework.web.server.ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST); + Map all = new LinkedHashMap<>(); + // 由统一订单服务一次返回全部履约状态,再按实际开船事实筛选。 + for (long sourcePage = 1; ; sourcePage++) { + UnifiedOrderQuery q = new UnifiedOrderQuery(null, orderNo, "PRE_DEPARTURE", customerId, null, null, + inboundCode, null, null, null, null, null, null, null, null, null, null, null, + null, null, sourcePage, 200, null); + var result = service.platformPage(customerId, q); + result.records().forEach(row -> { if (row.transport() == null || row.transport().actualSailingAt() == null) all.putIfAbsent(row.orderId(), row); }); + if (sourcePage >= result.pages()) break; + } + List rows = all.values().stream().sorted(Comparator.comparing(UnifiedOrderResponse.Row::createdAt, Comparator.nullsLast(Comparator.reverseOrder()))) + .map(this::row).toList(); + int from = Math.min((page - 1) * size, rows.size()); + int to = Math.min(from + size, rows.size()); + long total = rows.size(), pages = total == 0 ? 0 : (total + size - 1) / size; + return ApiResponse.success(new PageResult<>(rows.subList(from, to), page, size, total, pages)); + } + private PreDepartureOrderDtos.Row row(UnifiedOrderResponse.Row r) { + var t = r.transport(); + return new PreDepartureOrderDtos.Row(r.orderId(), r.customerId(), r.orderNo(), r.inboundCode(), r.mainStatus(), + r.customerName(), r.lxCode(), r.receivingWarehouseName(), r.destinationWarehouseName(), r.destinationCode(), + r.totalPackageCount(), r.actualVolumeCbm() == null ? r.expectedVolumeCbm() : r.actualVolumeCbm(), + r.actualGrossWeightKg(), r.totalNetWeightKg(), r.expectedArrivalAt(), t == null ? null : t.containerNo(), + t == null ? null : t.cabinetNo(), t == null ? null : t.estimatedSailingAt(), t == null ? null : t.actualSailingAt(), r.createdAt(), r.updatedAt()); + } +} diff --git a/order/src/main/java/com/baiyue/order/dto/PreDepartureOrderDtos.java b/order/src/main/java/com/baiyue/order/dto/PreDepartureOrderDtos.java new file mode 100644 index 000000000..789f50ac0 --- /dev/null +++ b/order/src/main/java/com/baiyue/order/dto/PreDepartureOrderDtos.java @@ -0,0 +1,17 @@ +package com.baiyue.order.dto; + +import java.time.LocalDateTime; + +/** Internal read model used by lastleg to show orders before an actual sailing fact exists. */ +public final class PreDepartureOrderDtos { + private PreDepartureOrderDtos() {} + public record Query(Long customerId, String orderNo, String inboundCode, int page, int size) {} + public record Row(Long orderId, Long customerId, String orderNo, String inboundCode, + String mainStatus, String customerName, String lxCode, + String receivingWarehouseName, String destinationWarehouseName, + String destinationCode, Integer packageCount, java.math.BigDecimal volumeCbm, + java.math.BigDecimal grossWeightKg, java.math.BigDecimal netWeightKg, + LocalDateTime expectedArrivalAt, String containerNo, String cabinetNo, + LocalDateTime estimatedSailingAt, LocalDateTime actualSailingAt, + LocalDateTime createdAt, LocalDateTime updatedAt) {} +} diff --git a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java index f3e317a93..94efaa141 100644 --- a/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java +++ b/order/src/main/java/com/baiyue/order/service/UnifiedOrderQueryService.java @@ -148,7 +148,7 @@ public class UnifiedOrderQueryService { /** 状态投影和当前页摘要共用原筛选,候选订单用于服务内批量收敛范围。 */ private FirstlegUnifiedOrderClient.Query statusRequest(Long tenantId, UnifiedOrderQuery query, List candidates) { - String status = Set.of("PENDING_REVIEW", "COMPLETED").contains( + String status = Set.of("PENDING_REVIEW", "COMPLETED", "PRE_DEPARTURE").contains( query.mainStatus() == null ? "" : query.mainStatus()) ? null : query.mainStatus(); return new FirstlegUnifiedOrderClient.Query(tenantId, query.keyword(), query.orderNo(), status, query.customerId(), query.receivingWarehouseId(), query.destinationWarehouseId(), query.inboundCode(), @@ -261,7 +261,7 @@ public class UnifiedOrderQueryService { private PageResult pageFromFirstleg(Long tenantId, UnifiedOrderQuery query, boolean includeCoLoadGroups) { List matching = matchingStatusRows(tenantId, query).stream() - .filter(row -> !StringUtils.hasText(query.mainStatus()) || query.mainStatus().equals(row.mainStatus())) + .filter(row -> !StringUtils.hasText(query.mainStatus()) || "PRE_DEPARTURE".equals(query.mainStatus()) || query.mainStatus().equals(row.mainStatus())) .collect(Collectors.collectingAndThen(Collectors.toMap(FirstlegUnifiedOrderClient.StatusRow::orderId, Function.identity(), (left, right) -> left, java.util.LinkedHashMap::new), values -> List.copyOf(values.values()))); @@ -682,7 +682,7 @@ public class UnifiedOrderQueryService { if (query.taxMode() != null && !Set.of("TAX_REFUND", "BUY_DECLARATION").contains(query.taxMode())) { throw new BizException(ResultCode.BAD_REQUEST, "taxMode仅支持TAX_REFUND或BUY_DECLARATION"); } - if (StringUtils.hasText(query.mainStatus()) + if (StringUtils.hasText(query.mainStatus()) && !"PRE_DEPARTURE".equals(query.mainStatus()) && !MAIN_STATUSES.contains(query.mainStatus()) && !WORKFLOW_STATUSES.contains(query.mainStatus()) ) { @@ -708,7 +708,7 @@ public class UnifiedOrderQueryService { } private boolean requiresFirstlegQuery(UnifiedOrderQuery query) { - return (StringUtils.hasText(query.mainStatus()) + return ("PRE_DEPARTURE".equals(query.mainStatus()) || StringUtils.hasText(query.mainStatus()) && !Set.of("PENDING_REVIEW", "COMPLETED").contains(query.mainStatus())) || query.expectedArrivalDate() != null || StringUtils.hasText(query.feeReviewStatus()) || StringUtils.hasText(query.containerNo()) From 2c3435adafe48c8e97ef145fa301eaa11ea230be Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 21 Oct 2026 23:20:09 +0800 Subject: [PATCH 077/113] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=B4=B9=E7=94=A8?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E3=80=81=E8=A3=85=E6=9F=9C=E5=AE=8C=E6=88=90?= =?UTF-8?q?=E5=8F=8A=E6=B5=B7=E5=A4=96=E5=B1=A5=E7=BA=A6=E8=B5=84=E6=96=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mapper/FinanceFeeSourceReadMapper.java | 60 ++++++ .../FinanceFeeSourceReadRepository.java | 145 +++++++++++++ .../FinancePhase1OperationService.java | 73 ++----- .../baiyue/finance/FinanceContractTest.java | 4 +- .../FinanceFeeSourceReadMapperTest.java | 141 +++++++++++++ .../FinancePhase1OperationBehaviorTest.java | 139 +++++++------ .../LoadingCompletionCommandMapper.java | 32 ++- .../service/AppointmentForecastService.java | 17 +- .../LoadingCompletionCommandRepository.java | 14 +- .../service/LoadingCompletionWorker.java | 71 +++++-- .../firstleg/service/LoadingPickService.java | 70 ++++--- .../mapper/ContainerCalendarSourceMapper.java | 19 ++ .../ContainerCalendarSourceRepository.java | 2 + .../appointment-forecasts-internal-v1.yaml | 1 + .../AppointmentForecastServiceTest.java | 16 +- .../LoadingCompletionCheckpointLeaseTest.java | 190 ++++++++++++++++++ ...LoadingCompletionOwnerPersistenceTest.java | 12 +- .../service/LoadingCompletionWorkerTest.java | 104 +++++++++- .../service/LoadingPickServiceTest.java | 43 +++- .../ContainerCalendarSourceMapperTest.java | 2 + lastleg-service/docs/business-index.md | 6 +- .../AppointmentForecastClient.java | 2 +- .../calendar/WarehouseCalendarMapper.java | 19 +- .../calendar/WarehouseCalendarModels.java | 4 +- .../calendar/WarehouseCalendarService.java | 18 +- .../dispatch/port/DispatchCommands.java | 2 +- .../dispatch/port/DispatchContracts.java | 34 +++- .../DispatchMaintenanceDateDeserializer.java | 29 +++ .../lastleg/dispatch/port/DispatchViews.java | 2 +- .../service/DispatchAggregateService.java | 21 +- .../transport/TransportReviewPhotos.java | 2 +- .../transport/TransportTaskProjection.java | 10 +- .../lastleg/dto/ContainerArrivalTimeView.java | 4 +- .../lastleg/fleet/trip/FleetTripService.java | 4 +- .../mapper/InboundPlanOrderMapper.java | 5 - .../service/InboundPlanService.java | 12 +- .../lastleg/mapper/LastlegOrderMapper.java | 17 +- .../ContainerWarehouseArrivalRepository.java | 16 ++ .../LastlegOrderNumberRepository.java | 21 ++ .../ContainerWarehouseArrivalService.java | 67 ++++++ .../LastlegDocumentNumberGenerator.java | 2 +- .../service/LastlegOrderNoGenerator.java | 20 +- .../lastleg/service/LastlegOrderService.java | 7 +- .../service/LoadingCompletedConsumer.java | 6 +- .../appointment-calendar-warehouse-v1.yaml | 7 +- .../openapi/overseas-business-v1.yaml | 58 +++++- .../AppointmentImpactPersistenceTest.java | 2 +- .../calendar/WarehouseCalendarMapperTest.java | 24 ++- .../WarehouseCalendarServiceTest.java | 11 +- .../DispatchAggregatePersistenceTest.java | 8 +- .../DispatchBolReadPersistenceTest.java | 2 +- ...DispatchCapacityDemandPersistenceTest.java | 2 +- .../DispatchMaintenancePersistenceTest.java | 178 ++++++++++++++++ ...DispatchResourcePhotosPersistenceTest.java | 2 +- ...DispatchReviewWorkflowPersistenceTest.java | 2 +- .../transport/TransportReviewPhotosTest.java | 4 +- .../OwnedBusinessScopePersistenceTest.java | 4 +- .../service/InboundPlanServiceTest.java | 13 +- .../ContainerWarehouseArrivalServiceTest.java | 67 ++++++ .../service/LastlegOrderNoGeneratorTest.java | 10 +- .../service/LastlegOrderServiceTest.java | 4 +- .../service/LoadingCompletedConsumerTest.java | 37 +++- 62 files changed, 1622 insertions(+), 298 deletions(-) create mode 100644 finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java create mode 100644 finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java create mode 100644 finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java create mode 100644 firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionCheckpointLeaseTest.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchMaintenanceDateDeserializer.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/ContainerWarehouseArrivalRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/repository/LastlegOrderNumberRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalService.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java create mode 100644 lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalServiceTest.java diff --git a/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java b/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java new file mode 100644 index 000000000..4fa153600 --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java @@ -0,0 +1,60 @@ +package com.baiyue.finance.mapper; + +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; +import org.apache.ibatis.annotations.Select; +import java.util.List; +import java.util.Map; + +/** 费用池按稳定来源读取当前版本,历史快照保留在同一费用的操作记录中。 */ +@Mapper +public interface FinanceFeeSourceReadMapper { + String GENERATED = "(EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version))"; + String STATUS = "CASE WHEN f.status='AVAILABLE' AND " + GENERATED + " THEN 'BILLED' ELSE f.status END"; + String BUSINESS_DATE = "CAST(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')) AS DATE)"; + String FILTER = " FROM fin_fee_source f WHERE 1=1 " + + "" + + " AND f.status<>'SUPERSEDED' AND NOT EXISTS(SELECT 1 FROM fin_fee_source newer" + + " WHERE newer.source_system=f.source_system AND newer.source_type=f.source_type" + + " AND newer.source_fee_id=f.source_fee_id AND newer.source_fee_version>f.source_fee_version)" + + "" + + " AND f.customer_id=#{customerId}" + + " AND f.direction=#{direction}" + + " AND f.review_status=#{reviewStatus}" + + " AND " + STATUS + "=#{status}" + + " AND " + GENERATED + "=#{statementGenerated}" + + " AND " + BUSINESS_DATE + ">=#{dateFrom}" + + " AND " + BUSINESS_DATE + "<=#{dateTo}" + + " AND (f.source_no LIKE CONCAT('%',#{keyword},'%')" + + " OR f.fee_item_name LIKE CONCAT('%',#{keyword},'%')" + + " OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',#{keyword},'%')" + + " OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',#{keyword},'%')" + + " OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',#{keyword},'%')" + + " OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',#{keyword},'%'))"; + + /** 筛选和计数共用当前版本条件,不能先分页再去重。 */ + @Select("") + long count(Map filters); + + /** 应收、应付分别展示当前快照;同名但来源费用号不同的费用不合并。 */ + @Select("") + List> list(Map filters); + + String LINEAGE = " FROM fin_fee_source anchor JOIN fin_fee_source f ON f.source_system=anchor.source_system" + + " AND f.source_type=anchor.source_type AND f.source_fee_id=anchor.source_fee_id" + + " AND f.customer_id=anchor.customer_id AND f.direction=anchor.direction" + + " AND f.source_fee_version<=anchor.source_fee_version"; + + /** 旧版本链接只读取当时及此前快照,不把后续调整倒灌到历史单据。 */ + @Select("") + List> versions(@Param("customerId") long customerId, @Param("id") long id); + + /** 按来源身份串起历次审核和操作,客户与方向边界不能被同名费用跨越。 */ + @Select("") + List> audits(@Param("customerId") long customerId, @Param("id") long id); +} diff --git a/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java b/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java new file mode 100644 index 000000000..c5862944e --- /dev/null +++ b/finance/src/main/java/com/baiyue/finance/repository/FinanceFeeSourceReadRepository.java @@ -0,0 +1,145 @@ +package com.baiyue.finance.repository; + +import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; +import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog; +import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper; +import com.baiyue.finance.service.FinanceEventException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.stereotype.Repository; +import org.springframework.util.LinkedCaseInsensitiveMap; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.sql.Timestamp; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** 费用当前投影及操作历史;版本记录只读,不覆盖账单引用的不可变快照。 */ +@Repository +public class FinanceFeeSourceReadRepository { + private final FinanceFeeSourceReadMapper mapper; + private final ObjectMapper json; + + /** 注入正式持久化入口与费用快照解析器。 */ + public FinanceFeeSourceReadRepository(FinanceFeeSourceReadMapper mapper, ObjectMapper json) { + this.mapper = mapper; + this.json = json; + } + + /** 组装统一查询参数,让计数和分页使用相同来源身份与筛选条件。 */ + public Map filters(Long customerId, String direction, String status, String reviewStatus, + Boolean generated, LocalDate from, LocalDate to, String keyword, int page, int size) { + Map values = new HashMap<>(); + values.put("customerId", customerId); values.put("direction", direction); values.put("status", status); + values.put("reviewStatus", reviewStatus); values.put("statementGenerated", generated); + values.put("dateFrom", from); values.put("dateTo", to); values.put("keyword", keyword); + values.put("limit", size); values.put("offset", (page - 1) * size); + return values; + } + + /** 计数只包含所筛选的当前费用,历史状态专项查询除外。 */ + public long count(Map filters) { return mapper.count(filters); } + + /** 当前费用映射沿用原币、数量、客户等正式字段。 */ + public List list(Map filters) { + return mapper.list(filters).stream().map(this::summary).toList(); + } + + /** 由不可变版本追加同步历史,并串联同一费用所有既有审计记录。 */ + public List logs(long customerId, long id) { + List result = new ArrayList<>(); + Map previous = null; + for (Map raw : mapper.versions(customerId, id)) { + Map row = row(raw); + String version = "来源版本 " + row.get("source_fee_version"); + String amount = amount(row) + " " + row.get("currency_code"); + // 同步时间与原业务操作者不能混用,原业务审批仍以来源系统记录为准。 + result.add(new OperationLog(time(row.get("created_at")), + previous == null ? "FEE_SOURCE_PUBLISHED" : "FEE_SOURCE_VERSION_ADJUST", + "系统同步", "SUCCESS", previous == null ? version + ";金额 " + amount + : "来源版本 " + previous.get("source_fee_version") + " → " + row.get("source_fee_version") + + ";金额 " + amount(previous) + " → " + amount)); + previous = row; + } + // 金额差分仍按版本升序计算;展示先反转版本,同毫秒时稳定排序保留新版本在前。 + java.util.Collections.reverse(result); + for (Map raw : mapper.audits(customerId, id)) { + Map row = row(raw); + String remark = text(row.get("reason")); + JsonNode before = parse(row.get("before_data")); + JsonNode after = parse(row.get("after_data")); + if (before.hasNonNull("amount") && after.hasNonNull("amount") + && before.path("amount").decimalValue().compareTo(after.path("amount").decimalValue()) != 0) { + remark = (remark == null ? "" : remark + ";") + "金额 " + before.path("amount").asText() + + " → " + after.path("amount").asText() + " " + after.path("currency").asText("CNY"); + } + result.add(new OperationLog(time(row.get("created_at")), text(row.get("action")), + text(row.get("operator_name_snapshot")), "SUCCESS", remark)); + } + // 时间排序保持同毫秒版本的新到旧顺序,以及 Mapper 返回的审计 ID 倒序。 + result.sort(Comparator.comparing(OperationLog::occurredAt, Comparator.nullsLast(Comparator.reverseOrder()))); + return List.copyOf(result); + } + + /** 将持久化快照转换为费用列表 DTO,不从其他客户或来源补齐业务事实。 */ + private FeeSourceSummary summary(Map raw) { + Map row = row(raw); + JsonNode snapshot = parse(row.get("source_snapshot")); + BigDecimal amount = decimal(row.get("amount")); + BigDecimal originalAmount = first(decimal(text(snapshot, "originalAmount")), amount); + BigDecimal quantity = decimal(text(snapshot, "chargeableQuantity")); + BigDecimal unitPrice = first(decimal(text(snapshot, "unitPrice")), quantity != null && quantity.signum() > 0 + ? originalAmount.divide(quantity, 6, RoundingMode.HALF_UP) : null); + return new FeeSourceSummary(number(row.get("id")), text(row.get("source_system")), text(row.get("source_type")), + first(text(row.get("source_no")), text(snapshot, "sourceBusinessId")), number(row.get("source_fee_id")), + ((Number) row.get("source_fee_version")).intValue(), text(row.get("direction")), number(row.get("customer_id")), + text(snapshot, "customerName"), positiveLong(text(snapshot, "supplierId")), text(snapshot, "supplierName"), + nullableLong(row.get("order_id")), nullableLong(row.get("inbound_id")), text(snapshot, "inboundCode"), + nullableLong(row.get("container_id")), text(snapshot, "containerNo"), + first(date(text(snapshot, "businessDate")), LocalDate.of(1970, 1, 1)), text(row.get("fee_item_name")), + quantity, text(snapshot, "quantityUnit"), unitPrice, date(text(snapshot, "periodStart")), date(text(snapshot, "periodEnd")), + amount, text(row.get("currency_code")), first(text(snapshot, "originalCurrency"), text(row.get("currency_code"))), + originalAmount, first(decimal(text(snapshot, "exchangeRate")), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), + first(text(row.get("review_status")), "DRAFT"), text(row.get("effective_status")), + Boolean.TRUE.equals(row.get("statement_generated")) || row.get("statement_generated") instanceof Number value && value.intValue() != 0); + } + + /** 保留不同数据库驱动的列名大小写读取语义。 */ + private static Map row(Map source) { + Map result = new LinkedCaseInsensitiveMap<>(); result.putAll(source); return result; + } + /** 解析不可变快照,损坏数据失败关闭。 */ + private JsonNode parse(Object value) { + try { return value == null ? json.createObjectNode() : json.readTree(value.toString()); } + catch (Exception failure) { throw new FinanceEventException("FIN-DATA-500", "费用来源快照损坏"); } + } + /** 格式化记录金额,保留定点精度。 */ + private static String amount(Map row) { return decimal(row.get("amount")).toPlainString(); } + /** 读取驱动返回的标准时间。 */ + private static LocalDateTime time(Object value) { return value instanceof Timestamp time ? time.toLocalDateTime() : (LocalDateTime) value; } + /** 读取数字主键,仅用于数据引用。 */ + private static long number(Object value) { return ((Number) value).longValue(); } + /** 保留未关联的可空引用。 */ + private static Long nullableLong(Object value) { return value == null ? null : number(value); } + /** 只接受合法的正整型供应商引用。 */ + private static Long positiveLong(String value) { + try { long parsed = Long.parseLong(value); return parsed > 0 ? parsed : null; } + catch (NumberFormatException failure) { return null; } + } + /** 读取可空金额,不经过浮点转换。 */ + private static BigDecimal decimal(Object value) { return value == null ? null : new BigDecimal(value.toString()); } + /** 读取可空业务日期。 */ + private static LocalDate date(String value) { return value == null ? null : LocalDate.parse(value); } + /** 保持空字段的缺失语义。 */ + private static String text(Object value) { return value == null || value.toString().isBlank() ? null : value.toString(); } + /** 读取快照字段。 */ + private static String text(JsonNode node, String field) { return node.path(field).isNull() || node.path(field).isMissingNode() ? null : text(node.path(field).asText()); } + /** 优先使用来源字段,其次使用已有正式默认值。 */ + private static T first(T value, T fallback) { return value == null ? fallback : value; } +} diff --git a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java index e9d3f6e1f..856ae61d7 100644 --- a/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java +++ b/finance/src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java @@ -1,10 +1,10 @@ package com.baiyue.finance.service; +import com.baiyue.finance.repository.FinanceFeeSourceReadRepository; import com.baiyue.finance.dto.FinancePhase1Dtos.AccountSummary; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceSummary; import com.baiyue.finance.dto.FinancePhase1Dtos.LedgerEntry; -import com.baiyue.finance.dto.FinancePhase1Dtos.OperationLog; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.dto.FinancePhase1Dtos.ReviewRequest; import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceActionRequest; @@ -44,19 +44,19 @@ public class FinancePhase1OperationService { private final ObjectMapper objectMapper; private final FirstlegFeeSourceClient firstleg; private final String internalToken; + private final FinanceFeeSourceReadRepository feeSources; + /** 注入费用来源查询及现有业务依赖。 */ @Autowired public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper, FirstlegFeeSourceClient firstleg, - @Value("${baiyue.security.internal-service-token}") String internalToken) { + @Value("${baiyue.security.internal-service-token}") String internalToken, + FinanceFeeSourceReadRepository feeSources) { this.jdbc = jdbc; this.objectMapper = objectMapper; this.firstleg = firstleg; this.internalToken = internalToken; - } - - public FinancePhase1OperationService(JdbcTemplate jdbc, ObjectMapper objectMapper) { - this(jdbc, objectMapper, null, ""); + this.feeSources = feeSources; } public PageEnvelope listDerivedCustomerAccounts(int page, int pageSize, String customerName, String status) { @@ -163,6 +163,7 @@ public class FinancePhase1OperationService { return PageEnvelope.success(List.copyOf(projected.subList(from, to)), page, pageSize, projected.size()); } + /** 按同一来源费用的当前版本分页,调整历史不重复计入客户应收应付。 */ public PageEnvelope listFinanceFeeSources(int page, int pageSize, Long customerId, String direction, String sourceStatus, String reviewStatus, @@ -178,34 +179,9 @@ public class FinancePhase1OperationService { List.of("DRAFT", "PENDING", "APPROVED", "REJECTED"), "审核状态无效"); String normalizedSourceStatus = normalized(sourceStatus, List.of("AVAILABLE", "LOCKED", "BILLED", "SETTLED", "VOIDED", "SUPERSEDED"), "费用来源状态无效"); - String generatedSql = "EXISTS(SELECT 1 FROM fin_bill_line bl WHERE bl.source_id=f.id AND bl.source_version=f.source_fee_version)"; - String effectiveStatusSql = "CASE WHEN f.status='AVAILABLE' AND " + generatedSql + " THEN 'BILLED' ELSE f.status END"; - String businessDateSql = "STR_TO_DATE(JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.businessDate')),'%Y-%m-%d')"; - StringBuilder where = new StringBuilder(" FROM fin_fee_source f WHERE 1=1"); - List args = new ArrayList<>(); - if (customerId != null) { where.append(" AND f.customer_id=?"); args.add(customerId); } - if (normalizedDirection != null) { where.append(" AND f.direction=?"); args.add(normalizedDirection); } - if (normalizedReview != null) { where.append(" AND f.review_status=?"); args.add(normalizedReview); } - if (normalizedSourceStatus != null) { where.append(" AND ").append(effectiveStatusSql).append("=?"); args.add(normalizedSourceStatus); } - if (statementGenerated != null) { where.append(" AND ").append(generatedSql).append("=?"); args.add(statementGenerated); } - if (businessDateFrom != null) { where.append(" AND ").append(businessDateSql).append(">=?"); args.add(businessDateFrom); } - if (businessDateTo != null) { where.append(" AND ").append(businessDateSql).append("<=?"); args.add(businessDateTo); } - String normalizedKeyword = blankToNull(keyword); - if (normalizedKeyword != null) { - where.append(" AND (f.source_no LIKE CONCAT('%',?,'%') OR f.fee_item_name LIKE CONCAT('%',?,'%')") - .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.customerName')) LIKE CONCAT('%',?,'%')") - .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.inboundCode')) LIKE CONCAT('%',?,'%')") - .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.containerNo')) LIKE CONCAT('%',?,'%')") - .append(" OR JSON_UNQUOTE(JSON_EXTRACT(f.source_snapshot,'$.supplierName')) LIKE CONCAT('%',?,'%'))"); - for (int i = 0; i < 6; i++) args.add(normalizedKeyword); - } - Long total = jdbc.queryForObject("SELECT COUNT(*)" + where, Long.class, args.toArray()); - List queryArgs = new ArrayList<>(args); - queryArgs.add(pageSize); queryArgs.add((page - 1) * pageSize); - String select = "SELECT f.*," + generatedSql + " statement_generated," + effectiveStatusSql + " effective_status" + - where + " ORDER BY " + businessDateSql + " DESC,f.id DESC LIMIT ? OFFSET ?"; - List rows = jdbc.query(select, (rs, n) -> summary(rs), queryArgs.toArray()); - return PageEnvelope.success(rows, page, pageSize, total == null ? 0 : total); + Map filters = feeSources.filters(customerId, normalizedDirection, normalizedSourceStatus, + normalizedReview, statementGenerated, businessDateFrom, businessDateTo, blankToNull(keyword), page, pageSize); + return PageEnvelope.success(feeSources.list(filters), page, pageSize, feeSources.count(filters)); } public FeeSourceDetail getFinanceFeeSource(long feeSourceId) { @@ -339,6 +315,7 @@ public class FinancePhase1OperationService { return after; } + /** 读取指定不可变版本,并附带同客户同方向的费用调整历史。 */ private FeeSourceDetail detail(ResultSet rs) throws SQLException { JsonNode snapshot = parse(rs.getString("source_snapshot")); long id = rs.getLong("id"); @@ -356,33 +333,7 @@ public class FinancePhase1OperationService { amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount, firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), defaultString(rs.getString("review_status"), "DRAFT"), status, generated, - rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), logs(rs.getLong("customer_id"), id)); - } - - private FeeSourceSummary summary(ResultSet rs) throws SQLException { - JsonNode snapshot = parse(rs.getString("source_snapshot")); - boolean generated = rs.getBoolean("statement_generated"); - BigDecimal quantity = decimal(snapshot, "chargeableQuantity"); - BigDecimal amount = money(rs, "amount"); - BigDecimal originalAmount = firstDecimal(decimal(snapshot, "originalAmount"), amount); - BigDecimal unitPrice = firstDecimal(decimal(snapshot, "unitPrice"), quantity != null && quantity.signum() > 0 - ? originalAmount.divide(quantity, 6, java.math.RoundingMode.HALF_UP) : null); - return new FeeSourceSummary(rs.getLong("id"), rs.getString("source_system"), rs.getString("source_type"), - firstText(rs.getString("source_no"), text(snapshot, "sourceBusinessId")), rs.getLong("source_fee_id"), rs.getInt("source_fee_version"), - rs.getString("direction"), rs.getLong("customer_id"), text(snapshot, "customerName"), - jsonLong(snapshot, "supplierId"), text(snapshot, "supplierName"), nullableLong(rs, "order_id"), - nullableLong(rs, "inbound_id"), text(snapshot, "inboundCode"), nullableLong(rs, "container_id"), - text(snapshot, "containerNo"), date(snapshot, "businessDate"), rs.getString("fee_item_name"), - quantity, text(snapshot, "quantityUnit"), unitPrice, nullableDate(snapshot, "periodStart"), nullableDate(snapshot, "periodEnd"), - amount, rs.getString("currency_code"), firstText(text(snapshot, "originalCurrency"), rs.getString("currency_code")), originalAmount, - firstDecimal(decimal(snapshot, "exchangeRate"), BigDecimal.ONE), text(snapshot, "exchangeRateMonth"), - defaultString(rs.getString("review_status"), "DRAFT"), - rs.getString("effective_status"), generated); - } - - private List logs(long customerId, long id) { - return jdbc.query("SELECT created_at,action,operator_name_snapshot,reason FROM fin_audit_log WHERE tenant_id=? AND resource_type='FIN_FEE_SOURCE' AND resource_id=? ORDER BY created_at DESC,id DESC", (rs, n) -> - new OperationLog(time(rs, "created_at"), rs.getString("action"), rs.getString("operator_name_snapshot"), "SUCCESS", rs.getString("reason")), customerId, id); + rs.getString("review_reason"), rs.getString("source_hash"), rs.getInt("operation_version"), feeSources.logs(rs.getLong("customer_id"), id)); } private Long reserve(long customerId, long operatorId, String operation, String key, String method, String path, Object body) { diff --git a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java index 1c052ca3f..ff8b8a6ca 100644 --- a/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinanceContractTest.java @@ -17,6 +17,7 @@ class FinanceContractTest { .getAnnotation(org.springframework.scheduling.annotation.EnableScheduling.class)); } + /** 固定财务访问域与账单关联键,查询迁入 Mapper 后仍核对真实来源记录。 */ @Test void controllersUseFrozenFinancePaths() throws Exception { assertEquals("/api/platform/finance/v1", PlatformFinanceController.class.getAnnotation(org.springframework.web.bind.annotation.RequestMapping.class).value()[0]); @@ -40,7 +41,8 @@ class FinanceContractTest { assertTrue(controller.contains("costSnapshotClient.loadingPlan") && controller.contains("createCost(request.customerId(), user.userId(), internal, volumes, key)")); assertTrue(service.contains("validateVoucherFiles(tenantId, request.voucherFileIds())")); String operations = Files.readString(Path.of("src/main/java/com/baiyue/finance/service/FinancePhase1OperationService.java")); - assertTrue(operations.contains("bl.source_id=f.id")); + String feeReads = Files.readString(Path.of("src/main/java/com/baiyue/finance/mapper/FinanceFeeSourceReadMapper.java")); + assertTrue(feeReads.contains("bl.source_id=f.id")); assertTrue(operations.contains("Boolean.class, id, rs.getInt(\"source_fee_version\")")); assertFalse(operations.contains("bl.source_id=f.source_fee_id")); String unified = Files.readString(Path.of("../deploy/sql/头程数据库统一增量-MySQL8.sql")); diff --git a/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java new file mode 100644 index 000000000..c48e46ab7 --- /dev/null +++ b/finance/src/test/java/com/baiyue/finance/FinanceFeeSourceReadMapperTest.java @@ -0,0 +1,141 @@ +package com.baiyue.finance; + +import com.baiyue.finance.mapper.FinanceFeeSourceReadMapper; +import com.baiyue.finance.repository.FinanceFeeSourceReadRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.ibatis.mapping.Environment; +import org.apache.ibatis.session.Configuration; +import org.apache.ibatis.session.SqlSessionFactoryBuilder; +import org.apache.ibatis.transaction.jdbc.JdbcTransactionFactory; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +/** 隔离数据库执行正式 Mapper,验证版本归并与历史不会改变金额和客户归属。 */ +public class FinanceFeeSourceReadMapperTest { + /** 应收和应付均以来源身份替换当前展示,同名费用、其他来源类型和系统保持独立。 */ + @ParameterizedTest + @ValueSource(strings = {"RECEIVABLE", "PAYABLE"}) + void currentProjectionAndHistoryKeepStableFeeIdentity(String direction) { + var data = new DriverManagerDataSource("jdbc:h2:mem:fee-current-" + UUID.randomUUID() + ";MODE=MySQL", "sa", ""); + var jdbc = new JdbcTemplate(data); + // 保持一个会话存活;Mapper 使用同一隔离数据库,绝不访问共享测试环境。 + try (var connection = data.getConnection()) { + jdbc.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonExtract'"); + jdbc.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.finance.FinanceFeeSourceReadMapperTest.jsonUnquote'"); + jdbc.execute("CREATE TABLE fin_fee_source(id BIGINT PRIMARY KEY,source_system VARCHAR(32),source_type VARCHAR(32),source_fee_id BIGINT,source_fee_version INT,customer_id BIGINT,direction VARCHAR(16),source_no VARCHAR(64),fee_item_name VARCHAR(100),amount DECIMAL(20,6),currency_code VARCHAR(3),review_status VARCHAR(16),status VARCHAR(16),source_snapshot VARCHAR(2000),order_id BIGINT,inbound_id BIGINT,container_id BIGINT,created_at TIMESTAMP)"); + jdbc.execute("CREATE TABLE fin_bill_line(source_id BIGINT,source_version INT)"); + jdbc.execute("CREATE TABLE fin_audit_log(id BIGINT PRIMARY KEY,tenant_id BIGINT,resource_type VARCHAR(32),resource_id BIGINT,action VARCHAR(64),operator_name_snapshot VARCHAR(64),reason VARCHAR(100),before_data VARCHAR(1000),after_data VARCHAR(1000),created_at TIMESTAMP)"); + insert(jdbc, 1, "FIRSTLEG", "INBOUND_FEE", 700, 1, 9, direction, "SUPERSEDED", "100.00"); + insert(jdbc, 2, "FIRSTLEG", "INBOUND_FEE", 700, 2, 9, direction, "AVAILABLE", "120.00"); + insert(jdbc, 3, "FIRSTLEG", "INBOUND_FEE", 701, 1, 9, direction, "AVAILABLE", "80.00"); + insert(jdbc, 4, "FIRSTLEG", "INBOUND_FEE", 702, 1, 9, direction, "AVAILABLE", "50.00"); + insert(jdbc, 5, "FIRSTLEG", "INBOUND_FEE", 702, 2, 9, direction, "VOIDED", "70.00"); + insert(jdbc, 6, "LASTLEG", "INBOUND_FEE", 700, 1, 9, direction, "AVAILABLE", "33.00"); + insert(jdbc, 7, "FIRSTLEG", "CONTAINER_FEE", 700, 1, 9, direction, "AVAILABLE", "44.00"); + insert(jdbc, 8, "FIRSTLEG", "INBOUND_FEE", 703, 1, 10, direction, "AVAILABLE", "55.00"); + jdbc.update("INSERT INTO fin_audit_log VALUES(1,9,'FIN_FEE_SOURCE',1,'FEE_SOURCE_REVIEW_APPROVE','审核员','原费用审核',NULL,NULL,'2026-10-21 10:01:30')"); + jdbc.update("INSERT INTO fin_audit_log VALUES(2,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_ADJUST','财务员','更正费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:03:00')"); + jdbc.update("INSERT INTO fin_audit_log VALUES(3,9,'FIN_FEE_SOURCE',3,'FEE_SOURCE_ADJUST','其他费用','不能合并',NULL,NULL,'2026-10-21 10:04:00')"); + jdbc.update("INSERT INTO fin_audit_log VALUES(4,10,'FIN_FEE_SOURCE',1,'FEE_SOURCE_ADJUST','其他客户','不能越界',NULL,NULL,'2026-10-21 10:04:00')"); + + var configuration = new Configuration(new Environment("test", new JdbcTransactionFactory(), data)); + configuration.addMapper(FinanceFeeSourceReadMapper.class); + try (var session = new SqlSessionFactoryBuilder().build(configuration).openSession()) { + var repository = new FinanceFeeSourceReadRepository(session.getMapper(FinanceFeeSourceReadMapper.class), new ObjectMapper()); + var filters = repository.filters(9L, direction, null, "APPROVED", false, + LocalDate.of(2026, 10, 1), LocalDate.of(2026, 10, 31), "操作费", 1, 20); + assertThat(repository.count(filters)).isEqualTo(5); + var rows = repository.list(filters); + assertThat(rows).extracting(row -> row.feeSourceId()).containsExactly(7L, 6L, 5L, 3L, 2L); + assertThat(rows.stream().map(row -> row.amount()).reduce(BigDecimal.ZERO, BigDecimal::add)) + .isEqualByComparingTo("347.00"); + assertThat(rows).allSatisfy(row -> { + assertThat(row.direction()).isEqualTo(direction); + assertThat(row.customerId()).isEqualTo(9); + assertThat(row.supplierId()).isEqualTo(79); + assertThat(row.inboundCode()).isEqualTo("IN-9"); + assertThat(row.businessDate()).isEqualTo(LocalDate.of(2026, 10, 21)); + assertThat(row.originalAmount()).isEqualByComparingTo("200"); + assertThat(row.unitPrice()).isEqualByComparingTo("20"); + }); + filters.put("limit", 2); + filters.put("offset", 2); + assertThat(repository.count(filters)).isEqualTo(5); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(5L, 3L); + filters.put("limit", 20); filters.put("offset", 0); filters.put("status", "AVAILABLE"); + assertThat(repository.count(filters)).isEqualTo(4); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).doesNotContain(4L, 5L); + filters.put("status", "SUPERSEDED"); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(1L); + + var logs = repository.logs(9, 2); + assertThat(logs).hasSize(4); + assertThat(logs).extracting(log -> log.action()).containsExactly( + "FEE_SOURCE_ADJUST", "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_REVIEW_APPROVE", "FEE_SOURCE_PUBLISHED"); + assertThat(logs.get(0).remark()).isEqualTo("更正费用;金额 100 → 120 CNY"); + assertThat(logs.get(1).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY"); + assertThat(logs).noneSatisfy(log -> assertThat(log.operatorName()).startsWith("其他")); + assertThat(repository.logs(9, 2)).isEqualTo(logs); + assertThat(repository.logs(9, 1)).hasSize(2); + assertThat(repository.logs(10, 2)).isEmpty(); + // 同毫秒必须先展示新版本;审计仍按 ID 倒序,不能反转金额差分或突破旧版本锚点。 + jdbc.update("UPDATE fin_fee_source SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)"); + jdbc.update("UPDATE fin_audit_log SET created_at='2026-10-21 10:03:00.123' WHERE id IN (1,2)"); + jdbc.update("INSERT INTO fin_audit_log VALUES(6,9,'FIN_FEE_SOURCE',2,'FEE_SOURCE_REVIEW_APPROVE','审核员','同毫秒后审计',NULL,NULL,'2026-10-21 10:03:00.123')"); + session.clearCache(); + var sameTimeLogs = repository.logs(9, 2); + assertThat(sameTimeLogs).extracting(log -> log.action()).containsExactly( + "FEE_SOURCE_VERSION_ADJUST", "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE", + "FEE_SOURCE_ADJUST", "FEE_SOURCE_REVIEW_APPROVE"); + assertThat(sameTimeLogs.get(0).remark()).contains("来源版本 1 → 2", "100.000000 → 120.000000 CNY"); + assertThat(sameTimeLogs.get(1).remark()).contains("来源版本 1;金额 100.000000 CNY"); + assertThat(sameTimeLogs.subList(2, 5)).extracting(log -> log.remark()).containsExactly( + "同毫秒后审计", "更正费用;金额 100 → 120 CNY", "原费用审核"); + assertThat(repository.logs(9, 2)).isEqualTo(sameTimeLogs); + assertThat(repository.logs(9, 1)).extracting(log -> log.action()).containsExactly( + "FEE_SOURCE_PUBLISHED", "FEE_SOURCE_REVIEW_APPROVE"); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fin_fee_source", Long.class)).isEqualTo(8); + jdbc.update("INSERT INTO fin_bill_line VALUES(2,2)"); + filters.put("status", "BILLED"); filters.put("statementGenerated", true); + assertThat(repository.count(filters)).isEqualTo(1); + assertThat(repository.list(filters)).extracting(row -> row.feeSourceId()).containsExactly(2L); + + // 财务内部费用原地改金额,其历史只取真实审计,不能伪造旧时间的同步金额。 + insert(jdbc, 9, "FINANCE", "MANUAL", 700, 1, 9, direction, "AVAILABLE", "120.00"); + jdbc.update("INSERT INTO fin_audit_log VALUES(5,9,'FIN_FEE_SOURCE',9,'FEE_SOURCE_ADJUST','财务员','更正人工费用','{\"amount\":100}','{\"amount\":120,\"currency\":\"CNY\"}','2026-10-21 10:05:00')"); + assertThat(repository.logs(9, 9)).singleElement().satisfies(log -> { + assertThat(log.action()).isEqualTo("FEE_SOURCE_ADJUST"); + assertThat(log.remark()).isEqualTo("更正人工费用;金额 100 → 120 CNY"); + }); + } + } catch (java.sql.SQLException failure) { throw new AssertionError(failure); } + } + + /** 准备具有相同名称、不同稳定来源的隔离费用快照。 */ + private static void insert(JdbcTemplate jdbc, long id, String system, String type, long fee, int version, + long customer, String direction, String status, String amount) { + jdbc.update("INSERT INTO fin_fee_source(id,source_system,source_type,source_fee_id,source_fee_version,customer_id,direction,source_no,fee_item_name,amount,currency_code,review_status,status,source_snapshot,created_at) VALUES(?,?,?,?,?,?,?,'IN-9','操作费',?,'CNY','APPROVED',?,?,?)", + id, system, type, fee, version, customer, direction, new BigDecimal(amount), status, + "{\"businessDate\":\"2026-10-21\",\"supplierId\":79,\"inboundCode\":\"IN-9\",\"chargeableQuantity\":10,\"originalAmount\":200}", + java.sql.Timestamp.valueOf("2026-10-21 10:0" + version + ":00")); + } + + /** H2 仅补足 MySQL JSON 读取函数,实际筛选、关联和分页仍执行正式 SQL。 */ + public static String jsonExtract(String value, String path) throws Exception { + var node = new ObjectMapper().readTree(value).path(path.substring(2)); + return node.isMissingNode() || node.isNull() ? null : node.toString(); + } + + /** 对测试 JSON 标量解引号,保持 MySQL 空值语义。 */ + public static String jsonUnquote(String value) throws Exception { + return value == null ? null : new ObjectMapper().readTree(value).asText(); + } +} diff --git a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java index 7b826cd3b..fa4cdff2d 100644 --- a/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java +++ b/finance/src/test/java/com/baiyue/finance/FinancePhase1OperationBehaviorTest.java @@ -9,10 +9,13 @@ import com.baiyue.finance.dto.FinancePhase1Dtos.FeeSourceDetail; import com.baiyue.finance.dto.FinancePhase1Dtos.PageEnvelope; import com.baiyue.finance.service.FinanceEventException; import com.baiyue.finance.service.FinancePhase1OperationService; +import com.baiyue.finance.repository.FinanceFeeSourceReadRepository; import com.baiyue.finance.service.FinanceCustomerAccountService; import com.fasterxml.jackson.databind.ObjectMapper; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.jdbc.core.JdbcTemplate; @@ -40,6 +43,9 @@ import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.times; @ExtendWith(MockitoExtension.class) class FinancePhase1OperationBehaviorTest { @@ -81,18 +87,20 @@ class FinancePhase1OperationBehaviorTest { assertThat(response.page().total()).isEqualTo(1); } + /** 无效客户在进入持久化查询前拒绝。 */ @Test void ledgerRejectsInvalidCustomerBeforeAnyQuery() { - FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); assertThatThrownBy(() -> service.listDerivedCustomerAccountLedger(0, 1, 20, null, null, null)) .isInstanceOf(FinanceEventException.class) .extracting(error -> ((FinanceEventException) error).code()).isEqualTo("FIN-REQ-400"); } + /** 保留客户账户汇总的固定分页与金额口径。 */ @Test void accountListReturnsFixedPagedDto() { AccountJdbc jdbc = new AccountJdbc(); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); PageEnvelope result = service.listDerivedCustomerAccounts(1, 20, "客户9", "ACTIVE"); @@ -112,10 +120,11 @@ class FinancePhase1OperationBehaviorTest { assertThat(jdbc.accountListSql).doesNotContain("CAST(c.customer_id AS CHAR)"); } + /** 所有派生流水查询保留目标客户范围。 */ @Test void ledgerQueriesEveryDerivedSourceWithinCustomerScope() { AccountJdbc jdbc = new AccountJdbc(); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); PageEnvelope result = service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); @@ -128,58 +137,24 @@ class FinancePhase1OperationBehaviorTest { .contains("fin_settlement_payment", "transaction_type='PAYMENT'", "s.direction='PAYABLE'")); } + /** 所有筛选在服务端进入同一版本投影,不在分页后按费用名称去重。 */ @Test - void feeSourceListUsesFormalFieldsAndAllActiveFilters() throws Exception { - JdbcTemplate jdbc = mock(JdbcTemplate.class); - when(jdbc.queryForObject(startsWith("SELECT COUNT(*) FROM fin_fee_source"), eq(Long.class), any(Object[].class))) - .thenReturn(1L); - when(jdbc.query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class))).thenAnswer(invocation -> { - @SuppressWarnings("unchecked") RowMapper mapper = invocation.getArgument(1); - ResultSet rs = mock(ResultSet.class); - when(rs.getLong("id")).thenReturn(88L); - when(rs.getLong("source_fee_id")).thenReturn(9001L); - when(rs.getInt("source_fee_version")).thenReturn(3); - when(rs.getLong("customer_id")).thenReturn(9L); - when(rs.getLong("order_id")).thenReturn(19L); - when(rs.getLong("inbound_id")).thenReturn(29L); - when(rs.getLong("container_id")).thenReturn(39L); - when(rs.getString("source_system")).thenReturn("FIRSTLEG"); - when(rs.getString("source_type")).thenReturn("INBOUND_FEE"); - when(rs.getString("source_no")).thenReturn("IN-9"); - when(rs.getString("direction")).thenReturn("RECEIVABLE"); - when(rs.getString("fee_item_name")).thenReturn("入库操作费"); - when(rs.getString("currency_code")).thenReturn("CNY"); - when(rs.getString("review_status")).thenReturn("APPROVED"); - when(rs.getString("effective_status")).thenReturn("AVAILABLE"); - when(rs.getString("source_snapshot")).thenReturn("{\"customerName\":\"客户9\",\"supplierId\":\"79\",\"supplierName\":\"供应商A\",\"inboundCode\":\"IN-9\",\"containerNo\":\"CONT-9\",\"businessDate\":\"2026-08-11\",\"chargeableQuantity\":\"10\"}"); - when(rs.getBigDecimal("amount")).thenReturn(new BigDecimal("120.00")); - when(rs.getBoolean("statement_generated")).thenReturn(false); - return List.of(mapper.mapRow(rs, 0)); - }); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); - + void feeSourceListUsesFormalFieldsAndAllActiveFilters() { + var reads = mock(FinanceFeeSourceReadRepository.class); + var filters = java.util.Map.of("query", "current"); + when(reads.filters(9L, "RECEIVABLE", "AVAILABLE", "APPROVED", false, + LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9", 2, 50)).thenReturn(filters); + when(reads.list(filters)).thenReturn(List.of()); + when(reads.count(filters)).thenReturn(3L); + var service = new FinancePhase1OperationService(mock(JdbcTemplate.class), objectMapper(), null, "", reads); var result = service.listFinanceFeeSources(2, 50, 9L, "receivable", "available", "approved", false, LocalDate.of(2026, 8, 1), LocalDate.of(2026, 8, 31), "IN-9"); - - assertThat(result.page()).satisfies(meta -> { - assertThat(meta.page()).isEqualTo(2); - assertThat(meta.pageSize()).isEqualTo(50); - assertThat(meta.total()).isEqualTo(1); - }); - assertThat(result.data()).singleElement().satisfies(source -> { - assertThat(source.sourceSystem()).isEqualTo("FIRSTLEG"); - assertThat(source.sourceBusinessId()).isEqualTo("IN-9"); - assertThat(source.inboundCode()).isEqualTo("IN-9"); - assertThat(source.containerNo()).isEqualTo("CONT-9"); - assertThat(source.supplierId()).isEqualTo(79L); - assertThat(source.supplierName()).isEqualTo("供应商A"); - assertThat(source.reviewStatus()).isEqualTo("APPROVED"); - assertThat(source.businessDate()).isEqualTo(LocalDate.of(2026, 8, 11)); - assertThat(source.statementGenerated()).isFalse(); - }); - verify(jdbc).query(startsWith("SELECT f.*"), any(RowMapper.class), any(Object[].class)); + assertThat(result.page().total()).isEqualTo(3); + assertThat(result.page().page()).isEqualTo(2); + verify(reads).list(filters); } + /** 流水返回业务单号和操作者快照。 */ @Test void ledgerReturnsStableBusinessTypeDocumentAndOperatorNameSnapshot() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); @@ -197,7 +172,7 @@ class FinancePhase1OperationBehaviorTest { }); when(jdbc.query(startsWith("SELECT id,receipt_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); when(jdbc.query(startsWith("SELECT id,cost_no"), any(RowMapper.class), eq(9L))).thenReturn(List.of()); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); PageEnvelope result = service.listDerivedCustomerAccountLedger(9L, 1, 20, null, null, null); @@ -211,18 +186,20 @@ class FinancePhase1OperationBehaviorTest { }); } + /** 保留费用审核状态读取。 */ @Test void approvedFeeCannotBeEditedAsDraft() throws Exception { JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 2); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); assertThat(service.getFinanceFeeSource(88L).reviewStatus()).isEqualTo("REJECTED"); } + /** 费用详情保持原客户与来源版本。 */ @Test void feeSourceDetailUsesFixedDtoAndRealCustomerScope() throws Exception { FinancePhase1OperationService service = new FinancePhase1OperationService( - feeSourceJdbc("APPROVED", true, 2), objectMapper()); + feeSourceJdbc("APPROVED", true, 2), objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); FeeSourceDetail detail = service.getFinanceFeeSource(88L); @@ -232,10 +209,11 @@ class FinancePhase1OperationBehaviorTest { assertThat(detail.reviewStatus()).isEqualTo("APPROVED"); } + /** 业务来源费用必须回到所属系统调整。 */ @Test void firstlegFeeEditAndAdjustAreBlockedInsteadOfMutatingFinanceCopy() throws Exception { JdbcTemplate jdbc = feeSourceJdbc("REJECTED", false, 1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); var request = new FinancePhase1Dtos.FeeSourceEditRequest( new BigDecimal("120.00"), "更正金额", 1); @@ -247,6 +225,7 @@ class FinancePhase1OperationBehaviorTest { (Object[]) any()); } + /** 头程作废等待权威来源事件,不能直接改财务副本。 */ @Test void voidingNyexx2608180003DelegatesToFirstlegAndWaitsForEvent() throws Exception { JdbcTemplate jdbc = feeSourceJdbc("APPROVED", false, 2); @@ -257,7 +236,7 @@ class FinancePhase1OperationBehaviorTest { .thenReturn(ApiResponse.success(new FirstlegFeeSourceClient.CommandResult( "INBOUND_FEE", 700L, 3, "VOIDED", false))); FinancePhase1OperationService service = new FinancePhase1OperationService( - jdbc, objectMapper(), firstleg, "internal-token"); + jdbc, objectMapper(), firstleg, "internal-token", mock(FinanceFeeSourceReadRepository.class)); FeeSourceDetail result = service.voidFinanceFeeSource(88L, 71L, "finance", "void-key", new FinancePhase1Dtos.FeeSourceActionRequest("测试作废", 2)); @@ -278,6 +257,7 @@ class FinancePhase1OperationBehaviorTest { (Object[]) any()); } + /** 审核以当前操作版本更新并追加同一记录的审计。 */ @Test void reviewUsesExpectedVersionAndReturnsUpdatedDetail() throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); @@ -289,7 +269,6 @@ class FinancePhase1OperationBehaviorTest { return List.of(mapper.mapRow(resultSet(review, false, version), 0)); }); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); - when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(List.of()); when(jdbc.update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), @@ -301,7 +280,7 @@ class FinancePhase1OperationBehaviorTest { eq(71L), eq("操作人 71"), any(), any())).thenReturn(1); when(jdbc.update(startsWith("UPDATE fin_idempotency_record SET status='SUCCEEDED'"), eq(88L), eq(9L), eq(71L), eq("FEE_SOURCE_REVIEW"), eq("review-key"))).thenReturn(1); - FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper()); + FinancePhase1OperationService service = new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class)); FeeSourceDetail result = service.reviewFinanceFeeSource(88L, 71L, "review-key", new FinancePhase1Dtos.ReviewRequest("APPROVE", "核对无误", 0)); @@ -311,6 +290,51 @@ class FinancePhase1OperationBehaviorTest { verify(jdbc).update(startsWith("UPDATE fin_fee_source SET review_status="), eq("APPROVED"), eq("核对无误"), eq(88L), eq(0)); } + /** 财务内部应收应付调整原费用记录,重复提交不能新增费用或重复记审计。 */ + @ParameterizedTest + @ValueSource(strings = {"RECEIVABLE", "PAYABLE"}) + void manualAdjustmentUpdatesOriginalFeeOnceAndChecksVersionAndFrozenState(String direction) throws Exception { + var jdbc = mock(JdbcTemplate.class); + var service = spy(new FinancePhase1OperationService(jdbc, objectMapper(), null, "", mock(FinanceFeeSourceReadRepository.class))); + var beforeJson = objectMapper().createObjectNode().put("feeSourceId", 88).put("sourceSystem", "FINANCE") + .put("sourceType", "MANUAL").put("sourceFeeId", 700).put("sourceVersion", 1).put("customerId", 9) + .put("direction", direction).put("amount", 100).put("currency", "CNY").put("operationVersion", 2) + .put("reviewStatus", "APPROVED").put("sourceStatus", "AVAILABLE").put("statementGenerated", false); + var before = objectMapper().treeToValue(beforeJson, FeeSourceDetail.class); + var after = objectMapper().treeToValue(beforeJson.deepCopy().put("amount", 120) + .put("operationVersion", 3).put("reviewStatus", "DRAFT"), FeeSourceDetail.class); + doReturn(before, after).when(service).getFinanceFeeSource(88L); + lenient().when(jdbc.update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED"))) + .thenReturn(1, 0); + var request = new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("120.00"), "调整金额", 2); + var result = service.editFinanceFeeSource(88, 71, "adjust-once", request, true); + assertThat(result.feeSourceId()).isEqualTo(88); + assertThat(result.sourceFeeId()).isEqualTo(700); + assertThat(result.direction()).isEqualTo(direction); + assertThat(result.amount()).isEqualByComparingTo("120"); + assertThat(result.operationVersion()).isEqualTo(3); + var hash = org.mockito.ArgumentCaptor.forClass(String.class); + verify(jdbc).update(startsWith("INSERT INTO fin_idempotency_record"), eq(9L), eq(71L), + eq("FEE_SOURCE_ADJUST"), eq("adjust-once"), hash.capture()); + lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_ADJUST"), eq("adjust-once"))) + .thenReturn(List.of(java.util.Map.of("request_hash", hash.getValue(), "status", "SUCCEEDED", "result_resource_id", 88L))); + assertThat(service.editFinanceFeeSource(88, 71, "adjust-once", request, true)).isEqualTo(after); + verify(jdbc, times(1)).update(startsWith("UPDATE fin_fee_source SET amount="), eq(new BigDecimal("120.00")), eq(88L), eq(2), eq("APPROVED")); + verify(jdbc, times(1)).update(startsWith("INSERT INTO fin_audit_log"), eq(9L), eq("FIN_FEE_SOURCE"), eq(88L), + eq("FEE_SOURCE_ADJUST"), eq("调整金额"), anyString(), anyString(), eq(71L), anyString(), any(), any()); + verify(jdbc, never()).update(startsWith("INSERT INTO fin_fee_source"), any(Object[].class)); + assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "adjust-once", + new FinancePhase1Dtos.FeeSourceEditRequest(new BigDecimal("121.00"), "调整金额", 2), true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("Idempotency-Key已用于其他请求"); + assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "stale-version", request, true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("状态或版本"); + doReturn(objectMapper().treeToValue(beforeJson.deepCopy().put("statementGenerated", true), FeeSourceDetail.class)) + .when(service).getFinanceFeeSource(88L); + assertThatThrownBy(() -> service.editFinanceFeeSource(88, 71, "frozen", request, true)) + .isInstanceOf(FinanceEventException.class).hasMessageContaining("已生成单据"); + } + + /** 准备指定来源版本的费用查询,不再模拟已迁入 Mapper 的审计查询。 */ private static JdbcTemplate feeSourceJdbc(String reviewStatus, boolean included, int version) throws Exception { JdbcTemplate jdbc = mock(JdbcTemplate.class); when(jdbc.query(startsWith("SELECT * FROM fin_fee_source"), any(RowMapper.class), (Object[]) any())).thenAnswer(invocation -> { @@ -318,7 +342,6 @@ class FinancePhase1OperationBehaviorTest { return List.of(mapper.mapRow(resultSet(reviewStatus, included, version), 0)); }); when(jdbc.queryForObject(startsWith("SELECT COUNT(*)>0"), eq(Boolean.class), eq(88L), eq(3))).thenReturn(false); - when(jdbc.query(startsWith("SELECT created_at,action"), any(RowMapper.class), eq(9L), eq(88L))).thenReturn(List.of()); lenient().when(jdbc.queryForList(startsWith("SELECT request_hash"), eq(9L), eq(71L), eq("FEE_SOURCE_SETTLEMENT"), eq("include-key"))).thenReturn(List.of()); return jdbc; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java index 3fe11e0b5..5bbaf6269 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingCompletionCommandMapper.java @@ -4,6 +4,7 @@ import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; import org.apache.ibatis.annotations.Insert; import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Options; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; import org.apache.ibatis.annotations.Update; @@ -55,28 +56,34 @@ public interface LoadingCompletionCommandMapper extends BaseMapperCURRENT_TIMESTAMP(3) """) int renew(@Param("id") long id, @Param("worker") String worker, @Param("leaseUntil") LocalDateTime leaseUntil); + /** 完成落库必须仍持有有效租约,拒绝过期或已易主的旧执行者。 */ @Update(""" UPDATE fl_loading_completion_command SET command_status='COMPLETED',completed_at=CURRENT_TIMESTAMP(3),updated_at=CURRENT_TIMESTAMP(3), lease_owner=NULL,lease_until=NULL,failure_stage=NULL,failure_code=NULL, failure_request_id=NULL,failure_trace_id=NULL WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker} + AND lease_until>CURRENT_TIMESTAMP(3) """) int complete(@Param("id") long id, @Param("worker") String worker); + /** 失败回执也受当前有效租约保护,不能覆盖新执行者的状态。 */ @Update(""" UPDATE fl_loading_completion_command SET command_status=#{status},next_retry_at=#{nextRetryAt},updated_at=CURRENT_TIMESTAMP(3), lease_owner=NULL,lease_until=NULL,failure_stage=#{stage},failure_code=#{code}, failure_request_id=#{requestId},failure_trace_id=#{traceId} WHERE id=#{id} AND command_status='PROCESSING' AND lease_owner=#{worker} + AND lease_until>CURRENT_TIMESTAMP(3) """) int fail(@Param("id") long id, @Param("worker") String worker, @Param("status") String status, @Param("nextRetryAt") LocalDateTime nextRetryAt, @Param("stage") String stage, @@ -97,13 +104,32 @@ public interface LoadingCompletionCommandMapper extends BaseMapperCURRENT_TIMESTAMP(3) + FOR UPDATE + """) + Long lockCheckpointLease(@Param("commandId") long commandId, @Param("worker") String worker); + + /** 在已锁定的命令行上重验有效租约;检查点只能前进,不能被迟到写入降级。 */ @Insert(""" INSERT INTO fl_loading_completion_checkpoint (command_id,tenant_id,inbound_id,owner_sequence,checkpoint_stage) - VALUES (#{commandId},#{tenantId},#{inboundId},#{sequence},#{stage}) - ON DUPLICATE KEY UPDATE checkpoint_stage=VALUES(checkpoint_stage),updated_at=CURRENT_TIMESTAMP(3) + SELECT id,#{tenantId},#{inboundId},#{sequence},#{stage} + FROM fl_loading_completion_command + WHERE id=#{commandId} AND command_status='PROCESSING' AND lease_owner=#{worker} + AND lease_until>CURRENT_TIMESTAMP(3) + ON DUPLICATE KEY UPDATE checkpoint_stage=CASE + WHEN checkpoint_stage='INBOUND_COMPLETED' OR #{stage}='INBOUND_COMPLETED' + THEN 'INBOUND_COMPLETED' + WHEN checkpoint_stage='SETTLED' OR #{stage}='SETTLED' THEN 'SETTLED' + ELSE 'READY' END,updated_at=CURRENT_TIMESTAMP(3) """) - int checkpointCompleted(@Param("commandId") long commandId, @Param("tenantId") long tenantId, + int checkpointCompleted(@Param("commandId") long commandId, @Param("worker") String worker, + @Param("tenantId") long tenantId, @Param("inboundId") long inboundId, @Param("sequence") int sequence, @Param("stage") String stage); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java index 7488f1163..5c25bd370 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastService.java @@ -11,16 +11,27 @@ import java.util.stream.Collectors; @Service public class AppointmentForecastService { public record Forecast(Long containerBookingId,AppointmentForecastMapper.Fact latest, - AppointmentForecastMapper.Fact previous,String timeBasis) { } + AppointmentForecastMapper.Fact previous,String timeBasis, + java.time.LocalDateTime portArrivalAt) { } private final AppointmentForecastMapper mapper; - public AppointmentForecastService(AppointmentForecastMapper mapper){this.mapper=mapper;} + private final com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources; + /** 读取船节点及日历权威到港来源,不从调用端接收推算日期。 */ + public AppointmentForecastService(AppointmentForecastMapper mapper, + com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository sources) { + this.mapper=mapper; this.sources=sources; + } + /** 返回明确来源柜的到港事实,保留未知日期。 */ public List batch(List ids) { if(ids==null || ids.isEmpty() || ids.size()>200 || ids.stream().anyMatch(id->id==null || id<=0) || new HashSet<>(ids).size()!=ids.size()) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"每批须提供1至200个不重复的柜次"); var facts=mapper.latest(ids).stream().collect(Collectors.groupingBy(AppointmentForecastMapper.Fact::containerBookingId)); + var arrivals=sources.byIds(ids).stream().collect(Collectors.toMap( + com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource::containerBookingId, + java.util.function.Function.identity())); return ids.stream().map(id->{ var rows=facts.getOrDefault(id,List.of()); - return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED"); + return new Forecast(id,rows.isEmpty()?null:rows.get(0),rows.size()<2?null:rows.get(1),"SOURCE_LOCAL_UNSPECIFIED", + arrivals.containsKey(id)?arrivals.get(id).arrivalAt():null); }).toList(); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java index d2f23881e..379c5126b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletionCommandRepository.java @@ -4,6 +4,7 @@ import com.baiyue.firstleg.entity.LoadingCompletionCommandEntity; import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper; import jakarta.annotation.Resource; import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; import java.time.LocalDateTime; import java.util.List; @@ -12,6 +13,9 @@ import java.util.List; public class LoadingCompletionCommandRepository { @Resource private LoadingCompletionCommandMapper mapper; + /** 按事务提交后的命令标识读取唯一完成请求。 */ + public LoadingCompletionCommandEntity command(long id) { return mapper.selectById(id); } + public int insert(LoadingCompletionCommandEntity command) { return mapper.insertCommand(command); } public LoadingCompletionCommandEntity operation(String operationId) { return mapper.findByOperation(operationId); } public LoadingCompletionCommandEntity active(long taskId) { return mapper.findActiveByTask(taskId); } @@ -28,7 +32,13 @@ public class LoadingCompletionCommandRepository { public String checkpoint(long commandId, long tenantId, long inboundId) { return mapper.checkpoint(commandId, tenantId, inboundId); } - public void checkpoint(long commandId, long tenantId, long inboundId, int sequence, String stage) { - mapper.checkpointCompleted(commandId, tenantId, inboundId, sequence, stage); + /** 命令行锁覆盖租约检查与检查点写入,旧执行者不能在租约易主后覆盖新结果。 */ + @Transactional + public boolean checkpoint(long commandId, String worker, long tenantId, long inboundId, + int sequence, String stage) { + if (mapper.lockCheckpointLease(commandId, worker) == null) return false; + int written = mapper.checkpointCompleted(commandId, worker, tenantId, inboundId, sequence, stage); + // 同阶段重放可能被驱动计为零行;命令行仍被本事务锁住,重验租约区分幂等与过期拒绝。 + return written > 0 || mapper.lockCheckpointLease(commandId, worker) != null; } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java index c03263fc5..0057602df 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingCompletionWorker.java @@ -9,37 +9,77 @@ import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.core.task.TaskExecutor; import org.springframework.stereotype.Service; +import org.springframework.transaction.event.TransactionalEventListener; import java.time.LocalDateTime; import java.util.UUID; +import java.util.concurrent.RejectedExecutionException; -/** Lease-based recovery worker for PDA completion commands. */ +/** 事务提交后立即处理 PDA 完成命令,并按持久化租约定时恢复。 */ @Service public class LoadingCompletionWorker { private static final Logger log = LoggerFactory.getLogger(LoadingCompletionWorker.class); private static final int MAX_ATTEMPTS = 5; - private final String workerId = "pda-completion-" + UUID.randomUUID(); @Resource private LoadingCompletionCommandRepository commands; @Resource private LoadingPickRepository picks; @Resource private LoadingPickService loadingPicks; + @Resource(name = "applicationTaskExecutor") private TaskExecutor completionExecutor; - @Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}") - public void dispatch() { - for (LoadingCompletionCommandEntity candidate : commands.due(10)) { - if (!commands.claim(candidate.getId(), workerId, LocalDateTime.now().plusMinutes(10))) { - continue; - } - try { - loadingPicks.processCompletion(candidate, workerId); - } catch (RuntimeException exception) { - handleFailure(candidate, exception); - } + /** 唤醒只发生在受理事务提交后,异步执行不延长 HTTP 受理请求。 */ + @TransactionalEventListener + public void accepted(LoadingPickService.CompletionAccepted accepted) { + try { + completionExecutor.execute(() -> dispatchAccepted(accepted)); + } catch (RejectedExecutionException exception) { + // 执行器繁忙或关闭时保留持久化命令,仍由定时扫描领取,不能影响已提交回执。 + log.warn("pda_completion_wakeup_deferred commandId={}", accepted.commandId(), exception); } } - private void handleFailure(LoadingCompletionCommandEntity command, RuntimeException exception) { + /** 唤醒与恢复共用原子领取;同一命令不会被两个执行入口重复结算。 */ + private void dispatchAccepted(LoadingPickService.CompletionAccepted accepted) { + try { + LoadingCompletionCommandEntity command = commands.command(accepted.commandId()); + if (command != null) process(command, "immediate", accepted.acceptedAtNanos()); + } catch (RuntimeException exception) { + log.warn("pda_completion_wakeup_failed commandId={}", accepted.commandId(), exception); + } + } + + /** 扫描到期或租约过期命令,覆盖唤醒丢失、进程重启及失败退避恢复。 */ + @Scheduled(fixedDelayString = "${baiyue.firstleg.pda-completion.fixed-delay-ms:2000}") + public void dispatch() { + for (LoadingCompletionCommandEntity candidate : commands.due(10)) { + process(candidate, "scheduled", System.nanoTime()); + } + } + + /** 保留租约、检查点和失败恢复语义,记录领取及实际处理耗时。 */ + private void process(LoadingCompletionCommandEntity command, String source, long queuedAt) { + // 每次领取使用独立所有者,避免旧执行线程在租约过期、同进程重新领取后继续写入。 + String leaseOwner = "pda-completion-" + UUID.randomUUID(); + if (!commands.claim(command.getId(), leaseOwner, LocalDateTime.now().plusMinutes(10))) return; + long started = System.nanoTime(); + boolean succeeded = false; + log.info("pda_completion_started commandId={} taskId={} source={} wakeupMs={}", + command.getId(), command.getPickTaskId(), source, (started - queuedAt) / 1_000_000); + try { + loadingPicks.processCompletion(command, leaseOwner); + succeeded = true; + } catch (RuntimeException exception) { + handleFailure(command, leaseOwner, exception); + } finally { + log.info("pda_completion_finished commandId={} taskId={} source={} durationMs={} succeeded={}", + command.getId(), command.getPickTaskId(), source, (System.nanoTime() - started) / 1_000_000, + succeeded); + } + } + + /** 确定性失败关闭任务,其余故障按原退避与最大次数恢复。 */ + private void handleFailure(LoadingCompletionCommandEntity command, String leaseOwner, RuntimeException exception) { Throwable cause = exception instanceof LoadingPickService.CompletionProcessingException && exception.getCause() != null ? exception.getCause() : exception; String stage = exception instanceof LoadingPickService.CompletionProcessingException staged @@ -53,7 +93,7 @@ public class LoadingCompletionWorker { int attempt = command.getAttemptCount() == null ? 1 : command.getAttemptCount() + 1; boolean terminal = isDeterministicLocalBusinessFailure(stage, cause) || attempt >= MAX_ATTEMPTS; LocalDateTime retryAt = LocalDateTime.now().plusSeconds(Math.min(300L, 15L << Math.min(attempt - 1, 4))); - boolean recorded = commands.fail(command.getId(), workerId, terminal ? "FAILED" : "RETRYABLE", retryAt, + boolean recorded = commands.fail(command.getId(), leaseOwner, terminal ? "FAILED" : "RETRYABLE", retryAt, stage, code, requestId, traceId); if (terminal && recorded) { picks.failCompletion(command.getPickTaskId()); @@ -63,6 +103,7 @@ public class LoadingCompletionWorker { command.getOperationId(), stage, code, requestId, traceId, attempt, terminal, cause); } + /** 仅将已明确不可重试的本地业务门禁判为终态。 */ private static boolean isDeterministicLocalBusinessFailure(String stage, Throwable cause) { if (!(cause instanceof BizException business)) { return false; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java index e123514c4..d40166d80 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/LoadingPickService.java @@ -56,6 +56,7 @@ import jakarta.annotation.Resource; import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.StringUtils; @@ -87,6 +88,8 @@ public class LoadingPickService { @Resource private LoadingCompletionCommandRepository completionCommands; @Resource + private ApplicationEventPublisher completionEvents; + @Resource private TransactionTemplate completionTransaction; @Resource private SmartLoadingRepository smartLoadingRepository; @@ -552,6 +555,7 @@ public class LoadingPickService { || !completionCommands.resume(replay.getId()) || repository.resumeCompletion(taskId) != 1) { throw conflict("装柜完成命令状态已变化,请刷新后重试"); } + completionEvents.publishEvent(new CompletionAccepted(replay.getId(), System.nanoTime())); } return view(requiredTask(taskId), null); } @@ -598,9 +602,14 @@ public class LoadingPickService { if (repository.acceptCompletion(taskId, task.getVersion()) != 1) { throw conflict("PDA任务版本已变化,请刷新后复核"); } + // 提交后立即唤醒仍经持久化租约领取;回滚不得触发结算,定时扫描继续负责故障恢复。 + completionEvents.publishEvent(new CompletionAccepted(command.getId(), System.nanoTime())); return view(requiredTask(taskId), null); } + /** 本进程事务提交后的唤醒信号,权威命令和恢复事实仍保存在数据库。 */ + public record CompletionAccepted(long commandId, long acceptedAtNanos) {} + private void requireCompletionContainerNumber(long taskId) { ContainerBookingEntity booking = repository.activeBookingByTask(taskId); if (booking == null || !StringUtils.hasText(booking.getContainerNo())) { @@ -613,7 +622,7 @@ public class LoadingPickService { } } - /** Called only by the completion worker, after its durable lease has committed. */ + /** 仅在持久化租约领取成功后执行,远程结算不占用受理事务。 */ public void processCompletion(LoadingCompletionCommandEntity command, String worker) { long taskId = command.getPickTaskId(); inboundHoldGuard.requireTaskNotHeld(taskId); @@ -625,55 +634,55 @@ public class LoadingPickService { .toList(); if (owners.isEmpty()) throw new CompletionProcessingException("OWNER_DISCOVERY", conflict("PDA任务未关联有效入库单")); List taskGroups = coloadGroups(owners); - runCompletionStage("CABINET_EVIDENCE", () -> + runCompletionStage(taskId, "CABINET_EVIDENCE", () -> cabinetEvidenceService.requireComplete(task.getContainerBookingId())); - runCompletionStage("COMPLETED_EVENT_READY", () -> + runCompletionStage(taskId, "COMPLETED_EVENT_READY", () -> loadingListService.requireCompletedEventReady(task.getLoadingPlanId())); - // Phase 1 is read-only: cabinet evidence is checked once, then every owner must pass its business gates - // before any warehouse resource or inbound status can be changed. This prevents a later customer gate from - // leaving earlier customers partially completed. + // 全柜所有客户先通过只读门禁,再开始资源结算,避免后续客户失败造成前序客户局部完成。 for (int index = 0; index < owners.size(); index++) { requireCompletionLease(command.getId(), worker); LoadingPickItemMapper.InboundOwner owner = owners.get(index); String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); if (!checkpointAtLeast(checkpoint, "SETTLED")) { - runCompletionStage("OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady( + runCompletionStage(taskId, "OWNER_READY", () -> loadingDeliveryService.requirePdaCompletionReady( owner.tenantId(), owner.inboundId())); if (!checkpointAtLeast(checkpoint, "READY")) { - requireCompletionLease(command.getId(), worker); - completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), + recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "READY"); } } } - // Phase 2 is recoverable by per-owner checkpoints and stable idempotency keys. It starts only after the - // complete cabinet passed phase 1, while prior deployments' completed checkpoints remain safely resumable. + // 全柜门禁通过后,按客户检查点和稳定幂等键逐步结算,已完成的检查点可安全续做。 for (int index = 0; index < owners.size(); index++) { requireCompletionLease(command.getId(), worker); LoadingPickItemMapper.InboundOwner owner = owners.get(index); String checkpoint = completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId()); if (!checkpointAtLeast(checkpoint, "SETTLED")) { - runCompletionStage("WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner( + runCompletionStage(taskId, "WAREHOUSE_SETTLEMENT", () -> settlementService.settleOwner( taskId, owner.inboundId(), command.getOperatorId(), command.getOperatorName())); - requireCompletionLease(command.getId(), worker); - completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, "SETTLED"); + recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "SETTLED"); } if (!checkpointAtLeast(checkpoint, "INBOUND_COMPLETED")) { - runCompletionStage("INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement( + runCompletionStage(taskId, "INBOUND_COMPLETE", () -> loadingDeliveryService.completeAfterPdaSettlement( owner.tenantId(), owner.inboundId(), command.getOperatorId(), command.getOperatorName(), "PDA-" + command.getOperationId() + '-' + owner.inboundId(), command.getRemark())); - requireCompletionLease(command.getId(), worker); - completionCommands.checkpoint(command.getId(), owner.tenantId(), owner.inboundId(), index + 1, + recordCompletionCheckpoint(command.getId(), worker, owner.tenantId(), owner.inboundId(), index + 1, "INBOUND_COMPLETED"); } } - completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups)); + long transactionStarted = System.nanoTime(); + try { + completionTransaction.executeWithoutResult(ignored -> finalizeCompletion(command, worker, task, taskGroups)); + } finally { + log.info("pda_completion_transaction taskId={} durationMs={}", taskId, + (System.nanoTime() - transactionStarted) / 1_000_000); + } } - /** Final Firstleg convergence only; all remote owner settlement calls have completed before this short transaction. */ + /** 远程结算完成后,仅在短事务内收敛 Firstleg 本地完成事实。 */ private void finalizeCompletion(LoadingCompletionCommandEntity command, String worker, LoadingPickTaskEntity task, List taskGroups) { long taskId = task.getId(); @@ -688,11 +697,11 @@ public class LoadingPickService { insertOperation(taskId, command.getOperationId(), command.getRequestHash(), "COMPLETE_LOADING", command.getOperatorId(), command.getOperatorName()); } - runCompletionStage("PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, + runCompletionStage(taskId, "PLAN_COMPLETE", () -> loadingListService.finishFromPda(task.getLoadingPlanId(), taskId, command.getOperatorId(), command.getOperatorName())); - runCompletionStage("COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId())); + runCompletionStage(taskId, "COMPLETED_EVENT", () -> loadingListService.publishCompletedEvent(task.getLoadingPlanId())); for (GroupSnapshot group : taskGroups) { - runCompletionStage("COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(), + runCompletionStage(taskId, "COLOAD_CONSUME", () -> coloadGroups.consumeWholeGroup(group.groupId(), task.getLoadingPlanId(), "PDA-CONSUME-" + hash(command.getOperationId() + '|' + group.groupId()).substring(0, 48), command.getOperatorId(), command.getOperatorName())); } @@ -750,6 +759,14 @@ public class LoadingPickService { } } + /** 检查点失去当前有效租约时立即停止,不再推进后续客户或柜次。 */ + private void recordCompletionCheckpoint(long commandId, String worker, long tenantId, long inboundId, + int sequence, String stage) { + if (!completionCommands.checkpoint(commandId, worker, tenantId, inboundId, sequence, stage)) { + throw new CompletionProcessingException("LEASE_LOST", conflict("装柜完成命令租约已变化")); + } + } + private static void requireSameCompletionCommand(LoadingCompletionCommandEntity command, long taskId, String requestHash, long operatorId) { if (command == null || !Long.valueOf(taskId).equals(command.getPickTaskId()) @@ -764,11 +781,18 @@ public class LoadingPickService { return checkpoint != null && stages.indexOf(checkpoint) >= stages.indexOf(target); } - private static void runCompletionStage(String stage, Runnable action) { + /** 记录实际阶段耗时,区分业务处理与调度、前端刷新等待。 */ + private static void runCompletionStage(long taskId, String stage, Runnable action) { + long started = System.nanoTime(); + boolean succeeded = false; try { action.run(); + succeeded = true; } catch (RuntimeException exception) { throw new CompletionProcessingException(stage, exception); + } finally { + log.info("pda_completion_stage taskId={} stage={} durationMs={} succeeded={}", + taskId, stage, (System.nanoTime() - started) / 1_000_000, succeeded); } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java index 00c852160..85317d783 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapper.java @@ -9,6 +9,25 @@ import java.util.List; @Mapper public interface ContainerCalendarSourceMapper { + /** 按明确来源柜读取与预约日历相同的到港事实,实际到港优先。 */ + @Select(""" + + """) + List byIds(@Param("ids") List ids); + /** 同时选出到港、缺日期和可能还柜的有效出仓柜,最终日期由业务服务复核。 */ @Select(""" SELECT c.id containerBookingId,c.cabinet_no cabinetNo,c.container_no containerNo, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java index 9e6588345..70693fd27 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/terminaldrayage/repository/ContainerCalendarSourceRepository.java @@ -11,6 +11,8 @@ public class ContainerCalendarSourceRepository { private final ContainerCalendarSourceMapper mapper; /** 使用本服务 Mapper 读取日历所需柜级来源。 */ public ContainerCalendarSourceRepository(ContainerCalendarSourceMapper mapper) { this.mapper = mapper; } + /** 内部批量读取明确柜次,日期口径与平台日历一致。 */ + public List byIds(List ids) { return mapper.byIds(ids); } /** 到港前移两天覆盖默认到仓,直送还柜候选额外留一天覆盖夏令时。 */ public List sources(LocalDate from, LocalDate to) { return mapper.sources(from.atStartOfDay(), from.minusDays(2).atStartOfDay(), diff --git a/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml b/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml index 7f0304f16..5ce9bd548 100644 --- a/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml +++ b/firstleg/src/main/resources/openapi/appointment-forecasts-internal-v1.yaml @@ -34,6 +34,7 @@ paths: properties: containerBookingId: {type: string} timeBasis: {type: string, enum: [SOURCE_LOCAL_UNSPECIFIED]} + portArrivalAt: {type: string, format: date-time, nullable: true, description: 与预约日历相同的权威到港当地时间,最新有效实际到港优先,其次柜资料实际到港及预计到港;未知保持空} latest: {$ref: '#/components/schemas/Fact'} previous: {$ref: '#/components/schemas/Fact'} '401': {description: 内部身份缺失、错误或服务端未配置} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java index f4992b9ca..477181eee 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/portschedule/service/AppointmentForecastServiceTest.java @@ -9,8 +9,20 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.Mockito.*; class AppointmentForecastServiceTest { + /** 无船期预计观察时仍返回日历所用的柜级到港事实,供海外统一推算日期。 */ + @Test void returnsTheCalendarArrivalEvenWhenLatestForecastIsAbsent() { + var mapper=mock(AppointmentForecastMapper.class); + var sources=mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class); + var arrival=LocalDateTime.of(2026,10,30,23,30); + when(sources.byIds(List.of(1L))).thenReturn(List.of( + new com.baiyue.firstleg.terminaldrayage.dto.ContainerCalendarSource(1L,"D0001","CONT1", + null,null,null,null,arrival,"ACTUAL"))); + var result=new AppointmentForecastService(mapper,sources).batch(List.of(1L)); + assertThat(result.get(0).latest()).isNull(); + assertThat(result.get(0).portArrivalAt()).isEqualTo(arrival); + } @Test void preservesMissingForecastAndSourceLocalTimesWithoutSynthesizingEta() { - var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper); + var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class)); var observed=LocalDateTime.of(2026,10,10,12,0); var latest=new AppointmentForecastMapper.Fact(1L,20L,"carrier","REF",observed,null,null); var previous=new AppointmentForecastMapper.Fact(1L,10L,"carrier","REF-OLD",observed.minusDays(1),observed.plusDays(5),null); @@ -21,7 +33,7 @@ class AppointmentForecastServiceTest { verify(mapper).latest(List.of(1L,2L));verifyNoMoreInteractions(mapper); } @Test void rejectsInvalidBatchesAndMissingOrBlankInternalCredentials() { - var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper); + var mapper=mock(AppointmentForecastMapper.class);var service=new AppointmentForecastService(mapper,mock(com.baiyue.firstleg.terminaldrayage.repository.ContainerCalendarSourceRepository.class)); for(var ids:List.of(List.of(),List.of(1L,1L),List.of(-1L),Collections.nCopies(201,1L))) assertThatThrownBy(()->service.batch(ids)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); assertThatThrownBy(()->service.batch(null)).isInstanceOf(org.springframework.web.server.ResponseStatusException.class); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionCheckpointLeaseTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionCheckpointLeaseTest.java new file mode 100644 index 000000000..3f45f9909 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionCheckpointLeaseTest.java @@ -0,0 +1,190 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.firstleg.mapper.LoadingCompletionCommandMapper; +import org.h2.jdbcx.JdbcDataSource; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.aop.framework.ProxyFactory; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.datasource.DataSourceTransactionManager; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.AnnotationTransactionAttributeSource; +import org.springframework.transaction.interceptor.TransactionInterceptor; + +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.AdditionalAnswers.delegatesTo; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; + +/** 用真实 Mapper SQL、事务代理和独立连接验证完成检查点的租约隔离。 */ +class LoadingCompletionCheckpointLeaseTest { + private JdbcDataSource dataSource; + private JdbcTemplate jdbc; + private LoadingCompletionCommandMapper mapper; + private LoadingCompletionCommandRepository commands; + + /** 每个用例使用独立内存数据库,不访问共享环境。 */ + @BeforeEach + void setup() throws Exception { + dataSource = new JdbcDataSource(); + dataSource.setURL("jdbc:h2:mem:completion-lease-" + UUID.randomUUID() + + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=1000"); + jdbc = new JdbcTemplate(dataSource); + jdbc.execute(""" + CREATE TABLE fl_loading_completion_command( + id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP, + attempt_count INT,next_retry_at TIMESTAMP,updated_at TIMESTAMP,completed_at TIMESTAMP, + failure_stage VARCHAR(40),failure_code VARCHAR(32),failure_request_id VARCHAR(64),failure_trace_id VARCHAR(64)) + """); + jdbc.execute(""" + CREATE TABLE fl_loading_completion_checkpoint( + command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24), + updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id)) + """); + jdbc.update(""" + INSERT INTO fl_loading_completion_command(id,command_status,lease_owner,lease_until,attempt_count,next_retry_at) + VALUES(900,'PROCESSING','old-owner',DATEADD('HOUR',1,CURRENT_TIMESTAMP),1,CURRENT_TIMESTAMP) + """); + var configuration = new MybatisConfiguration(); + configuration.setMapUnderscoreToCamelCase(true); + configuration.addMapper(LoadingCompletionCommandMapper.class); + var factory = new MybatisSqlSessionFactoryBean(); + factory.setDataSource(dataSource); + factory.setConfiguration(configuration); + mapper = new SqlSessionTemplate(factory.getObject()).getMapper(LoadingCompletionCommandMapper.class); + commands = repository(mapper); + } + + /** 生产 Repository 的注解事务必须真实生效,覆盖锁定与写入两条 SQL。 */ + private LoadingCompletionCommandRepository repository(LoadingCompletionCommandMapper targetMapper) { + var target = new LoadingCompletionCommandRepository(); + ReflectionTestUtils.setField(target, "mapper", targetMapper); + var proxy = new ProxyFactory(target); + proxy.addAdvice(new TransactionInterceptor(new DataSourceTransactionManager(dataSource), + new AnnotationTransactionAttributeSource())); + return (LoadingCompletionCommandRepository) proxy.getProxy(); + } + + /** 模拟原执行者续租后暂停,租约过期并被新执行者领取推进,再恢复旧线程。 */ + @Test + void pausedOldOwnerCannotDowngradeTheNewOwnersCompletedCheckpoint() throws Exception { + var renewed = new CountDownLatch(1); + var resumeOldOwner = new CountDownLatch(1); + var executor = Executors.newSingleThreadExecutor(); + try { + var oldWrite = executor.submit(() -> { + assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isTrue(); + renewed.countDown(); + assertThat(resumeOldOwner.await(10, TimeUnit.SECONDS)).isTrue(); + return commands.checkpoint(900, "old-owner", 101, 50, 1, "READY"); + }); + assertThat(renewed.await(10, TimeUnit.SECONDS)).isTrue(); + jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900"); + assertThat(commands.claim(900, "new-owner", LocalDateTime.now().plusMinutes(10))).isTrue(); + assertThat(commands.checkpoint(900, "new-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue(); + resumeOldOwner.countDown(); + assertThat(oldWrite.get(10, TimeUnit.SECONDS)).isFalse(); + assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "SETTLED")).isZero(); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); + assertThat(jdbc.queryForObject("SELECT lease_owner FROM fl_loading_completion_command WHERE id=900", String.class)) + .isEqualTo("new-owner"); + } finally { + resumeOldOwner.countDown(); + executor.shutdownNow(); + } + } + + /** 锁定后的实际写入前,独立数据库连接仍不能取得同一命令行的锁。 */ + @Test + void commandRowRemainsLockedAcrossTheLeaseCheckAndCheckpointInsert() { + LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper)); + doAnswer(call -> { + assertThatThrownBy(() -> { + try (var connection = dataSource.getConnection(); + var statement = connection.prepareStatement("SELECT id FROM fl_loading_completion_command WHERE id=900 FOR UPDATE NOWAIT")) { + statement.executeQuery().close(); + } + }).isInstanceOf(SQLException.class) + .satisfies(error -> assertThat(((SQLException) error).getErrorCode()).isEqualTo(50200)); + return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2), + call.getArgument(3), call.getArgument(4), call.getArgument(5)); + }).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()); + assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue(); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY"); + } + + /** 初次锁定检查后若租约失效,实际 INSERT 也必须拒绝,不能仅依赖前一次检查。 */ + @Test + void checkpointInsertRechecksLeaseValidityAfterTheInitialLock() { + LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper)); + doAnswer(call -> { + jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900"); + return mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2), + call.getArgument(3), call.getArgument(4), call.getArgument(5)); + }).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()); + assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse(); + assertThat(commands.checkpoint(900, 101, 50)).isNull(); + } + + /** 驱动将已持久化的幂等重放计为零行时,仍须以锁内当前有效租约确认。 */ + @Test + void zeroAffectedRowsCanConfirmAnIdempotentCheckpointUnderTheSameValidLease() { + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue(); + LoadingCompletionCommandMapper observed = mock(LoadingCompletionCommandMapper.class, delegatesTo(mapper)); + doAnswer(call -> { + mapper.checkpointCompleted(call.getArgument(0), call.getArgument(1), call.getArgument(2), + call.getArgument(3), call.getArgument(4), call.getArgument(5)); + return 0; + }).when(observed).checkpointCompleted(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()); + assertThat(repository(observed).checkpoint(900, "old-owner", 101, 50, 1, "READY")).isTrue(); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("READY"); + } + + /** 已过期但尚未被重新领取的所有者也不能写检查点、续租或写命令终态。 */ + @Test + void expiredLeaseCannotWriteCheckpointRenewFailOrComplete() { + jdbc.update("UPDATE fl_loading_completion_command SET lease_until=DATEADD('SECOND',-1,CURRENT_TIMESTAMP) WHERE id=900"); + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse(); + assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero(); + assertThat(commands.renew(900, "old-owner", LocalDateTime.now().plusMinutes(10))).isFalse(); + assertThat(commands.fail(900, "old-owner", "FAILED", LocalDateTime.now(), "LEASE_LOST", "A0409", null, null)).isFalse(); + assertThat(commands.complete(900, "old-owner")).isFalse(); + assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM fl_loading_completion_checkpoint", Integer.class)).isZero(); + assertThat(jdbc.queryForObject("SELECT command_status FROM fl_loading_completion_command WHERE id=900", String.class)) + .isEqualTo("PROCESSING"); + } + + /** 同一当前所有者的重复或迟到阶段也只能保持或推进,不能降低完成检查点。 */ + @Test + void validOwnerCheckpointStagesAreMonotonic() { + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED")).isTrue(); + commands.checkpoint(900, "old-owner", 101, 50, 1, "READY"); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("SETTLED"); + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "INBOUND_COMPLETED")).isTrue(); + commands.checkpoint(900, "old-owner", 101, 50, 1, "SETTLED"); + commands.checkpoint(900, "old-owner", 101, 50, 1, "READY"); + assertThat(commands.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); + } + + /** 完成或失败后的命令不允许追加检查点,即使所有者与租期字段仍有旧值。 */ + @Test + void terminalCommandCannotAcceptAnotherCheckpoint() { + jdbc.update("UPDATE fl_loading_completion_command SET command_status='COMPLETED' WHERE id=900"); + assertThat(commands.checkpoint(900, "old-owner", 101, 50, 1, "READY")).isFalse(); + assertThat(mapper.checkpointCompleted(900, "old-owner", 101, 50, 1, "READY")).isZero(); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java index 072f61b37..06278d63b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletionOwnerPersistenceTest.java @@ -31,6 +31,8 @@ class LoadingCompletionOwnerPersistenceTest { jdbc.execute("CREATE TABLE fl_loading_plan_item(id BIGINT,loading_plan_id BIGINT,account_id BIGINT,inbound_id BIGINT,deleted INT,package_count INT)"); jdbc.execute("CREATE TABLE fl_loading_pick_item(id BIGINT,pick_task_id BIGINT,loading_plan_item_id BIGINT,item_status VARCHAR,picked_package_count INT)"); jdbc.execute("CREATE TABLE fl_loading_completion_checkpoint(command_id BIGINT,tenant_id BIGINT,inbound_id BIGINT,owner_sequence INT,checkpoint_stage VARCHAR(24),updated_at TIMESTAMP,PRIMARY KEY(command_id,tenant_id,inbound_id))"); + jdbc.execute("CREATE TABLE fl_loading_completion_command(id BIGINT PRIMARY KEY,command_status VARCHAR(16),lease_owner VARCHAR(128),lease_until TIMESTAMP)"); + jdbc.update("INSERT INTO fl_loading_completion_command VALUES(900,'PROCESSING','owner-test',DATEADD('HOUR',1,CURRENT_TIMESTAMP))"); jdbc.update("INSERT INTO fl_loading_pick_task VALUES(20,10)"); row(1, 101, 50, 3, "PARTIAL", 2, 10, 0); row(2, 101, 50, 1, "SKIPPED", 0, 10, 0); // same source SKU history @@ -76,11 +78,11 @@ class LoadingCompletionOwnerPersistenceTest { @Test void completedActualCheckpointsAreReadBackUnchangedAndSkippedReadyCheckpointIsNotACompletionOwner() { - checkpoints.checkpointCompleted(900, 101, 50, 1, "READY"); - checkpoints.checkpointCompleted(900, 101, 50, 1, "SETTLED"); - checkpoints.checkpointCompleted(900, 101, 50, 1, "INBOUND_COMPLETED"); - checkpoints.checkpointCompleted(900, 202, 60, 2, "INBOUND_COMPLETED"); - checkpoints.checkpointCompleted(900, 303, 70, 3, "READY"); + checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "READY"); + checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "SETTLED"); + checkpoints.checkpointCompleted(900, "owner-test", 101, 50, 1, "INBOUND_COMPLETED"); + checkpoints.checkpointCompleted(900, "owner-test", 202, 60, 2, "INBOUND_COMPLETED"); + checkpoints.checkpointCompleted(900, "owner-test", 303, 70, 3, "READY"); assertThat(checkpoints.checkpoint(900, 101, 50)).isEqualTo("INBOUND_COMPLETED"); assertThat(checkpoints.checkpoint(900, 202, 60)).isEqualTo("INBOUND_COMPLETED"); assertThat(checkpoints.checkpoint(900, 303, 70)).isEqualTo("READY"); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java index 264f0d2c2..6080d0cf1 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingCompletionWorkerTest.java @@ -8,22 +8,124 @@ import com.baiyue.firstleg.repository.LoadingPickRepository; import org.junit.jupiter.api.Test; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.task.TaskExecutor; +import org.springframework.transaction.TransactionDefinition; +import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.transaction.support.AbstractPlatformTransactionManager; +import org.springframework.transaction.support.DefaultTransactionStatus; +import org.springframework.transaction.support.TransactionTemplate; +import org.mockito.ArgumentCaptor; import java.time.LocalDateTime; import java.util.List; +import java.util.concurrent.RejectedExecutionException; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -@SpringJUnitConfig(LoadingCompletionWorker.class) +@SpringJUnitConfig({LoadingCompletionWorker.class, LoadingCompletionWorkerTest.TransactionEvents.class}) class LoadingCompletionWorkerTest { + /** 与生产事务装配一致,注册事务事件工厂,避免普通事件工厂立即调用监听器。 */ + @Configuration + @EnableTransactionManagement + static class TransactionEvents {} + @MockBean private LoadingCompletionCommandRepository commands; @MockBean private LoadingPickRepository picks; @MockBean private LoadingPickService loadingPicks; + @MockBean(name = "applicationTaskExecutor") private TaskExecutor completionExecutor; @jakarta.annotation.Resource private LoadingCompletionWorker worker; + @jakarta.annotation.Resource private ApplicationEventPublisher events; + + /** 真实事务同步回调必须在提交后才将完成工作交给执行器。 */ + @Test + void committedAcceptanceStartsImmediatelyThroughTheDurableLease() { + LoadingCompletionCommandEntity command = command(0); + when(commands.command(900L)).thenReturn(command); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + transactions().executeWithoutResult(status -> { + events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime())); + verify(completionExecutor, never()).execute(any()); + verify(loadingPicks, never()).processCompletion(any(), any()); + }); + + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(completionExecutor).execute(work.capture()); + verify(loadingPicks, never()).processCompletion(any(), any()); + work.getValue().run(); + + verify(commands).claim(eq(900L), any(String.class), any(LocalDateTime.class)); + verify(loadingPicks).processCompletion(eq(command), any(String.class)); + } + + /** 回滚后的命令没有权威落库事实,不允许唤醒或执行结算。 */ + @Test + void rolledBackAcceptanceNeverSchedulesCompletion() { + transactions().executeWithoutResult(status -> { + events.publishEvent(new LoadingPickService.CompletionAccepted(900L, System.nanoTime())); + status.setRollbackOnly(); + }); + verify(completionExecutor, never()).execute(any()); + verify(commands, never()).command(900L); + } + + /** 即时唤醒不能绕过定时执行者已取得的租约。 */ + @Test + void immediateWakeupDoesNotProcessAnAlreadyLeasedCommand() { + when(commands.command(900L)).thenReturn(command(0)); + worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime())); + ArgumentCaptor work = ArgumentCaptor.forClass(Runnable.class); + verify(completionExecutor).execute(work.capture()); + work.getValue().run(); + verify(loadingPicks, never()).processCompletion(any(), any()); + } + + /** 执行器拒绝时受理不报错,定时扫描仍可从原命令恢复。 */ + @Test + void rejectedWakeupLeavesScheduledRecoveryAvailable() { + doThrow(new RejectedExecutionException("busy")).when(completionExecutor).execute(any()); + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + worker.accepted(new LoadingPickService.CompletionAccepted(900L, System.nanoTime())); + worker.dispatch(); + verify(loadingPicks).processCompletion(eq(command), any(String.class)); + } + + /** 同进程重领已到期命令也必须更换所有者,旧线程不能续租或落库。 */ + @Test + void everyClaimUsesAnIndependentLeaseOwner() { + LoadingCompletionCommandEntity command = command(0); + when(commands.due(10)).thenReturn(List.of(command)); + when(commands.claim(eq(900L), any(String.class), any(LocalDateTime.class))).thenReturn(true); + worker.dispatch(); + worker.dispatch(); + ArgumentCaptor owners = ArgumentCaptor.forClass(String.class); + verify(loadingPicks, times(2)).processCompletion(eq(command), owners.capture()); + assertThat(owners.getAllValues()).doesNotHaveDuplicates(); + } + + /** 隔离数据库依赖,仅用 Spring 的真实事务同步机制核对提交、回滚边界。 */ + private static TransactionTemplate transactions() { + return new TransactionTemplate(new AbstractPlatformTransactionManager() { + /** 为每次测试提供独立事务对象。 */ + @Override protected Object doGetTransaction() { return new Object(); } + /** 测试事务不访问数据库。 */ + @Override protected void doBegin(Object transaction, TransactionDefinition definition) {} + /** 让 Spring 正常触发提交同步回调。 */ + @Override protected void doCommit(DefaultTransactionStatus status) {} + /** 让 Spring 正常触发回滚同步回调。 */ + @Override protected void doRollback(DefaultTransactionStatus status) {} + }); + } @Test void transientFailureIsReleasedForBackoffRetry() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java index 04795248e..982738c4c 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/LoadingPickServiceTest.java @@ -50,6 +50,9 @@ import org.mockito.InOrder; import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; +import org.springframework.test.context.event.RecordApplicationEvents; +import org.springframework.test.context.event.ApplicationEvents; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.transaction.TransactionStatus; import org.springframework.transaction.support.TransactionTemplate; @@ -77,7 +80,9 @@ import static org.mockito.Mockito.clearInvocations; import static org.assertj.core.api.Assertions.assertThat; @SpringJUnitConfig(LoadingPickService.class) +@RecordApplicationEvents class LoadingPickServiceTest { + @Autowired private ApplicationEvents completionEvents; @MockBean private LoadingPickRepository repository; @MockBean @@ -154,6 +159,8 @@ class LoadingPickServiceTest { when(repository.activeBookingByTask(20L)).thenReturn(booking); when(completionCommands.insert(any())).thenReturn(1); when(completionCommands.renew(anyLong(), anyString(), any(java.time.LocalDateTime.class))).thenReturn(true); + when(completionCommands.checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString())) + .thenReturn(true); when(repository.acceptCompletion(anyLong(), anyInt())).thenReturn(1); when(repository.inboundOwners(anyLong())).thenReturn(List.of( new LoadingPickItemMapper.InboundOwner(101L, 50L))); @@ -931,6 +938,7 @@ class LoadingPickServiceTest { assertThat(replay.taskStatus()).isEqualTo("COMPLETING"); verify(repository, never()).taskForUpdate(20L); verify(completionCommands, times(1)).insert(any()); + assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class).count()).isEqualTo(1); verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); } @@ -997,6 +1005,8 @@ class LoadingPickServiceTest { assertThat(result.workflowStatus()).isEqualTo("PROCESSING"); verify(completionCommands).resume(902L); + assertThat(completionEvents.stream(LoadingPickService.CompletionAccepted.class) + .map(LoadingPickService.CompletionAccepted::commandId)).containsExactly(902L); verify(repository).resumeCompletion(20L); verify(completionCommands, never()).insert(any()); } @@ -1089,7 +1099,7 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); verify(completionCommands, never()).checkpoint(eq(931L), eq(303L), eq(70L)); - verify(completionCommands, never()).checkpoint(eq(931L), anyLong(), anyLong(), anyInt(), anyString()); + verify(completionCommands, never()).checkpoint(eq(931L), anyString(), anyLong(), anyLong(), anyInt(), anyString()); verify(loadingListService).publishCompletedEvent(10L); verify(completionCommands).complete(931L, "worker-completed-checkpoints"); } @@ -1109,6 +1119,31 @@ class LoadingPickServiceTest { verify(loadingListService, never()).publishCompletedEvent(anyLong()); } + /** 任一检查点被租约拒绝后都不得继续下一业务步骤或最终完成柜次。 */ + @ParameterizedTest + @ValueSource(strings = {"READY", "SETTLED", "INBOUND_COMPLETED"}) + void lostLeaseAtCheckpointStopsFurtherCompletion(String rejectedStage) { + LoadingPickTaskEntity completing = task(); + completing.setTaskStatus("COMPLETING"); + when(repository.task(20L)).thenReturn(completing); + when(completionCommands.checkpoint(eq(940L), eq("old-owner"), eq(101L), eq(50L), eq(1), eq(rejectedStage))) + .thenReturn(false); + + assertThatThrownBy(() -> service.processCompletion(completionCommand(940L, "lost-checkpoint"), "old-owner")) + .isInstanceOf(LoadingPickService.CompletionProcessingException.class) + .satisfies(error -> assertThat(((LoadingPickService.CompletionProcessingException) error).stage()) + .isEqualTo("LEASE_LOST")); + if ("READY".equals(rejectedStage)) { + verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); + } + if (!"INBOUND_COMPLETED".equals(rejectedStage)) { + verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), + anyString(), anyString(), anyString()); + } + verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); + verify(completionCommands, never()).complete(anyLong(), anyString()); + } + @Test void anEntirelySkippedTaskCannotCreateACompletionCommand() { when(repository.taskForUpdate(20L)).thenReturn(task()); @@ -1271,8 +1306,8 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); - verify(completionCommands).checkpoint(904L, 101L, 50L, 1, "READY"); - verify(completionCommands, never()).checkpoint(eq(904L), anyLong(), anyLong(), anyInt(), + verify(completionCommands).checkpoint(904L, "worker-failure", 101L, 50L, 1, "READY"); + verify(completionCommands, never()).checkpoint(eq(904L), anyString(), anyLong(), anyLong(), anyInt(), eq("SETTLED")); verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); @@ -1300,7 +1335,7 @@ class LoadingPickServiceTest { verify(settlementService, never()).settleOwner(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingDeliveryService, never()).completeAfterPdaSettlement(anyLong(), anyLong(), anyLong(), anyString(), anyString(), anyString()); - verify(completionCommands, never()).checkpoint(anyLong(), anyLong(), anyLong(), anyInt(), anyString()); + verify(completionCommands, never()).checkpoint(anyLong(), anyString(), anyLong(), anyLong(), anyInt(), anyString()); verify(repository, never()).insert(any(LoadingPickOperationEntity.class)); verify(loadingListService, never()).finishFromPda(anyLong(), anyLong(), anyLong(), anyString()); verify(loadingListService, never()).publishCompletedEvent(anyLong()); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java index 6b5a89190..7649869ee 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/terminaldrayage/mapper/ContainerCalendarSourceMapperTest.java @@ -46,6 +46,8 @@ class ContainerCalendarSourceMapperTest { var calendar = session.getMapper(ContainerCalendarSourceMapper.class).sources(from, from.minusDays(2), from.minusDays(4), from.plusMonths(1)); var workbench = session.getMapper(CarrierScheduleQueryMapper.class).selectContainerActualFacts(java.util.List.of(1L, 2L)); assertEquals(2, calendar.size()); + var explicit = session.getMapper(ContainerCalendarSourceMapper.class).byIds(java.util.List.of(1L,2L)); + assertEquals(calendar,explicit); assertEquals(2, workbench.size()); for (int index = 0; index < calendar.size(); index++) { var expected = LocalDateTime.parse(index == 0 ? "2026-10-21T14:00:00" : "2026-10-22T14:00:00"); diff --git a/lastleg-service/docs/business-index.md b/lastleg-service/docs/business-index.md index c2221b6fe..18aae008f 100644 --- a/lastleg-service/docs/business-index.md +++ b/lastleg-service/docs/business-index.md @@ -22,10 +22,10 @@ |---|---| | 内部模块化基线与依赖分析 | `docs/lastleg-internal-modularization-baseline.md`、`scripts/analyze-lastleg-dependencies.mjs`、`scripts/analyze-lastleg-dependencies.test.mjs`、`src/test/resources/architecture/lastleg-slice-dependencies.json`;只约束本服务内部包依赖,不改变部署单元或数据库边界 | | 事件接收、平台订单列表/详情、柜级进度、目的仓调整 | `src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java`;`/api/platform/v1/last-leg/orders/container-summaries` 按 `sourceContainerBookingId` 服务端聚合全柜订单、入仓码、有效计划及托盘资源异常并分页返回全部稳定来源柜,已完成计划转换的柜仍保留用于接柜仓查询与修改;单票手工计划保存后,整柜最后一票齐全时自动生成并绑定全柜托盘码,一票柜立即生成,`resourcePendingPlanCount` 只作为失败重试事实;`/{sourceContainerBookingId}` 返回柜内全部订单/入仓码/计划状态且不受订单分页截断;`plannedArrivalAt` 只取装柜完成事件 `estimatedWarehouseArrivalAt` 冻结到 `ll_order.estimated_warehouse_at` 的正式事实,按其升序、null 最后、稳定柜 ID 排序;目的仓调整仅接受国内 Warehouse Service 平台公共 `ACTIVE + FBA` 主档,写入前由 `service/FbaWarehouseValidator.java` 经内部只读目录校验 ID/编码/名称一致 | -| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜全部订单的 `estimated_warehouse_at`,缺失或同柜不一致时阻断,人工与自动计划统一写其日期;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | +| 尾程订单完整计划、柜级到仓时间、暂扣与人工节点 | `controller/LastlegOrderController.java`、`service/LastlegOrderCommandService.java`、`inboundplan/service/InboundPlanService.java`、`dto/LastlegOrderCommandDtos.java`;Admin 清关提柜列表通过 `/orders/container-arrival-times/query` 批量读取整柜预计/实际到仓,预计时间在任何入库计划生成前按柜级版本与幂等键人工维护并原子同步全柜 `ll_order.estimated_warehouse_at`,实际时间只取海外 PDA 会话首次 `receiving_at`。单票计划写入校验订单版本、客户范围、订单冻结入仓码、稳定接柜仓 ID、当前仓库主档与幂等键;预计到仓日期不接受计划请求输入,必须锁定来源柜,并由 `ContainerWarehouseArrivalService` 统一解析:人工维护日期或已有计划冻结日期优先,未维护未建计划时读取 Firstleg 权威到港当地日期加两个自然日;缺失或同柜不一致时阻断,清关提柜、日历和人工/自动计划统一使用 `plannedDate`,默认日期不补零点,首个计划创建后按已有计划日期冻结;一件代发人工计划必须逐托沿用不可变头程封托的 DP 托盘码和箱数,托数及各托箱数均不得重新分配,禁止保存无法绑定真实资源的半成品计划;接柜仓编码/名称以该 ID 当前目录快照为准,不要求订单残留旧展示快照。暂扣/解除写不可变历史并发布 `InboundOrderDetainedOutboxService`;新增暂扣只允许完成入库且尚未进入出库流程的订单,解除暂扣不受该状态限制;暂扣只阻断排车和出库,不阻断入库计划或入库作业。人工节点必须一次提交固定八行完整快照且至少一个时间,首次保存后永久使用 `MANUAL` 来源,不得回退柜级节点或反向修改头程船节点 | | 客户尾程订单列表与详情 | `controller/MerchantLastlegOrderController.java`;客户范围只取认证身份,稳定类型/状态见 `model/LastlegOrderType.java`、`LastlegOrderStatus.java` | | 装柜完成事件消费 | `service/LoadingCompletedConsumer.java`;当前生产信封为 `FIRST_LEG_LOADING_PLAN_COMPLETED` V3,消费端除冻结 V2 货物事实外,按 `sourceContainerBookingId` 幂等投影物理柜五类国内装柜照片;FBA 可按箱数且不生成逐箱作业账,OVERSEAS 必须逐箱校验并生成箱级事实;V1/V2 仅用于历史事件读取 | -| 尾程单号生成 | `service/LastlegOrderNoGenerator.java` | +| 尾程单号生成 | `service/LastlegOrderNoGenerator.java`、`service/LastlegDocumentNumberGenerator.java`;头程来源使用14位 `WC + yyyyMMdd + 四位流水`,建单经 `service/LoadingCompletedConsumer.java` 在来源柜锁内复用已有号,正式格式见工作区 `docs/业务单据编号契约.md` | | 订单查询与更新 | `service/LastlegOrderService.java` | | 业务 Inbox | `entity/LastlegEventInbox.java`、`mapper/LastlegEventInboxMapper.java` | | 尾程订单、明细、状态历史 | `entity/LastlegOrder.java`、`LastlegOrderDetail.java`、`LastlegOrderStatusHistory.java` 及对应 Mapper | @@ -112,7 +112,7 @@ ## 事件建单规则 - 只接受冻结契约支持的事件类型和版本;校验事件 ID、租户、区域、Payload 哈希及必要字段。 -- 同一实际集装箱 `containerNo` 生成一个共享业务尾程单号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。整柜操作不使用 `cabinetNo`。 +- 同一冻结来源柜 `sourceContainerBookingId` 共用一个14位 `WC + yyyyMMdd + 四位流水` 尾程单号;首个交接事件在来源柜行锁内取号,后续客户或跨日交接复用该柜已保存的编号,事件重放不取新号。不同来源柜复用真实集装箱号时仍分别编号;`payload.orders` 每项按 `sourceInboundId` 独立落一条雪花 ID `ll_order`,并保存对应明细和初始状态历史。真实 `containerNo` 继续保留作实箱快照,`cabinetNo` 仅为柜次展示,不替代稳定来源柜身份。 - `ONE_CLICK_DROPSHIP` 每个来源入仓单必须携带一个或多个不可变来源托盘快照;每托按 SKU 携带箱数、数量和箱唛。校验订单汇总一致、托盘号/SKU/箱唛唯一后,按托建立档案及 SKU 库存,并建立箱唛和流水。仓库实物托盘由 Warehouse API 分配,不得覆盖来源托盘快照。 - 其他订单类型建立入仓单维度的尾程订单与冻结明细,并保留头程推荐托数;尾程入仓计划至少按推荐托数生成计划托盘,PDA 再把箱级事实绑定到海外仓实物托盘。 - 整个事件消费在一个事务内完成;任意订单失败时不得留下部分订单。 diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java index 75727828e..ff9c71e3b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentplanning/AppointmentForecastClient.java @@ -11,7 +11,7 @@ public interface AppointmentForecastClient { record Request(List containerBookingIds) { } record Fact(Long containerBookingId,Long factId,String providerCode,String sourceReference, LocalDateTime observedAt,LocalDateTime estimatedAt,LocalDateTime actualAt) { } - record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis) { } + record Forecast(Long containerBookingId,Fact latest,Fact previous,String timeBasis,LocalDateTime portArrivalAt) { } @PostMapping("/internal/firstleg/appointment-forecasts/batch") ApiResponse> batch(@RequestHeader("X-Internal-Service-Token") String token,@RequestBody Request request); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java index b3666a448..0e410f503 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapper.java @@ -12,24 +12,31 @@ public interface WarehouseCalendarMapper { @Select(""" ") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/ContainerWarehouseArrivalRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/ContainerWarehouseArrivalRepository.java new file mode 100644 index 000000000..d6b76cc0b --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/ContainerWarehouseArrivalRepository.java @@ -0,0 +1,16 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 柜级人工到仓与已有入库计划日期的唯一数据库读取入口。 */ +@Repository +public class ContainerWarehouseArrivalRepository { + private final LastlegOrderMapper mapper; + /** 复用本库柜级事实查询。 */ + public ContainerWarehouseArrivalRepository(LastlegOrderMapper mapper) { this.mapper=mapper; } + /** 保留同柜版本及计划数量,不把缺失时间转换成零点。 */ + public List find(List ids) { return mapper.findContainerArrivalTimes(ids); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/LastlegOrderNumberRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/LastlegOrderNumberRepository.java new file mode 100644 index 000000000..1c75aee56 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/LastlegOrderNumberRepository.java @@ -0,0 +1,21 @@ +package com.baiyue.lastleg.repository; + +import com.baiyue.lastleg.mapper.LastlegOrderMapper; +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 读取来源柜已经分配的业务尾程号,不按客户拆分柜级身份。 */ +@Repository +public class LastlegOrderNumberRepository { + private final LastlegOrderMapper mapper; + + /** 复用所属服务订单 Mapper。 */ + public LastlegOrderNumberRepository(LastlegOrderMapper mapper) { this.mapper = mapper; } + + /** 调用方已持有来源柜门禁行锁,同柜的后续客户事件沿用首个尾程号。 */ + public String findContainerOrderNo(String bookingId) { + List values = mapper.containerOrderNumbers(bookingId); + if (values.size() > 1) throw new IllegalStateException("同一来源柜存在多个尾程业务编号"); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalService.java new file mode 100644 index 000000000..ce79135e9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalService.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +import com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.util.List; +import java.util.HashMap; +import java.util.Map; + +/** 清关列表、预约日历与入库计划共用的到仓日期解析。 */ +@Service +public class ContainerWarehouseArrivalService { + private final ContainerWarehouseArrivalRepository repository; + private final AppointmentForecastClient forecasts; + private final String token; + + /** 到港事实只从 Firstleg 权威来源读取,人工与计划日期只访问本库。 */ + public ContainerWarehouseArrivalService(ContainerWarehouseArrivalRepository repository, + AppointmentForecastClient forecasts,@Value("${baiyue.security.internal-service-token:}") String token) { + this.repository=repository; this.forecasts=forecasts; this.token=token; + } + + /** 人工时间及计划冻结日期优先,其余按最新到港当地日期加两个自然日。 */ + public List resolve(List ids) { + if(ids.isEmpty()) return List.of(); + var rows=repository.find(ids); + var pending=rows.stream().filter(row->row.version()==0 && row.planCount()==0 + && row.estimatedWarehouseArrivalAt()==null).map(row->Long.valueOf(row.sourceContainerBookingId())).toList(); + Map defaults=new HashMap<>(); + for(int start=0;start(); + for(var fact:response.data()) { + if(fact==null || !batch.contains(fact.containerBookingId()) || !seen.add(fact.containerBookingId())) throw unavailable(); + if(fact.portArrivalAt()!=null) defaults.put(fact.containerBookingId(),fact.portArrivalAt().toLocalDate().plusDays(2)); + } + } + return rows.stream().map(row->{ + boolean automatic=row.version()==0 && row.planCount()==0 && row.estimatedWarehouseArrivalAt()==null; + LocalDate date=row.estimatedWarehouseArrivalAt()!=null?row.estimatedWarehouseArrivalAt().toLocalDate() + :automatic?defaults.get(Long.valueOf(row.sourceContainerBookingId())):row.plannedDate(); + return new ContainerArrivalTimeView(row.sourceContainerBookingId(),row.estimatedWarehouseArrivalAt(), + row.actualWarehouseArrivalAt(),row.version(),row.planCount(),date,automatic && date!=null); + }).toList(); + } + + /** 生成计划沿用同一解析结果,缺失或不一致时阻断,不改写来源事实。 */ + public LocalDate requiredDate(String id) { + var rows=resolve(List.of(id)); + if(rows.size()!=1 || rows.get(0).version()<0 || rows.get(0).plannedDate()==null) + throw new ResponseStatusException(HttpStatus.CONFLICT,"来源柜预计到仓日期缺失或不一致,请先核对柜级时间"); + return rows.get(0).plannedDate(); + } + + /** 来源失败必须明确提示,不能用本地旧快照伪造默认日期。 */ + private ResponseStatusException unavailable() { + return new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"来源柜到港日期暂不可读取,请稍后重试"); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java index 95a9a594f..9d3bb752e 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegDocumentNumberGenerator.java @@ -10,7 +10,7 @@ import java.util.Set; /** 按北京时间生成两至四位前缀、八位日期和四位日流水。 */ @Component public class LastlegDocumentNumberGenerator { - private static final Set PREFIXES = Set.of("IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR"); + private static final Set PREFIXES = Set.of("WC", "IP", "CK", "DFCK", "HBCK", "THCK", "DL", "EX", "CD", "TB", "ZY", "TH", "THHB", "RR", "RB", "HX", "YY", "TR", "LB", "CG", "PR", "PJ", "PF", "PN", "PX", "PG", "CT", "FB", "FO", "VR"); private final DocumentSequenceRepository sequences; private final Clock clock; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java index 3ba5d9878..6eaa838ff 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderNoGenerator.java @@ -1,14 +1,9 @@ package com.baiyue.lastleg.service; import org.springframework.stereotype.Component; -import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; -import java.security.NoSuchAlgorithmException; -import java.time.LocalDate; -import java.time.format.DateTimeFormatter; import java.util.Locale; -/** 海外单据类型映射;头程来源尾程号与实物箱码保持本次确认的范围边界。 */ +/** 海外单据类型映射;业务单号统一使用本服务日流水。 */ @Component public class LastlegOrderNoGenerator { private final LastlegDocumentNumberGenerator documents; @@ -21,6 +16,7 @@ public class LastlegOrderNoGenerator { /** 明确业务类型对应的前缀,未知类型不再截断名称或使用内部 ID 回退。 */ public String next(String businessType) { String prefix = switch (businessType) { + case "LASTLEG" -> "WC"; case "ONE_CLICK_DROPSHIP" -> "DFCK"; case "OUTBOUND" -> "CK"; case "RELABEL_OUTBOUND" -> "HBCK"; @@ -45,16 +41,4 @@ public class LastlegOrderNoGenerator { return "BX-" + Long.toString(id, 36).toUpperCase(Locale.ROOT); } - /** 独立尾程编号尚未确认,头程装柜来源继续使用原有分组编号。 */ - public String forContainerNo(String containerNo) { - if (containerNo == null || containerNo.isBlank()) throw new IllegalArgumentException("containerNo is required"); - try { - byte[] digest = MessageDigest.getInstance("SHA-256").digest(containerNo.trim().getBytes(StandardCharsets.UTF_8)); - long suffix = java.nio.ByteBuffer.wrap(digest, 0, 8).getLong(); - return "FL-WH-" + LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE) + "-" - + String.format(Locale.ROOT, "%06d", Math.floorMod(suffix, 1_000_000)); - } catch (NoSuchAlgorithmException exception) { - throw new IllegalStateException("SHA-256 is unavailable", exception); - } - } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java index 6e556f464..9feafcbce 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LastlegOrderService.java @@ -83,6 +83,7 @@ public class LastlegOrderService { @Resource private InventoryCommandService inventory; @Resource private ReceivingWarehouseOperationMapper receivingWarehouseOperations; @Resource private ContainerArrivalTimeOperationMapper containerArrivalTimeOperations; + @Resource private ContainerWarehouseArrivalService warehouseArrival; @Resource private WarehouseResourceGateway warehouseResourceGateway; @Resource private ContainerShippingGateMapper containerShippingGates; @Resource private ReturnOrderReferenceResolver returnOrderReferences; @@ -142,6 +143,7 @@ public class LastlegOrderService { orderMapper.findContainerProgressItems(bookingId)); } + /** 清关列表读取统一解析的柜级日期,默认推算不写入订单。 */ @Transactional(readOnly = true) public List containerArrivalTimes( LastlegOrderRequests.ContainerArrivalTimeQuery request) { @@ -150,7 +152,7 @@ public class LastlegOrderService { if (bookingIds.size() != request.sourceContainerBookingIds().size()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "来源柜预订ID不能重复"); } - return orderMapper.findContainerArrivalTimes(List.copyOf(bookingIds)); + return warehouseArrival.resolve(List.copyOf(bookingIds)); } @Transactional @@ -194,8 +196,9 @@ public class LastlegOrderService { return requireContainerArrivalTime(bookingId); } + /** 保存后沿同一读取入口返回人工日期、版本和计划冻结事实。 */ private ContainerArrivalTimeView requireContainerArrivalTime(String bookingId) { - List values = orderMapper.findContainerArrivalTimes(List.of(bookingId)); + List values = warehouseArrival.resolve(List.of(bookingId)); if (values.size() != 1) throw new ResponseStatusException(HttpStatus.NOT_FOUND, "来源柜不存在"); if (values.get(0).version() < 0) { throw new ResponseStatusException(HttpStatus.CONFLICT, "柜内预计到仓时间版本不一致"); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 1b5471092..773fdf3c0 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -44,12 +44,14 @@ public class LoadingCompletedConsumer { @Resource private InboundPlanCartonMapper cartonMapper; @Resource private ObjectMapper objectMapper; @Resource private LastlegOrderNoGenerator orderNoGenerator; + @Resource private com.baiyue.lastleg.repository.LastlegOrderNumberRepository orderNumbers; @Resource private DropshipOrderService dropshipOrders; @Resource private InventoryCommandService inventory; @Resource private ContainerShippingGateMapper shippingGates; @Resource private LastlegSourcePalletMapper sourcePallets; @Resource private ContainerLoadingEvidenceMapper loadingEvidence; + /** 校验交接身份并在柜级锁内为各客户的冻结入仓单建立同柜尾程记录。 */ @Transactional(isolation = Isolation.READ_COMMITTED) public DeliveryAck consume(EventEnvelope envelope) { envelope.requireValid(); @@ -94,7 +96,9 @@ public class LoadingCompletedConsumer { // Seeding the shipping version from the loading-plan version makes a valid first node // (aggregateVersion=0) look stale whenever the loading plan has a higher version. JsonNode rawOrders = envelope.payload().path("orders"); - String orderNo = orderNoGenerator.forContainerNo(payload.containerNo()); + // 来源柜行锁覆盖跨客户交接事件,首票取号,后续票沿用同柜已落库编号。 + String orderNo = orderNumbers.findContainerOrderNo(sourceContainerBookingId); + if (orderNo == null) orderNo = orderNoGenerator.next("LASTLEG"); for (int index = 0; index < payload.orders().size(); index++) { var source = payload.orders().get(index); JsonNode rawSource = rawOrders.isArray() && index < rawOrders.size() ? rawOrders.get(index) : objectMapper.createObjectNode(); diff --git a/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml b/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml index 02ad3504d..5d55a1c11 100644 --- a/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/appointment-calendar-warehouse-v1.yaml @@ -8,8 +8,8 @@ paths: description: >- 仅平台,同时要求platform:appointment:list与platform:overseas-warehouse:last-mile-order:list。 返回所选来源柜,以及查询区间内已人工维护或已有入库计划的到仓记录,支持跨月人工改期。 - 同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0或已经建计划时才有值。 - 未人工维护、未建计划的日历默认日期由到港来源加2个自然日;此只读接口不写入库计划、不补造时刻。 + 同柜多个客户合并一条。plannedAt只有整柜时间一致且人工维护版本大于0时才有值;仅日期的冻结计划通过plannedDate返回。 + plannedDate/isDefault由与清关提柜和入库计划共用的服务端解析器返回,默认按权威到港当地日期加2个自然日;此只读接口不写入库计划、不补造时刻。 actualAt只读PDA首次收货时间,不替换计划日期。 具备原尾程订单编辑权限、版本一致、无入库计划且未实际到仓时canEdit=true。 保存复用overseas-business-v1的整柜expected-arrival入口和原版本、幂等键、冻结门禁。 @@ -41,10 +41,13 @@ paths: type: object properties: sourceContainerBookingId: {type: string} + cabinetNo: {type: string, nullable: true, description: 来源交接冻结的柜次号,作为日历主标识} containerNo: {type: string, nullable: true} warehouseName: {type: string, nullable: true} warehouseCode: {type: string, nullable: true} plannedAt: {type: string, format: date-time, nullable: true} + plannedDate: {type: string, format: date, nullable: true, description: 人工日期、已生成计划冻结日期或权威默认日期} + isDefault: {type: boolean, description: 仅当前未维护未建计划且有默认日期时为true} actualAt: {type: string, format: date-time, nullable: true} version: {type: integer, minimum: -1} planCount: {type: integer, minimum: 0} diff --git a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml index befd48f10..ed9a43846 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-business-v1.yaml @@ -3582,7 +3582,9 @@ components: required: [sourceContainerBookingId, version, planCount] properties: sourceContainerBookingId: { type: string } - estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护或入库计划冻结的整柜时间;未维护未建计划时为空,由页面按到港当地日期加两个自然日展示默认日期 } + estimatedWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 人工维护的整柜时间;默认日期及只有日期的冻结计划不补造时间 } + plannedDate: { type: string, format: date, nullable: true, description: 与日历和入库计划共用的服务端日期,人工或冻结日期优先,否则按权威到港当地日期加两个自然日 } + isDefault: { type: boolean, description: 未人工维护未建计划时使用权威到港推算日期 } actualWarehouseArrivalAt: { type: string, format: date-time, nullable: true, description: 海外 PDA 首次开始收货时间 } version: { type: integer, minimum: -1, description: -1表示柜内时间版本不一致 } planCount: { type: integer, format: int64, minimum: 0, description: 整柜已有入库计划数量;非零时禁止默认推算及改期 } @@ -4022,6 +4024,11 @@ components: remark: type: string nullable: true + maintenance: + allOf: + - $ref: '#/components/schemas/DispatchMaintenance' + nullable: true + description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。 required: - publishLooseboardDemand - recordVersion @@ -4558,7 +4565,7 @@ components: items: { $ref: '#/components/schemas/LastlegOrderManualNode' } ManualInboundPlanRequest: type: object - description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由服务端读取来源柜冻结的 estimatedWarehouseArrivalAt,同柜所有计划保持一致,不接受人工提交。 + description: 单票人工计划只能使用所属柜已冻结的接柜仓;接柜仓变更必须通过柜级接口完成。预计到仓日期由共用服务端解析器返回 plannedDate:人工时间或已有计划冻结日期优先,其余按 Firstleg 权威到港当地日期加两个自然日;同柜所有计划保持一致,不接受人工提交,不补造默认时间。 required: [customerId, orderId, expectedOrderVersion, palletCount, palletType, assigned, mergeRequired, urgent] properties: customerId: { type: string, pattern: '^[1-9][0-9]*$' } @@ -5390,6 +5397,43 @@ components: - name - phone - evidence + DispatchMaintenance: + type: object + x-java-record: DispatchContracts.Maintenance + description: 保存于排车resourcesJson.maintenance的唯一正式资料结构,不新增费用金额或审核门禁。 + properties: + plannedDeliveryDate: + type: string + format: date + nullable: true + deliveryMethod: + type: string + enum: [FBA自提, 客户自提, 卡派打托, 卡派地板, 快递, 外配] + nullable: true + pickupMethod: + type: string + enum: [一提一卸, 一提多卸] + nullable: true + loadType: + type: string + enum: [托盘, 地板, 混装] + nullable: true + fleetEmail: + type: string + maxLength: 254 + nullable: true + vehicleType: + type: string + enum: [厢式货车, 平板车, 集装箱卡车] + nullable: true + appointmentAccount: + type: string + maxLength: 128 + nullable: true + appointmentNo: + type: string + maxLength: 128 + nullable: true DispatchResources: type: object x-java-record: DispatchContracts.Resources @@ -5402,6 +5446,11 @@ components: allOf: - $ref: '#/components/schemas/DispatchDriverSnapshot' nullable: true + maintenance: + allOf: + - $ref: '#/components/schemas/DispatchMaintenance' + nullable: true + description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。 required: - vehicle - driver @@ -6356,6 +6405,11 @@ components: allOf: - $ref: '#/components/schemas/DispatchDriverSnapshot' nullable: true + maintenance: + allOf: + - $ref: '#/components/schemas/DispatchMaintenance' + nullable: true + description: 本趟可空补充资料,八字段均可空;空白字符串规范为null,不替代正式FBA预约或实际发车时间。 required: - vehicle - driver diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java index 17ae1e105..b3e0f1487 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/appointmentplanning/AppointmentImpactPersistenceTest.java @@ -26,7 +26,7 @@ class AppointmentImpactPersistenceTest { return new AppointmentForecastClient.Fact(1L,revision,"CARRIER","REF",LocalDateTime.of(2026,10,10,12,0).plusMinutes(revision),LocalDateTime.of(2026,10,day,9,0),null); } static void observe(AnnotationConfigApplicationContext c,long revision,int day) { - when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED")))); + when(c.getBean(AppointmentForecastClient.class).batch(anyString(),any())).thenReturn(ApiResponse.success(List.of(new AppointmentForecastClient.Forecast(1L,fact(revision,day),fact(1,15),"SOURCE_LOCAL_UNSPECIFIED",null)))); } static AppointmentImpactService.Command command(AppointmentImpactService.View row,String key,String action) { return new AppointmentImpactService.Command(row.version(),row.forecastToken(),key,action,"已联系收货方核实,保留原预约另行跟进","再次核对",null); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java index a71d9901c..cbd540d9d 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarMapperTest.java @@ -11,11 +11,21 @@ import java.time.LocalDateTime; import java.util.List; import static org.junit.jupiter.api.Assertions.*; -class WarehouseCalendarMapperTest { +public class WarehouseCalendarMapperTest { + /** H2 测试替身按真实 JSON 内容读取柜次,不把样例值写死到 SQL。 */ + public static String jsonExtract(String json,String path) throws Exception { + return json==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(json).path(path.substring(2)).toString(); + } + /** 测试替身复用 JSON 解码语义。 */ + public static String jsonUnquote(String value) throws Exception { + return value==null?null:new com.fasterxml.jackson.databind.ObjectMapper().readTree(value).asText(); + } /** 同柜跨客户不重复,人工改期与已建计划日期分别保留。 */ @Test void deduplicatesCustomersPreservesManualAndFrozenDatesAndFindsCrossMonthRescheduling() throws Exception { var ds = new JdbcDataSource(); ds.setURL("jdbc:h2:mem:warehouse-calendar;MODE=MySQL;DB_CLOSE_DELAY=-1"); try (var connection=ds.getConnection(); var sql=connection.createStatement()) { + sql.execute("CREATE ALIAS JSON_EXTRACT FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonExtract'"); + sql.execute("CREATE ALIAS JSON_UNQUOTE FOR 'com.baiyue.lastleg.calendar.WarehouseCalendarMapperTest.jsonUnquote'"); sql.execute("CREATE TABLE ll_order(id BIGINT,customer_id VARCHAR,source_container_booking_id VARCHAR,container_no VARCHAR," + "overseas_warehouse_id VARCHAR,overseas_warehouse_name VARCHAR,overseas_warehouse_code VARCHAR," + "estimated_warehouse_at TIMESTAMP,arrival_time_version INT)"); @@ -26,6 +36,12 @@ class WarehouseCalendarMapperTest { "(3,'A','2','CONT2','W','Warehouse','W','2026-11-10 09:00:00',2)," + "(4,'A','3','CONT3','W','Warehouse','W','2026-11-12 09:00:00',0)"); sql.execute("INSERT INTO ll_inbound_plan VALUES(4,'A')"); + sql.execute("ALTER TABLE ll_order ADD source_event_id VARCHAR"); + sql.execute("ALTER TABLE ll_inbound_plan ADD planned_arrival_date DATE"); + sql.execute("UPDATE ll_inbound_plan SET planned_arrival_date='2026-11-12'"); + sql.execute("CREATE TABLE ll_loading_event_inbox(event_id VARCHAR,payload_json VARCHAR)"); + sql.execute("INSERT INTO ll_loading_event_inbox VALUES('source-1','{\"cabinetNo\":\"D0001\"}')"); + sql.execute("UPDATE ll_order SET source_event_id='source-1' WHERE source_container_booking_id='1'"); } var config=new MybatisConfiguration(); config.setEnvironment(new Environment("test",new JdbcTransactionFactory(),ds)); config.addMapper(WarehouseCalendarMapper.class); @@ -34,9 +50,11 @@ class WarehouseCalendarMapperTest { var rows=session.getMapper(WarehouseCalendarMapper.class).calendar(LocalDateTime.parse("2026-11-01T00:00:00"),LocalDateTime.parse("2026-12-01T00:00:00"),List.of("1")); assertEquals(3,rows.size()); assertEquals("1",rows.get(0).sourceContainerBookingId()); assertNull(rows.get(0).plannedAt()); + assertEquals("D0001",rows.get(0).cabinetNo()); assertEquals(2,rows.get(1).version()); assertEquals("2026-11-10T09:00",rows.get(1).plannedAt().toString()); - assertEquals(1,rows.get(2).planCount()); assertNotNull(rows.get(2).plannedAt()); - // 清关提柜与日历必须读取相同的人工/冻结事实,默认日期由前端统一推算。 + assertEquals(1,rows.get(2).planCount()); assertNull(rows.get(2).plannedAt()); + assertEquals(java.time.LocalDate.of(2026,11,12),rows.get(2).plannedDate()); + // 清关提柜与日历读取相同冻结事实,默认日期由共用服务端解析器返回。 var customs=session.getMapper(LastlegOrderMapper.class).findContainerArrivalTimes(List.of("1","2","3")); assertEquals(3,customs.size()); for(var calendar:rows) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java index 279e6902c..b70462747 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/calendar/WarehouseCalendarServiceTest.java @@ -10,10 +10,15 @@ import static org.mockito.Mockito.*; class WarehouseCalendarServiceTest { /** 读取资格不代替写权限,已有入库计划仍冻结时间。 */ @Test void keepsReadAccessSeparateFromWriteAndFreezesExistingPlans() { - var repository=mock(WarehouseCalendarRepository.class); var service=new WarehouseCalendarService(repository); + var repository=mock(WarehouseCalendarRepository.class); + var arrivals=mock(com.baiyue.lastleg.service.ContainerWarehouseArrivalService.class); + var service=new WarehouseCalendarService(repository,arrivals); var query=new WarehouseCalendarModels.Query(LocalDate.of(2026,11,1),LocalDate.of(2026,12,1),List.of("1")); - var open=new WarehouseCalendarModels.Entry("1","CONT","Warehouse","W",null,null,0,0,false); - var frozen=new WarehouseCalendarModels.Entry("2","CONT2","Warehouse","W",null,null,1,1,false); + var open=new WarehouseCalendarModels.Entry("1","D0001","CONT","Warehouse","W",null,null,0,0,false,null,false); + var frozen=new WarehouseCalendarModels.Entry("2","D0002","CONT2","Warehouse","W",null,null,1,1,false,null,false); + when(arrivals.resolve(List.of("1","2"))).thenReturn(List.of( + new com.baiyue.lastleg.dto.ContainerArrivalTimeView("1",null,null,0,0,LocalDate.of(2026,11,2),true), + new com.baiyue.lastleg.dto.ContainerArrivalTimeView("2",null,null,1,1,LocalDate.of(2026,11,3),false))); when(repository.calendar(query)).thenReturn(List.of(open,frozen)); assertTrue(service.calendar(query,true).get(0).canEdit()); assertFalse(service.calendar(query,true).get(1).canEdit()); assertTrue(service.calendar(query,false).stream().noneMatch(WarehouseCalendarModels.Entry::canEdit)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index 5b367fa19..f717ac4e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -93,7 +93,7 @@ public class DispatchAggregatePersistenceTest { var resources=e.f.json.readValue(row.getResourcesJson(),DispatchContracts.Resources.class); var d=resources.driver();var proof=d.evidence(); var next=new DispatchContracts.Resources(resources.vehicle(),new DispatchContracts.DriverSnapshot(d.mode(),d.driverId(),d.name(),d.phone(), - new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L))); + new DispatchContracts.DriverEvidence(proof.licenseNo(),proof.licenseExpiresOn(),proof.fileIds(),proof.expectedTaskEndAt(),proof.photoFileIds(),81L)), null); e.f.db.update("UPDATE ll_dispatch_order SET task_type='PICKUP', resources_json=? WHERE dispatch_no=?",e.f.json.writeValueAsString(next),number); var kind=com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.Kind.DRIVER; var uploaded=new com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway.File(91L,"pickup.png","image/png",3L); @@ -167,7 +167,7 @@ public class DispatchAggregatePersistenceTest { String dispatch(ResourceMode mode,Long driverId,List ids) throws Exception { var created=f.tx.execute(t->f.service.create(f.actor,f.create("evidence-"+UUID.randomUUID(),List.of()))); var resource=new DispatchContracts.Resources(null,new DispatchContracts.DriverSnapshot(mode,driverId,"测试司机","123456789", - new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of()))); + new DispatchContracts.DriverEvidence("TEST-LICENSE",LocalDate.of(2099,1,1),ids,Instant.parse("2098-01-01T00:00:00Z"),List.of())), null); f.db.update("UPDATE ll_dispatch_order SET resources_json=? WHERE dispatch_no=?",f.json.writeValueAsString(resource),created.dispatchNo());return created.dispatchNo(); } /** File内容固定为离线测试字节;真实复合归属由既有两个HTTP网关合同分别覆盖。 */ @@ -204,7 +204,7 @@ public class DispatchAggregatePersistenceTest { /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); - var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true); + var request=new DispatchCommands.Update(0L,"empty-edit-original",null,null,null,null,"空单补充备注", true, null); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request)); assertThat(saved.stage()).isEqualTo(Stage.BEFORE_LOADING);assertThat(saved.counts().packageCount()).isZero(); assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); @@ -244,7 +244,7 @@ public class DispatchAggregatePersistenceTest { } @Test void updateSavesCurrentVehiclePlanAndReplayWithoutAnyVehicleAppointmentField() throws Exception { var f=new Fixture();var input=f.configured("update-without-vehicle-appointment",List.of(f.source(1,"101",501,"ONT8",3)));var created=f.tx.execute(t->f.service.create(f.actor,input)); - var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true); + var arrival=input.plannedDepartureAt().plusSeconds(3600);var request=new DispatchCommands.Update(created.recordVersion(),"update-current-plan",input.vehicle(),input.driver(),input.plannedDepartureAt(),arrival,"调整本趟计划", true, null); var saved=f.tx.execute(t->f.service.update(f.actor,created.dispatchNo(),request));assertThat(saved.recordVersion()).isGreaterThan(created.recordVersion()); var detail=f.tx.execute(t->f.service.detail(f.actor,created.dispatchNo()));assertThat(detail.dispatchInfo().estimatedArrivalAt()).isEqualTo(arrival);assertThat(detail.documents().remark()).isEqualTo("调整本趟计划"); var publicJson=f.json.valueToTree(detail);assertThat(publicJson.path("dispatchInfo").has("vehicleAppointment")).isFalse();assertThat(publicJson.path("dispatchInfo").has("fbaAppointments")).isTrue(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java index 1e2e0e22b..01c80c0ae 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchBolReadPersistenceTest.java @@ -186,7 +186,7 @@ class DispatchBolReadPersistenceTest { db.execute("CREATE TABLE ll_inbound_fba_appointment(id BIGINT PRIMARY KEY,appointment_no VARCHAR(64),confirmation_no VARCHAR(128))"); db.execute("CREATE TABLE ll_inbound_fba_appointment_item(id BIGINT PRIMARY KEY,appointment_id BIGINT NOT NULL,source_key VARCHAR(160) NOT NULL,customer_id VARCHAR(128) NOT NULL,order_id BIGINT,fba_warehouse_code VARCHAR(128) NOT NULL,UNIQUE(appointment_id,source_key))"); var config=new MybatisConfiguration();config.addMapper(DispatchBolMapper.class);var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet();var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchBolMapper.class); - var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null)));when(roots.find("DP-ONE")).thenReturn(row); + var roots=mock(DispatchAggregateRepository.class);var row=new DispatchAggregate();row.setId(1L);row.setDispatchNo("DP-ONE");row.setOverseasWarehouseId("9");row.setResourcesJson(new ObjectMapper().writeValueAsString(new DispatchContracts.Resources(null,null, null)));when(roots.find("DP-ONE")).thenReturn(row); service=new DispatchBolReadService(roots,new DispatchBolRepository(mapper),new ObjectMapper()); tx=new TransactionTemplate(new DataSourceTransactionManager(ds));tx.setReadOnly(true);tx.setIsolationLevel(TransactionDefinition.ISOLATION_REPEATABLE_READ); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java index 4af5a43be..a8ec56d61 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchCapacityDemandPersistenceTest.java @@ -17,7 +17,7 @@ class DispatchCapacityDemandPersistenceTest { var service=new DispatchCapacityDemandService(f.repository,f.json); assertThat(service.page(query()).records()).extracting(DispatchCapacityDemands.Demand::dispatchNo).containsExactly(created.dispatchNo()); assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isTrue(); - var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false); + var update=new DispatchCommands.Update(created.recordVersion(),"pool-disable",null,null,null,null,null,false, null); var saved=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),update)); assertThat(f.service.detail(f.actor,created.dispatchNo()).publishLooseboardDemand()).isFalse(); assertThat(service.page(query()).total()).isZero(); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java new file mode 100644 index 000000000..dcebf01ac --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMaintenancePersistenceTest.java @@ -0,0 +1,178 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.lastleg.dispatch.gateway.DispatchEvidenceGateway; +import com.baiyue.lastleg.dispatch.port.DispatchCommands; +import com.baiyue.lastleg.dispatch.port.DispatchContracts.*; +import com.baiyue.lastleg.dispatch.port.DispatchTypes.ResourceMode; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.LocalDate; +import java.util.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.Mockito.*; + +/** 维护资料与车辆照片经真实聚合、Fleet资源解析、持久化及审核往返。 */ +class DispatchMaintenancePersistenceTest { + /** 主档和临时车辆均保留本趟照片与八项资料,不能被Fleet主档重建吞掉。 */ + @ParameterizedTest + @ValueSource(strings = {"MASTER", "EXTERNAL"}) + void maintenanceAndVehiclePhotosSurviveReplayAndBothReviews(String mode) throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("maintenance-create", List.of(f.source(1, "101", 501, "ONT8", 3))); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var vehicle = vehicle(f, mode, List.of(301L)); + var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), " FBA自提 ", "一提多卸", "混装", + " fleet@example.test ", "厢式货车", " account ", " appointment "); + var request = new DispatchCommands.Update(created.recordVersion(), "maintenance-save", vehicle, input.driver(), + input.plannedDepartureAt(), input.estimatedArrivalAt(), "保留原备注", true, maintenance); + var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)); + assertThat(f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request)).replayed()).isTrue(); + f.approve(saved); + var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())); + assertThat(detail.vehicleInfo().maintenance()).isEqualTo(maintenance); + assertThat(detail.vehicleInfo().maintenance().appointmentAccount()).isEqualTo("account"); + assertThat(detail.vehicleInfo().vehicle().evidenceFileIds()).containsExactly(301L); + assertThat(detail.dispatchInfo().plannedDepartureAt()).isEqualTo(input.plannedDepartureAt()); + assertThat(detail.dispatchInfo().departedAt()).isNull(); + var persisted = f.json.readValue(f.repository.find(created.dispatchNo()).getResourcesJson(), Resources.class); + assertThat(persisted.maintenance()).isEqualTo(maintenance); + assertThat(persisted.vehicle().evidenceFileIds()).containsExactly(301L); + assertThat(f.repository.find(created.dispatchNo()).getRemark()).isEqualTo("保留原备注"); + verify(files(f), atLeastOnce()).metadata(f.repository.find(created.dispatchNo()).getId(), DispatchEvidenceGateway.Kind.VEHICLE, 301L); + if (mode.equals("MASTER")) { + assertThat(detail.vehicleInfo().vehicle().plate()).isEqualTo("MASTER-71"); + assertThat(detail.vehicleInfo().vehicle().capacity().palletSpaces()).isEqualTo(15); + } + } + + /** 跨车、跨分类及失效照片的提供者拒绝必须整体回滚,MASTER同样不能绕过。 */ + @ParameterizedTest + @ValueSource(strings = {"跨车", "错分类", "已失效"}) + void invalidVehiclePhotoCannotChangeDraft(String reason) throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("invalid-vehicle-create", List.of()); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var before = f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo()); + var dispatchId = f.repository.find(created.dispatchNo()).getId(); + doThrow(new IllegalArgumentException(reason)).when(files(f)).metadata(dispatchId, DispatchEvidenceGateway.Kind.VEHICLE, 301L); + var request = new DispatchCommands.Update(created.recordVersion(), "invalid-vehicle-save", vehicle(f, "MASTER", List.of(301L)), + input.driver(), input.plannedDepartureAt(), null, null, true, null); + assertThatThrownBy(() -> f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), request))).hasMessageContaining(reason); + assertThat(f.db.queryForMap("SELECT resources_json,record_version FROM ll_dispatch_order WHERE dispatch_no=?", created.dispatchNo())).isEqualTo(before); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='invalid-vehicle-save'", Long.class)).isZero(); + } + + /** 维护整体为空或八字段为空均可保存空草稿,清空资料不会制造实际发车或正式预约。 */ + @Test void emptyMaintenanceCanBeSavedAndCleared() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var created = f.tx.execute(t -> f.service.create(f.actor, f.create("empty-maintenance", List.of()))); + var empty = f.json.readValue("{\"plannedDeliveryDate\":\" \",\"deliveryMethod\":\" \",\"pickupMethod\":\"\",\"loadType\":\" \",\"fleetEmail\":\" \",\"vehicleType\":\"\",\"appointmentAccount\":\" \",\"appointmentNo\":\"\"}", Maintenance.class); + assertThat(empty).isEqualTo(new Maintenance(null, null, null, null, null, null, null, null)); + var saved = f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update( + created.recordVersion(), "empty-maintenance-save", null, null, null, null, null, true, empty))); + f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update( + saved.recordVersion(), "empty-maintenance-clear", null, null, null, null, null, true, null))); + var detail = f.tx.execute(t -> f.service.detail(f.actor, created.dispatchNo())); + assertThat(detail.vehicleInfo().maintenance()).isNull(); + assertThat(detail.dispatchInfo().departedAt()).isNull(); + } + + /** 文本仅限约定选项和长度,ISO日期不能接受时间串或不存在的日期。 */ + @Test void maintenanceRejectsUnknownOptionsAndOverlongText() throws Exception { + var json = new DispatchAggregatePersistenceTest.Fixture().json; + for (String field : List.of("deliveryMethod", "pickupMethod", "loadType", "vehicleType")) { + assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "未知"), Maintenance.class)) + .hasRootCauseInstanceOf(IllegalArgumentException.class); + } + for (String field : List.of("fleetEmail", "appointmentAccount", "appointmentNo")) { + int max = field.equals("fleetEmail") ? 254 : 128; + assertThat(json.treeToValue(json.createObjectNode().put(field, "a".repeat(max)), Maintenance.class)).isNotNull(); + assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put(field, "a".repeat(max + 1)), Maintenance.class)) + .hasRootCauseInstanceOf(IllegalArgumentException.class); + } + for (String date : List.of("2026-10-32", "2026-02-29", "2026-10-21T10:00:00", "2026/10/21")) { + assertThatThrownBy(() -> json.treeToValue(json.createObjectNode().put("plannedDeliveryDate", date), Maintenance.class)).isInstanceOf(com.fasterxml.jackson.core.JsonProcessingException.class); + } + var parsed = json.treeToValue(json.createObjectNode().put("plannedDeliveryDate", "2026-10-21"), Maintenance.class); + assertThat(parsed.plannedDeliveryDate()).isEqualTo(LocalDate.of(2026, 10, 21)); + assertThat(json.valueToTree(parsed).path("plannedDeliveryDate").asText()).isEqualTo("2026-10-21"); + } + + /** 取货资源投影和照片保存同样保留正式维护资料,不能在重建快照时掉字段。 */ + @Test void transportResourceRebuildAndPhotoSaveKeepMaintenance() throws Exception { + var f = new DispatchAggregatePersistenceTest.Fixture(); + var input = f.configured("projection-maintenance-create", List.of()); + var created = f.tx.execute(t -> f.service.create(f.actor, input)); + var maintenance = new Maintenance(LocalDate.of(2026, 11, 1), "外配", "一提一卸", "地板", null, "平板车", null, "AP-LOCAL"); + f.tx.execute(t -> f.service.update(f.actor, created.dispatchNo(), new DispatchCommands.Update(created.recordVersion(), + "projection-maintenance-save", vehicle(f, "MASTER", List.of(301L)), input.driver(), input.plannedDepartureAt(), null, null, true, maintenance))); + var projection = new com.baiyue.lastleg.dispatch.transport.TransportTaskProjection(f.repository, + mock(com.baiyue.lastleg.dispatch.transport.TransportTaskRepository.class), + mock(com.baiyue.lastleg.trucking.repository.UsTruckOrderRepository.class), mock(DispatchNumberGenerator.class), f.json); + var resources = f.json.readValue(f.repository.find(created.dispatchNo()).getResourcesJson(), Resources.class); + f.tx.executeWithoutResult(t -> projection.savePhotos(created.dispatchNo(), f.actor.operatorId(), List.of(302L), resources.driver().evidence())); + var row = f.repository.find(created.dispatchNo()); + assertThat(f.json.readValue(row.getResourcesJson(), Resources.class).maintenance()).isEqualTo(maintenance); + var trip = new com.baiyue.lastleg.fleet.trip.FleetTripEntity(); + trip.setVehicleId(71L); trip.setVehiclePlate("MASTER-71"); + ReflectionTestUtils.invokeMethod(projection, "resources", row, trip); + var rebuilt = f.json.readValue(row.getResourcesJson(), Resources.class); + assertThat(rebuilt.maintenance()).isEqualTo(maintenance); + assertThat(rebuilt.vehicle().evidenceFileIds()).containsExactly(302L); + } + + /** 请求、资源与详情共用可空维护结构;文档中的每个选项都必须被正式DTO接受。 */ + @Test void openApiPublishesSymmetricNullableMaintenanceContract() throws Exception { + var json = new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(); + com.fasterxml.jackson.databind.JsonNode root; + try (var input = getClass().getResourceAsStream("/openapi/overseas-business-v1.yaml")) { + root = json.valueToTree(new org.yaml.snakeyaml.Yaml().load(input)); + } + var schemas = root.path("components").path("schemas"); + for (String name : List.of("DispatchUpdate", "DispatchResources", "DispatchVehicleInfo")) { + var field = schemas.path(name).path("properties").path("maintenance"); + assertThat(field.path("nullable").asBoolean()).isTrue(); + assertThat(field.path("allOf").get(0).path("$ref").asText()).isEqualTo("#/components/schemas/DispatchMaintenance"); + for (var required : schemas.path(name).path("required")) assertThat(required.asText()).isNotEqualTo("maintenance"); + } + var schema = schemas.path("DispatchMaintenance"); + var fields = schema.path("properties"); + assertThat(fields.size()).isEqualTo(Maintenance.class.getRecordComponents().length); + assertThat(schema.path("required").isMissingNode()).isTrue(); + for (var field : Maintenance.class.getRecordComponents()) { + assertThat(fields.path(field.getName()).path("nullable").asBoolean()).isTrue(); + for (var value : fields.path(field.getName()).path("enum")) { + assertThat(json.treeToValue(json.createObjectNode().put(field.getName(), value.asText()), Maintenance.class)).isNotNull(); + } + } + assertThat(fields.path("plannedDeliveryDate").path("format").asText()).isEqualTo("date"); + assertThat(fields.path("fleetEmail").path("maxLength").asInt()).isEqualTo(254); + assertThat(fields.path("appointmentAccount").path("maxLength").asInt()).isEqualTo(128); + assertThat(fields.path("appointmentNo").path("maxLength").asInt()).isEqualTo(128); + } + + /** 主档请求只携带身份与本趟照片,名称及容量必须仍取权威主档。 */ + private static DispatchCommands.Vehicle vehicle(DispatchAggregatePersistenceTest.Fixture f, String mode, List photos) { + if (mode.equals("MASTER")) { + var vehicles = (VehicleRepository) ReflectionTestUtils.getField(f.nativeFleetService, "vehicles"); + var actual = new VehicleRepository.VehicleSnapshot(71L, "MASTER-71", null, null, 0L, "主档车队", null, + "OVERSEAS", "OWNED", "IN_SERVICE", null, 0L, null, null, 15, new BigDecimal("10000"), new BigDecimal("50")); + when(vehicles.findById(71L)).thenReturn(Optional.of(actual)); + return new DispatchCommands.Vehicle(ResourceMode.MASTER, 71L, "不能覆盖主档", null, null, photos); + } + return new DispatchCommands.Vehicle(ResourceMode.EXTERNAL, null, "EXTERNAL-71", "本趟车队", + new Capacity(15, new BigDecimal("10000"), new BigDecimal("50")), photos); + } + + /** 附件提供者仍执行本车及分类边界;只隔离外部File服务。 */ + private static DispatchEvidenceGateway files(DispatchAggregatePersistenceTest.Fixture f) { + return (DispatchEvidenceGateway) ReflectionTestUtils.getField(f.service, "evidence"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java index 8a3f4cdba..464afa4b9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchResourcePhotosPersistenceTest.java @@ -83,7 +83,7 @@ class DispatchResourcePhotosPersistenceTest { var e = d.evidence(); var driver = new DispatchCommands.Driver(d.mode(), d.driverId(), d.name(), d.phone(), new DispatchContracts.DriverEvidence(e.licenseNo(), e.licenseExpiresOn(), e.fileIds(), e.expectedTaskEndAt(), photos)); - return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true); + return new DispatchCommands.Update(version, key, input.vehicle(), driver, input.plannedDepartureAt(), input.estimatedArrivalAt(), "维护照片", true, null); } /** 只替代外部附件提供者,资源、命令和事务仍走真实服务。 */ diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java index f8705f263..628852cc0 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchReviewWorkflowPersistenceTest.java @@ -88,7 +88,7 @@ class DispatchReviewWorkflowPersistenceTest { long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item WHERE plan_pallet_id=3",Long.class); var removed=f.tx.execute(s->f.service.removeCargo(f.actor,created.dispatchNo(),item,new DispatchCommands.ReasonCommand(addedAgain.recordVersion(),"remove","调整目的仓"))); var resources=f.configured("updated-resources",List.of()); - var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true))); + var updated=f.tx.execute(s->f.service.update(f.actor,created.dispatchNo(),new DispatchCommands.Update(removed.recordVersion(),"update-vehicle",resources.vehicle(),resources.driver(),resources.plannedDepartureAt().plusSeconds(60),null,"客服更正", true, null))); var detail=f.service.detail(f.actor,created.dispatchNo()); var retained=loading?detail.reviews().loadingReview():detail.reviews().dispatchReview(); assertThat(detail.reviewState()).isEqualTo(ReviewState.DRAFT); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java index 127eea75e..95e4d02e5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/transport/TransportReviewPhotosTest.java @@ -55,13 +55,13 @@ class TransportReviewPhotosTest { /** 换司机后旧人像不允许重复绑定。 */ @Test void rejectsPhotosCarriedFromAnotherDriver() throws Exception { row.setResourcesJson(json.writeValueAsString(new Resources(null,new DriverSnapshot(ResourceMode.MASTER,5L,"原司机","000", - new DriverEvidence(null,null,List.of(23L),null,List.of(22L)))))); + new DriverEvidence(null,null,List.of(23L),null,List.of(22L))), null))); assertThatThrownBy(()->service.validate(row,trip,request(new ReviewAttachments(List.of(21L),List.of(22L),List.of(24L)),null))).hasMessageContaining("重新上传"); } /** 审核复验已保存证据,照片后续失效也不能通过。 */ @Test void reviewRevalidatesPersistedPhotoOwnership() throws Exception { row.setResourcesJson(json.writeValueAsString(new Resources(new VehicleSnapshot(ResourceMode.MASTER,1L,"TEST",null,null,null,List.of(21L)), - new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L)))))); + new DriverSnapshot(ResourceMode.MASTER,2L,"司机","000",new DriverEvidence(null,null,List.of(23L),null,List.of(22L))), null))); service.verifyReview(row,trip);verify(files).metadata(10L,VEHICLE,21L); when(files.metadata(10L,DRIVER,23L)).thenThrow(new IllegalArgumentException("附件失效")); assertThatThrownBy(()->service.verifyReview(row,trip)).hasMessageContaining("附件失效"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java index b95f2a579..3e60ea53a 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/fleet/ownedbusiness/OwnedBusinessScopePersistenceTest.java @@ -40,7 +40,7 @@ class OwnedBusinessScopePersistenceTest { when(reader.detail(any(),anyString())).thenAnswer(call->{ var s=baseline.repository.source(call.getArgument(1));var r=baseline.codec.read(s.getResourcesJson(),DispatchContracts.Resources.class); return new DispatchViews.Detail(s.getDispatchNo(),"DELIVERY",DispatchTypes.Status.ACTIVE,DispatchTypes.Stage.valueOf(s.getStage()),s.getRecordVersion(), - DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver()),null,null,List.of(),true); + DispatchTypes.ReviewState.APPROVED,null,null,true,Map.of(),new DispatchViews.VehicleInfo(r.vehicle(),r.driver(), null),null,null,List.of(),true); }); queries=tx(new OwnedBusinessQueryService(baseline.repository,baseline.projection,baseline.codec,reader)); var commands=tx(new OwnedBusinessCommandService(baseline.repository,baseline.codec)); @@ -67,7 +67,7 @@ class OwnedBusinessScopePersistenceTest { var original=OwnedBusinessFixtures.source(); var resources=baseline.codec.read(original.getResourcesJson(),DispatchContracts.Resources.class); var vehicle=new DispatchContracts.VehicleSnapshot(DispatchTypes.ResourceMode.valueOf(mode),"MASTER".equals(mode)?7L:null,"TEST-OUT-"+id,carrier,"IN_SERVICE",resources.vehicle().capacity(),List.of()); - String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver())); + String snapshot=baseline.codec.write(new DispatchContracts.Resources(vehicle,resources.driver(), null)); String load=original.getLoadingConfirmationJson().replace("\"warehousePalletId\":31","\"warehousePalletId\":"+(31+id)); baseline.jdbc.update(""" INSERT INTO ll_dispatch_order SELECT ?,?,trip_id,status,?,record_version,planned_departure_at,loading_completed_at,departed_at,delivery_completed_at, diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java index 9df6a6190..c58703e0c 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/service/InboundPlanServiceTest.java @@ -58,12 +58,14 @@ class InboundPlanServiceTest { @Mock WarehouseResourceGateway warehouseGateway; @Mock LastlegOrderCommandRecordMapper commandMapper; @Mock LastlegSourcePalletMapper sourcePalletMapper; + @Mock com.baiyue.lastleg.service.ContainerWarehouseArrivalService warehouseArrival; private InboundPlanService service; @BeforeEach void setUp() { service = new InboundPlanService(); + ReflectionTestUtils.setField(service,"warehouseArrival",warehouseArrival); ReflectionTestUtils.setField(service, "planMapper", planMapper); ReflectionTestUtils.setField(service, "lineMapper", lineMapper); ReflectionTestUtils.setField(service, "palletMapper", palletMapper); @@ -95,8 +97,8 @@ class InboundPlanServiceTest { "100", 2L, "BOOKING-1", "193000000000200003"))); org.mockito.Mockito.lenient().when(orderMapper.findSourceContainerBookingId(any(), anyLong())) .thenReturn("BOOKING-1"); - org.mockito.Mockito.lenient().when(orderMapper.containerEstimatedWarehouseTimes(any())) - .thenReturn(List.of(LocalDateTime.of(2026, 9, 15, 8, 0))); + org.mockito.Mockito.lenient().when(warehouseArrival.requiredDate(any())) + .thenReturn(java.time.LocalDate.of(2026, 9, 15)); } @Test @@ -176,15 +178,16 @@ class InboundPlanServiceTest { void manualPlanRejectsMissingOrInconsistentContainerArrivalTime() { ManualInboundPlanRequest request = new ManualInboundPlanRequest("100", 2L, 0, 2, "STANDARD", true, true, true, "优先收货"); - when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of()); + when(warehouseArrival.requiredDate("BOOKING-1")).thenThrow(new ResponseStatusException(org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间缺失,请先补齐柜级时间")); ResponseStatusException missing = assertThrows(ResponseStatusException.class, () -> service.convertManual(request, "manual-missing-arrival", "900")); assertEquals(409, missing.getStatusCode().value()); assertEquals("来源柜预计到仓时间缺失,请先补齐柜级时间", missing.getReason()); - when(orderMapper.containerEstimatedWarehouseTimes("BOOKING-1")).thenReturn(List.of( - LocalDateTime.of(2026, 9, 15, 8, 0), LocalDateTime.of(2026, 9, 16, 8, 0))); + org.mockito.Mockito.doThrow(new ResponseStatusException( + org.springframework.http.HttpStatus.CONFLICT,"来源柜预计到仓时间不一致,请先修正柜级时间")) + .when(warehouseArrival).requiredDate("BOOKING-1"); ResponseStatusException inconsistent = assertThrows(ResponseStatusException.class, () -> service.convertManual(request, "manual-inconsistent-arrival", "900")); assertEquals(409, inconsistent.getStatusCode().value()); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalServiceTest.java new file mode 100644 index 000000000..12e3444dd --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/ContainerWarehouseArrivalServiceTest.java @@ -0,0 +1,67 @@ +package com.baiyue.lastleg.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient; +import com.baiyue.lastleg.dto.ContainerArrivalTimeView; +import com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository; +import org.junit.jupiter.api.Test; +import org.springframework.web.server.ResponseStatusException; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; +import static org.mockito.ArgumentMatchers.*; + +class ContainerWarehouseArrivalServiceTest { + private final ContainerWarehouseArrivalRepository repository=mock(ContainerWarehouseArrivalRepository.class); + private final AppointmentForecastClient forecasts=mock(AppointmentForecastClient.class); + private final ContainerWarehouseArrivalService service=new ContainerWarehouseArrivalService(repository,forecasts,"internal-test"); + + /** 默认日期跨月按当地自然日推进,展示和计划创建必须读取同一结果。 */ + @Test void defaultsAcrossMonthAndUsesExactlyTheSameDateForPlanning() { + when(repository.find(List.of("1"))).thenReturn(List.of(new ContainerArrivalTimeView("1",null,null,0,0,null,false))); + when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of( + new AppointmentForecastClient.Forecast(1L,null,null,"SOURCE_LOCAL_UNSPECIFIED",LocalDateTime.of(2026,10,30,23,30))))); + var result=service.resolve(List.of("1")).get(0); + assertEquals(LocalDate.of(2026,11,1),result.plannedDate()); + assertNull(result.estimatedWarehouseArrivalAt()); + assertTrue(result.isDefault()); + assertEquals(result.plannedDate(),service.requiredDate("1")); + } + + /** 人工维护优先,已生成计划后的日期不再随到港变化重新计算。 */ + @Test void manualAndFrozenDatesNeverReadNewForecasts() { + var manual=LocalDateTime.of(2026,11,3,9,30); + when(repository.find(List.of("1","2"))).thenReturn(List.of( + new ContainerArrivalTimeView("1",manual,null,1,0,null,false), + new ContainerArrivalTimeView("2",null,null,0,1,LocalDate.of(2026,11,4),false))); + var result=service.resolve(List.of("1","2")); + assertEquals(manual.toLocalDate(),result.get(0).plannedDate()); + assertEquals(LocalDate.of(2026,11,4),result.get(1).plannedDate()); + assertFalse(result.get(1).isDefault()); + verifyNoInteractions(forecasts); + } + + /** 真实未知日期不补零点,提供方漏行也不能当作完整空事实。 */ + @Test void unknownAndIncompleteProviderResultsBlockPlanCreation() { + when(repository.find(List.of("1"))).thenReturn(List.of(new ContainerArrivalTimeView("1",null,null,0,0,null,false))); + when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of( + new AppointmentForecastClient.Forecast(1L,null,null,"SOURCE_LOCAL_UNSPECIFIED",null)))); + assertNull(service.resolve(List.of("1")).get(0).plannedDate()); + assertEquals(409,assertThrows(ResponseStatusException.class,()->service.requiredDate("1")).getStatusCode().value()); + when(forecasts.batch(anyString(),any())).thenReturn(ApiResponse.success(List.of())); + assertEquals(503,assertThrows(ResponseStatusException.class,()->service.requiredDate("1")).getStatusCode().value()); + } + + /** 同柜版本或已建计划日期冲突时不能被默认到港算法覆盖。 */ + @Test void inconsistentOrMissingFrozenDatesRemainBlocked() { + for(var row:List.of(new ContainerArrivalTimeView("1",null,null,-1,0,null,false), + new ContainerArrivalTimeView("1",null,null,1,0,null,false), + new ContainerArrivalTimeView("1",null,null,0,2,null,false))) { + when(repository.find(List.of("1"))).thenReturn(List.of(row)); + assertThrows(ResponseStatusException.class,()->service.requiredDate("1")); + } + verifyNoInteractions(forecasts); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java index e6f0e86a7..0e886ee52 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderNoGeneratorTest.java @@ -8,7 +8,7 @@ class LastlegOrderNoGeneratorTest { /** 各作业类型使用不同前缀,同类型第二次调用必须递增。 */ @Test void routesEveryOperationalTypeToItsDailySequence() { var generator=TestDocumentNumbers.orders(); - Map.ofEntries(Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"), + Map.ofEntries(Map.entry("LASTLEG","WC"),Map.entry("OUTBOUND","CK"),Map.entry("ONE_CLICK_DROPSHIP","DFCK"), Map.entry("RELABEL_OUTBOUND","HBCK"),Map.entry("RETURN_OUTBOUND","THCK"), Map.entry("DELIVERY","DL"),Map.entry("TRUCK","TR"),Map.entry("LOOSEBOARD","LB"), Map.entry("EXCEPTION","EX"),Map.entry("DISCARD","CD"),Map.entry("INBOUND_PLAN","IP"), @@ -19,12 +19,4 @@ class LastlegOrderNoGeneratorTest { }); assertThrows(IllegalArgumentException.class,()->generator.next("UNKNOWN")); } - /** 头程来源尾程号未纳入独立改号,保持同柜分组关系。 */ - @Test void actualContainerNumberRemainsStableAndShared() { - var generator=TestDocumentNumbers.orders(); - assertTrue(generator.forContainerNo("MSCU6639870").matches("FL-WH-\\d{8}-\\d{6}")); - assertEquals(generator.forContainerNo("MSCU6639870"),TestDocumentNumbers.orders().forContainerNo("MSCU6639870")); - assertNotEquals(generator.forContainerNo("MSCU6639870"),generator.forContainerNo("CSQU3054383")); - assertThrows(IllegalArgumentException.class,()->generator.forContainerNo(" ")); - } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java index ddd8685d2..7cd7faa73 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LastlegOrderServiceTest.java @@ -117,7 +117,7 @@ class LastlegOrderServiceTest { void setUp() { com.baomidou.mybatisplus.core.metadata.TableInfoHelper.initTableInfo( new org.apache.ibatis.builder.MapperBuilderAssistant(new org.apache.ibatis.session.Configuration(), ""), LastlegOrder.class); - service = new LastlegOrderService(); + service = new LastlegOrderService(); ReflectionTestUtils.setField(service,"warehouseArrival", new ContainerWarehouseArrivalService(new com.baiyue.lastleg.repository.ContainerWarehouseArrivalRepository(orders), org.mockito.Mockito.mock(com.baiyue.lastleg.appointmentplanning.AppointmentForecastClient.class), "test")); ReflectionTestUtils.setField(service, "transferQuantities", transferQuantities); ReflectionTestUtils.setField(service, "returnOrderReferences", returnOrderReferences); ReflectionTestUtils.setField(service, "orderMapper", orders); @@ -148,7 +148,7 @@ class LastlegOrderServiceTest { when(orders.countContainerPlans("BOOKING-1")).thenReturn(0L); when(orders.updateContainerExpectedWarehouseArrival("BOOKING-1", estimatedAt, 0)).thenReturn(2); when(orders.findContainerArrivalTimes(List.of("BOOKING-1"))).thenReturn(List.of( - new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0))); + new ContainerArrivalTimeView("BOOKING-1", estimatedAt, null, 1, 0, estimatedAt.toLocalDate(), false))); ContainerArrivalTimeView result = service.changeExpectedWarehouseArrival("BOOKING-1", "arrival-1", new LastlegOrderRequests.ChangeExpectedWarehouseArrival(estimatedAt, 0, null), "admin"); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 5f43e3f66..1aab0bfb9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -2,11 +2,42 @@ package com.baiyue.lastleg.service; import com.baiyue.common.event.*; import com.baiyue.lastleg.entity.*; import com.baiyue.lastleg.mapper.*; import com.baiyue.lastleg.dropship.dto.DropshipRequests; import com.baiyue.lastleg.dropship.service.DropshipOrderService; import com.baiyue.lastleg.inboundplan.entity.InboundPlanCarton; import com.baiyue.lastleg.inboundplan.mapper.InboundPlanCartonMapper; import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.databind.*; import org.junit.jupiter.api.*; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.*; import org.springframework.test.util.ReflectionTestUtils; import java.math.BigDecimal; import java.time.Instant; import java.util.*; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @ExtendWith(org.mockito.junit.jupiter.MockitoExtension.class) class LoadingCompletedConsumerTest { + /** 两个来源柜复用同一实箱时仍各自取号,不能把历史箱号当本次柜身份。 */ + @Test void reusedPhysicalContainerKeepsDistinctSourceBookingNumbers() { + when(sequences.next("WC")).thenReturn("WC202610210001","WC202610210002"); + EventEnvelope first=event(); service.consume(first); + com.fasterxml.jackson.databind.node.ObjectNode payload=first.payload().deepCopy(); + payload.put("containerBookingId","book-2"); + EventEnvelope second=new EventEnvelope("123e4567-e89b-12d3-a456-426614174099",first.eventType(),first.eventVersion(), + first.occurredAt(),first.tenantId(),first.aggregateType(),first.aggregateId(),first.aggregateVersion(), + first.handoverVersion(),first.traceId(),first.correlationId(),null,payload, + EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(payload))); + service.consume(second); + ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders,times(4)).insert(captured.capture()); + assertEquals(2,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); + verify(orders).containerOrderNumbers("book-1"); verify(orders).containerOrderNumbers("book-2"); + } + /** 同柜后续客户交接沿用首票编号,不再次占用日流水。 */ + @Test void reusesTheExistingContainerNumberWithoutAllocatingAgain() { + when(orders.containerOrderNumbers(anyString())).thenReturn(List.of("WC202610200001")); + service.consume(event()); + ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); + verify(orders,times(2)).insert(captured.capture()); + assertTrue(captured.getAllValues().stream().allMatch(order->"WC202610200001".equals(order.getOrderNo()))); + verify(sequences,never()).next(anyString()); + } + /** 同柜身份不一致必须停止,不能挑选任一旧号继续生成订单。 */ + @Test void rejectsConflictingContainerBusinessNumbers() { + when(orders.containerOrderNumbers(anyString())).thenReturn(List.of("WC202610200001","WC202610200002")); + assertThrows(IllegalStateException.class,()->service.consume(event())); + verify(orders,never()).insert(any(LastlegOrder.class)); + } private final Map frozenContainerNos=new HashMap<>(); @Mock LastlegEventInboxMapper inbox; @Mock LastlegOrderMapper orders; @Mock LastlegInboundOrderMapper inboundOrders; @Mock LastlegOrderDetailMapper details; @Mock LastlegOrderStatusHistoryMapper histories; @Mock InboundPlanCartonMapper cartons; @Mock DropshipOrderService dropshipOrders; @Mock InventoryCommandService inventory; @Mock com.baiyue.lastleg.shipping.mapper.ContainerShippingGateMapper shippingGates; @Mock com.baiyue.lastleg.inboundplan.pda.mapper.ContainerLoadingEvidenceMapper loadingEvidence; @Mock LastlegDocumentNumberGenerator sequences; ObjectMapper json=new ObjectMapper().findAndRegisterModules(); LoadingCompletedConsumer service; - @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } - @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("FL-WH-[0-9]{8}-[0-9]{6}")); } - @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^FL-WH-\\d{8}-\\d{6}$")); verifyNoInteractions(sequences); } + @BeforeEach void setup(){ service=new LoadingCompletedConsumer(); ReflectionTestUtils.setField(service,"inboxMapper",inbox); ReflectionTestUtils.setField(service,"orderMapper",orders); ReflectionTestUtils.setField(service,"orderNumbers",new com.baiyue.lastleg.repository.LastlegOrderNumberRepository(orders)); lenient().when(sequences.next("WC")).thenReturn("WC202610210001"); ReflectionTestUtils.setField(service,"inboundOrderMapper",inboundOrders); ReflectionTestUtils.setField(service,"detailMapper",details); ReflectionTestUtils.setField(service,"historyMapper",histories); ReflectionTestUtils.setField(service,"cartonMapper",cartons); ReflectionTestUtils.setField(service,"dropshipOrders",dropshipOrders); ReflectionTestUtils.setField(service,"inventory",inventory); ReflectionTestUtils.setField(service,"shippingGates",shippingGates); ReflectionTestUtils.setField(service,"loadingEvidence",loadingEvidence); ReflectionTestUtils.setField(service,"objectMapper",json); LastlegOrderNoGenerator generator=new LastlegOrderNoGenerator(sequences); ReflectionTestUtils.setField(service,"orderNoGenerator",generator); lenient().doAnswer(i->{ LastlegOrder o=i.getArgument(0); o.setId(Math.abs(o.getSourceInboundId().hashCode())+1L); return 1; }).when(orders).insert(any(LastlegOrder.class)); lenient().when(orders.lockContainerNumbers(anyString())).thenReturn(List.of()); lenient().when(shippingGates.initialize(anyString(),anyString(),anyString(),anyLong(),anyLong())).thenAnswer(i->{ String key=i.getArgument(0); String value=i.getArgument(2); return frozenContainerNos.putIfAbsent(key,value)==null?1:0; }); lenient().when(shippingGates.lock(anyString())).thenAnswer(i->{ String key=i.getArgument(0); String value=frozenContainerNos.get(key); if(value==null)return null; var gate=new com.baiyue.lastleg.shipping.entity.ContainerShippingGate(); gate.setContainerBookingId(key); gate.setContainerNo(value); return gate; }); } + @Test void createsPendingOrdersAndInTransitSnapshotsWithoutInboundPlans(){ EventEnvelope event=event(); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(event).outcome()); assertThrows(NoSuchFieldException.class,()->LoadingCompletedConsumer.class.getDeclaredField("inboundPlans")); verify(orders,times(2)).insert(any(LastlegOrder.class)); ArgumentCaptor inTransit=ArgumentCaptor.forClass(InventoryCommandRequests.InTransit.class); verify(inventory,times(2)).registerInTransit(eq("100"),eq("SYSTEM"),inTransit.capture()); assertEquals(Set.of("RK1","RK2"),inTransit.getAllValues().stream().map(InventoryCommandRequests.InTransit::inboundOrderNo).collect(java.util.stream.Collectors.toSet())); verify(details,times(2)).insert(any(LastlegOrderDetail.class)); verify(histories,times(2)).insert(any(LastlegOrderStatusHistory.class)); ArgumentCaptor c=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(c.capture()); assertEquals(Set.of("in-1","in-2"),c.getAllValues().stream().map(LastlegOrder::getSourceInboundId).collect(java.util.stream.Collectors.toSet())); assertTrue(c.getAllValues().stream().allMatch(o->"PENDING_ASSIGNMENT".equals(o.getCurrentStatus()))); assertTrue(c.getAllValues().stream().allMatch(o->o.getInboundOrderNo()==null)); assertTrue(c.getAllValues().stream().allMatch(o->o.getId()!=null)); assertTrue(c.getAllValues().stream().allMatch(o->"MSCU6639870".equals(o.getContainerNo()))); assertTrue(c.getAllValues().stream().allMatch(o->"FBA".equals(o.getDestinationType()))); assertTrue(c.getAllValues().stream().allMatch(o->"Customer 100".equals(o.getCustomerName()))); assertTrue(c.getAllValues().stream().allMatch(o->"ow-1".equals(o.getSourceWarehouseId())&&"193000000000200003".equals(o.getOverseasWarehouseId())&&"US-LAX-01".equals(o.getOverseasWarehouseCode())&&"洛杉矶仓".equals(o.getOverseasWarehouseName())&&Integer.valueOf(0).equals(o.getReceivingWarehouseVersion()))); ArgumentCaptor snapshots=ArgumentCaptor.forClass(LastlegInboundOrder.class); verify(inboundOrders,times(2)).insert(snapshots.capture()); assertTrue(snapshots.getAllValues().stream().allMatch(o->"100".equals(o.getCustomerId())&&"FBA".equals(o.getDestinationType()))); assertEquals(1,c.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(c.getAllValues().get(0).getOrderNo().matches("WC[0-9]{8}[0-9]{4}")); } + @Test void oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); com.fasterxml.jackson.databind.node.ArrayNode rows=json.createArrayNode(); for(int index=1;index<=15;index++){ com.fasterxml.jackson.databind.node.ObjectNode row=node.path("orders").get(0).deepCopy(); row.put("sourceInboundId","in-"+index).put("rootInboundId","root-"+index).put("sourceOrderId","order-"+index).put("sourceOrderNo","FL-"+index).put("rootOrderId","root-order-"+index).put("inboundCode","RK-"+index); rows.add(row); } node.set("orders",rows); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope fifteen=new EventEnvelope("123e4567-e89b-12d3-a456-426614174015",source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); assertEquals(com.baiyue.common.event.DeliveryAck.Outcome.APPLIED, service.consume(fifteen).outcome()); ArgumentCaptor captured=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(15)).insert(captured.capture()); assertEquals(1,captured.getAllValues().stream().map(LastlegOrder::getOrderNo).distinct().count()); assertTrue(captured.getAllValues().get(0).getOrderNo().matches("^WC\\d{8}\\d{4}$")); verify(sequences).next("WC"); } @Test void rejectsMissingOwnerSnapshotBeforeAnyLastlegWrite(){ EventEnvelope source=event(); com.fasterxml.jackson.databind.node.ObjectNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node.path("orders").get(0)).put("customerName"," "); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope missing=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(missing)); assertTrue(error.getMessage().contains("尾程事件负载无效")||error.getMessage().contains("货主快照")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); verify(orders,never()).insert(any(LastlegOrder.class)); } @Test void freezesFormalPlannedWarehouseArrivalFromLoadingEvent(){ EventEnvelope source=event(); JsonNode node=source.payload().deepCopy(); ((com.fasterxml.jackson.databind.node.ObjectNode)node).put("estimatedWarehouseArrivalAt","2026-10-03T09:00"); String hash=EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(node)); EventEnvelope changed=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),node,hash); service.consume(changed); ArgumentCaptor captor=ArgumentCaptor.forClass(LastlegOrder.class); verify(orders,times(2)).insert(captor.capture()); assertTrue(captor.getAllValues().stream().allMatch(order->java.time.LocalDateTime.of(2026,10,3,9,0).equals(order.getEstimatedWarehouseAt()))); } @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","","MSCU6639870",0L,1L); } From 8adaea75fa165009a0719e131bb266288a3e8b55 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 08:16:51 +0800 Subject: [PATCH 078/113] fix(lastleg): allow appointment registration before dispatch assignment --- .../work/FbaAppointmentExecutionAdapter.java | 3 ++- .../FbaAppointmentExecutionAdapterTest.java | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java index 04242c5c0..297689303 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java @@ -39,7 +39,8 @@ public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPo var members=members(input);if(members.isEmpty()) throw bad("请选择需要重新预约的客户"); var before=read(batches(members));var selected=selected(before,members); var tripNos=tripNos(selected);var numbers=dispatchNos(selected); - amendments.lockExistingAppointmentDispatches(new Actor(operator,ActorMode.PLATFORM,null),numbers); + // 尚未排车的客户仍需核对完整执行版本,但没有原排车资源需要加锁。 + if(!numbers.isEmpty()) amendments.lockExistingAppointmentDispatches(new Actor(operator,ActorMode.PLATFORM,null),numbers); for(var number:numbers) { dispatches.completion(number,true); dispatches.lockForPlanning(number); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java index 6d73f4d0d..fc81dcad2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java @@ -52,6 +52,33 @@ class FbaAppointmentExecutionAdapterTest { assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty","operator")); verifyNoInteractions(mapper,amendments,dispatches); } + /** 有真实客户货物但尚未排车时,可按当前完整版本登记预约,不调用原排车锁。 */ + @Test void unassignedCustomerCanRebookWithoutLockingAnEmptyDispatchScope() { + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of()); + var current=adapter.inspect(List.of(b)); + var locked=adapter.lockAndInspect(List.of(b),current.version(),"operator"); + assertEquals(current.version(),locked.version()); + assertFalse(locked.members().get(0).completed());assertTrue(locked.members().get(0).canRebook()); + verifyNoInteractions(amendments,dispatches); + } + /** 无排车不免除乐观锁,过期的完整执行版本仍须拒绝。 */ + @Test void unassignedCustomerStillRequiresTheCurrentCompleteExecutionVersion() { + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of()); + var error=assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->adapter.lockAndInspect(List.of(b),"stale-version","operator")); + assertEquals(409,error.getStatusCode().value()); + verifyNoInteractions(amendments,dispatches); + } + /** 首读无排车、复核时已关联排车,必须拒绝并要求刷新,不能沿空范围放行。 */ + @Test void dispatchAssociatedDuringTheEmptyScopeRecheckInvalidatesTheVersion() { + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(),List.of(),List.of(row("B","BEFORE_LOADING","PLANNED"))); + var prior=adapter.inspect(List.of(b)); + var error=assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->adapter.lockAndInspect(List.of(b),prior.version(),"operator")); + assertEquals(409,error.getStatusCode().value()); + verifyNoInteractions(amendments); + verify(dispatches,never()).completion(anyString(),eq(true));verify(dispatches,never()).lockForPlanning(anyString()); + } /** 同车总发运量不能替代本客户的真实签收数量。 */ @Test void onePhysicalDispatchUsesCustomerEvidenceInsteadOfTheWholeCarShipmentTotal() { var rows=List.of(new AppointmentWorkDispatchMapper.TransportRow(1,"D-ONE","A","ACTIVE","DELIVERING",2L,200,200,LocalDateTime.of(2026,10,1,12,0),11,101,100,"RECEIVED",batch,"ORDER:1","FBA:ONT8",100,"T-ONE",2L,"IN_TRANSIT",201L,"OUTBOUNDED","COMPLETED"), From 74f510a49b8a378b377f94c92f007a2b937f6744 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 08:16:52 +0800 Subject: [PATCH 079/113] fix(lastleg): repair first appointment execution scope on validated release baseline --- .../work/FbaAppointmentExecutionAdapter.java | 3 ++- .../FbaAppointmentExecutionAdapterTest.java | 27 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java index 04242c5c0..297689303 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapter.java @@ -39,7 +39,8 @@ public class FbaAppointmentExecutionAdapter implements FbaAppointmentExecutionPo var members=members(input);if(members.isEmpty()) throw bad("请选择需要重新预约的客户"); var before=read(batches(members));var selected=selected(before,members); var tripNos=tripNos(selected);var numbers=dispatchNos(selected); - amendments.lockExistingAppointmentDispatches(new Actor(operator,ActorMode.PLATFORM,null),numbers); + // 尚未排车的客户仍需核对完整执行版本,但没有原排车资源需要加锁。 + if(!numbers.isEmpty()) amendments.lockExistingAppointmentDispatches(new Actor(operator,ActorMode.PLATFORM,null),numbers); for(var number:numbers) { dispatches.completion(number,true); dispatches.lockForPlanning(number); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java index 6d73f4d0d..fc81dcad2 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/work/FbaAppointmentExecutionAdapterTest.java @@ -52,6 +52,33 @@ class FbaAppointmentExecutionAdapterTest { assertThrows(org.springframework.web.server.ResponseStatusException.class,()->adapter.lockAndInspect(List.of(),"empty","operator")); verifyNoInteractions(mapper,amendments,dispatches); } + /** 有真实客户货物但尚未排车时,可按当前完整版本登记预约,不调用原排车锁。 */ + @Test void unassignedCustomerCanRebookWithoutLockingAnEmptyDispatchScope() { + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of()); + var current=adapter.inspect(List.of(b)); + var locked=adapter.lockAndInspect(List.of(b),current.version(),"operator"); + assertEquals(current.version(),locked.version()); + assertFalse(locked.members().get(0).completed());assertTrue(locked.members().get(0).canRebook()); + verifyNoInteractions(amendments,dispatches); + } + /** 无排车不免除乐观锁,过期的完整执行版本仍须拒绝。 */ + @Test void unassignedCustomerStillRequiresTheCurrentCompleteExecutionVersion() { + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of()); + var error=assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->adapter.lockAndInspect(List.of(b),"stale-version","operator")); + assertEquals(409,error.getStatusCode().value()); + verifyNoInteractions(amendments,dispatches); + } + /** 首读无排车、复核时已关联排车,必须拒绝并要求刷新,不能沿空范围放行。 */ + @Test void dispatchAssociatedDuringTheEmptyScopeRecheckInvalidatesTheVersion() { + when(mapper.transportRows(List.of(batch),List.of())).thenReturn(List.of(),List.of(),List.of(row("B","BEFORE_LOADING","PLANNED"))); + var prior=adapter.inspect(List.of(b)); + var error=assertThrows(org.springframework.web.server.ResponseStatusException.class, + ()->adapter.lockAndInspect(List.of(b),prior.version(),"operator")); + assertEquals(409,error.getStatusCode().value()); + verifyNoInteractions(amendments); + verify(dispatches,never()).completion(anyString(),eq(true));verify(dispatches,never()).lockForPlanning(anyString()); + } /** 同车总发运量不能替代本客户的真实签收数量。 */ @Test void onePhysicalDispatchUsesCustomerEvidenceInsteadOfTheWholeCarShipmentTotal() { var rows=List.of(new AppointmentWorkDispatchMapper.TransportRow(1,"D-ONE","A","ACTIVE","DELIVERING",2L,200,200,LocalDateTime.of(2026,10,1,12,0),11,101,100,"RECEIVED",batch,"ORDER:1","FBA:ONT8",100,"T-ONE",2L,"IN_TRANSIT",201L,"OUTBOUNDED","COMPLETED"), From 0c05c15e5aede6e94bccc4f302bc6cb648df318a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 09:08:26 +0800 Subject: [PATCH 080/113] fix(dispatch): cancel planned cargo without loading readiness --- .../service/DispatchAggregateService.java | 3 +- .../dispatch/service/DispatchService.java | 12 +++++ .../DispatchAggregatePersistenceTest.java | 45 +++++++++++++++++++ .../dispatch/service/DispatchServiceTest.java | 29 ++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index a5af12d9e..62e2b1bac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -373,7 +373,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); if(trim(request.reason())==null)throw bad("取消原因不能为空"); if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消"); - var items=repository.cargo(row.getId());lockExecutionSources(row,items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); + // 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。 + var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); releaseSources(row,actor,items,now()); fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消"); return saveCommand(row,actor,identity,request.reason()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 09aa03622..3d9e901ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -382,6 +382,18 @@ public class DispatchService { }); } + /** 取消只锁本车待释放的申请与原实托,不以当前库存、实收或组托完整性作为撤销安排的资格。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockCancellationSources(String warehouse,List items) { + if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("取消排车仍须明确起运仓库"); + var applications=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())) + .map(DispatchItem::getSourceBusinessNo).collect(Collectors.toCollection(TreeSet::new)); + // 先锁实际需要恢复预占的换标申请,再取得共同实托串行点;计划来源不伪造实托或查询库存。 + if(!applications.isEmpty())lockRelabelApplications(applications); + var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList(); + if(!pallets.isEmpty())palletSerial.lock(pallets); + } + /** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */ @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List items) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index f717ac4e1..e55866488 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -201,6 +201,51 @@ public class DispatchAggregatePersistenceTest { verifyNoInteractions(facts,sources,serial); } + /** 有货取消只释放原安排;未实收计划和已有实托都不重验当前货物资格,也不扣减或恢复库存。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={true,false}) + void cancellationReleasesOriginalClaimsWithoutRequiringCurrentCargoReadiness(boolean planned) throws Exception { + var f=new Fixture();var a=f.source(1,"101",501,"ONT8",10);var b=f.source(2,"101",502,"ONT8",10); + if(planned)for(var row:f.candidates.values())row.warehousePalletId(null).sourceStage("PLAN"); + var created=f.tx.execute(t->f.service.create(f.actor,f.create("cargo-cancel",List.of(a,b)))); + var root=f.repository.find(created.dispatchNo()); + var originalCargo=f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"); + var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial); + when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("当前货物不满足装车条件")); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules); + var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-cargo-original","仅撤销运输安排"); + var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)); + assertThat(cancelled.status()).isEqualTo(Status.CANCELLED); + assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThat(f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")).isEqualTo(originalCargo); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='cancel-cargo-original'",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip WHERE id=?",String.class,root.getTripId())).isEqualTo("CANCELLED"); + verifyNoInteractions(facts,sources); + if(planned)verifyNoInteractions(serial);else {verify(serial).lock(List.of(501L,502L));verifyNoMoreInteractions(serial);} + } + + /** 撤销安排不校验装车就绪,但真实扫码和旧版本仍阻断且不释放原占用。 */ + @Test void cancellationStillRejectsStaleVersionAndRealPickWithZeroHeaderCounts() throws Exception { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",10); + var created=f.tx.execute(t->f.service.create(f.actor,f.create("guarded-cancel",List.of(source)))); + var stale=new DispatchCommands.ReasonCommand(created.recordVersion()+1,"stale-cancel","旧版本不能释放占用"); + assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),stale))).hasMessageContaining("版本已变化"); + long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item",Long.class); + f.db.update("INSERT INTO ll_dispatch_pda_pick(customer_id,dispatch_item_id,packages)VALUES('101',?,1)",item); + var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"picked-cancel","不能隐去真实扫码"); + assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request))).hasMessageContaining("真实执行"); + assertThat(f.db.queryForObject("SELECT status FROM ll_dispatch_order",String.class)).isEqualTo("ACTIVE"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isZero(); + } + /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 9962d2043..4db465dd5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -57,6 +57,35 @@ class DispatchServiceTest { assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("完整实托来源已变化"); verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial); } + /** 未实收计划撤销不依赖实托、当前货源资格或库存,执行链仍保留独立严格校验。 */ + @Test void cancellationOfPlannedCargoNeedsNoPhysicalOrInventoryFacts(){ + var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem();item.setPlanPalletId(1L);item.setSourceStage("PLAN"); + service.lockCancellationSources("9",List.of(item)); + service.lockCancellationSources("9",List.of()); + verifyNoInteractions(facts,sources,palletSerial,relabels,measurements); + } + /** 已有实托只按原身份取得升序串行锁,不因货物数量或新来源变化阻断释放本车占用。 */ + @Test void physicalCancellationLocksOriginalPalletsWithoutReadingCurrentStock(){ + var a=new com.baiyue.lastleg.dispatch.entity.DispatchItem();a.setWarehousePalletId(502L); + var b=new com.baiyue.lastleg.dispatch.entity.DispatchItem();b.setWarehousePalletId(501L); + service.lockCancellationSources("9",List.of(a,b,a)); + verify(palletSerial).lock(List.of(501L,502L));verifyNoMoreInteractions(palletSerial); + verifyNoInteractions(facts,sources,relabels,measurements); + } + /** 换标恢复预占仍先锁齐本车申请再锁原实托,不锁入后来新增的其他申请。 */ + @Test void relabelCancellationLocksSelectedApplicationsBeforeOriginalPallets(){ + var a=new com.baiyue.lastleg.dispatch.entity.DispatchItem();a.setSourceBusinessType("RELABEL_ORDER");a.setSourceBusinessNo("RL-2");a.setWarehousePalletId(502L); + var b=new com.baiyue.lastleg.dispatch.entity.DispatchItem();b.setSourceBusinessType("RELABEL_ORDER");b.setSourceBusinessNo("RL-1");b.setWarehousePalletId(501L); + org.springframework.transaction.support.TransactionSynchronizationManager.initSynchronization(); + try { + service.lockCancellationSources("9",List.of(a,b)); + var order=inOrder(facts,palletSerial);order.verify(facts).lockApplications(List.of("RL-1","RL-2"));order.verify(palletSerial).lock(List.of(501L,502L)); + verifyNoMoreInteractions(facts,palletSerial);verifyNoInteractions(sources,relabels,measurements); + } finally { + for(var synchronization:org.springframework.transaction.support.TransactionSynchronizationManager.getSynchronizations())synchronization.afterCompletion(0); + org.springframework.transaction.support.TransactionSynchronizationManager.clearSynchronization(); + } + } @Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){ assertThatThrownBy(()->service.candidatePage("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign",1,20,"ALL"))).hasMessageContaining("草稿");verifyNoInteractions(sources); } From e60890e63e36fd48ea601ecc1e9f95b1aa49d2b9 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 09:08:27 +0800 Subject: [PATCH 081/113] fix(dispatch): release original planned transport claims on cancellation Exact deployed baseline plus four reviewed files. Focused cancellation tests: 40 passed. --- .../service/DispatchAggregateService.java | 3 +- .../dispatch/service/DispatchService.java | 12 +++++ .../DispatchAggregatePersistenceTest.java | 45 +++++++++++++++++++ .../dispatch/service/DispatchServiceTest.java | 29 ++++++++++++ 4 files changed, 88 insertions(+), 1 deletion(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index a5af12d9e..62e2b1bac 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -373,7 +373,8 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get();requireEditing(row,request.recordVersion()); if(trim(request.reason())==null)throw bad("取消原因不能为空"); if(repository.realPicked(row.getId())!=0 || repository.outbounds(row.getId()).stream().anyMatch(o->!"CANCELLED".equals(o.status())))throw conflict("本车已有真实执行或关联出库,不能取消"); - var items=repository.cargo(row.getId());lockExecutionSources(row,items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); + // 撤销的是本车运输占用,不重新要求货物已实收或满足装车条件;真实执行仍由前面的零扫码及出库门禁保护。 + var items=repository.cargo(row.getId());sourceRules.lockCancellationSources(row.getOverseasWarehouseId(),items);appointments.lock(repository.batches(row.getId()),List.of());appointments.remove(row.getId(),items);sourceRules.releaseWholeRelabel(items); releaseSources(row,actor,items,now()); fleet.cancelDispatchShell(number,actor.operatorId(),request.reason());row.setStatus(Status.CANCELLED.name());invalidate(row,"排车已取消"); return saveCommand(row,actor,identity,request.reason()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 09aa03622..3d9e901ca 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -382,6 +382,18 @@ public class DispatchService { }); } + /** 取消只锁本车待释放的申请与原实托,不以当前库存、实收或组托完整性作为撤销安排的资格。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockCancellationSources(String warehouse,List items) { + if(warehouse==null || warehouse.isBlank())throw new IllegalArgumentException("取消排车仍须明确起运仓库"); + var applications=items.stream().filter(i->"RELABEL_ORDER".equals(i.getSourceBusinessType())) + .map(DispatchItem::getSourceBusinessNo).collect(Collectors.toCollection(TreeSet::new)); + // 先锁实际需要恢复预占的换标申请,再取得共同实托串行点;计划来源不伪造实托或查询库存。 + if(!applications.isEmpty())lockRelabelApplications(applications); + var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList(); + if(!pallets.isEmpty())palletSerial.lock(pallets); + } + /** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */ @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List items) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java index f717ac4e1..e55866488 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchAggregatePersistenceTest.java @@ -201,6 +201,51 @@ public class DispatchAggregatePersistenceTest { verifyNoInteractions(facts,sources,serial); } + /** 有货取消只释放原安排;未实收计划和已有实托都不重验当前货物资格,也不扣减或恢复库存。 */ + @org.junit.jupiter.params.ParameterizedTest + @org.junit.jupiter.params.provider.ValueSource(booleans={true,false}) + void cancellationReleasesOriginalClaimsWithoutRequiringCurrentCargoReadiness(boolean planned) throws Exception { + var f=new Fixture();var a=f.source(1,"101",501,"ONT8",10);var b=f.source(2,"101",502,"ONT8",10); + if(planned)for(var row:f.candidates.values())row.warehousePalletId(null).sourceStage("PLAN"); + var created=f.tx.execute(t->f.service.create(f.actor,f.create("cargo-cancel",List.of(a,b)))); + var root=f.repository.find(created.dispatchNo()); + var originalCargo=f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id"); + var rules=new DispatchService();var facts=mock(com.baiyue.lastleg.relabel.port.RelabelDispatchSourcePort.class); + var sources=mock(DispatchSourceRepository.class);var serial=mock(com.baiyue.lastleg.inventory.port.PhysicalPalletSerialPort.class); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"relabelSources",facts); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"wholeSources",sources); + org.springframework.test.util.ReflectionTestUtils.setField(rules,"palletSerial",serial); + when(facts.snapshot(anyString(),anyList())).thenThrow(new IllegalArgumentException("当前货物不满足装车条件")); + org.springframework.test.util.ReflectionTestUtils.setField(f.service,"sourceRules",rules); + var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"cancel-cargo-original","仅撤销运输安排"); + var cancelled=f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)); + assertThat(cancelled.status()).isEqualTo(Status.CANCELLED); + assertThat(f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request)).replayed()).isTrue(); + assertThat(f.db.queryForList("SELECT * FROM ll_dispatch_item ORDER BY id")).isEqualTo(originalCargo); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_physical_pallet_lock WHERE status='ACTIVE'",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_inventory_ledger",Long.class)).isZero(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE idempotency_key='cancel-cargo-original'",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT status FROM ll_fleet_trip WHERE id=?",String.class,root.getTripId())).isEqualTo("CANCELLED"); + verifyNoInteractions(facts,sources); + if(planned)verifyNoInteractions(serial);else {verify(serial).lock(List.of(501L,502L));verifyNoMoreInteractions(serial);} + } + + /** 撤销安排不校验装车就绪,但真实扫码和旧版本仍阻断且不释放原占用。 */ + @Test void cancellationStillRejectsStaleVersionAndRealPickWithZeroHeaderCounts() throws Exception { + var f=new Fixture();var source=f.source(1,"101",501,"ONT8",10); + var created=f.tx.execute(t->f.service.create(f.actor,f.create("guarded-cancel",List.of(source)))); + var stale=new DispatchCommands.ReasonCommand(created.recordVersion()+1,"stale-cancel","旧版本不能释放占用"); + assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),stale))).hasMessageContaining("版本已变化"); + long item=f.db.queryForObject("SELECT id FROM ll_dispatch_item",Long.class); + f.db.update("INSERT INTO ll_dispatch_pda_pick(customer_id,dispatch_item_id,packages)VALUES('101',?,1)",item); + var request=new DispatchCommands.ReasonCommand(created.recordVersion(),"picked-cancel","不能隐去真实扫码"); + assertThatThrownBy(()->f.tx.execute(t->f.service.cancel(f.actor,created.dispatchNo(),request))).hasMessageContaining("真实执行"); + assertThat(f.db.queryForObject("SELECT status FROM ll_dispatch_order",String.class)).isEqualTo("ACTIVE"); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pallet_lock WHERE status='ACTIVE'",Long.class)).isOne(); + assertThat(f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_command WHERE action='CANCEL'",Long.class)).isZero(); + } + /** 空单编辑保留零货量与版本命令,不借编辑隐式制造可装车来源。 */ @Test void emptyShellEditPreservesZeroCargoAndVersionedReplay() throws Exception { var f=new Fixture();var created=f.tx.execute(t->f.service.create(f.actor,f.create("empty-edit",List.of()))); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java index 9962d2043..4db465dd5 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchServiceTest.java @@ -57,6 +57,35 @@ class DispatchServiceTest { assertThatThrownBy(()->service.lockExecutionSources("9",List.of(item))).hasMessageContaining("完整实托来源已变化"); verify(facts).snapshot("9",List.of(501L));verifyNoInteractions(sources,palletSerial); } + /** 未实收计划撤销不依赖实托、当前货源资格或库存,执行链仍保留独立严格校验。 */ + @Test void cancellationOfPlannedCargoNeedsNoPhysicalOrInventoryFacts(){ + var item=new com.baiyue.lastleg.dispatch.entity.DispatchItem();item.setPlanPalletId(1L);item.setSourceStage("PLAN"); + service.lockCancellationSources("9",List.of(item)); + service.lockCancellationSources("9",List.of()); + verifyNoInteractions(facts,sources,palletSerial,relabels,measurements); + } + /** 已有实托只按原身份取得升序串行锁,不因货物数量或新来源变化阻断释放本车占用。 */ + @Test void physicalCancellationLocksOriginalPalletsWithoutReadingCurrentStock(){ + var a=new com.baiyue.lastleg.dispatch.entity.DispatchItem();a.setWarehousePalletId(502L); + var b=new com.baiyue.lastleg.dispatch.entity.DispatchItem();b.setWarehousePalletId(501L); + service.lockCancellationSources("9",List.of(a,b,a)); + verify(palletSerial).lock(List.of(501L,502L));verifyNoMoreInteractions(palletSerial); + verifyNoInteractions(facts,sources,relabels,measurements); + } + /** 换标恢复预占仍先锁齐本车申请再锁原实托,不锁入后来新增的其他申请。 */ + @Test void relabelCancellationLocksSelectedApplicationsBeforeOriginalPallets(){ + var a=new com.baiyue.lastleg.dispatch.entity.DispatchItem();a.setSourceBusinessType("RELABEL_ORDER");a.setSourceBusinessNo("RL-2");a.setWarehousePalletId(502L); + var b=new com.baiyue.lastleg.dispatch.entity.DispatchItem();b.setSourceBusinessType("RELABEL_ORDER");b.setSourceBusinessNo("RL-1");b.setWarehousePalletId(501L); + org.springframework.transaction.support.TransactionSynchronizationManager.initSynchronization(); + try { + service.lockCancellationSources("9",List.of(a,b)); + var order=inOrder(facts,palletSerial);order.verify(facts).lockApplications(List.of("RL-1","RL-2"));order.verify(palletSerial).lock(List.of(501L,502L)); + verifyNoMoreInteractions(facts,palletSerial);verifyNoInteractions(sources,relabels,measurements); + } finally { + for(var synchronization:org.springframework.transaction.support.TransactionSynchronizationManager.getSynchronizations())synchronization.afterCompletion(0); + org.springframework.transaction.support.TransactionSynchronizationManager.clearSynchronization(); + } + } @Test void anotherOperatorsDraftIsRejectedBeforeAnyCandidateRead(){ assertThatThrownBy(()->service.candidatePage("operator",new DispatchPlanningModels.CandidateQuery("9",null,null,null,"PLAN","foreign",1,20,"ALL"))).hasMessageContaining("草稿");verifyNoInteractions(sources); } From 5d71af1ac989b009268f3e0e39e7016288f67d94 Mon Sep 17 00:00:00 2001 From: liuyihe <2513762445@qq.com> Date: Thu, 22 Oct 2026 10:28:38 +0800 Subject: [PATCH 082/113] =?UTF-8?q?=E8=8A=82=E7=82=B9=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../firstleg/workflow/WorkflowTabService.java | 8 ++++---- .../firstleg/workflow/WorkflowBoundaryTest.java | 15 ++++++++++++--- .../firstleg/workflow/WorkflowBusinessTest.java | 6 +++--- 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java index 1e6a102dc..6a8b56e57 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/workflow/WorkflowTabService.java @@ -27,7 +27,7 @@ public class WorkflowTabService { if(facts.stream().noneMatch(f->f.tenantId()==fee.tenantId() && f.orderId()==fee.orderId())) throw unavailable("费用确认事实不属于本批入仓来源"); result.putIfAbsent(fee.orderId()+":"+fee.mainStatus(),fee); } - var sources=facts.stream().filter(f->f.departed() || f.clearanceUploaded()).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); + var sources=facts.stream().filter(WorkflowFacts.Fact::departed).map(f->new WorkflowFacts.Source(f.tenantId(),f.inboundId())).distinct().toList(); Map overseas=new HashMap<>(); if(!sources.isEmpty()) { try { @@ -61,11 +61,11 @@ public class WorkflowTabService { Set keys=new LinkedHashSet<>(); if("MEASURE_PENDING".equals(process) && "PENDING".equals(f.reviewStatus())) keys.add("MEASUREMENT_REVIEW"); boolean signed="DELIVERED".equals(process); - // 报关完成即进入待清关;不再等待船期接口补齐实际离港节点。 - boolean clearanceStage=f.customsCompleted() || f.departed() || f.clearanceUploaded(); + // 人工补录或船司查询的实际开船节点才推进阶段,报关完成和提前上传资料不能代替开船。 + boolean clearanceStage=f.departed(); if(!clearanceStage && !signed) { if(f.missingCustoms()) keys.add("WAIT_CUSTOMS_DOCS"); - if(f.exportCustomsReady()) keys.add("EXPORT_CUSTOMS"); + if(f.exportCustomsReady() || f.customsCompleted()) keys.add("EXPORT_CUSTOMS"); } if(signed) keys.add("SIGNED"); if(clearanceStage && !signed) { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java index ca565dcbe..006955233 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBoundaryTest.java @@ -36,8 +36,8 @@ class WorkflowBoundaryTest { assertThatThrownBy(()->service.rows(List.of(inbound))).isInstanceOf(BizException.class).hasCause(failure); } - /** 未记录离港时,报关完成即可待清关;上传清关资料后也必须查询精确海外来源。 */ - @Test void completedCustomsAndClearanceUploadDoNotWaitForDeparture() { + /** 报关完成或提前上传资料仍留在出口报关,实际开船后才查询精确海外来源。 */ + @Test void completedCustomsAndClearanceUploadWaitForActualDeparture() { var repository=mock(WorkflowFactRepository.class); var downstream=mock(LastlegWorkflowClient.class); var service=new WorkflowTabService(repository,downstream); @@ -45,12 +45,21 @@ class WorkflowBoundaryTest { inbound.setOrderId(101L);inbound.setProcessStatus("LOADED"); when(repository.facts(List.of(1L))).thenReturn(List.of( new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,true))); - assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE"); + assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS"); verifyNoInteractions(downstream); when(repository.facts(List.of(1L))).thenReturn(List.of( new WorkflowFacts.Fact(11,1,101,"APPROVED",false,true,false,true,true))); + assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("EXPORT_CUSTOMS"); + verifyNoInteractions(downstream); + when(repository.facts(List.of(1L))).thenReturn(List.of( + new WorkflowFacts.Fact(11,1,101,"APPROVED",true,false,false,true,true))); when(downstream.read(any(),any())).thenReturn(ApiResponse.success(List.of( new WorkflowFacts.Overseas(11,1,false,false,false,false)))); + assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("SAILING_CLEARANCE"); + verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1))))); + clearInvocations(downstream); + when(repository.facts(List.of(1L))).thenReturn(List.of( + new WorkflowFacts.Fact(11,1,101,"APPROVED",true,true,false,true,true))); assertThat(service.rows(List.of(inbound))).extracting(r->r.mainStatus()).containsExactly("WAIT_UNLOAD","WAIT_DISPATCH"); verify(downstream).read(any(),eq(new WorkflowFacts.Request(List.of(new WorkflowFacts.Source(11,1))))); } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java index e0aa37b6e..fa85c1fdc 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/workflow/WorkflowBusinessTest.java @@ -3,7 +3,7 @@ import org.junit.jupiter.api.Test; import static org.assertj.core.api.Assertions.*; /** 取消回显、分柜隔离和资料页签回归。 */ class WorkflowBusinessTest { - /** 报关完成进入待清关,清关资料保存后才进入拆柜与排车待办。 */ + /** 实际开船前保留出口报关,实际开船后再按清关资料进入海外待办。 */ @Test void customsDocumentsDepartureAndClearanceDriveTabs() { var missing=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,true,false,false); var uploaded=new WorkflowFacts.Fact(11,1,101,"APPROVED",false,false,false,true,false); @@ -18,9 +18,9 @@ class WorkflowBusinessTest { assertThat(WorkflowTabService.classify(uploaded,"LOADED",null)) .contains("EXPORT_CUSTOMS").doesNotContain("WAIT_CUSTOMS_DOCS","SAILING_CLEARANCE","WAIT_UNLOAD"); assertThat(WorkflowTabService.classify(completed,"LOADED",null)) - .containsExactly("SAILING_CLEARANCE"); + .containsExactly("EXPORT_CUSTOMS"); assertThat(WorkflowTabService.classify(uploadedBeforeDeparture,"LOADED",pendingUnload)) - .containsExactly("WAIT_UNLOAD","WAIT_DISPATCH"); + .containsExactly("EXPORT_CUSTOMS"); assertThat(WorkflowTabService.classify(departed,"LOADED",pendingUnload)) .containsExactly("SAILING_CLEARANCE"); assertThat(WorkflowTabService.classify(cleared,"LOADED",pendingUnload)) From de96e47d7494bbe830a36e5d0324431bf95639d5 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 11:56:47 +0800 Subject: [PATCH 083/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/sql/README.md | 2 + deploy/sql/头程数据库统一增量-MySQL8.sql | 69 +++++++++- firstleg/docs/business-index.md | 2 +- .../PlatformOceanBookingController.java | 11 ++ .../oceanbooking/dto/OceanBookingDtos.java | 32 ++++- .../entity/OceanBookingEntity.java | 11 ++ .../entity/OceanBookingPartyEntity.java | 2 + .../mapper/OceanBookingMapper.java | 11 +- .../repository/OceanBookingRepository.java | 4 + .../service/OceanBookingPrintService.java | 59 +++++++-- .../service/OceanBookingService.java | 45 ++++++- .../db/phase3-ocean-booking-schema.sql | 66 +++++++++- .../resources/openapi/ocean-bookings-v1.yaml | 41 +++++- ...ormOceanBookingControllerContractTest.java | 19 +++ .../OceanBookingMapperContractTest.java | 10 ++ .../OceanBookingSqlContractTest.java | 6 +- .../service/OceanBookingPrintServiceTest.java | 35 +++++- .../service/OceanBookingServiceTest.java | 95 +++++++++++++- scripts/README.md | 2 + scripts/admin-test-env-acceptance.mjs | 4 + scripts/admin-test-env-modules/README.md | 20 ++- .../domestic-container-contract.test.mjs | 24 ++++ .../domestic-container.mjs | 32 ++++- ...ship-mixed-palletization-contract.test.mjs | 5 + .../dropship-mixed-palletization.mjs | 1 + .../finance-full-fixtures-contract.test.mjs | 20 ++- .../finance-full-fixtures.mjs | 10 +- ...ross-customer-acceptance-contract.test.mjs | 2 + ...rnal-dispatch-acceptance-contract.test.mjs | 4 +- .../fleet-quotes-operations-contract.test.mjs | 15 +++ .../fleet-quotes-operations.mjs | 8 +- ...rn-redispatch-acceptance-contract.test.mjs | 20 ++- .../fleet-return-redispatch-acceptance.mjs | 4 +- .../fleet-trip-fulfillment-contract.test.mjs | 113 ++++++++++++++++- .../fleet-trip-fulfillment.mjs | 91 ++++++++++++-- .../manual-loading-complete-contract.test.mjs | 2 +- .../manual-order-splitting-contract.test.mjs | 2 +- .../manual-pda-batch-contract.test.mjs | 2 +- .../manual-pda-ready-contract.test.mjs | 2 +- .../manual-preload-push-contract.test.mjs | 2 +- ...nt-qualification-fixture-contract.test.mjs | 2 +- ...merged-overseas-coverage-contract.test.mjs | 7 +- ...e-cross-service-recovery-contract.test.mjs | 3 +- .../pda-picker-fixture-contract.test.mjs | 84 ++++++++++++- .../admin-test-env-modules/phase3-customs.mjs | 12 +- .../admin-test-env-modules/phase3-loading.mjs | 6 + ...phase4-overseas-business-contract.test.mjs | 32 ++++- .../phase4-overseas-business.mjs | 23 +++- ...eas-finance-cross-region-contract.test.mjs | 15 +++ .../phase4-overseas-finance-cross-region.mjs | 12 +- ...-overseas-pda-operations-contract.test.mjs | 21 +++- .../phase4-overseas-pda-operations.mjs | 12 +- .../phase4-overseas-pda.mjs | 57 ++++++++- ...se5-overseas-fulfillment-contract.test.mjs | 114 +++++++++-------- .../phase5-overseas-fulfillment.mjs | 119 ++++++------------ .../phase5-root-fulfillment-contract.test.mjs | 12 +- .../phase5-root-fulfillment.mjs | 31 ++--- .../procurement-sample-fulfillment.mjs | 4 +- .../tailleg-owner-order-number-contract.mjs | 10 +- ...illeg-owner-order-number-contract.test.mjs | 29 +++-- .../truck-appointments.mjs | 7 ++ .../unified-pda-session-revocation.mjs | 5 + .../client-notifications-contract.test.mjs | 13 +- scripts/fleet-capacity-pool-test-env.mjs | 16 +-- scripts/fleet-capacity-pool-test-env.test.mjs | 5 +- scripts/java-quality-baseline.json | 2 +- scripts/java-quality-summary.test.mjs | 5 +- scripts/mixed-container-outbound-test-env.mjs | 38 ++---- ...native-retained-outbound-contract.test.mjs | 10 +- scripts/procurement-sample-test-env.mjs | 8 +- scripts/procurement-sample-test-env.test.mjs | 9 +- 71 files changed, 1296 insertions(+), 332 deletions(-) diff --git a/deploy/sql/README.md b/deploy/sql/README.md index 5697a4360..dc50965a4 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -54,6 +54,8 @@ 排车内部散板需求发布选项由 `lastleg-service/src/main/resources/db/dispatch-capacity-demand.sql` 提供精确结构变更:仅向 `ll_dispatch_order` 新增默认勾选的 `publish_looseboard_demand` 列,Lastleg与海外空库基线同步。该文件不是自动启动脚本;执行须单独授权,不能重放完整空库文件,不新增业务数据回填或清理。实际执行与部署状态仍归测试环境主契约。 +订舱委/实资料在 `firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql` 与国内统一空库基线同步维护:订舱主表增加条款、包装、品名、唛头和委/实三项货量,关系方增加邮箱、税号;已放舱主表触发器仅允许实际货量及更新审计元数据变化,运输、预报和创建信息冻结。匹配发布单元为 Firstleg 与 Admin;Print 继续使用现有模板。本轮只准备初始化定义,不执行 SQL、不回填测试存量、不提供新旧兼容。已有测试库须另行审阅、授权结构更新,不能重放完整空库基线。 + ## 唯一入口与区域边界 V13 收货覆盖由 `lastleg-service/src/main/resources/db/appointment-receiving.sql` 提供四表:独立收货预约、当前/历史客户成员、待确认变化和不可变覆盖版本;同步 Lastleg 与海外空库基线。有效外部约号唯一性归收货预约的目的仓与约号,批次头表不再独立占用约号。部署前必须核对四表及旧头表唯一索引;本批只准备基线,不提供测试存量回填或自动重置。旧 `reset-test-fba-appointments.sql` 已拒绝存在这些新表的环境,不能用于重放 V13 初始化。实际 SQL 与部署须逐项授权。 diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index 86c727afb..22935332d 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -3442,7 +3442,7 @@ CREATE TABLE fl_mojia_measurement_receipt ( CREATE TABLE fl_ocean_booking ( id bigint unsigned NOT NULL COMMENT '海运订舱ID', booking_no varchar(40) NOT NULL COMMENT '平台海运订舱编号', - status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', + status varchar(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录', customer_id bigint unsigned DEFAULT NULL COMMENT '客户ID和数据隔离键', customer_name varchar(128) CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci DEFAULT NULL COMMENT '客户名称快照', channel_code varchar(64) DEFAULT NULL COMMENT '订舱计划物流渠道组编码快照', @@ -3465,6 +3465,17 @@ CREATE TABLE fl_ocean_booking ( estimated_sailing_at datetime(3) DEFAULT NULL COMMENT '当前确认预计开船时间', estimated_arrival_at datetime(3) DEFAULT NULL COMMENT '当前确认预计到港时间', remark varchar(1000) DEFAULT NULL COMMENT '平台内部备注', + freight_term VARCHAR(64) NULL COMMENT '运费条款', + service_term VARCHAR(64) NULL COMMENT '运输条款', + package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS', + cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求', + marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码', + declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)', + declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)', + declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)', + actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数', + actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg', + actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm', created_by bigint unsigned NOT NULL COMMENT '创建操作人ID', created_by_name varchar(64) DEFAULT NULL COMMENT '创建操作人姓名快照', created_at datetime(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间', @@ -3479,6 +3490,14 @@ CREATE TABLE fl_ocean_booking ( KEY idx_ocean_booking_voyage (vessel_name,voyage_no,updated_at), CONSTRAINT chk_ocean_booking_carrier_code CHECK (((carrier_code is null) or (carrier_code in (_utf8mb4'MATSON',_utf8mb4'SML',_utf8mb4'ZIM',_utf8mb4'COSCO',_utf8mb4'EMC',_utf8mb4'HEDE',_utf8mb4'OTHER')))), CONSTRAINT chk_ocean_booking_customer CHECK ((customer_id > 0)), + CONSTRAINT chk_ocean_booking_cargo CHECK ( + (package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS')) + AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999) + AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999) + AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0) + AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0) + AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0) + AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)), CONSTRAINT chk_ocean_booking_schedule CHECK (((estimated_sailing_at is null) or (estimated_arrival_at is null) or (estimated_arrival_at >= estimated_sailing_at))), CONSTRAINT chk_ocean_booking_status CHECK ((status in (_utf8mb4'DRAFT',_utf8mb4'RELEASED'))) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; @@ -3565,6 +3584,8 @@ CREATE TABLE fl_ocean_booking_party ( address varchar(1000) DEFAULT NULL COMMENT '关系方地址快照', contact_name varchar(128) DEFAULT NULL COMMENT '关系方联系人快照', contact_phone varchar(64) DEFAULT NULL COMMENT '关系方联系电话快照', + contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱', + tax_no VARCHAR(128) NULL COMMENT '关系方税号', PRIMARY KEY (id), UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type), CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking (id) ON DELETE CASCADE, @@ -6026,9 +6047,49 @@ CREATE TRIGGER trg_ocean_allocation_before_insert BEFORE INSERT ON fl_ocean_book END IF; END$$ -CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN - IF OLD.status = 'RELEASED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改'; +CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW +BEGIN + -- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。 + IF OLD.status = 'RELEASED' AND NOT ( + (NEW.id <=> OLD.id) + AND (NEW.booking_no <=> OLD.booking_no) + AND (NEW.status <=> OLD.status) + AND (NEW.customer_id <=> OLD.customer_id) + AND (NEW.customer_name <=> OLD.customer_name) + AND (NEW.channel_code <=> OLD.channel_code) + AND (NEW.channel_name <=> OLD.channel_name) + AND (NEW.warehouse_id <=> OLD.warehouse_id) + AND (NEW.warehouse_name <=> OLD.warehouse_name) + AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no) + AND (NEW.shipping_company_id <=> OLD.shipping_company_id) + AND (NEW.shipping_company_name <=> OLD.shipping_company_name) + AND (NEW.carrier_code <=> OLD.carrier_code) + AND (NEW.booking_agent_id <=> OLD.booking_agent_id) + AND (NEW.booking_agent_name <=> OLD.booking_agent_name) + AND (NEW.vessel_name <=> OLD.vessel_name) + AND (NEW.voyage_no <=> OLD.voyage_no) + AND (NEW.departure_port_code <=> OLD.departure_port_code) + AND (NEW.departure_port_name <=> OLD.departure_port_name) + AND (NEW.destination_port_code <=> OLD.destination_port_code) + AND (NEW.destination_port_name <=> OLD.destination_port_name) + AND (NEW.place_of_delivery <=> OLD.place_of_delivery) + AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at) + AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at) + AND (NEW.remark <=> OLD.remark) + AND (NEW.freight_term <=> OLD.freight_term) + AND (NEW.service_term <=> OLD.service_term) + AND (NEW.package_unit <=> OLD.package_unit) + AND (NEW.cargo_description <=> OLD.cargo_description) + AND (NEW.marks_and_nos <=> OLD.marks_and_nos) + AND (NEW.declared_package_count <=> OLD.declared_package_count) + AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg) + AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm) + AND (NEW.created_by <=> OLD.created_by) + AND (NEW.created_by_name <=> OLD.created_by_name) + AND (NEW.created_at <=> OLD.created_at) + AND NEW.version = OLD.version + 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量'; END IF; IF OLD.status <> NEW.status AND NOT ( (OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index 9d6532238..e4ef56b22 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -79,7 +79,7 @@ - 客户箱规配置必须恰好保持四档且默认算法在四行一致;`ADD` 以实测尺寸为基准对三个维度分别增加命中箱规的尺寸值,`MULTIPLY` 使用当前客户唯一默认计费规则,并由服务端按规则中的规格档位选择系数。测量行必须保存实际尺寸、选用规则和算法快照,页面不得自行重算后覆盖服务端结果;计价使用用户确认的重量/体积选择值并纳入幂等请求哈希。 - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 -- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后永久只读。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 +- 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 - 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 - 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java index 2829b8e52..5b390f5c6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingController.java @@ -126,6 +126,17 @@ public class PlatformOceanBookingController { @PutMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')") @BusinessAudit(event="OCEAN_BOOKING_UPDATE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="编辑海运订舱草稿") public ApiResponse updateDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody UpdateRequest request) { validateOptionalCustomer(user, request.data().customerId()); return ApiResponse.success(service.updateDraft(oceanBookingId, user.userId(), user.username(), key, request)); } + /** 客服在放舱后补录实货量,沿用订舱编辑权限并记录审计。 */ + @PutMapping("/{oceanBookingId}/actual-cargo") + @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:edit')") + @BusinessAudit(event="OCEAN_BOOKING_UPDATE_ACTUAL_CARGO", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="补录订舱实际货量") + public ApiResponse updateActualCargo(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, + @Valid @RequestBody ActualCargoRequest request) { + FirstlegScopeGuard.platform(user); + return ApiResponse.success(service.updateActualCargo(oceanBookingId, user.userId(), user.username(), key, request)); + } + @DeleteMapping("/{oceanBookingId}") @PreAuthorize("hasAuthority('platform:firstleg:ocean-booking:delete')") @BusinessAudit(event="OCEAN_BOOKING_DELETE_DRAFT", resourceType="OCEAN_BOOKING", resourceId="#oceanBookingId", description="删除海运订舱草稿") public ApiResponse deleteDraft(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable @Positive long oceanBookingId, @RequestHeader("Idempotency-Key") String key, @Valid @RequestBody ActionRequest request) { FirstlegScopeGuard.platform(user); service.deleteDraft(oceanBookingId, user.userId(), user.username(), key, request); return ApiResponse.success(null); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java index 5e4ea1a51..7ff4b807b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/dto/OceanBookingDtos.java @@ -45,10 +45,12 @@ public final class OceanBookingDtos { String customerName, String warehouseName, String channelName) {} public record PartyRequest(@NotBlank @Size(max = 24) String partyType, - @NotBlank @Size(max = 255) String partyName, + @NotNull @Size(max = 255) String partyName, @Size(max = 1000) String address, @Size(max = 128) String contactName, - @Size(max = 64) String contactPhone) {} + @Size(max = 64) String contactPhone, + @jakarta.validation.constraints.Email @Size(max = 255) String contactEmail, + @Size(max = 128) String taxNo) {} public record ContainerRequirementRequest(@NotBlank @Size(max = 32) String containerType, @NotBlank @Size(max = 32) String containerKind, @@ -76,7 +78,31 @@ public final class OceanBookingDtos { LocalDateTime estimatedArrivalAt, @Size(max = 1000) String remark, @Valid @Size(max = 3) List parties, - @Valid @Size(max = 20) List containerRequirements) {} + @Valid @Size(max = 20) List containerRequirements, + @Size(max = 64) String freightTerm, + @Size(max = 64) String serviceTerm, + @Pattern(regexp = "CARTONS|PCS|PALLETS") String packageUnit, + @Size(max = 1000) String cargoDescription, + @Size(max = 1000) String marksAndNos, + @Min(1) @Max(999999999) Integer declaredPackageCount, + @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) + @jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredGrossWeightKg, + @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) + @jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal declaredVolumeCbm, + @Min(1) @Max(999999999) Integer actualPackageCount, + @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) + @jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg, + @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) + @jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm) {} + + /** 放舱后的受控实货量补录,不接收运输资料。 */ + public record ActualCargoRequest(@NotNull @Min(0) Integer expectedVersion, + @Min(1) @Max(999999999) Integer actualPackageCount, + @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) + @jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualGrossWeightKg, + @jakarta.validation.constraints.DecimalMin(value = "0", inclusive = false) + @jakarta.validation.constraints.Digits(integer = 12, fraction = 3) BigDecimal actualVolumeCbm, + @NotBlank @Size(max = 1000) String reason) {} public record CreateRequest(@NotNull @Min(0) Integer expectedVersion, @Valid @NotNull DraftData data) {} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java index f4d3caef3..9ef63cf5d 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingEntity.java @@ -40,6 +40,17 @@ public class OceanBookingEntity { private LocalDateTime estimatedSailingAt; private LocalDateTime estimatedArrivalAt; private String remark; + private String freightTerm; + private String serviceTerm; + private String packageUnit; + private String cargoDescription; + private String marksAndNos; + private Integer declaredPackageCount; + private java.math.BigDecimal declaredGrossWeightKg; + private java.math.BigDecimal declaredVolumeCbm; + private Integer actualPackageCount; + private java.math.BigDecimal actualGrossWeightKg; + private java.math.BigDecimal actualVolumeCbm; private Long createdBy; private String createdByName; @TableField(fill = FieldFill.INSERT) private LocalDateTime createdAt; diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingPartyEntity.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingPartyEntity.java index f51e1f66f..922ed74be 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingPartyEntity.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/entity/OceanBookingPartyEntity.java @@ -17,4 +17,6 @@ public class OceanBookingPartyEntity { private String address; private String contactName; private String contactPhone; + private String contactEmail; + private String taxNo; } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java index ddd83f869..7b8aae173 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/mapper/OceanBookingMapper.java @@ -25,8 +25,17 @@ public interface OceanBookingMapper extends BaseMapper { """) int touchDraft(@Param("id") long id, @Param("expectedVersion") int expectedVersion, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); - @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") + @Update("UPDATE fl_ocean_booking SET customer_id=#{value.customerId},customer_name=#{value.customerName},channel_code=#{value.channelCode},channel_name=#{value.channelName},warehouse_id=#{value.warehouseId},warehouse_name=#{value.warehouseName},bill_of_lading_no=#{value.billOfLadingNo},shipping_company_id=#{value.shippingCompanyId},shipping_company_name=#{value.shippingCompanyName},carrier_code=#{value.carrierCode},booking_agent_id=#{value.bookingAgentId},booking_agent_name=#{value.bookingAgentName},vessel_name=#{value.vesselName},voyage_no=#{value.voyageNo},departure_port_code=#{value.departurePortCode},departure_port_name=#{value.departurePortName},destination_port_code=#{value.destinationPortCode},destination_port_name=#{value.destinationPortName},place_of_delivery=#{value.placeOfDelivery},estimated_sailing_at=#{value.estimatedSailingAt},estimated_arrival_at=#{value.estimatedArrivalAt},remark=#{value.remark},freight_term=#{value.freightTerm},service_term=#{value.serviceTerm},package_unit=#{value.packageUnit},cargo_description=#{value.cargoDescription},marks_and_nos=#{value.marksAndNos},declared_package_count=#{value.declaredPackageCount},declared_gross_weight_kg=#{value.declaredGrossWeightKg},declared_volume_cbm=#{value.declaredVolumeCbm},actual_package_count=#{value.actualPackageCount},actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm},updated_by=#{operatorId},updated_by_name=#{operatorName},version=version+1,updated_at=NOW() WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int updateDraft(@Param("id") long id, @Param("value") OceanBookingEntity value, @Param("version") int version, @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); + /** 已放舱补实只更新白名单字段,保留乐观锁及状态门禁。 */ + @Update(""" + UPDATE fl_ocean_booking SET actual_package_count=#{value.actualPackageCount}, + actual_gross_weight_kg=#{value.actualGrossWeightKg},actual_volume_cbm=#{value.actualVolumeCbm}, + updated_by=#{operatorId},updated_by_name=#{operatorName},updated_at=CURRENT_TIMESTAMP(3),version=version+1 + WHERE id=#{value.id} AND status='RELEASED' AND version=#{version} + """) + int updateActualCargo(@Param("value") OceanBookingEntity value, @Param("version") int version, + @Param("operatorId") long operatorId, @Param("operatorName") String operatorName); @Delete("DELETE FROM fl_ocean_booking WHERE id=#{id} AND status='DRAFT' AND version=#{version}") int deleteDraft(@Param("id") long id, @Param("version") int version); @Select(""" diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java index b3b032e26..1246d23b6 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingRepository.java @@ -54,6 +54,10 @@ public class OceanBookingRepository { public int touchDraft(long id, int version, long operatorId, String operatorName) { return bookings.touchDraft(id, version, operatorId, operatorName); } + /** 只更新已放舱订舱的实际货量,运输快照保持冻结。 */ + public int updateActualCargo(OceanBookingEntity value, int version, long operatorId, String operatorName) { + return bookings.updateActualCargo(value, version, operatorId, operatorName); + } public int deleteDraft(long id, int version) { return bookings.deleteDraft(id, version); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java index 2f4dcf7cf..f40d64110 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintService.java @@ -46,7 +46,7 @@ public class OceanBookingPrintService { String fileName = booking.getBookingNo() + "-" + suffix + ".pdf"; PrintPdfClient.FreightA4PdfRequest request = new PrintPdfClient.FreightA4PdfRequest( template, A4, fileName, List.of(new PrintPdfClient.FreightDocument( - fields(template, detail), List.of(emptyCargo())))); + fields(template, detail), List.of(cargo(template, booking))))); try { ResponseEntity response = print.generateFreightA4Documents(internalToken, requestId, request); MediaType contentType = response.getHeaders().getContentType(); @@ -75,14 +75,21 @@ public class OceanBookingPrintService { fields.put("portOfLoading", first(booking.getDeparturePortName(), booking.getDeparturePortCode())); fields.put("portOfDischarge", first(booking.getDestinationPortName(), booking.getDestinationPortCode())); fields.put("placeOfDelivery", text(booking.getPlaceOfDelivery())); - fields.put("serviceTerm", ""); - fields.put("freightTerm", ""); - fields.put("totalPackagesInWords", ""); + fields.put("serviceTerm", text(booking.getServiceTerm())); + fields.put("freightTerm", text(booking.getFreightTerm())); + Integer count = SHIPPING_INSTRUCTION.equals(template) ? booking.getDeclaredPackageCount() : booking.getActualPackageCount(); + fields.put("totalPackagesInWords", packageWords(count, booking.getPackageUnit())); if (SHIPPING_INSTRUCTION.equals(template)) { fields.put("toCompany", text(booking.getBookingAgentName())); fields.put("jobNumber", text(booking.getBookingNo())); fields.put("etdDate", date(booking.getEstimatedSailingAt())); - fields.put("cargoQuantity", ""); + fields.put("cargoQuantity", detail.containerRequirements().stream() + .map(value -> value.getQuantity() + "*" + value.getContainerType()).collect(Collectors.joining(" + "))); + OceanBookingPartyEntity shipper = parties.get("SHIPPER"); + fields.put("picName", shipper == null ? "" : text(shipper.getContactName())); + fields.put("picTel", shipper == null ? "" : text(shipper.getContactPhone())); + fields.put("apName", text(booking.getBookingAgentName())); + fields.put("printDate", java.time.LocalDate.now(java.time.ZoneId.of("Asia/Shanghai")).toString()); fields.put("carrier", text(booking.getShippingCompanyName())); fields.put("bookingAgent", text(booking.getBookingAgentName())); fields.put("remark", text(booking.getRemark())); @@ -98,8 +105,43 @@ public class OceanBookingPrintService { return fields; } - private static PrintPdfClient.CargoRow emptyCargo() { - return new PrintPdfClient.CargoRow("—", "—", "—", "—", "—", "—"); + /** 两类单据严格分别读取委、实数据,不以柜型容量或预报量代替实装量。 */ + private static PrintPdfClient.CargoRow cargo(String template, OceanBookingEntity booking) { + boolean declared = SHIPPING_INSTRUCTION.equals(template); + Integer count = declared ? booking.getDeclaredPackageCount() : booking.getActualPackageCount(); + BigDecimal weight = declared ? booking.getDeclaredGrossWeightKg() : booking.getActualGrossWeightKg(); + BigDecimal volume = declared ? booking.getDeclaredVolumeCbm() : booking.getActualVolumeCbm(); + return new PrintPdfClient.CargoRow(display(booking.getMarksAndNos()), count == null ? "—" : count.toString(), + display(booking.getPackageUnit()), display(booking.getCargoDescription()), + weight == null ? "—" : weight.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " KGS", + volume == null ? "—" : volume.setScale(3, java.math.RoundingMode.HALF_UP).toPlainString() + " CBM"); + } + + /** 打印空字段使用统一占位符。 */ + private static String display(String value) { return text(value).isEmpty() ? "—" : text(value); } + + /** 件数大写跟随所选数据和包装单位生成,未录入时明确留空。 */ + private static String packageWords(Integer count, String unit) { + if (count == null || !org.springframework.util.StringUtils.hasText(unit)) return ""; + String packaging = count == 1 ? switch (unit) { + case "CARTONS" -> "CARTON"; + case "PALLETS" -> "PALLET"; + case "PCS" -> "PIECE"; + default -> unit; + } : ("PCS".equals(unit) ? "PIECES" : unit); + return "SAY " + words(count) + " " + packaging + " ONLY"; + } + + /** 将受件数范围约束的正整数转换为英文数量。 */ + private static String words(int value) { + String[] small = {"ZERO", "ONE", "TWO", "THREE", "FOUR", "FIVE", "SIX", "SEVEN", "EIGHT", "NINE", "TEN", + "ELEVEN", "TWELVE", "THIRTEEN", "FOURTEEN", "FIFTEEN", "SIXTEEN", "SEVENTEEN", "EIGHTEEN", "NINETEEN"}; + String[] tens = {"", "", "TWENTY", "THIRTY", "FORTY", "FIFTY", "SIXTY", "SEVENTY", "EIGHTY", "NINETY"}; + if (value < 20) return small[value]; + if (value < 100) return tens[value / 10] + (value % 10 == 0 ? "" : " " + words(value % 10)); + int divisor = value >= 1000000 ? 1000000 : value >= 1000 ? 1000 : 100; + String scale = divisor == 1000000 ? "MILLION" : divisor == 1000 ? "THOUSAND" : "HUNDRED"; + return words(value / divisor) + " " + scale + (value % divisor == 0 ? "" : " " + words(value % divisor)); } private static String requirementSummary(List values) { @@ -110,7 +152,8 @@ public class OceanBookingPrintService { private static String party(OceanBookingPartyEntity value) { if (value == null) return ""; return List.of(text(value.getPartyName()), text(value.getAddress()), - join(value.getContactName(), value.getContactPhone())).stream() + join(value.getContactName(), value.getContactPhone()), text(value.getContactEmail()), + text(value.getTaxNo()).isEmpty() ? "" : "TAX NO: " + text(value.getTaxNo())).stream() .filter(part -> !part.isEmpty()).collect(Collectors.joining("\n")); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index 1bccf15df..f55a9770b 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -145,6 +145,26 @@ public class OceanBookingService { addHistory(id, OceanBookingStatus.DRAFT.name(), OceanBookingStatus.DRAFT.name(), action, null, before, after, operatorId, operatorName); return complete(id, operatorId, action, key, detail(after)); } + /** 放舱后仅补录实货量,版本、幂等结果和修改前后快照在同一事务保存。 */ + @Transactional + public Detail updateActualCargo(long id, long operatorId, String name, String key, ActualCargoRequest request) { + String action = "UPDATE_ACTUAL_CARGO"; + Detail replay = claimOrReplay(id, operatorId, action, key, hash(action, id, request)); + if (replay != null) return replay; + OceanBookingEntity before = requiredVersionForUpdate(id, request.expectedVersion()); + requireStatus(before, OceanBookingStatus.RELEASED); + if (!StringUtils.hasText(request.reason())) throw badRequest("请填写实货量补录或修正原因"); + validateCargo(request.actualPackageCount(), request.actualGrossWeightKg(), request.actualVolumeCbm()); + OceanBookingEntity after = objectMapper.convertValue(before, OceanBookingEntity.class); + after.setActualPackageCount(request.actualPackageCount()); + after.setActualGrossWeightKg(request.actualGrossWeightKg()); + after.setActualVolumeCbm(request.actualVolumeCbm()); + if (repository.updateActualCargo(after, request.expectedVersion(), operatorId, name) != 1) throw concurrent(); + OceanBookingEntity saved = required(id); + addHistory(id, before.getStatus(), saved.getStatus(), action, request.reason(), before, saved, operatorId, name); + return complete(id, operatorId, action, key, detail(saved)); + } + @Transactional public void deleteDraft(long id, long operatorId, String operatorName, String key, ActionRequest request) { String action = "DELETE_DRAFT"; @@ -209,7 +229,18 @@ public class OceanBookingService { private OceanBookingEntity requiredVersionForUpdate(long id, int version) { OceanBookingEntity value = repository.findForUpdate(id); if (value == null) throw new BizException(ResultCode.NOT_FOUND, "订舱单不存在"); if (!Integer.valueOf(version).equals(value.getVersion())) throw concurrent(); return value; } private static void requireStatus(OceanBookingEntity value, OceanBookingStatus status) { if (!status.name().equals(value.getStatus())) throw conflict("订舱状态不允许当前操作"); } + /** 未采集使用空值;已填写的件数、重量和体积必须为正数且不超出存储精度。 */ + private static void validateCargo(Integer count, java.math.BigDecimal weight, java.math.BigDecimal volume) { + if (count != null && (count < 1 || count > 999999999)) throw badRequest("货物件数必须为1到999999999的整数"); + for (java.math.BigDecimal number : new java.math.BigDecimal[] {weight, volume}) { + if (number != null && (number.signum() <= 0 || number.scale() > 3 + || number.precision() - number.scale() > 12)) throw badRequest("货物重量和体积必须为正数,最多12位整数和3位小数"); + } + } + private void validateDraftStructure(DraftData data) { + validateCargo(data.declaredPackageCount(), data.declaredGrossWeightKg(), data.declaredVolumeCbm()); + validateCargo(data.actualPackageCount(), data.actualGrossWeightKg(), data.actualVolumeCbm()); boolean hasCustomerId = data.customerId() != null; boolean hasCustomerName = StringUtils.hasText(data.customerName()); if (!hasCustomerId && hasCustomerName) throw badRequest("客户名称不能脱离客户ID提交"); @@ -242,6 +273,7 @@ public class OceanBookingService { || !StringUtils.hasText(booking.getPlaceOfDelivery()) || !StringUtils.hasText(booking.getCarrierCode()) || booking.getBookingAgentId() == null || parties == null || parties.size() != 3 + || (parties != null && parties.stream().anyMatch(party -> !StringUtils.hasText(party.getPartyName()))) || requirements == null || requirements.isEmpty()) throw badRequest("订舱资料尚未填写完整,无法发布"); validateRange(booking.getEstimatedSailingAt(), booking.getEstimatedArrivalAt()); } @@ -298,6 +330,17 @@ public class OceanBookingService { v.setPlaceOfDelivery(trim(d.placeOfDelivery())); v.setEstimatedSailingAt(d.estimatedSailingAt()); v.setEstimatedArrivalAt(d.estimatedArrivalAt()); v.setRemark(trim(d.remark())); + v.setFreightTerm(trim(d.freightTerm())); + v.setServiceTerm(trim(d.serviceTerm())); + v.setPackageUnit(trim(d.packageUnit())); + v.setCargoDescription(trim(d.cargoDescription())); + v.setMarksAndNos(trim(d.marksAndNos())); + v.setDeclaredPackageCount(d.declaredPackageCount()); + v.setDeclaredGrossWeightKg(d.declaredGrossWeightKg()); + v.setDeclaredVolumeCbm(d.declaredVolumeCbm()); + v.setActualPackageCount(d.actualPackageCount()); + v.setActualGrossWeightKg(d.actualGrossWeightKg()); + v.setActualVolumeCbm(d.actualVolumeCbm()); } private void requireUniqueBillOfLadingNo(String billOfLadingNo, Long excludedId) { @@ -370,7 +413,7 @@ public class OceanBookingService { return normalized; } - private static List parties(long id, List requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); return v; }).toList(); } + private static List parties(long id, List requests) { if (requests == null) return List.of(); return requests.stream().map(r -> { OceanBookingPartyEntity v = new OceanBookingPartyEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setPartyType(r.partyType().trim().toUpperCase()); v.setPartyName(r.partyName().trim()); v.setAddress(trim(r.address())); v.setContactName(trim(r.contactName())); v.setContactPhone(trim(r.contactPhone())); v.setContactEmail(trim(r.contactEmail())); v.setTaxNo(trim(r.taxNo())); return v; }).toList(); } private static List requirements(long id, List requests) { if (requests == null) return List.of(); int[] sort = {0}; return requests.stream().map(r -> { var specification = ContainerSpecificationCatalog.requireMatch(r.containerType(), r.capacityCbm(), r.maxWeightKg()); OceanBookingContainerRequirementEntity v = new OceanBookingContainerRequirementEntity(); v.setId(IdWorker.getId()); v.setOceanBookingId(id); v.setContainerType(specification.containerType()); v.setContainerKind(r.containerKind().trim().toUpperCase()); v.setCapacityCbm(specification.capacityCbm()); v.setMaxWeightKg(specification.maxWeightKg()); v.setQuantity(r.quantity()); v.setSortNo(sort[0]++); return v; }).toList(); } private WarehouseMasterDataClient.Warehouse requireDomesticTransitWarehouse(long warehouseId) { try { diff --git a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql index 7e829536e..86ff6b97f 100644 --- a/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql +++ b/firstleg/src/main/resources/db/phase3-ocean-booking-schema.sql @@ -4,7 +4,7 @@ USE baiyue_firstleg; CREATE TABLE IF NOT EXISTS fl_ocean_booking ( id BIGINT UNSIGNED NOT NULL COMMENT '海运订舱ID', booking_no VARCHAR(40) NOT NULL COMMENT '平台海运订舱编号', - status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已发布且永久只读', + status VARCHAR(24) NOT NULL DEFAULT 'DRAFT' COMMENT '订舱状态:DRAFT草稿、RELEASED已放舱,运输快照冻结且实货量受控补录', customer_id BIGINT UNSIGNED NULL COMMENT '可选客户ID快照', customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照', channel_code VARCHAR(64) NULL COMMENT '订舱计划物流渠道组编码快照', @@ -27,6 +27,17 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( estimated_sailing_at DATETIME(3) NULL COMMENT '当前确认预计开船时间', estimated_arrival_at DATETIME(3) NULL COMMENT '当前确认预计到港时间', remark VARCHAR(1000) NULL COMMENT '平台内部备注', + freight_term VARCHAR(64) NULL COMMENT '运费条款', + service_term VARCHAR(64) NULL COMMENT '运输条款', + package_unit VARCHAR(16) NULL COMMENT '包装单位:CARTONS、PCS或PALLETS', + cargo_description VARCHAR(1000) NULL COMMENT '货物品名及托运要求', + marks_and_nos VARCHAR(1000) NULL COMMENT '唛头和号码', + declared_package_count INT UNSIGNED NULL COMMENT '订舱预报件数(委)', + declared_gross_weight_kg DECIMAL(15,3) NULL COMMENT '订舱预报毛重kg(委)', + declared_volume_cbm DECIMAL(15,3) NULL COMMENT '订舱预报体积cbm(委)', + actual_package_count INT UNSIGNED NULL COMMENT '客服手工实货件数', + actual_gross_weight_kg DECIMAL(15,3) NULL COMMENT '客服手工实货毛重kg', + actual_volume_cbm DECIMAL(15,3) NULL COMMENT '客服手工实货体积cbm', created_by BIGINT UNSIGNED NOT NULL COMMENT '创建操作人ID', created_by_name VARCHAR(64) NULL COMMENT '创建操作人姓名快照', created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '订舱记录创建时间', @@ -44,6 +55,14 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking ( ('DRAFT','RELEASED')), CONSTRAINT chk_ocean_booking_carrier_code CHECK (carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')), + CONSTRAINT chk_ocean_booking_cargo CHECK ( + (package_unit IS NULL OR package_unit IN ('CARTONS','PCS','PALLETS')) + AND (declared_package_count IS NULL OR declared_package_count BETWEEN 1 AND 999999999) + AND (actual_package_count IS NULL OR actual_package_count BETWEEN 1 AND 999999999) + AND (declared_gross_weight_kg IS NULL OR declared_gross_weight_kg > 0) + AND (actual_gross_weight_kg IS NULL OR actual_gross_weight_kg > 0) + AND (declared_volume_cbm IS NULL OR declared_volume_cbm > 0) + AND (actual_volume_cbm IS NULL OR actual_volume_cbm > 0)), CONSTRAINT chk_ocean_booking_schedule CHECK (estimated_sailing_at IS NULL OR estimated_arrival_at IS NULL OR estimated_arrival_at >= estimated_sailing_at) ) ENGINE=InnoDB COMMENT='独立海运订舱聚合;柜次仍由fl_container_booking承载'; @@ -110,6 +129,8 @@ CREATE TABLE IF NOT EXISTS fl_ocean_booking_party ( address VARCHAR(1000) NULL COMMENT '关系方地址快照', contact_name VARCHAR(128) NULL COMMENT '关系方联系人快照', contact_phone VARCHAR(64) NULL COMMENT '关系方联系电话快照', + contact_email VARCHAR(255) NULL COMMENT '关系方联系邮箱', + tax_no VARCHAR(128) NULL COMMENT '关系方税号', PRIMARY KEY (id), UNIQUE KEY uk_ocean_booking_party (ocean_booking_id,party_type), CONSTRAINT fk_ocean_booking_party FOREIGN KEY (ocean_booking_id) REFERENCES fl_ocean_booking(id) ON DELETE CASCADE, @@ -211,8 +232,47 @@ DROP TRIGGER IF EXISTS trg_ocean_booking_before_update; DELIMITER $$ CREATE TRIGGER trg_ocean_booking_before_update BEFORE UPDATE ON fl_ocean_booking FOR EACH ROW BEGIN - IF OLD.status = 'RELEASED' THEN - SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱订舱不可修改'; + -- 已放舱只允许实货量及其审计元数据变化,运输和预报快照保持冻结。 + IF OLD.status = 'RELEASED' AND NOT ( + (NEW.id <=> OLD.id) + AND (NEW.booking_no <=> OLD.booking_no) + AND (NEW.status <=> OLD.status) + AND (NEW.customer_id <=> OLD.customer_id) + AND (NEW.customer_name <=> OLD.customer_name) + AND (NEW.channel_code <=> OLD.channel_code) + AND (NEW.channel_name <=> OLD.channel_name) + AND (NEW.warehouse_id <=> OLD.warehouse_id) + AND (NEW.warehouse_name <=> OLD.warehouse_name) + AND (NEW.bill_of_lading_no <=> OLD.bill_of_lading_no) + AND (NEW.shipping_company_id <=> OLD.shipping_company_id) + AND (NEW.shipping_company_name <=> OLD.shipping_company_name) + AND (NEW.carrier_code <=> OLD.carrier_code) + AND (NEW.booking_agent_id <=> OLD.booking_agent_id) + AND (NEW.booking_agent_name <=> OLD.booking_agent_name) + AND (NEW.vessel_name <=> OLD.vessel_name) + AND (NEW.voyage_no <=> OLD.voyage_no) + AND (NEW.departure_port_code <=> OLD.departure_port_code) + AND (NEW.departure_port_name <=> OLD.departure_port_name) + AND (NEW.destination_port_code <=> OLD.destination_port_code) + AND (NEW.destination_port_name <=> OLD.destination_port_name) + AND (NEW.place_of_delivery <=> OLD.place_of_delivery) + AND (NEW.estimated_sailing_at <=> OLD.estimated_sailing_at) + AND (NEW.estimated_arrival_at <=> OLD.estimated_arrival_at) + AND (NEW.remark <=> OLD.remark) + AND (NEW.freight_term <=> OLD.freight_term) + AND (NEW.service_term <=> OLD.service_term) + AND (NEW.package_unit <=> OLD.package_unit) + AND (NEW.cargo_description <=> OLD.cargo_description) + AND (NEW.marks_and_nos <=> OLD.marks_and_nos) + AND (NEW.declared_package_count <=> OLD.declared_package_count) + AND (NEW.declared_gross_weight_kg <=> OLD.declared_gross_weight_kg) + AND (NEW.declared_volume_cbm <=> OLD.declared_volume_cbm) + AND (NEW.created_by <=> OLD.created_by) + AND (NEW.created_by_name <=> OLD.created_by_name) + AND (NEW.created_at <=> OLD.created_at) + AND NEW.version = OLD.version + 1 + ) THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='已放舱运输资料不可修改,仅允许补录实货量'; END IF; IF OLD.status <> NEW.status AND NOT ( (OLD.status='DRAFT' AND NEW.status='RELEASED')) THEN diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 4f28595ac..187ad0953 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -1,8 +1,8 @@ openapi: 3.0.3 info: title: Ocean Booking API - version: 2.1.0 - description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released bookings and their file links are permanently read-only. + version: 2.2.0 + description: Booking-owned files use File businessType OCEAN_BOOKING_DOCUMENT and businessId=oceanBookingId. Release requires at least one active BOOKING_CONFIRMATION or BILL_OF_LADING file. Released transport and declared cargo snapshots and file links are read-only. Manual actual cargo may be supplemented with version, idempotency and audit reason. paths: /api/platform/v1/ocean-bookings: get: @@ -29,6 +29,14 @@ paths: parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActionRequest'}}}} responses: {'200': {description: Deleted}} + /api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo: + put: + summary: Supplement manual actual cargo on a RELEASED booking + description: Only actual package count, gross weight and volume may change. Transport and declared cargo remain frozen. Requires expectedVersion, Idempotency-Key and a reason; saves before/after audit snapshots atomically. Null means not recorded, never zero or a fallback to declared cargo. + x-permission: platform:firstleg:ocean-booking:edit + parameters: [{$ref: '#/components/parameters/OceanBookingId'}, {$ref: '#/components/parameters/IdempotencyKey'}] + requestBody: {required: true, content: {application/json: {schema: {$ref: '#/components/schemas/ActualCargoRequest'}}}} + responses: {'200': {description: Updated RELEASED detail}, '409': {description: State or version conflict}} /api/platform/v1/ocean-bookings/{oceanBookingId}/release: post: summary: Validate and permanently release a DRAFT @@ -89,7 +97,7 @@ paths: /api/platform/v1/ocean-bookings/{oceanBookingId}/prints/shipping-instruction: get: summary: Render the current saved booking as an export shipping instruction PDF - description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Container requirements are not treated as actual cargo quantity, weight, volume, marks, packaging, or goods descriptions. + description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved declared values; container requirements describe requested containers only. Count in words is generated from the declared count and packageUnit. x-permission: platform:firstleg:ocean-booking:list parameters: [{$ref: '#/components/parameters/OceanBookingId'}] responses: @@ -97,7 +105,7 @@ paths: /api/platform/v1/ocean-bookings/{oceanBookingId}/prints/bill-of-lading-confirmation: get: summary: Render the current saved booking as a bill-of-lading confirmation PDF - description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Container requirements remain a container summary and are not treated as actual cargo facts. + description: Firstleg maps the authoritative saved aggregate (DRAFT or RELEASED, with missing fields displayed as —) to BILL_OF_LADING_CONFIRMATION_A4_V1 and synchronously proxies the generated PDF. Cargo uses saved manually entered actual values only; missing actual data displays — and never falls back to declared values. This is a confirmation copy; the externally issued bill of lading remains an uploaded attachment. x-permission: platform:firstleg:ocean-booking:list parameters: [{$ref: '#/components/parameters/OceanBookingId'}] responses: @@ -194,10 +202,31 @@ components: estimatedSailingAt: {type: string, format: date-time, nullable: true} estimatedArrivalAt: {type: string, format: date-time, nullable: true} remark: {type: string, nullable: true} + freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID} + serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY} + packageUnit: {type: string, enum: [CARTONS, PCS, PALLETS], nullable: true} + cargoDescription: {type: string, maxLength: 1000, nullable: true} + marksAndNos: {type: string, maxLength: 1000, nullable: true} + declaredPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true} + declaredGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true} + declaredVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true} + actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true} + actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true} + actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true} parties: {type: array, nullable: true, items: {$ref: '#/components/schemas/PartyRequest'}} containerRequirements: {type: array, nullable: true, items: {$ref: '#/components/schemas/ContainerRequirementRequest'}} - PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}}} + PartyRequest: {type: object, required: [partyType, partyName], properties: {partyType: {type: string, enum: [SHIPPER, CONSIGNEE, NOTIFY_PARTY]}, partyName: {type: string}, address: {type: string, nullable: true}, contactName: {type: string, nullable: true}, contactPhone: {type: string, nullable: true}, contactEmail: {type: string, format: email, maxLength: 255, nullable: true}, taxNo: {type: string, maxLength: 128, nullable: true}}} ContainerRequirementRequest: {type: object, required: [containerType, containerKind, quantity, capacityCbm, maxWeightKg], properties: {containerType: {type: string, enum: [40GP, 40HQ, 45HQ], default: 40HQ}, containerKind: {type: string}, quantity: {type: integer, minimum: 1}, capacityCbm: {type: number, exclusiveMinimum: true, minimum: 0, description: 统一实装上限;40GP=58、40HQ=68、45HQ=86}, maxWeightKg: {type: number, enum: [19500], description: 统一货重上限}}} + ActualCargoRequest: + type: object + additionalProperties: false + required: [expectedVersion, reason] + properties: + expectedVersion: {type: integer, minimum: 0} + reason: {type: string, minLength: 1, maxLength: 1000} + actualPackageCount: {type: integer, minimum: 1, maximum: 999999999, nullable: true} + actualGrossWeightKg: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true} + actualVolumeCbm: {type: number, minimum: 0, exclusiveMinimum: true, maximum: 999999999999.999, multipleOf: 0.001, nullable: true} ReleaseRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} ActionRequest: {type: object, required: [expectedVersion], properties: {expectedVersion: {type: integer, minimum: 0}, reason: {type: string, nullable: true}}} DocumentType: {type: string, enum: [BOOKING_CONFIRMATION, BILL_OF_LADING]} @@ -311,6 +340,8 @@ components: required: [oceanBooking, parties, containerRequirements, containers, documents, histories] properties: oceanBooking: + allOf: + - {$ref: '#/components/schemas/DraftData'} type: object properties: bookingNo: {type: string, pattern: '^OB[0-9]{12}$', example: OB202608180001, description: 平台订舱号;按北京时间创建日逐日编号} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java index c58a9e2cf..cf9e422b4 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/controller/PlatformOceanBookingControllerContractTest.java @@ -32,6 +32,7 @@ class PlatformOceanBookingControllerContractTest { Map.entry("uploadDocument", "platform:firstleg:ocean-booking:edit"), Map.entry("invalidateDocument", "platform:firstleg:ocean-booking:edit"), Map.entry("create", "platform:firstleg:ocean-booking:create"), + Map.entry("updateActualCargo", "platform:firstleg:ocean-booking:edit"), Map.entry("updateDraft", "platform:firstleg:ocean-booking:edit"), Map.entry("deleteDraft", "platform:firstleg:ocean-booking:delete"), Map.entry("release", "platform:firstleg:ocean-booking:release")); @@ -47,6 +48,24 @@ class PlatformOceanBookingControllerContractTest { }); } + @Test + @SuppressWarnings("unchecked") + void actualCargoContractExposesOnlySupplementFieldsAndReusesEditPermission() throws Exception { + assertThat(method("updateActualCargo").getAnnotation(org.springframework.web.bind.annotation.PutMapping.class).value()) + .containsExactly("/{oceanBookingId}/actual-cargo"); + Map document = new org.yaml.snakeyaml.Yaml().load( + Files.readString(Path.of("src/main/resources/openapi/ocean-bookings-v1.yaml"))); + var paths = (Map) document.get("paths"); + var endpoint = (Map) paths.get("/api/platform/v1/ocean-bookings/{oceanBookingId}/actual-cargo"); + var operation = (Map) endpoint.get("put"); + assertThat(operation).containsEntry("x-permission", "platform:firstleg:ocean-booking:edit"); + var schemas = (Map) ((Map) document.get("components")).get("schemas"); + var request = (Map) schemas.get("ActualCargoRequest"); + assertThat((Map) request.get("properties")).containsOnlyKeys( + "expectedVersion", "reason", "actualPackageCount", "actualGrossWeightKg", "actualVolumeCbm"); + assertThat((java.util.List) request.get("required")).contains("expectedVersion", "reason"); + } + @Test void pathVariablesUseExplicitAggregateNames() { assertThat(Arrays.stream(PlatformOceanBookingController.class.getDeclaredMethods()) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java index 8fd2146ce..8a5e0934d 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingMapperContractTest.java @@ -46,6 +46,16 @@ class OceanBookingMapperContractTest { .doesNotContain("CONFIRMED", "CHANGE_PENDING", "CANCELLED", "SUBMITTED", "FAILED"); } + @Test + void releasedSupplementSqlCannotWriteTransportOrDeclaredCargo() throws Exception { + String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("updateActualCargo", + com.baiyue.firstleg.oceanbooking.entity.OceanBookingEntity.class, int.class, long.class, String.class) + .getAnnotation(Update.class).value()); + assertThat(sql).contains("status='RELEASED' AND version=#{version}", "version=version+1", + "actual_package_count=#{value.actualPackageCount}", "actual_gross_weight_kg=#{value.actualGrossWeightKg}", "actual_volume_cbm=#{value.actualVolumeCbm}") + .doesNotContain("bill_of_lading_no=", "vessel_name=", "declared_", "warehouse_id=", "package_unit="); + } + @Test void linkCandidatesUsesOneBoundedSetQueryAndExactRemainingSemantics() throws Exception { String sql = String.join("\n", OceanBookingMapper.class.getDeclaredMethod("selectLinkCandidates", diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java index feacbb4ab..6df996887 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/repository/OceanBookingSqlContractTest.java @@ -27,8 +27,8 @@ class OceanBookingSqlContractTest { .contains("MODIFY COLUMN customer_name VARCHAR(128) NULL COMMENT '可选客户名称快照'") .contains("UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)") .contains("ADD UNIQUE KEY uk_ocean_booking_bill_of_lading (bill_of_lading_no)") - .doesNotContain("service_term") - .doesNotContain("freight_term") + .contains("service_term", "declared_package_count", "actual_package_count") + .contains("freight_term", "contact_email", "tax_no") .contains("carrier_code VARCHAR(16) NULL COMMENT '船司标准编码") .contains("carrier_code IS NULL OR carrier_code IN ('MATSON','SML','ZIM','COSCO','EMC','HEDE','OTHER')") .contains("container_booking_id BIGINT UNSIGNED NOT NULL COMMENT '明确引用现有柜次ID'") @@ -49,7 +49,7 @@ class OceanBookingSqlContractTest { void enforcesStateIdempotencyAndAuditContracts() throws Exception { assertThat(sql()) .contains("OLD.status='DRAFT' AND NEW.status='RELEASED'") - .contains("OLD.status = 'RELEASED'") + .contains("OLD.status = 'RELEASED'", "NEW.declared_package_count <=> OLD.declared_package_count", "NEW.bill_of_lading_no <=> OLD.bill_of_lading_no", "NEW.version = OLD.version + 1") .contains("UNIQUE KEY uk_ocean_operation (operator_id,operation,idempotency_key)") .contains("request_hash CHAR(64) NOT NULL") .contains("<> 'RELEASED' THEN") diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java index c8cbbfd66..f04add2d0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingPrintServiceTest.java @@ -49,7 +49,7 @@ class OceanBookingPrintServiceTest { assertThat(document.fields()).containsEntry("jobNumber", "OB-1") .containsEntry("shipper", "Shipper\nAddress") .containsEntry("oceanVesselVoyage", "Vessel / V001") - .containsEntry("cargoQuantity", "—") + .containsEntry("cargoQuantity", "2*40HQ") .containsEntry("totalPackagesInWords", "—") .containsKeys("toCompany", "consignee", "notifyParty", "cargoQuantity", "portOfLoading", "portOfDischarge", "placeOfDelivery", "totalPackagesInWords"); @@ -95,6 +95,39 @@ class OceanBookingPrintServiceTest { .containsEntry("totalPackagesInWords", "—"); } + @Test void routesDeclaredAndActualCargoSeparatelyAndGeneratesWords() { + Detail detail = detail("RELEASED"); + var booking = detail.oceanBooking(); + booking.setPackageUnit("CARTONS"); booking.setDeclaredPackageCount(500); booking.setActualPackageCount(480); + booking.setDeclaredGrossWeightKg(new BigDecimal("15000")); booking.setDeclaredVolumeCbm(new BigDecimal("68")); + booking.setActualGrossWeightKg(new BigDecimal("14320.125")); booking.setActualVolumeCbm(new BigDecimal("65.321")); + booking.setCargoDescription("HOUSEHOLD GOODS"); booking.setMarksAndNos("N/M"); + booking.setServiceTerm("CY-CY"); booking.setFreightTerm("FREIGHT PREPAID"); + detail.parties().get(0).setContactEmail("ops@example.test"); detail.parties().get(0).setTaxNo("TEST-TAX"); + when(bookings.detail(10L)).thenReturn(detail); + when(print.generateFreightA4Documents(any(), any(), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_PDF).body(new byte[]{1})); + service.shippingInstruction(10L, "split"); service.billConfirmation(10L, "split"); + var capture = ArgumentCaptor.forClass(PrintPdfClient.FreightA4PdfRequest.class); + verify(print, times(2)).generateFreightA4Documents(any(), any(), capture.capture()); + var declared = capture.getAllValues().get(0).documents().get(0); + var actual = capture.getAllValues().get(1).documents().get(0); + assertThat(declared.cargoRows().get(0).quantity()).isEqualTo("500"); + assertThat(declared.cargoRows().get(0).grossWeight()).isEqualTo("15000.000 KGS"); + assertThat(declared.fields()).containsEntry("totalPackagesInWords", "SAY FIVE HUNDRED CARTONS ONLY") + .containsEntry("serviceTerm", "CY-CY").containsEntry("freightTerm", "FREIGHT PREPAID"); + assertThat(declared.fields().get("shipper")).contains("ops@example.test", "TAX NO: TEST-TAX"); + assertThat(actual.cargoRows().get(0).quantity()).isEqualTo("480"); + assertThat(actual.cargoRows().get(0).grossWeight()).isEqualTo("14320.125 KGS"); + assertThat(actual.cargoRows().get(0).measurement()).isEqualTo("65.321 CBM"); + assertThat(actual.fields()).containsEntry("totalPackagesInWords", "SAY FOUR HUNDRED EIGHTY CARTONS ONLY"); + booking.setActualPackageCount(null); booking.setActualGrossWeightKg(null); booking.setActualVolumeCbm(null); + service.billConfirmation(10L, "missing"); + verify(print).generateFreightA4Documents(any(), eq("missing"), capture.capture()); + assertThat(capture.getValue().documents().get(0).cargoRows().get(0).quantity()).isEqualTo("—"); + assertThat(capture.getValue().documents().get(0).fields()).containsEntry("totalPackagesInWords", "—"); + } + private static Detail detail(String status) { OceanBookingEntity booking = new OceanBookingEntity(); booking.setId(10L); booking.setBookingNo("OB-1"); booking.setStatus(status); diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index 7620551e0..d40fb7097 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -386,6 +386,91 @@ class OceanBookingServiceTest { verify(repository, never()).transition(anyLong(), anyString(), anyString(), anyInt(), anyLong(), anyString()); } + @Test + void supplementsReleasedActualCargoWithVersionAndBeforeAfterAudit() { + OceanBookingEntity before = completeBooking("RELEASED", 3); + before.setDeclaredPackageCount(500); + before.setActualPackageCount(450); + AtomicReference stored = new AtomicReference<>(before); + when(repository.find(1L)).thenAnswer(invocation -> stored.get()); + when(repository.updateActualCargo(any(), eq(3), eq(9L), eq("admin"))).thenAnswer(invocation -> { + OceanBookingEntity after = invocation.getArgument(0); + after.setVersion(4); stored.set(after); return 1; + }); + var request = new ActualCargoRequest(3, 480, new BigDecimal("14000.125"), new BigDecimal("65.321"), "修正实装货量"); + var result = service.updateActualCargo(1L, 9L, "admin", "actual-save", request); + assertThat(result.oceanBooking().getActualPackageCount()).isEqualTo(480); + assertThat(result.oceanBooking().getDeclaredPackageCount()).isEqualTo(500); + assertThat(result.oceanBooking().getBillOfLadingNo()).isEqualTo("BL-001"); + assertThat(before.getActualPackageCount()).isEqualTo(450); + var history = ArgumentCaptor.forClass(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class); + verify(repository).insert(history.capture()); + assertThat(history.getValue().getAction()).isEqualTo("UPDATE_ACTUAL_CARGO"); + assertThat(history.getValue().getBeforeSnapshot()).contains("\"actualPackageCount\":450"); + assertThat(history.getValue().getAfterSnapshot()).contains("\"actualPackageCount\":480"); + assertThat(history.getValue().getReason()).isEqualTo("修正实装货量"); + verify(repository, never()).updateDraft(any(), anyInt(), anyLong(), any()); + verify(repository, never()).replaceRequirements(anyLong(), any()); + verifyNoInteractions(documents, priceBooks, suppliers, warehouses); + } + + @Test + void rejectsActualCargoOnDraftOrStaleVersionAndRequiresReason() { + OceanBookingEntity value = completeBooking("DRAFT", 3); + when(repository.find(1L)).thenReturn(value); + var request = new ActualCargoRequest(3, 100, null, null, "补录"); + assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-draft", request)).isInstanceOf(BizException.class); + value.setStatus("RELEASED"); + assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-stale", + new ActualCargoRequest(2, 100, null, null, "补录"))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-no-reason", + new ActualCargoRequest(3, 100, null, null, " "))).isInstanceOf(BizException.class).hasMessageContaining("原因"); + assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-negative", + new ActualCargoRequest(3, 100, new BigDecimal("-1"), null, "补录"))).isInstanceOf(BizException.class); + assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-precision", + new ActualCargoRequest(3, 100, null, new BigDecimal("1.0001"), "补录"))).isInstanceOf(BizException.class); + when(repository.updateActualCargo(any(), anyInt(), anyLong(), any())).thenReturn(0); + assertThatThrownBy(() -> service.updateActualCargo(1L, 9L, "admin", "actual-concurrent", request)).isInstanceOf(BizException.class); + verify(repository, never()).insert(any(com.baiyue.firstleg.oceanbooking.entity.OceanBookingStatusHistoryEntity.class)); + } + + @Test + void replayedActualCargoDoesNotWriteAgain() throws Exception { + var request = new ActualCargoRequest(3, 100, null, null, "补录"); + var replay = new Detail(completeBooking("RELEASED", 4), List.of(), List.of(), List.of(), List.of(), List.of()); + var operation = new OceanBookingOperationEntity(); + operation.setRequestHash(hash("UPDATE_ACTUAL_CARGO", 1L, request)); + operation.setStatus("SUCCESS"); operation.setResultJson(objectMapper.writeValueAsString(replay)); + when(repository.claimOperation(1L, 9L, "UPDATE_ACTUAL_CARGO", "actual-replay", operation.getRequestHash())).thenReturn(false); + when(repository.operation(9L, "UPDATE_ACTUAL_CARGO", "actual-replay")).thenReturn(operation); + assertThat(service.updateActualCargo(1L, 9L, "admin", "actual-replay", request).oceanBooking().getVersion()).isEqualTo(4); + verify(repository, never()).updateActualCargo(any(), anyInt(), anyLong(), any()); + } + + @Test + void savesUnnamedPartyContactDraftButRequiresNamesBeforeRelease() throws Exception { + var tree = (com.fasterxml.jackson.databind.node.ObjectNode) objectMapper.valueToTree(partialDraft(null)); + tree.putArray("parties").addObject().put("partyType", "SHIPPER").put("partyName", "") + .put("contactEmail", "ops@example.test").put("taxNo", "TEST-TAX"); + stubCreatedDraft(); + service.create(9L, "admin", "partial-party", new CreateRequest(0, objectMapper.treeToValue(tree, DraftData.class))); + @SuppressWarnings("unchecked") + ArgumentCaptor> capture = ArgumentCaptor.forClass(List.class); + verify(repository).replaceParties(anyLong(), capture.capture()); + assertThat(capture.getValue()).singleElement().satisfies(party -> { + assertThat(party.getContactEmail()).isEqualTo("ops@example.test"); + assertThat(party.getTaxNo()).isEqualTo("TEST-TAX"); + }); + var draft = completeBooking("DRAFT", 3); + when(repository.find(1L)).thenReturn(draft); + var parties = parties(); parties.get(0).setPartyName(""); + when(repository.parties(1L)).thenReturn(parties); + when(repository.requirements(1L)).thenReturn(List.of(requirement())); + assertThatThrownBy(() -> service.release(1L, 9L, "admin", "missing-party", new ReleaseRequest(3, null))) + .isInstanceOf(BizException.class).hasMessageContaining("尚未填写完整"); + verify(repository, never()).transition(anyLong(), any(), any(), anyInt(), anyLong(), any()); + } + private AtomicReference stubCreatedDraft() { AtomicReference inserted = new AtomicReference<>(); doAnswer(invocation -> { inserted.set(invocation.getArgument(0)); return null; }) @@ -412,7 +497,7 @@ class OceanBookingServiceTest { private static DraftData partialDraft(String remark) { return new DraftData(null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, remark, null, null); + null, null, null, null, null, null, null, null, null, remark, null, null, null, null, null, null, null, null, null, null, null, null, null); } private static DraftData completeDraft() { @@ -420,11 +505,11 @@ class OceanBookingServiceTest { 81L, null, "VESSEL", "V001", "CNSZX", "深圳", "USLAX", "洛杉矶", "LAX", LocalDateTime.of(2026, 9, 1, 0, 0), LocalDateTime.of(2026, 9, 20, 0, 0), null, - List.of(new PartyRequest("SHIPPER", "发货人", null, null, null), - new PartyRequest("CONSIGNEE", "收货人", null, null, null), - new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null)), + List.of(new PartyRequest("SHIPPER", "发货人", null, null, null, null, null), + new PartyRequest("CONSIGNEE", "收货人", null, null, null, null, null), + new PartyRequest("NOTIFY_PARTY", "通知人", null, null, null, null, null)), List.of(new ContainerRequirementRequest("40HQ", "DRY", 2, - new BigDecimal("68"), new BigDecimal("19500")))); + new BigDecimal("68"), new BigDecimal("19500"))), null, null, null, null, null, null, null, null, null, null, null); } private static OceanBookingEntity completeBooking(String status, int version) { diff --git a/scripts/README.md b/scripts/README.md index e4065a966..2145c12fd 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -1,5 +1,7 @@ # 后端脚本管理与全量 HTTP 验收索引 +`procurement-sample-test-env.mjs` 复用固定客户后只读取钱包,不再创建充值流水;执行前要求现有可用余额至少 3 元(货款 1 元加运费冻结 2 元),不足或余额字段异常即停止。仍会创建本轮采购业务与附件,不复用历史完成单据。 + `dispatch-loading-flow-test-env.mjs` 是 TEST 固定客户两托一车的定向 HTTP 验收入口,验证实收入库、确认预约、审核、扫码拣货、五张装车图片的 Admin 读取、自动出库派送及原请求幂等。默认只预览;显式 `--execute --run-id <唯一标识> --warehouse-id <起运仓> --destination-warehouse-id --test-image <明确标注TEST的PNG>` 写入业务测试记录,通过 `node --env-file="<统一TEST配置>"` 读取凭据和固定客户。报告位于 `reports/dispatch-loading-/`,保留失败前的请求和业务数据;同标识拒绝重跑,先核对原请求再决定恢复。`--resume-report <原报告>` 仅用于已明确收到编辑 A0400、仍为零拣货草稿的原单,保留原车与原司机,回查状态后补齐编辑字段继续;未知写入结果不能使用此恢复入口。默认停用本次临时 PDA 账号;`--keep-pda-for-ui` 仅在成功后将临时登录资料保存到系统临时目录供页面验收,页面结束后必须停用账号。所有 HTTP 会话结束时注销,不执行 SQL、删除、重置或部署。新增入口验证运行语法检查和 `script-documentation-contract.test.mjs`,经 `local-check.ps1`。 Admin/Client TEST 回归统一配置 `ADMIN_TEST_FIXED_CUSTOMER_ID` 与 `MERCHANT_TEST_USERNAME/PASSWORD`,复用指定 ACTIVE 客户;独立 Admin runner 同样读取这些显式环境变量。Client 默认不再注册审核、修改固定客户资料/账号、创建成员角色或通知,也不运行余额/渠道共享配置及缺第二客户的隔离场景;这些范围列为 skipped,`fullCoverage=false`,执行范围通过不代表全量覆盖。Admin 排除的主档生命周期范围见模块 README。业务单据及附件仍按本轮证据需要新增;不自动清理历史记录。 diff --git a/scripts/admin-test-env-acceptance.mjs b/scripts/admin-test-env-acceptance.mjs index 3f3d79038..91389354c 100644 --- a/scripts/admin-test-env-acceptance.mjs +++ b/scripts/admin-test-env-acceptance.mjs @@ -642,6 +642,9 @@ export class AcceptanceRun { this.options.fixedCustomerId ||= process.env.ADMIN_TEST_FIXED_CUSTOMER_ID || '' this.options.merchantUsername ||= process.env.MERCHANT_TEST_USERNAME || '' this.options.merchantPassword ||= process.env.MERCHANT_TEST_PASSWORD || '' + this.options.fixedPdaActors ??= JSON.parse(process.env.ADMIN_TEST_OVERSEAS_PDA_ACTORS || '[]') + this.options.fixedDomesticDenyActor ??= JSON.parse(process.env.ADMIN_TEST_DOMESTIC_DENY_ACTOR || 'null') + this.options.fixedFleetResources ??= JSON.parse(process.env.ADMIN_TEST_FLEET_RESOURCES || '[]') } const markerNamespace = String(options.markerNamespace || '').replace(/[^A-Za-z0-9_-]/g, '').slice(0, 12) this.marker = `${options.environment === 'gray' ? 'GRAY' : 'ADMIN'}-API-${new Date().toISOString().replace(/\D/g, '').slice(0, 17)}${markerNamespace ? `-${markerNamespace}` : ''}` @@ -665,6 +668,7 @@ export class AcceptanceRun { if (this.options.environment !== 'test') return const pathname = new URL(path, 'https://acceptance.invalid').pathname.replace(/\/$/, '') if (String(method).toUpperCase() === 'POST' && (pathname === '/api/platform/v1/iam/tenants' + || pathname === '/api/auth/register' || /^\/api\/platform\/v1\/iam\/tenants\/[^/]+\/(archive|submit-review)$/.test(pathname))) { throw new Error('TEST 固定客户模式禁止创建、归档客户或重提资质;请复用已配置客户') } diff --git a/scripts/admin-test-env-modules/README.md b/scripts/admin-test-env-modules/README.md index 7dc90aa6d..054792853 100644 --- a/scripts/admin-test-env-modules/README.md +++ b/scripts/admin-test-env-modules/README.md @@ -1,5 +1,17 @@ # Admin 测试环境 HTTP 模块 +固定客户车队履约通过显式环境文件的 `ADMIN_TEST_FLEET_RESOURCES` JSON 数组配置已有资源,每项为 `vehicleId`、`driverId`(十进制字符串)。助手只读核验海外自有在役车辆、已审核启用司机及截至预期任务结束的派车资格,不创建车辆/司机、不上传或重审证照。当前 runner 中尚未完成资源释放的组合不得重复分配;全部占用或缺配置直接阻断,失败资源保持占用待排查。复用车辆按审核后真实车牌查询,并以本轮排车号核对唯一 Fleet 绑定,不能把历史同车次记录当作当前证据。跨 runner 仍须串行,真实占用、仓与容量由正式分配接口最终校验。本规则不减少必要的业务排车与履约证据,也不覆盖车辆或司机主档生命周期。 + +国内整柜权限拒绝探针通过显式环境文件的 `ADMIN_TEST_DOMESTIC_DENY_ACTOR` JSON 对象配置 `membershipId`、`username`、`password`。固定客户模式在业务写入前核对既有 ACTIVE 平台账号及实际 A0403 拒绝响应,结束退出本轮会话;缺配置或权限不符直接阻断,不创建角色、账号或重置密码。实际凭据不得写入仓库或报告。 + +固定 PDA 账号取得会话后即占用;权限核验失败也检查退出结果。退出被拒绝或请求异常会记录失败并保留占用,禁止本轮再次分配该账号,同时继续尝试退出其他账号。不将退出未知结果视为清理成功。 + +固定海外 PDA 账号池通过显式环境文件的 `ADMIN_TEST_OVERSEAS_PDA_ACTORS` JSON 数组配置,每项为 `membershipId`、`warehouseId`、`username`、`password`,不得将实际值写入仓库或报告。同仓双操作员场景需要两个不同账号;当前 runner 按 membershipId 占用并在退出会话成功后释放。使用前核对 ACTIVE、平台身份、PDA 角色及仓绑定;缺失或不匹配直接阻断,不创建或修复账号。复用账号只注销本轮会话,不停用、不改绑、不清空角色;仓绑定撤销生命周期明确列为未验收。该占用只在当前 runner 内有效,仍须遵守共享环境造数串行规则。 + +固定客户下,报关、装柜、订舱级拖车和车队报价复用启用供应商;按 CUSTOMS/BOOKING/TRUCKING 类别、MATSON 船司及国内/海外拖车范围核验列表和详情。缺少匹配供应商时阻断,不自动创建或重新启用;车队报价结束只停用本轮报价,不停用复用供应商。 + +固定客户模式下,PDA 换标只核验现有可用余额至少 0.01 元(本场景换标费用),不自动补款;采购样本专项要求现有可用余额至少 3 元(货款 1 元加运费冻结 2 元),不自动充值。海外采购合作仓从正式候选复用,退出时不得停用复用仓。固定客户及本轮入仓计划查询覆盖后续分页;混托专项缺第二固定客户时,在首次下单和测量前阻断。以上前置检查不替代服务端执行时的实时余额、资格与版本校验。 + 固定客户编排也将 `customers`、`supplemental-boundaries` 明确记为跳过,不运行建客或边界仓库/库位生命周期。Finance 供应商按启用状态与费用类别查询复用,详情变化或无候选时阻断,不补建;海外入仓费用只允许关联本轮 PDA `generatedPlanIds` 内且客户、批次均匹配的计划,禁止回退历史计划。混合报关柜缺第二固定客户时在准备订单和装柜前阻断。 固定客户模式下,默认编排排除 `system`、`master-data`、`suppliers`、`notifications`、`pricing`、`phase4-overseas-master-data`、`phase4-overseas-wallet-and-pallet-cost` 的生命周期写入,报告使用 skipped 和 coverage gap,不计模块通过。海外仓模块复用现有仓及只读资源,缺仓直接阻断;不自动创建库位/托盘。业务订单与履约证据仍按批次创建,不能将历史已完成单据当作本轮新证据。其他业务模块内嵌的权限探针账号及专用业务资源仍可能创建,当前模式不代表零新增数据。 @@ -78,7 +90,7 @@ OCR模块必须通过`ADMIN_TEST_OCR_IMAGE`显式指定本轮图片,不依赖 头程提交、平台编辑与取消按首次原始详情版本冻结请求。原键原载荷重放成功;同键异载荷、旧版本及新键重复操作分别核验拒绝后事实不变。Platform 编辑使用平台权限与显式客户范围,客户 Token 不得调用。 -`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;五期与F40通过正式API维护本轮车辆、司机及证照,提交并通过调度/理货两级审核,再读取该排车唯一自动生成的Fleet资源记录。PDA拣货核平后,显式确认装车完成,再由平台安排全车客户目的地派送,最后使用当前主单版本发车;不再独立创建、绑定或审核Fleet车次。实载托位、重量、体积及卸货站须有本轮真实来源证据,不能使用计费量或固定值;ID和版本无损保留,Long箱数字符串仅在安全数量边界归一化。资源及文件ID留在报告,失败保留原命令。直接调用者也须同步消费原生DTO及该顺序;合同通过不代表HTTP或PDA硬件验收。 +`fleet-trip-fulfillment.mjs` 是内部整车验收助手,不是独立入口。一张原生排车单对应一趟车;通过正式 API 准备资源与两级审核,读取唯一自动生成的 Fleet 记录。PDA 拣货及来源核验通过后,读取本车五个装车照片框,复用已保存文件,仅为缺失框上传标明 TEST 的合成 PNG,再绑定、原键重放并回读;上传前保存检查点与文件摘要,未知结果停写,不自动重新上传。照片在 LOADING 保存不得提前发车;AFTER_LOADING 最后一个缺失框保存后须进入 DELIVERING。资料齐全后重新读取主单资格与实载清单,再确认装车并核对自动生成的派送,不再调用手工安排派送或发车。实载数据由服务端返回,重量和体积缺失保持未知,不以请求中的测量值或计费量替代。PDA 上传及绑定始终保留原账号会话;ID 和版本无损保留,资源及文件留在报告。直接调用者须消费当前 DTO;离线合同通过不代表真实 HTTP、实物照片或 PDA 硬件验收。 `prepareFleetTrip` 对零扫码的装车中排车,须先显式退回装车前再维护资源并重走两级审核;已扫码时不能重排。`confirmFleetLoading` 验证已扫码编辑被拒绝且主单与实际货物零漂移,然后按真实装载预览/实测回执发送装车完成命令;PDA成功后,平台使用同一原键原载荷重放,必须保持原结果和版本。报告`createdResources.fleetTrips[].reassignment`保留原请求、前后快照及拒绝证据;未知写结果停止,不自动换键重造。装车返回版本不替代发车前重新读取的公共版本。 @@ -257,7 +269,7 @@ export async function run(context) { | 仓储转运审核 | `phase4-overseas-client-services` 调用内部 `warehouse-transfer-review.mjs` | 本轮资质客户 Token、PDA 完成柜来源订单;另通过客户 API 创建独立新到仓转运对照票 | 平台显式 customerId;客户 Token 无审核权限;缺作用域/非法决定拒绝且状态不漂移;已有库存 APPROVED/PENDING_ASSIGNMENT;新到仓 APPROVE→WAIT_PDA_RECEIVING/WAIT_DELIVERY,REJECT→REJECTED/CANCELLED;同决定重放、反向审核冲突、客户详情回查;空驳回原因应 A0400 并保持待审核 | | 智能预排真实草稿柜 | `phase4-overseas-dispatch` | `phase4-overseas-pda-operations` 后本轮实际收货托盘 | 草稿柜创建、明确 draftContainerId 绑定、结构回查、unlock 恢复候选、删除柜释放锁、重建柜预排;保留同草稿重锁、其他草稿冲突、旧载荷预排冲突与 release 合同 | | 排车托盘移除 | `phase4-overseas-dispatch` | 同一本轮托盘先创建独立移除对照排车,移除后才进入主链预排 | DELETE 明细后箱数/托数/明细归零;重复删除 A0404;正式锁释放后候选恢复。空对照排车保留,不借历史排车 | -| 排车编辑、BOL、后台转派送 | `phase5-overseas-fulfillment` | `phase4-overseas-dispatch` 生成的主链排车 | 复用车辆编辑和客户转派送;新增 BOL content-type 与 %PDF- 签名检查;后台同载荷转派送复用同一出库/派送业务号;生成出库后移除托盘 A0409 且数量和身份不漂移;原 PDA 拣货/发车、派送、异常和回访继续执行 | +| 排车编辑、BOL、自动派送 | `phase5-overseas-fulfillment` | 本轮主链排车 | 核对车辆编辑、BOL 文件、PDA 拣货、五框资料及装车自动生成出库/派送;双端回读阶段版本、客户归属和数量,库存与 SHIP 零重复扣减;继续逐单签收、异常和回访 | | 退货签收与合箱 | `phase4-overseas-return-receipt` | 本轮退货预报及临时 PDA,不使用历史100单 | 已有分批签收、超收、幂等、数量守恒、库存与计费批次、合箱;不重复编排 | | PDA 收货、换标托盘及采购资金 | `phase4-overseas-pda`、`phase4-overseas-pda-operations`、`phase4-overseas-wallet-and-pallet-cost` | 原本轮收货与钱包前置 | 复用已有收货、换标拆托、出库衔接和采购冻结/结算/释放,不新增旁路写库 fixture | @@ -320,7 +332,7 @@ export async function run(context) { | 海外业务 HTTP:采购创建/备注/审核边界,卡车与散板状态及取消 | `phase4-overseas-business.mjs` | `phase4-overseas-business-contract.test.mjs` | | 海外主数据 HTTP 生命周期 | `phase4-overseas-master-data.mjs`;车辆新建、详情、修改及列表均核对实际托位/载重/容积,修改使用不同容量值,CSV 同时核对修改后的值与业务车牌号;缺失或猜测容量形成阻断,finally 停用本轮资源。内部助手 `vehicle-care.mjs` 验证保养/加油创建重放、版本冲突、历史、单位、照片重放和字节下载,并逐类验证维修/故障/救援/异常项目地点回显、处理人、完成处理及费用/付款独立;另建本轮车辆验证跨车读取/修改/照片拒绝,结束停用隔离车辆并保留记录。报告在 `phase4OverseasMasterData.vehicle.care`,错误地以处理完成推定付款必须阻断;无SQL或旧数据修复,不作独立入口 | `phase4-overseas-master-data-contract.test.mjs` | | 目的港码头拖车与船节点终态 | `phase5-terminal-drayage.mjs`(PDA 卸柜前复用 `ACTIVE + TRUCKING + truckingScope=OVERSEAS` 主数据完成选车队、预约和实际提柜,卸柜后完成实际还柜,并覆盖四命令幂等/历史)、`phase4-carrier-nodes.mjs`(PDA 前补录至 `PICKUP_READY`;拖车完成后只读核验 `PICKED_UP/EMPTY_RETURNED` 和停止语义) | `phase5-terminal-drayage-contract.test.mjs`、`phase4-carrier-nodes-contract.test.mjs`;不再由船节点脚本直接伪造提柜和还柜 | -| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(复用本轮海外仓已收货托盘和预排单,正式创建临时 PDA 操作员并在 `finally` 停用;覆盖排车编辑、按 FBA 目的仓转出库/拆分派送单、PDA 全量拣货/发车、逐单签收资料保存自动完成、头程入仓与订单跨区回推、客户回访、人工异常及派送退回) | `phase5-overseas-fulfillment-contract.test.mjs` | +| 海外排车、出库、派送、异常与回访 HTTP 生命周期 | `phase5-overseas-fulfillment.mjs`(本轮已收货托盘和排车,PDA 拣货、资料齐全后装车自动发车、只读单据守恒、签收与回推;固定 PDA 账号退出会话) | `phase5-overseas-fulfillment-contract.test.mjs` | | 初始化基线(仅离线) | — | `initialization-baseline-contract.test.mjs` | `phase4-overseas-inbound-operations.mjs` 的内部 `prepareAppointmentFixture` 可由已核对的本轮最小正式 API 流程复用:必须传入真实转换所得的入仓计划和同仓明细,至少两箱才执行拆分合并;两箱验证 `2 → 1 + 1 → 2`,不补造货量。完整 Admin 模块仍按上表四箱参数及统一依赖运行,不能为缩减造数而伪造生产者矩阵或跳过前置事实。 @@ -347,7 +359,7 @@ node scripts/admin-test-env-acceptance.mjs --gateway --modules ph 只有已登记在 `../admin-test-env-acceptance.mjs` 的 `CONFIGURED_MODULES` 中的名称才能用于 `--modules`;目录中存在同名 `.mjs` 不代表总入口已注册。新增或改名 HTTP 模块时必须同步总入口、依赖展开、离线入口合同和本表;尚未注册的实验脚本不得写成可执行验收入口。 -海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`。依赖展开固定从本轮国内货柜履约装柜完成、海外仓业务跨区接柜和海外 PDA 实收入库生成真实库存,再由海外仓业务智能预排创建 marker 专属 `PENDING_DEPARTURE` 排车。海外仓出库与派送模块只消费该本轮排车,先补齐车辆与司机,原子转为一张 `WAIT_OUTBOUND` 出库单和按 FBA 目的仓拆分的 `WAIT_DELIVERY` 派送单,随后使用绑定同一海外仓的临时 PDA 身份执行全量拣货和发车,使出库单进入 `OUTBOUNDED`;最后逐张派送单保存签收文件自动完成,轮询核对头程入仓单与统一订单跨区回推,并覆盖回访、人工异常与退回海外仓。所有幂等重放都核对业务单号或数量不漂移,真实前置不足必须形成 deployment blocker,临时 PDA 身份无论成功失败都由正式 IAM 状态接口停用。 +海外仓出库与派送履约链定向入口为 `--modules phase5-overseas-fulfillment`,只消费本轮国内装柜、海外接柜与 PDA 实收形成的排车和库存。正式资源及两级审核就绪后,由绑定同仓的 PDA 逐来源拣货,补齐五框装车资料,再确认装车;服务端自动生成客户出库与派送并发车。五期、F40、运力池和保留样本专项统一使用 `readAutomaticDispatchDelivery`、`verifyAutomaticDeparture` 只读核对单据、双端阶段/版本及客户数量,禁止重复调用手工转派送或发车。装车重放由共享助手核验,之后独立回查库存和 SHIP 不重复扣减,再逐单签收并验证头程回推。固定账号只退出本轮会话;非固定专项账号按原生命周期清理。前置不足明确失败,不能借历史单据冒充本轮自动发车。 该模块同时核对拣货、发车和重放期间库存及 SHIP 流水只扣一次;同一派送退回异常经接收、证据上传、处理完成后必须生成真实恢复库存及 `RETURN_STOCK_IN` 流水,原键重放不重复入账。PROCESSING 续跑必须保留原异常、原目标托盘、原证据和幂等键,缺失即阻断,不另建异常替代。客户签收文件验证下载真实字节,并通过本轮独立 SIGNED-B 客户验证隔离,凭据不入报告。分片完成门禁只接受同一订单的多个有效入仓分片;不同订单、多目的仓或已经完成的分片不能代替此条件,缺少生产者时仍报告覆盖阻断。 diff --git a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs index 229acd3dc..c63b01e2a 100644 --- a/scripts/admin-test-env-modules/domestic-container-contract.test.mjs +++ b/scripts/admin-test-env-modules/domestic-container-contract.test.mjs @@ -3,6 +3,30 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' +import { verifyPermissionDenial } from './domestic-container.mjs' + +test('fixed denial actor never creates, resets or disables accounts and always logs out', async () => { + for (const deniedCode of ['A0403', '200']) { + const calls = [] + const actor = { membershipId: '123', username: 'deny-fixture', password: 'fixture-password' } + const context = { options: { fixedCustomerId: '456', fixedDomesticDenyActor: actor }, + async request(method, path) { + calls.push({ method, path }) + const data = path.includes('/iam/users?') ? { total: 1, records: [{ ...actor, tenantId: '0', status: 'ACTIVE' }] } + : path === '/api/auth/login' ? { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' } : {} + return { http: 200, body: { code: path.includes('/inventories?') ? deniedCode : '200', data }, requestId: 'test', traceId: 'test' } + }, apiSuccess(_module, _label, response) { return response.body.data }, assert() {}, + } + if (deniedCode === 'A0403') await verifyPermissionDenial(context, 'RUN', '789', 'CONTAINER') + else await assert.rejects(verifyPermissionDenial(context, 'RUN', '789', 'CONTAINER'), /实际权限不符/) + assert.equal(calls.at(-1).path, '/api/auth/logout') + assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path))) + } +}) + +test('missing fixed denial account blocks without requesting a phone or making HTTP calls', async () => { + await assert.rejects(verifyPermissionDenial({ options: { fixedCustomerId: '123' } }, 'RUN', '789', 'CONTAINER'), /禁止自动创建/) +}) const here = path.dirname(fileURLToPath(import.meta.url)) const moduleSource = fs.readFileSync(path.join(here, 'domestic-container.mjs'), 'utf8') diff --git a/scripts/admin-test-env-modules/domestic-container.mjs b/scripts/admin-test-env-modules/domestic-container.mjs index d74d9893e..6e97502c3 100644 --- a/scripts/admin-test-env-modules/domestic-container.mjs +++ b/scripts/admin-test-env-modules/domestic-container.mjs @@ -131,6 +131,7 @@ export async function run(context) { if (!warehouse) throw new Error('缺少启用的平台国内仓,整柜验收无法继续') const warehouseId = id(warehouse.id) const containerNo = `DCA${marker}`.slice(0, 32).toUpperCase() + if (context.options?.fixedCustomerId) await verifyPermissionDenial(context, marker, warehouseId, containerNo) const now = new Date().toISOString().slice(0, 19) await verifyValidationBoundaries(context, warehouseId, marker, now) @@ -222,11 +223,38 @@ export async function run(context) { await verifyExtendedInventoryScenarios(context, warehouseId, marker, now) - await verifyPermissionDenial(context, marker, warehouseId, containerNo) + if (!context.options?.fixedCustomerId) await verifyPermissionDenial(context, marker, warehouseId, containerNo) context.createdResources.domesticContainer = { warehouseId, inventoryId, containerNo, permissions: PERMISSIONS } } -async function verifyPermissionDenial(context, marker, warehouseId, containerNo) { +export async function verifyPermissionDenial(context, marker, warehouseId, containerNo) { + if (context.options?.fixedCustomerId) { + const actor = context.options.fixedDomesticDenyActor + if (!actor?.username || !actor.password || !/^[1-9]\d*$/.test(id(actor.membershipId))) { + throw Error('缺少固定整柜权限拒绝账号,禁止自动创建用户或角色') + } + let user + for (let page = 1; ; page++) { + const response = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`) + const result = context.apiSuccess(MODULE, '查询固定权限拒绝账号', response) + user = result?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username) + if (user || !result?.records?.length || page * Number(result.size || 100) >= Number(result.total || 0)) break + } + if (user?.status !== 'ACTIVE' || id(user.tenantId) !== '0') throw Error('固定权限拒绝账号必须为启用的平台账号') + const login = await context.request('POST', '/api/auth/login', { loginId: actor.username, password: actor.password }, '') + const session = context.apiSuccess(MODULE, '登录固定权限拒绝账号', login) + if (!session?.accessToken || !session.refreshToken) throw Error('固定权限拒绝账号未返回完整会话') + try { + const denied = await context.request('GET', `${BASE}/inventories?page=1&size=20&warehouseId=${warehouseId}&containerNo=${containerNo}`, undefined, session.accessToken) + expectCode(context, '固定无整柜权限用户被拒绝', denied, 'A0403') + if (denied.http !== 200 || String(denied.body?.code) !== 'A0403') throw Error('固定探针账号实际权限不符,停止整柜造数') + } finally { + const logout = await context.request('POST', '/api/auth/logout', { refreshToken: session.refreshToken }, session.accessToken) + context.apiSuccess(MODULE, '退出固定权限拒绝账号会话,不停用账号或角色', logout) + if (!succeeded(logout)) throw Error('固定权限拒绝账号退出失败') + } + return + } const phone = await context.findAvailablePhone(MODULE) const roleCode = `DC_DENY_${marker}`.slice(0, 64) const username = `dc_deny_${marker}`.slice(0, 64).toLowerCase() diff --git a/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs b/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs index 22b568ccc..59c5103ed 100644 --- a/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs +++ b/scripts/admin-test-env-modules/dropship-mixed-palletization-contract.test.mjs @@ -1,6 +1,11 @@ import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' +import { run } from './dropship-mixed-palletization.mjs' + +test('single fixed customer blocks before first order or measurement', async () => { + await assert.rejects(run({ options: { fixedCustomerId: '123' } }), /停止下单和测量造数/) +}) const source = readFileSync(new URL('./dropship-mixed-palletization.mjs', import.meta.url), 'utf8') const orders = readFileSync(new URL('./orders.mjs', import.meta.url), 'utf8') diff --git a/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs index a732f79de..172d3cea4 100644 --- a/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs +++ b/scripts/admin-test-env-modules/dropship-mixed-palletization.mjs @@ -8,6 +8,7 @@ const OPTIONS = { scenario: MODULE, deploymentBlocker: true } // 正式下单和测量产生两客户三票,再用 PDA 公网接口逐箱混托并保留全部证据。 export async function run(context) { + if (context.options?.fixedCustomerId) throw new Error('混托需要第二个固定客户,停止下单和测量造数') const originalMarker = context.marker const savedContext = { ...context.context } const originalManualCount = context.options.prepareDropshipManualCount diff --git a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs index db8d2db84..a6d66daee 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures-contract.test.mjs @@ -14,8 +14,8 @@ test('fixed customer supplier selection paginates and never creates a fallback', async request(method, path) { calls.push({ method, path }) const data = path.includes('/123?') ? supplier : path.includes('page=2') - ? { records: [supplier], total: 101 } - : { records: Array.from({ length: 100 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), total: 101 } + ? { records: [supplier], size: 20, total: 21 } + : { records: Array.from({ length: 20 }, () => ({ id: '99', status: 'ACTIVE', category: 'BOOKING' })), size: 20, total: 21 } return { body: { data } } }, apiSuccess(_module, _name, response) { return response.body.data }, @@ -30,6 +30,22 @@ test('fixed customer supplier selection paginates and never creates a fallback', }) const here = path.dirname(fileURLToPath(import.meta.url)) +test('reused suppliers must match carrier and trucking scope in both list and detail', async () => { + for (const criteria of [{ carrierCode: 'MATSON' }, { truckingScope: 'OVERSEAS' }]) { + const good = { id: '123', category: 'BOOKING', status: 'ACTIVE', carrierCodes: ['MATSON'], truckingScope: 'OVERSEAS' } + let detail = good + const context = { options: { fixedCustomerId: '456' }, marker: 'REUSE', + async request(method, path) { + assert.equal(method, 'GET') + return { body: { data: path.includes('/123?') ? detail : { records: [ + { ...good, id: '999', carrierCodes: [], truckingScope: 'DOMESTIC' }, good], total: 2 } } } + }, apiSuccess(_module, _name, response) { return response.body.data }, addStep() {}, + } + assert.equal((await loadSupplier(context, 'BOOKING', criteria)).id, '123') + detail = { ...good, carrierCodes: [], truckingScope: 'DOMESTIC' } + assert.equal(await loadSupplier(context, 'BOOKING', criteria), null) + } +}) const source = fs.readFileSync(path.join(here, 'finance-full-fixtures.mjs'), 'utf8') test('finance fixture module is independently exported and marker isolated', () => { diff --git a/scripts/admin-test-env-modules/finance-full-fixtures.mjs b/scripts/admin-test-env-modules/finance-full-fixtures.mjs index 8e40bdbd0..a70133cff 100644 --- a/scripts/admin-test-env-modules/finance-full-fixtures.mjs +++ b/scripts/admin-test-env-modules/finance-full-fixtures.mjs @@ -258,23 +258,25 @@ async function loadPublishedAutomaticFee(context, resource) { actualWeightKg: responseWeightKg, actualVolumeCbm: responseVolumeCbm } } -export async function loadSupplier(context, category) { +export async function loadSupplier(context, category, { carrierCode, truckingScope } = {}) { if (context.financeFixtureHooks?.loadSupplier) return context.financeFixtureHooks.loadSupplier(context, category) if (context.options?.fixedCustomerId) { + const matches = item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category) + && (!carrierCode || item.carrierCodes?.includes(carrierCode)) && (!truckingScope || item.truckingScope === truckingScope) for (let page = 1; ; page += 1) { const response = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&status=ACTIVE&page=${page}&size=100`) const data = context.apiSuccess(MODULE, '读取可复用的现有供应商', response, '', OPTIONS) if (!data) return null const records = rows(data) - const candidate = records.find(item => id(item?.id) && item.status === 'ACTIVE' && (!category || item.category === category)) + const candidate = records.find(matches) if (candidate) { const detailResponse = await request(context, 'GET', `/api/platform/v1/suppliers/${encodeURIComponent(candidate.id)}?tenantId=0`) const detail = context.apiSuccess(MODULE, '核对复用供应商状态与类别', detailResponse, '', OPTIONS) - if (id(detail?.id) === id(candidate.id) && detail?.status === 'ACTIVE' && (!category || detail.category === category)) return detail + if (id(detail?.id) === id(candidate.id) && matches(detail)) return detail blocker(context, '复用供应商已变化', '候选详情不匹配,不创建替代供应商') return null } - if (!records.length || page * 100 >= Number(data.total ?? records.length)) break + if (!records.length || page * Number(data.size || 100) >= Number(data.total ?? records.length)) break } blocker(context, '缺少可复用供应商', `需要 ACTIVE ${category || '任意类别'} 供应商;禁止自动创建`) return null diff --git a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs index 26653a445..ad72fd20a 100644 --- a/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-cross-customer-acceptance-contract.test.mjs @@ -1,3 +1,4 @@ +import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs' import test from 'node:test' import assert from 'node:assert/strict' import { readFile } from 'node:fs/promises' @@ -49,6 +50,7 @@ function model(fault = '') { } return response({...prior.result,replayed:true}) } + if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-ONE', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (path === '/api/platform/v1/last-leg/fleet/trips/TRIP') return response(trip) if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE/items') return response({dispatchNo:'DS-ONE',recordVersion:nativeVersion,items:items()}) if (path === '/api/platform/v1/last-leg/dispatches/DS-ONE') { diff --git a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs index d43409129..c69001b39 100644 --- a/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-external-dispatch-acceptance-contract.test.mjs @@ -1,3 +1,4 @@ +import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs' import test from 'node:test' import assert from 'node:assert/strict' import { readFileSync } from 'node:fs' @@ -86,6 +87,7 @@ function fixture(fault = '', nativeMode = false) { if (method === 'GET' && normalized === `${BASE}/DS-OWN` && path.startsWith(PDA)) return response({ ...data, items: items.map((i, n) => ({ ...i, positions: [{ positionId: `6${n}`, availablePackages: i.scheduledPackages }] })) }) if (method === 'GET' && normalized.endsWith('/items')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, status: 'ACTIVE', stage: data.stage, items: cargo }) if (method === 'GET' && normalized.endsWith('/route')) return response({ dispatchNo: data.dispatchNo, recordVersion: data.recordVersion, stops: tripProof.stops }) + if (method === 'GET' && path.endsWith('/loading-documents')) return response({ dispatchNo: 'DS-OWN', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (method === 'GET' && normalized.endsWith('/loading')) return response({ ...data, manifestHash: 'c'.repeat(64), loadingComplete: { allowed: true }, pallets: items.map((i, n) => ({ ...i, ...(fault === 'missing-measurements' ? {} : { palletSpaces: n === 2 ? 2 : 1, weightKg: n === 2 ? 37 : 10, volumeCbm: n === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || n === 2 ? 1 : n + 1, evidence: [['palletSpaces', n === 2 ? 2 : 1], ['weightKg', n === 2 ? 37 : 10], ['volumeCbm', n === 2 ? 0.7 : 0.2]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: i.inboundCode, observedAt: '2026-10-19T01:00:00Z' })) }) })) }) if (method === 'GET') { assert.ok(normalized === `${BASE}/DS-OWN` || normalized === `${BASE}/DS-FULL`, raw); return response(data) } @@ -95,7 +97,7 @@ function fixture(fault = '', nativeMode = false) { if (path.endsWith('/items')) { assert.equal(stage, 'BEFORE_LOADING'); assert.equal(body.cargo.length, 1); items.push(external); recordVersion++; fleetVersion++; if (fault === 'unknown-add') return { http: 0, body: null }; return remember(ack('ADD_CARGO')) } if (path.endsWith('/submit-review') || path.endsWith('/dispatch-review') || path.endsWith('/loading-review')) { if (path.endsWith('/loading-review') && fault !== 'review-not-applied') { review = true; stage = 'LOADING' }; recordVersion++; return remember(ack('REVIEW')) } if (path.endsWith('/pick')) { assert.ok(token); const itemId = path.split('/').at(-2); items.find(i => i.dispatchItemId === itemId).pickedPackages += body.packages; recordVersion++; return remember(ack('PICK')) } - if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + if (path.endsWith('/loading-complete')) { assert.equal(items.every(i => i.pickedPackages === i.scheduledPackages), true); stage = 'DELIVERING'; recordVersion++; return remember(ack('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: items.map((item, index) => ({ warehousePalletId: item.warehousePalletId, palletSpaces: 1, weightKg: index === 2 ? 37 : 10, volumeCbm: index === 2 ? 0.7 : 0.2, deliveryIndex: nativeMode || index === 2 ? 1 : index + 1 })) } })) } throw Error(`Unexpected ${method} ${raw}`) } } const options = nativeMode ? {pickerToken:'OFFLINE-ONLY'} : {coverageCase:currentCase,pickerToken:'OFFLINE-ONLY'} diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs index dea04930d..d8d4fbce5 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations-contract.test.mjs @@ -6,6 +6,21 @@ import { run, runQuoteSession, verifyOperations, requireCurrentFixture, fixtureC import { AcceptanceRun } from '../admin-test-env-acceptance.mjs' const copy = value => structuredClone(value) +test('fixed quote supplier is reused without creation or deactivation', async () => { + const { context } = harness(), request = context.request.bind(context), writes = [] + context.options = { fixedCustomerId: '101' } + const supplier = { id: '901', name: 'Existing', category: 'TRUCKING', truckingScope: 'OVERSEAS', status: 'ACTIVE' } + context.request = async (method, path, ...args) => { + if (path.includes('/suppliers')) { + if (method !== 'GET') writes.push({ method, path }) + return { http: 200, body: { code: '200', data: path.includes('/901') ? supplier : { records: [supplier], total: 1 } } } + } + return request(method, path, ...args) + } + await run(context) + assert.equal(context.createdResources.fleetQuoteAcceptance.supplierReused, true) + assert.deepEqual(writes, []) +}) function harness(fault) { const marker = 'FLEET-CURRENT-RUN', tripNo = 'TR-CURRENT', departedAt = '2026-10-03T01:00:00Z' const rows = new Map(), histories = new Map(), commands = new Map(), measuredKeys = new Map(), calls = [] diff --git a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs index b34cc8404..4f5dcaab2 100644 --- a/scripts/admin-test-env-modules/fleet-quotes-operations.mjs +++ b/scripts/admin-test-env-modules/fleet-quotes-operations.mjs @@ -227,10 +227,14 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames } if (scope === 'QUOTES_ONLY') { const customer = requireQuoteCustomer(context) - const supplier = await command('POST', '/api/platform/v1/suppliers?tenantId=0', { + const supplier = context.options?.fixedCustomerId + ? await (await import('./finance-full-fixtures.mjs')).loadSupplier(context, 'TRUCKING', { truckingScope: 'OVERSEAS' }) + : await command('POST', '/api/platform/v1/suppliers?tenantId=0', { name: `车辆报价验收-${context.marker}`, category: 'TRUCKING', truckingScope: 'OVERSEAS', carrierCodes: [], status: 'ACTIVE', remark: context.marker, region: 'TEST', legalRepresentative: '测试法人', contactName: '测试联系人', phone: '19900000000', email: 'fleet-acceptance@example.com', settlementPeriod: 'MONTHLY_30', }, '新建本轮海外车队供应商') + if (!supplier) throw new Error('缺少现有海外拖车供应商,不自动创建') + proof.supplierReused = Boolean(context.options?.fixedCustomerId) proof.supplierId = id(supplier.id ?? supplier) check('供应商返回明确标识', null, /^\d+$/.test(proof.supplierId)) const day = new Date().toISOString().slice(0, 10) @@ -311,7 +315,7 @@ async function execute(context, { scope, writeMeasured = false, idempotencyNames if (row.status === 'ACTIVE') await command('PATCH', `${QUOTES}/${encode(no)}/status`, { recordVersion: row.recordVersion, status: 'INACTIVE' }, '停用本轮专用报价并保留历史') } catch (error) { cleanupErrors.push(error.message) } } - if (proof.supplierId && !context.quoteRequestOutcomeUnknown) { + if (proof.supplierId && !proof.supplierReused && !context.quoteRequestOutcomeUnknown) { try { const path = `/api/platform/v1/suppliers/${encode(proof.supplierId)}` const response = await request('PATCH', `${path}/status?tenantId=0`, { status: 'INACTIVE' }) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs index ec0f40de8..a321b6008 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance-contract.test.mjs @@ -1,3 +1,4 @@ +import { LOADING_PHOTO_SLOTS } from './fleet-trip-fulfillment.mjs' import test from 'node:test' import assert from 'node:assert/strict' import { mkdtempSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'node:fs' @@ -33,7 +34,7 @@ function fixture(fault = '') { const detail = next => ({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: next && nextCancelled ? 'CANCELLED' : 'ACTIVE', stage: next ? 'BEFORE_LOADING' : newSigned ? 'DELIVERED' : stage, documents: { deliveries: departed && !next ? [ref()] : [], outbounds: departed && !next ? [outbound()] : [] }, reviewState: review ? 'APPROVED' : 'DRAFT', reviews: { dispatchReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' }, loadingReview: { status: review ? 'APPROVED' : 'NOT_SUBMITTED' } }, counts: counts(next), pickingReconciled: picked, - vehicleInfo: { vehicle: { vehicleId: '601' }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] }) + vehicleInfo: { vehicle: { vehicleId: '601', plate: vehiclePlate }, driver: { driverId: '701' } }, dispatchInfo: { route: next ? '' : 'ONT8' }, orderDetails: next ? [] : [{ customerId: CUSTOMER, sourceKey: 'RETURN:300', inboundCode: 'IN-A', destinationFbaWarehouse: 'ONT8' }] }) const ref = () => ({ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, outboundNo: 'OUT-NEW', deliveryNo: 'DL-NEW', status: departed ? 'DELIVERING' : 'WAIT_DELIVERY', packageCount: 2 }) const outbound = () => ({ customerId: CUSTOMER, outboundNo: 'OUT-NEW', status: departed ? 'OUTBOUNDED' : 'WAIT_OUTBOUND', packageCount: 2 }) const command = (action, result = {}, next = false) => ({ ...detail(next), action, result, replayed: false }) @@ -113,8 +114,9 @@ function fixture(fault = '') { if (method === 'GET' && normalized.endsWith('/items')) return envelope({ dispatchNo: next ? 'DP-NEXT' : 'DP-NEW', recordVersion: next ? 0 : recordVersion, status: 'ACTIVE', stage: next ? 'BEFORE_LOADING' : stage, items: next ? [] : [nativeItem()] }) if (method === 'GET' && normalized.endsWith('/route')) return envelope({ dispatchNo: 'DP-NEW', recordVersion, stops }) if (method === 'GET' && path.startsWith(PDA) && normalized.endsWith('/DP-NEW')) return envelope({ ...detail(false), items: [nativeItem()] }) + if (method === 'GET' && path.endsWith('/loading-documents')) return envelope({ dispatchNo: 'DP-NEW', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) if (method === 'GET' && normalized.endsWith('/loading')) return envelope({ ...detail(false), manifestHash: 'b'.repeat(64), loadingComplete: { allowed: true }, pallets: [{ warehousePalletId: '100', palletNo: context.palletCode, scheduledPackages: 2, pickedPackages: 2, - ...(fault === 'missing-measurements' ? {} : { palletSpaces: 2, weightKg: 37, volumeCbm: 0.7, deliveryIndex: 1, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] }) + deliveryIndex: 1, ...(fault === 'missing-measurements' ? {} : { palletSpaces: 1, weightKg: 37, volumeCbm: 0.7, evidence: [['palletSpaces', 2], ['weightKg', 37], ['volumeCbm', 0.7]].map(([field, value]) => ({ field, value, actual: true, sourceType: 'MEASURED', sourceBusinessNo: 'EX-NEW', observedAt: '2026-10-19T02:00:00Z' })) }) }] }) if (method === 'GET' && normalized.endsWith('/delivery-arrangement')) return envelope({ ...detail(false), cargoHash: 'a'.repeat(64), loadingCompletionHash: 'b'.repeat(64), arrange: { allowed: true }, groups: [{ groupKey: 'GROUP-A', customerId: CUSTOMER, destination, groupCargoHash: 'c'.repeat(64), externalFreightHash: 'd'.repeat(64), feeCoverageCargoHash: 'c'.repeat(64), blockReasons: [], requiredServices: ['DELIVERY_FEE'], cargo: [{ ...nativeItem(), packages: 2 }] }] }) if (method === 'GET' && normalized === `${DISPATCH}/${next ? 'DP-NEXT' : 'DP-NEW'}`) return envelope(detail(next)) assert.ok(created, `${method} ${raw}`) @@ -124,7 +126,7 @@ function fixture(fault = '') { assert.equal(body.recordVersion, recordVersion, raw) if (/\/(submit-review|dispatch-review|loading-review)$/.test(normalized)) { if (normalized.endsWith('/loading-review')) { review = true; stage = 'LOADING' }; recordVersion++; return remember(command('REVIEW')) } if (normalized.endsWith('/pick')) { assert.equal(token, 'private-pda-token'); picked = true; recordVersion++; return remember(command('PICK', { packages: 2 })) } - if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: body.measurements } })) } + if (normalized.endsWith('/loading-complete')) { assert.equal(picked, true); stage = 'DELIVERING'; arranged = true; departed = true; recordVersion++; return remember(command('LOADING_COMPLETE', { confirmedLoad: { manifestHash: body.manifestHash, pallets: [{ warehousePalletId: '100', palletSpaces: 1, weightKg: fault === 'missing-measurements' ? null : 37, volumeCbm: fault === 'missing-measurements' ? null : 0.7, deliveryIndex: 1 }] } })) } throw Error(`Unexpected ${method} ${raw}`) } } const dependencies = { wait: async () => {}, async onBeforeRedispatchSignature(proof) { assert.equal(newSigned, false); assert.equal(originalSigned, true); assert.equal(proof.preSignatureGate.status, 'WAITING'); if (fault === 'audit-gate') throw Error('Audit gate not approved'); return { sourceAEventCount: 0, sourceBEventCount: 1, auditEvidenceSha256: 'a'.repeat(64) } } } @@ -133,6 +135,15 @@ function fixture(fault = '') { } const run = f => verifyFleetReturnRedispatch(f.context, 'test', f.trip, f.input, f.dependencies) +test('server-owned loading permits absent optional weight and volume without inventing measurements', async () => { + const f = fixture('missing-measurements') + await run(f) + const request = f.requests.find(item => item.path.endsWith('/loading-complete')) + assert.ok(request) + assert.equal(request.body?.measurements, undefined) + assert.equal(f.trip.returnRedispatch.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED') +}) + test('native one-main multi-source return redispatch executes real APIs through new signature and resource closure', async () => { const f = fixture(), result = await run(f) assert.equal(result.status, 'HTTP_CHAIN_VERIFIED_SOURCE_EVENT_EVIDENCE_REQUIRED'); assert.equal(result.sourceEventsVerified, false); assert.equal(result.readOnlyEvidenceRequired.length, 3) @@ -172,10 +183,9 @@ for (const fault of ['missing-master', 'wrong-delivery', 'wrong-outbound', 'fore for (const mutate of [source => { source.inventoryPositionId = null }, source => { source.returnCartonId = '701' }]) test('ordinary two-box return cannot borrow a source or native carton', async () => { const f = fixture(); mutate(f.relations[0].returnSources[0]); await assert.rejects(run(f), /Selected master must contain/); assert.ok(f.requests.every(r => r.method === 'GET')) }) -for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'missing-measurements', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => { +for (const fault of ['missing-B', 'wrong-return', 'premature-A', 'domestic-premature-A', 'blocked-B', 'domestic-blocked-B', 'root-premature-A', 'wrong-candidate', 'rewritten-original', 'duplicate-ledger', 'borrowed-child', 'child-premature-A', 'root-premature-with-pending']) test(`native chain rejects ${fault}`, async () => { const f = fixture(fault); await assert.rejects(run(f)); assert.equal(f.trip.returnRedispatch.status, 'FAILED') if (fault === 'missing-B') assert.ok(f.requests.every(r => r.method === 'GET')) - if (fault === 'missing-measurements') assert.equal(f.requests.some(r => r.path.endsWith('/loading-complete') || r.path.endsWith('/transfer-delivery')), false) }) test('a shared formal root stays pending for A while child B completes and finishes only after new A signature', async () => { const f = fixture('shared-root'), proof = await run(f) diff --git a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs index 7305f827a..3995a04f4 100644 --- a/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs +++ b/scripts/admin-test-env-modules/fleet-return-redispatch-acceptance.mjs @@ -61,7 +61,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, if (file) entry.file = { name: file.name, type: file.type, sizeBytes: file.content.byteLength, sha256: createHash('sha256').update(file.content).digest('hex') } proof.requests.push(entry); await context.checkpoint() try { - const response = file ? await context.requestMultipart(method, path, file) : await context.request(method, path, body, token, options) + const response = file ? await context.requestMultipart(method, path, file, token) : await context.request(method, path, body, token, options) if (!definiteResponse(response)) { stopped = true; proof.outcomeUnknown = true Object.assign(entry, { status: 'UNKNOWN_RESULT', http: response?.http, code: response?.body?.code, @@ -77,7 +77,7 @@ export async function verifyFleetReturnRedispatch(context, module, trip, input, } catch (error) { if (entry.status === 'SENDING') { stopped = true; proof.outcomeUnknown = true; entry.status = 'UNKNOWN_RESULT'; entry.error = error.message; await context.checkpoint() } throw error } } io.request = (method, path, body, token, options) => invoke(method, path, body, token, null, options) - io.requestMultipart = (method, path, file) => invoke(method, path, undefined, undefined, file) + io.requestMultipart = (method, path, file, token) => invoke(method, path, undefined, token, file) async function request(method, path, body, token) { const response = await io.request(method, path, body, token); check(`HTTP failed: ${method} ${path}`, ok(response)); return response.body.data } diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs index 2dc86911e..d0d3c9e38 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment-contract.test.mjs @@ -1,3 +1,4 @@ +import { LOADING_PHOTO_SLOTS, ensureFleetLoadingDocuments, reuseFleetResources } from './fleet-trip-fulfillment.mjs' import test from 'node:test' import assert from 'node:assert/strict' import { prepareFleetTrip, confirmFleetLoading, controlledPalletMeasurements, finishFleetTrip } from './fleet-trip-fulfillment.mjs' @@ -17,6 +18,69 @@ const receipt = () => ({ environment: 'TEST', marker: 'TEST-NATIVE', dispatchNo: { warehousePalletId: PALLET, palletSpaces: 2, deliveryIndex: 1, evidence: [evidence('palletSpaces', 2)] }, ] }) +for (const initialStage of ['LOADING', 'AFTER_LOADING']) test(`loading documents fill missing slots without replacing existing files from ${initialStage}`, async () => { + const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [], replays = new Map() + const state = { dispatchNo: 'DP-A', recordVersion: '9007199254740993', dispatchRecordVersion: '11', stage: initialStage, + complete: false, editable: true, photos: [{ slot: LOADING_PHOTO_SLOTS[0], fileId: 'existing' }] } + let saved = false + const context = { async checkpoint() { saved = true }, + assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response.body.data }, + async requestMultipart(method, path, file, token) { + assert.ok(saved); saved = false; assert.equal(token, 'PDA-TEST'); assert.equal(method, 'POST') + const slot = path.split('/').at(-2) + assert.match(path, /idempotencyKey=[a-f0-9]{64}$/); assert.equal(file.type, 'image/png') + assert.equal(file.content.subarray(1, 4).toString(), 'PNG'); calls.push(slot) + assert.equal(proof.loadingDocuments.uploads.at(-1).status, 'PREPARED') + return ok({ slot, fileId: `new-${slot}` }) + }, + async request(method, path, body, token) { + assert.equal(token, 'PDA-TEST') + if (method === 'GET') return ok(state) + assert.equal(method, 'PUT') + if (replays.has(body.idempotencyKey)) return ok(replays.get(body.idempotencyKey)) + assert.equal(body.recordVersion, state.recordVersion) + state.photos.push({ slot: path.split('/').at(-1), fileId: body.fileId }) + state.recordVersion = String(BigInt(state.recordVersion) + 1n) + state.complete = state.photos.length === 5 + if (state.complete && initialStage === 'AFTER_LOADING') state.stage = 'DELIVERING' + replays.set(body.idempotencyKey, structuredClone(state)); return ok(state) + } } + await ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST') + assert.deepEqual(calls, LOADING_PHOTO_SLOTS.slice(1)) + assert.equal(state.photos[0].fileId, 'existing') + assert.equal(state.stage, initialStage === 'LOADING' ? 'LOADING' : 'DELIVERING') + assert.equal(proof.loadingDocuments.result.complete, true) +}) + +for (const fault of ['checkpoint', 'upload-unknown', 'wrong-slot', 'replay-drift', 'early-departure']) test(`loading document ${fault} stops before loading confirmation`, async () => { + const proof = { marker: 'TEST-DOCS', dispatchNo: 'DP-A' }, calls = [] + const state = { dispatchNo: 'DP-A', stage: 'LOADING', recordVersion: '1', complete: false, editable: true, photos: [] } + const context = { async checkpoint() { if (fault === 'checkpoint') throw Error('checkpoint failed') }, + assert(_m, label, _r, passed) { assert.ok(passed, label) }, apiSuccess(_m, _l, response) { return response?.body?.data }, + async requestMultipart(_method, path) { + calls.push(path) + if (fault === 'upload-unknown') throw Error('unknown upload') + return ok({ slot: fault === 'wrong-slot' ? 'RECEIPT' : 'LOADING_SCENE', fileId: 'new-file' }) + }, + async request(method, path) { + calls.push(path) + if (method === 'GET') return ok(state) + if (state.photos.length && fault === 'replay-drift') return ok({ ...state, recordVersion: '3' }) + state.photos = [{ slot: 'LOADING_SCENE', fileId: 'new-file' }] + if (fault === 'early-departure') state.stage = 'DELIVERING' + return ok(state) + } } + await assert.rejects(ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST')) + assert.equal(calls.some(path => path.endsWith('/loading-complete')), false) + if (fault === 'checkpoint') assert.equal(calls.length, 1) + if (fault === 'upload-unknown') { + assert.equal(proof.loadingDocuments.uploads[0].status, 'UNKNOWN') + const sent = calls.filter(path => path.includes('/uploads?')).length + await assert.rejects(ensureFleetLoadingDocuments(context, 'docs', proof, 'PDA-TEST'), /不自动重发/) + assert.equal(calls.filter(path => path.includes('/uploads?')).length, sent) + } +}) + test('real Jackson long counts accept safe decimal strings without changing pallet IDs', () => { const expected = controlledPalletMeasurements([pallet()], stops, receipt().pallets) for (const counts of [{ scheduledPackages: '3', pickedPackages: '3' }, { scheduledPackages: '3', pickedPackages: 3 }]) { @@ -37,7 +101,7 @@ function server(fault = '') { counts: { packageCount: 3, pickedPackageCount: 0, shippedPackageCount: 0 }, reviews: {}, publishLooseboardDemand: false, dispatchInfo: { estimatedArrivalAt: '2026-10-21T10:00:00Z', fbaAppointments: [{ appointmentNo: 'FBA-ORIGINAL' }] }, vehicleInfo: {} } const trip = { tripNo: 'TR-A', status: 'PLANNED', warehouseId: '92', recordVersion: 4, tripPurpose: 'DELIVERY', stops } - let driverVersion = 0, uploadComplete = false, driverReviewed = false, nextPlate = '', checkpointError = false, nextTrip, nextDetail + let driverVersion = 0, uploadComplete = false, driverReviewed = fault === 'fixed-master', nextPlate = fault === 'fixed-master' ? 'TEST-EXISTING' : '', checkpointError = false, nextTrip, nextDetail let mutatedItems = false, mutatedLoading = false const claims = new Map([['VEHICLE', 'TR-A'], ['DRIVER', 'TR-A']]) const bump = () => detail.recordVersion = String(BigInt(detail.recordVersion) + 1n) @@ -57,6 +121,9 @@ function server(fault = '') { }, async request(method, path, body, token) { calls.push({ method, path, body: structuredClone(body), actor: token ? 'PDA' : 'PLATFORM' }) + if (method === 'GET' && path.endsWith('/loading-documents')) return ok({ dispatchNo: 'DP-A', stage: 'LOADING', complete: true, photos: LOADING_PHOTO_SLOTS.map((slot, index) => ({ slot, fileId: String(index + 1) })) }) + if (method === 'GET' && path.endsWith(`/master-data/vehicles/${VID}`)) return ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) + if (method === 'GET' && path.includes(`/fleet/drivers/${DRIVER}?`)) return ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } }) if (fault === 'unknown-edit' && method === 'PUT' && path === D) throw new Error('transport result unknown') if (path.endsWith('/master-data/vehicles')) { assert.equal(body.status, 'IN_SERVICE'); assert.match(body.remark, /TEST/); nextPlate = body.plateNumber.toUpperCase() @@ -232,6 +299,48 @@ test('native preparation edits existing master then submits both reviews, discov assert.ok(s.assertions.every(a => a.passed)) }) +test('fixed customer prepares a real trip without creating vehicles, drivers or uploading licenses', async () => { + const s = server('fixed-master') + s.context.options = { fixedCustomerId: '123', fixedFleetResources: [{ vehicleId: VID, driverId: DRIVER }] } + const proof = await prepare(s) + assert.equal(proof.reusedMasterData, true) + assert.equal(proof.preparation, 'VERIFIED') + assert.equal(proof.vehicleId, VID) + assert.equal(proof.driverId, DRIVER) + assert.equal(s.calls.some(call => call.file), false) + assert.ok(s.calls.filter(call => call.method !== 'GET').every(call => call.path.startsWith(D))) +}) + +test('fixed fleet resources block missing, locally occupied and ineligible pairs without master writes', async () => { + const pair = { vehicleId: VID, driverId: DRIVER } + for (const fault of ['missing', 'occupied', 'vehicle-status', 'driver-qualification']) { + const proof = {}, calls = [] + const context = { options: { fixedFleetResources: fault === 'missing' ? [] : [pair] }, + createdResources: { fleetTrips: fault === 'occupied' ? [{ ...pair }] : [proof] }, + async request(method, path) { + calls.push(path); assert.equal(method, 'GET') + return path.includes('/vehicles/') ? ok({ id: VID, status: fault === 'vehicle-status' ? 'DISPATCHED' : 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) + : ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: fault !== 'driver-qualification', reason: 'ELIGIBLE' } }) + }, apiSuccess(_m, _l, response) { return response.body.data } } + await assert.rejects(reuseFleetResources(context, 'test', proof), /固定车辆司机/) + if (['missing', 'occupied'].includes(fault)) assert.equal(calls.length, 0) + } +}) + +test('verified resource closure makes a fixed pair available again', async () => { + const proof = {} + const context = { options: { fixedFleetResources: [{ vehicleId: VID, driverId: DRIVER }] }, + createdResources: { fleetTrips: [{ vehicleId: VID, driverId: DRIVER, closure: { status: 'CARGO_COMPLETED_AND_RESOURCES_RELEASED' } }, proof] }, + async request(method, path) { + assert.equal(method, 'GET') + return path.includes('/vehicles/') ? ok({ id: VID, status: 'IN_SERVICE', region: 'OVERSEAS', ownership: 'OWNED' }) + : ok({ id: DRIVER, active: true, reviewStatus: 'APPROVED', details: { ownership: 'OWNED' }, qualification: { canDispatch: true, reason: 'ELIGIBLE' } }) + }, apiSuccess(_m, _l, response) { return response.body.data } } + await reuseFleetResources(context, 'test', proof) + assert.equal(proof.vehicleId, VID) + assert.equal(proof.driverId, DRIVER) +}) + test('zero-scan LOADING must explicitly return to review before editing and pass a new review round', async () => { const s = server(); s.detail.stage = 'LOADING'; s.detail.reviewState = 'APPROVED' const proof = await prepare(s) @@ -253,7 +362,7 @@ test('authoritative uppercase vehicle plate discovers both the prepared and subs assert.equal(proof.tripNo, 'TR-A') s.trip.status = 'IN_TRANSIT' const closure = await finishFleetTrip(s.context, 'native', proof) - assert.equal(closure.nextDispatch.marker, marker); assert.equal(closure.nextDispatch.vehicle, undefined) + assert.equal(closure.nextDispatch.marker, marker); assert.deepEqual(closure.nextDispatch.vehicle, proof.vehicle) assert.equal(closure.nextDispatch.tripNo, 'TR-NEXT'); assert.equal(closure.nextDispatch.finalStatus, 'CANCELLED') const queries = s.calls.filter(c => c.path.startsWith('/api/platform/v1/last-leg/fleet/trips?')) assert.equal(queries.length, 2) diff --git a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs index 59e4eca41..c87cb5cba 100644 --- a/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs +++ b/scripts/admin-test-env-modules/fleet-trip-fulfillment.mjs @@ -43,14 +43,14 @@ async function command(context, module, method, path, body, label, token, expect return data } -// 排车公开详情未提供tripNo,按本轮唯一新车牌分页,再核对真实资源及唯一绑定。 +// 按审核后权威车辆快照分页,再核对真实资源及本轮排车唯一绑定。 async function discoverTrip(context, module, proof) { - // 正式车辆主档将车牌转为大写;保留原marker及命令键,仅按该权威规则生成精确查询身份。 - const vehiclePlate = proof.marker.toUpperCase() + const vehiclePlate = proof.vehicle?.plate + if (typeof vehiclePlate !== 'string' || !vehiclePlate.trim()) throw Error('审核后的车辆快照缺少真实车牌') const rows = [], seen = new Set(); let total for (let page = 1; ; page++) { const query = new URLSearchParams({ page: String(page), size: '200', keyword: vehiclePlate, warehouseId: proof.warehouseId }) - const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按本轮唯一车牌读取自动资源记录') + const data = await command(context, module, 'GET', `${BASE}/trips?${query}`, undefined, '按实际车牌读取自动资源记录') if (!Array.isArray(data.records) || !Number.isSafeInteger(Number(data.total)) || Number(data.total) < 0 || Number(data.page) !== page || Number(data.size) !== 200) throw new Error('车次分页不完整,不能证明唯一关联') total ??= Number(data.total) @@ -79,7 +79,25 @@ async function discoverTrip(context, module, proof) { Object.assign(proof, { tripNo: detail.trip.tripNo, trip: detail.trip, stops: detail.trip.stops, fleetRecordVersion: detail.trip.recordVersion }) } -/** 本轮TEST主档经正式API建立;主单创建时已自动建立Fleet资源记录。 */ +export async function reuseFleetResources(context, module, proof, arrival) { + const pairs = context.options.fixedFleetResources + const busy = (context.createdResources.fleetTrips || []).filter(item => item !== proof + && item.closure?.status !== 'CARGO_COMPLETED_AND_RESOURCES_RELEASED') + const pair = Array.isArray(pairs) && pairs.find(item => /^[1-9][0-9]*$/.test(id(item?.vehicleId)) + && /^[1-9][0-9]*$/.test(id(item?.driverId)) + && !busy.some(trip => id(trip.vehicleId) === id(item.vehicleId) || id(trip.driverId) === id(item.driverId))) + if (!pair) throw Error('缺少本轮可用的固定车辆司机组合;禁止自动新建主档或证照') + proof.expectedDriverEndAt = new Date(Math.max(Date.now() + 48 * 3600000, arrival ? Date.parse(arrival) + 6 * 3600000 : 0)).toISOString() + const vehicle = await command(context, module, 'GET', `/api/platform/v1/last-leg/master-data/vehicles/${id(pair.vehicleId)}`, undefined, '核对固定车辆状态') + const driver = await command(context, module, 'GET', `${BASE}/drivers/${id(pair.driverId)}?expectedEnd=${encodeURIComponent(proof.expectedDriverEndAt)}`, undefined, '核对固定司机至任务结束的资格') + if (id(vehicle.id) !== id(pair.vehicleId) || vehicle.status !== 'IN_SERVICE' || vehicle.region !== 'OVERSEAS' || vehicle.ownership !== 'OWNED' + || id(driver.id) !== id(pair.driverId) || driver.active !== true || driver.reviewStatus !== 'APPROVED' + || driver.details?.ownership !== 'OWNED' || driver.qualification?.canDispatch !== true || driver.qualification?.reason !== 'ELIGIBLE') + throw Error('固定车辆司机状态或资格不符;不创建、启用或修复主档') + Object.assign(proof, { vehicleId: id(vehicle.id), driverId: id(driver.id), reusedMasterData: true }) +} + +/** 固定客户复用已配置主档;非固定生命周期场景才建立专用主档。 */ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) { if (!dispatchNo || !warehouseId) throw new Error('车次验收缺少本轮排车单或实际起运仓') const proof = { dispatchNo, warehouseId: id(warehouseId), marker: `FT-${randomUUID().slice(0, 12)}`, @@ -90,6 +108,9 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) version(before.recordVersion) if (before.dispatchNo !== dispatchNo || before.status !== 'ACTIVE' || safePackageCount(before.counts?.pickedPackageCount) !== 0 || safePackageCount(before.counts?.shippedPackageCount) !== 0) throw new Error('仅本轮有效零扫码排车可准备车辆和审核') + const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort() + const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null + if (context.options?.fixedCustomerId) await reuseFleetResources(context, module, proof, arrival) if (before.stage === 'LOADING') { proof.returnRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'RETURN_TO_REVIEW'), reason: 'TEST零扫码明确退回装车前后调整资源' } await command(context, module, 'POST', `${path}/return-to-review`, proof.returnRequest, '零扫码排车显式退回装车前') @@ -97,6 +118,7 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) } if (before.stage !== 'BEFORE_LOADING' || !['DRAFT', 'DISPATCH_REJECTED', 'LOADING_REJECTED'].includes(before.reviewState)) throw new Error('排车未处于可编辑装车前阶段,不能隐式撤销审核或改派') + if (!context.options?.fixedCustomerId) { const vehicle = await command(context, module, 'POST', '/api/platform/v1/last-leg/master-data/vehicles', { plateNumber: proof.marker, model: 'Box Truck', fuelConsumptionPer100Km: 12.5, mileageKm: 100, region: 'OVERSEAS', ownership: 'OWNED', status: 'IN_SERVICE', maxPalletSpaces: 30, maxLoadKg: 18000, maxVolumeCbm: 90, @@ -104,9 +126,8 @@ export async function prepareFleetTrip(context, module, dispatchNo, warehouseId) }, '建立本轮TEST车辆主档及容量') proof.vehicleId = id(vehicle.id) if (!proof.vehicleId) throw new Error('车辆主档没有返回真实资源ID') - const confirmedTimes = (before.dispatchInfo?.fbaAppointments || []).filter(row => row.status === 'SUCCESS' && row.confirmedAt).map(row => row.confirmedAt).sort() - const arrival = before.dispatchInfo?.estimatedArrivalAt ?? confirmedTimes[0] ?? null await prepareQualifiedDriver(context, module, proof, arrival) + } proof.updateRequest = { recordVersion: before.recordVersion, idempotencyKey: key(proof, 'EDIT'), publishLooseboardDemand: before.publishLooseboardDemand, vehicle: { mode: 'MASTER', vehicleId: proof.vehicleId }, @@ -222,17 +243,69 @@ async function verifyPreDepartureReassignment(context, module, proof) { state.status = 'REJECTED_WITHOUT_MUTATION' } +export const LOADING_PHOTO_SLOTS = ['LOADING_SCENE', 'LOADING_LIST', 'PRE_DEPARTURE_CHECK', 'DRIVER_LICENSE', 'RECEIPT'] + +export async function ensureFleetLoadingDocuments(context, module, proof, token) { + const path = `${token ? PDA : DISPATCH}/${encodeURIComponent(proof.dispatchNo)}/loading-documents` + const read = () => command(context, module, 'GET', path, undefined, '回查本车装车资料', token) + let documents = await read() + const initialStage = documents.stage + const complete = value => LOADING_PHOTO_SLOTS.every(slot => value.photos?.some(photo => photo.slot === slot && id(photo.fileId))) + const validate = value => { + if (value.dispatchNo !== proof.dispatchNo || !['LOADING', 'AFTER_LOADING', 'DELIVERING'].includes(value.stage) + || value.complete !== complete(value)) throw Error('本车装车资料身份、阶段或五框完整性不匹配') + } + validate(documents) + proof.loadingDocuments ??= { syntheticTestMaterials: true, uploads: [] } + for (const slot of LOADING_PHOTO_SLOTS) { + if (documents.photos?.some(photo => photo.slot === slot && id(photo.fileId))) continue + if (!documents.editable || typeof context.requestMultipart !== 'function') throw Error('缺少本车装车照片写入资格或上传能力') + const content = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+aH1kAAAAASUVORK5CYII=', 'base64') + const upload = { slot, path: `${path}/${slot}/uploads?idempotencyKey=${key(proof, `PHOTO_UPLOAD_${slot}`)}`, + name: `${proof.marker}-TEST-${slot}.png`, sha256: createHash('sha256').update(content).digest('hex'), status: 'PREPARED' } + if (proof.loadingDocuments.uploads.some(item => item.slot === slot)) throw Error('该照片上传已开始,保留原键和结果排查,不自动重发') + proof.loadingDocuments.uploads.push(upload); await context.checkpoint?.() + let response + try { response = await context.requestMultipart('POST', upload.path, { name: upload.name, type: 'image/png', content }, token) } + catch (error) { upload.status = 'UNKNOWN'; await context.checkpoint?.(); throw error } + Object.assign(upload, { status: response?.http === 200 && response?.body?.code ? 'RESPONDED' : 'UNKNOWN', + http: response?.http, code: response?.body?.code, requestId: response?.body?.requestId, traceId: response?.body?.traceId }) + await context.checkpoint?.() + const photo = context.apiSuccess(module, '上传本轮TEST合成装车照片', response, '', OPTIONS) + if (response?.http !== 200 || String(response?.body?.code) !== '200' || photo?.slot !== slot || !id(photo.fileId)) throw Error('装车照片上传未确认,停止绑定及发车') + const body = { recordVersion: documents.recordVersion, idempotencyKey: key(proof, `PHOTO_BIND_${slot}`), fileId: id(photo.fileId) } + const bound = await command(context, module, 'PUT', `${path}/${slot}`, body, '保存本车装车照片', token) + validate(bound) + check(context, module, '绑定回执保留本槽文件', bound.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId))) + const replay = await command(context, module, 'PUT', `${path}/${slot}`, body, '原键重放装车照片绑定', token) + check(context, module, '照片绑定重放恢复原回执', JSON.stringify(replay) === JSON.stringify(bound)) + documents = await read(); validate(documents) + check(context, module, '装车资料未齐不得提前发车', documents.stage === (initialStage === 'AFTER_LOADING' && documents.complete ? 'DELIVERING' : initialStage)) + check(context, module, '装车照片已持久化', documents.photos.some(item => item.slot === slot && id(item.fileId) === id(photo.fileId))) + } + check(context, module, '五个装车照片框均已保存', documents.complete && complete(documents)) + proof.loadingDocuments.result = documents + await context.checkpoint?.() + return documents +} + export async function confirmFleetLoading(context, module, proof, pdaToken, actualMeasurements, beforeComplete) { if (!proof?.dispatchNo || !proof.tripNo) throw new Error('缺少本轮原生排车及唯一资源记录证明') if (proof.loadingCommand) throw new Error('本轮装车命令已开始;保留原请求排查,不自动重试') const path = `${DISPATCH}/${encodeURIComponent(proof.dispatchNo)}`, pdaPath = `${PDA}/${encodeURIComponent(proof.dispatchNo)}` - const preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken) + let preview = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '读取真实整车拣货及实载证据', pdaToken) if (preview.dispatchNo !== proof.dispatchNo || preview.status !== 'ACTIVE' || preview.stage !== 'LOADING' || preview.pickingReconciled !== true || preview.loadingComplete?.allowed !== true || !preview.manifestHash) throw new Error(`整车未就绪:${preview.loadingComplete?.blockReason || '真实拣货未核平或阶段不符'}`) if (actualMeasurements && (actualMeasurements.environment !== 'TEST' || actualMeasurements.marker !== context.marker || actualMeasurements.dispatchNo !== proof.dispatchNo || !Array.isArray(actualMeasurements.pallets))) throw new Error('实测回执不属于本轮TEST排车') const measurements = preview.pallets.map(p => ({ warehousePalletId: id(p.warehousePalletId), palletSpaces: 1, weightKg: p.weightKg ?? null, volumeCbm: p.volumeCbm ?? null, deliveryIndex: p.deliveryIndex })) await verifyPreDepartureReassignment(context, module, proof) + await ensureFleetLoadingDocuments(context, module, proof, pdaToken) + const refreshed = await command(context, module, 'GET', `${pdaToken ? pdaPath : path}/loading`, undefined, '照片保存后回查装车资格', pdaToken) + check(context, module, '资料保存不提前装车或改变实载清单', refreshed.dispatchNo === proof.dispatchNo + && refreshed.status === 'ACTIVE' && refreshed.stage === 'LOADING' && refreshed.pickingReconciled === true + && refreshed.loadingComplete?.allowed === true && refreshed.manifestHash === preview.manifestHash) + preview = refreshed proof.loadingPreview = preview; proof.loadingMeasurementReceipt = actualMeasurements proof.loadingCommand = { path: `${pdaToken ? pdaPath : path}/loading-complete`, body: { recordVersion: preview.recordVersion, idempotencyKey: key(proof, 'LOADING_COMPLETE'), manifestHash: preview.manifestHash, remark: 'TEST本轮实际读取测量证据,不使用计费重量或固定值' } } @@ -310,7 +383,7 @@ export async function finishFleetTrip(context, module, proof) { state.nextDispatchResult = await command(context, module, 'POST', DISPATCH, state.nextDispatchRequest, '建立本轮原生空排车占用已结束资源') if (!state.nextDispatchResult.dispatchNo || state.nextDispatchResult.dispatchNo === proof.dispatchNo) throw new Error('原生空排车未返回独立真实业务号') state.nextDispatch = { dispatchNo: state.nextDispatchResult.dispatchNo, warehouseId: proof.warehouseId, - marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId } + marker: proof.marker, vehicleId: proof.vehicleId, driverId: proof.driverId, vehicle: proof.vehicle } await discoverTrip(context, module, state.nextDispatch) if (state.nextDispatch.tripNo === proof.tripNo) throw new Error('新空排车未建立独立资源记录') await occupancy('VEHICLE', state.nextDispatch.tripNo); await occupancy('DRIVER', state.nextDispatch.tripNo) diff --git a/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs b/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs index 5bc28b9d8..b6cddf355 100644 --- a/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-loading-complete-contract.test.mjs @@ -16,7 +16,7 @@ test('manual loading-complete modules are optional and require a source report', const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value', '--modules', module, '--manual-loading-complete-source-report', 'source.json']) - assert.deepEqual(options.modules, [module]) + assert.deepEqual(options.modules, ['merchant-qualification-fixture', module]) assert.equal(validateExecutionOptions(options), options) assert.throws(() => validateExecutionOptions({ ...options, manualLoadingCompleteSourceReport: '' }), /manual-loading-complete-source-report/) diff --git a/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs b/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs index c81b839ce..5b81f174b 100644 --- a/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-order-splitting-contract.test.mjs @@ -34,7 +34,7 @@ test('fixture defaults to six orders while verifier requires an explicit report '--modules', 'manual-order-splitting-verify', '--manual-split-fixture-report', 'fixture.json', '--manual-split-plan-id', '123', ]) - assert.deepEqual(verifyOptions.modules, ['manual-order-splitting-verify']) + assert.deepEqual(verifyOptions.modules, ['merchant-qualification-fixture', 'manual-order-splitting-verify']) assert.equal(validateExecutionOptions(verifyOptions), verifyOptions) assert.throws(() => validateExecutionOptions({ ...verifyOptions, manualSplitPlanId: '' }), /manual-split-plan-id/) diff --git a/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs b/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs index fdb36948e..4a0bdaef9 100644 --- a/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-pda-batch-contract.test.mjs @@ -15,7 +15,7 @@ test('manual PDA batch modules are optional and require a source report', () => for (const module of ['manual-pda-batch-fixture', 'manual-pda-batch-verify']) { const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value', '--modules', module, '--manual-pda-batch-source-report', 'source.json']) - assert.deepEqual(options.modules, [module]) + assert.deepEqual(options.modules, ['merchant-qualification-fixture', module]) assert.equal(validateExecutionOptions(options), options) assert.throws(() => validateExecutionOptions({ ...options, manualPdaBatchSourceReport: '' }), /manual-pda-batch-source-report/) diff --git a/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs b/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs index 2cd64f7ca..867835111 100644 --- a/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-pda-ready-contract.test.mjs @@ -15,7 +15,7 @@ test('manual PDA-ready modules are optional and require a source report', () => for (const module of ['manual-pda-ready-fixture', 'manual-pda-ready-verify']) { const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value', '--modules', module, '--manual-pda-source-report', 'source.json']) - assert.deepEqual(options.modules, [module]) + assert.deepEqual(options.modules, ['merchant-qualification-fixture', module]) assert.equal(validateExecutionOptions(options), options) assert.throws(() => validateExecutionOptions({ ...options, manualPdaSourceReport: '' }), /manual-pda-source-report/) diff --git a/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs b/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs index 0c4d507cc..0bb8ff975 100644 --- a/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs +++ b/scripts/admin-test-env-modules/manual-preload-push-contract.test.mjs @@ -15,7 +15,7 @@ test('manual preload modules are optional and require an explicit source report' for (const module of ['manual-preload-push-fixture', 'manual-preload-push-verify']) { const options = parseArguments(['--gateway', 'http://127.0.0.1:9000', '--username', 'admin', '--password', 'password-value', '--modules', module, '--manual-preload-source-report', 'source.json']) - assert.deepEqual(options.modules, [module]) + assert.deepEqual(options.modules, ['merchant-qualification-fixture', module]) assert.equal(validateExecutionOptions(options), options) assert.throws(() => validateExecutionOptions({ ...options, manualPreloadSourceReport: '' }), /manual-preload-source-report/) diff --git a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs index 0ed2513be..4cf29c7ed 100644 --- a/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/merchant-qualification-fixture-contract.test.mjs @@ -86,7 +86,7 @@ test('fixed resource runner excludes lifecycle handlers without marking them pas test('both request entrypoints reject customer creation before network access', async () => { const runner = new AcceptanceRun({ environment: 'test' }) for (const method of ['request', 'requestWithBody']) { - for (const path of ['/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) { + for (const path of ['/api/auth/register', '/api/auth/register/?source=test', '/api/platform/v1/iam/tenants', '/api/platform/v1/iam/tenants/123/archive', '/api/platform/v1/iam/tenants/123/submit-review']) { await assert.rejects(runner[method]('POST', path, {}), /禁止创建、归档客户/) } } diff --git a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs index 19fd5a923..d7d522aa7 100644 --- a/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs +++ b/scripts/admin-test-env-modules/merged-overseas-coverage-contract.test.mjs @@ -45,12 +45,13 @@ test('draft and removal checks use actual IDs plus readback rather than snapshot assert.ok(source.includes(value), value) }) -test('fulfillment checks PDF signature, platform replay and immutable pallets after transfer', async () => { +test('fulfillment checks PDF signature, automatic documents and immutable stock around loading replay', async () => { const source = await read('phase5-overseas-fulfillment.mjs') - for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", '/transfer-delivery', - 'replay.data?.replayed', 'JSON.stringify(replay.data.result) === JSON.stringify(result)', + for (const value of ['requestBinary', '/prints/bol', "=== '%PDF-'", 'await readAutomaticDispatchDelivery(', + 'value?.documents?.deliveries', 'value?.documents?.outbounds', 'await verifyAutomaticDeparture(', 'sameSnapshot(proof.stockAfter, proof.stockAfterDepart)', 'sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart)']) assert.ok(source.includes(value), value) + assert.doesNotMatch(source, /\/transfer-delivery|\/delivery-arrangement|\/depart`/) const helper = await read('fleet-trip-fulfillment.mjs') for (const value of ['state.itemsBefore', 'JSON.stringify(state.itemsBefore) === JSON.stringify(state.itemsAfter)', 'proof.loadingCommand.replay = replay', 'proof.loadingCommand.platformRejection = await command', diff --git a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs index 80d5557a9..83651295e 100644 --- a/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-complete-cross-service-recovery-contract.test.mjs @@ -33,7 +33,8 @@ test('offline orchestration model distinguishes processing replay recovery and c test('POST complete is reliable acceptance only and returns PROCESSING/COMPLETING', () => { const start = loadingPickService.indexOf('public Task complete(') - const end = loadingPickService.indexOf('/** Called only by the completion worker', start) + const end = loadingPickService.indexOf('public void processCompletion(', start) + assert.ok(start >= 0 && end > start) const block = loadingPickService.slice(start, end) assert.match(block, /LoadingCompletionCommandEntity/) assert.match(block, /acceptCompletion/) diff --git a/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs b/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs index a560e1757..412cb274d 100644 --- a/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs +++ b/scripts/admin-test-env-modules/pda-picker-fixture-contract.test.mjs @@ -1,6 +1,88 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { pickerFixtureSuffix } from './phase4-overseas-pda.mjs' +import { pickerFixtureSuffix, createPicker, disablePicker, pickerSessionHandoff } from './phase4-overseas-pda.mjs' +import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs' + +test('fixed PDA actors are leased separately and only logged out, never modified', async () => { + const actors = ['11', '12'].map(membershipId => ({ membershipId, warehouseId: '91', username: `test-${membershipId}`, password: 'fixture-only' })) + const calls = [], gaps = [] + const context = { options: { fixedCustomerId: '101', fixedPdaActors: actors }, + async request(method, path, body) { + calls.push({ method, path }) + let data = {} + if (path.includes('/iam/users?')) data = { total: 2, records: actors.map(actor => ({ ...actor, + overseasWarehouseId: '91', tenantId: '0', status: 'ACTIVE', roleIds: ['7'] })) } + if (path === '/api/auth/login') data = { accessToken: body.loginId, refreshToken: `refresh-${body.loginId}` } + return { http: 200, body: { code: '200', data } } + }, + apiSuccess(_module, _name, result) { return result.body.data }, addStep() {}, + addCoverageGap(...args) { gaps.push(args) }, + } + const primary = await createPicker(context, 1, '91', { role: { id: '7' } }) + const secondary = await createPicker(context, 2, '91', { role: { id: '7' } }) + assert.notEqual(primary.membershipId, secondary.membershipId) + assert.equal(await createPicker(context, 3, '91', { role: { id: '7' } }), null) + const handoff = pickerSessionHandoff(primary) + assert.equal(handoff.reused, true) + assert.equal(handoff.password, undefined) + await verifyPdaWarehouseRevocation(context, handoff) + assert.equal(gaps.length, 1) + await disablePicker(context, handoff, 1) + await disablePicker(context, primary, 1) + await disablePicker(context, secondary, 2) + assert.equal(context.fixedPdaLeases.size, 0) + assert.equal(calls.filter(call => call.path === '/api/auth/logout').length, 2) + assert.ok(calls.every(call => call.method === 'GET' || ['/api/auth/login', '/api/auth/logout'].includes(call.path))) +}) + +test('missing fixed PDA pool stops before network or phone allocation', async () => { + const context = { options: { fixedCustomerId: '101' }, addStep() {}, + request() { throw Error('unexpected HTTP') }, findAvailablePhone() { throw Error('unexpected allocation') } } + assert.equal(await createPicker(context, 1, '91', { role: { id: '7' } }), null) +}) + +test('failed PDA projection checks logout and retains leases on rejected or uncertain logout', async () => { + for (const logout of ['success', 'rejected', 'throws']) { + const actor = { membershipId: '11', warehouseId: '91', username: 'fixture-actor', password: 'fixture-only' } + const steps = [], calls = [] + const context = { options: { fixedCustomerId: '101', fixedPdaActors: [actor] }, + async request(method, path) { + calls.push(path) + if (path.includes('/iam/users?')) return { body: { data: { records: [{ ...actor, + overseasWarehouseId: '91', tenantId: '0', status: 'ACTIVE', roleIds: ['7'] }] } } } + if (path === '/api/auth/login') return { body: { data: { accessToken: 'fixture-token', refreshToken: 'fixture-refresh' } } } + if (path === '/api/auth/logout' && logout === 'throws') throw Error('network failure') + return { http: 200, body: { code: path === '/api/auth/logout' && logout === 'success' ? '200' : 'A0403' } } + }, + apiSuccess(_module, _name, response) { return response.body.data }, + addStep(...args) { steps.push(args) }, + } + assert.equal(await createPicker(context, 1, '91', { role: { id: '7' } }), null) + assert.equal(calls.filter(path => path === '/api/auth/logout').length, 1) + assert.equal(context.fixedPdaLeases.has('11'), logout !== 'success') + assert.ok(steps.some(step => step[1].includes('核验失败') && step[3] === false)) + if (logout !== 'success') { + assert.ok(steps.some(step => step[1].includes('退出固定PDA') && step[3] === false)) + const before = calls.length + assert.equal(await createPicker(context, 2, '91', { role: { id: '7' } }), null) + assert.equal(calls.length, before) + } + } +}) + +test('logout exception does not prevent remaining fixed actors from being cleaned up', async () => { + const calls = [] + const context = { fixedPdaLeases: new Set(['11', '12']), addStep() {}, + async request(_method, _path, body) { + calls.push(body.refreshToken) + if (body.refreshToken === 'first') throw Error('network failure') + return { http: 200, body: { code: '200' } } + } } + await disablePicker(context, { membershipId: '11', refreshToken: 'first', reused: true }, 1) + await disablePicker(context, { membershipId: '12', refreshToken: 'second', reused: true }, 2) + assert.deepEqual(calls, ['first', 'second']) + assert.deepEqual([...context.fixedPdaLeases], ['11']) +}) test('长场景尾部相同时PDA账号仍保留完整运行标识的区别', () => { const first = { marker: 'ADMIN-API-20261005134027677-COMPLETED-F40-PDA' } diff --git a/scripts/admin-test-env-modules/phase3-customs.mjs b/scripts/admin-test-env-modules/phase3-customs.mjs index f86f0ee96..daa625d01 100644 --- a/scripts/admin-test-env-modules/phase3-customs.mjs +++ b/scripts/admin-test-env-modules/phase3-customs.mjs @@ -367,6 +367,13 @@ export async function run(context) { approved?.customsCase?.status === 'READY_TO_SUBMIT' && Number(approved?.customsCase?.version) === 4, `status=${approved?.customsCase?.status || 'missing'}, version=${approved?.customsCase?.version ?? 'missing'}`, OPTIONS) + let customsAgentId + if (context.options?.fixedCustomerId) { + const { loadSupplier } = await import('./finance-full-fixtures.mjs') + const existing = await loadSupplier(context, 'CUSTOMS') + if (!existing) throw new Error('缺少现有报关代理,不自动创建供应商') + customsAgentId = id(existing.id) + } else { const supplierCreateResponse = await context.request('POST', '/api/platform/v1/suppliers?tenantId=0', { name: `报关代理-${context.marker}`.slice(0, 128), category: 'CUSTOMS', region: '深圳', legalRepresentative: '验收法人', contactName: '报关联系人', phone: '13800000000', @@ -374,15 +381,16 @@ export async function run(context) { businessLicenseNo: `CUSTOMS${context.marker.replace(/\D/g, '')}`.slice(0, 64), licenseFileId: null, status: 'ACTIVE', settlementPeriod: 'MONTHLY_30', remark: context.marker, }) - const customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商', + customsAgentId = id(context.apiSuccess(MODULE, '创建本轮正式 ACTIVE 报关代理供应商', supplierCreateResponse, `marker=${context.marker}`, OPTIONS)) + } if (!customsAgentId) return const supplierDetailResponse = await context.request('GET', `/api/platform/v1/suppliers/${encodeURIComponent(customsAgentId)}?tenantId=0`) const customsAgent = context.apiSuccess(MODULE, '回查本轮报关代理供应商', supplierDetailResponse, '', OPTIONS) context.assert(MODULE, '代理提交使用本轮正式 CUSTOMS 供应商身份', supplierDetailResponse, id(customsAgent?.id) === customsAgentId && customsAgent?.status === 'ACTIVE' - && customsAgent?.category === 'CUSTOMS' && customsAgent?.remark === context.marker, + && customsAgent?.category === 'CUSTOMS' && (context.options?.fixedCustomerId || customsAgent?.remark === context.marker), `customsAgentId=${id(customsAgent?.id) || 'missing'}, customsAgentName=${customsAgent?.name || 'missing'}`, OPTIONS) if (id(customsAgent?.id) !== customsAgentId || customsAgent?.status !== 'ACTIVE' || customsAgent?.category !== 'CUSTOMS') return diff --git a/scripts/admin-test-env-modules/phase3-loading.mjs b/scripts/admin-test-env-modules/phase3-loading.mjs index 86fc02d2f..283ab7ca6 100644 --- a/scripts/admin-test-env-modules/phase3-loading.mjs +++ b/scripts/admin-test-env-modules/phase3-loading.mjs @@ -42,6 +42,12 @@ function failure(response) { function containerRecord(value) { return value?.container } async function ensureMatsonSupplier(context, records) { + if (context.options?.fixedCustomerId) { + const { loadSupplier } = await import('./finance-full-fixtures.mjs') + const supplier = await loadSupplier(context, 'BOOKING', { carrierCode: 'MATSON' }) + if (!supplier) throw new Error('缺少启用的 MATSON 订舱供应商,不创建或启用主档') + return supplier + } const existing = records.find((value) => value?.id && value?.name === MATSON_SUPPLIER_NAME) if (existing?.status === 'ACTIVE') return existing if (existing?.id) { diff --git a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs index 27d6a21de..3314e0e6d 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business-contract.test.mjs @@ -3,10 +3,40 @@ import assert from 'node:assert/strict' import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' -import { run } from './phase4-overseas-business.mjs' +import { run, createPartnerWarehouse, retirePartnerWarehouse } from './phase4-overseas-business.mjs' + +test('fixed partner warehouse is reused and never retired', async () => { + const calls = [] + const context = { options: { fixedCustomerId: '123' }, marker: 'RUN', createdResources: {}, + async request(method, path) { calls.push({ method, path }); return { body: { data: [{ warehouseId: '456' }] } } }, + apiSuccess(_module, _name, response) { return response.body.data }, + } + const warehouse = await createPartnerWarehouse(context) + assert.equal(warehouse.id, '456') + await retirePartnerWarehouse(context, warehouse) + assert.equal(calls.length, 1) + assert.equal(calls[0].method, 'GET') +}) const here = path.dirname(fileURLToPath(import.meta.url)) const source = fs.readFileSync(path.join(here, 'phase4-overseas-business.mjs'), 'utf8') + +test('fixed customer beyond first page is found without creating or retiring a partner warehouse', async () => { + const context = fixture(), request = context.request.bind(context), pages = [] + context.options = { fixedCustomerId: '10001' } + context.request = async (method, path, ...args) => { + if (path.startsWith('/api/platform/v1/iam/tenants?')) { + pages.push(path) + if (path.includes('page=1&')) return response({ ...page([{ id: '9999', status: 'ACTIVE' }]), size: 200, total: 201, pages: 2 }) + return response(page([{ id: '10001', tenantName: '客户甲', status: 'ACTIVE' }])) + } + return request(method, path, ...args) + } + await run(context) + assert.equal(pages.length, 2) + assert.ok(context.createdResources.phase4OverseasBusiness.purchase) + assert.ok(context.calls.filter(call => call.path.includes('/warehouse-admin')).every(call => call.method === 'GET')) +}) function response(data, code = '200') { return { http: 200, body: { code, message: code === '200' ? 'success' : 'conflict', data }, requestId: `request-${code}`, traceId: `trace-${code}` } diff --git a/scripts/admin-test-env-modules/phase4-overseas-business.mjs b/scripts/admin-test-env-modules/phase4-overseas-business.mjs index a8f628708..672ac8024 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-business.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-business.mjs @@ -64,7 +64,15 @@ function partnerWarehousePayload(context) { } } -async function createPartnerWarehouse(context) { +export async function createPartnerWarehouse(context) { + if (context.options?.fixedCustomerId) { + const result = await overseas(context, 'GET', '查询可复用采购合作仓', `${BASE}/overseas-procurements/warehouse-options`) + const candidate = Array.isArray(result.data) ? result.data.find(item => longId(item?.warehouseId)) : null + if (!candidate) return blocker(context, '缺少可复用采购合作仓', result.response, '不自动创建仓库') + const resource = { marker: context.marker, id: id(candidate.warehouseId), reused: true, finalStatus: 'ACTIVE', retained: true } + ownedRegistry(context).partnerWarehouse = resource + return resource + } const created = await domestic(context, 'POST', '创建本轮采购合作仓', '/api/platform/v1/warehouse-admin', partnerWarehousePayload(context)) const warehouseId = id(created.data) const valid = longId(warehouseId) @@ -140,7 +148,14 @@ async function prerequisites(context, session) { if (!activeCustomers.length) return blocker(context, '海外业务写验收 ACTIVE 客户前置', tenants.response, 'IAM 未返回真实 ACTIVE 客户,禁止跳过完整生命周期') const fixtureCustomerId = id(context.createdResources?.merchantQualificationFixture?.tenantId) - const activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId) + let activeCustomer = activeCustomers.find((item) => id(item?.id) === fixtureCustomerId) + const customerPageSize = Number(tenants.data?.size || 200) + for (let page = 2; !activeCustomer && (page - 1) * customerPageSize < Number(tenants.data?.total); page += 1) { + const next = await domesticGet(context, '分页查询固定客户', `/api/platform/v1/iam/tenants?page=${page}&size=200&status=ACTIVE`) + assertPage(context, '固定客户候选', next) + activeCustomer = records(next.data).find(item => id(item?.id) === fixtureCustomerId && item.status === 'ACTIVE') + if (!records(next.data).length) break + } if (!longId(fixtureCustomerId) || !activeCustomer) return blocker(context, '本轮资格客户属于 ACTIVE 候选', tenants.response, `marker fixture 客户 ${fixtureCustomerId || 'missing'} 不在 ACTIVE 候选中,禁止借用其他客户身份`) const token = await loginMerchant(context, activeCustomer, session) @@ -490,8 +505,8 @@ async function looseboardFlow(context, customerId) { } } -async function retirePartnerWarehouse(context, resource) { - if (!resource?.id) return +export async function retirePartnerWarehouse(context, resource) { + if (!resource?.id || resource.reused) return const retired = await context.request('PATCH', `/api/platform/v1/warehouse-admin/${encodeURIComponent(resource.id)}/status?tenantId=0&status=INACTIVE`) const passed = retired?.http === 200 && codeOf(retired) === '200' diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs index 1076661f4..1a1850046 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region-contract.test.mjs @@ -7,6 +7,21 @@ import { run, selectCurrentInboundPlan } from './phase4-overseas-finance-cross-r const source = readFileSync(fileURLToPath(new URL('./phase4-overseas-finance-cross-region.mjs', import.meta.url)), 'utf8') +test('current plan on second page is used instead of historical first-page plans', async () => { + const context = fixture(), request = context.request.bind(context), pages = [] + context.request = async (method, path, ...args) => { + if (path.startsWith('/api/platform/v1/last-leg/inbound-plans?')) { + pages.push(path) + if (path.includes('page=1&')) return response({ records: [{ planId: '9999', planNo: 'OLD', customerId: '2001' }], size: 200, total: 201 }) + } + return request(method, path, ...args) + } + await run(context) + assert.equal(pages.length, 2) + assert.ok(context.steps.find(step => step.name === '海外入仓费用关联真实计划')?.passed) + assert.equal(context.calls.some(call => call.method === 'POST' && call.body?.businessId === '9999'), false) +}) + test('inbound fees cannot select historical plans or another customer under a fixed account', () => { const current = { planId: '4001', planNo: 'CURRENT', customerId: '2001' } const old = { planId: '3999', planNo: 'OLD', customerId: '2001' } diff --git a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs index 3d2aa93f7..da30aae88 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-finance-cross-region.mjs @@ -81,9 +81,17 @@ async function resolveFixtures(context) { const inboundCustomerId = id(pda?.customerId) let inbound = null if (longId(inboundCustomerId)) { - const listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划', + let listed = await overseas(context, 'GET', '查询本轮客户海外入仓计划', `/api/platform/v1/last-leg/inbound-plans?page=1&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`) - const plan = selectCurrentInboundPlan(listed.data, pda, context.marker) + let plan = selectCurrentInboundPlan(listed.data, pda, context.marker) + const total = Number(listed.data?.total || 0) + const pageSize = Number(listed.data?.size || 200) + for (let page = 2; !plan && (page - 1) * pageSize < total; page += 1) { + listed = await overseas(context, 'GET', '分页查询本轮入仓计划', + `/api/platform/v1/last-leg/inbound-plans?page=${page}&size=200&customerId=${encodeURIComponent(inboundCustomerId)}`) + plan = selectCurrentInboundPlan(listed.data, pda, context.marker) + if (!rows(listed.data).length) break + } context.assert(MODULE, '海外入仓费用关联真实计划', listed.response, Boolean(plan) && longId(plan?.planId || plan?.id), `planId=${id(plan?.planId || plan?.id) || 'missing'}, planNo=${plan?.planNo || 'missing'}; ${trace(listed.response)}`, OPTIONS) diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs index ad085d33a..16f76a0b0 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations-contract.test.mjs @@ -5,7 +5,24 @@ import test from 'node:test' import { fileURLToPath } from 'node:url' import { normalizeModuleOrder } from '../admin-test-env-acceptance.mjs' -import { run, remainingRelabelItem, selectPhysicalRelabelSources } from './phase4-overseas-pda-operations.mjs' +import { run, remainingRelabelItem, selectPhysicalRelabelSources, fundRelabelWallet, RELABEL_CHARGE_AMOUNT } from './phase4-overseas-pda-operations.mjs' + +test('fixed customer relabel wallet only reads existing available funds', async () => { + assert.equal(RELABEL_CHARGE_AMOUNT, 0.01) + for (const availableBalance of ['0.01', '0.00', undefined]) { + const calls = [], steps = [] + const context = { options: { fixedCustomerId: '123' }, + async request(method, path) { + calls.push({ method, path }) + return { http: 200, body: { code: '200', data: [{ customerId: '123', accountVersion: 1, actualBalance: 100, availableBalance }] } } + }, + addStep(...args) { steps.push(args) }, + } + assert.equal(await fundRelabelWallet(context, '123'), availableBalance === '0.01') + assert.equal(calls.length, 1) + assert.equal(calls[0].method, 'GET') + } +}) test('relabel uses three physical cartons across current inbounds, never UNBOUND or duplicate positions', () => { const a = { inventoryBalanceId: 'A', inboundOrderId: '1', positions: [ @@ -69,7 +86,7 @@ test('P2 to P5 cover all frozen endpoints, replays and conflict boundaries witho ]) assert.ok(source.includes(endpoint), endpoint) for (const marker of ['按原数计数', '同键重放', '同键异载荷A0409', '旧版本A0409', 'SUBMITTED', '移回原库位', 'WAIT_REVIEW', 'REWORK', 'COMPLETED', - 'chargeAmount: 0.01', 'cartonMark:', 'fbaCartonMark:', + 'chargeAmount: RELABEL_CHARGE_AMOUNT', 'cartonMark:', 'fbaCartonMark:', 'inventory-candidates', 'sourceInventoryBalanceId:', 'sourceInventoryPositionId:', 'sourceInboundOrderId:', 'operationWarehouseId:', 'relabelItemForUnit', 'sourceLines.map', 'sourceLines.push({ source, position, packageCount: 1 })', 'oldLabel: item.cartonMark', diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs index dcd59091e..1a33f830f 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda-operations.mjs @@ -2,6 +2,7 @@ import { verifyReceivedOrderHold } from './phase4-tail-order-controls.mjs' import { verifyPdaWarehouseRevocation } from './unified-pda-session-revocation.mjs' const MODULE = 'phase4-overseas-pda-operations' +export const RELABEL_CHARGE_AMOUNT = 0.01 const SCENARIO = 'phase4-overseas-pda-p2-p5' const OPTIONS = { scenario: SCENARIO, deploymentBlocker: true } const PNG = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64')) @@ -63,7 +64,7 @@ async function poll(action, predicate, attempts = 60) { return { result, attempts } } -async function fundRelabelWallet(context, customerId) { +export async function fundRelabelWallet(context, customerId) { const accountResponse = await context.request('GET', `/api/platform/finance/v1/accounts?page=1&pageSize=20&customerId=${encodeURIComponent(customerId)}`) const account = success(accountResponse) @@ -71,6 +72,13 @@ async function fundRelabelWallet(context, customerId) { context.addStep(MODULE, 'P5读取本轮客户钱包版本', accountResponse, Boolean(account) && Number.isInteger(Number(account.accountVersion)), trace(accountResponse), OPTIONS) if (!account) return false + if (context.options?.fixedCustomerId) { + const sufficient = account.availableBalance != null && Number.isFinite(Number(account.availableBalance)) + && Number(account.availableBalance) >= RELABEL_CHARGE_AMOUNT + context.addStep(MODULE, '复用已有可用余额;不足时禁止自动充值', accountResponse, + sufficient, `availableBalance=${account.availableBalance ?? 'missing'}`, OPTIONS) + return sufficient + } const recharge = await context.request('POST', `/api/platform/finance/v1/accounts/${encodeURIComponent(customerId)}/adjustments`, { type: 'RECHARGE', amount: 1, occurredDate: new Date().toISOString().slice(0, 10), @@ -415,7 +423,7 @@ async function verifyRelabel(context, token, merchantToken, fixture) { const createBody = { customerOrderNo: String(customerOrderNo).slice(0, 128), operationWarehouseId: id(fixture.overseasWarehouseId), labelDeadline: new Date(Date.now() + 7 * 86400000).toISOString().slice(0, 19), - contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: 0.01, remark: context.marker, + contactName: 'PDA换标验收', contactPhone: '13800000006', chargeAmount: RELABEL_CHARGE_AMOUNT, remark: context.marker, idempotencyKey: key(context, 'RELABEL-CREATE'), deliveryMethod: 'TRUCK', printShippingLabel: false, items: sourceLines.map(({ source, position, packageCount }, index) => { diff --git a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs index a829c9abc..e0ed7c23a 100644 --- a/scripts/admin-test-env-modules/phase4-overseas-pda.mjs +++ b/scripts/admin-test-env-modules/phase4-overseas-pda.mjs @@ -316,7 +316,47 @@ export async function resolvePickerAccess(context) { return { role, operate } } +async function reusePicker(context, index, overseasWarehouseId, access) { + const actors = context.options.fixedPdaActors + context.fixedPdaLeases ??= new Set() + const actor = Array.isArray(actors) && actors.find(item => id(item?.warehouseId) === id(overseasWarehouseId) + && longId(item.membershipId) && nonBlank(item.username) && nonBlank(item.password) + && !context.fixedPdaLeases.has(id(item.membershipId))) + if (!actor) { + context.addStep(MODULE, `固定PDA操作员${index}前置`, null, false, '缺少该仓未占用的固定账号,禁止自动创建', OPTIONS) + return null + } + let token, refreshToken + try { + let user + for (let page = 1; ; page++) { + const result = await context.request('GET', `/api/platform/v1/iam/users?page=${page}&size=100&keyword=${encodeURIComponent(actor.username)}`) + const data = context.apiSuccess(MODULE, '核对固定PDA账号', result, '', OPTIONS) + user = data?.records?.find(item => id(item.membershipId) === id(actor.membershipId) && item.username === actor.username) + if (user || !data?.records?.length || page * Number(data.size || 100) >= Number(data.total || 0)) break + } + if (user?.status !== 'ACTIVE' || id(user.tenantId) !== '0' + || id(user.overseasWarehouseId) !== id(overseasWarehouseId) + || !user.roleIds?.map(id).includes(id(access.role.id))) throw Error('固定PDA账号状态、角色或仓绑定不匹配') + const login = await context.request('POST', '/api/auth/login', { loginId: actor.username, password: actor.password }, '') + const session = context.apiSuccess(MODULE, '登录固定PDA账号', login, '', OPTIONS) + token = session?.accessToken; refreshToken = session?.refreshToken + if (token || refreshToken) context.fixedPdaLeases.add(id(actor.membershipId)) + if (!token || !refreshToken) throw Error('固定PDA登录未返回完整会话') + const projection = await context.request('GET', '/api/pda/v1/last-leg/receiving-containers/current', undefined, token, {}) + if (!success(projection) && !notFound(projection)) throw Error('固定PDA作业权限检查失败') + return { membershipId: id(actor.membershipId), username: actor.username, token, refreshToken, reused: true } + } catch (error) { + if (token && refreshToken) await disablePicker(context, { + membershipId: id(actor.membershipId), token, refreshToken, reused: true, + }, index) + context.addStep(MODULE, `固定PDA操作员${index}核验失败`, null, false, error.message, OPTIONS) + return null + } +} + export async function createPicker(context, index, overseasWarehouseId, access) { + if (context.options?.fixedCustomerId) return reusePicker(context, index, overseasWarehouseId, access) const role = access.role const suffix = pickerFixtureSuffix(context) const username = `pda_overseas_${suffix.toLowerCase()}_${index}`.slice(0, 64) @@ -359,13 +399,26 @@ export async function createPicker(context, index, overseasWarehouseId, access) export function pickerSessionHandoff(picker) { if (!picker) return null - const { membershipId, username, token, refreshToken, password, userUpdate } = picker - return { membershipId, username, token, refreshToken, password, + const { membershipId, username, token, refreshToken, password, userUpdate, reused } = picker + return { membershipId, username, token, refreshToken, password, reused, userUpdate: userUpdate ? { ...userUpdate, roleIds: [...userUpdate.roleIds] } : undefined } } export async function disablePicker(context, picker, index) { if (!longId(picker?.membershipId)) return + if (picker.reused) { + if (!context.fixedPdaLeases?.has(id(picker.membershipId))) return + let response + try { + response = await context.request('POST', '/api/auth/logout', { refreshToken: picker.refreshToken }, picker.token) + } catch { + context.addStep(MODULE, `退出固定PDA操作员${index}会话失败`, null, false, '退出请求异常;保留账号占用,继续清理其他会话', OPTIONS) + return + } + context.addStep(MODULE, `退出固定PDA操作员${index}会话,不停用账号`, response, success(response), trace(response), OPTIONS) + if (success(response)) context.fixedPdaLeases.delete(id(picker.membershipId)) + return + } const response = await context.request('PUT', `/api/platform/v1/iam/users/${picker.membershipId}/status`, { status: 'DISABLED' }) const disabled = success(response) && response.body?.data?.status === 'DISABLED' context.addStep(MODULE, `finally停用海外PDA仓库操作员${index}`, response, disabled, trace(response), OPTIONS) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs index bf99f7a13..534fff44e 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment-contract.test.mjs @@ -1,7 +1,7 @@ import test from 'node:test' -import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyDepartureReadiness, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, +import { relabelOutboundMarksMatch, inventoryQuantitySnapshot, inventorySnapshot, dispatchInventorySources, inventorySourcesPresent, dispatchStockConservation, verifyAutomaticDeparture, verifyPartialOrderCompletion, verifyFulfillmentRootStatus, createDeliveryReturn, completeReturnedException, returnedExceptionFactsMatch, verifyCustomerSignedDocuments, createSignedDocumentIsolationCustomer, - dispatchDestinationConservation, pickAll, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' + dispatchDestinationConservation, pickAll, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' import assert from 'node:assert/strict' import { readFile, mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -64,21 +64,20 @@ test('phase-five fulfillment is a registered default HTTP module after the real test('native fulfillment uses real public DTOs and scans before loading, arranging and departing', () => { for (const token of ["const DISPATCH_BASE = '/api/platform/v1/last-leg/dispatches'", - "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", '/transfer-delivery', '/delivery-arrangement', - 'cargoHash: value.cargoHash', 'loadingCompletionHash: value.loadingCompletionHash', + "const PDA_DISPATCH_BASE = '/api/pda/v1/last-leg/dispatches'", 'value?.documents?.deliveries', 'value?.documents?.outbounds', 'recordVersion: version(detail.data.recordVersion)', 'positionId: id', - 'departed.data.result?.shippedPackages', 'outboundRef.outboundNo']) { + 'ready.data.counts?.shippedPackageCount', 'outboundRef.outboundNo']) { if (token === 'positionId: id') assert.match(source, /positionId: source.position \? id\(source.position.positionId\)/) else assert.ok(source.includes(token), token) } assert.doesNotMatch(source, /READY_TO_DEPART|PENDING_DEPARTURE|DISPATCH-COMPLETE|\/items\/.*['"]DELETE['"]/) - assert.match(source, /data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING'/) - assert.match(source, /departed.data.stage === 'DELIVERING'/) + assert.match(source, /data.status === 'ACTIVE' && data.stage === 'DELIVERING'/) + assert.doesNotMatch(source, /\/transfer-delivery|\/delivery-arrangement|\/depart`/) const run = source.slice(source.indexOf('export async function run')) assert.ok(run.indexOf('prepareFleetTrip(') < run.indexOf('await pickAll(')) assert.ok(run.indexOf('await pickAll(') < run.indexOf('await confirmFleetLoading(')) - assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await arrangeDispatchDelivery(')) - assert.ok(run.indexOf('await arrangeDispatchDelivery(') < run.indexOf('await verifyDepartureReadiness(')) + assert.ok(run.indexOf('await confirmFleetLoading(') < run.indexOf('await readAutomaticDispatchDelivery(')) + assert.ok(run.indexOf('await readAutomaticDispatchDelivery(') < run.indexOf('await verifyAutomaticDeparture(')) assert.ok(run.indexOf('全部客户正常签收汇总整车DELIVERED') < run.indexOf('await finishFleetTrip(')) assert.ok(run.indexOf('await finishFleetTrip(') < run.indexOf('const returnRequest')) }) @@ -133,9 +132,8 @@ test('phase-five module uses marker-owned fixtures and always disables its tempo }) test('phase-five writes carry idempotency and record deployment blockers instead of passing empty fixtures', () => { - assert.match(source, /idempotencyKey: key\(context, 'TRANSFER', dispatchNo\)/) assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-PICK'/) - assert.match(source, /idempotencyKey: key\(context, 'DISPATCH-DEPART', dispatchNo\)/) + assert.doesNotMatch(source, /DISPATCH-DEPART/) assert.match(source, /addCoverageGap\(MODULE, SCENARIO/) assert.match(source, /deploymentBlocker: true/) }) @@ -497,13 +495,13 @@ test('inventory evidence captures zero drift independently for every balance buc }) test('departure requires independent matching native stage, shared version, reconciled picks and both action gates', async () => { - const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740993', - pickingReconciled: true, actions: { DEPART: { allowed: true } } } - for (const change of [null, { stage: 'LOADING' }, { stage: 'DELIVERING' }, { status: 'CANCELLED' }, + const valid = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '9007199254740993', + pickingReconciled: true, counts: { packageCount: 2, shippedPackageCount: 2 } } + for (const change of [null, { stage: 'LOADING' }, { stage: 'AFTER_LOADING' }, { status: 'CANCELLED' }, { dispatchNo: 'OTHER' }, { recordVersion: '9007199254740994' }, { pickingReconciled: false }, - { actions: { DEPART: { allowed: false } } }]) { + { counts: { packageCount: 2, shippedPackageCount: 1 } }]) { const context = contextFor((method, path) => ok(path.startsWith('/api/pda/') ? valid : { ...valid, ...change })) - assert.equal(Boolean(await verifyDepartureReadiness(context, 'PDA', 'DS-1')), change == null) + assert.equal(Boolean(await verifyAutomaticDeparture(context, 'PDA', 'DS-1')), change == null) assert.equal(context.calls.length, 2) assert.equal(context.calls.some(call => call.method !== 'GET'), false) } @@ -787,50 +785,56 @@ test('native PDA scans use exact current versions including zero, immutable repl assert.deepEqual(context.assertions.filter(row => !row.passed), []) }) -function nativeArrangementPreview(recordVersion = '0') { - return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'AFTER_LOADING', cargoHash: 'CARGO', - loadingCompletionHash: 'LOAD', arrange: { allowed: true }, groups: ['100', '200'].map(customerId => ({ - groupKey: `GROUP-${customerId}`, customerId, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, - groupCargoHash: `GROUP-HASH-${customerId}`, externalFreightHash: 'FREIGHT', feeCoverageCargoHash: 'COVERAGE', - requiredServices: ['DELIVERY_FEE'], feeSources: [], cargo: [{ customerId, packages: 1 }], blockReasons: [] })) } +function automaticDispatch(recordVersion = '0') { + return { dispatchNo: 'DS-1', recordVersion, status: 'ACTIVE', stage: 'DELIVERING', pickingReconciled: true, + counts: { packageCount: 2, pickedPackageCount: 2, shippedPackageCount: 2 }, + documents: { outbounds: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, status: 'OUTBOUNDED', packageCount: 1 })), + deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, deliveryNo: `DL-${customerId}`, + destination: { key: 'FBA:ONT8' }, status: 'DELIVERING', packageCount: 1 })) } } } - -test('native arrangement carries frozen group and cost hashes, preserves all customer outbounds, and replays the exact original command', async () => { +test('automatic delivery reader preserves customer grouping and performs no writes', async () => { for (const recordVersion of ['0', '9007199254740993']) { - const preview = nativeArrangementPreview(recordVersion) - let original, result - const context = contextFor((method, path, body) => { - if (method === 'GET') { assert.match(path, /delivery-arrangement$/); return ok(preview) } - assert.match(path, /transfer-delivery$/) - if (original) { assert.deepEqual(body, original); return ok({ ...result, replayed: true }) } - original = structuredClone(body) - assert.equal(body.recordVersion, recordVersion) - assert.equal(body.cargoHash, 'CARGO'); assert.equal(body.loadingCompletionHash, 'LOAD') - assert.deepEqual(body.groups.map(row => row.groupKey), ['GROUP-100', 'GROUP-200']) - assert.ok(body.groups.every(row => row.externalFreightHash === 'FREIGHT' && row.feeCoverageCargoHash === 'COVERAGE')) - result = { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'AFTER_LOADING', recordVersion: '9007199254740994', - result: { dispatchNo: 'DS-1', cargoHash: 'CARGO', outbounds: ['100', '200'].map(customerId => ({ - customerId, outboundNo: `OB-${customerId}`, status: 'WAIT_OUTBOUND', packageCount: 1 })), - deliveries: ['100', '200'].map(customerId => ({ customerId, outboundNo: `OB-${customerId}`, - deliveryNo: `DL-${customerId}`, destination: { key: 'FBA:ONT8', warehouseCode: 'ONT8' }, status: 'WAIT_DELIVERY', packageCount: 1 })) } } - return ok(result) - }) - const arranged = await arrangeDispatchDelivery(context, 'DS-1') - assert.deepEqual(arranged.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200']) - assert.equal(context.calls.filter(call => call.method === 'POST').length, 2) - assert.deepEqual(context.assertions.filter(row => !row.passed), []) + const current = automaticDispatch(recordVersion) + const context = contextFor((method, path) => { assert.equal(method, 'GET'); assert.ok(path.endsWith('/DS-1')); return ok(current) }) + const result = await readAutomaticDispatchDelivery(context, 'DS-1') + assert.equal(result.data.recordVersion, recordVersion) + assert.deepEqual(result.outbounds.map(row => row.outboundNo), ['OB-100', 'OB-200']) + assert.equal(context.calls.length, 1) + } +}) +test('automatic delivery reader rejects incomplete departure and customer or quantity drift', async () => { + for (const mutate of [v => { v.stage = 'AFTER_LOADING' }, v => { v.status = 'CANCELLED' }, + v => { v.documents.deliveries[0].customerId = '999' }, v => { v.documents.outbounds[0].packageCount = 2 }, + v => { v.documents.deliveries = [] }, v => { v.documents.deliveries[1].deliveryNo = 'DL-100' }, + v => { v.counts.shippedPackageCount = 1 }, v => { v.documents.outbounds[0].status = 'WAIT_OUTBOUND' }]) { + const current = automaticDispatch(); mutate(current) + const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) }) + assert.equal(await readAutomaticDispatchDelivery(context, 'DS-1'), null) + assert.ok(context.assertions.some(row => !row.passed)) } }) -test('arrangement cannot run before loading or with missing hashes, groups or server gate', async () => { - for (const mutate of [value => { value.stage = 'LOADING' }, value => { value.status = 'CANCELLED' }, - value => { value.cargoHash = '' }, value => { value.loadingCompletionHash = '' }, - value => { value.groups = [] }, value => { value.arrange.allowed = false }, - value => { value.groups[0].blockReasons = ['MISSING_FEE'] }]) { - const preview = nativeArrangementPreview(); mutate(preview) - const context = contextFor((method) => { assert.equal(method, 'GET'); return ok(preview) }) - assert.equal(await arrangeDispatchDelivery(context, 'DS-1'), null) - assert.ok(context.assertions.some(row => !row.passed)) +test('automatic delivery cannot hide duplicated customer or destination groups behind conserved totals', async () => { + for (const sameOutbound of [false, true]) { + const current = automaticDispatch() + current.documents.outbounds[1].customerId = '100' + current.documents.deliveries[1].customerId = '100' + if (sameOutbound) { + current.documents.outbounds.pop() + current.documents.outbounds[0].packageCount = 2 + current.documents.deliveries[1].outboundNo = 'OB-100' + } else current.documents.deliveries[1].destination.key = 'FBA:ONT9' + const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) }) + assert.equal(await readAutomaticDispatchDelivery(context, 'DS-1'), null) + } +}) + +test('automatic delivery accepts canonical Long quantities and rejects coercible non-quantities', async () => { + for (const quantity of ['1', true, '01', '1e0', ' 1 ', '9007199254740993']) { + const current = automaticDispatch() + current.documents.deliveries[0].packageCount = quantity + const context = contextFor(method => { assert.equal(method, 'GET'); return ok(current) }) + assert.equal(Boolean(await readAutomaticDispatchDelivery(context, 'DS-1')), quantity === '1') } }) diff --git a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs index 57a746812..0c3df14f8 100644 --- a/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-overseas-fulfillment.mjs @@ -133,14 +133,6 @@ async function merchant(context, token, method, name, path, body = undefined) { return { response, data } } -function transferDeliveries(data) { - return Array.isArray(data?.result?.deliveries) ? data.result.deliveries.filter((value) => nonBlank(value?.deliveryNo)) : [] -} - -function deliveryNumbers(data) { - return transferDeliveries(data).map((value) => id(value.deliveryNo)).sort() -} - export function dispatchDestinationConservation(detail, fixture, deliveries = null) { const codes = values => values.map(value => id(value).trim().toUpperCase()) const sameSet = (left,right) => left.length > 0 && right.length > 0 && left.every(nonBlank) @@ -858,7 +850,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseI const proof = await prepareFleetTrip(context, MODULE, relabel.dispatchNo, warehouseId) if (!await pickAll(context, token, relabel.dispatchNo)) return await confirmFleetLoading(context, MODULE, proof, token, relabel.loadingMeasurements) - const transferred = await arrangeDispatchDelivery(context, relabel.dispatchNo) + const transferred = await readAutomaticDispatchDelivery(context, relabel.dispatchNo) if (!transferred) return const customerId = id(relabel.customerId) const outboundNo = transferred.outbounds.find(row => id(row.customerId) === customerId)?.outboundNo @@ -869,7 +861,7 @@ async function verifyRelabelDispatchOutbound(context, relabel, token, warehouseI success(detail.response) && detail.data?.outbound?.outboundNo === outboundNo && detail.data?.outbound?.dispatchNo === relabel.dispatchNo && relabelOutboundMarksMatch(detail.data, relabel), trace(detail.response), OPTIONS) - Object.assign(relabel, { outboundNo, deliveryNos: deliveryNumbers(transferred.data) }) + Object.assign(relabel, { outboundNo, deliveryNos: transferred.deliveries.map(row => row.deliveryNo).sort() }) } async function loginMerchant(context, customerId) { @@ -909,17 +901,18 @@ function fixture(context) { return { dispatch, receiving } } -export async function verifyDepartureReadiness(context, token, dispatchNo) { - const ready = await pda(context, token, 'GET', '发车前回查PDA公共动作门禁', +export async function verifyAutomaticDeparture(context, token, dispatchNo) { + const ready = await pda(context, token, 'GET', '回查PDA自动发车结果', `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) - const dispatch = await platform(context, 'GET', '发车前独立回查实际排车阶段', + const dispatch = await platform(context, 'GET', '独立回查平台自动发车结果', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) const passed = success(ready.response) && success(dispatch.response) && [ready.data, dispatch.data].every(data => data?.dispatchNo === dispatchNo - && data.status === 'ACTIVE' && data.stage === 'AFTER_LOADING' - && data.pickingReconciled === true && data.actions?.DEPART?.allowed === true) + && data.status === 'ACTIVE' && data.stage === 'DELIVERING' + && data.pickingReconciled === true && Number(data.counts?.packageCount) > 0 + && Number(data.counts?.shippedPackageCount) === Number(data.counts?.packageCount)) && String(ready.data.recordVersion) === String(dispatch.data.recordVersion) - context.assert(MODULE, '双端同版本装车后且服务端发车门禁通过', ready.response, + context.assert(MODULE, '双端自动发车阶段与版本一致', ready.response, passed, `stage=${ready.data?.stage}, dispatchStage=${dispatch.data?.stage}; ${trace(ready.response)}`, OPTIONS) if (passed) version(ready.data.recordVersion) return passed ? ready : null @@ -983,56 +976,30 @@ export async function pickAll(context, token, dispatchNo) { return passed } -export async function arrangeDispatchDelivery(context, dispatchNo) { - const path = `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}` - const preview = await platform(context, 'GET', '装车后读取全车真实派送分组与费用证据', `${path}/delivery-arrangement`) - const value = preview.data - if (!success(preview.response) || value?.dispatchNo !== dispatchNo || value.status !== 'ACTIVE' - || value.stage !== 'AFTER_LOADING' || value.arrange?.allowed !== true - || !nonBlank(value.cargoHash) || !nonBlank(value.loadingCompletionHash) - || !value.groups?.length || value.groups.some(group => !nonBlank(group.groupKey) - || !nonBlank(id(group.customerId)) || !nonBlank(group.destination?.key) - || !group.cargo?.length || !Array.isArray(group.blockReasons) || group.blockReasons.length)) { - blocker(context, '全车派送安排', '装车后正式预览或完整客户目的地分组门禁未满足', preview.response) - return null - } - const body = { recordVersion: version(value.recordVersion), idempotencyKey: key(context, 'TRANSFER', dispatchNo), - cargoHash: value.cargoHash, loadingCompletionHash: value.loadingCompletionHash, - groups: value.groups.map((group, index) => ({ groupKey: group.groupKey, groupCargoHash: group.groupCargoHash, - plannedDeliveryAt: new Date(Date.now() + 172_800_000).toISOString(), - deliveryMethod: 'TRUCK_PALLET', waybillNo: `${context.marker}-${index + 1}`.slice(0, 64), - carrierName: '本轮TEST排车', pickupMethod: 'WAREHOUSE_PICKUP', loadingType: 'PALLET', - vehicleType: 'TRUCK', vehicleSize: '按本轮真实实载', appointmentAccount: 'SELF', sellerName: null, - remark: `${context.marker}-phase5-arrangement`, externalFreightHash: group.externalFreightHash, - feeCoverageCargoHash: group.feeCoverageCargoHash, feeCoverage: [], - fees: (group.requiredServices || []).map(service => ({ feeType: service, feeName: service, - direction: 'RECEIVABLE', currency: 'USD', amount: 10, remark: `${context.marker}-controlled-test-fee` })) })) } - const transferred = await platform(context, 'POST', '平台一次安排全车全部客户目的地派送', `${path}/transfer-delivery`, body) - if (!success(transferred.response)) return null - const result = transferred.data?.result - const deliveries = transferDeliveries(transferred.data) - const outbounds = result?.outbounds - const customerGroups = value.groups.map(group => `${id(group.customerId)}:${group.destination.key}`).sort() - const actualGroups = deliveries.map(row => `${id(row.customerId)}:${row.destination?.key}`).sort() - const passed = transferred.data?.dispatchNo === dispatchNo && transferred.data?.status === 'ACTIVE' - && transferred.data?.stage === 'AFTER_LOADING' && result?.dispatchNo === dispatchNo - && result.cargoHash === body.cargoHash && deliveries.length === value.groups.length +export async function readAutomaticDispatchDelivery(context, dispatchNo) { + const current = await platform(context, 'GET', '回查装车后自动派送单据', `${DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}`) + const value = current.data, deliveries = value?.documents?.deliveries, outbounds = value?.documents?.outbounds + const positive = count => (typeof count === 'number' || typeof count === 'string') + && /^[1-9][0-9]*$/.test(String(count)) && Number.isSafeInteger(Number(count)) + const passed = success(current.response) && value?.dispatchNo === dispatchNo && value.status === 'ACTIVE' + && value.stage === 'DELIVERING' && value.pickingReconciled === true && positive(value.counts?.packageCount) + && positive(value.counts.pickedPackageCount) && positive(value.counts.shippedPackageCount) + && Number(value.counts.pickedPackageCount) === Number(value.counts.packageCount) + && Number(value.counts.shippedPackageCount) === Number(value.counts.packageCount) + && Array.isArray(deliveries) && deliveries.length > 0 && Array.isArray(outbounds) && outbounds.length > 0 && new Set(deliveries.map(row => row.deliveryNo)).size === deliveries.length - && JSON.stringify(customerGroups) === JSON.stringify(actualGroups) - && Array.isArray(outbounds) && outbounds.length === new Set(value.groups.map(group => id(group.customerId))).size - && outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'WAIT_OUTBOUND' - && deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).every(delivery => id(delivery.customerId) === id(row.customerId)) + && new Set(outbounds.map(row => row.outboundNo)).size === outbounds.length + && new Set(outbounds.map(row => id(row.customerId))).size === outbounds.length + && new Set(deliveries.map(row => JSON.stringify([id(row.customerId), row.destination?.key]))).size === deliveries.length + && deliveries.every(row => nonBlank(row.deliveryNo) && nonBlank(id(row.customerId)) && nonBlank(row.destination?.key) + && row.status === 'DELIVERING' && positive(row.packageCount) + && outbounds.some(outbound => outbound.outboundNo === row.outboundNo && id(outbound.customerId) === id(row.customerId))) + && outbounds.every(row => nonBlank(row.outboundNo) && row.status === 'OUTBOUNDED' && positive(row.packageCount) && deliveries.filter(delivery => delivery.outboundNo === row.outboundNo).reduce((sum, delivery) => sum + Number(delivery.packageCount), 0) === Number(row.packageCount)) - && deliveries.every(row => row.status === 'WAIT_DELIVERY') - context.assert(MODULE, '派送按客户出库及客户目的地完整分组并数量守恒', transferred.response, passed, trace(transferred.response), OPTIONS) + && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === Number(value.counts.packageCount) + context.assert(MODULE, '自动派送客户归属、单据状态和实发数量守恒', current.response, passed, trace(current.response), OPTIONS) if (!passed) return null - const replay = await platform(context, 'POST', '全车安排派送原命令重放', `${path}/transfer-delivery`, body) - const stable = success(replay.response) && replay.data?.replayed === true - && String(replay.data.recordVersion) === String(transferred.data.recordVersion) - && JSON.stringify(replay.data.result) === JSON.stringify(result) - context.assert(MODULE, '安排重放返回原命令结果且不增单据', replay.response, stable, trace(replay.response), OPTIONS) - if (!stable) return null - return { ...transferred, preview: value, outbounds, deliveries } + return { ...current, outbounds, deliveries } } /** 只从本次派送的真实主单及其原出库来源创建退回请求,不要求普通入仓关联。 */ @@ -1146,40 +1113,26 @@ export async function run(context) { const primaryInventory = proofs.find(proof => proof.customerId === customerId) if (primaryInventory) context.createdResources.phase5OverseasFulfillment.inventoryProof = { ...primaryInventory, ...primaryInventory.conservation } await confirmFleetLoading(context, MODULE, tripProof, picker.token, source.dispatch.loadingMeasurements) - transferred = await arrangeDispatchDelivery(context, dispatchNo) + transferred = await readAutomaticDispatchDelivery(context, dispatchNo) if (!transferred) return deliveries = transferred.deliveries; outbounds = transferred.outbounds routeProof.deliveries = deliveries context.assert(MODULE, '转派送客户目的地与本轮冻结来源守恒', transferred.response, dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries), trace(transferred.response), OPTIONS) if (!dispatchDestinationConservation(beforeEdit.data,source.dispatch,deliveries)) return - const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) + const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo) if (!ready) return - const departBody = { recordVersion: version(ready.data.recordVersion), - idempotencyKey: key(context, 'DISPATCH-DEPART', dispatchNo).slice(0, 128), remark: `${context.marker}-depart` } - const departed = await pda(context, picker.token, 'POST', 'PDA实际发车并完成真实出库', - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) const expectedPackages = proofs.reduce((sum, proof) => sum + proof.expectedPackages, 0) - const departedCorrectly = success(departed.response) && departed.data?.dispatchNo === dispatchNo - && departed.data.status === 'ACTIVE' && departed.data.stage === 'DELIVERING' - && Number(departed.data.result?.shippedPackages) === expectedPackages - && JSON.stringify(deliveryNumbers(departed.data)) === JSON.stringify(deliveries.map(row => row.deliveryNo).sort()) - context.assert(MODULE, '实际发车进入DELIVERING且全部客户实发数量守恒', departed.response, departedCorrectly, trace(departed.response), OPTIONS) - if (!departedCorrectly) return - const departReplay = await pda(context, picker.token, 'POST', 'PDA发车原命令重放', - `${PDA_DISPATCH_BASE}/${encodeURIComponent(dispatchNo)}/depart`, departBody) - const replayStable = success(departReplay.response) && departReplay.data?.replayed === true - && String(departReplay.data.recordVersion) === String(departed.data.recordVersion) - && JSON.stringify(departReplay.data.result) === JSON.stringify(departed.data.result) - context.assert(MODULE, 'PDA发车重放保持原结果及公共版本', departReplay.response, replayStable, trace(departReplay.response), OPTIONS) - if (!replayStable) return + if (Number(ready.data.counts?.shippedPackageCount) !== expectedPackages) { + blocker(context, '自动发车数量', '实发数量与本轮扫码来源不一致', ready.response); return + } for (const proof of proofs) { proof.stockAfterDepart = await inventorySnapshot(context, proof.customerId, warehouseId, proof.inboundCodes, `客户${proof.customerId}发车后库存`, proof.sourceBalanceIds) proof.ledgerAfterDepart = await shipmentLedger(context, proof.customerId, dispatchNo, `客户${proof.customerId}发车后SHIP`, 'SHIP', dispatchNo) const unchanged = inventorySourcesPresent(proof.stockAfterDepart, proof.sourcePositionBalances) && sameSnapshot(proof.stockAfter, proof.stockAfterDepart) && sameSnapshot(proof.ledgerAfter, proof.ledgerAfterDepart) - context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', departReplay.response, unchanged, trace(departReplay.response), OPTIONS) + context.assert(MODULE, '发车及重放库存和SHIP零额外扣减', ready.response, unchanged, trace(ready.response), OPTIONS) if (!unchanged) return } // 换标副场景仍核对真实新箱唛快照,执行同一原生装车前置。 diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs index 00d869942..69e8f423a 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment-contract.test.mjs @@ -621,11 +621,11 @@ function nativeFragmentFixture() { async confirmFleetLoading(current, module, proof, token, actual) { events.push('loading-complete'); assert.equal(token, 'pda-memory'); assert.equal(actual, measurement) if (!actual) throw new Error('实际测量缺少有效palletSpaces') - return '9007199254740998' + departed = true; proof.loadingCommand = { replay: { replayed: true } }; return '9007199254740998' }, - async arrangeDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery], - outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { result: { deliveries: [delivery] } } } }, - async verifyDepartureReadiness() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999' } } }, + async readAutomaticDispatchDelivery() { events.push('arrange'); return { deliveries: [delivery], + outbounds: [{ customerId: '90', outboundNo: 'OB-F40', packageCount: '1' }], data: { documents: { deliveries: [delivery] } } } }, + async verifyAutomaticDeparture() { events.push('read-current-depart-version'); return { data: { recordVersion: '9007199254740999', stage: 'DELIVERING', counts: { shippedPackageCount: 1 } } } }, async saveReceiptAndComplete(current, actual, customerId, index) { events.push('receipt'); assert.equal(actual, delivery); assert.equal(customerId, '90'); assert.equal(index, 0) signed = true @@ -642,11 +642,11 @@ test('F40 fragment uses native creation, reviews, real PDA scan, explicit loadin assert.equal(result.outboundNo, 'OB-F40'); assert.equal(result.receiptVersion, '9007199254741003') assert.equal(result.signedAt, '2026-10-19T01:00:00') assert.deepEqual(f.events.filter(event => event !== 'checkpoint'), ['evidence', 'create', 'create', 'prepare-review', 'pick', - 'loading-complete', 'arrange', 'read-current-depart-version', 'depart', 'depart', 'receipt', 'normal-completed', 'finish']) + 'loading-complete', 'arrange', 'read-current-depart-version', 'receipt', 'normal-completed', 'finish']) assert.equal(f.calls.some(call => /pre-arrange|\/status|\/api\/merchant\//.test(call.path)), false) assert.equal(f.calls.some(call => call.method === 'PUT'), false) assert.equal(f.rootProof.dispatches[0].stage, 'DELIVERED') - assert.equal(f.rootProof.dispatches[0].departureReplay.replayed, true) + assert.equal(f.rootProof.dispatches[0].loadingReplay.replayed, true) assert.equal(f.context.steps.every(step => step.passed), true) }) diff --git a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs index 57d3ef7bc..650043673 100644 --- a/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs +++ b/scripts/admin-test-env-modules/phase5-root-fulfillment.mjs @@ -4,7 +4,7 @@ import { verifyPalletLabels } from './phase3-loading-finalize.mjs' import { stableLoadedCartonMarks } from './phase3-loading.mjs' import { awaitProducerConsumption, convertReceivingContainers, runReceiving, resolvePickerAccess, createPicker, disablePicker } from './phase4-overseas-pda.mjs' -import { pickAll, verifyDepartureReadiness, saveReceiptAndComplete, arrangeDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' +import { pickAll, verifyAutomaticDeparture, saveReceiptAndComplete, readAutomaticDispatchDelivery, fulfillmentEvidenceContext } from './phase5-overseas-fulfillment.mjs' import { isDeepStrictEqual } from 'node:util' import { assertTestDeliveryFaultProof } from '../test-delivery-fault-proof.mjs' import { prepareTerminalDrayage } from './phase5-terminal-drayage.mjs' @@ -484,11 +484,11 @@ async function fragment(context, source, rootOrderId, sequence) { shippingReady: true, carrierScheduleQueryId: pickup.queryId, carrierLatestActualAt: pickup.latestActualAt, palletLabelManifest, rootOrderId } } -const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, - verifyDepartureReadiness, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } +const F40_DISPATCH_DEPENDENCIES = { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery, + verifyAutomaticDeparture, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } export async function sendFragment(context, producer, picker, merchantToken, index, overrides = {}) { - const { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness, + const { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery, verifyAutomaticDeparture, saveReceiptAndComplete, finishFleetTrip, fulfillmentEvidenceContext } = { ...F40_DISPATCH_DEPENDENCIES, ...overrides } const customerId = id(producer.tenantId), warehouseId = id(picker.warehouseId) context.marker = `${context.context.f40BaseMarker}-SEND-${index}` @@ -529,8 +529,8 @@ export async function sendFragment(context, producer, picker, merchantToken, ind dispatchProof.action = 'LOADING_COMPLETE' const actualMeasurements = await resolveF40LoadingMeasurements(context, dispatchNo, producer, warehouseId) await confirmFleetLoading(context, MODULE, tripProof, picker.token, actualMeasurements) - dispatchProof.action = 'ARRANGE_DELIVERY' - const transfer = await arrangeDispatchDelivery(context, dispatchNo) + dispatchProof.action = 'READ_AUTOMATIC_DELIVERY' + const transfer = await readAutomaticDispatchDelivery(context, dispatchNo) check(context, 'F40 平台装车后一次安排本片完整客户派送', Boolean(transfer), `dispatch=${dispatchNo}`) const deliveries = transfer.deliveries, outbounds = transfer.outbounds check(context, 'F40 同客户同目的仓一片对应唯一出库与派送', deliveries?.length === 1 && outbounds?.length === 1 @@ -538,27 +538,18 @@ export async function sendFragment(context, producer, picker, merchantToken, ind && deliveries[0].outboundNo === outbounds[0].outboundNo && Number(deliveries[0].packageCount) === 1 && deliveries[0].destination?.key === matches[0].destination.key, 'one-actual-customer-destination') const outboundNo = outbounds[0].outboundNo - dispatchProof.arrangement = transfer.data?.result; dispatchProof.outboundNo = outboundNo + dispatchProof.arrangement = transfer.data?.documents; dispatchProof.outboundNo = outboundNo dispatchProof.deliveryNo = deliveries[0].deliveryNo const deliveryPath = query(`/api/platform/v1/last-leg/delivery-orders/${deliveries[0].deliveryNo}`, { customerId }) const detail = await api(context, 'GET', '核对派送实际 SKU 仅来自本片入仓码', deliveryPath) check(context, 'F40 派送实际物品来源和一箱数量严格匹配', id(detail.order?.customerId) === customerId && detail.skus?.length > 0 && detail.skus.every(row => row.inboundCode === producer.inboundCode) && detail.skus.reduce((sum,row) => sum + Number(row.packageCount),0) === 1, `source=${producer.inboundCode}`) - dispatchProof.action = 'PDA_DEPART' - const ready = await verifyDepartureReadiness(context, picker.token, dispatchNo) + dispatchProof.action = 'VERIFY_AUTOMATIC_DEPARTURE' + const ready = await verifyAutomaticDeparture(context, picker.token, dispatchNo) check(context, 'F40 双端当前公共版本发车门禁就绪', Boolean(ready), `dispatch=${dispatchNo}`) - const departBody = { recordVersion: wireVersion(ready.data.recordVersion), - idempotencyKey: `${context.marker}-DEPART`.slice(0,128), remark: context.marker } - const departed = await api(context, 'POST', 'F40 正式 PDA 实际发车', `${PDA}/${dispatchNo}/depart`, departBody, picker.token) - check(context, 'F40 本片原生发车进入DELIVERING并且真实出库一箱', departed.dispatchNo === dispatchNo - && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 1 - && departed.result?.deliveries?.length === 1 && departed.result.deliveries[0].deliveryNo === deliveries[0].deliveryNo, `dispatch=${dispatchNo}`) - const departureReplay = await api(context, 'POST', 'F40 实际发车原命令重放', `${PDA}/${dispatchNo}/depart`, departBody, picker.token) - check(context, 'F40 发车重放保留原公共版本及全部发运结果', departureReplay.replayed === true - && String(departureReplay.recordVersion) === String(departed.recordVersion) - && JSON.stringify(departureReplay.result) === JSON.stringify(departed.result), 'immutable-departure-result') - dispatchProof.departure = departed; dispatchProof.departureReplay = departureReplay; dispatchProof.stage = 'DELIVERING' + check(context, 'F40 自动发车真实出库一箱', Number(ready.data.counts?.shippedPackageCount) === 1, `dispatch=${dispatchNo}`) + dispatchProof.departure = ready.data; dispatchProof.loadingReplay = tripProof.loadingCommand?.replay; dispatchProof.stage = 'DELIVERING' const outbound = await api(context, 'GET', '回查实际来源出库完成', `/api/platform/v1/last-leg/outbound-orders/${outboundNo}`) check(context,'F40 实际出库单 OUTBOUNDED 且来源入仓码匹配', id(outbound.outbound?.customerId) === customerId && outbound.outbound?.outboundNo === outboundNo && outbound.outbound?.status === 'OUTBOUNDED' diff --git a/scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs b/scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs index acc1c72f6..3c1022e93 100644 --- a/scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs +++ b/scripts/admin-test-env-modules/procurement-sample-fulfillment.mjs @@ -1,4 +1,6 @@ const MODULE = 'procurement-sample-fulfillment' +// Peak cash requirement: 1.00 purchase settled plus 2.00 freight frozen. +export const PROCUREMENT_REQUIRED_AVAILABLE_BALANCE = '3.00' const PLATFORM = '/api/platform/v1/last-leg/overseas-procurements' const MERCHANT = '/api/merchant/v1/last-leg/overseas-procurements' const clone = value => value == null ? value : structuredClone(value) @@ -67,7 +69,7 @@ export async function verifyProcurementSample(context, inputs) { } try { proof.walletBefore = clone(await inputs.readWallet()) - await check('客户现金余额足以完成独立样本', cents(proof.walletBefore.availableBalance) >= 500n) + await check('客户现金余额足以完成独立样本', cents(proof.walletBefore.availableBalance) >= cents(PROCUREMENT_REQUIRED_AVAILABLE_BALANCE)) const recipient = { name: '采购样品验收', phone: '13800000002', address: '广东省深圳市南山区科技园测试路1号' } const create = { customerName: inputs.customerName, purchaseCategory: 'SAMPLE', productName: `${context.marker} sample`, productSpecification: 'one-spec', purchaseQuantity: 2, budgetUnitPrice: '1.00', currency: 'CNY', receivingAddress: recipient.address, contactName: recipient.name, contactPhone: recipient.phone, idempotencyKey: key('create') } const created = await request('POST', MERCHANT, create, '客户创建样品订单', inputs.customerToken) diff --git a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs index ea946baa8..cabe61bbc 100644 --- a/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs +++ b/scripts/admin-test-env-modules/tailleg-owner-order-number-contract.mjs @@ -6,9 +6,9 @@ export const IMMUTABLE_TAILLEG_EVENT_FIXTURE = Object.freeze({ }) export const TAILLEG_ORDER_NUMBER = Object.freeze({ - pattern: /^FL-WH-\d{8}-\d{6}$/, - timeZone: 'server-local', - sharedBy: 'containerNo', + pattern: /^WC\d{8}(?!0000)\d{4}$/, + timeZone: 'Asia/Shanghai', + sharedBy: 'sourceContainerBookingId', }) export function validOwnerSnapshots(orders, expectedCount = orders?.length) { @@ -25,10 +25,10 @@ export function validOwnerSnapshots(orders, expectedCount = orders?.length) { export function validContainerOrderGroup(rows) { if (!Array.isArray(rows) || rows.length === 0) return false - const containerNos = new Set(rows.map((row) => String(row?.containerNo ?? '').trim())) + const sourceBookings = new Set(rows.map((row) => String(row?.sourceContainerBookingId ?? '').trim())) const orderNos = new Set(rows.map((row) => String(row?.orderNo ?? '').trim())) const sourceInboundIds = rows.map((row) => String(row?.sourceInboundId ?? '').trim()) - return containerNos.size === 1 && !containerNos.has('') + return sourceBookings.size === 1 && !sourceBookings.has('') && orderNos.size === 1 && TAILLEG_ORDER_NUMBER.pattern.test([...orderNos][0]) && sourceInboundIds.every(Boolean) && new Set(sourceInboundIds).size === rows.length } diff --git a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs index 7c1b3dfa7..e6cabe6e8 100644 --- a/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs +++ b/scripts/admin-test-env-modules/test-tailleg-owner-order-number-contract.test.mjs @@ -62,37 +62,42 @@ test('Lastleg query DTO uses the persisted frozen owner name', () => { assert.match(orderService, /o\.getCustomerName\(\)/) }) -test('head-leg sourced tail orders preserve the existing container grouping number', () => { - assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('FL-WH-20261020-000042'), true) +test('head-leg sourced tail orders use the registered WC daily sequence', () => { + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('WC202610200042'), true) assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('LMO2087379674555461633'), false) - assert.equal(TAILLEG_ORDER_NUMBER.sharedBy, 'containerNo') - assert.match(generator, /FL-WH-/) - assert.match(generator, /BASIC_ISO_DATE/) - assert.match(generator, /SHA-256/) - assert.match(generatorTests, /actualContainerNumberRemainsStableAndShared/) + assert.equal(TAILLEG_ORDER_NUMBER.sharedBy, 'sourceContainerBookingId') + assert.equal(TAILLEG_ORDER_NUMBER.timeZone, 'Asia/Shanghai') + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('WC202610200000'), false) + assert.equal(TAILLEG_ORDER_NUMBER.pattern.test('FL-WH-20261020-000042'), false) + assert.match(generator, /case "LASTLEG" -> "WC"/) + assert.match(generator, /return documents\.next\(prefix\)/) + assert.match(generatorTests, /routesEveryOperationalTypeToItsDailySequence/) }) test('one container with 15 inbound rows shares one stable container order number', () => { const rows = Array.from({ length: 15 }, (_, index) => ({ - containerNo: 'CONT-15', orderNo: 'FL-WH-20261020-000042', sourceInboundId: `inbound-${index + 1}`, + sourceContainerBookingId: 'booking-15', containerNo: 'CONT-15', orderNo: 'WC202610200042', sourceInboundId: `inbound-${index + 1}`, })) assert.equal(validContainerOrderGroup(rows), true) assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 - ? { ...row, orderNo: 'FL-WH-20261020-000043' } : row)), false) - assert.match(consumer, /String orderNo\s*=\s*orderNoGenerator\.forContainerNo\(payload\.containerNo\(\)\);[\s\S]*for \(int index/) + ? { ...row, orderNo: 'WC202610200043' } : row)), false) + assert.equal(validContainerOrderGroup(rows.map((row, index) => index === 14 + ? { ...row, sourceContainerBookingId: 'another-booking' } : row)), false) + assert.equal(validContainerOrderGroup(rows.map((row) => ({ ...row, sourceContainerBookingId: '' }))), false) + assert.match(consumer, /String orderNo\s*=\s*orderNumbers\.findContainerOrderNo\(sourceContainerBookingId\);\s*if \(orderNo == null\) orderNo = orderNoGenerator\.next\("LASTLEG"\);[\s\S]*for \(int index/) assert.match(consumer, /createOrder\(envelope, payload, source, orderNo, rawSource, containerNo/) assert.match(consumerTests, /oneContainerWith15InboundRowsSharesOneStableContainerOrderNumber/) }) test('same event replay adds nothing and preserves the allocated number', () => { - const before = { orderCount: 15, orderNo: 'FL-WH-20261020-000042', detailCount: 30, historyCount: 15 } + const before = { orderCount: 15, orderNo: 'WC202610200042', detailCount: 30, historyCount: 15 } assert.equal(validEventReplay(before, { ...before }), true) assert.equal(validEventReplay(before, { ...before, orderCount: 30 }), false) const duplicateBranch = consumer.slice(consumer.indexOf('if(existing!=null)'), consumer.indexOf('LastlegEventInbox inbox=')) assert.match(duplicateBranch, /existing\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/) assert.match(duplicateBranch, /!"PROCESSED"\.equals\(existing\.getStatus\(\)\).*throw new IllegalStateException/) assert.match(duplicateBranch, /return DeliveryAck\.of\(envelope, DeliveryAck\.Outcome\.DUPLICATE\)/) - assert.doesNotMatch(duplicateBranch, /createOrder|forContainerNo|nextValue/) + assert.doesNotMatch(duplicateBranch, /createOrder|orderNoGenerator|nextValue/) const racedBranch = consumer.slice(consumer.indexOf('catch(DuplicateKeyException exception)'), consumer.indexOf('projectLoadingEvidence(envelope')) assert.match(racedBranch, /raced\.getPayloadHash\(\)\.equalsIgnoreCase\(envelope\.payloadHash\(\)\)/) assert.match(racedBranch, /!"PROCESSED"\.equals\(raced\.getStatus\(\)\).*throw new IllegalStateException/) diff --git a/scripts/admin-test-env-modules/truck-appointments.mjs b/scripts/admin-test-env-modules/truck-appointments.mjs index 40478fa9b..6cbcd1ea1 100644 --- a/scripts/admin-test-env-modules/truck-appointments.mjs +++ b/scripts/admin-test-env-modules/truck-appointments.mjs @@ -79,6 +79,13 @@ export function truckAppointmentPayload(context, suffix, identity, version = 0, } async function activeSupplier(context, category, suffix) { + if (context.options?.fixedCustomerId) { + const { loadSupplier } = await import('./finance-full-fixtures.mjs') + const supplier = await loadSupplier(context, category, category === 'BOOKING' + ? { carrierCode: 'MATSON' } : { truckingScope: 'DOMESTIC' }) + if (!supplier) throw new Error(`缺少现有 ${category} 供应商,禁止自动创建`) + return { supplierId: id(supplier.id), supplierName: supplier.name } + } const carrierFilter = category === 'BOOKING' ? '&carrierCode=MATSON' : category === 'TRUCKING' ? '&truckingScope=DOMESTIC' : '' const list = await request(context, 'GET', `/api/platform/v1/suppliers?tenantId=0&category=${category}&status=ACTIVE${carrierFilter}&page=1&size=100`) const existing = records(data(list)).find((item) => item?.category === category && item?.status === 'ACTIVE' diff --git a/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs b/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs index e1a92802a..321befe90 100644 --- a/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs +++ b/scripts/admin-test-env-modules/unified-pda-session-revocation.mjs @@ -18,6 +18,11 @@ export function warehouseClaim(token) { // Called only after the owned actor's inventory operations finish. Credentials // stay in the private handoff context and are never added to createdResources. export async function verifyPdaWarehouseRevocation(context, picker) { + if (picker?.reused) { + context.addCoverageGap(MODULE, 'fixed-pda-revocation', '固定PDA账号不执行改绑和降权生命周期', + '保留当前账号仓绑定与角色;本轮仅退出测试会话,撤销生命周期未验收', 'not-implemented', { blocking: false }) + return + } if (!picker?.userUpdate || !picker.password || !picker.refreshToken) throw Error('PDA撤销检查缺少本轮完整会话和资料') const optionsResponse = await context.request('GET', '/api/platform/v1/warehouse-admin/overseas-receiving-options?page=1&size=200') const page = context.apiSuccess(MODULE, '查询PDA仓绑定变更可用的权威收货仓', optionsResponse, '', OPTIONS) diff --git a/scripts/client-notifications-contract.test.mjs b/scripts/client-notifications-contract.test.mjs index 77c6a22a0..4c73c0c7a 100644 --- a/scripts/client-notifications-contract.test.mjs +++ b/scripts/client-notifications-contract.test.mjs @@ -7,11 +7,12 @@ import { fileURLToPath } from 'node:url' const scriptsDir = dirname(fileURLToPath(import.meta.url)) const source = readFileSync(resolve(scriptsDir, 'client-test-env-acceptance.mjs'), 'utf8') -test('Client full acceptance executes the real notification inbox flow', () => { - const isolation = source.indexOf('await isolationModule()') - const notifications = source.indexOf('await notificationInboxModule()') - const security = source.indexOf('await accountSecurityModule()') - assert.ok(isolation >= 0 && notifications > isolation && security > notifications) +test('fixed customer acceptance excludes notification writes and reports incomplete coverage', () => { + const orchestration = source.match(/try\s*\{\s*await reuseFixedClient\(\)[\s\S]*?\} catch \(error\)/)?.[0] || '' + assert.match(orchestration, /'notification-inbox'/) + assert.match(orchestration, /recordSkipped\(module,/) + assert.doesNotMatch(orchestration, /await (?:isolationModule|notificationInboxModule|accountSecurityModule)\(/) + assert.match(orchestration, /report\.fullCoverage = !report\.steps\.some\(\(step\) => step\.skipped\)/) assert.match(source, /async function notificationInboxModule\(\)/) for (const permission of [ 'merchant:notification:view', @@ -20,7 +21,7 @@ test('Client full acceptance executes the real notification inbox flow', () => { ]) assert.match(source, new RegExp(`['"]${permission}['"]`)) }) -test('notification delivery targets only the newly registered customer owner', () => { +test('excluded notification lifecycle targets only its fixture customer owner', () => { assert.match(source, /tenantId:\s*String\(report\.resources\.tenantId\)/) assert.match(source, /recipientType:\s*['"]MEMBERSHIP['"]/) assert.match(source, /recipientIds:\s*\[String\(report\.resources\.membershipId\)\]/) diff --git a/scripts/fleet-capacity-pool-test-env.mjs b/scripts/fleet-capacity-pool-test-env.mjs index ca65f2279..2760bfc97 100644 --- a/scripts/fleet-capacity-pool-test-env.mjs +++ b/scripts/fleet-capacity-pool-test-env.mjs @@ -7,7 +7,7 @@ import { parseCrossCustomerArguments, runCrossCustomer } from './fleet-cross-cus import { guardFleetWrites, redactFleetWrite, findFleetActorPhone } from './fleet-test-env-acceptance.mjs' import { confirmFleetLoading } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' import { testPlannedCapacity } from './admin-test-env-modules/fleet-booking-reservation-acceptance.mjs' -import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' +import { pickAll, saveReceiptAndComplete, readAutomaticDispatchDelivery } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..') const API = '/api/platform/v1/last-leg', POOL = `${API}/fleet/capacity-offers`, IAM = '/api/platform/v1/iam' @@ -486,7 +486,7 @@ async function poolScenario(context, fixture, { execute = false, readInventory, && task.items.length === 4 && members.every(member => task.items.some(row => id(row.customerId) === member.customerId && row.palletNo === member.palletNo && Number(row.pickedPackages) === 1))) await confirmFleetLoading(context, MODULE, tripProof, fixture.pdaToken, fixture.loadingMeasurements) - const arranged = await arrangeDispatchDelivery(context, fixture.dispatchNo) + const arranged = await readAutomaticDispatchDelivery(context, fixture.dispatchNo) check('One native platform arrangement covers all four real sources and both customers', arranged?.deliveries?.length === 2 && arranged.outbounds?.length === 2 && arranged.deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === 4) for (const member of members) { @@ -495,16 +495,8 @@ async function poolScenario(context, fixture, { execute = false, readInventory, Object.assign(member, { outboundNo: matches[0].outboundNo, deliveryNo: matches[0].deliveryNo }) } const readyTask = await request('GET', `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}`, undefined, fixture.pdaToken) - check('Arrangement leaves native main ready in AFTER_LOADING', readyTask.status === 'ACTIVE' && readyTask.stage === 'AFTER_LOADING' && readyTask.actions?.DEPART?.allowed === true) - const departBody = { recordVersion: readyTask.recordVersion, idempotencyKey: key('DEPART'), remark: context.marker } - const departPath = `/api/pda/v1/last-leg/dispatches/${encode(fixture.dispatchNo)}/depart` - const departed = await request('POST', departPath, departBody, fixture.pdaToken) - check('One native actual departure covers four sources and two customer deliveries', departed.dispatchNo === fixture.dispatchNo - && departed.status === 'ACTIVE' && departed.stage === 'DELIVERING' && Number(departed.result?.shippedPackages) === 4 - && departed.result.deliveries.length === 2) - const departReplay = await request('POST', departPath, departBody, fixture.pdaToken) - check('Departure original-key replay preserves exact version and documents', departReplay.replayed === true - && id(departReplay.recordVersion) === id(departed.recordVersion) && JSON.stringify(departReplay.result) === JSON.stringify(departed.result)) + check('Automatic departure covers four sources and both customers', readyTask.dispatchNo === fixture.dispatchNo + && readyTask.status === 'ACTIVE' && readyTask.stage === 'DELIVERING' && Number(readyTask.counts?.shippedPackageCount) === 4) for (const [index, delivery] of arranged.deliveries.entries()) { const actual = await request('GET', `${API}/delivery-orders/${encode(delivery.deliveryNo)}?customerId=${encode(delivery.customerId)}`) check('Actual departure has already set the customer delivery to DELIVERING', actual.order.status === 'DELIVERING') diff --git a/scripts/fleet-capacity-pool-test-env.test.mjs b/scripts/fleet-capacity-pool-test-env.test.mjs index f35f9ac86..29500b083 100644 --- a/scripts/fleet-capacity-pool-test-env.test.mjs +++ b/scripts/fleet-capacity-pool-test-env.test.mjs @@ -295,11 +295,12 @@ test('pool orchestration keeps native one-main boundaries, explicit reviews and assert.match(source, /actual: false/) assert.match(source, /arranged\.booking\.dispatchNo === fixture\.dispatchNo/) assert.match(source, /\['submit-review', 'dispatch-review', 'loading-review'\]/) - const pick = source.indexOf('const namespace = await pickPoolDispatch('), load = source.indexOf('await confirmFleetLoading('), arrange = source.indexOf('await arrangeDispatchDelivery('), depart = source.indexOf("const departed = await request('POST', departPath") + const pick = source.indexOf('const namespace = await pickPoolDispatch('), load = source.indexOf('await confirmFleetLoading('), arrange = source.indexOf('await readAutomaticDispatchDelivery('), depart = source.indexOf("const readyTask = await request('GET'") assert.ok(pick > 0 && pick < load && load < arrange && arrange < depart) assert.match(source, /fixture\.loadingMeasurements/) assert.match(source, /recordVersion: current\.recordVersion/) - assert.match(source, /readyTask\.actions\?\.DEPART\?\.allowed === true/) + assert.match(source, /readyTask\.stage === 'DELIVERING'/) + assert.doesNotMatch(source, /\/depart`|\/transfer-delivery/) assert.match(source, /JSON\.stringify\(actual\.fees\) === JSON\.stringify\(signed\.fees\)/) assert.match(source, /sameCustomerOrders\.every\(source => actual\.operations\.filter/) }) diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json index 117bfdec5..98df06f13 100644 --- a/scripts/java-quality-baseline.json +++ b/scripts/java-quality-baseline.json @@ -11,7 +11,7 @@ "services": 13 }, "openapi": { - "documents": 109, + "documents": 111, "coveredServices": 11, "codeFirstServices": 2 }, diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 48af828a6..b505746aa 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -65,7 +65,10 @@ test('repository freezes the current topology and non-blocking aggregate summary const summary = await buildQualitySummary(repositoryRoot) const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 20, codeModules: 19, services: 13 }) - assert.equal(summary.openapi.documents, 109) + assert.equal(summary.openapi.documents, 111) + for (const name of ['dispatch-loading-documents', 'dispatch-vehicle-documents']) { + assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) + } for (const name of ['cargo-discard-statistics', 'dispatch-transport-tasks', 'fleet-outsourced-business', 'fleet-owned-business', 'order-workflow', 'relabel-quantities', 'return-order-statistics', 'warehouse-transfer-quantities']) { assert.ok(summary.openapi.files.includes(`lastleg-service/src/main/resources/openapi/${name}-v1.yaml`)) } diff --git a/scripts/mixed-container-outbound-test-env.mjs b/scripts/mixed-container-outbound-test-env.mjs index e5ac68416..20cf0ea8e 100644 --- a/scripts/mixed-container-outbound-test-env.mjs +++ b/scripts/mixed-container-outbound-test-env.mjs @@ -4,7 +4,7 @@ import { fileURLToPath } from 'node:url' import { createHash } from 'node:crypto' import { AcceptanceRun, parseArguments, validateExecutionOptions } from './admin-test-env-acceptance.mjs' import { createPicker, disablePicker, resolvePickerAccess } from './admin-test-env-modules/phase4-overseas-pda.mjs' -import { pickAll, saveReceiptAndComplete, arrangeDispatchDelivery, verifyDepartureReadiness, +import { pickAll, saveReceiptAndComplete, readAutomaticDispatchDelivery, verifyAutomaticDeparture, dispatchInventorySources, inventorySnapshot, dispatchStockConservation, inventorySourcesPresent, relabelOutboundMarksMatch } from './admin-test-env-modules/phase5-overseas-fulfillment.mjs' import { prepareFleetTrip, confirmFleetLoading, finishFleetTrip } from './admin-test-env-modules/fleet-trip-fulfillment.mjs' @@ -124,7 +124,7 @@ async function ledger(context, scope, dispatchNo) { return rows.map(row => ({ ...row, id: id(row.id), customerId: id(row.customerId), overseasWarehouseId: id(row.overseasWarehouseId), beforePackages: Number(row.beforePackages), deltaPackages: Number(row.deltaPackages), afterPackages: Number(row.afterPackages) })).sort((a, b) => a.id.localeCompare(b.id)) } -const NATIVE_DEPS = { prepareFleetTrip, pickAll, confirmFleetLoading, arrangeDispatchDelivery, verifyDepartureReadiness, +const NATIVE_DEPS = { prepareFleetTrip, pickAll, confirmFleetLoading, readAutomaticDispatchDelivery, verifyAutomaticDeparture, saveReceiptAndComplete, finishFleetTrip, inventorySnapshot, dispatchInventorySources, dispatchStockConservation, ledger } /** Continue only a proved native dispatch. All mutations remain real public API commands. */ export async function runRetainedNativeDispatch(context, result, scope, pdaToken, overrides = {}) { @@ -190,15 +190,10 @@ export async function runRetainedNativeDispatch(context, result, scope, pdaToken detail = await api('GET', path) } requireFact(result.inventory?.conservation, 'No original scan quantity/ledger proof for this retained native dispatch') - const recovered = action => result.requests?.find(row => row.status === 'RESPONDED' && String(row.code) === '200' && row.result?.dispatchNo === dispatchNo && row.result?.action === action)?.result if (!result.transfer) { - const saved = recovered('ARRANGE_DELIVERY') - if (saved) result.transfer = { data: saved, deliveries: saved.result.deliveries, outbounds: saved.result.outbounds } - else { - requireFact(detail.stage === 'AFTER_LOADING', 'Missing original arrangement command; do not borrow existing historical delivery rows') - result.transfer = await deps.arrangeDispatchDelivery(context, dispatchNo) - requireFact(result.transfer, 'Native arrangement failed') - } + requireFact(detail.stage === 'DELIVERING', 'Current dispatch has no verified automatic departure') + result.transfer = await deps.readAutomaticDispatchDelivery(context, dispatchNo) + requireFact(result.transfer, 'Automatic delivery documents failed validation') await context.checkpoint() } const deliveries = result.transfer.deliveries, outbounds = result.transfer.outbounds @@ -207,24 +202,15 @@ export async function runRetainedNativeDispatch(context, result, scope, pdaToken && row.outboundNo === outbounds[0].outboundNo) && deliveries.reduce((sum, row) => sum + Number(row.packageCount), 0) === scope.expectedPackages, 'Native arrangement does not conserve the retained customer and physical quantity') result.outboundNo = outbounds[0].outboundNo; result.deliveryNos = deliveries.map(row => row.deliveryNo) - if (!result.departure) result.departure = recovered('DEPART') - if (detail.stage === 'AFTER_LOADING') { - const ready = await deps.verifyDepartureReadiness(context, pdaToken, dispatchNo) - requireFact(ready && validVersion(ready.data.recordVersion), 'Native server DEPART gates unavailable') - result.departBody ||= { recordVersion: ready.data.recordVersion, idempotencyKey: `${scope.marker}-DEPART`.slice(0, 128), remark: `${scope.marker}-TEST` } + if (!result.departure) { + const verified = await deps.verifyAutomaticDeparture(context, pdaToken, dispatchNo) + requireFact(verified, 'Automatic departure has not converged on both endpoints') + result.departure = verified.data await context.checkpoint() - result.departure = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken) - const replay = await api('POST', `${PDA}/${encodeURIComponent(dispatchNo)}/depart`, result.departBody, pdaToken) - requireFact(replay.replayed === true && String(replay.recordVersion) === String(result.departure.recordVersion) - && same(replay.result, result.departure.result), 'Departure original command replay drift') - result.departureReplay = replay - detail = await api('GET', path) } - requireFact(result.departure?.stage === 'DELIVERING' && result.departure?.status === 'ACTIVE' - && Number(result.departure.result?.shippedPackages) === scope.expectedPackages - && same(sorted(result.departure.result?.deliveries?.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`) || []), - sorted(deliveries.map(row => `${id(row.customerId)}:${row.outboundNo}:${row.deliveryNo}`))) - && ['DELIVERING', 'DELIVERED'].includes(detail.stage), 'Original native departure receipt missing or invalid') + requireFact(result.departure?.dispatchNo === dispatchNo && result.departure.stage === 'DELIVERING' + && result.departure.status === 'ACTIVE' && Number(result.departure.counts?.shippedPackageCount) === scope.expectedPackages + && ['DELIVERING', 'DELIVERED'].includes(detail.stage), 'Original automatic departure evidence missing or invalid') result.inventory.stockAfterDepart = await deps.inventorySnapshot(context, id(scope.customerId), id(scope.warehouseId), scope.inboundCodes, 'retained-after-depart', result.inventory.sourceBalanceIds) result.inventory.ledgerAfterDepart = await deps.ledger(context, scope, dispatchNo) requireFact(same(result.inventory.stockAfter, result.inventory.stockAfterDepart) diff --git a/scripts/native-retained-outbound-contract.test.mjs b/scripts/native-retained-outbound-contract.test.mjs index 8d7b6f1df..e02f39c96 100644 --- a/scripts/native-retained-outbound-contract.test.mjs +++ b/scripts/native-retained-outbound-contract.test.mjs @@ -111,10 +111,10 @@ function nativeRunFixture() { async confirmFleetLoading(_context, _module, _proof, token, measurements) { events.push('loading'); assert.equal(token, 'PDA-IN-MEMORY'); assert.equal(measurements, result.loadingMeasurements) if (!measurements) throw Error('Actual palletSpaces measurement is missing') - stage = 'AFTER_LOADING'; return '9007199254740995' + stage = 'DELIVERING'; return '9007199254740995' }, - async arrangeDispatchDelivery() { events.push('arrange'); return { data: { result: {} }, deliveries: [delivery], outbounds: [{ customerId: '100', outboundNo: 'OB-1' }] } }, - async verifyDepartureReadiness() { events.push('ready'); return { data: { recordVersion: '9007199254740996' } } }, + async readAutomaticDispatchDelivery() { events.push('arrange'); return { data: { result: {} }, deliveries: [delivery], outbounds: [{ customerId: '100', outboundNo: 'OB-1' }] } }, + async verifyAutomaticDeparture() { events.push('ready'); return { data: { dispatchNo: 'DS-1', status: 'ACTIVE', stage: 'DELIVERING', recordVersion: '9007199254740996', counts: { shippedPackageCount: 4 } } } }, async saveReceiptAndComplete(current) { await current.request('PUT', DOCS, { expectedVersion: '0', idempotencyKey: 'ORIGINAL-POD' }); return { order: { status: 'DELIVERED' } } }, async finishFleetTrip(_context, _module, proof) { events.push('finish'); proof.closure = { status: 'VERIFIED' }; return proof.closure }, async inventorySnapshot() { return stock(picked) }, @@ -128,10 +128,10 @@ test('retained native execution preserves real ordering, token/measurement scope const f = nativeRunFixture() await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps) assert.equal(f.result.finalStatus, 'PASSED') - assert.deepEqual(f.events.filter(value => value !== 'checkpoint'), ['prepare', 'pick', 'loading', 'arrange', 'ready', 'depart', 'depart', 'pod', 'finish']) + assert.deepEqual(f.events.filter(value => value !== 'checkpoint'), ['prepare', 'pick', 'loading', 'arrange', 'ready', 'pod', 'finish']) assert.equal(f.result.inventory.conservation.totals.ledgerShipped, 4) assert.deepEqual(f.result.inventory.ledgerAfter, f.result.inventory.ledgerAfterDepart) - assert.equal(f.result.departBody.recordVersion, '9007199254740996') + assert.equal(f.result.departure.recordVersion, '9007199254740996') const originalDepartCount = f.calls.filter(row => row.method === 'POST').length await runRetainedNativeDispatch(f.context, f.result, scope(), 'PDA-IN-MEMORY', f.deps) assert.equal(f.calls.filter(row => row.method === 'POST').length, originalDepartCount) diff --git a/scripts/procurement-sample-test-env.mjs b/scripts/procurement-sample-test-env.mjs index d5b4de83f..a97734cd9 100644 --- a/scripts/procurement-sample-test-env.mjs +++ b/scripts/procurement-sample-test-env.mjs @@ -4,9 +4,13 @@ import { resolve, dirname } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { AcceptanceRun } from './admin-test-env-acceptance.mjs' import { run as prepareCustomer } from './admin-test-env-modules/merchant-qualification-fixture.mjs' -import { verifyProcurementSample } from './admin-test-env-modules/procurement-sample-fulfillment.mjs' +import { verifyProcurementSample, PROCUREMENT_REQUIRED_AVAILABLE_BALANCE } from './admin-test-env-modules/procurement-sample-fulfillment.mjs' const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64') +export function requireExistingProcurementBalance(account) { + if (account?.availableBalance == null || !Number.isFinite(Number(account.availableBalance)) + || Number(account.availableBalance) < Number(PROCUREMENT_REQUIRED_AVAILABLE_BALANCE)) throw new Error(`采购样本需要至少 ${PROCUREMENT_REQUIRED_AVAILABLE_BALANCE} 元现有可用余额;禁止自动充值`) +} export function options(args) { const value = name => { const index = args.indexOf(name); return index < 0 ? '' : args[index + 1] || '' } if (!args.includes('--execute')) return { execute: false } @@ -89,7 +93,7 @@ export async function execute(args = process.argv.slice(2), execArgs = process.e return accountForCustomer(page, customerId) } const before = await readWallet() - requireSuccess(await context.request('POST',`/api/platform/finance/v1/accounts/${customerId}/adjustments`,{ type:'RECHARGE', amount:'10.00', occurredDate:new Date().toISOString().slice(0,10), paymentMethod:'BANK_TRANSFER', referenceNo:context.marker, remark:context.marker, expectedAccountVersion:before.accountVersion },context.token,{'Idempotency-Key':`${context.marker}-fund`})) + requireExistingProcurementBalance(before) const upload = async (orderNo,category) => requireSuccess(await context.requestMultipart('POST',`/api/platform/v1/last-leg/overseas-procurements/${encodeURIComponent(orderNo)}/files/${category}?customerId=${customerId}`,{ content:PNG,type:'image/png',name:`${category.toLowerCase()}.png` })) report.status = 'RUNNING'; await checkpoint() await verifyProcurementSample(context,{ marker:context.marker,customerId,customerName:fixture.tenantName,customerToken,warehouseId:config.warehouseId,readWallet,upload }) diff --git a/scripts/procurement-sample-test-env.test.mjs b/scripts/procurement-sample-test-env.test.mjs index f2ad8769f..65e9be2e9 100644 --- a/scripts/procurement-sample-test-env.test.mjs +++ b/scripts/procurement-sample-test-env.test.mjs @@ -1,6 +1,13 @@ import test from 'node:test' import assert from 'node:assert/strict' -import { execute, options, accountForCustomer } from './procurement-sample-test-env.mjs' +import { execute, options, accountForCustomer, requireExistingProcurementBalance } from './procurement-sample-test-env.mjs' + +test('procurement uses available existing funds without funding the account', () => { + requireExistingProcurementBalance({ availableBalance: '3.00' }) + for (const value of [undefined, null, 'invalid', '2.99', '-1']) { + assert.throws(() => requireExistingProcurementBalance({ actualBalance: '100', availableBalance: value }), /禁止自动充值/) + } +}) test('default preview needs no credentials and performs no execution', async () => { const result = await execute([], []) From 760bf8daa1239e76a8e97483e3faf34ef3812d78 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 14:37:59 +0800 Subject: [PATCH 084/113] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AppointmentWorkDispatchPort.java | 6 + .../DispatchAggregateController.java | 12 ++ .../dispatch/port/DispatchWorkModels.java | 8 ++ .../service/DispatchAggregateService.java | 49 +++++++ .../DispatchAppointmentAllocations.java | 8 ++ .../service/DispatchDraftService.java | 9 +- .../dispatch/service/DispatchService.java | 12 ++ .../AppointmentDispatchAllocationReader.java | 62 ++++++++ ...pointmentDispatchAllocationRepository.java | 20 +++ .../work/AppointmentDispatchAvailability.java | 40 ++++++ .../work/AppointmentWorkDispatchBinding.java | 13 +- .../AppointmentWorkDispatchCandidates.java | 15 +- .../work/AppointmentWorkDispatchMapper.java | 24 +++- .../openapi/overseas-business-v1.yaml | 135 ++++++++++++++++++ ...mentWorkPreArrangementPersistenceTest.java | 18 ++- ...ppointmentWorkDispatchPersistenceTest.java | 18 ++- scripts/acceptance-overview.mjs | 34 +++++ scripts/acceptance-overview.test.mjs | 28 +++- scripts/admin-test-env-acceptance.mjs | 12 +- scripts/admin-test-env-modules/README.md | 28 +++- ...argo-discard-fulfillment-contract.test.mjs | 89 ++++++++++++ .../cargo-discard-fulfillment.mjs | 114 +++++++++++++++ ...ient-relabel-fulfillment-contract.test.mjs | 90 ++++++++++++ .../client-relabel-fulfillment.mjs | 132 +++++++++++++++++ .../looseboard-fulfillment-contract.test.mjs | 68 +++++++++ .../looseboard-fulfillment.mjs | 85 +++++++++++ .../looseboard-pool-binding-contract.test.mjs | 87 +++++++++++ .../looseboard-pool-binding.mjs | 100 +++++++++++++ ...looseboard-pool-dispatch-contract.test.mjs | 75 ++++++++++ .../looseboard-pool-dispatch.mjs | 86 +++++++++++ ...seboard-pool-preparation-contract.test.mjs | 75 ++++++++++ .../looseboard-pool-preparation.mjs | 97 +++++++++++++ .../looseboard-pool-receipt-contract.test.mjs | 89 ++++++++++++ .../looseboard-pool-receipt.mjs | 108 ++++++++++++++ ...nt-qualification-fixture-contract.test.mjs | 6 +- ...phase4-overseas-business-contract.test.mjs | 74 ++++++++-- .../phase4-overseas-business.mjs | 13 +- ...overseas-client-services-contract.test.mjs | 33 ++++- .../phase4-overseas-client-services.mjs | 121 +++++++++++----- .../phase4-overseas-return-followups.mjs | 47 +++--- ...-overseas-return-receipt-contract.test.mjs | 74 +++++++++- .../phase4-overseas-return-receipt.mjs | 42 +++++- ...s-wallet-and-pallet-cost-contract.test.mjs | 77 +++++++++- ...phase4-overseas-wallet-and-pallet-cost.mjs | 35 +++++ ...se5-overseas-fulfillment-contract.test.mjs | 13 +- .../phase5-overseas-fulfillment.mjs | 16 ++- .../suppliers-contract.test.mjs | 56 ++++++++ scripts/admin-test-env-modules/suppliers.mjs | 38 +++++ .../us-truck-fulfillment.mjs | 76 ++++++++++ 49 files changed, 2450 insertions(+), 117 deletions(-) create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationRepository.java create mode 100644 lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java create mode 100644 scripts/admin-test-env-modules/cargo-discard-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/cargo-discard-fulfillment.mjs create mode 100644 scripts/admin-test-env-modules/client-relabel-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/client-relabel-fulfillment.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-fulfillment-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-fulfillment.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-binding-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-binding.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-dispatch-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-dispatch.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-preparation-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-preparation.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-receipt-contract.test.mjs create mode 100644 scripts/admin-test-env-modules/looseboard-pool-receipt.mjs create mode 100644 scripts/admin-test-env-modules/us-truck-fulfillment.mjs diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java index 188106dc0..6469b9ab6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/appointmentwork/AppointmentWorkDispatchPort.java @@ -16,6 +16,12 @@ public interface AppointmentWorkDispatchPort { record CandidateBatches(List appointmentBatches,List blockReasons,boolean occupied) { } /** 整页真实客户/计划托盘一次批读;occupied含有效草稿与正式实托占用,由调用方核对本草稿身份。 */ Map candidateBatches(List candidates); + /** 只读取本车已选实物的逐票预约选项;本车原占用不扣减本车可重新分配量。 */ + Map> allocationOptions(long dispatchId,List items); + record AllocationOption(String batchKey,String batchNo,Long recordVersion,int availablePackages, + String confirmationNo,String appointmentAt,String appointmentEndAt,String timezone) { } + record AllocationSource(String sourceKey,String orderNo,String inboundCode,String destinationKey,Integer packageCount, + List options,List blockReasons) { } /** Current original allocations, grouped for authorized dispatches; historical copies are read-only evidence. */ java.util.Map> references(List dispatchIds); /** 在来源锁前按批次键一次性预锁并核对权威版本,版本零合法。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java index b0ea5d313..c27f77439 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/controller/DispatchAggregateController.java @@ -53,6 +53,18 @@ public class DispatchAggregateController { public ApiResponse items(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { return ApiResponse.success(service.items(DispatchActorFactory.platform(user),dispatchNo)); } + /** 本车货物逐票读取正式预约选项及当前分配。 */ + @GetMapping("/{dispatchNo}/appointment-allocations") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") + public ApiResponse appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo) { + return ApiResponse.success(service.appointmentAllocations(DispatchActorFactory.platform(user),dispatchNo)); + } + /** 原车版本化关联正式预约,不新建排车或修改库存。 */ + @PutMapping("/{dispatchNo}/appointment-allocations") + @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:edit')") + public ApiResponse appointmentAllocations(@AuthenticationPrincipal AuthenticatedUser user,@PathVariable String dispatchNo,@RequestBody com.baiyue.lastleg.dispatch.port.DispatchWorkModels.AppointmentAllocationCommand request) { + return ApiResponse.success(service.saveAppointmentAllocations(DispatchActorFactory.platform(user),dispatchNo,request)); + } /** 读取实际起点和卸货顺序。 */ @GetMapping("/{dispatchNo}/route") @PreAuthorize("hasAuthority('platform:overseas-warehouse:dispatch:list')") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java index d2f1b708b..3480b5e3c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/port/DispatchWorkModels.java @@ -14,6 +14,14 @@ public final class DispatchWorkModels { public record Items(String dispatchNo,long recordVersion,Status status,Stage stage,List items) { } public record Stop(int index,String name,String address,String destinationKey) { } public record Route(String dispatchNo,long recordVersion,List stops,Gate edit) { } + public record AppointmentItem(Long dispatchItemId,Long planPalletId,String customerId,String customerName,String palletNo, + List sources,List allocations) { } + public record AppointmentAllocations(String dispatchNo,long recordVersion,List items,Gate edit) { } + /** 只替换明确选中的本车实物分配,未触及客户和实物保持原关联。 */ + public record AppointmentAllocationCommand(Long recordVersion,String idempotencyKey,List dispatchItemIds,List batchAllocations) { + /** 主版本零合法;保存失败不能用默认版本覆盖后续修改。 */ + public AppointmentAllocationCommand {recordVersion=DispatchCommands.requireVersion(recordVersion);} + } /** 仅重排现有真实卸货点;起运仓由服务端保留,外接目的地不得丢失。 */ public record RouteCommand(Long recordVersion,String idempotencyKey,List destinationKeys,String reason) { /** 显式零版本合法,缺版本不能覆盖并发变更。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index 62e2b1bac..7ba70e6c5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -177,6 +177,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var fact=row.planPalletId()==null?null:batchFacts.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(row.customerId(),row.planPalletId())); var references=new ArrayList();var reasons=new LinkedHashSet(); reasons.addAll(group.blockReasons()); + if("FBA".equals(destination.type()) && fact==null)reasons.add("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"); if(fact!=null) { reasons.addAll(fact.blockReasons()); // 来源候选已验证当前草稿拥有者并过滤其他作业占用,不能把本草稿占用再次当禁用。 @@ -380,6 +381,53 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA return saveCommand(row,actor,identity,request.reason()); } + /** 已有排车按当前真实客户托盘读取逐票正式预约,不将本车占用当成其他车占用。 */ + @Transactional(readOnly=true,isolation=Isolation.REPEATABLE_READ) + public DispatchWorkModels.AppointmentAllocations appointmentAllocations(Actor actor,String number) { + requirePlatform(actor);var row=required(actor,number,false); + var items=appointmentItems(row);var options=appointments.options(row.getId(),items);var current=repository.batches(row.getId()); + var result=items.stream().map(i->new DispatchWorkModels.AppointmentItem(i.getId(),i.getPlanPalletId(),i.getCustomerId(),i.getCustomerName(),i.getPalletNo(), + options.get(new com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())), + current.stream().filter(a->Objects.equals(a.dispatchItemId(),i.getId())).map(a->new BatchAllocation(a.planPalletId(),a.batchKey(),a.customerId(),a.sourceKey(),a.destinationKey(),a.packageCount(),a.recordVersion())).toList())).toList(); + boolean permitted=hasPermission(actor,Action.EDIT);boolean editable=appointmentEditable(row) && !items.isEmpty(); + return new DispatchWorkModels.AppointmentAllocations(number,row.getRecordVersion(),result,new Gate(permitted && editable, + !permitted?"FORBIDDEN":editable?null:"NOT_ELIGIBLE",!permitted?"当前无此操作权限":editable?null:"仅装车前、未扫码且无有效下游单据的FBA货物可关联预约")); + } + + /** 原车补选或改选正式预约,分配、主版本、审核失效、历史和命令结果同事务保存。 */ + @Transactional(isolation=Isolation.READ_COMMITTED) + public CommandResult saveAppointmentAllocations(Actor actor,String number,DispatchWorkModels.AppointmentAllocationCommand request) { + var row=lockPlatform(actor,number); + if(!hasPermission(actor,Action.EDIT))throw new BizException(ResultCode.FORBIDDEN,"当前无排车维护权限"); + var identity=identity(actor,number,Action.EDIT,request.recordVersion(),request.idempotencyKey(),request); + var replay=commands.replay(actor,number,identity);if(replay.isPresent())return replay.get(); + requireEditing(row,request.recordVersion()); + if(!appointmentEditable(row))throw conflict("本车已有实际扫码或有效下游单据,不能调整预约分配"); + var ids=request.dispatchItemIds(); + if(ids==null || ids.isEmpty() || ids.size()>1000 || ids.stream().anyMatch(Objects::isNull) || new HashSet<>(ids).size()!=ids.size())throw bad("请选择完整且不重复的本车货物明细"); + if(request.batchAllocations()==null || request.batchAllocations().size()>1000)throw bad("请明确本次正式预约分配"); + var touched=appointmentItems(row).stream().filter(i->ids.contains(i.getId())).toList(); + if(touched.size()!=ids.size())throw conflict("预约分配只能调整本车已有的真实FBA货物"); + // 先父车/资源/主单,再全部旧新预约,最后订单/实托/计划来源;不反向追加批次锁。 + appointments.lock(repository.batches(row.getId()),request.batchAllocations()); + sourceRules.lockAppointmentSources(row.getOverseasWarehouseId(),touched); + appointments.bind(row.getId(),touched,request.batchAllocations()); + String reason="正式预约分配已调整,请客服重新提交两级审核";invalidate(row,reason); + return saveCommand(row,actor,identity,reason,new DispatchResults.Saved(number,touched.stream().map(DispatchItem::getId).toList())); + } + /** 只处理普通计划来源的FBA货物,其他来源与客户原分配保持不动。 */ + private List appointmentItems(DispatchAggregate row) { + return repository.cargo(row.getId()).stream().filter(i->i.getPlanPalletId()!=null && "FBA".equals(i.getDestinationType()) + && !"RELABEL_ORDER".equals(i.getSourceBusinessType())).toList(); + } + /** 显式查真实扫码流水,不以主单零计数覆盖实际执行事实。 */ + private boolean appointmentEditable(DispatchAggregate row) { + return (row.getTaskType()==null || "DELIVERY".equals(row.getTaskType())) && "ACTIVE".equals(row.getStatus()) && "BEFORE_LOADING".equals(row.getStage()) + && n(row.getPickedPackageCount())==0 && n(row.getShippedPackageCount())==0 && repository.realPicked(row.getId())==0 + && repository.outbounds(row.getId()).stream().allMatch(o->"CANCELLED".equals(o.status())) + && repository.deliveries(row.getId()).stream().allMatch(d->"CANCELLED".equals(d.status())); + } + /** 旧新资源身份一次锁齐,资料和本趟照片随正式资源快照保存,原审核失效规则保留。 */ @Transactional(isolation=Isolation.READ_COMMITTED) public CommandResult update(Actor actor,String number,DispatchCommands.Update request) { @@ -811,6 +859,7 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA for(var source:resolved) { if(keys.contains(source.sourceKey())) throw conflict("所选来源已在本车,不能重复加入"); var c=source.candidate(); + if("FBA".equals(c.destinationType()) && c.planPalletId()==null)throw conflict("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"); sources.expire(c.customerId(),source.sourceKey(),at); if(!sources.claims(c.customerId(),source.sourceKey(),at).isEmpty())throw conflict("所选逻辑来源已被有效运输安排占用"); try {if(sources.claim(IdWorker.getId(),c.customerId(),source.sourceKey(),c.planPalletId(),c.warehousePalletId(),c.returnReceiptId(),c.warehousePalletId()==null?null:physical.get(c.warehousePalletId()),row.getId(),actor.operatorId(),at)!=1)throw conflict("来源占用未保存");} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java index 34434c8af..d5223c1d1 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAppointmentAllocations.java @@ -22,6 +22,14 @@ public class DispatchAppointmentAllocations { /** 预排与执行门禁分开;审核之前不要求已有拣货记录。 */ public String reviewBlockReason(String dispatchNo,java.time.Instant departure,java.time.Instant arrival){return provider.reviewBlockReason(dispatchNo,departure,arrival);} public List reviewEvidence(String dispatchNo){return provider.reviewEvidence(dispatchNo);} + /** 本车货物已经由主单范围核实,选项不重新选货或制造托盘占用。 */ + public Map> options(long dispatch,List items) { + var identities=items.stream().map(i->new AppointmentWorkDispatchPort.CandidatePhysical(i.getCustomerId(),i.getPlanPalletId())).distinct().toList(); + if(identities.isEmpty())return Map.of(); + var result=provider.allocationOptions(dispatch,identities); + if(result==null || !result.keySet().equals(new HashSet<>(identities)))throw new BizException(ResultCode.SERVICE_UNAVAILABLE,"本车预约选项范围不完整,请重新读取"); + return result; + } /** 一整页只做一次Provider批读;没有计划来源的独立退货不伪造计划ID。 */ public Map candidates(List rows) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java index a6c4667e6..533dc145c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchDraftService.java @@ -113,7 +113,9 @@ public class DispatchDraftService implements DispatchPlanningPort { var resolved=lockedIndex.get(member.sourceKey()); if(!Objects.equals(resolved.selection(),member.selection()) || !Objects.equals(resolved.candidate().customerId(),member.customerId()) || resolved.candidate().packageCount()==null || resolved.candidate().packageCount().longValue()!=member.packages())throw conflict("锁内完整来源身份、版本或箱数已变化"); - next.add(candidate(resolved,group));keys.add(member.sourceKey()); + var projected=candidate(resolved,group); + if(!projected.blockReasons().isEmpty())throw conflict(String.join(";",projected.blockReasons())); + next.add(projected);keys.add(member.sourceKey()); } requireCapacity(container.getCapacity(),keys,index(next)); var at=now();var physical=physicalClaims.reserve(physicalOwner(row,container.getId()),members.values().stream() @@ -428,7 +430,10 @@ public class DispatchDraftService implements DispatchPlanningPort { } /** 显式映射原批次版本;本草稿有效占用不作为所选来源自身的阻断理由。 */ private Candidate projectCandidate(Candidate c,Map batches) { - if(c.selection().planPalletId()==null)return c; + if(c.selection().planPalletId()==null) { + if(!"FBA".equals(c.destination().type()))return c; + return new Candidate(c.selection(),c.sourceKey(),c.sourceVersion(),c.customerId(),c.customerName(),c.warehouseId(),c.warehouseCode(),c.warehouseName(),c.orderNo(),c.inboundCode(),c.planNo(),c.palletNo(),c.containerNo(),c.destination(),c.availablePackages(),c.suggestedWeightKg(),c.suggestedVolumeCbm(),c.measurementSource(),List.of(),List.of("本票FBA货物缺少可核对的正式预约来源,请先确认送仓时间"),c.selectionGroup()); + } var result=batches.get(new AppointmentWorkDispatchPort.CandidatePhysical(c.customerId(),c.selection().planPalletId())); var reasons=new LinkedHashSet(); if(result==null)reasons.add("预约批次实物读取不完整,请重新核对来源");else reasons.addAll(result.blockReasons()); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java index 3d9e901ca..5b1527594 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchService.java @@ -394,6 +394,18 @@ public class DispatchService { if(!pallets.isEmpty())palletSerial.lock(pallets); } + /** 关联预约只锁原订单、共同实托及计划托盘,不要求尚未发生的实收库存。 */ + @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) + public void lockAppointmentSources(String warehouse,List items) { + var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).collect(Collectors.toCollection(TreeSet::new)); + var orders=executionOrders(warehouse,items,pallets); + orders.forEach((id,customer)->{if(wholeSources.lockOrder(customer,id)==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车真实来源订单已缺失或暂扣");}); + palletSerial.lock(List.copyOf(pallets)); + if(!orders.equals(executionOrders(warehouse,items,pallets)))throw new ResponseStatusException(HttpStatus.CONFLICT,"本车逐票来源已变化,请重新核对"); + for(var item:items.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) + if(wholeSources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw new ResponseStatusException(HttpStatus.CONFLICT,"本车计划托盘已缺失或归属变化"); + } + /** 执行沿用全车申请/订单→共同实托串行顺序;库存变化不重新解释原冻结贡献。 */ @Transactional(propagation=org.springframework.transaction.annotation.Propagation.MANDATORY) public RelabelDispatchSourcePort.Snapshot lockExecutionSources(String warehouse,List items) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java new file mode 100644 index 000000000..cb62d2540 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAllocationReader.java @@ -0,0 +1,62 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.AppointmentWorkDispatchPort.*; +import org.springframework.stereotype.Service; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.*; + +/** 为本车已有货物提供逐票原预约选项,读取不会释放占用或修改预约。 */ +@Service +public class AppointmentDispatchAllocationReader { + private final AppointmentDispatchAllocationRepository repository; + private final AppointmentSourceService sources; + private final AppointmentDispatchAvailability availability; + /** 注入本域只读仓储、来源规范化和正式确认资格。 */ + public AppointmentDispatchAllocationReader(AppointmentDispatchAllocationRepository repository,AppointmentSourceService sources,AppointmentDispatchAvailability availability) { + this.repository=repository;this.sources=sources;this.availability=availability; + } + /** 本车来源身份由上层核对;只返回明确已确认且仍有可分配数量的原批次。 */ + public Map> read(long dispatch,List items) { + if(dispatch<=0 || items==null || items.size()>1000 || items.stream().anyMatch(Objects::isNull))throw bad("本车预约实物范围无效"); + if(items.isEmpty())return Map.of(); + var physical=repository.physical(dispatch,items); + if(physical.size()>20000)throw unavailable("本车逐票实物超过完整读取上限"); + var orders=physical.stream().map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).toList(); + var canonical=new HashMap(); + var distinct=orders.stream().filter(p->p.orderId()!=null).distinct().toList(); + for(int offset=0;offsetp.orderId()!=null && p.destinationKey()!=null && p.destinationKey().startsWith("FBA:")) + .map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); + var batches=refs.isEmpty()?List.of():repository.batches(dispatch,refs,orders); + if(batches.size()>20000)throw unavailable("本车原预约选项超过完整读取上限"); + var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList()); + var result=new LinkedHashMap>(); + for(var identity:items) { + var rows=new ArrayList(); + for(var p:physical.stream().filter(p->identity.planPalletId().equals(p.planPalletId()) && identity.customerId().equals(p.customerId())).toList()) { + var reasons=new ArrayList();var options=new ArrayList(); + if(p.orderId()==null || p.destinationKey()==null || p.packages()==null || p.packages()<=0)throw conflict("本车逐票实物来源或箱数尚未核实"); + var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).sourceKey(); + if(p.destinationKey().startsWith("FBA:")) { + for(var b:batches)if(identity.customerId().equals(b.customerId()) && source.equals(b.sourceKey()) && p.destinationKey().equals(b.destinationKey())) { + int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,p.destinationKey()); + long remaining=b.packages()==null?0:Math.min(b.packages(),confirmed)-b.allocated(); + if(remaining>0 && b.recordVersion()!=null && b.recordVersion()>=0) { + var agreement=confirmations.get(b.batchKey()).agreements().stream().filter(a->"ACTIVE".equals(a.status()) + && a.members().stream().anyMatch(m->b.batchKey().equals(m.batchKey()) && b.customerId().equals(m.customerId()))).findFirst().orElseThrow(); + var details=agreement.details(); + options.add(new AllocationOption(b.batchKey(),b.batchNo(),b.recordVersion(),Math.toIntExact(remaining),details.confirmationNo(), + details.appointmentAt().toString(),details.appointmentEndAt()==null?null:details.appointmentEndAt().toString(),agreement.timezone())); + } + } + if(options.stream().mapToLong(AllocationOption::availablePackages).sum() physical(long dispatch,List items) {return mapper.allocationPhysical(dispatch,items);} + /** 同一原批次来源扣除其他有效车辆占用,本车原分配由替换事务重新核对。 */ + public List batches(long dispatch,List refs,List orders) { + return mapper.candidateBatches(refs,orders,dispatch); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java new file mode 100644 index 000000000..820e36542 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentDispatchAvailability.java @@ -0,0 +1,40 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore; +import com.baiyue.lastleg.inboundplan.receiving.FbaReceivingContracts.*; +import org.springframework.stereotype.Service; +import java.time.*; +import java.util.*; + +/** FBA选入排车前必须已有覆盖本客户、来源和目的仓的正式时间确认。 */ +@Service +public class AppointmentDispatchAvailability { + private final FbaReceivingStore receiving; + /** 只消费原收货确认权威,不使用车辆补充预约号。 */ + public AppointmentDispatchAvailability(FbaReceivingStore receiving) {this.receiving=receiving;} + /** 按既有200批上限分块批读,避免逐托查询预约。 */ + public Map snapshots(List keys) { + var unique=keys.stream().distinct().sorted().toList();var result=new HashMap(); + for(int offset=0;offset"WAITING".equals(p.status()) && customer.equals(p.intent().customerId())))return 0; + var agreements=snapshot.agreements().stream().filter(a->"ACTIVE".equals(a.status()) && destination.equals(a.destinationKey()) + && a.members().stream().anyMatch(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId()))).toList(); + if(agreements.size()!=1)return 0; + var agreement=agreements.get(0);var details=agreement.details(); + if(details==null || !"SUCCESS".equals(details.appointmentStatus()) || details.appointmentAt()==null || !validTime(details.appointmentAt(),agreement.timezone(),details.startOffset()))return 0; + if(details.appointmentEndAt()!=null && (!validTime(details.appointmentEndAt(),agreement.timezone(),details.endOffset()) || details.appointmentEndAt().isBefore(details.appointmentAt())))return 0; + var members=agreement.members().stream().filter(m->snapshot.batchKey().equals(m.batchKey()) && customer.equals(m.customerId())).toList(); + if(members.size()!=1)return 0; + var sources=members.get(0).sources().stream().filter(s->source.equals(s.sourceKey()) && destination.equals(s.destinationKey())).toList(); + return sources.size()==1?Math.max(0,sources.get(0).packageCount()):0; + } + /** 夏令时歧义须有明确偏移;不能把当地时间按服务器时区猜测。 */ + private boolean validTime(LocalDateTime at,String timezone,String offset) { + try {var offsets=ZoneId.of(timezone).getRules().getValidOffsets(at);return offset==null?offsets.size()==1:offsets.contains(ZoneOffset.of(offset));} + catch(DateTimeException|NullPointerException error){return false;} + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java index 6ad5a1e21..3694a66d6 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -38,6 +38,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo private final AppointmentWorkChanges changes; private final com.baiyue.lastleg.inboundplan.receiving.FbaReceivingStore receiving; private final AppointmentWorkDispatchCandidates candidates; + @jakarta.annotation.Resource private AppointmentDispatchAllocationReader allocationReader; private final Object prelockedBatchesKey=new Object(); private final Object pendingInvalidationKey=new Object(); /** 注入原预约、真实实物和收货确认权威。 */ @@ -48,6 +49,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo /** 候选批读沿唯一原实物Provider实现,不读取工作清单投影。 */ @Transactional(readOnly=true,propagation=Propagation.SUPPORTS) public Map candidateBatches(List input) {return candidates.batchReferences(input);} + /** 已有排车的选项读取不受本车原占用影响,也不会修改来源。 */ + @Transactional(readOnly=true,propagation=Propagation.SUPPORTS) + public Map> allocationOptions(long dispatchId,List items) {return allocationReader.read(dispatchId,items);} /** 来源锁前一次锁齐全部批次;后续调用只能复用本事务已经锁定的范围。 */ public void lockBatches(Map expectedBatchVersions) { if(expectedBatchVersions==null || expectedBatchVersions.size()>1000) throw bad("预约批次版本范围无效"); @@ -115,9 +119,12 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo var appointment=appointments.batchContext(batch).appointment(); if(appointment.version()==null || appointment.version()<0 || versions.containsKey(batch) && appointment.version().longValue()!=versions.get(batch)) throw conflict("预约批次版本已变化,请重新读取实物候选"); if(!appointment.replacementBatchKeys().isEmpty()) throw conflict("不能绑定已被替代的预约批次"); - for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) + for(var customer:effective.stream().filter(a->batch.equals(a.batchKey())).map(AppointmentWorkDispatchMapper.Bound::customerId).distinct().sorted().toList()) { requireCurrentAllocation(appointment,customer,dispatch,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())) .map(a->allocation(a,appointment.version().longValue())).toList()); + // FBA确认前不能把货物加入车辆;提交审核仍复验运输时间。 + requireReceiving(batch,customer,effective.stream().filter(a->batch.equals(a.batchKey()) && customer.equals(a.customerId())).toList()); + } } // 所有校验结束后才按明确item删除;没有整车清空操作。 for(var item:touched) mapper.removeItem(dispatch,item); @@ -230,7 +237,8 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo if(receiving.hasPending(batch,customer)) throw conflict("本客户有待仓库确认的变化,保留原安排但禁止发车,请先完成明确回复"); var agreement=receiving.currentAgreement(batch,customer); if(agreement==null || !"ACTIVE".equals(agreement.status()) || agreement.details()==null || !"SUCCESS".equals(agreement.details().appointmentStatus()) - || agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先完成当前预约后发车"); + || agreement.details().appointmentAt()==null) throw conflict("本客户尚无有效收货确认,请先预约成功并明确送仓时间,再加入排车或发车"); + receivingInstant(agreement.details().appointmentAt(),agreement.timezone(),agreement.details().startOffset()); var members=agreement.members().stream().filter(m->batch.equals(m.batchKey()) && customer.equals(m.customerId())).toList(); if(members.size()!=1) throw conflict("当前收货确认未唯一覆盖本客户批次,请重新核对"); for(var group:rows.stream().collect(Collectors.groupingBy(r->List.of(r.sourceKey(),r.destinationKey()))).values()) { @@ -260,6 +268,7 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo var refs=physical.stream().map(p->new AppointmentCoverageService.SourceRef(customer,AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); if(!refs.isEmpty() && appointments.relatedBatchSnapshots(refs).stream().anyMatch(a->a.replacementBatchKeys().isEmpty())) throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); + if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车"); } /** 调用方已锁主单与原批次,删除只作用于指定实物。 */ public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java index 46fbf5285..e4375e608 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchCandidates.java @@ -16,8 +16,9 @@ public class AppointmentWorkDispatchCandidates { private final AppointmentWorkDispatchMapper mapper; private final InboundFbaAppointmentService appointments; private final AppointmentSourceService sources; + private final AppointmentDispatchAvailability availability; /** 注入唯一原批次、实物和来源规范化边界。 */ - public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources) { this.mapper=mapper; this.appointments=appointments; this.sources=sources; } + public AppointmentWorkDispatchCandidates(AppointmentWorkDispatchMapper mapper,InboundFbaAppointmentService appointments,AppointmentSourceService sources,AppointmentDispatchAvailability availability) { this.mapper=mapper; this.appointments=appointments; this.sources=sources;this.availability=availability; } /** 整页两次本地SQL;来源按已有100票上限分块规范化,不逐托或逐批读取上下文。 */ @Transactional(readOnly=true) public Map batchReferences(List input) { @@ -32,8 +33,9 @@ public class AppointmentWorkDispatchCandidates { var refs=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentCoverageService.SourceRef(p.customerId(),AppointmentWorkPhysicalSources.ref(canonical,p).sourceKey(),p.destinationKey())).distinct().toList(); if(refs.size()>20000) throw unavailable("候选逐票来源超过完整读取上限"); var orders=physical.stream().filter(p->p.destinationKey().startsWith("FBA:")).map(p->new AppointmentWorkDispatchMapper.Physical(p.orderId(),p.customerId(),p.destinationKey(),null,null)).distinct().toList(); - var batches=refs.isEmpty()?List.of():mapper.candidateBatches(refs,orders); + var batches=refs.isEmpty()?List.of():mapper.candidateBatches(refs,orders,-1); if(batches.size()>20000) throw unavailable("候选原批次超过完整读取上限"); + var confirmations=availability.snapshots(batches.stream().map(AppointmentWorkDispatchMapper.CandidateBatch::batchKey).toList()); var output=new LinkedHashMap(); for(var identity:identities) { var selected=rows.stream().filter(r->r.planPalletId()==identity.planPalletId() && identity.customerId().equals(r.customerId())).toList(); @@ -46,15 +48,16 @@ public class AppointmentWorkDispatchCandidates { if(!row.destinationKey().startsWith("FBA:")) continue; var source=AppointmentWorkPhysicalSources.ref(canonical,new AppointmentWorkDispatchMapper.Physical(row.orderId(),row.customerId(),row.destinationKey(),row.plannedPackages(),row.countedPackages())).sourceKey(); var matched=batches.stream().filter(b->identity.customerId().equals(b.customerId()) && (source.equals(b.sourceKey()) || row.orderId().equals(b.orderId())) && row.destinationKey().equals(b.destinationKey())).toList(); - if(matched.isEmpty()) {unassigned=true;continue;} + if(matched.isEmpty()) {unassigned=true;reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车");continue;} related=true;long available=0;var seen=new HashSet(); for(var b:matched) { if(!source.equals(b.sourceKey())) {reasons.add("原预约逐票来源与当前真实规范不一致,请先核对原批次");continue;} if(b.batchKey()==null || !seen.add(b.batchKey()) || b.batchNo()==null || b.batchNo().isBlank() || b.recordVersion()==null || b.recordVersion()<0 || b.packages()==null || b.packages()<=0 || b.allocated()<0 || b.allocated()>b.packages()) {reasons.add("原预约批次版本、逐票分配或占用不完整");continue;} - int remaining=Math.toIntExact(b.packages()-b.allocated());available=Math.addExact(available,remaining); + int confirmed=availability.confirmedPackages(confirmations.get(b.batchKey()),b.customerId(),source,row.destinationKey()); + int remaining=Math.toIntExact(Math.max(0,Math.min(b.packages(),confirmed)-b.allocated()));available=Math.addExact(available,remaining); if(remaining>0) references.computeIfAbsent(List.of(b.batchKey(),b.batchNo(),b.recordVersion().toString(),b.customerId()),ignored->new ArrayList<>()).add(new BatchSource(source,row.destinationKey(),Math.min(remaining,count))); } - if(availablenew BatchReference(e.getKey().get(0),e.getKey().get(1),Long.valueOf(e.getKey().get(2)),e.getKey().get(3),List.copyOf(e.getValue()))).toList(); @@ -77,6 +80,7 @@ public class AppointmentWorkDispatchCandidates { // Receiving outcome does not release this batch's cargo or decide another customer's physical eligibility. if(!appointment.replacementBatchKeys().isEmpty()) return new Result(batch,appointment.version(),List.of(),List.of("原批次已替代,不能选择实物")); var candidates=mapper.candidates(appointment.id()); + var confirmation=availability.snapshots(List.of(batch)).get(batch); if(candidates.size()>500) throw conflict("本批实物超过500托,请先按原预约分批"); var output=new ArrayList(); for(var candidate:candidates) { @@ -93,6 +97,7 @@ public class AppointmentWorkDispatchCandidates { if(item==null) { reasons.add("托盘含本批之外的货物,请先拆托或选择完整的关联批次"); continue; } long used=mapper.allocated(batch,part.customerId(),ref.sourceKey(),part.destinationKey(),-1); if(item.packageCount()==null || used+count>item.packageCount()) reasons.add("本托货量超过该来源本批剩余可排量,请先核对分批或拆托"); + if(used+count>availability.confirmedPackages(confirmation,part.customerId(),ref.sourceKey(),part.destinationKey()))reasons.add("FBA货物须先预约成功并明确送仓时间,再加入排车"); if(appointment.version()==null || appointment.version()<0) throw unavailable("原预约批次版本缺失"); allocations.add(new Allocation(candidate.planPalletId(),batch,appointment.version().longValue(),part.customerId(),ref.sourceKey(),part.destinationKey(),count)); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index 1dd048ce0..a1cd445df 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -18,6 +18,26 @@ public interface AppointmentWorkDispatchMapper { record ReferenceRow(long dispatchId,String batchKey,String batchNo,Long recordVersion,String customerId,String sourceKey,String destinationKey,int packages) { } record CandidateSource(long planPalletId,String customerId,String sourceStage,boolean locked,Long orderId,String destinationKey,Integer plannedPackages,Integer countedPackages) { } record CandidateBatch(String batchKey,String batchNo,Long recordVersion,String customerId,Long orderId,String sourceKey,String destinationKey,Integer packages,long allocated) { } + record AllocationPhysical(long planPalletId,String customerId,Long orderId,String orderNo,String inboundCode,String destinationKey,Integer packages) { } + /** 来源展示使用业务编号;逐票数量按本车原计划或实收阶段读取。 */ + @Select(""" + + """) + List allocationPhysical(@Param("dispatchId") long dispatchId,@Param("items") List items); /** 一次读取整页逐票来源及占用;仅预留但未收货的托盘仍按计划箱数预排。 */ @Select(""" """) - List candidateBatches(@Param("refs") List refs,@Param("orders") List orders); + List candidateBatches(@Param("refs") List refs,@Param("orders") List orders,@Param("excludedDispatchId") long excludedDispatchId); @Select(""" ") + List receiptDispatches(@Param("ids") List planIds); + + record ReceiptDispatch(String dispatchNo, Long tripId, Long id) { } + /** 平台整车读取全客户明细,后续库存与费用仍按每条真实客户执行。 */ @Select("SELECT * FROM ll_dispatch_item WHERE dispatch_order_id=#{dispatchOrderId} ORDER BY customer_id,id") List findWholeDispatch(@Param("dispatchOrderId") Long dispatchOrderId); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java index 80fff1894..5b045037b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/mapper/DispatchSourceMapper.java @@ -21,6 +21,24 @@ public interface DispatchSourceMapper { record OrderReference(Long orderId,String customerId) { } record ItemPicked(Long itemId,long packages) { } + /** 收货交接只读已确认逐票实收,不以可用库存、出货资格或计划均分反推箱数。 */ + @Select(""" + SELECT p.id AS planPalletId,p.plan_id AS planId,p.customer_id AS customerId, + p.warehouse_pallet_id AS warehousePalletId,p.pallet_no AS palletNo, + CAST(ip.overseas_warehouse_id AS CHAR(128)) AS overseasWarehouseId, + MIN(l.destination_type) AS destinationType,MIN(l.fba_warehouse_code) AS fbaWarehouseCode, + p.delivery_address AS deliveryAddress,SUM(a.counted_package_count) AS packageCount,'RECEIVED' AS sourceStage + FROM ll_inbound_plan_pallet p JOIN ll_inbound_plan ip ON ip.id=p.plan_id AND ip.customer_id=p.customer_id + JOIN ll_inbound_plan_pallet_allocation a ON a.pallet_id=p.id AND a.customer_id=p.customer_id + JOIN ll_inbound_plan_line l ON l.id=a.line_id AND l.customer_id=a.customer_id + WHERE p.id=#{id} AND p.customer_id=#{customer} AND ip.status='COMPLETED' AND p.warehouse_pallet_id IS NOT NULL + GROUP BY p.id,p.plan_id,p.customer_id,p.warehouse_pallet_id,p.pallet_no,ip.overseas_warehouse_id,p.delivery_address + HAVING COUNT(*)=COUNT(a.counted_package_count) AND COUNT(DISTINCT l.destination_type)=1 + AND COUNT(DISTINCT COALESCE(l.fba_warehouse_code,''))=1 + """) + @Options(useCache=false,flushCache=Options.FlushCachePolicy.TRUE) + List confirmedPlan(@Param("customer") String customer,@Param("id") long id); + /** 全车普通来源剩余箱数依据实际扫码,不以当前聚合库存反推选中份额。 */ @Select("SELECT i.id AS itemId,COALESCE(SUM(p.packages),0) AS packages FROM ll_dispatch_item i LEFT JOIN ll_dispatch_pda_pick p " + "ON p.dispatch_item_id=i.id AND p.customer_id=i.customer_id WHERE i.dispatch_order_id=#{dispatch} GROUP BY i.id") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java index f98e11c32..26aaea617 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchAggregateRepository.java @@ -42,6 +42,10 @@ public class DispatchAggregateRepository { public List summaries(DispatchAggregateMapper.Search query) { return mapper.summaries(query); } /** 按整车读取全部真实客户明细。 */ public List cargo(long dispatchId) { return items.findWholeDispatch(dispatchId); } + /** 只读取本次确认入库的原计划关联排车。 */ + public List receiptDispatches(List planIds) { + return planIds.isEmpty()?List.of():items.receiptDispatches(planIds).stream().map(DispatchItemMapper.ReceiptDispatch::dispatchNo).toList(); + } /** 保存已核对真实客户与仓库的来源快照。 */ public int insertItem(DispatchItem item) { return items.insert(item); } /** 未执行明细才可移除,资格和根版本由应用层保证。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java index bf2d59126..3e7f1328c 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/repository/DispatchSourceRepository.java @@ -66,6 +66,8 @@ public class DispatchSourceRepository { public DispatchSourceMapper.RelabelBasis relabelBasis(String no) { return source.relabelBasis(no); } /** 取得已有普通计划候选,保留原来源资格SQL。 */ public List selectedPlan(String customer, Long id) { return plans.readSelected(customer, List.of(id)); } + /** 确认入库直接读取逐票实收,与可用、暂扣等出货资格分离。 */ + public List confirmedPlan(String customer,long id) { return source.confirmedPlan(customer,id); } /** 取得已有真实退回候选。 */ public List selectedReturn(String customer, Long id) { return returns.readSelected(customer, List.of(id)); } /** 同托真实来源订单组成。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java index a2c6ba90e..d3781bfab 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchAggregateService.java @@ -693,9 +693,30 @@ public class DispatchAggregateService implements DispatchExecutionPort,DispatchA var facts=new TreeMap();facts.put("cargoHash",row.getCargoHash());facts.put("resources",read(row.getResourcesJson(),Resources.class)); facts.put("plannedDepartureAt",row.getPlannedDepartureAt());facts.put("estimatedArrivalAt",row.getEstimatedArrivalAt()); if("PICKUP".equals(row.getTaskType()))facts.put("route",transportTasks.transport(row.getDispatchNo(),false).getStopsJson()); - else {facts.put("batches",repository.batches(row.getId()));facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo()));} + else { + var batches=json.valueToTree(repository.batches(row.getId())); + // 客户仓的DISPATCH引用属于本车自身,扫码递增公共版本不代表预约或货物依据变化。 + for(var batch:batches)if(("DISPATCH:"+row.getId()).equals(batch.path("batchKey").asText())) + ((com.fasterxml.jackson.databind.node.ObjectNode)batch).remove("recordVersion"); + facts.put("batches",batches);facts.put("route",fleet.dispatchStops(row.getDispatchNo()));facts.put("receiving",appointments.reviewEvidence(row.getDispatchNo())); + } return hashes.source(facts); } + /** 交接前记住原审核依据是否仍有效,不借实收替资源或预约变化重新背书。 */ + boolean currentReceiptBasis(DispatchAggregate row) { + return row.getReviewBasisHash()!=null && Objects.equals(row.getReviewBasisHash(),basis(row)); + } + + /** 自然实收只延续仍有效的原审核依据,失效审核继续保持失效。 */ + void saveReceivedCargo(DispatchAggregate row,String operator,LocalDateTime at,boolean preserveReview) { + long version=row.getRecordVersion(); + if(preserveReview)row.setReviewBasisHash(basis(row)); + row.setRecordVersion(Math.addExact(version,1));row.setUpdatedAt(at); + if(repository.saveState(row,version)!=1)throw conflict("收货交接期间排车版本已变化"); + if(repository.history(IdWorker.getId(),row,"RECEIVING_COMPLETE",operator,"按确认入库的逐托实收交接原预排",write(row),at)!=1) + throw conflict("排车实收交接历史未保存"); + } + /** 作废原因进入两级显式记录,旧审核证据仍保留在不可变历史中。 */ private void invalidate(DispatchAggregate row,String reason) { row.setReviewState(ReviewState.DRAFT.name());row.setReviewBasisHash(null); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java new file mode 100644 index 000000000..d07d57d93 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptService.java @@ -0,0 +1,111 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.lastleg.dispatch.entity.DispatchItem; +import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; +import com.baiyue.lastleg.dispatch.repository.*; +import com.baiyue.lastleg.fleet.trip.FleetTripExecutionService; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.*; +import org.springframework.transaction.support.*; +import java.time.LocalDateTime; +import java.util.*; + +/** 确认入库时完成原预排实收交接,出货只执行已确认的逐托数量。 */ +@Service +@Transactional(propagation=Propagation.MANDATORY) +public class DispatchInboundReceiptService implements InboundDispatchReceiptGateway { + private final DispatchAggregateRepository repository; + private final DispatchSourceRepository sources; + private final DispatchPickingRepository picking; + private final DispatchPhysicalClaimService physicalClaims; + private final DispatchAggregateService workflow; + private final FleetTripExecutionService fleet; + private final InboundDispatchReceiptGateway.Appointments appointments; + private final Object locksKey=new Object(); + + /** 使用原排车、实物占用及预约边界,不新增库存出库动作。 */ + public DispatchInboundReceiptService(DispatchAggregateRepository repository, DispatchSourceRepository sources, + DispatchPickingRepository picking, + DispatchPhysicalClaimService physicalClaims, DispatchAggregateService workflow, FleetTripExecutionService fleet, + InboundDispatchReceiptGateway.Appointments appointments) { + this.repository=repository;this.sources=sources;this.picking=picking; + this.physicalClaims=physicalClaims;this.workflow=workflow;this.fleet=fleet;this.appointments=appointments; + } + + /** 先车辆父锁、排车主锁和预约锁,再由收货链路锁来源与库存。 */ + @Override public void lock(List planIds) { + var numbers=repository.receiptDispatches(planIds); + @SuppressWarnings("unchecked") var previous=(Set)TransactionSynchronizationManager.getResource(locksKey); + if(previous!=null) { + if(!previous.containsAll(numbers))throw conflict("收货期间关联排车已变化,请刷新后重新确认入库"); + return; + } + if(!numbers.isEmpty())fleet.lockExistingDispatchShells(numbers); + for(var row:repository.findMany(numbers).stream().sorted(Comparator.comparing(com.baiyue.lastleg.dispatch.entity.DispatchAggregate::getId)).toList())repository.lock(row.getDispatchNo()); + appointments.lock(numbers); + TransactionSynchronizationManager.bindResource(locksKey,Set.copyOf(numbers)); + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + /** 事务完成后清除本次收货持锁范围。 */ + @Override public void afterCompletion(int status) { TransactionSynchronizationManager.unbindResourceIfPossible(locksKey); } + }); + } + + /** 实收确认、原排车数量及预约交接随同一收货事务提交或回滚。 */ + @Override public void complete(List planIds, String operator, LocalDateTime at) { + var numbers=repository.receiptDispatches(planIds); + @SuppressWarnings("unchecked") var locked=(Set)TransactionSynchronizationManager.getResource(locksKey); + if(locked==null || !locked.containsAll(numbers))throw conflict("收货前未锁齐原排车,请刷新后重新确认入库"); + for(var number:numbers) { + var row=repository.lock(number); + if(row==null || !"ACTIVE".equals(row.getStatus()))continue; + var items=repository.cargo(row.getId()); + var pending=items.stream().filter(i->planIds.contains(i.getPlanId()) && i.getWarehousePalletId()==null).toList(); + if(pending.isEmpty())continue; + if(!Set.of("BEFORE_LOADING","LOADING").contains(row.getStage()))throw conflict("原排车已进入出货后阶段,无法确认实收交接"); + boolean preserveReview=workflow.currentReceiptBasis(row); + var received=new LinkedHashMap(); + for(var item:pending) { + var actual=DispatchReceivedPlan.resolve(row.getOverseasWarehouseId(),item,sources) + .orElseThrow(()->conflict("实收托盘与原排车来源或占用不一致,请核对后确认入库")); + received.put(item,actual); + } + for(var entry:received.entrySet()) { + entry.getKey().setWarehousePalletId(entry.getValue().warehousePalletId()); + entry.getKey().setPackageCount(entry.getValue().packageCount()); + } + for(var item:pending.stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { + if(sources.lockPlan(item.getCustomerId(),item.getPlanPalletId())==null)throw conflict("原计划来源已变化"); + var fresh=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId()); + if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(item,fresh.get(0)) + || !Objects.equals(item.getWarehousePalletId(),fresh.get(0).warehousePalletId()) + || !Objects.equals(item.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请重新确认入库"); + } + var pallets=pending.stream().map(DispatchItem::getWarehousePalletId).distinct().sorted().toList(); + var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),row.getId(),null,null,operator,null),pallets,at); + for(var entry:received.entrySet()) { + var item=entry.getKey();var actual=entry.getValue(); + if(sources.materialize(row.getId(),item.getCustomerId(),item.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1) + throw conflict("原排车托盘占用已变化,入库交接未保存"); + item.setPalletNo(actual.palletNo());item.setSourceStage("RECEIVED"); + if(picking.materialize(item)!=1)throw conflict("原排车逐托实收数量未保存"); + } + appointments.complete(row.getId(),pending.stream().map(i->new InboundDispatchReceiptGateway.Item(i.getId(),i.getCustomerId(),i.getPlanPalletId(),i.getPackageCount())).toList()); + int total=Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum()); + if(total!=row.getPackageCount()) { + var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop( + i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key())) + .collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); + fleet.updateDispatchCargo(number,total,stops); + } + row.setPackageCount(total); + row.setPalletCount(Math.toIntExact(items.stream().map(i->i.getWarehousePalletId()==null?"PLAN:"+i.getPlanPalletId():"PALLET:"+i.getWarehousePalletId()).distinct().count())); + workflow.saveReceivedCargo(row,operator,at,preserveReview); + } + } + + /** 来源变化必须使收货交接整体失败,不留下半份运输事实。 */ + private static BizException conflict(String message) { return new BizException(ResultCode.CONFLICT,message); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java index 0272daf1a..e32cfe195 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPdaQueryService.java @@ -16,8 +16,6 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; @Service public class DispatchPdaQueryService { @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchItemSourceRepository sourceOrigins; - @jakarta.annotation.Resource private com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository sources; - @jakarta.annotation.Resource private DispatchService sourceRules; private final DispatchPdaReadRepository repository;private final DispatchAggregateService workflow; /** 共用主流程资格,PDA不维护第二套版本和阶段。 */ public DispatchPdaQueryService(DispatchPdaReadRepository repository,DispatchAggregateService workflow){this.repository=repository;this.workflow=workflow;} @@ -30,7 +28,7 @@ public class DispatchPdaQueryService { require(actor);if(number==null || number.isBlank())throw new BizException(ResultCode.BAD_REQUEST,"请输入排车单号"); var rows=repository.headers(actor.boundWarehouseId(),number,null);if(rows.size()!=1)throw new BizException(ResultCode.NOT_FOUND,"当前绑定仓无此排车作业");return assemble(actor,rows).get(0); } - /** 已绑定实托批量读取;尚未绑定的原PLAN仅在本次只读快照内核对自然实收。 */ + /** 按收货已确认的实托批量读取;未交接的原PLAN不推算实收仓位或箱数。 */ private List assemble(Actor actor,List roots) { if(roots.isEmpty())return List.of();if(roots.size()>200)throw new BizException(ResultCode.BAD_REQUEST,"作业超过完整读取上限,请按阶段筛选或扫描具体排车单号"); var ids=roots.stream().map(DispatchAggregate::getId).toList();var cargo=repository.items(ids).stream().collect(Collectors.groupingBy(DispatchItem::getDispatchOrderId)); @@ -61,8 +59,6 @@ public class DispatchPdaQueryService { var marks=Arrays.stream(Objects.requireNonNullElse(item.getCartonMarks(),"").split(",")).map(String::trim).filter(s->!s.isEmpty()).collect(Collectors.toSet()); var sourcePositions=positions.getOrDefault(item.getCustomerId()+":"+item.getWarehousePalletId(),List.of()).stream() .map(p->new DispatchViews.Position(p.getId(),p.getBalanceId(),n(p.getAvailablePackages()),n(p.getAllocatedPackages()),n(p.getPickedPackages()))).toList(); - if(item.getWarehousePalletId()==null && item.getPlanPalletId()!=null) - sourcePositions=receivedPlanPositions(actor,item,items); if(normalRelabel(item)) { var contributions=byItem.getOrDefault(item.getId(),List.of());var ownPositions=new ArrayList(); if(contributions.isEmpty() || contributions.stream().mapToLong(s->n(s.getPackageCount())).sum()!=n(item.getPackageCount()))sourceValid=false; @@ -95,14 +91,6 @@ public class DispatchPdaQueryService { } return List.copyOf(result); } - /** 预排首次扫码前只解析原计划实收仓位,不在GET补写实托;真正绑定仍由pick锁内重验。 */ - private List receivedPlanPositions(Actor actor,DispatchItem item,List items) { - var received=DispatchReceivedPlan.resolve(actor.boundWarehouseId(),item,items,sources,sourceRules); - if(received.isEmpty())return List.of(); - // 只修改本次投影对象,不在GET更新排车、版本或库存。 - item.setPackageCount(received.get().source().packageCount()); - return received.get().positions(); - } /** 仓和操作者均来自受信身份,不接收请求自报。 */ private static void require(Actor actor){if(actor==null || actor.mode()!=ActorMode.PDA || actor.operatorId()==null || actor.boundWarehouseId()==null)throw new BizException(ResultCode.FORBIDDEN,"仅绑定仓作业人员可读取PDA作业");} /** 缺失计数不作为业务标识使用。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java index 79dd6bc11..9faad5cba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchPickingService.java @@ -12,7 +12,6 @@ import com.baiyue.lastleg.inventory.command.dto.InventoryCommandRequests; import com.baiyue.lastleg.inventory.command.service.InventoryCommandService; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.*; import java.time.*; @@ -24,10 +23,8 @@ import static com.baiyue.lastleg.dispatch.port.DispatchTypes.*; /** 新整车PDA实际扫码入口,真实客户从明细解析,库存SHIP只在成功扫码事务发生一次。 */ @Service public class DispatchPickingService { - @jakarta.annotation.Resource private DispatchPhysicalClaimService physicalClaims; @jakarta.annotation.Resource private DispatchItemSourceService relabelSources; @jakarta.annotation.Resource private DispatchService sourceRules; - @jakarta.annotation.Resource private com.baiyue.lastleg.fleet.trip.FleetTripExecutionService fleet; private final DispatchAggregateService workflow;private final DispatchAggregateRepository repository;private final DispatchSourceRepository sources; private final DispatchPickingRepository picking;private final DispatchPickOrderAttributionService attribution; private final InventoryCommandService inventory;private final ReturnRelabelDispatchStock returns; @@ -53,7 +50,8 @@ public class DispatchPickingService { var currentItems=repository.cargo(row.getId()); var item=currentItems.stream().filter(i->Objects.equals(i.getId(),itemId)).findFirst().orElseThrow(()->new BizException(ResultCode.NOT_FOUND,"本车货物明细不存在")); if(!Objects.equals(item.getOverseasWarehouseId(),actor.boundWarehouseId()))throw conflict("本货物不属于当前绑定仓"); - materialize(row,item,actor,currentItems); + if(item.getWarehousePalletId()==null)throw conflict("该托盘尚未完成收货交接,请先确认入库后再拣货"); + relabelSources.validateFrozen(row,currentItems,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),currentItems)); var task=picking.task(row.getId());var at=LocalDateTime.ofInstant(Instant.now().truncatedTo(ChronoUnit.MILLIS),ZoneOffset.UTC); if(task==null) { task=new DispatchPdaTask();task.setId(IdWorker.getId());task.setDispatchOrderId(row.getId()); @@ -103,45 +101,6 @@ public class DispatchPickingService { new Counts(row.getItemCount(),row.getPalletCount(),row.getPackageCount(),row.getPickedPackageCount(),row.getShippedPackageCount()),new DispatchResults.Picked(itemId,request.positionId(),returnCartonId,request.packages(),before+request.packages(),complete),false,false); commands.append(row.getId(),actor,identity,result);return result; } - /** 首拣将本车已实收原计划按真实整托数量绑定,保持原审核身份并与扣货同事务回滚。 */ - private void materialize(com.baiyue.lastleg.dispatch.entity.DispatchAggregate row,DispatchItem item,Actor actor,List items) { - long dispatch=row.getId(); - if(item.getWarehousePalletId()!=null) { - relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items));return; - } - var received=new LinkedHashMap(); - for(var candidate:items)DispatchReceivedPlan.resolve(actor.boundWarehouseId(),candidate,items,sources,sourceRules) - .ifPresent(fact->received.put(candidate,fact)); - if(!received.containsKey(item))throw conflict("该托盘尚未完成可拣实收,或原来源、目的地、库存占用已变化,请刷新核对"); - // 同车同实托的多个原计划一次绑定,避免首拣把其他已审核成员误判为新增货物。 - for(var entry:received.entrySet()) { - entry.getKey().setWarehousePalletId(entry.getValue().source().warehousePalletId()); - entry.getKey().setPackageCount(entry.getValue().source().packageCount()); - } - relabelSources.validateFrozen(row,items,sourceRules.lockExecutionSources(row.getOverseasWarehouseId(),items)); - for(var candidate:received.keySet().stream().sorted(Comparator.comparing(DispatchItem::getPlanPalletId)).toList()) { - if(sources.lockPlan(candidate.getCustomerId(),candidate.getPlanPalletId())==null)throw conflict("原计划来源已变化"); - var fresh=sources.selectedPlan(candidate.getCustomerId(),candidate.getPlanPalletId()); - if(fresh.size()!=1 || !DispatchReceivedPlan.sameIdentity(candidate,fresh.get(0)) - || !Objects.equals(candidate.getWarehousePalletId(),fresh.get(0).warehousePalletId()) - || !Objects.equals(candidate.getPackageCount(),fresh.get(0).packageCount()))throw conflict("实收托盘已变化,请刷新后重新扫码"); - } - var at=LocalDateTime.ofInstant(Instant.now(),ZoneOffset.UTC); - var pallets=items.stream().map(DispatchItem::getWarehousePalletId).filter(Objects::nonNull).distinct().sorted().toList(); - var physical=physicalClaims.reserve(new DispatchPhysicalClaimService.Owner(row.getOverseasWarehouseId(),dispatch,null,null,actor.operatorId(),null),pallets,at); - for(var entry:received.entrySet()) { - var candidate=entry.getKey();var actual=entry.getValue().source(); - try{if(sources.materialize(dispatch,candidate.getCustomerId(),candidate.getPlanPalletId(),actual.warehousePalletId(),physical.get(actual.warehousePalletId()),at)!=1)throw conflict("原计划来源占用已变化");} - catch(DuplicateKeyException occupied){throw conflict("实托已经被其他排车占用");} - candidate.setPalletNo(actual.palletNo());candidate.setSourceStage("RECEIVED"); - if(picking.materialize(candidate)!=1)throw conflict("本来源实际实托未保存"); - } - row.setPackageCount(Math.toIntExact(items.stream().mapToLong(DispatchItem::getPackageCount).sum())); - var stops=items.stream().map(i->new com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop( - i.getDestinationWarehouseName(),i.getDeliveryAddress(),workflow.destination(i).key())) - .collect(java.util.stream.Collectors.toMap(com.baiyue.lastleg.fleet.trip.FleetExecutionModels.DispatchStop::destinationKey,s->s,(a,b)->a,TreeMap::new)).values().stream().toList(); - fleet.updateDispatchCargo(row.getDispatchNo(),row.getPackageCount(),stops); - } /** 库存幂等键分别标识一次实际动作,业务单号始终使用本车号。 */ private static InventoryCommandRequests.Move move(DispatchCommands.Pick request,String dispatch,String reference,String key){return new InventoryCommandRequests.Move(request.positionId(),request.packages(),reference,dispatch,null,key,request.remark());} /** 不可变扫码证据序列化失败随根命令回滚。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java index 2eb896b59..b23211c9d 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/dispatch/service/DispatchReceivedPlan.java @@ -2,45 +2,23 @@ package com.baiyue.lastleg.dispatch.service; import com.baiyue.lastleg.dispatch.entity.DispatchItem; import com.baiyue.lastleg.dispatch.model.DispatchPalletCandidate; -import com.baiyue.lastleg.dispatch.port.*; import com.baiyue.lastleg.dispatch.repository.DispatchSourceRepository; import java.util.*; -import static com.baiyue.lastleg.dispatch.port.DispatchTypes.SourceType; -/** 预排来源只冻结货物身份;自然实收的逐托箱数由真实库存提供,查询和首拣共用此规则。 */ +/** 预排来源只冻结货物身份;自然实收的逐托箱数在确认入库时由真实库存提供。 */ final class DispatchReceivedPlan { - record Fact(DispatchPalletCandidate source, List positions) { } - - /** 仅解析原车完整来源,禁止把新客户、新订单或冻结货物混入实收数量。 */ - static Optional resolve(String warehouse, DispatchItem item, List items, - DispatchSourceRepository sources, DispatchService rules) { + /** 原客户、计划及目的地保持一致,数量直接取确认入库的逐票实收。 */ + static Optional resolve(String warehouse, DispatchItem item, DispatchSourceRepository sources) { if(item.getWarehousePalletId()!=null || item.getPlanPalletId()==null || !Objects.equals(warehouse,item.getOverseasWarehouseId()))return Optional.empty(); - if(!sources.receivedPlanCompleted(item.getCustomerId(),item.getPlanPalletId()))return Optional.empty(); - var rows=sources.selectedPlan(item.getCustomerId(),item.getPlanPalletId()); + var rows=sources.confirmedPlan(item.getCustomerId(),item.getPlanPalletId()); if(rows.size()!=1)return Optional.empty(); var actual=rows.get(0); if(!sameIdentity(item,actual) || actual.packageCount()==null || actual.packageCount()<=0)return Optional.empty(); var original=sources.sourceOrders(item.getCustomerId(),null,item.getPlanPalletId()); if(original.isEmpty() || !new HashSet<>(original).containsAll( sources.sourceOrders(item.getCustomerId(),actual.warehousePalletId(),item.getPlanPalletId())))return Optional.empty(); - var group=rules.selectionGroup(warehouse,new DispatchCommands.CargoSelection( - SourceType.INBOUND_PLAN,item.getPlanPalletId(),null,null,actual.warehousePalletId(),null)); - if(!group.complete() || group.sources().stream().noneMatch(s->Objects.equals(s.sourceKey(),item.getSourceKey())))return Optional.empty(); - for(var member:group.sources()) { - var selected=items.stream().filter(i->Objects.equals(i.getSourceKey(),member.sourceKey())).findFirst().orElse(null); - if(selected==null || !Objects.equals(selected.getCustomerId(),member.customerId()))return Optional.empty(); - // 原计划的估算箱数不限制实收;已经绑定或换标的冻结贡献仍严格核对。 - if((selected.getWarehousePalletId()!=null || selected.getPlanPalletId()==null) - && (selected.getPackageCount()==null || selected.getPackageCount().longValue()!=member.packages()))return Optional.empty(); - } - var stock=sources.stock(item.getCustomerId(),actual.warehousePalletId(),false); - if(stock.stream().anyMatch(p->!Objects.equals(p.warehouseId(),warehouse) - || !Objects.equals(p.palletId(),actual.warehousePalletId()) || p.holdPackages()!=0 - || p.allocatedPackages()!=0 || p.pickedPackages()!=0) - || stock.stream().mapToLong(p->p.availablePackages()).sum()!=actual.packageCount())return Optional.empty(); - return Optional.of(new Fact(actual,stock.stream().filter(p->p.availablePackages()>0) - .map(p->new DispatchViews.Position(p.id(),p.balanceId(),p.availablePackages(),p.allocatedPackages(),p.pickedPackages())).toList())); + return Optional.of(actual); } /** 箱数允许随自然实收变化,客户、原计划、起运仓和目的地必须保持原身份。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java new file mode 100644 index 000000000..18257f5bd --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/InboundDispatchReceiptGateway.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.pda.gateway; + +import java.time.LocalDateTime; +import java.util.List; + +/** 收货确认在原事务内交接真实托盘,预排估算不进入出货执行。 */ +public interface InboundDispatchReceiptGateway { + /** 收货锁来源前,按固定顺序锁齐已有车辆、排车及预约。 */ + void lock(List planIds); + + /** 库存与计划完成后,按实收逐托数量更新原运输安排,不扣出货库存。 */ + void complete(List planIds, String operatorId, LocalDateTime at); + + /** 只传同一客户原计划的真实托盘与数量,不传推算的逐票分摊。 */ + record Item(long itemId, String customerId, long planPalletId, int packages) { } + + /** 预约域负责核对原批次归属及实收来源,排车域不直接修改预约账本。 */ + interface Appointments { + /** 在库存来源锁之前锁齐全部原预约。 */ + void lock(List dispatchNumbers); + + /** 逐票采用已确认实收,禁止对多批次来源猜测分配。 */ + void complete(long dispatchId, List items); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java index ac1bc3d3a..dbd9b4615 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/repository/PdaWorkflowRepository.java @@ -37,6 +37,13 @@ public class PdaWorkflowRepository { @Resource private InboundPlanCartonMapper cartons; @Resource private LastlegOrderMapper orders; + /** 收货完成前只读计划身份,运输父锁取得后再加来源行锁。 */ + public InboundPlan findPlan(String customerId, String planNo) { + return plans.selectOne(new LambdaQueryWrapper() + .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo)); + } + + /** 父运输锁完成后锁定原客户入仓计划。 */ public InboundPlan lockPlan(String customerId, String planNo) { return plans.selectOne(new LambdaQueryWrapper() .eq(InboundPlan::getCustomerId, customerId).eq(InboundPlan::getPlanNo, planNo).last("FOR UPDATE")); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java index 2f955b56b..bcd469f2a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowService.java @@ -55,6 +55,7 @@ public class InboundPdaWorkflowService { @Resource private PdaWorkflowRepository repository; @Resource private WarehouseResourceGateway warehouse; @Resource private InboundStockReceiptService stockReceipts; + @Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts; @Resource private InboundUnloadCompletedOutboxService unloadCompletedOutbox; @Resource private LastlegOrderMapper orders; @Resource private UnloadPhotoFileGateway unloadPhotoFiles; @@ -544,16 +545,21 @@ public class InboundPdaWorkflowService { return result; } + /** 单票确认入库也先锁运输父记录,再复用整柜实收交接事务。 */ @Transactional public PdaResponses.Task complete(String customerId, String planNo) { + InboundPlan original = repository.findPlan(customerId, planNo); + if (original == null) throw conflict("入仓计划不存在"); + dispatchReceipts.lock(List.of(original.getId())); InboundPlan plan = requirePlan(customerId, planNo); return completeContainer(List.of(plan)).get(0); } - /** Completes every plan of one physical container and emits exactly one container-level outbox event. */ + /** 整柜确认实收、库存及原预排交接,并且仅发布一次柜级卸货事件。 */ @Transactional public List completeContainer(List containerPlans) { if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); + dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList()); List orderedPlans = containerPlans.stream().sorted(Comparator.comparing(InboundPlan::getId)).toList(); String containerNo = orderedPlans.get(0).getContainerNo(); if (containerNo == null || containerNo.isBlank() || orderedPlans.stream().anyMatch(plan -> @@ -569,6 +575,8 @@ public class InboundPdaWorkflowService { LocalDateTime now = LocalDateTime.now(); for (CompletionContext context : contexts) completePlan(context.plan().getCustomerId(), context, now); + // 实收库存和原预排一起交接,出货时不再补写数量。 + dispatchReceipts.complete(orderedPlans.stream().map(InboundPlan::getId).toList(), contexts.get(0).task().getOperatorId(), now); String sourceContainerBookingId = sourceContainerBookingId(contexts); LocalDateTime photoUploadedAt = contexts.stream().map(context -> context.task().getUnloadPhotoUploadedAt()) .max(LocalDateTime::compareTo).orElseThrow(); @@ -587,6 +595,7 @@ public class InboundPdaWorkflowService { public void completeContainerWithDifferences(List containerPlans, List reconciledPlans) { if (containerPlans == null || containerPlans.isEmpty()) throw conflict("Receiving container has no inbound plans"); + dispatchReceipts.lock(containerPlans.stream().map(InboundPlan::getId).toList()); Set reconciledIds = reconciledPlans.stream().map(InboundPlan::getId).collect(java.util.stream.Collectors.toSet()); LocalDateTime now = LocalDateTime.now(); List allTasks = new ArrayList<>(); @@ -606,6 +615,7 @@ public class InboundPdaWorkflowService { allTasks.add(task); } } + if (!reconciledPlans.isEmpty()) dispatchReceipts.complete(reconciledPlans.stream().map(InboundPlan::getId).toList(), allTasks.get(0).getOperatorId(), now); String containerNo = containerPlans.get(0).getContainerNo(); if (containerPlans.stream().anyMatch(plan -> !Objects.equals(containerNo, plan.getContainerNo()))) { throw conflict("Inbound plans must belong to one physical container"); @@ -678,6 +688,7 @@ public class InboundPdaWorkflowService { return new CompletionContext(plan, task, unloadPhotoFileIds, lines, allocations, cartons); } + /** 按确认的逐托实收入库,完成状态与运输交接属于同一事务。 */ private void completePlan(String customerId, CompletionContext context, LocalDateTime now) { InboundPlan plan = context.plan(); InboundPdaTask task = context.task(); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java index d1ffba223..949763110 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerService.java @@ -84,6 +84,7 @@ public class ReceivingContainerService { "一个托盘最多只能收66箱,请拆分到其他托盘"; @Resource private ReceivingCandidateRepository candidateRepository; + @Resource private com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts; @Resource private InboundPlanMapper plans; @Resource private InboundPlanLineMapper lines; @Resource private InboundPlanPalletMapper pallets; @@ -594,11 +595,15 @@ public class ReceivingContainerService { return result; } + /** 确认拆柜入库前先锁原运输安排;实际数量在本次收货事务完成交接。 */ @Transactional(isolation = Isolation.READ_COMMITTED) public ReceivingContainerResponses.Session complete(String operatorKey, ReceivingContainerRequests.Complete request) { ReceivingContainerOperation replay = replay(operatorKey, "COMPLETE", request.idempotencyKey(), request); if (replay != null) return read(replay, ReceivingContainerResponses.Session.class); + // 在收货会话、订单及库存来源加锁之前,先锁齐原运输父记录。 + ReceivingContainerSession original = requireCurrent(operatorKey, false); + dispatchReceipts.lock(requirePlans(original.getSourceContainerBookingId(), original.getContainerNo()).stream().map(InboundPlan::getId).toList()); ReceivingContainerSession session = requireSigned(operatorKey); List containerPlans = requirePlans(session.getSourceContainerBookingId(), session.getContainerNo()); Totals totals = totals(session, containerPlans); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java new file mode 100644 index 000000000..144d78424 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptDispatchAdapter.java @@ -0,0 +1,68 @@ +package com.baiyue.lastleg.inboundplan.work; + +import com.baiyue.lastleg.appointmentplanning.AppointmentSourceService; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.*; +import java.util.*; +import static com.baiyue.lastleg.appointmentwork.AppointmentWorkPolicy.conflict; + +/** 实收沿原逐票预约归属更新数量,绝不按托数均分货物或新选批次。 */ +@Component +@Transactional(propagation=Propagation.MANDATORY) +public class AppointmentReceiptDispatchAdapter implements InboundDispatchReceiptGateway.Appointments { + private final AppointmentReceiptRepository repository; + private final AppointmentWorkDispatchBinding binding; + private final InboundFbaAppointmentService appointments; + private final AppointmentSourceService sources; + + /** 复用原批次锁和真实来源;收货记录不依赖预约是否已满足出货资格。 */ + public AppointmentReceiptDispatchAdapter(AppointmentReceiptRepository repository, AppointmentWorkDispatchBinding binding, + InboundFbaAppointmentService appointments, AppointmentSourceService sources) { + this.repository=repository;this.binding=binding;this.appointments=appointments;this.sources=sources; + } + + /** 收货前按业务批次顺序锁齐原有效预约,避免库存与预约反向取锁。 */ + @Override public void lock(List numbers) { + var versions=new TreeMap(); + for(var number:numbers) for(var row:repository.bindings(number)) + if(!row.batchKey().startsWith("DISPATCH:"))versions.computeIfAbsent(row.batchKey(),this::version); + binding.lockBatches(versions); + } + + /** 实收来源必须与原绑定唯一对应,多预约分摊不明确时拒绝猜测。 */ + @Override public void complete(long dispatch, List items) { + var previous=repository.bindings(dispatch); + for(var item:items) { + var original=previous.stream().filter(r->r.itemId()==item.itemId()).toList(); + var physical=repository.physical(item.planPalletId(),item.customerId()); + var canonical=AppointmentWorkPhysicalSources.canonical(physical,sources); + var quantities=new LinkedHashMap,Integer>(); + for(var part:physical) { + if(part.countedPackages()==null || part.countedPackages()<=0)throw conflict("收货逐票实物箱数尚未确认"); + var key=List.of(AppointmentWorkPhysicalSources.ref(canonical,part).sourceKey(),part.destinationKey()); + quantities.merge(key,part.countedPackages(),Math::addExact); + } + var originalKeys=new HashSet>(); + for(var row:original)originalKeys.add(List.of(row.sourceKey(),row.destinationKey())); + if(!quantities.keySet().equals(originalKeys))throw conflict("实收货物来源与原排车预约不一致,请核对原逐票安排"); + if(quantities.values().stream().mapToLong(Integer::longValue).sum()!=item.packages())throw conflict("逐票实收与托盘确认箱数不一致"); + for(var entry:quantities.entrySet()) { + var matches=original.stream().filter(r->r.sourceKey().equals(entry.getKey().get(0)) && r.destinationKey().equals(entry.getKey().get(1))).toList(); + if(matches.size()!=1)throw conflict("同一实收来源关联多个预约批次,请明确逐票分配,不能自动均分"); + var row=matches.get(0); + if(!row.customerId().equals(item.customerId()) || row.palletId()!=item.planPalletId())throw conflict("原预约客户或托盘归属已变化"); + if(row.packages()!=entry.getValue() && repository.receiveQuantity(row,entry.getValue())!=1)throw conflict("原预约逐托分配已变化,收货交接未保存"); + } + } + binding.receiptQuantitiesChanged(); + } + + /** 使用同事务已锁定的原预约版本,不沿用客户端旧快照。 */ + private long version(String batch) { + var value=appointments.batchContext(batch).appointment().version(); + if(value==null || value<0)throw conflict("原预约批次版本缺失,无法确认收货交接"); + return value.longValue(); + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptRepository.java new file mode 100644 index 000000000..974413588 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentReceiptRepository.java @@ -0,0 +1,25 @@ +package com.baiyue.lastleg.inboundplan.work; + +import org.springframework.stereotype.Repository; +import java.util.List; + +/** 收货交接只读取原排车预约绑定和已确认逐票来源。 */ +@Repository +public class AppointmentReceiptRepository { + private final AppointmentWorkDispatchMapper mapper; + + /** 复用权威预约与托盘分配Mapper。 */ + public AppointmentReceiptRepository(AppointmentWorkDispatchMapper mapper) { this.mapper=mapper; } + + /** 读取本车已选择的批次,不选择新预约。 */ + public List bindings(String number) { return mapper.bindings(number); } + + /** 读取原车逐托绑定,供实收来源逐项匹配。 */ + public List bindings(long id) { return mapper.byDispatch(id); } + + /** 真实逐票数量以收货确认的分配记录为准。 */ + public List physical(long pallet, String customer) { return mapper.physical(pallet,customer); } + + /** 原绑定身份及旧数量同时参与更新,防止覆盖并发改量。 */ + public int receiveQuantity(AppointmentWorkDispatchMapper.Bound original,int actual) { return mapper.receiveQuantity(original,actual); } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java index 3694a66d6..aa4a8f040 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchBinding.java @@ -270,6 +270,9 @@ public class AppointmentWorkDispatchBinding implements AppointmentWorkDispatchPo throw conflict("所选实物已有预约批次,必须明确逐批逐票分配后才能排车或发车"); if(!physical.isEmpty())throw conflict("FBA货物须先预约成功并明确送仓时间,再加入排车"); } + /** 实收只更新原逐票绑定后,仍在业务提交末尾刷新工作视图。 */ + void receiptQuantitiesChanged() { invalidateAtBusinessEnd(); } + /** 调用方已锁主单与原批次,删除只作用于指定实物。 */ public void removeItem(long dispatchId,long itemId) { mapper.removeItem(dispatchId,itemId);invalidateAtBusinessEnd(); } /** 同事务提交前统一失效,避免逐车移货时在后续来源释放前持有guard。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java index a1cd445df..bf0d22b64 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/work/AppointmentWorkDispatchMapper.java @@ -135,5 +135,14 @@ public interface AppointmentWorkDispatchMapper { @Select("SELECT DISTINCT t.trip_id FROM ll_dispatch_appointment_allocation a JOIN ll_fleet_trip_dispatch t ON t.dispatch_id=a.dispatch_id JOIN ll_dispatch_order d ON d.id=a.dispatch_id AND d.status<>'CANCELLED' WHERE a.batch_key=#{batch}") List trips(String batch); @Select("SELECT b.dispatch_id,b.dispatch_item_id item_id,b.plan_pallet_id pallet_id,b.batch_key,b.customer_id,b.source_key,b.destination_key,b.package_count packages FROM ll_dispatch_appointment_allocation b JOIN ll_dispatch_order d ON d.id=b.dispatch_id WHERE d.dispatch_no=#{number}") List bindings(String number); + + /** 实收只改原绑定箱数,原客户、实物、来源及预约批次必须全部一致。 */ + @Update(""" + UPDATE ll_dispatch_appointment_allocation SET package_count=#{actual} + WHERE dispatch_id=#{row.dispatchId} AND dispatch_item_id=#{row.itemId} AND plan_pallet_id=#{row.palletId} + AND batch_key=#{row.batchKey} AND customer_id=#{row.customerId} AND source_key=#{row.sourceKey} + AND destination_key=#{row.destinationKey} AND package_count=#{row.packages} + """) + int receiveQuantity(@Param("row") Bound original,@Param("actual") int actual); @Select("SELECT dispatch_id,dispatch_item_id item_id,plan_pallet_id pallet_id,batch_key,customer_id,source_key,destination_key,package_count packages FROM ll_dispatch_appointment_allocation WHERE dispatch_id=#{id}") List byDispatch(long id); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java new file mode 100644 index 000000000..b51261679 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchInboundReceiptPersistenceTest.java @@ -0,0 +1,211 @@ +package com.baiyue.lastleg.dispatch.service; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; +import com.baiyue.lastleg.appointmentplanning.*; +import com.baiyue.lastleg.appointmentwork.*; +import com.baiyue.lastleg.dispatch.port.*; +import com.baiyue.lastleg.dispatch.mapper.DispatchSourceMapper; +import com.baiyue.lastleg.dispatch.repository.DispatchPickingRepository; +import com.baiyue.lastleg.inboundplan.dto.*; +import com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway; +import com.baiyue.lastleg.inboundplan.receiving.*; +import com.baiyue.lastleg.inboundplan.service.InboundFbaAppointmentService; +import com.baiyue.lastleg.inboundplan.work.*; +import org.junit.jupiter.api.Test; +import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.test.util.ReflectionTestUtils; +import java.time.*; +import java.util.*; +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** 使用真实排车、预约绑定和MyBatis验证收货交接,再连续整托出货;库存外部命令沿原夹具隔离。 */ +class DispatchInboundReceiptPersistenceTest { + /** 直接读取9+11实收,不读10+10计划估算,也不依赖可用库存或出货冻结状态。 */ + @Test void confirmedSourceMapperReadsReceiptCountsWithoutInventoryAvailability() throws Exception { + var ds=new org.springframework.jdbc.datasource.DriverManagerDataSource("jdbc:h2:mem:receipt-source-"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1","sa",""); + var db=new org.springframework.jdbc.core.JdbcTemplate(ds); + db.execute("CREATE TABLE ll_inbound_plan(id BIGINT,customer_id VARCHAR,overseas_warehouse_id BIGINT,status VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet(id BIGINT,plan_id BIGINT,customer_id VARCHAR,warehouse_pallet_id BIGINT,pallet_no VARCHAR,delivery_address VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_line(id BIGINT,customer_id VARCHAR,destination_type VARCHAR,fba_warehouse_code VARCHAR)"); + db.execute("CREATE TABLE ll_inbound_plan_pallet_allocation(pallet_id BIGINT,line_id BIGINT,customer_id VARCHAR,planned_package_count INT,counted_package_count INT)"); + db.update("INSERT INTO ll_inbound_plan VALUES(1,'101',9,'COMPLETED')"); + db.update("INSERT INTO ll_inbound_plan_line VALUES(1,'101','FBA','ONT8')"); + for(int n=1;n<=2;n++) { + db.update("INSERT INTO ll_inbound_plan_pallet VALUES(?,1,'101',?,?,NULL)",n,500+n,"P-"+n); + db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,?)",n,n==1?9:11); + } + var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchSourceMapper.class); + var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(ds);factory.setConfiguration(config);factory.afterPropertiesSet(); + var mapper=new SqlSessionTemplate(factory.getObject()).getMapper(DispatchSourceMapper.class); + assertThat(mapper.confirmedPlan("101",1).get(0).packageCount()).isEqualTo(9); + assertThat(mapper.confirmedPlan("101",2).get(0).packageCount()).isEqualTo(11); + assertThat(mapper.confirmedPlan("other",1)).isEmpty(); + db.update("UPDATE ll_inbound_plan SET status='RECEIVING'"); + assertThat(mapper.confirmedPlan("101",1)).isEmpty(); + } + + /** 入库确认立即将10+10交接为9+11,尚未扫码也已核平预约;出货不再补改数量。 */ + @Test void receiptConfirmsNineAndElevenBeforeAnyPickingAndBothPalletsCanLeave() throws Exception { + var f=new Fixture();f.confirm(); + assertThat(f.quantities()).containsExactly(9,11); + assertThat(f.allocations()).containsExactly(9,11); + assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_pda_pick",Long.class)).isZero(); + assertThat(f.p.f.db.queryForObject("SELECT picked_package_count FROM ll_dispatch_order",Integer.class)).isZero(); + verifyNoInteractions(f.p.inventory); + var before=f.version(); + var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(before,"receipt-nine",11L,9,0,null,null))); + assertThatCode(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).doesNotThrowAnyException(); + var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"receipt-eleven",12L,11,0,null,null))); + assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); + assertThat(f.binding.departureBlockReason(f.p.number)).isNull(); + assertThat(f.quantities()).containsExactly(9,11); + assertThat(f.allocations()).containsExactly(9,11); + verify(f.p.inventory,times(2)).ship(eq("101"),eq("picker"),any()); + } + + /** 重复交接不修改版本、重复占用托盘或新增收货历史。 */ + @Test void confirmedReceiptIsIdempotent() throws Exception { + var f=new Fixture();f.confirm();long version=f.version(); + f.p.f.tx.executeWithoutResult(s->{f.receipt.lock(List.of(1L,2L));f.receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now());}); + assertThat(f.version()).isEqualTo(version); + assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_history WHERE action='RECEIVING_COMPLETE'",Integer.class)).isEqualTo(1); + } + + /** 已因其他事实变化失效的审核,不能在确认入库时被重新放行。 */ + @Test void receiptDoesNotApproveAnAlreadyStaleReview() throws Exception { + var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash='stale-review'"); + f.confirm(); + assertThat(f.p.f.db.queryForObject("SELECT review_basis_hash FROM ll_dispatch_order",String.class)).isEqualTo("stale-review"); + assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("审核依据失效"); + } + + /** 仓库预约回复尚待处理不改变实收数量,收货成功后出货门禁仍明确阻止发运。 */ + @Test void receivingConfirmsFactsWhileAppointmentStillBlocksShipping() throws Exception { + var f=new Fixture();when(f.receiving.hasPending("FBA_APPOINTMENT:1","101")).thenReturn(true); + f.confirm(); + assertThat(f.quantities()).containsExactly(9,11); + assertThat(f.allocations()).containsExactly(9,11); + assertThatThrownBy(()->f.p.f.service.requireReviewAppointment(f.p.f.repository.find(f.p.number))).hasMessageContaining("待仓库确认"); + } + + /** 未关联有效排车的正常收货也可完成,不能要求存在车辆或预约。 */ + @Test void receiptWithoutActiveDispatchDoesNotRequireTransportResources() throws Exception { + var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_order SET status='CANCELLED'"); + assertThatCode(f::confirm).doesNotThrowAnyException(); + assertThat(f.quantities()).containsExactly(10,10); + } + + /** 客户仓沿本车逐票绑定交接9+11,首托扫码递增公共版本不能使第二托审核失效。 */ + @Test void privateWarehouseReceiptAndBothPicksKeepTheSameReview() throws Exception { + var f=new Fixture();long dispatch=f.p.f.repository.find(f.p.number).getId(); + f.p.f.db.update("UPDATE ll_dispatch_item SET destination_type='PRIVATE'"); + f.p.f.db.update("UPDATE ll_inbound_plan_line SET destination_type='PRIVATE'"); + f.p.f.db.update("UPDATE ll_dispatch_appointment_allocation SET batch_key=?,destination_key='CUSTOMER:9001'","DISPATCH:"+dispatch); + f.p.f.candidates.values().forEach(c->c.destinationType("PRIVATE")); + f.p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(f.p.f.service,"basis",f.p.f.repository.find(f.p.number))); + f.confirm(); + var first=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.item,new DispatchCommands.Pick(f.version(),"private-nine",11L,9,0,null,null))); + var second=f.p.f.tx.execute(s->f.p.service.pick(f.p.actor,f.p.number,f.p.secondItem,new DispatchCommands.Pick(first.recordVersion(),"private-eleven",12L,11,0,null,null))); + assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); + assertThat(f.allocations()).containsExactly(9,11); + } + + /** 预约逐票交接失败时,收货数量、库存及排车实托绑定必须整体回滚。 */ + @Test void ambiguousBatchAllocationRollsBackTheWholeReceipt() throws Exception { + var f=new Fixture(); + f.p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_id,dispatch_item_id,plan_pallet_id,'FBA_APPOINTMENT:2',customer_id,source_key,destination_key,1 FROM ll_dispatch_appointment_allocation WHERE plan_pallet_id=1"); + f.p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(2,0,'AP-2')"); + when(f.appointments.batchContext("FBA_APPOINTMENT:2")).thenReturn(new FbaAppointmentBatchContext(f.snapshot,List.of(),List.of(),List.of(),List.of(),List.of())); + assertThatThrownBy(f::confirm).hasMessageContaining("不能自动均分"); + assertThat(f.quantities()).containsExactly(10,10); + assertThat(f.p.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Integer.class)).isZero(); + assertThat(f.p.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); + assertThat(f.p.f.db.queryForObject("SELECT SUM(counted_package_count) FROM ll_inbound_plan_pallet_allocation",Integer.class)).isZero(); + } + + /** 不能借收货确认接管其他排车已经占用的实托。 */ + @Test void occupiedPhysicalPalletRejectsReceiptWithoutPartialUpdates() throws Exception { + var f=new Fixture();f.p.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501"); + assertThatThrownBy(f::confirm).hasMessageContaining("占用"); + assertThat(f.quantities()).containsExactly(10,10); + assertThat(f.allocations()).containsExactly(10,10); + } + + /** 单客户同票两托的真实预约提供者,避免用恒定放行的预约Mock掩盖数量冲突。 */ + private static final class Fixture { + final DispatchPickingPersistenceTest.Picking p=new DispatchPickingPersistenceTest.Picking(true); + final InboundFbaAppointmentService appointments=mock(InboundFbaAppointmentService.class); + final FbaAppointmentResponse snapshot=mock(FbaAppointmentResponse.class); + final FbaReceivingStore receiving=mock(FbaReceivingStore.class); + final AppointmentWorkDispatchBinding binding; + final DispatchInboundReceiptService receipt; + + /** 预排估算为10+10,收货输入固定9+11,来源及预约均保持同一票。 */ + Fixture() throws Exception { + p.receivedPlans(9,11); + p.f.db.update("UPDATE ll_dispatch_item SET customer_id='101',order_id=9001,destination_type='FBA',fba_warehouse_code='ONT8'"); + p.f.db.update("UPDATE ll_inventory_position SET customer_id='101',available_packages=0"); + p.f.db.update("UPDATE ll_inbound_stock_receipt SET customer_id='101'"); + p.f.db.update("UPDATE ll_dispatch_order SET estimated_arrival_at=DATEADD('HOUR',1,planned_departure_at)"); + p.f.candidates.get(2L).customerId("101");p.f.candidates.get(2L).orderId(9001L);p.f.candidates.get(2L).fbaWarehouseCode("ONT8"); + p.f.candidates.get(2L).destinationWarehouseCode("ONT8"); + doReturn(List.of(p.f.candidates.get(2L))).when(p.sources).selectedPlan("101",2L); + doReturn(true).when(p.sources).receivedPlanCompleted("101",2L); + for(long id=1;id<=2;id++) { + doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",null,id); + doReturn(List.of(9001L)).when(p.sources).sourceOrders("101",500+id,id); + doReturn(id).when(p.sources).lockPlan("101",id); + doReturn(List.of(p.f.candidates.get(id))).when(p.sources).confirmedPlan("101",id); + doReturn(1).when(p.sources).materialize(anyLong(),eq("101"),eq(id),eq(500+id),anyLong(),any()); + } + p.f.db.execute("ALTER TABLE ll_inbound_plan_line ADD (destination_type VARCHAR(20),fba_warehouse_code VARCHAR(20))"); + p.f.db.execute("ALTER TABLE ll_inbound_plan_pallet_allocation ADD (customer_id VARCHAR(40),planned_package_count INT,counted_package_count INT)"); + p.f.db.update("INSERT INTO ll_inbound_plan_line(id,order_id,customer_id,destination_type,fba_warehouse_code) VALUES(1,9001,'101','FBA','ONT8')"); + for(long id=1;id<=2;id++) { + p.f.db.update("INSERT INTO ll_inbound_plan_pallet_allocation VALUES(?,1,'101',10,0)",id); + p.f.db.update("INSERT INTO ll_dispatch_appointment_allocation SELECT dispatch_order_id,id,plan_pallet_id,'FBA_APPOINTMENT:1','101','ORDER:9001','FBA:ONT8',10 FROM ll_dispatch_item WHERE plan_pallet_id=?",id); + } + p.f.db.update("INSERT INTO ll_inbound_fba_appointment(id,version,appointment_no) VALUES(1,0,'AP-1')"); + // 与生产一致,排车和预约Mapper共用同一SqlSession,更新后一起失效本地查询缓存。 + p.f.session.getConfiguration().addMapper(AppointmentWorkDispatchMapper.class); + var mapper=p.f.session.getMapper(AppointmentWorkDispatchMapper.class); + var sources=mock(AppointmentSourceService.class); + when(sources.canonicalOrders(anyList())).thenAnswer(call->{List refs=call.getArgument(0);return refs.stream().map(r->new AppointmentCoverageService.SourceRef(r.customerId(),"ORDER:"+r.orderId(),null)).toList();}); + when(snapshot.version()).thenReturn(0);when(snapshot.batchKey()).thenReturn("FBA_APPOINTMENT:1");when(snapshot.replacementBatchKeys()).thenReturn(List.of()); + var item=mock(FbaAppointmentResponse.Item.class);when(item.customerId()).thenReturn("101");when(item.sourceKey()).thenReturn("ORDER:9001");when(item.destinationKey()).thenReturn("FBA:ONT8");when(item.packageCount()).thenReturn(20);when(snapshot.items()).thenReturn(List.of(item)); + when(appointments.batchContext("FBA_APPOINTMENT:1")).thenReturn(new FbaAppointmentBatchContext(snapshot,List.of(),List.of(),List.of(),List.of(),List.of())); + var details=new FbaAppointmentDetails(null,null,null,"CONF-1",LocalDateTime.now(ZoneOffset.UTC).plusDays(3),null,"SUCCESS",null,null,null); + var member=new FbaReceivingContracts.Member("FBA_APPOINTMENT:1","AP-1",0,"101",List.of(new FbaReceivingContracts.Source("ORDER:9001","FBA:ONT8",20,"v1",1,"IN-1")),null); + when(receiving.currentAgreement("FBA_APPOINTMENT:1","101")).thenReturn(new FbaReceivingContracts.Agreement("AG-1",0,"ACTIVE","FBA:ONT8",details,"UTC",List.of(member),"确认收货")); + binding=new AppointmentWorkDispatchBinding(mapper,appointments,sources,mock(AppointmentWorkChanges.class),receiving,null); + var bridge=new AppointmentReceiptDispatchAdapter(new AppointmentReceiptRepository(mapper),binding,appointments,sources); + var picking=(DispatchPickingRepository)ReflectionTestUtils.getField(p.service,"picking"); + receipt=new DispatchInboundReceiptService(p.f.repository,p.sources,picking,(DispatchPhysicalClaimService)ReflectionTestUtils.getField(p.f.service,"physicalClaims"),p.f.service,p.f.fleet,bridge); + ReflectionTestUtils.setField(p.f.service,"appointments",new DispatchAppointmentAllocations(binding)); + p.f.db.update("UPDATE ll_dispatch_order SET review_basis_hash=?",(String)ReflectionTestUtils.invokeMethod(p.f.service,"basis",p.f.repository.find(p.number))); + doAnswer(call->{var pick=(com.baiyue.lastleg.dispatch.pda.entity.DispatchPdaPick)call.getArgument(1);long receiptId=pick.getInventoryPositionId()==11?1:2; + p.f.db.update("INSERT INTO ll_dispatch_pda_pick_order(id,customer_id,pick_id,order_id,receipt_id,packages,product_quantity) VALUES(?,'101',?,9001,?,?,0)",pick.getId(),pick.getId(),receiptId,pick.getPackages());return List.of(new com.baiyue.lastleg.dispatch.pda.service.DispatchPickOrderAttributionService.OrderPackages(9001L,pick.getPackages())); + }).when(p.attribution).attribute(eq("101"),any(),isNull()); + } + + /** 模拟收货库存落账后,在同一事务交接原排车和预约;不执行任何出库命令。 */ + void confirm() { + p.f.tx.executeWithoutResult(s->{ + receipt.lock(List.of(1L,2L)); + p.f.db.update("UPDATE ll_inbound_plan_pallet_allocation SET counted_package_count=CASE WHEN pallet_id=1 THEN 9 ELSE 11 END"); + p.f.db.update("UPDATE ll_inventory_position SET available_packages=CASE WHEN id=11 THEN 9 ELSE 11 END"); + receipt.complete(List.of(1L,2L),"receiver",LocalDateTime.now(ZoneOffset.UTC)); + }); + } + + /** 读取持久化实收箱数,不能只检查响应投影。 */ + List quantities() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY plan_pallet_id",Integer.class); } + /** 读取实际预约分配,覆盖本次遗漏的第二套账本。 */ + List allocations() { return p.f.db.queryForList("SELECT package_count FROM ll_dispatch_appointment_allocation ORDER BY plan_pallet_id",Integer.class); } + /** 后续扫码必须使用收货交接后的公共版本。 */ + long version() { return p.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class); } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java index 0efae0f2d..ed245b2eb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchMergedRelabelPersistenceTest.java @@ -259,7 +259,7 @@ class DispatchMergedRelabelPersistenceTest { ReflectionTestUtils.setField(inventory,"warehouseGateway",mock(com.baiyue.lastleg.inboundplan.pda.gateway.WarehouseResourceGateway.class)); ReflectionTestUtils.setField(attribution,"attributions",f.session.getMapper(DispatchPdaPickOrderMapper.class));ReflectionTestUtils.setField(attribution,"orders",f.session.getMapper(LastlegOrderMapper.class));ReflectionTestUtils.setField(attribution,"relabelSources",origins); picking=new DispatchPickingService(f.service,f.repository,sourceRepository,new DispatchPickingRepository(f.session.getMapper(DispatchPdaTaskMapper.class),f.session.getMapper(DispatchPdaPickMapper.class),f.session.getMapper(InventoryPositionMapper.class),f.session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)),new DispatchCommandHasher(f.json),f.json); - ReflectionTestUtils.setField(picking,"physicalClaims",ReflectionTestUtils.getField(f.service,"physicalClaims"));ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots); + ReflectionTestUtils.setField(picking,"sourceRules",rules);ReflectionTestUtils.setField(picking,"relabelSources",snapshots); pda=new DispatchPdaQueryService(new DispatchPdaReadRepository(f.session.getMapper(DispatchWholePdaReadMapper.class)),f.service);ReflectionTestUtils.setField(pda,"sourceOrigins",origins); balance(601,"101",5);position(101,601,"101",crossCustomer?2:5); if(crossCustomer){f.db.update("UPDATE ll_inventory_balance SET hold_packages=2 WHERE id=601");balance(602,"102",3);position(102,602,"102",3);} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java index 6eab729cc..7a124ba99 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPickingPersistenceTest.java @@ -34,44 +34,16 @@ class DispatchPickingPersistenceTest { verify(fixture.inventory,never()).ship(any(),any(),any()); assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); } - /** 数量可自然同步不代表能接管其他车的实际托盘。 */ - @Test void receivedPalletClaimedByAnotherDispatchCannotBePicked() throws Exception { + /** 未在收货事务完成交接的原计划不能在出货时临时修数量。 */ + @Test void pickingCannotMaterializeAnUnconfirmedReceipt() throws Exception { var fixture=new Picking(true);fixture.receivedPlans(9,11); - fixture.f.db.update("UPDATE ll_dispatch_physical_pallet_lock SET dispatch_order_id=999 WHERE warehouse_pallet_id=501"); assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, - new DispatchCommands.Pick(fixture.version,"occupied-pallet",11L,9,0,null,null)))) - .isInstanceOf(com.baiyue.common.core.exception.BizException.class).hasMessageContaining("占用"); - verify(fixture.inventory,never()).ship(any(),any(),any()); + new DispatchCommands.Pick(fixture.version,"receipt-not-confirmed",11L,9,0,null,null)))) + .hasMessageContaining("收货交接"); + verifyNoInteractions(fixture.inventory); assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); } - /** 预排两托10+10、实收9+11,首次扫码同步真实数量并保留原审核,原键重放不重复扣货。 */ - @Test void receivedPalletCountsReplaceEstimatesAtomically() throws Exception { - var fixture=new Picking(true);fixture.receivedPlans(9,11); - var firstRequest=new DispatchCommands.Pick(fixture.version,"received-nine",11L,9,0,null,null); - var first=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest)); - assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(9,11); - assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20); - assertThat(fixture.f.db.queryForObject("SELECT review_state FROM ll_dispatch_order",String.class)).isEqualTo("APPROVED"); - var replay=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item,firstRequest)); - assertThat(replay.replayed()).isTrue(); - var second=fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.secondItem, - new DispatchCommands.Pick(first.recordVersion(),"received-eleven",12L,11,0,null,null))); - assertThat(((DispatchResults.Picked)second.result()).pickingReconciled()).isTrue(); - verify(fixture.inventory,times(2)).ship(anyString(),eq("picker"),any()); - assertThat(fixture.f.db.queryForObject("SELECT SUM(available_packages) FROM ll_inventory_position",Long.class)).isZero(); - } - /** 实收绑定后扣货失败必须同时回滚数量、绑定、总数和原审核。 */ - @Test void receivedQuantityAndBindingRollBackWhenStockWriteFails() throws Exception { - var fixture=new Picking(true);fixture.receivedPlans(8,11); - doThrow(new IllegalStateException("库存命令失败")).when(fixture.inventory).ship(anyString(),anyString(),any()); - assertThatThrownBy(()->fixture.f.tx.execute(s->fixture.service.pick(fixture.actor,fixture.number,fixture.item, - new DispatchCommands.Pick(fixture.version,"receipt-rollback",11L,8,0,null,null)))).isInstanceOf(IllegalStateException.class); - assertThat(fixture.f.db.queryForList("SELECT package_count FROM ll_dispatch_item ORDER BY customer_id",Integer.class)).containsExactly(10,10); - assertThat(fixture.f.db.queryForObject("SELECT COUNT(*) FROM ll_dispatch_item WHERE warehouse_pallet_id IS NOT NULL",Long.class)).isZero(); - assertThat(fixture.f.db.queryForObject("SELECT package_count FROM ll_dispatch_order",Integer.class)).isEqualTo(20); - assertThat(fixture.f.db.queryForObject("SELECT record_version FROM ll_dispatch_order",Long.class)).isEqualTo(fixture.version); - } /** 原键在后续版本仍恢复原结果,不能重复调用SHIP或创建第二条真实扫码。 */ @Test void sameKeyShipsOnceAndLastPickDoesNotConfirmLoading() throws Exception { var fixture=new Picking();var request=new DispatchCommands.Pick(fixture.version,"pda-pick-original",11L,3,6,null,"实际扫码"); @@ -113,7 +85,7 @@ class DispatchPickingPersistenceTest { } /** 真主单和实际MyBatis写入;仅模拟两个独立应用命令提供者,未连接真实环境。 */ - private static final class Picking { + static final class Picking { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); final InventoryCommandService inventory=mock(InventoryCommandService.class);final DispatchPickOrderAttributionService attribution=mock(DispatchPickOrderAttributionService.class); final DispatchPickingService service;final String number;final long item,version;final Long secondItem; @@ -147,8 +119,7 @@ class DispatchPickingPersistenceTest { sources=spy(new DispatchSourceRepository(mock(DispatchCandidateMapper.class),mock(ReturnDispatchCandidateMapper.class),f.session.getMapper(DispatchSourceMapper.class))); var commands=new DispatchCommandLedger(new DispatchCommandRepository(f.session.getMapper(DispatchCommandMapper.class)),new DispatchCommandCodec(f.json)); service=new DispatchPickingService(f.service,f.repository,sources,new DispatchPickingRepository(session.getMapper(DispatchPdaTaskMapper.class),session.getMapper(DispatchPdaPickMapper.class),session.getMapper(InventoryPositionMapper.class),session.getMapper(DispatchItemMapper.class)),attribution,inventory,mock(ReturnRelabelDispatchStock.class),commands,new DispatchCommandHasher(f.json),f.json); - for(var field:List.of("physicalClaims","relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); - org.springframework.test.util.ReflectionTestUtils.setField(service,"fleet",f.fleet); + for(var field:List.of("relabelSources","sourceRules"))org.springframework.test.util.ReflectionTestUtils.setField(service,field,org.springframework.test.util.ReflectionTestUtils.getField(f.service,field)); } /** 来源查询在此隔离,实收同步仍使用真实主单、明细、库存及车次事务。 */ void receivedPlans(int first,int second) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java index 9fcc6d86a..d116164cb 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/dispatch/service/DispatchPlanPdaReadPersistenceTest.java @@ -33,62 +33,30 @@ class DispatchPlanPdaReadPersistenceTest { assertThat(mapper.receivedPlanCompleted("101",3L)).isZero(); } } - /** 已实收原计划直接提供首拣仓位,查询不推进原车版本或写入实托。 */ - @Test void receivedPlanExposesPositionWithoutMaterializing() throws Exception { - var f=new Fixture();var before=f.dbState(); - var task=f.read(); - assertThat(task.items().get(0).positions()).extracting(DispatchViews.Position::positionId).containsExactly(11L); - assertThat(task.items().get(0).positions().get(0).availablePackages()).isEqualTo(3); - assertThat(f.dbState()).isEqualTo(before); - verify(f.sources).stock("101",501L,false); - verify(f.sources,never()).materialize(anyLong(),anyString(),anyLong(),anyLong(),anyLong(),any()); - verify(f.rules,never()).lockExecutionSources(anyString(),anyList()); - } - /** 在途和来源身份变化均不成为可拣库存。 */ - @Test void unreceivedOrChangedPlanHasNoPositions() throws Exception { - var f=new Fixture();var row=f.f.candidates.get(1L); - row.sourceStage("PLAN");assertThat(f.read().items().get(0).positions()).isEmpty(); - row.sourceStage("RECEIVED");row.overseasWarehouseId("99");assertThat(f.read().items().get(0).positions()).isEmpty(); - row.overseasWarehouseId("9");row.customerId("other");assertThat(f.read().items().get(0).positions()).isEmpty(); - verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); - } - /** 自动预分配的10+10不约束实收9+11;查询给出实际数量且不改数据库。 */ - @Test void plannedTenAndTenProjectsReceivedNineAndEleven() throws Exception { + /** PDA查询只读取确认入库时已经交接的事实,不在GET补写或投影修正计划数量。 */ + @Test void pendingReceiptDoesNotExposeStockOrReplaceEstimates() throws Exception { var f=new Fixture(true);var before=f.dbState(); var task=f.read(); + assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(10L,10L); + assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isEmpty()); + assertThat(f.dbState()).isEqualTo(before); + verifyNoInteractions(f.sources,f.rules); + } + + /** 收货已保存9+11时,PDA直接显示9+11并提供实际托盘仓位。 */ + @Test void confirmedReceiptUsesPersistedNineAndEleven() throws Exception { + var f=new Fixture(true); + for(int n=1;n<=2;n++) { + int count=n==1?9:11; + f.f.db.update("UPDATE ll_dispatch_item SET warehouse_pallet_id=?,source_stage='RECEIVED',package_count=? WHERE plan_pallet_id=?",500+n,count,n); + f.f.db.update("INSERT INTO ll_inventory_position(id,customer_id,balance_id,pallet_id,overseas_warehouse_id,available_packages,hold_packages,allocated_packages,picked_packages) VALUES(?,'101',?,?,'9',?,0,0,0)",10+n,20+n,500+n,count); + } + var before=f.dbState();var task=f.read(); assertThat(task.items()).extracting(DispatchViews.PdaItem::scheduledPackages).containsExactly(9L,11L); - assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).isNotEmpty()); - assertThat(task.counts().packageCount()).isEqualTo(20); + assertThat(task.items()).allSatisfy(i->assertThat(i.positions()).hasSize(1)); assertThat(f.dbState()).isEqualTo(before); } - /** 原计划不能吸收新的订单或未经原车审核的同托来源。 */ - @Test void changedOrdersOrAdditionalPalletSourceBlocksFirstPick() throws Exception { - var f=new Fixture(); - when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L,9002L)); - assertThat(f.read().items().get(0).positions()).isEmpty(); - when(f.sources.sourceOrders("101",501L,1L)).thenReturn(List.of(9001L)); - when(f.rules.selectionGroup(anyString(),any())).thenReturn(new DispatchPlanningModels.SelectionGroup( - "group","version",List.of(new DispatchPlanningModels.SelectionGroupSource( - null,"PLAN:2","202","其他客户","INBOUND_PLAN","PLAN-2",1)),List.of(),true,List.of())); - assertThat(f.read().items().get(0).positions()).isEmpty(); - verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); - } - /** 库存移动或数量变化不能用原计划数量伪造首拣仓位。 */ - @Test void actualStockMustMatchWarehousePalletAndQuantity() throws Exception { - var f=new Fixture(); - for(var stock:List.of(new Stock(11,21,501L,"99",3,0,0,0,1), - new Stock(11,21,502L,"9",3,0,0,0,1),new Stock(11,21,501L,"9",2,0,0,0,1), - new Stock(11,21,501L,"9",3,1,0,0,1),new Stock(11,21,501L,"9",3,0,1,0,1))) { - when(f.sources.stock("101",501L,false)).thenReturn(List.of(stock)); - assertThat(f.read().items().get(0).positions()).isEmpty(); - } - } - /** 收货尚未结束时不把当前部分实收数量当成整托最终数量。 */ - @Test void receivingPlanDoesNotExposePartialPallet() throws Exception { - var f=new Fixture();when(f.sources.receivedPlanCompleted("101",1L)).thenReturn(false); - assertThat(f.read().items().get(0).positions()).isEmpty(); - verify(f.sources,never()).stock(anyString(),anyLong(),anyBoolean()); - } + /** 复用真实排车及PDA Mapper,模拟原计划来源快照。 */ private static final class Fixture { final DispatchAggregatePersistenceTest.Fixture f=new DispatchAggregatePersistenceTest.Fixture(); @@ -109,7 +77,6 @@ class DispatchPlanPdaReadPersistenceTest { var config=new MybatisConfiguration();config.setMapUnderscoreToCamelCase(true);config.addMapper(DispatchWholePdaReadMapper.class); var factory=new MybatisSqlSessionFactoryBean();factory.setDataSource(f.db.getDataSource());factory.setConfiguration(config);factory.afterPropertiesSet(); queries=new DispatchPdaQueryService(new DispatchPdaReadRepository(new SqlSessionTemplate(factory.getObject()).getMapper(DispatchWholePdaReadMapper.class)),f.service); - ReflectionTestUtils.setField(queries,"sources",sources);ReflectionTestUtils.setField(queries,"sourceRules",rules); when(sources.selectedPlan("101",1L)).thenReturn(List.of(f.candidates.get(1L))); when(sources.receivedPlanCompleted(anyString(),anyLong())).thenReturn(true); when(sources.sourceOrders("101",null,1L)).thenReturn(List.of(9001L)); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java index ac10851af..99ee3f048 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaContainerCompletionTest.java @@ -33,11 +33,13 @@ class InboundPdaContainerCompletionTest { @Mock InboundStockReceiptService stockReceipts; @Mock InboundUnloadCompletedOutboxService outbox; @Mock LastlegOrderMapper orders; + @Mock com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway dispatchReceipts; private InboundPdaWorkflowService service; @BeforeEach void setUp() { service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "dispatchReceipts", dispatchReceipts); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); ReflectionTestUtils.setField(service, "unloadCompletedOutbox", outbox); @@ -74,6 +76,14 @@ class InboundPdaContainerCompletionTest { any(Instant.class), any(Instant.class), anyLong()); verify(repository, times(2)).update(any(InboundPlan.class)); verify(repository, times(2)).update(any(InboundPdaTask.class)); + // 运输父锁必须早于收货来源锁;两票库存落账后才交接原排车,并且只交接一次。 + var sequence=org.mockito.Mockito.inOrder(dispatchReceipts,repository,stockReceipts); + sequence.verify(dispatchReceipts).lock(List.of(2L,1L)); + sequence.verify(repository).lockTask("100",1L); + sequence.verify(stockReceipts).stockIn(eq("100"),any(),any(),any(),any(),any(),any()); + sequence.verify(stockReceipts).stockIn(eq("200"),any(),any(),any(),any(),any(),any()); + sequence.verify(dispatchReceipts).complete(eq(List.of(1L,2L)),org.mockito.ArgumentMatchers.isNull(),any()); + verify(dispatchReceipts,times(1)).complete(any(),any(),any()); } private static InboundPlan plan(long id, String planNo) { diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java index 2e002b88a..dc8fe4a36 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/InboundPdaWorkflowServiceTest.java @@ -71,6 +71,7 @@ class InboundPdaWorkflowServiceTest { @BeforeEach void setUp() { service = new InboundPdaWorkflowService(); + ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class)); ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "warehouse", warehouse); ReflectionTestUtils.setField(service, "stockReceipts", stockReceipts); @@ -91,6 +92,7 @@ class InboundPdaWorkflowServiceTest { source = allocation(32L, sourceLine.getId(), sourcePallet.getId(), 2, 2, 2); otherWarehouse = allocation(33L, sourceLine.getId(), otherWarehousePallet.getId(), 2, 2, 2); lenient().when(repository.lockPlan("100", "LIP1")).thenReturn(plan); + lenient().when(repository.findPlan("100", "LIP1")).thenReturn(plan); LastlegOrder sourceOrder = new LastlegOrder(); sourceOrder.setId(1001L); sourceOrder.setSourceContainerBookingId("BOOKING-1"); lenient().when(orders.selectById(1001L)).thenReturn(sourceOrder); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java index 4f2d8ac9e..e35c6aef8 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/inboundplan/pda/service/ReceivingContainerServiceBehaviorTest.java @@ -83,6 +83,7 @@ class ReceivingContainerServiceBehaviorTest { @BeforeEach void setUp() { service = new ReceivingContainerService(); + ReflectionTestUtils.setField(service, "dispatchReceipts", org.mockito.Mockito.mock(com.baiyue.lastleg.inboundplan.pda.gateway.InboundDispatchReceiptGateway.class)); ReflectionTestUtils.setField(service, "candidateRepository", candidateRepository); org.mockito.Mockito.lenient().when(candidateRepository.currentSession(any())).thenAnswer(invocation -> sessions.findActiveByOperator(invocation.getArgument(0))); @@ -1166,6 +1167,7 @@ class ReceivingContainerServiceBehaviorTest { order.setContainerNo(containerNo); order.setSourceContainerBookingId("BOOKING-A"); order.setInboundCode("IN-1"); when(sessions.lockActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); + org.mockito.Mockito.lenient().when(sessions.findActiveByOperator("OPERATOR:7")).thenReturn(session(containerNo, "SIGNED")); when(plans.selectList(any())).thenReturn(List.of(plan)); when(lines.selectList(any())).thenReturn(List.of(line)); org.mockito.Mockito.lenient().when(orders.selectList(any())).thenReturn(List.of(order)); From 9e0266b7c356049596a62bd4c5beb173c3ad8bd0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 16:43:46 +0800 Subject: [PATCH 090/113] fix(booking): allow release without estimated arrival --- firstleg/docs/business-index.md | 2 +- .../service/OceanBookingService.java | 3 ++- .../resources/openapi/ocean-bookings-v1.yaml | 2 +- .../service/OceanBookingServiceTest.java | 20 +++++++++++++++++++ 4 files changed, 24 insertions(+), 3 deletions(-) diff --git a/firstleg/docs/business-index.md b/firstleg/docs/business-index.md index e4ef56b22..da93dbc67 100644 --- a/firstleg/docs/business-index.md +++ b/firstleg/docs/business-index.md @@ -80,7 +80,7 @@ - 报关、订舱和拖车引用的供应商必须通过 `WarehouseSupplierSnapshotService` 按 ID 向 Warehouse 校验:报关使用 `CUSTOMS`,订舱使用 `BOOKING`(不区分国内/国外 scope),国内拖车使用 `TRUCKING + truckingScope=DOMESTIC`,清关提柜/目的港码头拖车使用 `TRUCKING + truckingScope=OVERSEAS`;供应商必须为 `ACTIVE`。请求中的名称只能作为一致性提示,持久化名称必须来自 Warehouse 快照,禁止用自由文本绕过 ID、类别或启用状态校验。修改任一供应商引用 DTO、OpenAPI 或内部客户端时,必须同步检查三条业务链、Warehouse `supplier-v1.yaml`、数据库快照字段和对应契约测试。 - Order 渠道变更门禁必须检查装柜或后续状态、PDA task、预装、分柜及同柜组兼容性;任何不确定结果均失败关闭。`ORDER_DECLARATION_UPDATED_V1` 消费必须校验订单号/入仓码归属,永久保留 `eventId + payload hash` 幂等历史并按版本单调更新申报投影;不得覆盖仓库实测、箱码、测量审核或已确认费用。 - 订舱状态固定为 `DRAFT → RELEASED`:草稿允许资料不完整并可替换或删除,放舱时校验完整持久化聚合,放舱后运输、预报货量、三方和柜型需求只读;仅三项实际货量允许通过 `OceanBookingService.updateActualCargo` 按编辑权限、版本、幂等键和原因补录并保存前后审计快照。创建草稿和放舱都只维护订舱聚合、三类参与方及柜型需求,不创建真实 `containerNo`、货柜行或分配关系;真实货柜只在后续提柜关联时占用已放舱柜型需求。 -- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。 +- 订舱柜型需求至少一项、最多 20 项,数量必须为正整数,容量和限重必须匹配平台柜型规格。起运仓必须是启用的国内中转仓;物流渠道必须来自该仓当前 ACTIVE 价格版本,服务端保存渠道名称快照,渠道失效时失败关闭。预计到港为可选计划,不作为放舱必填项;填写时不得早于预计开船时间。 - 订舱号遵循 `../../../docs/业务单据编号契约.md`;不得以实体 ID 拼接生成。提单号在订舱主表全局唯一,柜次表只保存快照;真实集装箱号只在同一订舱内唯一,不同提单允许复用。放舱不要求真实柜号,也不得为满足放舱校验伪造柜号。 - 订舱列表 `containerCount` 固定为该订舱全部柜型需求 `quantity` 之和,与真实柜关联/解除无关;关联候选只返回 `RELEASED`、剩余数量大于 0 且 `warehouseId + channelCode + containerType` 精确匹配的需求,由服务端集合查询 ACTIVE 分配量。前端不得扇出详情计算或用已关联柜数覆盖该字段,最终关联命令仍须重验目标货柜可修改性。 - 拖车预约是平台级订舱聚合,不是客户或柜次资源:请求、实体、查询、响应和附件不得恢复 `customerId/customerName`,公开 DTO 也不得出现 `containerBookingId/loadingPlanId/cabinetNo/containerNo`。创建候选只返回有提单号、ACTIVE `BOOKING_CONFIRMATION` 或 `BILL_OF_LADING` 且没有非终态拖车单的 `RELEASED` 订舱;创建或编辑只冻结订舱仓库/提单与 TRUCKING/DOMESTIC 国内拖车供应商快照。每订舱通过查询锁与唯一约束最多一张非终态预约,重复创建返回现有派车单号;PDA 装柜完成不得改变拖车状态;只有订舱全部柜型需求均已关联且每柜全部入仓绑定确认国内离仓,才自动完成(含草稿/晚提交)。离仓、拖车创建/编辑/操作统一先锁订舱,完成状态、`AUTO_COMPLETE` 历史、按实装体积守恒分摊的柜次应付及 Finance Outbox 同事务落库;取消/异常单不自动恢复,已有有效或完成单不得重复创建。应付拖车费仅人民币 RMB(财务快照为 CNY),固定使用 `FEE_TYPE_108`,关联 ACTIVE/TRUCKING/DOMESTIC 供应商,零金额不生成费用;正金额缺归属、有效体积或费用配置时失败关闭,不丢费、不伪造均分。手工 DEPART/COMPLETE 动作已移除。附件使用 `tenantId=0 + TRUCK_APPOINTMENT_ATTACHMENT + appointmentId`,提单文件继续归订舱。 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java index f55a9770b..c7153d1ec 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingService.java @@ -262,12 +262,13 @@ public class OceanBookingService { } } private static void validateRange(LocalDateTime from, LocalDateTime to) { if (from != null && to != null && to.isBefore(from)) throw badRequest("预计到港时间不能早于预计开船时间"); } + /** 放舱核对必要运输资料;预计到港为可选计划,不阻断放舱。 */ private static void validateRelease(OceanBookingEntity booking, List parties, List requirements) { if (!StringUtils.hasText(booking.getChannelCode()) || booking.getWarehouseId() == null || !StringUtils.hasText(booking.getBillOfLadingNo()) || !StringUtils.hasText(booking.getVesselName()) || !StringUtils.hasText(booking.getVoyageNo()) || booking.getEstimatedSailingAt() == null - || booking.getEstimatedArrivalAt() == null || booking.getShippingCompanyId() == null + || booking.getShippingCompanyId() == null || (!StringUtils.hasText(booking.getDeparturePortName()) && !StringUtils.hasText(booking.getDeparturePortCode())) || (!StringUtils.hasText(booking.getDestinationPortName()) && !StringUtils.hasText(booking.getDestinationPortCode())) || !StringUtils.hasText(booking.getPlaceOfDelivery()) diff --git a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml index 187ad0953..48ef6a28b 100644 --- a/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml +++ b/firstleg/src/main/resources/openapi/ocean-bookings-v1.yaml @@ -200,7 +200,7 @@ components: destinationPortName: {type: string, nullable: true} placeOfDelivery: {type: string, nullable: true} estimatedSailingAt: {type: string, format: date-time, nullable: true} - estimatedArrivalAt: {type: string, format: date-time, nullable: true} + estimatedArrivalAt: {type: string, format: date-time, nullable: true, description: 可选预计到港时间;放舱不要求填写,填写时不得早于预计开船时间} remark: {type: string, nullable: true} freightTerm: {type: string, maxLength: 64, nullable: true, description: 运费条款;新建页面默认FREIGHT PREPAID} serviceTerm: {type: string, maxLength: 64, nullable: true, description: 运输条款;新建页面默认CY-CY} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java index d40fb7097..edfa3e646 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/oceanbooking/service/OceanBookingServiceTest.java @@ -231,6 +231,26 @@ class OceanBookingServiceTest { verify(repository, times(1)).transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin"); } + /** 不录预计到港仍可放舱,其余资料及附件门禁保持有效。 */ + @Test + void releasesWithoutEstimatedArrival() { + stubMasterData(); + OceanBookingEntity draft = completeBooking("DRAFT", 3); + OceanBookingEntity released = completeBooking("RELEASED", 4); + draft.setEstimatedArrivalAt(null); + released.setEstimatedArrivalAt(null); + when(repository.find(1L)).thenReturn(draft, released); + when(repository.parties(1L)).thenReturn(parties()); + when(repository.requirements(1L)).thenReturn(List.of(requirement())); + when(repository.transition(1L, "DRAFT", "RELEASED", 3, 9L, "admin")).thenReturn(1); + + Detail result = service.release(1L, 9L, "admin", "release-no-arrival", new ReleaseRequest(3, "确认放舱")); + + assertThat(result.oceanBooking().getStatus()).isEqualTo("RELEASED"); + assertThat(result.oceanBooking().getEstimatedArrivalAt()).isNull(); + verify(documents).requireActiveValidDocument(draft); + } + @Test void rejectsCarrierSnapshotMismatchAndUnassociatedBookingAgent() { stubMasterData(); From bfeeef130e85381f9f38941f5b09f4b3c6a5af60 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Thu, 22 Oct 2026 18:03:45 +0800 Subject: [PATCH 091/113] fix(appointments): expose dispatch cabinet short numbers --- .../trip/FleetTripCalendarRepository.java | 6 +-- .../fleet/trip/FleetTripCalendarService.java | 52 ++++++++++++------- .../lastleg/fleet/trip/FleetTripMapper.java | 8 +-- .../resources/openapi/fleet-trips-v1.yaml | 7 ++- .../trip/FleetTripCalendarMapperTest.java | 17 ++++-- .../trip/FleetTripCalendarServiceTest.java | 37 +++++++++---- 6 files changed, 88 insertions(+), 39 deletions(-) diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java index 3e877bc27..21f0e00c2 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarRepository.java @@ -7,7 +7,7 @@ import org.springframework.stereotype.Repository; /** 日历只读访问保留真实主单范围,整批读取原货物快照而不逐车查询。 */ @Repository public class FleetTripCalendarRepository { - public record Source(String dispatchNo, String sourceSnapshotJson) { } + public record Source(String dispatchNo, String sourceSnapshotJson, String loadingPayloadJson) { } private final FleetTripMapper mapper; /** 装配现有车次Mapper,日历服务不直接访问SQL。 */ @@ -18,10 +18,10 @@ public class FleetTripCalendarRepository { return mapper.calendar(from, to); } - /** 一次读取指定主单的全部货物快照,空窗口不发送空IN查询。 */ + /** 一次读取指定主单的全部货物与原装柜快照,空窗口不发送空IN查询。 */ public List sourceSnapshots(List dispatchNumbers) { if (dispatchNumbers.isEmpty()) return List.of(); return mapper.calendarSources(dispatchNumbers).stream() - .map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson())).toList(); + .map(row -> new Source(row.dispatchNo(), row.sourceSnapshotJson(), row.loadingPayloadJson())).toList(); } } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java index caeabfde1..8c70177db 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripCalendarService.java @@ -19,8 +19,9 @@ public class FleetTripCalendarService { String originWarehouseCode, String originWarehouseName, String originCountryCode, String originTimezone, String originWarehouseStatus, Instant plannedDepartureAt, Instant expectedDriverEndAt, Instant departedAt, Instant completedAt, - List stops, List containerNumbers, List incompleteReasons) { } + List stops, List containerNumbers, List cabinetNumbers, List incompleteReasons) { } public record Result(List records, List unresolvedRecords) { } + private record ContainerReferences(List containerNumbers, List cabinetNumbers) { } private static final Set COUNTRIES = Set.of(Locale.getISOCountries()); private final FleetTripCalendarRepository trips; private final FleetWarehouseClient warehouses; @@ -41,7 +42,7 @@ public class FleetTripCalendarService { var rows=trips.calendar(LocalDateTime.ofInstant(from,ZoneOffset.UTC),LocalDateTime.ofInstant(to,ZoneOffset.UTC)); if (rows.size()>2000) throw new ResponseStatusException(HttpStatus.BAD_REQUEST,"排车过多,请缩小查询日期范围"); if (rows.isEmpty()) return new Result(List.of(),List.of()); - var containers=containerNumbers(rows); + var containers=containerReferences(rows); var ids=rows.stream().map(FleetTripEntity::getWarehouseId).filter(Objects::nonNull).filter(id->id>0).distinct().sorted().toList(); var metadata=metadata(ids); var records=new ArrayList(); var unresolved=new ArrayList(); @@ -64,35 +65,50 @@ public class FleetTripCalendarService { throw new IllegalArgumentException("incomplete route"); stops=List.copyOf(stops); } catch (Exception invalid) { stops=List.of(); reasons.add("ROUTE_INCOMPLETE"); } + var references=containers.getOrDefault(row.getDispatchNo(),new ContainerReferences(List.of(),List.of())); var entry=new Entry("DISPATCH",row.getDispatchNo(),row.getStatus(),row.getWarehouseId()==null?null:row.getWarehouseId().toString(), warehouse==null?row.getWarehouseCode():warehouse.warehouseCode(),warehouse==null?row.getWarehouseName():warehouse.warehouseName(), country,zone,warehouse==null?null:warehouse.status(),instant(row.getPlannedDepartureAt()),instant(row.getExpectedDriverEndAt()), - instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,containers.getOrDefault(row.getDispatchNo(),List.of()),List.copyOf(reasons)); + instant(row.getDepartedAt()),instant(row.getCompletedAt()),stops,references.containerNumbers(),references.cabinetNumbers(),List.copyOf(reasons)); (unresolvedOrigin?unresolved:records).add(entry); } return new Result(List.copyOf(records),List.copyOf(unresolved)); } - /** 柜号只取排车建单时正式保存的SourceDisplay.containerNo,不反查或推测其他来源。 */ - private Map> containerNumbers(List rows) { + /** 完整柜号取排车货物快照,柜次短号取同客户真实来源的原装柜事件,分别去空去重。 */ + private Map containerReferences(List rows) { var numbers=rows.stream().map(FleetTripCalendarRow::getDispatchNo).distinct().sorted().toList(); - var requested=new HashSet<>(numbers);var grouped=new LinkedHashMap>(); + var requested=new HashSet<>(numbers); + var containers=new LinkedHashMap>(); + var cabinets=new LinkedHashMap>(); for(var source:trips.sourceSnapshots(numbers)) { if(source==null || !requested.contains(source.dispatchNo()) || source.sourceSnapshotJson()==null || source.sourceSnapshotJson().isBlank()) throw sourceUnavailable(); - try { - var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS) - .readTree(source.sourceSnapshotJson()); - if(snapshot==null || !snapshot.isObject())throw sourceUnavailable(); - var value=snapshot.get("containerNo"); - if(value==null || value.isNull())continue; - if(!value.isTextual())throw sourceUnavailable(); - var number=value.textValue().strip(); - if(!number.isEmpty())grouped.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(number); - } catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); } + var container=snapshotReference(source.sourceSnapshotJson(),"containerNo"); + if(container!=null)containers.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(container); + // 非头程来源没有装柜事件,保持短号为空,不从完整柜号或内部ID猜测。 + if(source.loadingPayloadJson()!=null) { + var cabinet=snapshotReference(source.loadingPayloadJson(),"cabinetNo"); + if(cabinet!=null)cabinets.computeIfAbsent(source.dispatchNo(),ignored->new LinkedHashSet<>()).add(cabinet); + } } - var result=new LinkedHashMap>(); - grouped.forEach((number,values)->result.put(number,List.copyOf(values)));return Map.copyOf(result); + var result=new LinkedHashMap(); + for(var number:numbers)result.put(number,new ContainerReferences( + List.copyOf(containers.getOrDefault(number,new LinkedHashSet<>())),List.copyOf(cabinets.getOrDefault(number,new LinkedHashSet<>())))); + return Map.copyOf(result); + } + + /** 只接受正式JSON对象中的文本业务标识,缺失留空,损坏资料明确报错。 */ + private String snapshotReference(String payload,String field) { + try { + var snapshot=json.reader().with(com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(payload); + if(snapshot==null || !snapshot.isObject())throw sourceUnavailable(); + var value=snapshot.get(field); + if(value==null || value.isNull())return null; + if(!value.isTextual())throw sourceUnavailable(); + var number=value.textValue().strip(); + return number.isEmpty()?null:number; + } catch(com.fasterxml.jackson.core.JsonProcessingException invalid) { throw sourceUnavailable(); } } /** 元数据批次必须完整覆盖请求身份,未知地点保持显式未核实。 */ diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java index 391401a36..d335449ba 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/fleet/trip/FleetTripMapper.java @@ -19,12 +19,14 @@ public interface FleetTripMapper extends BaseMapper { ORDER BY COALESCE(t.departed_at,t.planned_departure_at),d.dispatch_no LIMIT 2001 """) java.util.List calendar(@Param("from") java.time.LocalDateTime from,@Param("to") java.time.LocalDateTime to); - record CalendarSource(String dispatchNo, String sourceSnapshotJson) { } - /** 主查询限额之后批量读取原货物快照,不以货物JOIN放大整车记录或截断多柜。 */ + record CalendarSource(String dispatchNo, String sourceSnapshotJson, String loadingPayloadJson) { } + /** 主查询限额后批读货物快照及同客户原装柜事件,柜次短号只沿真实订单关联读取。 */ @Select("""